clan-cli: "fix" ssh option parsing

Calling it fix in double quotes since that's still quite hand-crafted,
but at least you can now specify options with `@` inside them (e.g.
`ProxyJump`) and have it work properly.

Moreover this fixes the syntax for GET-like variables in the networking
clanCore module. Only the fixed syntax is supported since that's what
was tested, and actually parsed in the code.
This commit is contained in:
Louis Opter
2025-02-18 04:49:59 +00:00
parent 2d0bcfe6f6
commit c689c23d0c
2 changed files with 38 additions and 25 deletions

View File

@@ -9,11 +9,11 @@
By default, the node's attribute name will be used. By default, the node's attribute name will be used.
If set to null, only local deployment will be supported. If set to null, only local deployment will be supported.
format: user@host:port&SSH_OPTION=SSH_VALUE format: user@host:port?SSH_OPTION=SSH_VALUE[&SSH_OPTION_2=VALUE_2]
examples: examples:
- machine.example.com - machine.example.com
- user@machine2.example.com - user@machine2.example.com
- root@example.com:2222&IdentityFile=/path/to/private/key - root@example.com:2222?IdentityFile=/path/to/private/key&StrictHostKeyChecking=yes
''; '';
default = null; default = null;
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
@@ -24,11 +24,11 @@
If set to null, the targetHost will be used. If set to null, the targetHost will be used.
format: user@host:port&SSH_OPTION=SSH_VALUE format: user@host:port?SSH_OPTION=SSH_VALUE&SSH_OPTION_2=VALUE_2
examples: examples:
- machine.example.com - machine.example.com
- user@machine2.example.com - user@machine2.example.com
- root@example.com:2222&IdentityFile=/path/to/private/key - root@example.com:2222?IdentityFile=/path/to/private/key&StrictHostKeyChecking=yes
''; '';
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;

View File

@@ -14,33 +14,46 @@ def parse_deployment_address(
forward_agent: bool = True, forward_agent: bool = True,
meta: dict[str, Any] | None = None, meta: dict[str, Any] | None = None,
) -> Host: ) -> Host:
if meta is None: parts = host.split("?", maxsplit=1)
meta = {} endpoint, maybe_options = parts if len(parts) == 2 else (parts[0], "")
parts = host.split("@")
user: str | None = None parts = endpoint.split("@")
# count the number of : in the hostname match len(parts):
if host.count(":") > 1 and not re.match(r".*\[.*\]", host): case 2:
user, host_port = parts
case 1:
user, host_port = "", parts[0]
case _:
msg = f"Invalid host, got `{host}` but expected something like `[user@]hostname[:port]`"
raise ClanError(msg)
# Make this check now rather than failing with a `ValueError`
# when looking up the port from the `urlsplit` result below:
if host_port.count(":") > 1 and not re.match(r".*\[.*]", host_port):
msg = f"Invalid hostname: {host}. IPv6 addresses must be enclosed in brackets , e.g. [::1]" msg = f"Invalid hostname: {host}. IPv6 addresses must be enclosed in brackets , e.g. [::1]"
raise ClanError(msg) raise ClanError(msg)
if len(parts) > 1:
user = parts[0]
hostname = parts[1]
else:
hostname = parts[0]
maybe_options = hostname.split("?")
options: dict[str, str] = {} options: dict[str, str] = {}
if len(maybe_options) > 1: for o in maybe_options.split("&"):
hostname = maybe_options[0] if len(o) == 0:
for option in maybe_options[1].split("&"): continue
k, v = option.split("=") parts = o.split("=", maxsplit=1)
options[k] = v if len(parts) != 2:
result = urllib.parse.urlsplit("//" + hostname) msg = (
f"Invalid option in host `{host}`: option `{o}` does not have "
f"a value (i.e. expected something like `name=value`)"
)
raise ClanError(msg)
name, value = parts
options[name] = value
result = urllib.parse.urlsplit(f"//{host_port}")
if not result.hostname: if not result.hostname:
msg = f"Invalid hostname: {hostname}" msg = f"Invalid host, got `{host}` but expected something like `[user@]hostname[:port]`"
raise ClanError(msg) raise ClanError(msg)
hostname = result.hostname hostname = result.hostname
port = result.port port = result.port
meta = meta.copy()
return Host( return Host(
hostname, hostname,
user=user, user=user,
@@ -48,6 +61,6 @@ def parse_deployment_address(
host_key_check=host_key_check, host_key_check=host_key_check,
command_prefix=machine_name, command_prefix=machine_name,
forward_agent=forward_agent, forward_agent=forward_agent,
meta=meta, meta={} if meta is None else meta.copy(),
ssh_options=options, ssh_options=options,
) )