eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env sh
2# πŸ€– ops dev β€” the two-node dev mesh (Dublin + Helsinki; docs/multi-edge.md,
3# runbook docs/multi-edge-dev-cutover.md).
4#
5# ./deploy/ops dev plan # tofu plan, with the AMI-replacement guard
6# ./deploy/ops dev deploy [ie|fi] # build -> scp -> swap -> restart (both by default)
7# ./deploy/ops dev deploy --stage-only # stop after scp (pair the swap with env edits)
8# ./deploy/ops dev deploy --home # ALSO swap the home service binary (lchat-home)
9# ./deploy/ops dev provision [ie|fi] # refresh systemd units + route the short-link apex
10# ./deploy/ops dev rebuild --yes # destroy + recreate the Dublin box (new keys)
11# ./deploy/ops dev rebuild --yes --adopt # provision a Dublin box something ELSE already
12# # replaced (no tofu touch) β€” same bootstrap
13#
14# What this encodes that prose used to carry:
15# - The dev mesh is NOT a release channel. Nothing here touches the signed
16# feed; deploying is scp + stage + swap, by hand, on purpose (the mesh runs
17# unmerged code the fleet updater must never see).
18# - The verb sorts its own credentials out (the release-server.sh shape):
19# values already in the environment win; otherwise Bitwarden fills the
20# gaps β€” AWS_* from the per-operator dev agent item (docs/secrets.md
21# Β§ AWS), and DNSIMPLE_* from that item too when it carries them, else
22# from the standalone DNSimple token item the migration split out.
23# Unlocks interactively when the vault is locked; no tty -> instructions,
24# never a hang; values are exported for this process only, never printed.
25# - Dublin is arm64 (t4g), Helsinki amd64 β€” build both, ship the right one.
26# - The binary is staged BESIDE the live one and swapped in a separate step,
27# so router+cloud restart exactly once, and the previous binary is parked
28# as a .bak for one-command rollback.
29# - Dublin runs router + cloud + control (restart all three); Helsinki runs
30# only the router.
31# - `deploy` swaps the BINARY; `provision` refreshes the UNITS. They are
32# separate because deploy restarts only units that already exist, so a
33# newly-added unit never appears on a dev box however often you deploy β€”
34# only `lchat bootstrap` writes unit files. provision runs it with
35# --no-update-timer and then asserts the timer really is off, because a
36# plain bootstrap would enable the hourly updater and quietly replace the
37# unmerged code under test with the current stable build.
38# - compute.tf's aws_ami is `most_recent = true` with no lifecycle guard: the
39# day Canonical publishes a newer image, a plain `tofu apply` DESTROYS AND
40# RECREATES the Dublin box (its /data dies with it; only the EIP survives).
41# `plan` refuses to look green when that is pending; `rebuild` is the
42# deliberate version of the same event.
43# - The dev security group admits SSH only from the per-operator /32
44# allowlist (compute.tf ssh_ingress_cidr), so being outside it looks
45# exactly like a dead box (`ops doctor dev` names which it is). Adding
46# yourself is a PR + `ops dev apply` (an in-place SG update); removing an
47# entry revokes that operator β€” only with their say-so.
48# - EC2 injects the SSH keypair ONLY at first boot β€” which is why `rebuild`
49# exists: swapping the key pair in compute.tf does nothing to a running
50# box, and replacing the box is the honest way to rotate a key you don't
51# hold. Helsinki is hand-provisioned Hetzner and cannot be rebuilt from
52# here (only its scoped S3 IAM user lives in the tofu).
53set -eu
54
55here="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
56root="$(CDPATH= cd -- "$here/.." && pwd)"
57# The Bitwarden item names + value locations this script reads β€” a contract,
58# not a search (deploy/vault-items.sh).
59# shellcheck source=deploy/vault-items.sh
60. "$here/vault-items.sh"
61# shellcheck source=deploy/bw-lib.sh
62. "$here/bw-lib.sh"
63
64# The boxes, as committed in docs/multi-edge-dev-cutover.md. The Dublin key is
65# the SHARED box key (comment `eleven-dev-box` β€” box-named, not personal),
66# never vaulted: copies live only on operator machines. ssh falls back to
67# ~/.ssh/config if it's absent.
68BOX_IE="${LCHAT_DEV_BOX_IE:-ubuntu@54.154.95.205}"
69BOX_FI="${LCHAT_DEV_BOX_FI:-root@157.180.123.183}"
70KEY_IE="$HOME/.ssh/eleven-dev-box"
71AWS_PROFILE="${LCHAT_DEV_AWS_PROFILE:-eleven-dev}"
72# The dev mesh's short-link apex β€” the dev-side stand-in for 11ch.at. It is the
73# APEX of the dev subtree, not a host under it, because that is what production
74# is: the wildcard cert that covers every space does not cover this name, so
75# rehearsing short links here exercises the same apex DNS + certificate path
76# that production needs. Its A record is aws_route53_record.dev_apex.
77DEV_LINKS_HOST="${LCHAT_DEV_LINKS_HOST:-dev.11ch.at}"
78
79ssh_ie() { if [ -f "$KEY_IE" ]; then ssh -i "$KEY_IE" -o ConnectTimeout=10 "$@"; else ssh -o ConnectTimeout=10 "$@"; fi; }
80scp_ie() { if [ -f "$KEY_IE" ]; then scp -i "$KEY_IE" "$@"; else scp "$@"; fi; }
81
82usage() {
83 echo "usage: ops dev plan [tofu args] | ops dev apply [tofu args] |" >&2
84 echo " ops dev deploy [ie|fi] [--stage-only] [--home] | ops dev provision [ie|fi] |" >&2
85 echo " ops dev rebuild [--yes]" >&2
86 exit 2
87}
88
89# plan_guard <planfile|-> [tofu args...]: run a plan, show it, and hard-stop
90# when it would DESTROY anything. Shared by `plan` (whose whole job this is)
91# and `apply` (so an interactive "yes" can't sleepwalk into the AMI rebuild).
92# Extra args pass through, so a targeted apply guards exactly the graph it
93# will touch. With a planfile (not "-"), the plan is also SAVED so apply can
94# execute exactly what was inspected β€” nothing can change between the guarded
95# plan and the applied one.
96#
97# The stop condition is the plan summary's own destroy COUNT, not resource-
98# header wording: the original grep for "must be replaced" demonstrably missed
99# a live AMI replacement on 2026-07-23 (the operator got a bare approve prompt
100# and the Dublin box was destroyed), and a count can't be missed by phrasing
101# or formatting drift. The wording grep stays as a second net.
102plan_guard() {
103 pf="$1"; shift
104 planout="$(mktemp -t lchat-dev-plan.XXXXXX)"
105 outflag=""
106 [ "$pf" = - ] || outflag="-out=$pf"
107 # -detailed-exitcode: 0 no changes, 2 changes present, 1 error. Changes are
108 # fine; what we refuse to shrug at is anything pending DESTRUCTION.
109 # -no-color so the detection greps see the same bytes a human does.
110 # (Captured to a file, not piped through tee β€” a pipeline would report tee's
111 # exit status and silently lose tofu's.)
112 rc=0
113 # shellcheck disable=SC2086 # outflag is deliberately word-split (empty or one flag)
114 run_tofu plan -input=false -no-color -detailed-exitcode $outflag "$@" >"$planout" 2>&1 || rc=$?
115 cat "$planout"
116 if [ "$rc" -eq 1 ]; then rm -f "$planout"; exit 1; fi
117 destroys="$(sed -n 's/^Plan: .* \([0-9][0-9]*\) to destroy.*/\1/p' "$planout" | tail -1)"
118 if [ "${destroys:-0}" -gt 0 ] || grep -Eq "must be replaced|forces replacement" "$planout"; then
119 rm -f "$planout"
120 cat >&2 <<'EOF'
121
122 β–ˆβ–ˆ STOP β€” this plan DESTROYS or REPLACES something (see the plan above).
123 If it is aws_instance.ie_node, the cause is usually a newer Canonical AMI β€”
124 a plain apply would DESTROY the Dublin box; its /data dies with it, only
125 the EIP (and DNS) survive. Your options:
126 - apply just the safe resource: ops dev apply -target=<addr>
127 - make the rebuild deliberate: ops dev rebuild (new keys, guided)
128 - a genuinely wanted destroy: run tofu by hand in infra/dev-mesh,
129 eyes open β€” this verb will not do it for you.
130EOF
131 exit 1
132 fi
133 rm -f "$planout"
134}
135
136# --- credentials ----------------------------------------------------------
137# Only these five ever leave the Bitwarden item for the environment β€” an
138# allowlist, so a stray line in the item can never export something else.
139DEV_CRED_VARS='AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|DNSIMPLE_TOKEN|DNSIMPLE_ACCOUNT'
140
141have_aws_auth() {
142 [ -n "${AWS_ACCESS_KEY_ID:-}" ] && return 0
143 aws configure list-profiles 2>/dev/null | grep -qx "$AWS_PROFILE"
144}
145
146# Run tofu with whichever auth we ended up with. Explicit env credentials win;
147# AWS_PROFILE is only set when that profile really exists (setting it to an
148# absent profile would itself be the error).
149run_tofu() {
150 if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then tofu "$@"; else AWS_PROFILE="$AWS_PROFILE" tofu "$@"; fi
151}
152
153# find_item <search> <pinned-name> <hint> β€” resolve exactly one vault item,
154# echoing its name. bw reads a LOCAL CACHE of the vault: an item added,
155# removed, or deduplicated in another client is invisible here until
156# `bw sync` β€” so a surprising result (none, or more than one) gets one sync
157# and retry before it is believed. Two items SHARING a name is its own error:
158# `bw get item <name>` can't disambiguate, so the vault needs a rename/delete.
159find_item() {
160 search="$1"; pinned="$2"; hint="$3"
161 if [ -n "$pinned" ]; then printf '%s\n' "$pinned"; return 0; fi
162 synced=no
163 while :; do
164 names="$(bw_run 30 list items --search "$search" 2>/dev/null | jq -r '.[].name' || true)"
165 n="$(printf '%s\n' "$names" | grep -c . || true)"
166 if [ "$n" = 1 ]; then printf '%s\n' "$names"; return 0; fi
167 if [ "$synced" = no ]; then
168 echo "ops dev: $n items match '$search' β€” syncing bw's local cache and retrying" >&2
169 bw_run 60 sync >/dev/null 2>&1 || true
170 synced=yes
171 continue
172 fi
173 if [ "$n" = 0 ]; then
174 echo "ops dev: no item matching '$search' in your vault ($hint)" >&2
175 elif [ "$(printf '%s\n' "$names" | sort -u | grep -c .)" -lt "$n" ]; then
176 echo "ops dev: DUPLICATE items named alike match '$search' β€” bw can't fetch by an" >&2
177 echo " ambiguous name; delete or rename one in the vault, then re-run:" >&2
178 printf '%s\n' "$names" | sed 's/^/ /' >&2
179 else
180 echo "ops dev: several items match '$search' β€” pin one ($hint):" >&2
181 printf '%s\n' "$names" | sed 's/^/ /' >&2
182 fi
183 return 1
184 done
185}
186
187load_dev_creds() {
188 command -v bw >/dev/null 2>&1 || {
189 echo "ops dev: bw is not installed, and the credentials aren't in the environment" >&2
190 return 1; }
191 command -v jq >/dev/null 2>&1 || { echo "ops dev: jq is required" >&2; return 1; }
192 st="$(bw_run 20 status 2>/dev/null | jq -r '.status' 2>/dev/null || true)"
193 case "$st" in
194 unlocked) ;;
195 locked)
196 if [ -t 0 ]; then
197 echo "ops dev: unlocking bitwarden to read the dev agent creds" >&2
198 # Our prompt, never bw's; watchdogged (bw-lib.sh) β€” bw's own prompt
199 # inside $(...) is invisible and reads as a hang.
200 BW_SESSION="$(bw_unlock_session)" || true
201 [ -n "${BW_SESSION:-}" ] || { echo "ops dev: unlock failed" >&2; return 1; }
202 export BW_SESSION
203 else
204 echo "ops dev: bitwarden is locked and there is no tty to prompt on" >&2
205 echo " bw unlock # then paste the 'export BW_SESSION=...' line it prints, and re-run" >&2
206 return 1
207 fi ;;
208 *)
209 echo "ops dev: bw is not logged in (bw login β€” EU vault: https://vault.bitwarden.eu)" >&2
210 return 1 ;;
211 esac
212
213 # The dev agent item is the one contract entry that is a naming CONVENTION
214 # rather than an exact name β€” it is per-operator (each has a revocable
215 # aws-dev-agent-<name> IAM identity, docs/secrets.md Β§ AWS), so resolve
216 # "exactly one item with the contract prefix"; LCHAT_DEV_CREDS_ITEM pins one.
217 item="$(find_item "$BW_ITEM_DEV_AGENT_PREFIX" "${LCHAT_DEV_CREDS_ITEM:-}" \
218 "each operator gets their own dev agent creds, docs/secrets.md Β§ AWS; LCHAT_DEV_CREDS_ITEM pins one")" \
219 || return 1
220
221 # The item is the sealed env file: KEY=VALUE lines in its secure note (or
222 # custom fields). Pull only the allowlisted names; never echo a value.
223 lines="$(bw_run 30 get item "$item" 2>/dev/null | jq -r --arg re "^($DEV_CRED_VARS)=" '
224 ((.notes // "" | split("\n")) + [.fields[]? | "\(.name)=\(.value)"])[]
225 | select(test($re))' || true)"
226 if [ -n "$lines" ]; then
227 while IFS= read -r line; do
228 k="${line%%=*}"
229 # Environment always wins β€” the item only fills gaps. ($k is allowlisted
230 # by the select() above, so the eval can't be steered by item content.)
231 eval "cur=\${$k:-}"
232 # shellcheck disable=SC2163 # the K=V-operand form of export is intended
233 [ -n "$cur" ] || export "$line"
234 done <<EOF
235$lines
236EOF
237 echo "ops dev: credentials loaded from bitwarden item '$item'" >&2
238 else
239 echo "ops dev: note: '$item' carried no AWS_*/DNSIMPLE_* lines in its note or fields" >&2
240 fi
241
242 # The Bitwarden migration split the DNSimple token out of the sops-era
243 # bundle into its own item, so when the agent item didn't carry it, fetch it
244 # from there β€” by the contract's EXACT name and slot (the login password),
245 # no searching, no guessing. A failed fetch gets one cache sync + retry (a
246 # freshly shared item is invisible to bw's local cache until then), then a
247 # crisp contract error. The account id is public (committed in main.tf) β€”
248 # only the token is a secret.
249 if [ -z "${DNSIMPLE_TOKEN:-}" ]; then
250 ditem="${LCHAT_DNSIMPLE_ITEM:-$BW_ITEM_DNSIMPLE}"
251 json="$(bw_run 30 get item "$ditem" 2>/dev/null || true)"
252 if [ -z "$json" ]; then
253 echo "ops dev: item '$ditem' not in bw's local cache β€” syncing and retrying" >&2
254 bw_run 60 sync >/dev/null 2>&1 || true
255 json="$(bw_run 30 get item "$ditem" 2>/dev/null || true)"
256 fi
257 if [ -z "$json" ]; then
258 echo "ops dev: no bitwarden item named exactly '$ditem'" >&2
259 echo " β€” the vault contract (deploy/vault-items.sh); is it shared to your collection," >&2
260 echo " or was it renamed without a matching PR?" >&2
261 return 1
262 fi
263 tok="$(printf '%s' "$json" | jq -r '.login.password // empty' 2>/dev/null || true)"
264 [ -n "$tok" ] || {
265 echo "ops dev: the vault contract expects the DNSimple token in the PASSWORD slot" >&2
266 echo " of '$ditem' (deploy/vault-items.sh) β€” fix the item, or export DNSIMPLE_TOKEN" >&2
267 return 1; }
268 export DNSIMPLE_TOKEN="$tok"
269 echo "ops dev: DNSIMPLE_TOKEN loaded from bitwarden item '$ditem'" >&2
270 fi
271}
272
273# ensure_creds: make DNSIMPLE_TOKEN + AWS auth real, from env or Bitwarden.
274ensure_creds() {
275 # The account id is public (176940, committed in main.tf) β€” default it here
276 # rather than in the bw path, so a hand-exported DNSIMPLE_TOKEN (which skips
277 # Bitwarden entirely) still gets a working provider config.
278 [ -n "${DNSIMPLE_ACCOUNT:-}" ] || export DNSIMPLE_ACCOUNT=176940
279 if [ -z "${DNSIMPLE_TOKEN:-}" ] || ! have_aws_auth; then
280 load_dev_creds || {
281 echo "ops dev: still missing credentials β€” export DNSIMPLE_TOKEN/DNSIMPLE_ACCOUNT and" >&2
282 echo " AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (or configure the '$AWS_PROFILE' aws" >&2
283 echo " profile), or make your dev agent item reachable in bw and re-run" >&2
284 exit 1
285 }
286 fi
287 [ -n "${DNSIMPLE_TOKEN:-}" ] || {
288 echo "ops dev: DNSIMPLE_TOKEN still unset (the item didn't carry it?)" >&2; exit 1; }
289 have_aws_auth || {
290 echo "ops dev: no AWS credentials (env or '$AWS_PROFILE' profile)" >&2; exit 1; }
291}
292
293# swap <ie|fi> <ver>: back up the live binary, move the staged one in, restart
294# the units that box runs, and confirm the running stamp matches the build.
295swap_box() {
296 which_box="$1"; ver="$2"
297 if [ "$which_box" = ie ]; then box="$BOX_IE"; units="lchat-router lchat-cloud lchat-control"; run() { ssh_ie "$box" "$@"; }
298 else box="$BOX_FI"; units="lchat-router"; run() { ssh -o ConnectTimeout=10 "$box" "$@"; }
299 fi
300 run "sudo cp /opt/lchat/lchat /opt/lchat/lchat.bak-pre-$ver \
301 && sudo mv /opt/lchat/incoming/lchat-new /opt/lchat/lchat \
302 && sudo chmod +x /opt/lchat/lchat \
303 && sudo systemctl restart $units"
304 running="$(run '/opt/lchat/lchat version' 2>/dev/null || true)"
305 if [ "$running" = "$ver" ] && run 'systemctl is-active --quiet lchat-router'; then
306 echo " $which_box: running $running"
307 else
308 echo "ops dev: $which_box is NOT healthy after the swap (version '$running', wanted '$ver')" >&2
309 echo " inspect: ssh $box journalctl -u lchat-router -n 60 --no-pager" >&2
310 echo " rollback: ssh $box 'sudo mv /opt/lchat/lchat.bak-pre-$ver /opt/lchat/lchat && sudo systemctl restart $units'" >&2
311 exit 1
312 fi
313}
314
315# swap_home <ie|fi> <ver>: the home service rides its own binary (releases
316# ship it as lchat-home-linux-* via updaterservices.go; the dev mesh swaps it
317# directly, same as the control binary). A box that doesn't RUN home (Helsinki
318# today β€” home runs on the dev apex box only) is skipped, never failed.
319#
320# Enabled, not merely present: `lchat bootstrap` writes the unit file on every
321# box it touches, so "the unit exists" says nothing about whether this box is
322# a home server. Helsinki has the file, disabled, with no /data/home.env β€” and
323# the old presence test walked straight into restarting it, which fails on the
324# missing env ("Failed to load environment files") and took the whole deploy
325# down with it after Dublin had already swapped fine.
326swap_home() {
327 which_box="$1"; ver="$2"
328 if [ "$which_box" = ie ]; then box="$BOX_IE"; run() { ssh_ie "$box" "$@"; }
329 else box="$BOX_FI"; run() { ssh -o ConnectTimeout=10 "$box" "$@"; }
330 fi
331 case "$(run 'systemctl is-enabled lchat-home.service 2>/dev/null || true')" in
332 enabled|enabled-runtime|static) ;;
333 *)
334 echo " $which_box: lchat-home not enabled here β€” home skipped"
335 run 'rm -f /opt/lchat/incoming/home-new' || true
336 return 0 ;;
337 esac
338 run "sudo cp /opt/lchat/home /opt/lchat/home.prev-$ver 2>/dev/null || true; \
339 sudo mv /opt/lchat/incoming/home-new /opt/lchat/home \
340 && sudo chmod +x /opt/lchat/home \
341 && sudo systemctl restart lchat-home"
342 if run 'systemctl is-active --quiet lchat-home'; then
343 echo " $which_box: lchat-home running ($ver)"
344 else
345 echo "ops dev: $which_box lchat-home is NOT active after the swap" >&2
346 echo " inspect: ssh $box journalctl -u lchat-home -n 60 --no-pager" >&2
347 echo " rollback: ssh $box 'sudo mv /opt/lchat/home.prev-$ver /opt/lchat/home && sudo systemctl restart lchat-home'" >&2
348 exit 1
349 fi
350}
351
352cmd="${1:-}"
353[ $# -gt 0 ] && shift
354case "$cmd" in
355
356plan)
357 cd "$root/infra/dev-mesh"
358 ensure_creds
359 [ -d .terraform ] || run_tofu init -input=false
360 plan_guard - "$@"
361 ;;
362
363apply)
364 # The self-serve apply: same credential fetch, same destroy guard. What
365 # applies is the SAVED plan the guard inspected β€” never a fresh one β€” so a
366 # refresh between guard and apply can't smuggle a replacement in. The verb
367 # keeps its own explicit confirm (a saved-plan apply skips tofu's). Extra
368 # args pass through (e.g. `ops dev apply
369 # -target=aws_security_group.ie_node` to land a security-group change
370 # while something destructive is pending).
371 cd "$root/infra/dev-mesh"
372 ensure_creds
373 [ -d .terraform ] || run_tofu init -input=false
374 planfile="$(mktemp -t lchat-dev-planfile.XXXXXX)"
375 trap 'rm -f "$planfile"' EXIT
376 plan_guard "$planfile" "$@"
377 printf 'apply exactly this plan? (type yes): '
378 read -r answer || answer="" # EOF (no tty) refuses below instead of dying silently
379 [ "$answer" = yes ] || { echo "ops dev: not applied"; exit 1; }
380 run_tofu apply -input=false "$planfile"
381 ;;
382
383deploy)
384 targets="ie fi"; stage_only=no; with_home=no
385 for a in "$@"; do
386 case "$a" in
387 ie|fi) targets="$a" ;;
388 --stage-only) stage_only=yes ;;
389 --home) with_home=yes ;;
390 *) usage ;;
391 esac
392 done
393
394 # The invariant `provision` asserts, checked again HERE β€” before a build, a
395 # copy and a restart go to waste. A dev box with the auto-updater enabled
396 # takes your unmerged code back to the stable channel within the hour, and
397 # the deploy that put it there reports success on the way past: seen
398 # 2026-08-28, when an update pass landed 55 seconds after a swap and quietly
399 # restored the previous home binary. Cheap SSH, loud failure, named fix.
400 for t in $targets; do
401 if [ "$t" = ie ]; then probe_box="$BOX_IE"; timer="$(ssh_ie "$probe_box" 'systemctl is-enabled lchat-update.timer 2>/dev/null || true')"
402 else probe_box="$BOX_FI"; timer="$(ssh -o ConnectTimeout=10 "$probe_box" 'systemctl is-enabled lchat-update.timer 2>/dev/null || true')"
403 fi
404 if [ "$timer" = enabled ]; then
405 echo "ops dev: ABORT β€” $t's auto-update timer is ENABLED." >&2
406 echo " It would replace whatever you deploy with the current stable build," >&2
407 echo " within the hour, silently. Turn it off first:" >&2
408 echo " ./deploy/ops dev provision $t" >&2
409 exit 1
410 fi
411 done
412
413 ver="$(git -C "$root" rev-parse --short HEAD)"
414 if ! git -C "$root" diff --quiet || ! git -C "$root" diff --cached --quiet; then
415 ver="${ver}-dirty"
416 echo "ops dev: working tree has uncommitted changes β€” building anyway ($ver)" >&2
417 fi
418
419 tmp="$(mktemp -d -t lchat-dev.XXXXXX)"
420 trap 'rm -rf "$tmp"' EXIT
421 cd "$root" # build-linux.sh compiles the package at the current directory
422 for t in $targets; do
423 case "$t" in
424 ie) arch=arm64 ;; # Dublin is a Graviton box
425 fi) arch=amd64 ;;
426 esac
427 echo "ops dev: building $ver for linux/$arch ($t)"
428 OUT="$tmp/lchat-$t" ARCH="$arch" "$here/build-linux.sh" "$ver"
429 if [ "$with_home" = yes ]; then
430 # The home service is its own package/binary (like release-server.sh's
431 # service builds) β€” no version stamp, the unit check gates the swap.
432 echo "ops dev: building home for linux/$arch ($t)"
433 GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -o "$tmp/home-$t" ./home
434 fi
435 done
436
437 for t in $targets; do
438 if [ "$t" = ie ]; then box="$BOX_IE"; else box="$BOX_FI"; fi
439 echo "ops dev: staging on $t ($box)"
440 # Stage beside the live binary, never over it β€” the swap is its own step.
441 # An SSH hang here usually means you are outside the security group's
442 # /32 allowlist, not that the box is down: run `ops doctor dev`.
443 if [ "$t" = ie ]; then
444 # The hardening pass roots /opt/lchat β€” hand the staging dir back to
445 # the ssh user so plain scp can write it (the swap is sudo either way).
446 ssh_ie "$box" 'sudo mkdir -p /opt/lchat/incoming && sudo chown "$(id -un)" /opt/lchat/incoming'
447 scp_ie -q "$tmp/lchat-$t" "$box:/opt/lchat/incoming/lchat-new"
448 [ "$with_home" = yes ] && scp_ie -q "$tmp/home-$t" "$box:/opt/lchat/incoming/home-new"
449 else
450 ssh -o ConnectTimeout=10 "$box" 'sudo mkdir -p /opt/lchat/incoming && sudo chown "$(id -un)" /opt/lchat/incoming'
451 scp -q "$tmp/lchat-$t" "$box:/opt/lchat/incoming/lchat-new"
452 [ "$with_home" = yes ] && scp -q "$tmp/home-$t" "$box:/opt/lchat/incoming/home-new"
453 fi
454 done
455
456 if [ "$stage_only" = yes ]; then
457 echo "ops dev: staged at /opt/lchat/incoming/lchat-new on: $targets (not swapped)"
458 [ "$with_home" = yes ] && echo " (home staged beside it as incoming/home-new)"
459 echo " swap by hand when ready (pairs with env edits β€” see the cutover runbook)"
460 exit 0
461 fi
462
463 for t in $targets; do
464 echo "ops dev: swapping + restarting $t"
465 swap_box "$t" "$ver"
466 [ "$with_home" = yes ] && swap_home "$t" "$ver"
467 done
468 echo
469 echo "note: this opened dev-box SSH sessions β€” if this session hasn't written its"
470 echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\""
471 ;;
472
473provision)
474 # Refresh the dev boxes' systemd units from the binary they are already
475 # running, and route the short-link redirector at the dev apex.
476 #
477 # Why this is a verb and not a documented ssh sequence: `deploy` only swaps
478 # the binary and restarts the units that already exist, so a NEW unit (like
479 # lchat-links) never appears on a dev box no matter how many times you
480 # deploy. Only `lchat bootstrap` writes units β€” and running plain bootstrap
481 # here would be a trap, because it enables the hourly auto-updater, which
482 # would replace this box's unmerged code with the current stable build. The
483 # whole point of the dev mesh is that it is NOT a release channel. So this
484 # passes --no-update-timer (which disables the timer outright rather than
485 # merely skipping it) and then VERIFIES the timer really is off before
486 # touching anything else.
487 targets="ie fi"
488 for a in "$@"; do
489 case "$a" in
490 ie|fi) targets="$a" ;;
491 *) usage ;;
492 esac
493 done
494
495 for t in $targets; do
496 if [ "$t" = ie ]; then box="$BOX_IE"; run() { ssh_ie "$box" "$@"; }
497 else box="$BOX_FI"; run() { ssh -o ConnectTimeout=10 "$box" "$@"; }
498 fi
499 # Dublin holds the EIP that dev.11ch.at points at, so it is the dev apex
500 # and bootstraps AS it (one step: starts the redirector and registers the
501 # route). Helsinki is not the apex, so its bootstrap leaves the redirector
502 # disabled β€” which is the behaviour worth exercising here too.
503 if [ "$t" = ie ]; then apexflag="--links-apex $DEV_LINKS_HOST"; else apexflag=""; fi
504
505 # Ordering guard: provision drives flags that only exist in a NEW binary,
506 # and `deploy` is what puts one on the box. Running these in the wrong
507 # order otherwise fails deep inside bootstrap's flag parsing with
508 # "flag provided but not defined", which reads like a bug in this script
509 # rather than a missing step.
510 if ! run 'sudo /opt/lchat/lchat bootstrap -h 2>&1' | grep -q 'links-apex'; then
511 echo "ops dev: ABORT β€” the binary on $t predates --links-apex." >&2
512 echo " provision configures the box; deploy is what ships the code. Run:" >&2
513 echo " ./deploy/ops dev deploy $t" >&2
514 exit 1
515 fi
516
517 echo "ops dev: bootstrapping units on $t ($box)${apexflag:+ (as short-link apex $DEV_LINKS_HOST)}"
518 run "sudo /opt/lchat/lchat bootstrap --no-update-timer $apexflag"
519
520 # The invariant, asserted rather than assumed: if bootstrap ever regains a
521 # path that leaves the updater on, this box starts silently overwriting the
522 # unmerged code under test and the mesh stops meaning anything.
523 timer="$(run 'systemctl is-enabled lchat-update.timer 2>/dev/null || true')"
524 case "$timer" in
525 enabled)
526 echo "ops dev: ABORT β€” $t's auto-update timer is ENABLED after bootstrap." >&2
527 echo " The dev mesh runs unmerged code; an updater pass would replace it with" >&2
528 echo " the current stable build. Disable it before using this box:" >&2
529 echo " ssh $box 'sudo systemctl disable --now lchat-update.timer'" >&2
530 exit 1 ;;
531 *) echo " $t: auto-update timer is '$timer' (not enabled β€” correct for dev)" ;;
532 esac
533
534 links="$(run 'systemctl is-active lchat-links 2>/dev/null || true')"
535 if [ -n "$apexflag" ]; then
536 echo " $t: lchat-links is $links (apex β€” expected active)"
537 [ "$links" = active ] || {
538 echo "ops dev: $t is the apex but its links unit did not start β€” inspect:" >&2
539 echo " ssh $box journalctl -u lchat-links -n 40 --no-pager" >&2
540 exit 1
541 }
542 else
543 echo " $t: lchat-links is $links (not the apex β€” expected inactive)"
544 [ "$links" != active ] || echo "ops dev: note β€” $t runs the redirector but isn't the apex" >&2
545 fi
546 done
547
548 # bootstrap --links-apex registered the route above, so there is no separate
549 # add-service step: prove the result instead.
550 case "$targets" in *ie*)
551 # Prove it end to end. The certificate is issued lazily on first handshake,
552 # so a cold apex can take a few seconds β€” and a TLS failure here is the
553 # exact symptom of the production bug this rehearses (an unrouted host gets
554 # no certificate at all, so it fails the handshake rather than 404ing).
555 echo "ops dev: checking https://$DEV_LINKS_HOST/ ..."
556 apex=""
557 for _ in 1 2 3 4 5 6; do
558 apex="$(curl -sS -o /dev/null -m 15 -w '%{http_code} -> %{redirect_url}' "https://$DEV_LINKS_HOST/" 2>&1 || true)"
559 case "$apex" in 30*) break ;; esac
560 sleep 5
561 done
562 echo " apex: $apex"
563 echo " (expect a 30x to elevenmessenger.com β€” that is the redirector answering)"
564 case "$apex" in
565 30*) ;;
566 *) echo "ops dev: the apex did not redirect. If this is a TLS error, check the" >&2
567 echo " A record exists (ops dev apply lands aws_route53_record.dev_apex) β€”" >&2
568 echo " a *.dev.11ch.at wildcard does NOT cover the bare apex." >&2
569 exit 1 ;;
570 esac
571
572 # /l/<host> only redirects to a host that verifiably runs lchat, so point it
573 # at a real dev space rather than a made-up name.
574 probe="${LCHAT_DEV_LINK_PROBE:-}"
575 if [ -n "$probe" ]; then
576 echo "ops dev: checking https://$DEV_LINKS_HOST/l/$probe ..."
577 echo " /l/: $(curl -sS -o /dev/null -m 20 -w '%{http_code} -> %{redirect_url}' "https://$DEV_LINKS_HOST/l/$probe" 2>&1 || true)"
578 echo " (expect a 30x to https://$probe β€” a 404 means the target failed verification)"
579 else
580 echo "ops dev: set LCHAT_DEV_LINK_PROBE=<a live dev space host> to also test /l/<host>"
581 fi
582 ;; esac
583
584 echo
585 echo "note: this opened dev-box SSH sessions β€” if this session hasn't written its"
586 echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\""
587 ;;
588
589rebuild)
590 # Destroy + recreate the Dublin box β€” the deliberate key rotation. EC2
591 # injects the keypair only at first boot, so this is how you take over a box
592 # whose key you don't hold: commit YOUR public key + /32 to compute.tf,
593 # replace the instance, and it boots trusting only you. The EIP, S3 bucket
594 # (hibernated spaces, mesh membership, the id ledger), IAM, and the Route53
595 # zone are separate resources and survive; the box's /data does not.
596 confirm=no; adopt=no
597 for a in "$@"; do case "$a" in --yes) confirm=yes ;; --adopt) adopt=yes ;; *) usage ;; esac; done
598 ct="$root/infra/dev-mesh/compute.tf"
599
600 # A fresh box trusts exactly one key β€” make sure it will be one you hold.
601 # Generating is safe (refuses to overwrite); committing it is the PR's job.
602 if [ ! -f "$KEY_IE" ]; then
603 echo "ops dev: no $KEY_IE β€” generating a fresh box key (never vaulted; the"
604 echo " rebuilt box trusts it alone, so share it with the other operators)"
605 ssh-keygen -q -t ed25519 -N '' -C eleven-dev-box -f "$KEY_IE"
606 fi
607 mypub="$(ssh-keygen -y -f "$KEY_IE" | awk '{print $1" "$2}')"
608 tfpub="$(awk '/resource "aws_key_pair" "ie_node"/,/^}/' "$ct" \
609 | sed -n 's/.*public_key *= *"\([^"]*\)".*/\1/p' | awk '{print $1" "$2}')"
610 if [ "$mypub" != "$tfpub" ]; then
611 echo "ops dev: compute.tf's key pair is NOT your key β€” tofu applies what the file says," >&2
612 echo " so the rebuilt box would trust someone else. Put your public key in" >&2
613 echo " aws_key_pair.ie_node (a reviewed PR β€” the public key is committed on purpose):" >&2
614 echo " public_key = \"$(ssh-keygen -y -f "$KEY_IE")\"" >&2
615 exit 1
616 fi
617
618 # Same rule for the SSH allowlist: outside it, the post-rebuild SSH wait
619 # would hang forever and read as a dead box. TF_VAR_ssh_ingress_cidr (a
620 # JSON list) overrides the committed default, same as for tofu itself;
621 # either way, every a.b.c.d/nn token counts.
622 cidrs="$(printf '%s' "${TF_VAR_ssh_ingress_cidr:-}" \
623 | grep -oE '[0-9]+(\.[0-9]+){3}/[0-9]+' || true)"
624 [ -n "$cidrs" ] || cidrs="$(awk '/variable "ssh_ingress_cidr"/,/^}/' "$ct" \
625 | grep -oE '[0-9]+(\.[0-9]+){3}/[0-9]+' || true)"
626 mine="$(curl -s -m5 https://api.ipify.org 2>/dev/null || true)"
627 if [ -z "$mine" ] || ! printf '%s\n' "$cidrs" | sed 's|/.*||' | grep -qxF "$mine"; then
628 echo "ops dev: the security group would admit [$(printf '%s' "$cidrs" | tr '\n' ' ')] but your IP" >&2
629 echo " is ${mine:-unknown} β€” the rebuilt box would be unreachable from here. Add your /32" >&2
630 echo " to ssh_ingress_cidr in compute.tf (removing an entry revokes that operator)." >&2
631 exit 1
632 fi
633
634 if [ "$confirm" != yes ]; then
635 cat <<EOF
636ops dev rebuild β€” this DESTROYS and recreates the Dublin box.
637 dies: everything on its disk β€” /data (non-hibernating dev spaces, the
638 router registry, router/cloud/home env files and their tokens)
639 survives: the EIP (DNS keeps pointing right), the S3 bucket (hibernated
640 spaces, mesh membership, the id ledger), IAM, the Route53 zone
641 needs: LCHAT_MESH_TOKEN re-paired with Helsinki afterwards, and the env
642 files rebuilt (the runbook's Step 3 blocks)
643With --adopt: no destroy β€” the box was ALREADY replaced (an AMI-drift apply,
644a console rebuild); this just runs the same provisioning against it.
645Coordinate first β€” this tears down the live half of the dev mesh until
646re-provisioned. Re-run with --yes to proceed (tofu will still show its plan
647and ask for approval).
648EOF
649 exit 1
650 fi
651
652 if [ "$adopt" = yes ]; then
653 # Adopt: the instance was already replaced outside this verb, so there is
654 # nothing for tofu to do β€” which also means no AWS/DNSimple credentials
655 # are needed. The facts tofu would have output come from elsewhere: the
656 # EIP survives replacement (it IS the committed BOX_IE address), and the
657 # bucket is shared with Helsinki, which survived and knows it.
658 ip="${BOX_IE#*@}"
659 bucket="$(ssh -o ConnectTimeout=10 "$BOX_FI" \
660 "sudo grep '^LCHAT_HIBERNATE_BUCKET=' /data/router.env | cut -d= -f2-" 2>/dev/null || true)"
661 [ -n "$bucket" ] || { echo "ops dev: couldn't read the mesh bucket from Helsinki's router.env β€” is $BOX_FI up?" >&2; exit 1; }
662 else
663 cd "$root/infra/dev-mesh"
664 ensure_creds
665 [ -d .terraform ] || run_tofu init -input=false
666 # tofu's own interactive approve is the second gate β€” read the plan: it
667 # should replace aws_instance.ie_node (and the key pair), nothing else
668 # destructive. (-replace overrides the instance's ignore_changes=[ami],
669 # so pinning the AMI does not break deliberate rebuilds.)
670 run_tofu apply -replace=aws_instance.ie_node
671
672 ip="$(run_tofu output -raw ie_node_ip)"
673 bucket="$(run_tofu output -raw mesh_bucket)"
674 fi
675 # Same EIP, new host key β€” the stale known_hosts entry would make every ssh
676 # fail with "REMOTE HOST IDENTIFICATION HAS CHANGED", so drop it first.
677 ssh-keygen -R "$ip" >/dev/null 2>&1 || true
678 echo "ops dev: waiting for the new box to accept ssh ($ip)"
679 i=0
680 until ssh -i "$KEY_IE" -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
681 "ubuntu@$ip" true 2>/dev/null; do
682 i=$((i + 1))
683 [ "$i" -lt 60 ] || { echo "ops dev: box not reachable after 5m β€” check the AWS console" >&2; exit 1; }
684 sleep 5
685 done
686
687 # First provision: fresh arm64 binary + `lchat bootstrap` (writes the
688 # router/control/cloud units and starts the router). After this, routine
689 # updates go through `ops dev deploy ie`.
690 ver="$(git -C "$root" rev-parse --short HEAD)"
691 tmp="$(mktemp -d -t lchat-rebuild.XXXXXX)"
692 trap 'rm -rf "$tmp"' EXIT
693 cd "$root" # we were in infra/dev-mesh; build-linux.sh compiles the cwd package
694 echo "ops dev: building $ver for linux/arm64 and bootstrapping"
695 OUT="$tmp/lchat" ARCH=arm64 "$here/build-linux.sh" "$ver"
696 scp_ie -q "$tmp/lchat" "ubuntu@$ip:/tmp/lchat"
697 ssh_ie "ubuntu@$ip" 'sudo mkdir -p /opt/lchat && sudo mv /tmp/lchat /opt/lchat/lchat \
698 && sudo chmod +x /opt/lchat/lchat && sudo /opt/lchat/lchat bootstrap'
699
700 # The env files are derived facts plus one secret β€” encoded here so no
701 # runbook is needed. The mesh token is READ from the surviving Helsinki box
702 # (the pair must match) and never printed; the cloud admin token is freshly
703 # minted, because a rebuild is a new cloud.
704 echo "ops dev: writing /data/router.env + /data/cloud.env"
705 mesh_token="$(ssh -o ConnectTimeout=10 "$BOX_FI" \
706 "sudo grep '^LCHAT_MESH_TOKEN=' /data/router.env | cut -d= -f2-" 2>/dev/null || true)"
707 [ -n "$mesh_token" ] || echo "ops dev: WARNING: couldn't read Helsinki's mesh token β€”" \
708 "router.env is written without LCHAT_MESH_TOKEN; the mesh stays unpaired until it is set on both boxes" >&2
709 admin_token="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
710 {
711 printf 'LCHAT_MESH_DOMAINS=dev.11ch.at\n'
712 printf 'LCHAT_DNS_PROVIDER=route53\n'
713 printf 'LCHAT_WILDCARD_DOMAINS=dev.11ch.at\n'
714 printf 'AWS_REGION=eu-west-1\n'
715 printf 'LCHAT_HIBERNATE_BUCKET=%s\n' "$bucket"
716 printf 'LCHAT_MESH_REGION=ie\n'
717 printf 'LCHAT_MESH_SELF=https://one.ie.dev.11ch.at\n'
718 [ -z "$mesh_token" ] || printf 'LCHAT_MESH_TOKEN=%s\n' "$mesh_token"
719 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/router.env >/dev/null && sudo chmod 600 /data/router.env'
720 {
721 printf 'LCHAT_CLOUD_BASE_DOMAIN=dev.11ch.at\n'
722 printf 'LCHAT_CLOUD_ID_LEDGER=1\n'
723 printf 'LCHAT_HIBERNATE_BUCKET=%s\n' "$bucket"
724 printf 'LCHAT_HIBERNATE_REGION=eu-west-1\n'
725 printf 'LCHAT_CLOUD_REGION=ie\n'
726 printf 'LCHAT_ADMIN_TOKEN=%s\n' "$admin_token"
727 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/cloud.env >/dev/null && sudo chmod 600 /data/cloud.env'
728 ssh_ie "ubuntu@$ip" 'sudo systemctl restart lchat-router lchat-cloud lchat-control'
729
730 # A fresh registry knows no routes: the surviving peer bulk-seeds every
731 # in-scope route into it (meshreplicate.go β€” mesh-seed exists exactly for a
732 # freshly joined box). sudo strips env, so the peer's mesh config is
733 # sourced explicitly (the old runbook's hardest-won gotcha).
734 if [ -n "$mesh_token" ]; then
735 echo "ops dev: seeding routes from Helsinki"
736 ssh -o ConnectTimeout=10 "$BOX_FI" \
737 "sudo sh -c 'set -a; . /data/router.env; set +a; /opt/lchat/lchat mesh-seed https://one.ie.dev.11ch.at'" \
738 || echo "ops dev: WARNING: mesh-seed failed β€” rerun it on Helsinki when the router is up" >&2
739 fi
740
741 # controld extras: without these a rebuilt Dublin silently provisions
742 # non-hibernating, uncapped spaces β€” the opposite of the dev mesh's point.
743 echo "ops dev: writing /data/control.env"
744 {
745 printf 'LCHAT_CONTROL_HIBERNATE_CREATES=1\n'
746 printf 'LCHAT_MAX_INSTANCES=40\n'
747 printf 'LCHAT_HIBERNATE_BUCKET=%s\n' "$bucket"
748 printf 'LCHAT_HIBERNATE_REGION=eu-west-1\n'
749 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/control.env >/dev/null && sudo chmod 600 /data/control.env && sudo systemctl restart lchat-control'
750
751 # The dev home server (encoded here per the old completion note). bootstrap
752 # already wrote its unit; home is its own binary. The env carries the
753 # WebAuthn RP origin β€” it MUST stay home.dev.elevenmessenger.com or every
754 # previously-enrolled dev passkey orphans β€” and one caller token per region,
755 # minted from each cloud's admin API over its local socket. Token values
756 # move through pipes and shell variables only; nothing prints them.
757 echo "ops dev: building + standing up the dev home server"
758 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o "$tmp/home" ./home
759 scp_ie -q "$tmp/home" "ubuntu@$ip:/tmp/home"
760 ssh_ie "ubuntu@$ip" 'sudo mv /tmp/home /opt/lchat/home && sudo chmod +x /opt/lchat/home && sudo chown root:root /opt/lchat/home'
761
762 tok_of() { sed -n 's/.*"token":"\([^"]*\)".*/\1/p'; }
763 ie_caller="$(ssh_ie "ubuntu@$ip" 'sudo sh' <<EOS | tok_of
764curl -s --unix-socket /run/lchat/cloud.sock -X POST http://cloud/api/admin/tokens \
765 -H "Authorization: Bearer $admin_token" -d '{"label":"home.dev"}'
766EOS
767)"
768 fi_caller="$(ssh -o ConnectTimeout=10 "$BOX_FI" 'sudo sh' <<'EOS' | tok_of
769ADM="$(grep '^LCHAT_ADMIN_TOKEN=' /data/cloud.env | cut -d= -f2-)"
770curl -s --unix-socket /run/lchat/cloud.sock -X POST http://cloud/api/admin/tokens \
771 -H "Authorization: Bearer $ADM" -d '{"label":"home.dev"}'
772EOS
773)"
774 [ -n "$ie_caller" ] || echo "ops dev: WARNING: no ie caller token minted β€” home's ie region will 502 until one is set in /data/home.env" >&2
775 [ -n "$fi_caller" ] || echo "ops dev: WARNING: no fi caller token minted β€” home's fi region will 502 until one is set in /data/home.env" >&2
776 {
777 printf 'LCHAT_HOME_ORIGIN=https://home.dev.elevenmessenger.com\n'
778 printf 'LCHAT_CLOUD_REGIONS=[{"code":"ie","name":"Ireland","url":"https://cloud.ie.dev.11ch.at","token":"%s"},{"code":"fi","name":"Finland","url":"https://cloud.fi.dev.11ch.at","token":"%s"}]\n' "$ie_caller" "$fi_caller"
779 printf 'LCHAT_CLOUD_DEFAULT_REGION=ie\n'
780 # Sign in with Apple: the audiences are committed facts (web Services ID +
781 # iOS bundle id); the account-keying pepper is minted fresh β€” fine on a
782 # rebuilt (empty) anchor DB, where there are no siwa anchors to orphan.
783 # Without these the /siwa/return trampoline answers 404 and the Apple door
784 # dies silently (2026-07-23). Google/Microsoft doors need OAuth secrets and
785 # stay dark until set by hand.
786 printf 'LCHAT_HOME_SIWA_CLIENT_IDS=com.elevenmessenger.signin,com.elevenmessenger.messenger\n'
787 printf 'LCHAT_HOME_SIWA_PEPPER=%s\n' "$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
788 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/home.env >/dev/null && sudo chmod 600 /data/home.env'
789 ssh_ie "ubuntu@$ip" 'sudo systemctl enable --now lchat-home'
790 # Service routes are local registry rows mesh-seed doesn't carry; re-adding
791 # an existing row fails harmlessly on a re-run.
792 ssh_ie "ubuntu@$ip" 'sudo /opt/lchat/lchat add-service home.dev.elevenmessenger.com --socket /run/lchat/home.sock' \
793 || echo "ops dev: (home route already present)"
794 ssh_ie "ubuntu@$ip" 'sudo /opt/lchat/lchat add-service cloud.ie.dev.11ch.at --socket /run/lchat/cloud.sock' \
795 || echo "ops dev: (cloud route already present)"
796
797 cat <<EOF
798
799Dublin rebuilt on $ip (same EIP β€” DNS untouched), running $ver, trusting only
800your key: env files written (router/cloud/control/home), units restarted,
801routes seeded from Helsinki, home + cloud service routes added.
802Still not encoded here β€” EXTEND THIS VERB when next needed, don't reach for a
803runbook: the dev connect broker (lchat-connect-dev needs the vault's sandbox
804Stripe pair) and the cloud.env demo/pool/billing extras.
805 verify: ssh ubuntu@$ip journalctl -u lchat-router -n 40 --no-pager | grep -i wildcard
806 curl -s https://home.dev.elevenmessenger.com/api/features
807 then: land the compute.tf PR if it changed for this rebuild, and
808 ./deploy/ops ledger "rebuilt the dev-mesh Dublin box"
809EOF
810 ;;
811
812*) usage ;;
813esac
814