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.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .runtime/
10
+ .coverage
@@ -0,0 +1,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
+ )