ssh-over-sse-gateway 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_gateway-0.1.0/.gitignore +10 -0
- ssh_over_sse_gateway-0.1.0/PKG-INFO +120 -0
- ssh_over_sse_gateway-0.1.0/README.md +109 -0
- ssh_over_sse_gateway-0.1.0/pyproject.toml +29 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/__init__.py +1 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/app.py +368 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/audit.py +31 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/auth.py +142 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/bridge.py +75 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/cli.py +62 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/config.py +251 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/metrics.py +38 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/policy.py +109 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/protocol.py +33 -0
- ssh_over_sse_gateway-0.1.0/src/ssh_sse_gateway/sessions.py +156 -0
- ssh_over_sse_gateway-0.1.0/uv.lock +706 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ssh-over-sse-gateway
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Policy-restricted SSH TCP relay over HTTPS POST and SSE
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: cryptography>=45
|
|
7
|
+
Requires-Dist: pyyaml<7,>=6
|
|
8
|
+
Requires-Dist: starlette<2,>=1.3.1
|
|
9
|
+
Requires-Dist: uvicorn<1,>=0.35
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# ssh-sse-gateway
|
|
13
|
+
|
|
14
|
+
Provide authorized SSH access through an HTTPS endpoint. Run the gateway on a
|
|
15
|
+
host that can reach your approved SSH destinations.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Requires Python 3.12 or newer and OpenSSH's `ssh-keygen`.
|
|
20
|
+
|
|
21
|
+
With uv:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv tool install --python 3.12 ssh-over-sse-gateway
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or with pip:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install 'ssh-over-sse[gateway]'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use `ssh-over-sse[all]` with pip to install the client command too.
|
|
34
|
+
|
|
35
|
+
## Configure
|
|
36
|
+
|
|
37
|
+
Save your configuration as `~/.config/ssh-over-sse/gateway.yaml`. Use your public
|
|
38
|
+
HTTPS root URL and allow each SSH destination and login username:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
server:
|
|
42
|
+
audience: https://gateway.example.com
|
|
43
|
+
listen: 0.0.0.0:7010
|
|
44
|
+
ingress_network_restricted: true
|
|
45
|
+
targets:
|
|
46
|
+
- host: 192.0.2.20
|
|
47
|
+
ports: [22]
|
|
48
|
+
ssh_users: [alice]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Replace the example values and restrict the file to its owner. Limit backend
|
|
52
|
+
access to your trusted network before setting `ingress_network_restricted: true`;
|
|
53
|
+
this setting acknowledges your network restrictions and does not create them.
|
|
54
|
+
|
|
55
|
+
The default listener is `127.0.0.1:7010`. With OCI providing HTTPS, the gateway
|
|
56
|
+
runs plain HTTP on port **7010** and needs no backend certificate. The example
|
|
57
|
+
above accepts ingress on all interfaces.
|
|
58
|
+
|
|
59
|
+
## Authorize users
|
|
60
|
+
|
|
61
|
+
The gateway reads public keys from its account's `~/.ssh/authorized_keys` by
|
|
62
|
+
default. Set `server.authorized_keys_file` if the file is elsewhere.
|
|
63
|
+
|
|
64
|
+
Supported entries are unrestricted Ed25519, RSA (2048–8192 bits), and ECDSA
|
|
65
|
+
(P-256, P-384, P-521) public keys. Entries with SSH restrictions such as `from=` or
|
|
66
|
+
`command=` are skipped. Use only keys intended to have gateway access.
|
|
67
|
+
|
|
68
|
+
All eligible keys authenticate to the gateway account. The requested destination
|
|
69
|
+
and SSH username must also be allowed in your configuration. The destination
|
|
70
|
+
server separately authorizes the user's SSH login. Private user keys stay on
|
|
71
|
+
users' computers.
|
|
72
|
+
|
|
73
|
+
Removing a key denies new access and ends its active connections. Restart the
|
|
74
|
+
gateway after changing destination permissions; restarting ends active sessions.
|
|
75
|
+
|
|
76
|
+
## Start
|
|
77
|
+
|
|
78
|
+
Validate the configuration, then start:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
ssh-sse-gateway --check-config
|
|
82
|
+
ssh-sse-gateway
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
To use another configuration file:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
ssh-sse-gateway --config /path/to/gateway.yaml --check-config
|
|
89
|
+
ssh-sse-gateway --config /path/to/gateway.yaml
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Configure HTTPS ingress to forward requests to the gateway. It must pass
|
|
93
|
+
authorization headers and support streaming without buffering or compression.
|
|
94
|
+
The root URL shows a landing page in a browser. Users pass that same root URL to
|
|
95
|
+
`ssh-sse-cli`.
|
|
96
|
+
|
|
97
|
+
For direct HTTPS without an ingress, set `server.tls_mode: direct` and provide
|
|
98
|
+
`--tls-cert /path/to/cert.pem` and `--tls-key /path/to/key.pem` when starting.
|
|
99
|
+
Restrict the server's private certificate key to its owner.
|
|
100
|
+
|
|
101
|
+
## Check access
|
|
102
|
+
|
|
103
|
+
From a user's computer:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
ssh-sse-cli --url https://gateway.example.com -i ~/.ssh/id_ecdsa check
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Success confirms gateway key access. Users still need destination permission and
|
|
110
|
+
an authorized SSH login. A landing page alone does not confirm SSH access.
|
|
111
|
+
|
|
112
|
+
## Troubleshooting
|
|
113
|
+
|
|
114
|
+
If startup fails, run `--check-config` and check required values, file ownership,
|
|
115
|
+
and permissions. If users cannot connect, check the HTTPS route, public-key
|
|
116
|
+
access, destination permissions, and network reachability.
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
ssh-sse-gateway --help
|
|
120
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# ssh-sse-gateway
|
|
2
|
+
|
|
3
|
+
Provide authorized SSH access through an HTTPS endpoint. Run the gateway on a
|
|
4
|
+
host that can reach your approved SSH destinations.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
Requires Python 3.12 or newer and OpenSSH's `ssh-keygen`.
|
|
9
|
+
|
|
10
|
+
With uv:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv tool install --python 3.12 ssh-over-sse-gateway
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or with pip:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install 'ssh-over-sse[gateway]'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use `ssh-over-sse[all]` with pip to install the client command too.
|
|
23
|
+
|
|
24
|
+
## Configure
|
|
25
|
+
|
|
26
|
+
Save your configuration as `~/.config/ssh-over-sse/gateway.yaml`. Use your public
|
|
27
|
+
HTTPS root URL and allow each SSH destination and login username:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
server:
|
|
31
|
+
audience: https://gateway.example.com
|
|
32
|
+
listen: 0.0.0.0:7010
|
|
33
|
+
ingress_network_restricted: true
|
|
34
|
+
targets:
|
|
35
|
+
- host: 192.0.2.20
|
|
36
|
+
ports: [22]
|
|
37
|
+
ssh_users: [alice]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Replace the example values and restrict the file to its owner. Limit backend
|
|
41
|
+
access to your trusted network before setting `ingress_network_restricted: true`;
|
|
42
|
+
this setting acknowledges your network restrictions and does not create them.
|
|
43
|
+
|
|
44
|
+
The default listener is `127.0.0.1:7010`. With OCI providing HTTPS, the gateway
|
|
45
|
+
runs plain HTTP on port **7010** and needs no backend certificate. The example
|
|
46
|
+
above accepts ingress on all interfaces.
|
|
47
|
+
|
|
48
|
+
## Authorize users
|
|
49
|
+
|
|
50
|
+
The gateway reads public keys from its account's `~/.ssh/authorized_keys` by
|
|
51
|
+
default. Set `server.authorized_keys_file` if the file is elsewhere.
|
|
52
|
+
|
|
53
|
+
Supported entries are unrestricted Ed25519, RSA (2048–8192 bits), and ECDSA
|
|
54
|
+
(P-256, P-384, P-521) public keys. Entries with SSH restrictions such as `from=` or
|
|
55
|
+
`command=` are skipped. Use only keys intended to have gateway access.
|
|
56
|
+
|
|
57
|
+
All eligible keys authenticate to the gateway account. The requested destination
|
|
58
|
+
and SSH username must also be allowed in your configuration. The destination
|
|
59
|
+
server separately authorizes the user's SSH login. Private user keys stay on
|
|
60
|
+
users' computers.
|
|
61
|
+
|
|
62
|
+
Removing a key denies new access and ends its active connections. Restart the
|
|
63
|
+
gateway after changing destination permissions; restarting ends active sessions.
|
|
64
|
+
|
|
65
|
+
## Start
|
|
66
|
+
|
|
67
|
+
Validate the configuration, then start:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
ssh-sse-gateway --check-config
|
|
71
|
+
ssh-sse-gateway
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To use another configuration file:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
ssh-sse-gateway --config /path/to/gateway.yaml --check-config
|
|
78
|
+
ssh-sse-gateway --config /path/to/gateway.yaml
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Configure HTTPS ingress to forward requests to the gateway. It must pass
|
|
82
|
+
authorization headers and support streaming without buffering or compression.
|
|
83
|
+
The root URL shows a landing page in a browser. Users pass that same root URL to
|
|
84
|
+
`ssh-sse-cli`.
|
|
85
|
+
|
|
86
|
+
For direct HTTPS without an ingress, set `server.tls_mode: direct` and provide
|
|
87
|
+
`--tls-cert /path/to/cert.pem` and `--tls-key /path/to/key.pem` when starting.
|
|
88
|
+
Restrict the server's private certificate key to its owner.
|
|
89
|
+
|
|
90
|
+
## Check access
|
|
91
|
+
|
|
92
|
+
From a user's computer:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
ssh-sse-cli --url https://gateway.example.com -i ~/.ssh/id_ecdsa check
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Success confirms gateway key access. Users still need destination permission and
|
|
99
|
+
an authorized SSH login. A landing page alone does not confirm SSH access.
|
|
100
|
+
|
|
101
|
+
## Troubleshooting
|
|
102
|
+
|
|
103
|
+
If startup fails, run `--check-config` and check required values, file ownership,
|
|
104
|
+
and permissions. If users cannot connect, check the HTTPS route, public-key
|
|
105
|
+
access, destination permissions, and network reachability.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
ssh-sse-gateway --help
|
|
109
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ssh-over-sse-gateway"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
description = "Policy-restricted SSH TCP relay over HTTPS POST and SSE"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["starlette>=1.3.1,<2", "uvicorn>=0.35,<1", "pyyaml>=6,<7", "cryptography>=45"]
|
|
8
|
+
[project.scripts]
|
|
9
|
+
ssh-sse-gateway = "ssh_sse_gateway.cli:main"
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = ["pytest>=8", "pytest-asyncio>=1", "httpx>=0.28", "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_gateway"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
include = ["/pyproject.toml", "/README.md", "/uv.lock", "/src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SSH-over-SSE gateway."""
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""ASGI routes for ssh-sse/v1. No forwarding headers are trusted."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncGenerator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
|
|
11
|
+
from starlette.applications import Starlette
|
|
12
|
+
from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
|
13
|
+
from starlette.routing import Route
|
|
14
|
+
|
|
15
|
+
from .audit import audit
|
|
16
|
+
from .auth import AuthError, Challenges
|
|
17
|
+
from .config import valid_name
|
|
18
|
+
from .metrics import Metrics
|
|
19
|
+
from .policy import authorize
|
|
20
|
+
from .sessions import Sessions
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def landing(request):
|
|
24
|
+
return HTMLResponse(
|
|
25
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
26
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
27
|
+
"<title>ssh-over-sse</title></head><body><h1>ssh-over-sse</h1></body></html>",
|
|
28
|
+
headers={
|
|
29
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
30
|
+
"X-Content-Type-Options": "nosniff",
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def body(request, limit):
|
|
36
|
+
result = bytearray()
|
|
37
|
+
async with asyncio.timeout(request.app.state.config.server.request_timeout):
|
|
38
|
+
async for part in request.stream():
|
|
39
|
+
if len(result) + len(part) > limit:
|
|
40
|
+
raise AuthError("LIMIT_EXCEEDED", 413)
|
|
41
|
+
result.extend(part)
|
|
42
|
+
return bytes(result)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def no_duplicates(pairs):
|
|
46
|
+
result = {}
|
|
47
|
+
for key, value in pairs:
|
|
48
|
+
if key in result:
|
|
49
|
+
raise ValueError("duplicate JSON key")
|
|
50
|
+
result[key] = value
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def control(request):
|
|
55
|
+
if request.headers.get("content-type", "").split(";")[0] != "application/json":
|
|
56
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
57
|
+
try:
|
|
58
|
+
result = json.loads(await body(request, 16384), object_pairs_hook=no_duplicates)
|
|
59
|
+
if not isinstance(result, dict):
|
|
60
|
+
raise ValueError()
|
|
61
|
+
return result
|
|
62
|
+
except (ValueError, TypeError, RecursionError):
|
|
63
|
+
raise AuthError("BAD_REQUEST", 400) from None
|
|
64
|
+
except TimeoutError:
|
|
65
|
+
raise AuthError("REQUEST_TIMEOUT", 408) from None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def challenge(request):
|
|
69
|
+
data = await control(request)
|
|
70
|
+
if set(data) != {"principal", "public_key"} or (
|
|
71
|
+
data.get("principal") != "" and not valid_name(data.get("principal"))
|
|
72
|
+
):
|
|
73
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
74
|
+
try:
|
|
75
|
+
result = request.app.state.challenges.create(data["principal"], data["public_key"])
|
|
76
|
+
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
|
77
|
+
except AuthError:
|
|
78
|
+
request.app.state.metrics.increment("auth_failure_total")
|
|
79
|
+
audit("gateway_auth_failure", reason="UNAUTHORIZED")
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def check_key(request):
|
|
84
|
+
data = await control(request)
|
|
85
|
+
if (
|
|
86
|
+
data.get("target_host") != ""
|
|
87
|
+
or type(data.get("target_port")) is not int
|
|
88
|
+
or data["target_port"] != 0
|
|
89
|
+
or data.get("ssh_user") != ""
|
|
90
|
+
or not isinstance(data.get("challenge_id"), str)
|
|
91
|
+
):
|
|
92
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
93
|
+
state = request.app.state
|
|
94
|
+
try:
|
|
95
|
+
await state.challenges.verify(data, check=True)
|
|
96
|
+
except AuthError:
|
|
97
|
+
state.metrics.increment("auth_failure_total")
|
|
98
|
+
audit("gateway_auth_failure", reason="UNAUTHORIZED")
|
|
99
|
+
raise
|
|
100
|
+
state.metrics.increment("auth_success_total")
|
|
101
|
+
audit(
|
|
102
|
+
"gateway_key_check",
|
|
103
|
+
principal=data["principal"],
|
|
104
|
+
key_fingerprint=data["public_key_fingerprint"],
|
|
105
|
+
)
|
|
106
|
+
return JSONResponse({"authenticated": True}, headers={"Cache-Control": "no-store"})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def create_session(request):
|
|
110
|
+
data = await control(request)
|
|
111
|
+
for key in ("principal", "ssh_user"):
|
|
112
|
+
if not valid_name(data.get(key)):
|
|
113
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
114
|
+
for key in ("challenge_id", "signature_b64", "public_key_fingerprint", "target_host"):
|
|
115
|
+
if not isinstance(data.get(key), str) or len(data[key]) > 12000:
|
|
116
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
117
|
+
if type(data.get("target_port")) is not int:
|
|
118
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
119
|
+
state = request.app.state
|
|
120
|
+
try:
|
|
121
|
+
await state.challenges.verify(data)
|
|
122
|
+
except AuthError:
|
|
123
|
+
request.app.state.metrics.increment("auth_failure_total")
|
|
124
|
+
audit("gateway_auth_failure", reason="UNAUTHORIZED")
|
|
125
|
+
raise
|
|
126
|
+
try:
|
|
127
|
+
host = authorize(
|
|
128
|
+
state.config,
|
|
129
|
+
data["principal"],
|
|
130
|
+
data["target_host"],
|
|
131
|
+
data["target_port"],
|
|
132
|
+
data["ssh_user"],
|
|
133
|
+
)
|
|
134
|
+
except ValueError:
|
|
135
|
+
state.metrics.increment("target_denied_total")
|
|
136
|
+
audit("gateway_target_denied", principal=data["principal"], reason="FORBIDDEN_TARGET")
|
|
137
|
+
raise AuthError("FORBIDDEN_TARGET", 403) from None
|
|
138
|
+
state.metrics.increment("auth_success_total")
|
|
139
|
+
audit(
|
|
140
|
+
"gateway_auth_success",
|
|
141
|
+
principal=data["principal"],
|
|
142
|
+
key_fingerprint=data["public_key_fingerprint"],
|
|
143
|
+
)
|
|
144
|
+
session, token = await state.sessions.open(
|
|
145
|
+
data["principal"], host, data["target_port"], data["public_key_fingerprint"]
|
|
146
|
+
)
|
|
147
|
+
s = state.config.server
|
|
148
|
+
return JSONResponse(
|
|
149
|
+
dict(
|
|
150
|
+
session_id=session.id,
|
|
151
|
+
session_token=token,
|
|
152
|
+
expires_at=int(time.time() + s.session_max_lifetime),
|
|
153
|
+
max_chunk_bytes=s.max_chunk_bytes,
|
|
154
|
+
max_in_flight_bytes=s.max_buffer_bytes_per_direction,
|
|
155
|
+
),
|
|
156
|
+
status_code=201,
|
|
157
|
+
headers={"Cache-Control": "no-store"},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def authenticated(request):
|
|
162
|
+
if request.url.query:
|
|
163
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
164
|
+
return request.app.state.sessions.get(
|
|
165
|
+
request.path_params["id"], request.headers.get("authorization", "")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def input_bytes(request):
|
|
170
|
+
session = authenticated(request)
|
|
171
|
+
b, sessions = session.bridge, request.app.state.sessions
|
|
172
|
+
if b.input_busy or b.input_eof or request.headers.get("x-ssh-sse-seq") != str(b.in_seq):
|
|
173
|
+
await sessions.close(session, "BAD_SEQUENCE")
|
|
174
|
+
raise AuthError("BAD_SEQUENCE", 409)
|
|
175
|
+
b.input_busy = True
|
|
176
|
+
success = False
|
|
177
|
+
try:
|
|
178
|
+
if request.headers.get("content-type") != "application/octet-stream":
|
|
179
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
180
|
+
data = await body(request, b.settings.max_chunk_bytes)
|
|
181
|
+
if not data or b.closed:
|
|
182
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
183
|
+
await b.write(data)
|
|
184
|
+
seq = b.in_seq
|
|
185
|
+
b.in_seq += 1
|
|
186
|
+
success = True
|
|
187
|
+
return JSONResponse({"acked_seq": seq, "bytes": len(data)})
|
|
188
|
+
except (OSError, TimeoutError):
|
|
189
|
+
raise AuthError("TRANSPORT_INTERRUPTED", 502) from None
|
|
190
|
+
finally:
|
|
191
|
+
b.input_busy = False
|
|
192
|
+
if not success:
|
|
193
|
+
await sessions.close(session, "INPUT_INTERRUPTED")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def input_eof(request):
|
|
197
|
+
session = authenticated(request)
|
|
198
|
+
b = session.bridge
|
|
199
|
+
if b.input_busy:
|
|
200
|
+
await request.app.state.sessions.close(session, "BAD_SEQUENCE")
|
|
201
|
+
raise AuthError("BAD_SEQUENCE", 409)
|
|
202
|
+
b.input_busy = True
|
|
203
|
+
success = False
|
|
204
|
+
try:
|
|
205
|
+
if await body(request, 0):
|
|
206
|
+
raise AuthError("BAD_REQUEST", 400)
|
|
207
|
+
await b.half_close()
|
|
208
|
+
success = True
|
|
209
|
+
return JSONResponse({"eof": True})
|
|
210
|
+
except (OSError, TimeoutError):
|
|
211
|
+
await request.app.state.sessions.close(session, "INPUT_INTERRUPTED")
|
|
212
|
+
raise AuthError("TRANSPORT_INTERRUPTED", 502) from None
|
|
213
|
+
finally:
|
|
214
|
+
b.input_busy = False
|
|
215
|
+
if not success:
|
|
216
|
+
await request.app.state.sessions.close(session, "INPUT_INTERRUPTED")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def delete(request):
|
|
220
|
+
# Valid existing capability required; repeated deletion returns generic 404.
|
|
221
|
+
session = authenticated(request)
|
|
222
|
+
await request.app.state.sessions.close(session, "CLIENT_CLOSED")
|
|
223
|
+
return JSONResponse({"closed": True})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class BoundedStreamingResponse(StreamingResponse):
|
|
227
|
+
def __init__(
|
|
228
|
+
self, content: AsyncGenerator[bytes, None], *, stall_timeout, on_close=None, **kwargs
|
|
229
|
+
):
|
|
230
|
+
super().__init__(content, **kwargs)
|
|
231
|
+
self.stall_timeout = stall_timeout
|
|
232
|
+
self.iterator = content
|
|
233
|
+
self.on_close = on_close
|
|
234
|
+
|
|
235
|
+
async def __call__(self, scope, receive, send):
|
|
236
|
+
try:
|
|
237
|
+
await super().__call__(scope, receive, send)
|
|
238
|
+
finally:
|
|
239
|
+
# A header failure can happen before the generator's finally is entered.
|
|
240
|
+
if self.on_close is not None:
|
|
241
|
+
await asyncio.shield(asyncio.create_task(self.on_close()))
|
|
242
|
+
|
|
243
|
+
async def stream_response(self, send):
|
|
244
|
+
async def bounded_send(message):
|
|
245
|
+
await asyncio.wait_for(send(message), self.stall_timeout)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
await super().stream_response(bounded_send)
|
|
249
|
+
finally:
|
|
250
|
+
await self.iterator.aclose()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def events(request):
|
|
254
|
+
session = authenticated(request)
|
|
255
|
+
b = session.bridge
|
|
256
|
+
if b.attached or request.headers.get("last-event-id") is not None:
|
|
257
|
+
await request.app.state.sessions.close(session, "SSE_REATTACH")
|
|
258
|
+
raise AuthError("TRANSPORT_INTERRUPTED", 409)
|
|
259
|
+
b.attached = True
|
|
260
|
+
|
|
261
|
+
async def generate():
|
|
262
|
+
seq = 0
|
|
263
|
+
try:
|
|
264
|
+
while True:
|
|
265
|
+
try:
|
|
266
|
+
data = await asyncio.wait_for(b.output.get(), b.settings.heartbeat)
|
|
267
|
+
except TimeoutError:
|
|
268
|
+
yield b": heartbeat\n\n"
|
|
269
|
+
continue
|
|
270
|
+
if data is None:
|
|
271
|
+
if b.failure:
|
|
272
|
+
yield b'event: error\ndata: {"code":"TRANSPORT_INTERRUPTED"}\n\n'
|
|
273
|
+
else:
|
|
274
|
+
yield b"event: eof\ndata: {}\n\n"
|
|
275
|
+
return
|
|
276
|
+
payload = json.dumps(
|
|
277
|
+
{"seq": seq, "payload_b64": base64.b64encode(data).decode()},
|
|
278
|
+
separators=(",", ":"),
|
|
279
|
+
)
|
|
280
|
+
yield f"id: {seq}\nevent: data\ndata: {payload}\n\n".encode()
|
|
281
|
+
b.bytes_out += len(data)
|
|
282
|
+
b.activity = time.monotonic()
|
|
283
|
+
seq += 1
|
|
284
|
+
finally:
|
|
285
|
+
# Shield cleanup from StreamingResponse's disconnect cancellation scope.
|
|
286
|
+
cleanup = asyncio.create_task(request.app.state.sessions.close(session, "SSE_CLOSED"))
|
|
287
|
+
await asyncio.shield(cleanup)
|
|
288
|
+
|
|
289
|
+
return BoundedStreamingResponse(
|
|
290
|
+
generate(),
|
|
291
|
+
stall_timeout=b.settings.stall_timeout,
|
|
292
|
+
on_close=lambda: request.app.state.sessions.close(session, "SSE_CLOSED"),
|
|
293
|
+
media_type="text/event-stream",
|
|
294
|
+
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def metrics(request):
|
|
299
|
+
if not request.client or not ipaddress.ip_address(request.client.host).is_loopback:
|
|
300
|
+
return JSONResponse({"code": "NOT_FOUND"}, status_code=404)
|
|
301
|
+
return PlainTextResponse(request.app.state.metrics.render(request.app.state.sessions))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def error(request, exc):
|
|
305
|
+
if exc.status == 429:
|
|
306
|
+
request.app.state.metrics.increment("rate_limited_total")
|
|
307
|
+
audit("rate_limited", reason=exc.code)
|
|
308
|
+
return JSONResponse(
|
|
309
|
+
{"code": exc.code}, status_code=exc.status, headers={"Cache-Control": "no-store"}
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class Limits:
|
|
314
|
+
def __init__(self, app, metrics):
|
|
315
|
+
self.app = app
|
|
316
|
+
self.metrics = metrics
|
|
317
|
+
|
|
318
|
+
async def __call__(self, scope, receive, send):
|
|
319
|
+
if scope["type"] == "http":
|
|
320
|
+
headers = scope.get("headers", [])
|
|
321
|
+
if sum(len(k) + len(v) for k, v in headers) > 16384 or len(headers) > 64:
|
|
322
|
+
return await JSONResponse({"code": "LIMIT_EXCEEDED"}, status_code=431)(
|
|
323
|
+
scope, receive, send
|
|
324
|
+
)
|
|
325
|
+
start = time.monotonic()
|
|
326
|
+
try:
|
|
327
|
+
await self.app(scope, receive, send)
|
|
328
|
+
finally:
|
|
329
|
+
if scope["type"] == "http":
|
|
330
|
+
self.metrics.requests += 1
|
|
331
|
+
self.metrics.latency_sum += time.monotonic() - start
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def create_app(config):
|
|
335
|
+
@asynccontextmanager
|
|
336
|
+
async def lifespan(app):
|
|
337
|
+
reaper = asyncio.create_task(app.state.sessions.reap())
|
|
338
|
+
try:
|
|
339
|
+
yield
|
|
340
|
+
finally:
|
|
341
|
+
reaper.cancel()
|
|
342
|
+
await asyncio.gather(reaper, return_exceptions=True)
|
|
343
|
+
await app.state.sessions.shutdown()
|
|
344
|
+
|
|
345
|
+
base = config.server.base_path + "/v1"
|
|
346
|
+
app = Starlette(
|
|
347
|
+
routes=[
|
|
348
|
+
Route("/", landing, methods=["GET"]),
|
|
349
|
+
Route(config.server.base_path or "/", create_session, methods=["POST"]),
|
|
350
|
+
Route("/metrics", metrics),
|
|
351
|
+
Route(base + "/challenges", challenge, methods=["POST"]),
|
|
352
|
+
Route(base + "/check", check_key, methods=["POST"]),
|
|
353
|
+
Route(base + "/sessions", create_session, methods=["POST"]),
|
|
354
|
+
Route(base + "/sessions/{id}/input", input_bytes, methods=["POST"]),
|
|
355
|
+
Route(base + "/sessions/{id}/input-eof", input_eof, methods=["POST"]),
|
|
356
|
+
Route(base + "/sessions/{id}/events", events),
|
|
357
|
+
Route(base + "/sessions/{id}", delete, methods=["DELETE"]),
|
|
358
|
+
],
|
|
359
|
+
lifespan=lifespan,
|
|
360
|
+
exception_handlers={AuthError: error},
|
|
361
|
+
)
|
|
362
|
+
app.router.redirect_slashes = False
|
|
363
|
+
app.state.metrics = Metrics()
|
|
364
|
+
app.add_middleware(Limits, metrics=app.state.metrics)
|
|
365
|
+
app.state.config = config
|
|
366
|
+
app.state.challenges = Challenges(config)
|
|
367
|
+
app.state.sessions = Sessions(config, app.state.metrics)
|
|
368
|
+
return app
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Allowlisted structured audit fields; never serialize requests or exceptions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("ssh_sse.audit")
|
|
8
|
+
FIELDS = {
|
|
9
|
+
"session_id",
|
|
10
|
+
"principal",
|
|
11
|
+
"key_fingerprint",
|
|
12
|
+
"target",
|
|
13
|
+
"reason",
|
|
14
|
+
"duration",
|
|
15
|
+
"bytes_in",
|
|
16
|
+
"bytes_out",
|
|
17
|
+
"active_sessions",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def audit(event, **fields):
|
|
22
|
+
logger.info(
|
|
23
|
+
json.dumps(
|
|
24
|
+
{
|
|
25
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
26
|
+
"event": event,
|
|
27
|
+
**{k: v for k, v in fields.items() if k in FIELDS},
|
|
28
|
+
},
|
|
29
|
+
separators=(",", ":"),
|
|
30
|
+
)
|
|
31
|
+
)
|