#!/usr/bin/env sh # ๐Ÿค– ops provision โ€” refresh a fleet box's systemd units, and declare which box # is the short-link apex. # # ./deploy/ops provision units [box...] # refresh unit files (lchat bootstrap) # ./deploy/ops provision apex [box] # ...and make that box the apex for # ./deploy/ops provision status [box...] # read-only: who runs what # ./deploy/ops provision operator (--github | --keys-file ) # # create/converge an operator account # # Why this verb exists: **no new systemd unit ever reaches an existing box on # its own.** `deploy` and `ship` swap binaries and restart units that already # exist; the hourly auto-updater does the same. Only `lchat bootstrap` writes # unit files, and nothing re-runs it after the first provision. So a unit added # to the repo lands on fresh boxes and silently never appears on the fleet โ€” # which is exactly how the short-link redirector shipped in the binary and ran # on no box at all (issue #905). # # `provision units` is therefore the missing fleet-side counterpart to # `ops dev provision`, and the thing to run after merging any change that adds # or edits a unit file. # # What it does NOT do: touch /data, instances, or versions. `bootstrap` is # idempotent and data-safe โ€” it writes units, ensures directories and the # service user, and restarts the ROUTER. That router restart is the one # member-visible effect (a sub-second blip on a retrying router), which is why # this is a deliberate verb you run, never something folded into a deploy. # # NEVER point this at a dev-mesh box. Plain `bootstrap` ENABLES the hourly # auto-updater, which would replace the dev mesh's unmerged code with the # current stable build โ€” `ops dev provision` exists for those boxes and passes # --no-update-timer. The only guardrail here is the $LCHAT_BOXES default, so # be deliberate when overriding it or naming boxes explicitly. # # `provision apex` additionally passes --links-apex, which starts the # redirector and registers as a route pointing at its socket. Exactly # one box in a fleet is the apex; every other box keeps the unit written and # disabled. After the first time, the registry route is the source of truth โ€” # plain `provision units` converges to it, so the apex survives later runs # without anyone remembering a flag. set -eu # Same default fleet as doctor/ship, same override. BOXES="${LCHAT_BOXES:-elevenbox kepler}" # The box that fronts the short-link apex, when `apex` is given no box. APEX_BOX="${LCHAT_APEX_BOX:-elevenbox}" usage() { echo "usage: ops provision units [box...] | ops provision apex [box] |" >&2 echo " ops provision status [box...] |" >&2 echo " ops provision operator (--github | --keys-file )" >&2 echo >&2 echo " units re-run 'lchat bootstrap' so newly-added systemd units land (restarts the router)" >&2 echo " apex the same, declaring that box the short-link apex for " >&2 echo " status read-only: unit + route state, no changes" >&2 echo " operator create/converge a named operator account on (bootstrap --add-operator)" >&2 exit 2 } # on : privileged box command through the committed sudo # allowlist (`/opt/lchat/lchat *`, deploy/sudoers-operators). Operator SSH # rides WireGuard and the username comes from ~/.ssh/config โ€” root SSH is # bootstrap-only, so no user is baked in here. on() { box="$1"; shift; ssh -o ConnectTimeout=10 "$box" "$@"; } # report : the read-only picture โ€” is the redirector running here, and # which host (if any) routes to it. report() { box="$1" printf ' %s:\n' "$box" # is-enabled/is-active exit non-zero for the states we most want to SEE # (disabled, inactive, not-found), and is-enabled adds a stderr explanation # for a missing unit โ€” so discard stderr, keep the first line, and only # substitute a word when there was genuinely no output. enabled="$(on "$box" 'systemctl is-enabled lchat-links 2>/dev/null | head -1' || true)" active="$(on "$box" 'systemctl is-active lchat-links 2>/dev/null | head -1' || true)" printf ' lchat-links %s / %s\n' "${enabled:-not-found}" "${active:-inactive}" route="$(on "$box" "sudo /opt/lchat/lchat instances 2>/dev/null | grep -F '/run/lchat/links.sock' || true")" if [ -n "$route" ]; then printf ' apex route %s\n' "$(printf '%s' "$route" | awk '{print $1}')" else printf ' apex route (none โ€” not the short-link apex)\n' fi } cmd="${1:-}" [ $# -gt 0 ] && shift case "$cmd" in units) boxes="${*:-$BOXES}" echo "ops provision: refreshing systemd units on: $boxes" echo " (this restarts the router on each โ€” a sub-second blip, not a data operation)" for box in $boxes; do echo "ops provision: $box" on "$box" 'sudo /opt/lchat/lchat bootstrap' report "$box" done echo echo "note: this opened box SSH sessions โ€” if this session hasn't written its" echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\"" ;; apex) host="${1:-}"; [ -n "$host" ] || usage shift box="${1:-$APEX_BOX}" # The apex is a real, public hostname whose DNS must already point at this # box โ€” the router issues its certificate lazily on first handshake, and an # apex is NOT covered by a *.domain wildcard, so this is the one part the # script can't do for you. Say so plainly rather than failing later at TLS. echo "ops provision: making $box the short-link apex for $host" echo " prerequisite: $host must already resolve to $box, with its OWN A record." # Only worth spelling out where a covering wildcard plausibly exists: a host # under a parent domain (dev.11ch.at), not a registrable apex (11ch.at), # where "a *.ch.at wildcard" would be nonsense. case "$host" in *.*.*) echo " A *.${host#*.} wildcard does NOT cover $host โ€” an apex never is." ;; esac on "$box" "sudo /opt/lchat/lchat bootstrap --links-apex $host" report "$box" echo "ops provision: checking https://$host/ ..." # The certificate is issued on the first handshake, so a cold apex can take a # few seconds. A TLS failure here (rather than an HTTP status) almost always # means DNS doesn't point at this box, or the route didn't register. apex="" for _ in 1 2 3 4 5 6; do apex="$(curl -sS -o /dev/null -m 15 -w '%{http_code} -> %{redirect_url}' "https://$host/" 2>&1 || true)" case "$apex" in 30*) break ;; esac sleep 5 done echo " apex: $apex" case "$apex" in 30*) echo " (a 30x to the marketing site is the redirector answering โ€” short links are live)" ;; *) echo "ops provision: $host did not redirect." >&2 echo " TLS error -> DNS probably doesn't point at $box, or the route didn't register" >&2 echo " 404/timeout -> check: ssh $box journalctl -u lchat-links -n 40 --no-pager" >&2 exit 1 ;; esac echo echo "note: this opened box SSH sessions โ€” if this session hasn't written its" echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\"" ;; status) boxes="${*:-$BOXES}" echo "ops provision: short-link state" for box in $boxes; do report "$box"; done ;; # The later-joiner half of operator setup (docs/instance-router.md ยง # Hardening): the first operators were created in the box's one root # session, and a box hardened before an operator joined has no root shell # left โ€” kepler had James's WireGuard peer but no account. This drives the # allowlist-reachable `bootstrap --add-operator`, keys piped on stdin. # The account is only HALF of access: the operator's WireGuard peer must # already be on the box's wg0 (see RUNBOOK ยง Box access), or nothing routes. operator) name="${1:-}"; box="${2:-}"; src="${3:-}"; arg="${4:-}" [ -n "$name" ] && [ -n "$box" ] && [ -n "$arg" ] || usage case "$name" in *[!a-z0-9-]*|-*) echo "ops provision: operator name must be lowercase [a-z0-9-]" >&2; exit 1 ;; esac case "$src" in --github) keys="$(curl -fsS "https://github.com/$arg.keys")" ;; --keys-file) keys="$(cat "$arg")" ;; *) usage ;; esac [ -n "$keys" ] || { echo "ops provision: no keys from $src $arg" >&2; exit 1; } echo "ops provision: installing for '$name' on $box:" printf '%s\n' "$keys" | awk '{print " " $1 " ..." substr($2, length($2)-7) " " $3}' # Public keys fetched from GitHub are an INTAKE, not an identity proof โ€” # same caveat as the vault recipients: confirm over the messenger that the # keys are currently theirs. [ "$src" = "--github" ] && echo " (from github.com/$arg.keys โ€” confirm with them over the messenger that these are current)" printf '%s\n' "$keys" | on "$box" "sudo /opt/lchat/lchat bootstrap --add-operator $name" echo "ops provision: verifying:" on "$box" "id $name" echo echo "note: their WireGuard peer must already be on $box's wg0 for SSH to route" echo " (sudo wg show wg0 โ€” see RUNBOOK ยง Box access to add one)." echo "note: this opened box SSH sessions โ€” if this session hasn't written its" echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\"" ;; -h|--help|help|"") usage ;; *) echo "ops provision: unknown subcommand '$cmd'" >&2; usage ;; esac