Summary
When a config file pulled in via Include ends inside a non-matching Host/Match block, that non-matching state leaks back into the parent file. Every subsequent non-conditional directive in the parent — including further Include directives — is then silently skipped. OpenSSH does not behave this way: it processes the rest of the parent file normally.
The practical impact is that asyncssh can silently ignore ProxyJump, ProxyCommand, IdentityFile, etc. that OpenSSH applies, leading to failed or misrouted connections that are very hard to diagnose — the directives simply vanish, with no error raised.
Environment
- asyncssh 2.23.0
- Python 3.10
- Reproduced on macOS, but the cause is platform-independent.
Minimal reproduction
Three files (main uses absolute includes for clarity):
main
Include /tmp/asshbug/inc1
Include /tmp/asshbug/inc2
inc1 — ends with a Host block that does not match the target host:
Host doesnotmatch
User nobody
inc2 — the rule we actually care about:
Host targethost
ProxyJump jump.example.net
import asyncssh
opts = asyncssh.SSHClientConnectionOptions(host="targethost", config=["/tmp/asshbug/main"])
print(opts.tunnel) # ProxyJump resolved by asyncssh
|
ProxyJump for targethost |
| asyncssh 2.23.0 |
None (incorrect) |
ssh -G -F main targethost (OpenSSH) |
jump.example.net (correct) |
asyncssh reading inc2 directly (config=["/tmp/asshbug/inc2"]) |
jump.example.net (correct) |
The rule in inc2 is correct in isolation — the non-matching Host block at the end of the previous include (inc1) is what causes Include inc2 to be skipped.
Root cause
In asyncssh/config.py:
parse() sets self._matching = True on entry (~line 399), but neither parse() nor _include() saves/restores the caller's _matching state.
_include() (~line 151) saves and restores self._path around the recursive self.parse(path) call (~lines 156 and 175), but does not do the same for self._matching.
- So when an included file's final
Host/Match block does not match, parse() returns with self._matching = False, and that value persists back in the parent file.
- The per-directive gate (~line 444) then drops everything non-conditional for the remainder of the parent file:
if not self._matching and loption not in self._conditionals: # _conditionals = {'host', 'match'}
continue
Since Include is not a conditional keyword, the parent's next Include (and any other non-Host/Match directive) is silently skipped.
Suggested fix
In _include(), save and restore self._matching (and likely self._final) around the recursive parse() loop, mirroring the existing self._path save/restore — so that a Host/Match block inside an included file does not leak its active state back into the including file. This matches OpenSSH's observed behavior, where an Include does not suppress processing of subsequent directives in the parent.
Notes
Encountered in the wild with a user ~/.ssh/config that does:
Include teleport_config # ends with a non-matching `Host *.teleport` block
Include ssh-config-max.txt # contains Host 10.x.x.* -> ProxyJump ... (silently skipped)
OpenSSH read all three files and resolved the ProxyJump; asyncssh stopped after the first include and dialed the target directly (connect timeout). Inserting a bare Host * line before the second Include works around it by resetting _matching to True.
Summary
When a config file pulled in via
Includeends inside a non-matchingHost/Matchblock, that non-matching state leaks back into the parent file. Every subsequent non-conditional directive in the parent — including furtherIncludedirectives — is then silently skipped. OpenSSH does not behave this way: it processes the rest of the parent file normally.The practical impact is that asyncssh can silently ignore
ProxyJump,ProxyCommand,IdentityFile, etc. that OpenSSH applies, leading to failed or misrouted connections that are very hard to diagnose — the directives simply vanish, with no error raised.Environment
Minimal reproduction
Three files (
mainuses absolute includes for clarity):maininc1— ends with aHostblock that does not match the target host:inc2— the rule we actually care about:ProxyJumpfortargethostNone(incorrect)ssh -G -F main targethost(OpenSSH)jump.example.net(correct)inc2directly (config=["/tmp/asshbug/inc2"])jump.example.net(correct)The rule in
inc2is correct in isolation — the non-matchingHostblock at the end of the previous include (inc1) is what causesInclude inc2to be skipped.Root cause
In
asyncssh/config.py:parse()setsself._matching = Trueon entry (~line 399), but neitherparse()nor_include()saves/restores the caller's_matchingstate._include()(~line 151) saves and restoresself._patharound the recursiveself.parse(path)call (~lines 156 and 175), but does not do the same forself._matching.Host/Matchblock does not match,parse()returns withself._matching = False, and that value persists back in the parent file.Since
Includeis not a conditional keyword, the parent's nextInclude(and any other non-Host/Matchdirective) is silently skipped.Suggested fix
In
_include(), save and restoreself._matching(and likelyself._final) around the recursiveparse()loop, mirroring the existingself._pathsave/restore — so that aHost/Matchblock inside an included file does not leak its active state back into the including file. This matches OpenSSH's observed behavior, where anIncludedoes not suppress processing of subsequent directives in the parent.Notes
Encountered in the wild with a user
~/.ssh/configthat does:OpenSSH read all three files and resolved the
ProxyJump; asyncssh stopped after the first include and dialed the target directly (connect timeout). Inserting a bareHost *line before the secondIncludeworks around it by resetting_matchingtoTrue.