relay-shell 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.
@@ -0,0 +1,28 @@
1
+ [Unit]
2
+ Description=relay-shell - MCP server for shell and SSH operations
3
+ Documentation=https://github.com/rmednitzer/relay-shell
4
+ After=network-online.target
5
+ Wants=network-online.target
6
+
7
+ [Service]
8
+ Type=simple
9
+ User=relay-shell
10
+ Group=relay-shell
11
+ WorkingDirectory=/var/lib/relay-shell
12
+ # Configuration is environment-driven; keep secrets out of the unit.
13
+ EnvironmentFile=-/etc/relay-shell/relay-shell.env
14
+ Environment=RELAY_SHELL_TRANSPORT=http
15
+ Environment=RELAY_SHELL_HTTP_HOST=127.0.0.1
16
+ Environment=RELAY_SHELL_HTTP_PORT=8080
17
+ Environment=RELAY_SHELL_AUDIT_PATH=/var/log/relay-shell/audit.jsonl
18
+ ExecStart=/var/lib/relay-shell/venv/bin/relay-shell
19
+ Restart=on-failure
20
+ RestartSec=5
21
+ StartLimitBurst=5
22
+ StartLimitIntervalSec=300
23
+ StandardOutput=journal
24
+ StandardError=journal
25
+ SyslogIdentifier=relay-shell
26
+
27
+ [Install]
28
+ WantedBy=multi-user.target
@@ -0,0 +1,37 @@
1
+ # relay-shell hardening drop-in.
2
+ #
3
+ # DELIBERATELY PARTIAL. relay-shell exists to run arbitrary commands and reach other
4
+ # hosts (see docs/adr/0002-no-sandbox-full-access.md). The directives that
5
+ # would actually confine it - ProtectSystem=strict, NoNewPrivileges,
6
+ # ProtectHome, PrivateDevices, a restrictive SystemCallFilter - would break
7
+ # the shell/SSH/PTY/sudo capability this service is for, while implying a
8
+ # containment guarantee it cannot honour. Those are intentionally OMITTED.
9
+ #
10
+ # What is applied here is the safe envelope: resource caps so a runaway tool
11
+ # cannot exhaust the host, a private /tmp, and the Protect* directives that do
12
+ # not interfere with command execution. The real boundary is the service
13
+ # account and its credentials, plus the audit/policy layer and the network
14
+ # edge - not this drop-in.
15
+
16
+ [Service]
17
+ # Resource envelope (tune to the host).
18
+ MemoryHigh=768M
19
+ MemoryMax=1024M
20
+ CPUQuota=80%
21
+ TasksMax=128
22
+ LimitNOFILE=8192
23
+
24
+ # Safe isolation that does not break execution.
25
+ PrivateTmp=true
26
+ ProtectClock=true
27
+ ProtectHostname=true
28
+ ProtectKernelLogs=true
29
+ ProtectKernelModules=true
30
+ RestrictRealtime=true
31
+ LockPersonality=true
32
+ UMask=0077
33
+
34
+ # Encrypted credential delivery (systemd-creds). Create with:
35
+ # systemd-creds encrypt plaintext /etc/credstore.encrypted/relay-shell-oauth ...
36
+ # then reference the decrypted path via $CREDENTIALS_DIRECTORY in relay-shell.env.
37
+ # LoadCredentialEncrypted=relay-shell-oauth:/etc/credstore.encrypted/relay-shell-oauth
relay_shell/audit.py ADDED
@@ -0,0 +1,199 @@
1
+ """Append-only, output-hashed audit trail.
2
+
3
+ One JSON object per line. The output *body* is never written - only its
4
+ SHA-256 and byte length - so the log is safe to ship off-host. Arguments are
5
+ redacted by the caller (see :mod:`relay_shell.redaction`) before they arrive here.
6
+
7
+ The handler is rotation-safe (:class:`logging.handlers.WatchedFileHandler`):
8
+ make ``audit.jsonl`` append-only on disk with ``chattr +a`` and rotate it with
9
+ the bundled logrotate config; the handler reopens the file after rotation.
10
+
11
+ Audit failures must never break a tool call: if the sink cannot be opened the
12
+ logger degrades to stderr (or silence) and records ``degraded=True``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import json
19
+ import logging
20
+ import os
21
+ import sys
22
+ from logging.handlers import WatchedFileHandler
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .util import now_iso, sha256_hex
27
+
28
+ __all__ = ["AuditLogger"]
29
+
30
+
31
+ def _format_jsonl(entry: dict[str, Any]) -> str:
32
+ return json.dumps(entry, default=str, ensure_ascii=False)
33
+
34
+
35
+ def _stringify(value: Any) -> str:
36
+ if isinstance(value, (str, int, float, bool)) or value is None:
37
+ return str(value)
38
+ return json.dumps(value, default=str, ensure_ascii=False, separators=(",", ":"))
39
+
40
+
41
+ def _cef_escape(value: str) -> str:
42
+ # CEF extension escaping for separators and line breaks.
43
+ return (
44
+ value.replace("\\", "\\\\")
45
+ .replace("=", "\\=")
46
+ .replace("|", "\\|")
47
+ .replace("\n", "\\n")
48
+ .replace("\r", "\\r")
49
+ )
50
+
51
+
52
+ def _leef_escape(value: str) -> str:
53
+ # LEEF extension escaping with tab-separated key-value fields.
54
+ return (
55
+ value.replace("\\", "\\\\")
56
+ .replace("=", "\\=")
57
+ .replace("\t", "\\t")
58
+ .replace("\n", "\\n")
59
+ .replace("\r", "\\r")
60
+ )
61
+
62
+
63
+ def _format_cef(entry: dict[str, Any]) -> str:
64
+ ext = " ".join(f"{k}={_cef_escape(_stringify(v))}" for k, v in sorted(entry.items()))
65
+ return f"CEF:0|relay-shell|relay-shell|1.0|audit|tool-call|5|{ext}"
66
+
67
+
68
+ def _format_leef(entry: dict[str, Any]) -> str:
69
+ ext = "\t".join(f"{k}={_leef_escape(_stringify(v))}" for k, v in sorted(entry.items()))
70
+ return f"LEEF:2.0|relay-shell|relay-shell|1.0|audit\t{ext}"
71
+
72
+
73
+ _FORMATTERS = {"jsonl": _format_jsonl, "cef": _format_cef, "leef": _format_leef}
74
+
75
+
76
+ class AuditLogger:
77
+ """Writes structured audit records. Construction never raises."""
78
+
79
+ def __init__(self, path: str, also_stderr: bool = False, fmt: str = "jsonl") -> None:
80
+ self.path = path
81
+ self.degraded = False
82
+ self.degraded_reason = ""
83
+ self.format = fmt
84
+ self._log = logging.getLogger("relay_shell.audit")
85
+ self._log.setLevel(logging.INFO)
86
+ self._log.propagate = False
87
+ for handler in list(self._log.handlers):
88
+ self._log.removeHandler(handler)
89
+
90
+ sink: logging.Handler
91
+ try:
92
+ target = Path(path).expanduser()
93
+ target.parent.mkdir(parents=True, exist_ok=True)
94
+ # Pre-create using O_APPEND to stay compatible with append-only
95
+ # hardened files (e.g., chattr +a on Linux).
96
+ fd = os.open(str(target), os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
97
+ os.close(fd)
98
+ # Defensive for existing files that might already be too permissive.
99
+ with contextlib.suppress(OSError):
100
+ target.chmod(0o600)
101
+ sink = WatchedFileHandler(str(target), encoding="utf-8")
102
+ except OSError as exc: # unwritable path -> degrade, never crash
103
+ self.degraded = True
104
+ self.degraded_reason = str(exc)
105
+ sink = logging.StreamHandler(sys.stderr)
106
+ sink.setFormatter(logging.Formatter("%(message)s"))
107
+ self._log.addHandler(sink)
108
+
109
+ if also_stderr and not self.degraded:
110
+ echo = logging.StreamHandler(sys.stderr)
111
+ echo.setFormatter(logging.Formatter("AUDIT %(message)s"))
112
+ self._log.addHandler(echo)
113
+
114
+ def record(
115
+ self,
116
+ *,
117
+ tool: str,
118
+ args: dict[str, Any],
119
+ output: str,
120
+ exit_code: int | None,
121
+ tier: int,
122
+ request_id: str = "",
123
+ client_id: str = "",
124
+ denied: bool = False,
125
+ ) -> None:
126
+ """Append one audit line. Best-effort; swallows its own errors."""
127
+ entry: dict[str, Any] = {
128
+ "ts": now_iso(),
129
+ "tool": tool,
130
+ "tier": tier,
131
+ "denied": denied,
132
+ "args": args,
133
+ "output_sha256": sha256_hex(output),
134
+ "output_len": len(output.encode("utf-8", "replace")),
135
+ "exit_code": exit_code,
136
+ }
137
+ if request_id:
138
+ entry["request_id"] = request_id
139
+ if client_id:
140
+ entry["client_id"] = client_id
141
+ # Audit must never break a tool call.
142
+ with contextlib.suppress(Exception):
143
+ formatter = _FORMATTERS.get(self.format, _format_jsonl)
144
+ self._log.info(formatter(entry))
145
+
146
+ def tail(self, lines: int) -> str:
147
+ """Return the last ``lines`` audit records as on-disk text lines.
148
+
149
+ Records are returned in their original on-disk order (oldest first).
150
+ The empty string is returned if the audit file does not exist, is
151
+ empty, or cannot be read - this method must never raise; it is
152
+ consumed by a read-only diagnostic tool and a failure here should
153
+ not break the caller.
154
+
155
+ Reading is opened on a fresh fd; the writer's append-only fd is
156
+ untouched so this is safe to call concurrently with normal tool
157
+ execution. ``WatchedFileHandler`` line-buffers each emit, so any
158
+ record returned here is structurally complete.
159
+
160
+ Implementation reads backward from end-of-file in 8 KiB chunks
161
+ and stops as soon as it has ``lines + 1`` newlines (the +1
162
+ catches a partial leading line). Worst-case memory is bounded
163
+ by ``lines * record_size + chunk_size``, not by file size, so
164
+ the bounded-execution invariant holds even when the audit log
165
+ has not rotated in a long time.
166
+ """
167
+ if lines <= 0:
168
+ return ""
169
+ # Outer `except Exception` keeps the contract literal: even an
170
+ # exotic OSError subclass, a ValueError from a path with an
171
+ # embedded NUL, or an unexpected decoding failure must collapse
172
+ # to "" rather than propagate to the diagnostic tool's caller.
173
+ try:
174
+ path = Path(self.path).expanduser()
175
+ with path.open("rb") as fh:
176
+ fh.seek(0, os.SEEK_END)
177
+ size = fh.tell()
178
+ if size == 0:
179
+ return ""
180
+
181
+ chunk_size = 8192
182
+ data = b""
183
+ offset = size
184
+ # Read backward until we have at least one more newline
185
+ # than requested, or we hit the start of the file.
186
+ while offset > 0 and data.count(b"\n") <= lines:
187
+ read = min(chunk_size, offset)
188
+ offset -= read
189
+ fh.seek(offset)
190
+ data = fh.read(read) + data
191
+
192
+ text = data.decode("utf-8", errors="replace")
193
+ # Drop blank trailing lines (logger does not write them, but
194
+ # a caller might tail a partial write window) and strip the
195
+ # line terminator on each record for consistent line output.
196
+ records = [ln for ln in text.splitlines() if ln.strip()]
197
+ return "\n".join(records[-lines:])
198
+ except Exception: # noqa: BLE001 - contract is "never raise"
199
+ return ""
@@ -0,0 +1,7 @@
1
+ """Optional OAuth 2.1 surface for the HTTP transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .oauth import FileOAuthProvider, build_auth_settings, make_oauth_provider
6
+
7
+ __all__ = ["FileOAuthProvider", "build_auth_settings", "make_oauth_provider"]
@@ -0,0 +1,295 @@
1
+ """File-backed OAuth 2.1 authorization-server provider.
2
+
3
+ Modeled on a production MCP gateway's provider: dynamic client registration
4
+ with optional single-client lockdown, PKCE (the SDK enforces the challenge),
5
+ short-lived authorization codes, rotating refresh tokens, and lazy expiry on
6
+ read. State is three JSON files under ``auth_state_dir``; no database.
7
+
8
+ This is optional and only constructed for the HTTP transport when
9
+ ``RELAY_SHELL_AUTH_ENABLED=true``. Errors here must surface as auth failures, never
10
+ as a crashed transport, so reconstruction is defensive.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import json
17
+ import os
18
+ import secrets
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from mcp.server.auth.provider import (
24
+ AccessToken,
25
+ AuthorizationCode,
26
+ AuthorizationParams,
27
+ OAuthAuthorizationServerProvider,
28
+ RefreshToken,
29
+ construct_redirect_uri,
30
+ )
31
+ from mcp.server.auth.settings import (
32
+ AuthSettings,
33
+ ClientRegistrationOptions,
34
+ RevocationOptions,
35
+ )
36
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
37
+ from pydantic import AnyHttpUrl
38
+
39
+ __all__ = ["FileOAuthProvider", "build_auth_settings", "make_oauth_provider"]
40
+
41
+ _SCOPES = ["mcp:tools"]
42
+ _REFRESH_PREFIX = "refresh:"
43
+
44
+
45
+ def _now() -> int:
46
+ return int(time.time())
47
+
48
+
49
+ _DIR_MODE = 0o700
50
+ _FILE_MODE = 0o600
51
+
52
+
53
+ class _Store:
54
+ """Tiny JSON file store. Each call reads/writes the whole file.
55
+
56
+ Directory and file permissions are set explicitly so the security
57
+ expectation does not depend on the caller's umask. systemd's
58
+ ``UMask=0077`` in the hardening drop-in matches this, but an operator
59
+ running the HTTP transport ad-hoc (tests, dev shells) gets the same
60
+ private permissions for free.
61
+ """
62
+
63
+ def __init__(self, path: Path) -> None:
64
+ self._path = path
65
+ parent = self._path.parent
66
+ parent.mkdir(parents=True, exist_ok=True)
67
+ # mkdir(mode=...) only applies when the directory is freshly
68
+ # created and never to existing parents; chmod is idempotent and
69
+ # covers both. Best-effort: a parent we cannot chmod (e.g. owned
70
+ # by another user) is the operator's responsibility, not fatal.
71
+ with contextlib.suppress(OSError):
72
+ parent.chmod(_DIR_MODE)
73
+
74
+ def load(self) -> dict[str, Any]:
75
+ try:
76
+ data: Any = json.loads(self._path.read_text(encoding="utf-8"))
77
+ except (OSError, json.JSONDecodeError):
78
+ return {}
79
+ return data if isinstance(data, dict) else {}
80
+
81
+ def save(self, data: dict[str, Any]) -> None:
82
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
83
+ payload = json.dumps(data, indent=2, default=str)
84
+ # Race-free: create the temp file with 0o600 atomically rather than
85
+ # writing first and chmod'ing after.
86
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _FILE_MODE)
87
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
88
+ fh.write(payload)
89
+ tmp.replace(self._path)
90
+
91
+
92
+ class FileOAuthProvider(OAuthAuthorizationServerProvider): # type: ignore[type-arg]
93
+ """OAuth 2.1 AS provider with file-backed state."""
94
+
95
+ def __init__(
96
+ self,
97
+ state_dir: str,
98
+ *,
99
+ single_client: bool,
100
+ access_ttl: int,
101
+ refresh_ttl: int,
102
+ code_ttl: int,
103
+ ) -> None:
104
+ base = Path(state_dir).expanduser()
105
+ self._clients = _Store(base / "clients.json")
106
+ self._codes = _Store(base / "codes.json")
107
+ self._tokens = _Store(base / "tokens.json")
108
+ self._single_client = single_client
109
+ self._access_ttl = access_ttl
110
+ self._refresh_ttl = refresh_ttl
111
+ self._code_ttl = code_ttl
112
+
113
+ # --- clients ---
114
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
115
+ data = self._clients.load().get(client_id)
116
+ if not data:
117
+ return None
118
+ try:
119
+ return OAuthClientInformationFull.model_validate(data)
120
+ except Exception: # noqa: BLE001
121
+ return None
122
+
123
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
124
+ cid = client_info.client_id or ""
125
+ if not cid:
126
+ raise ValueError("client_id is required")
127
+ clients = self._clients.load()
128
+ if self._single_client and clients and cid not in clients:
129
+ raise ValueError("Dynamic client registration is closed (single-client lockdown).")
130
+ clients[cid] = json.loads(client_info.model_dump_json())
131
+ self._clients.save(clients)
132
+
133
+ # --- authorization codes ---
134
+ async def authorize(
135
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
136
+ ) -> str:
137
+ code = secrets.token_urlsafe(48)
138
+ codes = self._codes.load()
139
+ codes[code] = {
140
+ "code": code,
141
+ "client_id": client.client_id or "",
142
+ "scopes": list(getattr(params, "scopes", None) or _SCOPES),
143
+ "expires_at": _now() + self._code_ttl,
144
+ "code_challenge": getattr(params, "code_challenge", ""),
145
+ "redirect_uri": str(params.redirect_uri),
146
+ "redirect_uri_provided_explicitly": bool(
147
+ getattr(params, "redirect_uri_provided_explicitly", True)
148
+ ),
149
+ "resource": getattr(params, "resource", None),
150
+ }
151
+ self._codes.save(codes)
152
+ return construct_redirect_uri(
153
+ str(params.redirect_uri), code=code, state=getattr(params, "state", None)
154
+ )
155
+
156
+ def _build_auth_code(self, rec: dict[str, Any]) -> AuthorizationCode | None:
157
+ try:
158
+ return AuthorizationCode(
159
+ code=rec["code"],
160
+ scopes=rec["scopes"],
161
+ expires_at=float(rec["expires_at"]),
162
+ client_id=rec["client_id"],
163
+ code_challenge=rec.get("code_challenge", ""),
164
+ redirect_uri=rec["redirect_uri"],
165
+ redirect_uri_provided_explicitly=rec.get("redirect_uri_provided_explicitly", True),
166
+ )
167
+ except Exception: # noqa: BLE001
168
+ return None
169
+
170
+ async def load_authorization_code(
171
+ self, client: OAuthClientInformationFull, authorization_code: str
172
+ ) -> AuthorizationCode | None:
173
+ codes = self._codes.load()
174
+ rec = codes.get(authorization_code)
175
+ if not rec or rec.get("client_id") != (client.client_id or ""):
176
+ return None
177
+ if int(rec.get("expires_at", 0)) < _now():
178
+ codes.pop(authorization_code, None)
179
+ self._codes.save(codes)
180
+ return None
181
+ return self._build_auth_code(rec)
182
+
183
+ async def exchange_authorization_code(
184
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
185
+ ) -> OAuthToken:
186
+ codes = self._codes.load()
187
+ codes.pop(authorization_code.code, None)
188
+ self._codes.save(codes)
189
+ scopes = list(authorization_code.scopes or _SCOPES)
190
+ return self._issue(client.client_id or "", scopes)
191
+
192
+ # --- tokens ---
193
+ def _issue(self, client_id: str, scopes: list[str]) -> OAuthToken:
194
+ access = secrets.token_urlsafe(48)
195
+ refresh = secrets.token_urlsafe(48)
196
+ tokens = self._tokens.load()
197
+ tokens[access] = {
198
+ "token": access,
199
+ "client_id": client_id,
200
+ "scopes": scopes,
201
+ "expires_at": _now() + self._access_ttl,
202
+ }
203
+ tokens[_REFRESH_PREFIX + refresh] = {
204
+ "token": refresh,
205
+ "client_id": client_id,
206
+ "scopes": scopes,
207
+ "expires_at": _now() + self._refresh_ttl,
208
+ }
209
+ self._tokens.save(tokens)
210
+ return OAuthToken(
211
+ access_token=access,
212
+ token_type="Bearer",
213
+ expires_in=self._access_ttl,
214
+ refresh_token=refresh,
215
+ scope=" ".join(scopes),
216
+ )
217
+
218
+ async def load_access_token(self, token: str) -> AccessToken | None:
219
+ tokens = self._tokens.load()
220
+ rec = tokens.get(token)
221
+ if not rec:
222
+ return None
223
+ if int(rec.get("expires_at", 0)) < _now():
224
+ tokens.pop(token, None)
225
+ self._tokens.save(tokens)
226
+ return None
227
+ try:
228
+ return AccessToken(
229
+ token=rec["token"],
230
+ client_id=rec["client_id"],
231
+ scopes=rec["scopes"],
232
+ expires_at=int(rec["expires_at"]),
233
+ )
234
+ except Exception: # noqa: BLE001
235
+ return None
236
+
237
+ async def load_refresh_token(
238
+ self, client: OAuthClientInformationFull, refresh_token: str
239
+ ) -> RefreshToken | None:
240
+ rec = self._tokens.load().get(_REFRESH_PREFIX + refresh_token)
241
+ if not rec or rec.get("client_id") != (client.client_id or ""):
242
+ return None
243
+ if int(rec.get("expires_at", 0)) < _now():
244
+ return None
245
+ try:
246
+ return RefreshToken(
247
+ token=rec["token"],
248
+ client_id=rec["client_id"],
249
+ scopes=rec["scopes"],
250
+ expires_at=int(rec["expires_at"]),
251
+ )
252
+ except Exception: # noqa: BLE001
253
+ return None
254
+
255
+ async def exchange_refresh_token(
256
+ self,
257
+ client: OAuthClientInformationFull,
258
+ refresh_token: RefreshToken,
259
+ scopes: list[str],
260
+ ) -> OAuthToken:
261
+ tokens = self._tokens.load()
262
+ tokens.pop(_REFRESH_PREFIX + refresh_token.token, None)
263
+ self._tokens.save(tokens)
264
+ effective = list(scopes or refresh_token.scopes or _SCOPES)
265
+ return self._issue(client.client_id or "", effective)
266
+
267
+ async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
268
+ tokens = self._tokens.load()
269
+ raw = getattr(token, "token", "")
270
+ tokens.pop(raw, None)
271
+ tokens.pop(_REFRESH_PREFIX + raw, None)
272
+ self._tokens.save(tokens)
273
+
274
+
275
+ def build_auth_settings(issuer: str) -> AuthSettings:
276
+ url = AnyHttpUrl(issuer)
277
+ return AuthSettings(
278
+ issuer_url=url,
279
+ resource_server_url=url,
280
+ client_registration_options=ClientRegistrationOptions(
281
+ enabled=True, valid_scopes=_SCOPES, default_scopes=_SCOPES
282
+ ),
283
+ revocation_options=RevocationOptions(enabled=True),
284
+ required_scopes=_SCOPES,
285
+ )
286
+
287
+
288
+ def make_oauth_provider(settings: Any) -> FileOAuthProvider:
289
+ return FileOAuthProvider(
290
+ settings.auth_state_dir,
291
+ single_client=settings.auth_single_client,
292
+ access_ttl=settings.auth_access_ttl,
293
+ refresh_ttl=settings.auth_refresh_ttl,
294
+ code_ttl=settings.auth_code_ttl,
295
+ )
relay_shell/config.py ADDED
@@ -0,0 +1,109 @@
1
+ """Typed, environment-driven configuration.
2
+
3
+ All settings are read from ``RELAY_SHELL_*`` environment variables (and an optional
4
+ ``.env``). Invalid values fail fast at startup rather than mid-run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+
11
+ from pydantic import Field, field_validator
12
+ from pydantic_settings import BaseSettings, SettingsConfigDict
13
+
14
+ __all__ = ["Settings", "get_settings"]
15
+
16
+ _TRANSPORTS = {"stdio", "http"}
17
+ _POLICY_MODES = {"open", "guarded", "readonly"}
18
+ _KNOWN_HOSTS = {"strict", "accept-new", "ignore"}
19
+ _AUDIT_FORMATS = {"jsonl", "cef", "leef"}
20
+
21
+
22
+ class Settings(BaseSettings):
23
+ """Effective server configuration. Immutable after load."""
24
+
25
+ model_config = SettingsConfigDict(
26
+ env_prefix="RELAY_SHELL_",
27
+ env_file=".env",
28
+ env_file_encoding="utf-8",
29
+ extra="ignore",
30
+ frozen=True,
31
+ )
32
+
33
+ # Transport
34
+ transport: str = "stdio"
35
+ http_host: str = "127.0.0.1"
36
+ http_port: int = Field(default=8080, ge=1, le=65535)
37
+
38
+ # Limits
39
+ max_output: int = Field(default=65536, ge=1024)
40
+ max_output_hard: int = Field(default=1_048_576, ge=4096)
41
+ default_timeout: int = Field(default=60, ge=1)
42
+ max_timeout: int = Field(default=900, ge=1)
43
+ max_sessions: int = Field(default=64, ge=1, le=1024)
44
+ session_idle_timeout: int = Field(default=1800, ge=10)
45
+ session_buffer_bytes: int = Field(default=262_144, ge=4096)
46
+
47
+ # Policy
48
+ policy_mode: str = "open"
49
+ policy_deny: str = ""
50
+ policy_allow: str = ""
51
+
52
+ # Audit
53
+ audit_path: str = "/var/log/relay-shell/audit.jsonl"
54
+ audit_stderr: bool = False
55
+ audit_format: str = "jsonl"
56
+
57
+ # SSH
58
+ ssh_config: str = "~/.ssh/config"
59
+ inventory: str = ""
60
+ ssh_known_hosts: str = "accept-new"
61
+ ssh_connect_timeout: int = Field(default=10, ge=1, le=120)
62
+ ssh_keepalive: int = Field(default=30, ge=0, le=600)
63
+
64
+ # OAuth 2.1 (HTTP transport only)
65
+ auth_enabled: bool = False
66
+ auth_issuer: str = "https://localhost:8080"
67
+ auth_state_dir: str = "/var/lib/relay-shell/oauth"
68
+ auth_single_client: bool = True
69
+ auth_access_ttl: int = Field(default=3600, ge=60)
70
+ auth_refresh_ttl: int = Field(default=2_592_000, ge=300)
71
+ auth_code_ttl: int = Field(default=300, ge=30)
72
+
73
+ @field_validator("transport")
74
+ @classmethod
75
+ def _v_transport(cls, v: str) -> str:
76
+ v = v.strip().lower()
77
+ if v not in _TRANSPORTS:
78
+ raise ValueError(f"transport must be one of {sorted(_TRANSPORTS)}")
79
+ return v
80
+
81
+ @field_validator("policy_mode")
82
+ @classmethod
83
+ def _v_policy(cls, v: str) -> str:
84
+ v = v.strip().lower()
85
+ if v not in _POLICY_MODES:
86
+ raise ValueError(f"policy_mode must be one of {sorted(_POLICY_MODES)}")
87
+ return v
88
+
89
+ @field_validator("ssh_known_hosts")
90
+ @classmethod
91
+ def _v_known_hosts(cls, v: str) -> str:
92
+ v = v.strip().lower()
93
+ if v not in _KNOWN_HOSTS:
94
+ raise ValueError(f"ssh_known_hosts must be one of {sorted(_KNOWN_HOSTS)}")
95
+ return v
96
+
97
+ @field_validator("audit_format")
98
+ @classmethod
99
+ def _v_audit_format(cls, v: str) -> str:
100
+ v = v.strip().lower()
101
+ if v not in _AUDIT_FORMATS:
102
+ raise ValueError(f"audit_format must be one of {sorted(_AUDIT_FORMATS)}")
103
+ return v
104
+
105
+
106
+ @lru_cache(maxsize=1)
107
+ def get_settings() -> Settings:
108
+ """Process-wide cached settings instance."""
109
+ return Settings()