Let Jsch know about ~/.ssh/config

Ensure the Jsch instance used knows about ~/.ssh/config. This
enables Jsch to honor more user configurations (see
com.jcraft.jsch.Session.applyConfig()), in particular also the
UserKnownHostsFile configuration, or additional identities given
via multiple IdentityFile entries.

Turn JGit's OpenSshConfig into a full parser that can be a
Jsch-compliant ConfigRepository. This avoids a few bugs
in Jsch's OpenSSHConfig and keeps the JGit-facing interface
unchanged. At the same time we can supply a JGit OpenSshConfig
instance as a ConfigRepository to Jsch. And since they'll both
work from the same object, we can also be sure that the parsing
behavior is identical.

The parser does not handle the "Match" and "Include" keys, and it
doesn't do %-token substitutions (yet).

Note that Jsch doesn't handle multi-valued UserKnownHostFile
entries as known by modern OpenSSH.[1]

[1] http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5

Additional tests for new features are provided in OpenSshConfigTest.

Bug: 490939
Change-Id: Ic683bd412fa8c5632142aebba4a07fad4c64c637
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
Signed-off-by: Matthias Sohn <matthias.sohn@sap.com>
This commit is contained in:
Thomas Wolf 2017-06-07 18:39:19 +02:00 committed by Matthias Sohn
parent 7e82be66cd
commit 9d2447063d
4 changed files with 687 additions and 149 deletions

View File

