@wpelevator/tug 0.7.0 → 0.9.0

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.
Files changed (48) hide show
  1. package/README.md +26 -24
  2. package/dist/agent-credential.d.ts +39 -0
  3. package/dist/agent-credential.js +135 -0
  4. package/dist/agent.d.ts +8 -9
  5. package/dist/agent.js +100 -102
  6. package/dist/backup-config.js +1 -1
  7. package/dist/backup-destination.d.ts +1 -1
  8. package/dist/backup-destination.js +1 -1
  9. package/dist/build.d.ts +6 -0
  10. package/dist/build.js +27 -7
  11. package/dist/cli-build.js +77 -4
  12. package/dist/cli-config.d.ts +9 -5
  13. package/dist/cli-config.js +89 -34
  14. package/dist/cli-install.js +1 -1
  15. package/dist/cli-live-transport.d.ts +3 -2
  16. package/dist/cli-live-transport.js +9 -5
  17. package/dist/cli.js +266 -142
  18. package/dist/compose.d.ts +5 -1
  19. package/dist/compose.js +23 -2
  20. package/dist/control-credential.d.ts +12 -0
  21. package/dist/control-credential.js +46 -0
  22. package/dist/control.d.ts +18 -1
  23. package/dist/control.js +602 -203
  24. package/dist/credentials.d.ts +111 -0
  25. package/dist/{tokens.js → credentials.js} +70 -54
  26. package/dist/db.d.ts +20 -5
  27. package/dist/db.js +3 -3
  28. package/dist/diagnostics.d.ts +44 -0
  29. package/dist/diagnostics.js +65 -0
  30. package/dist/ingress.js +4 -3
  31. package/dist/live.d.ts +2 -0
  32. package/dist/live.js +9 -1
  33. package/dist/main.js +35 -0
  34. package/dist/mesh.d.ts +4 -0
  35. package/dist/mesh.js +17 -0
  36. package/dist/normalize.js +1 -1
  37. package/dist/pki.d.ts +130 -0
  38. package/dist/pki.js +356 -0
  39. package/dist/protocol.d.ts +51 -22
  40. package/dist/status-report.d.ts +11 -0
  41. package/dist/status-report.js +147 -0
  42. package/dist/templates/agent.env +1 -3
  43. package/dist/templates/control-compose.yml +4 -3
  44. package/dist/templates/control-install.sh +8 -2
  45. package/dist/tls-server.d.ts +11 -0
  46. package/dist/tls-server.js +23 -0
  47. package/package.json +10 -8
  48. package/dist/tokens.d.ts +0 -91
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command, Option } from 'commander';
3
3
  import { openAsBlob } from 'node:fs';
4
4
  import { applyConfigDefaults, integerOption, normalizeControlUrl, nodeSettings, resolveCliConfig, writeInitConfig, } from "./cli-config.js";
5
- import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, controlPortCheck, localImageBuildArgs, meshPortCheck, } from "./cli-install.js";
5
+ import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, CONTROL_CONTAINER, controlPortCheck, localImageBuildArgs, meshPortCheck, } from "./cli-install.js";
6
6
  import { createSourceArchive, prepareLocalImages } from "./cli-build.js";
7
7
  import { saveImageArchive } from "./artifacts.js";
8
8
  import { spawnSync } from 'node:child_process';
@@ -12,9 +12,11 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { composeHash } from "./compose.js";
14
14
  import { normalizeCompose } from "./normalize.js";
15
- import { errMessage, token, loadTemplate } from "./util.js";
15
+ import { errMessage, loadTemplate } from "./util.js";
16
+ import { createTransport } from "./agent-credential.js";
17
+ import { decodeCredential, writeFileDurable } from "./pki.js";
16
18
  import { backupCheckProblems } from "./backup-check.js";
17
- import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
19
+ import { PROJECT_NAME_PATTERN, ROLES, CREDENTIAL_NAME_PATTERN } from "./credentials.js";
18
20
  import { packageVersion, resolveImage } from "./image.js";
19
21
  import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
20
22
  import { CliLiveError, parseLiveRequest, runCliLive } from "./cli-live.js";
@@ -27,11 +29,12 @@ const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tu
27
29
  const program = new Command();
28
30
  program
29
31
  .name('tug')
30
- .description('Portable VPS deployments with Docker Compose')
32
+ .description('Deploy Docker Compose applications to your own servers')
31
33
  .version(packageVersion())
32
34
  .enablePositionalOptions()
33
35
  .option('--config <file>', CONFIG_HELP)
