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.
@@ -1,24 +1,54 @@
1
1
  /**
2
- * The files `centralu serve` keeps in the data folder: the token and port (serve.json, private to the
3
- * user), the running launcher's pid (serve.pid), and the host's own lock (host.lock), read to tell a
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 token and the port, mode 0600.
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
- * The token is generated once, the first time either `serve` or `serve --connection` runs, and kept:
16
- * the client stores it (in the OS secret store) and has to find the same one after the host
17
- * restarts. The port is the one the last `serve` actually listened on, which is how
18
- * `--connection`, a separate process, learns a `--port` given to `serve` in a systemd unit.
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
- if (typeof v?.token !== 'string' || v.token.length < 32) return null
126
- return { token: v.token, port: Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535 ? v.port : null }
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: 1, token, port: null }, 'wx')
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
- const state = readServeState(dataDir)
152
- if (!state) throw new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
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
- /** Writes the port `serve` listened on, keeping the token. Atomic: a temp file renamed over it */
158
- export function recordServePort(dataDir, port, platform = process.platform) {
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 state = readServeState(dataDir)
161
- if (!state) throw new Error(`${file} is missing or unreadable`)
162
- if (state.port === port) return
163
- replacePrivate(file, { v: 1, token: state.token, port }, platform)
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
- * A new token, keeping the port. Only when the person asks (`--rotate-token`): every client that
168
- * stored the old one has to fetch the new one with `--connection`, and a serve already running
169
- * keeps answering to the old one until it restarts.
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
- const before = ensureServeState(dataDir, platform)
173
- replacePrivate(join(dataDir, SERVE_STATE_FILE), { v: 1, token: randomBytes(32).toString('base64url'), port: before.port }, platform)
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 host is alive (`host.lock`): a host from before #838
227
- * still running after an update. It is running, and is not sent the token; a hub asking it for a proof is refused
228
- * until it restarts. Without a live lock holder, what answered is not ours.
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 (a host from before #184) or a system without `ps` (Windows) cannot be told apart, so it does not count.
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
- let holder
244
- try {
245
- holder = parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
246
- } catch {
247
- return false
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? }`; a bare pid (an older host) has no start time. `by` (#1223, additive) is who
255
- * started the host: `app`, `keeper` or `serve` (`instance-lock.ts`), absent when the host did not say
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(t)
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
- /** Who `host.lock` names as the data folder's owner, `{ pid, started, by? }`, or null */
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'))