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/serve-host.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The host `centralu serve` runs: how it is started, and talking to a running one over its own socket
|
|
3
|
-
* (a
|
|
3
|
+
* (a proof, then a hello with the token, `host.stop`).
|
|
4
4
|
*/
|
|
5
5
|
import { createHmac, randomBytes } from 'node:crypto'
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs'
|
|
7
7
|
import { connect } from 'node:net'
|
|
8
8
|
import { dirname, join } from 'node:path'
|
|
9
|
+
import { LINK_NONCE_BYTES, signedHostKey } from './serve-keys.mjs'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The protocol a host folder speaks, from the `bundle-info.json` the bundler writes next to
|
|
@@ -34,20 +35,26 @@ export function protocolVersionOf(entry) {
|
|
|
34
35
|
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
35
36
|
* is added by `--detach` only.
|
|
36
37
|
*/
|
|
37
|
-
export function connectionLine({ port, token, credential, limited, others, version, protocolVersion, dataDir, hostRunning, install, autostart, detach,
|
|
38
|
+
export function connectionLine({ port, token, credential, limited, others, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, stopped, hostKey, keyId }) {
|
|
38
39
|
/*
|
|
39
|
-
* The options this serve takes that an older one refuses (#1223): a hub adds `--from <its name>`
|
|
40
|
-
* when the line says so, since an older serve answers an option it does not know with an error. `window`: the
|
|
40
|
+
* The options this serve takes that an older one refuses (#1223): a hub up to 0.1.0-beta.45's adds `--from <its name>`
|
|
41
|
+
* to `--detach` only when the line says so, since an older serve answers an option it does not know with an error. `window`: the
|
|
41
42
|
* desktop window on Windows uses this serve's host through `--window` only when it is listed (#1223 review)
|
|
42
43
|
*/
|
|
43
44
|
/*
|
|
44
45
|
* `pair` (#1405): `--connection --pair <label>` hands out a full credential of its own; without it the line carries the
|
|
45
46
|
* limited credential only
|
|
46
47
|
*/
|
|
47
|
-
|
|
48
|
+
/*
|
|
49
|
+
* `pairKey` (#1711, docs/plans/key-pairing.md §4.2): `--pair-key` made a key pairing, said only on a line that is one
|
|
50
|
+
* (`hostKey` and `keyId`, and no `token`). A line from `--pair-key` without it is a verifier pairing
|
|
51
|
+
*/
|
|
52
|
+
const takes = ['from', 'window', 'pair', ...(hostKey && keyId ? ['pairKey'] : [])]
|
|
48
53
|
return JSON.stringify({
|
|
49
54
|
v: 1,
|
|
50
55
|
port,
|
|
56
|
+
// The key the running host signs with, and the hub's key entry it signs for (#1711); never with a credential
|
|
57
|
+
...(hostKey && keyId ? { hostKey, keyId } : {}),
|
|
51
58
|
// A full credential only to `--pair` (#1405), with its id for the proof; an older serve always printed its token
|
|
52
59
|
...(token ? { token } : {}),
|
|
53
60
|
...(credential ? { credential } : {}),
|
|
@@ -62,8 +69,8 @@ export function connectionLine({ port, token, credential, limited, others, versi
|
|
|
62
69
|
...(install ? { install } : {}),
|
|
63
70
|
...(autostart ? { autostart } : {}),
|
|
64
71
|
...(detach ? { detach } : {}),
|
|
65
|
-
// The running host proves it holds the token before the hub sends it (#838)
|
|
66
|
-
|
|
72
|
+
// The running host proves it holds the token before the hub sends it (#838): every host from 0.1.0-beta.45 on does
|
|
73
|
+
proof: true,
|
|
67
74
|
// The person stopped the host here, and when (#1143, `readStopped`); absent: an older serve, or no such stop
|
|
68
75
|
...(stopped ? { stopped } : {}),
|
|
69
76
|
takes,
|
|
@@ -108,12 +115,10 @@ function parseStartedFrom(raw) {
|
|
|
108
115
|
/**
|
|
109
116
|
* The host's environment.
|
|
110
117
|
*
|
|
111
|
-
* - The
|
|
118
|
+
* - The key is in neither: arguments are readable by every user on the machine (`ps`), and a
|
|
112
119
|
* process's start environment by every process of its user for as long as it runs (`ps eww`,
|
|
113
|
-
* #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host
|
|
114
|
-
* (`
|
|
115
|
-
* from before #538 reads only `CC_HOST_TOKEN`: given `legacyToken` (`takesTokenOnStdin` said no),
|
|
116
|
-
* it gets the token there, as before.
|
|
120
|
+
* #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host its key comes as the first line on its stdin
|
|
121
|
+
* (`keyLine`, #538), which is a pipe to this launcher anyway (`--watch-parent`).
|
|
117
122
|
* - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
|
|
118
123
|
* client could not see which version the host it reached is.
|
|
119
124
|
* - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
|
|
@@ -123,12 +128,12 @@ function parseStartedFrom(raw) {
|
|
|
123
128
|
* `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
|
|
124
129
|
* - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
|
|
125
130
|
*/
|
|
126
|
-
export function hostEnv(env, { dataDir, version, hostDir,
|
|
131
|
+
export function hostEnv(env, { dataDir, version, hostDir, from = null, serveState = null }) {
|
|
127
132
|
const out = { ...env }
|
|
128
133
|
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
134
|
/*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
135
|
+
* The host checks full credentials against serve.json's verifiers (#1405): the file it reads at each hello. A path,
|
|
136
|
+
* not a secret: the file is the user's own, 0600. Its key comes on stdin (`keyLine`)
|
|
132
137
|
*/
|
|
133
138
|
if (serveState) out.CC_SERVE_STATE = serveState
|
|
134
139
|
// Who asked for this host, for its host.lock (#1223): a second host on the folder names it when refused
|
|
@@ -136,62 +141,19 @@ export function hostEnv(env, { dataDir, version, hostDir, legacyToken, from = nu
|
|
|
136
141
|
out.CC_DATA_DIR = dataDir
|
|
137
142
|
// The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
|
|
138
143
|
out.CC_SERVE = '1'
|
|
139
|
-
|
|
140
|
-
else out.CC_HOST_TOKEN = legacyToken
|
|
144
|
+
out.CC_HOST_TOKEN_ON_STDIN = '1'
|
|
141
145
|
out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
|
|
142
146
|
return out
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* older bundle, which would otherwise get no token and refuse every hello.
|
|
149
|
-
*/
|
|
150
|
-
export function takesTokenOnStdin(entry) {
|
|
151
|
-
try {
|
|
152
|
-
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).tokenOnStdin === 1
|
|
153
|
-
} catch {
|
|
154
|
-
// not a bundle: the source, which reads it
|
|
155
|
-
}
|
|
156
|
-
return !existsSync(join(dirname(entry), 'bundle-info.json'))
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** The first line on the host's stdin: the token (#538, the host's `readHandedToken`) */
|
|
160
|
-
export function tokenLine(token) {
|
|
161
|
-
return `${JSON.stringify({ token })}\n`
|
|
162
|
-
}
|
|
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)
|
|
150
|
+
* The first line on the host's stdin (#538, #1405; the host's `readHandedToken`): its key, from which it derives what it
|
|
151
|
+
* hands out. Not a full credential: the host never takes it as one (`CC_SERVE_STATE` says where the full ones' verifiers are)
|
|
167
152
|
*/
|
|
168
153
|
export function keyLine(key) {
|
|
169
154
|
return `${JSON.stringify({ key })}\n`
|
|
170
155
|
}
|
|
171
156
|
|
|
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
|
-
|
|
195
157
|
/**
|
|
196
158
|
* How to start the host.
|
|
197
159
|
*
|
|
@@ -249,17 +211,82 @@ function mismatchProtocolOf(error) {
|
|
|
249
211
|
}
|
|
250
212
|
|
|
251
213
|
/**
|
|
252
|
-
* Whether the
|
|
253
|
-
* from source has `transport/
|
|
254
|
-
*
|
|
214
|
+
* Whether the host at `entry` takes a hub's key hello (#1711): a bundle says so in `bundle-info.json` (`keyPairing`), a
|
|
215
|
+
* host run from source has `transport/link-proof.ts`. `--pair-key` reports a key derived from serve.json only for a host
|
|
216
|
+
* started next that will sign with it (docs/plans/key-pairing.md §4.2)
|
|
255
217
|
*/
|
|
256
|
-
export function
|
|
218
|
+
export function takesKeyHellos(entry) {
|
|
257
219
|
try {
|
|
258
|
-
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).
|
|
220
|
+
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).keyPairing === 1
|
|
259
221
|
} catch {
|
|
260
222
|
// not a bundle; try the source below
|
|
261
223
|
}
|
|
262
|
-
return existsSync(join(dirname(entry), 'transport', '
|
|
224
|
+
return existsSync(join(dirname(entry), 'transport', 'link-proof.ts'))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Asks the host on `port` to sign for the key entry `keyId` (#1711, docs/plans/key-pairing.md §4.2 step 3), as a hub
|
|
229
|
+
* would: a `prove` naming it, with a nonce of this launcher's, and the answer's signature checked against the key it
|
|
230
|
+
* carries, the hub's key `hubKey` and the address `127.0.0.1:<port>`. No hello follows. One of:
|
|
231
|
+
*
|
|
232
|
+
* - `{ outcome: 'signed', hostKey }`: the running host signs, with `hostKey` (after `--rotate-token`, still its old one);
|
|
233
|
+
* - `{ outcome: 'unsigned' }`: a proof with no signature at all, from a host from before key pairing still running;
|
|
234
|
+
* - `{ outcome: 'none' }`: the connection was refused, so nothing listens there;
|
|
235
|
+
* - `{ outcome: 'unknown' }`: anything else (no answer within `timeoutMs`, a socket closed before a proof, a proof that is
|
|
236
|
+
* malformed or does not verify, no WebSocket in this Node), which pairs as a host that does not sign does.
|
|
237
|
+
*/
|
|
238
|
+
export async function probeKeyProof({ port, keyId, hubKey, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket, connectTo = connect }) {
|
|
239
|
+
// Refused at once: nothing listens. Told apart from a socket that opens and says nothing, which is not known to be nothing
|
|
240
|
+
const reach = await new Promise((resolve) => {
|
|
241
|
+
const s = connectTo({ host: '127.0.0.1', port })
|
|
242
|
+
const done = (r) => {
|
|
243
|
+
s.destroy()
|
|
244
|
+
resolve(r)
|
|
245
|
+
}
|
|
246
|
+
s.setTimeout(timeoutMs, () => done('unknown'))
|
|
247
|
+
s.once('connect', () => done('open'))
|
|
248
|
+
s.once('error', (e) => done(e?.code === 'ECONNREFUSED' ? 'none' : 'unknown'))
|
|
249
|
+
})
|
|
250
|
+
if (reach !== 'open') return { outcome: reach }
|
|
251
|
+
if (typeof WebSocketImpl !== 'function') return { outcome: 'unknown' }
|
|
252
|
+
const nonce = randomBytes(LINK_NONCE_BYTES).toString('base64url')
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
let ws = null
|
|
255
|
+
let settled = false
|
|
256
|
+
const done = (r) => {
|
|
257
|
+
if (settled) return
|
|
258
|
+
settled = true
|
|
259
|
+
clearTimeout(timer)
|
|
260
|
+
try {
|
|
261
|
+
ws?.close()
|
|
262
|
+
} catch {
|
|
263
|
+
// already closed
|
|
264
|
+
}
|
|
265
|
+
resolve(r)
|
|
266
|
+
}
|
|
267
|
+
const timer = setTimeout(() => done({ outcome: 'unknown' }), timeoutMs)
|
|
268
|
+
try {
|
|
269
|
+
ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
270
|
+
} catch {
|
|
271
|
+
return done({ outcome: 'unknown' })
|
|
272
|
+
}
|
|
273
|
+
ws.onopen = () => ws.send(JSON.stringify({ kind: 'prove', nonce, key: keyId }))
|
|
274
|
+
ws.onmessage = (ev) => {
|
|
275
|
+
let m = null
|
|
276
|
+
try {
|
|
277
|
+
m = JSON.parse(String(ev.data))
|
|
278
|
+
} catch {
|
|
279
|
+
// not a frame
|
|
280
|
+
}
|
|
281
|
+
const hostKey = signedHostKey(m, { hubNonce: nonce, hubKey, keyId, port })
|
|
282
|
+
if (hostKey) return done({ outcome: 'signed', hostKey })
|
|
283
|
+
// A proof without any of the three fields: a host that does not sign. One with some of them and no valid signature is not known
|
|
284
|
+
const unsigned = m?.kind === 'proof' && m.nonce === nonce && m.hostNonce === undefined && m.hostKey === undefined && m.sig === undefined
|
|
285
|
+
done({ outcome: unsigned ? 'unsigned' : 'unknown' })
|
|
286
|
+
}
|
|
287
|
+
ws.onerror = () => done({ outcome: 'unknown' })
|
|
288
|
+
ws.onclose = () => done({ outcome: 'unknown' })
|
|
289
|
+
})
|
|
263
290
|
}
|
|
264
291
|
|
|
265
292
|
/** The answer a host holding `token` gives to a `prove` of `nonce` (the host's `hostProof`) */
|
|
@@ -268,20 +295,19 @@ function proofOf(token, nonce) {
|
|
|
268
295
|
}
|
|
269
296
|
|
|
270
297
|
/**
|
|
271
|
-
* Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do.
|
|
272
|
-
*
|
|
273
|
-
*
|
|
298
|
+
* Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do. The host first
|
|
299
|
+
* proves it holds the token (#838), and the token goes out only then: whatever else listens on the port (another
|
|
300
|
+
* account's program, while the host is down) never sees it.
|
|
274
301
|
* `on.message(m, ws)` gets every frame after the hello; `on.unproven()` runs when the proof did not come, `on.gone()`
|
|
275
302
|
* when the socket failed or closed.
|
|
276
303
|
*/
|
|
277
|
-
function helloSocket({ port, token, proofKey, protocolVersion,
|
|
304
|
+
function helloSocket({ port, token, proofKey, protocolVersion, WebSocketImpl, on }) {
|
|
278
305
|
const ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
279
|
-
const nonce =
|
|
306
|
+
const nonce = randomBytes(24).toString('base64url')
|
|
280
307
|
// Proving only once the socket is open: a port where nothing listens is gone, not unproven
|
|
281
308
|
let proving = false
|
|
282
309
|
const hello = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
283
310
|
ws.onopen = () => {
|
|
284
|
-
if (!prove) return hello()
|
|
285
311
|
proving = true
|
|
286
312
|
ws.send(JSON.stringify({ kind: 'prove', nonce }))
|
|
287
313
|
}
|
|
@@ -309,17 +335,17 @@ function helloSocket({ port, token, proofKey, protocolVersion, prove, WebSocketI
|
|
|
309
335
|
* Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
|
|
310
336
|
* stranger's program on the same port does not count, and the answer carries the running host's
|
|
311
337
|
* own protocol and version (which differ from the installed package's after an `npm i -g` the
|
|
312
|
-
* host was not restarted for).
|
|
338
|
+
* host was not restarted for). The host proves it holds the token first (#838).
|
|
313
339
|
*
|
|
314
340
|
* `{ running, protocolVersion?, version?, unproven? }`. `unproven`: something answered on the port but did not prove
|
|
315
|
-
* it holds the token, so it was not sent: another program, or a host
|
|
341
|
+
* it holds the token, so it was not sent: another program, or a host still on its old key after `--rotate-token`
|
|
316
342
|
* (`heldHere` tells those apart by the data folder's lock). Node 22.4+ has a global WebSocket; on an older 22 this falls
|
|
317
343
|
* back to a TCP connect and reports only `running`.
|
|
318
344
|
*/
|
|
319
|
-
export function probeHost({ port, token, proofKey, protocolVersion,
|
|
345
|
+
export function probeHost({ port, token, proofKey, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
|
|
320
346
|
if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
|
|
321
347
|
return helloExchange({
|
|
322
|
-
port, token, proofKey, protocolVersion,
|
|
348
|
+
port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
|
|
323
349
|
timedOut: { running: false },
|
|
324
350
|
failed: () => ({ running: false }),
|
|
325
351
|
on: (done) => ({
|
|
@@ -391,10 +417,10 @@ const HOST_LOGONS = ['interactive', 'network', 'batch', 'service']
|
|
|
391
417
|
* One call to a running host (`method` with `params`), as `askHostToStop` makes it: a hello with the token, then the
|
|
392
418
|
* call. Its result, or null when it did not answer or refused. For `host.handover` (#1224)
|
|
393
419
|
*/
|
|
394
|
-
export function askHostRpc({ port, token, proofKey, protocolVersion,
|
|
420
|
+
export function askHostRpc({ port, token, proofKey, protocolVersion, method, params = {}, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
395
421
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve(null)
|
|
396
422
|
return helloExchange({
|
|
397
|
-
port, token, proofKey, protocolVersion,
|
|
423
|
+
port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
|
|
398
424
|
timedOut: null,
|
|
399
425
|
failed: () => null,
|
|
400
426
|
on: (done) => ({
|
|
@@ -416,11 +442,10 @@ export function askHostRpc({ port, token, proofKey, protocolVersion, prove = fal
|
|
|
416
442
|
* `ifIdle` (#1224): only if nothing runs there, checked and acted on by the host in one step; `{ ok: false, busy: true }`
|
|
417
443
|
* when something does. `forUpdate` (#1618): a hub's update stops it, and the host lists the turns it ends for the next one.
|
|
418
444
|
*/
|
|
419
|
-
export function askHostToStop({ port, token, proofKey, protocolVersion,
|
|
445
|
+
export function askHostToStop({ port, token, proofKey, protocolVersion, ifIdle = false, forUpdate = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
420
446
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
|
|
421
|
-
// With `prove`, the token goes out only to a host that proved it holds it (#838)
|
|
422
447
|
return helloExchange({
|
|
423
|
-
port, token, proofKey, protocolVersion,
|
|
448
|
+
port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
|
|
424
449
|
timedOut: { ok: false, message: 'no answer in time' },
|
|
425
450
|
failed: (e) => ({ ok: false, message: e.message }),
|
|
426
451
|
on: (done) => ({
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher's side of a hub's key pairing (#1711, docs/plans/key-pairing.md §4.1 to §4.3): the check of a hub's
|
|
3
|
+
* public key at `--pair-key`, the public key of the host's signing key derived from serve.json's `key`, and the check of
|
|
4
|
+
* the running host's signed proof. The same rules as the host's `transport/link-keys.ts`, which this standalone launcher
|
|
5
|
+
* cannot import; `tooling/launcher-keys.test.ts` holds the two to the same answers and to the pinned vector.
|
|
6
|
+
*/
|
|
7
|
+
import { createHmac, createPrivateKey, createPublicKey, verify } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
/** Bytes of a nonce, a public key and a signature (`@cc/protocol` `link-wire.ts`) */
|
|
10
|
+
export const LINK_NONCE_BYTES = 24
|
|
11
|
+
export const LINK_PUBLIC_KEY_BYTES = 32
|
|
12
|
+
export const LINK_SIGNATURE_BYTES = 64
|
|
13
|
+
|
|
14
|
+
const CONTEXT_LABEL = 'centralu link v1'
|
|
15
|
+
const HOST_ROLE_LABEL = 'centralu link host v1'
|
|
16
|
+
const HOST_KEY_LABEL = 'centralu host key v1'
|
|
17
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
|
18
|
+
const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
|
|
19
|
+
const P = 2n ** 255n - 19n
|
|
20
|
+
|
|
21
|
+
/** The bytes of `text` when it is exactly the base64url (no padding) of `bytes` bytes: encoding them again gives the same string */
|
|
22
|
+
export function decodeBase64url(text, bytes) {
|
|
23
|
+
if (typeof text !== 'string') return null
|
|
24
|
+
const raw = Buffer.from(text, 'base64url')
|
|
25
|
+
if (raw.length !== bytes || raw.toString('base64url') !== text) return null
|
|
26
|
+
return raw
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The 32 bytes as a little-endian number with the top bit (the sign of x) cleared */
|
|
30
|
+
function yOf(raw) {
|
|
31
|
+
let y = 0n
|
|
32
|
+
for (let i = raw.length - 1; i >= 0; i--) y = (y << 8n) | BigInt(i === raw.length - 1 ? raw[i] & 0x7f : raw[i])
|
|
33
|
+
return y
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The y of the eight small-order points: 0, 1, p − 1 and the two of order 8 (libsodium's `ge25519_has_small_order`) */
|
|
37
|
+
const SMALL_ORDER_Y = [
|
|
38
|
+
0n,
|
|
39
|
+
1n,
|
|
40
|
+
P - 1n,
|
|
41
|
+
yOf(Buffer.from('26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'hex')),
|
|
42
|
+
yOf(Buffer.from('c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a', 'hex')),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `{ text, raw, key }` for a public key the host takes (§4.1), or null: exactly 43 canonical base64url characters, a y
|
|
47
|
+
* below p, not the y of a small-order point (either sign bit), and an Ed25519 key `node:crypto` imports. Unchecked, on
|
|
48
|
+
* Node 22 a small-order key verifies a forged signature over every message.
|
|
49
|
+
*/
|
|
50
|
+
export function parseLinkPublicKey(text) {
|
|
51
|
+
const raw = decodeBase64url(text, LINK_PUBLIC_KEY_BYTES)
|
|
52
|
+
if (!raw) return null
|
|
53
|
+
const y = yOf(raw)
|
|
54
|
+
if (y >= P || SMALL_ORDER_Y.includes(y)) return null
|
|
55
|
+
let key
|
|
56
|
+
try {
|
|
57
|
+
key = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' })
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
return key.asymmetricKeyType === 'ed25519' ? { text, raw, key } : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The public key a host started with `key` signs with (§4.1): `seed = HMAC-SHA256(key, "centralu host key v1")`, as
|
|
66
|
+
* PKCS#8. What `--pair-key` reports when no host runs and none of a keeper's may run with another key (§4.2).
|
|
67
|
+
*/
|
|
68
|
+
export function hostLinkPublicKey(key) {
|
|
69
|
+
if (typeof key !== 'string' || key.length === 0) throw new Error('The host key is empty')
|
|
70
|
+
const seed = createHmac('sha256', key).update(HOST_KEY_LABEL).digest()
|
|
71
|
+
const priv = createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, seed]), format: 'der', type: 'pkcs8' })
|
|
72
|
+
const der = createPublicKey(priv).export({ format: 'der', type: 'spki' })
|
|
73
|
+
const text = der.subarray(SPKI_PREFIX.length).toString('base64url')
|
|
74
|
+
if (!parseLinkPublicKey(text)) throw new Error('The derived host key was refused')
|
|
75
|
+
return text
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `u32be(len) ‖ bytes` for each part; a string as ASCII, and one with another character throws */
|
|
79
|
+
export function encodeFields(parts) {
|
|
80
|
+
const out = []
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
if (typeof part === 'string' && Array.from(part).some((c) => c.codePointAt(0) > 0x7f)) throw new Error('A field string is ASCII')
|
|
83
|
+
const b = typeof part === 'string' ? Buffer.from(part, 'ascii') : Buffer.from(part)
|
|
84
|
+
const len = Buffer.alloc(4)
|
|
85
|
+
len.writeUInt32BE(b.length)
|
|
86
|
+
out.push(len, b)
|
|
87
|
+
}
|
|
88
|
+
return Buffer.concat(out)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `127.0.0.1:<port>` for a port 1 to 65535, null otherwise (the host's `linkAddress`) */
|
|
92
|
+
export function linkAddress(port) {
|
|
93
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? `127.0.0.1:${port}` : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The transcript `T` (§4.3), or null when a value is refused */
|
|
97
|
+
export function linkTranscript({ hubNonce, hostNonce, hostKey, hubKey, keyId, address }) {
|
|
98
|
+
const nh = decodeBase64url(hubNonce, LINK_NONCE_BYTES)
|
|
99
|
+
const ns = decodeBase64url(hostNonce, LINK_NONCE_BYTES)
|
|
100
|
+
const pm = parseLinkPublicKey(hostKey)
|
|
101
|
+
const ph = parseLinkPublicKey(hubKey)
|
|
102
|
+
if (!nh || !ns || !pm || !ph) return null
|
|
103
|
+
if (typeof keyId !== 'string' || !/^[0-9a-f]{8}$/.test(keyId)) return null
|
|
104
|
+
if (typeof address !== 'string' || !/^127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(address) || linkAddress(Number(address.slice(10))) !== address) return null
|
|
105
|
+
return encodeFields([CONTEXT_LABEL, nh, ns, pm.raw, ph.raw, keyId, address])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The key a running host signs with, when `proof` is its signed answer to a key `prove` of `hubNonce` (§4.2 step 3):
|
|
110
|
+
* `hostNonce`, `hostKey` and `sig` all present, strict, and the host's signature verifying over the transcript built
|
|
111
|
+
* here from the hub's key, the entry's id and the address the hub forwards to. Null for anything else: an answer
|
|
112
|
+
* without a signature (a host from before key pairing), or one that does not verify.
|
|
113
|
+
*/
|
|
114
|
+
export function signedHostKey(proof, { hubNonce, hubKey, keyId, port }) {
|
|
115
|
+
if (proof?.kind !== 'proof' || proof.nonce !== hubNonce) return null
|
|
116
|
+
const pm = parseLinkPublicKey(proof.hostKey)
|
|
117
|
+
const sig = decodeBase64url(proof.sig, LINK_SIGNATURE_BYTES)
|
|
118
|
+
if (!pm || !sig) return null
|
|
119
|
+
const t = linkTranscript({ hubNonce, hostNonce: proof.hostNonce, hostKey: proof.hostKey, hubKey, keyId, address: linkAddress(port) })
|
|
120
|
+
if (!t) return null
|
|
121
|
+
try {
|
|
122
|
+
return verify(null, Buffer.concat([encodeFields([HOST_ROLE_LABEL]), t]), pm.key, sig) ? pm.text : null
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
}
|
package/bin/serve-run.mjs
CHANGED
|
@@ -4,10 +4,11 @@ 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, pairServe,
|
|
7
|
+
import { SERVE_STATE_FILE, SERVE_PID_FILE, readStopped, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, holderWho, removeServePid, pairServe, pairKeyServe, dropVerifierUnder, revokeServe } 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,
|
|
10
|
+
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, probeHost, probeKeyProof, serveFrom, keyLine, takesKeyHellos } from './serve-host.mjs'
|
|
11
|
+
import { hostLinkPublicKey } from './serve-keys.mjs'
|
|
11
12
|
import { autostartInfo } from './serve-autostart.mjs'
|
|
12
13
|
import { moveIntoSignIn } from './serve-handover.mjs'
|
|
13
14
|
|
|
@@ -27,29 +28,33 @@ export function serveFolder({ env, home, cliPath, platform = process.platform })
|
|
|
27
28
|
*
|
|
28
29
|
* The credentials (#1405): `paired` (`--pair`, `{ token, id }`) is a full credential just made for whoever asked, and
|
|
29
30
|
* the only one this ever prints; without it the line carries the limited credential, which reads and does the everyday
|
|
30
|
-
* things.
|
|
31
|
+
* things.
|
|
31
32
|
* `others` names the full credentials the folder holds besides the one just paired, never their values, each marked
|
|
32
33
|
* `replaced` when its pairing took the place of an earlier one of its label.
|
|
33
34
|
*/
|
|
34
|
-
export async function describe({ env, dataDir, state, entry, version, paired = null }) {
|
|
35
|
+
export async function describe({ env, dataDir, state, entry, version, paired = null, keyPaired = null }) {
|
|
35
36
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
36
37
|
const installed = protocolVersionOf(entry)
|
|
37
|
-
|
|
38
|
-
const proof = hostProvesOf(entry)
|
|
39
|
-
const own = ownCredentials(state, entry)
|
|
40
|
-
const probe = await probeHost({ port, ...own, protocolVersion: installed, prove: proof })
|
|
38
|
+
const probe = await probeHost({ port, token: state.token, proofKey: state.proofKey, protocolVersion: installed })
|
|
41
39
|
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
42
40
|
if (protocolVersion === null) return null
|
|
43
|
-
const hostRunning = heldHere(probe, dataDir)
|
|
41
|
+
const hostRunning = heldHere(probe, dataDir, undefined, state.port != null)
|
|
44
42
|
// The person stopped it here (#1143): said while no host runs, so a hub starts none by itself
|
|
45
43
|
const stopped = hostRunning ? null : readStopped(dataDir)
|
|
46
|
-
const verifiers = takesVerifiers(entry)
|
|
47
44
|
return {
|
|
48
|
-
proof,
|
|
49
45
|
port,
|
|
50
|
-
...(paired ? { token: paired.token,
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
...(paired ? { token: paired.token, credential: paired.id } : {}),
|
|
47
|
+
// A key pairing (#1711): the key the running host signs with and the entry it signs for, never a credential
|
|
48
|
+
...(keyPaired ? { hostKey: keyPaired.hostKey, keyId: keyPaired.keyId } : {}),
|
|
49
|
+
limited: state.token,
|
|
50
|
+
/*
|
|
51
|
+
* The hubs' key entries are listed too, with their public keys (#1711, §4.4), which a hub compares with its own; the
|
|
52
|
+
* one this line pairs is left out, as the full credential just paired is
|
|
53
|
+
*/
|
|
54
|
+
others: [
|
|
55
|
+
...state.full.filter((e) => e.id !== paired?.id).map(({ id, label, created, replaced }) => ({ id, label, created, ...(replaced ? { replaced: true } : {}) })),
|
|
56
|
+
...(state.keys ?? []).filter((k) => k.id !== keyPaired?.keyId).map(({ id, label, created, replaced, publicKey }) => ({ id, label, created, publicKey, ...(replaced ? { replaced: true } : {}) })),
|
|
57
|
+
],
|
|
53
58
|
version: (probe.running ? probe.version : undefined) ?? version,
|
|
54
59
|
protocolVersion,
|
|
55
60
|
dataDir,
|
|
@@ -64,23 +69,35 @@ export async function describe({ env, dataDir, state, entry, version, paired = n
|
|
|
64
69
|
* `centralu serve --connection`. stdout carries exactly one line, the JSON; everything else goes to
|
|
65
70
|
* stderr. Exit 1 when there is no host to describe.
|
|
66
71
|
*/
|
|
67
|
-
export async function printConnection({ env, home, entry, version, cliPath, pair = null }) {
|
|
72
|
+
export async function printConnection({ env, home, entry, version, cliPath, pair = null, pairKey = null, keyPaired = false, probeKey = probeKeyProof }) {
|
|
68
73
|
const folder = serveFolder({ env, home, cliPath })
|
|
69
74
|
const { dataDir } = folder
|
|
70
75
|
let { state } = folder
|
|
71
76
|
// `--pair <label>` (#1405): a full credential for whoever asked, printed once and written down as its verifier only
|
|
72
77
|
let paired = null
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// `--pair-key <label> <key>` (#1711): a key pairing when the host that will check it signs; else a verifier pairing as `--pair`
|
|
79
|
+
let keyLine = null
|
|
80
|
+
if (pairKey !== null) {
|
|
81
|
+
const made = pairKeyServe(dataDir, pairKey.label, pairKey.publicKey)
|
|
82
|
+
state = made.state
|
|
83
|
+
const hostKey = await signingHostKey({ dataDir, state, entry, keyId: made.entry.id, hubKey: pairKey.publicKey, probeKey })
|
|
84
|
+
if (hostKey) {
|
|
85
|
+
keyLine = { hostKey, keyId: made.entry.id }
|
|
86
|
+
// The hub paired by key here before (§4.2): its verifier entry under the label is no longer its own, and goes now
|
|
87
|
+
if (keyPaired) state = dropVerifierUnder(dataDir, pairKey.label)
|
|
88
|
+
say(`paired the key of ${JSON.stringify(pairKey.label)} (${made.entry.id}); serve --revoke ${made.entry.id} withdraws it`)
|
|
79
89
|
} else {
|
|
80
|
-
|
|
90
|
+
// The key entry stays: it is public, and a host that signs uses it once one runs here
|
|
91
|
+
pair = pairKey.label
|
|
81
92
|
}
|
|
82
93
|
}
|
|
83
|
-
|
|
94
|
+
if (pair !== null) {
|
|
95
|
+
const made = pairServe(dataDir, pair)
|
|
96
|
+
state = made.state
|
|
97
|
+
paired = { token: made.token, id: made.id }
|
|
98
|
+
say(`made a full credential for ${JSON.stringify(pair)} (${made.id}); serve --revoke ${made.id} withdraws it`)
|
|
99
|
+
}
|
|
100
|
+
const d = await describe({ env, dataDir, state, entry, version, paired, keyPaired: keyLine })
|
|
84
101
|
if (!d) {
|
|
85
102
|
say(`cannot read the protocol version of the host at ${entry}`)
|
|
86
103
|
return 1
|
|
@@ -89,6 +106,28 @@ export async function printConnection({ env, home, entry, version, cliPath, pair
|
|
|
89
106
|
return 0
|
|
90
107
|
}
|
|
91
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The key the host that checks a key hello signs with (#1711, docs/plans/key-pairing.md §4.2), or null when a key
|
|
111
|
+
* pairing cannot be made now and the line pairs by verifier:
|
|
112
|
+
*
|
|
113
|
+
* - the running host signs a `prove` for the entry: the key it signs with, which after `--rotate-token` is still its old
|
|
114
|
+
* one until it restarts;
|
|
115
|
+
* - nothing listens on the port (the connection refused), nothing holds the data folder (`host.lock`: no keeper's host,
|
|
116
|
+
* no host starting) and the installed host takes key hellos: the key derived from serve.json's `key`, which the host
|
|
117
|
+
* started next is handed;
|
|
118
|
+
* - otherwise (a host that does not sign, no answer in time, a proof that does not verify, a holder of the folder):
|
|
119
|
+
* null. A slow old host never gets a key line, and no line reports a key the running host does not sign with.
|
|
120
|
+
*/
|
|
121
|
+
async function signingHostKey({ dataDir, state, entry, keyId, hubKey, probeKey }) {
|
|
122
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
123
|
+
const probe = await probeKey({ port, keyId, hubKey })
|
|
124
|
+
if (probe.outcome === 'signed') return probe.hostKey
|
|
125
|
+
if (probe.outcome !== 'none' || !takesKeyHellos(entry)) return null
|
|
126
|
+
const holder = lockHolder(dataDir)
|
|
127
|
+
if (holder && isAlive(holder.pid)) return null
|
|
128
|
+
return hostLinkPublicKey(state.key)
|
|
129
|
+
}
|
|
130
|
+
|
|
92
131
|
/**
|
|
93
132
|
* `centralu serve`. Resolves with the exit code once the host has exited.
|
|
94
133
|
*
|
|
@@ -108,9 +147,9 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
108
147
|
// A serve that is already up answers on the port it recorded; say so before starting a host that
|
|
109
148
|
// would only fail on the data folder's lock with a less specific message.
|
|
110
149
|
const recorded = state.port ?? DEFAULT_SERVE_PORT
|
|
111
|
-
const existing = await probeHost({ port: recorded,
|
|
150
|
+
const existing = await probeHost({ port: recorded, token: state.token, proofKey: state.proofKey, protocolVersion })
|
|
112
151
|
// A host the hub's ssh started before anyone signed in moves into this sign-in, once idle (#1224, serve-handover.mjs)
|
|
113
|
-
if (heldHere(existing, dataDir) && !(await handOver({ dataDir, entry, env, existing, platform }))) {
|
|
152
|
+
if (heldHere(existing, dataDir, undefined, state.port != null) && !(await handOver({ dataDir, entry, env, existing, platform }))) {
|
|
114
153
|
const pid = lockHolderPid(dataDir)
|
|
115
154
|
say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
|
|
116
155
|
return 1
|
|
@@ -129,14 +168,10 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
129
168
|
}
|
|
130
169
|
|
|
131
170
|
const plan = hostCommand(execPath, entry, { port, dataDir, platform })
|
|
132
|
-
const onStdin = takesTokenOnStdin(entry)
|
|
133
171
|
/*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
172
|
+
* The host gets the key on stdin and the path of serve.json, whose full credentials it checks at each hello (#1405);
|
|
173
|
+
* nothing it gets opens it whole
|
|
136
174
|
*/
|
|
137
|
-
const verifiers = takesVerifiers(entry)
|
|
138
|
-
if (verifiers) dropLegacyHostToken(dataDir, platform)
|
|
139
|
-
const legacy = verifiers ? null : legacyHostTokenOf(dataDir, platform)
|
|
140
175
|
const child = spawn(plan.command, plan.args, {
|
|
141
176
|
stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
|
|
142
177
|
env: hostEnv(env, {
|
|
@@ -144,14 +179,13 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
144
179
|
version,
|
|
145
180
|
hostDir: dirname(entry),
|
|
146
181
|
from: serveFrom(env, from),
|
|
147
|
-
|
|
148
|
-
...(onStdin ? {} : { legacyToken: legacy ?? state.token }),
|
|
182
|
+
serveState: join(dataDir, SERVE_STATE_FILE),
|
|
149
183
|
}),
|
|
150
184
|
detached: plan.detached,
|
|
151
185
|
windowsHide: true,
|
|
152
186
|
})
|
|
153
187
|
// What the host holds, before anything else on the pipe it watches (#538); stdin stays open for --watch-parent
|
|
154
|
-
|
|
188
|
+
child.stdin.write(keyLine(state.key))
|
|
155
189
|
|
|
156
190
|
let ready = false
|
|
157
191
|
if (logFd !== null) {
|
|
@@ -217,8 +251,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
217
251
|
const holder = lockHolder(dataDir)
|
|
218
252
|
if (holder && holder.pid !== child.pid && isAlive(holder.pid)) {
|
|
219
253
|
// Who it is, when its host.lock says (#1223)
|
|
220
|
-
|
|
221
|
-
say(`the host did not start. ${dataDir} belongs to another Centralu host (pid ${holder.pid}), ${who}. Quit it, or point CC_DATA_DIR at another folder.`)
|
|
254
|
+
say(`the host did not start. ${dataDir} belongs to another Centralu host (pid ${holder.pid}), ${holderWho(holder.by)}. Quit it, or point CC_DATA_DIR at another folder.`)
|
|
222
255
|
}
|
|
223
256
|
}
|
|
224
257
|
if (logFd !== null) say(`the host exited (${signal ?? code})`)
|
|
@@ -244,9 +277,9 @@ export function revokeCredential({ env, home, id, write = (line) => process.stdo
|
|
|
244
277
|
write(`${JSON.stringify({ v: 1, revoked, id })}
|
|
245
278
|
`)
|
|
246
279
|
if (!revoked) {
|
|
247
|
-
say(`no
|
|
280
|
+
say(`no credential ${id} in ${join(dataDir, SERVE_STATE_FILE)}; --connection lists them under others.`)
|
|
248
281
|
return 1
|
|
249
282
|
}
|
|
250
|
-
say(`withdrew
|
|
283
|
+
say(`withdrew ${id}. A running host refuses its next hello with it and closes a connection already open with it within a second.`)
|
|
251
284
|
return 0
|
|
252
285
|
}
|