centralu 0.1.0-beta.45 → 0.1.0-beta.47
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/bin/centralu.mjs +31 -1
- package/bin/serve-args.mjs +36 -1
- package/bin/serve-autostart.mjs +6 -5
- package/bin/serve-detach.mjs +2 -6
- package/bin/serve-files.mjs +158 -60
- package/bin/serve-handover.mjs +51 -29
- package/bin/serve-host.mjs +107 -82
- package/bin/serve-install.mjs +16 -2
- package/bin/serve-keys.mjs +126 -0
- package/bin/serve-run.mjs +71 -39
- package/bin/serve-switch.mjs +636 -0
- package/bin/serve-window.mjs +5 -6
- package/bin/serve-windows.mjs +10 -0
- package/bin/serve.mjs +6 -4
- package/package.json +5 -5
package/bin/serve-handover.mjs
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Windows sign-in (#1224). One module so the foreground `serve` (serve-run.mjs) and `--stop` (serve-detach.mjs) share
|
|
4
4
|
* the stop without importing each other.
|
|
5
5
|
*/
|
|
6
|
-
import { readServeState, heldHere,
|
|
7
|
-
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
8
|
-
import { protocolVersionOf,
|
|
6
|
+
import { readServeState, heldHere, startedByServe, foreignHolder, holderWho, lockHolderIsAlive, lockHolderPid, readServePid } from './serve-files.mjs'
|
|
7
|
+
import { DEFAULT_SERVE_PORT, listensHere } from './serve-port.mjs'
|
|
8
|
+
import { protocolVersionOf, probeHost, askHostToStop, askHostRpc } from './serve-host.mjs'
|
|
9
9
|
import { ownLogon } from './serve-windows.mjs'
|
|
10
10
|
import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
|
|
11
11
|
|
|
@@ -20,7 +20,11 @@ import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
|
|
|
20
20
|
* one still there afterwards is ended.
|
|
21
21
|
*
|
|
22
22
|
* Only the processes serve.pid and host.lock name, and only while they agree with each other and the host answered
|
|
23
|
-
* with this folder's token
|
|
23
|
+
* with this folder's token, or nothing listens on the port while the process host.lock names is the one that wrote it
|
|
24
|
+
* (`heldSilently`, #1903: a host that took the lock and hung before it listened; never under `ifIdle`): a stale pid is
|
|
25
|
+
* never signalled.
|
|
26
|
+
* And only a host `serve` started (`startedByServe`, #1722): a lock held by the app's or the keeper's host is theirs,
|
|
27
|
+
* whatever answers on the port, so `--stop` says whose it is and leaves it.
|
|
24
28
|
*
|
|
25
29
|
* `{ ok, wasRunning, how?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
|
|
26
30
|
* when nothing runs there, and `{ ok: false, busy: true }` says it did not. `forUpdate` (#1618): a hub's update stops it, and
|
|
@@ -32,28 +36,42 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
|
|
|
32
36
|
if (!state) return { ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` }
|
|
33
37
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
34
38
|
const installed = protocolVersionOf(entry) ?? 0
|
|
35
|
-
const prove = hostProvesOf(entry)
|
|
36
39
|
// The limited credential is enough to probe and to stop it (#1405, `host.stop` is everyday)
|
|
37
|
-
const own =
|
|
38
|
-
const probe = () => probeHost({ port, ...own, protocolVersion: installed
|
|
40
|
+
const own = { token: state.token, proofKey: state.proofKey }
|
|
41
|
+
const probe = () => probeHost({ port, ...own, protocolVersion: installed })
|
|
39
42
|
const first = await probe()
|
|
40
|
-
|
|
43
|
+
const silent = !first.running && !first.unproven && (await heldSilently(dataDir, port))
|
|
44
|
+
if (!silent && !heldHere(first, dataDir, undefined, state.port != null)) {
|
|
45
|
+
// The folder is held, but not by a serve (#1722): the app's or the keeper's host, whatever answered on the port
|
|
46
|
+
const other = foreignHolder(dataDir)
|
|
47
|
+
const whose = other ? `. It belongs to another Centralu host (pid ${other.pid}), ${holderWho(other.by)}; not stopping it` : ''
|
|
48
|
+
return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}${whose}` }
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
const hostPid = lockHolderPid(dataDir)
|
|
52
|
+
// Only a host `serve` started is ever signalled, whoever answered with this folder's token
|
|
53
|
+
const mayKill = startedByServe(dataDir)
|
|
43
54
|
const launcher = readServePid(dataDir)
|
|
44
55
|
// serve.pid is only believed when it names the host that holds the folder now
|
|
45
|
-
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
56
|
+
const launcherPid = mayKill && launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
46
57
|
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
47
58
|
|
|
48
|
-
let asked = await askHostToStop({ port, ...own, protocolVersion: first.protocolVersion ?? installed, prove, ifIdle, forUpdate })
|
|
49
|
-
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, ...own, protocolVersion: asked.protocolVersion, prove, ifIdle, forUpdate })
|
|
50
|
-
// `ifIdle` and it has work running (#1224): it was left as it is
|
|
51
|
-
if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
|
|
52
59
|
let how = 'asked'
|
|
53
|
-
let stopped =
|
|
54
|
-
let note =
|
|
60
|
+
let stopped = false
|
|
61
|
+
let note = silent ? 'nothing listens on its port' : null
|
|
62
|
+
// The hand-over stops only a host that said it is idle: one that cannot say so is left
|
|
63
|
+
if (silent && ifIdle) return { ok: false, wasRunning: true, message: `the host holds the folder but ${note}` }
|
|
64
|
+
if (!silent) {
|
|
65
|
+
let asked = await askHostToStop({ port, ...own, protocolVersion: first.protocolVersion ?? installed, ifIdle, forUpdate })
|
|
66
|
+
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, ...own, protocolVersion: asked.protocolVersion, ifIdle, forUpdate })
|
|
67
|
+
// `ifIdle` and it has work running (#1224): it was left as it is
|
|
68
|
+
if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
|
|
69
|
+
stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
70
|
+
if (!asked.ok) note = `the host did not take the request to stop (${asked.message})`
|
|
71
|
+
}
|
|
55
72
|
if (!stopped) {
|
|
56
|
-
const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
|
|
73
|
+
const target = platform !== 'win32' && launcherPid ? launcherPid : mayKill ? hostPid : null
|
|
74
|
+
if (hostPid && !mayKill) note = `${note ? `${note}; ` : ''}the lock names pid ${hostPid}, which centralu serve did not start, so it was not signalled`
|
|
57
75
|
if (target) {
|
|
58
76
|
how = platform === 'win32' ? 'ended' : 'signal'
|
|
59
77
|
try {
|
|
@@ -85,6 +103,16 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
|
|
|
85
103
|
return { ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) }
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A host `serve` started that holds the folder's lock while nothing listens on its port (#1903): it took the lock and hung
|
|
108
|
+
* before it listened, or is still starting. Told by a listen on the port, not by the probe: a host that listens but whose
|
|
109
|
+
* event loop is stalled answers no probe in time either, and is running (#1906 review). Not part of `heldHere`, which
|
|
110
|
+
* `--connection` and `--detach`'s wait read: there a host still starting must not count as one that answers
|
|
111
|
+
*/
|
|
112
|
+
async function heldSilently(dataDir, port) {
|
|
113
|
+
return startedByServe(dataDir) && lockHolderIsAlive(dataDir) && !(await listensHere(port))
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
/** How long the hand-over waits for the host's work to end before it gives up and says so (#1224 review) */
|
|
89
117
|
export const HANDOVER_WAIT_MS = 30 * 60_000
|
|
90
118
|
|
|
@@ -126,28 +154,22 @@ export async function moveIntoSignIn({
|
|
|
126
154
|
const state = readServeState(dataDir)
|
|
127
155
|
if (!state) return false
|
|
128
156
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
129
|
-
const prove = hostProvesOf(entry)
|
|
130
157
|
const protocolVersion = existing.protocolVersion ?? protocolVersionOf(entry) ?? 0
|
|
131
|
-
const own =
|
|
158
|
+
const own = { token: state.token, proofKey: state.proofKey }
|
|
132
159
|
/*
|
|
133
|
-
* `host.handover`
|
|
134
|
-
*
|
|
160
|
+
* `host.handover` takes everyday rights (#1711, docs/plans/key-pairing.md §4.4): said with the credentials this
|
|
161
|
+
* launcher holds for its own host (the limited credential, after a proof under the key), and no credential is made for
|
|
162
|
+
* it. Before, a full one made for this call went to whatever answered that proof, which a reader of serve.json can. A
|
|
163
|
+
* host from #1405 to key pairing refuses it (`link`): no note on the row while this waits, and the move goes on
|
|
135
164
|
*/
|
|
136
|
-
const sayHandover =
|
|
137
|
-
const made = takesVerifiers(entry) ? pairServe(dataDir, 'handover', platform, Date.now(), 'handover') : null
|
|
138
|
-
try {
|
|
139
|
-
return await askHostRpc({ port, token: made?.token ?? own.token, proofKey: own.proofKey, protocolVersion, prove, method: 'host.handover', params: { handover: h } })
|
|
140
|
-
} finally {
|
|
141
|
-
if (made) revokeServe(dataDir, made.id, platform)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
165
|
+
const sayHandover = (h) => askHostRpc({ port, ...own, protocolVersion, method: 'host.handover', params: { handover: h } })
|
|
144
166
|
const report = (handover) => (tell ?? sayHandover)(handover)
|
|
145
167
|
const pid = lockHolderPid(dataDir)
|
|
146
168
|
say(`the host running for ${dataDir}${pid ? ` (pid ${pid})` : ''} runs in a network logon (started over ssh), where Git cannot use the credentials saved in Windows; moving it into this Windows sign-in once nothing runs there`)
|
|
147
169
|
const t0 = now()
|
|
148
170
|
let waiting = false
|
|
149
171
|
for (;;) {
|
|
150
|
-
if (!(await probeHost({ port, ...own, protocolVersion
|
|
172
|
+
if (!(await probeHost({ port, ...own, protocolVersion })).running) return true
|
|
151
173
|
const r = await stop()
|
|
152
174
|
if (r.ok) {
|
|
153
175
|
say(`stopped it${r.how && r.how !== 'asked' ? ` (${r.how})` : ''}; starting the host in this sign-in`)
|
package/bin/serve-host.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The host `centralu serve` runs: how it is started, and talking to a running one over its own socket
|
|
3
|
-
* (a
|
|
3
|
+
* (a proof, then a hello with the token, `host.stop`).
|
|
4
4
|
*/
|
|
5
5
|
import { createHmac, randomBytes } from 'node:crypto'
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs'
|
|
7
7
|
import { connect } from 'node:net'
|
|
8
8
|
import { dirname, join } from 'node:path'
|
|
9
|
+
import { LINK_NONCE_BYTES, signedHostKey } from './serve-keys.mjs'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The protocol a host folder speaks, from the `bundle-info.json` the bundler writes next to
|
|
@@ -34,20 +35,26 @@ export function protocolVersionOf(entry) {
|
|
|
34
35
|
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
35
36
|
* is added by `--detach` only.
|
|
36
37
|
*/
|
|
37
|
-
export function connectionLine({ port, token, credential, limited, others, version, protocolVersion, dataDir, hostRunning, install, autostart, detach,
|
|
38
|
+
export function connectionLine({ port, token, credential, limited, others, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, stopped, hostKey, keyId }) {
|
|
38
39
|
/*
|
|
39
|
-
* The options this serve takes that an older one refuses (#1223): a hub adds `--from <its name>`
|
|
40
|
-
* when the line says so, since an older serve answers an option it does not know with an error. `window`: the
|
|
40
|
+
* The options this serve takes that an older one refuses (#1223): a hub up to 0.1.0-beta.45's adds `--from <its name>`
|
|
41
|
+
* to `--detach` only when the line says so, since an older serve answers an option it does not know with an error. `window`: the
|
|
41
42
|
* desktop window on Windows uses this serve's host through `--window` only when it is listed (#1223 review)
|
|
42
43
|
*/
|
|
43
44
|
/*
|
|
44
45
|
* `pair` (#1405): `--connection --pair <label>` hands out a full credential of its own; without it the line carries the
|
|
45
46
|
* limited credential only
|
|
46
47
|
*/
|
|
47
|
-
|
|
48
|
+
/*
|
|
49
|
+
* `pairKey` (#1711, docs/plans/key-pairing.md §4.2): `--pair-key` made a key pairing, said only on a line that is one
|
|
50
|
+
* (`hostKey` and `keyId`, and no `token`). A line from `--pair-key` without it is a verifier pairing
|
|
51
|
+
*/
|
|
52
|
+
const takes = ['from', 'window', 'pair', ...(hostKey && keyId ? ['pairKey'] : [])]
|
|
48
53
|
return JSON.stringify({
|
|
49
54
|
v: 1,
|
|
50
55
|
port,
|
|
56
|
+
// The key the running host signs with, and the hub's key entry it signs for (#1711); never with a credential
|
|
57
|
+
...(hostKey && keyId ? { hostKey, keyId } : {}),
|
|
51
58
|
// A full credential only to `--pair` (#1405), with its id for the proof; an older serve always printed its token
|
|
52
59
|
...(token ? { token } : {}),
|
|
53
60
|
...(credential ? { credential } : {}),
|
|
@@ -62,8 +69,8 @@ export function connectionLine({ port, token, credential, limited, others, versi
|
|
|
62
69
|
...(install ? { install } : {}),
|
|
63
70
|
...(autostart ? { autostart } : {}),
|
|
64
71
|
...(detach ? { detach } : {}),
|
|
65
|
-
// The running host proves it holds the token before the hub sends it (#838)
|
|
66
|
-
|
|
72
|
+
// The running host proves it holds the token before the hub sends it (#838): every host from 0.1.0-beta.45 on does
|
|
73
|
+
proof: true,
|
|
67
74
|
// The person stopped the host here, and when (#1143, `readStopped`); absent: an older serve, or no such stop
|
|
68
75
|
...(stopped ? { stopped } : {}),
|
|
69
76
|
takes,
|
|
@@ -108,12 +115,10 @@ function parseStartedFrom(raw) {
|
|
|
108
115
|
/**
|
|
109
116
|
* The host's environment.
|
|
110
117
|
*
|
|
111
|
-
* - The
|
|
118
|
+
* - The key is in neither: arguments are readable by every user on the machine (`ps`), and a
|
|
112
119
|
* process's start environment by every process of its user for as long as it runs (`ps eww`,
|
|
113
|
-
* #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host
|
|
114
|
-
* (`
|
|
115
|
-
* from before #538 reads only `CC_HOST_TOKEN`: given `legacyToken` (`takesTokenOnStdin` said no),
|
|
116
|
-
* it gets the token there, as before.
|
|
120
|
+
* #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host its key comes as the first line on its stdin
|
|
121
|
+
* (`keyLine`, #538), which is a pipe to this launcher anyway (`--watch-parent`).
|
|
117
122
|
* - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
|
|
118
123
|
* client could not see which version the host it reached is.
|
|
119
124
|
* - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
|
|
@@ -123,12 +128,12 @@ function parseStartedFrom(raw) {
|
|
|
123
128
|
* `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
|
|
124
129
|
* - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
|
|
125
130
|
*/
|
|
126
|
-
export function hostEnv(env, { dataDir, version, hostDir,
|
|
131
|
+
export function hostEnv(env, { dataDir, version, hostDir, from = null, serveState = null }) {
|
|
127
132
|
const out = { ...env }
|
|
128
133
|
for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_TOKEN_ON_STDIN', 'CC_HOST_SOURCE', 'CC_SERVE_STATE', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM', 'CC_SERVE_FROM', 'CC_STARTED_BY']) delete out[k]
|
|
129
134
|
/*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
135
|
+
* The host checks full credentials against serve.json's verifiers (#1405): the file it reads at each hello. A path,
|
|
136
|
+
* not a secret: the file is the user's own, 0600. Its key comes on stdin (`keyLine`)
|
|
132
137
|
*/
|
|
133
138
|
if (serveState) out.CC_SERVE_STATE = serveState
|
|
134
139
|
// Who asked for this host, for its host.lock (#1223): a second host on the folder names it when refused
|
|
@@ -136,62 +141,19 @@ export function hostEnv(env, { dataDir, version, hostDir, legacyToken, from = nu
|
|
|
136
141
|
out.CC_DATA_DIR = dataDir
|
|
137
142
|
// The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
|
|
138
143
|
out.CC_SERVE = '1'
|
|
139
|
-
|
|
140
|
-
else out.CC_HOST_TOKEN = legacyToken
|
|
144
|
+
out.CC_HOST_TOKEN_ON_STDIN = '1'
|
|
141
145
|
out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
|
|
142
146
|
return out
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* older bundle, which would otherwise get no token and refuse every hello.
|
|
149
|
-
*/
|
|
150
|
-
export function takesTokenOnStdin(entry) {
|
|
151
|
-
try {
|
|
152
|
-
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).tokenOnStdin === 1
|
|
153
|
-
} catch {
|
|
154
|
-
// not a bundle: the source, which reads it
|
|
155
|
-
}
|
|
156
|
-
return !existsSync(join(dirname(entry), 'bundle-info.json'))
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** The first line on the host's stdin: the token (#538, the host's `readHandedToken`) */
|
|
160
|
-
export function tokenLine(token) {
|
|
161
|
-
return `${JSON.stringify({ token })}\n`
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* The first line on the stdin of a host that takes verifiers (#1405): its key, from which it derives what it hands out.
|
|
166
|
-
* Not a full credential: the host never takes it as one (`CC_SERVE_STATE` says where the full ones' verifiers are)
|
|
150
|
+
* The first line on the host's stdin (#538, #1405; the host's `readHandedToken`): its key, from which it derives what it
|
|
151
|
+
* hands out. Not a full credential: the host never takes it as one (`CC_SERVE_STATE` says where the full ones' verifiers are)
|
|
167
152
|
*/
|
|
168
153
|
export function keyLine(key) {
|
|
169
154
|
return `${JSON.stringify({ key })}\n`
|
|
170
155
|
}
|
|
171
156
|
|
|
172
|
-
/**
|
|
173
|
-
* Whether the host at `entry` checks full credentials against serve.json's verifiers (#1405): a bundle says so in
|
|
174
|
-
* `bundle-info.json` (`verifiers`), a host run from source has `lifecycle/serve-credentials.ts`. An older one knows a
|
|
175
|
-
* single token, which this launcher then keeps for it (`legacyHostTokenOf`).
|
|
176
|
-
*/
|
|
177
|
-
export function takesVerifiers(entry) {
|
|
178
|
-
try {
|
|
179
|
-
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).verifiers === 1
|
|
180
|
-
} catch {
|
|
181
|
-
// not a bundle; try the source below
|
|
182
|
-
}
|
|
183
|
-
return existsSync(join(dirname(entry), 'lifecycle', 'serve-credentials.ts'))
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* What this launcher says hello with to its own host, and what that host proves with to it: the limited credential
|
|
188
|
-
* and the key for a host that takes verifiers, the one token an older host knows otherwise (#1405).
|
|
189
|
-
*/
|
|
190
|
-
export function ownCredentials(state, entry) {
|
|
191
|
-
if (takesVerifiers(entry) || !state.legacyHostToken) return { token: state.token, proofKey: state.proofKey }
|
|
192
|
-
return { token: state.legacyHostToken, proofKey: state.legacyHostToken }
|
|
193
|
-
}
|
|
194
|
-
|
|
195
157
|
/**
|
|
196
158
|
* How to start the host.
|
|
197
159
|
*
|
|
@@ -249,17 +211,82 @@ function mismatchProtocolOf(error) {
|
|
|
249
211
|
}
|
|
250
212
|
|
|
251
213
|
/**
|
|
252
|
-
* Whether the
|
|
253
|
-
* from source has `transport/
|
|
254
|
-
*
|
|
214
|
+
* Whether the host at `entry` takes a hub's key hello (#1711): a bundle says so in `bundle-info.json` (`keyPairing`), a
|
|
215
|
+
* host run from source has `transport/link-proof.ts`. `--pair-key` reports a key derived from serve.json only for a host
|
|
216
|
+
* started next that will sign with it (docs/plans/key-pairing.md §4.2)
|
|
255
217
|
*/
|
|
256
|
-
export function
|
|
218
|
+
export function takesKeyHellos(entry) {
|
|
257
219
|
try {
|
|
258
|
-
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).
|
|
220
|
+
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).keyPairing === 1
|
|
259
221
|
} catch {
|
|
260
222
|
// not a bundle; try the source below
|
|
261
223
|
}
|
|
262
|
-
return existsSync(join(dirname(entry), 'transport', '
|
|
224
|
+
return existsSync(join(dirname(entry), 'transport', 'link-proof.ts'))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Asks the host on `port` to sign for the key entry `keyId` (#1711, docs/plans/key-pairing.md §4.2 step 3), as a hub
|
|
229
|
+
* would: a `prove` naming it, with a nonce of this launcher's, and the answer's signature checked against the key it
|
|
230
|
+
* carries, the hub's key `hubKey` and the address `127.0.0.1:<port>`. No hello follows. One of:
|
|
231
|
+
*
|
|
232
|
+
* - `{ outcome: 'signed', hostKey }`: the running host signs, with `hostKey` (after `--rotate-token`, still its old one);
|
|
233
|
+
* - `{ outcome: 'unsigned' }`: a proof with no signature at all, from a host from before key pairing still running;
|
|
234
|
+
* - `{ outcome: 'none' }`: the connection was refused, so nothing listens there;
|
|
235
|
+
* - `{ outcome: 'unknown' }`: anything else (no answer within `timeoutMs`, a socket closed before a proof, a proof that is
|
|
236
|
+
* malformed or does not verify, no WebSocket in this Node), which pairs as a host that does not sign does.
|
|
237
|
+
*/
|
|
238
|
+
export async function probeKeyProof({ port, keyId, hubKey, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket, connectTo = connect }) {
|
|
239
|
+
// Refused at once: nothing listens. Told apart from a socket that opens and says nothing, which is not known to be nothing
|
|
240
|
+
const reach = await new Promise((resolve) => {
|
|
241
|
+
const s = connectTo({ host: '127.0.0.1', port })
|
|
242
|
+
const done = (r) => {
|
|
243
|
+
s.destroy()
|
|
244
|
+
resolve(r)
|
|
245
|
+
}
|
|
246
|
+
s.setTimeout(timeoutMs, () => done('unknown'))
|
|
247
|
+
s.once('connect', () => done('open'))
|
|
248
|
+
s.once('error', (e) => done(e?.code === 'ECONNREFUSED' ? 'none' : 'unknown'))
|
|
249
|
+
})
|
|
250
|
+
if (reach !== 'open') return { outcome: reach }
|
|
251
|
+
if (typeof WebSocketImpl !== 'function') return { outcome: 'unknown' }
|
|
252
|
+
const nonce = randomBytes(LINK_NONCE_BYTES).toString('base64url')
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
let ws = null
|
|
255
|
+
let settled = false
|
|
256
|
+
const done = (r) => {
|
|
257
|
+
if (settled) return
|
|
258
|
+
settled = true
|
|
259
|
+
clearTimeout(timer)
|
|
260
|
+
try {
|
|
261
|
+
ws?.close()
|
|
262
|
+
} catch {
|
|
263
|
+
// already closed
|
|
264
|
+
}
|
|
265
|
+
resolve(r)
|
|
266
|
+
}
|
|
267
|
+
const timer = setTimeout(() => done({ outcome: 'unknown' }), timeoutMs)
|
|
268
|
+
try {
|
|
269
|
+
ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
270
|
+
} catch {
|
|
271
|
+
return done({ outcome: 'unknown' })
|
|
272
|
+
}
|
|
273
|
+
ws.onopen = () => ws.send(JSON.stringify({ kind: 'prove', nonce, key: keyId }))
|
|
274
|
+
ws.onmessage = (ev) => {
|
|
275
|
+
let m = null
|
|
276
|
+
try {
|
|
277
|
+
m = JSON.parse(String(ev.data))
|
|
278
|
+
} catch {
|
|
279
|
+
// not a frame
|
|
280
|
+
}
|
|
281
|
+
const hostKey = signedHostKey(m, { hubNonce: nonce, hubKey, keyId, port })
|
|
282
|
+
if (hostKey) return done({ outcome: 'signed', hostKey })
|
|
283
|
+
// A proof without any of the three fields: a host that does not sign. One with some of them and no valid signature is not known
|
|
284
|
+
const unsigned = m?.kind === 'proof' && m.nonce === nonce && m.hostNonce === undefined && m.hostKey === undefined && m.sig === undefined
|
|
285
|
+
done({ outcome: unsigned ? 'unsigned' : 'unknown' })
|
|
286
|
+
}
|
|
287
|
+
ws.onerror = () => done({ outcome: 'unknown' })
|
|
288
|
+
ws.onclose = () => done({ outcome: 'unknown' })
|
|
289
|
+
})
|
|
263
290
|
}
|
|
264
291
|
|
|
265
292
|
/** The answer a host holding `token` gives to a `prove` of `nonce` (the host's `hostProof`) */
|
|
@@ -268,20 +295,19 @@ function proofOf(token, nonce) {
|
|
|
268
295
|
}
|
|
269
296
|
|
|
270
297
|
/**
|
|
271
|
-
* Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do.
|
|
272
|
-
*
|
|
273
|
-
*
|
|
298
|
+
* Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do. The host first
|
|
299
|
+
* proves it holds the token (#838), and the token goes out only then: whatever else listens on the port (another
|
|
300
|
+
* account's program, while the host is down) never sees it.
|
|
274
301
|
* `on.message(m, ws)` gets every frame after the hello; `on.unproven()` runs when the proof did not come, `on.gone()`
|
|
275
302
|
* when the socket failed or closed.
|
|
276
303
|
*/
|
|
277
|
-
function helloSocket({ port, token, proofKey, protocolVersion,
|
|
304
|
+
function helloSocket({ port, token, proofKey, protocolVersion, WebSocketImpl, on }) {
|
|
278
305
|
const ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
279
|
-
const nonce =
|
|
306
|
+
const nonce = randomBytes(24).toString('base64url')
|
|
280
307
|
// Proving only once the socket is open: a port where nothing listens is gone, not unproven
|
|
281
308
|
let proving = false
|
|
282
309
|
const hello = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
283
310
|
ws.onopen = () => {
|
|
284
|
-
if (!prove) return hello()
|
|
285
311
|
proving = true
|
|
286
312
|
ws.send(JSON.stringify({ kind: 'prove', nonce }))
|
|
287
313
|
}
|
|
@@ -309,17 +335,17 @@ function helloSocket({ port, token, proofKey, protocolVersion, prove, WebSocketI
|
|
|
309
335
|
* Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
|
|
310
336
|
* stranger's program on the same port does not count, and the answer carries the running host's
|
|
311
337
|
* own protocol and version (which differ from the installed package's after an `npm i -g` the
|
|
312
|
-
* host was not restarted for).
|
|
338
|
+
* host was not restarted for). The host proves it holds the token first (#838).
|
|
313
339
|
*
|
|
314
340
|
* `{ running, protocolVersion?, version?, unproven? }`. `unproven`: something answered on the port but did not prove
|
|
315
|
-
* it holds the token, so it was not sent: another program, or a host
|
|
341
|
+
* it holds the token, so it was not sent: another program, or a host still on its old key after `--rotate-token`
|
|
316
342
|
* (`heldHere` tells those apart by the data folder's lock). Node 22.4+ has a global WebSocket; on an older 22 this falls
|
|
317
343
|
* back to a TCP connect and reports only `running`.
|
|
318
344
|
*/
|
|
319
|
-
export function probeHost({ port, token, proofKey, protocolVersion,
|
|
345
|
+
export function probeHost({ port, token, proofKey, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
|
|
320
346
|
if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
|
|
321
347
|
return helloExchange({
|
|
322
|
-
port, token, proofKey, protocolVersion,
|
|
348
|
+
port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
|
|
323
349
|
timedOut: { running: false },
|
|
324
350
|
failed: () => ({ running: false }),
|
|
325
351
|
on: (done) => ({
|
|
@@ -391,10 +417,10 @@ const HOST_LOGONS = ['interactive', 'network', 'batch', 'service']
|
|
|
391
417
|
* One call to a running host (`method` with `params`), as `askHostToStop` makes it: a hello with the token, then the
|
|
392
418
|
* call. Its result, or null when it did not answer or refused. For `host.handover` (#1224)
|
|
393
419
|
*/
|
|
394
|
-
export function askHostRpc({ port, token, proofKey, protocolVersion,
|
|
420
|
+
export function askHostRpc({ port, token, proofKey, protocolVersion, method, params = {}, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
395
421
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve(null)
|
|
396
422
|
return helloExchange({
|
|
397
|
-
port, token, proofKey, protocolVersion,
|
|
423
|
+
port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
|
|
398
424
|
timedOut: null,
|
|
399
425
|
failed: () => null,
|
|
400
426
|
on: (done) => ({
|
|
@@ -416,11 +442,10 @@ export function askHostRpc({ port, token, proofKey, protocolVersion, prove = fal
|
|
|
416
442
|
* `ifIdle` (#1224): only if nothing runs there, checked and acted on by the host in one step; `{ ok: false, busy: true }`
|
|
417
443
|
* when something does. `forUpdate` (#1618): a hub's update stops it, and the host lists the turns it ends for the next one.
|
|
418
444
|
*/
|
|
419
|
-
export function askHostToStop({ port, token, proofKey, protocolVersion,
|
|
445
|
+
export function askHostToStop({ port, token, proofKey, protocolVersion, ifIdle = false, forUpdate = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
420
446
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
|
|
421
|
-
// With `prove`, the token goes out only to a host that proved it holds it (#838)
|
|
422
447
|
return helloExchange({
|
|
423
|
-
port, token, proofKey, protocolVersion,
|
|
448
|
+
port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
|
|
424
449
|
timedOut: { ok: false, message: 'no answer in time' },
|
|
425
450
|
failed: (e) => ({ ok: false, message: e.message }),
|
|
426
451
|
on: (done) => ({
|
package/bin/serve-install.mjs
CHANGED
|
@@ -105,7 +105,7 @@ export function remoteLayout(dataDir) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/** A word a pointer file may hold: a folder name, nothing a path could escape with */
|
|
108
|
-
const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
|
|
108
|
+
export const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
|
|
109
109
|
|
|
110
110
|
/** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
|
|
111
111
|
export function parsePointer(text) {
|
|
@@ -146,7 +146,21 @@ export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
|
|
|
146
146
|
const source = p && installSource(join(l.versions, p.version))
|
|
147
147
|
return p && source ? { ...p, source } : p
|
|
148
148
|
}
|
|
149
|
-
return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
|
|
149
|
+
return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion, lastSwitch: lastSwitchOf(l.root) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The last switch there (`switch.json`, serve-switch.mjs), as much as a hub needs to know whether to read it: its id,
|
|
154
|
+
* whether it ended, and whether its outcome was reported (`--seen`). A hub that was away for the whole of it, or
|
|
155
|
+
* restarted since, learns from this at its next open that there is an outcome to say (#1907). Null when none ran
|
|
156
|
+
*/
|
|
157
|
+
function lastSwitchOf(root) {
|
|
158
|
+
try {
|
|
159
|
+
const s = JSON.parse(readFileSync(join(root, 'switch.json'), 'utf8'))
|
|
160
|
+
return typeof s?.id === 'string' ? { id: s.id, ended: typeof s.outcome === 'string', seen: s.seen === true } : null
|
|
161
|
+
} catch {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
150
164
|
}
|
|
151
165
|
|
|
152
166
|
/**
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher's side of a hub's key pairing (#1711, docs/plans/key-pairing.md §4.1 to §4.3): the check of a hub's
|
|
3
|
+
* public key at `--pair-key`, the public key of the host's signing key derived from serve.json's `key`, and the check of
|
|
4
|
+
* the running host's signed proof. The same rules as the host's `transport/link-keys.ts`, which this standalone launcher
|
|
5
|
+
* cannot import; `tooling/launcher-keys.test.ts` holds the two to the same answers and to the pinned vector.
|
|
6
|
+
*/
|
|
7
|
+
import { createHmac, createPrivateKey, createPublicKey, verify } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
/** Bytes of a nonce, a public key and a signature (`@cc/protocol` `link-wire.ts`) */
|
|
10
|
+
export const LINK_NONCE_BYTES = 24
|
|
11
|
+
export const LINK_PUBLIC_KEY_BYTES = 32
|
|
12
|
+
export const LINK_SIGNATURE_BYTES = 64
|
|
13
|
+
|
|
14
|
+
const CONTEXT_LABEL = 'centralu link v1'
|
|
15
|
+
const HOST_ROLE_LABEL = 'centralu link host v1'
|
|
16
|
+
const HOST_KEY_LABEL = 'centralu host key v1'
|
|
17
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
|
18
|
+
const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
|
|
19
|
+
const P = 2n ** 255n - 19n
|
|
20
|
+
|
|
21
|
+
/** The bytes of `text` when it is exactly the base64url (no padding) of `bytes` bytes: encoding them again gives the same string */
|
|
22
|
+
export function decodeBase64url(text, bytes) {
|
|
23
|
+
if (typeof text !== 'string') return null
|
|
24
|
+
const raw = Buffer.from(text, 'base64url')
|
|
25
|
+
if (raw.length !== bytes || raw.toString('base64url') !== text) return null
|
|
26
|
+
return raw
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The 32 bytes as a little-endian number with the top bit (the sign of x) cleared */
|
|
30
|
+
function yOf(raw) {
|
|
31
|
+
let y = 0n
|
|
32
|
+
for (let i = raw.length - 1; i >= 0; i--) y = (y << 8n) | BigInt(i === raw.length - 1 ? raw[i] & 0x7f : raw[i])
|
|
33
|
+
return y
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The y of the eight small-order points: 0, 1, p − 1 and the two of order 8 (libsodium's `ge25519_has_small_order`) */
|
|
37
|
+
const SMALL_ORDER_Y = [
|
|
38
|
+
0n,
|
|
39
|
+
1n,
|
|
40
|
+
P - 1n,
|
|
41
|
+
yOf(Buffer.from('26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'hex')),
|
|
42
|
+
yOf(Buffer.from('c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a', 'hex')),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `{ text, raw, key }` for a public key the host takes (§4.1), or null: exactly 43 canonical base64url characters, a y
|
|
47
|
+
* below p, not the y of a small-order point (either sign bit), and an Ed25519 key `node:crypto` imports. Unchecked, on
|
|
48
|
+
* Node 22 a small-order key verifies a forged signature over every message.
|
|
49
|
+
*/
|
|
50
|
+
export function parseLinkPublicKey(text) {
|
|
51
|
+
const raw = decodeBase64url(text, LINK_PUBLIC_KEY_BYTES)
|
|
52
|
+
if (!raw) return null
|
|
53
|
+
const y = yOf(raw)
|
|
54
|
+
if (y >= P || SMALL_ORDER_Y.includes(y)) return null
|
|
55
|
+
let key
|
|
56
|
+
try {
|
|
57
|
+
key = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' })
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
return key.asymmetricKeyType === 'ed25519' ? { text, raw, key } : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The public key a host started with `key` signs with (§4.1): `seed = HMAC-SHA256(key, "centralu host key v1")`, as
|
|
66
|
+
* PKCS#8. What `--pair-key` reports when no host runs and none of a keeper's may run with another key (§4.2).
|
|
67
|
+
*/
|
|
68
|
+
export function hostLinkPublicKey(key) {
|
|
69
|
+
if (typeof key !== 'string' || key.length === 0) throw new Error('The host key is empty')
|
|
70
|
+
const seed = createHmac('sha256', key).update(HOST_KEY_LABEL).digest()
|
|
71
|
+
const priv = createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, seed]), format: 'der', type: 'pkcs8' })
|
|
72
|
+
const der = createPublicKey(priv).export({ format: 'der', type: 'spki' })
|
|
73
|
+
const text = der.subarray(SPKI_PREFIX.length).toString('base64url')
|
|
74
|
+
if (!parseLinkPublicKey(text)) throw new Error('The derived host key was refused')
|
|
75
|
+
return text
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `u32be(len) ‖ bytes` for each part; a string as ASCII, and one with another character throws */
|
|
79
|
+
export function encodeFields(parts) {
|
|
80
|
+
const out = []
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
if (typeof part === 'string' && Array.from(part).some((c) => c.codePointAt(0) > 0x7f)) throw new Error('A field string is ASCII')
|
|
83
|
+
const b = typeof part === 'string' ? Buffer.from(part, 'ascii') : Buffer.from(part)
|
|
84
|
+
const len = Buffer.alloc(4)
|
|
85
|
+
len.writeUInt32BE(b.length)
|
|
86
|
+
out.push(len, b)
|
|
87
|
+
}
|
|
88
|
+
return Buffer.concat(out)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `127.0.0.1:<port>` for a port 1 to 65535, null otherwise (the host's `linkAddress`) */
|
|
92
|
+
export function linkAddress(port) {
|
|
93
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? `127.0.0.1:${port}` : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The transcript `T` (§4.3), or null when a value is refused */
|
|
97
|
+
export function linkTranscript({ hubNonce, hostNonce, hostKey, hubKey, keyId, address }) {
|
|
98
|
+
const nh = decodeBase64url(hubNonce, LINK_NONCE_BYTES)
|
|
99
|
+
const ns = decodeBase64url(hostNonce, LINK_NONCE_BYTES)
|
|
100
|
+
const pm = parseLinkPublicKey(hostKey)
|
|
101
|
+
const ph = parseLinkPublicKey(hubKey)
|
|
102
|
+
if (!nh || !ns || !pm || !ph) return null
|
|
103
|
+
if (typeof keyId !== 'string' || !/^[0-9a-f]{8}$/.test(keyId)) return null
|
|
104
|
+
if (typeof address !== 'string' || !/^127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(address) || linkAddress(Number(address.slice(10))) !== address) return null
|
|
105
|
+
return encodeFields([CONTEXT_LABEL, nh, ns, pm.raw, ph.raw, keyId, address])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The key a running host signs with, when `proof` is its signed answer to a key `prove` of `hubNonce` (§4.2 step 3):
|
|
110
|
+
* `hostNonce`, `hostKey` and `sig` all present, strict, and the host's signature verifying over the transcript built
|
|
111
|
+
* here from the hub's key, the entry's id and the address the hub forwards to. Null for anything else: an answer
|
|
112
|
+
* without a signature (a host from before key pairing), or one that does not verify.
|
|
113
|
+
*/
|
|
114
|
+
export function signedHostKey(proof, { hubNonce, hubKey, keyId, port }) {
|
|
115
|
+
if (proof?.kind !== 'proof' || proof.nonce !== hubNonce) return null
|
|
116
|
+
const pm = parseLinkPublicKey(proof.hostKey)
|
|
117
|
+
const sig = decodeBase64url(proof.sig, LINK_SIGNATURE_BYTES)
|
|
118
|
+
if (!pm || !sig) return null
|
|
119
|
+
const t = linkTranscript({ hubNonce, hostNonce: proof.hostNonce, hostKey: proof.hostKey, hubKey, keyId, address: linkAddress(port) })
|
|
120
|
+
if (!t) return null
|
|
121
|
+
try {
|
|
122
|
+
return verify(null, Buffer.concat([encodeFields([HOST_ROLE_LABEL]), t]), pm.key, sig) ? pm.text : null
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
}
|