34
- .option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)')
36
+ .option('--control-url <url>', 'connect target for the control API (the credential still verifies its own public URL)')
37
+ .option('--credential <file>', 'credential file (overrides TUG_CREDENTIAL, TUG_CREDENTIAL_FILE and config)')
35
38
  .option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
36
39
  let resolvedConfig;
37
40
  async function loadCfg() {
@@ -44,22 +47,32 @@ async function readStdin() {
44
47
  chunks.push(Buffer.from(chunk));
45
48
  return Buffer.concat(chunks).toString('utf8');
46
49
  }
50
+ /** The pinned mTLS transport for the resolved credential; an address override only changes the connect target. */
51
+ async function transportFor(c) {
52
+ if (!c.credential)
53
+ throw new Error('no credential: run `tug init <credential-file>`, set TUG_CREDENTIAL_FILE or pass --credential');
54
+ return createTransport(c.credential, c.controlUrl);
55
+ }
47
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
57
  async function request(method, route, body, multipart = false) {
49
58
  const c = await loadCfg();
50
- const base = c.controlUrl;
51
- const tok = c.token;
52
- const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
59
+ const transport = await transportFor(c);
60
+ const headers = {};
53
61
  if (!multipart)
54
62
  headers['content-type'] = 'application/json';
55
- const res = await fetch(`${base}${route}`, {
56
- method,
57
- headers,
58
- body: multipart ? body : body ? JSON.stringify(body) : undefined,
59
- });
60
- if (!res.ok)
61
- throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
62
- return res.json();
63
+ try {
64
+ const res = await transport.http(`${transport.base}${route}`, {
65
+ method,
66
+ headers,
67
+ body: multipart ? body : body ? JSON.stringify(body) : undefined,
68
+ });
69
+ if (!res.ok)
70
+ throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
71
+ return (await res.json());
72
+ }
73
+ finally {
74
+ void transport.close();
75
+ }
63
76
  }
64
77
  function runLocal(cmd, args, opts = {}) {
65
78
  const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
@@ -72,13 +85,6 @@ function runCapture(cmd, args, opts = {}) {
72
85
  throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
73
86
  return String(result.stdout);
74
87
  }
75
- function remoteHost(sshTarget) {
76
- return sshTarget.replace(/^[^@]+@/, '').split(':')[0];
77
- }
78
- function assertHttps(url, allowHttp) {
79
- if (!allowHttp && !String(url).startsWith('https://'))
80
- throw new Error(`${url} must use https:// outside local development (pass --http-only-dev to override)`);
81
- }
82
88
  function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false, compose = false, } = {}) {
83
89
  const checks = [
84
90
  [
@@ -130,8 +136,9 @@ function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDis
130
136
  if (controlUrl)
131
137
  checks.push([
132
138
  'control.reachable',
133
- `curl -fsS ${sh(controlUrl)}/healthz >/dev/null`,
134
- 'Verify DNS/firewall/TLS for the control URL from the node.',
139
+ // TCP reachability only: no credential is copied to the host, and real TLS authorization happens when the agent connects.
140
+ `timeout 5 bash -c ${sh(`exec 3<>/dev/tcp/${new URL(controlUrl).hostname.replace(/^\[|\]$/g, '')}/${new URL(controlUrl).port || 443}`)}`,
141
+ 'Verify DNS and the firewall from the node to the control port (control speaks mTLS only, so only TCP reachability is checked here).',
135
142
  ]);
136
143
  const script = `set -e
137
144
  ${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
@@ -182,33 +189,49 @@ async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
182
189
  throw new Error(`timed out waiting for deployment ${deploymentId}`);
183
190
  }
184
191
  program
185
- .command('init')
186
- .option('--token <token>', 'operator token (prefer TUG_TOKEN to avoid process-list exposure)')
187
- .description('create the config file if missing and store the operator token in its git-ignored .local overlay')
188
- .action(async (o, command) => {
192
+ .command('init [credentialFile]')
193
+ .description('create the config file if missing and import a credential (a file, or - for stdin) into a 0600 user-config file that the git-ignored .local overlay points at')
194
+ .action(async (credentialFile, _o, command) => {
189
195
  const cfg = await loadCfg();
190
196
  const { controlUrl, project } = command.optsWithGlobals();
191
- for (const file of await writeInitConfig(cfg, o.token || cfg.token || token('tug_op'), {
192
- controlUrl,
193
- project,
194
- }))
197
+ let text;
198
+ if (credentialFile === '-')
199
+ text = await readStdin();
200
+ else if (credentialFile)
201
+ text = await fs.readFile(credentialFile, 'utf8');
202
+ else if (cfg.credential)
203
+ text = JSON.stringify(cfg.credential);
204
+ if (!text)
205
+ throw new Error('tug init needs a credential file (or - for stdin)');
206
+ const credential = decodeCredential(text);
207
+ // TUG_CREDENTIALS_DIR keeps scripts and tests out of the real home directory.
208
+ const dir = process.env.TUG_CREDENTIALS_DIR ||
209
+ path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'tug', 'credentials');
210
+ const source = path.join(dir, `${new URL(credential.url).host.replace(/[^A-Za-z0-9.-]/g, '_')}.json`);
211
+ await writeFileDurable(source, `${JSON.stringify(credential, null, 2)}\n`, 0o600);
212
+ for (const file of await writeInitConfig(cfg, source, { controlUrl, project }))
195
213
  console.log(`wrote ${file}`);
214
+ console.log(`imported credential to ${source}`);
196
215
  });
197
216
  program
198
217
  .command('node-register <name>')
199
- .description('register a node with the control plane')
218
+ .description('sign a node-generated CSR and print the identity response (the primitive behind node-install; the private key stays with whoever made the CSR)')
219
+ .requiredOption('--csr <file>', 'PEM certificate request generated on the node (- for stdin)')
220
+ .option('--mode <mode>', 'create a new node or replace an existing node key', 'create')
200
221
  .option('--address <addr>')
201
222
  .option('--public-address <addr>')
202
223
  .option('--label <label>', 'node label (repeatable)', collect, [])
203
224
  .action(async (name, o) => {
204
- const r = await request('POST', '/api/v1/nodes/register', {
205
- name,
206
- address: o.address,
207
- publicAddress: o.publicAddress,
208
- labels: o.label,
209
- });
225
+ if (o.mode !== 'create' && o.mode !== 'replace')
226
+ throw new Error("--mode must be 'create' or 'replace'");
227
+ const csr = o.csr === '-' ? await readStdin() : await fs.readFile(o.csr, 'utf8');
228
+ const r = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address, publicAddress: o.publicAddress, labels: o.label });
210
229
  console.log(JSON.stringify(r, null, 2));
211
230
  });
231
+ program
232
+ .command('node-disable <name>')
233
+ .description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node-replace-identity")
234
+ .action(async (name) => console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {}), null, 2)));
212
235
  program
213
236
  .command('node-rotate-wg-key <name>')
214
237
  .description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
@@ -222,8 +245,8 @@ program
222
245
  });
223
246
  program
224
247
  .command('node-remove <name>')
225
- .description('remove a retired node from control (its agent token, WireGuard peer and queued commands)')
226
- .option('--force', 'remove even if the agent is still reporting')
248
+ .description('remove a retired node from the control plane (its identity, WireGuard peer, statuses and queued commands); stop its agent and remove its containers on the host yourself')
249
+ .option('--force', 'remove even if the node is still reporting')
227
250
  .action(async (name, o) => {
228
251
  console.log(JSON.stringify(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`), null, 2));
