@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
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');
@@ -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 publicUrl = process.env.TUG_PUBLIC_URL;
29
- if (!publicUrl)
30
- throw new Error('TUG_PUBLIC_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(publicUrl).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_PUBLIC_URL`. */ publicUrl?: 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.
package/dist/control.js CHANGED
@@ -16,15 +16,15 @@ 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";
27
- import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
26
+ import { isNodeHost } from "./node-address.js";
27
+ import { buildIngressWork, ingressConfigHash, 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";
30
30
  import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
@@ -431,14 +431,8 @@ export async function createApp(options = {}) {
431
431
  });
432
432
  const dbFile = path.join(DATA_DIR, 'tug.db');
433
433
  const masterKeyFile = path.join(DATA_DIR, 'master.key');
434
- // Networking layers are opt-in; with all of them off the work response is unchanged.
435
- const MESH = process.env.TUG_MESH === 'true';
436
- const MESH_CIDR = process.env.TUG_MESH_CIDR || DEFAULT_MESH_CIDR;
437
- const WG_PORT = Number(process.env.TUG_WG_PORT || DEFAULT_WG_PORT);
438
- const SERVICE_DNS = (process.env.TUG_SERVICE_NAMES ?? String(MESH)) === 'true';
434
+ const SERVICE_DNS = process.env.TUG_SERVICE_NAMES !== 'false';
439
435
  const ACME_EMAIL = process.env.TUG_ACME_EMAIL || undefined;
440
- if (MESH)
441
- parseCidr(MESH_CIDR);
442
436
  const db = await (options.openDatabase || openDb)(dbFile);
443
437
  const wakeHub = new WakeHub();
444
438
  // Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
@@ -479,14 +473,16 @@ export async function createApp(options = {}) {
479
473
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
480
474
  // A CA is created only for a fresh installation; existing state with missing or mismatched CA material is a startup error.
481
475
  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;
476
+ const configuredControlUrl = options.controlUrl ?? process.env.TUG_CONTROL_URL;
477
+ const controlUrl = () => {
478
+ const url = configuredControlUrl;
484
479
  if (!url)
485
- throw httpError(500, 'TUG_PUBLIC_URL is not set');
480
+ throw httpError(500, 'TUG_CONTROL_URL is not set');
486
481
  return new URL(url).origin;
487
482
  };
488
483
  const app = express();
489
484
  app.locals.pki = pki;
485
+ app.locals.controlUrl = configuredControlUrl;
490
486
  app.locals.dataDir = DATA_DIR;
491
487
  /** Resolves a presented certificate (principal URI and key hash) to a principal from current database state. */
492
488
  function resolvePrincipal(uri, keyHash) {
@@ -685,7 +681,40 @@ export async function createApp(options = {}) {
685
681
  ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision
686
682
  : byNode.get(node)?.observed_version === state.version) &&
687
683
  ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
688
- if (ok)
684
+ // For projects using managed ingress, wait until each enabled ingress node has applied
685
+ // the current routing configuration. Container health alone does not prove a switch.
686
+ const ingressServices = Object.entries(state.compose.services || {})
687
+ .filter(([, service]) => service['x-tug']?.ingress)
688
+ .map(([name]) => name);
689
+ // A revision that removes its last route must wait for that removal too.
690
+ const previous = db.get(`SELECT ds.state_json FROM desired_states ds JOIN deployments d ON d.id=ds.deployment_id
691
+ WHERE d.project_id=? AND d.id<>? AND d.state='succeeded' ORDER BY d.created_at DESC LIMIT 1`, projectId, project.current_deployment_id);
692
+ const previouslyRouted = previous
693
+ ? Object.values(JSON.parse(previous.state_json).compose.services || {}).some((service) => service['x-tug']?.ingress)
694
+ : false;
695
+ const ingressNodes = ingressServices.length || previouslyRouted
696
+ ? db.all('SELECT * FROM nodes WHERE ingress=1 AND disabled_at IS NULL')
697
+ : [];
698
+ const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
699
+ const ingressReady = !ingressNodes.length ||
700
+ ingressNodes.every((node) => {
701
+ const work = ingressFor(node, currentDesiredStates());
702
+ if (!ingressServices.every((service) => work.routes.some((route) => route.project === projectId && route.service === service && route.upstreams.length)))
703
+ return false;
704
+ if (!node.ingress_status_json)
705
+ return false;
706
+ try {
707
+ const status = JSON.parse(node.ingress_status_json);
708
+ return (status.state === 'serving' &&
709
+ status.configHash === ingressConfigHash(work) &&
710
+ typeof status.reportedAt === 'string' &&
711
+ status.reportedAt >= staleBefore);
712
+ }
713
+ catch {
714
+ return false;
715
+ }
716
+ });
717
+ if (ok && ingressReady)
689
718
  db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
690
719
  }
691
720
  /** Events control writes when it rolls back on its own; they are attributed to a reserved system actor. */
@@ -708,11 +737,10 @@ export async function createApp(options = {}) {
708
737
  for (const other of currentDesiredStates()) {
709
738
  if (other.project === project)
710
739
  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
- }
740
+ for (const [host, service] of ingressHostsOf(other.compose))
741
+ for (const [wantedHost, mine] of wanted)
742
+ if (ingressHostsOverlap(wantedHost, host))
743
+ throw httpError(409, `${mine}: ingress.host ${wantedHost} ${wantedHost === host ? 'is' : `overlaps ${host},`} already routed by project ${other.project} (${service})`);
716
744
  }
717
745
  }
718
746
  function createRollback(project, opts) {
@@ -938,41 +966,7 @@ export async function createApp(options = {}) {
938
966
  /** A node whose agent has been seen before but not within TUG_STALE_SECONDS; never-seen (just registered) nodes stay schedulable. */
939
967
  const isStale = (row) => !!row.last_seen_at &&
940
968
  Date.parse(row.last_seen_at) < Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000;
941
- const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,labels_json,status,created_at,updated_at FROM nodes WHERE id=?';
942
- /** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
943
- function ensureOverlayIp(nodeId) {
944
- if (!MESH)
945
- return;
946
- for (let attempt = 0; attempt < 5; attempt++) {
947
- const row = db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
948
- if (row?.overlay_ip)
949
- return;
950
- const used = db
951
- .all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')
952
- .map((r) => r.overlay_ip);
953
- try {
954
- db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
955
- return;
956
- }
957
- catch (e) {
958
- if (!isUniqueViolation(e))
959
- throw e;
960
- }
961
- }
962
- throw new Error('could not allocate an overlay address');
963
- }
964
- /** Stores (or refreshes) the node's WireGuard peer entry; the private key never reaches control. */
965
- function syncWgPeer(nodeId, publicKey) {
966
- if (!MESH)
967
- return;
968
- const node = db.get('SELECT * FROM nodes WHERE id=?', nodeId);
969
- const existing = db.get('SELECT * FROM wg_peers WHERE node_id=?', nodeId);
970
- const key = publicKey ?? existing?.public_key;
971
- if (!node?.overlay_ip || !key)
972
- return;
973
- db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
974
- ON CONFLICT(node_id) DO UPDATE SET public_key=excluded.public_key,overlay_ip=excluded.overlay_ip,endpoint=excluded.endpoint,allowed_ips=excluded.allowed_ips,updated_at=excluded.updated_at`, nodeId, key, node.overlay_ip, wgEndpoint(node.public_address, WG_PORT) ?? null, `${node.overlay_ip}/32`, now());
975
- }
969
+ const NODE_INFO_SQL = 'SELECT id,name,address,labels_json,status,created_at,updated_at FROM nodes WHERE id=?';
976
970
  const NODE_RENEW_MIN_INTERVAL_MS = () => Number(process.env.TUG_RENEW_MIN_INTERVAL_MS ?? 60 * 60 * 1000);
977
971
  const nodeInfo = (nodeId) => {
978
972
  const row = db.get(NODE_INFO_SQL, nodeId);
@@ -982,8 +976,6 @@ export async function createApp(options = {}) {
982
976
  id: row.id,
983
977
  name: row.name,
984
978
  address: row.address,
985
- public_address: row.public_address,
986
- overlay_ip: row.overlay_ip,
987
979
  labels: row.labels_json ? JSON.parse(row.labels_json) : [],
988
980
  status: row.status,
989
981
  created_at: row.created_at,
@@ -995,20 +987,18 @@ export async function createApp(options = {}) {
995
987
  function insertNode(input) {
996
988
  if (!NODE_NAME.test(input.name))
997
989
  throw httpError(400, 'node name must be 1-63 letters, numbers, dots, underscores or hyphens');
998
- if (input.wgPublicKey !== undefined && !isWgKey(input.wgPublicKey))
999
- throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
990
+ if (input.address && !isNodeHost(input.address))
991
+ throw httpError(400, 'address must be an IP address or hostname without a port');
1000
992
  const ts = now();
1001
- db.run(`INSERT INTO nodes(id,name,address,public_address,status,labels_json,key_hash,cert_not_after,created_at,updated_at)
1002
- VALUES(?,?,?,?,?,?,?,?,?,?)`, input.id, input.name, input.address || null, input.publicAddress || null, 'registered', JSON.stringify(Array.isArray(input.labels) ? input.labels : []), input.keyHash, input.notAfter.toISOString(), ts, ts);
1003
- ensureOverlayIp(input.id);
1004
- syncWgPeer(input.id, input.wgPublicKey);
993
+ db.run(`INSERT INTO nodes(id,name,address,status,labels_json,key_hash,cert_not_after,created_at,updated_at)
994
+ VALUES(?,?,?,?,?,?,?,?,?)`, input.id, input.name, input.address || null, 'registered', JSON.stringify(Array.isArray(input.labels) ? input.labels : []), input.keyHash, input.notAfter.toISOString(), ts, ts);
1005
995
  wakeAll();
1006
996
  }
1007
997
  const identityResponse = (nodeId, cert) => ({
1008
998
  node: nodeInfo(nodeId),
1009
999
  certificate: cert,
1010
1000
  ca: pki.caPem,
1011
- url: publicUrl(),
1001
+ url: controlUrl(),
1012
1002
  });
1013
1003
  // The node generates its key and CSR; an admin posts the CSR here. The private key is never transmitted.
1014
1004
  api('post', '/api/v1/nodes/:name/identity', null, async (req, res) => {
@@ -1044,7 +1034,6 @@ export async function createApp(options = {}) {
1044
1034
  keyHash: signed.keyHash,
1045
1035
  notAfter: signed.notAfter,
1046
1036
  address: body.address,
1047
- publicAddress: body.publicAddress,
1048
1037
  labels: body.labels,
1049
1038
  });
1050
1039
  }
@@ -1052,13 +1041,18 @@ export async function createApp(options = {}) {
1052
1041
  else {
1053
1042
  if (!existing)
1054
1043
  return res.status(404).json({ error: 'node not found' });
1055
- db.run("UPDATE nodes SET key_hash=?,disabled_at=NULL,status='registered',cert_not_after=?,updated_at=? WHERE id=?", signed.keyHash, signed.notAfter.toISOString(), now(), existing.id);
1044
+ if (body.address !== undefined && !isNodeHost(body.address))
1045
+ throw httpError(400, 'address must be an IP address or hostname without a port');
1046
+ const address = body.address === undefined ? existing.address : body.address;
1047
+ db.run("UPDATE nodes SET key_hash=?,disabled_at=NULL,status='registered',cert_not_after=?,address=?,updated_at=? WHERE id=?", signed.keyHash, signed.notAfter.toISOString(), address || null, now(), existing.id);
1056
1048
  audit(req.actor, {
1057
1049
  type: 'node.replaced',
1058
1050
  message: `node ${name} key replaced by ${req.actor.name}`,
1059
1051
  metadata: { node: name },
1060
1052
  });
1061
1053
  reconcileSockets();
1054
+ endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1055
+ wakeAll();
1062
1056
  }
1063
1057
  res.json(identityResponse(nodeId, signed.cert));
1064
1058
  }
@@ -1079,7 +1073,7 @@ export async function createApp(options = {}) {
1079
1073
  return res.status(400).json({ error: 'labels must be a list of strings' });
1080
1074
  const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? ENROLL_DEFAULT_TTL_MS / 1000));
1081
1075
  const rowId = id('enr');
1082
- const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, publicUrl());
1076
+ const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, controlUrl());
1083
1077
  const expiresAt = issued.notAfter.toISOString();
1084
1078
  db.run('DELETE FROM enrollment_credentials WHERE expires_at < ?', new Date(Date.now() - 86_400_000).toISOString());
1085
1079
  // Minting again for a name revokes its previous unconsumed key.
@@ -1120,8 +1114,6 @@ export async function createApp(options = {}) {
1120
1114
  keyHash: signed.keyHash,
1121
1115
  notAfter: signed.notAfter,
1122
1116
  address: body.address,
1123
- publicAddress: body.publicAddress,
1124
- wgPublicKey: body.wgPublicKey,
1125
1117
  labels: fresh.labels_json ? JSON.parse(fresh.labels_json) : [],
1126
1118
  });
1127
1119
  db.run('UPDATE enrollment_credentials SET node_id=?,node_key_hash=? WHERE id=?', fresh.reserved_node_id, signed.keyHash, fresh.id);
@@ -1170,13 +1162,13 @@ export async function createApp(options = {}) {
1170
1162
  metadata: { node: node.name },
1171
1163
  });
1172
1164
  reconcileSockets();
1173
- // Healthy peers drop the node from their mesh and ingress configuration on their next work fetch.
1165
+ // Other nodes drop the node from service endpoints and ingress configuration on their next work fetch.
1174
1166
  endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1175
1167
  wakeAll();
1176
1168
  res.json({ ok: true, node: node.name });
1177
1169
  });
1178
1170
  api('get', '/api/v1/nodes', null, async (req, res) => {
1179
- const rows = db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,labels_json,last_seen_at,disabled_at,cert_not_after,created_at,updated_at FROM nodes ORDER BY name');
1171
+ const rows = db.all('SELECT id,name,address,ingress,ingress_status_json,status,labels_json,last_seen_at,disabled_at,cert_not_after,created_at,updated_at FROM nodes ORDER BY name');
1180
1172
  res.json({
1181
1173
  nodes: rows.map((row) => {
1182
1174
  const { labels_json, ...rest } = row;
@@ -1194,17 +1186,7 @@ export async function createApp(options = {}) {
1194
1186
  wakeHub.wake(nodeId, projectId ?? undefined);
1195
1187
  return commandId;
1196
1188
  }
1197
- // The private key lives only on the node, so rotation is a command the agent executes: it
1198
- // generates a new keypair, reports the public key and peers pick it up from their next work response.
1199
- api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
1200
- if (!MESH)
1201
- return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
1202
- const node = findNode(String(req.params.name));
1203
- if (!node)
1204
- return res.status(404).json({ error: 'node not found' });
1205
- res.json({ commandId: queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
1206
- });
1207
- // Removes a retired node: its identity, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1189
+ // Removes a retired node: its identity, statuses and queued commands. Refused while a current deployment still places
1208
1190
  // something on it (redeploy without it first) or while its agent is still reporting, unless force=true.
1209
1191
  api('delete', '/api/v1/nodes/:name', null, async (req, res) => {
1210
1192
  const node = db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', String(req.params.name), String(req.params.name));
@@ -1229,7 +1211,6 @@ export async function createApp(options = {}) {
1229
1211
  });
1230
1212
  db.run('DELETE FROM commands WHERE node_id=?', node.id);
1231
1213
  db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
1232
- db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
1233
1214
  db.run('DELETE FROM nodes WHERE id=?', node.id);
1234
1215
  db.run('UPDATE enrollment_credentials SET revoked_at=COALESCE(revoked_at,?) WHERE name=? AND node_id IS NULL', now(), node.name);
1235
1216
  reconcileSockets();
@@ -1241,33 +1222,37 @@ export async function createApp(options = {}) {
1241
1222
  wakeAll();
1242
1223
  res.json({ ok: true, node: node.name });
1243
1224
  });
1225
+ api('post', '/api/v1/nodes/:name/address', null, async (req, res) => {
1226
+ const node = findNode(String(req.params.name));
1227
+ if (!node)
1228
+ return res.status(404).json({ error: 'node not found' });
1229
+ const { address } = req.body;
1230
+ if (!isNodeHost(address))
1231
+ return res
1232
+ .status(400)
1233
+ .json({ error: 'address must be an IP address or hostname without a port' });
1234
+ db.run('UPDATE nodes SET address=?,updated_at=? WHERE id=?', address, now(), node.id);
1235
+ audit(req.actor, {
1236
+ type: 'node.address',
1237
+ message: `node ${node.name} address changed`,
1238
+ metadata: { node: node.name, address },
1239
+ });
1240
+ endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1241
+ wakeAll();
1242
+ res.json({ ok: true, node: node.name, address });
1243
+ });
1244
1244
  api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
1245
1245
  const node = findNode(String(req.params.name));
1246
1246
  if (!node)
1247
1247
  return res.status(404).json({ error: 'node not found' });
1248
1248
  const enabled = req.body.enabled !== false;
1249
- db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
1249
+ db.run('UPDATE nodes SET ingress_status_json=CASE WHEN ingress<>? THEN NULL ELSE ingress_status_json END,ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, enabled ? 1 : 0, now(), node.id);
1250
1250
  wakeHub.wake(node.id);
1251
1251
  res.json({ ok: true, node: node.name, ingress: enabled });
1252
1252
  });
1253
- post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
1254
- if (!MESH)
1255
- return res.status(409).json({ error: 'the WireGuard mesh is not enabled' });
1256
- const { publicKey } = req.body;
1257
- if (!isWgKey(publicKey))
1258
- return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
1259
- try {
1260
- syncWgPeer(req.node.id, publicKey);
1261
- wakeAll();
1262
- res.json({ ok: true });
1263
- }
1264
- catch (e) {
1265
- sendError(res, isUniqueViolation(e) ? httpError(409, 'public key already registered to another node') : e);
1266
- }
1267
- });
1268
1253
  api('post', '/api/v1/credentials', null, async (req, res) => {
1269
1254
  const request = parseCredentialRequest(req.body);
1270
- const created = await createCredential(db, pki, publicUrl(), request, req.actor.name);
1255
+ const created = await createCredential(db, pki, controlUrl(), request, req.actor.name);
1271
1256
  audit(req.actor, {
1272
1257
  type: 'credential.create',
1273
1258
  message: `credential ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
@@ -1624,6 +1609,10 @@ export async function createApp(options = {}) {
1624
1609
  }
1625
1610
  }
1626
1611
  const nodes = nodeRows.map((row) => ({ name: row.name }));
1612
+ for (const [name, svc] of Object.entries(compose.services || {}))
1613
+ for (const node of svc['x-tug']?.placement?.nodes || [])
1614
+ if (!nodes.some((n) => n.name === node))
1615
+ throw httpError(400, `${name}: placement node ${node} is not an enabled node`);
1627
1616
  try {
1628
1617
  validateCompose(compose, nodes.map((n) => n.name));
1629
1618
  }
@@ -2212,15 +2201,6 @@ export async function createApp(options = {}) {
2212
2201
  await check('docker', async () => ({
2213
2202
  output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim(),
2214
2203
  }));
2215
- if (MESH) {
2216
- await check('mesh', async () => {
2217
- const nodes = db.all('SELECT n.name, n.overlay_ip, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
2218
- const missing = nodes.filter((n) => !n.has_key).map((n) => n.name);
2219
- if (missing.length)
2220
- throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
2221
- return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
2222
- });
2223
- }
2224
2204
  const ingressNodes = db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
2225
2205
  if (ingressNodes.length) {
2226
2206
  await check('ingress', async () => {
@@ -2254,10 +2234,9 @@ export async function createApp(options = {}) {
2254
2234
  }
2255
2235
  function endpointsFor(states) {
2256
2236
  const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
2257
- const nodes = db.all('SELECT n.*, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n WHERE n.disabled_at IS NULL');
2237
+ const nodes = db.all('SELECT * FROM nodes WHERE disabled_at IS NULL');
2258
2238
  const views = nodes.map((n) => ({
2259
2239
  name: n.name,
2260
- overlayIp: MESH && n.has_key ? n.overlay_ip : null,
2261
2240
  address: n.address,
2262
2241
  lastSeenAt: n.last_seen_at,
2263
2242
  }));
@@ -2270,24 +2249,6 @@ export async function createApp(options = {}) {
2270
2249
  }));
2271
2250
  return resolveServiceEndpoints(states, views, statuses, staleBefore);
2272
2251
  }
2273
- function networkFor(node) {
2274
- if (!MESH || !node.overlay_ip)
2275
- return undefined;
2276
- const peers = db.all('SELECT w.*, n.name FROM wg_peers w JOIN nodes n ON n.id=w.node_id WHERE w.node_id != ? AND n.disabled_at IS NULL ORDER BY n.name', node.id);
2277
- return {
2278
- interface: WG_INTERFACE,
2279
- overlayIp: node.overlay_ip,
2280
- cidr: MESH_CIDR,
2281
- listenPort: WG_PORT,
2282
- peers: peers.map((p) => ({
2283
- node: p.name,
2284
- publicKey: p.public_key,
2285
- endpoint: p.endpoint ?? undefined,
2286
- allowedIps: p.allowed_ips.split(','),
2287
- persistentKeepalive: WG_KEEPALIVE,
2288
- })),
2289
- };
2290
- }
2291
2252
  /** Names of all nodes and of the ingress nodes, for port binding and upstream decisions. */
2292
2253
  function nodeTopology() {
2293
2254
  const rows = db.all('SELECT name,ingress FROM nodes WHERE disabled_at IS NULL ORDER BY name');
@@ -2298,7 +2259,6 @@ export async function createApp(options = {}) {
2298
2259
  }
2299
2260
  /** Where this node's published ports listen: the address other nodes and the ingress reach it on, or loopback. */
2300
2261
  function portBindingFor(node, states) {
2301
- const hasKey = MESH && node.overlay_ip && db.get('SELECT 1 FROM wg_peers WHERE node_id=?', node.id);
2302
2262
  const { nodes, ingressNodes } = nodeTopology();
2303
2263
  const loopback = {};
2304
2264
  for (const state of states) {
@@ -2306,7 +2266,7 @@ export async function createApp(options = {}) {
2306
2266
  if (services.length)
2307
2267
  loopback[state.project] = services;
2308
2268
  }
2309
- return { address: (hasKey ? node.overlay_ip : node.address) || null, loopback };
2269
+ return { address: node.address || null, loopback };
2310
2270
  }
2311
2271
  function ingressFor(node, states) {
2312
2272
  const secrets = new Map();
@@ -2360,9 +2320,6 @@ export async function createApp(options = {}) {
2360
2320
  commands,
2361
2321
  retainedImages,
2362
2322
  };
2363
- const network = networkFor(node);
2364
- if (network)
2365
- work.network = network;
2366
2323
  work.ports = portBindingFor(node, desiredStates);
2367
2324
  const holds = restores.holds();
2368
2325
  if (Object.keys(holds).length)
@@ -2401,7 +2358,6 @@ export async function createApp(options = {}) {
2401
2358
  }),
2402
2359
  };
2403
2360
  db.run(`INSERT INTO node_statuses(id,node_id,project_id,observed_version,status_json,health,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(node_id,project_id) DO UPDATE SET observed_version=excluded.observed_version,status_json=excluded.status_json,health=excluded.health,updated_at=excluded.updated_at`, id('ns'), req.node.id, st.project, st.observedVersion, JSON.stringify(sanitized), st.health, ts);
2404
- updateDeploymentConvergence(st.project);
2405
2361
  }
2406
2362
  // Commands are resolved under the calling node first: only a running command of this node can be
2407
2363
  // heartbeated or completed, so foreign and already-finished command ids change nothing.
@@ -2416,10 +2372,14 @@ export async function createApp(options = {}) {
2416
2372
  db.run("UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=? AND state='running'", cr.state, JSON.stringify(cr.result), ts, cr.commandId, req.node.id);
2417
2373
  }
2418
2374
  if (report.ingress)
2419
- db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
2375
+ db.run('UPDATE nodes SET ingress_status_json=? WHERE id=? AND ingress=1 AND disabled_at IS NULL', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
2420
2376
  db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
2421
2377
  if (report.sentAt)
2422
2378
  db.run('UPDATE nodes SET clock_skew_ms=?,clock_observed_at=? WHERE id=?', Date.parse(report.sentAt) - Date.parse(ts), ts, req.node.id);
2379
+ // Ingress-only nodes can acknowledge routing without reporting any project; recheck
2380
+ // every active rollout after all project and ingress observations have been stored.
2381
+ for (const deployment of db.all("SELECT DISTINCT project_id FROM deployments WHERE state='rolling_out'"))
2382
+ updateDeploymentConvergence(deployment.project_id);
2423
2383
  try {
2424
2384
  restores.advance();
2425
2385
  }
@@ -2460,6 +2420,14 @@ export async function createApp(options = {}) {
2460
2420
  return res.status(409).json({ error: `command is ${command.state}, not running` });
2461
2421
  if (!command.project_id || !backupDestination())
2462
2422
  return res.status(409).json({ error: 'no backup destination is configured' });
2423
+ // A command that outlived the project's placement on this node (a newer version moved it) gets nothing.
2424
+ const desiredRow = currentDesired(command.project_id);
2425
+ const desiredNow = desiredRow
2426
+ ? JSON.parse(desiredRow.state_json)
2427
+ : undefined;
2428
+ const placedHere = Object.values(desiredNow?.services || {}).some((svc) => svc.placement?.nodes?.includes(req.node.name));
2429
+ if (!placedHere)
2430
+ return res.status(403).json({ error: 'project is no longer placed on this node' });
2463
2431
  const response = {
2464
2432
  env: {
2465
2433
  ...backupEnv(),
@@ -2583,18 +2551,18 @@ async function ensureServerCredential(pki, dir, names, force = false) {
2583
2551
  export async function listen(app, port = PORT) {
2584
2552
  const pki = app.locals.pki;
2585
2553
  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);
2554
+ const advertised = app.locals.controlUrl;
2555
+ if (!advertised)
2556
+ throw new Error('TUG_CONTROL_URL is required: the credential-free https origin clients connect to');
2557
+ const url = new URL(advertised);
2590
2558
  if (url.protocol !== 'https:' ||
2591
2559
  url.username ||
2592
2560
  url.password ||
2593
2561
  url.search ||
2594
2562
  url.hash ||
2595
2563
  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(','));
2564
+ throw new Error('TUG_CONTROL_URL must be a credential-free https origin such as https://control.example.com:8443');
2565
+ const names = serverNames(advertised, (process.env.TUG_EXTRA_SANS || '').split(','));
2598
2566
  const credential = await ensureServerCredential(pki, dataDir, names);
2599
2567
  const server = https.createServer(serverTlsOptions({ cert: credential.cert, key: credential.key, ca: pki.caPem }), app);
2600
2568
  server.requestTimeout = 5 * 60_000;
@@ -39,7 +39,7 @@ export const ROUTE_ROLES = {
39
39
  'POST /api/v1/nodes/enrollment-credential': 'admin',
40
40
  'POST /api/v1/nodes/:name/identity': 'admin',
41
41
  'POST /api/v1/nodes/:name/disable': 'admin',
42
- 'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
42
+ 'POST /api/v1/nodes/:name/address': 'admin',
43
43
  'POST /api/v1/nodes/:name/ingress': 'admin',
44
44
  'DELETE /api/v1/nodes/:name': 'admin',
45
45
  'POST /api/v1/backups/run': 'deploy',
package/dist/db.d.ts CHANGED
@@ -13,8 +13,6 @@ export interface NodeRow {
13
13
  id: string;
14
14
  name: string;
15
15
  address: string | null;
16
- public_address: string | null;
17
- overlay_ip: string | null;
18
16
  ingress: number;
19
17
  ingress_status_json: string | null;
20
18
  status: string;
@@ -31,14 +29,6 @@ export interface NodeRow {
31
29
  created_at: string;
32
30
  updated_at: string;
33
31
  }
34
- export interface WgPeerRow {
35
- node_id: string;
36
- public_key: string;
37
- overlay_ip: string;
38
- endpoint: string | null;
39
- allowed_ips: string;
40
- updated_at: string;
41
- }
42
32
  export interface EnrollmentCredentialRow {
43
33
  id: string;
44
34
  key_hash: string;
package/dist/db.js CHANGED
@@ -54,8 +54,7 @@ function initializeControl(db) {
54
54
  db.exec(`
55
55
  CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
56
56
  CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
57
- CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, key_hash TEXT, disabled_at TEXT, cert_not_after TEXT, last_renewed_at TEXT, last_seen_at TEXT, clock_skew_ms INTEGER, clock_observed_at TEXT, created_at TEXT, updated_at TEXT);
58
- CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
57
+ CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, key_hash TEXT, disabled_at TEXT, cert_not_after TEXT, last_renewed_at TEXT, last_seen_at TEXT, clock_skew_ms INTEGER, clock_observed_at TEXT, created_at TEXT, updated_at TEXT);
59
58
  CREATE TABLE IF NOT EXISTS enrollment_credentials(id TEXT PRIMARY KEY, key_hash TEXT UNIQUE NOT NULL, name TEXT NOT NULL, labels_json TEXT, expires_at TEXT NOT NULL, revoked_at TEXT, node_id TEXT, reserved_node_id TEXT NOT NULL, node_key_hash TEXT, created_at TEXT);
60
59
  CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
61
60
  CREATE TABLE IF NOT EXISTS desired_states(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, version TEXT, state_json TEXT, created_at TEXT);