@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.
- package/README.md +259 -0
- package/bin/curia.mjs +15 -0
- package/package.json +36 -0
- package/src/acquire.mjs +188 -0
- package/src/archive.mjs +154 -0
- package/src/atomic.mjs +38 -0
- package/src/bundle.mjs +119 -0
- package/src/cli.mjs +73 -0
- package/src/commands.mjs +45 -0
- package/src/compose.mjs +137 -0
- package/src/config.mjs +289 -0
- package/src/doctor.mjs +392 -0
- package/src/exit.mjs +32 -0
- package/src/install.mjs +194 -0
- package/src/launcher.mjs +53 -0
- package/src/layout.mjs +95 -0
- package/src/lock.mjs +74 -0
- package/src/manifest.mjs +571 -0
- package/src/preflight.mjs +593 -0
- package/src/purge.mjs +230 -0
- package/src/resources.mjs +146 -0
- package/src/rollback.mjs +131 -0
- package/src/root.mjs +196 -0
- package/src/secrets.mjs +149 -0
- package/src/stable.mjs +314 -0
- package/src/stage.mjs +141 -0
- package/src/steps.mjs +25 -0
- package/src/switch.mjs +199 -0
- package/src/tailscale.mjs +156 -0
- package/src/uninstall.mjs +190 -0
- package/src/update.mjs +174 -0
- package/stable-index.pub +3 -0
|
@@ -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
|
+
}
|