@ -8,6 +8,7 @@ Bundle-Vendor: %provider_name
Bundle-ActivationPolicy: lazy Bundle-ActivationPolicy: lazy
Bundle-RequiredExecutionEnvironment: JavaSE-1.8 Bundle-RequiredExecutionEnvironment: JavaSE-1.8
Import-Package: com.googlecode.javaewah;version="[1.1.6,2.0.0)", Import-Package: com.googlecode.javaewah;version="[1.1.6,2.0.0)",
com.jcraft.jsch;version="[0.1.54,0.2.0)",
org.eclipse.jgit.api;version="[4.9.0,4.10.0)", org.eclipse.jgit.api;version="[4.9.0,4.10.0)",
org.eclipse.jgit.api.errors;version="[4.9.0,4.10.0)", org.eclipse.jgit.api.errors;version="[4.9.0,4.10.0)",
org.eclipse.jgit.attributes;version="[4.9.0,4.10.0)", org.eclipse.jgit.attributes;version="[4.9.0,4.10.0)",

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2008, 2014 Google Inc. * Copyright (C) 2008, 2017 Google Inc.
* and other copyright owners as documented in the project's IP log. * and other copyright owners as documented in the project's IP log.
* *
* This program and the accompanying materials are made available * This program and the accompanying materials are made available
@ -43,10 +43,13 @@
package org.eclipse.jgit.transport; package org.eclipse.jgit.transport;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import java.io.File; import java.io.File;
@ -61,6 +64,8 @@
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import com.jcraft.jsch.ConfigRepository;
public class OpenSshConfigTest extends RepositoryTestCase { public class OpenSshConfigTest extends RepositoryTestCase {
private File home; private File home;
@ -84,10 +89,13 @@ public void setUp() throws Exception {
} }
private void config(final String data) throws IOException { private void config(final String data) throws IOException {
final OutputStreamWriter fw = new OutputStreamWriter( long lastMtime = configFile.lastModified();
new FileOutputStream(configFile), "UTF-8"); do {
fw.write(data); try (final OutputStreamWriter fw = new OutputStreamWriter(
fw.close(); new FileOutputStream(configFile), "UTF-8")) {
fw.write(data);
}
} while (lastMtime == configFile.lastModified());
} }
@Test @Test
@ -155,13 +163,18 @@ public void testQuoteParsing() throws Exception {
@Test @Test
public void testAlias_DoesNotMatch() throws Exception { public void testAlias_DoesNotMatch() throws Exception {
config("Host orcz\n" + "\tHostName repo.or.cz\n"); config("Host orcz\n" + "Port 29418\n" + "\tHostName repo.or.cz\n");
final Host h = osc.lookup("repo.or.cz"); final Host h = osc.lookup("repo.or.cz");
assertNotNull(h); assertNotNull(h);
assertEquals("repo.or.cz", h.getHostName()); assertEquals("repo.or.cz", h.getHostName());
assertEquals("jex_junit", h.getUser()); assertEquals("jex_junit", h.getUser());
assertEquals(22, h.getPort()); assertEquals(22, h.getPort());
assertNull(h.getIdentityFile()); assertNull(h.getIdentityFile());
final Host h2 = osc.lookup("orcz");
assertEquals("repo.or.cz", h.getHostName());
assertEquals("jex_junit", h.getUser());
assertEquals(29418, h2.getPort());
assertNull(h.getIdentityFile());
} }
@Test @Test
@ -282,4 +295,153 @@ public void testAlias_badConnectionAttempts() throws Exception {
assertNotNull(h); assertNotNull(h);
assertEquals(1, h.getConnectionAttempts()); assertEquals(1, h.getConnectionAttempts());
} }
@Test
public void testDefaultBlock() throws Exception {
config("ConnectionAttempts 5\n\nHost orcz\nConnectionAttempts 3\n");
final Host h = osc.lookup("orcz");
assertNotNull(h);
assertEquals(5, h.getConnectionAttempts());
}
@Test
public void testHostCaseInsensitive() throws Exception {
config("hOsT orcz\nConnectionAttempts 3\n");
final Host h = osc.lookup("orcz");
assertNotNull(h);
assertEquals(3, h.getConnectionAttempts());
}
@Test
public void testListValueSingle() throws Exception {
config("Host orcz\nUserKnownHostsFile /foo/bar\n");
final ConfigRepository.Config c = osc.getConfig("orcz");
assertNotNull(c);
assertEquals("/foo/bar", c.getValue("UserKnownHostsFile"));
}
@Test
public void testListValueMultiple() throws Exception {
// Tilde expansion doesn't occur within the parser
config("Host orcz\nUserKnownHostsFile \"~/foo/ba z\" /foo/bar \n");
final ConfigRepository.Config c = osc.getConfig("orcz");
assertNotNull(c);
assertArrayEquals(new Object[] { "~/foo/ba z", "/foo/bar" },
c.getValues("UserKnownHostsFile"));
}
@Test
public void testRepeatedLookups() throws Exception {
config("Host orcz\n" + "\tConnectionAttempts 5\n");
final Host h1 = osc.lookup("orcz");
final Host h2 = osc.lookup("orcz");
assertNotNull(h1);
assertSame(h1, h2);
assertEquals(5, h1.getConnectionAttempts());
assertEquals(h1.getConnectionAttempts(), h2.getConnectionAttempts());
final ConfigRepository.Config c = osc.getConfig("orcz");
assertNotNull(c);
assertSame(c, h1.getConfig());
assertSame(c, h2.getConfig());
}
@Test
public void testRepeatedLookupsWithModification() throws Exception {
config("Host orcz\n" + "\tConnectionAttempts -1\n");
final Host h1 = osc.lookup("orcz");
assertNotNull(h1);
assertEquals(1, h1.getConnectionAttempts());
config("Host orcz\n" + "\tConnectionAttempts 5\n");
final Host h2 = osc.lookup("orcz");
assertNotNull(h2);
assertNotSame(h1, h2);
assertEquals(5, h2.getConnectionAttempts());
assertEquals(1, h1.getConnectionAttempts());
assertNotSame(h1.getConfig(), h2.getConfig());
}
@Test
public void testIdentityFile() throws Exception {
config("Host orcz\nIdentityFile \"~/foo/ba z\"\nIdentityFile /foo/bar");
final Host h = osc.lookup("orcz");
assertNotNull(h);
File f = h.getIdentityFile();
assertNotNull(f);
// Host does tilde replacement
assertEquals(new File(home, "foo/ba z"), f);
final ConfigRepository.Config c = h.getConfig();
// Config doesn't
assertArrayEquals(new Object[] { "~/foo/ba z", "/foo/bar" },
c.getValues("IdentityFile"));
}
@Test
public void testMultiIdentityFile() throws Exception {
config("IdentityFile \"~/foo/ba z\"\nHost orcz\nIdentityFile /foo/bar\nHOST *\nIdentityFile /foo/baz");
final Host h = osc.lookup("orcz");
assertNotNull(h);
File f = h.getIdentityFile();
assertNotNull(f);
// Host does tilde replacement
assertEquals(new File(home, "foo/ba z"), f);
final ConfigRepository.Config c = h.getConfig();
// Config doesn't
assertArrayEquals(new Object[] { "~/foo/ba z", "/foo/bar", "/foo/baz" },
c.getValues("IdentityFile"));
}
@Test
public void testNegatedPattern() throws Exception {
config("Host repo.or.cz\nIdentityFile ~/foo/bar\nHOST !*.or.cz\nIdentityFile /foo/baz");
final Host h = osc.lookup("repo.or.cz");
assertNotNull(h);
assertEquals(new File(home, "foo/bar"), h.getIdentityFile());
assertArrayEquals(new Object[] { "~/foo/bar" },
h.getConfig().getValues("IdentityFile"));
}
@Test
public void testPattern() throws Exception {
config("Host repo.or.cz\nIdentityFile ~/foo/bar\nHOST *.or.cz\nIdentityFile /foo/baz");
final Host h = osc.lookup("repo.or.cz");
assertNotNull(h);
assertEquals(new File(home, "foo/bar"), h.getIdentityFile());
assertArrayEquals(new Object[] { "~/foo/bar", "/foo/baz" },
h.getConfig().getValues("IdentityFile"));
}
@Test
public void testMultiHost() throws Exception {
config("Host orcz *.or.cz\nIdentityFile ~/foo/bar\nHOST *.or.cz\nIdentityFile /foo/baz");
final Host h1 = osc.lookup("repo.or.cz");
assertNotNull(h1);
assertEquals(new File(home, "foo/bar"), h1.getIdentityFile());
assertArrayEquals(new Object[] { "~/foo/bar", "/foo/baz" },
h1.getConfig().getValues("IdentityFile"));
final Host h2 = osc.lookup("orcz");
assertNotNull(h2);
assertEquals(new File(home, "foo/bar"), h2.getIdentityFile());
assertArrayEquals(new Object[] { "~/foo/bar" },
h2.getConfig().getValues("IdentityFile"));
}
@Test
public void testEqualsSign() throws Exception {
config("Host=orcz\n\tConnectionAttempts = 5\n\tUser=\t foobar\t\n");
final Host h = osc.lookup("orcz");
assertNotNull(h);
assertEquals(5, h.getConnectionAttempts());
assertEquals("foobar", h.getUser());
}
@Test
public void testMissingArgument() throws Exception {
config("Host=orcz\n\tSendEnv\nIdentityFile\t\nForwardX11\n\tUser=\t foobar\t\n");
final Host h = osc.lookup("orcz");
assertNotNull(h);
assertEquals("foobar", h.getUser());
assertArrayEquals(new String[0], h.getConfig().getValues("SendEnv"));
assertNull(h.getIdentityFile());
assertNull(h.getConfig().getValue("ForwardX11"));
}
} }

