centralu 0.1.0-beta.43 → 0.1.0-beta.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/centralu.mjs +7 -1
- package/bin/serve-args.mjs +38 -8
- package/bin/serve-detach.mjs +3 -2
- package/bin/serve-files.mjs +319 -31
- package/bin/serve-handover.mjs +26 -9
- package/bin/serve-host.mjs +62 -15
- package/bin/serve-run.mjs +71 -16
- package/bin/serve-util.mjs +7 -1
- package/bin/serve-window.mjs +10 -5
- package/bin/serve.mjs +4 -4
- package/package.json +5 -5
package/bin/centralu.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
parseServeArgs,
|
|
49
49
|
printConnection,
|
|
50
50
|
rotateToken,
|
|
51
|
+
revokeCredential,
|
|
51
52
|
runAutostart,
|
|
52
53
|
runDetach,
|
|
53
54
|
runDetachedChild,
|
|
@@ -621,6 +622,11 @@ switch (cmd) {
|
|
|
621
622
|
break
|
|
622
623
|
}
|
|
623
624
|
try {
|
|
625
|
+
// One full credential withdrawn (#1405): like --rotate-token, nothing to start or describe
|
|
626
|
+
if (args.mode === 'revoke') {
|
|
627
|
+
process.exitCode = revokeCredential({ env: process.env, home: HOME, id: args.revoke })
|
|
628
|
+
break
|
|
629
|
+
}
|
|
624
630
|
if (args.mode === 'rotate') {
|
|
625
631
|
process.exitCode = rotateToken({ env: process.env, home: HOME })
|
|
626
632
|
break
|
|
@@ -655,7 +661,7 @@ switch (cmd) {
|
|
|
655
661
|
if (spec) Object.assign(process.env, spec.env)
|
|
656
662
|
// realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
|
|
657
663
|
const opts = { env: process.env, home: HOME, entry: requireHostEntry(), version: pkg.version, cliPath: realpathSync(process.argv[1]) }
|
|
658
|
-
if (args.mode === 'connection') process.exitCode = await printConnection(opts)
|
|
664
|
+
if (args.mode === 'connection') process.exitCode = await printConnection({ ...opts, pair: args.pair ?? null })
|
|
659
665
|
else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port, from: args.from })
|
|
660
666
|
else if (args.mode === 'stop') process.exitCode = await runStop(opts)
|
|
661
667
|
else if (args.mode === 'window') process.exitCode = await runWindow(opts)
|
package/bin/serve-args.mjs
CHANGED
|
@@ -8,10 +8,17 @@ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine
|
|
|
8
8
|
centralu serve --detach Start it in the background, outside this session; return once it answers
|
|
9
9
|
centralu serve --stop Ask the running host to stop, and wait until it has
|
|
10
10
|
centralu serve --window What the Centralu window on Windows runs here: start the host if
|
|
11
|
-
none runs, print its port and
|
|
12
|
-
stdin ends, and leave the host running
|
|
13
|
-
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
14
|
-
|
|
11
|
+
none runs, print its port and a full credential for the window as
|
|
12
|
+
a ready line, stay until stdin ends, and leave the host running
|
|
13
|
+
centralu serve --connection Print how to reach it as one JSON line, then exit. It carries the
|
|
14
|
+
limited credential (everyday calls) and lists the full ones
|
|
15
|
+
centralu serve --connection --pair <label>
|
|
16
|
+
... and a new full credential for <label>, printed once and kept
|
|
17
|
+
here only as its hash; it replaces <label>'s earlier one. What the
|
|
18
|
+
app on another computer runs at each link
|
|
19
|
+
centralu serve --revoke <id> Withdraw one full credential (--connection lists their ids)
|
|
20
|
+
centralu serve --rotate-token A new key, and every full credential withdrawn (a running serve
|
|
21
|
+
keeps its old key until restarted)
|
|
15
22
|
centralu serve --from <hub> With serve or --detach: the name of the computer whose link asks,
|
|
16
23
|
said by a second Centralu on this data folder when it is refused
|
|
17
24
|
centralu serve --autostart on|off|remove|status
|
|
@@ -32,7 +39,16 @@ from an install the app made over SSH, which has a launcher of its own.`
|
|
|
32
39
|
|
|
33
40
|
/** The flags that each make serve a different command */
|
|
34
41
|
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop', '--window': 'window' }
|
|
35
|
-
const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart' }
|
|
42
|
+
const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart', revoke: '--revoke' }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What `--pair` takes (#1405): who the full credential is for, said back in `--connection`'s `others` and on the hub's
|
|
46
|
+
* machine row. A hub sends `hub:<its machine id>`
|
|
47
|
+
*/
|
|
48
|
+
export const PAIR_LABEL_RE = /^[A-Za-z0-9:._-]{1,80}$/
|
|
49
|
+
|
|
50
|
+
/** What `--revoke` takes: a full credential's id, as `--connection` lists it (8 hex characters) */
|
|
51
|
+
export const CREDENTIAL_ID_RE = /^[0-9a-f]{8}$/
|
|
36
52
|
|
|
37
53
|
/** What `--autostart` takes */
|
|
38
54
|
export const AUTOSTART_ACTIONS = ['on', 'off', 'remove', 'status']
|
|
@@ -66,9 +82,21 @@ export function parseServeArgs(argv) {
|
|
|
66
82
|
let child = null
|
|
67
83
|
let autostart = null
|
|
68
84
|
let from = null
|
|
85
|
+
let pair = null
|
|
86
|
+
let revoke = null
|
|
69
87
|
for (let i = 0; i < argv.length; i++) {
|
|
70
88
|
const a = argv[i]
|
|
71
|
-
if (a === '--
|
|
89
|
+
if (a === '--pair' || a.startsWith('--pair=')) {
|
|
90
|
+
const raw = a === '--pair' ? argv[++i] : a.slice('--pair='.length)
|
|
91
|
+
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
|
+
pair = raw
|
|
93
|
+
} else if (a === '--revoke' || a.startsWith('--revoke=')) {
|
|
94
|
+
const raw = a === '--revoke' ? argv[++i] : a.slice('--revoke='.length)
|
|
95
|
+
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)})` }
|
|
96
|
+
if (mode !== 'serve' && mode !== 'revoke') return { error: `${FLAG_OF[mode]} and --revoke are separate commands; pass one` }
|
|
97
|
+
mode = 'revoke'
|
|
98
|
+
revoke = raw
|
|
99
|
+
} else if (a === '--autostart' || a.startsWith('--autostart=')) {
|
|
72
100
|
const raw = a === '--autostart' ? argv[++i] : a.slice('--autostart='.length)
|
|
73
101
|
if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off, remove or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
74
102
|
if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
|
|
@@ -76,7 +104,7 @@ export function parseServeArgs(argv) {
|
|
|
76
104
|
autostart = raw
|
|
77
105
|
} else if (Object.hasOwn(MODE_FLAGS, a)) {
|
|
78
106
|
const m = MODE_FLAGS[a]
|
|
79
|
-
if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
|
|
107
|
+
if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode] ?? mode} and ${a} are separate commands; pass one` }
|
|
80
108
|
mode = m
|
|
81
109
|
} else if (a === '--help' || a === '-h') help = true
|
|
82
110
|
else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
|
|
@@ -92,6 +120,8 @@ export function parseServeArgs(argv) {
|
|
|
92
120
|
} else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
|
|
93
121
|
}
|
|
94
122
|
if (help) return { mode: 'help', port }
|
|
123
|
+
if (pair !== null && mode !== 'connection') return { error: '--pair goes with --connection' }
|
|
124
|
+
if (mode === 'revoke') return port !== null ? { error: '--revoke takes no --port' } : { mode, port, revoke }
|
|
95
125
|
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]}` }
|
|
96
126
|
if (child !== null) {
|
|
97
127
|
if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
|
|
@@ -106,7 +136,7 @@ export function parseServeArgs(argv) {
|
|
|
106
136
|
if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
|
|
107
137
|
return { mode, port, autostart }
|
|
108
138
|
}
|
|
109
|
-
return { mode, port, ...(from !== null ? { from } : {}) }
|
|
139
|
+
return { mode, port, ...(from !== null ? { from } : {}), ...(pair !== null ? { pair } : {}) }
|
|
110
140
|
}
|
|
111
141
|
|
|
112
142
|
function parsePort(raw) {
|
package/bin/serve-detach.mjs
CHANGED
|
@@ -270,7 +270,8 @@ export async function runDetachedChild({ spec, ...opts }) {
|
|
|
270
270
|
*
|
|
271
271
|
* A stop that went through is remembered in the data folder (`markStopped`, #1143), so a linked hub does not start the
|
|
272
272
|
* host again by itself; the next host that starts here removes the mark. Not for a hub's own stop, which starts the
|
|
273
|
-
* host again itself (an update, a rollback): the hub runs it with `CENTRALU_STOP_BY=hub` (links/tunnel.ts `stopCommand`)
|
|
273
|
+
* host again itself (an update, a rollback): the hub runs it with `CENTRALU_STOP_BY=hub` (links/tunnel.ts `stopCommand`).
|
|
274
|
+
* With `CENTRALU_STOP_FOR=update` too (#1618), the host is asked to list the turns it ends, which the next host continues
|
|
274
275
|
*/
|
|
275
276
|
export async function runStop({
|
|
276
277
|
env,
|
|
@@ -283,7 +284,7 @@ export async function runStop({
|
|
|
283
284
|
write = (line) => process.stdout.write(line),
|
|
284
285
|
}) {
|
|
285
286
|
const dataDir = serveDataDir(env, home)
|
|
286
|
-
const stopped = await stopServedHost({ dataDir, entry, platform, timeoutMs, graceMs, kill })
|
|
287
|
+
const stopped = await stopServedHost({ dataDir, entry, platform, forUpdate: env.CENTRALU_STOP_FOR === 'update', timeoutMs, graceMs, kill })
|
|
287
288
|
if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, 'stop')
|
|
288
289
|
return answerLine(write, 'stop', stopped)
|
|
289
290
|
}
|
package/bin/serve-files.mjs
CHANGED
|
@@ -1,24 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The files `centralu serve` keeps in the data folder: the
|
|
3
|
-
* user), the running launcher's pid (serve.pid), and the host's own lock (host.lock),
|
|
4
|
-
* live host of this folder from a stale pid.
|
|
2
|
+
* The files `centralu serve` keeps in the data folder: the host's key, the verifiers of the full credentials and the
|
|
3
|
+
* port (serve.json, private to the user), the running launcher's pid (serve.pid), and the host's own lock (host.lock),
|
|
4
|
+
* read to tell a live host of this folder from a stale pid.
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from 'node:child_process'
|
|
7
|
-
import { randomBytes } from 'node:crypto'
|
|
8
|
-
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
7
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
|
8
|
+
import { chmodSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { isAlive, renameRetrying, replaceFile, sleepSync } from './serve-util.mjs'
|
|
11
|
+
import { CREDENTIAL_ID_RE, PAIR_LABEL_RE } from './serve-args.mjs'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* The file in the data folder that holds the
|
|
14
|
+
* The file in the data folder that holds the host's key, the full credentials' verifiers and the port, mode 0600.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Since #1405 (version 2) it holds **no credential that opens the host whole**:
|
|
17
|
+
*
|
|
18
|
+
* - `key`: 32 random bytes the host derives what it hands out from (the limited credential `--connection` prints, a
|
|
19
|
+
* Codex bridge's, the proof of a launcher's probe). With it a process of this user gets the limited credential;
|
|
20
|
+
* it is never a full one (a hello with it is refused as full).
|
|
21
|
+
* - `full`: one entry per full credential, `{ id, label, verifier, created }`, the verifier being the SHA-256 of the
|
|
22
|
+
* credential. The credential itself is printed once, to whoever asked for it (`--pair`: a hub at each link,
|
|
23
|
+
* `--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
|
+
* 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
|
+
*
|
|
28
|
+
* The port is the one the last `serve` actually listened on, which is how `--connection`, a separate process, learns a
|
|
29
|
+
* `--port` given to `serve` in a systemd unit. A version 1 file (`{ token, port }`) is moved to version 2 on first
|
|
30
|
+
* use: its token becomes a full credential labelled `legacy` (a hub or window still holding it keeps working), which the
|
|
31
|
+
* next pairing removes.
|
|
19
32
|
*/
|
|
20
33
|
export const SERVE_STATE_FILE = 'serve.json'
|
|
21
34
|
|
|
35
|
+
/** The label of the full credential a version 1 file's token became; removed by the next pairing */
|
|
36
|
+
export const LEGACY_LABEL = 'legacy'
|
|
37
|
+
|
|
38
|
+
/** A full credential's verifier: the SHA-256 of it, as hex (the host's `serve-credentials.ts` compares the same) */
|
|
39
|
+
export function verifierOf(credential) {
|
|
40
|
+
return createHash('sha256').update(credential).digest('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The limited credential the host derives from `key` (#1405, the host's `transport/limited-token.ts`) */
|
|
44
|
+
export function limitedOf(key) {
|
|
45
|
+
return `ccl1:${createHmac('sha256', key).update('centralu limited token v1').digest('base64url')}`
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
/** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
|
|
23
49
|
export const SERVE_LOG_FILE = 'serve.log'
|
|
24
50
|
|
|
@@ -119,16 +145,45 @@ export function readServeState(dataDir, { tries = 5, sleep = sleepSync } = {}) {
|
|
|
119
145
|
}
|
|
120
146
|
}
|
|
121
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The state as the launcher uses it. `token` is the credential the launcher's own probes say hello with, and
|
|
150
|
+
* `proofKey` what the host proves with to them: the limited credential and the key for version 2, the token for a
|
|
151
|
+
* version 1 file not moved yet.
|
|
152
|
+
*/
|
|
122
153
|
function parseServeState(raw) {
|
|
123
154
|
try {
|
|
124
155
|
const v = JSON.parse(raw)
|
|
156
|
+
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 }
|
|
164
|
+
}
|
|
125
165
|
if (typeof v?.token !== 'string' || v.token.length < 32) return null
|
|
126
|
-
return { token: v.token,
|
|
166
|
+
return { v: 1, token: v.token, proofKey: v.token, port, key: null, full: [], legacyHostToken: null }
|
|
127
167
|
} catch {
|
|
128
168
|
return null
|
|
129
169
|
}
|
|
130
170
|
}
|
|
131
171
|
|
|
172
|
+
/** An entry the launcher lists, `--revoke` takes and the host accepts: an 8-hex id, a label `--pair` takes, a hex verifier */
|
|
173
|
+
export function isListedEntry(e) {
|
|
174
|
+
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
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What version 2 writes for a state */
|
|
178
|
+
function stateFile(state) {
|
|
179
|
+
return { v: 2, key: state.key, port: state.port, full: state.full, ...(state.legacyHostToken ? { legacyHostToken: state.legacyHostToken } : {}) }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A new full credential and its id */
|
|
183
|
+
function mint() {
|
|
184
|
+
return { token: randomBytes(32).toString('base64url'), id: randomBytes(4).toString('hex') }
|
|
185
|
+
}
|
|
186
|
+
|
|
132
187
|
/**
|
|
133
188
|
* The state, created on first use. Never replaces a token that is already there.
|
|
134
189
|
*
|
|
@@ -137,41 +192,274 @@ function parseServeState(raw) {
|
|
|
137
192
|
* that exists but cannot be read as state is refused rather than overwritten: it may be a token
|
|
138
193
|
* the client already holds, and a silent new one would lock that client out with "bad token".
|
|
139
194
|
*/
|
|
140
|
-
export function ensureServeState(dataDir, platform = process.platform) {
|
|
195
|
+
export function ensureServeState(dataDir, platform = process.platform, now = Date.now()) {
|
|
141
196
|
mkdirSync(dataDir, { recursive: true })
|
|
142
197
|
const file = join(dataDir, SERVE_STATE_FILE)
|
|
143
198
|
if (!existsSync(file)) {
|
|
144
|
-
const token = randomBytes(32).toString('base64url')
|
|
145
199
|
try {
|
|
146
|
-
writePrivate(file, { v:
|
|
200
|
+
writePrivate(file, { v: 2, key: randomBytes(32).toString('base64url'), port: null, full: [] }, 'wx')
|
|
147
201
|
} catch (e) {
|
|
148
202
|
if (e?.code !== 'EEXIST') throw e
|
|
149
203
|
}
|
|
150
204
|
}
|
|
151
|
-
|
|
152
|
-
if (!state) throw
|
|
205
|
+
let state = readServeState(dataDir)
|
|
206
|
+
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
|
|
208
|
+
if (state.v === 1) {
|
|
209
|
+
state = changeServeState(dataDir, (s) =>
|
|
210
|
+
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 }
|
|
212
|
+
: s,
|
|
213
|
+
platform,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
153
216
|
keepPrivate(file, platform)
|
|
154
217
|
return state
|
|
155
218
|
}
|
|
156
219
|
|
|
157
|
-
/**
|
|
158
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Why serve.json gives no state (#1405 review): a file this user may not read is said as that, with what makes it
|
|
222
|
+
* readable again. Moving it aside, the advice for a file that is not a state file, would lock out every hub and window
|
|
223
|
+
* that holds a credential it names.
|
|
224
|
+
*/
|
|
225
|
+
export function noState(file, read = readFileSync) {
|
|
226
|
+
try {
|
|
227
|
+
read(file)
|
|
228
|
+
} catch (e) {
|
|
229
|
+
if (e?.code === 'EACCES' || e?.code === 'EPERM') {
|
|
230
|
+
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.`)
|
|
231
|
+
}
|
|
232
|
+
if (e?.code === 'ENOENT') return new Error(`${file} is missing`)
|
|
233
|
+
}
|
|
234
|
+
return new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** How long a held `serve.json.lock` is waited on before it counts as a crashed writer's (`changeServeState`) */
|
|
238
|
+
export const STATE_LOCK_STALE_MS = 10_000
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* How long before `STATE_LOCK_STALE_MS` a writer stops trying to write (#1405 review): the time it leaves itself
|
|
242
|
+
* for the rename, so that a rename it starts lands before any waiter may count its lock as stale
|
|
243
|
+
*/
|
|
244
|
+
export const STATE_LOCK_MARGIN_MS = 2_000
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Reads the state, changes it with `change` and writes it back, under a lock (`serve.json.lock`) so two changes at once
|
|
248
|
+
* (a hub pairing while a window starts) do not lose one another. Returns the state written.
|
|
249
|
+
*
|
|
250
|
+
* - A lock whose time is more than `STATE_LOCK_STALE_MS` from now, either way, is a crashed writer's and is taken over.
|
|
251
|
+
* Either way (#1405 review): a lock dated ahead of now (a crashed writer, then the clock set back) blocked every
|
|
252
|
+
* `--pair` and `--revoke` for as long as it was ahead, so a hub could not link.
|
|
253
|
+
* - The lock holds a nonce of its writer's, and the writer reads it back before each try of the rename that replaces
|
|
254
|
+
* the file: a writer stalled past the 10 s, whose lock another took over meanwhile, writes nothing (#1405 review),
|
|
255
|
+
* rather than a state it computed before a `--revoke` that came in between. It throws, and whoever asked tries again
|
|
256
|
+
* (a hub at its next link attempt, a person by running it again).
|
|
257
|
+
* - A writer also writes only within `STATE_LOCK_STALE_MS - STATE_LOCK_MARGIN_MS` of taking its lock, by this
|
|
258
|
+
* process's own clock, which a clock set back does not stretch (#1405 review): on Windows a rename is tried again
|
|
259
|
+
* for up to 5 s while a reader holds the file, and a try seconds after the read-back could land after another writer
|
|
260
|
+
* took the lock over and wrote. Its last try is then at least 2 s before any waiter may take the lock over.
|
|
261
|
+
* - Taking over a stale lock, and removing its own at the end, first move the lock aside under a name of this
|
|
262
|
+
* writer's and look at what was moved: a fresh lock, or another writer's, is put back (`releaseLock`). Before, two
|
|
263
|
+
* waiters taking over one stale lock at once could remove the other's fresh lock, and a writer's end could remove the
|
|
264
|
+
* lock of one that took it over between its read-back and its removal.
|
|
265
|
+
*
|
|
266
|
+
* What is left: a writer suspended for over 2 s between its last read-back and the rename right after it; a
|
|
267
|
+
* system clock set forward by more than 10 s while a writer holds the lock, which makes its lock look stale to a
|
|
268
|
+
* waiter; and the moment a lock is moved aside to be looked at, during which a third writer may create one and read
|
|
269
|
+
* the state while the lock's owner, past its read-back, renames (on a file system without hard links the moved lock is
|
|
270
|
+
* not put back at all, and its owner's next read-back refuses). No lock file can close these.
|
|
271
|
+
*/
|
|
272
|
+
export function changeServeState(dataDir, change, platform = process.platform, { sleep = sleepSync, nowMs = Date.now, clock = () => performance.now(), read = readFileSync } = {}) {
|
|
159
273
|
const file = join(dataDir, SERVE_STATE_FILE)
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
274
|
+
const lock = `${file}.lock`
|
|
275
|
+
const mine = randomBytes(8).toString('hex')
|
|
276
|
+
for (let i = 0; ; i++) {
|
|
277
|
+
try {
|
|
278
|
+
const fd = openSync(lock, 'wx', 0o600)
|
|
279
|
+
try {
|
|
280
|
+
writeSync(fd, mine)
|
|
281
|
+
} finally {
|
|
282
|
+
closeSync(fd)
|
|
283
|
+
}
|
|
284
|
+
break
|
|
285
|
+
} catch (e) {
|
|
286
|
+
if (e?.code !== 'EEXIST') throw e
|
|
287
|
+
try {
|
|
288
|
+
if (Math.abs(nowMs() - statSync(lock).mtimeMs) > STATE_LOCK_STALE_MS) takeOverStale(lock, mine, nowMs)
|
|
289
|
+
} catch {
|
|
290
|
+
// gone meanwhile: try again
|
|
291
|
+
}
|
|
292
|
+
// 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
|
|
293
|
+
if (i >= 480) throw new Error(`${lock} is held by another serve; try again`)
|
|
294
|
+
sleep(25)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const took = clock()
|
|
298
|
+
const held = () => {
|
|
299
|
+
try {
|
|
300
|
+
return read(lock, 'utf8') === mine
|
|
301
|
+
} catch {
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Whether this writer may still write: within its time, and the lock still its own
|
|
306
|
+
const mayWrite = () => {
|
|
307
|
+
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`)
|
|
308
|
+
if (!held()) return new Error(`${lock} was taken over while this serve held it; nothing was written, try again`)
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const before = readServeState(dataDir)
|
|
313
|
+
if (!before) throw noState(file)
|
|
314
|
+
const after = change(before)
|
|
315
|
+
if (after !== before) {
|
|
316
|
+
const refusal = mayWrite()
|
|
317
|
+
if (refusal) throw refusal
|
|
318
|
+
replacePrivate(file, stateFile(after), platform, { mayTry: mayWrite })
|
|
319
|
+
}
|
|
320
|
+
// What was written, as a reader reads it: never null once written
|
|
321
|
+
return after !== before ? parseServeState(JSON.stringify(stateFile(after))) : before
|
|
322
|
+
} finally {
|
|
323
|
+
// Only its own: a lock another writer took over is that writer's to keep
|
|
324
|
+
releaseLock(lock, mine, { read })
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Removes `lock` if it is `mine` (#1405 review): moved aside under a name of this writer's first, so what is looked at
|
|
330
|
+
* is what is removed, and put back when it was another writer's. Reading it and then removing it by name could remove
|
|
331
|
+
* a lock another writer took over in between. Never throws: a lock left behind goes stale and is taken over.
|
|
332
|
+
*/
|
|
333
|
+
export function releaseLock(lock, mine, { read = readFileSync, rename = renameSync, link = linkSync, rm = rmSync } = {}) {
|
|
334
|
+
const aside = `${lock}.${mine}.release`
|
|
335
|
+
try {
|
|
336
|
+
renameRetrying(lock, aside, { rename, budgetMs: 1_000 })
|
|
337
|
+
} catch {
|
|
338
|
+
// not there (taken over and removed), or held open past the retries: nothing of this writer's to remove now
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
let found = null
|
|
342
|
+
try {
|
|
343
|
+
found = read(aside, 'utf8')
|
|
344
|
+
} catch {
|
|
345
|
+
// 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
|
|
346
|
+
}
|
|
347
|
+
if (found !== mine) putBack(aside, lock, link)
|
|
348
|
+
try {
|
|
349
|
+
rm(aside, { force: true })
|
|
350
|
+
} catch {
|
|
351
|
+
// a stray file beside the lock, nothing a writer reads
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Takes over a lock that looked stale (`changeServeState`): moved aside first, and put back when what was moved is not
|
|
357
|
+
* stale after all (another waiter's fresh lock, taken over a moment before). The caller then creates its own
|
|
358
|
+
*/
|
|
359
|
+
function takeOverStale(lock, mine, nowMs) {
|
|
360
|
+
const aside = `${lock}.${mine}.stale`
|
|
361
|
+
renameSync(lock, aside)
|
|
362
|
+
let stale = true
|
|
363
|
+
try {
|
|
364
|
+
stale = Math.abs(nowMs() - statSync(aside).mtimeMs) > STATE_LOCK_STALE_MS
|
|
365
|
+
} catch {
|
|
366
|
+
// gone: nothing to put back
|
|
367
|
+
}
|
|
368
|
+
if (!stale) putBack(aside, lock, linkSync)
|
|
369
|
+
rmSync(aside, { force: true })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Puts a moved lock back where it was, unless another writer created one there since (a hard link never replaces) */
|
|
373
|
+
function putBack(aside, lock, link) {
|
|
374
|
+
try {
|
|
375
|
+
link(aside, lock)
|
|
376
|
+
} catch {
|
|
377
|
+
// another holds the lock by now, or links are not possible here: that writer's read-back sees it is not its own
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* How a full credential was asked for, kept on its entry as `via` (#1405 review): `pair` (`--connection --pair`, a
|
|
383
|
+
* hub's at each link or any program's), `window` (`--window`, the person's window on Windows at each start), `handover`
|
|
384
|
+
* (the launcher's own, for one `host.handover` call and withdrawn right after)
|
|
385
|
+
*/
|
|
386
|
+
export const PAIRED_VIA = ['pair', 'window', 'handover']
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A new full credential for `label` (#1405): written as its verifier only, and returned once. One label holds one
|
|
390
|
+
* credential: a hub pairing again (each link does) replaces its own, and `window` the window's. The `legacy` one a
|
|
391
|
+
* 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.
|
|
392
|
+
* `via` is how it was asked for (`PAIRED_VIA`). Returns `{ token, id, state }`.
|
|
393
|
+
*
|
|
394
|
+
* `replaced` (#1405 review): set on the new entry only when the one of its label it takes the place of was asked for
|
|
395
|
+
* another way, or the file does not say how. The window pairing again at each start, and a hub at each link, are the
|
|
396
|
+
* same pairer as before and are not marked: marked at every routine pairing, the flag meant nothing. A `--pair window`
|
|
397
|
+
* taking the window's place, or the window's next start taking that one's, is marked. What this cannot tell is two
|
|
398
|
+
* askers of the same way: a program's `--pair hub:<name>-<key>` against that hub's own. The hub's row tells that one
|
|
399
|
+
* by itself (`hubsName`, and its own credential gone), docs/security-boundaries.md.
|
|
400
|
+
*/
|
|
401
|
+
export function pairServe(dataDir, label, platform = process.platform, now = Date.now(), via = 'pair') {
|
|
402
|
+
ensureServeState(dataDir, platform, now)
|
|
403
|
+
const { token, id } = mint()
|
|
404
|
+
const state = changeServeState(dataDir, (s) => ({
|
|
405
|
+
...s,
|
|
406
|
+
/*
|
|
407
|
+
* A hub's or the window's pairing removes the `legacy` one (#1405 review): whoever held it was a hub or the window
|
|
408
|
+
* of before #1405, each of which pairs now, so on a machine no hub links to it does not outlive the window's next
|
|
409
|
+
* start. Any other label leaves it: a program's own `--pair` does not cut off a hub that has not paired yet
|
|
410
|
+
*/
|
|
411
|
+
full: [
|
|
412
|
+
...s.full.filter((e) => e.label !== label && !(takesLegacyPlace(label) && e.label === LEGACY_LABEL)),
|
|
413
|
+
{ id, label, verifier: verifierOf(token), created: now, via, ...(s.full.some((e) => e.label === label && e.via !== via) ? { replaced: true } : {}) },
|
|
414
|
+
],
|
|
415
|
+
}), platform)
|
|
416
|
+
return { token, id, state }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The pairings that stand for whoever held a version 1 file's token: a hub's and the window's (`pairServe`) */
|
|
420
|
+
function takesLegacyPlace(label) {
|
|
421
|
+
return label === 'window' || label.startsWith('hub:')
|
|
422
|
+
}
|
|
423
|
+
|
|
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
|
|
430
|
+
}, platform)
|
|
431
|
+
return found
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
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.
|
|
437
|
+
*/
|
|
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
|
|
443
|
+
}
|
|
444
|
+
|
|
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)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Writes the port `serve` listened on, keeping the rest. Atomic: a temp file renamed over it */
|
|
451
|
+
export function recordServePort(dataDir, port, platform = process.platform) {
|
|
452
|
+
changeServeState(dataDir, (s) => (s.port === port ? s : { ...s, port }), platform)
|
|
164
453
|
}
|
|
165
454
|
|
|
166
455
|
/**
|
|
167
|
-
* A new
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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.
|
|
170
459
|
*/
|
|
171
460
|
export function rotateServeToken(dataDir, platform = process.platform) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return readServeState(dataDir)
|
|
461
|
+
ensureServeState(dataDir, platform)
|
|
462
|
+
return changeServeState(dataDir, (s) => ({ ...s, key: randomBytes(32).toString('base64url'), full: [], legacyHostToken: null }), platform)
|
|
175
463
|
}
|
|
176
464
|
|
|
177
465
|
/**
|
|
@@ -187,9 +475,9 @@ function writePrivate(file, value, flag) {
|
|
|
187
475
|
}
|
|
188
476
|
}
|
|
189
477
|
|
|
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' })
|
|
478
|
+
/** Replaces a private file whole (`replaceFile`), created 0600 as `writePrivate` creates one; `rename` goes to `renameRetrying` */
|
|
479
|
+
function replacePrivate(file, value, platform, rename = {}) {
|
|
480
|
+
replaceFile(file, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx', ...rename })
|
|
193
481
|
keepPrivate(file, platform)
|
|
194
482
|
}
|
|
195
483
|
|
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, lockHolderPid, readServePid } from './serve-files.mjs'
|
|
6
|
+
import { readServeState, heldHere, lockHolderPid, readServePid, pairServe, revokeServe } from './serve-files.mjs'
|
|
7
7
|
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
8
|
-
import { protocolVersionOf, hostProvesOf, probeHost, askHostToStop, askHostRpc } from './serve-host.mjs'
|
|
8
|
+
import { protocolVersionOf, hostProvesOf, probeHost, askHostToStop, askHostRpc, ownCredentials, takesVerifiers } 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
|
|
|
@@ -23,15 +23,19 @@ import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
|
|
|
23
23
|
* with this folder's token: a stale pid is never signalled.
|
|
24
24
|
*
|
|
25
25
|
* `{ ok, wasRunning, how?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
|
|
26
|
-
* when nothing runs there, and `{ ok: false, busy: true }` says it did not.
|
|
26
|
+
* when nothing runs there, and `{ ok: false, busy: true }` says it did not. `forUpdate` (#1618): a hub's update stops it, and
|
|
27
|
+
* the host writes the turns it ends for the next one (`host.stop { forUpdate }`); a host that does not take the request is
|
|
28
|
+
* ended as any is, and lists nothing.
|
|
27
29
|
*/
|
|
28
|
-
export async function stopServedHost({ dataDir, entry, platform = process.platform, ifIdle = false, timeoutMs = 30_000, graceMs = 15_000, kill = (pid, sig) => process.kill(pid, sig) }) {
|
|
30
|
+
export async function stopServedHost({ dataDir, entry, platform = process.platform, ifIdle = false, forUpdate = false, timeoutMs = 30_000, graceMs = 15_000, kill = (pid, sig) => process.kill(pid, sig) }) {
|
|
29
31
|
const state = readServeState(dataDir)
|
|
30
32
|
if (!state) return { ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` }
|
|
31
33
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
32
34
|
const installed = protocolVersionOf(entry) ?? 0
|
|
33
35
|
const prove = hostProvesOf(entry)
|
|
34
|
-
|
|
36
|
+
// The limited credential is enough to probe and to stop it (#1405, `host.stop` is everyday)
|
|
37
|
+
const own = ownCredentials(state, entry)
|
|
38
|
+
const probe = () => probeHost({ port, ...own, protocolVersion: installed, prove })
|
|
35
39
|
const first = await probe()
|
|
36
40
|
if (!heldHere(first, dataDir)) return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` }
|
|
37
41
|
|
|
@@ -41,8 +45,8 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
|
|
|
41
45
|
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
42
46
|
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
43
47
|
|
|
44
|
-
let asked = await askHostToStop({ port,
|
|
45
|
-
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port,
|
|
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 })
|
|
46
50
|
// `ifIdle` and it has work running (#1224): it was left as it is
|
|
47
51
|
if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
|
|
48
52
|
let how = 'asked'
|
|
@@ -124,13 +128,26 @@ export async function moveIntoSignIn({
|
|
|
124
128
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
125
129
|
const prove = hostProvesOf(entry)
|
|
126
130
|
const protocolVersion = existing.protocolVersion ?? protocolVersionOf(entry) ?? 0
|
|
127
|
-
const
|
|
131
|
+
const own = ownCredentials(state, entry)
|
|
132
|
+
/*
|
|
133
|
+
* `host.handover` is a hub's call (#1405, `link`): said with a full credential made for this one call and withdrawn
|
|
134
|
+
* right after, never kept. An older installed host knows only the token kept for it
|
|
135
|
+
*/
|
|
136
|
+
const sayHandover = async (h) => {
|
|
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
|
+
}
|
|
144
|
+
const report = (handover) => (tell ?? sayHandover)(handover)
|
|
128
145
|
const pid = lockHolderPid(dataDir)
|
|
129
146
|
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`)
|
|
130
147
|
const t0 = now()
|
|
131
148
|
let waiting = false
|
|
132
149
|
for (;;) {
|
|
133
|
-
if (!(await probeHost({ port,
|
|
150
|
+
if (!(await probeHost({ port, ...own, protocolVersion, prove })).running) return true
|
|
134
151
|
const r = await stop()
|
|
135
152
|
if (r.ok) {
|
|
136
153
|
say(`stopped it${r.how && r.how !== 'asked' ? ` (${r.how})` : ''}; starting the host in this sign-in`)
|
package/bin/serve-host.mjs
CHANGED
|
@@ -34,17 +34,27 @@ export function protocolVersionOf(entry) {
|
|
|
34
34
|
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
35
35
|
* is added by `--detach` only.
|
|
36
36
|
*/
|
|
37
|
-
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof, stopped }) {
|
|
37
|
+
export function connectionLine({ port, token, credential, limited, others, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof, stopped }) {
|
|
38
38
|
/*
|
|
39
39
|
* The options this serve takes that an older one refuses (#1223): a hub adds `--from <its name>` to `--detach` only
|
|
40
40
|
* when the line says so, since an older serve answers an option it does not know with an error. `window`: the
|
|
41
41
|
* desktop window on Windows uses this serve's host through `--window` only when it is listed (#1223 review)
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
/*
|
|
44
|
+
* `pair` (#1405): `--connection --pair <label>` hands out a full credential of its own; without it the line carries the
|
|
45
|
+
* limited credential only
|
|
46
|
+
*/
|
|
47
|
+
const takes = ['from', 'window', 'pair']
|
|
44
48
|
return JSON.stringify({
|
|
45
49
|
v: 1,
|
|
46
50
|
port,
|
|
47
|
-
token
|
|
51
|
+
// A full credential only to `--pair` (#1405), with its id for the proof; an older serve always printed its token
|
|
52
|
+
...(token ? { token } : {}),
|
|
53
|
+
...(credential ? { credential } : {}),
|
|
54
|
+
// What a process of this user gets without pairing (#1405): everyday calls only
|
|
55
|
+
...(limited ? { limited } : {}),
|
|
56
|
+
// The full credentials this folder holds besides the one just paired, as `{ id, label, created }` (#1405)
|
|
57
|
+
...(others ? { others } : {}),
|
|
48
58
|
version,
|
|
49
59
|
protocolVersion,
|
|
50
60
|
dataDir,
|
|
@@ -113,9 +123,14 @@ function parseStartedFrom(raw) {
|
|
|
113
123
|
* `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
|
|
114
124
|
* - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
|
|
115
125
|
*/
|
|
116
|
-
export function hostEnv(env, { dataDir, version, hostDir, legacyToken, from = null }) {
|
|
126
|
+
export function hostEnv(env, { dataDir, version, hostDir, legacyToken, from = null, serveState = null }) {
|
|
117
127
|
const out = { ...env }
|
|
118
|
-
for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_TOKEN_ON_STDIN', 'CC_HOST_SOURCE', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM', 'CC_SERVE_FROM', 'CC_STARTED_BY']) delete out[k]
|
|
128
|
+
for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_TOKEN_ON_STDIN', 'CC_HOST_SOURCE', 'CC_SERVE_STATE', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM', 'CC_SERVE_FROM', 'CC_STARTED_BY']) delete out[k]
|
|
129
|
+
/*
|
|
130
|
+
* A host that checks full credentials against serve.json's verifiers (#1405, `takesVerifiers`): the file it reads at
|
|
131
|
+
* each hello. A path, not a secret: the file is the user's own, 0600. Its key comes on stdin (`keyLine`)
|
|
132
|
+
*/
|
|
133
|
+
if (serveState) out.CC_SERVE_STATE = serveState
|
|
119
134
|
// Who asked for this host, for its host.lock (#1223): a second host on the folder names it when refused
|
|
120
135
|
if (from) out.CC_SERVE_FROM = JSON.stringify(from)
|
|
121
136
|
out.CC_DATA_DIR = dataDir
|
|
@@ -146,6 +161,37 @@ export function tokenLine(token) {
|
|
|
146
161
|
return `${JSON.stringify({ token })}\n`
|
|
147
162
|
}
|
|
148
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The first line on the stdin of a host that takes verifiers (#1405): its key, from which it derives what it hands out.
|
|
166
|
+
* Not a full credential: the host never takes it as one (`CC_SERVE_STATE` says where the full ones' verifiers are)
|
|
167
|
+
*/
|
|
168
|
+
export function keyLine(key) {
|
|
169
|
+
return `${JSON.stringify({ key })}\n`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether the host at `entry` checks full credentials against serve.json's verifiers (#1405): a bundle says so in
|
|
174
|
+
* `bundle-info.json` (`verifiers`), a host run from source has `lifecycle/serve-credentials.ts`. An older one knows a
|
|
175
|
+
* single token, which this launcher then keeps for it (`legacyHostTokenOf`).
|
|
176
|
+
*/
|
|
177
|
+
export function takesVerifiers(entry) {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).verifiers === 1
|
|
180
|
+
} catch {
|
|
181
|
+
// not a bundle; try the source below
|
|
182
|
+
}
|
|
183
|
+
return existsSync(join(dirname(entry), 'lifecycle', 'serve-credentials.ts'))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What this launcher says hello with to its own host, and what that host proves with to it: the limited credential
|
|
188
|
+
* and the key for a host that takes verifiers, the one token an older host knows otherwise (#1405).
|
|
189
|
+
*/
|
|
190
|
+
export function ownCredentials(state, entry) {
|
|
191
|
+
if (takesVerifiers(entry) || !state.legacyHostToken) return { token: state.token, proofKey: state.proofKey }
|
|
192
|
+
return { token: state.legacyHostToken, proofKey: state.legacyHostToken }
|
|
193
|
+
}
|
|
194
|
+
|
|
149
195
|
/**
|
|
150
196
|
* How to start the host.
|
|
151
197
|
*
|
|
@@ -228,7 +274,7 @@ function proofOf(token, nonce) {
|
|
|
228
274
|
* `on.message(m, ws)` gets every frame after the hello; `on.unproven()` runs when the proof did not come, `on.gone()`
|
|
229
275
|
* when the socket failed or closed.
|
|
230
276
|
*/
|
|
231
|
-
function helloSocket({ port, token, protocolVersion, prove, WebSocketImpl, on }) {
|
|
277
|
+
function helloSocket({ port, token, proofKey, protocolVersion, prove, WebSocketImpl, on }) {
|
|
232
278
|
const ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
233
279
|
const nonce = prove ? randomBytes(24).toString('base64url') : null
|
|
234
280
|
// Proving only once the socket is open: a port where nothing listens is gone, not unproven
|
|
@@ -248,7 +294,8 @@ function helloSocket({ port, token, protocolVersion, prove, WebSocketImpl, on })
|
|
|
248
294
|
}
|
|
249
295
|
if (proving) {
|
|
250
296
|
proving = false
|
|
251
|
-
|
|
297
|
+
// A host that takes verifiers proves with its key, which is not the credential this says hello with (#1405)
|
|
298
|
+
if (m?.kind === 'proof' && m.nonce === nonce && m.mac === proofOf(proofKey ?? token, nonce)) return hello()
|
|
252
299
|
return on.unproven()
|
|
253
300
|
}
|
|
254
301
|
if (m) on.message(m, ws)
|
|
@@ -269,10 +316,10 @@ function helloSocket({ port, token, protocolVersion, prove, WebSocketImpl, on })
|
|
|
269
316
|
* (`heldHere` tells those apart by the data folder's lock). Node 22.4+ has a global WebSocket; on an older 22 this falls
|
|
270
317
|
* back to a TCP connect and reports only `running`.
|
|
271
318
|
*/
|
|
272
|
-
export function probeHost({ port, token, protocolVersion, prove = false, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
|
|
319
|
+
export function probeHost({ port, token, proofKey, protocolVersion, prove = false, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
|
|
273
320
|
if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
|
|
274
321
|
return helloExchange({
|
|
275
|
-
port, token, protocolVersion, prove, WebSocketImpl, timeoutMs,
|
|
322
|
+
port, token, proofKey, protocolVersion, prove, WebSocketImpl, timeoutMs,
|
|
276
323
|
timedOut: { running: false },
|
|
277
324
|
failed: () => ({ running: false }),
|
|
278
325
|
on: (done) => ({
|
|
@@ -344,10 +391,10 @@ const HOST_LOGONS = ['interactive', 'network', 'batch', 'service']
|
|
|
344
391
|
* One call to a running host (`method` with `params`), as `askHostToStop` makes it: a hello with the token, then the
|
|
345
392
|
* call. Its result, or null when it did not answer or refused. For `host.handover` (#1224)
|
|
346
393
|
*/
|
|
347
|
-
export function askHostRpc({ port, token, protocolVersion, prove = false, method, params = {}, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
394
|
+
export function askHostRpc({ port, token, proofKey, protocolVersion, prove = false, method, params = {}, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
348
395
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve(null)
|
|
349
396
|
return helloExchange({
|
|
350
|
-
port, token, protocolVersion, prove, WebSocketImpl, timeoutMs,
|
|
397
|
+
port, token, proofKey, protocolVersion, prove, WebSocketImpl, timeoutMs,
|
|
351
398
|
timedOut: null,
|
|
352
399
|
failed: () => null,
|
|
353
400
|
on: (done) => ({
|
|
@@ -367,20 +414,20 @@ export function askHostRpc({ port, token, protocolVersion, prove = false, method
|
|
|
367
414
|
* `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
|
|
368
415
|
* last names the host's protocol when the hello was refused for it, so the caller can ask again.
|
|
369
416
|
* `ifIdle` (#1224): only if nothing runs there, checked and acted on by the host in one step; `{ ok: false, busy: true }`
|
|
370
|
-
* when something does.
|
|
417
|
+
* when something does. `forUpdate` (#1618): a hub's update stops it, and the host lists the turns it ends for the next one.
|
|
371
418
|
*/
|
|
372
|
-
export function askHostToStop({ port, token, protocolVersion, prove = false, ifIdle = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
419
|
+
export function askHostToStop({ port, token, proofKey, protocolVersion, prove = false, ifIdle = false, forUpdate = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
373
420
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
|
|
374
421
|
// With `prove`, the token goes out only to a host that proved it holds it (#838)
|
|
375
422
|
return helloExchange({
|
|
376
|
-
port, token, protocolVersion, prove, WebSocketImpl, timeoutMs,
|
|
423
|
+
port, token, proofKey, protocolVersion, prove, WebSocketImpl, timeoutMs,
|
|
377
424
|
timedOut: { ok: false, message: 'no answer in time' },
|
|
378
425
|
failed: (e) => ({ ok: false, message: e.message }),
|
|
379
426
|
on: (done) => ({
|
|
380
427
|
unproven: () => done({ ok: false, message: 'what answers on the port did not prove it holds the token' }),
|
|
381
428
|
gone: () => done({ ok: false, message: 'the connection closed' }),
|
|
382
429
|
message: (m, ws) => {
|
|
383
|
-
if (m.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: ifIdle ? { ifIdle: true } : {} }))
|
|
430
|
+
if (m.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: { ...(ifIdle ? { ifIdle: true } : {}), ...(forUpdate ? { forUpdate: true } : {}) } }))
|
|
384
431
|
else if (m.kind === 'res' && m.id === 'stop' && m.ok && m.result?.ok === false) {
|
|
385
432
|
// `ifIdle` and something runs there (#1224): nothing was changed
|
|
386
433
|
done({ ok: false, busy: true, message: 'it has work running', activity: m.result.busy ?? null, calls: m.result.calls ?? 0 })
|
package/bin/serve-run.mjs
CHANGED
|
@@ -4,10 +4,10 @@ import { writeFileSync, writeSync } from 'node:fs'
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { createInterface } from 'node:readline'
|
|
6
6
|
import { isAlive, say } from './serve-util.mjs'
|
|
7
|
-
import { SERVE_STATE_FILE, SERVE_PID_FILE, readStopped, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, removeServePid } from './serve-files.mjs'
|
|
7
|
+
import { SERVE_STATE_FILE, SERVE_PID_FILE, readStopped, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, removeServePid, pairServe, revokeServe, legacyHostTokenOf, dropLegacyHostToken } from './serve-files.mjs'
|
|
8
8
|
import { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, windowsListening } from './serve-port.mjs'
|
|
9
9
|
import { keepLauncher, installInfo } from './serve-install.mjs'
|
|
10
|
-
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
10
|
+
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, serveFrom, takesTokenOnStdin, tokenLine, keyLine, takesVerifiers, ownCredentials } from './serve-host.mjs'
|
|
11
11
|
import { autostartInfo } from './serve-autostart.mjs'
|
|
12
12
|
import { moveIntoSignIn } from './serve-handover.mjs'
|
|
13
13
|
|
|
@@ -22,22 +22,34 @@ export function serveFolder({ env, home, cliPath, platform = process.platform })
|
|
|
22
22
|
return { dataDir, state }
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* What `--connection` answers, as fields: the running host's versions when one answers, else the installed ones.
|
|
27
|
+
*
|
|
28
|
+
* The credentials (#1405): `paired` (`--pair`, `{ token, id }`) is a full credential just made for whoever asked, and
|
|
29
|
+
* the only one this ever prints; without it the line carries the limited credential, which reads and does the everyday
|
|
30
|
+
* things. An installed host from before verifiers knows one token: `--pair` hands out that one (`legacyHostTokenOf`).
|
|
31
|
+
* `others` names the full credentials the folder holds besides the one just paired, never their values, each marked
|
|
32
|
+
* `replaced` when its pairing took the place of an earlier one of its label.
|
|
33
|
+
*/
|
|
34
|
+
export async function describe({ env, dataDir, state, entry, version, paired = null }) {
|
|
27
35
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
28
36
|
const installed = protocolVersionOf(entry)
|
|
29
37
|
// Whether the installed host proves it holds the token (#838): asked of the running one before its hello, and said in the line
|
|
30
38
|
const proof = hostProvesOf(entry)
|
|
31
|
-
const
|
|
39
|
+
const own = ownCredentials(state, entry)
|
|
40
|
+
const probe = await probeHost({ port, ...own, protocolVersion: installed, prove: proof })
|
|
32
41
|
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
33
42
|
if (protocolVersion === null) return null
|
|
34
43
|
const hostRunning = heldHere(probe, dataDir)
|
|
35
44
|
// The person stopped it here (#1143): said while no host runs, so a hub starts none by itself
|
|
36
45
|
const stopped = hostRunning ? null : readStopped(dataDir)
|
|
46
|
+
const verifiers = takesVerifiers(entry)
|
|
37
47
|
return {
|
|
38
48
|
proof,
|
|
39
49
|
port,
|
|
40
|
-
token:
|
|
50
|
+
...(paired ? { token: paired.token, ...(paired.id ? { credential: paired.id } : {}) } : {}),
|
|
51
|
+
...(verifiers ? { limited: state.token } : {}),
|
|
52
|
+
others: state.full.filter((e) => e.id !== paired?.id).map(({ id, label, created, replaced }) => ({ id, label, created, ...(replaced ? { replaced: true } : {}) })),
|
|
41
53
|
version: (probe.running ? probe.version : undefined) ?? version,
|
|
42
54
|
protocolVersion,
|
|
43
55
|
dataDir,
|
|
@@ -52,9 +64,23 @@ export async function describe({ env, dataDir, state, entry, version }) {
|
|
|
52
64
|
* `centralu serve --connection`. stdout carries exactly one line, the JSON; everything else goes to
|
|
53
65
|
* stderr. Exit 1 when there is no host to describe.
|
|
54
66
|
*/
|
|
55
|
-
export async function printConnection({ env, home, entry, version, cliPath }) {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
67
|
+
export async function printConnection({ env, home, entry, version, cliPath, pair = null }) {
|
|
68
|
+
const folder = serveFolder({ env, home, cliPath })
|
|
69
|
+
const { dataDir } = folder
|
|
70
|
+
let { state } = folder
|
|
71
|
+
// `--pair <label>` (#1405): a full credential for whoever asked, printed once and written down as its verifier only
|
|
72
|
+
let paired = null
|
|
73
|
+
if (pair !== null) {
|
|
74
|
+
if (takesVerifiers(entry)) {
|
|
75
|
+
const made = pairServe(dataDir, pair)
|
|
76
|
+
state = made.state
|
|
77
|
+
paired = { token: made.token, id: made.id }
|
|
78
|
+
say(`made a full credential for ${JSON.stringify(pair)} (${made.id}); serve --revoke ${made.id} withdraws it`)
|
|
79
|
+
} else {
|
|
80
|
+
paired = { token: legacyHostTokenOf(dataDir), id: null }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const d = await describe({ env, dataDir, state, entry, version, paired })
|
|
58
84
|
if (!d) {
|
|
59
85
|
say(`cannot read the protocol version of the host at ${entry}`)
|
|
60
86
|
return 1
|
|
@@ -82,7 +108,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
82
108
|
// A serve that is already up answers on the port it recorded; say so before starting a host that
|
|
83
109
|
// would only fail on the data folder's lock with a less specific message.
|
|
84
110
|
const recorded = state.port ?? DEFAULT_SERVE_PORT
|
|
85
|
-
const existing = await probeHost({ port: recorded,
|
|
111
|
+
const existing = await probeHost({ port: recorded, ...ownCredentials(state, entry), protocolVersion, prove: hostProvesOf(entry) })
|
|
86
112
|
// A host the hub's ssh started before anyone signed in moves into this sign-in, once idle (#1224, serve-handover.mjs)
|
|
87
113
|
if (heldHere(existing, dataDir) && !(await handOver({ dataDir, entry, env, existing, platform }))) {
|
|
88
114
|
const pid = lockHolderPid(dataDir)
|
|
@@ -104,14 +130,28 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
104
130
|
|
|
105
131
|
const plan = hostCommand(execPath, entry, { port, dataDir, platform })
|
|
106
132
|
const onStdin = takesTokenOnStdin(entry)
|
|
133
|
+
/*
|
|
134
|
+
* A host that takes verifiers (#1405) gets the key on stdin and the path of serve.json, whose full credentials it
|
|
135
|
+
* checks at each hello; nothing it gets opens it whole. An older host gets the one token it knows, kept for it
|
|
136
|
+
*/
|
|
137
|
+
const verifiers = takesVerifiers(entry)
|
|
138
|
+
if (verifiers) dropLegacyHostToken(dataDir, platform)
|
|
139
|
+
const legacy = verifiers ? null : legacyHostTokenOf(dataDir, platform)
|
|
107
140
|
const child = spawn(plan.command, plan.args, {
|
|
108
141
|
stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
|
|
109
|
-
env: hostEnv(env, {
|
|
142
|
+
env: hostEnv(env, {
|
|
143
|
+
dataDir,
|
|
144
|
+
version,
|
|
145
|
+
hostDir: dirname(entry),
|
|
146
|
+
from: serveFrom(env, from),
|
|
147
|
+
...(verifiers ? { serveState: join(dataDir, SERVE_STATE_FILE) } : {}),
|
|
148
|
+
...(onStdin ? {} : { legacyToken: legacy ?? state.token }),
|
|
149
|
+
}),
|
|
110
150
|
detached: plan.detached,
|
|
111
151
|
windowsHide: true,
|
|
112
152
|
})
|
|
113
|
-
//
|
|
114
|
-
if (onStdin) child.stdin.write(
|
|
153
|
+
// What the host holds, before anything else on the pipe it watches (#538); stdin stays open for --watch-parent
|
|
154
|
+
if (onStdin) child.stdin.write(verifiers ? keyLine(state.key) : tokenLine(legacy))
|
|
115
155
|
|
|
116
156
|
let ready = false
|
|
117
157
|
if (logFd !== null) {
|
|
@@ -187,11 +227,26 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
187
227
|
})
|
|
188
228
|
}
|
|
189
229
|
|
|
190
|
-
/** `centralu serve --rotate-token
|
|
230
|
+
/** `centralu serve --rotate-token`: a new key, and every full credential withdrawn (#1405) */
|
|
191
231
|
export function rotateToken({ env, home }) {
|
|
192
232
|
const dataDir = serveDataDir(env, home)
|
|
193
233
|
rotateServeToken(dataDir)
|
|
194
|
-
say(`replaced the
|
|
195
|
-
say('A serve that is running keeps
|
|
234
|
+
say(`replaced the key and withdrew every full credential in ${join(dataDir, SERVE_STATE_FILE)}.`)
|
|
235
|
+
say('A linked computer pairs again at its next connection, and a window at its next start. A serve that is running keeps its old key until it restarts: the limited credential --connection prints works once it has.')
|
|
236
|
+
return 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `centralu serve --revoke <id>`: one full credential withdrawn (#1405); its next hello is refused, and the host closes a socket already in with it */
|
|
240
|
+
export function revokeCredential({ env, home, id, write = (line) => process.stdout.write(line) }) {
|
|
241
|
+
const dataDir = serveDataDir(env, home)
|
|
242
|
+
const revoked = revokeServe(dataDir, id)
|
|
243
|
+
// One JSON line on stdout, as every serve command a hub runs prints one: whether there was one to withdraw
|
|
244
|
+
write(`${JSON.stringify({ v: 1, revoked, id })}
|
|
245
|
+
`)
|
|
246
|
+
if (!revoked) {
|
|
247
|
+
say(`no full credential ${JSON.stringify(id)} in ${join(dataDir, SERVE_STATE_FILE)}; --connection lists them under others.`)
|
|
248
|
+
return 1
|
|
249
|
+
}
|
|
250
|
+
say(`withdrew the full credential ${id}. A running host refuses its next hello and closes a connection already open with it within a second.`)
|
|
196
251
|
return 0
|
|
197
252
|
}
|
package/bin/serve-util.mjs
CHANGED
|
@@ -44,10 +44,16 @@ const BUSY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
|
44
44
|
* `rename`, tried again for up to `budgetMs` while the system refuses it because someone has the target open (#1041;
|
|
45
45
|
* `retry`: Windows only, `renameWaitsForReaders`). With `retry` off, and for any other error, it is one try. Backs off 10, 20, 40 ... 200 ms between tries; the last
|
|
46
46
|
* error is thrown when the budget is spent.
|
|
47
|
+
*
|
|
48
|
+
* `mayTry`, when given, is asked before every try, the first too, and a false answer ends it with its error (#1405
|
|
49
|
+
* review): a writer under a lock checks there that the lock is still its own, so a retry seconds later cannot land a
|
|
50
|
+
* write after another writer took the lock over.
|
|
47
51
|
*/
|
|
48
|
-
export function renameRetrying(from, to, { retry = renameWaitsForReaders(), rename = renameSync, sleep = sleepSync, budgetMs = 5_000, now = Date.now } = {}) {
|
|
52
|
+
export function renameRetrying(from, to, { retry = renameWaitsForReaders(), rename = renameSync, sleep = sleepSync, budgetMs = 5_000, now = Date.now, mayTry = null } = {}) {
|
|
49
53
|
const t0 = now()
|
|
50
54
|
for (let delay = 10; ; delay = Math.min(delay * 2, 200)) {
|
|
55
|
+
const refusal = mayTry?.()
|
|
56
|
+
if (refusal) throw refusal
|
|
51
57
|
try {
|
|
52
58
|
return rename(from, to)
|
|
53
59
|
} catch (e) {
|
package/bin/serve-window.mjs
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* lock refuses.
|
|
6
6
|
*/
|
|
7
7
|
import { join } from 'node:path'
|
|
8
|
-
import { readServeState } from './serve-files.mjs'
|
|
8
|
+
import { legacyHostTokenOf, pairServe, readServeState } from './serve-files.mjs'
|
|
9
9
|
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
10
|
-
import { hostProvesOf, probeHost, protocolVersionOf } from './serve-host.mjs'
|
|
10
|
+
import { hostProvesOf, ownCredentials, probeHost, protocolVersionOf, takesVerifiers } from './serve-host.mjs'
|
|
11
11
|
import { serveFolder } from './serve-run.mjs'
|
|
12
12
|
import { runDetach } from './serve-detach.mjs'
|
|
13
13
|
import { startInSignIn } from './serve-autostart.mjs'
|
|
@@ -15,7 +15,10 @@ import { say, sleep } from './serve-util.mjs'
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Makes sure the folder's serve host runs, then prints the window's ready line, `{"ready":true,"port","token","db"}`
|
|
18
|
-
* (the line a host the window starts prints), and stays until the window goes.
|
|
18
|
+
* (the line a host the window starts prints), and stays until the window goes. The token is a full credential made for
|
|
19
|
+
* the window at each start (#1405, `--pair window`'s, replacing the last window's): serve.json keeps only its verifier.
|
|
20
|
+
* It runs the same on Linux and macOS, though only the Windows app runs it. Residual: any process of this user, on any
|
|
21
|
+
* OS, can run `--window` too, until it checks its parent (#1417).
|
|
19
22
|
*
|
|
20
23
|
* - No host answers: one is started the way `--detach` starts it, in this Windows sign-in through the scheduled task,
|
|
21
24
|
* so it outlives the window and the hub's link finds it (`serve --connection`).
|
|
@@ -48,7 +51,7 @@ export async function runWindow({
|
|
|
48
51
|
const protocolVersion = protocolVersionOf(entry) ?? 0
|
|
49
52
|
const probe = () => {
|
|
50
53
|
const state = readServeState(dataDir)
|
|
51
|
-
return state ? probeHost({ port: state.port ?? DEFAULT_SERVE_PORT,
|
|
54
|
+
return state ? probeHost({ port: state.port ?? DEFAULT_SERVE_PORT, ...ownCredentials(state, entry), protocolVersion, prove }) : Promise.resolve({ running: false })
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
let host = await probe()
|
|
@@ -75,8 +78,10 @@ export async function runWindow({
|
|
|
75
78
|
)
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
// The window's own full credential (#1405); an older installed host knows only the token kept for it
|
|
82
|
+
const token = takesVerifiers(entry) ? pairServe(dataDir, 'window', platform, Date.now(), 'window').token : legacyHostTokenOf(dataDir, platform)
|
|
78
83
|
const state = readServeState(dataDir)
|
|
79
|
-
write(`${JSON.stringify({ ready: true, port: state.port ?? DEFAULT_SERVE_PORT, token
|
|
84
|
+
write(`${JSON.stringify({ ready: true, port: state.port ?? DEFAULT_SERVE_PORT, token, db: join(dataDir, 'store.db') })}\n`)
|
|
80
85
|
|
|
81
86
|
return await new Promise((resolve) => {
|
|
82
87
|
let done = false
|
package/bin/serve.mjs
CHANGED
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
* (`serve-autostart.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
|
|
25
25
|
* (`serve-util.mjs`).
|
|
26
26
|
*/
|
|
27
|
-
export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolderIsAlive, processStartTime, lockHolder, lockHolderPid, readServePid, SERVE_STOPPED_FILE, SERVE_LOGS_KEPT, markStopped, readStopped, rotateServeLog } from './serve-files.mjs'
|
|
27
|
+
export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, pairServe, revokeServe, verifierOf, limitedOf, legacyHostTokenOf, changeServeState, heldHere, lockHolderIsAlive, processStartTime, lockHolder, lockHolderPid, readServePid, SERVE_STOPPED_FILE, SERVE_LOGS_KEPT, markStopped, readStopped, rotateServeLog } from './serve-files.mjs'
|
|
28
28
|
export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, ownLogon } from './serve-windows.mjs'
|
|
29
29
|
export { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, parseNetstatListening } from './serve-port.mjs'
|
|
30
|
-
export { SERVE_HELP, AUTOSTART_ACTIONS, parseServeArgs } from './serve-args.mjs'
|
|
30
|
+
export { SERVE_HELP, AUTOSTART_ACTIONS, parseServeArgs, PAIR_LABEL_RE, CREDENTIAL_ID_RE } from './serve-args.mjs'
|
|
31
31
|
export { launcherScript, ensureLauncher, remoteLayout, parsePointer, installInfo, managedLauncherScript } from './serve-install.mjs'
|
|
32
|
-
export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, askHostRpc, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
32
|
+
export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, askHostRpc, serveFrom, takesTokenOnStdin, tokenLine, keyLine, takesVerifiers, ownCredentials } from './serve-host.mjs'
|
|
33
33
|
export { stopServedHost, moveIntoSignIn, HANDOVER_WAIT_MS } from './serve-handover.mjs'
|
|
34
34
|
export { runWindow } from './serve-window.mjs'
|
|
35
35
|
export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart, startInSignIn } from './serve-autostart.mjs'
|
|
36
|
-
export { printConnection, runServe, rotateToken } from './serve-run.mjs'
|
|
36
|
+
export { printConnection, runServe, rotateToken, revokeCredential } from './serve-run.mjs'
|
|
37
37
|
export { CARRIED_ENV, encodeChildSpec, decodeChildSpec, startDetached, runDetach, runDetachedChild, runStop } from './serve-detach.mjs'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "centralu",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.45",
|
|
4
4
|
"description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ijun17",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"win32"
|
|
39
39
|
],
|
|
40
40
|
"optionalDependencies": {
|
|
41
|
-
"@centralu/darwin-arm64": "0.1.0-beta.
|
|
42
|
-
"@centralu/linux-arm64": "0.1.0-beta.
|
|
43
|
-
"@centralu/linux-x64": "0.1.0-beta.
|
|
44
|
-
"@centralu/win32-x64": "0.1.0-beta.
|
|
41
|
+
"@centralu/darwin-arm64": "0.1.0-beta.45",
|
|
42
|
+
"@centralu/linux-arm64": "0.1.0-beta.45",
|
|
43
|
+
"@centralu/linux-x64": "0.1.0-beta.45",
|
|
44
|
+
"@centralu/win32-x64": "0.1.0-beta.45"
|
|
45
45
|
}
|
|
46
46
|
}
|