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 CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from './platform.mjs'
46
46
  import {
47
47
  decodeChildSpec,
48
+ gateHostStart,
48
49
  parseServeArgs,
49
50
  printConnection,
50
51
  rotateToken,
@@ -54,6 +55,9 @@ import {
54
55
  runDetachedChild,
55
56
  runServe,
56
57
  runStop,
58
+ runSwitch,
59
+ runSwitchRun,
60
+ runSwitchStatus,
57
61
  runWindow,
58
62
  SERVE_HELP,
59
63
  } from './serve.mjs'
@@ -631,6 +635,19 @@ switch (cmd) {
631
635
  process.exitCode = rotateToken({ env: process.env, home: HOME })
632
636
  break
633
637
  }
638
+ // The hub's switch of the install it manages (serve-switch.mjs): it starts and checks hosts only through serve's CLI
639
+ if (args.mode === 'switch') {
640
+ process.exitCode = await runSwitch({ env: process.env, home: HOME, raw: args.switchArg, cliPath: realpathSync(process.argv[1]) })
641
+ break
642
+ }
643
+ if (args.mode === 'switch-run') {
644
+ process.exitCode = await runSwitchRun({ env: process.env, home: HOME, raw: args.switchArg, dataDir: args.dataDir })
645
+ break
646
+ }
647
+ if (args.mode === 'switch-status') {
648
+ process.exitCode = await runSwitchStatus({ env: process.env, home: HOME, seen: args.seen })
649
+ break
650
+ }
634
651
  /*
635
652
  * Before anything spawns a host: under a Node below the floor the host crashes on its first
636
653
  * database call, with nothing in its log (issue #699). `--detach` also answers in the line the
@@ -640,6 +657,19 @@ switch (cmd) {
640
657
  * machine what Node it has.
641
658
  */
642
659
  const startsHost = args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window' || (args.mode === 'autostart' && args.autostart === 'on')
660
+ /*
661
+ * A switch of the managed install under way there (§10.5a): no host starts between its stop and its start, and one
662
+ * whose switcher died is finished first, before this version refuses for a reason of its own (a host already
663
+ * answering, a missing host entry, a Node below the floor), so a reboot or a sign-in finishes it with no hub (#1907).
664
+ * Not for the launcher `--detach` started: that one asked already
665
+ */
666
+ if ((args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window') && args.child === undefined) {
667
+ const refused = await gateHostStart({ env: process.env, home: HOME, version: pkg.version, detach: args.mode === 'detach' })
668
+ if (refused !== null) {
669
+ process.exitCode = refused
670
+ break
671
+ }
672
+ }
643
673
  if (startsHost && belowFloor(process.versions.node, NODE_FLOOR)) {
644
674
  const message = oldNodeSentence(process.versions.node, NODE_FLOOR)
645
675
  console.error(`centralu serve: ${message}`)
@@ -661,7 +691,7 @@ switch (cmd) {
661
691
  if (spec) Object.assign(process.env, spec.env)
662
692
  // realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
663
693
  const opts = { env: process.env, home: HOME, entry: requireHostEntry(), version: pkg.version, cliPath: realpathSync(process.argv[1]) }
664
- if (args.mode === 'connection') process.exitCode = await printConnection({ ...opts, pair: args.pair ?? null })
694
+ if (args.mode === 'connection') process.exitCode = await printConnection({ ...opts, pair: args.pair ?? null, pairKey: args.pairKey ?? null, keyPaired: args.keyPaired === true })
665
695
  else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port, from: args.from })
666
696
  else if (args.mode === 'stop') process.exitCode = await runStop(opts)
667
697
  else if (args.mode === 'window') process.exitCode = await runWindow(opts)
@@ -1,5 +1,6 @@
1
1
  /** The arguments after `centralu serve`, and its help */
2
2
  import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
3
+ import { parseLinkPublicKey } from './serve-keys.mjs'
3
4
 
4
5
  export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
5
6
 
@@ -76,6 +77,22 @@ export const FROM_RE = /^[A-Za-z0-9._-]{1,64}$/
76
77
  * `--autostart` writes starts serve on the port it recorded.
77
78
  */
78
79
  export function parseServeArgs(argv) {
80
+ // The hub's switch of the install it manages (docs/plans/remote-hub.md §10.5a): whole commands of their own, not in the help
81
+ const b64 = (a) => typeof a === 'string' && /^[A-Za-z0-9_-]{1,4096}$/.test(a)
82
+ if (argv[0] === '--switch') {
83
+ if (argv.length !== 2 || !b64(argv[1])) return { error: '--switch takes one argument, the switch the hub wrote' }
84
+ return { mode: 'switch', port: null, switchArg: argv[1] }
85
+ }
86
+ // What `--switch` starts: the hub's argument as it came, and the data folder (base64url) when it is not the default
87
+ if (argv[0] === '--switch-run') {
88
+ if (argv.length < 2 || argv.length > 3 || !argv.slice(1).every(b64)) return { error: '--switch-run takes the switch the hub wrote, and the data folder' }
89
+ return { mode: 'switch-run', port: null, switchArg: argv[1], dataDir: argv[2] ?? null }
90
+ }
91
+ if (argv[0] === '--switch-status') {
92
+ if (argv.length === 1) return { mode: 'switch-status', port: null, seen: null }
93
+ if (argv.length === 3 && argv[1] === '--seen' && /^[0-9A-Za-z-]{8,64}$/.test(argv[2])) return { mode: 'switch-status', port: null, seen: argv[2] }
94
+ return { error: '--switch-status takes only --seen <id>' }
95
+ }
79
96
  let mode = 'serve'
80
97
  let port = null
81
98
  let help = false
@@ -83,6 +100,8 @@ export function parseServeArgs(argv) {
83
100
  let autostart = null
84
101
  let from = null
85
102
  let pair = null
103
+ let pairKey = null
104
+ let keyPaired = false
86
105
  let revoke = null
87
106
  for (let i = 0; i < argv.length; i++) {
88
107
  const a = argv[i]
@@ -90,6 +109,19 @@ export function parseServeArgs(argv) {
90
109
  const raw = a === '--pair' ? argv[++i] : a.slice('--pair='.length)
91
110
  if (typeof raw !== 'string' || !PAIR_LABEL_RE.test(raw)) return { error: `--pair needs a label: letters, digits, ':', '.', '_' and '-' (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
92
111
  pair = raw
112
+ } else if (a === '--pair-key') {
113
+ /*
114
+ * `--pair-key <label> <public key>` (#1711, docs/plans/key-pairing.md §4.2): a hub's key pairing, at each link in
115
+ * place of `--pair`. The key is checked as the host checks it (§4.1): a refused one writes nothing
116
+ */
117
+ const label = argv[++i]
118
+ const publicKey = argv[++i]
119
+ if (typeof label !== 'string' || !PAIR_LABEL_RE.test(label)) return { error: `--pair-key needs a label: letters, digits, ':', '.', '_' and '-' (got ${label === undefined ? 'nothing' : JSON.stringify(label)})` }
120
+ if (!parseLinkPublicKey(publicKey)) return { error: `--pair-key needs an Ed25519 public key after the label, as 43 characters of base64url (got ${publicKey === undefined ? 'nothing' : JSON.stringify(String(publicKey).slice(0, 64))})` }
121
+ pairKey = { label, publicKey }
122
+ } else if (a === '--key-paired') {
123
+ // The hub has paired by key with this machine before (#1711, §4.2): a key line removes the label's verifier entry
124
+ keyPaired = true
93
125
  } else if (a === '--revoke' || a.startsWith('--revoke=')) {
94
126
  const raw = a === '--revoke' ? argv[++i] : a.slice('--revoke='.length)
95
127
  if (typeof raw !== 'string' || !CREDENTIAL_ID_RE.test(raw)) return { error: `--revoke needs a credential's id, as --connection lists it (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
@@ -121,6 +153,9 @@ export function parseServeArgs(argv) {
121
153
  }
122
154
  if (help) return { mode: 'help', port }
123
155
  if (pair !== null && mode !== 'connection') return { error: '--pair goes with --connection' }
156
+ if (pairKey !== null && mode !== 'connection') return { error: '--pair-key goes with --connection' }
157
+ if (pair !== null && pairKey !== null) return { error: '--pair and --pair-key are two ways of pairing; pass one' }
158
+ if (keyPaired && pairKey === null) return { error: '--key-paired goes with --pair-key' }
124
159
  if (mode === 'revoke') return port !== null ? { error: '--revoke takes no --port' } : { mode, port, revoke }
125
160
  if (from !== null && mode !== 'serve' && mode !== 'detach') return { error: `--from goes with \`centralu serve\` and --detach, which start a host; not with ${FLAG_OF[mode]}` }
126
161
  if (child !== null) {
@@ -136,7 +171,7 @@ export function parseServeArgs(argv) {
136
171
  if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
137
172
  return { mode, port, autostart }
138
173
  }
139
- return { mode, port, ...(from !== null ? { from } : {}), ...(pair !== null ? { pair } : {}) }
174
+ return { mode, port, ...(from !== null ? { from } : {}), ...(pair !== null ? { pair } : {}), ...(pairKey !== null ? { pairKey, keyPaired } : {}) }
140
175
  }
141
176
 
142
177
  function parsePort(raw) {
@@ -384,10 +384,10 @@ async function systemdAutostart(action, { dataDir, env, home, name, run, user })
384
384
  return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
385
385
  }
386
386
 
387
- async function taskAutostart(action, { dataDir, env, home, name, run, user, platform, interop }) {
387
+ async function taskAutostart(action, { dataDir, env, home, name, run, user, platform, interop, mounts }) {
388
388
  const legacy = legacyTaskName({ dataDir, home, env, platform })
389
389
  const detach = detachHow(platform, env, interop)
390
- const ps = windowsPowershell(detach, env)
390
+ const ps = windowsPowershell(detach, env, mounts)
391
391
  const base = { ok: true, how: 'task', linger: null }
392
392
  if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
393
393
  const ask = (script) => askPowershell(ps, script, env, parseTaskAnswer, run)
@@ -456,8 +456,8 @@ export async function startInSignIn({ dataDir, env, home, platform = process.pla
456
456
  * hub asks (the link started it), and the entry is for the next boot.
457
457
  *
458
458
  * stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
459
- * machine offers nothing (`autostartHow`). `run` (a command runner) and `how` are parameters so
460
- * tests never reach the real systemd or Task Scheduler.
459
+ * machine offers nothing (`autostartHow`). `run` (a command runner), `how` and `mounts` (what /proc/mounts says, where WSL finds
460
+ * the Windows drive) are parameters so tests never reach the real systemd or Task Scheduler, nor depend on the drives of the machine.
461
461
  */
462
462
  export async function runAutostart({
463
463
  env,
@@ -467,6 +467,7 @@ export async function runAutostart({
467
467
  platform = process.platform,
468
468
  interop = wslInterop,
469
469
  how = autostartHow(platform, env, { interop }),
470
+ mounts = undefined,
470
471
  run = runCaptured,
471
472
  user = userInfo().username,
472
473
  write = (line) => process.stdout.write(line),
@@ -481,7 +482,7 @@ export async function runAutostart({
481
482
  }
482
483
  try {
483
484
  const name = autostartName(how, { dataDir, home, env, platform, user })
484
- const opts = { dataDir, env, home, name, run, user, platform, interop }
485
+ const opts = { dataDir, env, home, name, run, user, platform, interop, mounts }
485
486
  return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
486
487
  } catch (e) {
487
488
  return report({ ok: false, on: readAutostartMark(dataDir)?.how === how, how, linger: null, message: e?.message ?? String(e) })
@@ -8,7 +8,7 @@ import { userInfo } from 'node:os'
8
8
  import { join } from 'node:path'
9
9
  import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep } from './serve-util.mjs'
10
10
  import { SERVE_LOG_FILE, serveDataDir, ensureServeState, markStopped, rotateServeLog } from './serve-files.mjs'
11
- import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
11
+ import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslCommandLine } from './serve-windows.mjs'
12
12
  import { CHILD_FLAG } from './serve-args.mjs'
13
13
  import { startInSignIn } from './serve-autostart.mjs'
14
14
  import { stopServedHost } from './serve-handover.mjs'
@@ -72,11 +72,7 @@ export async function startDetached({ how, execPath, args, env, cwd }) {
72
72
  let commandLine
73
73
  try {
74
74
  if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
75
- else {
76
- const distro = wslDistro(env.WSL_DISTRO_NAME ?? '')
77
- const user = wslUser(userInfo().username)
78
- commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
79
- }
75
+ else commandLine = wslCommandLine({ distro: env.WSL_DISTRO_NAME ?? '', user: userInfo().username, cwd, execPath, args })
80
76
  } catch (e) {
81
77
  return { ok: false, blocked: false, message: e.message }
82
78
  }
@@ -9,6 +9,8 @@ import { chmodSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, ren
9
9
  import { join } from 'node:path'
10
10
  import { isAlive, renameRetrying, replaceFile, sleepSync } from './serve-util.mjs'
11
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'
12
14
 
13
15
  /**
14
16
  * The file in the data folder that holds the host's key, the full credentials' verifiers and the port, mode 0600.
@@ -22,13 +24,15 @@ import { CREDENTIAL_ID_RE, PAIR_LABEL_RE } from './serve-args.mjs'
22
24
  * credential. The credential itself is printed once, to whoever asked for it (`--pair`: a hub at each link,
23
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,
24
26
  * read again at each hello, so a pairing or a revocation applies at once.
25
- * - `legacyHostToken`: only while the installed host is from before verifiers (a rollback): the one token such a
26
- * host knows, given to it and to the hub that pairs, as before #1405.
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.
27
30
  *
28
31
  * The port is the one the last `serve` actually listened on, which is how `--connection`, a separate process, learns a
29
32
  * `--port` given to `serve` in a systemd unit. A version 1 file (`{ token, port }`) is moved to version 2 on first
30
33
  * use: its token becomes a full credential labelled `legacy` (a hub or window still holding it keeps working), which the
31
- * next pairing removes.
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.
32
36
  */
33
37
  export const SERVE_STATE_FILE = 'serve.json'
34
38
 
@@ -154,16 +158,18 @@ function parseServeState(raw) {
154
158
  try {
155
159
  const v = JSON.parse(raw)
156
160
  const port = Number.isInteger(v?.port) && v.port >= 1 && v.port <= 65535 ? v.port : null
157
- if (v?.v === 2) {
158
- if (typeof v.key !== 'string' || v.key.length < 32) return null
159
- // Exactly what the host takes (serve-credentials.ts `isListedEntry`): an entry of another shape is dropped here, so
160
- // the next write leaves it out too, and the host refuses it meanwhile
161
- 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 } : {}) }))
162
- const legacyHostToken = typeof v.legacyHostToken === 'string' && v.legacyHostToken.length >= 32 ? v.legacyHostToken : null
163
- return { v: 2, key: v.key, port, full, legacyHostToken, token: limitedOf(v.key), proofKey: v.key }
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: [] }
164
165
  }
165
- if (typeof v?.token !== 'string' || v.token.length < 32) return null
166
- return { v: 1, token: v.token, proofKey: v.token, port, key: null, full: [], legacyHostToken: null }
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 }
167
173
  } catch {
168
174
  return null
169
175
  }
@@ -174,9 +180,14 @@ export function isListedEntry(e) {
174
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)
175
181
  }
176
182
 
177
- /** What version 2 writes for a state */
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 */
178
189
  function stateFile(state) {
179
- return { v: 2, key: state.key, port: state.port, full: state.full, ...(state.legacyHostToken ? { legacyHostToken: state.legacyHostToken } : {}) }
190
+ return { v: 2, key: state.key, port: state.port, full: state.full, ...(state.keys?.length ? { keys: state.keys } : {}) }
180
191
  }
181
192
 
182
193
  /** A new full credential and its id */
@@ -204,11 +215,12 @@ export function ensureServeState(dataDir, platform = process.platform, now = Dat
204
215
  }
205
216
  let state = readServeState(dataDir)
206
217
  if (!state) throw noState(file)
207
- // A version 1 file (#1405): its token becomes a full credential no longer written down, and a new key is drawn
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
208
220
  if (state.v === 1) {
209
221
  state = changeServeState(dataDir, (s) =>
210
222
  s.v === 1
211
- ? { 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 }], legacyHostToken: null }
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: [] }
212
224
  : s,
213
225
  platform,
214
226
  )
@@ -421,30 +433,58 @@ function takesLegacyPlace(label) {
421
433
  return label === 'window' || label.startsWith('hub:')
422
434
  }
423
435
 
424
- /** Removes the full credential `id`; true when there was one. A hello with it is refused from then on */
425
- export function revokeServe(dataDir, id, platform = process.platform) {
426
- let found = false
427
- changeServeState(dataDir, (s) => {
428
- found = s.full.some((e) => e.id === id)
429
- return found ? { ...s, full: s.full.filter((e) => e.id !== id) } : s
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] }
430
460
  }, platform)
431
- return found
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
+ }
432
470
  }
433
471
 
434
472
  /**
435
- * The one token an installed host from before verifiers knows (a rollback): kept in the state while such a host is
436
- * installed, given to it and to a hub that pairs. Created once; returns it.
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.
437
475
  */
438
- export function legacyHostTokenOf(dataDir, platform = process.platform) {
439
- const state = ensureServeState(dataDir, platform)
440
- if (state.legacyHostToken) return state.legacyHostToken
441
- const token = randomBytes(32).toString('base64url')
442
- return changeServeState(dataDir, (s) => (s.legacyHostToken ? s : { ...s, legacyHostToken: token }), platform).legacyHostToken
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)
443
478
  }
444
479
 
445
- /** A host of today runs: the token kept for an older one is not kept any longer */
446
- export function dropLegacyHostToken(dataDir, platform = process.platform) {
447
- changeServeState(dataDir, (s) => (s.legacyHostToken ? { ...s, legacyHostToken: null } : s), platform)
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
448
488
  }
449
489
 
450
490
  /** Writes the port `serve` listened on, keeping the rest. Atomic: a temp file renamed over it */
@@ -453,13 +493,13 @@ export function recordServePort(dataDir, port, platform = process.platform) {
453
493
  }
454
494
 
455
495
  /**
456
- * A new key, and no full credential, keeping the port. Only when the person asks (`--rotate-token`): every hub pairs
457
- * again at its next link, the window at its next start, and a serve already running keeps its old key (what it hands
458
- * out) until it restarts, while the full credentials end at once.
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.
459
499
  */
460
500
  export function rotateServeToken(dataDir, platform = process.platform) {
461
501
  ensureServeState(dataDir, platform)
462
- return changeServeState(dataDir, (s) => ({ ...s, key: randomBytes(32).toString('base64url'), full: [], legacyHostToken: null }), platform)
502
+ return changeServeState(dataDir, (s) => ({ ...s, key: randomBytes(32).toString('base64url'), full: [], keys: [] }), platform)
463
503
  }
464
504
 
465
505
  /**
@@ -511,45 +551,86 @@ export function removeServePid(dataDir, pid) {
511
551
  }
512
552
 
513
553
  /**
514
- * A probe that did not prove the token, while this data folder's host is alive (`host.lock`): a host from before #838
515
- * still running after an update. It is running, and is not sent the token; a hub asking it for a proof is refused
516
- * 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.
517
563
  */
518
- export function heldHere(probe, dataDir, startOf = processStartTime) {
564
+ export function heldHere(probe, dataDir, startOf = processStartTime, portRecorded = true) {
519
565
  if (probe.running) return true
520
- if (!probe.unproven) return false
521
- 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
522
592
  }
523
593
 
524
594
  /**
525
595
  * Whether the process `host.lock` names is the one that wrote it (#838 review): alive, and started when the lock says
526
596
  * (`ps -o lstart`, as the host writes it, `instance-lock.ts`). A lock left by a host that died, whose pid another
527
597
  * process of the same user now has, names a stranger: counted as running, `--stop` would signal it. A lock without a
528
- * 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.
529
603
  */
530
- export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
531
- let holder
532
- try {
533
- holder = parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
534
- } catch {
535
- return false
536
- }
537
- if (!holder || !holder.started || !isAlive(holder.pid)) return false
538
- return startOf(holder.pid) === holder.started
604
+ export function lockHolderIsAlive(dataDir, startOf = processStartTime, createdAt = processCreatedAt) {
605
+ return isWriter(lockHolder(dataDir), startOf, createdAt) === true
539
606
  }
540
607
 
541
608
  /**
542
- * host.lock's `{ pid, started, by? }`; a bare pid (an older host) has no start time. `by` (#1223, additive) is who
543
- * started the host: `app`, `keeper` or `serve` (`instance-lock.ts`), absent when the host did not say
609
+ * Whether `{ pid, started, at }` names the live process that wrote it, by `lockHolderIsAlive`'s rule: true, false, or
610
+ * null when it lives and Windows cannot say when it was created (another logon's process); each caller decides what
611
+ * that counts as
612
+ */
613
+ export function isWriter(holder, startOf = processStartTime, createdAt = processCreatedAt) {
614
+ if (!holder || !isAlive(holder.pid)) return false
615
+ if (holder.started) return startOf(holder.pid) === holder.started
616
+ if (holder.at === undefined || holder.at > Date.now()) return false
617
+ const created = createdAt(holder.pid)
618
+ return created === null ? null : created <= holder.at + CLOCK_TICK_MS
619
+ }
620
+
621
+ /** One tick of the Windows clock */
622
+ const CLOCK_TICK_MS = 16
623
+
624
+ /**
625
+ * host.lock's `{ pid, started, by? }`. `by` (#1223, additive) is who started the host: `app`, `keeper` or `serve`
626
+ * (`instance-lock.ts`), absent when the host did not say
544
627
  */
545
628
  function parseLockHolder(text) {
546
- const t = text.trim()
547
- if (/^\d+$/.test(t)) return Number(t) > 0 ? { pid: Number(t), started: null } : null
548
629
  try {
549
- const v = JSON.parse(t)
630
+ const v = JSON.parse(text)
550
631
  if (!Number.isInteger(v?.pid) || v.pid <= 0) return null
551
632
  const by = v.by === 'app' || v.by === 'keeper' || v.by === 'serve' ? v.by : null
552
- return { pid: v.pid, started: typeof v.started === 'string' ? v.started : null, ...(by ? { by } : {}) }
633
+ return { pid: v.pid, started: typeof v.started === 'string' ? v.started : null, ...(Number.isFinite(v.at) ? { at: v.at } : {}), ...(by ? { by } : {}) }
553
634
  } catch {
554
635
  return null
555
636
  }
@@ -568,7 +649,24 @@ export function processStartTime(pid) {
568
649
  }
569
650
  }
570
651
 
571
- /** Who `host.lock` names as the data folder's owner, `{ pid, started, by? }`, or null */
652
+ /**
653
+ * When a pid's process was created, as milliseconds since the epoch, on Windows (PowerShell); null elsewhere, or when
654
+ * it cannot be told (a process of another account). Asked of .NET, not `Get-Process`: that one loads a module, which
655
+ * under PowerShell 7's module path (a CI runner's, a pwsh terminal's) fails, and every lock held on Windows read as a
656
+ * dead one's (#1906)
657
+ */
658
+ export function processCreatedAt(pid) {
659
+ if (process.platform !== 'win32') return null
660
+ try {
661
+ 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'] })
662
+ const ms = Number(out.trim())
663
+ return Number.isFinite(ms) && ms > 0 ? ms : null
664
+ } catch {
665
+ return null
666
+ }
667
+ }
668
+
669
+ /** Who `host.lock` names as the data folder's owner, `{ pid, started, by?, at? }`, or null */
572
670
  export function lockHolder(dataDir) {
573
671
  try {
574
672
  return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))