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# πŸ€– doctor β€” one read-only preflight for every credential and access path an
3# operator needs. Answers "can I actually deploy right now?" in one command,
4# instead of six documents. Verb one of the ops surface (`./deploy/ops`) β€” as
5# verbs land there, their prerequisites get a check here, so the preflight
6# stays honest.
7#
8# ./deploy/ops doctor # everything (doctor.sh runs standalone too)
9# ./deploy/ops doctor release # one section: tools|release|fleet|dev
10# ./deploy/ops doctor --no-ssh # skip the box checks (see the note below)
11# ./deploy/ops doctor --no-unlock # never prompt (it prompts to unlock by
12# # default, so the key check is real)
13#
14# Why this exists: the knowledge for this was spread across CLAUDE.md,
15# CLAUDE.local.md, docs/server-releases.md, docs/multi-edge-dev-cutover.md, the
16# vault's RUNBOOK.md, and raw tofu source β€” and drifted from reality in four
17# places at once. Prose drifts silently; a script that runs drifts loudly. Every
18# check below is a fact about *this machine right now*, not a claim in a doc.
19#
20# Two rules this script holds to, because it touches real credentials:
21# 1. It NEVER prints a secret. Checks assert reachability/permission and
22# discard output β€” a fetched credential is tested for existence, never
23# echoed. The one interactive moment (the Bitwarden unlock) captures the
24# session token into a variable and never displays it.
25# 2. It NEVER mutates anything β€” no deploys, no writes, no unlocks. Every
26# remote command is a read (`lchat instances`, `id`, `groups`).
27#
28# NOTE: the `fleet` section opens real operator SSH sessions, which incur a
29# why-entry in the public access ledger (web/status/ssh/, RUNBOOK.md Β§ Box
30# access). Use --no-ssh when you only want the local picture.
31set -u
32
33section="${1:-all}"
34no_ssh=no
35unlock=yes
36for a in "$@"; do
37 [ "$a" = "--no-ssh" ] && no_ssh=yes
38 [ "$a" = "--no-unlock" ] && unlock=no
39done
40case "$section" in --*) section=all ;; esac
41
42root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
43# The Bitwarden item names checked below β€” the vault contract.
44# shellcheck source=deploy/vault-items.sh
45. "$root/deploy/vault-items.sh"
46# shellcheck source=deploy/bw-lib.sh
47. "$root/deploy/bw-lib.sh"
48# Only used to point at gen-fleet-conf.sh when a tunnel is down.
49VAULT="${LCHAT_VAULT_DIR:-$HOME/elevenmessenger/secrets-vault}"
50# Box aliases from ~/.ssh/config; `ssh -G` resolves user+hostname, so the ssh
51# config stays the single source of truth for how to reach a box.
52BOXES="${LCHAT_BOXES:-elevenbox kepler}"
53RELEASES_REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}"
54
55fails=0
56warns=0
57
58# --- output helpers -------------------------------------------------------
59# Two columns: a fixed-width label, then a verdict. Fix hints are indented
60# under the failing line so the next action is always adjacent to the problem.
61ok() { printf ' \033[32mok\033[0m %s\n' "$1"; }
62bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fails=$((fails + 1)); }
63warn() { printf ' \033[33mwarn\033[0m %s\n' "$1"; warns=$((warns + 1)); }
64fix() { printf ' β†’ %s\n' "$1"; }
65head_() { printf '\n\033[1m%s\033[0m\n' "$1"; }
66have() { command -v "$1" >/dev/null 2>&1; }
67
68want() { [ "$section" = all ] || [ "$section" = "$1" ]; }
69
70# --- tools ----------------------------------------------------------------
71check_tools() {
72 head_ "Local tooling"
73 # Each tool is annotated with what breaks without it, so a missing one is
74 # self-explanatory rather than a bare name.
75 for pair in \
76 "bw:release signing key (Bitwarden)" \
77 "gh:publishing to the releases repo" \
78 "jq:parsing manifests + bw/gh output" \
79 "go:building the release binaries" \
80 "tofu:the dev-mesh infrastructure" \
81 "aws:the dev-mesh + release S3 mirror" \
82 "ssh:reaching every box"
83 do
84 t="${pair%%:*}"; why="${pair#*:}"
85 if have "$t"; then ok "$t"; else
86 bad "$t β€” needed for $why"
87 fix "brew install $t"
88 fi
89 done
90}
91
92# --- release publishing ---------------------------------------------------
93# Confirm the item is reachable WITHOUT printing it: we only look at whether
94# the fetch succeeded, never at the value.
95check_signing_item() {
96 if bw_run 30 get item "$BW_ITEM_SIGNING_KEY" \
97 >/dev/null 2>&1; then
98 ok "signing key item is present"
99 else
100 bad "signing key item not found in this vault"
101 fix "check it is shared to your org collection (BITWARDEN-MIGRATION.md)"
102 fi
103}
104
105check_release() {
106 head_ "Publishing a release"
107
108 if ! have bw; then
109 bad "bw not installed β€” cannot reach the signing key"
110 fix "brew install bitwarden-cli"
111 else
112 st="$(bw_run 20 status 2>/dev/null | tr ',' '\n' | sed -n 's/.*"status":"\([a-z]*\)".*/\1/p')"
113 case "$st" in
114 unlocked)
115 ok "bitwarden unlocked"
116 check_signing_item ;;
117 locked)
118 # Unlock by default: "locked, so I can't tell you" is a non-answer, and
119 # the whole point is to find out whether the key is actually reachable.
120 # The session is this process's only β€” a child cannot export BW_SESSION
121 # back to the calling shell β€” so this deepens the check and nothing
122 # more. Without a tty there is nobody to prompt, so degrade instead of
123 # hanging; --no-unlock forces that path deliberately.
124 if [ "$unlock" = yes ] && [ -t 0 ]; then
125 printf ' unlocking bitwarden to verify the signing key\n'
126 # Our prompt, never bw's; watchdogged (bw-lib.sh) β€” bw's own
127 # prompt inside $(...) is invisible and reads as a hang.
128 BW_SESSION="$(bw_unlock_session)" || BW_SESSION=""
129 export BW_SESSION
130 if [ -n "${BW_SESSION:-}" ]; then
131 ok "bitwarden unlocked (this run only)"
132 check_signing_item
133 else
134 bad "unlock failed β€” signing key not verified"
135 fix "wrong master password, or run 'bw unlock' by hand to see why"
136 fi
137 else
138 warn "bitwarden is locked β€” signing key not verified"
139 fix "bw unlock # then paste the 'export BW_SESSION=...' line it prints, and re-run"
140 fi ;;
141 *)
142 bad "bitwarden not logged in"
143 fix "bw login # EU vault: https://vault.bitwarden.eu" ;;
144 esac
145 fi
146
147 if have gh && gh auth status >/dev/null 2>&1; then
148 ok "gh authenticated"
149 push="$(gh api "repos/$RELEASES_REPO" --jq .permissions.push 2>/dev/null)"
150 if [ "$push" = "true" ]; then ok "write access to $RELEASES_REPO"; else
151 bad "no write access to $RELEASES_REPO"
152 fix "ask for push rights on the releases repo"
153 fi
154 else
155 bad "gh not authenticated"
156 fix "gh auth login"
157 fi
158
159 # release-server.sh refuses to build from a dirty tree; surface that here
160 # rather than 20 minutes into a release.
161 if [ -n "$(git -C "$root" status --porcelain 2>/dev/null)" ]; then
162 warn "working tree is dirty β€” release-server.sh will refuse to publish"
163 fix "commit or stash, and publish from a clean main"
164 else
165 ok "working tree is clean"
166 fi
167}
168
169# --- fleet boxes ----------------------------------------------------------
170check_fleet() {
171 head_ "Fleet boxes (WireGuard + SSH + sudo)"
172
173 if [ "$no_ssh" = yes ]; then
174 warn "skipped (--no-ssh)"
175 return
176 fi
177
178 for box in $BOXES; do
179 # ssh -G resolves the config the way ssh itself will, so we test what will
180 # actually happen rather than re-parsing ~/.ssh/config by hand.
181 hostname="$(ssh -G "$box" 2>/dev/null | sed -n 's/^hostname //p' | head -1)"
182 user="$(ssh -G "$box" 2>/dev/null | sed -n 's/^user //p' | head -1)"
183 # One connection attempt tells us both things. A tunnel that is down and a
184 # key that is refused look identical from a bare exit code, so classify the
185 # error text instead β€” and skip ping, whose -W flag means milliseconds on
186 # macOS and seconds on Linux.
187 err="$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$box" true 2>&1)"
188 if [ -z "$err" ]; then
189 ok "$box β€” tunnel up, ssh as '$user' ($hostname)"
190 else
191 case "$err" in
192 *"Permission denied"*)
193 bad "$box β€” reachable, but ssh as '$user' was refused"
194 fix "confirm which key the box authorises; a key not at a default"
195 fix "path needs IdentityFile + IdentitiesOnly in ~/.ssh/config" ;;
196 *"Host key verification failed"*)
197 # The box answered β€” BatchMode just can't accept a first-seen host
198 # key. Distinct from unreachable/refused, and self-serve fixable.
199 bad "$box β€” reachable, but its host key isn't in known_hosts yet"
200 fix "one-off interactive: ssh $box # accept the host key, then re-run" ;;
201 *"Could not resolve hostname"*)
202 # ssh -G echoes an unknown alias back rather than failing, so an
203 # absent Host block surfaces here as a resolve error.
204 bad "$box β€” no Host block in ~/.ssh/config (or DNS failure)"
205 fix "add a Host block for $box, or set LCHAT_BOXES" ;;
206 *"timed out"*|*"No route"*|*"Network is unreachable"*)
207 bad "$box β€” $hostname unreachable (WireGuard down?)"
208 fix "bring the tunnel up, then re-run; regenerate it with"
209 fix "cd $VAULT && ./gen-fleet-conf.sh > ~/Downloads/fleet.conf" ;;
210 *)
211 bad "$box β€” $(printf '%s' "$err" | head -1)" ;;
212 esac
213 continue
214 fi
215
216 # lchat-operators is what the sudoers allowlist keys off; without it the
217 # ssh login succeeds but every privileged command fails.
218 if ssh -o BatchMode=yes "$box" 'id -nG' 2>/dev/null \
219 | tr ' ' '\n' | grep -qx lchat-operators; then
220 ok "$box β€” in lchat-operators"
221 elif [ "$user" = root ]; then
222 ok "$box β€” root (allowlist not applicable)"
223 else
224 bad "$box β€” not in lchat-operators"
225 fix "ask an operator to add you to the group"
226 fi
227
228 if ssh -o BatchMode=yes "$box" \
229 'sudo -n /opt/lchat/lchat instances' >/dev/null 2>&1; then
230 ok "$box β€” sudo allowlist works (can deploy)"
231 else
232 bad "$box β€” 'sudo -n lchat' denied"
233 fix "check /etc/sudoers.d/lchat-operators (deploy/sudoers-operators)"
234 fi
235 done
236}
237
238# --- dev mesh -------------------------------------------------------------
239check_dev() {
240 head_ "Dev mesh (Dublin + Helsinki)"
241
242 # The Dublin box authorises exactly one key, committed in compute.tf: the
243 # SHARED box key (its comment is the box's name, not a person's) β€” created
244 # at provisioning, never vaulted, so copies exist only on operator machines.
245 keyfile="$HOME/.ssh/eleven-dev-box"
246 if [ -f "$keyfile" ]; then
247 ok "eleven-dev-box key present"
248 else
249 bad "no $keyfile"
250 fix "the shared dev-box key β€” not in any vault; ask an operator who holds it"
251 fi
252
253 # What `ops dev deploy` actually needs: a working SSH session to each box.
254 # Same one-attempt classification as the fleet section β€” and here a timeout
255 # is USUALLY the single-/32 security group (named by the CIDR check below),
256 # not a dead box.
257 if [ "$no_ssh" = yes ]; then
258 warn "dev box ssh checks skipped (--no-ssh)"
259 else
260 for spec in "ie:${LCHAT_DEV_BOX_IE:-ubuntu@54.154.95.205}" \
261 "fi:${LCHAT_DEV_BOX_FI:-root@157.180.123.183}"; do
262 node="${spec%%:*}"; dest="${spec#*:}"
263 if [ "$node" = ie ] && [ -f "$keyfile" ]; then
264 err="$(ssh -i "$keyfile" -o ConnectTimeout=8 -o BatchMode=yes "$dest" true 2>&1)"
265 else
266 err="$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$dest" true 2>&1)"
267 fi
268 if [ -z "$err" ]; then
269 ok "dev $node β€” ssh works ($dest)"
270 else
271 case "$err" in
272 *"Permission denied"*)
273 bad "dev $node β€” reachable, but ssh as '$dest' was refused"
274 fix "ie wants ~/.ssh/eleven-dev-box; fi wants the 1Password hetzner key" ;;
275 *"Host key verification failed"*)
276 bad "dev $node β€” reachable, but its host key isn't in known_hosts yet"
277 fix "one-off interactive: ssh $dest # accept the host key, then re-run" ;;
278 *"timed out"*|*"No route"*|*"Network is unreachable"*)
279 bad "dev $node β€” unreachable; if the IP check below fails, that is why"
280 fix "your IP must be in the security group's allowlist (ssh_ingress_cidr)" ;;
281 *)
282 bad "dev $node β€” $(printf '%s' "$err" | head -1)" ;;
283 esac
284 fi
285 done
286 fi
287
288 # ops dev pulls AWS_*/DNSIMPLE_* from the per-operator dev agent item in
289 # Bitwarden itself, so a standing profile is a convenience, not a
290 # prerequisite β€” only "no route to any credential" is a failure.
291 if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
292 ok "AWS credentials in the environment"
293 elif aws configure list-profiles 2>/dev/null | grep -qx eleven-dev; then
294 ok "aws profile 'eleven-dev' configured"
295 elif have bw; then
296 warn "no standing AWS auth β€” ops dev will pull the dev agent creds from Bitwarden per run"
297 else
298 bad "no AWS auth at all (no env creds, no 'eleven-dev' profile, no bw)"
299 fix "install bw, or seed the profile from your dev agent item (docs/secrets.md Β§ AWS)"
300 fi
301
302 # SSH ingress is a short per-operator /32 allowlist (compute.tf,
303 # ssh_ingress_cidr), so being outside it looks exactly like the box being
304 # down. Say which it is. Anchored to the variable block β€” every a.b.c.d/nn
305 # inside it counts, so keep literal IPs out of its description text.
306 cidrs="$(awk '/variable "ssh_ingress_cidr"/,/^}/' \
307 "$root/infra/dev-mesh/compute.tf" 2>/dev/null \
308 | grep -oE '[0-9]+(\.[0-9]+){3}/[0-9]+')"
309 if [ -n "$cidrs" ]; then
310 mine="$(curl -s -m5 https://api.ipify.org 2>/dev/null)"
311 if [ -n "$mine" ] && printf '%s\n' "$cidrs" | sed 's|/.*||' | grep -qxF "$mine"; then
312 ok "your IP ($mine) is in the dev security group's allowlist"
313 else
314 bad "your IP (${mine:-unknown}) is not in [$(printf '%s' "$cidrs" | tr '\n' ' ')] β€” SSH will hang, not refuse"
315 fix "add your /32 to var.ssh_ingress_cidr (a PR), then: ops dev apply"
316 fix "removing someone else's /32 revokes their access β€” only with their say-so"
317 fi
318 fi
319}
320
321# --- run ------------------------------------------------------------------
322printf '\033[1mlchat operator preflight\033[0m β€” read-only; no secrets printed\n'
323
324want tools && check_tools
325want release && check_release
326want fleet && check_fleet
327want dev && check_dev
328
329printf '\n'
330if [ "$fails" -eq 0 ] && [ "$warns" -eq 0 ]; then
331 printf '\033[32mAll checks passed.\033[0m\n'
332elif [ "$fails" -eq 0 ]; then
333 printf '\033[33m%s warning(s), no failures.\033[0m\n' "$warns"
334else
335 printf '\033[31m%s failure(s), %s warning(s).\033[0m\n' "$fails" "$warns"
336fi
337# Non-zero on hard failures only, so this is usable as a gate in other scripts
338# while warnings (a locked vault, a dirty tree) stay informational.
339[ "$fails" -eq 0 ]
340