| … | … | @@ -55,6 +55,9 @@ deployment.** |
| 55 | 55 | - Fleet updater config supports a validated per-host `PINNED_TAG`, allowing a |
| 56 | 56 | remigrated box to restore its timer without following an older shared channel |
| 57 | 57 | target. Signature verification now also precedes up-to-date and dry-run exits. |
| 58 | - The exact-target updater repair command defaults to a read-only probe and | |
| 59 | requires repeated relay confirmation before atomically applying a pin; it | |
| 60 | refuses application-version changes and restores updater state on failure. | |
| 58 | 61 | - StartOS and Umbrel metadata, release-evidence tests, and operator docs are |
| 59 | 62 | synchronized to the current prerelease baseline and distinguish configured |
| 60 | 63 | defaults from signed runtime proof. |
| … | … | @@ -120,6 +120,25 @@ operator-owned lanes. To **roll a whole channel back**, set its shared tag back |
| 120 | 120 | — boxes check out the older tag exactly like a forward update (health-gated the |
| 121 | 121 | same way). |
| 122 | 122 | |
| 123 | For a remotely operated box, use the exact-target repair command. It is a | |
| 124 | read-only probe unless both `--apply` and a matching `--confirm-relay` are | |
| 125 | present. The apply path verifies the transferred updater hash, verifies the | |
| 126 | signed pin, requires the current checkout and healthy runtime to already match | |
| 127 | that pin, atomically installs only the updater/config, restores prior state on | |
| 128 | failure, and refuses any application-version mutation: | |
| 129 | ||
| 130 | ```bash | |
| 131 | HIVERELAY_FLEET_INVENTORY=fleet/relays.local.json \ | |
| 132 | npm run fleet:repair-updater-pin -- \ | |
| 133 | --relay utah-8gb --channel stable --pin v0.25.0-rc.4 | |
| 134 | ||
| 135 | # Production authority boundary: | |
| 136 | HIVERELAY_FLEET_INVENTORY=fleet/relays.local.json \ | |
| 137 | npm run fleet:repair-updater-pin -- \ | |
| 138 | --relay utah-8gb --channel stable --pin v0.25.0-rc.4 \ | |
| 139 | --apply --confirm-relay utah-8gb | |
| 140 | ``` | |
| 141 | ||
| 123 | 142 | ## Safety properties |
| 124 | 143 | |
| 125 | 144 | - **Health-gated:** after restart the agent polls `/health` for |
| … | … | @@ -51,6 +51,7 @@ |
| 51 | 51 | "release:check-image-manifest": "node scripts/check-release-image-manifest.mjs", |
| 52 | 52 | "release:smoke-image": "node scripts/smoke-release-image.mjs", |
| 53 | 53 | "fleet:check-rollout": "node scripts/check-fleet-rollout.mjs", |
| 54 | "fleet:repair-updater-pin": "node scripts/repair-fleet-updater-pin.mjs", | |
| 54 | 55 | "dashboard:admin-operator": "node dashboard-admin-operator/server.cjs", |
| 55 | 56 | "dashboard:admin-operator:dev": "PORT=3458 FLEET_INTERVAL=30 node dashboard-admin-operator/server.cjs", |
| 56 | 57 | "fleet:control": "node dashboard-admin-operator/server.cjs", |
| … | … | @@ -203,6 +203,9 @@ const umbrelSmokePackage = readText(hiverelayRoot, 'scripts', 'smoke-umbrel-pack |
| 203 | 203 | const fleetRolloutCheck = readText(hiverelayRoot, 'scripts', 'check-fleet-rollout.mjs') |
| 204 | 204 | const fleetStatusScript = readText(hiverelayRoot, 'fleet', 'fleet-status.sh') |
| 205 | 205 | const fleetUpdaterScript = readText(hiverelayRoot, 'fleet', 'updater.sh') |
| 206 | const fleetUpdaterPinRepair = readText(hiverelayRoot, 'scripts', 'repair-fleet-updater-pin.mjs') | |
| 207 | const fleetUpdaterPinRepairApply = readText(hiverelayRoot, 'scripts', 'lib', 'repair-fleet-updater-apply.sh') | |
| 208 | const fleetUpdaterPinRepairTest = readText(hiverelayRoot, 'test', 'unit', 'fleet-updater-pin-repair.test.js') | |
| 206 | 209 | const deployVpsScript = readText(hiverelayRoot, 'scripts', 'deploy-vps.sh') |
| 207 | 210 | const relayJanitorScript = readText(hiverelayRoot, 'scripts', 'relay-janitor.js') |
| 208 | 211 | const rootDockerCompose = readText(hiverelayRoot, 'docker-compose.yml') |
| … | … | @@ -9198,6 +9201,24 @@ if ( |
| 9198 | 9201 | fail('fleet updater can accept an unverified pin, exit before rollback, continue after failed rollback checkout, or accept a stale runtime version') |
| 9199 | 9202 | } |
| 9200 | 9203 | |
| 9204 | if ( | |
| 9205 | fleetUpdaterPinRepair.includes('if (apply && confirmation !== relayName)') && | |
| 9206 | fleetUpdaterPinRepair.includes('inventory.relays.filter(relay => relay?.name === relayName)') && | |
| 9207 | fleetUpdaterPinRepair.includes('mode: \'dry-run\'') && | |
| 9208 | fleetUpdaterPinRepair.includes('mutation: false') && | |
| 9209 | fleetUpdaterPinRepairApply.includes('bash "$candidate" --verify-only "$pin"') && | |
| 9210 | fleetUpdaterPinRepairApply.includes('git status --porcelain=v1 --untracked-files=normal') && | |
| 9211 | fleetUpdaterPinRepairApply.includes('[ "$current_sha" = "$target_sha" ]') && | |
| 9212 | fleetUpdaterPinRepairApply.includes('[ "$(git rev-parse HEAD)" = "$current_sha" ]') && | |
| 9213 | !fleetUpdaterPinRepairApply.includes('systemctl restart hiverelay') && | |
| 9214 | fleetUpdaterPinRepairTest.includes('Sydney is never contacted') && | |
| 9215 | fleetUpdaterPinRepairTest.includes('Dallas is never contacted') | |
| 9216 | ) { | |
| 9217 | pass('fleet updater pin repair defaults read-only, requires exact relay confirmation, verifies immutable application state, and excludes unrelated relays') | |
| 9218 | } else { | |
| 9219 | fail('fleet updater pin repair can mutate without exact confirmation, contact unrelated relays, or change the relay application') | |
| 9220 | } | |
| 9221 | ||
| 9201 | 9222 | if ( |
| 9202 | 9223 | cliIndex.includes('const interactiveStatus = process.stdout.isTTY === true') && |
| 9203 | 9224 | cliIndex.includes('const statusIntervalMs = interactiveStatus ? 5000 : 60000') && |
| … | … | @@ -0,0 +1,101 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | set -euo pipefail | |
| 3 | ||
| 4 | candidate="$1" | |
| 5 | expected_hash="$2" | |
| 6 | channel="$3" | |
| 7 | pin="$4" | |
| 8 | repo="${HIVERELAY_REPO_DIR:-/root/hiverelay}" | |
| 9 | conf="${HIVERELAY_UPDATER_CONF:-/etc/hiverelay-updater.conf}" | |
| 10 | updater="/usr/local/bin/hiverelay-updater" | |
| 11 | allowed="${HIVERELAY_ALLOWED_SIGNERS:-/etc/hiverelay/allowed-signers}" | |
| 12 | backup="/run/hiverelay-updater.repair.$$" | |
| 13 | config_backup="/run/hiverelay-updater.conf.repair.$$" | |
| 14 | config_existed=0 | |
| 15 | success=0 | |
| 16 | timer_enabled="$(systemctl is-enabled hiverelay-updater.timer 2>/dev/null || true)" | |
| 17 | timer_active="$(systemctl is-active hiverelay-updater.timer 2>/dev/null || true)" | |
| 18 | ||
| 19 | cleanup() { | |
| 20 | status="${1:-1}" | |
| 21 | trap - EXIT HUP INT TERM | |
| 22 | if [ "$success" != 1 ]; then | |
| 23 | if [ -f "$backup" ]; then install -m 0755 "$backup" "$updater"; fi | |
| 24 | if [ "$config_existed" = 1 ] && [ -f "$config_backup" ]; then | |
| 25 | install -m 0644 "$config_backup" "$conf" | |
| 26 | elif [ "$config_existed" = 0 ]; then | |
| 27 | rm -f "$conf" | |
| 28 | fi | |
| 29 | if [ "$timer_enabled" = enabled ]; then | |
| 30 | systemctl enable hiverelay-updater.timer >/dev/null 2>&1 || true | |
| 31 | if [ "$timer_active" = active ]; then | |
| 32 | systemctl start hiverelay-updater.timer >/dev/null 2>&1 || true | |
| 33 | else | |
| 34 | systemctl stop hiverelay-updater.timer >/dev/null 2>&1 || true | |
| 35 | fi | |
| 36 | else | |
| 37 | systemctl disable --now hiverelay-updater.timer >/dev/null 2>&1 || true | |
| 38 | fi | |
| 39 | fi | |
| 40 | rm -f "$candidate" "$backup" "$config_backup" "${conf}.next" "${updater}.next" | |
| 41 | exit "$status" | |
| 42 | } | |
| 43 | trap 'cleanup $?' EXIT | |
| 44 | trap 'cleanup 129' HUP | |
| 45 | trap 'cleanup 130' INT | |
| 46 | trap 'cleanup 143' TERM | |
| 47 | ||
| 48 | [ "$(id -u)" = 0 ] || { echo 'repair requires root' >&2; exit 1; } | |
| 49 | [[ "$channel" =~ ^[A-Za-z0-9._-]{1,32}$ ]] || { echo 'invalid channel' >&2; exit 1; } | |
| 50 | [[ "$pin" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo 'invalid pin' >&2; exit 1; } | |
| 51 | [ -f "$candidate" ] || { echo 'candidate updater missing' >&2; exit 1; } | |
| 52 | [ "$(sha256sum "$candidate" | awk '{print $1}')" = "$expected_hash" ] || { echo 'candidate updater checksum mismatch' >&2; exit 1; } | |
| 53 | bash -n "$candidate" | |
| 54 | ||
| 55 | cd "$repo" | |
| 56 | [ -z "$(git status --porcelain=v1 --untracked-files=normal)" ] || { echo 'relay repo is dirty' >&2; exit 1; } | |
| 57 | git fetch --tags --quiet origin | |
| 58 | HIVERELAY_REPO_DIR="$repo" HIVERELAY_ALLOWED_SIGNERS="$allowed" \ | |
| 59 | bash "$candidate" --verify-only "$pin" | |
| 60 | target_sha="$(git rev-parse -q --verify "refs/tags/$pin^{}")" | |
| 61 | current_sha="$(git rev-parse HEAD)" | |
| 62 | [ "$current_sha" = "$target_sha" ] || { | |
| 63 | echo 'pin is not the currently installed commit; refusing application mutation' >&2 | |
| 64 | exit 1 | |
| 65 | } | |
| 66 | version="v$(node -p 'require(process.argv[1]).version' "$repo/package.json")" | |
| 67 | [ "$version" = "$pin" ] || { echo 'runtime package version does not match pin' >&2; exit 1; } | |
| 68 | ||
| 69 | health_body="$(curl -fsS --max-time 8 http://127.0.0.1:9100/health)" | |
| 70 | HEALTH="$health_body" EXPECTED="${pin#v}" python3 -c \ | |
| 71 | 'import json,os; h=json.loads(os.environ["HEALTH"]); assert h.get("running") is True and h.get("version") == os.environ["EXPECTED"]' | |
| 72 | ||
| 73 | systemctl stop hiverelay-updater.timer >/dev/null 2>&1 || true | |
| 74 | [ "$(systemctl is-active hiverelay-updater.service 2>/dev/null || true)" != active ] || { | |
| 75 | echo 'updater service is already active; retry after it exits' >&2 | |
| 76 | exit 1 | |
| 77 | } | |
| 78 | if [ -f "$updater" ]; then cp -p "$updater" "$backup"; fi | |
| 79 | if [ -f "$conf" ]; then cp -p "$conf" "$config_backup"; config_existed=1; fi | |
| 80 | install -m 0755 "$candidate" "${updater}.next" | |
| 81 | mv -f "${updater}.next" "$updater" | |
| 82 | printf 'CHANNEL=%s\nPINNED_TAG=%s\n' "$channel" "$pin" > "${conf}.next" | |
| 83 | chmod 0644 "${conf}.next" | |
| 84 | mv -f "${conf}.next" "$conf" | |
| 85 | ||
| 86 | HIVERELAY_REPO_DIR="$repo" HIVERELAY_UPDATER_CONF="$conf" \ | |
| 87 | HIVERELAY_ALLOWED_SIGNERS="$allowed" "$updater" --dry-run | |
| 88 | systemctl enable --now hiverelay-updater.timer >/dev/null | |
| 89 | [ "$(systemctl is-enabled hiverelay-updater.timer)" = enabled ] | |
| 90 | [ "$(systemctl is-active hiverelay-updater.timer)" = active ] | |
| 91 | ||
| 92 | [ "$(git rev-parse HEAD)" = "$current_sha" ] || { | |
| 93 | echo 'application commit changed during no-op repair' >&2 | |
| 94 | exit 1 | |
| 95 | } | |
| 96 | health_body="$(curl -fsS --max-time 8 http://127.0.0.1:9100/health)" | |
| 97 | HEALTH="$health_body" EXPECTED="${pin#v}" python3 -c \ | |
| 98 | 'import json,os; h=json.loads(os.environ["HEALTH"]); assert h.get("running") is True and h.get("version") == os.environ["EXPECTED"]' | |
| 99 | ||
| 100 | success=1 | |
| 101 | printf 'HIVERELAY_REPAIR_OK|%s|%s|enabled|active\n' "$version" "$pin" |
| … | … | @@ -0,0 +1,53 @@ |
| 1 | #!/usr/bin/env bash | |
| 2 | set -euo pipefail | |
| 3 | ||
| 4 | repo="${HIVERELAY_REPO_DIR:-/root/hiverelay}" | |
| 5 | conf="${HIVERELAY_UPDATER_CONF:-/etc/hiverelay-updater.conf}" | |
| 6 | allowed="${HIVERELAY_ALLOWED_SIGNERS:-/etc/hiverelay/allowed-signers}" | |
| 7 | requested_pin="${1:-}" | |
| 8 | [[ "$requested_pin" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { | |
| 9 | echo 'invalid requested pin' >&2 | |
| 10 | exit 1 | |
| 11 | } | |
| 12 | ||
| 13 | version="v$(node -p 'require(process.argv[1]).version' "$repo/package.json" 2>/dev/null || printf '?')" | |
| 14 | health="$(curl -fsS --max-time 8 http://127.0.0.1:9100/health 2>/dev/null || true)" | |
| 15 | health_fields="$(HEALTH="$health" python3 -c 'import json,os; h=json.loads(os.environ["HEALTH"]); print("{}|{}".format(str(h.get("running", "?")).lower(), h.get("version", "?")))' 2>/dev/null || printf '?|?')" | |
| 16 | IFS='|' read -r running health_version <<< "$health_fields" | |
| 17 | enabled="$(systemctl is-enabled hiverelay-updater.timer 2>/dev/null || true)" | |
| 18 | active="$(systemctl is-active hiverelay-updater.timer 2>/dev/null || true)" | |
| 19 | channel="$(awk -F= '/^[[:space:]]*CHANNEL[[:space:]]*=/ {sub(/^[^=]*=/,"");sub(/^[[:space:]]*/,"");sub(/[[:space:]]*$/,"");print;exit}' "$conf" 2>/dev/null || true)" | |
| 20 | pin="$(awk -F= '/^[[:space:]]*PINNED_TAG[[:space:]]*=/ {sub(/^[^=]*=/,"");sub(/^[[:space:]]*/,"");sub(/[[:space:]]*$/,"");print;exit}' "$conf" 2>/dev/null || true)" | |
| 21 | ||
| 22 | tag_verified=false | |
| 23 | head_matches=false | |
| 24 | repo_clean=false | |
| 25 | cd "$repo" | |
| 26 | [ -z "$(git status --porcelain=v1 --untracked-files=normal 2>/dev/null || printf '?')" ] && repo_clean=true | |
| 27 | target_sha="$(git rev-parse -q --verify "refs/tags/$requested_pin^{}" 2>/dev/null || true)" | |
| 28 | current_sha="$(git rev-parse HEAD 2>/dev/null || true)" | |
| 29 | if [ -n "$target_sha" ] && [ "$target_sha" = "$current_sha" ]; then head_matches=true; fi | |
| 30 | if [ -n "$target_sha" ] && [ -r "$allowed" ] && | |
| 31 | [ "$(git cat-file -t "refs/tags/$requested_pin" 2>/dev/null || true)" = tag ]; then | |
| 32 | if verify_output="$(git -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$allowed" \ | |
| 33 | verify-tag --raw "$requested_pin" 2>&1)"; then | |
| 34 | if printf '%s' "$verify_output" | grep -Eq 'GOODSIG|TRUST_(FULLY|ULTIMATE)' || | |
| 35 | printf '%s' "$verify_output" | grep -qi 'Good.*signature'; then | |
| 36 | tag_verified=true | |
| 37 | fi | |
| 38 | fi | |
| 39 | fi | |
| 40 | ||
| 41 | ready=false | |
| 42 | if [ "$tag_verified" = true ] && [ "$head_matches" = true ] && | |
| 43 | [ "$repo_clean" = true ] && | |
| 44 | [ "$version" = "$requested_pin" ] && [ "$running" = true ] && | |
| 45 | [ "$health_version" = "${requested_pin#v}" ]; then | |
| 46 | ready=true | |
| 47 | fi | |
| 48 | ||
| 49 | printf 'HIVERELAY_REPAIR_PROBE|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ | |
| 50 | "${version:-?}" "${running:-?}" "${enabled:-missing}" \ | |
| 51 | "${active:-inactive}" "${channel:-?}" "${pin:--}" \ | |
| 52 | "${tag_verified}" "${head_matches}" "${health_version:-?}" \ | |
| 53 | "${repo_clean}" "${ready}" |
| … | … | @@ -0,0 +1,157 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import { createHash } from 'node:crypto' | |
| 4 | import { readFileSync } from 'node:fs' | |
| 5 | import { spawnSync } from 'node:child_process' | |
| 6 | import { resolve } from 'node:path' | |
| 7 | ||
| 8 | const ROOT = resolve(import.meta.dirname, '..') | |
| 9 | const NAME_RE = /^[A-Za-z0-9._-]{1,64}$/ | |
| 10 | const HOST_RE = /^(?!-)[A-Za-z0-9._:[\]-]{1,253}$/ | |
| 11 | const TAG_RE = /^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/ | |
| 12 | const PROBE_SCRIPT = readFileSync(resolve(import.meta.dirname, 'lib', 'repair-fleet-updater-probe.sh'), 'utf8') | |
| 13 | const APPLY_SCRIPT = readFileSync(resolve(import.meta.dirname, 'lib', 'repair-fleet-updater-apply.sh'), 'utf8') | |
| 14 | ||
| 15 | function valueAfter (args, name) { | |
| 16 | const index = args.indexOf(name) | |
| 17 | if (index === -1) return null | |
| 18 | if (index + 1 >= args.length) throw new Error(`${name} requires a value`) | |
| 19 | return args[index + 1] | |
| 20 | } | |
| 21 | ||
| 22 | function safeField (value) { | |
| 23 | return String(value ?? '?').replace(/[^\x20-\x7e]/g, '?').replace(/\|/g, '?').slice(0, 100) || '?' | |
| 24 | } | |
| 25 | ||
| 26 | function run (command, args, input) { | |
| 27 | const result = spawnSync(command, args, { | |
| 28 | encoding: 'utf8', | |
| 29 | input, | |
| 30 | env: process.env, | |
| 31 | maxBuffer: 1024 * 1024 | |
| 32 | }) | |
| 33 | if (result.error || result.status !== 0) { | |
| 34 | throw new Error(`${command} failed with status ${result.status ?? 'unavailable'}`) | |
| 35 | } | |
| 36 | return result.stdout || '' | |
| 37 | } | |
| 38 | ||
| 39 | function sshArgs (relay) { | |
| 40 | const args = ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes'] | |
| 41 | if (relay.sshKey && relay.sshKey !== 'default') args.push('-i', relay.sshKey) | |
| 42 | args.push(`root@${relay.tailnet || relay.publicIp}`) | |
| 43 | return args | |
| 44 | } | |
| 45 | ||
| 46 | function loadRelay (inventoryPath, relayName) { | |
| 47 | let inventory | |
| 48 | try { | |
| 49 | inventory = JSON.parse(readFileSync(inventoryPath, 'utf8')) | |
| 50 | } catch { | |
| 51 | throw new Error('fleet inventory is unreadable or invalid') | |
| 52 | } | |
| 53 | if (!Array.isArray(inventory.relays)) throw new Error('inventory has no relays array') | |
| 54 | const matches = inventory.relays.filter(relay => relay?.name === relayName) | |
| 55 | if (matches.length !== 1) throw new Error(`inventory must contain exactly one relay named ${relayName}`) | |
| 56 | const relay = matches[0] | |
| 57 | const host = relay.tailnet || relay.publicIp | |
| 58 | if (!HOST_RE.test(host || '')) throw new Error('selected relay has an invalid host') | |
| 59 | if (relay.sshKey && relay.sshKey !== 'default' && | |
| 60 | (typeof relay.sshKey !== 'string' || relay.sshKey.length > 1024)) { | |
| 61 | throw new Error('selected relay has an invalid SSH key path') | |
| 62 | } | |
| 63 | const unsafeKeyPath = typeof relay.sshKey === 'string' && [...relay.sshKey].some(char => { | |
| 64 | const code = char.charCodeAt(0) | |
| 65 | return code < 32 || code === 127 | |
| 66 | }) | |
| 67 | if (relay.sshKey !== 'default' && unsafeKeyPath) { | |
| 68 | throw new Error('selected relay has an invalid SSH key path') | |
| 69 | } | |
| 70 | return relay | |
| 71 | } | |
| 72 | ||
| 73 | function printProbe (relayName, stdout, hash) { | |
| 74 | const line = stdout.split(/\r?\n/).find(entry => entry.startsWith('HIVERELAY_REPAIR_PROBE|')) | |
| 75 | if (!line) throw new Error('relay probe returned no structured result') | |
| 76 | const [, version, running, enabled, active, channel, pin, tagVerified, headMatches, healthVersion, repoClean, ready] = line.split('|') | |
| 77 | process.stdout.write(`${JSON.stringify({ | |
| 78 | mode: 'dry-run', | |
| 79 | relay: relayName, | |
| 80 | updaterSha256: hash, | |
| 81 | current: { | |
| 82 | version: safeField(version), | |
| 83 | running: safeField(running), | |
| 84 | timer: `${safeField(enabled)}/${safeField(active)}`, | |
| 85 | channel: safeField(channel), | |
| 86 | pin: safeField(pin), | |
| 87 | healthVersion: safeField(healthVersion) | |
| 88 | }, | |
| 89 | verification: { | |
| 90 | tagVerified: tagVerified === 'true', | |
| 91 | headMatches: headMatches === 'true', | |
| 92 | repoClean: repoClean === 'true', | |
| 93 | readyToApply: ready === 'true' | |
| 94 | }, | |
| 95 | mutation: false | |
| 96 | }, null, 2)}\n`) | |
| 97 | } | |
| 98 | ||
| 99 | function main () { | |
| 100 | const args = process.argv.slice(2) | |
| 101 | if (args.includes('--help')) { | |
| 102 | process.stdout.write('Usage: node scripts/repair-fleet-updater-pin.mjs --relay <name> --pin <vX.Y.Z> [--channel stable] [--inventory path] [--apply --confirm-relay <name>]\n') | |
| 103 | return | |
| 104 | } | |
| 105 | ||
| 106 | const relayName = valueAfter(args, '--relay') | |
| 107 | const pin = valueAfter(args, '--pin') | |
| 108 | const channel = valueAfter(args, '--channel') || 'stable' | |
| 109 | const inventoryPath = resolve(valueAfter(args, '--inventory') || process.env.HIVERELAY_FLEET_INVENTORY || resolve(ROOT, 'fleet', 'relays.local.json')) | |
| 110 | const updaterPath = resolve(valueAfter(args, '--updater') || resolve(ROOT, 'fleet', 'updater.sh')) | |
| 111 | const apply = args.includes('--apply') | |
| 112 | const confirmation = valueAfter(args, '--confirm-relay') | |
| 113 | ||
| 114 | if (!NAME_RE.test(relayName || '')) throw new Error('--relay must be one exact inventory name') | |
| 115 | if (!TAG_RE.test(pin || '')) throw new Error('--pin must be one exact release tag') | |
| 116 | if (!NAME_RE.test(channel)) throw new Error('--channel is invalid') | |
| 117 | if (apply && confirmation !== relayName) throw new Error('--apply requires --confirm-relay to exactly repeat the relay name') | |
| 118 | ||
| 119 | const relay = loadRelay(inventoryPath, relayName) | |
| 120 | let updater | |
| 121 | try { | |
| 122 | updater = readFileSync(updaterPath) | |
| 123 | } catch { | |
| 124 | throw new Error('updater source is unreadable') | |
| 125 | } | |
| 126 | const hash = createHash('sha256').update(updater).digest('hex') | |
| 127 | const base = sshArgs(relay) | |
| 128 | ||
| 129 | if (!apply) { | |
| 130 | const stdout = run('ssh', [...base, 'bash', '-s', '--', pin], PROBE_SCRIPT) | |
| 131 | printProbe(relayName, stdout, hash) | |
| 132 | return | |
| 133 | } | |
| 134 | ||
| 135 | const remotePath = `/tmp/hiverelay-updater.${hash}.repair` | |
| 136 | const scpArgs = base.slice(0, -1) | |
| 137 | run('scp', [...scpArgs, updaterPath, `${base.at(-1)}:${remotePath}`]) | |
| 138 | const stdout = run('ssh', [...base, 'bash', '-s', '--', remotePath, hash, channel, pin], APPLY_SCRIPT) | |
| 139 | const line = stdout.split(/\r?\n/).find(entry => entry.startsWith('HIVERELAY_REPAIR_OK|')) | |
| 140 | if (!line) throw new Error('relay repair returned no success proof') | |
| 141 | process.stdout.write(`${JSON.stringify({ | |
| 142 | mode: 'apply', | |
| 143 | relay: relayName, | |
| 144 | updaterSha256: hash, | |
| 145 | pin, | |
| 146 | channel, | |
| 147 | result: safeField(line), | |
| 148 | applicationRestarted: false | |
| 149 | }, null, 2)}\n`) | |
| 150 | } | |
| 151 | ||
| 152 | try { | |
| 153 | main() | |
| 154 | } catch (err) { | |
| 155 | process.stderr.write(`fleet updater pin repair: ${safeField(err?.message)}\n`) | |
| 156 | process.exitCode = 1 | |
| 157 | } |
| … | … | @@ -0,0 +1,101 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { spawnSync } from 'node:child_process' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import path from 'node:path' | |
| 6 | ||
| 7 | const root = path.resolve('.') | |
| 8 | const repair = path.join(root, 'scripts', 'repair-fleet-updater-pin.mjs') | |
| 9 | const updater = path.join(root, 'fleet', 'updater.sh') | |
| 10 | const applyHelper = readFileSync(path.join(root, 'scripts', 'lib', 'repair-fleet-updater-apply.sh'), 'utf8') | |
| 11 | ||
| 12 | test('fleet updater pin repair is exact-target, dry-run-first, and application-immutable', (t) => { | |
| 13 | const work = mkdtempSync(path.join(tmpdir(), 'hr-fleet-pin-repair-')) | |
| 14 | t.teardown(() => rmSync(work, { recursive: true, force: true })) | |
| 15 | const bin = path.join(work, 'bin') | |
| 16 | const inventory = path.join(work, 'relays.json') | |
| 17 | const log = path.join(work, 'commands.log') | |
| 18 | mkdirSync(bin) | |
| 19 | ||
| 20 | writeFileSync(inventory, JSON.stringify({ | |
| 21 | relays: [ | |
| 22 | { name: 'utah-8gb', publicIp: '10.0.0.8', sshKey: 'default' }, | |
| 23 | { name: 'sydney', publicIp: '10.0.0.9', sshKey: 'default' }, | |
| 24 | { name: 'dallas', publicIp: '10.0.0.10', sshKey: 'default' } | |
| 25 | ] | |
| 26 | })) | |
| 27 | ||
| 28 | const ssh = path.join(bin, 'ssh') | |
| 29 | const scp = path.join(bin, 'scp') | |
| 30 | writeFileSync(ssh, `#!/bin/sh | |
| 31 | printf 'ssh %s\\n' "$*" >> "$FLEET_REPAIR_TEST_LOG" | |
| 32 | payload="$(cat)" | |
| 33 | printf '%s\\n' "$payload" >> "$FLEET_REPAIR_TEST_LOG" | |
| 34 | case "$payload" in | |
| 35 | *HIVERELAY_REPAIR_PROBE*) printf 'HIVERELAY_REPAIR_PROBE|v0.25.0-rc.4|true|disabled|inactive|stable|-|true|true|0.25.0-rc.4|true|true\\n' ;; | |
| 36 | *HIVERELAY_REPAIR_OK*) printf 'HIVERELAY_REPAIR_OK|v0.25.0-rc.4|v0.25.0-rc.4|enabled|active\\n' ;; | |
| 37 | esac | |
| 38 | `) | |
| 39 | writeFileSync(scp, `#!/bin/sh | |
| 40 | printf 'scp %s\\n' "$*" >> "$FLEET_REPAIR_TEST_LOG" | |
| 41 | `) | |
| 42 | chmodSync(ssh, 0o755) | |
| 43 | chmodSync(scp, 0o755) | |
| 44 | ||
| 45 | const run = (args) => spawnSync(process.execPath, [repair, '--inventory', inventory, '--updater', updater, ...args], { | |
| 46 | encoding: 'utf8', | |
| 47 | env: { | |
| 48 | ...process.env, | |
| 49 | PATH: `${bin}:${process.env.PATH}`, | |
| 50 | FLEET_REPAIR_TEST_LOG: log | |
| 51 | } | |
| 52 | }) | |
| 53 | ||
| 54 | const dryRun = run(['--relay', 'utah-8gb', '--pin', 'v0.25.0-rc.4']) | |
| 55 | t.is(dryRun.status, 0, 'default probe succeeds') | |
| 56 | const proof = JSON.parse(dryRun.stdout) | |
| 57 | t.is(proof.relay, 'utah-8gb') | |
| 58 | t.is(proof.mutation, false, 'default mode is read-only') | |
| 59 | t.is(proof.verification.readyToApply, true, 'probe proves signature, HEAD, and health alignment') | |
| 60 | let calls = readFileSync(log, 'utf8') | |
| 61 | t.ok(calls.includes('root@10.0.0.8')) | |
| 62 | t.absent(calls.includes('10.0.0.9'), 'Sydney is never contacted') | |
| 63 | t.absent(calls.includes('10.0.0.10'), 'Dallas is never contacted') | |
| 64 | t.absent(calls.includes('\nscp '), 'dry run transfers nothing') | |
| 65 | ||
| 66 | writeFileSync(log, '') | |
| 67 | const unconfirmed = run(['--relay', 'utah-8gb', '--pin', 'v0.25.0-rc.4', '--apply']) | |
| 68 | t.not(unconfirmed.status, 0, 'apply fails without repeated exact relay confirmation') | |
| 69 | t.is(readFileSync(log, 'utf8'), '', 'failed confirmation opens no connection') | |
| 70 | ||
| 71 | const applied = run([ | |
| 72 | '--relay', 'utah-8gb', | |
| 73 | '--pin', 'v0.25.0-rc.4', | |
| 74 | '--channel', 'stable', | |
| 75 | '--apply', | |
| 76 | '--confirm-relay', 'utah-8gb' | |
| 77 | ]) | |
| 78 | t.is(applied.status, 0, 'confirmed exact-target apply reaches the remote transaction') | |
| 79 | calls = readFileSync(log, 'utf8') | |
| 80 | t.ok(calls.includes('scp '), 'apply transfers the audited updater') | |
| 81 | t.ok(calls.includes('root@10.0.0.8')) | |
| 82 | t.absent(calls.includes('10.0.0.9')) | |
| 83 | t.absent(calls.includes('10.0.0.10')) | |
| 84 | t.ok(calls.includes('refusing application mutation')) | |
| 85 | t.ok(calls.includes('git rev-parse HEAD')) | |
| 86 | t.ok(calls.includes('systemctl enable --now hiverelay-updater.timer')) | |
| 87 | }) | |
| 88 | ||
| 89 | test('remote updater pin repair rolls back control-plane state and never restarts the application', (t) => { | |
| 90 | t.ok(applyHelper.includes("trap 'cleanup $?' EXIT")) | |
| 91 | t.ok(applyHelper.includes('install -m 0755 "$backup" "$updater"')) | |
| 92 | t.ok(applyHelper.includes('install -m 0644 "$config_backup" "$conf"')) | |
| 93 | t.ok(applyHelper.includes('systemctl disable --now hiverelay-updater.timer')) | |
| 94 | t.ok(applyHelper.includes('HIVERELAY_ALLOWED_SIGNERS="$allowed"')) | |
| 95 | t.ok(applyHelper.includes('bash "$candidate" --verify-only "$pin"')) | |
| 96 | t.ok(applyHelper.includes('git status --porcelain=v1 --untracked-files=normal')) | |
| 97 | t.ok(applyHelper.includes('[ "$current_sha" = "$target_sha" ]')) | |
| 98 | t.ok(applyHelper.includes('[ "$(git rev-parse HEAD)" = "$current_sha" ]')) | |
| 99 | t.absent(applyHelper.includes('systemctl restart hiverelay')) | |
| 100 | t.absent(applyHelper.includes('systemctl restart "$SERVICE"')) | |
| 101 | }) |