ssh-over-sse-cli 0.1.0__py3-none-any.whl
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.dist-info/METADATA +131 -0
- ssh_over_sse_cli-0.1.0.dist-info/RECORD +12 -0
- ssh_over_sse_cli-0.1.0.dist-info/WHEEL +4 -0
- ssh_over_sse_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ssh_sse_tool/__init__.py +1 -0
- ssh_sse_tool/auth.py +65 -0
- ssh_sse_tool/cli.py +86 -0
- ssh_sse_tool/config.py +242 -0
- ssh_sse_tool/protocol.py +48 -0
- ssh_sse_tool/sse.py +87 -0
- ssh_sse_tool/stdio.py +54 -0
- ssh_sse_tool/transport.py +274 -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,12 @@
|
|
|
1
|
+
ssh_sse_tool/__init__.py,sha256=yEo61nlPcB0HIPTn9MtlKWVxnZkOmbJnEvH0aI-L8OQ,40
|
|
2
|
+
ssh_sse_tool/auth.py,sha256=u_j3DMvebsfIWNSoWqkXtLeBDvfQCUXgWBimxrfY-bQ,2609
|
|
3
|
+
ssh_sse_tool/cli.py,sha256=C6AB86o2y3XDMKQ6lt17UNjIFh1BBWJfzTQ58kVFHyA,2558
|
|
4
|
+
ssh_sse_tool/config.py,sha256=zeBamyb4UO2GdFRMMi6BxaApGIyBXxIMdC63e1NdP_U,8915
|
|
5
|
+
ssh_sse_tool/protocol.py,sha256=GlY3t-rF4zK0Dy7RxeFqpLDgikjDsh3QlY5aaLZaMA8,1290
|
|
6
|
+
ssh_sse_tool/sse.py,sha256=iP69t9XzPgj_suqIq68VdT-n2wDhq78gC-iwryB0Eg8,3320
|
|
7
|
+
ssh_sse_tool/stdio.py,sha256=1ZtTzLib7ngXuQRoTkn2L8uyYS9KbAZB6YdBQRz-eP4,1515
|
|
8
|
+
ssh_sse_tool/transport.py,sha256=u4ZBudzn8zdWDlKKlspcUvMmCuc8LF7oI-u1qQjA7m0,11189
|
|
9
|
+
ssh_over_sse_cli-0.1.0.dist-info/METADATA,sha256=b47afg0-kYRBfveLbERxHf3R_MBErSjMeqGjlrGHrEk,4040
|
|
10
|
+
ssh_over_sse_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
ssh_over_sse_cli-0.1.0.dist-info/entry_points.txt,sha256=wO_cvxhdFSS0MHg0sjy8koJDvVwSLUjkVhx7V1mRsQk,54
|
|
12
|
+
ssh_over_sse_cli-0.1.0.dist-info/RECORD,,
|
ssh_sse_tool/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SSH-over-SSE ProxyCommand client."""
|
ssh_sse_tool/auth.py
ADDED
|
@@ -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()
|
ssh_sse_tool/cli.py
ADDED
|
@@ -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")
|
ssh_sse_tool/config.py
ADDED
|
@@ -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)
|
ssh_sse_tool/protocol.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Canonical authentication transcript contract for ssh-sse/v1."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
PROTOCOL = "ssh-sse/v1"
|
|
6
|
+
TRANSCRIPT_FIELDS = (
|
|
7
|
+
"protocol",
|
|
8
|
+
"purpose",
|
|
9
|
+
"audience",
|
|
10
|
+
"namespace",
|
|
11
|
+
"challenge_id",
|
|
12
|
+
"nonce",
|
|
13
|
+
"principal",
|
|
14
|
+
"target_host",
|
|
15
|
+
"target_port",
|
|
16
|
+
"ssh_user",
|
|
17
|
+
"expires_at",
|
|
18
|
+
"public_key_fingerprint",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def transcript(challenge, request, *, check=False):
|
|
23
|
+
data = {"purpose": "ssh-sse-gateway/check/v1" if check else "ssh-sse-gateway/v1"}
|
|
24
|
+
for key in TRANSCRIPT_FIELDS:
|
|
25
|
+
if key in challenge:
|
|
26
|
+
data[key] = challenge[key]
|
|
27
|
+
elif key in request:
|
|
28
|
+
data[key] = request[key]
|
|
29
|
+
if set(data) != set(TRANSCRIPT_FIELDS) or data["protocol"] != PROTOCOL:
|
|
30
|
+
raise ValueError("invalid transcript")
|
|
31
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode(
|
|
32
|
+
"ascii"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def strict_json(data):
|
|
37
|
+
def unique(pairs):
|
|
38
|
+
result = {}
|
|
39
|
+
for key, value in pairs:
|
|
40
|
+
if key in result:
|
|
41
|
+
raise ValueError("duplicate JSON field")
|
|
42
|
+
result[key] = value
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
def constant(value):
|
|
46
|
+
raise ValueError("invalid JSON constant")
|
|
47
|
+
|
|
48
|
+
return json.loads(data, object_pairs_hook=unique, parse_constant=constant)
|
ssh_sse_tool/sse.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Incremental bounded SSE decoder with strict binary sequence validation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
|
|
6
|
+
from .protocol import strict_json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SSE:
|
|
10
|
+
def __init__(self, max_chunk=16384):
|
|
11
|
+
self.buffer = bytearray()
|
|
12
|
+
self.fields = {}
|
|
13
|
+
self.event_size = 0
|
|
14
|
+
self.seq = 0
|
|
15
|
+
self.eof = False
|
|
16
|
+
self.max_chunk = max_chunk
|
|
17
|
+
self.max_event = max_chunk * 2 + 1024
|
|
18
|
+
|
|
19
|
+
def feed(self, chunk):
|
|
20
|
+
if self.eof and chunk:
|
|
21
|
+
raise ValueError("bytes after EOF event")
|
|
22
|
+
self.buffer.extend(chunk)
|
|
23
|
+
result = []
|
|
24
|
+
while b"\n" in self.buffer:
|
|
25
|
+
raw, _, rest = self.buffer.partition(b"\n")
|
|
26
|
+
self.buffer = bytearray(rest)
|
|
27
|
+
if len(raw) > self.max_event:
|
|
28
|
+
raise ValueError("oversized SSE line")
|
|
29
|
+
line = raw.rstrip(b"\r").decode("utf-8", errors="strict")
|
|
30
|
+
self.event_size += len(raw) + 1
|
|
31
|
+
if self.event_size > self.max_event:
|
|
32
|
+
raise ValueError("oversized SSE event")
|
|
33
|
+
if not line:
|
|
34
|
+
if self.fields:
|
|
35
|
+
result.append(self.event())
|
|
36
|
+
self.fields = {}
|
|
37
|
+
self.event_size = 0
|
|
38
|
+
elif line.startswith(":"):
|
|
39
|
+
continue
|
|
40
|
+
else:
|
|
41
|
+
name, sep, value = line.partition(":")
|
|
42
|
+
if not sep or name not in ("event", "id", "data"):
|
|
43
|
+
raise ValueError("invalid SSE field")
|
|
44
|
+
if value.startswith(" "):
|
|
45
|
+
value = value[1:]
|
|
46
|
+
if name in self.fields:
|
|
47
|
+
if name != "data":
|
|
48
|
+
raise ValueError("duplicate SSE field")
|
|
49
|
+
self.fields[name] += "\n" + value
|
|
50
|
+
else:
|
|
51
|
+
self.fields[name] = value
|
|
52
|
+
if len(self.buffer) > self.max_event:
|
|
53
|
+
raise ValueError("oversized SSE line")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
def event(self):
|
|
57
|
+
if self.eof:
|
|
58
|
+
raise ValueError("event after EOF")
|
|
59
|
+
kind = self.fields.get("event")
|
|
60
|
+
data = strict_json(self.fields.get("data", ""))
|
|
61
|
+
if kind == "data":
|
|
62
|
+
if (
|
|
63
|
+
set(self.fields) != {"event", "id", "data"}
|
|
64
|
+
or not isinstance(data, dict)
|
|
65
|
+
or set(data) != {"seq", "payload_b64"}
|
|
66
|
+
or type(data["seq"]) is not int
|
|
67
|
+
or data["seq"] != self.seq
|
|
68
|
+
or self.fields["id"] != str(self.seq)
|
|
69
|
+
or not isinstance(data["payload_b64"], str)
|
|
70
|
+
):
|
|
71
|
+
raise ValueError("invalid SSE sequence")
|
|
72
|
+
try:
|
|
73
|
+
decoded = base64.b64decode(data["payload_b64"], validate=True)
|
|
74
|
+
except binascii.Error:
|
|
75
|
+
raise ValueError("invalid SSE Base64") from None
|
|
76
|
+
if not 0 < len(decoded) <= self.max_chunk:
|
|
77
|
+
raise ValueError("invalid SSE payload size")
|
|
78
|
+
self.seq += 1
|
|
79
|
+
return decoded
|
|
80
|
+
if kind == "eof" and set(self.fields) == {"event", "data"} and data == {}:
|
|
81
|
+
self.eof = True
|
|
82
|
+
return None
|
|
83
|
+
raise ValueError("gateway transport error or unexpected SSE event")
|
|
84
|
+
|
|
85
|
+
def finish(self):
|
|
86
|
+
if self.buffer or self.fields or not self.eof:
|
|
87
|
+
raise ValueError("SSE interrupted before explicit EOF")
|
ssh_sse_tool/stdio.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Cancelable bounded nonblocking pipe I/O for OpenSSH ProxyCommand."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def ready(fd, write=False):
|
|
8
|
+
loop = asyncio.get_running_loop()
|
|
9
|
+
future = loop.create_future()
|
|
10
|
+
|
|
11
|
+
def wake():
|
|
12
|
+
if not future.done():
|
|
13
|
+
future.set_result(None)
|
|
14
|
+
|
|
15
|
+
add = loop.add_writer if write else loop.add_reader
|
|
16
|
+
remove = loop.remove_writer if write else loop.remove_reader
|
|
17
|
+
add(fd, wake)
|
|
18
|
+
try:
|
|
19
|
+
await future
|
|
20
|
+
finally:
|
|
21
|
+
remove(fd)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Stdio:
|
|
25
|
+
def __init__(self, input_fd=0, output_fd=1):
|
|
26
|
+
self.input_fd, self.output_fd = input_fd, output_fd
|
|
27
|
+
self.previous = {fd: os.get_blocking(fd) for fd in (input_fd, output_fd)}
|
|
28
|
+
for fd in self.previous:
|
|
29
|
+
os.set_blocking(fd, False)
|
|
30
|
+
|
|
31
|
+
async def read(self, size):
|
|
32
|
+
while True:
|
|
33
|
+
try:
|
|
34
|
+
return os.read(self.input_fd, size)
|
|
35
|
+
except BlockingIOError:
|
|
36
|
+
await ready(self.input_fd)
|
|
37
|
+
|
|
38
|
+
async def write(self, data):
|
|
39
|
+
view = memoryview(data)
|
|
40
|
+
while view:
|
|
41
|
+
try:
|
|
42
|
+
count = os.write(self.output_fd, view)
|
|
43
|
+
if not count:
|
|
44
|
+
raise BrokenPipeError()
|
|
45
|
+
view = view[count:]
|
|
46
|
+
except BlockingIOError:
|
|
47
|
+
await ready(self.output_fd, write=True)
|
|
48
|
+
|
|
49
|
+
def close(self):
|
|
50
|
+
for fd, blocking in self.previous.items():
|
|
51
|
+
try:
|
|
52
|
+
os.set_blocking(fd, blocking)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""One input POST at a time; no retries, resume, or redirects."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .auth import identity_key, sign
|
|
12
|
+
from .config import base_url
|
|
13
|
+
from .protocol import PROTOCOL, strict_json, transcript
|
|
14
|
+
from .sse import SSE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GatewayError(ValueError):
|
|
18
|
+
"""Sanitized gateway status, without response bodies or credentials."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def json_response(response):
|
|
22
|
+
if response.status_code >= 300:
|
|
23
|
+
# Only expose stable codes, never server response bodies or tokens.
|
|
24
|
+
code = "GATEWAY_ERROR"
|
|
25
|
+
try:
|
|
26
|
+
candidate = strict_json(response.content).get("code")
|
|
27
|
+
if isinstance(candidate, str) and re.fullmatch(r"[A-Z][A-Z0-9_]{0,62}", candidate):
|
|
28
|
+
code = candidate
|
|
29
|
+
except (ValueError, AttributeError):
|
|
30
|
+
pass
|
|
31
|
+
raise GatewayError(f"{code} (HTTP {response.status_code})")
|
|
32
|
+
if len(response.content) > 16384:
|
|
33
|
+
raise ValueError("oversized gateway control response")
|
|
34
|
+
return strict_json(response.content)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_identity_encoding(response):
|
|
38
|
+
if response.headers.get("content-encoding", "identity").strip().lower() != "identity":
|
|
39
|
+
raise ValueError("compressed gateway responses are not supported")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def control_response(client, method, url, **kwargs):
|
|
43
|
+
# Streaming prevents a malicious gateway from forcing an unbounded control read.
|
|
44
|
+
timeout = kwargs.get("timeout", client.timeout)
|
|
45
|
+
deadline = timeout.read if isinstance(timeout, httpx.Timeout) else timeout
|
|
46
|
+
async with asyncio.timeout(30 if deadline is None else deadline):
|
|
47
|
+
async with client.stream(method, url, **kwargs) as response:
|
|
48
|
+
require_identity_encoding(response)
|
|
49
|
+
content = bytearray()
|
|
50
|
+
async for chunk in response.aiter_bytes():
|
|
51
|
+
if len(content) + len(chunk) > 16384:
|
|
52
|
+
raise ValueError("oversized gateway control response")
|
|
53
|
+
content.extend(chunk)
|
|
54
|
+
if response.status_code >= 300:
|
|
55
|
+
json_response(httpx.Response(response.status_code, content=bytes(content)))
|
|
56
|
+
if response.headers.get("content-type", "").split(";")[0] != "application/json":
|
|
57
|
+
raise ValueError("invalid control response type")
|
|
58
|
+
return json_response(httpx.Response(response.status_code, content=bytes(content)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Transport:
|
|
62
|
+
def __init__(self, settings):
|
|
63
|
+
self.settings = settings
|
|
64
|
+
self.path = settings.url + "/v1"
|
|
65
|
+
self.session_path = None
|
|
66
|
+
self.headers = {}
|
|
67
|
+
self.remote_eof = False
|
|
68
|
+
self.input_task: asyncio.Task | None = None
|
|
69
|
+
|
|
70
|
+
async def authenticate(self, client, *, check=False):
|
|
71
|
+
s = self.settings
|
|
72
|
+
public, fp = await identity_key(s.identity)
|
|
73
|
+
challenge = await control_response(
|
|
74
|
+
client,
|
|
75
|
+
"POST",
|
|
76
|
+
self.path + "/challenges",
|
|
77
|
+
json={"principal": s.principal or "", "public_key": public},
|
|
78
|
+
)
|
|
79
|
+
expected = {
|
|
80
|
+
"protocol",
|
|
81
|
+
"audience",
|
|
82
|
+
"namespace",
|
|
83
|
+
"challenge_id",
|
|
84
|
+
"nonce",
|
|
85
|
+
"expires_at",
|
|
86
|
+
"principal",
|
|
87
|
+
"public_key_fingerprint",
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
not isinstance(challenge, dict)
|
|
91
|
+
or set(challenge) != expected
|
|
92
|
+
or challenge["protocol"] != PROTOCOL
|
|
93
|
+
or (
|
|
94
|
+
challenge["audience"] != s.url
|
|
95
|
+
and not (
|
|
96
|
+
check
|
|
97
|
+
and s.url.startswith("http://")
|
|
98
|
+
and isinstance(challenge["audience"], str)
|
|
99
|
+
and base_url(challenge["audience"]) == challenge["audience"]
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
or challenge["namespace"] != s.namespace
|
|
103
|
+
or not isinstance(challenge["principal"], str)
|
|
104
|
+
or not re.fullmatch(r"[a-zA-Z0-9_@.+-]{1,128}", challenge["principal"])
|
|
105
|
+
or (s.principal is not None and challenge["principal"] != s.principal)
|
|
106
|
+
or challenge["public_key_fingerprint"] != fp
|
|
107
|
+
or type(challenge["expires_at"]) is not int
|
|
108
|
+
or not time.time() < challenge["expires_at"] <= time.time() + 604800
|
|
109
|
+
or any(
|
|
110
|
+
not isinstance(challenge[k], str) or not 32 <= len(challenge[k]) <= 128
|
|
111
|
+
for k in ("nonce", "challenge_id")
|
|
112
|
+
)
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("invalid gateway challenge binding")
|
|
115
|
+
request = dict(
|
|
116
|
+
protocol=PROTOCOL,
|
|
117
|
+
challenge_id=challenge["challenge_id"],
|
|
118
|
+
principal=challenge["principal"],
|
|
119
|
+
target_host="" if check else s.host,
|
|
120
|
+
target_port=0 if check else s.port,
|
|
121
|
+
ssh_user="" if check else s.user,
|
|
122
|
+
public_key_fingerprint=fp,
|
|
123
|
+
signature_format="sshsig",
|
|
124
|
+
)
|
|
125
|
+
request["signature_b64"] = await sign(
|
|
126
|
+
s.identity, s.namespace, transcript(challenge, request, check=check)
|
|
127
|
+
)
|
|
128
|
+
if check:
|
|
129
|
+
result = await control_response(client, "POST", self.path + "/check", json=request)
|
|
130
|
+
if result != {"authenticated": True} or type(result.get("authenticated")) is not bool:
|
|
131
|
+
raise ValueError("invalid gateway key-check response")
|
|
132
|
+
return None
|
|
133
|
+
session_url = s.url if urlsplit(s.url).path else s.url + "/"
|
|
134
|
+
session = await control_response(client, "POST", session_url, json=request)
|
|
135
|
+
if not isinstance(session, dict) or set(session) != {
|
|
136
|
+
"session_id",
|
|
137
|
+
"session_token",
|
|
138
|
+
"expires_at",
|
|
139
|
+
"max_chunk_bytes",
|
|
140
|
+
"max_in_flight_bytes",
|
|
141
|
+
}:
|
|
142
|
+
raise ValueError("invalid gateway session")
|
|
143
|
+
for key in ("session_id", "session_token"):
|
|
144
|
+
if not isinstance(session[key], str) or not re.fullmatch(
|
|
145
|
+
r"[A-Za-z0-9_-]{32,128}", session[key]
|
|
146
|
+
):
|
|
147
|
+
raise ValueError("invalid session capability")
|
|
148
|
+
chunk = session["max_chunk_bytes"]
|
|
149
|
+
if type(chunk) is not int or not 1 <= chunk <= 16384:
|
|
150
|
+
raise ValueError("invalid chunk size")
|
|
151
|
+
self.session_path = self.path + "/sessions/" + session["session_id"]
|
|
152
|
+
self.headers = {"Authorization": "Bearer " + session["session_token"]}
|
|
153
|
+
return chunk
|
|
154
|
+
|
|
155
|
+
async def post_chunk(self, client, data, seq):
|
|
156
|
+
assert self.session_path is not None
|
|
157
|
+
response = await control_response(
|
|
158
|
+
client,
|
|
159
|
+
"POST",
|
|
160
|
+
self.session_path + "/input",
|
|
161
|
+
content=data,
|
|
162
|
+
headers={
|
|
163
|
+
**self.headers,
|
|
164
|
+
"Content-Type": "application/octet-stream",
|
|
165
|
+
"X-SSH-SSE-Seq": str(seq),
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
if (
|
|
169
|
+
response != {"acked_seq": seq, "bytes": len(data)}
|
|
170
|
+
or type(response.get("acked_seq")) is not int
|
|
171
|
+
or type(response.get("bytes")) is not int
|
|
172
|
+
):
|
|
173
|
+
raise ValueError("ambiguous input acknowledgement")
|
|
174
|
+
|
|
175
|
+
async def post_eof(self, client):
|
|
176
|
+
assert self.session_path is not None
|
|
177
|
+
result = await control_response(
|
|
178
|
+
client, "POST", self.session_path + "/input-eof", headers=self.headers
|
|
179
|
+
)
|
|
180
|
+
if result != {"eof": True} or type(result.get("eof")) is not bool:
|
|
181
|
+
raise ValueError("ambiguous input EOF acknowledgement")
|
|
182
|
+
|
|
183
|
+
async def send(self, client, stdio, chunk):
|
|
184
|
+
assert self.session_path is not None
|
|
185
|
+
seq = 0
|
|
186
|
+
while not self.remote_eof:
|
|
187
|
+
data = await stdio.read(chunk)
|
|
188
|
+
if self.remote_eof:
|
|
189
|
+
return
|
|
190
|
+
if not data:
|
|
191
|
+
self.input_task = asyncio.create_task(self.post_eof(client))
|
|
192
|
+
await self.input_task
|
|
193
|
+
return
|
|
194
|
+
self.input_task = asyncio.create_task(self.post_chunk(client, data, seq))
|
|
195
|
+
await self.input_task
|
|
196
|
+
seq += 1
|
|
197
|
+
|
|
198
|
+
async def receive(self, client, stdio, chunk):
|
|
199
|
+
assert self.session_path is not None
|
|
200
|
+
parser = SSE(chunk)
|
|
201
|
+
async with client.stream(
|
|
202
|
+
"GET",
|
|
203
|
+
self.session_path + "/events",
|
|
204
|
+
headers=self.headers,
|
|
205
|
+
timeout=httpx.Timeout(
|
|
206
|
+
self.settings.stream_timeout, connect=self.settings.request_timeout
|
|
207
|
+
),
|
|
208
|
+
) as response:
|
|
209
|
+
require_identity_encoding(response)
|
|
210
|
+
if (
|
|
211
|
+
response.status_code != 200
|
|
212
|
+
or response.headers.get("content-type", "").split(";")[0] != "text/event-stream"
|
|
213
|
+
):
|
|
214
|
+
raise ValueError("gateway SSE subscription failed")
|
|
215
|
+
async for part in response.aiter_bytes(chunk_size=None):
|
|
216
|
+
for data in parser.feed(part):
|
|
217
|
+
if data is not None:
|
|
218
|
+
await asyncio.wait_for(stdio.write(data), self.settings.stream_timeout)
|
|
219
|
+
if parser.eof:
|
|
220
|
+
parser.finish()
|
|
221
|
+
return
|
|
222
|
+
parser.finish()
|
|
223
|
+
|
|
224
|
+
async def check(self):
|
|
225
|
+
async with self.client() as client:
|
|
226
|
+
await self.authenticate(client, check=True)
|
|
227
|
+
|
|
228
|
+
def client(self):
|
|
229
|
+
context = ssl.create_default_context(cafile=self.settings.ca_file)
|
|
230
|
+
return httpx.AsyncClient(
|
|
231
|
+
verify=context,
|
|
232
|
+
trust_env=False,
|
|
233
|
+
follow_redirects=False,
|
|
234
|
+
timeout=self.settings.request_timeout,
|
|
235
|
+
limits=httpx.Limits(max_connections=4),
|
|
236
|
+
headers={"Accept-Encoding": "identity"},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def run(self, stdio):
|
|
240
|
+
tasks = []
|
|
241
|
+
async with self.client() as client:
|
|
242
|
+
try:
|
|
243
|
+
chunk = await self.authenticate(client)
|
|
244
|
+
assert chunk is not None
|
|
245
|
+
sender = asyncio.create_task(self.send(client, stdio, chunk))
|
|
246
|
+
receiver = asyncio.create_task(self.receive(client, stdio, chunk))
|
|
247
|
+
tasks = [sender, receiver]
|
|
248
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
249
|
+
if receiver in done:
|
|
250
|
+
await receiver
|
|
251
|
+
self.remote_eof = True
|
|
252
|
+
# Remote EOF does not prove an outstanding POST was acknowledged.
|
|
253
|
+
# Finish that request before cancelling any blocked stdin read.
|
|
254
|
+
if self.input_task is not None:
|
|
255
|
+
await asyncio.wait_for(
|
|
256
|
+
asyncio.shield(self.input_task), self.settings.request_timeout
|
|
257
|
+
)
|
|
258
|
+
if sender.done():
|
|
259
|
+
await sender
|
|
260
|
+
else:
|
|
261
|
+
await sender
|
|
262
|
+
await receiver
|
|
263
|
+
finally:
|
|
264
|
+
for task in tasks:
|
|
265
|
+
task.cancel()
|
|
266
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
267
|
+
if self.session_path:
|
|
268
|
+
try:
|
|
269
|
+
async with client.stream(
|
|
270
|
+
"DELETE", self.session_path, headers=self.headers, timeout=2
|
|
271
|
+
):
|
|
272
|
+
pass
|
|
273
|
+
except (httpx.HTTPError, asyncio.CancelledError):
|
|
274
|
+
pass
|