| … | … | @@ -52,6 +52,9 @@ deployment.** |
| 52 | 52 | - Fleet health and service probes support exact include/exclude scopes before |
| 53 | 53 | SSH, report exact prerelease versions and updater/channel drift, inspect |
| 54 | 54 | signed capability evidence, and avoid exposing private inventory/key paths. |
| 55 | - Fleet updater config supports a validated per-host `PINNED_TAG`, allowing a | |
| 56 | remigrated box to restore its timer without following an older shared channel | |
| 57 | target. Signature verification now also precedes up-to-date and dry-run exits. | |
| 55 | 58 | - StartOS and Umbrel metadata, release-evidence tests, and operator docs are |
| 56 | 59 | synchronized to the current prerelease baseline and distinguish configured |
| 57 | 60 | defaults from signed runtime proof. |
| … | … | @@ -121,7 +121,7 @@ exists, every enablement is a manual, unrepeatable, undocumented act. |
| 121 | 121 | | 0.1 | `kill 51423`; bind `127.0.0.1`, add a shared secret, replace `exec` with `execFile(argv)`, drop wildcard CORS | R-1 — remote root on 13 relays from any network the laptop joins | |
| 122 | 122 | | 0.2 | Recover **utah-0.5gb**: `npm rebuild` (or reinstall deps) and confirm the addon resolves; if it will not, revert it to `v0.24.3` | Box is down; it is a seed holder | |
| 123 | 123 | | 0.3 | Fix **C-1**: `config/default.js:205` `serviceDefaultPeerRole: 'authenticated-user'` → `'anonymous'` | Silently reverts commit `9125f3c`; opens `arbitration.submit` (uncapped Map) to any anonymous swarm peer on 10 boxes | |
| 124 | | 0.4 | Pin **utah-8gb** to `hold` before its timer is ever armed | R-3 — it runs rc.4 on `channel: stable` (pinned 0.24.3); arming converges it *backwards*, logged as success | | |
| 124 | | 0.4 | Pin **utah-8gb** to exact signed tag `v0.25.0-rc.4` with `PINNED_TAG` before its timer is ever armed; retain `CHANNEL=stable` for its eventual destination | R-3 — it runs rc.4 while stable and the shared `hold` target are both 0.24.3; arming either unpinned channel converges it *backwards* across its Corestore-7 remigration, logged as success | | |
| 125 | 125 | |
| 126 | 126 | **Gate out of Phase 0:** utah-0.5gb green, admin operator on loopback with auth, C-1 shipped. |
| 127 | 127 |
| … | … | @@ -440,8 +440,9 @@ You can call the **product ladder complete for honest marketing** when: |
| 440 | 440 | |
| 441 | 441 | 1. **Name and ship the repair candidate:** finish release gates, cut a new signed |
| 442 | 442 | post-RC7 prerelease, and deploy it to the permitted canary only. Correct |
| 443 | Utah-8GB's disabled stable updater separately; do not fold a downgrade into | |
| 444 | a canary promotion. | |
| 443 | Utah-8GB's disabled stable updater separately: install the repaired updater, | |
| 444 | set `CHANNEL=stable` plus `PINNED_TAG=v0.25.0-rc.4`, prove a no-op dry run, | |
| 445 | then arm its timer. Do not fold a downgrade into a canary promotion. | |
| 445 | 446 | 2. **Prove the claims on live canary:** configure operator-owned push egress if |
| 446 | 447 | wake is a release claim; verify exact-lane wake, outboxlog persistence, |
| 447 | 448 | restricted-Tor negative probe, and the 100 MB onion transfer. Observe for |
| … | … | @@ -102,9 +102,23 @@ allowlist. Invalid list syntax fails closed before any SSH connection. The |
| 102 | 102 | runtime service-catalogue probe accepts the same environment variables, or |
| 103 | 103 | equivalent `--include` / `--exclude` options. |
| 104 | 104 | |
| 105 | To **hold** a box, point its channel at the version it already runs. To | |
| 106 | **roll the whole channel back**, set the tag back — boxes check out the | |
| 107 | older tag exactly like a forward update (health-gated the same way). | |
| 105 | To **hold one box** on the exact signed tag it already runs, set a per-host | |
| 106 | pin while preserving its intended channel: | |
| 107 | ||
| 108 | ```bash | |
| 109 | sudo bash fleet/install-updater.sh stable v0.25.0-rc.4 | |
| 110 | hiverelay-updater --dry-run | |
| 111 | ``` | |
| 112 | ||
| 113 | The installer writes `PINNED_TAG=` as data in | |
| 114 | `/etc/hiverelay-updater.conf`. A valid pin bypasses the moving channel target, | |
| 115 | but still goes through fetch, signed-tag verification, runtime preflight, and | |
| 116 | the health gate. Remove the `PINNED_TAG` line only when that box is ready to | |
| 117 | rejoin its channel. Do not use the shared `hold` channel to freeze a box that | |
| 118 | runs a different version: `hold` has one fleet-wide target and may serve other | |
| 119 | operator-owned lanes. To **roll a whole channel back**, set its shared tag back | |
| 120 | — boxes check out the older tag exactly like a forward update (health-gated the | |
| 121 | same way). | |
| 108 | 122 | |
| 109 | 123 | ## Safety properties |
| 110 | 124 | |
| … | … | @@ -117,8 +131,9 @@ older tag exactly like a forward update (health-gated the same way). |
| 117 | 131 | version. See "Signed releases" above and `docs/SUPPLY-CHAIN.md`. |
| 118 | 132 | - **Dirty-tree guard:** the agent refuses to act if the repo has |
| 119 | 133 | uncommitted changes — it never clobbers a hand-edit. |
| 120 | - **Config treated as data:** `/etc/hiverelay-updater.conf` is parsed for a | |
| 121 | single validated `CHANNEL=` value; the updater does not source it as shell. | |
| 134 | - **Config treated as data:** `/etc/hiverelay-updater.conf` is parsed for | |
| 135 | validated `CHANNEL=` and optional `PINNED_TAG=` values; the updater does not | |
| 136 | source it as shell. | |
| 122 | 137 | - **Single-flight:** `flock`; overlapping ticks can't collide. |
| 123 | 138 | - **Jittered:** boxes update on randomized offsets — no thundering herd |
| 124 | 139 | on GitHub and no fleet-wide simultaneous restart. |
| … | … | @@ -112,6 +112,8 @@ U_ACTIVE="$(systemctl is-active hiverelay-updater.timer 2>/dev/null || true)" |
| 112 | 112 | [ -n "$U_ACTIVE" ] || U_ACTIVE=inactive |
| 113 | 113 | C=$(awk -F= '/^[[:space:]]*CHANNEL[[:space:]]*=/ { sub(/^[^=]*=/,""); sub(/^[[:space:]]*/,""); sub(/[[:space:]]*$/,""); print; exit }' /etc/hiverelay-updater.conf 2>/dev/null || true) |
| 114 | 114 | [ -n "$C" ] || C=? |
| 115 | P=$(awk -F= '/^[[:space:]]*PINNED_TAG[[:space:]]*=/ { sub(/^[^=]*=/,""); sub(/^[[:space:]]*/,""); sub(/[[:space:]]*$/,""); print; exit }' /etc/hiverelay-updater.conf 2>/dev/null || true) | |
| 116 | [ -z "$P" ] || C="$C@$P" | |
| 115 | 117 | echo "$V|$R|$D|$U_ENABLED/$U_ACTIVE|$C" |
| 116 | 118 | REMOTE_EOF |
| 117 | 119 |
| … | … | @@ -6,11 +6,14 @@ |
| 6 | 6 | # curl -fsSL .../fleet/install-updater.sh | bash -s -- <channel> |
| 7 | 7 | # or, from a checked-out repo on the box: |
| 8 | 8 | # sudo bash fleet/install-updater.sh stable |
| 9 | # sudo bash fleet/install-updater.sh stable v0.25.0-rc.4 # exact signed-tag pin | |
| 9 | 10 | # |
| 10 | # <channel> must match a key in fleet/channels.json (stable | canary). | |
| 11 | # <channel> must match a key in fleet/channels.json (stable | canary | hold). | |
| 12 | # [pinned-tag] overrides the moving channel target until an operator removes it. | |
| 11 | 13 | set -euo pipefail |
| 12 | 14 | |
| 13 | 15 | CHANNEL="${1:-stable}" |
| 16 | PINNED_TAG="${2:-}" | |
| 14 | 17 | REPO_DIR="${HIVERELAY_REPO_DIR:-$HOME/hiverelay}" |
| 15 | 18 | SRC="$REPO_DIR/fleet" |
| 16 | 19 | |
| … | … | @@ -18,16 +21,27 @@ if [[ ! "$CHANNEL" =~ ^[A-Za-z0-9._-]{1,32}$ ]]; then |
| 18 | 21 | echo "Invalid channel '$CHANNEL' — use a key from fleet/channels.json (for example stable or canary)" >&2 |
| 19 | 22 | exit 1 |
| 20 | 23 | fi |
| 24 | if [ -n "$PINNED_TAG" ] && [[ ! "$PINNED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then | |
| 25 | echo "Invalid pinned tag '$PINNED_TAG' — use a release tag such as v0.25.0-rc.4" >&2 | |
| 26 | exit 1 | |
| 27 | fi | |
| 21 | 28 | |
| 22 | 29 | [ -d "$SRC" ] || { echo "fleet/ not found in $REPO_DIR — clone/pull the repo first"; exit 1; } |
| 23 | 30 | |
| 24 | echo "Installing hiverelay-updater (channel=$CHANNEL)…" | |
| 31 | if [ -n "$PINNED_TAG" ]; then | |
| 32 | echo "Installing hiverelay-updater (channel=$CHANNEL, pinned-tag=$PINNED_TAG)…" | |
| 33 | else | |
| 34 | echo "Installing hiverelay-updater (channel=$CHANNEL)…" | |
| 35 | fi | |
| 25 | 36 | |
| 26 | 37 | install -m 0755 "$SRC/updater.sh" /usr/local/bin/hiverelay-updater |
| 27 | 38 | install -m 0644 "$SRC/hiverelay-updater.service" /etc/systemd/system/hiverelay-updater.service |
| 28 | 39 | install -m 0644 "$SRC/hiverelay-updater.timer" /etc/systemd/system/hiverelay-updater.timer |
| 29 | 40 | |
| 30 | 41 | printf 'CHANNEL=%s\n' "$CHANNEL" > /etc/hiverelay-updater.conf |
| 42 | if [ -n "$PINNED_TAG" ]; then | |
| 43 | printf 'PINNED_TAG=%s\n' "$PINNED_TAG" >> /etc/hiverelay-updater.conf | |
| 44 | fi | |
| 31 | 45 | chmod 0644 /etc/hiverelay-updater.conf |
| 32 | 46 | |
| 33 | 47 | systemctl daemon-reload |
| … | … | @@ -50,7 +64,11 @@ if [ -f "$SRC/harden-box.sh" ]; then |
| 50 | 64 | bash "$SRC/harden-box.sh" || echo "WARN harden-box.sh failed (non-fatal)" |
| 51 | 65 | fi |
| 52 | 66 | |
| 53 | echo "Installed. Channel=$CHANNEL. Timer:" | |
| 67 | if [ -n "$PINNED_TAG" ]; then | |
| 68 | echo "Installed. Channel=$CHANNEL pinned-tag=$PINNED_TAG. Timer:" | |
| 69 | else | |
| 70 | echo "Installed. Channel=$CHANNEL. Timer:" | |
| 71 | fi | |
| 54 | 72 | systemctl status hiverelay-updater.timer --no-pager | sed -n '1,4p' || true |
| 55 | 73 | echo |
| 56 | 74 | echo "Dry-run the agent now (no changes):" |
| … | … | @@ -5,8 +5,9 @@ |
| 5 | 5 | # Run by a systemd timer (every ~15 min). Each box owns its own lifecycle: |
| 6 | 6 | # no inbound SSH, no orchestrator, works behind NAT. The flow: |
| 7 | 7 | # |
| 8 | # 1. Read this box's channel (/etc/hiverelay-updater.conf, default stable). | |
| 9 | # 2. Fetch fleet/channels.json from the repo and resolve the target tag. | |
| 8 | # 1. Read this box's channel and optional exact signed-tag pin | |
| 9 | # (/etc/hiverelay-updater.conf, default stable). | |
| 10 | # 2. Resolve the pin, or fetch fleet/channels.json and resolve the channel. | |
| 10 | 11 | # 3. If already on the target -> no-op. |
| 11 | 12 | # 4. Otherwise: snapshot current SHA, fetch + checkout the target tag, |
| 12 | 13 | # reinstall deps only if package-lock changed, restart the relay. |
| … | … | @@ -147,6 +148,7 @@ fi |
| 147 | 148 | |
| 148 | 149 | # ── channel ──────────────────────────────────────────────────────── |
| 149 | 150 | CHANNEL="stable" |
| 151 | PINNED_TAG="" | |
| 150 | 152 | if [ -r "$CONF" ]; then |
| 151 | 153 | # Treat config as data, not shell. The installer writes CHANNEL=<name>; |
| 152 | 154 | # sourcing this file would make a writable config path code-executable. |
| … | … | @@ -156,16 +158,29 @@ if [ -r "$CONF" ]; then |
| 156 | 158 | CONF_CHANNEL="${CONF_CHANNEL%\'}" |
| 157 | 159 | CONF_CHANNEL="${CONF_CHANNEL#\'}" |
| 158 | 160 | [ -n "$CONF_CHANNEL" ] && CHANNEL="$CONF_CHANNEL" |
| 161 | CONF_PINNED_TAG="$(sed -n 's/^[[:space:]]*PINNED_TAG[[:space:]]*=[[:space:]]*//p' "$CONF" | head -n 1)" | |
| 162 | CONF_PINNED_TAG="${CONF_PINNED_TAG%\"}" | |
| 163 | CONF_PINNED_TAG="${CONF_PINNED_TAG#\"}" | |
| 164 | CONF_PINNED_TAG="${CONF_PINNED_TAG%\'}" | |
| 165 | CONF_PINNED_TAG="${CONF_PINNED_TAG#\'}" | |
| 166 | [ -n "$CONF_PINNED_TAG" ] && PINNED_TAG="$CONF_PINNED_TAG" | |
| 159 | 167 | fi |
| 160 | 168 | if [[ ! "$CHANNEL" =~ ^[A-Za-z0-9._-]{1,32}$ ]]; then |
| 161 | 169 | die "invalid channel '$CHANNEL' in $CONF" |
| 162 | 170 | fi |
| 171 | if [ -n "$PINNED_TAG" ] && [[ ! "$PINNED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then | |
| 172 | die "invalid pinned tag '$PINNED_TAG' in $CONF" | |
| 173 | fi | |
| 163 | 174 | |
| 164 | 175 | # ── resolve target tag ───────────────────────────────────────────── |
| 165 | JSON="$(curl -fsS --max-time 20 "$CHANNELS_URL")" || die "cannot fetch channels.json" | |
| 166 | TARGET="$(printf '%s' "$JSON" | CHANNEL="$CHANNEL" python3 -c 'import os,sys,json; print(json.load(sys.stdin).get(os.environ["CHANNEL"], ""))')" \ | |
| 167 | || die "cannot parse channels.json" | |
| 168 | [ -n "$TARGET" ] || die "no target tag for channel '$CHANNEL'" | |
| 176 | if [ -n "$PINNED_TAG" ]; then | |
| 177 | TARGET="$PINNED_TAG" | |
| 178 | else | |
| 179 | JSON="$(curl -fsS --max-time 20 "$CHANNELS_URL")" || die "cannot fetch channels.json" | |
| 180 | TARGET="$(printf '%s' "$JSON" | CHANNEL="$CHANNEL" python3 -c 'import os,sys,json; print(json.load(sys.stdin).get(os.environ["CHANNEL"], ""))')" \ | |
| 181 | || die "cannot parse channels.json" | |
| 182 | [ -n "$TARGET" ] || die "no target tag for channel '$CHANNEL'" | |
| 183 | fi | |
| 169 | 184 | if [[ ! "$TARGET" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then |
| 170 | 185 | die "invalid target tag '$TARGET' for channel '$CHANNEL'" |
| 171 | 186 | fi |
| … | … | @@ -183,10 +198,24 @@ CUR_SHA="$(git rev-parse HEAD)" |
| 183 | 198 | git fetch --tags --quiet origin || die "git fetch failed" |
| 184 | 199 | TARGET_SHA="$(git rev-parse -q --verify "refs/tags/$TARGET^{}")" || die "target tag $TARGET not found after fetch" |
| 185 | 200 | |
| 201 | # Verify even when the target already equals HEAD or this is a dry run. A | |
| 202 | # no-op decision proves only commit equality; it must not let an unsigned or | |
| 203 | # untrusted pin masquerade as a safe installed release. | |
| 204 | verify_tag "$TARGET" || die "refusing to update: tag $TARGET is not signed by a trusted key (see fleet/README.md 'Signed releases')" | |
| 205 | ||
| 186 | 206 | if [ "$CUR_SHA" = "$TARGET_SHA" ]; then |
| 187 | log "channel=$CHANNEL up-to-date at $TARGET ($TARGET_SHA)"; exit 0 | |
| 207 | if [ -n "$PINNED_TAG" ]; then | |
| 208 | log "channel=$CHANNEL pinned=$PINNED_TAG up-to-date at $TARGET ($TARGET_SHA)" | |
| 209 | else | |
| 210 | log "channel=$CHANNEL up-to-date at $TARGET ($TARGET_SHA)" | |
| 211 | fi | |
| 212 | exit 0 | |
| 213 | fi | |
| 214 | if [ -n "$PINNED_TAG" ]; then | |
| 215 | log "channel=$CHANNEL pinned=$PINNED_TAG current=$CUR_VER/$CUR_SHA target=$TARGET/$TARGET_SHA" | |
| 216 | else | |
| 217 | log "channel=$CHANNEL current=$CUR_VER/$CUR_SHA target=$TARGET/$TARGET_SHA" | |
| 188 | 218 | fi |
| 189 | log "channel=$CHANNEL current=$CUR_VER/$CUR_SHA target=$TARGET/$TARGET_SHA" | |
| 190 | 219 | if [ "$DRY_RUN" = 1 ]; then log "dry-run: would update $CUR_SHA -> $TARGET_SHA"; exit 0; fi |
| 191 | 220 | |
| 192 | 221 | # ── helpers ──────────────────────────────────────────────────────── |
| … | … | @@ -300,13 +329,6 @@ rollback_to_previous() { |
| 300 | 329 | } |
| 301 | 330 | |
| 302 | 331 | # ── update ───────────────────────────────────────────────────────── |
| 303 | # SUPPLY-CHAIN GATE (fail closed): never check out a tag we can't verify was | |
| 304 | # signed by a trusted key. This runs AFTER fetch (so the tag object + its | |
| 305 | # signature are local) and BEFORE checkout (so a forged/moved tag can never | |
| 306 | # reach the working tree, deps install, or a service restart). A failure | |
| 307 | # here leaves the box exactly where it was — no rollback needed. | |
| 308 | verify_tag "$TARGET" || die "refusing to update: tag $TARGET is not signed by a trusted key (see fleet/README.md 'Signed releases')" | |
| 309 | ||
| 310 | 332 | git checkout --quiet "$TARGET" || die "checkout $TARGET failed" |
| 311 | 333 | deps_if_changed "$CUR_SHA" "$TARGET_SHA" || rollback_to_previous "dependency install failed on $TARGET" |
| 312 | 334 | preflight_runtime || rollback_to_previous "runtime preflight failed on $TARGET (native addon or Node ABI)" |
| … | … | @@ -9185,11 +9185,17 @@ if ( |
| 9185 | 9185 | fleetUpdaterScript.includes(updaterExpectedVersionArg) && |
| 9186 | 9186 | fleetUpdaterScript.includes(updaterTargetHealthGate) && |
| 9187 | 9187 | fleetUpdaterScript.includes(updaterRollbackHealthGate) && |
| 9188 | fleetUpdaterScript.includes('TARGET="$PINNED_TAG"') && | |
| 9189 | fleetUpdaterScript.indexOf('verify_tag "$TARGET" ||') < | |
| 9190 | fleetUpdaterScript.indexOf('if [ "$CUR_SHA" = "$TARGET_SHA" ]') && | |
| 9191 | fleetUpdaterScript.indexOf('verify_tag "$TARGET" ||') < | |
| 9192 | fleetUpdaterScript.indexOf('if [ "$DRY_RUN" = 1 ]') && | |
| 9193 | fleetShellSafetyTest.includes('fleet updater supports an exact signed-tag pin') && | |
| 9188 | 9194 | fleetShellSafetyTest.includes('fleet updater health-gates target and rollback runtime versions') |
| 9189 | 9195 | ) { |
| 9190 | pass('fleet updater rolls back dependency-install failures, stops on failed rollback checkout, and verifies runtime versions') | |
| 9196 | pass('fleet updater verifies exact pins before early exits, rolls back dependency-install failures, stops on failed rollback checkout, and verifies runtime versions') | |
| 9191 | 9197 | } else { |
| 9192 | fail('fleet updater can exit before rollback, continue after failed rollback checkout, or accept a stale runtime version') | |
| 9198 | fail('fleet updater can accept an unverified pin, exit before rollback, continue after failed rollback checkout, or accept a stale runtime version') | |
| 9193 | 9199 | } |
| 9194 | 9200 | |
| 9195 | 9201 | if ( |
| … | … | @@ -19,6 +19,19 @@ test('fleet scripts pass channel names into JSON parsing as data', (t) => { |
| 19 | 19 | t.absent(fleetStatus.includes('python3 -c "import sys,json;print(json.load(sys.stdin).get(')) |
| 20 | 20 | }) |
| 21 | 21 | |
| 22 | test('fleet updater supports an exact signed-tag pin without changing shared channels', (t) => { | |
| 23 | t.ok(updater.includes('PINNED_TAG=""')) | |
| 24 | t.ok(updater.includes('PINNED_TAG[[:space:]]*=[[:space:]]*')) | |
| 25 | t.ok(updater.includes("invalid pinned tag '$PINNED_TAG' in $CONF")) | |
| 26 | t.ok(updater.includes('TARGET="$PINNED_TAG"')) | |
| 27 | t.ok(updater.includes('pinned=$PINNED_TAG')) | |
| 28 | t.ok(updater.indexOf('verify_tag "$TARGET"') > updater.indexOf('TARGET="$PINNED_TAG"'), | |
| 29 | 'a pinned tag still crosses the signed-tag gate') | |
| 30 | t.absent(updater.includes('source "$CONF"')) | |
| 31 | t.ok(fleetStatus.includes('PINNED_TAG[[:space:]]*=')) | |
| 32 | t.ok(fleetStatus.includes('C="$C@$P"')) | |
| 33 | }) | |
| 34 | ||
| 22 | 35 | test('fleet updater routes dependency-install failures through rollback', (t) => { |
| 23 | 36 | t.ok(updater.includes('rollback_to_previous')) |
| 24 | 37 | t.ok(updater.includes('deps_if_changed "$CUR_SHA" "$TARGET_SHA" || rollback_to_previous')) |
| … | … | @@ -31,6 +31,10 @@ test('updater defines a tag-signature gate wired before checkout', (t) => { |
| 31 | 31 | t.ok(gateIdx !== -1, 'target tag is gated by verify_tag') |
| 32 | 32 | t.ok(checkoutIdx !== -1, 'target checkout still present') |
| 33 | 33 | t.ok(gateIdx < checkoutIdx, 'verify_tag gate precedes the checkout') |
| 34 | t.ok(gateIdx < updater.indexOf('if [ "$CUR_SHA" = "$TARGET_SHA" ]'), | |
| 35 | 'signature gate also precedes the up-to-date early exit') | |
| 36 | t.ok(gateIdx < updater.indexOf('if [ "$DRY_RUN" = 1 ]'), | |
| 37 | 'signature gate also precedes the dry-run early exit') | |
| 34 | 38 | |
| 35 | 39 | // Fail-closed building blocks. |
| 36 | 40 | t.ok(updater.includes('ALLOWED_SIGNERS='), 'allowed-signers path is configurable') |
| … | … | @@ -75,7 +79,8 @@ test('updater --verify-only accepts a trusted-signed tag and rejects everything |
| 75 | 79 | git(['config', 'user.email', 'good@hiverelay']) |
| 76 | 80 | git(['config', 'gpg.format', 'ssh']) |
| 77 | 81 | writeFileSync(path.join(repo, 'a'), 'hi\n') |
| 78 | git(['add', 'a']) | |
| 82 | writeFileSync(path.join(repo, 'package.json'), '{"version":"1.0.0"}\n') | |
| 83 | git(['add', 'a', 'package.json']) | |
| 79 | 84 | git(['commit', '-qm', 'init']) |
| 80 | 85 | |
| 81 | 86 | // 1. annotated tag signed by the TRUSTED key |
| … | … | @@ -104,4 +109,30 @@ test('updater --verify-only accepts a trusted-signed tag and rejects everything |
| 104 | 109 | // Break-glass override is honored but loud (documented emergency escape). |
| 105 | 110 | t.is(verify('v2.0.0-unsigned', { HIVERELAY_REQUIRE_SIGNED_TAGS: '0' }).status, 0, |
| 106 | 111 | 'HIVERELAY_REQUIRE_SIGNED_TAGS=0 disables the gate (break-glass)') |
| 112 | ||
| 113 | // A per-host pin must bypass the moving channel document, but it must not | |
| 114 | // bypass trust. Use a bare origin because the normal updater always fetches | |
| 115 | // tags before deciding whether the installed commit is already current. | |
| 116 | const origin = path.join(work, 'origin.git') | |
| 117 | run('git', ['clone', '--bare', '-q', repo, origin]) | |
| 118 | git(['remote', 'add', 'origin', origin]) | |
| 119 | const conf = path.join(work, 'updater.conf') | |
| 120 | const pinnedDryRun = (tag) => { | |
| 121 | writeFileSync(conf, `CHANNEL=stable\nPINNED_TAG=${tag}\n`) | |
| 122 | return spawnSync('bash', [updaterPath, '--dry-run'], { | |
| 123 | encoding: 'utf8', | |
| 124 | env: { | |
| 125 | ...process.env, | |
| 126 | HIVERELAY_REPO_DIR: repo, | |
| 127 | HIVERELAY_UPDATER_CONF: conf, | |
| 128 | HIVERELAY_ALLOWED_SIGNERS: allowedSigners, | |
| 129 | HIVERELAY_CHANNELS_URL: 'file:///definitely-not-a-channel-document' | |
| 130 | } | |
| 131 | }) | |
| 132 | } | |
| 133 | ||
| 134 | const trustedPin = pinnedDryRun('v1.0.0-trusted') | |
| 135 | t.is(trustedPin.status, 0, 'trusted exact pin succeeds without reading the channel document') | |
| 136 | t.ok(trustedPin.stdout.includes('pinned=v1.0.0-trusted'), 'pin decision is visible to the operator') | |
| 137 | t.not(pinnedDryRun('v9.9.9-forged').status, 0, 'untrusted exact pin fails even when it resolves to current HEAD') | |
| 107 | 138 | }) |