eleven / messenger Public

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

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 without the unit (Helsinki
318# today β€” home runs on the dev apex box only) is skipped, never failed.
319swap_home() {
320 which_box="$1"; ver="$2"
321 if [ "$which_box" = ie ]; then box="$BOX_IE"; run() { ssh_ie "$box" "$@"; }
322 else box="$BOX_FI"; run() { ssh -o ConnectTimeout=10 "$box" "$@"; }
323 fi
324 if ! run 'systemctl list-unit-files lchat-home.service --no-legend 2>/dev/null' | grep -q lchat-home; then
325 echo " $which_box: no lchat-home unit β€” home skipped"
326 return 0
327 fi
328 run "sudo cp /opt/lchat/home /opt/lchat/home.prev-$ver 2>/dev/null || true; \
329 sudo mv /opt/lchat/incoming/home-new /opt/lchat/home \
330 && sudo chmod +x /opt/lchat/home \
331 && sudo systemctl restart lchat-home"
332 if run 'systemctl is-active --quiet lchat-home'; then
333 echo " $which_box: lchat-home running ($ver)"
334 else
335 echo "ops dev: $which_box lchat-home is NOT active after the swap" >&2
336 echo " inspect: ssh $box journalctl -u lchat-home -n 60 --no-pager" >&2
337 echo " rollback: ssh $box 'sudo mv /opt/lchat/home.prev-$ver /opt/lchat/home && sudo systemctl restart lchat-home'" >&2
338 exit 1
339 fi
340}
341
342cmd="${1:-}"
343[ $# -gt 0 ] && shift
344case "$cmd" in
345
346plan)
347 cd "$root/infra/dev-mesh"
348 ensure_creds
349 [ -d .terraform ] || run_tofu init -input=false
350 plan_guard - "$@"
351 ;;
352
353apply)
354 # The self-serve apply: same credential fetch, same destroy guard. What
355 # applies is the SAVED plan the guard inspected β€” never a fresh one β€” so a
356 # refresh between guard and apply can't smuggle a replacement in. The verb
357 # keeps its own explicit confirm (a saved-plan apply skips tofu's). Extra
358 # args pass through (e.g. `ops dev apply
359 # -target=aws_security_group.ie_node` to land a security-group change
360 # while something destructive is pending).
361 cd "$root/infra/dev-mesh"
362 ensure_creds
363 [ -d .terraform ] || run_tofu init -input=false
364 planfile="$(mktemp -t lchat-dev-planfile.XXXXXX)"
365 trap 'rm -f "$planfile"' EXIT
366 plan_guard "$planfile" "$@"
367 printf 'apply exactly this plan? (type yes): '
368 read -r answer || answer="" # EOF (no tty) refuses below instead of dying silently
369 [ "$answer" = yes ] || { echo "ops dev: not applied"; exit 1; }
370 run_tofu apply -input=false "$planfile"
371 ;;
372
373deploy)
374 targets="ie fi"; stage_only=no; with_home=no
375 for a in "$@"; do
376 case "$a" in
377 ie|fi) targets="$a" ;;
378 --stage-only) stage_only=yes ;;
379 --home) with_home=yes ;;
380 *) usage ;;
381 esac
382 done
383
384 ver="$(git -C "$root" rev-parse --short HEAD)"
385 if ! git -C "$root" diff --quiet || ! git -C "$root" diff --cached --quiet; then
386 ver="${ver}-dirty"
387 echo "ops dev: working tree has uncommitted changes β€” building anyway ($ver)" >&2
388 fi
389
390 tmp="$(mktemp -d -t lchat-dev.XXXXXX)"
391 trap 'rm -rf "$tmp"' EXIT
392 cd "$root" # build-linux.sh compiles the package at the current directory
393 for t in $targets; do
394 case "$t" in
395 ie) arch=arm64 ;; # Dublin is a Graviton box
396 fi) arch=amd64 ;;
397 esac
398 echo "ops dev: building $ver for linux/$arch ($t)"
399 OUT="$tmp/lchat-$t" ARCH="$arch" "$here/build-linux.sh" "$ver"
400 if [ "$with_home" = yes ]; then
401 # The home service is its own package/binary (like release-server.sh's
402 # service builds) β€” no version stamp, the unit check gates the swap.
403 echo "ops dev: building home for linux/$arch ($t)"
404 GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -o "$tmp/home-$t" ./home
405 fi
406 done
407
408 for t in $targets; do
409 if [ "$t" = ie ]; then box="$BOX_IE"; else box="$BOX_FI"; fi
410 echo "ops dev: staging on $t ($box)"
411 # Stage beside the live binary, never over it β€” the swap is its own step.
412 # An SSH hang here usually means you are outside the security group's
413 # /32 allowlist, not that the box is down: run `ops doctor dev`.
414 if [ "$t" = ie ]; then
415 # The hardening pass roots /opt/lchat β€” hand the staging dir back to
416 # the ssh user so plain scp can write it (the swap is sudo either way).
417 ssh_ie "$box" 'sudo mkdir -p /opt/lchat/incoming && sudo chown "$(id -un)" /opt/lchat/incoming'
418 scp_ie -q "$tmp/lchat-$t" "$box:/opt/lchat/incoming/lchat-new"
419 [ "$with_home" = yes ] && scp_ie -q "$tmp/home-$t" "$box:/opt/lchat/incoming/home-new"
420 else
421 ssh -o ConnectTimeout=10 "$box" 'sudo mkdir -p /opt/lchat/incoming && sudo chown "$(id -un)" /opt/lchat/incoming'
422 scp -q "$tmp/lchat-$t" "$box:/opt/lchat/incoming/lchat-new"
423 [ "$with_home" = yes ] && scp -q "$tmp/home-$t" "$box:/opt/lchat/incoming/home-new"
424 fi
425 done
426
427 if [ "$stage_only" = yes ]; then
428 echo "ops dev: staged at /opt/lchat/incoming/lchat-new on: $targets (not swapped)"
429 [ "$with_home" = yes ] && echo " (home staged beside it as incoming/home-new)"
430 echo " swap by hand when ready (pairs with env edits β€” see the cutover runbook)"
431 exit 0
432 fi
433
434 for t in $targets; do
435 echo "ops dev: swapping + restarting $t"
436 swap_box "$t" "$ver"
437 [ "$with_home" = yes ] && swap_home "$t" "$ver"
438 done
439 echo
440 echo "note: this opened dev-box SSH sessions β€” if this session hasn't written its"
441 echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\""
442 ;;
443
444provision)
445 # Refresh the dev boxes' systemd units from the binary they are already
446 # running, and route the short-link redirector at the dev apex.
447 #
448 # Why this is a verb and not a documented ssh sequence: `deploy` only swaps
449 # the binary and restarts the units that already exist, so a NEW unit (like
450 # lchat-links) never appears on a dev box no matter how many times you
451 # deploy. Only `lchat bootstrap` writes units β€” and running plain bootstrap
452 # here would be a trap, because it enables the hourly auto-updater, which
453 # would replace this box's unmerged code with the current stable build. The
454 # whole point of the dev mesh is that it is NOT a release channel. So this
455 # passes --no-update-timer (which disables the timer outright rather than
456 # merely skipping it) and then VERIFIES the timer really is off before
457 # touching anything else.
458 targets="ie fi"
459 for a in "$@"; do
460 case "$a" in
461 ie|fi) targets="$a" ;;
462 *) usage ;;
463 esac
464 done
465
466 for t in $targets; do
467 if [ "$t" = ie ]; then box="$BOX_IE"; run() { ssh_ie "$box" "$@"; }
468 else box="$BOX_FI"; run() { ssh -o ConnectTimeout=10 "$box" "$@"; }
469 fi
470 # Dublin holds the EIP that dev.11ch.at points at, so it is the dev apex
471 # and bootstraps AS it (one step: starts the redirector and registers the
472 # route). Helsinki is not the apex, so its bootstrap leaves the redirector
473 # disabled β€” which is the behaviour worth exercising here too.
474 if [ "$t" = ie ]; then apexflag="--links-apex $DEV_LINKS_HOST"; else apexflag=""; fi
475
476 # Ordering guard: provision drives flags that only exist in a NEW binary,
477 # and `deploy` is what puts one on the box. Running these in the wrong
478 # order otherwise fails deep inside bootstrap's flag parsing with
479 # "flag provided but not defined", which reads like a bug in this script
480 # rather than a missing step.
481 if ! run 'sudo /opt/lchat/lchat bootstrap -h 2>&1' | grep -q 'links-apex'; then
482 echo "ops dev: ABORT β€” the binary on $t predates --links-apex." >&2
483 echo " provision configures the box; deploy is what ships the code. Run:" >&2
484 echo " ./deploy/ops dev deploy $t" >&2
485 exit 1
486 fi
487
488 echo "ops dev: bootstrapping units on $t ($box)${apexflag:+ (as short-link apex $DEV_LINKS_HOST)}"
489 run "sudo /opt/lchat/lchat bootstrap --no-update-timer $apexflag"
490
491 # The invariant, asserted rather than assumed: if bootstrap ever regains a
492 # path that leaves the updater on, this box starts silently overwriting the
493 # unmerged code under test and the mesh stops meaning anything.
494 timer="$(run 'systemctl is-enabled lchat-update.timer 2>/dev/null || true')"
495 case "$timer" in
496 enabled)
497 echo "ops dev: ABORT β€” $t's auto-update timer is ENABLED after bootstrap." >&2
498 echo " The dev mesh runs unmerged code; an updater pass would replace it with" >&2
499 echo " the current stable build. Disable it before using this box:" >&2
500 echo " ssh $box 'sudo systemctl disable --now lchat-update.timer'" >&2
501 exit 1 ;;
502 *) echo " $t: auto-update timer is '$timer' (not enabled β€” correct for dev)" ;;
503 esac
504
505 links="$(run 'systemctl is-active lchat-links 2>/dev/null || true')"
506 if [ -n "$apexflag" ]; then
507 echo " $t: lchat-links is $links (apex β€” expected active)"
508 [ "$links" = active ] || {
509 echo "ops dev: $t is the apex but its links unit did not start β€” inspect:" >&2
510 echo " ssh $box journalctl -u lchat-links -n 40 --no-pager" >&2
511 exit 1
512 }
513 else
514 echo " $t: lchat-links is $links (not the apex β€” expected inactive)"
515 [ "$links" != active ] || echo "ops dev: note β€” $t runs the redirector but isn't the apex" >&2
516 fi
517 done
518
519 # bootstrap --links-apex registered the route above, so there is no separate
520 # add-service step: prove the result instead.
521 case "$targets" in *ie*)
522 # Prove it end to end. The certificate is issued lazily on first handshake,
523 # so a cold apex can take a few seconds β€” and a TLS failure here is the
524 # exact symptom of the production bug this rehearses (an unrouted host gets
525 # no certificate at all, so it fails the handshake rather than 404ing).
526 echo "ops dev: checking https://$DEV_LINKS_HOST/ ..."
527 apex=""
528 for _ in 1 2 3 4 5 6; do
529 apex="$(curl -sS -o /dev/null -m 15 -w '%{http_code} -> %{redirect_url}' "https://$DEV_LINKS_HOST/" 2>&1 || true)"
530 case "$apex" in 30*) break ;; esac
531 sleep 5
532 done
533 echo " apex: $apex"
534 echo " (expect a 30x to elevenmessenger.com β€” that is the redirector answering)"
535 case "$apex" in
536 30*) ;;
537 *) echo "ops dev: the apex did not redirect. If this is a TLS error, check the" >&2
538 echo " A record exists (ops dev apply lands aws_route53_record.dev_apex) β€”" >&2
539 echo " a *.dev.11ch.at wildcard does NOT cover the bare apex." >&2
540 exit 1 ;;
541 esac
542
543 # /l/<host> only redirects to a host that verifiably runs lchat, so point it
544 # at a real dev space rather than a made-up name.
545 probe="${LCHAT_DEV_LINK_PROBE:-}"
546 if [ -n "$probe" ]; then
547 echo "ops dev: checking https://$DEV_LINKS_HOST/l/$probe ..."
548 echo " /l/: $(curl -sS -o /dev/null -m 20 -w '%{http_code} -> %{redirect_url}' "https://$DEV_LINKS_HOST/l/$probe" 2>&1 || true)"
549 echo " (expect a 30x to https://$probe β€” a 404 means the target failed verification)"
550 else
551 echo "ops dev: set LCHAT_DEV_LINK_PROBE=<a live dev space host> to also test /l/<host>"
552 fi
553 ;; esac
554
555 echo
556 echo "note: this opened dev-box SSH sessions β€” if this session hasn't written its"
557 echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\""
558 ;;
559
560rebuild)
561 # Destroy + recreate the Dublin box β€” the deliberate key rotation. EC2
562 # injects the keypair only at first boot, so this is how you take over a box
563 # whose key you don't hold: commit YOUR public key + /32 to compute.tf,
564 # replace the instance, and it boots trusting only you. The EIP, S3 bucket
565 # (hibernated spaces, mesh membership, the id ledger), IAM, and the Route53
566 # zone are separate resources and survive; the box's /data does not.
567 confirm=no; adopt=no
568 for a in "$@"; do case "$a" in --yes) confirm=yes ;; --adopt) adopt=yes ;; *) usage ;; esac; done
569 ct="$root/infra/dev-mesh/compute.tf"
570
571 # A fresh box trusts exactly one key β€” make sure it will be one you hold.
572 # Generating is safe (refuses to overwrite); committing it is the PR's job.
573 if [ ! -f "$KEY_IE" ]; then
574 echo "ops dev: no $KEY_IE β€” generating a fresh box key (never vaulted; the"
575 echo " rebuilt box trusts it alone, so share it with the other operators)"
576 ssh-keygen -q -t ed25519 -N '' -C eleven-dev-box -f "$KEY_IE"
577 fi
578 mypub="$(ssh-keygen -y -f "$KEY_IE" | awk '{print $1" "$2}')"
579 tfpub="$(awk '/resource "aws_key_pair" "ie_node"/,/^}/' "$ct" \
580 | sed -n 's/.*public_key *= *"\([^"]*\)".*/\1/p' | awk '{print $1" "$2}')"
581 if [ "$mypub" != "$tfpub" ]; then
582 echo "ops dev: compute.tf's key pair is NOT your key β€” tofu applies what the file says," >&2
583 echo " so the rebuilt box would trust someone else. Put your public key in" >&2
584 echo " aws_key_pair.ie_node (a reviewed PR β€” the public key is committed on purpose):" >&2
585 echo " public_key = \"$(ssh-keygen -y -f "$KEY_IE")\"" >&2
586 exit 1
587 fi
588
589 # Same rule for the SSH allowlist: outside it, the post-rebuild SSH wait
590 # would hang forever and read as a dead box. TF_VAR_ssh_ingress_cidr (a
591 # JSON list) overrides the committed default, same as for tofu itself;
592 # either way, every a.b.c.d/nn token counts.
593 cidrs="$(printf '%s' "${TF_VAR_ssh_ingress_cidr:-}" \
594 | grep -oE '[0-9]+(\.[0-9]+){3}/[0-9]+' || true)"
595 [ -n "$cidrs" ] || cidrs="$(awk '/variable "ssh_ingress_cidr"/,/^}/' "$ct" \
596 | grep -oE '[0-9]+(\.[0-9]+){3}/[0-9]+' || true)"
597 mine="$(curl -s -m5 https://api.ipify.org 2>/dev/null || true)"
598 if [ -z "$mine" ] || ! printf '%s\n' "$cidrs" | sed 's|/.*||' | grep -qxF "$mine"; then
599 echo "ops dev: the security group would admit [$(printf '%s' "$cidrs" | tr '\n' ' ')] but your IP" >&2
600 echo " is ${mine:-unknown} β€” the rebuilt box would be unreachable from here. Add your /32" >&2
601 echo " to ssh_ingress_cidr in compute.tf (removing an entry revokes that operator)." >&2
602 exit 1
603 fi
604
605 if [ "$confirm" != yes ]; then
606 cat <<EOF
607ops dev rebuild β€” this DESTROYS and recreates the Dublin box.
608 dies: everything on its disk β€” /data (non-hibernating dev spaces, the
609 router registry, router/cloud/home env files and their tokens)
610 survives: the EIP (DNS keeps pointing right), the S3 bucket (hibernated
611 spaces, mesh membership, the id ledger), IAM, the Route53 zone
612 needs: LCHAT_MESH_TOKEN re-paired with Helsinki afterwards, and the env
613 files rebuilt (the runbook's Step 3 blocks)
614With --adopt: no destroy β€” the box was ALREADY replaced (an AMI-drift apply,
615a console rebuild); this just runs the same provisioning against it.
616Coordinate first β€” this tears down the live half of the dev mesh until
617re-provisioned. Re-run with --yes to proceed (tofu will still show its plan
618and ask for approval).
619EOF
620 exit 1
621 fi
622
623 if [ "$adopt" = yes ]; then
624 # Adopt: the instance was already replaced outside this verb, so there is
625 # nothing for tofu to do β€” which also means no AWS/DNSimple credentials
626 # are needed. The facts tofu would have output come from elsewhere: the
627 # EIP survives replacement (it IS the committed BOX_IE address), and the
628 # bucket is shared with Helsinki, which survived and knows it.
629 ip="${BOX_IE#*@}"
630 bucket="$(ssh -o ConnectTimeout=10 "$BOX_FI" \
631 "sudo grep '^LCHAT_HIBERNATE_BUCKET=' /data/router.env | cut -d= -f2-" 2>/dev/null || true)"
632 [ -n "$bucket" ] || { echo "ops dev: couldn't read the mesh bucket from Helsinki's router.env β€” is $BOX_FI up?" >&2; exit 1; }
633 else
634 cd "$root/infra/dev-mesh"
635 ensure_creds
636 [ -d .terraform ] || run_tofu init -input=false
637 # tofu's own interactive approve is the second gate β€” read the plan: it
638 # should replace aws_instance.ie_node (and the key pair), nothing else
639 # destructive. (-replace overrides the instance's ignore_changes=[ami],
640 # so pinning the AMI does not break deliberate rebuilds.)
641 run_tofu apply -replace=aws_instance.ie_node
642
643 ip="$(run_tofu output -raw ie_node_ip)"
644 bucket="$(run_tofu output -raw mesh_bucket)"
645 fi
646 # Same EIP, new host key β€” the stale known_hosts entry would make every ssh
647 # fail with "REMOTE HOST IDENTIFICATION HAS CHANGED", so drop it first.
648 ssh-keygen -R "$ip" >/dev/null 2>&1 || true
649 echo "ops dev: waiting for the new box to accept ssh ($ip)"
650 i=0
651 until ssh -i "$KEY_IE" -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
652 "ubuntu@$ip" true 2>/dev/null; do
653 i=$((i + 1))
654 [ "$i" -lt 60 ] || { echo "ops dev: box not reachable after 5m β€” check the AWS console" >&2; exit 1; }
655 sleep 5
656 done
657
658 # First provision: fresh arm64 binary + `lchat bootstrap` (writes the
659 # router/control/cloud units and starts the router). After this, routine
660 # updates go through `ops dev deploy ie`.
661 ver="$(git -C "$root" rev-parse --short HEAD)"
662 tmp="$(mktemp -d -t lchat-rebuild.XXXXXX)"
663 trap 'rm -rf "$tmp"' EXIT
664 cd "$root" # we were in infra/dev-mesh; build-linux.sh compiles the cwd package
665 echo "ops dev: building $ver for linux/arm64 and bootstrapping"
666 OUT="$tmp/lchat" ARCH=arm64 "$here/build-linux.sh" "$ver"
667 scp_ie -q "$tmp/lchat" "ubuntu@$ip:/tmp/lchat"
668 ssh_ie "ubuntu@$ip" 'sudo mkdir -p /opt/lchat && sudo mv /tmp/lchat /opt/lchat/lchat \
669 && sudo chmod +x /opt/lchat/lchat && sudo /opt/lchat/lchat bootstrap'
670
671 # The env files are derived facts plus one secret β€” encoded here so no
672 # runbook is needed. The mesh token is READ from the surviving Helsinki box
673 # (the pair must match) and never printed; the cloud admin token is freshly
674 # minted, because a rebuild is a new cloud.
675 echo "ops dev: writing /data/router.env + /data/cloud.env"
676 mesh_token="$(ssh -o ConnectTimeout=10 "$BOX_FI" \
677 "sudo grep '^LCHAT_MESH_TOKEN=' /data/router.env | cut -d= -f2-" 2>/dev/null || true)"
678 [ -n "$mesh_token" ] || echo "ops dev: WARNING: couldn't read Helsinki's mesh token β€”" \
679 "router.env is written without LCHAT_MESH_TOKEN; the mesh stays unpaired until it is set on both boxes" >&2
680 admin_token="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
681 {
682 printf 'LCHAT_MESH_DOMAINS=dev.11ch.at\n'
683 printf 'LCHAT_DNS_PROVIDER=route53\n'
684 printf 'LCHAT_WILDCARD_DOMAINS=dev.11ch.at\n'
685 printf 'AWS_REGION=eu-west-1\n'
686 printf 'LCHAT_HIBERNATE_BUCKET=%s\n' "$bucket"
687 printf 'LCHAT_MESH_REGION=ie\n'
688 printf 'LCHAT_MESH_SELF=https://one.ie.dev.11ch.at\n'
689 [ -z "$mesh_token" ] || printf 'LCHAT_MESH_TOKEN=%s\n' "$mesh_token"
690 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/router.env >/dev/null && sudo chmod 600 /data/router.env'
691 {
692 printf 'LCHAT_CLOUD_BASE_DOMAIN=dev.11ch.at\n'
693 printf 'LCHAT_CLOUD_ID_LEDGER=1\n'
694 printf 'LCHAT_HIBERNATE_BUCKET=%s\n' "$bucket"
695 printf 'LCHAT_HIBERNATE_REGION=eu-west-1\n'
696 printf 'LCHAT_CLOUD_REGION=ie\n'
697 printf 'LCHAT_ADMIN_TOKEN=%s\n' "$admin_token"
698 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/cloud.env >/dev/null && sudo chmod 600 /data/cloud.env'
699 ssh_ie "ubuntu@$ip" 'sudo systemctl restart lchat-router lchat-cloud lchat-control'
700
701 # A fresh registry knows no routes: the surviving peer bulk-seeds every
702 # in-scope route into it (meshreplicate.go β€” mesh-seed exists exactly for a
703 # freshly joined box). sudo strips env, so the peer's mesh config is
704 # sourced explicitly (the old runbook's hardest-won gotcha).
705 if [ -n "$mesh_token" ]; then
706 echo "ops dev: seeding routes from Helsinki"
707 ssh -o ConnectTimeout=10 "$BOX_FI" \
708 "sudo sh -c 'set -a; . /data/router.env; set +a; /opt/lchat/lchat mesh-seed https://one.ie.dev.11ch.at'" \
709 || echo "ops dev: WARNING: mesh-seed failed β€” rerun it on Helsinki when the router is up" >&2
710 fi
711
712 # controld extras: without these a rebuilt Dublin silently provisions
713 # non-hibernating, uncapped spaces β€” the opposite of the dev mesh's point.
714 echo "ops dev: writing /data/control.env"
715 {
716 printf 'LCHAT_CONTROL_HIBERNATE_CREATES=1\n'
717 printf 'LCHAT_MAX_INSTANCES=40\n'
718 printf 'LCHAT_HIBERNATE_BUCKET=%s\n' "$bucket"
719 printf 'LCHAT_HIBERNATE_REGION=eu-west-1\n'
720 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/control.env >/dev/null && sudo chmod 600 /data/control.env && sudo systemctl restart lchat-control'
721
722 # The dev home server (encoded here per the old completion note). bootstrap
723 # already wrote its unit; home is its own binary. The env carries the
724 # WebAuthn RP origin β€” it MUST stay home.dev.elevenmessenger.com or every
725 # previously-enrolled dev passkey orphans β€” and one caller token per region,
726 # minted from each cloud's admin API over its local socket. Token values
727 # move through pipes and shell variables only; nothing prints them.
728 echo "ops dev: building + standing up the dev home server"
729 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o "$tmp/home" ./home
730 scp_ie -q "$tmp/home" "ubuntu@$ip:/tmp/home"
731 ssh_ie "ubuntu@$ip" 'sudo mv /tmp/home /opt/lchat/home && sudo chmod +x /opt/lchat/home && sudo chown root:root /opt/lchat/home'
732
733 tok_of() { sed -n 's/.*"token":"\([^"]*\)".*/\1/p'; }
734 ie_caller="$(ssh_ie "ubuntu@$ip" 'sudo sh' <<EOS | tok_of
735curl -s --unix-socket /run/lchat/cloud.sock -X POST http://cloud/api/admin/tokens \
736 -H "Authorization: Bearer $admin_token" -d '{"label":"home.dev"}'
737EOS
738)"
739 fi_caller="$(ssh -o ConnectTimeout=10 "$BOX_FI" 'sudo sh' <<'EOS' | tok_of
740ADM="$(grep '^LCHAT_ADMIN_TOKEN=' /data/cloud.env | cut -d= -f2-)"
741curl -s --unix-socket /run/lchat/cloud.sock -X POST http://cloud/api/admin/tokens \
742 -H "Authorization: Bearer $ADM" -d '{"label":"home.dev"}'
743EOS
744)"
745 [ -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
746 [ -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
747 {
748 printf 'LCHAT_HOME_ORIGIN=https://home.dev.elevenmessenger.com\n'
749 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"
750 printf 'LCHAT_CLOUD_DEFAULT_REGION=ie\n'
751 # Sign in with Apple: the audiences are committed facts (web Services ID +
752 # iOS bundle id); the account-keying pepper is minted fresh β€” fine on a
753 # rebuilt (empty) anchor DB, where there are no siwa anchors to orphan.
754 # Without these the /siwa/return trampoline answers 404 and the Apple door
755 # dies silently (2026-07-23). Google/Microsoft doors need OAuth secrets and
756 # stay dark until set by hand.
757 printf 'LCHAT_HOME_SIWA_CLIENT_IDS=com.elevenmessenger.signin,com.elevenmessenger.messenger\n'
758 printf 'LCHAT_HOME_SIWA_PEPPER=%s\n' "$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
759 } | ssh_ie "ubuntu@$ip" 'sudo tee /data/home.env >/dev/null && sudo chmod 600 /data/home.env'
760 ssh_ie "ubuntu@$ip" 'sudo systemctl enable --now lchat-home'
761 # Service routes are local registry rows mesh-seed doesn't carry; re-adding
762 # an existing row fails harmlessly on a re-run.
763 ssh_ie "ubuntu@$ip" 'sudo /opt/lchat/lchat add-service home.dev.elevenmessenger.com --socket /run/lchat/home.sock' \
764 || echo "ops dev: (home route already present)"
765 ssh_ie "ubuntu@$ip" 'sudo /opt/lchat/lchat add-service cloud.ie.dev.11ch.at --socket /run/lchat/cloud.sock' \
766 || echo "ops dev: (cloud route already present)"
767
768 cat <<EOF
769
770Dublin rebuilt on $ip (same EIP β€” DNS untouched), running $ver, trusting only
771your key: env files written (router/cloud/control/home), units restarted,
772routes seeded from Helsinki, home + cloud service routes added.
773Still not encoded here β€” EXTEND THIS VERB when next needed, don't reach for a
774runbook: the dev connect broker (lchat-connect-dev needs the vault's sandbox
775Stripe pair) and the cloud.env demo/pool/billing extras.
776 verify: ssh ubuntu@$ip journalctl -u lchat-router -n 40 --no-pager | grep -i wildcard
777 curl -s https://home.dev.elevenmessenger.com/api/features
778 then: land the compute.tf PR if it changed for this rebuild, and
779 ./deploy/ops ledger "rebuilt the dev-mesh Dublin box"
780EOF
781 ;;
782
783*) usage ;;
784esac
785