| … | … | @@ -57,6 +57,9 @@ canary promotion remain required before deployment.** |
| 57 | 57 | - Fleet health and service probes support exact include/exclude scopes before |
| 58 | 58 | SSH, report exact prerelease versions and updater/channel drift, inspect |
| 59 | 59 | signed capability evidence, and avoid exposing private inventory/key paths. |
| 60 | The service probe now distinguishes catalogue reachability, host reachability, | |
| 61 | and an active relay application, and counts restricted Tor advertisements as | |
| 62 | ready only after their anonymous-rejection proof succeeds. | |
| 60 | 63 | - Fleet updater config supports a validated per-host `PINNED_TAG`, allowing a |
| 61 | 64 | remigrated box to restore its timer without following an older shared channel |
| 62 | 65 | target. Signature verification now also precedes up-to-date and dry-run exits. |
| … | … | @@ -55,19 +55,19 @@ Marketing one-liner when both are live: **“Fleet v0.25.0 + Blind public-test o |
| 55 | 55 | | Item | State | |
| 56 | 56 | |------|--------| |
| 57 | 57 | | Fleet channel | **canary `v0.25.0-rc.7`** · **stable `v0.24.3`** · held `v0.24.3` | |
| 58 | | Repair candidate | `v0.25.0-rc.8` package identity prepared: full Node unit **3561/3561**, integration **107/107**, lint, public-artifact audit, release mailbox benchmark, and npm package dry-runs green; signed tag and CI image evidence remain pending | | |
| 59 | | Scoped live health (2026-07-29) | Latest operator scan excludes Sydney/Dallas and covers 12 inventory entries: runtime service evidence reached **8/12**. Dubai is healthy on `v0.24.3` despite its public service probe failing; Bern remains unreachable; Dubai-2GB is a blank/inactive host with no relay repo, service, updater, config, or health endpoint. `utah-8gb` is healthy on signed RC4 with `CHANNEL=stable`, exact `PINNED_TAG=v0.25.0-rc.4`, and updater timer enabled/active | | |
| 60 | | Wake runtime | `notify` loaded on **8/8** service-reachable relays, but signed live egress **0/8** and exact-lane wake **0/8**; seven stable relays still carry the old false `notify-v1` feature claim, while Utah-8GB correctly reports loaded-not-live | | |
| 61 | | Agent mailbox | `outboxlog` is signed-advertised on **7/8** service-reachable relays; Utah-8GB has mailbox off, while the canary Utah service endpoint was unreachable in this scan | | |
| 62 | | Privacy runtime | one signed ready Tor endpoint across the eight service-reachable relays; no deployed restricted endpoint has the required anonymous rejection proof. Post-RC7 code suppresses the restricted claim until that probe succeeds | | |
| 63 | | One-hop forward | advertised on **0/8** service-reachable relays; the bounded compatibility route exists in code but is not live split-transport/OHTTP evidence | | |
| 58 | | Repair candidate | `v0.25.0-rc.8` package identity prepared: full Node unit **3564/3564**, integration **107/107**, lint, public-artifact audit, release mailbox benchmark, and npm package dry-runs green; signed tag and CI image evidence remain pending | | |
| 59 | | Scoped live health (2026-07-29) | Latest operator scan excludes Sydney/Dallas and covers 12 inventory entries: service catalogues reached **10/12**, hosts **11/12**, and active relay applications **10/12**. Bern remains unreachable; Dubai-2GB is reachable but blank/inactive with no relay repo, service, updater, config, or health endpoint. `utah-8gb` is healthy on signed RC4 with `CHANNEL=stable`, exact `PINNED_TAG=v0.25.0-rc.4`, and updater timer enabled/active | | |
| 60 | | Wake runtime | `notify` loaded on **10/10** service-reachable relays, but signed live egress **0/10** and exact-lane wake **0/10**; eight stable relays still carry the old false `notify-v1` feature claim, while Utah and Utah-8GB correctly report loaded-not-live | | |
| 61 | | Agent mailbox | `outboxlog` is signed-advertised on **8/10** service-reachable relays; Utah and Utah-8GB have mailbox off | | |
| 62 | | Privacy runtime | two restricted Tor endpoints are signed-advertised, but signed-ready is **0/10** because neither has the required anonymous rejection proof. Post-RC7 code suppresses readiness until that probe succeeds | | |
| 63 | | One-hop forward | advertised on **1/10** service-reachable relays; the bounded compatibility route exists live, but it is not split-transport/OHTTP evidence | | |
| 64 | 64 | | Packaging truth | Node/Bare now translate bounded service/Tor env into first-boot defaults; persisted config and `services.json` remain authoritative, while `/api/v1/services` plus the signed capability doc are runtime proof | |
| 65 | 65 | | App release storage | Bounded Hyperdrive publishing is integrated in the repair candidate: exact tree mirroring, signed monotonic releases, budget-triggered drive rotation, pinned-tree verification, and exact app/publisher rollback reclamation; not deployed until the new prerelease ships | |
| 66 | 66 | | Track B | Blind public-test pilot (syd/dal) — **parallel** version line, not this fleet tag | |
| 67 | 67 | | Boot-restore | Worktree only — **not on main** until Track C | |
| 68 | 68 | | Blind packages on main | **Absent** (`blind-daemon` / edge only in vNext worktrees) | |
| 69 | 69 | |
| 70 | **Badge today (honest):** G0 + **partial G1** (mailbox present; push wake not live) + **partial G2-S/G3** (shard-store code exists; not fleet-wide) + **partial G4-T** (one signed-ready endpoint; no deployed restricted endpoint has the required negative proof). | |
| 70 | **Badge today (honest):** G0 + **partial G1** (mailbox present; push wake not live) + **partial G2-S/G3** (shard-store code exists; not fleet-wide) + **partial G4-T** (two restricted endpoints advertised; zero ready until negative proof lands). | |
| 71 | 71 | |
| 72 | 72 | ### Retiring or becoming redundant |
| 73 | 73 | |
| … | … | @@ -75,7 +75,7 @@ Marketing one-liner when both are live: **“Fleet v0.25.0 + Blind public-test o |
| 75 | 75 | |---------|-----------------|-----| |
| 76 | 76 | | `v0.25.0-rc.5` | Retired; never deploy | Its updater install path cannot complete. | |
| 77 | 77 | | RC4 / RC6 fleet targets | Retired by RC7 and the next repair prerelease | They lack the complete updater/security/health corrections. | |
| 78 | | Stable `v0.24.3` `notify-v1` claim | Must be retired by a health-gated stable promotion | Seven live relays advertise wake while using a non-live memory sink. | | |
| 78 | | Stable `v0.24.3` `notify-v1` claim | Must be retired by a health-gated stable promotion | Eight live relays advertise wake while using a non-live memory sink. | | |
| 79 | 79 | | `notify-feed-head` | Compatibility-only; deprecated in signed capabilities when exact lanes are wired | Global sender-head changes can wake unrelated recipients; `notify-outbox-lane` is precise and opaque. | |
| 80 | 80 | | Config-derived fleet service reports | Replaced by runtime `/api/v1/services` plus signed capability evidence | Config can be overridden by `services.json` and previously reported services that never loaded. | |
| 81 | 81 | | Grep-and-strip package version parsing | Replaced by exact package JSON parsing | It changed `v0.25.0-rc.7` into the misleading `v0.25.0.7`. | |
| … | … | @@ -99,6 +99,19 @@ export const AUDIT_OWNED_DIFF_SLICES = Object.freeze([ |
| 99 | 99 | 'test/unit/audit-owned-diff.test.js' |
| 100 | 100 | ] |
| 101 | 101 | }, |
| 102 | { | |
| 103 | id: 'fleet-runtime-evidence', | |
| 104 | title: 'Fleet runtime, application, and host evidence separation', | |
| 105 | files: [ | |
| 106 | 'CHANGELOG.md', | |
| 107 | 'docs/LADDER-SHIP-MAP.md', | |
| 108 | 'scripts/lib/audit-owned-diff.mjs', | |
| 109 | 'scripts/probe-fleet-services.mjs', | |
| 110 | 'test/unit/audit-owned-diff.test.js', | |
| 111 | 'test/unit/fleet-shell-safety.test.js', | |
| 112 | 'test/unit/probe-fleet-services.test.js' | |
| 113 | ] | |
| 114 | }, | |
| 102 | 115 | { |
| 103 | 116 | id: 'hyperdrive-release-lifecycle', |
| 104 | 117 | title: 'Bounded Hyperdrive release rotation and scoped reclamation', |
| … | … | @@ -23,6 +23,7 @@ |
| 23 | 23 | * node scripts/probe-fleet-services.mjs --include utah,bern |
| 24 | 24 | * node scripts/probe-fleet-services.mjs --exclude sydney,dallas |
| 25 | 25 | * node scripts/probe-fleet-services.mjs --no-ssh # HTTP only |
| 26 | * node scripts/probe-fleet-services.mjs --ssh-only # loopback API + host status | |
| 26 | 27 | */ |
| 27 | 28 | |
| 28 | 29 | import { readFileSync, existsSync } from 'node:fs' |
| … | … | @@ -35,8 +36,14 @@ const execFileAsync = promisify(execFile) |
| 35 | 36 | const REPO = join(dirname(fileURLToPath(import.meta.url)), '..') |
| 36 | 37 | |
| 37 | 38 | const args = process.argv.slice(2) |
| 39 | if (args.includes('--help')) { | |
| 40 | process.stdout.write('Usage: node scripts/probe-fleet-services.mjs [--json] [--relays path] [--include names] [--exclude names] [--no-ssh|--ssh-only]\n') | |
| 41 | process.exit(0) | |
| 42 | } | |
| 38 | 43 | const asJson = args.includes('--json') |
| 39 | 44 | const allowSsh = !args.includes('--no-ssh') |
| 45 | const allowHttp = !args.includes('--ssh-only') | |
| 46 | if (!allowSsh && !allowHttp) throw new Error('--no-ssh and --ssh-only cannot be combined') | |
| 40 | 47 | const relaysArg = args[args.indexOf('--relays') + 1] |
| 41 | 48 | const include = parseNameList(optionValue('--include') || process.env.HIVERELAY_FLEET_INCLUDE || '', '--include') |
| 42 | 49 | const exclude = parseNameList(optionValue('--exclude') || process.env.HIVERELAY_FLEET_EXCLUDE || '', '--exclude') |
| … | … | @@ -75,35 +82,49 @@ function expandHome (p) { |
| 75 | 82 | return typeof p === 'string' ? p.replace(/^~(?=\/|$)/, process.env.HOME || '') : p |
| 76 | 83 | } |
| 77 | 84 | |
| 85 | function relayHost (relay) { | |
| 86 | return relay.tailnet || relay.publicIp | |
| 87 | } | |
| 88 | ||
| 89 | function sshArgs (relay) { | |
| 90 | const host = relayHost(relay) | |
| 91 | if (!isSafeHost(host)) throw new Error('unsafe host in inventory') | |
| 92 | const key = expandHome(relay.sshKey) | |
| 93 | const keyArgs = key && key !== 'default' ? ['-i', key] : [] | |
| 94 | return [ | |
| 95 | ...keyArgs, | |
| 96 | '-o', 'BatchMode=yes', | |
| 97 | '-o', 'ConnectTimeout=8', | |
| 98 | '-o', 'StrictHostKeyChecking=accept-new', | |
| 99 | `root@${host}` | |
| 100 | ] | |
| 101 | } | |
| 102 | ||
| 78 | 103 | async function probeHttp (relay) { |
| 79 | const base = `http://${relay.publicIp}:9100` | |
| 104 | const host = relay.publicIp || relay.tailnet | |
| 105 | if (!isSafeHost(host)) throw new Error('unsafe host in inventory') | |
| 106 | const base = `http://${host}:9100` | |
| 80 | 107 | const [services, capability] = await Promise.all([ |
| 81 | 108 | fetch(`${base}/api/v1/services`, { signal: AbortSignal.timeout(6000) }), |
| 82 | 109 | fetch(`${base}/.well-known/hiverelay.json`, { signal: AbortSignal.timeout(6000) }) |
| 110 | .catch(() => null) | |
| 83 | 111 | ]) |
| 84 | 112 | return { |
| 85 | 113 | body: await services.json(), |
| 86 | capability: capability.ok ? await capability.json() : null, | |
| 114 | capability: capability?.ok ? await capability.json() : null, | |
| 87 | 115 | status: services.status, |
| 88 | 116 | via: 'http' |
| 89 | 117 | } |
| 90 | 118 | } |
| 91 | 119 | |
| 92 | 120 | async function probeSsh (relay) { |
| 93 | if (!isSafeHost(relay.publicIp)) throw new Error('unsafe host in inventory') | |
| 94 | const key = expandHome(relay.sshKey) | |
| 95 | const keyArgs = key && key !== 'default' ? ['-i', key] : [] | |
| 96 | 121 | const remote = [ |
| 97 | 122 | 'S=$(curl -fsS --max-time 6 http://127.0.0.1:9100/api/v1/services) || exit $?', |
| 98 | 123 | 'C=$(curl -fsS --max-time 6 http://127.0.0.1:9100/.well-known/hiverelay.json 2>/dev/null || printf \'{}\')', |
| 99 | 124 | 'printf \'%s\\n__HIVERELAY_CAP__\\n%s\\n\' "$S" "$C"' |
| 100 | 125 | ].join('\n') |
| 101 | 126 | const { stdout } = await execFileAsync('ssh', [ |
| 102 | ...keyArgs, | |
| 103 | '-o', 'BatchMode=yes', | |
| 104 | '-o', 'ConnectTimeout=8', | |
| 105 | '-o', 'StrictHostKeyChecking=accept-new', | |
| 106 | `root@${relay.publicIp}`, | |
| 127 | ...sshArgs(relay), | |
| 107 | 128 | remote |
| 108 | 129 | ], { timeout: 25000 }) |
| 109 | 130 | const parts = stdout.split('\n__HIVERELAY_CAP__\n') |
| … | … | @@ -115,17 +136,57 @@ async function probeSsh (relay) { |
| 115 | 136 | } |
| 116 | 137 | } |
| 117 | 138 | |
| 139 | async function probeSshHostStatus (relay) { | |
| 140 | const remote = [ | |
| 141 | 'app=$(systemctl is-active hiverelay 2>/dev/null || true)', | |
| 142 | '[ -n "$app" ] || app=unknown', | |
| 143 | 'repo=absent; [ -d "$HOME/hiverelay/.git" ] && repo=present', | |
| 144 | 'version="v$(node -p \'require(process.env.HOME + "/hiverelay/package.json").version\' 2>/dev/null || printf \'?\')"', | |
| 145 | 'updater=absent; [ -x /usr/local/bin/hiverelay-updater ] && updater=present', | |
| 146 | 'timer_enabled=$(systemctl is-enabled hiverelay-updater.timer 2>/dev/null || true)', | |
| 147 | '[ -n "$timer_enabled" ] || timer_enabled=unknown', | |
| 148 | 'timer_active=$(systemctl is-active hiverelay-updater.timer 2>/dev/null || true)', | |
| 149 | '[ -n "$timer_active" ] || timer_active=unknown', | |
| 150 | 'printf \'HIVERELAY_HOST_STATUS|%s|%s|%s|%s|%s|%s\\n\' "$app" "$repo" "$version" "$updater" "$timer_enabled" "$timer_active"' | |
| 151 | ].join('\n') | |
| 152 | const { stdout } = await execFileAsync('ssh', [ | |
| 153 | ...sshArgs(relay), | |
| 154 | remote | |
| 155 | ], { timeout: 25000 }) | |
| 156 | const line = stdout.split(/\r?\n/).find(entry => entry.startsWith('HIVERELAY_HOST_STATUS|')) | |
| 157 | if (!line) throw new Error('host status unavailable') | |
| 158 | const [, appStateRaw, repoRaw, versionRaw, updaterRaw, timerEnabledRaw, timerActiveRaw] = line.split('|') | |
| 159 | const appState = safeStatusToken(appStateRaw) | |
| 160 | const version = /^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(versionRaw || '') ? versionRaw : '?' | |
| 161 | return { | |
| 162 | hostReachable: true, | |
| 163 | appRunning: appState === 'active', | |
| 164 | appState, | |
| 165 | version, | |
| 166 | repoPresent: repoRaw === 'present', | |
| 167 | updaterPresent: updaterRaw === 'present', | |
| 168 | updaterTimer: `${safeStatusToken(timerEnabledRaw)}/${safeStatusToken(timerActiveRaw)}` | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | function safeStatusToken (value) { | |
| 173 | return /^[A-Za-z0-9_.-]{1,32}$/.test(value || '') ? value : '?' | |
| 174 | } | |
| 175 | ||
| 118 | 176 | async function probe (relay) { |
| 119 | 177 | const base = { name: relay.name, region: relay.region ?? null, ramGB: relay.ramGB ?? null } |
| 120 | if (!isSafeHost(relay.publicIp)) { | |
| 121 | return { ...base, reachable: false, error: 'unsafe host in inventory', services: [] } | |
| 178 | if (!isSafeHost(relayHost(relay))) { | |
| 179 | return { ...base, reachable: false, serviceCatalogueReachable: false, hostReachable: false, error: 'unsafe host in inventory', services: [] } | |
| 122 | 180 | } |
| 123 | for (const attempt of allowSsh ? [probeHttp, probeSsh] : [probeHttp]) { | |
| 181 | const attempts = [] | |
| 182 | if (allowHttp) attempts.push(probeHttp) | |
| 183 | if (allowSsh) attempts.push(probeSsh) | |
| 184 | for (const attempt of attempts) { | |
| 124 | 185 | try { |
| 125 | 186 | const { body, capability, via } = await attempt(relay) |
| 126 | 187 | // 503 {"error":"Services not enabled"} is a real answer, not a failure. |
| 127 | 188 | if (body && body.error) { |
| 128 | return { ...base, reachable: true, via, servicesEnabled: false, services: [], note: body.error } | |
| 189 | return { ...base, reachable: true, serviceCatalogueReachable: true, hostReachable: true, appRunning: true, via, servicesEnabled: false, services: [], note: body.error } | |
| 129 | 190 | } |
| 130 | 191 | const services = Array.isArray(body?.services) |
| 131 | 192 | ? body.services.map(s => (typeof s === 'string' ? s : s?.name)).filter(Boolean).sort() |
| … | … | @@ -136,10 +197,15 @@ async function probe (relay) { |
| 136 | 197 | const supportedTransports = Array.isArray(capability?.supported_transports) ? capability.supported_transports : [] |
| 137 | 198 | const privacyTransports = Array.isArray(capability?.privacyTransports) ? capability.privacyTransports : [] |
| 138 | 199 | const torPrivacy = privacyTransports.find(entry => entry?.network === 'tor') || null |
| 200 | const torRestricted = torPrivacy?.auth?.mode === 'client-auth-v3' | |
| 201 | const torNegativeProbe = torPrivacy?.negativeProbe === true | |
| 139 | 202 | const notifyWatchSources = Array.isArray(notifyProfile?.watch_sources) ? notifyProfile.watch_sources : [] |
| 140 | 203 | return { |
| 141 | 204 | ...base, |
| 142 | 205 | reachable: true, |
| 206 | serviceCatalogueReachable: true, | |
| 207 | hostReachable: true, | |
| 208 | appRunning: true, | |
| 143 | 209 | via, |
| 144 | 210 | servicesEnabled: true, |
| 145 | 211 | services, |
| … | … | @@ -152,16 +218,32 @@ async function probe (relay) { |
| 152 | 218 | outboxlogLoaded: services.includes('outboxlog'), |
| 153 | 219 | outboxlogAdvertised: !!serviceProfile.outboxlog, |
| 154 | 220 | torRuntime: supportedTransports.includes('tor'), |
| 155 | torSignedReady: !!torPrivacy, | |
| 156 | torRestricted: torPrivacy?.auth?.mode === 'client-auth-v3', | |
| 157 | torNegativeProbe: torPrivacy?.negativeProbe === true, | |
| 221 | torAdvertised: !!torPrivacy, | |
| 222 | torSignedReady: !!torPrivacy && (!torRestricted || torNegativeProbe), | |
| 223 | torRestricted, | |
| 224 | torNegativeProbe, | |
| 158 | 225 | forwardRelayAdvertised: supportedTransports.includes('hiverelay-forward') |
| 159 | 226 | } |
| 160 | 227 | } catch (err) { |
| 161 | 228 | base.error = safeProbeError(err) |
| 162 | 229 | } |
| 163 | 230 | } |
| 164 | return { ...base, reachable: false, services: [] } | |
| 231 | if (allowSsh) { | |
| 232 | try { | |
| 233 | const status = await probeSshHostStatus(relay) | |
| 234 | return { ...base, ...status, reachable: false, serviceCatalogueReachable: false, services: [] } | |
| 235 | } catch (err) { | |
| 236 | return { | |
| 237 | ...base, | |
| 238 | reachable: false, | |
| 239 | serviceCatalogueReachable: false, | |
| 240 | hostReachable: false, | |
| 241 | error: safeProbeError(err), | |
| 242 | services: [] | |
| 243 | } | |
| 244 | } | |
| 245 | } | |
| 246 | return { ...base, reachable: false, serviceCatalogueReachable: false, hostReachable: null, services: [] } | |
| 165 | 247 | } |
| 166 | 248 | |
| 167 | 249 | function safeProbeError (err) { |
| … | … | @@ -198,7 +280,11 @@ if (asJson) { |
| 198 | 280 | console.log('Source: GET /api/v1/services (what is LOADED, not what is configured)\n') |
| 199 | 281 | const w = Math.max(...results.map(r => r.name.length), 6) |
| 200 | 282 | for (const r of results) { |
| 201 | const status = !r.reachable ? 'unreachable' : r.servicesEnabled === false ? 'services off' : `${r.services.length} loaded` | |
| 283 | const status = r.reachable | |
| 284 | ? (r.servicesEnabled === false ? 'services off' : `${r.services.length} loaded`) | |
| 285 | : r.hostReachable | |
| 286 | ? (r.appRunning ? 'catalogue n/a' : 'relay inactive') | |
| 287 | : 'host unreachable' | |
| 202 | 288 | const wake = !r.notifyLoaded |
| 203 | 289 | ? 'wake=off' |
| 204 | 290 | : r.notifyEgressLive |
| … | … | @@ -210,13 +296,19 @@ if (asJson) { |
| 210 | 296 | ? (r.outboxlogAdvertised ? 'mailbox=advertised' : 'mailbox=loaded-not-advertised') |
| 211 | 297 | : 'mailbox=off' |
| 212 | 298 | const privacy = privacyLabel(r) |
| 213 | const detail = !r.reachable ? (r.error || '') : `${wake} ${mailbox} ${privacy}; ${r.services.join(', ') || (r.note || '')}` | |
| 299 | const detail = !r.reachable | |
| 300 | ? hostStatusDetail(r) | |
| 301 | : `${wake} ${mailbox} ${privacy}; ${r.services.join(', ') || (r.note || '')}` | |
| 214 | 302 | console.log(` ${r.name.padEnd(w)} ${String(r.region ?? '-').padEnd(5)} ${status.padEnd(13)} ${detail}`) |
| 215 | 303 | } |
| 216 | 304 | |
| 217 | 305 | const reachable = results.filter(r => r.reachable) |
| 306 | const hostReachable = results.filter(r => r.hostReachable === true) | |
| 307 | const appRunning = results.filter(r => r.appRunning === true) | |
| 218 | 308 | const union = [...new Set(reachable.flatMap(r => r.services))].sort() |
| 219 | console.log(`\n reachable: ${reachable.length}/${results.length}`) | |
| 309 | console.log(`\n service catalogue reachable: ${reachable.length}/${results.length}`) | |
| 310 | console.log(` host reachable: ${hostReachable.length}/${results.length}`) | |
| 311 | console.log(` relay application active: ${appRunning.length}/${results.length}`) | |
| 220 | 312 | console.log(` distinct services running across the fleet: ${union.length}${union.length ? ' — ' + union.join(', ') : ''}`) |
| 221 | 313 | |
| 222 | 314 | // The number that actually matters for diversity: a service on one box is a |
| … | … | @@ -229,15 +321,29 @@ if (asJson) { |
| 229 | 321 | console.log(` signed exact-lane wake: ${reachable.filter(r => r.exactLaneWake).length}/${reachable.length}`) |
| 230 | 322 | console.log(` unsafe legacy wake advertisements: ${reachable.filter(r => r.notifyFeatureAdvertised && !r.notifyEgressLive).length}/${reachable.length}`) |
| 231 | 323 | console.log(` advertised outbox mailbox: ${reachable.filter(r => r.outboxlogAdvertised).length}/${reachable.length}`) |
| 324 | console.log(` signed Tor advertisements: ${reachable.filter(r => r.torAdvertised).length}/${reachable.length}`) | |
| 232 | 325 | console.log(` signed ready Tor endpoints: ${reachable.filter(r => r.torSignedReady).length}/${reachable.length}`) |
| 233 | 326 | console.log(` restricted Tor endpoints with negative proof: ${reachable.filter(r => r.torRestricted && r.torNegativeProbe).length}/${reachable.length}`) |
| 234 | 327 | console.log(` advertised one-hop forward relays: ${reachable.filter(r => r.forwardRelayAdvertised).length}/${reachable.length}`) |
| 235 | 328 | console.log() |
| 236 | 329 | } |
| 237 | 330 | |
| 331 | function hostStatusDetail (relay) { | |
| 332 | if (!relay.hostReachable) return relay.error || 'probe failed' | |
| 333 | const fields = [ | |
| 334 | 'host=reachable', | |
| 335 | `app=${relay.appState || '?'}`, | |
| 336 | `version=${relay.version || '?'}`, | |
| 337 | `repo=${relay.repoPresent ? 'present' : 'absent'}`, | |
| 338 | `updater=${relay.updaterPresent ? 'present' : 'absent'}`, | |
| 339 | `timer=${relay.updaterTimer || '?'}` | |
| 340 | ] | |
| 341 | return fields.join(' ') | |
| 342 | } | |
| 343 | ||
| 238 | 344 | function privacyLabel (relay) { |
| 239 | 345 | if (!relay.torRuntime) return relay.forwardRelayAdvertised ? 'privacy=forward-only' : 'privacy=clearnet' |
| 240 | if (!relay.torSignedReady) return 'privacy=tor-loaded-not-ready' | |
| 346 | if (!relay.torAdvertised) return 'privacy=tor-loaded-not-advertised' | |
| 241 | 347 | if (!relay.torRestricted) return 'privacy=tor-public-ready' |
| 242 | 348 | return relay.torNegativeProbe ? 'privacy=tor-restricted-proved' : 'privacy=tor-restricted-unproved' |
| 243 | 349 | } |
| … | … | @@ -243,6 +243,22 @@ test('audit-owned diff assigns lockstep prerelease version surfaces', (t) => { |
| 243 | 243 | } |
| 244 | 244 | }) |
| 245 | 245 | |
| 246 | test('audit-owned diff assigns fleet runtime evidence surfaces', (t) => { | |
| 247 | const paths = [ | |
| 248 | 'CHANGELOG.md', | |
| 249 | 'docs/LADDER-SHIP-MAP.md', | |
| 250 | 'scripts/probe-fleet-services.mjs', | |
| 251 | 'test/unit/probe-fleet-services.test.js' | |
| 252 | ] | |
| 253 | const report = buildOwnedDiffReport(paths.map(file => ` M ${file}`).join('\n')) | |
| 254 | ||
| 255 | t.is(report.status, 'pass') | |
| 256 | t.is(report.totals.unknown, 0) | |
| 257 | for (const entry of report.entries) { | |
| 258 | t.ok(entry.owners.some(owner => owner.id === 'fleet-runtime-evidence'), entry.path) | |
| 259 | } | |
| 260 | }) | |
| 261 | ||
| 246 | 262 | test('audit-owned diff blocks unknown development paths', async (t) => { |
| 247 | 263 | const fixture = await tempFile(t, '?? stray-release-note.txt\n') |
| 248 | 264 | const res = await runAudit(['--json', '--status-file', fixture]) |
| … | … | @@ -179,6 +179,7 @@ test('fleet service probe scopes relay ownership before opening connections', (t |
| 179 | 179 | t.ok(fleetServiceProbe.includes('signed exact-lane wake:')) |
| 180 | 180 | t.ok(fleetServiceProbe.includes('advertised outbox mailbox:')) |
| 181 | 181 | t.ok(fleetServiceProbe.includes("privacyTransports.find(entry => entry?.network === 'tor')")) |
| 182 | t.ok(fleetServiceProbe.includes('signed Tor advertisements:')) | |
| 182 | 183 | t.ok(fleetServiceProbe.includes('restricted Tor endpoints with negative proof:')) |
| 183 | 184 | t.ok(fleetServiceProbe.includes('advertised one-hop forward relays:')) |
| 184 | 185 | }) |
| … | … | @@ -0,0 +1,98 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { spawn } from 'node:child_process' | |
| 3 | import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { delimiter, join } from 'node:path' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | ||
| 7 | function runProbe (args, env = {}) { | |
| 8 | return new Promise((resolve, reject) => { | |
| 9 | const child = spawn(process.execPath, ['scripts/probe-fleet-services.mjs', ...args], { | |
| 10 | env: { ...process.env, ...env }, | |
| 11 | stdio: ['ignore', 'pipe', 'pipe'] | |
| 12 | }) | |
| 13 | let stdout = '' | |
| 14 | let stderr = '' | |
| 15 | child.stdout.on('data', chunk => { stdout += chunk }) | |
| 16 | child.stderr.on('data', chunk => { stderr += chunk }) | |
| 17 | child.once('error', reject) | |
| 18 | child.once('close', code => resolve({ code, stdout, stderr })) | |
| 19 | }) | |
| 20 | } | |
| 21 | ||
| 22 | test('fleet service probe help is local and side-effect free', async (t) => { | |
| 23 | const result = await runProbe(['--help'], { PATH: '' }) | |
| 24 | t.is(result.code, 0) | |
| 25 | t.ok(result.stdout.startsWith('Usage: node scripts/probe-fleet-services.mjs')) | |
| 26 | t.is(result.stderr, '') | |
| 27 | }) | |
| 28 | ||
| 29 | test('fleet service probe distinguishes catalogue, application, and host reachability', async (t) => { | |
| 30 | const dir = await mkdtemp(join(tmpdir(), 'hiverelay-fleet-probe-')) | |
| 31 | t.teardown(() => rm(dir, { recursive: true, force: true })) | |
| 32 | const inventory = join(dir, 'relays.json') | |
| 33 | const ssh = join(dir, 'ssh') | |
| 34 | await writeFile(inventory, JSON.stringify({ | |
| 35 | relays: [ | |
| 36 | { name: 'legacy-live', publicIp: 'legacy-live.test', sshKey: 'default', region: 'EU' }, | |
| 37 | { name: 'blank-host', publicIp: 'blank-host.test', sshKey: 'default', region: 'ME' }, | |
| 38 | { name: 'down-host', publicIp: 'down-host.test', sshKey: 'default', region: 'NA' }, | |
| 39 | { name: 'restricted-tor', publicIp: 'restricted-tor.test', sshKey: 'default', region: 'EU' } | |
| 40 | ] | |
| 41 | })) | |
| 42 | await writeFile(ssh, `#!/bin/sh | |
| 43 | case "$*" in | |
| 44 | *restricted-tor.test*api/v1/services*) | |
| 45 | printf '%s\n' '{"services":["notify"],"total":1}' '__HIVERELAY_CAP__' '{"features":[],"supported_transports":["tor"],"privacyTransports":[{"network":"tor","auth":{"mode":"client-auth-v3"}}],"protocol_profile":{"services":{"notify":{"egress":{"live":false},"watch_sources":[]}}}}' | |
| 46 | exit 0 | |
| 47 | ;; | |
| 48 | *legacy-live.test*HIVERELAY_HOST_STATUS*) | |
| 49 | printf '%s\n' 'HIVERELAY_HOST_STATUS|active|present|v0.24.3|present|enabled|active' | |
| 50 | exit 0 | |
| 51 | ;; | |
| 52 | *blank-host.test*HIVERELAY_HOST_STATUS*) | |
| 53 | printf '%s\n' 'HIVERELAY_HOST_STATUS|inactive|absent|v?|absent|not-found|inactive' | |
| 54 | exit 0 | |
| 55 | ;; | |
| 56 | esac | |
| 57 | exit 22 | |
| 58 | `) | |
| 59 | await chmod(ssh, 0o755) | |
| 60 | ||
| 61 | const result = await runProbe(['--json', '--ssh-only', '--relays', inventory], { | |
| 62 | PATH: `${dir}${delimiter}${process.env.PATH || ''}` | |
| 63 | }) | |
| 64 | t.is(result.code, 0, result.stderr) | |
| 65 | const body = JSON.parse(result.stdout) | |
| 66 | const live = body.relays.find(relay => relay.name === 'legacy-live') | |
| 67 | const blank = body.relays.find(relay => relay.name === 'blank-host') | |
| 68 | const down = body.relays.find(relay => relay.name === 'down-host') | |
| 69 | const restrictedTor = body.relays.find(relay => relay.name === 'restricted-tor') | |
| 70 | ||
| 71 | t.is(live.serviceCatalogueReachable, false) | |
| 72 | t.is(live.hostReachable, true) | |
| 73 | t.is(live.appRunning, true) | |
| 74 | t.is(live.appState, 'active') | |
| 75 | t.is(live.version, 'v0.24.3') | |
| 76 | t.is(live.repoPresent, true) | |
| 77 | t.is(live.updaterPresent, true) | |
| 78 | t.is(live.updaterTimer, 'enabled/active') | |
| 79 | ||
| 80 | t.is(blank.serviceCatalogueReachable, false) | |
| 81 | t.is(blank.hostReachable, true) | |
| 82 | t.is(blank.appRunning, false) | |
| 83 | t.is(blank.appState, 'inactive') | |
| 84 | t.is(blank.version, '?') | |
| 85 | t.is(blank.repoPresent, false) | |
| 86 | t.is(blank.updaterPresent, false) | |
| 87 | t.is(blank.updaterTimer, 'not-found/inactive') | |
| 88 | ||
| 89 | t.is(down.serviceCatalogueReachable, false) | |
| 90 | t.is(down.hostReachable, false) | |
| 91 | t.is(down.appRunning, undefined) | |
| 92 | ||
| 93 | t.is(restrictedTor.serviceCatalogueReachable, true) | |
| 94 | t.is(restrictedTor.torAdvertised, true) | |
| 95 | t.is(restrictedTor.torRestricted, true) | |
| 96 | t.is(restrictedTor.torNegativeProbe, false) | |
| 97 | t.is(restrictedTor.torSignedReady, false) | |
| 98 | }) |