229
252
  });
@@ -246,21 +269,20 @@ program
246
269
  throw new Error('replicas must be a positive integer');
247
270
  console.log(JSON.stringify(await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) }), null, 2));
248
271
  });
249
- program
250
- .command('control-token')
251
- .description('print local dev operator token if available')
252
- .action(async () => console.log((await fs.readFile('./data/operator.token', 'utf8')).trim()));
253
272
  program
254
273
  .command('control-install <sshTarget>')
255
- .description('install the control plane on a Docker-capable host over SSH; front it with your own reverse proxy/TLS if exposing it publicly')
274
+ .description('install the control plane on a Docker-capable host over SSH and issue the root credential into a local 0600 file; it listens for mTLS only, so front it with TCP passthrough rather than a terminating proxy')
275
+ .requiredOption('--advertise-url <url>', 'credential-free https origin clients use, including the published port')
276
+ .option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
256
277
  .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
257
278
  .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
258
279
  .option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
259
- .option('--token <token>', 'operator token')
260
- .option('--port <port>', 'published API port', '8080')
280
+ .option('--port <port>', 'published mTLS port', '8443')
281
+ .option('--credential-out <file>', 'where to write the root credential', 'tug-root.credential.json')
282
+ .option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
261
283
  .option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
262
284
  .action(async (sshTarget, o) => {
263
- const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
285
+ const advertiseUrl = normalizeControlUrl(o.advertiseUrl);
264
286
  if (o.installDocker)
265
287
  await bootstrapDocker(sshTarget, true);
266
288
  preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
@@ -270,63 +292,79 @@ program
270
292
  const yamlValue = (value) => JSON.stringify(value);
271
293
  const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
272
294
  IMAGE: yamlValue(image),
273
- OPERATOR_TOKEN: yamlValue(operatorToken),
295
+ ADVERTISE_URL: yamlValue(new URL(advertiseUrl).origin),
296
+ EXTRA_SANS: yamlValue(o.extraSan.join(',')),
274
297
  PORT: o.port,
275
298
  MESH: yamlValue(o.mesh ? 'true' : 'false'),
276
299
  });
