eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8" />
5<meta name="viewport" content="width=device-width, initial-scale=1" />
6<title>Day-to-day operations — Running the fleet</title>
7<link rel="stylesheet" href="guide.css" />
8<script src="guide.js" defer></script>
9</head>
10<body data-page="operations.html">
11<div class="frame">
12<nav class="side"></nav>
13<main class="content">
14<div class="wrap">
15
16<header class="top">
17 <p class="eyebrow"><span class="robot">&#129302;</span> Running the fleet &middot; Part 08 of 10</p>
18 <h1>Day-to-day operations and checks</h1>
19 <p class="thesis">This page is the runbook: <strong>the checks worth doing routinely, the log lines worth knowing, backups and how to actually restore one, moving an instance between boxes, and a table of common problems with their diagnoses.</strong> Every procedure links back to the page that explains why it works.</p>
20</header>
21
22<div class="sysmap" data-highlight="router instances services control updater backup"></div>
23<p class="sysmap-caption">You are here: everywhere. Operations cut across every component, which is why this page leans on the earlier ones for depth.</p>
24
25<h2 id="routine">The routine health check, in ninety seconds</h2>
26<figure class="cmd">
27<figcaption>Run on each box — the five questions and the commands that answer them</figcaption>
28<pre><code># 1. Are all supervised services up, and has anything been crash-looping?
29systemctl list-units 'lchat*' --failed
30systemctl status lchat-router lchat-control lchat-litestream --no-pager | grep -E 'Active|since'
31
32# 2. Does the registry agree — is every intended host ready?
33lchat instances # STATUS=failed rows need their last error read; CANARY=canary rows are throwaway test hosts, safe to reset/remove
34
35# 3. Is the fleet current on its release channels, and whose canaries are pinned?
36lchat update status # AUTO=pinned rows are someone&rsquo;s live experiment — leave them be
37 # (a throwaway test host is also flagged: lchat add-instance --canary, or lchat canary &lt;host&gt; --on; shows as CANARY in lchat instances)
38
39# Reset a canary to a fresh first-run (the operator-clean wipe — no `rm` in /data):
40lchat reset &lt;host&gt; --yes # CANARY-ONLY: refuses any instance not flagged (the data-safety gate).
41 # Stops the unit, archives its DB to /data/trash (recoverable; env/config kept),
42 # restarts fresh, and prints the onboarding link. A real instance can never be reset.
43
44# 4. Is disk comfortable? (disk, not memory, is the binding resource on these boxes)
45df -h /data
46
47# 5. Does one real space answer end to end, through DNS, TLS, router, and instance?
48curl -s https://chat-a.example.com/api/version</code></pre>
49</figure>
50<p>For the fleet-wide view without logging into boxes, the public status dashboard (<a href="#observability">below</a>) publishes instance counts, awake/asleep split, capacity bands, version spread, and replication health every five minutes.</p>
51
52<h2 id="reading-logs">Reading the logs: the lines worth knowing</h2>
53<p>Everything logs to the systemd journal. The single most informative journal is the router&rsquo;s, because the activator and mesh live inside it (<a href="hibernation.html#children">part 06</a>):</p>
54<figure class="cmd">
55<figcaption>Run on the box</figcaption>
56<pre><code>journalctl -u lchat-router -f # follow everything: routing, certificates, wakes, sleeps
57journalctl -u lchat@chat-a.example.com -n 200 # one systemd-managed instance
58journalctl -u lchat-update.service -n 100 # what the last hourly update pass did
59journalctl -u lchat-litestream -n 50 # backup replication health</code></pre>
60</figure>
61<div class="tablewrap">
62<table>
63 <tr><th>Log line (pattern)</th><th>Meaning</th><th>Action</th></tr>
64 <tr><td><code>&lt;host&gt; waking</code> / <code>awake</code> / <code>hibernated</code></td><td>The activator&rsquo;s normal lifecycle.</td><td>None — this is the system working.</td></tr>
65 <tr><td><code>LEASE LOST &hellip; standing down</code></td><td>This box could not renew a space&rsquo;s <a href="hibernation.html#the-lease">lease</a>; it stopped serving the space, which will re-wake on the next request (possibly on another box, in a mesh).</td><td>Occasional: fine. Frequent: investigate bucket reachability and credentials.</td></tr>
66 <tr><td><code>exited unexpectedly — recycling</code></td><td>A hibernating instance&rsquo;s child process died; the activator cleaned up and the next request re-wakes it.</td><td>Check the surrounding lines for the child&rsquo;s own crash output; repeated recycling of one host means its build or data needs attention.</td></tr>
67 <tr><td><code>another deploy/rollback/update is running</code></td><td>The <a href="deploys.html#manual-deploy">deploy lock</a> refused a second concurrent operation.</td><td>Wait for the running one; if none is visibly running, look for a stuck updater pass.</td></tr>
68 <tr><td><code>poisoned</code> (in an update pass)</td><td>A channel build failed its health check on one instance, so the pass declined to try it elsewhere.</td><td>Investigate that build before the next pass; pin anything sensitive meanwhile.</td></tr>
69 <tr><td>Certificate errors mentioning <code>acme</code></td><td>Let&rsquo;s Encrypt issuance failed — usually DNS not pointing at the box, port 80 blocked, or a rate limit.</td><td>Verify DNS and port 80; for rate limits, prefer the <a href="router.html#dns01">wildcard certificate</a>.</td></tr>
70</table>
71</div>
72
73<h2 id="backups">Backups: continuous replication, and the restore you should rehearse</h2>
74<p>Every database on the box — instance databases, the registry, the home, push, and cloud databases — is <strong>continuously replicated</strong> to a dedicated backup bucket by a stock Litestream process (<code>lchat-litestream.service</code>, configuration <code>/etc/litestream.yml</code>). This is replication as a property of normal operation, not nightly snapshots: recovery-point loss is measured in seconds, and point-in-time restore is available. Three properties of the arrangement matter operationally:</p>
75<ul>
76 <li><strong>The configuration is generated, not written.</strong> <code>deploy/fleet-backup.sh</code> regenerates <code>/etc/litestream.yml</code> from what is actually in <code>/data</code>; run it after adding instances so new databases get picked up. It deliberately <strong>skips hibernating hosts</strong> — their replication belongs to the activator, and two replicators on one database corrupt each other (<a href="hibernation.html#hibernation-gotchas">part 06</a>).</li>
77 <li><strong>The bucket is the ransomware posture.</strong> It keeps old versions of every object for fourteen days, and the box&rsquo;s credentials can write objects but can never change versioning, lifecycle, or the bucket itself — so even an attacker with the box&rsquo;s own keys cannot destroy the history they overwrite.</li>
78 <li><strong>Blobs and the certificate cache ride separately.</strong> Attachment files (<code>/data/&lt;host&gt;-blobs/</code>) are immutable and content-addressed; the certificate cache (<code>/data/acme/</code>) is key material. Both belong in whatever file-level backup wraps the box.</li>
79</ul>
80<h3 id="restore">Restoring a database (rehearse this before you need it)</h3>
81<ol class="procedure">
82 <li><p class="step-title">Always restore to a scratch path — never over a live file.</p>
83 <figure class="cmd"><figcaption>Run on the box</figcaption>
84 <pre><code>litestream restore -config /etc/litestream.yml \
85 -o /tmp/restore-check/chat-a.example.com.db /data/chat-a.example.com.db
86sqlite3 /tmp/restore-check/chat-a.example.com.db "PRAGMA integrity_check"
87<span class="out">ok</span></code></pre></figure></li>
88 <li><p class="step-title">Only after verifying, swap it into place with the instance stopped.</p>
89 <figure class="cmd"><figcaption>Run on the box, as root</figcaption>
90 <pre><code>systemctl stop lchat@chat-a.example.com
91mv /data/chat-a.example.com.db /data/chat-a.example.com.db.broken
92mv /tmp/restore-check/chat-a.example.com.db /data/chat-a.example.com.db
93chown lchat:lchat /data/chat-a.example.com.db
94systemctl start lchat@chat-a.example.com</code></pre></figure></li>
95</ol>
96<p>For a <em>hibernating</em> host the parked copy in the hibernation bucket is usually the freshest state; a cold wake restores it automatically. The backup bucket is your answer when the hibernation copy itself is what was damaged.</p>
97
98<h2 id="migration">Moving an instance between boxes</h2>
99<p>Migration is data movement plus a DNS change — nothing about members&rsquo; keys or history is affected, <em>provided the hostname never changes</em> (browser encryption keys and passkeys are bound to the origin). The procedure, condensed from the repository&rsquo;s validated runbook (<code>docs/instance-migration.md</code>):</p>
100<ol class="procedure">
101 <li><p class="step-title">Recover the admin token and per-instance configuration from the source box.</p>
102 <p>Both live in <code>/data/&lt;host&gt;.env</code>. The token must be <em>preserved</em> — importing it on the destination (rather than minting fresh) is what keeps every saved admin link working.</p></li>
103 <li><p class="step-title">Snapshot the database cleanly, on the live system.</p>
104 <figure class="cmd"><figcaption>Run on the source box</figcaption>
105 <pre><code>sqlite3 /data/chat-a.example.com.db "VACUUM INTO '/tmp/chat-a-snapshot.db'"
106sqlite3 /tmp/chat-a-snapshot.db "PRAGMA integrity_check"
107<span class="out">ok</span></code></pre></figure>
108 <p><code>VACUUM INTO</code> produces a single, transaction-consistent, compacted copy and is safe against a running instance — no stop required for the snapshot itself.</p></li>
109 <li><p class="step-title">Copy three things, not one.</p>
110 <p>The snapshot to <code>/data/&lt;host&gt;.db</code> on the destination; the blob directory (<code>rsync -a /data/&lt;host&gt;-blobs/ &hellip;</code> — immutable files, safe to copy live); and the web-push key file <code>/data/&lt;host&gt;.vapid</code>, without which every browser push subscription silently breaks.</p></li>
111 <li><p class="step-title">Import on the destination, preserving the token.</p>
112 <figure class="cmd"><figcaption>Run on the destination box, with the database already in place</figcaption>
113 <pre><code>lchat add-instance chat-a.example.com \
114 --admin-token "&lt;the existing token&gt;" \
115 --push-relay-url https://push.example.com --push-relay-token "&lt;existing relay token&gt;"</code></pre></figure>
116 <p><code>add-instance</code> adopts the pre-placed database, starts the unit against it, health-checks, and reloads the router (<a href="spaces.html#adding-manual">part 05</a>). Verify over the local socket <em>before</em> touching DNS.</p></li>
117 <li><p class="step-title">Check the gap, then flip DNS.</p>
118 <p>Compare message counts between source-now and the snapshot; if the space is busy, freeze writes and re-snapshot at cutover. Lower the DNS record&rsquo;s TTL to about sixty seconds a while <em>before</em> the flip. The TLS certificate cannot be pre-issued (issuance validates over public DNS), so it issues on the first request after the flip — a few seconds, once.</p></li>
119 <li><p class="step-title">Verify, and keep the source as the instant rollback.</p>
120 <p>Leave the source instance running until you are satisfied: flipping DNS back is a complete rollback. Bot sidecars are their own step — their identity files (<code>/data/&lt;host&gt;-bot/</code>) must be copied, <em>never</em> re-provisioned with <code>add-bot</code> (that would mint duplicate bot members).</p></li>
121</ol>
122
123<h2 id="hardening">The hardening checklist (one-time per box)</h2>
124<ul>
125 <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>
126 <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>
127 <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> with <code>lchat bootstrap --ssh-noroot</code> (refused until operator accounts exist, so a fresh box cannot shut root out before anyone else can get in; every full <code>bootstrap</code> run converges the same drop-in once they do, so a box that missed the step picks it up on the next provision pass — no root shell needed). The verb confirms the change actually took effect (<code>sshd -T</code>) and fails loudly when an earlier-sorting <code>sshd_config.d</code> file overrides the drop-in, rather than reporting a lockdown that is not real. 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. Operators who join <em>after</em> the lockdown are added the same no-root-shell way: <code>ops provision operator &lt;name&gt; &lt;box&gt;</code> drives the allowlisted <code>bootstrap --add-operator</code> over the tunnel, piping the joiner&rsquo;s public keys on stdin (account, groups and <code>authorized_keys</code> in one idempotent verb; re-running it with fresh keys is also the rotation path). The account is half of access — the joiner&rsquo;s WireGuard peer must be on the box&rsquo;s <code>wg0</code> too. <span class="status built">running today</span></li>
128 <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>
129 <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>
130 <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>
131</ul>
132
133<h2 id="observability">What you can observe, and what you cannot yet</h2>
134<p>A status publisher on one box regenerates a set of public dashboards every five minutes, including a fleet page whose data file (<code>fleet.json</code>) reports: total, systemd-managed, and hibernating instance counts; how many hibernators are awake versus asleep <em>fleet-wide</em> at this moment; the software version spread; up/down booleans for the sibling services; backup-replication health with its lag in seconds; and a coarse capacity band. The capacity band — <code>plenty</code>, <code>getting-full</code>, or <code>at-capacity</code> — is the worst of several internal signals (instance count against the cap, registered rows against the row ceiling, disk against the floor, memory), and deliberately <em>only the band word is published</em>: never counts of slots, gigabytes, percentages, hostnames, or addresses. The same discipline runs through everything public: aggregate numbers and band words, nothing that identifies a machine or a member.</p>
135<p>In a <a href="mesh.html">mesh</a>, the dashboard aggregates across every live box, not just the one running the publisher. Each mesh member — including that one — publishes its own capacity snapshot as a sibling object next to its membership heartbeat, on a slower (one-minute) cadence; the publisher reads every live peer&rsquo;s snapshot back over the same S3 substrate the mesh already uses, no new box-to-box port involved. Each box&rsquo;s own reported awake/asleep count is filtered to the hosts it currently owns by the mesh&rsquo;s <a href="mesh.html#placement">placement hash</a> — so a host that has been re-homed to a peer is never reported as &ldquo;asleep&rdquo; by more than one box at once, and the fleet page&rsquo;s <code>hosts</code> array lists every box exactly once, each with its own whimsical, hash-derived label (never a hostname or address) and, optionally, an operator-set coarse region (<code>LCHAT_BOX_REGION</code>, &ldquo;EU &middot; Finland&rdquo;-shaped — continent and country only).</p>
136<div class="roadmap">
137 <span class="label">Designed, not built: real metrics</span>
138 <p>Wake latency, lease conflicts, and replication lag exist today as <em>log lines</em>, not as queryable metrics with alerting. The design owes an end-to-end-encryption-safe metrics discipline; until it lands, trend questions (&ldquo;are wakes getting slower?&rdquo;) are answered by reading journals, and the five-minute dashboard is the only aggregation. Budget your expectations accordingly.</p>
139</div>
140
141<h2 id="troubleshooting">Common problems and their diagnoses</h2>
142<div class="tablewrap">
143<table>
144 <tr><th>Symptom</th><th>Likely cause</th><th>Diagnosis and fix</th></tr>
145 <tr><td><code>lchat instances</code> shows a host as <code>failed</code></td><td>Its creation or last restart did not become healthy in time.</td><td>Read the row&rsquo;s <code>last error</code> line, then the instance&rsquo;s journal. The files are left in place on failure; fixing the cause and re-running the operation is safe.</td></tr>
146 <tr><td>A hibernating host&rsquo;s first wake fails with a permission error</td><td>Its environment file is root-owned (the activator reads it unprivileged).</td><td><code>chown lchat:lchat /data/&lt;host&gt;.env</code> — and note the flip commands do this themselves now; hand-copied files are the remaining way to hit it. See <a href="hibernation.html#hibernation-gotchas">part 06</a>.</td></tr>
147 <tr><td>A burst of 503 responses on one host</td><td>Wake contention: a lease held elsewhere, or a wake exceeding its budget.</td><td>Check the router journal for lease messages. In a mesh, confirm both boxes see the same membership (<a href="mesh.html#membership">list the heartbeats</a>). Occasional 503s during wakes are by design; sustained ones are not.</td></tr>
148 <tr><td>A bot stops answering after a migration or DNS change</td><td>The sidecar dials the public hostname, and the box resolves it to the wrong place.</td><td>Verify the <code>/etc/hosts</code> loopback pin on the box that runs the sidecar; restart the sidecar unit. See <a href="processes.html#bot-sidecars">part 03</a>.</td></tr>
149 <tr><td><code>deploy</code> or <code>update</code> refuses: &ldquo;another deploy&hellip; is running&rdquo;</td><td>The deploy lock is held.</td><td>Usually a genuinely running hourly pass — check <code>systemctl status lchat-update.service</code>; a timer pass that lost the lock simply retries next hour.</td></tr>
150 <tr><td>A new hostname will not get a certificate</td><td>DNS not pointing at the box, port 80 unreachable, or the host missing from the registry (the router refuses to request certificates for unknown hosts).</td><td><code>dig &lt;host&gt;</code>, check the firewall, check <code>lchat instances</code>. See <a href="router.html#tls">part 02</a>.</td></tr>
151 <tr><td>Web push notifications silently stopped for one instance</td><td>Its <code>/data/&lt;host&gt;.vapid</code> key file was lost in a move, so it re-minted keys and orphaned subscriptions.</td><td>Restore the original file from backup if possible; otherwise members&rsquo; browsers re-subscribe over time. Prevent it by always copying the file during <a href="#migration">migration</a>.</td></tr>
152 <tr><td>Disk filling up</td><td>Attachments (blobs) — the one thing that grows with use.</td><td><code>du -sh /data/*-blobs</code> to find the heavy spaces; storage quotas per space are settable through the control daemon (<a href="spaces.html#controld">part 05</a>). Old builds are garbage-collected automatically and are rarely the cause.</td></tr>
153</table>
154</div>
155
156</div>
157</main>
158</div>
159</body>
160</html>
161