diff --git a/ssh/src/main/java/ch/cyberduck/core/sftp/openssh/config/transport/OpenSshConfig.java b/ssh/src/main/java/ch/cyberduck/core/sftp/openssh/config/transport/OpenSshConfig.java index 0e646bd5b5..8f67a39c23 100644 --- a/ssh/src/main/java/ch/cyberduck/core/sftp/openssh/config/transport/OpenSshConfig.java +++ b/ssh/src/main/java/ch/cyberduck/core/sftp/openssh/config/transport/OpenSshConfig.java @@ -87,6 +87,12 @@ public class OpenSshConfig { private Map hosts = Collections.emptyMap(); + /** + * Cached Match host blocks read out of the configuration file. + */ + private List matchBlocks + = Collections.emptyList(); + /** * Obtain the user's configuration data. *

@@ -125,6 +131,12 @@ public class OpenSshConfig { log.debug("Found host match in SSH config:{}", e.getValue()); h.copyFrom(e.getValue()); } + for(final MatchBlock mb : matchBlocks) { + if(isMatchHostApplicable(mb.patterns, hostName)) { + log.debug("Found match block applicable for {} in SSH config: {}", hostName, mb.host); + h.copyFrom(mb.host); + } + } if(h.port == 0) { h.port = -1; } @@ -136,13 +148,16 @@ public class OpenSshConfig { final long mtime = configuration.attributes().getModificationDate(); if(mtime != lastModified) { try { + final List newMatchBlocks = new ArrayList<>(); try(final InputStream in = configuration.getInputStream()) { - hosts = this.parse(in, configuration.getParent(), new HashSet<>()); + hosts = this.parse(in, configuration.getParent(), new HashSet<>(), newMatchBlocks); } + matchBlocks = newMatchBlocks; } catch(AccessDeniedException | IOException none) { log.warn("Failure reading {}", configuration); hosts = Collections.emptyMap(); + matchBlocks = Collections.emptyList(); } lastModified = mtime; } @@ -151,11 +166,12 @@ public class OpenSshConfig { /** * - * @param in Configuration file input stream - * @param directory Directory containing the configuration file. - * @param seen Previously read configuration files. + * @param in Configuration file input stream + * @param directory Directory containing the configuration file. + * @param seen Previously read configuration files. + * @param matchBlocks Accumulator for Match host blocks found during parsing. */ - private Map parse(final InputStream in, final Local directory, final Set seen) throws IOException { + private Map parse(final InputStream in, final Local directory, final Set seen, final List matchBlocks) throws IOException { final Map m = new LinkedHashMap<>(); final BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); final List current = new ArrayList<>(4); @@ -181,6 +197,26 @@ public class OpenSshConfig { } continue; } + if("Match".equalsIgnoreCase(keyword)) { + current.clear(); + // Only the "host" criteria keyword is supported; skip any other Match block + final String[] tokens = argValue.split("[ \t]+", 2); + if(tokens.length == 2 && "host".equalsIgnoreCase(tokens[0])) { + final List patterns = new ArrayList<>(); + for(final String p : tokens[1].split(",")) { + final String trimmed = dequote(p.trim()); + if(!trimmed.isEmpty()) { + patterns.add(trimmed); + } + } + if(!patterns.isEmpty()) { + final Host matchHost = new Host(); + matchBlocks.add(new MatchBlock(patterns, matchHost)); + current.add(matchHost); + } + } + continue; + } if("Include".equalsIgnoreCase(keyword)) { for(final String pattern : argValue.split("[ \t]")) { for(final Local included : resolve(directory, dequote(pattern))) { @@ -190,7 +226,7 @@ public class OpenSshConfig { } try { try(final InputStream i = included.getInputStream()) { - final Map sub = this.parse(i, included.getParent(), seen); + final Map sub = this.parse(i, included.getParent(), seen, matchBlocks); for(final Map.Entry e : sub.entrySet()) { m.computeIfAbsent(e.getKey(), k -> e.getValue()); } @@ -321,6 +357,31 @@ public class OpenSshConfig { return result; } + /** + * Evaluates whether a {@code Match host} block applies to the given hostname. + *

+ * A block applies when at least one positive pattern matches (or all patterns are negated) and no negated pattern + * matches. Negated patterns are prefixed with {@code !}. + */ + private static boolean isMatchHostApplicable(final List patterns, final String hostName) { + boolean hasPositive = false; + boolean anyPositiveMatch = false; + for(final String pattern : patterns) { + if(pattern.startsWith("!")) { + if(isHostMatch(pattern.substring(1), hostName)) { + return false; + } + } + else { + hasPositive = true; + if(isHostMatch(pattern, hostName)) { + anyPositiveMatch = true; + } + } + } + return !hasPositive || anyPositiveMatch; + } + private static boolean isHostPattern(final String s) { return s.indexOf('*') >= 0 || s.indexOf('?') >= 0; } @@ -500,6 +561,16 @@ public class OpenSshConfig { } } + private static final class MatchBlock { + final List patterns; + final Host host; + + MatchBlock(final List patterns, final Host host) { + this.patterns = patterns; + this.host = host; + } + } + @Override public String toString() { final StringBuilder sb = new StringBuilder("OpenSshConfig{"); diff --git a/ssh/src/test/java/ch/cyberduck/core/sftp/openssh/OpenSshConfigTest.java b/ssh/src/test/java/ch/cyberduck/core/sftp/openssh/OpenSshConfigTest.java index 39e7adc86c..d86ca6cb90 100644 --- a/ssh/src/test/java/ch/cyberduck/core/sftp/openssh/OpenSshConfigTest.java +++ b/ssh/src/test/java/ch/cyberduck/core/sftp/openssh/OpenSshConfigTest.java @@ -75,4 +75,48 @@ public class OpenSshConfigTest { final OpenSshConfig.Host host = config.lookup("circular-host"); assertEquals("circular.example.com", host.getHostName()); } + + @Test + public void testMatchHostGlobPattern() { + final OpenSshConfig config = new OpenSshConfig(new Local("src/test/resources", "openssh/config-match-host")); + final OpenSshConfig.Host host = config.lookup("foo.example.com"); + assertEquals("matchuser", host.getUser()); + assertEquals(2222, host.getPort()); + } + + @Test + public void testMatchHostExactPattern() { + final OpenSshConfig config = new OpenSshConfig(new Local("src/test/resources", "openssh/config-match-host")); + final OpenSshConfig.Host host = config.lookup("exact.example.com"); + // Exact pattern block sets IdentityFile + assertEquals("~/.ssh/exact-key", host.getIdentityFile()); + } + + @Test + public void testMatchHostNegationExcludesHost() { + final OpenSshConfig config = new OpenSshConfig(new Local("src/test/resources", "openssh/config-match-host")); + // excluded.example.com matches *.example.com but is negated in the third block + final OpenSshConfig.Host excluded = config.lookup("excluded.example.com"); + assertEquals(null, excluded.getIdentityAgent()); + // other.example.com is not excluded so it should get the IdentityAgent + final OpenSshConfig.Host other = config.lookup("other.example.com"); + assertEquals("~/.ssh/agent.sock", other.getIdentityAgent()); + } + + @Test + public void testMatchHostNoMatchForUnrelatedHost() { + final OpenSshConfig config = new OpenSshConfig(new Local("src/test/resources", "openssh/config-match-host")); + // Host outside *.example.com should not pick up any Match host settings + final OpenSshConfig.Host host = config.lookup("unrelated.org"); + assertEquals(null, host.getUser()); + assertEquals(-1, host.getPort()); + } + + @Test + public void testMatchUnsupportedCriteriaIsIgnored() { + final OpenSshConfig config = new OpenSshConfig(new Local("src/test/resources", "openssh/config-match-host")); + // "Match user alice" must be ignored; Port 9999 must not leak to any host lookup + final OpenSshConfig.Host host = config.lookup("foo.example.com"); + assertEquals(2222, host.getPort()); + } } diff --git a/ssh/src/test/resources/openssh/config-match-host b/ssh/src/test/resources/openssh/config-match-host new file mode 100644 index 0000000000..4e11ec099b --- /dev/null +++ b/ssh/src/test/resources/openssh/config-match-host @@ -0,0 +1,12 @@ +Match host *.example.com + User matchuser + Port 2222 + +Match host exact.example.com + IdentityFile ~/.ssh/exact-key + +Match host *.example.com,!excluded.example.com + IdentityAgent ~/.ssh/agent.sock + +Match user alice + Port 9999