ssh-over-sse-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .runtime/
10
+ .coverage
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.5
2
+ Name: ssh-over-sse-cli
3
+ Version: 0.1.0
4
+ Summary: OpenSSH ProxyCommand client for ssh-sse/v1
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: cryptography>=45
7
+ Requires-Dist: httpx<1,>=0.28
8
+ Requires-Dist: pyyaml<7,>=6
9
+ Description-Content-Type: text/markdown
10
+
11
+ # ssh-sse-cli
12
+
13
+ Connect to SSH servers through an HTTPS gateway with your usual OpenSSH commands.
14
+
15
+ ## Install
16
+
17
+ Requires Linux or macOS, Python 3.12 or newer, and OpenSSH.
18
+
19
+ With uv:
20
+
21
+ ```sh
22
+ uv tool install --python 3.12 ssh-over-sse-cli
23
+ ```
24
+
25
+ Or with pip:
26
+
27
+ ```sh
28
+ pip install 'ssh-over-sse[cli]'
29
+ ```
30
+
31
+ Use `ssh-over-sse[all]` with pip to install the gateway command too.
32
+ Supported keys are Ed25519, RSA (2048–8192 bits), and ECDSA (P-256, P-384, P-521).
33
+ Load encrypted keys into `ssh-agent` before use.
34
+
35
+ ## Configure SSH
36
+
37
+ Ask your administrator for the gateway URL and an allowed destination. Add an
38
+ entry to your SSH configuration, replacing the example values:
39
+
40
+ ```sshconfig
41
+ Host my-server
42
+ HostName 192.0.2.20
43
+ Port 22
44
+ User alice
45
+ IdentityFile ~/.ssh/id_ed25519
46
+ IdentitiesOnly yes
47
+ StrictHostKeyChecking yes
48
+ ProxyCommand ssh-sse-cli --url https://gateway.example.com --ssh-host %n
49
+ ```
50
+
51
+ Connect or transfer files normally:
52
+
53
+ ```sh
54
+ ssh my-server
55
+ scp report.txt my-server:report.txt
56
+ sftp my-server
57
+ ```
58
+
59
+ The client reads the destination, port, SSH user, and first available identity
60
+ from the selected host. `--target`, `--user`, and `-i` override those values.
61
+ If you use `ssh -F`, pass the same file using `--ssh-config` in the ProxyCommand.
62
+ To pass outer command-line user or port overrides, add `--user %r --target %h:%p`.
63
+
64
+ Your public key must be authorized by the gateway account and destination server.
65
+ Your private key stays on your computer. Keep SSH host-key verification enabled.
66
+
67
+ ## Check your key
68
+
69
+ Open the gateway URL in a browser to view the landing page. Then run:
70
+
71
+ ```sh
72
+ ssh-sse-cli --url https://gateway.example.com check
73
+ ```
74
+
75
+ Without a host alias, the identity defaults to `~/.ssh/id_ed25519`. To select a
76
+ key or use an existing SSH host's identity:
77
+
78
+ ```sh
79
+ ssh-sse-cli --url https://gateway.example.com -i ~/.ssh/id_ecdsa check
80
+ ssh-sse-cli --url https://gateway.example.com --ssh-host my-server check
81
+ ```
82
+
83
+ The check requires no destination or SSH user. It verifies gateway access without
84
+ connecting to a destination. Success prints
85
+ `Gateway SSH key authentication succeeded.` and exits with status 0. Errors exit
86
+ with status 1. HTTP 401 means the key was rejected; HTTP 500 indicates a gateway
87
+ or server error.
88
+
89
+ For a direct backend check, use its HTTP address:
90
+
91
+ ```sh
92
+ ssh-sse-cli --url http://192.0.2.10:7010 -i ~/.ssh/id_ecdsa check
93
+ ```
94
+
95
+ SSH connections use the public HTTPS URL. A successful key check does not confirm
96
+ destination permissions or the destination's SSH login.
97
+
98
+ ## Options
99
+
100
+ | Option | Purpose |
101
+ | --- | --- |
102
+ | `--url` | Gateway root URL |
103
+ | `--ssh-host` | Read connection and identity settings from an SSH host alias |
104
+ | `--ssh-config` | Read an alternate SSH configuration file |
105
+ | `--target` | Override the destination as `HOST:PORT`; bracket IPv6 addresses |
106
+ | `--user` | Override the destination SSH username |
107
+ | `-i`, `--identity` | Select an identity file |
108
+ | `--config` | Read client settings from a file |
109
+ | `--ca-file` | Trust a private HTTPS certificate authority |
110
+ | `--log-level` | Set diagnostics to `DEBUG`, `INFO`, `WARNING`, or `ERROR` |
111
+
112
+ Client settings default to `~/.config/ssh-over-sse/client.yaml` when that file
113
+ exists. Store your gateway URL there to omit `--url`:
114
+
115
+ ```yaml
116
+ url: https://gateway.example.com
117
+ ```
118
+
119
+ Command-line options take precedence. Connection diagnostics appear on stderr;
120
+ `check` prints its success message on stdout.
121
+
122
+ ## Troubleshooting
123
+
124
+ If authentication fails, check the URL, selected key, and gateway authorization.
125
+ For encrypted keys, check that the identity is loaded into `ssh-agent`. Ask your
126
+ administrator to confirm destination permissions if the key check succeeds but
127
+ SSH cannot connect. After an interruption, start a new connection.
128
+
129
+ ```sh
130
+ ssh-sse-cli --help
131
+ ```
@@ -0,0 +1,121 @@
1
+ # ssh-sse-cli
2
+
3
+ Connect to SSH servers through an HTTPS gateway with your usual OpenSSH commands.
4
+
5
+ ## Install
6
+
7
+ Requires Linux or macOS, Python 3.12 or newer, and OpenSSH.
8
+
9
+ With uv:
10
+
11
+ ```sh
12
+ uv tool install --python 3.12 ssh-over-sse-cli
13
+ ```
14
+
15
+ Or with pip:
16
+
17
+ ```sh
18
+ pip install 'ssh-over-sse[cli]'
19
+ ```
20
+
21
+ Use `ssh-over-sse[all]` with pip to install the gateway command too.
22
+ Supported keys are Ed25519, RSA (2048–8192 bits), and ECDSA (P-256, P-384, P-521).
23
+ Load encrypted keys into `ssh-agent` before use.
24
+
25
+ ## Configure SSH
26
+
27
+ Ask your administrator for the gateway URL and an allowed destination. Add an
28
+ entry to your SSH configuration, replacing the example values:
29
+
30
+ ```sshconfig
31
+ Host my-server
32
+ HostName 192.0.2.20
33
+ Port 22
34
+ User alice
35
+ IdentityFile ~/.ssh/id_ed25519
36
+ IdentitiesOnly yes
37
+ StrictHostKeyChecking yes
38
+ ProxyCommand ssh-sse-cli --url https://gateway.example.com --ssh-host %n
39
+ ```
40
+
41
+ Connect or transfer files normally:
42
+
43
+ ```sh
44
+ ssh my-server
45
+ scp report.txt my-server:report.txt
46
+ sftp my-server
47
+ ```
48
+
49
+ The client reads the destination, port, SSH user, and first available identity
50
+ from the selected host. `--target`, `--user`, and `-i` override those values.
51
+ If you use `ssh -F`, pass the same file using `--ssh-config` in the ProxyCommand.
52
+ To pass outer command-line user or port overrides, add `--user %r --target %h:%p`.
53
+
54
+ Your public key must be authorized by the gateway account and destination server.
55
+ Your private key stays on your computer. Keep SSH host-key verification enabled.
56
+
57
+ ## Check your key
58
+
59
+ Open the gateway URL in a browser to view the landing page. Then run:
60
+
61
+ ```sh
62
+ ssh-sse-cli --url https://gateway.example.com check
63
+ ```
64
+
65
+ Without a host alias, the identity defaults to `~/.ssh/id_ed25519`. To select a
66
+ key or use an existing SSH host's identity:
67
+
68
+ ```sh
69
+ ssh-sse-cli --url https://gateway.example.com -i ~/.ssh/id_ecdsa check
70
+ ssh-sse-cli --url https://gateway.example.com --ssh-host my-server check
71
+ ```
72
+
73
+ The check requires no destination or SSH user. It verifies gateway access without
74
+ connecting to a destination. Success prints
75
+ `Gateway SSH key authentication succeeded.` and exits with status 0. Errors exit
76
+ with status 1. HTTP 401 means the key was rejected; HTTP 500 indicates a gateway
77
+ or server error.
78
+
79
+ For a direct backend check, use its HTTP address:
80
+
81
+ ```sh
82
+ ssh-sse-cli --url http://192.0.2.10:7010 -i ~/.ssh/id_ecdsa check
83
+ ```
84
+
85
+ SSH connections use the public HTTPS URL. A successful key check does not confirm
86
+ destination permissions or the destination's SSH login.
87
+
88
+ ## Options
89
+
90
+ | Option | Purpose |
91
+ | --- | --- |
92
+ | `--url` | Gateway root URL |
93
+ | `--ssh-host` | Read connection and identity settings from an SSH host alias |
94
+ | `--ssh-config` | Read an alternate SSH configuration file |
95
+ | `--target` | Override the destination as `HOST:PORT`; bracket IPv6 addresses |
96
+ | `--user` | Override the destination SSH username |
97
+ | `-i`, `--identity` | Select an identity file |
98
+ | `--config` | Read client settings from a file |
99
+ | `--ca-file` | Trust a private HTTPS certificate authority |
100
+ | `--log-level` | Set diagnostics to `DEBUG`, `INFO`, `WARNING`, or `ERROR` |
101
+
102
+ Client settings default to `~/.config/ssh-over-sse/client.yaml` when that file
103
+ exists. Store your gateway URL there to omit `--url`:
104
+
105
+ ```yaml
106
+ url: https://gateway.example.com
107
+ ```
108
+
109
+ Command-line options take precedence. Connection diagnostics appear on stderr;
110
+ `check` prints its success message on stdout.
111
+
112
+ ## Troubleshooting
113
+
114
+ If authentication fails, check the URL, selected key, and gateway authorization.
115
+ For encrypted keys, check that the identity is loaded into `ssh-agent`. Ask your
116
+ administrator to confirm destination permissions if the key check succeeds but
117
+ SSH cannot connect. After an interruption, start a new connection.
118
+
119
+ ```sh
120
+ ssh-sse-cli --help
121
+ ```
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "ssh-over-sse-cli"
3
+ version = "0.1.0"
4
+ readme = "README.md"
5
+ description = "OpenSSH ProxyCommand client for ssh-sse/v1"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["httpx>=0.28,<1", "pyyaml>=6,<7", "cryptography>=45"]
8
+ [project.scripts]
9
+ ssh-sse-cli = "ssh_sse_tool.cli:main"
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+ [dependency-groups]
14
+ dev = ["pytest>=8", "pytest-asyncio>=1", "ruff>=0.12", "mypy>=1.16", "types-PyYAML"]
15
+ [tool.pytest.ini_options]
16
+ asyncio_mode = "auto"
17
+ asyncio_default_fixture_loop_scope = "function"
18
+ [tool.ruff]
19
+ line-length = 100
20
+ [tool.ruff.lint]
21
+ select = ["E4", "E7", "E9", "F", "I"]
22
+ [tool.mypy]
23
+ check_untyped_defs = true
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/ssh_sse_tool"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["/pyproject.toml", "/README.md", "/uv.lock", "/src"]
@@ -0,0 +1 @@
1
+ """SSH-over-SSE ProxyCommand client."""
@@ -0,0 +1,65 @@
1
+ """OpenSSH SSHSIG signer, including public-key path + ssh-agent support."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from cryptography.exceptions import UnsupportedAlgorithm
9
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
11
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
12
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_ssh_public_key
13
+
14
+
15
+ async def command(*args, data=None):
16
+ process = await asyncio.create_subprocess_exec(
17
+ *args,
18
+ stdin=asyncio.subprocess.PIPE if data is not None else asyncio.subprocess.DEVNULL,
19
+ stdout=asyncio.subprocess.PIPE,
20
+ stderr=asyncio.subprocess.DEVNULL,
21
+ )
22
+ try:
23
+ stdout, _ = await asyncio.wait_for(process.communicate(data), 10)
24
+ if process.returncode:
25
+ raise ValueError("SSH key operation failed; load your identity into ssh-agent")
26
+ return stdout
27
+ finally:
28
+ if process.returncode is None:
29
+ process.kill()
30
+ await process.wait()
31
+
32
+
33
+ async def identity_key(identity):
34
+ path = Path(identity)
35
+ pub = path if path.suffix == ".pub" else Path(str(path) + ".pub")
36
+ if pub.is_file():
37
+ if pub.stat().st_size > 8192:
38
+ raise ValueError("public key too large")
39
+ line = pub.read_bytes().strip()
40
+ else:
41
+ line = (await command("ssh-keygen", "-y", "-f", str(path))).strip()
42
+ try:
43
+ key = load_ssh_public_key(line)
44
+ except UnsupportedAlgorithm:
45
+ raise ValueError("unsupported SSH public key") from None
46
+ if not isinstance(key, (Ed25519PublicKey, RSAPublicKey, EllipticCurvePublicKey)):
47
+ raise ValueError("unsupported SSH public key")
48
+ if isinstance(key, RSAPublicKey) and not 2048 <= key.key_size <= 8192:
49
+ raise ValueError("RSA keys must be 2048 to 8192 bits")
50
+ if isinstance(key, EllipticCurvePublicKey) and key.curve.name not in (
51
+ "secp256r1",
52
+ "secp384r1",
53
+ "secp521r1",
54
+ ):
55
+ raise ValueError("unsupported ECDSA curve")
56
+ normalized = key.public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode()
57
+ blob = base64.b64decode(normalized.split()[1], validate=True)
58
+ fp = "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=")
59
+ return normalized, fp
60
+
61
+
62
+ async def sign(identity, namespace, message):
63
+ return base64.b64encode(
64
+ await command("ssh-keygen", "-Y", "sign", "-f", identity, "-n", namespace, data=message)
65
+ ).decode()
@@ -0,0 +1,86 @@
1
+ """Local OpenSSH ProxyCommand entrypoint. stdout is raw SSH bytes only."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import signal
7
+ import sys
8
+
9
+ import httpx
10
+ import yaml
11
+
12
+ from .config import settings
13
+ from .stdio import Stdio
14
+ from .transport import GatewayError, Transport
15
+
16
+
17
+ async def run(config):
18
+ stdio = Stdio()
19
+ task = asyncio.current_task()
20
+ assert task is not None
21
+ loop = asyncio.get_running_loop()
22
+ for sig in (signal.SIGINT, signal.SIGTERM):
23
+ loop.add_signal_handler(sig, task.cancel)
24
+ try:
25
+ await Transport(config).run(stdio)
26
+ finally:
27
+ stdio.close()
28
+ for sig in (signal.SIGINT, signal.SIGTERM):
29
+ loop.remove_signal_handler(sig)
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(
34
+ prog="ssh-sse-cli", description="OpenSSH ProxyCommand over HTTPS POST + SSE"
35
+ )
36
+ parser.add_argument(
37
+ "command",
38
+ nargs="?",
39
+ choices=["check"],
40
+ help="verify gateway access with your SSH key without connecting to a target",
41
+ )
42
+ for name in (
43
+ "url",
44
+ "target",
45
+ "user",
46
+ "config",
47
+ "log-level",
48
+ "ca-file",
49
+ "ssh-host",
50
+ "ssh-config",
51
+ ):
52
+ parser.add_argument("--" + name)
53
+ parser.add_argument("-i", "--identity", help="SSH identity file")
54
+ parser.add_argument(
55
+ "--test-only-allow-http-localhost", action="store_true", help=argparse.SUPPRESS
56
+ )
57
+ args = parser.parse_args()
58
+ try:
59
+ config = settings(args)
60
+ # HTTP-library debug logs can contain authorization headers: never enable them.
61
+ logging.basicConfig(level=getattr(logging, config.log_level), stream=sys.stderr)
62
+ logging.getLogger("httpx").setLevel(logging.CRITICAL)
63
+ logging.getLogger("httpcore").setLevel(logging.CRITICAL)
64
+ if args.command == "check":
65
+ asyncio.run(Transport(config).check())
66
+ print("Gateway SSH key authentication succeeded.")
67
+ else:
68
+ asyncio.run(run(config))
69
+ except GatewayError as error:
70
+ parser.exit(1, f"ssh-sse-cli: {error}\n")
71
+ except (
72
+ ValueError,
73
+ OSError,
74
+ TimeoutError,
75
+ TypeError,
76
+ KeyError,
77
+ RecursionError,
78
+ yaml.YAMLError,
79
+ httpx.HTTPError,
80
+ ):
81
+ parser.exit(
82
+ 1,
83
+ "ssh-sse-cli: authentication or transport failed; check configuration, key agent and gateway audit logs\n",
84
+ )
85
+ except (KeyboardInterrupt, asyncio.CancelledError):
86
+ parser.exit(130, "ssh-sse-cli: interrupted\n")
@@ -0,0 +1,242 @@
1
+ """Client arguments/config validation, with no production TLS bypass."""
2
+
3
+ import getpass
4
+ import ipaddress
5
+ import os
6
+ import re
7
+ import socket
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ import yaml
14
+
15
+
16
+ class UniqueLoader(yaml.SafeLoader):
17
+ pass
18
+
19
+
20
+ def unique_mapping(loader, node):
21
+ result = {}
22
+ for k, v in node.value:
23
+ key = loader.construct_object(k)
24
+ if not isinstance(key, str) or key in result:
25
+ raise ValueError("duplicate config key")
26
+ result[key] = loader.construct_object(v)
27
+ return result
28
+
29
+
30
+ UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping)
31
+
32
+
33
+ def target(value):
34
+ if not isinstance(value, str) or any(c.isspace() for c in value):
35
+ raise ValueError("invalid target")
36
+ pattern = r"\[([^\]]+)\]:([0-9]+)" if value.startswith("[") else r"([^:\[\]]+):([0-9]+)"
37
+ match = re.fullmatch(pattern, value)
38
+ if not match or not 1 <= int(match[2]) <= 65535:
39
+ raise ValueError("target must be HOST:PORT (bracket IPv6)")
40
+ host = match[1]
41
+ try:
42
+ host = str(ipaddress.ip_address(host))
43
+ except ValueError:
44
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}", host):
45
+ raise ValueError("invalid target host") from None
46
+ return host, int(match[2])
47
+
48
+
49
+ def base_url(value, test_only_http=False, *, allow_http_check=False):
50
+ if not isinstance(value, str) or any(c.isspace() for c in value):
51
+ raise ValueError("invalid gateway URL")
52
+ parsed = urlsplit(value)
53
+ if not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
54
+ raise ValueError("invalid gateway URL")
55
+ if parsed.scheme != "https":
56
+ if (not test_only_http and not allow_http_check) or parsed.scheme != "http":
57
+ raise ValueError("HTTPS is required")
58
+ try:
59
+ loopback = ipaddress.ip_address(parsed.hostname).is_loopback
60
+ except ValueError:
61
+ loopback = parsed.hostname == "localhost"
62
+ if not loopback and not allow_http_check:
63
+ raise ValueError("test HTTP requires loopback")
64
+ if not re.fullmatch(r"(?:/[a-zA-Z0-9_-]+)*/?", parsed.path):
65
+ raise ValueError("invalid base URL path")
66
+ _ = parsed.port # validate port syntax/range
67
+ return value.rstrip("/")
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Settings:
72
+ url: str
73
+ host: str
74
+ port: int
75
+ user: str
76
+ principal: str | None
77
+ identity: str
78
+ namespace: str = "ssh-sse-gateway@your-organization.example"
79
+ ca_file: str | None = None
80
+ request_timeout: float = 30
81
+ stream_timeout: float = 75
82
+ log_level: str = "WARNING"
83
+
84
+
85
+ def ssh_defaults(alias, config_file=None, user=None):
86
+ """Read effective local OpenSSH configuration without opening a connection."""
87
+ if not isinstance(alias, str) or not re.fullmatch(r"[A-Za-z0-9_.:@\[\]-]{1,253}", alias):
88
+ raise ValueError("invalid SSH host alias")
89
+ command = ["ssh", "-G"]
90
+ if config_file is not None:
91
+ if not isinstance(config_file, str):
92
+ raise ValueError("invalid SSH config path")
93
+ command.extend(["-F", str(Path(config_file).expanduser())])
94
+ if user is not None:
95
+ if not isinstance(user, str) or not re.fullmatch(r"[a-zA-Z0-9_@.+-]{1,128}", user):
96
+ raise ValueError("invalid SSH user")
97
+ command.extend(["-l", user])
98
+ command.extend(["--", alias])
99
+ try:
100
+ result = subprocess.run(
101
+ command,
102
+ stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.PIPE,
104
+ stderr=subprocess.DEVNULL,
105
+ text=True,
106
+ timeout=5,
107
+ check=False,
108
+ )
109
+ except (OSError, subprocess.SubprocessError):
110
+ raise ValueError("cannot read effective SSH configuration") from None
111
+ if result.returncode or len(result.stdout) > 1048576:
112
+ raise ValueError("cannot read effective SSH configuration")
113
+ values: dict[str, str] = {}
114
+ identities = []
115
+ for line in result.stdout.splitlines():
116
+ name, separator, value = line.partition(" ")
117
+ if not separator:
118
+ continue
119
+ if name == "identityfile":
120
+ identities.append(value)
121
+ elif name in ("hostname", "port", "user"):
122
+ values.setdefault(name, value)
123
+ if set(values) != {"hostname", "port", "user"}:
124
+ raise ValueError("incomplete effective SSH configuration")
125
+ hostname = values["hostname"]
126
+ tokens = {
127
+ "%": "%",
128
+ "d": str(Path.home()),
129
+ "h": hostname,
130
+ "n": alias,
131
+ "r": values["user"],
132
+ "p": values["port"],
133
+ "u": getpass.getuser(),
134
+ "i": str(os.getuid()),
135
+ "l": socket.gethostname(),
136
+ }
137
+ for identity in identities:
138
+ if identity.lower() == "none":
139
+ continue
140
+
141
+ def expand(match):
142
+ if match[1] not in tokens:
143
+ raise ValueError("unsupported IdentityFile token; specify --identity")
144
+ return tokens[match[1]]
145
+
146
+ path = Path(os.path.expandvars(re.sub(r"%(.)", expand, identity))).expanduser()
147
+ if path.is_file():
148
+ values["identity"] = str(path)
149
+ break
150
+ public = Path(str(path) + ".pub")
151
+ if public.is_file():
152
+ values["identity"] = str(public)
153
+ break
154
+ host = f"[{hostname}]" if ":" in hostname else hostname
155
+ values["target"] = host + ":" + values["port"]
156
+ return values
157
+
158
+
159
+ def settings(args):
160
+ raw = {}
161
+ default_config = Path.home() / ".config/ssh-over-sse/client.yaml"
162
+ config_path = args.config or (str(default_config) if default_config.is_file() else None)
163
+ if config_path:
164
+ path = Path(config_path).expanduser()
165
+ if path.stat().st_size > 65536:
166
+ raise ValueError("config too large")
167
+ raw = yaml.load(path.read_text(), Loader=UniqueLoader)
168
+ allowed = {
169
+ "url",
170
+ "target",
171
+ "user",
172
+ "principal",
173
+ "identity",
174
+ "namespace",
175
+ "ca_file",
176
+ "request_timeout",
177
+ "stream_timeout",
178
+ "log_level",
179
+ "ssh_host",
180
+ "ssh_config",
181
+ }
182
+ if not isinstance(raw, dict) or set(raw) - allowed:
183
+ raise ValueError("invalid client config")
184
+ for key in (
185
+ "url",
186
+ "target",
187
+ "user",
188
+ "identity",
189
+ "log_level",
190
+ "ca_file",
191
+ "ssh_host",
192
+ "ssh_config",
193
+ ):
194
+ value = getattr(args, key, None)
195
+ if value is not None:
196
+ raw[key] = value
197
+ alias = raw.pop("ssh_host", None)
198
+ ssh_config = raw.pop("ssh_config", None)
199
+ if alias is not None:
200
+ defaults = ssh_defaults(alias, ssh_config, raw.get("user"))
201
+ for key in ("target", "user", "identity"):
202
+ if key in defaults:
203
+ raw.setdefault(key, defaults[key])
204
+ if "identity" not in raw:
205
+ raise ValueError("no usable IdentityFile for this host; specify --identity")
206
+ elif ssh_config is not None:
207
+ raise ValueError("--ssh-config requires --ssh-host")
208
+ if getattr(args, "command", None) == "check":
209
+ # Settings retains its transport fields; check proofs carry no destination.
210
+ raw.setdefault("target", "localhost:22")
211
+ raw.setdefault("user", getpass.getuser())
212
+ raw.setdefault("identity", "~/.ssh/id_ed25519")
213
+ for key in ("url", "target", "user", "identity"):
214
+ if not isinstance(raw.get(key), str) or not raw[key]:
215
+ raise ValueError(f"{key} is required")
216
+ host, port = target(raw.pop("target"))
217
+ raw["url"] = base_url(
218
+ raw["url"],
219
+ args.test_only_allow_http_localhost,
220
+ allow_http_check=getattr(args, "command", None) == "check",
221
+ )
222
+ raw.setdefault("principal", None)
223
+ for key in ("user", "principal"):
224
+ if key == "principal" and raw[key] is None:
225
+ continue
226
+ if not isinstance(raw[key], str) or not re.fullmatch(r"[a-zA-Z0-9_@.+-]{1,128}", raw[key]):
227
+ raise ValueError("invalid user/principal")
228
+ for key in ("request_timeout", "stream_timeout"):
229
+ if key in raw and (type(raw[key]) not in (int, float) or not 0 < raw[key] <= 604800):
230
+ raise ValueError("invalid timeout")
231
+ if "namespace" in raw and (
232
+ not isinstance(raw["namespace"], str) or not re.fullmatch(r"[^\s]{1,255}", raw["namespace"])
233
+ ):
234
+ raise ValueError("invalid namespace")
235
+ if raw.get("log_level", "WARNING") not in ("DEBUG", "INFO", "WARNING", "ERROR"):
236
+ raise ValueError("invalid log level")
237
+ if "ca_file" in raw and not isinstance(raw["ca_file"], str):
238
+ raise ValueError("invalid CA file")
239
+ raw["identity"] = str(Path(raw["identity"]).expanduser().resolve())
240
+ if raw.get("ca_file"):
241
+ raw["ca_file"] = str(Path(raw["ca_file"]).expanduser())
242
+ return Settings(host=host, port=port, **raw)