eleven / messenger Public

docs+deploy: root SSH is bootstrap-only β€” WireGuard operator access, sudoers allowlist (#882)

* deploy: sudoers allowlist + env-install helper; scripts go operator-user + sudo -n

New committed operator ops surface: deploy/sudoers-operators (installed at
/etc/sudoers.d/lchat-operators by the operator-setup stage) and
deploy/lchat-env-install (the narrow, audited path for placing env secrets
without a root shell). go-router.sh and release-server.sh drop the root@
default β€” the operator user + WireGuard tunnel come from ssh config β€” and
prefix remote privileged commands with sudo -n (root can sudo too, so
bootstrap-era sessions keep working). deploy/README.md gains the explicit
operator-setup final step: root SSH is for first bootstrap only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: SSH-over-WireGuard + root-is-bootstrap-only across the hardening docs

docs/instance-router.md Β§ Hardening now carries the standing posture: public
firewall = 80/443/51820-udp only, SSH reachable only over the wg0 interface
(generic WireGuard recipe, box + operator sides), named operator accounts with
the committed sudoers allowlist and env-install helper after bootstrap
(PermitRootLogin no drop-in), break-glass via the provider's rescue console,
and the deadman revert pattern for remotely-reachable config flips. The
planet-scale guide's port/SSH statements (router, operations, reference,
deploys pages) are updated to match and badged running today. self-hosting.md
keeps its simple default trio and points optional hardeners at the recipe.
CLAUDE.md gains the one-bullet convention with pointers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* deploy: drop needless sudo on incoming-dir ensure (not allowlisted; dir is group-writable)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 736e5c257f770fd228798dc2585cf105188a2493 parent a8d295a
12 files changed, +169 −29
  • CLAUDE.md +7 −0
  • deploy/README.md +15 −0
  • deploy/go-router.sh +16 −9
  • deploy/lchat-env-install +14 −0
  • deploy/release-server.sh +7 −5
  • deploy/sudoers-operators +11 −0
  • docs/instance-router.md +86 −8
  • docs/planet-scale-guide/deploys.html +2 −2
  • docs/planet-scale-guide/operations.html +3 −2
  • docs/planet-scale-guide/reference.html +1 −1
  • docs/planet-scale-guide/router.html +1 −1
  • docs/self-hosting.md +6 −1
