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,7 @@
1
+ """relay_shell - a reliable, capable MCP server for shell and SSH operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = "0.1.0"
@@ -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
+ }