@wpelevator/tug 0.9.0 → 0.10.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.
@@ -1,7 +1,6 @@
1
1
  import * as yaml from 'js-yaml';
2
2
  /** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
3
3
  export const HOST_NETWORK_FLAG = '--network host';
4
- export const MESH_RUN_FLAGS = `${HOST_NETWORK_FLAG} --cap-add NET_ADMIN`;
5
4
  /** Docker platform for the architecture a host reports with `uname -m`. */
6
5
  export function dockerPlatform(uname) {
7
6
  const arch = uname.trim();
@@ -18,8 +17,8 @@ export function localImageBuildArgs(imageTag, uname) {
18
17
  export function shellQuote(value) {
19
18
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
20
19
  }
21
- export function dockerRunSnippet(image, joinKey, mesh = false) {
22
- return `docker run -d --name tug-agent --restart unless-stopped ${mesh ? `${MESH_RUN_FLAGS} -e TUG_MESH=true ` : `${HOST_NETWORK_FLAG} `}-v /var/run/docker.sock:/var/run/docker.sock -v tug-agent:/var/lib/tug -e TUG_JOIN_KEY=${shellQuote(joinKey)} ${shellQuote(image)} agent`;
20
+ export function dockerRunSnippet(image, joinKey, address) {
21
+ return `docker run -d --name tug-agent --restart unless-stopped ${HOST_NETWORK_FLAG} -v /var/run/docker.sock:/var/run/docker.sock -v tug-agent:/var/lib/tug -e TUG_JOIN_KEY=${shellQuote(joinKey)} ${address ? `-e TUG_NODE_ADDRESS=${shellQuote(address)} ` : ''}${shellQuote(image)} agent`;
23
22
  }
24
23
  /** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
25
24
  export function enrollmentCloudInit(bootstrap, command) {
@@ -42,24 +41,23 @@ ss -ltn | awk '{print $4}' | grep -qE '[.:]${port}$' || exit 0
42
41
  [ -n "$(docker ps -q --filter 'name=^/${CONTROL_CONTAINER}$' --filter 'publish=${port}' 2>/dev/null)" ]
43
42
  `;
44
43
  }
45
- /** An occupied mesh port is reusable only when the installed Tug agent owns the live key. */
46
- export function meshPortCheck(port) {
47
- if (!Number.isInteger(port) || port < 1 || port > 65535)
48
- throw new Error('mesh port must be an integer from 1 to 65535');
49
- return `command -v ss >/dev/null 2>&1 || exit 1
50
- listeners=$(ss -lun) || exit 1
51
- if ! printf '%s\\n' "$listeners" | awk '{print $4}' | grep -qE '[.:]${port}$'; then exit 0; fi
52
- [ "$(docker inspect --format '{{.HostConfig.NetworkMode}}' tug-agent 2>/dev/null)" = host ] || exit 1
53
- [ "$(docker inspect --format '{{.State.Running}}' tug-agent 2>/dev/null)" = true ] || exit 1
54
- docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' tug-agent 2>/dev/null | grep -qx 'TUG_MESH=true' || exit 1
55
- [ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/var/lib/tug"}}{{.Source}}{{end}}{{end}}' tug-agent 2>/dev/null)" = /var/lib/tug ] || exit 1
56
- docker exec tug-agent sh -eu -c ${shellQuote(`
57
- test -s /var/lib/tug/wireguard/private.key
58
- test -s /var/lib/tug/wireguard/reported.pub
59
- test "$(wg show wg0 listen-port)" = "${port}"
60
- live_key=$(wg show wg0 public-key)
61
- test -n "$live_key"
62
- test "$live_key" = "$(wg pubkey < /var/lib/tug/wireguard/private.key)"
63
- test "$live_key" = "$(cat /var/lib/tug/wireguard/reported.pub)"`)}
64
- `;
44
+ /**
45
+ * The default control URL for `control-install`: the SSH target's host and the published port. Accepts
46
+ * `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
47
+ * so pass `--control-url` when the alias is not a name or address clients can reach.
48
+ */
49
+ export function defaultControlUrl(sshTarget, port) {
50
+ let host = sshTarget
51
+ .trim()
52
+ .replace(/^ssh:\/\//, '')
53
+ .replace(/\/.*$/, '');
54
+ host = host.replace(/^[^@]*@/, '');
55
+ const bracketed = /^\[([^\]]+)\]/.exec(host);
56
+ if (bracketed)
57
+ host = bracketed[1];
58
+ else if (host.split(':').length === 2)
59
+ host = host.split(':')[0];
60
+ if (!host || /[\s'"]/.test(host))
61
+ throw new Error(`cannot derive a host from SSH target ${sshTarget}`);
62
+ return `https://${host.includes(':') ? `[${host}]` : host}:${port}`;
65
63
  }
@@ -0,0 +1,29 @@
1
+ /** The control status endpoint returns database rows and a JSON-encoded node report. */
2
+ export interface StatusResponse {
3
+ projects: {
4
+ id: string;
5
+ current_deployment_id: string | null;
6
+ }[];
7
+ deployments: {
8
+ id: string;
9
+ version: string;
10
+ state: string;
11
+ error_message: string | null;
12
+ }[];
13
+ nodes: {
14
+ id: string;
15
+ name: string;
16
+ status: string;
17
+ last_seen_at: string | null;
18
+ }[];
19
+ statuses: {
20
+ project_id: string;
21
+ node_id: string;
22
+ node: string | null;
23
+ health: string;
24
+ observed_version: string | null;
25
+ status_json: string;
26
+ }[];
27
+ }
28
+ /** Render observations without treating a stale node's last report as current health. */
29
+ export declare function formatStatus(status: StatusResponse): string;
@@ -0,0 +1,61 @@
1
+ /** Render observations without treating a stale node's last report as current health. */
2
+ export function formatStatus(status) {
3
+ const lines = [];
4
+ const nodes = new Map(status.nodes.map((node) => [node.id, node]));
5
+ for (const project of [...status.projects].sort((a, b) => a.id.localeCompare(b.id))) {
6
+ const deployment = status.deployments.find((item) => item.id === project.current_deployment_id);
7
+ lines.push(`Project ${project.id}`);
8
+ if (deployment) {
9
+ lines.push(` Deployment: ${deployment.version} (${deployment.state})`);
10
+ if (deployment.error_message)
11
+ lines.push(` Error: ${deployment.error_message}`);
12
+ }
13
+ else {
14
+ lines.push(project.current_deployment_id
15
+ ? ' Deployment: current deployment not in recent results'
16
+ : ' Deployment: none');
17
+ }
18
+ const reports = status.statuses
19
+ .filter((item) => item.project_id === project.id)
20
+ .sort((a, b) => (a.node || a.node_id).localeCompare(b.node || b.node_id));
21
+ if (!reports.length)
22
+ lines.push(' No node reports');
23
+ for (const report of reports) {
24
+ const node = nodes.get(report.node_id);
25
+ lines.push(` Node ${report.node || report.node_id}: ${node?.status || 'unknown'}; last reported ${report.health}${report.observed_version ? ` (${report.observed_version})` : ''}`);
26
+ try {
27
+ const detail = JSON.parse(report.status_json);
28
+ if (detail &&
29
+ typeof detail === 'object' &&
30
+ 'error' in detail &&
31
+ typeof detail.error === 'string' &&
32
+ detail.error)
33
+ lines.push(` Error: ${detail.error}`);
34
+ if (detail &&
35
+ typeof detail === 'object' &&
36
+ 'serviceStates' in detail &&
37
+ detail.serviceStates &&
38
+ typeof detail.serviceStates === 'object') {
39
+ for (const [name, state] of Object.entries(detail.serviceStates).sort(([a], [b]) => a.localeCompare(b))) {
40
+ if (!state || typeof state !== 'object')
41
+ continue;
42
+ const item = state;
43
+ if (typeof item.rolloutState === 'string')
44
+ lines.push(` ${name}: ${item.rolloutState}${typeof item.rolloutError === 'string' && item.rolloutError ? ` — ${item.rolloutError}` : ''}`);
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ lines.push(' Report details unavailable');
50
+ }
51
+ }
52
+ }
53
+ if (!status.projects.length)
54
+ lines.push('No projects');
55
+ lines.push('', 'Nodes');
56
+ if (!status.nodes.length)
57
+ lines.push(' No nodes');
58
+ for (const node of [...status.nodes].sort((a, b) => a.name.localeCompare(b.name)))
59
+ lines.push(` ${node.name}: ${node.status}; last seen ${node.last_seen_at || 'never'}`);
60
+ return lines.join('\n');
61
+ }
package/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { isNodeHost } from "./node-address.js";
2
3
  import { Command, Option } from 'commander';
3
4
  import { openAsBlob } from 'node:fs';
4
5
  import { applyConfigDefaults, integerOption, normalizeControlUrl, nodeSettings, resolveCliConfig, writeInitConfig, } from "./cli-config.js";
5
- import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, CONTROL_CONTAINER, controlPortCheck, localImageBuildArgs, meshPortCheck, } from "./cli-install.js";
6
- import { createSourceArchive, prepareLocalImages } from "./cli-build.js";
6
+ import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, CONTROL_CONTAINER, defaultControlUrl, controlPortCheck, localImageBuildArgs, } from "./cli-install.js";
7
+ import { createLocalBuildSnapshot, createSourceArchive, prepareLocalImages } from "./cli-build.js";
7
8
  import { saveImageArchive } from "./artifacts.js";
8
9
  import { spawnSync } from 'node:child_process';
9
10
  import fs from 'node:fs/promises';
@@ -16,6 +17,7 @@ import { errMessage, loadTemplate } from "./util.js";
16
17
  import { createTransport } from "./agent-credential.js";
17
18
  import { decodeCredential, writeFileDurable } from "./pki.js";
18
19
  import { backupCheckProblems } from "./backup-check.js";
20
+ import { formatStatus } from "./cli-status.js";
19
21
  import { PROJECT_NAME_PATTERN, ROLES, CREDENTIAL_NAME_PATTERN } from "./credentials.js";
20
22
  import { packageVersion, resolveImage } from "./image.js";
21
23
  import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
@@ -85,7 +87,7 @@ function runCapture(cmd, args, opts = {}) {
85
87
  throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
86
88
  return String(result.stdout);
87
89
  }
88
- function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false, compose = false, } = {}) {
90
+ function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 1024, compose = false, } = {}) {
89
91
  const checks = [
90
92
  [
91
93
  'docker.version',
@@ -119,20 +121,6 @@ function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDis
119
121
  `Port ${port} is already in use on the target host by something other than the Tug control container; stop the conflicting service or choose another port.`,
120
122
  ]);
121
123
  }
122
- if (mesh) {
123
- checks.push([
124
- 'wireguard.kernel',
125
- 'ip link add tugwgtest type wireguard && ip link del tugwgtest || modprobe -n wireguard 2>/dev/null',
126
- 'Install WireGuard (kernel 5.6+ has it built in; older kernels need the wireguard-dkms package) and ensure iproute2 is present.',
127
- ]);
128
- }
129
- for (const port of udpPorts) {
130
- checks.push([
131
- `udp.${port}.free`,
132
- meshPortCheck(port),
133
- `UDP port ${port} is in use by something other than this node's running Tug mesh agent (or the ss tool is missing). Stop the conflicting service, or choose another port with --wg-port and set TUG_WG_PORT on control.`,
134
- ]);
135
- }
136
124
  if (controlUrl)
137
125
  checks.push([
138
126
  'control.reachable',
@@ -219,33 +207,27 @@ program
219
207
  .requiredOption('--csr <file>', 'PEM certificate request generated on the node (- for stdin)')
220
208
  .option('--mode <mode>', 'create a new node or replace an existing node key', 'create')
221
209
  .option('--address <addr>')
222
- .option('--public-address <addr>')
223
210
  .option('--label <label>', 'node label (repeatable)', collect, [])
224
211
  .action(async (name, o) => {
225
212
  if (o.mode !== 'create' && o.mode !== 'replace')
226
213
  throw new Error("--mode must be 'create' or 'replace'");
227
214
  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 });
215
+ const r = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address, labels: o.label });
229
216
  console.log(JSON.stringify(r, null, 2));
230
217
  });
218
+ program
219
+ .command('node-address <name> <address>')
220
+ .description("update a node's inter-node address")
221
+ .action(async (name, address) => {
222
+ console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/address`, { address }), null, 2));
223
+ });
231
224
  program
232
225
  .command('node-disable <name>')
233
226
  .description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node-replace-identity")
234
227
  .action(async (name) => console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {}), null, 2)));
235
- program
236
- .command('node-rotate-wg-key <name>')
237
- .description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
238
- .option('--timeout <seconds>', 'wait timeout', '60')
239
- .action(async (name, o) => {
240
- const created = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-wg-key`, {});
241
- const cmd = await waitForCommand(created.commandId, Number(o.timeout));
242
- if (cmd.state === 'failed')
243
- throw new Error(JSON.parse(cmd.result_json || '{}').error || 'key rotation failed');
244
- console.log(JSON.stringify({ ok: true, node: name, ...JSON.parse(cmd.result_json || '{}') }, null, 2));
245
- });
246
228
  program
