Skip to content

Commit b99e8d5

Browse files
committed
Add support for hostname canonicalization
This commit adds support for hostname canonicalization. It supports the same config items used in OpenSSH config files, but also provides options which can be passed directly, without the need to use a config file. This commit also adds automatic reloading of the config file when canonicalization is performed, as well as support for match keywords "canonical" and "final". This commit also adds support for negation of match keywords. Thanks go to GitHub user commonism who suggested adding this support and provided a proposed implementation.
1 parent 32a9fe1 commit b99e8d5

File tree

7 files changed

+493
-61
lines changed

7 files changed

+493
-61
lines changed

asyncssh/config.py

Lines changed: 86 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,15 @@ class SSHConfig:
6060
_percent_expand = {'AuthorizedKeysFile'}
6161
_handlers: Dict[str, Tuple[str, Callable]] = {}
6262

63-
def __init__(self, last_config: Optional['SSHConfig'], reload: bool):
63+
def __init__(self, last_config: Optional['SSHConfig'], reload: bool,
64+
canonical: bool, final: bool):
6465
if last_config:
6566
self._last_options = last_config.get_options(reload)
6667
else:
6768
self._last_options = {}
6869

70+
self._canonical = canonical
71+
self._final = True if final else None
6972
self._default_path = Path('~', '.ssh').expanduser()
7073
self._path = Path()
7174
self._line_no = 0
@@ -153,35 +156,53 @@ def _match(self, option: str, args: List[str]) -> None:
153156

154157
# pylint: disable=unused-argument
155158

159+
matching = True
160+
156161
while args:
157162
match = args.pop(0).lower()
158163

164+
if match[0] == '!':
165+
match = match[1:]
166+
negated = True
167+
else:
168+
negated = False
169+
170+
if match == 'final' and self._final is None:
171+
self._final = False
172+
159173
if match == 'all':
160-
self._matching = True
161-
continue
174+
result = True
175+
elif match == 'canonical':
176+
result = self._canonical
177+
elif match == 'final':
178+
result = cast(bool, self._final)
179+
else:
180+
match_val = self._match_val(match)
162181

163-
match_val = self._match_val(match)
182+
if match != 'exec' and match_val is None:
183+
self._error(f'Invalid match condition {match}')
164184

165-
if match != 'exec' and match_val is None:
166-
self._error('Invalid match condition')
185+
try:
186+
arg = args.pop(0)
187+
except IndexError:
188+
self._error(f'Missing {match} match pattern')
189+
190+
if matching:
191+
if match == 'exec':
192+
result = _exec(arg)
193+
elif match in ('address', 'localaddress'):
194+
host_pat = HostPatternList(arg)
195+
ip = ip_address(cast(str, match_val)) \
196+
if match_val else None
197+
result = host_pat.matches(None, match_val, ip)
198+
else:
199+
wild_pat = WildcardPatternList(arg)
200+
result = wild_pat.matches(match_val)
167201

168-
try:
169-
if match == 'exec':
170-
self._matching = _exec(args.pop(0))
171-
elif match in ('address', 'localaddress'):
172-
host_pat = HostPatternList(args.pop(0))
173-
ip = ip_address(cast(str, match_val)) \
174-
if match_val else None
175-
self._matching = host_pat.matches(None, match_val, ip)
176-
else:
177-
wild_pat = WildcardPatternList(args.pop(0))
178-
self._matching = wild_pat.matches(match_val)
179-
except IndexError:
180-
self._error(f'Missing {match} match pattern')
202+
if matching and result == negated:
203+
matching = False
181204

182-
if not self._matching:
183-
args.clear()
184-
break
205+
self._matching = matching
185206

186207
def _set_bool(self, option: str, args: List[str]) -> None:
187208
"""Set a boolean config option"""
@@ -276,6 +297,23 @@ def _set_address_family(self, option: str, args: List[str]) -> None:
276297
if option not in self._options:
277298
self._options[option] = value
278299

300+
def _set_canonicalize_host(self, option: str, args: List[str]) -> None:
301+
"""Set a canonicalize host config option"""
302+
303+
value_str = args.pop(0).lower()
304+
305+
if value_str in ('yes', 'true'):
306+
value: Union[bool, str] = True
307+
elif value_str in ('no', 'false'):
308+
value = False
309+
elif value_str == 'always':
310+
value = value_str
311+
else:
312+
self._error(f'Invalid {option} value: {value_str}')
313+
314+
if option not in self._options:
315+
self._options[option] = value
316+
279317
def _set_rekey_limits(self, option: str, args: List[str]) -> None:
280318
"""Set rekey limits config option"""
281319

