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
relay_shell/__init__.py
ADDED
relay_shell/__main__.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Entrypoint: ``relay_shell`` / ``python -m relay_shell``.
|
|
2
|
+
|
|
3
|
+
Transport and all behaviour come from ``RELAY_SHELL_*`` environment variables (see
|
|
4
|
+
``.env.example``). Logging goes to **stderr** only: the stdio transport owns
|
|
5
|
+
stdout/stdin for JSON-RPC, so a stray stdout write would corrupt the stream.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import get_settings
|
|
17
|
+
from .server import Relay, build_server
|
|
18
|
+
from .verifier import Status, verify_deploy
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _configure_logging() -> None:
|
|
22
|
+
root = logging.getLogger()
|
|
23
|
+
root.setLevel(logging.INFO)
|
|
24
|
+
for handler in list(root.handlers):
|
|
25
|
+
root.removeHandler(handler)
|
|
26
|
+
stderr = logging.StreamHandler(sys.stderr)
|
|
27
|
+
stderr.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
28
|
+
root.addHandler(stderr)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="relay-shell",
|
|
34
|
+
description=(
|
|
35
|
+
"MCP server for governed shell and SSH operations. Behaviour is "
|
|
36
|
+
"configured via RELAY_SHELL_* environment variables; see "
|
|
37
|
+
".env.example for the full surface."
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--check-config",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help=(
|
|
44
|
+
"Load settings, construct the server (audit sink, policy, "
|
|
45
|
+
"inventory, OAuth if enabled) WITHOUT starting a transport, "
|
|
46
|
+
"and exit 0 if everything initialized cleanly. Exits 2 on "
|
|
47
|
+
"invalid configuration or a degraded audit sink. Intended "
|
|
48
|
+
"for CI pipelines that bake an image."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--verify-deploy",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help=(
|
|
55
|
+
"Compare each shipped deploy template (systemd unit + "
|
|
56
|
+
"drop-in, logrotate, Caddyfile) against the file the "
|
|
57
|
+
"installer is expected to have laid down on this host. "
|
|
58
|
+
"Exits 0 if every entry matches, 2 if any DRIFT / MISSING / "
|
|
59
|
+
"ABSENT_TEMPLATE is found. Intended for production drift "
|
|
60
|
+
"detection and image-bake validation."
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--templates-dir",
|
|
65
|
+
type=Path,
|
|
66
|
+
default=None,
|
|
67
|
+
help=(
|
|
68
|
+
"Override the shipped-templates lookup. Default: the wheel's "
|
|
69
|
+
"packaged copy, falling back to deploy/ next to this file."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--install-prefix",
|
|
74
|
+
type=Path,
|
|
75
|
+
default=None,
|
|
76
|
+
help=(
|
|
77
|
+
"Treat this directory as a chroot-style root: each absolute "
|
|
78
|
+
"install path is rebased under it. Used by tests and "
|
|
79
|
+
"image-bake validation."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--json",
|
|
84
|
+
action="store_true",
|
|
85
|
+
dest="json_out",
|
|
86
|
+
help=("Emit machine-readable output for --verify-deploy (ignored for other subcommands)."),
|
|
87
|
+
)
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_config() -> int:
|
|
92
|
+
"""Validate config + build the server without starting a transport.
|
|
93
|
+
|
|
94
|
+
Returns 0 on success, 2 on any initialization failure or a degraded
|
|
95
|
+
audit sink. All output goes to stderr so the stdio transport's
|
|
96
|
+
contract is preserved even when this is called from a parent process
|
|
97
|
+
that pipes our streams.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
settings = get_settings()
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
print(f"relay_shell: invalid configuration: {exc}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
try:
|
|
105
|
+
# build_server registers every tool and (for http+auth) constructs
|
|
106
|
+
# the OAuth provider. Both validate the side-effecting parts of the
|
|
107
|
+
# config (audit path open, ssh_config parse, OAuth state dir).
|
|
108
|
+
build_server(settings)
|
|
109
|
+
# build_server holds the Relay internally; instantiate one alongside
|
|
110
|
+
# so we can inspect the audit sink's degraded flag. The instantiation
|
|
111
|
+
# is cheap and benign (audit file is opened append-only).
|
|
112
|
+
relay = Relay(settings)
|
|
113
|
+
except Exception as exc: # noqa: BLE001
|
|
114
|
+
print(f"relay_shell: build_server failed: {exc}", file=sys.stderr)
|
|
115
|
+
return 2
|
|
116
|
+
|
|
117
|
+
if relay.audit.degraded:
|
|
118
|
+
print(
|
|
119
|
+
f"relay_shell: audit sink degraded ({relay.audit.degraded_reason}); "
|
|
120
|
+
f"refuse this configuration for production deployment.",
|
|
121
|
+
file=sys.stderr,
|
|
122
|
+
)
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
print(
|
|
126
|
+
f"relay_shell: config OK "
|
|
127
|
+
f"(transport={settings.transport}, "
|
|
128
|
+
f"policy={settings.policy_mode}, "
|
|
129
|
+
f"audit={settings.audit_path})",
|
|
130
|
+
file=sys.stderr,
|
|
131
|
+
)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _verify_deploy(
|
|
136
|
+
templates_dir: Path | None,
|
|
137
|
+
install_prefix: Path | None,
|
|
138
|
+
json_out: bool,
|
|
139
|
+
) -> int:
|
|
140
|
+
"""Run drift detection and print a report.
|
|
141
|
+
|
|
142
|
+
Returns 0 if every finding is OK, 2 otherwise. Errors never escape as
|
|
143
|
+
tracebacks: ``verify_deploy()`` itself folds template-resolution failures
|
|
144
|
+
into structured ``ABSENT_TEMPLATE`` findings.
|
|
145
|
+
"""
|
|
146
|
+
report = verify_deploy(templates_dir=templates_dir, install_prefix=install_prefix)
|
|
147
|
+
|
|
148
|
+
if json_out:
|
|
149
|
+
payload = {
|
|
150
|
+
"ok": report.ok,
|
|
151
|
+
"findings": [
|
|
152
|
+
{
|
|
153
|
+
"name": f.name,
|
|
154
|
+
"template": f.template,
|
|
155
|
+
"install_path": f.install_path,
|
|
156
|
+
"status": f.status.value,
|
|
157
|
+
"detail": f.detail,
|
|
158
|
+
}
|
|
159
|
+
for f in report.findings
|
|
160
|
+
],
|
|
161
|
+
}
|
|
162
|
+
print(json.dumps(payload, indent=2))
|
|
163
|
+
else:
|
|
164
|
+
# Column widths chosen so the longest name + status + path stays
|
|
165
|
+
# under 100 cols for typical install paths.
|
|
166
|
+
name_w = max((len(f.name) for f in report.findings), default=0)
|
|
167
|
+
status_w = max((len(f.status.value) for f in report.findings), default=0)
|
|
168
|
+
for f in report.findings:
|
|
169
|
+
line = f"{f.name:<{name_w}} {f.status.value:<{status_w}} {f.install_path}"
|
|
170
|
+
if f.detail and f.status is not Status.OK:
|
|
171
|
+
line += f" ({f.detail})"
|
|
172
|
+
print(line)
|
|
173
|
+
if report.ok:
|
|
174
|
+
print("relay-shell: verify-deploy OK", file=sys.stderr)
|
|
175
|
+
else:
|
|
176
|
+
drift = len(report.by_status(Status.DRIFT))
|
|
177
|
+
missing = len(report.by_status(Status.MISSING))
|
|
178
|
+
absent = len(report.by_status(Status.ABSENT_TEMPLATE))
|
|
179
|
+
print(
|
|
180
|
+
f"relay-shell: verify-deploy FAILED "
|
|
181
|
+
f"(drift={drift}, missing={missing}, absent_template={absent})",
|
|
182
|
+
file=sys.stderr,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return 0 if report.ok else 2
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main(argv: list[str] | None = None) -> int:
|
|
189
|
+
"""Build and run the server. Returns a process exit code."""
|
|
190
|
+
parser = _build_arg_parser()
|
|
191
|
+
args = parser.parse_args(argv)
|
|
192
|
+
|
|
193
|
+
_configure_logging()
|
|
194
|
+
|
|
195
|
+
if args.verify_deploy:
|
|
196
|
+
return _verify_deploy(args.templates_dir, args.install_prefix, args.json_out)
|
|
197
|
+
|
|
198
|
+
if args.check_config:
|
|
199
|
+
return _check_config()
|
|
200
|
+
|
|
201
|
+
log = logging.getLogger("relay_shell")
|
|
202
|
+
try:
|
|
203
|
+
settings = get_settings()
|
|
204
|
+
except Exception as exc: # noqa: BLE001
|
|
205
|
+
print(f"relay_shell: invalid configuration: {exc}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
|
|
208
|
+
server = build_server(settings)
|
|
209
|
+
log.info(
|
|
210
|
+
"relay_shell starting (transport=%s, policy=%s, audit=%s)",
|
|
211
|
+
settings.transport,
|
|
212
|
+
settings.policy_mode,
|
|
213
|
+
settings.audit_path,
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
if settings.transport == "http":
|
|
217
|
+
server.run(transport="streamable-http")
|
|
218
|
+
else:
|
|
219
|
+
server.run(transport="stdio")
|
|
220
|
+
except KeyboardInterrupt:
|
|
221
|
+
log.info("relay_shell stopped (interrupt)")
|
|
222
|
+
return 0
|
|
223
|
+
except Exception as exc: # noqa: BLE001
|
|
224
|
+
log.error("relay_shell exited with error: %s", exc)
|
|
225
|
+
return 1
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Caddyfile for the relay-shell HTTP transport.
|
|
2
|
+
#
|
|
3
|
+
# relay-shell binds 127.0.0.1:8080 by design. Caddy terminates TLS via ACME
|
|
4
|
+
# (Let's Encrypt by default, ZeroSSL fallback), restricts the source to an
|
|
5
|
+
# explicit CIDR allowlist, sets security headers, and reverse proxies to the
|
|
6
|
+
# loopback MCP port.
|
|
7
|
+
#
|
|
8
|
+
# Automated provisioning and renewal are handled by Caddy's built-in ACME
|
|
9
|
+
# client; no cron, no certbot. Certificates persist under Caddy's data
|
|
10
|
+
# directory (default `/var/lib/caddy/.local/share/caddy/certificates`) and
|
|
11
|
+
# renew in the background well before expiry.
|
|
12
|
+
#
|
|
13
|
+
# This file is parameterized through environment variables so it can be
|
|
14
|
+
# installed unmodified by `deploy/install-edge.sh`:
|
|
15
|
+
#
|
|
16
|
+
# RELAY_SHELL_EDGE_DOMAIN Public hostname presented in the TLS cert
|
|
17
|
+
# (must have a DNS A/AAAA record pointing here)
|
|
18
|
+
# RELAY_SHELL_EDGE_ACME_EMAIL Contact email for ACME registration
|
|
19
|
+
# RELAY_SHELL_EDGE_CLIENT_CIDRS Space-separated allowlist for tool traffic
|
|
20
|
+
# and /token (defaults to loopback only)
|
|
21
|
+
# RELAY_SHELL_EDGE_UPSTREAM Loopback upstream (default 127.0.0.1:8080)
|
|
22
|
+
# RELAY_SHELL_EDGE_ACME_CA Optional ACME directory override
|
|
23
|
+
# (e.g. Let's Encrypt staging for dry runs)
|
|
24
|
+
#
|
|
25
|
+
# To stage against the Let's Encrypt staging CA before going live, set
|
|
26
|
+
# RELAY_SHELL_EDGE_ACME_CA=https://acme-staging-v02.api.letsencrypt.org/directory.
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
email {$RELAY_SHELL_EDGE_ACME_EMAIL}
|
|
30
|
+
acme_ca {$RELAY_SHELL_EDGE_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
{$RELAY_SHELL_EDGE_DOMAIN} {
|
|
34
|
+
# OAuth browser/redirect + discovery must be reachable for the auth
|
|
35
|
+
# flow; they still require a registered client and PKCE.
|
|
36
|
+
handle /authorize {
|
|
37
|
+
reverse_proxy {$RELAY_SHELL_EDGE_UPSTREAM:127.0.0.1:8080} {
|
|
38
|
+
header_up Host {upstream_hostport}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
handle /.well-known/oauth-authorization-server {
|
|
42
|
+
reverse_proxy {$RELAY_SHELL_EDGE_UPSTREAM:127.0.0.1:8080} {
|
|
43
|
+
header_up Host {upstream_hostport}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
handle /.well-known/oauth-protected-resource* {
|
|
47
|
+
reverse_proxy {$RELAY_SHELL_EDGE_UPSTREAM:127.0.0.1:8080} {
|
|
48
|
+
header_up Host {upstream_hostport}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Everything else (tool traffic, /token) is CIDR-restricted. Override
|
|
53
|
+
# RELAY_SHELL_EDGE_CLIENT_CIDRS to the source ranges of your MCP client.
|
|
54
|
+
@blocked not remote_ip {$RELAY_SHELL_EDGE_CLIENT_CIDRS:127.0.0.1/8 ::1}
|
|
55
|
+
handle @blocked {
|
|
56
|
+
respond "Forbidden" 403
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
reverse_proxy {$RELAY_SHELL_EDGE_UPSTREAM:127.0.0.1:8080} {
|
|
60
|
+
header_up Host {upstream_hostport}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
header {
|
|
64
|
+
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
|
65
|
+
X-Content-Type-Options "nosniff"
|
|
66
|
+
X-Frame-Options "DENY"
|
|
67
|
+
Referrer-Policy "no-referrer"
|
|
68
|
+
-Server
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
log {
|
|
72
|
+
output file /var/log/caddy/relay-shell-access.log {
|
|
73
|
+
roll_size 50MiB
|
|
74
|
+
roll_keep 5
|
|
75
|
+
}
|
|
76
|
+
format json
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# relay-shell edge installer - idempotent.
|
|
5
|
+
#
|
|
6
|
+
# Installs Caddy and lays down the relay-shell Caddyfile with automated TLS
|
|
7
|
+
# via ACME (Let's Encrypt by default, ZeroSSL fallback). Provisioning and
|
|
8
|
+
# renewal are handled by Caddy's built-in ACME client; there is no cron and
|
|
9
|
+
# no certbot. Certificates persist across restarts under Caddy's data dir.
|
|
10
|
+
#
|
|
11
|
+
# Required:
|
|
12
|
+
# RELAY_SHELL_EDGE_DOMAIN public hostname (DNS A/AAAA must point here)
|
|
13
|
+
# RELAY_SHELL_EDGE_ACME_EMAIL contact email for ACME registration
|
|
14
|
+
#
|
|
15
|
+
# Recommended:
|
|
16
|
+
# RELAY_SHELL_EDGE_CLIENT_CIDRS space-separated source allowlist for
|
|
17
|
+
# tool traffic and /token (defaults to
|
|
18
|
+
# loopback only, which blocks remote clients)
|
|
19
|
+
#
|
|
20
|
+
# Optional:
|
|
21
|
+
# RELAY_SHELL_EDGE_UPSTREAM loopback upstream (default 127.0.0.1:8080)
|
|
22
|
+
# RELAY_SHELL_EDGE_ACME_CA ACME directory override (e.g. LE staging)
|
|
23
|
+
# RELAY_SHELL_EDGE_OPEN_FIREWALL set to 1 to open 80/443 via ufw if present
|
|
24
|
+
# RELAY_SHELL_EDGE_DRY_RUN set to 1 to print the parameterized
|
|
25
|
+
# Caddyfile template and exit
|
|
26
|
+
# RELAY_SHELL_EDGE_FORCE set to 1 to overwrite an existing
|
|
27
|
+
# /etc/caddy/Caddyfile that this installer
|
|
28
|
+
# did not place (back it up first!)
|
|
29
|
+
#
|
|
30
|
+
# Location: deploy/install-edge.sh Run as: root (sudo)
|
|
31
|
+
|
|
32
|
+
SCRIPT_NAME="$(basename "$0")"
|
|
33
|
+
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
34
|
+
CADDYFILE_SRC="$SRC_DIR/Caddyfile"
|
|
35
|
+
CADDYFILE_DST="/etc/caddy/Caddyfile"
|
|
36
|
+
ENV_DROPIN_DIR="/etc/systemd/system/caddy.service.d"
|
|
37
|
+
ENV_DROPIN="$ENV_DROPIN_DIR/relay-shell-edge.conf"
|
|
38
|
+
EDGE_ENV_FILE="/etc/relay-shell/relay-shell-edge.env"
|
|
39
|
+
OPERATOR_ENV_FILE="/etc/relay-shell/relay-shell.env"
|
|
40
|
+
|
|
41
|
+
log() { echo "[$(date -Iseconds)] [$SCRIPT_NAME] $*"; }
|
|
42
|
+
warn() { log "WARN: $*" >&2; }
|
|
43
|
+
die() { log "FATAL: $*" >&2; exit 1; }
|
|
44
|
+
|
|
45
|
+
require_var() {
|
|
46
|
+
local name="$1"
|
|
47
|
+
local val="${!name:-}"
|
|
48
|
+
[ -n "$val" ] || die "$name is required (export it before running, or set it in $OPERATOR_ENV_FILE)"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Parse a systemd-style EnvironmentFile safely. Unlike `source`, this does
|
|
52
|
+
# not execute the file, so values containing spaces, shell metacharacters,
|
|
53
|
+
# or unbalanced quotes cannot crash or hijack the installer. Only keys
|
|
54
|
+
# matching RELAY_SHELL_EDGE_* are exported.
|
|
55
|
+
load_edge_env() {
|
|
56
|
+
local file="$1" line key val
|
|
57
|
+
[ -r "$file" ] || return 0
|
|
58
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
59
|
+
case "$line" in
|
|
60
|
+
''|\#*) continue ;;
|
|
61
|
+
esac
|
|
62
|
+
line="${line#export }"
|
|
63
|
+
case "$line" in
|
|
64
|
+
*=*) ;;
|
|
65
|
+
*) continue ;;
|
|
66
|
+
esac
|
|
67
|
+
key="${line%%=*}"
|
|
68
|
+
val="${line#*=}"
|
|
69
|
+
case "$key" in
|
|
70
|
+
RELAY_SHELL_EDGE_*) ;;
|
|
71
|
+
*) continue ;;
|
|
72
|
+
esac
|
|
73
|
+
# Strip a single pair of surrounding double or single quotes if
|
|
74
|
+
# present (systemd permits, but does not require, them).
|
|
75
|
+
case "$val" in
|
|
76
|
+
\"*\") val="${val#\"}"; val="${val%\"}" ;;
|
|
77
|
+
\'*\') val="${val#\'}"; val="${val%\'}" ;;
|
|
78
|
+
esac
|
|
79
|
+
# Reject control characters and embedded newlines (the latter cannot
|
|
80
|
+
# appear in a single read line, but be explicit).
|
|
81
|
+
case "$val" in
|
|
82
|
+
*[$'\n\r']*) die "value for $key in $file contains a newline" ;;
|
|
83
|
+
esac
|
|
84
|
+
export "$key=$val"
|
|
85
|
+
done < "$file"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
load_edge_env "$OPERATOR_ENV_FILE"
|
|
89
|
+
|
|
90
|
+
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
91
|
+
[ -r "$CADDYFILE_SRC" ] || die "Caddyfile template not found at $CADDYFILE_SRC"
|
|
92
|
+
|
|
93
|
+
require_var RELAY_SHELL_EDGE_DOMAIN
|
|
94
|
+
require_var RELAY_SHELL_EDGE_ACME_EMAIL
|
|
95
|
+
|
|
96
|
+
: "${RELAY_SHELL_EDGE_UPSTREAM:=127.0.0.1:8080}"
|
|
97
|
+
: "${RELAY_SHELL_EDGE_CLIENT_CIDRS:=127.0.0.1/8 ::1}"
|
|
98
|
+
: "${RELAY_SHELL_EDGE_ACME_CA:=https://acme-v02.api.letsencrypt.org/directory}"
|
|
99
|
+
|
|
100
|
+
# Reject any value containing characters that would corrupt the systemd
|
|
101
|
+
# EnvironmentFile we write below (newlines were caught above; reject NULs
|
|
102
|
+
# and stray quotes that would unbalance the file).
|
|
103
|
+
for var in RELAY_SHELL_EDGE_DOMAIN RELAY_SHELL_EDGE_ACME_EMAIL \
|
|
104
|
+
RELAY_SHELL_EDGE_ACME_CA RELAY_SHELL_EDGE_UPSTREAM \
|
|
105
|
+
RELAY_SHELL_EDGE_CLIENT_CIDRS; do
|
|
106
|
+
case "${!var}" in
|
|
107
|
+
*[$'\n\r\0']*) die "$var contains a control character" ;;
|
|
108
|
+
esac
|
|
109
|
+
done
|
|
110
|
+
|
|
111
|
+
log "Edge domain : $RELAY_SHELL_EDGE_DOMAIN"
|
|
112
|
+
log "ACME email : $RELAY_SHELL_EDGE_ACME_EMAIL"
|
|
113
|
+
log "ACME CA : $RELAY_SHELL_EDGE_ACME_CA"
|
|
114
|
+
log "Upstream : $RELAY_SHELL_EDGE_UPSTREAM"
|
|
115
|
+
log "Client CIDRs: $RELAY_SHELL_EDGE_CLIENT_CIDRS"
|
|
116
|
+
|
|
117
|
+
if [ "${RELAY_SHELL_EDGE_CLIENT_CIDRS}" = "127.0.0.1/8 ::1" ]; then
|
|
118
|
+
warn "client CIDR allowlist is loopback only - remote clients will be 403'd until you set RELAY_SHELL_EDGE_CLIENT_CIDRS"
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
if [ "${RELAY_SHELL_EDGE_DRY_RUN:-0}" = "1" ]; then
|
|
122
|
+
log "Dry run - printing the parameterized Caddyfile template below."
|
|
123
|
+
log "Caddy substitutes {\$RELAY_SHELL_EDGE_*} at service start using the values logged above."
|
|
124
|
+
cat "$CADDYFILE_SRC"
|
|
125
|
+
exit 0
|
|
126
|
+
fi
|
|
127
|
+
|
|
128
|
+
if ! command -v caddy >/dev/null 2>&1; then
|
|
129
|
+
log "Installing Caddy from the official apt repository"
|
|
130
|
+
if ! command -v apt-get >/dev/null 2>&1; then
|
|
131
|
+
die "no caddy binary and apt-get unavailable - install Caddy manually (see https://caddyserver.com/docs/install) and re-run"
|
|
132
|
+
fi
|
|
133
|
+
apt-get update -qq
|
|
134
|
+
apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl gnupg
|
|
135
|
+
install -d -m 0755 /etc/apt/keyrings
|
|
136
|
+
if [ ! -f /etc/apt/keyrings/caddy-stable-archive-keyring.gpg ]; then
|
|
137
|
+
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
|
|
138
|
+
| gpg --dearmor -o /etc/apt/keyrings/caddy-stable-archive-keyring.gpg
|
|
139
|
+
chmod 0644 /etc/apt/keyrings/caddy-stable-archive-keyring.gpg
|
|
140
|
+
fi
|
|
141
|
+
if [ ! -f /etc/apt/sources.list.d/caddy-stable.list ]; then
|
|
142
|
+
cat >/etc/apt/sources.list.d/caddy-stable.list <<'REPO'
|
|
143
|
+
deb [signed-by=/etc/apt/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main
|
|
144
|
+
deb-src [signed-by=/etc/apt/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main
|
|
145
|
+
REPO
|
|
146
|
+
fi
|
|
147
|
+
apt-get update -qq
|
|
148
|
+
apt-get install -y -qq caddy
|
|
149
|
+
else
|
|
150
|
+
log "Caddy already installed: $(caddy version 2>/dev/null | head -1)"
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
# The rest of the installer assumes a systemd-managed caddy.service (the
|
|
154
|
+
# official apt package ships one). A binary installed by hand may not. Bail
|
|
155
|
+
# out early with an actionable message instead of failing inside `systemctl`.
|
|
156
|
+
if ! systemctl list-unit-files caddy.service >/dev/null 2>&1 \
|
|
157
|
+
|| ! systemctl list-unit-files caddy.service 2>/dev/null | grep -q '^caddy\.service'; then
|
|
158
|
+
die "caddy binary is present but no caddy.service systemd unit was found.
|
|
159
|
+
Install the official apt package (this script does that automatically
|
|
160
|
+
when caddy is missing) or provide your own caddy.service unit before
|
|
161
|
+
re-running. See https://caddyserver.com/docs/install."
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
install -d -m 0755 /etc/caddy
|
|
165
|
+
install -d -m 0750 -o caddy -g caddy /var/log/caddy 2>/dev/null || install -d -m 0755 /var/log/caddy
|
|
166
|
+
|
|
167
|
+
# A magic marker on the first non-comment line lets us recognize a Caddyfile
|
|
168
|
+
# this installer owns vs. one a human (or another tool) has placed there for
|
|
169
|
+
# unrelated sites. Refusing to clobber the latter prevents an outage when
|
|
170
|
+
# this is run on a host that already serves other vhosts via Caddy.
|
|
171
|
+
MANAGED_MARKER="# relay-shell:install-edge:managed"
|
|
172
|
+
RELAY_SHELL_EDGE_FORCE="${RELAY_SHELL_EDGE_FORCE:-0}"
|
|
173
|
+
|
|
174
|
+
if [ -e "$CADDYFILE_DST" ] && [ "$RELAY_SHELL_EDGE_FORCE" != "1" ]; then
|
|
175
|
+
if ! head -n 5 "$CADDYFILE_DST" | grep -qF "$MANAGED_MARKER"; then
|
|
176
|
+
die "$CADDYFILE_DST exists and was not written by this installer.
|
|
177
|
+
Refusing to overwrite a Caddyfile that may serve other sites.
|
|
178
|
+
Options:
|
|
179
|
+
- merge the contents of $CADDYFILE_SRC into $CADDYFILE_DST by hand
|
|
180
|
+
(it is a single site block scoped to \$RELAY_SHELL_EDGE_DOMAIN), or
|
|
181
|
+
- back up the existing file and re-run with RELAY_SHELL_EDGE_FORCE=1
|
|
182
|
+
to replace it."
|
|
183
|
+
fi
|
|
184
|
+
fi
|
|
185
|
+
|
|
186
|
+
log "Installing $CADDYFILE_DST"
|
|
187
|
+
# Prepend the ownership marker so a future run recognizes its own file.
|
|
188
|
+
{
|
|
189
|
+
echo "$MANAGED_MARKER"
|
|
190
|
+
cat "$CADDYFILE_SRC"
|
|
191
|
+
} > "$CADDYFILE_DST.tmp"
|
|
192
|
+
chmod 0644 "$CADDYFILE_DST.tmp"
|
|
193
|
+
mv "$CADDYFILE_DST.tmp" "$CADDYFILE_DST"
|
|
194
|
+
|
|
195
|
+
# Write a dedicated systemd EnvironmentFile rather than inlining values into
|
|
196
|
+
# the drop-in. Keeps the drop-in static and avoids `%`-specifier expansion
|
|
197
|
+
# that systemd applies inside Environment= assignments.
|
|
198
|
+
#
|
|
199
|
+
# Note on quoting: systemd's EnvironmentFile parser treats whitespace inside
|
|
200
|
+
# an unquoted value as a separator for additional KEY=VALUE pairs on the
|
|
201
|
+
# same line, which would silently truncate RELAY_SHELL_EDGE_CLIENT_CIDRS to
|
|
202
|
+
# the first CIDR. Emit every value double-quoted, with embedded double
|
|
203
|
+
# quotes escaped, so multi-token values survive intact.
|
|
204
|
+
emit_env() {
|
|
205
|
+
local key="$1" val="$2"
|
|
206
|
+
# Escape backslashes first, then double quotes.
|
|
207
|
+
val="${val//\\/\\\\}"
|
|
208
|
+
val="${val//\"/\\\"}"
|
|
209
|
+
printf '%s="%s"\n' "$key" "$val"
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
log "Installing edge env file at $EDGE_ENV_FILE"
|
|
213
|
+
install -d -m 0755 /etc/relay-shell
|
|
214
|
+
umask 077
|
|
215
|
+
{
|
|
216
|
+
echo "# Managed by relay-shell deploy/install-edge.sh - do not hand-edit."
|
|
217
|
+
echo "# Update $OPERATOR_ENV_FILE and re-run the installer instead."
|
|
218
|
+
emit_env RELAY_SHELL_EDGE_DOMAIN "$RELAY_SHELL_EDGE_DOMAIN"
|
|
219
|
+
emit_env RELAY_SHELL_EDGE_ACME_EMAIL "$RELAY_SHELL_EDGE_ACME_EMAIL"
|
|
220
|
+
emit_env RELAY_SHELL_EDGE_ACME_CA "$RELAY_SHELL_EDGE_ACME_CA"
|
|
221
|
+
emit_env RELAY_SHELL_EDGE_UPSTREAM "$RELAY_SHELL_EDGE_UPSTREAM"
|
|
222
|
+
emit_env RELAY_SHELL_EDGE_CLIENT_CIDRS "$RELAY_SHELL_EDGE_CLIENT_CIDRS"
|
|
223
|
+
} > "$EDGE_ENV_FILE"
|
|
224
|
+
# Not secret, but still security-relevant edge configuration: keep it
|
|
225
|
+
# root-owned and group-readable by caddy (which needs to read it for the
|
|
226
|
+
# drop-in's EnvironmentFile=), not world-readable.
|
|
227
|
+
if getent group caddy >/dev/null 2>&1; then
|
|
228
|
+
chown root:caddy "$EDGE_ENV_FILE"
|
|
229
|
+
chmod 0640 "$EDGE_ENV_FILE"
|
|
230
|
+
else
|
|
231
|
+
chmod 0600 "$EDGE_ENV_FILE"
|
|
232
|
+
warn "no 'caddy' group found; $EDGE_ENV_FILE is root-only - caddy may fail to read it"
|
|
233
|
+
fi
|
|
234
|
+
umask 022
|
|
235
|
+
|
|
236
|
+
log "Installing systemd environment drop-in at $ENV_DROPIN"
|
|
237
|
+
install -d -m 0755 "$ENV_DROPIN_DIR"
|
|
238
|
+
cat >"$ENV_DROPIN" <<EOF
|
|
239
|
+
# Managed by relay-shell deploy/install-edge.sh.
|
|
240
|
+
# Values live in $EDGE_ENV_FILE so unit syntax is not affected by user input.
|
|
241
|
+
[Service]
|
|
242
|
+
EnvironmentFile=$EDGE_ENV_FILE
|
|
243
|
+
EOF
|
|
244
|
+
chmod 0644 "$ENV_DROPIN"
|
|
245
|
+
|
|
246
|
+
log "Validating Caddyfile syntax"
|
|
247
|
+
# `caddy validate` reads the same env Caddy will see at start, so export here.
|
|
248
|
+
export RELAY_SHELL_EDGE_DOMAIN RELAY_SHELL_EDGE_ACME_EMAIL RELAY_SHELL_EDGE_ACME_CA \
|
|
249
|
+
RELAY_SHELL_EDGE_UPSTREAM RELAY_SHELL_EDGE_CLIENT_CIDRS
|
|
250
|
+
caddy validate --config "$CADDYFILE_DST" --adapter caddyfile
|
|
251
|
+
|
|
252
|
+
if [ "${RELAY_SHELL_EDGE_OPEN_FIREWALL:-0}" = "1" ] && command -v ufw >/dev/null 2>&1; then
|
|
253
|
+
log "Opening 80/tcp and 443/tcp via ufw"
|
|
254
|
+
ufw allow 80/tcp >/dev/null || warn "ufw allow 80/tcp failed"
|
|
255
|
+
ufw allow 443/tcp >/dev/null || warn "ufw allow 443/tcp failed"
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
systemctl daemon-reload
|
|
259
|
+
log "Enabling and (re)starting caddy"
|
|
260
|
+
systemctl enable caddy >/dev/null
|
|
261
|
+
systemctl restart caddy
|
|
262
|
+
|
|
263
|
+
# Give Caddy a brief moment to settle, then report.
|
|
264
|
+
sleep 1
|
|
265
|
+
if systemctl is-active --quiet caddy; then
|
|
266
|
+
log "Caddy is active. Initial certificate issuance happens on the first HTTPS request to ${RELAY_SHELL_EDGE_DOMAIN}."
|
|
267
|
+
log "Watch progress with: journalctl -u caddy -f"
|
|
268
|
+
else
|
|
269
|
+
die "caddy failed to start - check 'journalctl -u caddy -n 100'"
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
log "Done. Verify the cert with: curl -I https://${RELAY_SHELL_EDGE_DOMAIN}/.well-known/oauth-protected-resource"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# relay-shell installer - idempotent. Creates a dedicated service account and venv,
|
|
5
|
+
# installs the package, and lays down the systemd/logrotate assets. It does
|
|
6
|
+
# NOT enable or start the service: review the unit and configuration first.
|
|
7
|
+
#
|
|
8
|
+
# Location: deploy/install.sh Run as: root (sudo)
|
|
9
|
+
|
|
10
|
+
SCRIPT_NAME="$(basename "$0")"
|
|
11
|
+
PREFIX="/var/lib/relay-shell"
|
|
12
|
+
SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
13
|
+
|
|
14
|
+
log() { echo "[$(date -Iseconds)] [$SCRIPT_NAME] $*"; }
|
|
15
|
+
die() { log "FATAL: $*" >&2; exit 1; }
|
|
16
|
+
|
|
17
|
+
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
18
|
+
|
|
19
|
+
log "Ensuring service account 'relay-shell'"
|
|
20
|
+
if ! id relay-shell >/dev/null 2>&1; then
|
|
21
|
+
useradd --system --create-home --home-dir "$PREFIX" \
|
|
22
|
+
--shell /usr/sbin/nologin relay-shell
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
log "Creating venv at $PREFIX/venv"
|
|
26
|
+
if [ ! -x "$PREFIX/venv/bin/python" ]; then
|
|
27
|
+
sudo -u relay-shell python3 -m venv "$PREFIX/venv"
|
|
28
|
+
fi
|
|
29
|
+
sudo -u relay-shell "$PREFIX/venv/bin/pip" install --quiet --upgrade pip
|
|
30
|
+
sudo -u relay-shell "$PREFIX/venv/bin/pip" install --quiet "$SRC_DIR"
|
|
31
|
+
|
|
32
|
+
log "Audit log directory"
|
|
33
|
+
mkdir -p /var/log/relay-shell
|
|
34
|
+
chown relay-shell:relay-shell /var/log/relay-shell
|
|
35
|
+
if [ ! -e /var/log/relay-shell/audit.jsonl ]; then
|
|
36
|
+
install -o relay-shell -g relay-shell -m 0600 /dev/null /var/log/relay-shell/audit.jsonl
|
|
37
|
+
fi
|
|
38
|
+
chattr +a /var/log/relay-shell/audit.jsonl 2>/dev/null || \
|
|
39
|
+
log "WARN: could not set append-only (non-ext filesystem?)"
|
|
40
|
+
|
|
41
|
+
log "Installing systemd unit + hardening drop-in"
|
|
42
|
+
install -m 0644 "$SRC_DIR/deploy/systemd/relay-shell.service" /etc/systemd/system/relay-shell.service
|
|
43
|
+
mkdir -p /etc/systemd/system/relay-shell.service.d
|
|
44
|
+
install -m 0644 "$SRC_DIR/deploy/systemd/relay-shell.service.d/hardening.conf" \
|
|
45
|
+
/etc/systemd/system/relay-shell.service.d/hardening.conf
|
|
46
|
+
systemctl daemon-reload
|
|
47
|
+
|
|
48
|
+
log "Installing logrotate config"
|
|
49
|
+
install -m 0644 "$SRC_DIR/deploy/logrotate/relay-shell" /etc/logrotate.d/relay-shell
|
|
50
|
+
|
|
51
|
+
mkdir -p /etc/relay-shell
|
|
52
|
+
[ -e /etc/relay-shell/relay-shell.env ] || {
|
|
53
|
+
install -m 0640 -o root -g relay-shell "$SRC_DIR/.env.example" /etc/relay-shell/relay-shell.env
|
|
54
|
+
log "Wrote /etc/relay-shell/relay-shell.env from .env.example - EDIT IT before starting"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log "Done. Review /etc/relay-shell/relay-shell.env and the unit, then:"
|
|
58
|
+
log " systemctl enable --now relay-shell"
|
|
59
|
+
log " (HTTP transport: put deploy/Caddyfile in front; restrict the CIDRs)"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# /etc/logrotate.d/relay-shell
|
|
2
|
+
#
|
|
3
|
+
# The audit log is append-only on disk (chattr +a). The append-only attribute
|
|
4
|
+
# must be dropped only for the rotate itself and restored immediately, so a
|
|
5
|
+
# compromised process cannot rewrite history through the rotation window.
|
|
6
|
+
|
|
7
|
+
/var/log/relay-shell/audit.jsonl {
|
|
8
|
+
daily
|
|
9
|
+
rotate 90
|
|
10
|
+
missingok
|
|
11
|
+
notifempty
|
|
12
|
+
compress
|
|
13
|
+
delaycompress
|
|
14
|
+
create 0600 relay-shell relay-shell
|
|
15
|
+
dateext
|
|
16
|
+
prerotate
|
|
17
|
+
/usr/bin/chattr -a /var/log/relay-shell/audit.jsonl 2>/dev/null || true
|
|
18
|
+
endscript
|
|
19
|
+
postrotate
|
|
20
|
+
/usr/bin/chattr +a /var/log/relay-shell/audit.jsonl 2>/dev/null || true
|
|
21
|
+
endscript
|
|
22
|
+
}
|