centralu 0.1.0-beta.44 → 0.1.0-beta.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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, pairKey: args.pairKey ?? null, keyPaired: args.keyPaired === true })
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)
@@ -1,5 +1,6 @@
1
1
  /** The arguments after `centralu serve`, and its help */
2
2
  import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
3
+ import { parseLinkPublicKey } from './serve-keys.mjs'
3
4
 
4
5
  export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
5
6
 
@@ -8,10 +9,17 @@ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine
8
9
  centralu serve --detach Start it in the background, outside this session; return once it answers
9
10
  centralu serve --stop Ask the running host to stop, and wait until it has
10
11
  centralu serve --window What the Centralu window on Windows runs here: start the host if
11
- none runs, print its port and token as a ready line, stay until
12
- stdin ends, and leave the host running
13
- centralu serve --connection Print how to reach it as one JSON line, then exit
14
- centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
12
+ none runs, print its port and a full credential for the window as
13
+ a ready line, stay until stdin ends, and leave the host running
14
+ centralu serve --connection Print how to reach it as one JSON line, then exit. It carries the
15
+ limited credential (everyday calls) and lists the full ones
16
+ centralu serve --connection --pair <label>
17
+ ... and a new full credential for <label>, printed once and kept
18
+ here only as its hash; it replaces <label>'s earlier one. What the
19
+ app on another computer runs at each link
20
+ centralu serve --revoke <id> Withdraw one full credential (--connection lists their ids)
21
+ centralu serve --rotate-token A new key, and every full credential withdrawn (a running serve
22
+ keeps its old key until restarted)
15
23
  centralu serve --from <hub> With serve or --detach: the name of the computer whose link asks,
16
24
  said by a second Centralu on this data folder when it is refused
17
25
  centralu serve --autostart on|off|remove|status
@@ -32,7 +40,16 @@ from an install the app made over SSH, which has a launcher of its own.`
32
40
 
33
41
  /** The flags that each make serve a different command */
34
42
  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' }
43
+ const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart', revoke: '--revoke' }
44
+
45
+ /**
46
+ * What `--pair` takes (#1405): who the full credential is for, said back in `--connection`'s `others` and on the hub's
47
+ * machine row. A hub sends `hub:<its machine id>`
48
+ */
49
+ export const PAIR_LABEL_RE = /^[A-Za-z0-9:._-]{1,80}$/
50
+
51
+ /** What `--revoke` takes: a full credential's id, as `--connection` lists it (8 hex characters) */
52
+ export const CREDENTIAL_ID_RE = /^[0-9a-f]{8}$/
36
53
 
37
54
  /** What `--autostart` takes */
38
55
  export const AUTOSTART_ACTIONS = ['on', 'off', 'remove', 'status']
@@ -66,9 +83,36 @@ export function parseServeArgs(argv) {
66
83
  let child = null
67
84
  let autostart = null
68
85
  let from = null
86
+ let pair = null
87
+ let pairKey = null
88
+ let keyPaired = false
89
+ let revoke = null
69
90
  for (let i = 0; i < argv.length; i++) {
70
91
  const a = argv[i]
71
- if (a === '--autostart' || a.startsWith('--autostart=')) {
92
+ if (a === '--pair' || a.startsWith('--pair=')) {
93
+ const raw = a === '--pair' ? argv[++i] : a.slice('--pair='.length)
94
+ if (typeof raw !== 'string' || !PAIR_LABEL_RE.test(raw)) return { error: `--pair needs a label: letters, digits, ':', '.', '_' and '-' (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
95
+ pair = raw
96
+ } else if (a === '--pair-key') {
97
+ /*
98
+ * `--pair-key <label> <public key>` (#1711, docs/plans/key-pairing.md §4.2): a hub's key pairing, at each link in
99
+ * place of `--pair`. The key is checked as the host checks it (§4.1): a refused one writes nothing
100
+ */
101
+ const label = argv[++i]
102
+ const publicKey = argv[++i]
103
+ if (typeof label !== 'string' || !PAIR_LABEL_RE.test(label)) return { error: `--pair-key needs a label: letters, digits, ':', '.', '_' and '-' (got ${label === undefined ? 'nothing' : JSON.stringify(label)})` }
104
+ if (!parseLinkPublicKey(publicKey)) return { error: `--pair-key needs an Ed25519 public key after the label, as 43 characters of base64url (got ${publicKey === undefined ? 'nothing' : JSON.stringify(String(publicKey).slice(0, 64))})` }
105
+ pairKey = { label, publicKey }
106
+ } else if (a === '--key-paired') {
107
+ // The hub has paired by key with this machine before (#1711, §4.2): a key line removes the label's verifier entry
108
+ keyPaired = true
109
+ } else if (a === '--revoke' || a.startsWith('--revoke=')) {
110
+ const raw = a === '--revoke' ? argv[++i] : a.slice('--revoke='.length)
111
+ if (typeof raw !== 'string' || !CREDENTIAL_ID_RE.test(raw)) return { error: `--revoke needs a credential's id, as --connection lists it (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
112
+ if (mode !== 'serve' && mode !== 'revoke') return { error: `${FLAG_OF[mode]} and --revoke are separate commands; pass one` }
113
+ mode = 'revoke'
114
+ revoke = raw
115
+ } else if (a === '--autostart' || a.startsWith('--autostart=')) {
72
116
  const raw = a === '--autostart' ? argv[++i] : a.slice('--autostart='.length)
73
117
  if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off, remove or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
74
118
  if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
@@ -76,7 +120,7 @@ export function parseServeArgs(argv) {
76
120
  autostart = raw
77
121
  } else if (Object.hasOwn(MODE_FLAGS, a)) {
78
122
  const m = MODE_FLAGS[a]
79
- if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
123
+ if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode] ?? mode} and ${a} are separate commands; pass one` }
80
124
  mode = m
81
125
  } else if (a === '--help' || a === '-h') help = true