277
300
  const remote = await loadTemplate(templatesDir, 'control-install.sh', {
278
301
  COMPOSE: compose,
279
- OPERATOR_TOKEN_SH: sh(operatorToken),
302
+ CONTAINER_SH: sh(CONTROL_CONTAINER),
280
303
  });
281
304
  runLocal('ssh', ['--', sshTarget, remote]);
282
- console.log(JSON.stringify({
283
- ok: true,
284
- sshTarget,
285
- operatorToken,
286
- url: `http://${remoteHost(sshTarget)}:${o.port}`,
287
- }, null, 2));
305
+ // Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to a file.
306
+ const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
307
+ const out = spawnSync('ssh', ['--', sshTarget, issue], { encoding: 'utf8' });
308
+ if (out.status !== 0) {
309
+ if (!o.recoverRoot && /already used/.test(String(out.stderr)))
310
+ console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
311
+ else
312
+ throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
313
+ }
314
+ else {
315
+ decodeCredential(out.stdout);
316
+ await writeFileDurable(o.credentialOut, `${String(out.stdout).trim()}\n`, 0o600);
317
+ console.error(`root credential written to ${o.credentialOut}; import it with: tug init ${o.credentialOut}`);
318
+ }
319
+ console.log(JSON.stringify({ ok: true, sshTarget, url: advertiseUrl }, null, 2));
288
320
  });
