@wpelevator/tug 0.8.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.
Files changed (45) hide show
  1. package/README.md +21 -15
  2. package/dist/agent.d.ts +5 -7
  3. package/dist/agent.js +44 -66
  4. package/dist/backup-job.js +17 -5
  5. package/dist/backup-restore.js +4 -4
  6. package/dist/backup-scheduler.js +2 -0
  7. package/dist/build.d.ts +6 -0
  8. package/dist/build.js +27 -7
  9. package/dist/cli-build.d.ts +2 -0
  10. package/dist/cli-build.js +22 -2
  11. package/dist/cli-config.d.ts +4 -1
  12. package/dist/cli-config.js +28 -23
  13. package/dist/cli-install.d.ts +7 -4
  14. package/dist/cli-install.js +21 -23
  15. package/dist/cli-status.d.ts +29 -0
  16. package/dist/cli-status.js +61 -0
  17. package/dist/cli.js +60 -66
  18. package/dist/compose.d.ts +2 -0
  19. package/dist/compose.js +9 -0
  20. package/dist/container-spec.d.ts +5 -0
  21. package/dist/container-spec.js +11 -2
  22. package/dist/control-credential.d.ts +2 -0
  23. package/dist/control-credential.js +4 -4
  24. package/dist/control.d.ts +1 -1
  25. package/dist/control.js +109 -141
  26. package/dist/credentials.js +1 -1
  27. package/dist/db.d.ts +0 -10
  28. package/dist/db.js +1 -2
  29. package/dist/ingress.d.ts +1 -2
  30. package/dist/ingress.js +1 -1
  31. package/dist/live.d.ts +2 -0
  32. package/dist/live.js +9 -1
  33. package/dist/main.js +1 -1
  34. package/dist/node-address.d.ts +2 -0
  35. package/dist/node-address.js +6 -0
  36. package/dist/pki.d.ts +14 -3
  37. package/dist/pki.js +24 -7
  38. package/dist/protocol.d.ts +2 -34
  39. package/dist/reconciler.js +40 -7
  40. package/dist/runners.d.ts +0 -6
  41. package/dist/runners.js +1 -6
  42. package/dist/templates/control-compose.yml +1 -2
  43. package/package.json +1 -1
  44. package/dist/mesh.d.ts +0 -70
  45. package/dist/mesh.js +0 -202
@@ -8,7 +8,18 @@ import { errMessage } from "./util.js";
8
8
  export const COMMAND_SETTINGS = {
9
9
  file: { type: 'path', flag: '--file', commands: ['deploy'] },
10
10
  buildLocal: { type: 'boolean', flag: '--build-local', commands: ['deploy'] },
11
- wait: { type: 'boolean', flag: '--no-wait', commands: ['deploy', 'rollback'] },
11
+ wait: {
12
+ type: 'boolean',
13
+ flag: '--no-wait',
14
+ commands: [
15
+ 'deploy',
16
+ 'rollback',
17
+ 'backup-run',
18
+ 'node-install',
19
+ 'node-replace-identity',
20
+ 'node-repair-identity',
21
+ ],
22
+ },
12
23
  };
