OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay commits / 02a3a5f

fleet: publish operator identity, and probe what is actually running
Two halves of the same problem: the fleet could not measure its own diversity, and could not see its own service state. 1. The capability doc never published `operator`. quorum-selector's selectDiverse() keys on `r.operator || r.pubkey`, so an absent operator made every relay its own operator — five boxes in one datacenter under one owner satisfied a minOperators floor of five. The CLI has accepted --operator all along (cli/index.js:354) and the boxes already pass it (`--region NA --operator hive-foundation-utah` in their ExecStart); it simply was never surfaced, so no client could see the concentration. Additive, schemaVersion stays 1. This will make some current quorums honestly fail their minOperators floor — that is the point, the floor was being met by an accounting artefact. 2. scripts/probe-fleet-services.mjs reads GET /api/v1/services on every relay: the registry's own catalogue of what is LOADED, not what is configured. This project already learned once that config is not runtime truth (the dubai incident: services configured, silently not loaded, because services.json is the runtime authority and an unregistered plugin name fails quietly). Ten of twelve rows in the current fleet view are config-derived and so cannot be trusted by that same rule. First run corrects the picture immediately: - sing-2 (1 GB) runs the full 9-service suite while sing-1 (1 GB) is trimmed to 3 — same tier, opposite configuration, and 1 GB boxes are the ones with the documented status=6/ABRT history - amsterdam (2 GB) runs shard-store, which LADDER-SHIP-MAP.md:138 puts on large boxes only - utah runs shard-store but NOT outboxlog, unlike every other 9-service box - 10 distinct services fleet-wide; no service is a single-relay SPOF Read-only, no auth needed on the read path, HTTP with an SSH fallback for boxes whose API is not externally reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
02a3a5f19ab57493340c59a91f11ea4ced22aae4
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-28T14:05:16.000Z

Changed files (3) +169 -0