289
- program
290
- .command('node-install [sshTarget]')
291
- .description('register and install an agent container on a Docker-capable host over SSH (the SSH target defaults to nodes.<name>.ssh in the config file)')
292
- .requiredOption('--name <name>')
293
- .option('--address <addr>')
294
- .option('--public-address <addr>')
295
- .option('--label <label>', 'node label (repeatable)', collect, [])
296
- .option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
297
- .option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
298
- .option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
299
- .option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
300
- .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
301
- .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
302
- .option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
303
- .action(async (sshArgument, o) => {
321
+ const AGENT_STATE = '/var/lib/tug';
322
+ function addNodeIdentityOptions(command) {
323
+ return command
324
+ .option('--name <name>', 'node name')
325
+ .option('--all', 'walk every node with an ssh target in the config file, continuing after failures')
326
+ .option('--agent-control-url <url>', 'connect-only URL the agent dials (for example a private address); the certificate is still verified against the credential URL')
327
+ .option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
328
+ .option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
329
+ .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
330
+ .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
331
+ .option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
332
+ .option('--no-wait', 'do not wait for the node to report after the install');
333
+ }
334
+ /**
335
+ * The SSH-assisted flow: the node makes its own key and CSR, an admin credential authorizes the identity at this moment and
336
+ * the certificate is installed back over SSH, so no bootstrap secret travels and the private key never leaves the node.
337
+ */
338
+ async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
304
339
  const cfg = await loadCfg();
305
- const sshTarget = sshArgument ?? nodeSettings(cfg, o.name).ssh;
306
- if (!sshTarget)
307
- throw new Error(`node-install needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
308
- const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
309
- assertHttps(controlUrl, o.httpOnlyDev);
340
+ const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
341
+ await transportFor(cfg).then((t) => t.close()); // fail before touching the host when there is no credential
310
342
  if (o.installDocker)
311
343
  await bootstrapDocker(sshTarget);
312
344
  preflightRemote(sshTarget, {
313
- controlUrl,
345
+ controlUrl: connectUrl ?? cfg.credential?.url,
314
346
  mesh: o.mesh,
315
347
  udpPorts: o.mesh ? [Number(o.wgPort)] : [],
316
348
  });
317
- const registered = await request('POST', '/api/v1/nodes/register', {
318
- name: o.name,
319
- address: o.address,
320
- publicAddress: o.publicAddress,
321
- labels: o.label,
322
- });
323
349
  const image = resolveImage(o.image, o.buildLocalImage);
324
350
  if (o.buildLocalImage)
325
351
  await transferTugImage(sshTarget, image);
352
+ const started = new Date().toISOString();
353
+ if (repair)
354
+ runLocal('ssh', [
355
+ '--',
356
+ sshTarget,
357
+ `mkdir -p ${AGENT_STATE} && cd ${AGENT_STATE} && for f in agent.json agent-pending.json; do [ -e "$f" ] && mv "$f" "$f.corrupt-$(date +%s)"; done; true`,
358
+ ]);
359
+ const agent = (command, interactive = false) => `mkdir -p ${AGENT_STATE} && docker run --rm ${interactive ? '-i ' : ''}-v ${AGENT_STATE}:${AGENT_STATE} ${sh(image)} agent ${command}`;
360
+ const csr = runCapture('ssh', ['--', sshTarget, agent('identity')]);
361
+ const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address, publicAddress: o.publicAddress, labels: o.label });
362
+ runCapture('ssh', ['--', sshTarget, agent('install-cert', true)], {
363
+ input: JSON.stringify(response),
364
+ });
326
365
  const env = (await loadTemplate(templatesDir, 'agent.env', {
327
- CONTROL_URL: controlUrl,
328
- NODE_NAME: o.name,
329
- AGENT_TOKEN: registered.agentToken,
366
+ CONTROL_URL_LINE: connectUrl ? `TUG_CONTROL_URL=${connectUrl}\n` : '',
367
+ NODE_NAME: name,
330
368
  })) + (o.mesh ? 'TUG_MESH=true\n' : '');
331
369
  const remote = await loadTemplate(templatesDir, 'node-install.sh', {
332
370
  ENV: env,
@@ -334,54 +372,131 @@ program
334
372
  RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
335
373
  });
336
374
  runLocal('ssh', ['--', sshTarget, remote]);
337
- console.log(JSON.stringify({ ok: true, node: registered.node }, null, 2));
375
+ if (o.wait) {
376
+ // A report from the new identity: wait for a report after the install began, not a replaced node's stale status.
377
+ for (let i = 0; i < 30; i++) {
378
+ const { nodes } = await request('GET', '/api/v1/nodes');
379
+ const seen = nodes.find((n) => n.name === name)?.last_seen_at;
380
+ if (seen && seen > started)
381
+ return void console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
382
+ await new Promise((r) => setTimeout(r, 2000));
383
+ }
384
+ console.error(`node ${name} has not reported yet; check \`docker logs tug-agent\` on the host`);
385
+ }
386
+ console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
387
+ }
388
+ async function runNodeIdentity(sshArgument, o, mode, repair = false) {
389
+ const cfg = await loadCfg();
390
+ const verb = repair
391
+ ? 'node-repair-identity'
392
+ : mode === 'create'
393
+ ? 'node-install'
394
+ : 'node-replace-identity';
395
+ if (o.all) {
396
+ const results = [];
397
+ for (const [name, settings] of Object.entries(cfg.nodes)) {
398
+ const target = settings.ssh;
399
+ if (!target)
400
+ continue;
401
+ const merged = { ...o, ...nodeOverrides(settings), name };
402
+ try {
403
+ await installNodeIdentity(target, name, mode, merged, repair);
404
+ results.push({ node: name, ok: true });
405
+ }
406
+ catch (e) {
407
+ results.push({ node: name, ok: false, error: errMessage(e) });
408
+ }
409
+ }
410
+ console.log(JSON.stringify({ results }, null, 2));
411
+ if (results.some((r) => !r.ok))
412
+ process.exitCode = 1;
413
+ return;
414
+ }
415
+ if (!o.name)
416
+ throw new Error(`${verb} needs --name`);
417
+ const sshTarget = sshArgument ?? nodeSettings(cfg, o.name).ssh;
418
+ if (!sshTarget)
419
+ throw new Error(`${verb} needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
420
+ await installNodeIdentity(sshTarget, o.name, mode, o, repair);
421
+ }
422
+ const nodeOverrides = (settings) => ({
423
+ ...(settings.address ? { address: settings.address } : {}),
424
+ ...(settings.publicAddress ? { publicAddress: settings.publicAddress } : {}),
425
+ ...(Array.isArray(settings.label) ? { label: settings.label } : {}),
426
+ ...(settings.mesh ? { mesh: true } : {}),
338
427
  });
428
+ for (const [verb, description, mode, repair] of [
429
+ [
430
+ 'node-install',
431
+ 'create a node and install its agent over SSH (the SSH target defaults to nodes.<name>.ssh in the config file); safe to rerun after a partial failure',
432
+ 'create',
433
+ false,
434
+ ],
435
+ [
436
+ 'node-replace-identity',
437
+ 'install a new key on an existing node over SSH: for an expired, lost or suspected key, a disabled node, a CA loss or an older restore',
438
+ 'replace',
439
+ false,
440
+ ],
441
+ [
442
+ 'node-repair-identity',
443
+ 'move aside corrupt agent credential documents on the node (never service volumes) and replace its identity',
444
+ 'replace',
445
+ true,
446
+ ],
447
+ ]) {
448
+ const command = program
449
+ .command(`${verb} [sshTarget]`)
450
+ .description(description)
451
+ .option('--address <addr>')
452
+ .option('--public-address <addr>')
453
+ .option('--label <label>', 'node label (repeatable)', collect, []);
454
+ addNodeIdentityOptions(command).action(async (sshArgument, o) => runNodeIdentity(sshArgument, o, mode, repair));
455
+ }
339
456
  program
340
- .command('node-enroll')
341
- .description('mint a single-use enrollment key so a fresh host can self-join with one docker run (no SSH)')
342
- .option('--name <name>', 'pre-bind the node name (default: the agent uses its own hostname)')
343
- .option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
344
- .option('--ttl <seconds>', 'enrollment key lifetime', '3600')
345
- .option('--uses <n>', 'how many hosts may enroll with this key', '1')
346
- .option('--ca <file>', 'PEM CA cert to embed so agents trust a private/self-signed control')
347
- .option('--http-only-dev', 'allow a plaintext HTTP control URL for local/private development')
457
+ .command('node-prepare-enrollment')
458
+ .description('mint a single-use, name-bound join key so a host you cannot SSH to can self-join with one docker run; a saved cloud-init document contains a credential')
459
+ .requiredOption('--name <name>', 'the node name the key is bound to; mint one key per instance')
460
+ .option('--agent-control-url <url>', 'connect-only URL the agent dials; the certificate is still verified against the credential URL')
461
+ .option('--ttl <seconds>', 'join key lifetime', '3600')
348
462
  .option('--image <tag>', "agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
349
463
  .option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
350
464
  .option('--label <label>', 'node label (repeatable)', collect, [])
351
465
  .option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
352
466
  .option('--json', 'print JSON including the command or cloud-init payload')
353
467
  .action(async (o) => {
354
- const cfg = await loadCfg();
355
- const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
356
- assertHttps(controlUrl, o.httpOnlyDev);
357
- const ca = o.ca ? await fs.readFile(o.ca, 'utf8') : undefined;
358
- const minted = await request('POST', '/api/v1/nodes/enroll-token', {
359
- name: o.name || null,
468
+ const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
469
+ const minted = await request('POST', '/api/v1/nodes/enrollment-credential', {
470
+ name: o.name,
360
471
  ttlSeconds: Number(o.ttl),
361
- uses: Number(o.uses),
472
+ labels: o.label.length ? o.label : undefined,
362
473
  });
363
474
  const keyPayload = {
364
- v: 1,
365
- controlUrl,
366
- enrollToken: minted.enrollToken,
367
- ...(o.label.length ? { labels: o.label } : {}),
475
+ v: 2,
476
+ credential: minted.credential,
477
+ name: minted.name,
478
+ ...(connectUrl ? { connectUrl } : {}),
368
479
  };
369
- if (ca)
370
- keyPayload.ca = ca;
371
480
  const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
372
481
  const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh);
373
482
  const cloudInit = o.installDocker
374
483
  ? enrollmentCloudInit(await dockerBootstrap(), command)
375
484
  : undefined;
376
485
  if (o.json) {
377
- console.log(JSON.stringify({ ...minted, controlUrl, joinKey, command, ...(cloudInit ? { cloudInit } : {}) }, null, 2));
486
+ console.log(JSON.stringify({
487
+ name: minted.name,
488
+ expiresAt: minted.expiresAt,
489
+ joinKey,
490
+ command,
491
+ ...(cloudInit ? { cloudInit } : {}),
492
+ }, null, 2));
378
493
  return;
379
494
  }
380
495
  if (cloudInit) {
381
496
  process.stdout.write(cloudInit);
382
497
  return;
383
498
  }
384
- console.log(`# Enrollment key valid until ${minted.expiresAt} (${minted.usesRemaining} use(s)). Run on each fresh host:\n`);
499
+ console.log(`# Join key for ${minted.name} valid until ${minted.expiresAt}, single use. Run on the fresh host:\n`);
385
500
  console.log(command);
386
501
  });
