OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay commits / 9f079a0

fix: separate fleet runtime evidence layers
9f079a088424c58648795f5116cc1e152c8d958d
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-29T21:05:16.000Z

Changed files (7) +267 -30

CHANGELOG.md +3 -0
……@@ -57,6 +57,9 @@ canary promotion remain required before deployment.**
5757- Fleet health and service probes support exact include/exclude scopes before
5858 SSH, report exact prerelease versions and updater/channel drift, inspect
5959 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.
6063- Fleet updater config supports a validated per-host `PINNED_TAG`, allowing a
6164 remigrated box to restore its timer without following an older shared channel
6265 target. Signature verification now also precedes up-to-date and dry-run exits.
docs/LADDER-SHIP-MAP.md +8 -8
……@@ -55,19 +55,19 @@ Marketing one-liner when both are live: **“Fleet v0.25.0 + Blind public-test o
5555| Item | State |
5656|------|--------|
5757| 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 |
6464| 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 |
6565| 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 |
6666| Track B | Blind public-test pilot (syd/dal) — **parallel** version line, not this fleet tag |
6767| Boot-restore | Worktree only — **not on main** until Track C |
6868| Blind packages on main | **Absent** (`blind-daemon` / edge only in vNext worktrees) |
6969
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).
7171
7272### Retiring or becoming redundant
7373
……@@ -75,7 +75,7 @@ Marketing one-liner when both are live: **“Fleet v0.25.0 + Blind public-test o
7575|---------|-----------------|-----|
7676| `v0.25.0-rc.5` | Retired; never deploy | Its updater install path cannot complete. |
7777| 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. |
7979| `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. |
8080| 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. |
8181| 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`. |
scripts/lib/audit-owned-diff.mjs +13 -0
……@@ -99,6 +99,19 @@ export const AUDIT_OWNED_DIFF_SLICES = Object.freeze([
9999 'test/unit/audit-owned-diff.test.js'
100100 ]
101101 },
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 },
102115 {
103116 id: 'hyperdrive-release-lifecycle',
104117 title: 'Bounded Hyperdrive release rotation and scoped reclamation',
scripts/probe-fleet-services.mjs +128 -22
……@@ -23,6 +23,7 @@
2323 * node scripts/probe-fleet-services.mjs --include utah,bern
2424 * node scripts/probe-fleet-services.mjs --exclude sydney,dallas
2525 * node scripts/probe-fleet-services.mjs --no-ssh # HTTP only
26 * node scripts/probe-fleet-services.mjs --ssh-only # loopback API + host status
2627 */
2728
2829import { readFileSync, existsSync } from 'node:fs'
……@@ -35,8 +36,14 @@ const execFileAsync = promisify(execFile)
3536const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
3637
3738const args = process.argv.slice(2)
39if (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}
3843const asJson = args.includes('--json')
3944const allowSsh = !args.includes('--no-ssh')
45const allowHttp = !args.includes('--ssh-only')
46if (!allowSsh && !allowHttp) throw new Error('--no-ssh and --ssh-only cannot be combined')
4047const relaysArg = args[args.indexOf('--relays') + 1]
4148const include = parseNameList(optionValue('--include') || process.env.HIVERELAY_FLEET_INCLUDE || '', '--include')
4249const exclude = parseNameList(optionValue('--exclude') || process.env.HIVERELAY_FLEET_EXCLUDE || '', '--exclude')
……@@ -75,35 +82,49 @@ function expandHome (p) {
7582 return typeof p === 'string' ? p.replace(/^~(?=\/|$)/, process.env.HOME || '') : p
7683}
7784
85function relayHost (relay) {
86 return relay.tailnet || relay.publicIp
87}
88
89function 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
78103async 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`
80107 const [services, capability] = await Promise.all([
81108 fetch(`${base}/api/v1/services`, { signal: AbortSignal.timeout(6000) }),
82109 fetch(`${base}/.well-known/hiverelay.json`, { signal: AbortSignal.timeout(6000) })
110 .catch(() => null)
83111 ])
84112 return {
85113 body: await services.json(),
86 capability: capability.ok ? await capability.json() : null,
114 capability: capability?.ok ? await capability.json() : null,
87115 status: services.status,
88116 via: 'http'
89117 }
90118}
91119
92120async 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] : []
96121 const remote = [
97122 'S=$(curl -fsS --max-time 6 http://127.0.0.1:9100/api/v1/services) || exit $?',
98123 'C=$(curl -fsS --max-time 6 http://127.0.0.1:9100/.well-known/hiverelay.json 2>/dev/null || printf \'{}\')',
99124 'printf \'%s\\n__HIVERELAY_CAP__\\n%s\\n\' "$S" "$C"'
100125 ].join('\n')
101126 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),
107128 remote
108129 ], { timeout: 25000 })
109130 const parts = stdout.split('\n__HIVERELAY_CAP__\n')
……@@ -115,17 +136,57 @@ async function probeSsh (relay) {
115136 }
116137}
117138
139async 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
172function safeStatusToken (value) {
173 return /^[A-Za-z0-9_.-]{1,32}$/.test(value || '') ? value : '?'
174}
175
118176async function probe (relay) {
119177 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: [] }
122180 }
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) {
124185 try {
125186 const { body, capability, via } = await attempt(relay)
126187 // 503 {"error":"Services not enabled"} is a real answer, not a failure.
127188 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 }
129190 }
130191 const services = Array.isArray(body?.services)
131192 ? body.services.map(s => (typeof s === 'string' ? s : s?.name)).filter(Boolean).sort()
……@@ -136,10 +197,15 @@ async function probe (relay) {
136197 const supportedTransports = Array.isArray(capability?.supported_transports) ? capability.supported_transports : []
137198 const privacyTransports = Array.isArray(capability?.privacyTransports) ? capability.privacyTransports : []
138199 const torPrivacy = privacyTransports.find(entry => entry?.network === 'tor') || null
200 const torRestricted = torPrivacy?.auth?.mode === 'client-auth-v3'
201 const torNegativeProbe = torPrivacy?.negativeProbe === true
139202 const notifyWatchSources = Array.isArray(notifyProfile?.watch_sources) ? notifyProfile.watch_sources : []
140203 return {
141204 ...base,
142205 reachable: true,
206 serviceCatalogueReachable: true,
207 hostReachable: true,
208 appRunning: true,
143209 via,
144210 servicesEnabled: true,
145211 services,
……@@ -152,16 +218,32 @@ async function probe (relay) {
152218 outboxlogLoaded: services.includes('outboxlog'),
153219 outboxlogAdvertised: !!serviceProfile.outboxlog,
154220 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,
158225 forwardRelayAdvertised: supportedTransports.includes('hiverelay-forward')
159226 }
160227 } catch (err) {
161228 base.error = safeProbeError(err)
162229 }
163230 }
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: [] }
165247}
166248
167249function safeProbeError (err) {
……@@ -198,7 +280,11 @@ if (asJson) {
198280 console.log('Source: GET /api/v1/services (what is LOADED, not what is configured)\n')
199281 const w = Math.max(...results.map(r => r.name.length), 6)
200282 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'
202288 const wake = !r.notifyLoaded
203289 ? 'wake=off'
204290 : r.notifyEgressLive
……@@ -210,13 +296,19 @@ if (asJson) {
210296 ? (r.outboxlogAdvertised ? 'mailbox=advertised' : 'mailbox=loaded-not-advertised')
211297 : 'mailbox=off'
212298 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 || '')}`
214302 console.log(` ${r.name.padEnd(w)} ${String(r.region ?? '-').padEnd(5)} ${status.padEnd(13)} ${detail}`)
215303 }
216304
217305 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)
218308 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}`)
220312 console.log(` distinct services running across the fleet: ${union.length}${union.length ? ' — ' + union.join(', ') : ''}`)
221313
222314 // The number that actually matters for diversity: a service on one box is a
……@@ -229,15 +321,29 @@ if (asJson) {
229321 console.log(` signed exact-lane wake: ${reachable.filter(r => r.exactLaneWake).length}/${reachable.length}`)
230322 console.log(` unsafe legacy wake advertisements: ${reachable.filter(r => r.notifyFeatureAdvertised && !r.notifyEgressLive).length}/${reachable.length}`)
231323 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}`)
232325 console.log(` signed ready Tor endpoints: ${reachable.filter(r => r.torSignedReady).length}/${reachable.length}`)
233326 console.log(` restricted Tor endpoints with negative proof: ${reachable.filter(r => r.torRestricted && r.torNegativeProbe).length}/${reachable.length}`)
234327 console.log(` advertised one-hop forward relays: ${reachable.filter(r => r.forwardRelayAdvertised).length}/${reachable.length}`)
235328 console.log()
236329}
237330
331function 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
238344function privacyLabel (relay) {
239345 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'
241347 if (!relay.torRestricted) return 'privacy=tor-public-ready'
242348 return relay.torNegativeProbe ? 'privacy=tor-restricted-proved' : 'privacy=tor-restricted-unproved'
243349}
test/unit/audit-owned-diff.test.js +16 -0
……@@ -243,6 +243,22 @@ test('audit-owned diff assigns lockstep prerelease version surfaces', (t) => {
243243 }
244244})
245245
246test('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
246262test('audit-owned diff blocks unknown development paths', async (t) => {
247263 const fixture = await tempFile(t, '?? stray-release-note.txt\n')
248264 const res = await runAudit(['--json', '--status-file', fixture])
test/unit/fleet-shell-safety.test.js +1 -0
……@@ -179,6 +179,7 @@ test('fleet service probe scopes relay ownership before opening connections', (t
179179 t.ok(fleetServiceProbe.includes('signed exact-lane wake:'))
180180 t.ok(fleetServiceProbe.includes('advertised outbox mailbox:'))
181181 t.ok(fleetServiceProbe.includes("privacyTransports.find(entry => entry?.network === 'tor')"))
182 t.ok(fleetServiceProbe.includes('signed Tor advertisements:'))
182183 t.ok(fleetServiceProbe.includes('restricted Tor endpoints with negative proof:'))
183184 t.ok(fleetServiceProbe.includes('advertised one-hop forward relays:'))
184185})
test/unit/probe-fleet-services.test.js +98 -0
……@@ -0,0 +1,98 @@
1import test from 'brittle'
2import { spawn } from 'node:child_process'
3import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
4import { delimiter, join } from 'node:path'
5import { tmpdir } from 'node:os'
6
7function 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
22test('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
29test('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
43case "$*" 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 ;;
56esac
57exit 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})