@wpelevator/tug 0.8.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.
package/README.md CHANGED
@@ -56,19 +56,19 @@ Install the CLI (Node >= 22.18), or run it without installing through `npx @wpel
56
56
 
57
57
  ```bash
58
58
  npm install -g @wpelevator/tug
59
+ tug control-install root@203.0.113.5 --advertise-url https://203.0.113.5:8443 --install-docker
60
+ tug init tug-root.credential.json
61
+ tug node-install root@198.51.100.7 --name app1 --install-docker
62
+ tug deploy -f docker-compose.yml
59
63
  ```
60
64
 
61
- The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
65
+ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts. Control serves mutual TLS directly on its port (`8443`): every operator, CI job and node authenticates with a certificate from a private CA created at install, and clients verify a name generated from that CA, so the address can be a bare IP and can change later. Do not terminate TLS in front of it.
62
66
 
63
- 1. Install the control plane: `tug control-install root@control.example.com --public-url https://control.example.com:8443 --install-docker`. The flag installs missing Docker Engine and the Compose plugin that runs the control container on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts; omit it when Docker is ready. Control serves mutual TLS directly on its published port (`8443`); do not terminate TLS in front of it ([Control plane](docs/control-plane.md#authentication-and-pki)).
64
- 2. Point the CLI at it: `tug init tug-root.credential.json` (the file `control-install` wrote). It writes `tug.yml` (commit it, and list your servers in it), copies the credential to a `0600` file in `~/.config/tug/credentials/` and points the git-ignored `tug.local.yml` at it; see [CLI reference](docs/cli.md#config-file).
65
- 3. Set the secrets your services need: `tug secret-set DB_PASSWORD`.
66
- 4. Add nodes with `tug node-install root@app1.example.com --name app1 --install-docker`, or run `tug node-prepare-enrollment --name app1` and then the printed Docker command on the host. Nodes need Docker Engine only. For a bare cloud host, `tug node-prepare-enrollment --name app1 --install-docker` prints cloud-init user-data.
67
- 5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new desired-state revision healthy.
68
- 6. Operate with `tug status`, `events`, `exec`, `run`, `logs`, `ps`, `inspect`, `restart`, `rollback`, `backup-run`, `restore` and `doctor`. Create more operator credentials for CI and teammates with `tug credential-create`.
67
+ The full walkthrough, including nodes you cannot SSH to (`tug node-prepare-enrollment`), CI credentials (`tug credential-create`), moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
69
68
 
70
69
  ## Documentation
71
70
 
71
+ - [Getting started](docs/getting-started.md): install control, add nodes, deploy, give CI a credential, move control, recover.
72
72
  - [Terminology](docs/terminology.md): core concepts, the naming review (which recommendations are implemented), industry comparisons and the remaining proposed interface improvements.
73
73
  - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
74
74
  - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
package/dist/build.d.ts CHANGED
@@ -5,7 +5,13 @@ export interface RunResult {
5
5
  export interface RunOptions {
6
6
  cwd?: string;
7
7
  env?: NodeJS.ProcessEnv;
8
+ /** Kills the process after this long; defaults to {@link RUN_TIMEOUT_MS}. */
9
+ timeoutMs?: number;
8
10
  }
11
+ /** Longest a child process may run before it is killed, so a stalled build cannot hold the control writer forever. */
12
+ export declare const RUN_TIMEOUT_MS: number;
13
+ /** Per-stream cap on retained output; the tail is kept because failures are reported last. */
14
+ export declare const RUN_OUTPUT_LIMIT: number;
9
15
  export interface BuildSpec {
10
16
  context?: string;
11
17
  dockerfile?: string;
package/dist/build.js CHANGED
@@ -4,16 +4,36 @@ import path from 'node:path';
4
4
  import * as tar from 'tar';
5
5
  import { scanArchive } from "./archive.js";
6
6
  import { ensureDir, exists } from "./util.js";
7
+ /** Longest a child process may run before it is killed, so a stalled build cannot hold the control writer forever. */
8
+ export const RUN_TIMEOUT_MS = 30 * 60 * 1000;
9
+ /** Per-stream cap on retained output; the tail is kept because failures are reported last. */
10
+ export const RUN_OUTPUT_LIMIT = 1024 * 1024;
11
+ const tail = (text, chunk) => (text + chunk).length > RUN_OUTPUT_LIMIT ? (text + chunk).slice(-RUN_OUTPUT_LIMIT) : text + chunk;
7
12
  export function run(cmd, args, opts = {}) {
8
13
  return new Promise((resolve, reject) => {
9
- const p = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
14
+ const { timeoutMs = RUN_TIMEOUT_MS, ...spawnOptions } = opts;
15
+ const p = spawn(cmd, args, { ...spawnOptions, stdio: ['ignore', 'pipe', 'pipe'] });
10
16
  let out = '', err = '';
11
- p.stdout.on('data', (d) => (out += d));
12
- p.stderr.on('data', (d) => (err += d));
13
- p.on('error', reject);
14
- p.on('close', (code) => code === 0
15
- ? resolve({ out, err })
16
- : reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${out}\n${err}`)));
17
+ let timedOut = false;
18
+ const timer = setTimeout(() => {
19
+ timedOut = true;
20
+ p.kill('SIGKILL');
21
+ }, timeoutMs);
22
+ p.stdout.on('data', (d) => (out = tail(out, String(d))));
23
+ p.stderr.on('data', (d) => (err = tail(err, String(d))));
24
+ p.on('error', (error) => {
25
+ clearTimeout(timer);
26
+ reject(error);
27
+ });
28
+ p.on('close', (code) => {
29
+ clearTimeout(timer);
30
+ if (timedOut)
31
+ reject(new Error(`${cmd} ${args.join(' ')} timed out after ${timeoutMs} ms\n${out}\n${err}`));
32
+ else if (code === 0)
33
+ resolve({ out, err });
34
+ else
35
+ reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${out}\n${err}`));
36
+ });
17
37
  });
18
38
  }