387
502
  program
@@ -574,47 +689,48 @@ program
574
689
  .description('remove the backup destination and its stored credentials; existing backups stay in the repository')
575
690
  .action(async () => console.log(JSON.stringify(await request('DELETE', '/api/v1/backup/destination'))));
576
691
  program
577
- .command('backup-key')
692
+ .command('backup-repository-password')
578
693
  .description("print the project's repository and its password for offline escrow; anyone holding the password and destination credentials can read the project's backups")
579
694
  .action(async () => {
580
695
  const c = await loadCfg();
581
696
  const project = c.project || 'site';
582
- console.log(JSON.stringify(await request('GET', `/api/v1/backup/key?project=${encodeURIComponent(project)}`), null, 2));
697
+ console.log(JSON.stringify(await request('GET', `/api/v1/backup/repository-password?project=${encodeURIComponent(project)}`), null, 2));
583
698
  });
584
699
  program
585
- .command('token-create <name>')
586
- .description('create a named API token for CI or a teammate; the secret is printed once and cannot be shown again')
700
+ .command('credential-create <name>')
701
+ .description('create a named operator credential for CI or a teammate; it is printed once, control keeps no copy of its private key, and it expires after one year at most')
587
702
  .addOption(new Option('--role <role>', 'read: status and listings; deploy: read plus deploy, rollback, cancel, restart, logs; admin: everything')
588
703
  .choices(ROLES)
589
704
  .makeOptionMandatory())
