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.
- ssh_over_sse_cli-0.1.0/.gitignore +10 -0
- ssh_over_sse_cli-0.1.0/PKG-INFO +131 -0
- ssh_over_sse_cli-0.1.0/README.md +121 -0
- ssh_over_sse_cli-0.1.0/pyproject.toml +29 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/__init__.py +1 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/auth.py +65 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/cli.py +86 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/config.py +242 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/protocol.py +48 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/sse.py +87 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/stdio.py +54 -0
- ssh_over_sse_cli-0.1.0/src/ssh_sse_tool/transport.py +274 -0
- ssh_over_sse_cli-0.1.0/uv.lock +667 -0
|
@@ -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)
|