View File

@ -259,6 +259,9 @@ protected void configureJSch(JSch jsch) {
protected JSch getJSch(final OpenSshConfig.Host hc, FS fs) throws JSchException { protected JSch getJSch(final OpenSshConfig.Host hc, FS fs) throws JSchException {
if (defaultJSch == null) { if (defaultJSch == null) {
defaultJSch = createDefaultJSch(fs); defaultJSch = createDefaultJSch(fs);
if (defaultJSch.getConfigRepository() == null) {
defaultJSch.setConfigRepository(config);
}
for (Object name : defaultJSch.getIdentityNames()) for (Object name : defaultJSch.getIdentityNames())
byIdentityFile.put((String) name, defaultJSch); byIdentityFile.put((String) name, defaultJSch);
} }
@ -272,6 +275,9 @@ protected JSch getJSch(final OpenSshConfig.Host hc, FS fs) throws JSchException
if (jsch == null) { if (jsch == null) {
jsch = new JSch(); jsch = new JSch();
configureJSch(jsch); configureJSch(jsch);
if (jsch.getConfigRepository() == null) {
jsch.setConfigRepository(defaultJSch.getConfigRepository());
}
jsch.setHostKeyRepository(defaultJSch.getHostKeyRepository()); jsch.setHostKeyRepository(defaultJSch.getHostKeyRepository());
jsch.addIdentity(identityKey); jsch.addIdentity(identityKey);
byIdentityFile.put(identityKey, jsch); byIdentityFile.put(identityKey, jsch);

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2008, 2014, Google Inc. * Copyright (C) 2008, 2017, Google Inc.
* and other copyright owners as documented in the project's IP log. * and other copyright owners as documented in the project's IP log.
* *
* This program and the accompanying materials are made available * This program and the accompanying materials are made available
@ -46,17 +46,19 @@
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.security.AccessController; import java.security.AccessController;
import java.security.PrivilegedAction; import java.security.PrivilegedAction;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import org.eclipse.jgit.errors.InvalidPatternException; import org.eclipse.jgit.errors.InvalidPatternException;
import org.eclipse.jgit.fnmatch.FileNameMatcher; import org.eclipse.jgit.fnmatch.FileNameMatcher;
@ -64,14 +66,46 @@
import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.FS;
import org.eclipse.jgit.util.StringUtils; import org.eclipse.jgit.util.StringUtils;
import com.jcraft.jsch.ConfigRepository;
/** /**
* Simple configuration parser for the OpenSSH ~/.ssh/config file. * Fairly complete configuration parser for the OpenSSH ~/.ssh/config file.
* <p> * <p>
* Since JSch does not (currently) have the ability to parse an OpenSSH * JSch does have its own config file parser
* configuration file this is a simple parser to read that file and make the * {@link com.jcraft.jsch.OpenSSHConfig} since version 0.1.50, but it has a
* critical options available to {@link SshSessionFactory}. * number of problems:
* <ul>
* <li>it splits lines of the format "keyword = value" wrongly: you'd end up
* with the value "= value".
* <li>its "Host" keyword is not case insensitive.
* <li>it doesn't handle quoted values.
* <li>JSch's OpenSSHConfig doesn't monitor for config file changes.
* </ul>
* <p>
* Therefore implement our own parser to read an OpenSSH configuration file. It
* makes the critical options available to {@link SshSessionFactory} via
* {@link Host} objects returned by {@link #lookup(String)}, and implements a
* fully conforming {@link ConfigRepository} providing
* {@link com.jcraft.jsch.ConfigRepository.Config}s via
* {@link #getConfig(String)}.
* </p>
* <p>
* Limitations compared to the full OpenSSH 7.5 parser:
* </p>
* <ul>
* <li>This parser does not handle Match or Include keywords.
* <li>This parser does not do %-substitutions.
* <li>This parser does not do host name canonicalization (Jsch ignores it
* anyway).
* </ul>
* Note that OpenSSH's readconf.c is a validating parser; Jsch's
* ConfigRepository OTOH treats all option values as plain strings, so any
* validation must happen in Jsch outside of the parser. Thus this parser does
* not validate option values, except for a few options when constructing a
* {@link Host} object.
*/ */
public class OpenSshConfig { public class OpenSshConfig implements ConfigRepository {
/** IANA assigned port number for SSH. */ /** IANA assigned port number for SSH. */
static final int SSH_PORT = 22; static final int SSH_PORT = 22;
@ -105,16 +139,25 @@ public static OpenSshConfig get(FS fs) {
/** The .ssh/config file we read and monitor for updates. */ /** The .ssh/config file we read and monitor for updates. */
private final File configFile; private final File configFile;
/** Modification time of {@link #configFile} when {@link #hosts} loaded. */ /** Modification time of {@link #configFile} when it was last loaded. */
private long lastModified; private long lastModified;
/** Cached entries read out of the configuration file. */ /**
private Map<String, Host> hosts; * Encapsulates entries read out of the configuration file, and
* {@link Host}s created from that.
*/
private static class State {
Map<String, HostEntry> entries = new LinkedHashMap<>();
Map<String, Host> hosts = new HashMap<>();
}
/** State read from the config file, plus {@link Host}s created from it. */
private State state;
OpenSshConfig(final File h, final File cfg) { OpenSshConfig(final File h, final File cfg) {
home = h; home = h;
configFile = cfg; configFile = cfg;
hosts = Collections.emptyMap(); state = new State();
} }
/** /**
@ -127,75 +170,80 @@ public static OpenSshConfig get(FS fs) {
* @return r configuration for the requested name. Never null. * @return r configuration for the requested name. Never null.
*/ */
public Host lookup(final String hostName) { public Host lookup(final String hostName) {
final Map<String, Host> cache = refresh(); final State cache = refresh();
Host h = cache.get(hostName); Host h = cache.hosts.get(hostName);
if (h == null) if (h != null) {
h = new Host();
if (h.patternsApplied)
return h; 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());
} }
HostEntry fullConfig = new HostEntry();
if (h.hostName == null) // Initialize with default entries at the top of the file, before the
h.hostName = hostName; // first Host block.
if (h.user == null) fullConfig.merge(cache.entries.get(HostEntry.DEFAULT_NAME));
h.user = OpenSshConfig.userName(); for (final Map.Entry<String, HostEntry> e : cache.entries.entrySet()) {
if (h.port == 0) String key = e.getKey();
h.port = OpenSshConfig.SSH_PORT; if (isHostMatch(key, hostName)) {
if (h.connectionAttempts == 0) fullConfig.merge(e.getValue());
h.connectionAttempts = 1; }
h.patternsApplied = true; }
h = new Host(fullConfig, hostName, home);
cache.hosts.put(hostName, h);
return h; return h;
} }
private synchronized Map<String, Host> refresh() { private synchronized State refresh() {
final long mtime = configFile.lastModified(); final long mtime = configFile.lastModified();
if (mtime != lastModified) { if (mtime != lastModified) {
try { State newState = new State();
final FileInputStream in = new FileInputStream(configFile); try (FileInputStream in = new FileInputStream(configFile)) {
try { newState.entries = parse(in);
hosts = parse(in); } catch (IOException none) {
} finally { // Ignore -- we'll set and return an empty state
in.close();
}
} catch (FileNotFoundException none) {
hosts = Collections.emptyMap();
} catch (IOException err) {
hosts = Collections.emptyMap();
} }
lastModified = mtime; lastModified = mtime;
state = newState;
} }
return hosts; return state;
} }
private Map<String, Host> parse(final InputStream in) throws IOException { private Map<String, HostEntry> parse(final InputStream in)
final Map<String, Host> m = new LinkedHashMap<>(); throws IOException {
final Map<String, HostEntry> m = new LinkedHashMap<>();
final BufferedReader br = new BufferedReader(new InputStreamReader(in)); final BufferedReader br = new BufferedReader(new InputStreamReader(in));
final List<Host> current = new ArrayList<>(4); final List<HostEntry> current = new ArrayList<>(4);
String line; String line;
// The man page doesn't say so, but the OpenSSH parser (readconf.c)
// starts out in active mode and thus always applies any lines that
// occur before the first host block. We gather those options in a
// HostEntry for DEFAULT_NAME.
HostEntry defaults = new HostEntry();
current.add(defaults);
m.put(HostEntry.DEFAULT_NAME, defaults);
while ((line = br.readLine()) != null) { while ((line = br.readLine()) != null) {
line = line.trim(); line = line.trim();
if (line.length() == 0 || line.startsWith("#")) //$NON-NLS-1$ if (line.isEmpty() || line.startsWith("#")) { //$NON-NLS-1$
continue; continue;
}
final String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$ String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$
final String keyword = parts[0].trim(); // Although the ssh-config man page doesn't say so, the OpenSSH
final String argValue = parts[1].trim(); // parser does allow quoted keywords.
String keyword = dequote(parts[0].trim());
// man 5 ssh-config says lines had the format "keyword arguments",
// with no indication that arguments were optional. However, let's
// not crap out on missing arguments. See bug 444319.
String argValue = parts.length > 1 ? parts[1].trim() : ""; //$NON-NLS-1$
if (StringUtils.equalsIgnoreCase("Host", keyword)) { //$NON-NLS-1$ if (StringUtils.equalsIgnoreCase("Host", keyword)) { //$NON-NLS-1$
current.clear(); current.clear();
for (final String pattern : argValue.split("[ \t]")) { //$NON-NLS-1$ for (String name : HostEntry.parseList(argValue)) {
final String name = dequote(pattern); if (name == null || name.isEmpty()) {
Host c = m.get(name); // null should not occur, but better be safe than sorry.
continue;
}
HostEntry c = m.get(name);
if (c == null) { if (c == null) {
c = new Host(); c = new HostEntry();
m.put(name, c); m.put(name, c);
} }
current.add(c); current.add(c);
@ -206,57 +254,18 @@ private Map<String, Host> parse(final InputStream in) throws IOException {
if (current.isEmpty()) { if (current.isEmpty()) {
// We received an option outside of a Host block. We // We received an option outside of a Host block. We
// don't know who this should match against, so skip. // don't know who this should match against, so skip.
//
continue; continue;
} }
if (StringUtils.equalsIgnoreCase("HostName", keyword)) { //$NON-NLS-1$ if (HostEntry.isListKey(keyword)) {
for (final Host c : current) List<String> args = HostEntry.parseList(argValue);
if (c.hostName == null) for (HostEntry entry : current) {
c.hostName = dequote(argValue); entry.setValue(keyword, args);
} else if (StringUtils.equalsIgnoreCase("User", keyword)) { //$NON-NLS-1$
for (final Host c : current)
if (c.user == null)
c.user = dequote(argValue);
} else if (StringUtils.equalsIgnoreCase("Port", keyword)) { //$NON-NLS-1$
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 (StringUtils.equalsIgnoreCase("IdentityFile", keyword)) { //$NON-NLS-1$ } else if (!argValue.isEmpty()) {
for (final Host c : current) argValue = dequote(argValue);
if (c.identityFile == null) for (HostEntry entry : current) {
c.identityFile = toFile(dequote(argValue)); entry.setValue(keyword, argValue);
} else if (StringUtils.equalsIgnoreCase(
"PreferredAuthentications", keyword)) { //$NON-NLS-1$
for (final Host c : current)
if (c.preferredAuthentications == null)
c.preferredAuthentications = nows(dequote(argValue));
} else if (StringUtils.equalsIgnoreCase("BatchMode", keyword)) { //$NON-NLS-1$
for (final Host c : current)
if (c.batchMode == null)
c.batchMode = yesno(dequote(argValue));
} else if (StringUtils.equalsIgnoreCase(
"StrictHostKeyChecking", keyword)) { //$NON-NLS-1$
String value = dequote(argValue);
for (final Host c : current)
if (c.strictHostKeyChecking == null)
c.strictHostKeyChecking = value;
} else if (StringUtils.equalsIgnoreCase(
"ConnectionAttempts", keyword)) { //$NON-NLS-1$
try {
final int connectionAttempts = Integer.parseInt(dequote(argValue));
if (connectionAttempts > 0) {
for (final Host c : current)
if (c.connectionAttempts == 0)
c.connectionAttempts = connectionAttempts;
}
} catch (NumberFormatException nfe) {
// ignore bad values
} }
} }
} }
@ -264,23 +273,35 @@ private Map<String, Host> parse(final InputStream in) throws IOException {
return m; return m;
} }
private static boolean isHostPattern(final String s) { private static boolean isHostMatch(final String pattern,
return s.indexOf('*') >= 0 || s.indexOf('?') >= 0; final String name) {
if (pattern.startsWith("!")) { //$NON-NLS-1$
return !patternMatchesHost(pattern.substring(1), name);
} else {
return patternMatchesHost(pattern, name);
}
} }
private static boolean isHostMatch(final String pattern, final String name) { private static boolean patternMatchesHost(final String pattern,
final FileNameMatcher fn; final String name) {
try { if (pattern.indexOf('*') >= 0 || pattern.indexOf('?') >= 0) {
fn = new FileNameMatcher(pattern, null); final FileNameMatcher fn;
} catch (InvalidPatternException e) { try {
return false; fn = new FileNameMatcher(pattern, null);
} catch (InvalidPatternException e) {
return false;
}
fn.append(name);
return fn.isMatch();
} else {
// Not a pattern but a full host name
return pattern.equals(name);
} }
fn.append(name);
return fn.isMatch();
} }
private static String dequote(final String value) { private static String dequote(final String value) {
if (value.startsWith("\"") && value.endsWith("\"")) //$NON-NLS-1$ //$NON-NLS-2$ if (value.startsWith("\"") && value.endsWith("\"") //$NON-NLS-1$ //$NON-NLS-2$
&& value.length() > 1)
return value.substring(1, value.length() - 1); return value.substring(1, value.length() - 1);
return value; return value;
} }
@ -300,13 +321,15 @@ private static Boolean yesno(final String value) {
return Boolean.FALSE; return Boolean.FALSE;
} }
private File toFile(final String path) { private static int positive(final String value) {
if (path.startsWith("~/")) //$NON-NLS-1$ if (value != null) {
return new File(home, path.substring(2)); try {
File ret = new File(path); return Integer.parseUnsignedInt(value);
if (ret.isAbsolute()) } catch (NumberFormatException e) {
return ret; // Ignore
return new File(home, path); }
}
return -1;
} }
static String userName() { static String userName() {
@ -318,6 +341,293 @@ public String run() {
}); });
} }
private static class HostEntry implements ConfigRepository.Config {
/**
* "Host name" of the HostEntry for the default options before the first
* host block in a config file.
*/
public static final String DEFAULT_NAME = ""; //$NON-NLS-1$
// See com.jcraft.jsch.OpenSSHConfig. Translates some command-line keys
// to ssh-config keys.
private static final Map<String, String> KEY_MAP = new HashMap<>();
static {
KEY_MAP.put("kex", "KexAlgorithms"); //$NON-NLS-1$//$NON-NLS-2$
KEY_MAP.put("server_host_key", "HostKeyAlgorithms"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("cipher.c2s", "Ciphers"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("cipher.s2c", "Ciphers"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("mac.c2s", "Macs"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("mac.s2c", "Macs"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("compression.s2c", "Compression"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("compression.c2s", "Compression"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("compression_level", "CompressionLevel"); //$NON-NLS-1$ //$NON-NLS-2$
KEY_MAP.put("MaxAuthTries", "NumberOfPasswordPrompts"); //$NON-NLS-1$ //$NON-NLS-2$
}
/**
* Keys that can be specified multiple times, building up a list. (I.e.,
* those are the keys that do not follow the general rule of "first
* occurrence wins".)
*/
private static final Set<String> MULTI_KEYS = new HashSet<>();
static {
MULTI_KEYS.add("CERTIFICATEFILE"); //$NON-NLS-1$
MULTI_KEYS.add("IDENTITYFILE"); //$NON-NLS-1$
MULTI_KEYS.add("LOCALFORWARD"); //$NON-NLS-1$
MULTI_KEYS.add("REMOTEFORWARD"); //$NON-NLS-1$
MULTI_KEYS.add("SENDENV"); //$NON-NLS-1$
}
/**
* Keys that take a whitespace-separated list of elements as argument.
* Because the dequote-handling is different, we must handle those in
* the parser. There are a few other keys that take comma-separated
* lists as arguments, but for the parser those are single arguments
* that must be quoted if they contain whitespace, and taking them apart
* is the responsibility of the user of those keys.
*/
private static final Set<String> LIST_KEYS = new HashSet<>();
static {
LIST_KEYS.add("CANONICALDOMAINS"); //$NON-NLS-1$
LIST_KEYS.add("GLOBALKNOWNHOSTSFILE"); //$NON-NLS-1$
LIST_KEYS.add("SENDENV"); //$NON-NLS-1$
LIST_KEYS.add("USERKNOWNHOSTSFILE"); //$NON-NLS-1$
}
private Map<String, String> options;
private Map<String, List<String>> multiOptions;
private Map<String, List<String>> listOptions;
@Override
public String getHostname() {
return getValue("HOSTNAME"); //$NON-NLS-1$
}
@Override
public String getUser() {
return getValue("USER"); //$NON-NLS-1$
}
@Override
public int getPort() {
return positive(getValue("PORT")); //$NON-NLS-1$
}
private static String mapKey(String key) {
String k = KEY_MAP.get(key);
if (k == null) {
k = key;
}
return k.toUpperCase(Locale.ROOT);
}
private String findValue(String key) {
String k = mapKey(key);
String result = options != null ? options.get(k) : null;
if (result == null) {
// Also check the list and multi options. Modern OpenSSH treats
// UserKnownHostsFile and GlobalKnownHostsFile as list-valued,
// and so does this parser. Jsch 0.1.54 in general doesn't know
// about list-valued options (it _does_ know multi-valued
// options, though), and will ask for a single value for such
// options.
//
// Let's be lenient and return at least the first value from
// a list-valued or multi-valued key for which Jsch asks for a
// single value.
List<String> values = listOptions != null ? listOptions.get(k)
: null;
if (values == null) {
values = multiOptions != null ? multiOptions.get(k) : null;
}
if (values != null && !values.isEmpty()) {
result = values.get(0);
}
}
return result;
}
@Override
public String getValue(String key) {
// See com.jcraft.jsch.OpenSSHConfig.MyConfig.getValue() for this
// special case.
if (key.equals("compression.s2c") //$NON-NLS-1$
|| key.equals("compression.c2s")) { //$NON-NLS-1$
String foo = findValue(key);
if (foo == null || foo.equals("no")) { //$NON-NLS-1$
return "none,zlib@openssh.com,zlib"; //$NON-NLS-1$
}
return "zlib@openssh.com,zlib,none"; //$NON-NLS-1$
}
return findValue(key);
}
@Override
public String[] getValues(String key) {
String k = mapKey(key);
List<String> values = listOptions != null ? listOptions.get(k)
: null;
if (values == null) {
values = multiOptions != null ? multiOptions.get(k) : null;
}
if (values == null || values.isEmpty()) {
return new String[0];
}
return values.toArray(new String[values.size()]);
}
public void setValue(String key, String value) {
String k = key.toUpperCase(Locale.ROOT);
if (MULTI_KEYS.contains(k)) {
if (multiOptions == null) {
multiOptions = new HashMap<>();
}
List<String> values = multiOptions.get(k);
if (values == null) {
values = new ArrayList<>(4);
multiOptions.put(k, values);
}
values.add(value);
} else {
if (options == null) {
options = new HashMap<>();
}
if (!options.containsKey(k)) {
options.put(k, value);
}
}
}
public void setValue(String key, List<String> values) {
if (values.isEmpty()) {
// Can occur only on a missing argument: ignore.
return;
}
String k = key.toUpperCase(Locale.ROOT);
// Check multi-valued keys first; because of the replacement
// strategy, they must take precedence over list-valued keys
// which always follow the "first occurrence wins" strategy.
//
// Note that SendEnv is a multi-valued list-valued key. (It's
// rather immaterial for JGit, though.)
if (MULTI_KEYS.contains(k)) {
if (multiOptions == null) {
multiOptions = new HashMap<>(2 * MULTI_KEYS.size());
}
List<String> items = multiOptions.get(k);
if (items == null) {
items = new ArrayList<>(values);
multiOptions.put(k, items);
} else {
items.addAll(values);
}
} else {
if (listOptions == null) {
listOptions = new HashMap<>(2 * LIST_KEYS.size());
}
if (!listOptions.containsKey(k)) {
listOptions.put(k, values);
}
}
}
public static boolean isListKey(String key) {
return LIST_KEYS.contains(key.toUpperCase(Locale.ROOT));
}
/**
* Splits the argument into a list of whitespace-separated elements.
* Elements containing whitespace must be quoted and will be de-quoted.
*
* @param argument
* argument part of the configuration line as read from the
* config file
* @return a {@link List} of elements, possibly empty and possibly
* containing empty elements
*/
public static List<String> parseList(String argument) {
List<String> result = new ArrayList<>(4);
int start = 0;
int length = argument.length();
while (start < length) {
// Skip whitespace
if (Character.isSpaceChar(argument.charAt(start))) {
start++;
continue;
}
if (argument.charAt(start) == '"') {
int stop = argument.indexOf('"', start + 1);
if (stop <= start) {
// No closing double quote: skip
break;
}
result.add(argument.substring(start + 1, stop));
start = stop + 1;
} else {
int stop = start + 1;
while (stop < length
&& !Character.isSpaceChar(argument.charAt(stop))) {
stop++;
}
result.add(argument.substring(start, stop));
start = stop + 1;
}
}
return result;
}
protected void merge(HostEntry entry) {
if (entry == null) {
// Can occur if we could not read the config file
return;
}
if (entry.options != null) {
if (options == null) {
options = new HashMap<>();
}
for (Map.Entry<String, String> item : entry.options
.entrySet()) {
if (!options.containsKey(item.getKey())) {
options.put(item.getKey(), item.getValue());
}
}
}
if (entry.listOptions != null) {
if (listOptions == null) {
listOptions = new HashMap<>(2 * LIST_KEYS.size());
}
for (Map.Entry<String, List<String>> item : entry.listOptions
.entrySet()) {
if (!listOptions.containsKey(item.getKey())) {
listOptions.put(item.getKey(), item.getValue());
}
}
}
if (entry.multiOptions != null) {
if (multiOptions == null) {
multiOptions = new HashMap<>(2 * MULTI_KEYS.size());
}
for (Map.Entry<String, List<String>> item : entry.multiOptions
.entrySet()) {
List<String> values = multiOptions.get(item.getKey());
if (values == null) {
values = new ArrayList<>(item.getValue());
multiOptions.put(item.getKey(), values);
} else {
values.addAll(item.getValue());
}
}
}
}
}
/** /**
* Configuration of one "Host" block in the configuration file. * Configuration of one "Host" block in the configuration file.
* <p> * <p>
@ -330,8 +640,6 @@ public String run() {
* already merged into this block. * already merged into this block.
*/ */
public static class Host { public static class Host {
boolean patternsApplied;
String hostName; String hostName;
int port; int port;
@ -348,23 +656,18 @@ public static class Host {
int connectionAttempts; int connectionAttempts;
void copyFrom(final Host src) { private Config config;
if (hostName == null)
hostName = src.hostName; /**
if (port == 0) * Creates a new uninitialized {@link Host}.
port = src.port; */
if (identityFile == null) public Host() {
identityFile = src.identityFile; // For API backwards compatibility with pre-4.9 JGit
if (user == null) }
user = src.user;
if (preferredAuthentications == null) Host(Config config, String hostName, File homeDir) {
preferredAuthentications = src.preferredAuthentications; this.config = config;
if (batchMode == null) complete(hostName, homeDir);
batchMode = src.batchMode;
if (strictHostKeyChecking == null)
strictHostKeyChecking = src.strictHostKeyChecking;
if (connectionAttempts == 0)
connectionAttempts = src.connectionAttempts;
} }
/** /**
@ -432,5 +735,71 @@ public boolean isBatchMode() {
public int getConnectionAttempts() { public int getConnectionAttempts() {
return connectionAttempts; return connectionAttempts;
} }
private void complete(String initialHostName, File homeDir) {
// Try to set values from the options.
hostName = config.getHostname();
user = config.getUser();
port = config.getPort();
connectionAttempts = positive(
config.getValue("ConnectionAttempts")); //$NON-NLS-1$
strictHostKeyChecking = config.getValue("StrictHostKeyChecking"); //$NON-NLS-1$
String value = config.getValue("BatchMode"); //$NON-NLS-1$
if (value != null) {
batchMode = yesno(value);
}
value = config.getValue("PreferredAuthentications"); //$NON-NLS-1$
if (value != null) {
preferredAuthentications = nows(value);
}
// Fill in defaults if still not set
if (hostName == null) {
hostName = initialHostName;
}
if (user == null) {
user = OpenSshConfig.userName();
}
if (port <= 0) {
port = OpenSshConfig.SSH_PORT;
}
if (connectionAttempts <= 0) {
connectionAttempts = 1;
}
String[] identityFiles = config.getValues("IdentityFile"); //$NON-NLS-1$
if (identityFiles != null && identityFiles.length > 0) {
identityFile = toFile(identityFiles[0], homeDir);
}
}
private File toFile(String path, File home) {
if (path.startsWith("~/")) { //$NON-NLS-1$
return new File(home, path.substring(2));
}
File ret = new File(path);
if (ret.isAbsolute()) {
return ret;
}
return new File(home, path);
}
Config getConfig() {
return config;
}
}
/**
* Retrieves the full {@link com.jcraft.jsch.ConfigRepository.Config Config}
* for the given host name.
*
* @param hostName
* to get the config for
* @return the configuration for the host
* @since 4.9
*/
@Override
public Config getConfig(String hostName) {
Host host = lookup(hostName);
return host.getConfig();
} }
} }