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>
12 files changed,
+169
−29
CLAUDE.md+7 −0deploy/README.md+15 −0deploy/go-router.sh+16 −9deploy/lchat-env-install+14 −0deploy/release-server.sh+7 −5deploy/sudoers-operators+11 −0docs/instance-router.md+86 −8docs/planet-scale-guide/deploys.html+2 −2docs/planet-scale-guide/operations.html+3 −2docs/planet-scale-guide/reference.html+1 −1docs/planet-scale-guide/router.html+1 −1docs/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’s <code>wg0</code> interface and connects to the box’s tunnel address; the tunnel runs persistently on the operator’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’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> → <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’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><host></code> is always a full domain name; flags may come before or after the host; most commands accept <code>--registry <path></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><host></code> is always a full domain name; flags may come before or after the host; most commands accept <code>--registry <path></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=<a DNSimple account API token that can edit the example. |
| <tr><td><strong>80 (HTTP)</strong></td><td>Answers Let’s Encrypt validation requests (<code>/.well-known/acme-challenge/…</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) |