@@ -295,6 +333,11 @@ def _set_rekey_limits(self, option: str, args: List[str]) -> None:
295333
if option not in self._options:
296334
self._options[option] = byte_limit, time_limit
297335

336+
def has_match_final(self) -> bool:
337+
"""Return whether this config includes a 'Match final' block"""
338+
339+
return self._final is not None
340+
298341
def parse(self, path: Path) -> None:
299342
"""Parse an OpenSSH config file and return matching declarations"""
300343

@@ -384,10 +427,10 @@ def get_options(self, reload: bool) -> Dict[str, object]:
384427
@classmethod
385428
def load(cls, last_config: Optional['SSHConfig'],
386429
config_paths: ConfigPaths, reload: bool,
387-
*args: object) -> 'SSHConfig':
430+
canonical: bool, final: bool, *args: object) -> 'SSHConfig':
388431
"""Load a list of OpenSSH config files into a config object"""
389432

390-
config = cls(last_config, reload, *args)
433+
config = cls(last_config, reload, canonical, final, *args)
391434

392435
if config_paths:
393436
if isinstance(config_paths, (str, PurePath)):
@@ -429,8 +472,9 @@ class SSHClientConfig(SSHConfig):
429472
'IdentityFile', 'ProxyCommand', 'RemoteCommand'}
430473

431474
def __init__(self, last_config: 'SSHConfig', reload: bool,
432-
local_user: str, user: str, host: str, port: int) -> None:
433-
super().__init__(last_config, reload)
475+
canonical: bool, final: bool, local_user: str,
476+
user: str, host: str, port: int) -> None:
477+
super().__init__(last_config, reload, canonical, final)
434478

435479
self._local_user = local_user
436480
self._orig_host = host
@@ -485,10 +529,10 @@ def _set_request_tty(self, option: str, args: List[str]) -> None:
485529
value: Union[bool, str] = True
486530
elif value_str in ('no', 'false'):
487531
value = False
488-
elif value_str not in ('force', 'auto'):
489-
self._error(f'Invalid {option} value: {value_str}')
490-
else:
532+
elif value_str in ('force', 'auto'):
491533
value = value_str
534+
else:
535+
self._error(f'Invalid {option} value: {value_str}')
492536

493537
if option not in self._options:
494538
self._options[option] = value
@@ -531,6 +575,11 @@ def _set_tokens(self) -> None:
531575

532576
('AddressFamily', SSHConfig._set_address_family),
533577
('BindAddress', SSHConfig._set_string),
578+
('CanonicalDomains', SSHConfig._set_string_list),
579+
('CanonicalizeFallbackLocal', SSHConfig._set_bool),
580+
('CanonicalizeHostname', SSHConfig._set_canonicalize_host),
581+
('CanonicalizeMaxDots', SSHConfig._set_int),
582+
('CanonicalizePermittedCNAMEs', SSHConfig._set_string_list),
534583
('CASignatureAlgorithms', SSHConfig._set_string),
535584
('CertificateFile', SSHConfig._append_string),
536585
('ChallengeResponseAuthentication', SSHConfig._set_bool),
@@ -579,9 +628,9 @@ class SSHServerConfig(SSHConfig):
579628
"""Settings from an OpenSSH server config file"""
580629

581630
def __init__(self, last_config: 'SSHConfig', reload: bool,
582-
local_addr: str, local_port: int, user: str,
583-
host: str, addr: str) -> None:
584-
super().__init__(last_config, reload)
631+
canonical: bool, final: bool, local_addr: str,
632+
local_port: int, user: str, host: str, addr: str) -> None:
633+
super().__init__(last_config, reload, canonical, final)
585634

586635
self._local_addr = local_addr
587636
self._local_port = local_port
@@ -618,6 +667,11 @@ def _set_tokens(self) -> None:
618667
('AuthorizedKeysFile', SSHConfig._set_string_list),
619668
('AllowAgentForwarding', SSHConfig._set_bool),
620669
('BindAddress', SSHConfig._set_string),
670+
('CanonicalDomains', SSHConfig._set_string_list),
671+
('CanonicalizeFallbackLocal', SSHConfig._set_bool),
672+
('CanonicalizeHostname', SSHConfig._set_canonicalize_host),
673+
('CanonicalizeMaxDots', SSHConfig._set_int),
674+
('CanonicalizePermittedCNAMEs', SSHConfig._set_string_list),
621675
('CASignatureAlgorithms', SSHConfig._set_string),
622676
('ChallengeResponseAuthentication', SSHConfig._set_bool),
623677
('Ciphers', SSHConfig._set_string),

0 commit comments

Comments
 (0)