Justfile

  1# Fleet workflow (README.md, docs/fleet-migration-plan.md). Runs from any
  2# shell: the tools only the dev shell has (colmena, nvd, nixos-anywhere) are
  3# called through `dev`; jq, ssh, nix and `secrets` come from the system.
  4
  5# The `secrets` store (age-encrypted, one file per secret).
  6export SECRETS_DIR := env("SECRETS_DIR", home_directory() / "projects/data")
  7
  8# Runs a command from this flake's dev shell (the pinned colmena 0.5.0 etc.).
  9dev := "nix develop " + quote(justfile_directory()) + " --command"
 10
 11# List recipes.
 12default:
 13    @just --list
 14
 15# Evaluate every host (fast sanity check; no builds).
 16eval:
 17    {{ dev }} colmena eval -E '{ nodes, ... }: builtins.mapAttrs (n: v: v.config.system.build.toplevel.drvPath) nodes'
 18
 19# Build host systems without deploying (all hosts, or one).
 20build HOST="":
 21    {{ dev }} colmena build {{ if HOST == "" { "" } else { "--on " + HOST } }}
 22
 23# Builds HOST's system, fetches the one it runs, and compares them with nvd.
 24# "identical" means a deploy would change nothing.
 25# Compare what the fleet would deploy to HOST with what HOST runs.
 26diff-host HOST:
 27    #!/usr/bin/env bash
 28    set -euo pipefail
 29    running="$(ssh "root@{{ HOST }}" readlink -f /run/current-system)"
 30    new="$(nix build --no-link --print-out-paths '.#nixosConfigurations."{{ HOST }}".config.system.build.toplevel')"
 31    echo "running: $running"
 32    echo "fleet:   $new"
 33    if [ "$running" = "$new" ]; then
 34      echo "identical: deploying {{ HOST }} changes nothing"
 35      exit 0
 36    fi
 37    nix copy --no-check-sigs --from "ssh-ng://root@{{ HOST }}" "$running"
 38    {{ dev }} nvd diff "$running" "$new"
 39
 40# Refuses to start if any secret HOST declares is missing from the store.
 41# Extra args go to `colmena apply`: `just deploy HOST dry-activate`,
 42# `just deploy HOST --reboot`.
 43# Deploy HOST with Colmena (build, copy, upload keys, switch).
 44deploy HOST *ARGS: (check-secrets HOST)
 45    {{ dev }} colmena apply {{ ARGS }} --on {{ HOST }}
 46
 47# Every host with a role from PROJECT ("moods" or "sovrn") gets the new
 48# version; the lock bump is the deploy log, so commit it afterwards.
 49# Pick up PROJECT's last commit and deploy the hosts that run it.
 50deploy-project PROJECT *ARGS:
 51    #!/usr/bin/env bash
 52    set -euo pipefail
 53    nix flake update {{ PROJECT }}
 54    hosts="$(jq -r --arg p "{{ PROJECT }}" '[to_entries[] | select(any(.value.roles[]?; . == $p or startswith($p + "-"))) | .key] | join(",")' hosts.json)"
 55    [ -n "$hosts" ] || { echo "no host in hosts.json runs a {{ PROJECT }} role" >&2; exit 1; }
 56    for h in ${hosts//,/ }; do just check-secrets "$h"; done
 57    {{ dev }} colmena apply {{ ARGS }} --on "$hosts"
 58    echo "deployed {{ PROJECT }} $(jq -r '.nodes["{{ PROJECT }}"].locked.rev' flake.lock) to $hosts; commit flake.lock"
 59
 60# Covers every project's secrets: reads the `secrets decrypt <path>` key
 61# commands from each host's deployment.keys. With no HOST, checks all hosts.
 62# Verify every secret a host declares decrypts from the store.
 63check-secrets HOST="":
 64    #!/usr/bin/env bash
 65    set -euo pipefail
 66    paths="$(nix eval --json .#colmenaHive.nodes --apply \
 67      'ns: builtins.mapAttrs (n: v: map (k: builtins.elemAt k.keyCommand 2) (builtins.filter (k: k.keyCommand or null != null && builtins.length k.keyCommand == 3) (builtins.attrValues v.config.deployment.keys))) ns' |
 68      jq -r --arg h "{{ HOST }}" 'to_entries[] | select($h == "" or .key == $h) | .value[]' | sort -u)"
 69    missing=0
 70    for p in $paths; do
 71      # Byte count, not $(...): some keys are binary and may start with NUL.
 72      if [ "$(secrets decrypt "$p" 2>/dev/null | head -c1 | wc -c)" = 1 ]; then :; else echo "missing: $p" >&2; missing=1; fi
 73    done
 74    [ "$missing" = 0 ] || exit 1
 75    echo "secrets ok ({{ if HOST == "" { "all hosts" } else { HOST } }})"
 76
 77# SSH key authorized for root on fresh provider images (Hetzner's
 78# HCLOUD_DEFAULT_SSH_KEYS); one of keys/admins.pub.
 79SSH_KEY := env("FLEET_SSH_KEY", home_directory() / ".ssh/hetzner")
 80
 81# stateVersion recorded for newly installed standard hosts: the release they
 82# were installed with. Never change it for an existing host.
 83NEW_STATE_VERSION := "26.05"
 84
 85# Steps:
 86#   0. netcup only: ask for a public key, check it is in keys/admins.pub,
 87#      ssh-copy-id it to root (password login; netcup images allow no key),
 88#      and check key login works. Its private key (the path without .pub) is
 89#      used for the rest instead of FLEET_SSH_KEY.
 90#   1. Probe the provider image over SSH: architecture, install disk, IPv4
 91#      prefix and gateway, IPv6 address.
 92#   2. Record the host in hosts.json (provider, system, disk, addresses,
 93#      stateVersion, roles), merged into an existing entry.
 94#   3. `gen-host-secrets`: the SSH host key (servers/hosts/<fqdn>/, reused on
 95#      reinstalls) and the roles' host-scoped secrets.
 96#   4. nixos-anywhere, installing the host key via --extra-files.
 97#   5. Wait for the installed system, verify it presents the pinned key, then
 98#      pin it in ~/.ssh/known_hosts.
 99#