19
39
  export async function unpackContext(tarball, dest) {
package/dist/cli-build.js CHANGED
@@ -32,7 +32,7 @@ export function sourceArchiveFilter(entry, configPaths, ignores) {
32
32
  /** Credential documents written by `control-install --credential-out` (default `tug-root.credential.json`). */
33
33
  const CREDENTIAL_FILE = /\.credential\.json$/;
34
34
  const ignoreCache = new WeakMap();
35
- /** Translates one `.dockerignore` pattern: `*` and `?` stay within a path segment, `**` crosses them. */
35
+ /** Translates one `.dockerignore` pattern: `*`, `?` and `[...]` classes stay within a path segment, `**` crosses them. */
36
36
  function ignorePattern(pattern) {
37
37
  let source = '';
38
38
  for (let i = 0; i < pattern.length; i++) {
@@ -51,6 +51,13 @@ function ignorePattern(pattern) {
51
51
  source += '[^/]*';
52
52
  else if (char === '?')
53
53
  source += '[^/]';
54
+ else if (char === '[' && pattern.indexOf(']', i + 2) > 0) {
55
+ const close = pattern.indexOf(']', i + 2);
56
+ const body = pattern.slice(i + 1, close);
57
+ const negated = body[0] === '^' || body[0] === '!';
58
+ source += `[${negated ? '^' : ''}${(negated ? body.slice(1) : body).replace(/[\\\]^]/g, '\\$&')}]`;
59
+ i = close;
60
+ }
54
61
  else if (char === '\\' && i + 1 < pattern.length)
55
62
  source += pattern[++i].replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
56
63
  else
package/dist/cli.js CHANGED
@@ -272,7 +272,7 @@ program
272
272
  program
273
273
  .command('control-install <sshTarget>')
274
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('--public-url <url>', 'credential-free https origin clients use, including the published port')
275
+ .requiredOption('--advertise-url <url>', 'credential-free https origin clients use, including the published port')
276
276
  .option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
277
277
  .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
278
278
  .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
@@ -282,7 +282,7 @@ program
282
282
  .option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
283
283
  .option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
284
284
  .action(async (sshTarget, o) => {
285
- const publicUrl = normalizeControlUrl(o.publicUrl);
285
+ const advertiseUrl = normalizeControlUrl(o.advertiseUrl);
286
286
  if (o.installDocker)
287
287
  await bootstrapDocker(sshTarget, true);
288
288
  preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
@@ -292,7 +292,7 @@ program
292
292
  const yamlValue = (value) => JSON.stringify(value);
293
293
  const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
294
294
  IMAGE: yamlValue(image),
295
- PUBLIC_URL: yamlValue(new URL(publicUrl).origin),
295
+ ADVERTISE_URL: yamlValue(new URL(advertiseUrl).origin),
296
296
  EXTRA_SANS: yamlValue(o.extraSan.join(',')),
297
297
  PORT: o.port,
298
298
  MESH: yamlValue(o.mesh ? 'true' : 'false'),
@@ -316,7 +316,7 @@ program
316
316
  await writeFileDurable(o.credentialOut, `${String(out.stdout).trim()}\n`, 0o600);
317
317
  console.error(`root credential written to ${o.credentialOut}; import it with: tug init ${o.credentialOut}`);
318
318
  }
319
- console.log(JSON.stringify({ ok: true, sshTarget, url: publicUrl }, null, 2));
319
+ console.log(JSON.stringify({ ok: true, sshTarget, url: advertiseUrl }, null, 2));
320
320
  });
321
321
  const AGENT_STATE = '/var/lib/tug';
322
322
  function addNodeIdentityOptions(command) {
@@ -730,6 +730,7 @@ async function makeContext() {
730
730
  await createSourceArchive(process.cwd(), tmp, [
731
731
  resolvedConfig.file,
732
732
  resolvedConfig.localFile,
733
+ ...(resolvedConfig.credentialFile ? [resolvedConfig.credentialFile] : []),
733
734
  ]);
734
735
  }
735
736
  catch (error) {
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;
package/dist/compose.js CHANGED
@@ -289,6 +289,15 @@ export function ingressHostsOf(compose) {
289
289
  }
290
290
  return hosts;
291
291
  }
292
+ /** True when two lowercase ingress hosts could route the same request: equal, or one is a `*.` wildcard covering the other. */
293
+ export function ingressHostsOverlap(a, b) {
294
+ if (a === b)
295
+ return true;
296
+ const suffix = (host) => (host.startsWith('*.') ? host.slice(1) : undefined);
297
+ const wa = suffix(a);
298
+ const wb = suffix(b);
299
+ return (wa !== undefined && b.endsWith(wa)) || (wb !== undefined && a.endsWith(wb));
300
+ }
292
301
  export function validateCompose(compose, nodes = []) {
293
302
  if (!compose?.services || typeof compose.services !== 'object')
294
303
  throw new Error('compose file must define services');
@@ -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 publicUrl = process.env.TUG_PUBLIC_URL;
29
- if (!publicUrl)
30
- throw new Error('TUG_PUBLIC_URL is required');
28
+ const advertiseUrl = process.env.TUG_ADVERTISE_URL;
29
+ if (!advertiseUrl)
30
+ throw new Error('TUG_ADVERTISE_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(publicUrl).origin, request, 'host', {
36
+ const created = await createCredential(db, pki, new URL(advertiseUrl).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_PUBLIC_URL`. */ publicUrl?: string;
30
+ /** Credential-free HTTPS origin clients use to reach control; defaults to `TUG_ADVERTISE_URL`. */ advertiseUrl?: 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.
package/dist/control.js CHANGED
@@ -16,14 +16,14 @@ import * as tar from 'tar';
16
16
  import { openDb, openSnapshotDb } from "./db.js";
17
17
  import { isUniqueViolation } from "./db-driver.js";
18
18
  import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
19
- import { buildDesiredState, ingressHostsOf, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
19
+ import { buildDesiredState, ingressHostsOf, ingressHostsOverlap, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
20
20
  import { buildImage, unpackContext, run as runCommand } from "./build.js";
21
21
  import { sentinelNames } from "./normalize.js";
22
22
  import { ensureDir, errMessage, id, now } from "./util.js";
23
23
  import { ENROLL_DEFAULT_TTL_MS, NODE_LIFETIME_MS, Pki, SERVER_LIFETIME_MS, encodeCredential, identityUri, namesKey, parseIdentityUri, peerIdentity, serverNames, writeFileDurable, } from "./pki.js";
24
24
  import { EXPIRY_WARN_DAYS, certificateExpiry, diagnoseNode } from "./diagnostics.js";
25
25
  import { serverTlsOptions } from "./tls-server.js";
26
- import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } from "./mesh.js";
26
+ import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, isNodeEndpoint, isNodeHost, } from "./mesh.js";
27
27
  import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
28
28
  import { parseStatusReport } from "./status-report.js";
29
29
  import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, BACKUP_COMMAND_TYPES, } from "./protocol.js";
@@ -479,10 +479,10 @@ export async function createApp(options = {}) {
479
479
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
480
480
  // A CA is created only for a fresh installation; existing state with missing or mismatched CA material is a startup error.
481
481
  const pki = await Pki.load(path.join(DATA_DIR, 'pki'), !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes'));
482
- const publicUrl = () => {
483
- const url = options.publicUrl ?? process.env.TUG_PUBLIC_URL;
482
+ const advertiseUrl = () => {
483
+ const url = options.advertiseUrl ?? process.env.TUG_ADVERTISE_URL;
484
484
  if (!url)
485
- throw httpError(500, 'TUG_PUBLIC_URL is not set');
485
+ throw httpError(500, 'TUG_ADVERTISE_URL is not set');
486
486
  return new URL(url).origin;
487
487
  };
488
488
  const app = express();
@@ -708,11 +708,10 @@ export async function createApp(options = {}) {
708
708
  for (const other of currentDesiredStates()) {
709
709
  if (other.project === project)
710
710
  continue;
711
- for (const [host, service] of ingressHostsOf(other.compose)) {
712
- const mine = wanted.get(host);
713
- if (mine)
714
- throw httpError(409, `${mine}: ingress.host ${host} is already routed by project ${other.project} (${service})`);
715
- }
711
+ for (const [host, service] of ingressHostsOf(other.compose))
712
+ for (const [wantedHost, mine] of wanted)
713
+ if (ingressHostsOverlap(wantedHost, host))
714
+ throw httpError(409, `${mine}: ingress.host ${wantedHost} ${wantedHost === host ? 'is' : `overlaps ${host},`} already routed by project ${other.project} (${service})`);
716
715
  }
717
716
  }
718
717
  function createRollback(project, opts) {
@@ -995,6 +994,10 @@ export async function createApp(options = {}) {
995
994
  function insertNode(input) {
996
995
  if (!NODE_NAME.test(input.name))
997
996
  throw httpError(400, 'node name must be 1-63 letters, numbers, dots, underscores or hyphens');
997
+ if (input.address && !isNodeHost(input.address))
998
+ throw httpError(400, 'address must be an IP address or hostname without a port');
999
+ if (input.publicAddress && !isNodeEndpoint(input.publicAddress))
1000
+ throw httpError(400, 'publicAddress must be an IP address or hostname with an optional port');
998
1001
  if (input.wgPublicKey !== undefined && !isWgKey(input.wgPublicKey))
999
1002
  throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
1000
1003
  const ts = now();
@@ -1008,7 +1011,7 @@ export async function createApp(options = {}) {
1008
1011
  node: nodeInfo(nodeId),
1009
1012
  certificate: cert,
1010
1013
  ca: pki.caPem,
1011
- url: publicUrl(),
1014
+ url: advertiseUrl(),
1012
1015
  });
1013
1016
  // The node generates its key and CSR; an admin posts the CSR here. The private key is never transmitted.
1014
1017
  api('post', '/api/v1/nodes/:name/identity', null, async (req, res) => {
@@ -1079,7 +1082,7 @@ export async function createApp(options = {}) {
1079
1082
  return res.status(400).json({ error: 'labels must be a list of strings' });
1080
1083
  const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? ENROLL_DEFAULT_TTL_MS / 1000));
1081
1084
  const rowId = id('enr');
1082
- const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, publicUrl());
1085
+ const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, advertiseUrl());
1083
1086
  const expiresAt = issued.notAfter.toISOString();
1084
1087
  db.run('DELETE FROM enrollment_credentials WHERE expires_at < ?', new Date(Date.now() - 86_400_000).toISOString());
1085
1088
  // Minting again for a name revokes its previous unconsumed key.
@@ -1267,7 +1270,7 @@ export async function createApp(options = {}) {
1267
1270
  });
1268
1271
  api('post', '/api/v1/credentials', null, async (req, res) => {
1269
1272
  const request = parseCredentialRequest(req.body);
1270
- const created = await createCredential(db, pki, publicUrl(), request, req.actor.name);
1273
+ const created = await createCredential(db, pki, advertiseUrl(), request, req.actor.name);
1271
1274
  audit(req.actor, {
1272
1275
  type: 'credential.create',
1273
1276
  message: `credential ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
@@ -1624,6 +1627,10 @@ export async function createApp(options = {}) {
1624
1627
  }
1625
1628
  }
1626
1629
  const nodes = nodeRows.map((row) => ({ name: row.name }));
1630
+ for (const [name, svc] of Object.entries(compose.services || {}))
1631
+ for (const node of svc['x-tug']?.placement?.nodes || [])
1632
+ if (!nodes.some((n) => n.name === node))
1633
+ throw httpError(400, `${name}: placement node ${node} is not an enabled node`);
1627
1634
  try {
1628
1635
  validateCompose(compose, nodes.map((n) => n.name));
1629
1636
  }
@@ -2460,6 +2467,14 @@ export async function createApp(options = {}) {
2460
2467
  return res.status(409).json({ error: `command is ${command.state}, not running` });
2461
2468
  if (!command.project_id || !backupDestination())
2462
2469
  return res.status(409).json({ error: 'no backup destination is configured' });
2470
+ // A command that outlived the project's placement on this node (a newer version moved it) gets nothing.
2471
+ const desiredRow = currentDesired(command.project_id);
2472
+ const desiredNow = desiredRow
2473
+ ? JSON.parse(desiredRow.state_json)
2474
+ : undefined;
2475
+ const placedHere = Object.values(desiredNow?.services || {}).some((svc) => svc.placement?.nodes?.includes(req.node.name));
2476
+ if (!placedHere)
2477
+ return res.status(403).json({ error: 'project is no longer placed on this node' });
2463
2478
  const response = {
2464
2479
  env: {
2465
2480
  ...backupEnv(),
@@ -2583,18 +2598,18 @@ async function ensureServerCredential(pki, dir, names, force = false) {
2583
2598
  export async function listen(app, port = PORT) {
2584
2599
  const pki = app.locals.pki;
2585
2600
  const dataDir = app.locals.dataDir;
2586
- const publicUrl = process.env.TUG_PUBLIC_URL;
2587
- if (!publicUrl)
2588
- throw new Error('TUG_PUBLIC_URL is required: the credential-free https origin clients connect to');
2589
- const url = new URL(publicUrl);
2601
+ const advertiseUrl = process.env.TUG_ADVERTISE_URL;
2602
+ if (!advertiseUrl)
2603
+ throw new Error('TUG_ADVERTISE_URL is required: the credential-free https origin clients connect to');
2604
+ const url = new URL(advertiseUrl);
2590
2605
  if (url.protocol !== 'https:' ||
2591
2606
  url.username ||
2592
2607
  url.password ||
2593
2608
  url.search ||
2594
2609
  url.hash ||
2595
2610
  url.pathname !== '/')
2596
- throw new Error('TUG_PUBLIC_URL must be a credential-free https origin such as https://control.example.com:8443');
2597
- const names = serverNames(publicUrl, (process.env.TUG_EXTRA_SANS || '').split(','));
2611
+ throw new Error('TUG_ADVERTISE_URL must be a credential-free https origin such as https://control.example.com:8443');
2612
+ const names = serverNames(advertiseUrl, (process.env.TUG_EXTRA_SANS || '').split(','));
2598
2613
  const credential = await ensureServerCredential(pki, dataDir, names);
2599
2614
  const server = https.createServer(serverTlsOptions({ cert: credential.cert, key: credential.key, ca: pki.caPem }), app);
2600
2615
  server.requestTimeout = 5 * 60_000;
package/dist/live.d.ts CHANGED
@@ -62,6 +62,8 @@ export interface LiveEngineOptions {
62
62
  queryTimeoutMs?: number;
63
63
  }
64
64
  /** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
65
+ /** Keeps environment variable names but not values, so routine `inspect` output does not leak secrets by accident. */
66
+ export declare function redactInspect(inspected: Record<string, unknown>): Record<string, unknown>;
65
67
  export declare class LiveOperationEngine {
66
68
  private readonly options;
67
69
  private readonly sessions;
package/dist/live.js CHANGED
@@ -48,6 +48,14 @@ function validate(value) {
48
48
  throw new Error('Invalid projects');
49
49
  }
50
50
  /** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
51
+ /** Keeps environment variable names but not values, so routine `inspect` output does not leak secrets by accident. */
52
+ export function redactInspect(inspected) {
53
+ const config = inspected.Config;
54
+ if (!config || !Array.isArray(config.Env))
55
+ return inspected;
56
+ const Env = config.Env.map((entry) => typeof entry === 'string' ? `${entry.split('=', 1)[0]}=<redacted>` : entry);
57
+ return { ...inspected, Config: { ...config, Env } };
58
+ }
51
59
  export class LiveOperationEngine {
52
60
  options;
53
61
  sessions = new Map();
@@ -155,7 +163,7 @@ export class LiveOperationEngine {
155
163
  result = lists.flat();
156
164
  }
157
165
  else
158
- result = [await this.options.docker.inspectContainer(request.container)];
166
+ result = [redactInspect(await this.options.docker.inspectContainer(request.container))];
159
167
  const frame = { v: 1, type: 'live.result', sessionId: id, result };
160
168
  if (Buffer.byteLength(JSON.stringify(frame)) > LIVE_MAX_FRAME_BYTES)
161
169
  throw new Error('Live result exceeds serialized frame size limit');
package/dist/main.js CHANGED
@@ -9,7 +9,7 @@ async function readStdin() {
9
9
  return Buffer.concat(chunks).toString('utf8');
10
10
  }
11
11
  const usage = `Usage: tug-runtime <control|agent> [command]
12
- control run the control plane (needs TUG_PUBLIC_URL)
12
+ control run the control plane (needs TUG_ADVERTISE_URL)
13
13
  control issue-credential issue an operator credential on the control-plane host (--name, --role, --replace)
14
14
  agent run the node agent
15
15
  agent identity print the CSR for this node's pending key
package/dist/mesh.d.ts CHANGED
@@ -34,6 +34,10 @@ export declare function parseCidr(cidr: string): {
34
34
  };
35
35
  /** First free host address in the mesh CIDR (never the network or broadcast address). */
36
36
  export declare function allocateOverlayIp(cidr: string, used: Iterable<string>): string;
37
+ /** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
38
+ export declare const isNodeHost: (value: unknown) => value is string;
39
+ /** A node host with an optional port: `host`, `host:port` or `[ipv6]:port`. */
40
+ export declare const isNodeEndpoint: (value: unknown) => value is string;
37
41
  /** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
38
42
  export declare function wgEndpoint(publicAddress: string | null | undefined, port: number): string | undefined;
39
43
  /** `wg syncconf` input: interface key and port plus peers (no wg-quick-only keys such as Address). */
package/dist/mesh.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // and the idempotent `wg0` reconcile (agent). All shell-outs go through an injectable HostRunner.
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs/promises';
5
+ import { isIP } from 'node:net';
5
6
  import path from 'node:path';
6
7
  import { ensureDir, errMessage } from "./util.js";
7
8
  /** Default mesh CIDR. Chosen to avoid the integration subnet 10.89.240.0/24, Docker's 172.16/12 and common home/VPN ranges. */
@@ -96,6 +97,22 @@ export function allocateOverlayIp(cidr, used) {
96
97
  }
97
98
  throw new Error(`mesh CIDR ${cidr} has no free overlay addresses`);
98
99
  }
100
+ const HOST_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
101
+ /** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
102
+ export const isNodeHost = (value) => typeof value === 'string' &&
103
+ value.length <= 253 &&
104
+ (isIP(value) !== 0 || value.split('.').every((label) => HOST_LABEL.test(label)));
105
+ /** A node host with an optional port: `host`, `host:port` or `[ipv6]:port`. */
106
+ export const isNodeEndpoint = (value) => {
107
+ if (typeof value !== 'string')
108
+ return false;
109
+ const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value);
110
+ const parts = bracketed ?? (isIP(value) ? null : /^([^:]+)(?::(\d{1,5}))?$/.exec(value));
111
+ const [host, port] = parts ? [parts[1], parts[2]] : [value, undefined];
112
+ if (bracketed ? isIP(host) !== 6 : !isNodeHost(host))
113
+ return false;
114
+ return port === undefined || (Number(port) >= 1 && Number(port) <= 65535);
115
+ };
99
116
  /** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
100
117
  export function wgEndpoint(publicAddress, port) {
101
118
  const addr = publicAddress?.trim();
package/dist/pki.d.ts CHANGED
@@ -59,7 +59,7 @@ export interface SubjectNames {
59
59
  ip: string[];
60
60
  }
61
61
  /** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
62
- export declare function serverNames(publicUrl: string, extras?: string[]): SubjectNames;
62
+ export declare function serverNames(advertiseUrl: string, extras?: string[]): SubjectNames;
63
63
  export declare const namesKey: (names: SubjectNames) => string;
64
64
  export declare class Pki {
65
65
  readonly caPem: string;
@@ -95,7 +95,9 @@ export declare class Pki {
95
95
  notAfter: Date;
96
96
  }>;
97
97
  /** The server certificate and key for the given names. */
98
- issueServer(names: SubjectNames): Promise<{
98
+ get controlName(): string;
99
+ /** The server certificate always carries the generated control name, plus the given addresses. */
100
+ issueServer(addresses: SubjectNames): Promise<{
99
101
  cert: string;
100
102
  key: string;
101
103
  notAfter: Date;
@@ -108,7 +110,16 @@ export declare function validateInstalledCertificate(options: {
108
110
  keyPem: string;
109
111
  uri: string;
110
112
  }): void;
111
- /** TLS client options pinned to the credential's CA. Verification is always against the public host, even through a tunnel. */
113
+ /**
114
+ * The generated name control answers to, derived from its CA key: stable for the life of the CA and independent of any
115
+ * address. Control puts it in its server certificate and clients recompute it from the CA they pin, so moving control to
116
+ * another IP or DNS name needs no new credentials.
117
+ */
118
+ export declare function controlNameFromCa(caPem: string): string;
119
+ /**
120
+ * TLS client options pinned to the credential's CA. The server is verified by its generated control name, never by the
121
+ * address the connection goes to, so an address override (a tunnel, a private IP, a moved host) keeps full verification.
122
+ */
112
123
  export declare function clientTlsOptions(credential: Credential): tls.ConnectionOptions;
113
124
  /** The peer identity presented on a TLS socket, or null when none can be established. */
114
125
  export declare function peerIdentity(socket: tls.TLSSocket): {
package/dist/pki.js CHANGED
@@ -149,9 +149,9 @@ export async function verifyCsr(csrPem) {
149
149
  }
150
150
  const serial = () => `0${crypto.randomBytes(15).toString('hex')}`;
151
151
  /** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
152
- export function serverNames(publicUrl, extras = []) {
152
+ export function serverNames(advertiseUrl, extras = []) {
153
153
  const names = { dns: [], ip: [] };
154
- const host = new URL(publicUrl).hostname.replace(/^\[|\]$/g, '');
154
+ const host = new URL(advertiseUrl).hostname.replace(/^\[|\]$/g, '');
155
155
  for (const entry of [host, ...extras.map((e) => e.trim()).filter(Boolean)]) {
156
156
  const bucket = net.isIP(entry) ? names.ip : names.dns;
157
157
  if (!bucket.includes(entry))
@@ -280,7 +280,12 @@ export class Pki {
280
280
  return { credential, keyHash: certKeyHash(cert), notAfter };
281
281
  }
282
282
  /** The server certificate and key for the given names. */
283
- async issueServer(names) {
283
+ get controlName() {
284
+ return controlNameFromCa(this.caPem);
285
+ }
286
+ /** The server certificate always carries the generated control name, plus the given addresses. */
287
+ async issueServer(addresses) {
288
+ const names = { dns: [this.controlName, ...addresses.dns], ip: addresses.ip };
284
289
  const keys = await generateKeyPair();
285
290
  const { cert, notAfter } = await this.signLeaf({
286
291
  publicKey: keys.publicKey,
@@ -301,16 +306,28 @@ export function validateInstalledCertificate(options) {
301
306
  if (!(cert.subjectAltName ?? '').split(/,\s*/).includes(`URI:${options.uri}`))
302
307
  throw new Error(`certificate does not name ${options.uri}`);
303
308
  }
304
- /** TLS client options pinned to the credential's CA. Verification is always against the public host, even through a tunnel. */
309
+ /**
310
+ * The generated name control answers to, derived from its CA key: stable for the life of the CA and independent of any
311
+ * address. Control puts it in its server certificate and clients recompute it from the CA they pin, so moving control to
312
+ * another IP or DNS name needs no new credentials.
313
+ */
314
+ export function controlNameFromCa(caPem) {
315
+ const spki = new crypto.X509Certificate(caPem).publicKey.export({ type: 'spki', format: 'der' });
316
+ return `control-${hashSpki(spki).slice(0, 12)}.tug`;
317
+ }
318
+ /**
319
+ * TLS client options pinned to the credential's CA. The server is verified by its generated control name, never by the
320
+ * address the connection goes to, so an address override (a tunnel, a private IP, a moved host) keeps full verification.
321
+ */
305
322
  export function clientTlsOptions(credential) {
306
- const publicHost = new URL(credential.url).hostname.replace(/^\[|\]$/g, '');
323
+ const name = controlNameFromCa(credential.ca);
307
324
  return {
308
325
  ca: credential.ca,
309
326
  cert: credential.cert,
310
327
  key: credential.key,
311
328
  minVersion: 'TLSv1.3',
312
- ...(net.isIP(publicHost) ? {} : { servername: publicHost }),
313
- checkServerIdentity: (_host, cert) => tls.checkServerIdentity(publicHost, cert),
329
+ servername: name,
330
+ checkServerIdentity: (_host, cert) => tls.checkServerIdentity(name, cert),
314
331
  };
315
332
  }
316
333
  /** The peer identity presented on a TLS socket, or null when none can be established. */
@@ -6,7 +6,7 @@ services:
6
6
  environment:
7
7
  PORT: 8443
8
8
  TUG_DATA_DIR: /data
9
- TUG_PUBLIC_URL: {{PUBLIC_URL}}
9
+ TUG_ADVERTISE_URL: {{ADVERTISE_URL}}
10
10
  TUG_EXTRA_SANS: {{EXTRA_SANS}}
11
11
  TUG_MESH: {{MESH}}
12
12
  ports:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",