13
24
  /** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
14
25
  export const NODE_SETTINGS = {
@@ -17,32 +28,28 @@ export const NODE_SETTINGS = {
17
28
  flag: '<ssh-target>',
18
29
  commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
19
30
  },
20
- address: { type: 'string', flag: '--address', commands: ['node-install', 'node-register'] },
21
- publicAddress: {
31
+ address: {
22
32
  type: 'string',
23
- flag: '--public-address',
24
- commands: ['node-install', 'node-register'],
33
+ flag: '--address',
34
+ commands: [
35
+ 'node-install',
36
+ 'node-replace-identity',
37
+ 'node-repair-identity',
38
+ 'node-register',
39
+ 'node-prepare-enrollment',
40
+ ],
25
41
  },
26
42
  label: {
27
43
  type: 'list',
28
44
  flag: '--label',
29
- commands: ['node-install', 'node-register', 'node-prepare-enrollment'],
30
- },
31
- mesh: {
32
- type: 'boolean',
33
- flag: '--mesh',
34
45
  commands: [
35
46
  'node-install',
36
47
  'node-replace-identity',
37
48
  'node-repair-identity',
49
+ 'node-register',
38
50
  'node-prepare-enrollment',
39
51
  ],
40
52
  },
41
- wgPort: {
42
- type: 'port',
43
- flag: '--wg-port',
44
- commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
45
- },
46
53
  installDocker: {
47
54
  type: 'boolean',
48
55
  flag: '--install-docker',
@@ -51,6 +58,7 @@ export const NODE_SETTINGS = {
51
58
  'node-replace-identity',
52
59
  'node-repair-identity',
53
60
  'node-prepare-enrollment',
61
+ 'control-install',
54
62
  ],
55
63
  },
56
64
  image: {
@@ -61,6 +69,7 @@ export const NODE_SETTINGS = {
61
69
  'node-replace-identity',
62
70
  'node-repair-identity',
63
71
  'node-prepare-enrollment',
72
+ 'control-install',
64
73
  ],
65
74
  },
66
75
  agentControlUrl: {
@@ -74,7 +83,7 @@ export const NODE_SETTINGS = {
74
83
  ],
75
84
  },
76
85
  };
77
- const NODE_SPECIFIC_KEYS = ['ssh', 'address', 'publicAddress'];
86
+ const NODE_SPECIFIC_KEYS = ['ssh', 'address'];
78
87
  const SETTINGS = { ...COMMAND_SETTINGS, ...NODE_SETTINGS };
79
88
  /** Default file names, in lookup order; more than one present is an error. */
80
89
  export const CONFIG_NAMES = ['tug.yml', 'tug.yaml', 'tug.json', '.tug.json'];
@@ -118,16 +127,14 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
118
127
  if (!creating)
119
128
  throw new Error(`cannot use credential ${credentialFile ?? 'from TUG_CREDENTIAL'}: ${errMessage(e)}`, { cause: e });
120
129
  }
