* [EGIT PATCH 2/2] Honor ~/.ssh/config whenever possible during SSH based transport
2008-08-15 17:35 [EGIT PATCH 1/2] Refactor SSH key loading so we don't duplicate keys Shawn O. Pearce
@ 2008-08-15 17:35 ` Shawn O. Pearce
2008-08-20 20:34 ` Robin Rosenberg
0 siblings, 1 reply; 4+ messages in thread
From: Shawn O. Pearce @ 2008-08-15 17:35 UTC (permalink / raw)
To: Robin Rosenberg; +Cc: git
I rely on ~/.ssh/config to setup host aliases, especially for very
common destinations. For example I have the following on most of
my systems:
Host orcz
HostName repo.or.cz
User spearce
IdentityFile .ssh/id_orcz
as not every system I use has my local user name as "spearce". The
C Git transport honors these settings just fine for short URLs like
"orcz:/srv/git/egit.git" but jgit failed horribly on these as there
is no local system named "orcz" on any of my networks.
By reading (and caching) the ~/.ssh/config file jgit can now honor
the important aspects of the host configuration blocks, allowing it
to use the same URLs as C Git.
The JSch API does not seem to allow forcing a specific key identity
for a Session so we are forced to load the identity file listed in
the configuration into the core JSch object we are running with.
Signed-off-by: Shawn O. Pearce <spearce@spearce.org>
---
.../spearce/egit/ui/EclipseSshSessionFactory.java | 44 ++-
.../spearce/jgit/transport/OpenSshConfigTest.java | 131 ++++++++
.../jgit/transport/DefaultSshSessionFactory.java | 22 ++-
.../org/spearce/jgit/transport/OpenSshConfig.java | 318 ++++++++++++++++++++
4 files changed, 499 insertions(+), 16 deletions(-)
create mode 100644 org.spearce.jgit.test/tst/org/spearce/jgit/transport/OpenSshConfigTest.java
create mode 100644 org.spearce.jgit/src/org/spearce/jgit/transport/OpenSshConfig.java
diff --git a/org.spearce.egit.ui/src/org/spearce/egit/ui/EclipseSshSessionFactory.java b/org.spearce.egit.ui/src/org/spearce/egit/ui/EclipseSshSessionFactory.java
index 8f80373..640a165 100644
--- a/org.spearce.egit.ui/src/org/spearce/egit/ui/EclipseSshSessionFactory.java
+++ b/org.spearce.egit.ui/src/org/spearce/egit/ui/EclipseSshSessionFactory.java
@@ -8,13 +8,15 @@
*******************************************************************************/
package org.spearce.egit.ui;
+import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
-import java.security.AccessController;
-import java.security.PrivilegedAction;
+import java.util.HashSet;
+import java.util.Set;
import org.eclipse.jsch.core.IJSchService;
import org.eclipse.jsch.ui.UserInfoPrompter;
+import org.spearce.jgit.transport.OpenSshConfig;
import org.spearce.jgit.transport.SshSessionFactory;
import com.jcraft.jsch.JSchException;
@@ -23,15 +25,27 @@
class EclipseSshSessionFactory extends SshSessionFactory {
private final IJSchService provider;
+ private final Set<String> loadedIdentities = new HashSet<String>();
+
+ private OpenSshConfig config;
+
EclipseSshSessionFactory(final IJSchService p) {
provider = p;
}
@Override
- public Session getSession(final String user, final String pass,
- final String host, final int port) throws JSchException {
- final Session session = provider.createSession(host, port > 0 ? port
- : -1, user != null ? user : userName());
+ public Session getSession(String user, String pass, String host, int port)
+ throws JSchException {
+ final OpenSshConfig.Host hc = getConfig().lookup(host);
+ host = hc.getHostName();
+ if (port <= 0)
+ port = hc.getPort();
+ if (user == null)
+ user = hc.getUser();
+
+ final Session session = provider.createSession(host, port, user);
+ if (hc.getIdentityFile() != null)
+ addIdentity(hc.getIdentityFile());
if (pass != null)
session.setPassword(pass);
else
@@ -39,12 +53,17 @@ public Session getSession(final String user, final String pass,
return session;
}
- private static String userName() {
- return AccessController.doPrivileged(new PrivilegedAction<String>() {
- public String run() {
- return System.getProperty("user.name");
- }
- });
+ private synchronized OpenSshConfig getConfig() {
+ if (config == null)
+ config = OpenSshConfig.get();
+ return config;
+ }
+
+ private void addIdentity(final File identityFile)
+ throws JSchException {
+ final String path = identityFile.getAbsolutePath();
+ if (loadedIdentities.add(path))
+ provider.getJSch().addIdentity(path);
}
@Override
@@ -52,6 +71,7 @@ public OutputStream getErrorStream() {
return new OutputStream() {
StringBuilder all = new StringBuilder();
+
StringBuilder sb = new StringBuilder();
public String toString() {
diff --git a/org.spearce.jgit.test/tst/org/spearce/jgit/transport/OpenSshConfigTest.java b/org.spearce.jgit.test/tst/org/spearce/jgit/transport/OpenSshConfigTest.java
new file mode 100644
index 0000000..a250f9d
--- /dev/null
+++ b/org.spearce.jgit.test/tst/org/spearce/jgit/transport/OpenSshConfigTest.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2008, Google Inc.
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following
+ * disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * - Neither the name of the Git Development Community nor the
+ * names of its contributors may be used to endorse or promote
+ * products derived from this software without specific prior
+ * written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package org.spearce.jgit.transport;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+import org.spearce.jgit.lib.RepositoryTestCase;
+import org.spearce.jgit.transport.OpenSshConfig.Host;
+
+public class OpenSshConfigTest extends RepositoryTestCase {
+ private File home;
+
+ private File configFile;
+
+ private OpenSshConfig osc;
+
+ public void setUp() throws Exception {
+ super.setUp();
+
+ home = new File(trash, "home");
+ home.mkdir();
+
+ configFile = new File(new File(home, ".ssh"), "config");
+ configFile.getParentFile().mkdir();
+
+ System.setProperty("user.name", "jex_junit");
+ osc = new OpenSshConfig(home, configFile);
+ }
+
+ private void config(final String data) throws IOException {
+ final OutputStreamWriter fw = new OutputStreamWriter(
+ new FileOutputStream(configFile), "UTF-8");
+ fw.write(data);
+ fw.close();
+ }
+
+ public void testNoConfig() {
+ final Host h = osc.lookup("repo.or.cz");
+ assertNotNull(h);
+ assertEquals("repo.or.cz", h.getHostName());
+ assertEquals("jex_junit", h.getUser());
+ assertEquals(22, h.getPort());
+ assertNull(h.getIdentityFile());
+ }
+
+ public void testAlias_DoesNotMatch() throws Exception {
+ config("Host orcz\n" + "\tHostName repo.or.cz\n");
+ final Host h = osc.lookup("repo.or.cz");
+ assertNotNull(h);
+ assertEquals("repo.or.cz", h.getHostName());
+ assertEquals("jex_junit", h.getUser());
+ assertEquals(22, h.getPort());
+ assertNull(h.getIdentityFile());
+ }
+
+ public void testAlias_OptionsSet() throws Exception {
+ config("Host orcz\n" + "\tHostName repo.or.cz\n" + "\tPort 2222\n"
+ + "\tUser jex\n" + "\tIdentityFile .ssh/id_jex\n"
+ + "\tForwardX11 no\n");
+ final Host h = osc.lookup("orcz");
+ assertNotNull(h);
+ assertEquals("repo.or.cz", h.getHostName());
+ assertEquals("jex", h.getUser());
+ assertEquals(2222, h.getPort());
+ assertEquals(new File(home, ".ssh/id_jex"), h.getIdentityFile());
+ }
+
+
+ public void testAlias_OptionsKeywordCaseInsensitive() throws Exception {
+ config("hOsT orcz\n" + "\thOsTnAmE repo.or.cz\n" + "\tPORT 2222\n"
+ + "\tuser jex\n" + "\tidentityfile .ssh/id_jex\n"
+ + "\tForwardX11 no\n");
+ final Host h = osc.lookup("orcz");
+ assertNotNull(h);
+ assertEquals("repo.or.cz", h.getHostName());
+ assertEquals("jex", h.getUser());
+ assertEquals(2222, h.getPort());
+ assertEquals(new File(home, ".ssh/id_jex"), h.getIdentityFile());
+ }
+
+ public void testAlias_OptionsInherit() throws Exception {
+ config("Host orcz\n" + "\tHostName repo.or.cz\n" + "\n" + "Host *\n"
+ + "\tHostName not.a.host.example.com\n" + "\tPort 2222\n"
+ + "\tUser jex\n" + "\tIdentityFile .ssh/id_jex\n"
+ + "\tForwardX11 no\n");
+ final Host h = osc.lookup("orcz");
+ assertNotNull(h);
+ assertEquals("repo.or.cz", h.getHostName());
+ assertEquals("jex", h.getUser());
+ assertEquals(2222, h.getPort());
+ assertEquals(new File(home, ".ssh/id_jex"), h.getIdentityFile());
+ }
+}
diff --git a/org.spearce.jgit/src/org/spearce/jgit/transport/DefaultSshSessionFactory.java b/org.spearce.jgit/src/org/spearce/jgit/transport/DefaultSshSessionFactory.java
index 0484fc0..a2437c2 100644
--- a/org.spearce.jgit/src/org/spearce/jgit/transport/DefaultSshSessionFactory.java
+++ b/org.spearce.jgit/src/org/spearce/jgit/transport/DefaultSshSessionFactory.java
@@ -49,6 +49,8 @@
import java.io.OutputStream;
import java.security.AccessController;
import java.security.PrivilegedAction;
+import java.util.HashSet;
+import java.util.Set;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
@@ -76,21 +78,27 @@
*/
class DefaultSshSessionFactory extends SshSessionFactory {
/** IANA assigned port number for SSH. */
- private static final int SSH_PORT = 22;
+ static final int SSH_PORT = 22;
private Set<String> loadedIdentities;
private JSch userJSch;
+ private OpenSshConfig config;
+
@Override
public synchronized Session getSession(String user, String pass,
String host, int port) throws JSchException {
+ final OpenSshConfig.Host hc = getConfig().lookup(host);
+ host = hc.getHostName();
if (port <= 0)
- port = SSH_PORT;
+ port = hc.getPort();
if (user == null)
- user = userName();
+ user = hc.getUser();
final Session session = getUserJSch().getSession(user, host, port);
+ if (hc.getIdentityFile() != null)
+ addIdentity(hc.getIdentityFile());
if (pass != null)
session.setPassword(pass);
else
@@ -98,7 +106,7 @@ public synchronized Session getSession(String user, String pass,
return session;
}
- private static String userName() {
+ static String userName() {
return AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("user.name");
@@ -116,6 +124,12 @@ private JSch getUserJSch() throws JSchException {
return userJSch;
}
+ private OpenSshConfig getConfig() {
+ if (config == null)
+ config = OpenSshConfig.get();
+ return config;
+ }
+
private void knownHosts(final JSch sch) throws JSchException {
final File home = FS.userHome();
if (home == null)
diff --git a/org.spearce.jgit/src/org/spearce/jgit/transport/OpenSshConfig.java b/org.spearce.jgit/src/org/spearce/jgit/transport/OpenSshConfig.java
new file mode 100644
index 0000000..f927b1a
--- /dev/null
+++ b/org.spearce.jgit/src/org/spearce/jgit/transport/OpenSshConfig.java
@@ -0,0 +1,318 @@
+/*
+ * Copyright (C) 2008, Google Inc.
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following
+ * disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * - Neither the name of the Git Development Community nor the
+ * names of its contributors may be used to endorse or promote
+ * products derived from this software without specific prior
+ * written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package org.spearce.jgit.transport;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.spearce.jgit.errors.InvalidPatternException;
+import org.spearce.jgit.fnmatch.FileNameMatcher;
+import org.spearce.jgit.util.FS;
+
+/**
+ * Simple configuration parser for the OpenSSH ~/.ssh/config file.
+ * <p>
+ * Since JSch does not (currently) have the ability to parse an OpenSSH
+ * configuration file this is a simple parser to read that file and make the
+ * critical options available to {@link SshSessionFactory}.
+ */
+public class OpenSshConfig {
+ /**
+ * Obtain the user's configuration data.
+ * <p>
+ * The configuration file is always returned to the caller, even if no file
+ * exists in the user's home directory at the time the call was made. Lookup
+ * requests are cached and are automatically updated if the user modifies
+ * the configuration file since the last time it was cached.
+ *
+ * @return a caching reader of the user's configuration file.
+ */
+ public static OpenSshConfig get() {
+ File home = FS.userHome();
+ if (home == null)
+ home = new File(".").getAbsoluteFile();
+
+ final File config = new File(new File(home, ".ssh"), "config");
+ final OpenSshConfig osc = new OpenSshConfig(home, config);
+ osc.refresh();
+ return osc;
+ }
+
+ /** The user's home directory, as key files may be relative to here. */
+ private final File home;
+
+ /** The .ssh/config file we read and monitor for updates. */
+ private final File configFile;
+
+ /** Modification time of {@link #configFile} when {@link #hosts} loaded. */
+ private long lastModified;
+
+ /** Cached entries read out of the configuration file. */
+ private Map<String, Host> hosts;
+
+ protected OpenSshConfig(final File h, final File cfg) {
+ home = h;
+ configFile = cfg;
+ hosts = Collections.emptyMap();
+ }
+
+ /**
+ * Locate the configuration for a specific host request.
+ *
+ * @param hostName
+ * the name the user has supplied to the SSH tool. This may be a
+ * real host name, or it may just be a "Host" block in the
+ * configuration file.
+ * @return r configuration for the requested name. Never null.
+ */
+ public Host lookup(final String hostName) {
+ final Map<String, Host> cache = refresh();
+ Host h = cache.get(hostName);
+ if (h == null)
+ h = new Host();
+ if (h.patternsApplied)
+ return h;
+
+ for (final Map.Entry<String, Host> e : cache.entrySet()) {
+ if (!isHostPattern(e.getKey()))
+ continue;
+ if (!isHostMatch(e.getKey(), hostName))
+ continue;
+ h.copyFrom(e.getValue());
+ }
+
+ if (h.hostName == null)
+ h.hostName = hostName;
+ if (h.user == null)
+ h.user = DefaultSshSessionFactory.userName();
+ if (h.port == 0)
+ h.port = DefaultSshSessionFactory.SSH_PORT;
+ h.patternsApplied = true;
+ return h;
+ }
+
+ private synchronized Map<String, Host> refresh() {
+ final long mtime = configFile.lastModified();
+ if (mtime != lastModified) {
+ try {
+ final FileInputStream in = new FileInputStream(configFile);
+ try {
+ hosts = parse(in);
+ } finally {
+ in.close();
+ }
+ } catch (FileNotFoundException none) {
+ hosts = Collections.emptyMap();
+ } catch (IOException err) {
+ hosts = Collections.emptyMap();
+ }
+ lastModified = mtime;
+ }
+ return hosts;
+ }
+
+ private Map<String, Host> parse(final InputStream in) throws IOException {
+ final Map<String, Host> m = new LinkedHashMap<String, Host>();
+ final BufferedReader br = new BufferedReader(new InputStreamReader(in));
+ final List<Host> current = new ArrayList<Host>(4);
+ String line;
+
+ while ((line = br.readLine()) != null) {
+ line = line.trim();
+ if (line.length() == 0 || line.startsWith("#"))
+ continue;
+
+ final int sp = line.indexOf(' ');
+ final int eq = line.indexOf('=');
+ final int splitAt;
+ if (sp >= 0 && eq >= 0)
+ splitAt = Math.min(sp, eq);
+ else if (sp < 0)
+ splitAt = eq;
+ else
+ splitAt = sp;
+ final String keyword = line.substring(0, splitAt).trim();
+ final String argValue = line.substring(splitAt + 1).trim();
+
+ if ("Host".equalsIgnoreCase(keyword)) {
+ current.clear();
+ for (final String name : argValue.split("[ \t]")) {
+ Host c = m.get(name);
+ if (c == null) {
+ c = new Host();
+ m.put(name, c);
+ }
+ current.add(c);
+ }
+ continue;
+ }
+
+ if (current.isEmpty()) {
+ // We received an option outside of a Host block. We
+ // don't know who this should match against, so skip.
+ //
+ continue;
+ }
+
+ if ("HostName".equalsIgnoreCase(keyword)) {
+ for (final Host c : current)
+ if (c.hostName == null)
+ c.hostName = dequote(argValue);
+ } else if ("User".equalsIgnoreCase(keyword)) {
+ for (final Host c : current)
+ if (c.user == null)
+ c.user = dequote(argValue);
+ } else if ("Port".equalsIgnoreCase(keyword)) {
+ try {
+ final int port = Integer.parseInt(dequote(argValue));
+ for (final Host c : current)
+ if (c.port == 0)
+ c.port = port;
+ } catch (NumberFormatException nfe) {
+ // Bad port number. Don't set it.
+ }
+ } else if ("IdentityFile".equalsIgnoreCase(keyword)) {
+ for (final Host c : current)
+ if (c.identityFile == null)
+ c.identityFile = toFile(dequote(argValue));
+ }
+ }
+
+ return m;
+ }
+
+ private static boolean isHostPattern(final String s) {
+ return s.indexOf('*') >= 0 || s.indexOf('?') >= 0;
+ }
+
+ private static boolean isHostMatch(final String pattern, final String name) {
+ final FileNameMatcher fn;
+ try {
+ fn = new FileNameMatcher(pattern, null);
+ } catch (InvalidPatternException e) {
+ return false;
+ }
+ fn.append(name);
+ return fn.isMatch();
+ }
+
+ private static String dequote(final String value) {
+ if (value.startsWith("\"") && value.endsWith("\""))
+ return value.substring(1, value.length() - 2);
+ return value;
+ }
+
+ private File toFile(final String path) {
+ if (path.startsWith("~/"))
+ return new File(home, path.substring(2));
+ return new File(home, path);
+ }
+
+ /**
+ * Configuration of one "Host" block in the configuration file.
+ * <p>
+ * If returned from {@link OpenSshConfig#lookup(String)} some or all of the
+ * properties may not be populated. The properties which are not populated
+ * should be defaulted by the caller.
+ * <p>
+ * When returned from {@link OpenSshConfig#lookup(String)} any wildcard
+ * entries which appear later in the configuration file will have been
+ * already merged into this block.
+ */
+ public static class Host {
+ boolean patternsApplied;
+
+ String hostName;
+
+ int port;
+
+ File identityFile;
+
+ String user;
+
+ void copyFrom(final Host src) {
+ if (hostName == null)
+ hostName = src.hostName;
+ if (port == 0)
+ port = src.port;
+ if (identityFile == null)
+ identityFile = src.identityFile;
+ if (user == null)
+ user = src.user;
+ }
+
+ /**
+ * @return the real IP address or host name to connect to; never null.
+ */
+ public String getHostName() {
+ return hostName;
+ }
+
+ /**
+ * @return the real port number to connect to; never 0.
+ */
+ public int getPort() {
+ return port;
+ }
+
+ /**
+ * @return path of the private key file to use for authentication; null
+ * if the caller should use default authentication strategies.
+ */
+ public File getIdentityFile() {
+ return identityFile;
+ }
+
+ /**
+ * @return the real user name to connect as; never null.
+ */
+ public String getUser() {
+ return user;
+ }
+ }
+}
--
1.6.0.rc3.250.g8dd0
^ permalink raw reply related [flat|nested] 4+ messages in thread