247
229
  .command('node-remove <name>')
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')
230
+ .description('remove a retired node from the control plane (its identity, statuses and queued commands); stop its agent and remove its containers on the host yourself')
249
231
  .option('--force', 'remove even if the node is still reporting')
250
232
  .action(async (name, o) => {
251
233
  console.log(JSON.stringify(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`), null, 2));
@@ -272,7 +254,6 @@ program
272
254
  program
273
255
  .command('control-install <sshTarget>')
274
256
  .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
257
  .option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
277
258
  .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
278
259
  .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
@@ -280,9 +261,12 @@ program
280
261
  .option('--port <port>', 'published mTLS port', '8443')
281
262
  .option('--credential-out <file>', 'where to write the root credential', 'tug-root.credential.json')
282
263
  .option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
283
- .option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
284
264
  .action(async (sshTarget, o) => {
285
- const advertiseUrl = normalizeControlUrl(o.advertiseUrl);
265
+ const cfg = await loadCfg();
266
+ // The control URL clients use: --control-url, TUG_CONTROL_URL or the config's controlUrl, else the SSH target's host
267
+ // and the published port. Clients verify a name generated from the CA, so this is only an address.
268
+ const controlUrl = cfg.requestedControlUrl ?? defaultControlUrl(sshTarget, o.port);
269
+ console.error(`control URL: ${controlUrl} (${cfg.requestedControlUrl ? 'from --control-url, TUG_CONTROL_URL or the config file' : 'from the SSH target and --port'}); clients connect there by default`);
286
270
  if (o.installDocker)
287
271
  await bootstrapDocker(sshTarget, true);
288
272
  preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
@@ -292,10 +276,9 @@ program
292
276
  const yamlValue = (value) => JSON.stringify(value);
293
277
  const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
294
278
  IMAGE: yamlValue(image),
295
- ADVERTISE_URL: yamlValue(new URL(advertiseUrl).origin),
279
+ CONTROL_URL: yamlValue(new URL(controlUrl).origin),
296
280
  EXTRA_SANS: yamlValue(o.extraSan.join(',')),
297
281
  PORT: o.port,
298
- MESH: yamlValue(o.mesh ? 'true' : 'false'),
299
282
  });
300
283
  const remote = await loadTemplate(templatesDir, 'control-install.sh', {
301
284
  COMPOSE: compose,
@@ -316,7 +299,7 @@ program
316
299
  await writeFileDurable(o.credentialOut, `${String(out.stdout).trim()}\n`, 0o600);
317
300
  console.error(`root credential written to ${o.credentialOut}; import it with: tug init ${o.credentialOut}`);
318
301
  }
319
- console.log(JSON.stringify({ ok: true, sshTarget, url: advertiseUrl }, null, 2));
302
+ console.log(JSON.stringify({ ok: true, sshTarget, url: controlUrl }, null, 2));
320
303
  });
321
304
  const AGENT_STATE = '/var/lib/tug';
322
305
  function addNodeIdentityOptions(command) {
@@ -324,8 +307,6 @@ function addNodeIdentityOptions(command) {
324
307
  .option('--name <name>', 'node name')
325
308
  .option('--all', 'walk every node with an ssh target in the config file, continuing after failures')
326
309
  .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
310
  .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
330
311
  .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
331
312
  .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')
@@ -335,7 +316,9 @@ function addNodeIdentityOptions(command) {
335
316
  * The SSH-assisted flow: the node makes its own key and CSR, an admin credential authorizes the identity at this moment and
336
317
  * the certificate is installed back over SSH, so no bootstrap secret travels and the private key never leaves the node.
337
318
  */
338
- async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
319
+ async function installNodeIdentity(sshTarget, name, mode, o, repair = false,
320
+ /** False in batch mode, where only the final per-node results are printed. */
321
+ print = true) {
339
322
  const cfg = await loadCfg();
340
323
  const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
341
324
  await transportFor(cfg).then((t) => t.close()); // fail before touching the host when there is no credential
@@ -343,8 +326,6 @@ async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
343
326
  await bootstrapDocker(sshTarget);
344
327
  preflightRemote(sshTarget, {
345
328
  controlUrl: connectUrl ?? cfg.credential?.url,
346
- mesh: o.mesh,
347
- udpPorts: o.mesh ? [Number(o.wgPort)] : [],
348
329
  });
349
330
  const image = resolveImage(o.image, o.buildLocalImage);
350
331
  if (o.buildLocalImage)
@@ -358,18 +339,18 @@ async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
358
339
  ]);
359
340
  const agent = (command, interactive = false) => `mkdir -p ${AGENT_STATE} && docker run --rm ${interactive ? '-i ' : ''}-v ${AGENT_STATE}:${AGENT_STATE} ${sh(image)} agent ${command}`;
360
341
  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 });
342
+ const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address, labels: o.label });
362
343
  runCapture('ssh', ['--', sshTarget, agent('install-cert', true)], {
363
344
  input: JSON.stringify(response),
364
345
  });
365
- const env = (await loadTemplate(templatesDir, 'agent.env', {
346
+ const env = await loadTemplate(templatesDir, 'agent.env', {
366
347
  CONTROL_URL_LINE: connectUrl ? `TUG_CONTROL_URL=${connectUrl}\n` : '',
367
348
  NODE_NAME: name,
368
- })) + (o.mesh ? 'TUG_MESH=true\n' : '');
349
+ });
369
350
  const remote = await loadTemplate(templatesDir, 'node-install.sh', {
370
351
  ENV: env,
371
352
  IMAGE_SH: sh(image),
372
- RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
353
+ RUN_FLAGS: `${HOST_NETWORK_FLAG} `,
373
354
  });
374
355
  runLocal('ssh', ['--', sshTarget, remote]);
375
356
  if (o.wait) {
@@ -377,15 +358,19 @@ async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
377
358
  for (let i = 0; i < 30; i++) {
378
359
  const { nodes } = await request('GET', '/api/v1/nodes');
379
360
  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));
361
+ if (seen && seen > started) {
362
+ if (print)
363
+ console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
364
+ return;
365
+ }
382
366
  await new Promise((r) => setTimeout(r, 2000));
383
367
  }
384
368
  console.error(`node ${name} has not reported yet; check \`docker logs tug-agent\` on the host`);
385
369
  }
386
- console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
370
+ if (print)
371
+ console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
387
372
  }
388
- async function runNodeIdentity(sshArgument, o, mode, repair = false) {
373
+ async function runNodeIdentity(sshArgument, o, mode, command, repair = false) {
389
374
  const cfg = await loadCfg();
390
375
  const verb = repair
391
376
  ? 'node-repair-identity'
@@ -398,9 +383,10 @@ async function runNodeIdentity(sshArgument, o, mode, repair = false) {
398
383
  const target = settings.ssh;
399
384
  if (!target)
400
385
  continue;
401
- const merged = { ...o, ...nodeOverrides(settings), name };
386
+ const merged = { ...o, name };
387
+ applyConfigDefaults(command, merged, nodeSettings(cfg, name));
402
388
  try {
403
- await installNodeIdentity(target, name, mode, merged, repair);
389
+ await installNodeIdentity(target, name, mode, merged, repair, false);
404
390
  results.push({ node: name, ok: true });
405
391
  }
406
392
  catch (e) {
@@ -419,12 +405,6 @@ async function runNodeIdentity(sshArgument, o, mode, repair = false) {
419
405
  throw new Error(`${verb} needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
420
406
  await installNodeIdentity(sshTarget, o.name, mode, o, repair);
421
407
  }
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 } : {}),
427
- });
428
408
  for (const [verb, description, mode, repair] of [
429
409
  [
430
410
  'node-install',
@@ -449,22 +429,23 @@ for (const [verb, description, mode, repair] of [
449
429
  .command(`${verb} [sshTarget]`)
450
430
  .description(description)
451
431
  .option('--address <addr>')
452
- .option('--public-address <addr>')
453
432
  .option('--label <label>', 'node label (repeatable)', collect, []);
454
- addNodeIdentityOptions(command).action(async (sshArgument, o) => runNodeIdentity(sshArgument, o, mode, repair));
433
+ addNodeIdentityOptions(command).action(async (sshArgument, o, command) => runNodeIdentity(sshArgument, o, mode, command, repair));
455
434
  }
456
435
  program
457
436
  .command('node-prepare-enrollment')
458
437
  .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
438
  .requiredOption('--name <name>', 'the node name the key is bound to; mint one key per instance')
439
+ .option('--address <addr>', 'private inter-node address for this node')
460
440
  .option('--agent-control-url <url>', 'connect-only URL the agent dials; the certificate is still verified against the credential URL')
461
441
  .option('--ttl <seconds>', 'join key lifetime', '3600')
462
442
  .option('--image <tag>', "agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
463
- .option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
464
443
  .option('--label <label>', 'node label (repeatable)', collect, [])
465
444
  .option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
466
445
  .option('--json', 'print JSON including the command or cloud-init payload')
467
446
  .action(async (o) => {
447
+ if (o.address !== undefined && !isNodeHost(o.address))
448
+ throw new Error('address must be an IP address or hostname without a port');
468
449
  const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
469
450
  const minted = await request('POST', '/api/v1/nodes/enrollment-credential', {
470
451
  name: o.name,
@@ -478,7 +459,7 @@ program
478
459
  ...(connectUrl ? { connectUrl } : {}),
479
460
  };
480
461
  const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
481
- const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh);
462
+ const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.address);
482
463
  const cloudInit = o.installDocker
483
464
  ? enrollmentCloudInit(await dockerBootstrap(), command)
484
465
  : undefined;
@@ -758,7 +739,14 @@ program
758
739
  const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
759
740
  try {
760
741
  const form = new FormData();
761
- const localImages = await prepareLocalImages(compose, process.cwd(), directory);
742
+ const source = Object.values(compose.services).some((service) => service.build)
743
+ ? await createLocalBuildSnapshot(process.cwd(), directory, [
744
+ cfg.file,
745
+ cfg.localFile,
746
+ ...(cfg.credentialFile ? [cfg.credentialFile] : []),
747
+ ])
748
+ : process.cwd();
749
+ const localImages = await prepareLocalImages(compose, source, directory);
762
750
  for (const { field } of localImages)
763
751
  form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
764
752
  form.append('payload', JSON.stringify({ project, version, compose, localImages }));
@@ -792,7 +780,11 @@ program
792
780
  });
793
781
  program
794
782
  .command('status')
795
- .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
783
+ .option('--human', 'show a readable summary instead of JSON')
784
+ .action(async (o) => {
785
+ const status = await request('GET', '/api/v1/status');
786
+ console.log(o.human ? formatStatus(status) : JSON.stringify(status, null, 2));
787
+ });
796
788
  program
797
789
  .command('events')
798
790
  .description('list recent events with the credential that caused each control action; defaults to the resolved project')
@@ -984,7 +976,6 @@ program.hook('preAction', async (_root, command) => {
984
976
  const options = command.opts();
985
977
  for (const [key, min, max] of [
986
978
  ['port', 1, 65535],
987
- ['wgPort', 1, 65535],
988
979
  ['timeout', 1, 86400],
989
980
  ['ttl', 1, 31536000],
990
981
  ['uses', 1, 1000000],
@@ -1032,7 +1023,9 @@ program.hook('preAction', async (_root, command) => {
1032
1023
  : undefined;
1033
1024
  applyConfigDefaults(command, options, {
1034
1025
  ...resolvedConfig.commandDefaults,
1035
- ...(command.name().startsWith('node-') ? nodeSettings(resolvedConfig, nodeName) : {}),
1026
+ ...(command.name().startsWith('node-') || command.name() === 'control-install'
1027
+ ? nodeSettings(resolvedConfig, nodeName)
1028
+ : {}),
1036
1029
  });
1037
1030
  });
1038
1031
  for (const command of program.commands) {
@@ -1,4 +1,5 @@
1
1
  import type { CreateContainerBody } from './docker-api.ts';
2
+ import type { VolumeInspect } from './docker-api.ts';
2
3
  import type { DesiredState, ServiceEndpoint } from './protocol.ts';
3
4
  import type { PortBind } from './compose.ts';
4
5
  export declare const LABEL_PROJECT = "tug.project";
@@ -7,10 +8,14 @@ export declare const LABEL_JOB = "tug.job";
7
8
  export declare const LABEL_SERVICE = "tug.service";
8
9
  export declare const LABEL_REVISION = "tug.revision";
9
10
  export declare const LABEL_HASH = "tug.config-hash";
11
+ /** Never treat a Docker name alone as proof of ownership; validate its labels before reuse. */
12
+ export declare function assertVolumeOwnership(volume: VolumeInspect, project: string, name: string): void;
10
13
  /** The project network every container of the project joins; service names are its aliases. */
11
14
  export declare const networkName: (project: string) => string;
12
15
  /** Docker volume name for a Compose volume; `external` volumes keep their own name. */
13
16
  export declare const volumeName: (project: string, volume: string) => string;
17
+ /** Scratch volumes occupy a separate namespace from project-managed volumes. */
18
+ export declare const scratchVolumeName: (project: string, restore: string) => string;
14
19
  export declare const containerName: (project: string, service: string, hash: string) => string;
15
20
  export interface VolumeSpec {
16
21
  /** Docker volume name. */
@@ -9,10 +9,17 @@ export const LABEL_JOB = 'tug.job';
9
9
  export const LABEL_SERVICE = 'tug.service';
10
10
  export const LABEL_REVISION = 'tug.revision';
11
11
  export const LABEL_HASH = 'tug.config-hash';
12
+ /** Never treat a Docker name alone as proof of ownership; validate its labels before reuse. */
13
+ export function assertVolumeOwnership(volume, project, name) {
14
+ if (volume.Labels?.[LABEL_PROJECT] !== project || volume.Labels?.['tug.volume'] !== name)
15
+ throw new Error(`volume ${volume.Name} is not owned by project ${project} as volume ${name}`);
16
+ }
12
17
  /** The project network every container of the project joins; service names are its aliases. */
13
18
  export const networkName = (project) => `tug_${project}`;
14
19
  /** Docker volume name for a Compose volume; `external` volumes keep their own name. */
15
- export const volumeName = (project, volume) => `tug_${project}_${volume}`;
20
+ export const volumeName = (project, volume) => `tug_v${project.length}_${project}_${volume}`;
21
+ /** Scratch volumes occupy a separate namespace from project-managed volumes. */
22
+ export const scratchVolumeName = (project, restore) => `tug_r${project.length}_${project}_${restore}`;
16
23
  export const containerName = (project, service, hash) => `tug-${project}-${service}-${hash.slice(0, 8)}`;
17
24
  const SECOND_NS = 1_000_000_000;
18
25
  const UNITS = {
@@ -113,10 +120,12 @@ function volumeSpecs(desired, node, used) {
113
120
  for (const key of [...used].sort()) {
114
121
  const def = desired.compose.volumes?.[key] || {};
115
122
  const xe = def['x-tug'] || {};
123
+ const customLabels = { ...(def.labels || {}) };
124
+ delete customLabels['tug.restore'];
116
125
  const labels = {
126
+ ...customLabels,
117
127
  [LABEL_PROJECT]: desired.project,
118
128
  'tug.volume': key,
119
- ...(def.labels || {}),
120
129
  };
121
130
  if (def.external) {
122
131
  out.push({
@@ -2,6 +2,8 @@ export interface IssueArgs {
2
2
  name: string;
3
3
  role: string;
4
4
  replace: boolean;
5
+ /** The control URL to write into the credential; defaults to `TUG_CONTROL_URL`. */
6
+ controlUrl?: string;
5
7
  }
6
8
  export declare function parseIssueArgs(argv: string[]): IssueArgs;
7
9
  /**
@@ -25,15 +25,15 @@ export function parseIssueArgs(argv) {
25
25
  */
26
26
  export async function issueHostCredential(args) {
27
27
  const dataDir = process.env.TUG_DATA_DIR || './data';
28
- const advertiseUrl = process.env.TUG_ADVERTISE_URL;
29
- if (!advertiseUrl)
30
- throw new Error('TUG_ADVERTISE_URL is required');
28
+ const controlUrl = args.controlUrl ?? process.env.TUG_CONTROL_URL;
29
+ if (!controlUrl)
30
+ throw new Error('TUG_CONTROL_URL is required');
31
31
  const db = await openDb(path.join(dataDir, 'tug.db'));
32
32
  try {
33
33
  const fresh = !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes');
34
34
  const pki = await Pki.load(path.join(dataDir, 'pki'), fresh);
35
35
  const request = parseCredentialRequest({ name: args.name, role: args.role });
36
- const created = await createCredential(db, pki, new URL(advertiseUrl).origin, request, 'host', {
36
+ const created = await createCredential(db, pki, new URL(controlUrl).origin, request, 'host', {
37
37
  replace: args.replace,
38
38
  });
39
39
  if (args.replace)
package/dist/control.d.ts CHANGED
@@ -27,7 +27,7 @@ export declare function createApp(options?: {
27
27
  openDatabase?: typeof openDb;
28
28
  /** Minimum time between event prunes triggered by agent reports; default one hour. */ eventPruneIntervalMs?: number;
29
29
  /** Override the live open/agent startup timeout in milliseconds. */ liveStartTimeoutMs?: number;
30
- /** Credential-free HTTPS origin clients use to reach control; defaults to `TUG_ADVERTISE_URL`. */ advertiseUrl?: string;
30
+ /** Credential-free HTTPS origin clients connect to by default; defaults to `TUG_CONTROL_URL`. */ controlUrl?: string;
31
31
  /**
32
32
  * Replaces certificate-based identity resolution. Only for in-process tests that skip TLS; production never sets it.
33
33
  * It returns the principal URI and key hash a connection would have presented.