590
- .addOption(new Option('--project <name>', 'limit a read or deploy token to this project; repeat for several')
705
+ .addOption(new Option('--project <name>', 'limit a read or deploy credential to this project; repeat for several')
591
706
  .default([], 'all projects')
592
707
  .argParser((value, previous) => [...previous, value]))
593
- .option('--expires-in-days <n>', 'expire the token after this many days (default: never)')
708
+ .option('--expires-in-days <n>', 'expire the credential after this many days (default and maximum: 365)')
594
709
  .action(async (name, o) => {
595
- const created = await request('POST', '/api/v1/tokens', {
710
+ const created = await request('POST', '/api/v1/credentials', {
596
711
  name,
597
712
  role: o.role,
598
713
  projects: o.project.length ? o.project : undefined,
599
714
  expiresInSeconds: o.expiresInDays ? Number(o.expiresInDays) * 86400 : undefined,
600
715
  });
601
- console.error('Store this token now (for example as the TUG_TOKEN secret in CI); it is not shown again.');
716
+ console.error('Store this credential now (for example as the TUG_CREDENTIAL secret in CI); it is not shown again. Replace one by creating a new name, verifying it, then revoking the old name.');
602
717
  console.log(JSON.stringify(created, null, 2));
603
718
  });
604
719
  program
605
- .command('token-list')
606
- .description('list API tokens with role, projects, expiry and last use; secrets are never shown')
607
- .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/tokens'), null, 2)));
720
+ .command('credential-list')
721
+ .description('list operator credentials with role, projects, expiry and last use; secrets are never shown')
722
+ .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/credentials'), null, 2)));
608
723
  program
609
- .command('token-revoke <name>')
610
- .description('revoke an API token immediately; its name stays reserved for the audit trail')
611
- .action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/tokens/${encodeURIComponent(name)}`), null, 2)));
724
+ .command('credential-revoke <name>')
725
+ .description('revoke an operator credential immediately; its name stays reserved for the audit trail')
726
+ .action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(name)}`), null, 2)));
612
727
  async function makeContext() {
613
728
  const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
614
729
  try {
615
730
  await createSourceArchive(process.cwd(), tmp, [
616
731
  resolvedConfig.file,
617
732
  resolvedConfig.localFile,
733
+ ...(resolvedConfig.credentialFile ? [resolvedConfig.credentialFile] : []),
618
734
  ]);
619
735
  }
620
736
  catch (error) {
@@ -679,9 +795,9 @@ program
679
795
  .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
680
796
  program
681
797
  .command('events')
682
- .description('list recent events with the token that caused each control action; defaults to the resolved project')
798
+ .description('list recent events with the credential that caused each control action; defaults to the resolved project')
683
799
  .option('--limit <n>', 'number of events', '100')
684
- .option('--all', 'list events across all projects (limited to your projects for scoped tokens)')
800
+ .option('--all', 'list events across all projects (limited to your projects for scoped credentials)')
685
801
  .action(async (o) => {
686
802
  if (o.all && o.project)
687
803
  throw new Error('--all and --project cannot be combined');
@@ -692,9 +808,9 @@ program
692
808
  });
693
809
  program
694
810
  .command('doctor')
695
- .description('run local and control-plane diagnostics')
696
- .option('--nodes', 'also run diagnostics on every registered agent and wait')
697
- .option('--node <name>', 'run diagnostics on one agent and wait')
811
+ .description('check this machine and the control plane: certificate expiry, node connectivity and clock skew')
812
+ .option('--nodes', 'also run host diagnostics on every node through its agent and wait')
813
+ .option('--node <name>', 'run host diagnostics on one node through its agent and wait')
698
814
  .option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
699
815
  .action(async (o) => {
700
816
  const local = [];
@@ -711,8 +827,10 @@ program
711
827
  }
712
828
  }
713
829
  const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
830
+ const warnings = (controlDiagnostics.checks ?? []).flatMap((check) => (check.warnings ?? []).map((warning) => `${check.name}: ${warning}`));
714
831
  const output = {
715
832
  ok: local.every((c) => c.ok) && controlDiagnostics.ok,
833
+ ...(warnings.length && { warnings }),
716
834
  local,
717
835
  control: controlDiagnostics,
718
836
  };
@@ -765,11 +883,11 @@ program
765
883
  });