packages/core/core/capability-doc.js +15 -0
……@@ -304,6 +304,20 @@ export function buildCapabilityDoc (opts = {}) {
304304 // Region — operators configure via regions[]. First entry is canonical.
305305 const region = (Array.isArray(config.regions) && config.regions[0]) || null
306306
307 // Operator identity. Additive (schemaVersion stays 1); null when unset.
308 //
309 // Load-bearing for quorum diversity, not cosmetic. quorum-selector's
310 // selectDiverse() keys on `r.operator || r.pubkey`, so an absent operator
311 // makes every relay its own operator — and a quorum drawn from five boxes in
312 // one datacenter under one owner scores as five independent operators. The
313 // CLI has accepted --operator all along and stores it in config; it simply
314 // was never published, so no client could ever see it.
315 //
316 // Publishing it makes concentration VISIBLE, which will make some current
317 // quorums honestly fail their minOperators floor. That is the point: the
318 // floor was being met by an accounting artefact.
319 const operator = (typeof config.operator === 'string' && config.operator.trim()) || null
320
307321 // onionGatewayUrl — when the Tor transport is running with a hidden service,
308322 // the .onion forwards to the relay's HTTP API/gateway port, so the read plane
309323 // (/catalog.json, /v1/hyper/:key, /.well-known/hiverelay.json) is reachable
……@@ -338,6 +352,7 @@ export function buildCapabilityDoc (opts = {}) {
338352 version: opts.version || null,
339353 runtime,
340354 region,
355 operator,
341356 contact: opts.contact || config.contact || null,
342357 terms_of_service: opts.termsOfService || config.termsOfService || null,
343358 supported_transports: transports,
scripts/probe-fleet-services.mjs +135 -0
……@@ -0,0 +1,135 @@
1#!/usr/bin/env node
2/**
3 * probe-fleet-services.mjs — what each relay is ACTUALLY running.
4 *
5 * The fleet's service picture has been assembled from config: from
6 * `services.json`, from systemd `Environment=` lines, from what someone
7 * remembers enabling. This project already learned once that config is not
8 * runtime truth — the "dubai incident", where services were configured and did
9 * not load, because `services.json` is the runtime authority and a plugin name
10 * that does not match a registered builtin fails silently.
11 *
12 * So this reads the runtime instead: `GET /api/v1/services` on each relay,
13 * which is the registry's own catalogue of what is loaded right now. Where the
14 * HTTP API is not reachable from outside, it falls back to running the same
15 * request over SSH from the box's own loopback.
16 *
17 * Read-only. Makes no changes, needs no auth on the read path.
18 *
19 * Usage:
20 * node scripts/probe-fleet-services.mjs
21 * node scripts/probe-fleet-services.mjs --json > fleet-services.json
22 * node scripts/probe-fleet-services.mjs --relays fleet/relays.local.json
23 * node scripts/probe-fleet-services.mjs --no-ssh # HTTP only
24 */
25
26import { readFileSync, existsSync } from 'node:fs'
27import { execFile } from 'node:child_process'
28import { join, dirname } from 'node:path'
29import { fileURLToPath } from 'node:url'
30import { promisify } from 'node:util'
31
32const execFileAsync = promisify(execFile)
33const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
34
35const args = process.argv.slice(2)
36const asJson = args.includes('--json')
37const allowSsh = !args.includes('--no-ssh')
38const relaysArg = args[args.indexOf('--relays') + 1]
39
40function inventoryPath () {
41 if (relaysArg && args.includes('--relays')) return relaysArg
42 if (process.env.HIVERELAY_FLEET_INVENTORY) return process.env.HIVERELAY_FLEET_INVENTORY
43 const local = join(REPO, 'fleet', 'relays.local.json')
44 return existsSync(local) ? local : join(REPO, 'fleet', 'relays.json')
45}
46
47// Same allowlist discipline as the admin operator: inventory values become argv
48// elements, so validate before they reach a child process.
49function isSafeHost (host) {
50 return typeof host === 'string' && host.length > 0 && host.length <= 255 &&
51 /^[A-Za-z0-9.:-]+$/.test(host)
52}
53
54function expandHome (p) {
55 return typeof p === 'string' ? p.replace(/^~(?=\/|$)/, process.env.HOME || '') : p
56}
57
58async function probeHttp (relay) {
59 const url = `http://${relay.publicIp}:9100/api/v1/services`
60 const res = await fetch(url, { signal: AbortSignal.timeout(6000) })
61 return { body: await res.json(), status: res.status, via: 'http' }
62}
63
64async function probeSsh (relay) {
65 if (!isSafeHost(relay.publicIp)) throw new Error('unsafe host in inventory')
66 const key = expandHome(relay.sshKey)
67 const keyArgs = key && key !== 'default' ? ['-i', key] : []
68 const { stdout } = await execFileAsync('ssh', [
69 ...keyArgs,
70 '-o', 'BatchMode=yes',
71 '-o', 'ConnectTimeout=8',
72 '-o', 'StrictHostKeyChecking=accept-new',
73 `root@${relay.publicIp}`,
74 'curl -fsS --max-time 6 http://127.0.0.1:9100/api/v1/services'
75 ], { timeout: 25000 })
76 return { body: JSON.parse(stdout), status: 200, via: 'ssh' }
77}
78
79async function probe (relay) {
80 const base = { name: relay.name, region: relay.region ?? null, ramGB: relay.ramGB ?? null }
81 if (!isSafeHost(relay.publicIp)) {
82 return { ...base, reachable: false, error: 'unsafe host in inventory', services: [] }
83 }
84 for (const attempt of allowSsh ? [probeHttp, probeSsh] : [probeHttp]) {
85 try {
86 const { body, via } = await attempt(relay)
87 // 503 {"error":"Services not enabled"} is a real answer, not a failure.
88 if (body && body.error) {
89 return { ...base, reachable: true, via, servicesEnabled: false, services: [], note: body.error }
90 }
91 const services = Array.isArray(body?.services)
92 ? body.services.map(s => (typeof s === 'string' ? s : s?.name)).filter(Boolean).sort()
93 : []
94 return { ...base, reachable: true, via, servicesEnabled: true, services, total: body?.total ?? services.length }
95 } catch (err) {
96 base.error = err?.message?.slice(0, 120) || String(err)
97 }
98 }
99 return { ...base, reachable: false, services: [] }
100}
101
102const inv = inventoryPath()
103const relays = JSON.parse(readFileSync(inv, 'utf8')).relays
104const results = await Promise.all(relays.map(probe))
105
106if (asJson) {
107 console.log(JSON.stringify({
108 probedAt: new Date().toISOString(),
109 inventory: inv,
110 source: 'runtime GET /api/v1/services (not config)',
111 relays: results
112 }, null, 2))
113} else {
114 console.log(`\nRuntime service catalogue — ${relays.length} relays from ${inv}`)
115 console.log('Source: GET /api/v1/services (what is LOADED, not what is configured)\n')
116 const w = Math.max(...results.map(r => r.name.length), 6)
117 for (const r of results) {
118 const status = !r.reachable ? 'unreachable' : r.servicesEnabled === false ? 'services off' : `${r.services.length} loaded`
119 const detail = !r.reachable ? (r.error || '') : r.services.join(', ') || (r.note || '')
120 console.log(` ${r.name.padEnd(w)} ${String(r.region ?? '-').padEnd(5)} ${status.padEnd(13)} ${detail}`)
121 }
122
123 const reachable = results.filter(r => r.reachable)
124 const union = [...new Set(reachable.flatMap(r => r.services))].sort()
125 console.log(`\n reachable: ${reachable.length}/${results.length}`)
126 console.log(` distinct services running across the fleet: ${union.length}${union.length ? ' — ' + union.join(', ') : ''}`)
127
128 // The number that actually matters for diversity: a service on one box is a
129 // single point of failure regardless of how many relays exist.
130 const counts = new Map()
131 for (const r of reachable) for (const s of r.services) counts.set(s, (counts.get(s) || 0) + 1)
132 const singletons = [...counts].filter(([, n]) => n === 1).map(([s]) => s).sort()
133 if (singletons.length) console.log(` running on exactly ONE relay (SPOF): ${singletons.join(', ')}`)
134 console.log()
135}
test/unit/capability-doc.test.js +19 -0
……@@ -75,6 +75,25 @@ test('detects transports from runtime state', async (t) => {
7575 t.ok(doc.supported_transports.includes('holesail'))
7676})
7777
78test('publishes operator identity so quorum diversity can be counted honestly', async (t) => {
79 // quorum-selector's selectDiverse() keys on `r.operator || r.pubkey`. While
80 // the capability doc omitted `operator`, that fallback made every relay its
81 // own operator — so five boxes in one datacenter under one owner satisfied a
82 // minOperators floor of five. The CLI accepted --operator all along; it was
83 // simply never published, so no client could see the concentration.
84 const unset = buildCapabilityDoc({ relay: { config: {} } })
85 t.is(unset.operator, null, 'absent when the operator has not declared one')
86
87 const declared = buildCapabilityDoc({
88 relay: { config: { operator: 'hive-foundation-utah', regions: ['NA'] } }
89 })
90 t.is(declared.operator, 'hive-foundation-utah')
91 t.is(declared.region, 'NA')
92
93 t.is(buildCapabilityDoc({ relay: { config: { operator: ' ' } } }).operator, null,
94 'whitespace is not an operator identity')
95})
96
7897test('advertises hiverelay-forward only when the opt-in forward relay is enabled', async (t) => {
7998 const base = { config: { discovery: { dht: true } } }
8099