diff --git a/CLAUDE.md b/CLAUDE.md
index c9fc5fd..fea9ea7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -499,6 +499,13 @@ Principles that hold regardless of environment:
`LCHAT_`-prefix deliberately (a bare `ADMIN_TOKEN` can be auto-provisioned/
overridden by the platform).
- Keep a **throwaway instance** for verifying on real devices.
+- πŸ€– **Fleet boxes: operator SSH rides WireGuard; root SSH is bootstrap-only.**
+ Public firewall = 80/443 + the WireGuard port only; operators connect as
+ named accounts over the tunnel, and root capability exists solely through
+ the committed sudoers allowlist (`deploy/sudoers-operators`) β€” anything new
+ that needs root gets an allowlist line via a reviewed PR, never a root
+ shell. Every operator SSH session gets a public why-entry in the status
+ ledger (`web/status/ssh/`). Recipe: `docs/instance-router.md Β§ Hardening`.
### πŸ€– Per-session canaries (test-*.11ch.at)
diff --git a/deploy/README.md b/deploy/README.md
index 3abb5b2..746f8fc 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -10,6 +10,13 @@ ssh root@<box> '/opt/lchat/lchat bootstrap' # writes units + dirs, starts the
# 2. add an instance (mints token + env file, starts it, registers + reloads)
ssh root@<box> '/opt/lchat/lchat add-instance chat.example.com'
+
+# 3. operator setup β€” the last thing the root session does before retiring itself:
+# create a named account per operator (groups lchat-operators + systemd-journal),
+# install deploy/sudoers-operators -> /etc/sudoers.d/lchat-operators (0440)
+# and deploy/lchat-env-install -> /usr/local/sbin/ (0755), add each operator's
+# WireGuard peer, close public 22, and set PermitRootLogin no
+# (drop-in /etc/ssh/sshd_config.d/60-lchat-noroot.conf)
```
`lchat bootstrap` embeds the unit files (source `deploy/systemd/`) and writes
@@ -18,6 +25,14 @@ instead: `scp deploy/systemd/*.service` to `/etc/systemd/system/`, drop a
`/etc/tmpfiles.d/lchat.conf` (`d /run/lchat 0710 root root -`), `daemon-reload`,
`systemctl enable --now lchat-router`.
+**Root SSH is for first bootstrap only.** After step 3, operators are named
+users over the WireGuard tunnel, and root capability exists only through the
+committed sudoers allowlist (`deploy/sudoers-operators`) β€” anything new that
+needs root gets an allowlist line via a reviewed PR, never a root shell. The
+full recipe (firewall, WireGuard peer configs, break-glass via the provider's
+rescue console, the deadman revert pattern): `docs/instance-router.md
+Β§ Hardening`.
+
- `lchat-router.service` β€” the front door (binds :80/:443, autocert HTTP-01).
- `lchat@.service` β€” an instance template; runs one instance on
`/run/lchat/<host>.sock`, reading its token + config from `/data/<host>.env`.
diff --git a/deploy/go-router.sh b/deploy/go-router.sh
index 3459996..f1d57b1 100755
--- a/deploy/go-router.sh
+++ b/deploy/go-router.sh
@@ -19,7 +19,12 @@
set -euo pipefail
HOST="${1:-team.elevenmessenger.com}"
-BOX="${LCHAT_BOX:-root@89.167.122.227}"
+# No user in the default: operator SSH rides the WireGuard tunnel and the
+# username comes from ~/.ssh/config (root SSH is bootstrap-only β€” see
+# docs/instance-router.md Β§ Hardening). Privileged remote commands go through
+# `sudo -n` (the committed allowlist, deploy/sudoers-operators); root can
+# sudo too, so a bootstrap-era root session still works unchanged.
+BOX="${LCHAT_BOX:-89.167.122.227}"
SKIP_BOT="${SKIP_BOT:-}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
@@ -42,11 +47,13 @@ OUT="$OUT" ./deploy/build-linux.sh "$VER"
# /opt/lchat/incoming is a DIRECTORY now (the auto-updater downloads into it
# too β€” see updater.go); upload as a file inside it, staged then removed.
echo "Uploading to $BOX:/opt/lchat/incoming/lchat-upload..."
+# No sudo: the dir exists group-writable (lchat-operators) after operator
+# setup, and mkdir -p on an existing dir succeeds without write access.
ssh "$BOX" 'mkdir -p /opt/lchat/incoming'
scp -q "$OUT" "$BOX:/opt/lchat/incoming/lchat-upload"
echo "Staging on the box..."
-STAGE_OUT="$(ssh "$BOX" '/opt/lchat/lchat stage -file /opt/lchat/incoming/lchat-upload && rm -f /opt/lchat/incoming/lchat-upload')"
+STAGE_OUT="$(ssh "$BOX" 'sudo -n /opt/lchat/lchat stage -file /opt/lchat/incoming/lchat-upload && rm -f /opt/lchat/incoming/lchat-upload')"
echo " $STAGE_OUT"
STAGED_VER="$(echo "$STAGE_OUT" | awk '{print $2}')"
if [ "$STAGED_VER" != "$VER" ]; then
@@ -55,11 +62,11 @@ if [ "$STAGED_VER" != "$VER" ]; then
fi
echo "Deploying $STAGED_VER to $HOST (health-checked; auto-rolls back on a build mismatch)..."
-ssh "$BOX" "/opt/lchat/lchat deploy '$HOST' --version '$STAGED_VER'"
+ssh "$BOX" "sudo -n /opt/lchat/lchat deploy '$HOST' --version '$STAGED_VER'"
if [ -n "$SKIP_BOT" ]; then
echo "SKIP_BOT set β€” leaving the bot sidecar alone."
-elif ssh "$BOX" "test -f /data/${HOST}-bot.env"; then
+elif ssh "$BOX" "sudo -n test -f /data/${HOST}-bot.env"; then
echo "Bot sidecar already provisioned for $HOST β€” leaving it alone (add-bot mints fresh, one-time invites, so re-running it would orphan the existing Claude/Eleven identities)."
else
echo "No bot sidecar yet for $HOST β€” adding one (deterministic-only unless \$ANTHROPIC_API_KEY is set)..."
@@ -71,15 +78,15 @@ else
# Fixed upstream in 8e20692, not yet deployed to /opt/lchat/lchat's control
# binary on the box. Pre-creating the dir with the right owner is a no-op
# once that binary is updated, so this is safe to leave in permanently.
- ssh "$BOX" "mkdir -p /data/${HOST}-bot && chown lchat:lchat /data/${HOST}-bot && chmod 750 /data/${HOST}-bot"
+ ssh "$BOX" "sudo -n mkdir -p /data/${HOST}-bot && sudo -n chown lchat:lchat /data/${HOST}-bot && sudo -n chmod 750 /data/${HOST}-bot"
if [ -n "$ANTHROPIC_API_KEY" ]; then
- ssh "$BOX" "ANTHROPIC_API_KEY='$ANTHROPIC_API_KEY' /opt/lchat/lchat add-bot '$HOST'"
+ ssh "$BOX" "sudo -n ANTHROPIC_API_KEY='$ANTHROPIC_API_KEY' /opt/lchat/lchat add-bot '$HOST'"
else
- ssh "$BOX" "/opt/lchat/lchat add-bot '$HOST'"
+ ssh "$BOX" "sudo -n /opt/lchat/lchat add-bot '$HOST'"
fi
fi
echo
echo "βœ… $HOST is now running $STAGED_VER (branch '$BRANCH')"
-echo " Recover the admin link anytime: ssh $BOX /opt/lchat/lchat admin-link $HOST"
-echo " Step one instance back: ssh $BOX /opt/lchat/lchat rollback $HOST"
+echo " Recover the admin link anytime: ssh $BOX sudo -n /opt/lchat/lchat admin-link $HOST"
+echo " Step one instance back: ssh $BOX sudo -n /opt/lchat/lchat rollback $HOST"
diff --git a/deploy/lchat-env-install b/deploy/lchat-env-install
new file mode 100755
index 0000000..39cba88
--- /dev/null
+++ b/deploy/lchat-env-install
@@ -0,0 +1,14 @@
+#!/bin/sh
+# lchat-env-install <name> β€” write stdin to /data/<name>.env (0600 lchat:lchat).
+# The narrow, audited path for operators to place service/instance secrets
+# without a root shell: sops -d file.env | ssh <box> sudo lchat-env-install <name>
+set -eu
+name="$1"
+case "$name" in */*|*..*|"") echo "bad name" >&2; exit 1 ;; esac
+umask 077
+t=$(mktemp /data/.envtmp.XXXXXX)
+cat > "$t"
+chown lchat:lchat "$t"
+chmod 600 "$t"
+mv "$t" "/data/$name.env"
+echo "wrote /data/$name.env"
diff --git a/deploy/release-server.sh b/deploy/release-server.sh
index 8153375..842caa5 100755
--- a/deploy/release-server.sh
+++ b/deploy/release-server.sh
@@ -16,8 +16,10 @@
# until every un-pinned instance is current on its channel (absorbing GitHub's
# stale-CDN window on a fresh manifest); each pass restarts the updated
# instances' bot sidecars in lock-step (deploy/systemd/lchat-bot@.service).
-# Needs LCHAT_BOX (e.g. root@<box-ip> β€” environment specifics live in
-# CLAUDE.local.md, never here). `publish ... && ship` is a full deploy.
+# Needs LCHAT_BOX (e.g. <box-ip>; the operator user + WireGuard tunnel come
+# from ~/.ssh/config β€” root SSH is bootstrap-only, and the box commands run
+# via `sudo -n` through deploy/sudoers-operators. Environment specifics live
+# in CLAUDE.local.md, never here). `publish ... && ship` is a full deploy.
#
# Needs: gh (authed, write access to $LCHAT_RELEASES_REPO), jq, go, and the
# signing key at $LCHAT_RELEASE_KEY_FILE (1Password: "Eleven Messenger server
@@ -218,12 +220,12 @@ promote)
ship)
VER="${1:-$(git rev-parse --short HEAD)}"
BOX="${LCHAT_BOX:-}"
- [ -n "$BOX" ] || { echo "release-server: set LCHAT_BOX (e.g. root@<box>) to ship" >&2; exit 1; }
+ [ -n "$BOX" ] || { echo "release-server: set LCHAT_BOX (e.g. <box-ip>; user comes from ssh config) to ship" >&2; exit 1; }
echo "ship: updating instances on $BOX until current (feed target: $VER)"
DEADLINE=$(( $(date +%s) + 600 ))
while :; do
- ssh "$BOX" /opt/lchat/lchat update run || true
- STATUS="$(ssh "$BOX" /opt/lchat/lchat update status || true)"
+ ssh "$BOX" sudo -n /opt/lchat/lchat update run || true
+ STATUS="$(ssh "$BOX" sudo -n /opt/lchat/lchat update status || true)"
# Done when no un-pinned instance with a known channel head is behind, and
# at least one row proves a feed landed at all. Pinned rows are someone's
# canary: reported, never waited on. Held rows (a beta/stable head still
diff --git a/deploy/sudoers-operators b/deploy/sudoers-operators
new file mode 100644
index 0000000..b6a0a52
--- /dev/null
+++ b/deploy/sudoers-operators
@@ -0,0 +1,11 @@
+# Eleven Messenger fleet β€” operator command allowlist.
+# Installed at /etc/sudoers.d/lchat-operators (mode 0440) by the operator-setup stage.
+# This file IS the operator ops surface: amend it via a reviewed PR + redeploy,
+# never by hand-editing on a box. Root shells are bootstrap-only.
+%lchat-operators ALL=(root) NOPASSWD: /opt/lchat/lchat, /opt/lchat/lchat *
+%lchat-operators ALL=(root) NOPASSWD: /usr/bin/systemctl start lchat-*, /usr/bin/systemctl stop lchat-*, /usr/bin/systemctl restart lchat-*, /usr/bin/systemctl daemon-reload, /usr/bin/systemctl reset-failed lchat-*
+%lchat-operators ALL=(root) NOPASSWD: /usr/bin/systemctl enable lchat-*, /usr/bin/systemctl disable lchat-*
+%lchat-operators ALL=(root) NOPASSWD: /usr/bin/systemctl start sshroot-revert.service, /usr/bin/systemctl stop sshroot-revert.timer, /usr/bin/systemctl stop sshflip-revert.timer
+%lchat-operators ALL=(root) NOPASSWD: /usr/bin/wg, /usr/bin/wg *, /usr/bin/wg-quick save wg0
+%lchat-operators ALL=(root) NOPASSWD: /usr/local/sbin/lchat-env-install *
+%lchat-operators ALL=(root) NOPASSWD: /opt/lchat/fleet-backup.sh
diff --git a/docs/instance-router.md b/docs/instance-router.md
index 641d921..04979bd 100644
--- a/docs/instance-router.md
+++ b/docs/instance-router.md
@@ -571,14 +571,92 @@ else binds a unix socket and needs no capabilities (`CapabilityBoundingSet=`).
> sidecar has no such caveat and gets the same hardening as the router and
> instances.)
-**OS level** (one-time):
-
-- **Firewall** β€” allow only `22`, `80`, `443` inbound, default-deny the rest
- (`ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable`; add
- the `allow`s *before* enabling so you don't lock out SSH). Instances and services
- talk over unix sockets, so nothing else needs a public port.
-- **SSH** β€” key-only: `PasswordAuthentication no`, `PermitRootLogin
- prohibit-password` (or a sudo user + `PermitRootLogin no`).
+πŸ€– **OS level** (one-time):
+
+- **Firewall** β€” allow only `80`, `443`, and the WireGuard port (`51820/udp`)
+ inbound; default-deny the rest. SSH gets **no public allow** β€” it is reachable
+ only over the WireGuard interface (next bullet):
+
+ ```sh
+ ufw allow 80/tcp && ufw allow 443/tcp && ufw allow 51820/udp
+ ufw allow in on wg0 to any port 22 proto tcp
+ ufw enable # add the allows *before* enabling so you don't lock yourself out
+ ```
+
+ Instances and services talk over unix sockets, so nothing else needs a public
+ port. `sshd` itself is left unchanged (still listening on 22, key-only:
+ `PasswordAuthentication no`) β€” the *firewall* does the gating, so there's no
+ boot-order coupling between sshd and the tunnel.
+- **SSH over WireGuard** β€” operator SSH rides a WireGuard tunnel instead of a
+ public port. WireGuard is silent to unauthenticated packets, so to a scanner
+ the port doesn't exist: the constant background brute-forcing every public 22
+ attracts drops to zero, and the tunnel key becomes the hard boundary for box
+ access. The box runs one `wg0` interface with a peer per operator; each
+ operator's config points at the box and keeps the tunnel warm:
+
+ ```ini
+ # box: /etc/wireguard/wg0.conf β€” pick any private subnet for the tunnel
+ [Interface]
+ Address = <wg-subnet>.1/24
+ ListenPort = 51820
+ PrivateKey = <box key>
+
+ [Peer] # one block per operator
+ PublicKey = <operator pubkey>
+ AllowedIPs = <wg-subnet>.2/32 # that operator's fixed tunnel address
+ ```
+
+ ```ini
+ # operator machine: the client side of the same tunnel
+ [Interface]
+ Address = <wg-subnet>.2/32
+ PrivateKey = <operator key>
+
+ [Peer]
+ PublicKey = <box pubkey>
+ Endpoint = <box public IP>:51820
+ AllowedIPs = <wg-subnet>.0/24 # route only the tunnel subnet, not all traffic
+ PersistentKeepalive = 25 # keep NAT mappings alive so ssh works instantly
+ ```
+
+ Operator machines run the tunnel persistently (launchd/systemd) and add an
+ `~/.ssh/config` alias whose `HostName` is the box's WireGuard address, so
+ every existing `ssh <box>` command keeps working unchanged.
+- **Root SSH is bootstrap-only.** A box's *first* root session runs
+ `lchat bootstrap`, then the operator-setup stage: create a named account per
+ operator (groups `lchat-operators` + `systemd-journal`, the latter so
+ operators can read journals), install the committed sudo allowlist
+ `deploy/sudoers-operators` at `/etc/sudoers.d/lchat-operators` (mode `0440`)
+ and the env-placement helper `deploy/lchat-env-install` at
+ `/usr/local/sbin/lchat-env-install` (mode `0755`), add the WireGuard peers β€”
+ and then disable root logins with a drop-in
+ `/etc/ssh/sshd_config.d/60-lchat-noroot.conf` (`PermitRootLogin no`).
+ Thereafter operators are named users over the tunnel; root capability exists
+ only through the allowlist, and anything new that needs root gets a line
+ added **via a reviewed PR + redeploy**, never a root shell or a hand-edit on
+ the box. The helper exists because placing secrets is the one routine
+ privileged op that doesn't fit a fixed command line:
+ `sops -d file.env | ssh <box> sudo lchat-env-install <name>`. Every operator
+ SSH session is published to the public status ledger. Be honest about what
+ this buys: the allowlist includes deploying binaries, so it is
+ accident-proofing, attribution, and a reviewable ops surface β€” **not** a hard
+ security boundary; the hard boundary is the WireGuard key.
+- **Break-glass** β€” with no public SSH, a lost tunnel key or a broken `wg0` is
+ recovered through the hosting provider's out-of-band path: the web console,
+ or rescue mode (boot the rescue system, mount the disk, repair the config).
+ Rare true-root operations (OS surgery, filesystem repair) go the same way β€”
+ they're what the console is for, not a reason to keep a root login open.
+- **The deadman pattern** β€” when flipping any remotely-reachable config
+ (firewall rules, sshd drop-ins, the WireGuard interface), schedule the
+ automatic revert *first*, verify from outside over a *fresh* connection, and
+ only then cancel the revert β€” so a mistake un-does itself instead of locking
+ you out:
+
+ ```sh
+ systemd-run --on-active=10min --unit=sshflip-revert ufw disable # the safety net
+ # …apply the change, verify from a NEW connection from outside…
+ systemctl stop sshflip-revert.timer # all good β€” disarm
+ ```
- **Automatic security updates** β€” enable `unattended-upgrades`.
- If the box hosts its own services, pin their hostnames to loopback in
`/etc/hosts` (see *Bots*).
diff --git a/docs/planet-scale-guide/deploys.html b/docs/planet-scale-guide/deploys.html
index aed8fd1..734a614 100644
--- a/docs/planet-scale-guide/deploys.html
+++ b/docs/planet-scale-guide/deploys.html
@@ -82,8 +82,8 @@
<li><p class="step-title">Copy the binary to the box and stage it into the version store.</p>
<figure class="cmd">
<figcaption>Run on your own machine</figcaption>
- <pre><code>scp lchat-linux-amd64 root@box-1:/opt/lchat/incoming
-ssh root@box-1 /opt/lchat/lchat stage --file /opt/lchat/incoming
+ <pre><code>scp lchat-linux-amd64 box-1:/opt/lchat/incoming
+ssh box-1 sudo /opt/lchat/lchat stage --file /opt/lchat/incoming
<span class="out">staged 4f0a2d19</span></code></pre>
</figure>
<p><code>stage</code> asks the binary for its own build identifier and copies it to <code>/opt/lchat/versions/4f0a2d19/lchat</code>. Nothing is running it yet; staging is inert and always safe.</p></li>
diff --git a/docs/planet-scale-guide/operations.html b/docs/planet-scale-guide/operations.html
index a88f9c3..dc71c30 100644
--- a/docs/planet-scale-guide/operations.html
+++ b/docs/planet-scale-guide/operations.html
@@ -116,8 +116,9 @@ sqlite3 /tmp/chat-a-snapshot.db "PRAGMA integrity_check"
<h2 id="hardening">The hardening checklist (one-time per box)</h2>
<ul>
- <li><strong>Firewall:</strong> allow inbound 22, 80, 443 only; default-deny the rest. Everything internal is unix sockets, so nothing else needs a port. (Add the allows <em>before</em> enabling the firewall, so you do not lock out your own SSH session.)</li>
- <li><strong>SSH:</strong> key-only (<code>PasswordAuthentication no</code>).</li>
+ <li><strong>Firewall:</strong> allow inbound 80, 443, and the WireGuard port (51820, UDP) only; default-deny the rest. SSH gets no public allow β€” it is permitted only <em>on the WireGuard interface</em> (<code>ufw allow in on wg0 to any port 22 proto tcp</code>). Everything internal is unix sockets, so nothing else needs a port. (Add the allows <em>before</em> enabling the firewall, and verify the tunnel from outside before removing any public SSH rule, so you do not lock out your own session.) <span class="status built">running today</span></li>
+ <li><strong>SSH rides WireGuard:</strong> each operator is a peer on the box&rsquo;s <code>wg0</code> interface and connects to the box&rsquo;s tunnel address; the tunnel runs persistently on the operator&rsquo;s machine with an <code>ssh</code> configuration alias, so every existing command works unchanged. WireGuard does not answer unauthenticated packets, so the SSH port is invisible to scanners and brute-force noise drops to zero. Key-only remains in force (<code>PasswordAuthentication no</code>). The full recipe, including the break-glass path through the hosting provider&rsquo;s rescue console, is in <code>docs/instance-router.md</code> under Hardening. <span class="status built">running today</span></li>
+ <li><strong>Root SSH is bootstrap-only:</strong> the first root session bootstraps the box and creates named operator accounts (group <code>lchat-operators</code>), installs the committed sudo allowlist (<code>deploy/sudoers-operators</code> &rarr; <code>/etc/sudoers.d/lchat-operators</code>) and the <code>lchat-env-install</code> helper for placing secrets, then sets <code>PermitRootLogin no</code>. From then on operators are named users whose root capability is exactly the allowlist β€” anything new that needs root gets an allowlist line through a reviewed pull request, never a root shell. This is accident-proofing and attribution with a reviewable surface, not a hard security boundary (the allowlist can deploy binaries); the hard boundary is the WireGuard key. <span class="status built">running today</span></li>
<li><strong>Automatic security updates</strong> for the operating system (<code>unattended-upgrades</code>) β€” the application updates itself (<a href="deploys.html#auto-updates">part 04</a>), but the kernel and OpenSSL do not.</li>
<li><strong>Pin the box&rsquo;s own hostnames to loopback</strong> in <code>/etc/hosts</code>, so on-box clients (bot sidecars, calls to the push relay) reach the local router instead of the public internet β€” and disable any cloud-image behaviour that regenerates that file on boot. The subtle failure this prevents is described in <a href="processes.html#bot-sidecars">part 03</a>.</li>
<li><strong>The unit files already sandbox every service</strong> (<a href="processes.html#security-model">part 03</a>) β€” nothing to do, but worth knowing when something cannot write where you expected: <code>ProtectSystem=strict</code> means the whole filesystem is read-only to services except <code>/data</code> and <code>/run/lchat</code>.</li>
diff --git a/docs/planet-scale-guide/reference.html b/docs/planet-scale-guide/reference.html
index 2822ac7..2b895f0 100644
--- a/docs/planet-scale-guide/reference.html
+++ b/docs/planet-scale-guide/reference.html
@@ -23,7 +23,7 @@
<p class="sysmap-caption">The map one last time, unhighlighted. If a term below is unfamiliar, the <a href="#glossary">glossary</a> at the bottom of this page defines it.</p>
<h2 id="commands">Command reference: the <code>lchat</code> control commands</h2>
-<p>All of these run on a box (over <code>ssh</code>), as root, invoking <code>/opt/lchat/lchat</code>. Common conventions: <code>&lt;host&gt;</code> is always a full domain name; flags may come before or after the host; most commands accept <code>--registry &lt;path&gt;</code> to point at a non-default registry (you will essentially never need it β€” the default is <code>/data/registry.db</code>).</p>
+<p>All of these run on a box (over <code>ssh</code>, which rides the WireGuard tunnel β€” <a href="operations.html#hardening">part 08</a>), as a named operator invoking <code>/opt/lchat/lchat</code> through the committed sudo allowlist (root SSH is for first bootstrap only). Common conventions: <code>&lt;host&gt;</code> is always a full domain name; flags may come before or after the host; most commands accept <code>--registry &lt;path&gt;</code> to point at a non-default registry (you will essentially never need it β€” the default is <code>/data/registry.db</code>).</p>
<h3 id="cmd-lifecycle">Box and instance lifecycle</h3>
<dl>
diff --git a/docs/planet-scale-guide/router.html b/docs/planet-scale-guide/router.html
index dde5fba..fb7e1d9 100644
--- a/docs/planet-scale-guide/router.html
+++ b/docs/planet-scale-guide/router.html
@@ -102,7 +102,7 @@ LCHAT_DNSIMPLE_TOKEN=&lt;a DNSimple account API token that can edit the example.
<tr><td><strong>80 (HTTP)</strong></td><td>Answers Let&rsquo;s Encrypt validation requests (<code>/.well-known/acme-challenge/&hellip;</code>), and permanently redirects everything else to HTTPS.</td></tr>
</table>
</div>
-<p>These are the only two public ports on a box. The firewall guidance in <a href="operations.html#hardening">part 08</a> allows exactly 22 (SSH), 80, and 443 inbound, and nothing else.</p>
+<p>These are the only two public <em>TCP</em> ports on a box. The firewall guidance in <a href="operations.html#hardening">part 08</a> allows exactly 80, 443, and the WireGuard port (51820, UDP) inbound, and nothing else β€” SSH is not publicly reachable at all; operators reach it only through the WireGuard tunnel, and WireGuard itself does not answer unauthenticated packets, so to a port scanner the box shows nothing but the web. <span class="status built">running today</span></p>
<h2 id="routing">How a request is routed, step by step</h2>
<ol>
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index 3e67fd5..5ca5dc5 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -97,7 +97,12 @@ share nothing.
removal never destroys data.
- **Hardening**: the units are already sandboxed; for the box itself, a
firewall (22/80/443), key-only SSH, and unattended OS upgrades are the
- standard trio.
+ standard trio. Going further is optional: our own fleet closes public
+ port 22 entirely β€” SSH rides a WireGuard tunnel (invisible to scanners),
+ root login is disabled after first setup, and day-to-day admin runs
+ through named accounts with a committed sudo allowlist. The full recipe,
+ including the break-glass path via your provider's rescue console, is in
+ `docs/instance-router.md Β§ Hardening` in the main repo.
## Scaling up (optional)