121
- const controlUrl = options.controlUrl ||
122
- env.TUG_CONTROL_URL ||
123
- config.controlUrl ||
124
- credential?.url ||
125
- 'https://localhost:8443';
130
+ const requestedControlUrl = options.controlUrl || env.TUG_CONTROL_URL || config.controlUrl;
131
+ const controlUrl = requestedControlUrl || credential?.url || 'https://localhost:8443';
126
132
  return {
127
133
  file,
128
134
  fileExists: Boolean(base),
129
135
  localFile,
130
136
  controlUrl: normalizeControlUrl(controlUrl),
137
+ requestedControlUrl: requestedControlUrl ? normalizeControlUrl(requestedControlUrl) : undefined,
131
138
  credential,
132
139
  credentialFile,
133
140
  project: options.project || env.TUG_PROJECT || config.project || 'site',
@@ -214,8 +221,6 @@ function settingValue(setting, value, where, dir) {
214
221
  if (typeof value !== 'boolean')
215
222
  throw new Error(`${where} must be true or false`);
216
223
  return value;
217
- case 'port':
218
- return String(integerOption(String(value), where, 1, 65535));
219
224
  case 'list':
220
225
  return (Array.isArray(value) ? value : [value]).map((item, index) => text(item, Array.isArray(value) ? `${where}[${index}]` : where));
221
226
  }
@@ -1,17 +1,20 @@
1
1
  /** 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. */
2
2
  export declare const HOST_NETWORK_FLAG = "--network host";
3
- export declare const MESH_RUN_FLAGS = "--network host --cap-add NET_ADMIN";
4
3
  /** Docker platform for the architecture a host reports with `uname -m`. */
5
4
  export declare function dockerPlatform(uname: string): string;
6
5
  /** Build for the host the image will run on, not the machine that builds it (an arm64 laptop and an amd64 VPS differ). */
7
6
  export declare function localImageBuildArgs(imageTag: string, uname: string): string[];
8
7
  export declare function shellQuote(value: unknown): string;
9
- export declare function dockerRunSnippet(image: string, joinKey: string, mesh?: boolean): string;
8
+ export declare function dockerRunSnippet(image: string, joinKey: string, address?: string): string;
10
9
  /** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
11
10
  export declare function enrollmentCloudInit(bootstrap: string, command: string): string;
12
11
  /** Name of the container `control-install` creates (Compose project `tug-control`, service `control`). */
13
12
  export declare const CONTROL_CONTAINER = "tug-control-control-1";
14
13
  /** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
15
14
  export declare function controlPortCheck(port: number): string;
16
- /** An occupied mesh port is reusable only when the installed Tug agent owns the live key. */
17
- export declare function meshPortCheck(port: number): string;
15
+ /**
16
+ * The default control URL for `control-install`: the SSH target's host and the published port. Accepts
17
+ * `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
18
+ * so pass `--control-url` when the alias is not a name or address clients can reach.
19
+ */
20
+ export declare function defaultControlUrl(sshTarget: string, port: number | string): string;
@@ -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('--public-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 publicUrl = normalizeControlUrl(o.publicUrl);
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
- PUBLIC_URL: yamlValue(new URL(publicUrl).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: publicUrl }, 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;
@@ -730,6 +711,7 @@ async function makeContext() {
730
711
  await createSourceArchive(process.cwd(), tmp, [
731
712
  resolvedConfig.file,
732
713
  resolvedConfig.localFile,
714
+ ...(resolvedConfig.credentialFile ? [resolvedConfig.credentialFile] : []),
733
715
  ]);
734
716
  }
735
717
  catch (error) {
@@ -757,7 +739,14 @@ program
757
739
  const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
758
740
  try {
759
741
  const form = new FormData();
760
- 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);
761
750
  for (const { field } of localImages)
762
751
  form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
763
752
  form.append('payload', JSON.stringify({ project, version, compose, localImages }));
@@ -791,7 +780,11 @@ program
791
780
  });
792
781
  program
793
782
  .command('status')
794
- .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
+ });
795
788
  program
796
789
  .command('events')
797
790
  .description('list recent events with the credential that caused each control action; defaults to the resolved project')
@@ -983,7 +976,6 @@ program.hook('preAction', async (_root, command) => {
983
976
  const options = command.opts();
984
977
  for (const [key, min, max] of [
985
978
  ['port', 1, 65535],
986
- ['wgPort', 1, 65535],
987
979
  ['timeout', 1, 86400],
988
980
  ['ttl', 1, 31536000],
989
981
  ['uses', 1, 1000000],
@@ -1031,7 +1023,9 @@ program.hook('preAction', async (_root, command) => {
1031
1023
  : undefined;
1032
1024
  applyConfigDefaults(command, options, {
1033
1025
  ...resolvedConfig.commandDefaults,
1034
- ...(command.name().startsWith('node-') ? nodeSettings(resolvedConfig, nodeName) : {}),
1026
+ ...(command.name().startsWith('node-') || command.name() === 'control-install'
1027
+ ? nodeSettings(resolvedConfig, nodeName)
1028
+ : {}),
1035
1029
  });
1036
1030
  });
1037
1031
  for (const command of program.commands) {
package/dist/compose.d.ts CHANGED
@@ -36,6 +36,8 @@ export declare function isRolling(svc: ComposeService | undefined): boolean;
36
36
  export declare const SUPPORTED_SERVICE_KEYS: readonly string[];
37
37
  /** Lower-cased ingress hostnames a Compose definition claims, by `service` name. */
38
38
  export declare function ingressHostsOf(compose: ComposeFile): Map<string, string>;
39
+ /** True when two lowercase ingress hosts could route the same request: equal, or one is a `*.` wildcard covering the other. */
40
+ export declare function ingressHostsOverlap(a: string, b: string): boolean;
39
41
  export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
40
42
  export interface BuildDesiredStateOptions {
41
43
  project: string;