@curia-sh/cli 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync, statfsSync, writeFileSync } from 'node:fs'
4
+ import { createServer as createHttpServer } from 'node:http'
5
+ import { createServer } from 'node:net'
6
+ import { arch as osArch, cpus as osCpus, tmpdir, totalmem } from 'node:os'
7
+ import { dirname, join } from 'node:path'
8
+
9
+ import { Refusal } from './exit.mjs'
10
+
11
+ // The supported-host preflight (#868, implementing #850 and the direct-check
12
+ // constraints of #857).
13
+ //
14
+ // One module answers "may this operation proceed on this host?" for
15
+ // `curia install` (#873), `curia update` (#883), and `curia doctor` (#881).
16
+ // It has two halves behind one entry point:
17
+ //
18
+ // gatherHostFacts(context, probes) reads the host into one plain object,
19
+ // the facts. Every read goes through a
20
+ // probe, so a test hands in fakes and
21
+ // never depends on the machine it runs on.
22
+ // evaluateHostFacts(facts) turns the facts into one report: one
23
+ // result per check, each `passed`,
24
+ // `warning`, or `refused`, with what was
25
+ // observed and the one corrective action.
26
+ // preflight(context, probes) does both, prints the report, and
27
+ // returns it. The report carries the
28
+ // `Refusal` to throw when a check refused.
29
+ //
30
+ // A refused check is a demonstrated incompatibility that makes the operation
31
+ // unsafe or predictably broken. It stops the operation, and there is no force
32
+ // flag. A warning is a nonblocking fact: the operation continues, and Curia
33
+ // makes no lifecycle guarantee for what the warning names. Nothing here
34
+ // installs or reconfigures the host; every corrective action is a command or
35
+ // an official browser step for the operator.
36
+ //
37
+ // The probes may create temporary resources: a listening socket per port they
38
+ // test, one probe directory, one probe container. Each is removed before the
39
+ // probe returns, on success and on failure.
40
+
41
+ const GiB = 1024 ** 3
42
+
43
+ // The tested matrix. Another release, a derivative, or another architecture
44
+ // is refused until Curia tests and adds it deliberately (#856).
45
+ export const SUPPORTED_SYSTEMS = Object.freeze([
46
+ Object.freeze({ id: 'ubuntu', versionId: '24.04', name: 'Ubuntu 24.04 LTS' }),
47
+ Object.freeze({ id: 'debian', versionId: '13', name: 'Debian 13' }),
48
+ ])
49
+ export const SUPPORTED_ARCH = 'x64'
50
+
51
+ // Below the minimum a host is unsupported, not refused, when the stack can
52
+ // run: Curia warns and makes no guarantee.
53
+ export const MINIMUM_PROFILE = Object.freeze({ cpus: 2, memoryBytes: 4 * GiB, freeDiskBytes: 15 * GiB })
54
+ export const RECOMMENDED_PROFILE = Object.freeze({ cpus: 4, memoryBytes: 8 * GiB, freeDiskBytes: 30 * GiB })
55
+
56
+ // The versions Curia is tested against. Older than `oldest` warns, unless it
57
+ // is older than `incompatible`, which refuses. A major version past
58
+ // `newestMajor` warns. `oldest` is the version the supported systems ship or
59
+ // the official repository offered when the range was set.
60
+ export const TESTED_VERSIONS = Object.freeze({
61
+ docker: Object.freeze({ incompatible: '20.10', oldest: '24.0', newestMajor: 28, name: 'Docker Engine' }),
62
+ compose: Object.freeze({ incompatible: '2.0', oldest: '2.20', newestMajor: 2, name: 'Docker Compose' }),
63
+ tailscale: Object.freeze({ incompatible: '1.50', oldest: '1.80', newestMajor: 1, name: 'Tailscale' }),
64
+ })
65
+
66
+ // The loopback ports the Compose bundle binds on the host network. They
67
+ // mirror config/curia.yaml, and daemon/test/preflightports.test.mjs keeps them
68
+ // in step. The Serve ports (8443 to 8445, 8500 to 8599) are tailscaled's
69
+ // listeners, not host sockets, so they are not on this list.
70
+ export const REQUIRED_PORTS = Object.freeze([
71
+ Object.freeze({ port: 4272, holder: 'the timeline' }),
72
+ Object.freeze({ port: 4273, holder: 'the Curia app' }),
73
+ Object.freeze({ port: 4274, holder: 'the overseer' }),
74
+ Object.freeze({ port: 7681, holder: 'the attach surface' }),
75
+ Object.freeze({ port: 7682, holder: 'the identity proxy' }),
76
+ ])
77
+
78
+ // The range agent containers publish into, three ports per agent. A fresh
79
+ // installation runs four agents (#866), so twelve free ports is the floor an
80
+ // installation can start on. Fewer is exhausted sandbox-port capacity, the
81
+ // one capacity condition #850 refuses.
82
+ export const SANDBOX_PORTS = Object.freeze({ from: 9000, to: 9299, perAgent: 3, agents: 4 })
83
+
84
+ // Where a release comes from: the package from the npm registry, the
85
+ // bootstrap script and the stable index from GitHub, the images from GHCR.
86
+ // Preflight verifies these three and nothing else; each integration step
87
+ // verifies its own destination.
88
+ export const RELEASE_ORIGINS = Object.freeze(['https://registry.npmjs.org', 'https://github.com', 'https://ghcr.io'])
89
+
90
+ // How far the host clock may drift from a release origin before certificate
91
+ // and signature checks become unreliable.
92
+ export const CLOCK_SKEW_LIMIT_SECONDS = 300
93
+
94
+ const DOCKER_SOCKET = '/var/run/docker.sock'
95
+ const TAILSCALE_SOCKET = '/var/run/tailscale/tailscaled.sock'
96
+ const PROBE_IMAGE = 'busybox:stable'
97
+ const PROBE_TIMEOUT_MS = 60_000
98
+
99
+ // The checks, in the order the report prints them. `severity` says what a
100
+ // failed check can do: `blocking` refuses, `warning` never does, `mixed`
101
+ // refuses some conditions and warns on others.
102
+ export const CHECKS = Object.freeze([
103
+ Object.freeze({ name: 'operator', severity: 'blocking', summary: 'The command runs as a non-root operator.' }),
104
+ Object.freeze({ name: 'operating system', severity: 'blocking', summary: 'The release is Ubuntu 24.04 LTS or Debian 13.' }),
105
+ Object.freeze({ name: 'architecture', severity: 'blocking', summary: 'The processor is x86-64.' }),
106
+ Object.freeze({ name: 'host capacity', severity: 'warning', summary: 'CPU, memory, and free disk meet the minimum and recommended profiles.' }),
107
+ Object.freeze({ name: 'required ports', severity: 'blocking', summary: 'The five loopback ports are free and the sandbox range can hold four agents.' }),
108
+ Object.freeze({ name: 'Docker Engine', severity: 'mixed', summary: 'A running Docker Engine the operator can reach, in the tested range.' }),
109
+ Object.freeze({ name: 'Docker capabilities', severity: 'blocking', summary: 'A probe container reads a bind mount and reaches the host network.' }),
110
+ Object.freeze({ name: 'Docker Compose', severity: 'mixed', summary: 'The Compose v2 plugin, in the tested range.' }),
111
+ Object.freeze({ name: 'Tailscale', severity: 'mixed', summary: 'A logged-in node with HTTPS certificates whose operator may use Serve.' }),
112
+ Object.freeze({ name: 'outbound access', severity: 'blocking', summary: 'The three release origins answer over HTTPS.' }),
113
+ Object.freeze({ name: 'release verification', severity: 'blocking', summary: 'Certificates verify and the clock agrees with the release origins.' }),
114
+ Object.freeze({ name: 'Docker socket group', severity: 'blocking', summary: 'A docker group exists for the containers that reach the socket.' }),
115
+ ])
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Evaluation: facts in, report out. Pure.
119
+
120
+ export function evaluateHostFacts(facts) {
121
+ const checks = [
122
+ operatorCheck(facts),
123
+ operatingSystemCheck(facts),
124
+ architectureCheck(facts),
125
+ capacityCheck(facts),
126
+ portsCheck(facts),
127
+ dockerCheck(facts),
128
+ dockerCapabilitiesCheck(facts),
129
+ composeCheck(facts),
130
+ tailscaleCheck(facts),
131
+ outboundCheck(facts),
132
+ releaseVerificationCheck(facts),
133
+ dockerGroupCheck(facts),
134
+ ]
135
+ const refused = checks.filter((c) => c.status === 'refused')
136
+ const refusal = refused.length === 0 ? null : new Refusal(refusalText(refused))
137
+ return { ok: refused.length === 0, checks, refusal }
138
+ }
139
+
140
+ function passed(name, observed) {
141
+ return { name, status: 'passed', observed, action: null }
142
+ }
143
+ function warning(name, observed, action) {
144
+ return { name, status: 'warning', observed, action }
145
+ }
146
+ function refused(name, observed, action) {
147
+ return { name, status: 'refused', observed, action }
148
+ }
149
+
150
+ function refusalText(refused) {
151
+ const count = refused.length === 1 ? '1 condition' : `${refused.length} conditions`
152
+ const lines = refused.map((c) => ` ${c.name}: ${c.observed} ${c.action}`)
153
+ return `the host refused ${count}. Curia changed nothing.\n${lines.join('\n')}`
154
+ }
155
+
156
+ function operatorCheck({ uid }) {
157
+ if (uid === 0) return refused('operator', 'this command runs as root.', 'Run it as the operator that owns the installation. Curia runs unprivileged and has no force flag.')
158
+ return passed('operator', `uid ${uid}`)
159
+ }
160
+
161
+ const SUPPORTED_NAMES = SUPPORTED_SYSTEMS.map((s) => s.name).join(' or ')
162
+
163
+ function operatingSystemCheck({ os }) {
164
+ const action = `Install Curia on ${SUPPORTED_NAMES} on x86-64. Other releases are not supported yet.`
165
+ if (!os) return refused('operating system', 'could not read /etc/os-release, so the release is unknown.', action)
166
+ const seen = os.prettyName || `${os.id} ${os.versionId}`
167
+ const match = SUPPORTED_SYSTEMS.find((s) => s.id === os.id && s.versionId === os.versionId)
168
+ if (!match) return refused('operating system', `${seen} is not a supported release.`, action)
169
+ return passed('operating system', seen)
170
+ }
171
+
172
+ function architectureCheck({ arch }) {
173
+ if (arch !== SUPPORTED_ARCH) {
174
+ return refused('architecture', `the processor is ${arch}, and Curia publishes artifacts for x86-64 only.`, 'Install Curia on an x86-64 host.')
175
+ }
176
+ return passed('architecture', 'x86-64')
177
+ }
178
+
179
+ function gib(bytes) {
180
+ return `${(bytes / GiB).toFixed(1)} GiB`
181
+ }
182
+
183
+ function profileText(p) {
184
+ return `${p.cpus} CPU cores, ${p.memoryBytes / GiB} GiB of memory, and ${p.freeDiskBytes / GiB} GiB of free disk`
185
+ }
186
+
187
+ function capacityCheck({ cpus, memoryBytes, disk }) {
188
+ const seen = `${cpus} CPU${cpus === 1 ? '' : 's'}, ${gib(memoryBytes)} of memory, ${gib(disk.freeBytes)} free on ${disk.path}`
189
+ const below = (p) => cpus < p.cpus || memoryBytes < p.memoryBytes || disk.freeBytes < p.freeDiskBytes
190
+ if (below(MINIMUM_PROFILE)) {
191
+ return warning('host capacity', `${seen} is below the minimum profile, so Curia makes no guarantee that it runs well here.`, `Give the host at least ${profileText(MINIMUM_PROFILE)}.`)
192
+ }
193
+ if (below(RECOMMENDED_PROFILE)) {
194
+ return warning('host capacity', `${seen} is below the recommended profile, so agents may wait on memory or CPU.`, `For comfortable operation give the host ${profileText(RECOMMENDED_PROFILE)}.`)
195
+ }
196
+ return passed('host capacity', seen)
197
+ }
198
+
199
+ function portsCheck({ ports }) {
200
+ const needed = SANDBOX_PORTS.perAgent * SANDBOX_PORTS.agents
201
+ const total = SANDBOX_PORTS.to - SANDBOX_PORTS.from + 1
202
+ if (ports.busy.length > 0) {
203
+ const list = ports.busy.map((b) => {
204
+ const holder = REQUIRED_PORTS.find((p) => p.port === b.port)?.holder ?? 'Curia'
205
+ return `${b.port} (${holder}) is held by ${b.process ?? 'another program'}`
206
+ }).join('; ')
207
+ return refused('required ports', `port ${list}.`, 'Stop the program that listens on that port, or move it to another port, and run the command again.')
208
+ }
209
+ if (ports.sandboxFree < needed) {
210
+ return refused('required ports', `only ${ports.sandboxFree} of the ${total} ports from ${SANDBOX_PORTS.from} to ${SANDBOX_PORTS.to} are free, and four agents need ${needed}.`, `Free at least ${needed} ports in that range and run the command again.`)
211
+ }
212
+ return passed('required ports', `${REQUIRED_PORTS.map((p) => p.port).join(', ')} free; ${ports.sandboxFree} of ${total} sandbox ports free`)
213
+ }
214
+
215
+ // Compares dotted versions numerically, ignoring a suffix such as `-ce`.
216
+ function compareVersions(a, b) {
217
+ const parse = (v) => String(v).split(/[^0-9.]/)[0].split('.').map((n) => Number(n) || 0)
218
+ const [x, y] = [parse(a), parse(b)]
219
+ for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
220
+ const d = (x[i] ?? 0) - (y[i] ?? 0)
221
+ if (d !== 0) return d
222
+ }
223
+ return 0
224
+ }
225
+
226
+ // The version verdict shared by the three tools: `refused` below the
227
+ // incompatible line, a warning outside the tested range, else `null`.
228
+ function versionVerdict(name, tool, version, install) {
229
+ const range = TESTED_VERSIONS[tool]
230
+ if (compareVersions(version, range.incompatible) < 0) {
231
+ return refused(name, `${range.name} ${version} is known incompatible; Curia needs ${range.oldest} or later.`, install)
232
+ }
233
+ if (compareVersions(version, range.oldest) < 0) {
234
+ return warning(name, `${range.name} ${version} is older than the oldest tested version, ${range.oldest}.`, `Update ${range.name} to ${range.oldest} or later from its official repository.`)
235
+ }
236
+ if (compareVersions(version, `${range.newestMajor + 1}.0`) >= 0) {
237
+ return warning(name, `${range.name} ${version} is newer than the tested range, which ends at major version ${range.newestMajor}.`, 'Watch for behavior changes; Curia is not tested against this version yet.')
238
+ }
239
+ return null
240
+ }
241
+
242
+ const DOCKER_INSTALL = 'Install Docker Engine 24.0 or later from https://docs.docker.com/engine/install/ and run the command again.'
243
+
244
+ function dockerCheck({ docker }) {
245
+ if (!docker) return refused('Docker Engine', 'Docker Engine is not installed, or the docker command is not on the path.', DOCKER_INSTALL)
246
+ if (!docker.socket.accessible) {
247
+ return refused('Docker Engine', `the operator cannot open ${docker.socket.path}.`, 'Run `sudo usermod -aG docker $USER`, log out and in again so the group applies, and run the command again.')
248
+ }
249
+ if (!docker.server) {
250
+ return refused('Docker Engine', `Docker Engine is installed but not running (${docker.error ?? 'the daemon did not answer'}).`, 'Run `sudo systemctl start docker` and run the command again.')
251
+ }
252
+ const version = versionVerdict('Docker Engine', 'docker', docker.server.version, DOCKER_INSTALL)
253
+ if (version?.status === 'refused') return version
254
+ if (docker.server.rootless) {
255
+ return warning('Docker Engine', `Docker Engine ${docker.server.version} runs rootless. Curia is tested on rootful Docker only, and makes no guarantee here.`, 'For a supported host, use the rootful Docker Engine from its official repository.')
256
+ }
257
+ if (!docker.server.serviceEnabled) {
258
+ return warning('Docker Engine', `Docker Engine ${docker.server.version} runs, but the docker service is not enabled at boot, so Curia does not come back after a reboot.`, 'Run `sudo systemctl enable docker`.')
259
+ }
260
+ if (version) return version
261
+ return passed('Docker Engine', `Docker Engine ${docker.server.version}, API ${docker.server.apiVersion}`)
262
+ }
263
+
264
+ function dockerCapabilitiesCheck({ docker }) {
265
+ const action = 'Fix the Docker Engine installation so a container can read a bind mount and use the host network, then run the command again. See https://docs.docker.com/engine/install/.'
266
+ if (!docker?.server) return refused('Docker capabilities', 'the probe container did not run because Docker Engine is not available.', action)
267
+ const probe = docker.probe
268
+ if (!probe) return refused('Docker capabilities', 'the probe container did not run.', action)
269
+ if (!probe.mount) return refused('Docker capabilities', `a probe container could not read a bind mount from the host (${probe.error ?? 'no output'}).`, action)
270
+ if (!probe.network) return refused('Docker capabilities', `a probe container could not reach a listener on the host network (${probe.error ?? 'no output'}).`, action)
271
+ return passed('Docker capabilities', 'a probe container read a bind mount and reached the host network')
272
+ }
273
+
274
+ const COMPOSE_INSTALL = 'Install the Docker Compose v2 plugin, 2.20 or later (the docker-compose-v2 package, or the compose plugin from https://docs.docker.com/compose/install/linux/) and run the command again.'
275
+
276
+ function composeCheck({ compose }) {
277
+ if (!compose) return refused('Docker Compose', '`docker compose` is not available.', COMPOSE_INSTALL)
278
+ return versionVerdict('Docker Compose', 'compose', compose.version, COMPOSE_INSTALL) ?? passed('Docker Compose', `Docker Compose ${compose.version}`)
279
+ }
280
+
281
+ const TAILSCALE_INSTALL = 'Install Tailscale from https://tailscale.com/download/linux and run the command again.'
282
+
283
+ function tailscaleCheck({ tailscale }) {
284
+ if (!tailscale) return refused('Tailscale', 'Tailscale is not installed, or the tailscale command is not on the path.', TAILSCALE_INSTALL)
285
+ const version = versionVerdict('Tailscale', 'tailscale', tailscale.version, TAILSCALE_INSTALL)
286
+ if (version?.status === 'refused') return version
287
+ if (!tailscale.socket.accessible) {
288
+ return refused('Tailscale', `the operator cannot open ${tailscale.socket.path}.`, 'Run `sudo tailscale set --operator=$USER` and run the command again.')
289
+ }
290
+ if (tailscale.backendState !== 'Running' || !tailscale.online) {
291
+ return refused('Tailscale', `the node is ${tailscale.backendState}${tailscale.online ? '' : ' and offline'}.`, 'Run `sudo tailscale up`, finish the login in the browser, and run the command again.')
292
+ }
293
+ if (!tailscale.serve.permitted) {
294
+ return refused('Tailscale', `the operator may not use Tailscale Serve (${tailscale.serve.error ?? 'access denied'}).`, 'Run `sudo tailscale set --operator=$USER` and run the command again.')
295
+ }
296
+ if (tailscale.certDomains.length === 0) {
297
+ return refused('Tailscale', 'the tailnet issues no HTTPS certificates for this node, so Serve cannot publish the Curia app.', 'Enable HTTPS certificates under DNS in the Tailscale admin console at https://login.tailscale.com/admin/dns and run the command again.')
298
+ }
299
+ if (version) return version
300
+ return passed('Tailscale', `Tailscale ${tailscale.version}, ${tailscale.backendState}, ${tailscale.certDomains[0]}`)
301
+ }
302
+
303
+ function outboundCheck({ outbound }) {
304
+ const down = outbound.filter((o) => !o.reachable)
305
+ if (down.length > 0) {
306
+ const list = down.map((o) => `${o.origin} (${o.error ?? 'no answer'})`).join(', ')
307
+ return refused('outbound access', `${list} did not answer.`, 'Allow outbound HTTPS from this host to the release origins, or fix its DNS or proxy, and run the command again.')
308
+ }
309
+ return passed('outbound access', outbound.map((o) => o.origin).join(', '))
310
+ }
311
+
312
+ function releaseVerificationCheck({ outbound }) {
313
+ const invalid = outbound.filter((o) => o.reachable && o.certificateValid === false)
314
+ if (invalid.length > 0) {
315
+ const list = invalid.map((o) => `${o.origin} (${o.error ?? 'certificate rejected'})`).join(', ')
316
+ return refused('release verification', `the certificate of ${list} did not verify, so downloads cannot be trusted.`, 'Run `sudo apt-get install --reinstall ca-certificates`, remove any intercepting proxy, and run the command again.')
317
+ }
318
+ const skewed = outbound.filter((o) => o.reachable && typeof o.skewSeconds === 'number' && Math.abs(o.skewSeconds) > CLOCK_SKEW_LIMIT_SECONDS)
319
+ if (skewed.length > 0) {
320
+ const worst = Math.max(...skewed.map((o) => Math.abs(o.skewSeconds)))
321
+ return refused('release verification', `the host clock is ${Math.round(worst / 60)} minutes from the release origins, so certificates and signatures cannot be checked.`, 'Run `sudo timedatectl set-ntp true`, wait for the clock to sync, and run the command again.')
322
+ }
323
+ return passed('release verification', 'certificates verify and the clock agrees with the release origins')
324
+ }
325
+
326
+ function dockerGroupCheck({ docker }) {
327
+ if (!docker) return refused('Docker socket group', 'no docker group was found because Docker Engine is not installed.', DOCKER_INSTALL)
328
+ if (!docker.group) {
329
+ return refused('Docker socket group', 'no docker group exists, and the service and tmux containers join it to reach the socket.', 'Run `sudo groupadd docker`, then `sudo usermod -aG docker $USER`, log out and in again, and run the command again.')
330
+ }
331
+ return passed('Docker socket group', `${docker.group.name} (gid ${docker.group.gid})`)
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Rendering.
336
+
337
+ const STATUS_WORD = { passed: 'ok', warning: 'warning', refused: 'refused' }
338
+
339
+ export function renderPreflight(report) {
340
+ const width = Math.max(...report.checks.map((c) => c.name.length))
341
+ const lines = []
342
+ for (const c of report.checks) {
343
+ lines.push(`${STATUS_WORD[c.status].padEnd(8)} ${c.name.padEnd(width)} ${c.observed}`)
344
+ if (c.action) lines.push(`${''.padEnd(9 + width + 2)}${c.action}`)
345
+ }
346
+ const count = (status) => report.checks.filter((c) => c.status === status).length
347
+ const refusedCount = count('refused')
348
+ const warningCount = count('warning')
349
+ const summary = [`${count('passed')} checks passed`]
350
+ if (warningCount > 0) summary.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`)
351
+ if (refusedCount > 0) summary.push(`refused: ${refusedCount} condition${refusedCount === 1 ? '' : 's'}`)
352
+ lines.push(summary.join(', ') + '.')
353
+ return lines.join('\n') + '\n'
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Gathering: the host in, the facts out. Every read goes through a probe.
358
+
359
+ // The real probes. Each is one system boundary: a command, a file, a socket,
360
+ // a size, or an HTTPS request.
361
+ export const hostProbes = Object.freeze({
362
+ exec: (file, args, { timeoutMs = 15_000 } = {}) => new Promise((resolve) => {
363
+ execFile(file, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
364
+ if (!error) return resolve({ ok: true, stdout, stderr, code: 0 })
365
+ resolve({ ok: false, stdout, stderr: stderr || error.message, code: error.code, missing: error.code === 'ENOENT', timedOut: Boolean(error.killed) })
366
+ })
367
+ }),
368
+ readFile: (path) => {
369
+ try { return readFileSync(path, 'utf8') } catch { return null }
370
+ },
371
+ arch: () => osArch(),
372
+ cpus: () => osCpus().length,
373
+ memoryBytes: () => totalmem(),
374
+ freeDiskBytes: (path) => {
375
+ const s = statfsSync(path)
376
+ return Number(s.bavail) * Number(s.bsize)
377
+ },
378
+ socketAccessible: (path) => {
379
+ try { accessSync(path, constants.R_OK | constants.W_OK); return true } catch { return false }
380
+ },
381
+ groups: () => process.getgroups(),
382
+ // Whether the operator can listen on the port on every interface. A test
383
+ // that must not touch this machine's ports hands in its own answer.
384
+ portFree,
385
+ fetchOrigin: async (origin) => {
386
+ const started = Date.now()
387
+ try {
388
+ const response = await fetch(`${origin}/`, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(15_000) })
389
+ const date = Date.parse(response.headers.get('date') ?? '')
390
+ const skewSeconds = Number.isNaN(date) ? null : Math.round((date - (started + Date.now()) / 2) / 1000)
391
+ return { origin, reachable: true, certificateValid: true, skewSeconds }
392
+ } catch (e) {
393
+ const cause = e.cause ?? e
394
+ const code = cause.code ?? ''
395
+ const message = cause.message ?? String(e)
396
+ if (/CERT|SELF_SIGNED|ALTNAME|UNABLE_TO_GET_ISSUER|UNABLE_TO_VERIFY/.test(code) || /certificate/i.test(message)) {
397
+ return { origin, reachable: true, certificateValid: false, error: message }
398
+ }
399
+ return { origin, reachable: false, error: message }
400
+ }
401
+ },
402
+ })
403
+
404
+ export async function gatherHostFacts({ uid, root, ports = REQUIRED_PORTS, sandbox = SANDBOX_PORTS }, probes = hostProbes) {
405
+ const disk = nearestExisting(root)
406
+ const [docker, compose, tailscale, portFacts, outbound] = await Promise.all([
407
+ dockerFacts(probes),
408
+ composeFacts(probes),
409
+ tailscaleFacts(probes),
410
+ portFactsOf(ports, sandbox, probes),
411
+ Promise.all(RELEASE_ORIGINS.map((origin) => probes.fetchOrigin(origin))),
412
+ ])
413
+ return {
414
+ uid,
415
+ os: osRelease(probes.readFile('/etc/os-release')),
416
+ arch: probes.arch(),
417
+ cpus: probes.cpus(),
418
+ memoryBytes: probes.memoryBytes(),
419
+ disk: { path: disk, freeBytes: probes.freeDiskBytes(disk) },
420
+ ports: portFacts,
421
+ docker,
422
+ compose,
423
+ tailscale,
424
+ outbound,
425
+ }
426
+ }
427
+
428
+ function nearestExisting(path) {
429
+ let at = path
430
+ while (!existsSync(at)) {
431
+ const up = dirname(at)
432
+ if (up === at) break
433
+ at = up
434
+ }
435
+ return at
436
+ }
437
+
438
+ function osRelease(text) {
439
+ if (!text) return null
440
+ const fields = {}
441
+ for (const line of text.split('\n')) {
442
+ const m = line.match(/^([A-Z_]+)=("?)(.*)\2$/)
443
+ if (m) fields[m[1]] = m[3]
444
+ }
445
+ if (!fields.ID) return null
446
+ return { id: fields.ID, versionId: fields.VERSION_ID ?? '', prettyName: fields.PRETTY_NAME ?? `${fields.ID} ${fields.VERSION_ID ?? ''}`.trim() }
447
+ }
448
+
449
+ function parseJson(text) {
450
+ try { return JSON.parse(text) } catch { return null }
451
+ }
452
+
453
+ async function dockerFacts(probes) {
454
+ const version = await probes.exec('docker', ['version', '--format', 'json'])
455
+ if (version.missing) return null
456
+ const socket = { path: DOCKER_SOCKET, accessible: probes.socketAccessible(DOCKER_SOCKET) }
457
+ const group = await dockerGroup(probes)
458
+ const parsed = parseJson(version.stdout)
459
+ const client = { version: parsed?.Client?.Version ?? null }
460
+ if (!version.ok || !parsed?.Server) {
461
+ return { client, server: null, error: firstLine(version.stderr) || 'the daemon did not answer', socket, group, probe: null }
462
+ }
463
+ const info = parseJson((await probes.exec('docker', ['info', '--format', 'json'])).stdout) ?? {}
464
+ const enabled = await probes.exec('systemctl', ['is-enabled', 'docker'])
465
+ const server = {
466
+ version: parsed.Server.Version,
467
+ apiVersion: parsed.Server.ApiVersion ?? null,
468
+ rootless: (info.SecurityOptions ?? []).some((o) => /rootless/.test(o)),
469
+ serviceEnabled: enabled.ok && enabled.stdout.trim() === 'enabled',
470
+ }
471
+ const probe = await dockerProbe(probes)
472
+ return { client, server, socket, group, probe }
473
+ }
474
+
475
+ async function dockerGroup(probes) {
476
+ const out = await probes.exec('getent', ['group', 'docker'])
477
+ if (!out.ok) return null
478
+ const [name, , gid] = out.stdout.trim().split(':')
479
+ const gidNumber = Number(gid)
480
+ return { name, gid: gidNumber, member: probes.groups().includes(gidNumber) }
481
+ }
482
+
483
+ // One container run proves two capabilities the bundle depends on: a bind
484
+ // mount from the host reads back, and the host network reaches a listener
485
+ // the operator opened. The listener, the probe directory, and the container
486
+ // are temporary and are removed before this returns, whatever happened.
487
+ async function dockerProbe(probes) {
488
+ const token = randomBytes(16).toString('hex')
489
+ const dir = mkdtempSync(join(tmpdir(), 'curia-preflight-'))
490
+ const name = `curia-preflight-${randomBytes(4).toString('hex')}`
491
+ const listener = createHttpServer((request, response) => {
492
+ response.setHeader('Connection', 'close')
493
+ response.end(token)
494
+ })
495
+ try {
496
+ writeFileSync(join(dir, 'probe'), token)
497
+ await new Promise((resolve, reject) => listener.once('error', reject).listen(0, '127.0.0.1', resolve))
498
+ const url = `http://127.0.0.1:${listener.address().port}/`
499
+ const run = await probes.exec('docker', [
500
+ 'run', '--rm', '--name', name, '--network', 'host', '-v', `${dir}:${dir}:ro`, PROBE_IMAGE,
501
+ 'sh', '-c', `cat ${join(dir, 'probe')}; echo; wget -q -O - ${url}`,
502
+ ], { timeoutMs: PROBE_TIMEOUT_MS })
503
+ if (!run.ok) {
504
+ await probes.exec('docker', ['rm', '-f', name])
505
+ return { mount: false, network: false, error: firstLine(run.stderr) || `docker run exited ${run.code}` }
506
+ }
507
+ const [fromMount, fromNetwork] = run.stdout.split('\n').map((s) => s.trim())
508
+ return {
509
+ mount: fromMount === token,
510
+ network: fromNetwork === token,
511
+ ...(fromMount === token && fromNetwork === token ? {} : { error: `the probe printed ${JSON.stringify(run.stdout.trim())}` }),
512
+ }
513
+ } catch (e) {
514
+ await probes.exec('docker', ['rm', '-f', name])
515
+ return { mount: false, network: false, error: e.message }
516
+ } finally {
517
+ listener.closeAllConnections()
518
+ await new Promise((resolve) => listener.close(resolve))
519
+ rmSync(dir, { recursive: true, force: true })
520
+ }
521
+ }
522
+
523
+ async function composeFacts(probes) {
524
+ const out = await probes.exec('docker', ['compose', 'version', '--short'])
525
+ if (!out.ok) return null
526
+ const version = out.stdout.trim().replace(/^v/, '')
527
+ return version ? { version } : null
528
+ }
529
+
530
+ async function tailscaleFacts(probes) {
531
+ const version = await probes.exec('tailscale', ['version'])
532
+ if (version.missing || !version.ok) return null
533
+ const socket = { path: TAILSCALE_SOCKET, accessible: probes.socketAccessible(TAILSCALE_SOCKET) }
534
+ const status = parseJson((await probes.exec('tailscale', ['status', '--json'])).stdout) ?? {}
535
+ const serve = await probes.exec('tailscale', ['serve', 'status'])
536
+ return {
537
+ installed: true,
538
+ version: version.stdout.split('\n')[0].trim(),
539
+ backendState: status.BackendState ?? 'Unknown',
540
+ online: Boolean(status.Self?.Online),
541
+ socket,
542
+ certDomains: status.CertDomains ?? [],
543
+ serve: serve.ok ? { permitted: true } : { permitted: false, error: firstLine(serve.stderr) || firstLine(serve.stdout) || 'access denied' },
544
+ }
545
+ }
546
+
547
+ // A port is free when the operator can listen on it on every interface. The
548
+ // listener is closed before the answer is returned. The holder of a busy
549
+ // port comes from `ss`, when it is present and may name the process.
550
+ async function portFactsOf(ports, sandbox, probes) {
551
+ const portFree = probes.portFree ?? hostProbes.portFree
552
+ const busy = []
553
+ for (const { port } of ports) {
554
+ if (!(await portFree(port))) busy.push({ port, process: await holderOf(port, probes) })
555
+ }
556
+ let sandboxFree = 0
557
+ for (let port = sandbox.from; port <= sandbox.to; port += 1) {
558
+ if (await portFree(port)) sandboxFree += 1
559
+ }
560
+ return { busy, sandboxFree }
561
+ }
562
+
563
+ function portFree(port) {
564
+ return new Promise((resolve) => {
565
+ const server = createServer()
566
+ server.once('error', () => resolve(false))
567
+ server.listen({ port, host: '0.0.0.0', exclusive: true }, () => server.close(() => resolve(true)))
568
+ })
569
+ }
570
+
571
+ async function holderOf(port, probes) {
572
+ const out = await probes.exec('ss', ['-H', '-ltnp', `sport = :${port}`])
573
+ const m = out.ok ? out.stdout.match(/users:\(\("([^"]+)",pid=(\d+)/) : null
574
+ return m ? `${m[1]} (pid ${m[2]})` : null
575
+ }
576
+
577
+ function firstLine(text) {
578
+ return (text ?? '').trim().split('\n')[0]
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // The entry point.
583
+
584
+ // `context` is `{ uid, root, stdout }` from the command, or `{ facts, stdout }`
585
+ // when the caller already has the facts. It prints the report on `stdout` and
586
+ // returns it. The caller throws `report.refusal` to stop, or reads the checks
587
+ // to print more.
588
+ export async function preflight({ uid, root, facts, stdout, ports, sandbox }, probes = hostProbes) {
589
+ const observed = facts ?? await gatherHostFacts({ uid, root, ports, sandbox }, probes)
590
+ const report = evaluateHostFacts(observed)
591
+ stdout?.write(renderPreflight(report))
592
+ return { ...report, facts: observed }
593
+ }