766
884
  program
767
885
  .command('control-backup')
768
- .description('create a control-plane SQLite/master-key backup')
886
+ .description('create a control-plane backup (state, PKI, master key and retained image archives; not workload volumes)')
769
887
  .action(async () => console.log(JSON.stringify(await request('POST', '/api/v1/control/backups/run', {}), null, 2)));
770
888
  program
771
889
  .command('control-restore <artifactRef>')
772
- .description('restore a control-plane backup; requires control restart')
890
+ .description('restore a control-plane backup on the control-plane host; requires a control restart')
773
891
  .option('--confirm')
774
892
  .action(async (artifactRef, o) => console.log(JSON.stringify(await request('POST', '/api/v1/control/restore', {
775
893
  artifactRef,
@@ -799,7 +917,7 @@ program
799
917
  });
800
918
  async function runLive(operation, input) {
801
919
  const cfg = await loadCfg();
802
- process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.controlUrl, cfg.token));
920
+ process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.credential, cfg.controlUrl));
803
921
  }
804
922
  program
805
923
  .command('exec <service> [command...]')
@@ -835,7 +953,7 @@ program
835
953
  });
836
954
  program
837
955
  .command('logs <service>')
838
- .description('fetch logs from the assigned node agent')
956
+ .description('fetch service container logs from the node that runs the service')
839
957
  .option('--tail <n>', 'number of lines', '200')
840
958
  .option('-f, --follow', 'follow live logs')
841
959
  .option('--node <name>', 'select a node for followed logs')
@@ -888,15 +1006,15 @@ program.hook('preAction', async (_root, command) => {
888
1006
  if (options.name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(options.name))
889
1007
  throw new Error('--name must contain only letters, numbers, dots, underscores or hyphens');
890
1008
  const connection = command.optsWithGlobals();
891
- if (command.name() === 'token-create') {
892
- // --project is repeatable here and scopes the new token, so it must not also select the connection project.
893
- if (!TOKEN_NAME_PATTERN.test(command.args[0]))
894
- throw new Error('token name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
1009
+ if (command.name() === 'credential-create') {
1010
+ // --project is repeatable here and scopes the new credential, so it must not also select the connection project.
1011
+ if (!CREDENTIAL_NAME_PATTERN.test(command.args[0]))
1012
+ throw new Error('credential name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
895
1013
  for (const project of options.project)
896
1014
  if (!PROJECT_NAME_PATTERN.test(project))
897
1015
  throw new Error('--project must contain only letters, numbers, dots, underscores or hyphens');
898
1016
  if (options.role === 'admin' && options.project.length)
899
- throw new Error('admin tokens cannot be limited to projects; use --role read or --role deploy with --project');
1017
+ throw new Error('admin credentials cannot be limited to projects; use --role read or --role deploy with --project');
900
1018
  connection.project = undefined;
901
1019
  }
902
1020
  resolvedConfig = await resolveCliConfig(connection, process.env, process.cwd(), command.name() === 'init');
@@ -904,7 +1022,12 @@ program.hook('preAction', async (_root, command) => {
904
1022
  // commands that create or register one, never by commands that talk to an existing node.
905
1023
  const nodeName = command.name() === 'node-register'
906
1024
  ? command.args[0]
907
- : ['node-install', 'node-enroll'].includes(command.name())
1025
+ : [
1026
+ 'node-install',
1027
+ 'node-replace-identity',
1028
+ 'node-repair-identity',
1029
+ 'node-prepare-enrollment',
1030
+ ].includes(command.name())
908
1031
  ? options.name
909
1032
  : undefined;
910
1033
  applyConfigDefaults(command, options, {
@@ -915,7 +1038,8 @@ program.hook('preAction', async (_root, command) => {
915
1038
  for (const command of program.commands) {
916
1039
  command
917
1040
  .option('--config <file>', CONFIG_HELP)
918
- .option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)');
1041
+ .option('--control-url <url>', 'connect target for the control API (the credential still verifies its own public URL)')
1042
+ .option('--credential <file>', 'credential file (overrides TUG_CREDENTIAL, TUG_CREDENTIAL_FILE and config)');
919
1043
  if (!command.options.some((option) => option.long === '--project'))
920
1044
  command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
921
1045
  }