Add support for the Match directive with criteria keyword host.

This commit is contained in:
David Kocher
2026-04-28 14:42:22 +02:00
parent 4fc2fee5e9
commit c9d2bbf72a
3 changed files with 133 additions and 6 deletions
@@ -87,6 +87,12 @@ public class OpenSshConfig {
private Map<String, Host> hosts
= Collections.emptyMap();
/**
* Cached Match host blocks read out of the configuration file.
*/
private List<MatchBlock> matchBlocks
= Collections.emptyList();
/**
* Obtain the user's configuration data.
* <p/>
@@ -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<MatchBlock> 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<String, Host> parse(final InputStream in, final Local directory, final Set<Local> seen) throws IOException {
private Map<String, Host> parse(final InputStream in, final Local directory, final Set<Local> seen, final List<MatchBlock> matchBlocks) throws IOException {
final Map<String, Host> m = new LinkedHashMap<>();
final BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
final List<Host> 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<String> 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<String, Host> sub = this.parse(i, included.getParent(), seen);
final Map<String, Host> sub = this.parse(i, included.getParent(), seen, matchBlocks);
for(final Map.Entry<String, Host> 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.
* <p>
* 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<String> 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<String> patterns;
final Host host;
MatchBlock(final List<String> patterns, final Host host) {
this.patterns = patterns;
this.host = host;
}
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("OpenSshConfig{");
@@ -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());
}
}
@@ -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