[EGIT PATCH 2/2] Honor ~/.ssh/config whenever possible during SSH based transport

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

 



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@xxxxxxxxxxx>
---
 .../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

--
To unsubscribe from this list: send the line "unsubscribe git" in
the body of a message to majordomo@xxxxxxxxxxxxxxx
More majordomo info at  http://vger.kernel.org/majordomo-info.html

[Index of Archives]     [Linux Kernel Development]     [Gcc Help]     [IETF Annouce]     [DCCP]     [Netdev]     [Networking]     [Security]     [V4L]     [Bugtraq]     [Yosemite]     [MIPS Linux]     [ARM Linux]     [Linux Security]     [Linux RAID]     [Linux SCSI]     [Fedora Users]

  Powered by Linux