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.
- relay_shell/__init__.py +7 -0
- relay_shell/__main__.py +230 -0
- relay_shell/_deploy/Caddyfile +78 -0
- relay_shell/_deploy/install-edge.sh +272 -0
- relay_shell/_deploy/install.sh +59 -0
- relay_shell/_deploy/logrotate/relay-shell +22 -0
- relay_shell/_deploy/systemd/relay-shell.service +28 -0
- relay_shell/_deploy/systemd/relay-shell.service.d/hardening.conf +37 -0
- relay_shell/audit.py +199 -0
- relay_shell/auth/__init__.py +7 -0
- relay_shell/auth/oauth.py +295 -0
- relay_shell/config.py +109 -0
- relay_shell/errors.py +27 -0
- relay_shell/inventory.py +173 -0
- relay_shell/metrics.py +134 -0
- relay_shell/patterns.py +190 -0
- relay_shell/policy.py +138 -0
- relay_shell/py.typed +0 -0
- relay_shell/redaction.py +66 -0
- relay_shell/server.py +1126 -0
- relay_shell/sessions.py +378 -0
- relay_shell/shelltools.py +157 -0
- relay_shell/sshpool.py +337 -0
- relay_shell/util.py +58 -0
- relay_shell/verifier.py +198 -0
- relay_shell-0.1.0.dist-info/METADATA +253 -0
- relay_shell-0.1.0.dist-info/RECORD +30 -0
- relay_shell-0.1.0.dist-info/WHEEL +4 -0
- relay_shell-0.1.0.dist-info/entry_points.txt +2 -0
- relay_shell-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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,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()
|