Deployment¶
relay-shell grants real administrative power. The most important control is
where and as whom it runs. This guide describes a production-grade
deployment modeled on a mature MCP gateway.
Qualified host profiles are Ubuntu 24.04 LTS / Python 3.12 and Ubuntu 26.04 LTS /
Python 3.14. CI tests both OS/interpreter pairs and rehearses the actual installer
and recovery on each. Python 3.13 has no maintained installer lock. Preserve an
existing installation for rollback and rebuild its venv with a qualified interpreter;
do not replace the interpreter in place. Package metadata's >=3.12 floor does not
qualify other host profiles.
0. Pre-flight checklist¶
Complete this list before §1 (service account creation). Each item is a precondition for the matching later section.
- [ ] Service account name decided. The default is
relay-shellwith home directory/var/lib/relay-shell(§1). Pick a different name only if your host conventions require it; update the systemd unit + logrotate config +chownlines below accordingly. - [ ] Audit directory writable by the service account.
/var/log/relay-shell/exists, is owned by the service account, and the filesystem supportschattr +a(ext2/3/4, xfs). Append-only on atmpfsor fuse mount silently degrades to a normal write; confirm withlsattrafterchattr +a. - [ ] DNS resolves for the edge domain (HTTP transport only).
dig +short ${RELAY_SHELL_EDGE_DOMAIN}returns the host's public IP. The HTTP-01 ACME challenge does not work without this. - [ ] Ports 80 and 443 reachable from the internet (HTTP transport
only). Port 80 is required for HTTP-01; closing it blocks both
issuance and renewal. If a host firewall is in front, open
it before running
install-edge.sh(or setRELAY_SHELL_EDGE_OPEN_FIREWALL=1to have the installer try). - [ ] SSH service account keypair generated (
ssh-keygen -t ed25519as the service account) andknown_hostsstrategy chosen (strictfor production, pre-populated withssh-keyscan; see §7). - [ ] Off-host audit shipping target ready (Vector / Fluent Bit /
journal-remote; see
docs/audit-shipper.md). An on-host audit file is evidence only until the host is compromised; the shipper must be in place before the unit is enabled in production.
Fix any unchecked item before continuing. The install is idempotent, but recovering from a half-configured edge or a non-shipping audit log costs more than the precondition.
1. Service account¶
Default recommendation: run as a dedicated unprivileged user, never a human's account.
sudo useradd --system --create-home --home-dir /var/lib/relay-shell \
--shell /usr/sbin/nologin relay-shell
A full-root administrator deployment requires an explicitly provisioned,
noninteractive sudo grant for arbitrary commands, normally
relay-shell ALL=(ALL:ALL) NOPASSWD: ALL. Keep that declaration root-owned,
mode 0440, and validate it with visudo and an effective sudo probe. The generic
package installer does not silently grant new host privileges: deployment
configuration owns the authorization, including grants on remote hosts.
Run the daemon under its dedicated identity and elevate individual commands. Treat an administrator relay and its authenticated clients as a host-control plane, not as an application sandbox. Command-scoped deployments remain possible but must not be described or validated as full-root administrators. Separate job-specific SSH keys and application identities may retain their own boundaries.
2. Install¶
sudo -u relay-shell python3 -m venv /var/lib/relay-shell/venv
sudo -u relay-shell /var/lib/relay-shell/venv/bin/pip install --upgrade pip
sudo -u relay-shell /var/lib/relay-shell/venv/bin/pip install \
-c /path/to/relay-shell/requirements.txt /path/to/relay-shell # or: pip install relay-shell
deploy/install.sh does this idempotently (it installs against requirements.txt when the
checkout has one, and saves a copy of any unit, drop-in or logrotate file you edited under
/var/lib/relay-shell/preinstall-backups/ before replacing it). It deliberately does not
auto-start the service; review the unit and configuration first.
Validate the resolved configuration against the service account's environment before enabling the unit:
sudo -u relay-shell \
--preserve-env=RELAY_SHELL_AUDIT_PATH,RELAY_SHELL_TRANSPORT,RELAY_SHELL_POLICY_MODE \
/var/lib/relay-shell/venv/bin/relay-shell --check-config
--check-config loads the settings, builds the server (audit sink open,
ssh_config + inventory parse, OAuth state dir creation if auth_enabled=true)
without starting a transport, and exits 0 on success or 2 on any
initialization failure, including a degraded audit sink (the most common
production misconfiguration). Wire it into the image-bake step of your CI pipeline.
3. systemd¶
deploy/systemd/relay-shell.service plus the relay-shell.service.d/hardening.conf
drop-in. This is a resource envelope, not a containment boundary. Resource
caps (MemoryMax, CPUQuota, TasksMax), restart limits and umask 0077 remain.
The host capability set and filesystem view are preserved explicitly. In
particular, ProtectClock, ProtectKernelLogs and ProtectKernelModules remove
capabilities even from later sudo processes; PrivateTmp creates a separate
mount namespace. They are disabled for the administrator execution contract.
Authentication, auditing and tool admission policy remain unchanged. Optional
systemd credentials are independent of these settings.
sudo cp deploy/systemd/relay-shell.service /etc/systemd/system/
sudo mkdir -p /etc/systemd/system/relay-shell.service.d
sudo cp deploy/systemd/relay-shell.service.d/hardening.conf /etc/systemd/system/relay-shell.service.d/
sudo systemctl daemon-reload
sudo systemctl enable --now relay-shell
3a. Host integrity + config-drift monitoring (recommended)¶
The systemd confinement above is deliberately partial (ADR 0002): the executor
is unsandboxed by design, so the deploy host cannot prevent the service
account from touching the filesystem, and in privileged posture (root/sudo) it
can touch /etc, the unit files, even the relay binary. Where prevention is
intentionally absent, detection is the compensating control, as the audit
chain (§6a) is for the log. These are host-admin tools, not part of the relay;
configure them on the host, outside the service.
etckeeper: put/etcunder version control (git + a package-manager hook) so every config change is diffable and revertable. A privileged edit through the relay of/etc/sudoers.d, a systemd unit, orsshd_configthen shows up as a committed diff instead of a silent mutation. Pair with a periodicetckeeper vcs logreview or an off-host push of the/etcrepo.AIDE(or Tripwire): a file-integrity baseline over the things that should not change between deploys: the relay binary / venv (/var/lib/relay-shell/venv), the systemd unit +hardening.confdrop-in, theEnvironmentFileunder/etc/relay-shell/, and the OAuth state dir's permissions. A timer-drivenaide --checkalerts on an unexpected change (a swapped binary, a loosened unit, an altered drop-in). Exclude the live audit log from the baseline (it grows by design); its integrity is the §6a hash chain's job, not AIDE's. Re-baseline (aide --update) as part of the release procedure so a legitimate upgrade is not a false positive.fail2ban: watchsshdand the TLS edge (repeated Caddy 401/403 from outside the CIDR allowlist, §4) and ban brute-force sources. The relay's own SSH is outbound, but the host still runssshdfor admin and exposes the HTTP edge; fail2ban bounds credential-guessing against both.lynis: a periodic host hardening audit (run on a timer, track the score across runs). It surfaces drift in the host's own posture (kernel params, PAM, file perms) that sits underneath everything the relay depends on.
None of these is installed by deploy/; host hardening is the operator's
domain and the right tool set varies by distro and fleet-management stack. An
unsandboxed, possibly-privileged service raises the value of host-level
integrity and config-drift monitoring, so pair the in-app controls (audit chain,
redaction, tiered policy) with host-level detection.
4. Network edge (HTTP transport)¶
The HTTP transport binds RELAY_SHELL_HTTP_HOST:RELAY_SHELL_HTTP_PORT, which
defaults to 127.0.0.1:8080 (loopback by design), so a reverse proxy (below)
is the only thing that should face the network. You can retarget the bind (a
different loopback port, a container's internal interface), but do not bind
a non-loopback address without the edge protections here: the service stands up
no TLS or source-IP filtering of its own. Terminate TLS and restrict by source
IP at a reverse proxy. deploy/Caddyfile is shipped parameterized through
environment variables and provides:
- automatic TLS via ACME from the configured directory (
RELAY_SHELL_EDGE_ACME_CA, Let's Encrypt production by default), - an
@blockedmatcher that 403s any source outside the allowlisted CIDRs, - HSTS /
X-Content-Type-Options/X-Frame-Options/Referrer-Policy/Content-Security-Policy(default-src 'self'; frame-ancestors 'none'; base-uri 'none'), and theServerheader removed, - a 64 KB request-body cap on the public OAuth endpoints (the MCP path is capped by the
relay itself,
RELAY_SHELL_MAX_INPUT), - an access log that drops the
Locationresponse header, which carries the authorization code on the/authorizeredirect, reverse_proxyto the loopback MCP port.
Set the allowlist to the CIDRs of your MCP client only. The OAuth browser
endpoints (/authorize, /.well-known/oauth-authorization-server,
/.well-known/oauth-protected-resource*, /register, /token, /revoke)
are publicly reachable for the OAuth flow; every other path, other /.well-known/ paths
included, is CIDR-restricted. MCP tool traffic is CIDR-restricted,
including when upstream OAuth is disabled. The default loopback-only allowlist
denies remote MCP clients with 403. Enabling OAuth does not bypass this gate;
allowlist the client's source CIDRs. Do not use an all-address allowlist while
upstream authentication is disabled.
Defense in depth: a host firewall (only 80/443 inbound), the proxy CIDR matcher, OAuth 2.1, then the policy/audit layer.
Site-specific public /mcp exception (not the default template)¶
A deployment may deliberately place handle /mcp before the @blocked
CIDR matcher to support OAuth-authenticated clients without fixed source IPs.
This is a material exposure change: any Internet source can reach the relay's
MCP authentication boundary. It is not implemented by the shipped template
or install-edge.sh, and relay-shell --verify-deploy must report Caddyfile
drift for such a deployment. Treat the deviation as an explicitly owned,
reviewed site override, not as a reason to disable drift checks or copy the
exception into all installations.
Never use this exception with the built-in public-client flow
(token_endpoint_auth_method=none): client approval is not user authentication,
and the built-in /authorize flow has no login/consent step (see docs/auth.md).
Require an independently authenticating identity-aware proxy/IdP, or verified
confidential-client authentication with protected client secrets, before
allowing Internet reachability of /mcp. Treat approval of a public client
or loss of the stronger authentication boundary as an immediate revocation or
route-closure trigger. Reapprove the exception at least every 90 days and
whenever the OAuth configuration, approved clients, reverse proxy, or threat
model changes. Identify an owner and record an expiry date in the operational
change record; do not assume this documentation grants blanket approval.
Before retaining or reapplying this override, independently verify that OAuth
is enabled at the actual upstream, that unauthenticated and malformed bearer
requests to the public /mcp route receive HTTP 401, that a valid client
credential is accepted only with the intended audience/scopes, and that
revoked/expired/wrong-audience tokens fail. Check both IPv4 and IPv6 entry
paths and confirm the public discovery endpoints remain reachable. HTTP 401
alone proves only rejection; it does not establish authenticated tool
authorization. Do not put bearer tokens in terminal history, proxy access
logs, CI artifacts, or issue reports. Use short-lived credentials from a
protected test client for positive-path checks.
Back up the live Caddyfile and the associated EnvironmentFile before changing
this exception. Validate a candidate with the same environment as the service,
then reload Caddy and retest the negative and positive authentication paths.
Restoring the previous files and reloading Caddy is the configuration rollback.
A non-disruptive rollback-path rehearsal on 2026-10-22 used an isolated Caddy
instance derived from the shipped restricted template. With a loopback-only
test listener, temporary log path, disabled ACME/admin endpoints, and a test
allowlist excluding the request source, /mcp returned HTTP 403 before any
upstream authentication. This verifies the restricted template's denial
mechanism, not a live reload/rollback or public-origin network path.
Do not run the edge installer over an approved site override without first
reconciling the intended route: its managed template is CIDR-restricted.
After approval, establish a separately protected checksum/config-management
baseline of the effective Caddyfile, including the route exception, and
alert on any deviation from that approved baseline. On the currently assessed
relay host, a root-owned checksum is stored at
/var/lib/relay-shell/evidence/caddy-site-baseline/Caddyfile.sha256 and
relay-caddy-drift-check.timer invokes a read-only hourly systemd service.
Check it with systemctl status relay-caddy-drift-check.timer and
systemctl start relay-caddy-drift-check.service, then inspect its journal.
The check detects changes but does not reload Caddy, rotate the baseline or
provide external alert delivery; configure notification routing separately.
The baseline is a recorded observation, not approval of the exception. The stock
--verify-deploy will continue to report DRIFT against the canonical
CIDR-restricted template; do not suppress that check without substituting a
site-specific check that detects subsequent unauthorized changes. Keep the
stock drift report as a documented exception until equivalent verification is
operational. Preserve the previous config and test rollback on a candidate:
restore the CIDR-restricted route, validate under the actual Caddy service
environment, and verify a source outside the allowlist receives HTTP 403
(or another explicit edge denial) for /mcp, not merely HTTP 401 from the
upstream authentication handler. Then restore the intended approved candidate
without disturbing the live service during rehearsal. An inability to prove
route closure blocks approval of the exception.
4a. Automated TLS (Caddy + Let's Encrypt)¶
deploy/install-edge.sh is the supported turnkey path. It installs Caddy
from the official apt repository (if missing), validates the parameterized
Caddyfile as a candidate and only then replaces the live one (keeping the previous file as
/etc/caddy/Caddyfile.bak; a candidate that fails validation leaves the live file
untouched), writes a systemd environment drop-in, and starts the service. Renewal is driven by Caddy's built-in ACME
scheduler - no cron, no certbot.
Prerequisites: a public DNS A/AAAA record for the chosen hostname pointing at this host, and TCP/80 + TCP/443 reachable from the internet (port 80 is required for the HTTP-01 challenge).
# Edit /etc/relay-shell/relay-shell.env (or export inline; a value in the env file
# overrides an exported one) and set at minimum:
# RELAY_SHELL_EDGE_DOMAIN=relay-shell.example.org
# RELAY_SHELL_EDGE_ACME_EMAIL=admin@example.org
# RELAY_SHELL_EDGE_CLIENT_CIDRS="203.0.113.0/24 198.51.100.0/24"
sudo deploy/install-edge.sh
| Variable | Purpose |
|---|---|
RELAY_SHELL_EDGE_DOMAIN |
Public hostname presented in the TLS certificate. |
RELAY_SHELL_EDGE_ACME_EMAIL |
Contact email for the ACME account. |
RELAY_SHELL_EDGE_CLIENT_CIDRS |
Space-separated source CIDR allowlist (defaults to loopback only - remote clients get 403 until you set this). |
RELAY_SHELL_EDGE_UPSTREAM |
Loopback target (default 127.0.0.1:8080). |
RELAY_SHELL_EDGE_ACME_CA |
ACME directory override; set to https://acme-staging-v02.api.letsencrypt.org/directory for dry runs against LE staging. |
RELAY_SHELL_EDGE_OPEN_FIREWALL |
Set to 1 to ufw allow 80,443/tcp if ufw is present. |
RELAY_SHELL_EDGE_VALIDATE_ONLY |
Set to 1 to check every value above against a strict shape (fully qualified domain, user@host email, https:// ACME URL, host:port upstream with a valid port, IPv4/IPv6 addresses or CIDRs) and exit without needing root. The installer always runs this check first: a }, quote or space in a value would otherwise change the Caddyfile or the systemd environment file. A 0.0.0.0/0 or ::/0 CIDR is accepted but warned about, because it removes the source gate. |
RELAY_SHELL_EDGE_DRY_RUN |
Set to 1 to log the resolved values and print the parameterized Caddyfile template, then exit without installing. Caddy substitutes the {$RELAY_SHELL_EDGE_*} placeholders at service start. |
RELAY_SHELL_EDGE_FORCE |
Set to 1 to overwrite an existing /etc/caddy/Caddyfile that this installer did not write. Without it, the installer refuses to clobber a Caddyfile that may serve other sites on the host. |
RELAY_SHELL_EDGE_CADDY_GPG_FPR |
Pin the Caddy apt repo signing key: the installer fails closed if the fetched key's fingerprint does not match (DEP-1). The key is fetched over TLS but otherwise trust-on-first-use; the installer always logs the observed fingerprint, so set this to that value (after confirming it at https://caddyserver.com/docs/install) to enforce it. Unset = unpinned, with a warning. Only used when the installer provisions Caddy via apt. |
The installer is idempotent: re-run it after editing the env file to push
changes. Both installers create /etc/relay-shell as 0750
(root:relay-shell, or root:root on an edge-only host without the relay-shell group),
not world-listable; systemd reads the EnvironmentFiles
as root, so the service is unaffected. The drop-in at
/etc/systemd/system/caddy.service.d/relay-shell-edge.conf is static and
references a managed EnvironmentFile=/etc/relay-shell/relay-shell-edge.env,
so user-supplied values never land inside systemd unit syntax. Cert state
persists under Caddy's data directory across restarts. See
docs/adr/0004-edge-tls-automation.md
for the design rationale and rejected alternatives.
Operators running a non-Caddy edge (nginx, HAProxy, an upstream LB with its own ACME integration) can still use the relay-shell service; the loopback-bind contract and CIDR/header expectations described above are all that matter.
5. OAuth 2.1 (optional)¶
Authentication is opt-in and off by default (RELAY_SHELL_AUTH_ENABLED
defaults to false) and applies only to the HTTP transport. A fresh install
stands up no network listener unless you choose the HTTP transport; once you
do, enable OAuth for any exposure beyond a trusted loopback + edge.
RELAY_SHELL_TRANSPORT=http
RELAY_SHELL_AUTH_ENABLED=true # default false; opt in explicitly
RELAY_SHELL_AUTH_ISSUER=https://relay-shell.example.org
RELAY_SHELL_AUTH_STATE_DIR=/var/lib/relay-shell/oauth
RELAY_SHELL_AUTH_SINGLE_CLIENT=true # lock DCR after the first client registers
RELAY_SHELL_AUTH_REQUIRE_APPROVAL=true # default: a new client stays pending until you approve it
RELAY_SHELL_AUTH_RESOURCE_URL=https://relay-shell.example.org/mcp # exact public MCP endpoint tokens are bound to
RELAY_SHELL_AUTH_RESOURCE_URL (RFC 8707) defaults to the issuer URL when empty, so an upgrade does
not change the identifier already advertised; set it explicitly in a new deployment. Tokens issued
before resource binding existed are refused until re-authorized (auth.md).
The TTL settings (RELAY_SHELL_AUTH_ACCESS_TTL / _REFRESH_TTL / _CODE_TTL) are in the defaults
table in auth.md.
Approve your client. A client that registers is pending; /authorize
refuses it until you approve it on the host
(sudo -u relay-shell /var/lib/relay-shell/venv/bin/relay-shell --auth-list, then the same
binary with --auth-approve <client_id>; the commands read RELAY_SHELL_AUTH_STATE_DIR from
their own environment, not from the unit's EnvironmentFile, so export it when it is not the
default /var/lib/relay-shell/oauth). This
closes the window in which the first party to reach /register could obtain a
token; see auth.md. Existing installs are carried
over as approved on upgrade.
The [http] extra is an empty alias (mcp 2 already depends on PyJWT and cryptography), so
a plain install is enough. Tokens are file-backed under the state dir
(clients.json, approvals.json, codes.json, tokens.json), access tokens are short-lived,
refresh tokens rotate on use, secrets are stored hashed rather than raw, and expiry
is enforced lazily on read. With single-client lockdown, dynamic registration is
refused once one client exists. auth.md has the full lifecycle: how a
client registers, obtains tokens, and stays authenticated via refresh rotation.
6. Audit¶
RELAY_SHELL_AUDIT_PATH (default /var/log/relay-shell/audit.jsonl). Make it append-only
and rotate it without losing that attribute:
sudo mkdir -p /var/log/relay-shell && sudo chown relay-shell:relay-shell /var/log/relay-shell
sudo install -o relay-shell -g relay-shell -m 0600 /dev/null /var/log/relay-shell/audit.jsonl
sudo chattr +a /var/log/relay-shell/audit.jsonl
sudo cp deploy/logrotate/relay-shell /etc/logrotate.d/relay-shell
RELAY_SHELL_AUDIT_FORMAT controls serialization for downstream SIEM ingest:
jsonl (default), cef, or leef.
The bundled logrotate config rotates daily and keeps 90 dated archives
(audit.jsonl-YYYYMMDD; the newest stays uncompressed, older ones are .gz). It drops the append-only bit
from the active file and every archive for the rotation run only (firstaction), restores it
on the new active file (postrotate) and then on every archive, and on the active file again
in case the rotation aborted (lastaction), so archives stay append-only too. Ship the log off-host and alert on gaps; an
on-host log is evidence only until the host is compromised.
docs/audit-shipper.md has worked examples for
Vector, Fluent Bit, and journalctl → systemd-journal-remote.
6a-i. Cancelled calls and the write-ahead record¶
A call whose caller goes away mid-flight (client disconnect, MCP cancellation,
transport timeout) is audited with action=cancelled (output hash of the fixed
marker [CANCELLED], exit_code null) and counted on /metrics as
outcome="cancelled". The local command's process group is killed and a remote
command is sent a terminate, so a cancelled call does not keep running.
The completion record is written when the work returns, so a crash or SIGKILL of
the relay itself mid-command leaves no record of a command that may have run. Set
RELAY_SHELL_AUDIT_INTENT=true to also append an action=intent record (same
args, exit_code null) before the work starts; the completion record
follows. Default off keeps one record per call and every record byte-identical.
server_info.audit.intent reports the live state. A log consumer that counts
records per call must treat an intent record as the call's start, not a call.
6a-ii. Input typed into sessions¶
session_send is the one tool whose argument is often a secret with no keyword
(a sudo password). Its audit record therefore omits data (keeping data_len)
when the session is at a secret prompt: the last output line reads like one
([sudo] password for bob:, Enter passphrase for key ...:, Verification
code:), or the local terminal has echo off. Anything else is recorded as before,
after pattern redaction. Set RELAY_SHELL_AUDIT_SESSION_INPUT=hash to record
only data_len and data_sha256 for every send (at a recognised secret prompt the
digest is withheld too, leaving only data_len); the default is redacted.
server_info.audit.session_input reports the live setting. A secret typed at a
prompt the heuristic does not recognise is still recorded, so use hash where
typed content must never reach the log.
6a. Tamper-evident chain (optional)¶
chattr +a and off-host shipping protect the log, but neither makes a
single altered record detectable, and the shipper has a flush window. Set
RELAY_SHELL_AUDIT_CHAIN=true (requires RELAY_SHELL_AUDIT_FORMAT=jsonl) to
add a per-record hash chain (ADR 0007): each
record carries seq, the previous record's prev hash, and its own chain
hash. Default off keeps the record byte-identical; server_info.audit.chain
reports the live state. One relay process owns one chained log: a second writer
keeps its own seq / prev and forks the chain, which --verify-audit reports as
a break. If the log cannot be written, the chain still advances in memory and the
missing record shows up as a seq gap on verification.
What the chain proves, and what it does not. From a single file the chain detects any edit, insertion, reorder, or interior deletion by recomputation, including from the shipped copy, without trusting the relay host. Head-truncation (excising leading records) is caught by the genesis anchor. Tail-truncation (dropping the newest records) leaves a shorter but valid prefix and is not detectable from the file alone; catch it by comparing against the off-host copy, which has the later records. This split is by design (ADR 0007 delegates durability/truncation defense to off-host shipping).
--verify-audit is fail-closed: it exits 0 only when the file exists,
carries a chained record, verifies clean, and is genesis-anchored; a missing /
empty / unchained log, a broken chain, or a non-genesis start (head-truncation)
exits 2. Enable chaining on a freshly rotated log so the chain runs from
genesis. Verify the on-host log or a shipped copy:
relay-shell --verify-audit # uses RELAY_SHELL_AUDIT_PATH
relay-shell --verify-audit --audit-path /var/log/relay-shell/audit.jsonl-20260601 \
--segment --json # a mid-stream rotation segment
# exit 0 = clean, genesis-anchored chain; exit 2 = missing/empty log, a record
# edited / reordered / inserted / deleted from the interior, or a non-genesis
# start. Pass --segment when the file legitimately starts at seq > 0 (a rotation
# segment); a missing/empty log and a broken chain fail regardless.
Rotation. While the process keeps running, rotation preserves the chain:
the in-memory anchor follows the file (WatchedFileHandler reopens, or
copytruncate keeps the fd), so the new file continues the same seq/chain.
If a restart finds the active file empty, the logger resumes from the newest
dateext- or numeric-named rotated sibling, including a gzip-compressed one.
A missing, unchained, or unparseable tail creates a visible genesis seam
instead; a non-empty malformed active tail is never bypassed with older
history. Verify cross-file seams against the ordered off-host stream, which
remains authoritative for tail-truncation and durability.
6b. Syscall-level audit channel (optional)¶
The audit record covers what the model asked for and the SHA-256 of the
output, but not what a spawned child does after exec returns. Set
RELAY_SHELL_SECCOMP_NOTIFY=true to add an audit-only seccomp user-notify
channel (ADR 0006):
locally-spawned children (one-shot: shell_exec / shell_script /
ssh_keyscan; and local PTY sessions: shell_spawn) get a BPF filter that
traps a small, high-signal syscall set (execve, the set[re|res]?[ug]id
family, mount/umount2, unshare/setns, chroot/pivot_root,
ptrace, write-open/openat, creat, every openat2, and prctl for privilege-relevant options
such as PR_SET_SECUREBITS / PR_CAP_AMBIENT / PR_SET_NO_NEW_PRIVS), and
a supervisor appends one syscall_notify line per observed call to the same
JSONL stream (it extends the §6a hash chain when that is on). It never intentionally denies a syscall: the supervisor answers CONTINUE.
The kernel still waits for that answer; losing all listeners produces ENOSYS,
and the detached-listener cap remains finite. This is an audit aid, not an
availability-independent observer or a sandbox. For a PTY session the filter rides the
session child (and everything it forks) for the session's whole life, and
events carry the spawning call's request_id. SSH sessions have no local
child (asyncssh runs in-process), so nothing is observed (or missed)
on that path.
The opt-in path uses a fresh isolated Python launcher (-I -S) before target
exec, rather than a threaded-parent Python preexec_fn. Monitor shutdown runs
off the event loop. Filter version 3 records all openat2 calls because its
flags are behind a user pointer; these events do not establish write intent.
See ADR 0014.
Activation is gated, by design. A seccomp filter installs with
CAP_SYS_ADMIN or by latching no_new_privs; the latter would silently
disable set-uid escalation in the child (sudo would break). To preserve the
privileged-admin posture verbatim, the channel installs only with
CAP_SYS_ADMIN (e.g. running as root) and never latches no_new_privs. It
also requires Linux / x86_64 / kernel ≥ 5.5 and a notify ABI matching the
build. Where any prerequisite is missing it cleanly no-ops: local spawns
are byte-identical to the off path, and server_info.seccomp.supported is
false with a reason (also logged once at startup). There is no system
package to install: the channel is pure ctypes.
RELAY_SHELL_SECCOMP_NOTIFY_CAP (default 256, range 1..65536) bounds the
event volume per spawned child, counted per call for the one-shot executors and per
session for shell_spawn (a long-lived interactive session runs many
commands under one filter, so size the cap for the session, not the
command): beyond it, one syscall_notify_overflow line is written
and emission stops while the child still runs to completion. Watch
relay_shell_seccomp_notify_overflow_total (§9a) and raise the cap if a
legitimate workload trips it. A process that outlives its call (a backgrounded job or daemon) keeps running
normally: its listener is kept open and answered, but its syscalls are no longer audited once the
call has ended, and each such listener holds one file descriptor until that process exits (ADR 0006,
2026-10-14 correction). The events ride the same off-host shipper as the
rest of the log (docs/audit-shipper.md), which routes on the tool field.
6c. Audit evidence verifier and off-host sync (optional)¶
deploy/evidence/ ships a timer-driven verifier and an rsync wrapper that make the off-host
copy of the audit log trustworthy. Each run recomputes every retained record's hash, checks
sequence and prev continuity inside every segment and across rotation seams (a genesis reset
only with an exact entry in a root-owned approval ledger), writes chain-verification.jsonl,
latest-anchor.json and retained-checkpoints.json, and only then does the wrapper publish the
segments and, last, the anchor as the commit marker. A failed verification publishes nothing and
exits non-zero. The rsync run on the destination is plain rsync (RELAY_AUDIT_RSYNC_PATH,
default since 0.4.0); an installation that relied on the former sudo -n /usr/bin/rsync
default (root on the backup host) must now set it explicitly. It needs RELAY_AUDIT_RSYNC_DEST and, if logrotate keeps other than 90 rotated
segments, RELAY_AUDIT_RETAIN_SEGMENTS; after logrotate prunes the oldest segment the verifier
accepts the shortened history when it continues a checkpointed record (and hosts that rotated
before checkpoints existed need one reviewed approved_heads entry). Before publishing, the wrapper
pulls the remote anchor (capped at 64 KiB) and refuses (exit 4) a local chain that is behind it
or that differs from it at the same sequence number, since the sync is a mirror. A chain that
is ahead is accepted without proving the remote anchor is its ancestor. A missing anchor on the
first sync is skipped; any other pull failure stops the run, and write-only destinations must
set RELAY_AUDIT_REMOTE_CHECK=off explicitly (any value other than pull or off is refused).
Install steps, the ledger
format and the limits of that check are in
deploy/evidence/README.md;
host metrics and the anchor health check are in
deploy/monitoring/README.md.
It complements, and does not replace, the shipper recipes in audit-shipper.md.
7. SSH credential scoping¶
The realized credential surface is whatever keys the service account can use.
Prefer one key per role/scope, revocable independently, over one all-powerful
key. RELAY_SHELL_SSH_KNOWN_HOSTS=strict is recommended for production; pre-populate
~/.ssh/known_hosts for the service account. Provide a JSON inventory via
RELAY_SHELL_INVENTORY for hosts not in ssh_config.
The SSH tools that take connection parameters (ssh_exec, ssh_spawn, ssh_upload,
ssh_download, ssh_forward) also accept a per-call known_hosts argument, so by default a
caller can pass ignore against a strict server default. Set
RELAY_SHELL_SSH_ALLOW_WEAKER_KNOWN_HOSTS=false to refuse any per-call mode weaker than
RELAY_SHELL_SSH_KNOWN_HOSTS (strength order strict > accept-new > ignore) with a
bounded error; a stronger per-call mode is always allowed, and a known_hosts entry in the
operator-written inventory is honoured as written. The audit record's known_hosts field
is always the mode actually used (per call, then inventory, then the server default), and
server_info.ssh.allow_weaker_known_hosts reports the setting. The default is true
(historical behaviour); use false wherever the server default is strict.
The SSH connection pool caches one connection per user@host:port and
reuses it for follow-up calls. RELAY_SHELL_SSH_IDLE_TIMEOUT (default
1800 seconds) drops a cached connection that has not been used for that
many seconds the next time the pool is consulted; set 0 to keep the
historical behavior (closed connections are still purged on the next
sweep). Long-running deployments that fan out across a large host
inventory should leave the reaper on so a long-lived server does not
accumulate idle handles.
8. Policy posture¶
The default is open with no deny list and no confirmation step, so no command is refused
because of what it does (ADR 0013). The
server tells the caller instead: an admitted Tier 2 or Tier 3 call, or one using SSH with
host-key verification off, ends with a [NOTICE ...] line, and server_info.warnings plus the
startup log list the permissive settings (root, open policy, accept-new/ignore host keys,
per-call weaker host keys, HTTP without OAuth, audit chain off). RELAY_SHELL_UNSAFE_NOTICES=false
removes the trailing lines; the audit record is the same either way. A notice is information,
not a block, and a call without one is not thereby safe, because the classifier is a heuristic.
What still refuses a call is a resource bound (RELAY_SHELL_MAX_INPUT, the session and forward
caps), never the command's meaning.
open- full access, every call still classified and audited. The default.guarded- Tier 2+ refused unlessRELAY_SHELL_POLICY_ALLOWmatches; set an allowlist of sanctioned change patterns. The pattern is an unanchoredre.searchover<tool> <policy text>, and a match admits the whole call, so anchor the start ((?s)^shell_exec systemctl restart nginx\b) rather than naming a word that could appear anywhere in a longer command. SSH and transfer tools append host, jump and IP lines after the command, so do not end-anchor with$; the per-tool shapes are intools.md.readonly- only Tier 0. Useful for an observation-only client.
Interactive PTY sessions are classified per input: each session_send payload
is run through the same tier scan as a one-shot command (so a destructive
keystroke payload like rm -rf / is Tier 3 and refused under guarded, not
waved through at the Tier-1 keystroke default). The scan covers the line being
typed, not just the latest payload: text sent since the last Enter, Ctrl-C or
Ctrl-U is carried forward, so r followed by m -rf /x is classified as
rm -rf /x and refused. That closes plain fragmentation, but it is still text
matching: a line built with shell obfuscation, a script written to disk and run, or
an editor / REPL inside the session evades it the same way obfuscation evades the
deny list. If a guarded deployment must
prevent Tier-2+ actions inside an interactive shell, deny the spawn tools
(^shell_spawn / ^ssh_spawn) outright, run readonly, or rely on OS/network
controls; do not treat the guarded ceiling as airtight against a live shell.
RELAY_SHELL_POLICY_DENY is a regex evaluated first in every mode, against
the probe text "<tool> <command>" (the tool name is prepended, so anchor with
\b/substrings rather than ^command; you can also deny a whole tool, e.g.
^ssh_keyscan). Use it as an always-on first-line filter, but treat it as
defence in depth, not an absolute prohibition: it matches command text, so
a determined caller can evade it with shell obfuscation (extra whitespace,
quoting like r''m, ${IFS}, $(...), base64 | sh) or alternate encodings.
ssh_keyscan normalizes any literal IP in its target list into the probe, so
an IP deny (e.g. on the cloud metadata address) is not dodged by a
decimal/hex/octal/IPv4-mapped spelling of the same address (SSRF-1), but this
cannot help for hostnames (no DNS is resolved in the policy path; a DNS or
rebinding answer can differ from the one the connection dials). Enforce hard
prohibitions with OS/network controls, such as an egress firewall (DNS-rebinding-proof),
seccomp/AppArmor, a restricted service account or readonly/guarded mode, not
the deny list alone. See ADR 0003.
8a. Tier-3 confirmation broker (optional)¶
open runs Tier-3 (IRREVERSIBLE) commands on the first request (with a trailing notice);
guarded refuses them wholesale. For the middle ground (permit irreversible admin work, but
require a deliberate second step per call), set RELAY_SHELL_CONFIRM_TIER3=true
(ADR 0009). When on, a Tier-3 call that
passed the deny list and mode check does not run on first request: it returns a
single-use, TTL-bounded token (audited action=confirm_plan, no side effect),
and the caller must arm it with the operation_confirm tool then re-issue the
exact same call (audited action=confirm_execute). Tokens are bound to the
exact operation and expire after RELAY_SHELL_CONFIRM_TTL seconds (default 120,
range 5..3600). This is an added safeguard, never a bypass (the deny list and
mode policy still run first), so it composes with any mode. Default off keeps
the audit record byte-identical. server_info.confirm reports the live posture
(tier3, ttl, pending token count).
8b. Windows targets over OpenSSH + PowerShell 7¶
Administering Windows hosts over OpenSSH with PowerShell 7 (pwsh) as the
DefaultShell is supported (ADR 0011).
- Execution and file transfer work as-is.
ssh_exec/ssh_spawn/ssh_fanoutpass the command straight to the remote's own shell (no POSIX wrapping), sopwshinterprets it; SFTP paths (ssh_upload/ssh_download) pass through opaquely, so a caller usesC:\...remote paths as the server expects. There is no command- or path-translation layer; the caller supplies PowerShell-appropriate syntax. - The tier classifier covers common Windows/pwsh destructive operations
so
guarded/readonlymode and the Tier-3 confirmation broker apply to Windows targets, not just POSIX ones. Tier 3 (irreversible):Remove-Item -Recurse/-Force,Clear-Disk,Format-Volume,Stop-Computer/Restart-Computer,Remove-Service,Remove-LocalUser,Clear-EventLog, plus nativedel /s,rd /s,format <drive>,diskpart,vssadmin delete shadows,bcdedit,cipher /w,reg delete,sc delete,wevtutil cl. Tier 2 (stateful):Stop-/Set-Service,sc/net stop,Install-Module/-Package,choco/winget install,Remove-/Disable-NetFirewallRule,netsh advfirewall,reg add,New-LocalUser,Register-ScheduledTask,Set-ExecutionPolicy. Privilege escalation:runasandStart-Process -Verb RunAs(the same tier bump assudo). - Classification stays heuristic, with a sharper caveat for PowerShell. pwsh
parameters are case-insensitive, abbreviatable (
-Recurse→-rec→-r), and:-bindable, and cmdlets have aliases (ri,del,rd); a pipeline form (Get-ChildItem -Recurse | Remove-Item) hides the recursion from theRemove-Itemtoken. Fully-abbreviated or aliased destructive forms can still under-classify, exactly as shell obfuscation evades the deny list (ADR 0003). As on POSIX, the deny list (RELAY_SHELL_POLICY_DENY, e.g.Remove-Item,Format-Volume) andreadonly/guardedmode remain the hard controls; the classifier is defence in depth. - Secret redaction. Audited command arguments redact the PowerShell
credential shapes (
-Credential/credential=, and theConvertTo-SecureString 'plaintext' -AsPlainTextinline-secret idiom) as well as the shared-Password/-Token/-ApiKeyforms. As on POSIX this is best-effort: a positional secret with no keyword (net user bob NewP@ss) cannot be recognized and is not redacted, so prefer credential objects (Get-Credential, aPSCredential) over inline plaintext. - Output encoding.
pwsh7 defaults to UTF-8, so audited output is decoded correctly. The one edge: a legacy native.exeinvoked from pwsh (e.g.diskpart.exe) may emit OEM-codepage bytes, which decode with�replacement; a display artifact, not a correctness or security issue. - Interactive sessions (
ssh_spawn). Modern Windows OpenSSH + pwsh 7 handle the ConPTY-backed PTY and window resize. On older Windows OpenSSH builds the SSHsignalrequest may be unimplemented, sosession_killdegrades to a best-effort channel close rather than a delivered signal.
8c. Settings reference¶
The authoritative list of RELAY_SHELL_* settings, with types, defaults and ranges, is the
Settings class in
src/relay_shell/config.py;
.env.example is the
annotated template. Operator-relevant ones not covered above:
- HTTP listener:
RELAY_SHELL_HTTP_HOST/RELAY_SHELL_HTTP_PORT(default127.0.0.1:8080; see §4). - Resource bounds:
RELAY_SHELL_MAX_OUTPUT/_MAX_OUTPUT_HARD(output caps, defaults 65536 and 1048576 bytes),RELAY_SHELL_DEFAULT_TIMEOUT/_MAX_TIMEOUT(timeout used when ashell_exec/ssh_execcaller omits it, default 60 s, + the clamp for every call, default 900 s; other tools keep their own documented defaults),RELAY_SHELL_MAX_SESSIONS/_MAX_FORWARDS/_MAX_CONNS(concurrency caps for sessions, port forwards, and the SSH connection cache, defaults 64 / 64 / 256;_MAX_CONNSevicts the least-recently-used connection not in use when full),RELAY_SHELL_SESSION_IDLE_TIMEOUT/_SESSION_BUFFER_BYTES(PTY session reaping, default 1800 s, and the per-session buffer, default 262144 bytes),RELAY_SHELL_MAX_INPUT(default 2097152 characters: the command, stdin, script body and env overlay of one call, together). The tier classifier and the deny regex scan that text synchronously at about 0.5 s per MiB, so a call over the cap is refused, audited as denied, rather than stalling every other session or being scanned only in part (padding could then hide a command). Raise it only where multi-MiB payloads are really piped through stdin or a script. - Unauthenticated HTTP: the server refuses to start with
RELAY_SHELL_TRANSPORT=httpon a non-loopbackRELAY_SHELL_HTTP_HOST(anything other thanlocalhostor a loopback address) unlessRELAY_SHELL_AUTH_ENABLED=trueorRELAY_SHELL_ALLOW_UNAUTH_NETWORK=true. The override is for deployments where an authenticating proxy is the only route to the port;/metricsis outside OAuth by design, so it is exposed to whoever can reach the port. - SSH:
RELAY_SHELL_SSH_CONFIG(default~/.ssh/config) andRELAY_SHELL_INVENTORY(JSON inventory) say where hosts come from;RELAY_SHELL_SSH_KNOWN_HOSTSandRELAY_SHELL_SSH_ALLOW_WEAKER_KNOWN_HOSTSset host-key verification (§7). The server honoursProxyCommandandMatch execin that file, which start local processes under the service account without a per-call audit record; useProxyJumpinstead and keep the file operator-owned and not writable by the service account. Caller-supplied hosts, users and jump hops are restricted to plain tokens so they cannot add arguments to aProxyCommand. - SSH connect tuning:
RELAY_SHELL_SSH_CONNECT_TIMEOUT(dial timeout, default 10 s) /_SSH_KEEPALIVE(keepalive interval, default 30 s,0disables);_SSH_IDLE_TIMEOUTfor the pool reaper is in §7. - Audit sink:
RELAY_SHELL_AUDIT_STDERR(also mirror audit lines to stderr).
A RELAY_SHELL_* name that no setting reads (a typo such as RELAY_SHELL_POLICY_MOD,
in the environment or in .env) is ignored and the default stays in force, so the server
logs ignoring unknown setting ... (did you mean ...?) at startup. Read the start-up log
after changing a setting; RELAY_SHELL_EDGE_* names belong to the edge installer and are
not reported.
To check the effective bounds against the deployment posture, see runbook.md §2.6.
9. Health¶
scripts/healthcheck.sh checks the local HTTP port. For stdio, liveness is
the supervising client's concern. server_info reports effective limits and
whether the audit sink is degraded (a degraded audit sink is an alert).
The end-to-end HTTP smoke (start the transport, hit
/.well-known/oauth-protected-resource, stop it) is in
runbook.md §4.6.
9a. Prometheus metrics¶
The HTTP transport exposes a GET /metrics endpoint in Prometheus text
exposition format (no auth: the route bypasses OAuth by design, so scope it
via the Caddy CIDR allowlist). The audit log is the source of truth;
metrics are for dashboards only and reset on restart.
| metric | type | meaning |
|---|---|---|
relay_shell_tool_calls_total |
counter | One per tool call. Labels: tool, tier, mode, outcome. |
relay_shell_seccomp_notify_events_total |
counter | One per observed syscall when the §6b channel is on. Label: syscall (bounded set). |
relay_shell_seccomp_notify_overflow_total |
counter | One per tool call that hit RELAY_SHELL_SECCOMP_NOTIFY_CAP. |
relay_shell_active_sessions |
gauge | Live local + SSH PTY sessions. |
relay_shell_active_forwards |
gauge | Live SSH port forwards. |
relay_shell_audit_degraded |
gauge | 1 if the audit sink is degraded, 0 otherwise. Should always be 0. |
outcome is one of ok (work returned), denied (policy refused),
error (work raised), cancelled (the call was cancelled or the client disconnected), or confirm_required (a Tier-3 call returned a
confirmation token instead of running; only emitted when the Tier-3
confirmation broker is on, §8a). Combine mode + tier + outcome for the
classic "denied tier-3 calls per minute" panel, or alert on a rising
confirm_required rate to see irreversible operations being challenged.
The stdio transport does not expose /metrics; the route is gated on
RELAY_SHELL_TRANSPORT=http.
10. Drift detection¶
After install, and on a periodic schedule in production, run:
It compares each shipped template against the file the installer placed:
| name | install path |
|---|---|
| systemd-unit | /etc/systemd/system/relay-shell.service |
| systemd-hardening | /etc/systemd/system/relay-shell.service.d/hardening.conf |
| logrotate | /etc/logrotate.d/relay-shell |
| caddyfile | /etc/caddy/Caddyfile (marker line is stripped) |
Exit 0 means every entry matched byte-for-byte; exit 2 means at least one
DRIFT, MISSING, or ABSENT_TEMPLATE row was reported. Pair with
--json for machine-readable output (Nagios / Prometheus blackbox /
Ansible drift-detection callouts). A cron line like:
17 4 * * * relay-shell /var/lib/relay-shell/venv/bin/relay-shell --verify-deploy --json > /var/log/relay-shell/drift.json
…lets a log shipper trip an alert when ok: false lands in the JSON.
11. Backup and restore¶
The relay's persistent state is small and lives in three places; the relay is stateless beyond them. Back them up together:
| What | Where | Why it matters |
|---|---|---|
| OAuth state | ${RELAY_SHELL_AUTH_STATE_DIR}/{clients.json, approvals.json, codes.json, tokens.json} (default /var/lib/relay-shell/oauth/) |
Losing it logs every client out and re-runs single-client lockdown from scratch (a new client can register, the old one cannot). |
| systemd EnvironmentFile | /etc/relay-shell/relay-shell.env (plus /etc/relay-shell/relay-shell-edge.env for the edge) |
Source of truth for every RELAY_SHELL_* knob; recreating it from scratch is error-prone. |
| Audit log | ${RELAY_SHELL_AUDIT_PATH} (default /var/log/relay-shell/audit.jsonl) plus the audit.jsonl-YYYYMMDD[.gz] archives |
The on-host copy is the local fallback when the off-host shipper has fallen behind. |
A simple recipe (adapt to your backup tool):
backup_dir=/root/relay-shell-backups
sudo install -d -m 700 "$backup_dir"
sudo install -m 600 /dev/null "$backup_dir/relay-shell-state-$(date +%F).tar.gz"
sudo tar -czf "$backup_dir/relay-shell-state-$(date +%F).tar.gz" \
/etc/relay-shell \
/var/lib/relay-shell/oauth \
/var/log/relay-shell
The install steps create a root-only destination (directory 0700,
archive 0600) before tar writes sensitive OAuth state and environment material.
Restore the OAuth state with chmod 0700 oauth/ && chmod 0600 oauth/*.json
preserved (the file modes are part of the trust boundary; the relay
re-applies them on next write, but nothing should read 0644 files between restore
and first write). The audit log is append-only
on disk; restore it before the relay starts so chattr +a does not
race with a write into the unmoved file. The drift-detection CLI
(relay-shell --verify-deploy, §10) confirms the systemd unit + Caddyfile
+ logrotate config match the templates after a restore.
Additional recovery material and rebuild limits:
- A venv may be rebuilt for disaster recovery from a retained source revision,
qualified interpreter and matching hashed runtime/build profiles. An unpinned
pip install relay-shelldoes not reproduce the previous environment. For upgrade rollback, retain the complete stopped venv and restore at the same absolute path; venvs are not relocatable. - The Caddy data directory (
/var/lib/caddy/): Caddy will re-issue the certificate from ACME on first start. Backing it up is only worth doing if you are rate-limited on the ACME directory. - SSH host trust is state: retain the service account's
~/.ssh/known_hosts, SSH configuration and external inventory with the recovery set. Revalidate changed host keys through an independent trusted channel.ssh_keyscanalone discovers keys; it does not authenticate them.
Emergency¶
- Disable fast:
sudo systemctl stop relay-shell(and revoke OAuth tokens by clearingtokens.json, or rotate the proxy CIDR allowlist to none). - Revoke SSH reach: remove/disable the service account's keys on targets.
- The audit log (off-host copy) is the post-incident record.
11a. Upgrade recovery qualification¶
Stop the service before taking or restoring a consistent upgrade snapshot. Retain the complete original-path venv, OAuth state, EnvironmentFiles, systemd unit and drop-ins, logrotate configuration, SSH trust/configuration and audit segments. Preserve ownership, restrictive modes and symlinks. Preserve separately managed credentials/inventories in their existing protected store. Verify the backup before starting an in-place upgrade; the installer provides no atomic swap or automatic rollback. It preserves EnvironmentFiles and backs up replaced managed templates, but those copies alone are not a complete recovery set.
The required release rehearsal runs the actual installer on an empty disposable
hosted VM. It checks fresh/repeat installation, replacement of deliberately changed
installed module bytes at the same package version, injected partial dependency
replacement, complete original-path snapshot restoration, dependency consistency,
synthetic OAuth token loading, MCP shell execution and continued audit chaining.
scripts/rehearse_install.py refuses existing relay-shell paths/accounts and is
not an operator-host recovery tool. Only its source-bound receipt is uploaded.
Before restarting after a real restore, compare state/environment identities,
run pip check, verify audit segments against retained off-host anchors, restore
filesystem append-only flags where supported, and review unit/template drift.
Then test authentication and representative tool execution under the restored
identity. A valid local audit prefix alone does not establish that no tail was
lost. Live-host recovery, cross-version state migration, real OAuth issuance/refresh,
edge TLS, off-host custody
and full-host disaster recovery remain separately qualified.
11b. Cross-version migration and rollback limits¶
The required release rehearsal also qualifies one signed v0.3.0 fixture with its frozen SDK 1.x closure to current source on Ubuntu 26.04, with an explicit legacy Python 3.12 interpreter. This fixture is separate from the two native host profiles. Its receipt records tag/source, wheel and lock identities. This is a newly qualified fixture, not evidence of the dependencies on any historical operational installation.
Current code hashes legacy token storage, carries existing client approval forward, and rejects old grants without resource binding. The operator must reauthorize; do not add an audience to old grants merely to avoid that security transition. The fixture exercises authenticated MCP over loopback HTTP, PKCE, reauthorization, resource refusal, refresh rotation/replay refusal and token revocation.
A snapshot rollback is not automatically state-preserving. The fixture retains all accepted audit records, restores the old runtime/configuration together, clears access/refresh grants and pending codes before restart, and verifies fresh reauthorization and continued audit chaining. It confirms backed-up credentials are not revived. Other state/policy changes after a real snapshot need their own review and reconciliation while external access remains stopped. Blindly restoring old tokens can undo revocations; restoring an older log can discard accepted audit history. Binary-only downgrade and automatic rollback remain unqualified.
This does not recommend deploying the old release or qualify arbitrary versions, operational clients, TLS/edge configuration, full-host recovery or all newer policy semantics under old software. The historical fixture's advisory disposition is scoped in its README; current-runtime security gates are unchanged.
The installer owns the dedicated /var/lib/relay-shell/venv distribution graph.
After installing the target lock and wheel, it removes distributions outside
that graph (except bootstrap pip) and verifies all installed versions. Do not
install unrelated operator tools in this service venv. Removal or validation
failure stops installation; use the documented full snapshot recovery procedure.
11c. Installer failure recovery¶
Stop relay-shell.service before running deploy/install.sh, and prevent external
starts until installation or recovery is complete. The installer refuses an active
unit and a concurrent cooperating installer; it never stops or starts the service.
It builds first, then snapshots its venv, /etc/relay-shell, systemd unit/drop-in
and logrotate configuration in a root-private directory under
/var/backups/relay-shell-installer/ before replacing runtime files.
An ordinary command failure or handled HUP/INT/TERM restores those targets and reloads systemd, returns failure, and leaves the service stopped. It preserves the snapshot and prints its path. OAuth/SSH/audit stores are never rolled back. Verify the prior runtime, configuration and audit continuity before starting the service; remove the retained snapshot only after recovery is verified. Snapshots may contain configuration secrets and require root-only access and operator-managed retention. Successful installation deletes only its own transaction snapshot.
This is failure recovery, not an atomic installation or power-loss guarantee.
SIGKILL does not execute rollback handlers. After SIGKILL, power loss or an
incomplete rollback, keep the service stopped and follow the full consistent
snapshot recovery procedure above. Do not restart the installer blindly. The
private transaction targets.txt records the ordered target mapping; a missing
numbered entry means that target was absent before installation. External service
starts, unrelated processes and storage corruption remain outside this guarantee.
11d. Recovery after an interrupted installer¶
A pending record under /var/backups/relay-shell-installer/ blocks a new install
before it probes the service Python or mutates installation state. Keep the service
stopped. Inspect the reported interruption and the private snapshot; do not delete
or edit pending.json to bypass the guard. If the service has run since the
interruption, review auth/state compatibility and use full-state recovery instead.
With the service continuously stopped, run sudo bash deploy/install.sh --recover
from trusted source supporting this transaction format. The command validates the
root-private record and every sealed snapshot entry before touching managed paths,
restores the fixed runtime/configuration set, verifies exact content/mode/owner/link
identity, reloads systemd, synchronizes state, and clears the pending record. It
retains the snapshot and leaves the service stopped. Verify runtime, auth and audit
postconditions before restarting or attempting another installation. A failure
retains the pending record; investigate and retry recovery against the same intact
evidence. Do not retry normal installation until recovery is complete.
Malformed, missing, corrupt or escaped snapshot metadata refuses recovery. Restore from independently retained full backups if the transaction evidence is damaged. Neither root-private hashes nor filesystem synchronization prove protection from malicious root, real power failure, disk/controller failure or host loss. Those require separately qualified storage, backups and operational recovery exercises.