100# ROLES is comma-separated (e.g. sovrn-cell, or moods,sovrn-metrics). Set
101# FLEET_YES=1 to skip the confirmation prompt.
102# Install NixOS on a fresh Hetzner or netcup box with nixos-anywhere. ERASES its disk.
103new-host IP HOSTNAME PROVIDER ROLES:
104    #!/usr/bin/env bash
105    set -euo pipefail
106    IP="{{ IP }}"
107    HOST="{{ HOSTNAME }}"
108    PROVIDER="{{ PROVIDER }}"
109    ROLES="{{ ROLES }}"
110    SSH_KEY="{{ SSH_KEY }}"
111    INVENTORY=hosts.json
112    fail() { echo "new-host: $*" >&2; exit 1; }
113
114    [[ "$IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || fail "IP must be an IPv4 address, got: $IP"
115    [[ "$HOST" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)+$ ]] || fail "HOSTNAME must be a fully qualified lowercase name, got: $HOST"
116    case "$PROVIDER" in hetzner|netcup) ;; *) fail "PROVIDER must be hetzner or netcup, got: $PROVIDER" ;; esac
117    [[ "$ROLES" =~ ^[a-z0-9-]+(,[a-z0-9-]+)*$ ]] || fail "ROLES must be comma-separated role names, got: $ROLES"
118    for bin in secrets jq ssh ssh-keygen ssh-copy-id; do
119      command -v "$bin" >/dev/null || fail "$bin not on PATH"
120    done
121    if jq -e --arg h "$HOST" '.[$h].layout == "legacy"' "$INVENTORY" >/dev/null; then
122      fail "$HOST is a legacy-layout host; new-host installs the standard layout (see plan 2.8)"
123    fi
124    if jq -e --arg h "$HOST" 'has($h)' "$INVENTORY" >/dev/null; then
125      echo "note: $HOST is already in $INVENTORY; reinstalling (its entry will be updated)" >&2
126    fi
127    if [ "${FLEET_YES:-}" != 1 ]; then
128      echo "This installs NixOS ($ROLES) on $IP ($PROVIDER) as $HOST and ERASES the disk of $IP."
129      read -r -p "Type the hostname to continue: " answer
130      [ "$answer" = "$HOST" ] || fail "aborted"
131    fi
132
133    # 0. netcup images only allow root in with a password: authorize a key
134    # from keys/admins.pub, which then logs in to the NixOS host too. The
135    # image's host key is never trusted, as in step 1.
136    UNPINNED=(-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no)
137    if [ "$PROVIDER" = netcup ]; then
138      read -r -e -p "Public key to authorize for root@$IP (one of keys/admins.pub): " PUBKEY
139      PUBKEY="${PUBKEY/#\~/$HOME}"
140      [ -r "$PUBKEY" ] || fail "$PUBKEY not readable"
141      grep -qxF "$(cut -d' ' -f1,2 "$PUBKEY")" <(cut -d' ' -f1,2 keys/admins.pub) \
142        || fail "$PUBKEY is not in keys/admins.pub, so it couldn't log in once NixOS is installed"
143      SSH_KEY="${PUBKEY%.pub}"
144      [ -r "$SSH_KEY" ] || fail "private key $SSH_KEY (for $PUBKEY) not readable"
145      echo "Copying $PUBKEY to root@$IP; enter the root password from netcup when asked."
146      ssh-copy-id -i "$PUBKEY" "${UNPINNED[@]}" "root@$IP" || fail "ssh-copy-id to root@$IP failed"
147      ssh -i "$SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR \
148        "${UNPINNED[@]}" "root@$IP" true || fail "key login to root@$IP still fails after ssh-copy-id"
149      echo "Key login to root@$IP works."
150    fi
151    [ -r "$SSH_KEY" ] || fail "SSH key $SSH_KEY not readable (set FLEET_SSH_KEY)"
152
153    tmp="$(mktemp -d)"
154    trap 'rm -rf "$tmp"' EXIT
155
156    # 1. Probe. The provider image's host key is never trusted or recorded:
157    # it is about to be destroyed, and nixos-anywhere ignores known_hosts too.
158    SSH_OPTS=(-i "$SSH_KEY" -o IdentitiesOnly=yes -o ConnectTimeout=10 -o LogLevel=ERROR)
159    # One key=value per line, so a missing value can't shift the others.
160    probe="$(ssh "${SSH_OPTS[@]}" "${UNPINNED[@]}" "root@$IP" 'bash -s' <<'PROBE'
161    echo "arch=$(uname -m)"
162    echo "disk=$(lsblk -dpno NAME,TYPE | awk '$2 == "disk" && $1 ~ /^\/dev\/(sd|vd|nvme)/ { print $1; exit }')"
163    echo "ipv4=$(ip -4 -o addr show scope global | awk '{ print $4; exit }')"
164    echo "gateway4=$(ip -4 route show default | awk '{ print $3; exit }')"
165    echo "ipv6=$(ip -6 -o addr show scope global | awk '{ print $4; exit }')"
166    PROBE
167    )" || fail "cannot SSH to root@$IP with $SSH_KEY"
168    get() { sed -n "s/^$1=//p" <<<"$probe"; }
169    case "$(get arch)" in
170      x86_64) SYSTEM=x86_64-linux ;;
171      aarch64) SYSTEM=aarch64-linux ;;
172      *) fail "unsupported architecture: $(get arch)" ;;
173    esac
174    DISK="$(get disk)"
175    [ -n "$DISK" ] || fail "no install disk found on $IP"
176    IPV4_CIDR="$(get ipv4)"
177    [ "${IPV4_CIDR%/*}" = "$IP" ] || fail "$IP's image reports IPv4 ${IPV4_CIDR:-none}, not $IP"
178    IPV4_PREFIX="${IPV4_CIDR#*/}"
179    GATEWAY4="$(get gateway4)"
180    [ -n "$GATEWAY4" ] || fail "no IPv4 default gateway on $IP"
181    IPV6="$(get ipv6)"
182    echo "probe: $SYSTEM, disk $DISK, IPv4 $IPV4_CIDR via $GATEWAY4, IPv6 ${IPV6:-none}"
183    if [ -z "$IPV6" ]; then
184      echo "note: no IPv6 configured on the image; set ipv6 in $INVENTORY (e.g. \"2a01:...::1/64\") and deploy" >&2
185    fi
186
187    # 2. Inventory, merged into any existing entry so hand-set fields
188    # (settings, an existing stateVersion) survive a reinstall. The pinned key
189    # is filled in after step 3.
190    jq --arg h "$HOST" --arg provider "$PROVIDER" --arg system "$SYSTEM" --arg disk "$DISK" \
191      --arg ipv4 "$IP" --argjson prefix "$IPV4_PREFIX" --arg gateway4 "$GATEWAY4" --arg ipv6 "$IPV6" \
192      --arg sv "{{ NEW_STATE_VERSION }}" --arg roles "$ROLES" \
193      '.[$h] = ({stateVersion: $sv} + (.[$h] // {}) + {provider: $provider, system: $system, disk: $disk,
194                ipv4: $ipv4, ipv4Prefix: $prefix, ipv4Gateway: $gateway4,
195                ipv6: (if $ipv6 == "" then null else $ipv6 end), sshHostKey: null,
196                roles: ($roles | split(","))})' \
197      "$INVENTORY" > "$tmp/hosts.json"
198    mv "$tmp/hosts.json" "$INVENTORY"
199    jj st >/dev/null 2>&1 || true
200
201    # 3. Per-host secrets, including the SSH host key (reused on rebuilds).
202    just gen-host-secrets "$HOST"
203    mkdir -p "$tmp/extra/etc/ssh"
204    KEY="$tmp/extra/etc/ssh/ssh_host_ed25519_key"
205    (umask 077; secrets decrypt "servers/hosts/$HOST/ssh_host_ed25519_key" > "$KEY")
206    ssh-keygen -y -f "$KEY" > "$KEY.pub"
207    PUB="$(cut -d' ' -f1,2 "$KEY.pub")"
208    jq --arg h "$HOST" --arg key "$PUB" '.[$h].sshHostKey = $key' "$INVENTORY" > "$tmp/hosts.json"
209    mv "$tmp/hosts.json" "$INVENTORY"
210    jj st >/dev/null 2>&1 || true
211
212    # 4. Install (reboots into NixOS at the end).
213    {{ dev }} nixos-anywhere --flake ".#$HOST" -i "$SSH_KEY" --extra-files "$tmp/extra" --target-host "root@$IP"
214
215    # 5. Verify the pinned key, then trust it.
216    echo "$HOST,$IP $PUB" > "$tmp/known_hosts"
217    for _ in $(seq 1 60); do
218      if ssh "${SSH_OPTS[@]}" -o UserKnownHostsFile="$tmp/known_hosts" -o StrictHostKeyChecking=yes \
219        "root@$IP" true 2>/dev/null; then
220        just _pin-host-key "$HOST" "$IP" "$PUB"
221        ssh "${SSH_OPTS[@]}" "root@$IP" nixos-version
222        echo "$HOST is up on NixOS. Commit $INVENTORY, then deploy with: just deploy $HOST"
223        exit 0
224      fi
225      sleep 5
226    done
227    fail "$HOST did not come back with the pinned host key within 5 minutes"
228
229# Generates only what's missing; existing values are never rotated. The SSH
230# host key lives at servers/hosts/<fqdn>/ssh_host_ed25519_key; every other
231# host-scoped secret comes from the keys the host's roles declare
232# (<project>/hosts/<fqdn>/<name>), generated by that project's
233# gen-secret-<project> app. Values no generator makes (provider tokens) are
234# listed with the command to store them. DRY=1 shows what would happen.
235# Create HOST's missing per-host secrets in the store.
236gen-host-secrets HOST:
237    #!/usr/bin/env bash
238    set -euo pipefail
239    HOST="{{ HOST }}"
240    system="$(nix eval --raw --impure --expr builtins.currentSystem)"
241    tmp="$(mktemp -d)"
242    trap 'rm -rf "$tmp"' EXIT
243    paths="$(nix eval --json ".#nixosConfigurations.\"$HOST\".config.deployment.keys" --apply \
244      'ks: map (k: builtins.elemAt k.keyCommand 2) (builtins.filter (k: k.keyCommand or null != null && builtins.length k.keyCommand == 3) (builtins.attrValues ks))' |
245      jq -r --arg h "$HOST" '.[] | select(contains("/hosts/" + $h + "/"))' | sort -u)"
246    apps="$(nix eval --json ".#apps.$system" --apply builtins.attrNames | jq -r '.[]')"
247    manual=()
248    for secret in "servers/hosts/$HOST/ssh_host_ed25519_key" $paths; do
249      name="${secret##*/}"
250      project="${secret%%/*}"
251      if secrets decrypt "$secret" >/dev/null 2>&1; then
252        echo "exists     $secret"
253        continue
254      fi
255      if [ "$name" = ssh_host_ed25519_key ]; then
256        gen=(sh -c 'ssh-keygen -q -t ed25519 -N "" -C "root@$1" -f "$2/key" && cat "$2/key"' _ "$HOST" "$tmp")
257      elif grep -qx "gen-secret-$project" <<<"$apps"; then
258        gen=(nix run ".#gen-secret-$project" -- "$name" "$HOST")
259      else
260        gen=(false)
261      fi
262      if [ "${DRY:-}" = 1 ]; then
263        # Run the generator but keep nothing: it says whether it can.
264        if "${gen[@]}" >/dev/null 2>&1; then echo "would gen  $secret"; else echo "MISSING    $secret (not generated)"; fi
265      elif "${gen[@]}" > "$tmp/value" 2>/dev/null; then
266        secrets encrypt "$secret" < "$tmp/value" 2>/dev/null
267        echo "generated  $secret"
268      else
269        manual+=("$secret")
270        echo "MISSING    $secret (not generated)"
271      fi
272      rm -f "$tmp/key" "$tmp/key.pub" "$tmp/value"
273    done
274    if [ ${#manual[@]} -gt 0 ]; then
275      echo
276      echo "Store these by hand (e.g. a provider API token):"
277      for secret in "${manual[@]}"; do echo "  printf '%s' '<value>' | secrets encrypt $secret"; done
278    fi
279
280# Expected records come from hosts.json and sovrn's fleet.nix at the locked
281# input (what is deployed); see scripts/check-dns.sh. Needs dig and openssl.
282# Compare the sovrn.at zone (Marque record, nameservers, DNSSEC) with the fleet.
283check-dns: (_dns "check-dns")
284
285# Shows check-dns, the plan (records to remove/add in the managed rrsets),
286# asks, then writes the Marque record with goat (logged in as @rtw.run) and
287# re-checks once the nameservers have it. DRY=1 stops after the plan.
288# Converge the sovrn.at zone on the fleet through the Marque record.
289update-dns: (_dns "update-dns")
290
291_dns SCRIPT:
292    #!/usr/bin/env bash
293    set -euo pipefail
294    fleet="$(mktemp)"; trap 'rm -f "$fleet"' EXIT
295    nix eval --json --impure --expr \
296      'let f = import ((builtins.getFlake (toString ./.)).inputs.sovrn + "/nix/fleet.nix"); in { inherit (f) zone rootHost; }' \
297      2>/dev/null >"$fleet"
298    scripts/{{ SCRIPT }}.sh hosts.json "$fleet"
299
300# Use on a new workstation, or after a host was rebuilt elsewhere.
301# Pin every host in hosts.json into ~/.ssh/known_hosts.
302known-hosts:
303    #!/usr/bin/env bash
304    set -euo pipefail
305    jq -r 'to_entries[] | select(.value.sshHostKey != null) | "\(.key) \(.value.ipv4) \(.value.sshHostKey)"' hosts.json |
306      while read -r host ip keytype key; do
307        just _pin-host-key "$host" "$ip" "$keytype $key"
308      done
309
310# Replace the known_hosts entries for HOST and IP with KEY ("<type> <base64>").
311_pin-host-key HOST IP KEY:
312    #!/usr/bin/env bash
313    set -euo pipefail
314    KH="$HOME/.ssh/known_hosts"
315    touch "$KH"
316    ssh-keygen -R "{{ HOST }}" -f "$KH" >/dev/null 2>&1 || true
317    ssh-keygen -R "{{ IP }}" -f "$KH" >/dev/null 2>&1 || true
318    echo "{{ HOST }},{{ IP }} {{ KEY }}" >> "$KH"
319    rm -f "$KH.old"
320    echo "known_hosts: pinned {{ HOST }} ({{ IP }})"
321
322# Run after `just update-nixpkgs` in sovrn and moods. Fails unless both lock
323# the same revision; the fleet then pins it (flake and devenv) and re-locks
324# both projects.
325# Move the fleet's nixpkgs to the revision sovrn and moods lock.
326sync-nixpkgs:
327    #!/usr/bin/env bash
328    set -euo pipefail
329    sovrn="$(jq -r .nodes.nixpkgs.locked.rev ~/projects/sovrn/flake.lock)"
330    moods="$(jq -r .nodes.nixpkgs.locked.rev ~/projects/moods/flake.lock)"
331    [ "$sovrn" = "$moods" ] || { echo "sovrn locks $sovrn, moods locks $moods: bump them to the same revision first" >&2; exit 1; }
332    sed -i "s|nixpkgs\\.url = \"github:NixOS/nixpkgs/[0-9a-f]*\";|nixpkgs.url = \"github:NixOS/nixpkgs/$sovrn\";|" flake.nix
333    sed -i "s|url: github:NixOS/nixpkgs/[0-9a-f]*$|url: github:NixOS/nixpkgs/$sovrn|" devenv.yaml
334    nix flake update nixpkgs sovrn moods
335    devenv update nixpkgs
336    just eval >/dev/null
337    echo "nixpkgs -> $sovrn (fleet, devenv, sovrn, moods); commit flake.nix, flake.lock, devenv.yaml and devenv.lock"