centralu 0.1.0-beta.45 → 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 +1 -1
- package/bin/serve-args.mjs +20 -1
- package/bin/serve-autostart.mjs +6 -5
- package/bin/serve-detach.mjs +2 -6
- package/bin/serve-files.mjs +150 -60
- package/bin/serve-handover.mjs +51 -29
- package/bin/serve-host.mjs +107 -82
- package/bin/serve-keys.mjs +126 -0
- package/bin/serve-run.mjs +71 -38
- package/bin/serve-window.mjs +5 -6
- package/bin/serve-windows.mjs +10 -0
- package/bin/serve.mjs +4 -3
- package/package.json +5 -5
package/bin/centralu.mjs
CHANGED
|
@@ -661,7 +661,7 @@ switch (cmd) {
|
|
|
661
661
|
if (spec) Object.assign(process.env, spec.env)
|
|
662
662
|
// realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
|
|
663
663
|
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 })
|
|
664
|
+
if (args.mode === 'connection') process.exitCode = await printConnection({ ...opts, pair: args.pair ?? null, pairKey: args.pairKey ?? null, keyPaired: args.keyPaired === true })
|
|
665
665
|
else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port, from: args.from })
|
|
666
666
|
else if (args.mode === 'stop') process.exitCode = await runStop(opts)
|
|
667
667
|
else if (args.mode === 'window') process.exitCode = await runWindow(opts)
|
package/bin/serve-args.mjs
CHANGED
|
@@ -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
|
|
|
@@ -83,6 +84,8 @@ export function parseServeArgs(argv) {
|
|
|
83
84
|
let autostart = null
|
|
84
85
|
let from = null
|
|
85
86
|
let pair = null
|
|
87
|
+
let pairKey = null
|
|
88
|
+
let keyPaired = false
|
|
86
89
|
let revoke = null
|
|
87
90
|
for (let i = 0; i < argv.length; i++) {
|
|
88
91
|
const a = argv[i]
|
|
@@ -90,6 +93,19 @@ export function parseServeArgs(argv) {
|
|
|
90
93
|
const raw = a === '--pair' ? argv[++i] : a.slice('--pair='.length)
|
|
91
94
|
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
95
|
pair = raw
|
|
96
|
+
} else if (a === '--pair-key') {
|
|
97
|
+
/*
|
|
98
|
+
* `--pair-key <label> <public key>` (#1711, docs/plans/key-pairing.md §4.2): a hub's key pairing, at each link in
|
|
99
|
+
* place of `--pair`. The key is checked as the host checks it (§4.1): a refused one writes nothing
|
|
100
|
+
*/
|
|
101
|
+
const label = argv[++i]
|
|
102
|
+
const publicKey = argv[++i]
|
|
103
|
+
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)})` }
|
|
104
|
+
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))})` }
|
|
105
|
+
pairKey = { label, publicKey }
|
|
106
|
+
} else if (a === '--key-paired') {
|
|
107
|
+
// The hub has paired by key with this machine before (#1711, §4.2): a key line removes the label's verifier entry
|
|
108
|
+
keyPaired = true
|
|
93
109
|
} else if (a === '--revoke' || a.startsWith('--revoke=')) {
|
|
94
110
|
const raw = a === '--revoke' ? argv[++i] : a.slice('--revoke='.length)
|
|
95
111
|
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 +137,9 @@ export function parseServeArgs(argv) {
|
|
|
121
137
|
}
|
|
122
138
|
if (help) return { mode: 'help', port }
|
|
123
139
|
if (pair !== null && mode !== 'connection') return { error: '--pair goes with --connection' }
|
|
140
|
+
if (pairKey !== null && mode !== 'connection') return { error: '--pair-key goes with --connection' }
|
|
141
|
+
if (pair !== null && pairKey !== null) return { error: '--pair and --pair-key are two ways of pairing; pass one' }
|
|
142
|
+
if (keyPaired && pairKey === null) return { error: '--key-paired goes with --pair-key' }
|
|
124
143
|
if (mode === 'revoke') return port !== null ? { error: '--revoke takes no --port' } : { mode, port, revoke }
|
|
125
144
|
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
145
|
if (child !== null) {
|
|
@@ -136,7 +155,7 @@ export function parseServeArgs(argv) {
|
|
|
136
155
|
if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
|
|
137
156
|
return { mode, port, autostart }
|
|
138
157
|
}
|
|
139
|
-
return { mode, port, ...(from !== null ? { from } : {}), ...(pair !== null ? { pair } : {}) }
|
|
158
|
+
return { mode, port, ...(from !== null ? { from } : {}), ...(pair !== null ? { pair } : {}), ...(pairKey !== null ? { pairKey, keyPaired } : {}) }
|
|
140
159
|
}
|
|
141
160
|
|
|
142
161
|
function parsePort(raw) {
|
package/bin/serve-autostart.mjs
CHANGED
|
@@ -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 `
|
|
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) })
|
package/bin/serve-detach.mjs
CHANGED
|
@@ -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,
|
|
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
|
}
|
package/bin/serve-files.mjs
CHANGED
|
@@ -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
|
-
* - `
|
|
26
|
-
*
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
166
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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 }],
|
|
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
|
-
/**
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
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
|
-
*
|
|
436
|
-
*
|
|
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
|
|
439
|
-
|
|
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
|
-
/**
|
|
446
|
-
export function
|
|
447
|
-
|
|
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`):
|
|
457
|
-
* again at its next link, the window at its next start, and a serve already running keeps its old key
|
|
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: [],
|
|
502
|
+
return changeServeState(dataDir, (s) => ({ ...s, key: randomBytes(32).toString('base64url'), full: [], keys: [] }), platform)
|
|
463
503
|
}
|
|
464
504
|
|
|
465
505
|
/**
|
|
@@ -511,45 +551,78 @@ 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
|
|
515
|
-
*
|
|
516
|
-
*
|
|
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
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
+
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
|
|
539
611
|
}
|
|
540
612
|
|
|
613
|
+
/** One tick of the Windows clock */
|
|
614
|
+
const CLOCK_TICK_MS = 16
|
|
615
|
+
|
|
541
616
|
/**
|
|
542
|
-
* host.lock's `{ pid, started, by? }
|
|
543
|
-
*
|
|
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
|
|
544
619
|
*/
|
|
545
620
|
function parseLockHolder(text) {
|
|
546
|
-
const t = text.trim()
|
|
547
|
-
if (/^\d+$/.test(t)) return Number(t) > 0 ? { pid: Number(t), started: null } : null
|
|
548
621
|
try {
|
|
549
|
-
const v = JSON.parse(
|
|
622
|
+
const v = JSON.parse(text)
|
|
550
623
|
if (!Number.isInteger(v?.pid) || v.pid <= 0) return null
|
|
551
624
|
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 } : {}) }
|
|
625
|
+
return { pid: v.pid, started: typeof v.started === 'string' ? v.started : null, ...(Number.isFinite(v.at) ? { at: v.at } : {}), ...(by ? { by } : {}) }
|
|
553
626
|
} catch {
|
|
554
627
|
return null
|
|
555
628
|
}
|
|
@@ -568,7 +641,24 @@ export function processStartTime(pid) {
|
|
|
568
641
|
}
|
|
569
642
|
}
|
|
570
643
|
|
|
571
|
-
/**
|
|
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 */
|
|
572
662
|
export function lockHolder(dataDir) {
|
|
573
663
|
try {
|
|
574
664
|
return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
|
package/bin/serve-handover.mjs
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Windows sign-in (#1224). One module so the foreground `serve` (serve-run.mjs) and `--stop` (serve-detach.mjs) share
|
|
4
4
|
* the stop without importing each other.
|
|
5
5
|
*/
|
|
6
|
-
import { readServeState, heldHere,
|
|
7
|
-
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
8
|
-
import { protocolVersionOf,
|
|
6
|
+
import { readServeState, heldHere, startedByServe, foreignHolder, holderWho, lockHolderIsAlive, lockHolderPid, readServePid } from './serve-files.mjs'
|
|
7
|
+
import { DEFAULT_SERVE_PORT, listensHere } from './serve-port.mjs'
|
|
8
|
+
import { protocolVersionOf, probeHost, askHostToStop, askHostRpc } from './serve-host.mjs'
|
|
9
9
|
import { ownLogon } from './serve-windows.mjs'
|
|
10
10
|
import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
|
|
11
11
|
|
|
@@ -20,7 +20,11 @@ import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
|
|
|
20
20
|
* one still there afterwards is ended.
|
|
21
21
|
*
|
|
22
22
|
* Only the processes serve.pid and host.lock name, and only while they agree with each other and the host answered
|
|
23
|
-
* with this folder's token
|
|
23
|
+
* with this folder's token, or nothing listens on the port while the process host.lock names is the one that wrote it
|
|
24
|
+
* (`heldSilently`, #1903: a host that took the lock and hung before it listened; never under `ifIdle`): a stale pid is
|
|
25
|
+
* never signalled.
|
|
26
|
+
* And only a host `serve` started (`startedByServe`, #1722): a lock held by the app's or the keeper's host is theirs,
|
|
27
|
+
* whatever answers on the port, so `--stop` says whose it is and leaves it.
|
|
24
28
|
*
|
|
25
29
|
* `{ ok, wasRunning, how?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
|
|
26
30
|
* when nothing runs there, and `{ ok: false, busy: true }` says it did not. `forUpdate` (#1618): a hub's update stops it, and
|
|
@@ -32,28 +36,42 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
|
|
|
32
36
|
if (!state) return { ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` }
|
|
33
37
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
34
38
|
const installed = protocolVersionOf(entry) ?? 0
|
|
35
|
-
const prove = hostProvesOf(entry)
|
|
36
39
|
// The limited credential is enough to probe and to stop it (#1405, `host.stop` is everyday)
|
|
37
|
-
const own =
|
|
38
|
-
const probe = () => probeHost({ port, ...own, protocolVersion: installed
|
|
40
|
+
const own = { token: state.token, proofKey: state.proofKey }
|
|
41
|
+
const probe = () => probeHost({ port, ...own, protocolVersion: installed })
|
|
39
42
|
const first = await probe()
|
|
40
|
-
|
|
43
|
+
const silent = !first.running && !first.unproven && (await heldSilently(dataDir, port))
|
|
44
|
+
if (!silent && !heldHere(first, dataDir, undefined, state.port != null)) {
|
|
45
|
+
// The folder is held, but not by a serve (#1722): the app's or the keeper's host, whatever answered on the port
|
|
46
|
+
const other = foreignHolder(dataDir)
|
|
47
|
+
const whose = other ? `. It belongs to another Centralu host (pid ${other.pid}), ${holderWho(other.by)}; not stopping it` : ''
|
|
48
|
+
return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}${whose}` }
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
const hostPid = lockHolderPid(dataDir)
|
|
52
|
+
// Only a host `serve` started is ever signalled, whoever answered with this folder's token
|
|
53
|
+
const mayKill = startedByServe(dataDir)
|
|
43
54
|
const launcher = readServePid(dataDir)
|
|
44
55
|
// serve.pid is only believed when it names the host that holds the folder now
|
|
45
|
-
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
56
|
+
const launcherPid = mayKill && launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
46
57
|
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
47
58
|
|
|
48
|
-
let asked = await askHostToStop({ port, ...own, protocolVersion: first.protocolVersion ?? installed, prove, ifIdle, forUpdate })
|
|
49
|
-
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, ...own, protocolVersion: asked.protocolVersion, prove, ifIdle, forUpdate })
|
|
50
|
-
// `ifIdle` and it has work running (#1224): it was left as it is
|
|
51
|
-
if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
|
|
52
59
|
let how = 'asked'
|
|
53
|
-
let stopped =
|
|
54
|
-
let note =
|
|
60
|
+
let stopped = false
|
|
61
|
+
let note = silent ? 'nothing listens on its port' : null
|
|
62
|
+
// The hand-over stops only a host that said it is idle: one that cannot say so is left
|
|
63
|
+
if (silent && ifIdle) return { ok: false, wasRunning: true, message: `the host holds the folder but ${note}` }
|
|
64
|
+
if (!silent) {
|
|
65
|
+
let asked = await askHostToStop({ port, ...own, protocolVersion: first.protocolVersion ?? installed, ifIdle, forUpdate })
|
|
66
|
+
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, ...own, protocolVersion: asked.protocolVersion, ifIdle, forUpdate })
|
|
67
|
+
// `ifIdle` and it has work running (#1224): it was left as it is
|
|
68
|
+
if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
|
|
69
|
+
stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
70
|
+
if (!asked.ok) note = `the host did not take the request to stop (${asked.message})`
|
|
71
|
+
}
|
|
55
72
|
if (!stopped) {
|
|
56
|
-
const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
|
|
73
|
+
const target = platform !== 'win32' && launcherPid ? launcherPid : mayKill ? hostPid : null
|
|
74
|
+
if (hostPid && !mayKill) note = `${note ? `${note}; ` : ''}the lock names pid ${hostPid}, which centralu serve did not start, so it was not signalled`
|
|
57
75
|
if (target) {
|
|
58
76
|
how = platform === 'win32' ? 'ended' : 'signal'
|
|
59
77
|
try {
|
|
@@ -85,6 +103,16 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
|
|
|
85
103
|
return { ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) }
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A host `serve` started that holds the folder's lock while nothing listens on its port (#1903): it took the lock and hung
|
|
108
|
+
* before it listened, or is still starting. Told by a listen on the port, not by the probe: a host that listens but whose
|
|
109
|
+
* event loop is stalled answers no probe in time either, and is running (#1906 review). Not part of `heldHere`, which
|
|
110
|
+
* `--connection` and `--detach`'s wait read: there a host still starting must not count as one that answers
|
|
111
|
+
*/
|
|
112
|
+
async function heldSilently(dataDir, port) {
|
|
113
|
+
return startedByServe(dataDir) && lockHolderIsAlive(dataDir) && !(await listensHere(port))
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
/** How long the hand-over waits for the host's work to end before it gives up and says so (#1224 review) */
|
|
89
117
|
export const HANDOVER_WAIT_MS = 30 * 60_000
|
|
90
118
|
|
|
@@ -126,28 +154,22 @@ export async function moveIntoSignIn({
|
|
|
126
154
|
const state = readServeState(dataDir)
|
|
127
155
|
if (!state) return false
|
|
128
156
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
129
|
-
const prove = hostProvesOf(entry)
|
|
130
157
|
const protocolVersion = existing.protocolVersion ?? protocolVersionOf(entry) ?? 0
|
|
131
|
-
const own =
|
|
158
|
+
const own = { token: state.token, proofKey: state.proofKey }
|
|
132
159
|
/*
|
|
133
|
-
* `host.handover`
|
|
134
|
-
*
|
|
160
|
+
* `host.handover` takes everyday rights (#1711, docs/plans/key-pairing.md §4.4): said with the credentials this
|
|
161
|
+
* launcher holds for its own host (the limited credential, after a proof under the key), and no credential is made for
|
|
162
|
+
* it. Before, a full one made for this call went to whatever answered that proof, which a reader of serve.json can. A
|
|
163
|
+
* host from #1405 to key pairing refuses it (`link`): no note on the row while this waits, and the move goes on
|
|
135
164
|
*/
|
|
136
|
-
const sayHandover =
|
|
137
|
-
const made = takesVerifiers(entry) ? pairServe(dataDir, 'handover', platform, Date.now(), 'handover') : null
|
|
138
|
-
try {
|
|
139
|
-
return await askHostRpc({ port, token: made?.token ?? own.token, proofKey: own.proofKey, protocolVersion, prove, method: 'host.handover', params: { handover: h } })
|
|
140
|
-
} finally {
|
|
141
|
-
if (made) revokeServe(dataDir, made.id, platform)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
165
|
+
const sayHandover = (h) => askHostRpc({ port, ...own, protocolVersion, method: 'host.handover', params: { handover: h } })
|
|
144
166
|
const report = (handover) => (tell ?? sayHandover)(handover)
|
|
145
167
|
const pid = lockHolderPid(dataDir)
|
|
146
168
|
say(`the host running for ${dataDir}${pid ? ` (pid ${pid})` : ''} runs in a network logon (started over ssh), where Git cannot use the credentials saved in Windows; moving it into this Windows sign-in once nothing runs there`)
|
|
147
169
|
const t0 = now()
|
|
148
170
|
let waiting = false
|
|
149
171
|
for (;;) {
|
|
150
|
-
if (!(await probeHost({ port, ...own, protocolVersion
|
|
172
|
+
if (!(await probeHost({ port, ...own, protocolVersion })).running) return true
|
|
151
173
|
const r = await stop()
|
|
152
174
|
if (r.ok) {
|
|
153
175
|
say(`stopped it${r.how && r.how !== 'asked' ? ` (${r.how})` : ''}; starting the host in this sign-in`)
|