centralu 0.1.0-beta.44 → 0.1.0-beta.46
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 +7 -1
- package/bin/serve-args.mjs +57 -8
- package/bin/serve-autostart.mjs +6 -5
- package/bin/serve-detach.mjs +2 -6
- package/bin/serve-files.mjs +433 -55
- package/bin/serve-handover.mjs +55 -18
- package/bin/serve-host.mjs +126 -54
- package/bin/serve-keys.mjs +126 -0
- package/bin/serve-run.mjs +112 -24
- package/bin/serve-util.mjs +7 -1
- package/bin/serve-window.mjs +10 -6
- package/bin/serve-windows.mjs +10 -0
- package/bin/serve.mjs +6 -5
- package/package.json +5 -5
package/bin/serve-files.mjs
CHANGED
|
@@ -1,24 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The files `centralu serve` keeps in the data folder: the
|
|
3
|
-
* user), the running launcher's pid (serve.pid), and the host's own lock (host.lock),
|
|
4
|
-
* live host of this folder from a stale pid.
|
|
2
|
+
* The files `centralu serve` keeps in the data folder: the host's key, the verifiers of the full credentials and the
|
|
3
|
+
* port (serve.json, private to the user), the running launcher's pid (serve.pid), and the host's own lock (host.lock),
|
|
4
|
+
* read to tell a live host of this folder from a stale pid.
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from 'node:child_process'
|
|
7
|
-
import { randomBytes } from 'node:crypto'
|
|
8
|
-
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
7
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
|
8
|
+
import { chmodSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { isAlive, renameRetrying, replaceFile, sleepSync } from './serve-util.mjs'
|
|
11
|
+
import { CREDENTIAL_ID_RE, PAIR_LABEL_RE } from './serve-args.mjs'
|
|
12
|
+
import { parseLinkPublicKey } from './serve-keys.mjs'
|
|
13
|
+
import { windowsPowershell } from './serve-windows.mjs'
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
|
-
* The file in the data folder that holds the
|
|
16
|
+
* The file in the data folder that holds the host's key, the full credentials' verifiers and the port, mode 0600.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* Since #1405 (version 2) it holds **no credential that opens the host whole**:
|
|
19
|
+
*
|
|
20
|
+
* - `key`: 32 random bytes the host derives what it hands out from (the limited credential `--connection` prints, a
|
|
21
|
+
* Codex bridge's, the proof of a launcher's probe). With it a process of this user gets the limited credential;
|
|
22
|
+
* it is never a full one (a hello with it is refused as full).
|
|
23
|
+
* - `full`: one entry per full credential, `{ id, label, verifier, created }`, the verifier being the SHA-256 of the
|
|
24
|
+
* credential. The credential itself is printed once, to whoever asked for it (`--pair`: a hub at each link,
|
|
25
|
+
* `--window` for the person's own window, which the desktop app runs on Windows and which runs on every OS), and written nowhere. The host checks a hello against these,
|
|
26
|
+
* read again at each hello, so a pairing or a revocation applies at once.
|
|
27
|
+
* - `keys` (#1711, docs/plans/key-pairing.md §4.2, additive: still version 2): one entry per hub that paired by key,
|
|
28
|
+
* `{ id, label, publicKey, created, via: 'pair-key' }`, the hub's Ed25519 public key. Public: nothing in it opens the
|
|
29
|
+
* host. A host from before key pairing ignores it, and a launcher from before drops it at its next write.
|
|
30
|
+
*
|
|
31
|
+
* The port is the one the last `serve` actually listened on, which is how `--connection`, a separate process, learns a
|
|
32
|
+
* `--port` given to `serve` in a systemd unit. A version 1 file (`{ token, port }`) is moved to version 2 on first
|
|
33
|
+
* use: its token becomes a full credential labelled `legacy` (a hub or window still holding it keeps working), which the
|
|
34
|
+
* next pairing removes. This is the update path for a machine below `MIN_SUPPORTED_VERSION` (#1143): version 2 first
|
|
35
|
+
* shipped in 0.1.0-beta.45, so the hub's update of an older machine (`serve --detach`) and `npm i -g` run on a version 1 file.
|
|
19
36
|
*/
|
|
20
37
|
export const SERVE_STATE_FILE = 'serve.json'
|
|
21
38
|
|
|
39
|
+
/** The label of the full credential a version 1 file's token became; removed by the next pairing */
|
|
40
|
+
export const LEGACY_LABEL = 'legacy'
|
|
41
|
+
|
|
42
|
+
/** A full credential's verifier: the SHA-256 of it, as hex (the host's `serve-credentials.ts` compares the same) */
|
|
43
|
+
export function verifierOf(credential) {
|
|
44
|
+
return createHash('sha256').update(credential).digest('hex')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The limited credential the host derives from `key` (#1405, the host's `transport/limited-token.ts`) */
|
|
48
|
+
export function limitedOf(key) {
|
|
49
|
+
return `ccl1:${createHmac('sha256', key).update('centralu limited token v1').digest('base64url')}`
|
|
50
|
+
}
|
|
51
|
+
|
|
22
52
|
/** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
|
|
23
53
|
export const SERVE_LOG_FILE = 'serve.log'
|
|
24
54
|
|
|
@@ -119,16 +149,52 @@ export function readServeState(dataDir, { tries = 5, sleep = sleepSync } = {}) {
|
|
|
119
149
|
}
|
|
120
150
|
}
|
|
121
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The state as the launcher uses it. `token` is the credential the launcher's own probes say hello with, and
|
|
154
|
+
* `proofKey` what the host proves with to them: the limited credential and the key for version 2, the token for a
|
|
155
|
+
* version 1 file not moved yet.
|
|
156
|
+
*/
|
|
122
157
|
function parseServeState(raw) {
|
|
123
158
|
try {
|
|
124
159
|
const v = JSON.parse(raw)
|
|
125
|
-
|
|
126
|
-
|
|
160
|
+
const port = Number.isInteger(v?.port) && v.port >= 1 && v.port <= 65535 ? v.port : null
|
|
161
|
+
if (v?.v !== 2) {
|
|
162
|
+
// A version 1 file, moved by `ensureServeState`: the update path from below the minimum (#1143)
|
|
163
|
+
if (typeof v?.token !== 'string' || v.token.length < 32) return null
|
|
164
|
+
return { v: 1, token: v.token, proofKey: v.token, port, key: null, full: [], keys: [] }
|
|
165
|
+
}
|
|
166
|
+
if (typeof v.key !== 'string' || v.key.length < 32) return null
|
|
167
|
+
// Exactly what the host takes (serve-credentials.ts `isListedEntry`): an entry of another shape is dropped here, so
|
|
168
|
+
// the next write leaves it out too, and the host refuses it meanwhile
|
|
169
|
+
const full = (Array.isArray(v.full) ? v.full : []).filter((e) => isListedEntry(e)).map((e) => ({ id: e.id, label: e.label, verifier: e.verifier, created: Number.isFinite(e.created) ? e.created : 0, ...(PAIRED_VIA.includes(e.via) ? { via: e.via } : {}), ...(e.replaced === true ? { replaced: true } : {}) }))
|
|
170
|
+
// The same for the hubs' key entries (#1711): one with a key the host refuses (§4.1) is dropped at the next write
|
|
171
|
+
const keys = (Array.isArray(v.keys) ? v.keys : []).filter((e) => isKeyEntry(e)).map((e) => ({ id: e.id, label: e.label, publicKey: e.publicKey, created: Number.isFinite(e.created) ? e.created : 0, via: 'pair-key', ...(e.replaced === true ? { replaced: true } : {}) }))
|
|
172
|
+
return { v: 2, key: v.key, port, full, keys, token: limitedOf(v.key), proofKey: v.key }
|
|
127
173
|
} catch {
|
|
128
174
|
return null
|
|
129
175
|
}
|
|
130
176
|
}
|
|
131
177
|
|
|
178
|
+
/** An entry the launcher lists, `--revoke` takes and the host accepts: an 8-hex id, a label `--pair` takes, a hex verifier */
|
|
179
|
+
export function isListedEntry(e) {
|
|
180
|
+
return typeof e?.id === 'string' && CREDENTIAL_ID_RE.test(e.id) && typeof e.label === 'string' && PAIR_LABEL_RE.test(e.label) && typeof e.verifier === 'string' && /^[0-9a-f]{64}$/.test(e.verifier)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A hub's key entry the launcher lists, `--revoke` takes and the host accepts (#1711): an 8-hex id, a label `--pair` takes, a public key the host takes (§4.1) */
|
|
184
|
+
export function isKeyEntry(e) {
|
|
185
|
+
return typeof e?.id === 'string' && CREDENTIAL_ID_RE.test(e.id) && typeof e.label === 'string' && PAIR_LABEL_RE.test(e.label) && parseLinkPublicKey(e.publicKey) !== null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What version 2 writes for a state; `keys` only when there are any, so a folder no hub paired by key keeps today's file */
|
|
189
|
+
function stateFile(state) {
|
|
190
|
+
return { v: 2, key: state.key, port: state.port, full: state.full, ...(state.keys?.length ? { keys: state.keys } : {}) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A new full credential and its id */
|
|
194
|
+
function mint() {
|
|
195
|
+
return { token: randomBytes(32).toString('base64url'), id: randomBytes(4).toString('hex') }
|
|
196
|
+
}
|
|
197
|
+
|
|
132
198
|
/**
|
|
133
199
|
* The state, created on first use. Never replaces a token that is already there.
|
|
134
200
|
*
|
|
@@ -137,41 +203,303 @@ function parseServeState(raw) {
|
|
|
137
203
|
* that exists but cannot be read as state is refused rather than overwritten: it may be a token
|
|
138
204
|
* the client already holds, and a silent new one would lock that client out with "bad token".
|
|
139
205
|
*/
|
|
140
|
-
export function ensureServeState(dataDir, platform = process.platform) {
|
|
206
|
+
export function ensureServeState(dataDir, platform = process.platform, now = Date.now()) {
|
|
141
207
|
mkdirSync(dataDir, { recursive: true })
|
|
142
208
|
const file = join(dataDir, SERVE_STATE_FILE)
|
|
143
209
|
if (!existsSync(file)) {
|
|
144
|
-
const token = randomBytes(32).toString('base64url')
|
|
145
210
|
try {
|
|
146
|
-
writePrivate(file, { v:
|
|
211
|
+
writePrivate(file, { v: 2, key: randomBytes(32).toString('base64url'), port: null, full: [] }, 'wx')
|
|
147
212
|
} catch (e) {
|
|
148
213
|
if (e?.code !== 'EEXIST') throw e
|
|
149
214
|
}
|
|
150
215
|
}
|
|
151
|
-
|
|
152
|
-
if (!state) throw
|
|
216
|
+
let state = readServeState(dataDir)
|
|
217
|
+
if (!state) throw noState(file)
|
|
218
|
+
// A version 1 file (#1405): its token becomes a full credential no longer written down, and a new key is drawn. Kept as
|
|
219
|
+
// the update path for a machine below `MIN_SUPPORTED_VERSION` (#1143), whose launcher wrote version 1
|
|
220
|
+
if (state.v === 1) {
|
|
221
|
+
state = changeServeState(dataDir, (s) =>
|
|
222
|
+
s.v === 1
|
|
223
|
+
? { v: 2, key: randomBytes(32).toString('base64url'), port: s.port, full: [{ id: randomBytes(4).toString('hex'), label: LEGACY_LABEL, verifier: verifierOf(s.token), created: now }], keys: [] }
|
|
224
|
+
: s,
|
|
225
|
+
platform,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
153
228
|
keepPrivate(file, platform)
|
|
154
229
|
return state
|
|
155
230
|
}
|
|
156
231
|
|
|
157
|
-
/**
|
|
158
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Why serve.json gives no state (#1405 review): a file this user may not read is said as that, with what makes it
|
|
234
|
+
* readable again. Moving it aside, the advice for a file that is not a state file, would lock out every hub and window
|
|
235
|
+
* that holds a credential it names.
|
|
236
|
+
*/
|
|
237
|
+
export function noState(file, read = readFileSync) {
|
|
238
|
+
try {
|
|
239
|
+
read(file)
|
|
240
|
+
} catch (e) {
|
|
241
|
+
if (e?.code === 'EACCES' || e?.code === 'EPERM') {
|
|
242
|
+
return new Error(`${file} cannot be read by this user (${e.code}). Give this user read and write access to it again (on Linux and macOS: chmod 600 ${file}) and run again; do not move it aside.`)
|
|
243
|
+
}
|
|
244
|
+
if (e?.code === 'ENOENT') return new Error(`${file} is missing`)
|
|
245
|
+
}
|
|
246
|
+
return new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** How long a held `serve.json.lock` is waited on before it counts as a crashed writer's (`changeServeState`) */
|
|
250
|
+
export const STATE_LOCK_STALE_MS = 10_000
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* How long before `STATE_LOCK_STALE_MS` a writer stops trying to write (#1405 review): the time it leaves itself
|
|
254
|
+
* for the rename, so that a rename it starts lands before any waiter may count its lock as stale
|
|
255
|
+
*/
|
|
256
|
+
export const STATE_LOCK_MARGIN_MS = 2_000
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Reads the state, changes it with `change` and writes it back, under a lock (`serve.json.lock`) so two changes at once
|
|
260
|
+
* (a hub pairing while a window starts) do not lose one another. Returns the state written.
|
|
261
|
+
*
|
|
262
|
+
* - A lock whose time is more than `STATE_LOCK_STALE_MS` from now, either way, is a crashed writer's and is taken over.
|
|
263
|
+
* Either way (#1405 review): a lock dated ahead of now (a crashed writer, then the clock set back) blocked every
|
|
264
|
+
* `--pair` and `--revoke` for as long as it was ahead, so a hub could not link.
|
|
265
|
+
* - The lock holds a nonce of its writer's, and the writer reads it back before each try of the rename that replaces
|
|
266
|
+
* the file: a writer stalled past the 10 s, whose lock another took over meanwhile, writes nothing (#1405 review),
|
|
267
|
+
* rather than a state it computed before a `--revoke` that came in between. It throws, and whoever asked tries again
|
|
268
|
+
* (a hub at its next link attempt, a person by running it again).
|
|
269
|
+
* - A writer also writes only within `STATE_LOCK_STALE_MS - STATE_LOCK_MARGIN_MS` of taking its lock, by this
|
|
270
|
+
* process's own clock, which a clock set back does not stretch (#1405 review): on Windows a rename is tried again
|
|
271
|
+
* for up to 5 s while a reader holds the file, and a try seconds after the read-back could land after another writer
|
|
272
|
+
* took the lock over and wrote. Its last try is then at least 2 s before any waiter may take the lock over.
|
|
273
|
+
* - Taking over a stale lock, and removing its own at the end, first move the lock aside under a name of this
|
|
274
|
+
* writer's and look at what was moved: a fresh lock, or another writer's, is put back (`releaseLock`). Before, two
|
|
275
|
+
* waiters taking over one stale lock at once could remove the other's fresh lock, and a writer's end could remove the
|
|
276
|
+
* lock of one that took it over between its read-back and its removal.
|
|
277
|
+
*
|
|
278
|
+
* What is left: a writer suspended for over 2 s between its last read-back and the rename right after it; a
|
|
279
|
+
* system clock set forward by more than 10 s while a writer holds the lock, which makes its lock look stale to a
|
|
280
|
+
* waiter; and the moment a lock is moved aside to be looked at, during which a third writer may create one and read
|
|
281
|
+
* the state while the lock's owner, past its read-back, renames (on a file system without hard links the moved lock is
|
|
282
|
+
* not put back at all, and its owner's next read-back refuses). No lock file can close these.
|
|
283
|
+
*/
|
|
284
|
+
export function changeServeState(dataDir, change, platform = process.platform, { sleep = sleepSync, nowMs = Date.now, clock = () => performance.now(), read = readFileSync } = {}) {
|
|
159
285
|
const file = join(dataDir, SERVE_STATE_FILE)
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
286
|
+
const lock = `${file}.lock`
|
|
287
|
+
const mine = randomBytes(8).toString('hex')
|
|
288
|
+
for (let i = 0; ; i++) {
|
|
289
|
+
try {
|
|
290
|
+
const fd = openSync(lock, 'wx', 0o600)
|
|
291
|
+
try {
|
|
292
|
+
writeSync(fd, mine)
|
|
293
|
+
} finally {
|
|
294
|
+
closeSync(fd)
|
|
295
|
+
}
|
|
296
|
+
break
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e?.code !== 'EEXIST') throw e
|
|
299
|
+
try {
|
|
300
|
+
if (Math.abs(nowMs() - statSync(lock).mtimeMs) > STATE_LOCK_STALE_MS) takeOverStale(lock, mine, nowMs)
|
|
301
|
+
} catch {
|
|
302
|
+
// gone meanwhile: try again
|
|
303
|
+
}
|
|
304
|
+
// Past the 10 s after which a lock counts as a crashed writer's, so a waiter takes it over rather than giving up first
|
|
305
|
+
if (i >= 480) throw new Error(`${lock} is held by another serve; try again`)
|
|
306
|
+
sleep(25)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const took = clock()
|
|
310
|
+
const held = () => {
|
|
311
|
+
try {
|
|
312
|
+
return read(lock, 'utf8') === mine
|
|
313
|
+
} catch {
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Whether this writer may still write: within its time, and the lock still its own
|
|
318
|
+
const mayWrite = () => {
|
|
319
|
+
if (clock() - took >= STATE_LOCK_STALE_MS - STATE_LOCK_MARGIN_MS) return new Error(`${lock} was held too long by this serve to write safely; nothing was written, try again`)
|
|
320
|
+
if (!held()) return new Error(`${lock} was taken over while this serve held it; nothing was written, try again`)
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const before = readServeState(dataDir)
|
|
325
|
+
if (!before) throw noState(file)
|
|
326
|
+
const after = change(before)
|
|
327
|
+
if (after !== before) {
|
|
328
|
+
const refusal = mayWrite()
|
|
329
|
+
if (refusal) throw refusal
|
|
330
|
+
replacePrivate(file, stateFile(after), platform, { mayTry: mayWrite })
|
|
331
|
+
}
|
|
332
|
+
// What was written, as a reader reads it: never null once written
|
|
333
|
+
return after !== before ? parseServeState(JSON.stringify(stateFile(after))) : before
|
|
334
|
+
} finally {
|
|
335
|
+
// Only its own: a lock another writer took over is that writer's to keep
|
|
336
|
+
releaseLock(lock, mine, { read })
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Removes `lock` if it is `mine` (#1405 review): moved aside under a name of this writer's first, so what is looked at
|
|
342
|
+
* is what is removed, and put back when it was another writer's. Reading it and then removing it by name could remove
|
|
343
|
+
* a lock another writer took over in between. Never throws: a lock left behind goes stale and is taken over.
|
|
344
|
+
*/
|
|
345
|
+
export function releaseLock(lock, mine, { read = readFileSync, rename = renameSync, link = linkSync, rm = rmSync } = {}) {
|
|
346
|
+
const aside = `${lock}.${mine}.release`
|
|
347
|
+
try {
|
|
348
|
+
renameRetrying(lock, aside, { rename, budgetMs: 1_000 })
|
|
349
|
+
} catch {
|
|
350
|
+
// not there (taken over and removed), or held open past the retries: nothing of this writer's to remove now
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
let found = null
|
|
354
|
+
try {
|
|
355
|
+
found = read(aside, 'utf8')
|
|
356
|
+
} catch {
|
|
357
|
+
// unreadable (a scanner on Windows): it may be another writer's, so it is put back; if it was this one's, it goes stale
|
|
358
|
+
}
|
|
359
|
+
if (found !== mine) putBack(aside, lock, link)
|
|
360
|
+
try {
|
|
361
|
+
rm(aside, { force: true })
|
|
362
|
+
} catch {
|
|
363
|
+
// a stray file beside the lock, nothing a writer reads
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Takes over a lock that looked stale (`changeServeState`): moved aside first, and put back when what was moved is not
|
|
369
|
+
* stale after all (another waiter's fresh lock, taken over a moment before). The caller then creates its own
|
|
370
|
+
*/
|
|
371
|
+
function takeOverStale(lock, mine, nowMs) {
|
|
372
|
+
const aside = `${lock}.${mine}.stale`
|
|
373
|
+
renameSync(lock, aside)
|
|
374
|
+
let stale = true
|
|
375
|
+
try {
|
|
376
|
+
stale = Math.abs(nowMs() - statSync(aside).mtimeMs) > STATE_LOCK_STALE_MS
|
|
377
|
+
} catch {
|
|
378
|
+
// gone: nothing to put back
|
|
379
|
+
}
|
|
380
|
+
if (!stale) putBack(aside, lock, linkSync)
|
|
381
|
+
rmSync(aside, { force: true })
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Puts a moved lock back where it was, unless another writer created one there since (a hard link never replaces) */
|
|
385
|
+
function putBack(aside, lock, link) {
|
|
386
|
+
try {
|
|
387
|
+
link(aside, lock)
|
|
388
|
+
} catch {
|
|
389
|
+
// another holds the lock by now, or links are not possible here: that writer's read-back sees it is not its own
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* How a full credential was asked for, kept on its entry as `via` (#1405 review): `pair` (`--connection --pair`, a
|
|
395
|
+
* hub's at each link or any program's), `window` (`--window`, the person's window on Windows at each start), `handover`
|
|
396
|
+
* (the launcher's own, for one `host.handover` call and withdrawn right after)
|
|
397
|
+
*/
|
|
398
|
+
export const PAIRED_VIA = ['pair', 'window', 'handover']
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A new full credential for `label` (#1405): written as its verifier only, and returned once. One label holds one
|
|
402
|
+
* credential: a hub pairing again (each link does) replaces its own, and `window` the window's. The `legacy` one a
|
|
403
|
+
* version 1 file left goes with a hub's or the window's pairing: whoever held it was one of the two, which pairs now.
|
|
404
|
+
* `via` is how it was asked for (`PAIRED_VIA`). Returns `{ token, id, state }`.
|
|
405
|
+
*
|
|
406
|
+
* `replaced` (#1405 review): set on the new entry only when the one of its label it takes the place of was asked for
|
|
407
|
+
* another way, or the file does not say how. The window pairing again at each start, and a hub at each link, are the
|
|
408
|
+
* same pairer as before and are not marked: marked at every routine pairing, the flag meant nothing. A `--pair window`
|
|
409
|
+
* taking the window's place, or the window's next start taking that one's, is marked. What this cannot tell is two
|
|
410
|
+
* askers of the same way: a program's `--pair hub:<name>-<key>` against that hub's own. The hub's row tells that one
|
|
411
|
+
* by itself (`hubsName`, and its own credential gone), docs/security-boundaries.md.
|
|
412
|
+
*/
|
|
413
|
+
export function pairServe(dataDir, label, platform = process.platform, now = Date.now(), via = 'pair') {
|
|
414
|
+
ensureServeState(dataDir, platform, now)
|
|
415
|
+
const { token, id } = mint()
|
|
416
|
+
const state = changeServeState(dataDir, (s) => ({
|
|
417
|
+
...s,
|
|
418
|
+
/*
|
|
419
|
+
* A hub's or the window's pairing removes the `legacy` one (#1405 review): whoever held it was a hub or the window
|
|
420
|
+
* of before #1405, each of which pairs now, so on a machine no hub links to it does not outlive the window's next
|
|
421
|
+
* start. Any other label leaves it: a program's own `--pair` does not cut off a hub that has not paired yet
|
|
422
|
+
*/
|
|
423
|
+
full: [
|
|
424
|
+
...s.full.filter((e) => e.label !== label && !(takesLegacyPlace(label) && e.label === LEGACY_LABEL)),
|
|
425
|
+
{ id, label, verifier: verifierOf(token), created: now, via, ...(s.full.some((e) => e.label === label && e.via !== via) ? { replaced: true } : {}) },
|
|
426
|
+
],
|
|
427
|
+
}), platform)
|
|
428
|
+
return { token, id, state }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The pairings that stand for whoever held a version 1 file's token: a hub's and the window's (`pairServe`) */
|
|
432
|
+
function takesLegacyPlace(label) {
|
|
433
|
+
return label === 'window' || label.startsWith('hub:')
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The key entry for `label` with `publicKey` (#1711, docs/plans/key-pairing.md §4.2): `--pair-key`'s write. At most one
|
|
438
|
+
* key entry per label. The same key under the same label keeps its entry and id; another key under it is a new entry
|
|
439
|
+
* with a new id, marked `replaced`, and the old key entry goes, so a socket in under the old key is closed by the sweep
|
|
440
|
+
* (which compares id and key). The label's verifier entry is never touched here. Returns `{ entry, state }`.
|
|
441
|
+
*/
|
|
442
|
+
export function pairKeyServe(dataDir, label, publicKey, platform = process.platform, now = Date.now()) {
|
|
443
|
+
if (!parseLinkPublicKey(publicKey)) throw new Error('A refused public key is never written')
|
|
444
|
+
ensureServeState(dataDir, platform)
|
|
445
|
+
let entry = null
|
|
446
|
+
const state = changeServeState(dataDir, (s) => {
|
|
447
|
+
const before = s.keys.find((k) => k.label === label)
|
|
448
|
+
/*
|
|
449
|
+
* The host takes the first key entry of an id (#1717 review, L2), so one written by hand ahead of the hub's, with its id
|
|
450
|
+
* and another key, keeps the hub from linking. Such an id is not kept: the entry gets a fresh one, which the line reports.
|
|
451
|
+
* The same for an id a verifier entry has (#1717 recheck, F1): the host takes no key entry of such an id
|
|
452
|
+
*/
|
|
453
|
+
const shared = before !== undefined && (s.keys.some((k) => k !== before && k.id === before.id) || s.full.some((e) => e.id === before.id))
|
|
454
|
+
if (before && before.publicKey === publicKey && !shared) {
|
|
455
|
+
entry = before
|
|
456
|
+
return s
|
|
457
|
+
}
|
|
458
|
+
entry = { id: freshId(s), label, publicKey, created: now, via: 'pair-key', ...(before ? { replaced: true } : {}) }
|
|
459
|
+
return { ...s, keys: [...s.keys.filter((k) => k.label !== label), entry] }
|
|
460
|
+
}, platform)
|
|
461
|
+
return { entry, state }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** An entry id no full credential and no key entry of `state` has: a `--revoke <id>` names one entry */
|
|
465
|
+
function freshId(state) {
|
|
466
|
+
for (;;) {
|
|
467
|
+
const id = randomBytes(4).toString('hex')
|
|
468
|
+
if (!state.full.some((e) => e.id === id) && !state.keys.some((k) => k.id === id)) return id
|
|
469
|
+
}
|
|
164
470
|
}
|
|
165
471
|
|
|
166
472
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
|
|
473
|
+
* Removes the verifier entry under `label` (#1711, `--key-paired`): the hub paired by key with this machine before and
|
|
474
|
+
* now has a key line, so the verifier entry it made under its label is no longer its own. Returns the state written.
|
|
475
|
+
*/
|
|
476
|
+
export function dropVerifierUnder(dataDir, label, platform = process.platform) {
|
|
477
|
+
return changeServeState(dataDir, (s) => (s.full.some((e) => e.label === label) ? { ...s, full: s.full.filter((e) => e.label !== label) } : s), platform)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Removes the full credential or the key entry `id`; true when there was one. A hello with it is refused from then on */
|
|
481
|
+
export function revokeServe(dataDir, id, platform = process.platform) {
|
|
482
|
+
let found = false
|
|
483
|
+
changeServeState(dataDir, (s) => {
|
|
484
|
+
found = s.full.some((e) => e.id === id) || s.keys.some((k) => k.id === id)
|
|
485
|
+
return found ? { ...s, full: s.full.filter((e) => e.id !== id), keys: s.keys.filter((k) => k.id !== id) } : s
|
|
486
|
+
}, platform)
|
|
487
|
+
return found
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Writes the port `serve` listened on, keeping the rest. Atomic: a temp file renamed over it */
|
|
491
|
+
export function recordServePort(dataDir, port, platform = process.platform) {
|
|
492
|
+
changeServeState(dataDir, (s) => (s.port === port ? s : { ...s, port }), platform)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A new key, and no full credential and no key entry, keeping the port. Only when the person asks (`--rotate-token`):
|
|
497
|
+
* every hub pairs again at its next link, the window at its next start, and a serve already running keeps its old key
|
|
498
|
+
* (what it hands out, and what it signs with, #1711 §7) until it restarts, while the full credentials end at once.
|
|
170
499
|
*/
|
|
171
500
|
export function rotateServeToken(dataDir, platform = process.platform) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return readServeState(dataDir)
|
|
501
|
+
ensureServeState(dataDir, platform)
|
|
502
|
+
return changeServeState(dataDir, (s) => ({ ...s, key: randomBytes(32).toString('base64url'), full: [], keys: [] }), platform)
|
|
175
503
|
}
|
|
176
504
|
|
|
177
505
|
/**
|
|
@@ -187,9 +515,9 @@ function writePrivate(file, value, flag) {
|
|
|
187
515
|
}
|
|
188
516
|
}
|
|
189
517
|
|
|
190
|
-
/** Replaces a private file whole (`replaceFile`), created 0600 as `writePrivate` creates one */
|
|
191
|
-
function replacePrivate(file, value, platform) {
|
|
192
|
-
replaceFile(file, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' })
|
|
518
|
+
/** Replaces a private file whole (`replaceFile`), created 0600 as `writePrivate` creates one; `rename` goes to `renameRetrying` */
|
|
519
|
+
function replacePrivate(file, value, platform, rename = {}) {
|
|
520
|
+
replaceFile(file, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx', ...rename })
|
|
193
521
|
keepPrivate(file, platform)
|
|
194
522
|
}
|
|
195
523
|
|
|
@@ -223,45 +551,78 @@ export function removeServePid(dataDir, pid) {
|
|
|
223
551
|
}
|
|
224
552
|
|
|
225
553
|
/**
|
|
226
|
-
* A probe that did not prove the token, while this data folder's
|
|
227
|
-
*
|
|
228
|
-
*
|
|
554
|
+
* A probe that did not prove the token, while this data folder's `serve` is alive (`host.lock`): a host still on its old
|
|
555
|
+
* key after `--rotate-token`. It is running, and is not sent the token; a hub asking it for a proof is refused until it
|
|
556
|
+
* restarts. Without a live lock holder that `serve` started (`startedByServe`), what answered is not ours: a live holder
|
|
557
|
+
* the app or the keeper started owns the folder, and whatever sits on the recorded port (a hub's ssh forward, #1722) is
|
|
558
|
+
* not that holder.
|
|
559
|
+
*
|
|
560
|
+
* `portRecorded`: serve.json names the port the probe went to (`state.port != null`). A folder that has none was probed
|
|
561
|
+
* on the default port, where whatever listens is as likely another host's (a hub's ssh forward to a linked machine's serve
|
|
562
|
+
* lands there): an unproven answer from it says nothing about this folder, whoever holds the lock.
|
|
229
563
|
*/
|
|
230
|
-
export function heldHere(probe, dataDir, startOf = processStartTime) {
|
|
564
|
+
export function heldHere(probe, dataDir, startOf = processStartTime, portRecorded = true) {
|
|
231
565
|
if (probe.running) return true
|
|
232
|
-
if (!probe.unproven) return false
|
|
233
|
-
return lockHolderIsAlive(dataDir, startOf)
|
|
566
|
+
if (!probe.unproven || !portRecorded) return false
|
|
567
|
+
return lockHolderIsAlive(dataDir, startOf) && startedByServe(dataDir)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Whether the process `host.lock` names is a host `centralu serve` started (#1722): what lets the launcher signal it.
|
|
572
|
+
* The lock says so itself (`by: 'serve'`, #1223); a lock that names another starter (`app`, `keeper`), or none (a host
|
|
573
|
+
* started by hand), is not serve's.
|
|
574
|
+
*/
|
|
575
|
+
export function startedByServe(dataDir) {
|
|
576
|
+
return lockHolder(dataDir)?.by === 'serve'
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Who `by` in host.lock says started the host, as a phrase (#1223); an absent `by` is most likely the app */
|
|
580
|
+
export function holderWho(by) {
|
|
581
|
+
return { app: 'the Centralu window on this machine', keeper: "Centralu's keeper on this machine", serve: 'another centralu serve' }[by] ?? 'probably the Centralu app on this machine'
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The live process that holds this folder's lock and that `centralu serve` did not start, `{ pid, started, by? }`, or
|
|
586
|
+
* null (#1722): `--stop` names it rather than signal it. Only named, so a pid that is alive is enough (no start time:
|
|
587
|
+
* Windows has none), as for the message `serve` itself prints when the lock stops its host.
|
|
588
|
+
*/
|
|
589
|
+
export function foreignHolder(dataDir) {
|
|
590
|
+
const holder = lockHolder(dataDir)
|
|
591
|
+
return holder && isAlive(holder.pid) && !startedByServe(dataDir) ? holder : null
|
|
234
592
|
}
|
|
235
593
|
|
|
236
594
|
/**
|
|
237
595
|
* Whether the process `host.lock` names is the one that wrote it (#838 review): alive, and started when the lock says
|
|
238
596
|
* (`ps -o lstart`, as the host writes it, `instance-lock.ts`). A lock left by a host that died, whose pid another
|
|
239
597
|
* process of the same user now has, names a stranger: counted as running, `--stop` would signal it. A lock without a
|
|
240
|
-
* start time
|
|
598
|
+
* start time and no `at` (its host found no `ps`) cannot be told apart, so it does not count. Windows has no `ps` and its
|
|
599
|
+
* host records no start time (#1723), only `at`, when it took the folder: the process that wrote the lock was created by
|
|
600
|
+
* then, and one given its number afterwards was created later. Both times come from one clock and the host writes `at`
|
|
601
|
+
* 17 ms or more after its process was created, so the slack is one clock tick, for the two readings of it. An `at` in the
|
|
602
|
+
* future (a clock set back since) proves nothing.
|
|
241
603
|
*/
|
|
242
|
-
export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!holder || !holder.started || !isAlive(holder.pid)) return false
|
|
250
|
-
return startOf(holder.pid) === holder.started
|
|
604
|
+
export function lockHolderIsAlive(dataDir, startOf = processStartTime, createdAt = processCreatedAt) {
|
|
605
|
+
const holder = lockHolder(dataDir)
|
|
606
|
+
if (!holder || !isAlive(holder.pid)) return false
|
|
607
|
+
if (holder.started) return startOf(holder.pid) === holder.started
|
|
608
|
+
if (holder.at === undefined || holder.at > Date.now()) return false
|
|
609
|
+
const created = createdAt(holder.pid)
|
|
610
|
+
return created !== null && created <= holder.at + CLOCK_TICK_MS
|
|
251
611
|
}
|
|
252
612
|
|
|
613
|
+
/** One tick of the Windows clock */
|
|
614
|
+
const CLOCK_TICK_MS = 16
|
|
615
|
+
|
|
253
616
|
/**
|
|
254
|
-
* host.lock's `{ pid, started, by? }
|
|
255
|
-
*
|
|
617
|
+
* host.lock's `{ pid, started, by? }`. `by` (#1223, additive) is who started the host: `app`, `keeper` or `serve`
|
|
618
|
+
* (`instance-lock.ts`), absent when the host did not say
|
|
256
619
|
*/
|
|
257
620
|
function parseLockHolder(text) {
|
|
258
|
-
const t = text.trim()
|
|
259
|
-
if (/^\d+$/.test(t)) return Number(t) > 0 ? { pid: Number(t), started: null } : null
|
|
260
621
|
try {
|
|
261
|
-
const v = JSON.parse(
|
|
622
|
+
const v = JSON.parse(text)
|
|
262
623
|
if (!Number.isInteger(v?.pid) || v.pid <= 0) return null
|
|
263
624
|
const by = v.by === 'app' || v.by === 'keeper' || v.by === 'serve' ? v.by : null
|
|
264
|
-
return { pid: v.pid, started: typeof v.started === 'string' ? v.started : null, ...(by ? { by } : {}) }
|
|
625
|
+
return { pid: v.pid, started: typeof v.started === 'string' ? v.started : null, ...(Number.isFinite(v.at) ? { at: v.at } : {}), ...(by ? { by } : {}) }
|
|
265
626
|
} catch {
|
|
266
627
|
return null
|
|
267
628
|
}
|
|
@@ -280,7 +641,24 @@ export function processStartTime(pid) {
|
|
|
280
641
|
}
|
|
281
642
|
}
|
|
282
643
|
|
|
283
|
-
/**
|
|
644
|
+
/**
|
|
645
|
+
* When a pid's process was created, as milliseconds since the epoch, on Windows (PowerShell); null elsewhere, or when
|
|
646
|
+
* it cannot be told (a process of another account). Asked of .NET, not `Get-Process`: that one loads a module, which
|
|
647
|
+
* under PowerShell 7's module path (a CI runner's, a pwsh terminal's) fails, and every lock held on Windows read as a
|
|
648
|
+
* dead one's (#1906)
|
|
649
|
+
*/
|
|
650
|
+
export function processCreatedAt(pid) {
|
|
651
|
+
if (process.platform !== 'win32') return null
|
|
652
|
+
try {
|
|
653
|
+
const out = execFileSync(windowsPowershell('wmi', process.env), ['-NoProfile', '-NonInteractive', '-Command', `([DateTimeOffset][Diagnostics.Process]::GetProcessById(${pid}).StartTime).ToUnixTimeMilliseconds()`], { encoding: 'utf8', timeout: 10_000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
654
|
+
const ms = Number(out.trim())
|
|
655
|
+
return Number.isFinite(ms) && ms > 0 ? ms : null
|
|
656
|
+
} catch {
|
|
657
|
+
return null
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Who `host.lock` names as the data folder's owner, `{ pid, started, by?, at? }`, or null */
|
|
284
662
|
export function lockHolder(dataDir) {
|
|
285
663
|
try {
|
|
286
664
|
return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
|