82
126
  else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
@@ -92,6 +136,11 @@ export function parseServeArgs(argv) {
92
136
  } else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
93
137
  }
94
138
  if (help) return { mode: 'help', port }
139
+ if (pair !== null && mode !== 'connection') return { error: '--pair goes with --connection' }
140
+ if (pairKey !== null && mode !== 'connection') return { error: '--pair-key goes with --connection' }
141
+ if (pair !== null && pairKey !== null) return { error: '--pair and --pair-key are two ways of pairing; pass one' }
142
+ if (keyPaired && pairKey === null) return { error: '--key-paired goes with --pair-key' }
143
+ if (mode === 'revoke') return port !== null ? { error: '--revoke takes no --port' } : { mode, port, revoke }
95
144
  if (from !== null && mode !== 'serve' && mode !== 'detach') return { error: `--from goes with \`centralu serve\` and --detach, which start a host; not with ${FLAG_OF[mode]}` }
96
145
  if (child !== null) {
97
146
  if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
@@ -106,7 +155,7 @@ export function parseServeArgs(argv) {
106
155
  if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
107
156
  return { mode, port, autostart }
108
157
  }
109
- return { mode, port, ...(from !== null ? { from } : {}) }
158
+ return { mode, port, ...(from !== null ? { from } : {}), ...(pair !== null ? { pair } : {}), ...(pairKey !== null ? { pairKey, keyPaired } : {}) }
110
159
  }
111
160
 
112
161
  function parsePort(raw) {
@@ -384,10 +384,10 @@ async function systemdAutostart(action, { dataDir, env, home, name, run, user })
384
384
  return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
385
385
  }
386
386
 
387
- async function taskAutostart(action, { dataDir, env, home, name, run, user, platform, interop }) {
387
+ async function taskAutostart(action, { dataDir, env, home, name, run, user, platform, interop, mounts }) {
388
388
  const legacy = legacyTaskName({ dataDir, home, env, platform })
389
389
  const detach = detachHow(platform, env, interop)
390
- const ps = windowsPowershell(detach, env)
390
+ const ps = windowsPowershell(detach, env, mounts)
391
391
  const base = { ok: true, how: 'task', linger: null }
392
392
  if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
393
393
  const ask = (script) => askPowershell(ps, script, env, parseTaskAnswer, run)
@@ -456,8 +456,8 @@ export async function startInSignIn({ dataDir, env, home, platform = process.pla
456
456
  * hub asks (the link started it), and the entry is for the next boot.
457
457
  *
458
458
  * stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
459
- * machine offers nothing (`autostartHow`). `run` (a command runner) and `how` are parameters so
460
- * tests never reach the real systemd or Task Scheduler.
459
+ * machine offers nothing (`autostartHow`). `run` (a command runner), `how` and `mounts` (what /proc/mounts says, where WSL finds
460
+ * the Windows drive) are parameters so tests never reach the real systemd or Task Scheduler, nor depend on the drives of the machine.
461
461
  */
462
462
  export async function runAutostart({
463
463
  env,
@@ -467,6 +467,7 @@ export async function runAutostart({
467
467
  platform = process.platform,
468
468
  interop = wslInterop,
469
469
  how = autostartHow(platform, env, { interop }),
470
+ mounts = undefined,
470
471
  run = runCaptured,
471
472
  user = userInfo().username,
472
473
  write = (line) => process.stdout.write(line),
@@ -481,7 +482,7 @@ export async function runAutostart({
481
482
  }
482
483
  try {
483
484
  const name = autostartName(how, { dataDir, home, env, platform, user })
484
- const opts = { dataDir, env, home, name, run, user, platform, interop }
485
+ const opts = { dataDir, env, home, name, run, user, platform, interop, mounts }
485
486
  return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
486
487
  } catch (e) {
487
488
  return report({ ok: false, on: readAutostartMark(dataDir)?.how === how, how, linger: null, message: e?.message ?? String(e) })
@@ -8,7 +8,7 @@ import { userInfo } from 'node:os'
8
8
  import { join } from 'node:path'
9
9
  import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep } from './serve-util.mjs'
10
10
  import { SERVE_LOG_FILE, serveDataDir, ensureServeState, markStopped, rotateServeLog } from './serve-files.mjs'
11
- import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
11
+ import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslCommandLine } from './serve-windows.mjs'
12
12
  import { CHILD_FLAG } from './serve-args.mjs'
13
13
  import { startInSignIn } from './serve-autostart.mjs'
14
14
  import { stopServedHost } from './serve-handover.mjs'
@@ -72,11 +72,7 @@ export async function startDetached({ how, execPath, args, env, cwd }) {
72
72
  let commandLine
73
73
  try {
74
74
  if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
75
- else {
76
- const distro = wslDistro(env.WSL_DISTRO_NAME ?? '')
77
- const user = wslUser(userInfo().username)
78
- commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
79
- }
75
+ else commandLine = wslCommandLine({ distro: env.WSL_DISTRO_NAME ?? '', user: userInfo().username, cwd, execPath, args })
80
76
  } catch (e) {
81
77
  return { ok: false, blocked: false, message: e.message }
82
78
  }