@wpelevator/tug 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +26 -24
  2. package/dist/agent-credential.d.ts +39 -0
  3. package/dist/agent-credential.js +135 -0
  4. package/dist/agent.d.ts +8 -9
  5. package/dist/agent.js +100 -102
  6. package/dist/backup-config.js +1 -1
  7. package/dist/backup-destination.d.ts +1 -1
  8. package/dist/backup-destination.js +1 -1
  9. package/dist/build.d.ts +6 -0
  10. package/dist/build.js +27 -7
  11. package/dist/cli-build.js +77 -4
  12. package/dist/cli-config.d.ts +9 -5
  13. package/dist/cli-config.js +89 -34
  14. package/dist/cli-install.js +1 -1
  15. package/dist/cli-live-transport.d.ts +3 -2
  16. package/dist/cli-live-transport.js +9 -5
  17. package/dist/cli.js +266 -142
  18. package/dist/compose.d.ts +5 -1
  19. package/dist/compose.js +23 -2
  20. package/dist/control-credential.d.ts +12 -0
  21. package/dist/control-credential.js +46 -0
  22. package/dist/control.d.ts +18 -1
  23. package/dist/control.js +602 -203
  24. package/dist/credentials.d.ts +111 -0
  25. package/dist/{tokens.js → credentials.js} +70 -54
  26. package/dist/db.d.ts +20 -5
  27. package/dist/db.js +3 -3
  28. package/dist/diagnostics.d.ts +44 -0
  29. package/dist/diagnostics.js +65 -0
  30. package/dist/ingress.js +4 -3
  31. package/dist/live.d.ts +2 -0
  32. package/dist/live.js +9 -1
  33. package/dist/main.js +35 -0
  34. package/dist/mesh.d.ts +4 -0
  35. package/dist/mesh.js +17 -0
  36. package/dist/normalize.js +1 -1
  37. package/dist/pki.d.ts +130 -0
  38. package/dist/pki.js +356 -0
  39. package/dist/protocol.d.ts +51 -22
  40. package/dist/status-report.d.ts +11 -0
  41. package/dist/status-report.js +147 -0
  42. package/dist/templates/agent.env +1 -3
  43. package/dist/templates/control-compose.yml +4 -3
  44. package/dist/templates/control-install.sh +8 -2
  45. package/dist/tls-server.d.ts +11 -0
  46. package/dist/tls-server.js +23 -0
  47. package/package.json +10 -8
  48. package/dist/tokens.d.ts +0 -91
package/dist/control.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import express from 'express';
3
3
  import { resolveImage, parseRegistryCredentials, imageLocation, REGISTRY_AUTH_SECRET, } from "./registry.js";
4
4
  import multer from 'multer';
5
+ import crypto from 'node:crypto';
5
6
  import fs from 'node:fs/promises';
6
7
  import { createReadStream } from 'node:fs';
7
8
  import { pipeline } from 'node:stream/promises';
@@ -15,17 +16,21 @@ import * as tar from 'tar';
15
16
  import { openDb, openSnapshotDb } from "./db.js";
16
17
  import { isUniqueViolation } from "./db-driver.js";
17
18
  import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
18
- import { buildDesiredState, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
19
+ import { buildDesiredState, ingressHostsOf, ingressHostsOverlap, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
19
20
  import { buildImage, unpackContext, run as runCommand } from "./build.js";
20
21
  import { sentinelNames } from "./normalize.js";
21
- import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
22
- import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } from "./mesh.js";
22
+ import { ensureDir, errMessage, id, now } from "./util.js";
23
+ import { ENROLL_DEFAULT_TTL_MS, NODE_LIFETIME_MS, Pki, SERVER_LIFETIME_MS, encodeCredential, identityUri, namesKey, parseIdentityUri, peerIdentity, serverNames, writeFileDurable, } from "./pki.js";
24
+ import { EXPIRY_WARN_DAYS, certificateExpiry, diagnoseNode } from "./diagnostics.js";
25
+ import { serverTlsOptions } from "./tls-server.js";
26
+ import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, isNodeEndpoint, isNodeHost, } from "./mesh.js";
23
27
  import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
28
+ import { parseStatusReport } from "./status-report.js";
24
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";
25
30
  import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
26
31
  import { createBackupService } from "./backup-scheduler.js";
27
32
  import { createRestoreService } from "./backup-restore.js";
28
- import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken, } from "./tokens.js";
33
+ import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createCredential, listCredentials, parseCredentialRequest, queryProject, recordEvent, revokeCredential, } from "./credentials.js";
29
34
  class WakeHub {
30
35
  cursors = new Map();
31
36
  projects = new Map();
@@ -398,14 +403,14 @@ function deploymentFailureMessage(rows) {
398
403
  parts.push(`${service}: ${oneLine(state.rolloutError)}`);
399
404
  }
400
405
  const detail = parts.join('; ');
401
- return detail ? `${row.node_id}: ${detail}` : row.node_id;
406
+ return detail ? `${row.node}: ${detail}` : row.node;
402
407
  });
403
408
  return `one or more assigned nodes reported failed health: ${details.join('; ')}`;
404
409
  }
405
410
  /** Versions of rollback deployments start with this; `tug deploy` may not use it, which is how automatic rollback tells them apart. */
406
411
  const ROLLBACK_VERSION_PREFIX = 'rollback-';
407
412
  const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map((s) => `'${s}'`).join(',');
408
- const PORT = Number(process.env.PORT || 8080);
413
+ const PORT = Number(process.env.PORT || 8443);
409
414
  export async function createApp(options = {}) {
410
415
  const images = {
411
416
  resolve: resolveImage,
@@ -430,7 +435,7 @@ export async function createApp(options = {}) {
430
435
  const MESH = process.env.TUG_MESH === 'true';
431
436
  const MESH_CIDR = process.env.TUG_MESH_CIDR || DEFAULT_MESH_CIDR;
432
437
  const WG_PORT = Number(process.env.TUG_WG_PORT || DEFAULT_WG_PORT);
433
- const SERVICE_DNS = (process.env.TUG_SERVICE_DNS ?? String(MESH)) === 'true';
438
+ const SERVICE_DNS = (process.env.TUG_SERVICE_NAMES ?? String(MESH)) === 'true';
434
439
  const ACME_EMAIL = process.env.TUG_ACME_EMAIL || undefined;
435
440
  if (MESH)
436
441
  parseCidr(MESH_CIDR);
@@ -472,23 +477,94 @@ export async function createApp(options = {}) {
472
477
  console.error(`event pruning failed: ${errMessage(e)}`);
473
478
  }
474
479
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
475
- const operator = db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
476
- if (!operator) {
477
- const t = process.env.TUG_OPERATOR_TOKEN || token('tug_op');
478
- db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
479
- await fs
480
- .writeFile(path.join(DATA_DIR, 'operator.token'), `${t}\n`, { mode: 0o600 })
481
- .catch(() => { });
482
- console.log(`operator token: ${t}`);
483
- }
480
+ // A CA is created only for a fresh installation; existing state with missing or mismatched CA material is a startup error.
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 advertiseUrl = () => {
483
+ const url = options.advertiseUrl ?? process.env.TUG_ADVERTISE_URL;
484
+ if (!url)
485
+ throw httpError(500, 'TUG_ADVERTISE_URL is not set');
486
+ return new URL(url).origin;
487
+ };
484
488
  const app = express();
485
- const authenticateAgent = (raw) => raw
486
- ? db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw))
487
- : undefined;
489
+ app.locals.pki = pki;
490
+ app.locals.dataDir = DATA_DIR;
491
+ /** Resolves a presented certificate (principal URI and key hash) to a principal from current database state. */
492
+ function resolvePrincipal(uri, keyHash) {
493
+ const parsed = parseIdentityUri(uri);
494
+ if (!parsed)
495
+ return null;
496
+ if (parsed.klass === 'node') {
497
+ const node = db.get('SELECT * FROM nodes WHERE id=?', parsed.id);
498
+ return node && node.key_hash === keyHash && !node.disabled_at
499
+ ? { klass: 'node', node }
500
+ : null;
501
+ }
502
+ if (parsed.klass === 'operator') {
503
+ const actor = authenticate(db, parsed.id, keyHash);
504
+ return actor ? { klass: 'operator', actor, credentialId: parsed.id } : null;
505
+ }
506
+ const row = db.get('SELECT * FROM enrollment_credentials WHERE id=?', parsed.id);
507
+ return row && row.key_hash === keyHash && !row.revoked_at && row.expires_at > now()
508
+ ? { klass: 'enroll', row }
509
+ : null;
510
+ }
511
+ const liveSockets = new Set();
512
+ app.locals.liveSockets = liveSockets;
513
+ /** Closes connections whose principal is disabled, revoked, replaced, removed or expired. */
514
+ const reconcileSockets = () => {
515
+ for (const entry of [...liveSockets]) {
516
+ if (entry.socket.destroyed)
517
+ liveSockets.delete(entry);
518
+ else if (entry.notAfter <= Date.now() || !resolvePrincipal(entry.uri, entry.keyHash)) {
519
+ entry.socket.destroy();
520
+ liveSockets.delete(entry);
521
+ }
522
+ }
523
+ };
524
+ const closeAllSockets = () => {
525
+ for (const entry of liveSockets)
526
+ entry.socket.destroy();
527
+ liveSockets.clear();
528
+ };
529
+ app.locals.reconcileSockets = () => {
530
+ if (!restartRequired)
531
+ reconcileSockets();
532
+ };
533
+ /** The certificate a request or upgrade arrived with, as a (uri, keyHash) pair; null if none can be established. */
534
+ const presented = (request) => {
535
+ if (options.identity)
536
+ return options.identity(request, db);
537
+ const socket = request.socket;
538
+ if (typeof socket.getPeerCertificate !== 'function' || socket.isSessionReused())
539
+ return null;
540
+ return peerIdentity(socket);
541
+ };
542
+ const identify = (request) => {
543
+ const cert = presented(request);
544
+ return cert ? resolvePrincipal(cert.uri, cert.keyHash) : null;
545
+ };
488
546
  let restartRequired = false;
489
547
  app.use((_req, res, next) => restartRequired
490
548
  ? res.status(503).json({ error: 'control restored; restart required', restartRequired: true })
491
549
  : next());
550
+ // Route class gate: runs before body parsing and every handler. There is no unauthenticated route.
551
+ app.use((req, res, next) => {
552
+ const identity = identify(req);
553
+ if (!identity)
554
+ return void res.status(401).json({ error: 'unauthorized' });
555
+ const enrollRoute = req.method === 'POST' && req.path === '/agent/v1/enroll';
556
+ const allowed = identity.klass === 'enroll'
557
+ ? enrollRoute
558
+ : identity.klass === 'node'
559
+ ? req.path.startsWith('/agent/v1/') && !enrollRoute
560
+ : req.path.startsWith('/api/v1/');
561
+ if (!allowed)
562
+ return void res
563
+ .status(403)
564
+ .json({ error: `forbidden: ${identity.klass} credentials cannot use this route` });
565
+ req.identity = identity;
566
+ next();
567
+ });
492
568
  app.use(express.json({ limit: '20mb' }));
493
569
  // Hold the writer for the operation lifetime, including after an HTTP disconnect.
494
570
  let writer = Promise.resolve();
@@ -534,8 +610,8 @@ export async function createApp(options = {}) {
534
610
  const route = (method, url, handlers) => app[method](url, ...handlers.map(safe));
535
611
  const get = (url, ...handlers) => route('get', url, handlers);
536
612
  const post = (url, ...handlers) => route('post', url, handlers);
537
- // Operator-facing routes go through api(): the role table in tokens.ts is the only place a route's access is declared.
538
- const guard = createGuard(db);
613
+ // Operator-facing routes go through api(): the role table in credentials.ts is the only place a route's access is declared.
614
+ const guard = createGuard();
539
615
  const apiRoutes = new Map();
540
616
  app.locals.apiRoutes = apiRoutes;
541
617
  const api = (method, url, resolve, ...handlers) => {
@@ -546,10 +622,9 @@ export async function createApp(options = {}) {
546
622
  apiRoutes.set(key, role);
547
623
  route(method, url, [guard(role, resolve ?? undefined), ...handlers]);
548
624
  };
549
- const deploymentProject = (req) => db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id)
550
- ?.project_id;
625
+ const deploymentProject = (req) => db.get('SELECT project_id FROM deployments WHERE id=?', String(req.params.id))?.project_id;
551
626
  const commandProject = (req) => {
552
- const row = db.get('SELECT project_id FROM commands WHERE id=?', req.params.id);
627
+ const row = db.get('SELECT project_id FROM commands WHERE id=?', String(req.params.id));
553
628
  return row ? row.project_id : undefined;
554
629
  };
555
630
  /** Audit trail entry for a control action; bookkeeping failures are logged, never returned to the caller. */
@@ -568,11 +643,9 @@ export async function createApp(options = {}) {
568
643
  }
569
644
  : { where: '1=1', args: [] };
570
645
  const authAgent = async (req, res, next) => {
571
- const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
572
- const node = authenticateAgent(raw);
573
- if (!node)
574
- return res.status(401).json({ error: 'unauthorized' });
575
- req.node = node;
646
+ if (req.identity?.klass !== 'node')
647
+ return res.status(403).json({ error: 'node credentials required' });
648
+ req.node = req.identity.node;
576
649
  next();
577
650
  };
578
651
  const wakeAll = (project) => {
@@ -594,8 +667,9 @@ export async function createApp(options = {}) {
594
667
  required.add(node);
595
668
  if (!required.size)
596
669
  return;
597
- const statuses = db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
598
- const byNode = new Map(statuses.map((s) => [s.node_id, s]));
670
+ // Placement names nodes; reports are keyed by the immutable node ID, so join to the current name.
671
+ const statuses = db.all('SELECT n.name AS node,s.observed_version,s.health,s.status_json FROM node_statuses s JOIN nodes n ON n.id=s.node_id WHERE s.project_id=?', projectId);
672
+ const byNode = new Map(statuses.map((s) => [s.node, s]));
599
673
  const reportsRevision = (node) => state.revision
600
674
  ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision
601
675
  : byNode.get(node)?.observed_version === state.version;
@@ -623,6 +697,23 @@ export async function createApp(options = {}) {
623
697
  * new revision. Shared by `POST /api/v1/rollback` and automatic rollback; the caller holds the writer queue.
624
698
  * `auto` only considers `succeeded` deployments and requires `failedDeploymentId` to still be current.
625
699
  */
700
+ /**
701
+ * Ingress hostnames belong to the first project that publishes them: another project's current deployment
702
+ * that already routes a host blocks any project from taking it, on deploy and on rollback alike.
703
+ */
704
+ function assertIngressHostsFree(project, compose) {
705
+ const wanted = ingressHostsOf(compose);
706
+ if (!wanted.size)
707
+ return;
708
+ for (const other of currentDesiredStates()) {
709
+ if (other.project === project)
710
+ continue;
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})`);
715
+ }
716
+ }
626
717
  function createRollback(project, opts) {
627
718
  let lock;
628
719
  try {
@@ -641,6 +732,7 @@ export async function createApp(options = {}) {
641
732
  const ts = now();
642
733
  const depId = id('dep');
643
734
  const previousState = JSON.parse(previousDs.state_json);
735
+ assertIngressHostsFree(project, previousState.compose);
644
736
  previousState.revision = depId;
645
737
  db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at,actor) VALUES(?,?,?,?,?,?,?)', depId, project, `${ROLLBACK_VERSION_PREFIX}${previousState.version}-${Date.now()}`, 'rolling_out', ts, ts, opts.actor);
646
738
  db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, previousState.version, JSON.stringify(previousState), ts);
@@ -734,9 +826,10 @@ export async function createApp(options = {}) {
734
826
  }, options.liveStartTimeoutMs);
735
827
  streamRuntimes.set(app, {
736
828
  hub: wakeHub,
737
- authenticate: authenticateAgent,
738
- authenticateOperator: (raw) => authenticate(db, raw),
829
+ identify,
830
+ resolvePrincipal,
739
831
  live,
832
+ isRestoring: () => restartRequired,
740
833
  });
741
834
  api('get', '/api/v1/live', null, (_req, res) => {
742
835
  res.status(426).json({ error: 'WebSocket upgrade required' });
@@ -799,7 +892,7 @@ export async function createApp(options = {}) {
799
892
  const state = JSON.parse(dsRow.state_json);
800
893
  if (!state.services?.[service])
801
894
  throw httpError(404, `service ${service} not found in project ${project}`);
802
- const nodeRows = db.all('SELECT name,labels_json,last_seen_at FROM nodes ORDER BY created_at,name');
895
+ const nodeRows = db.all('SELECT name,labels_json,last_seen_at FROM nodes WHERE disabled_at IS NULL ORDER BY created_at,name');
803
896
  const nodeInfos = nodeRows
804
897
  .filter((row) => !isStale(row))
805
898
  .map((row) => ({
@@ -809,7 +902,7 @@ export async function createApp(options = {}) {
809
902
  const composeSvc = state.compose?.services?.[service] || {};
810
903
  const ext = composeSvc['x-tug'] || {};
811
904
  const placement = ext.placement || {};
812
- const global = ext.global === true || placement.global === true;
905
+ const global = ext.global === true;
813
906
  const labels = Array.isArray(placement.labels) ? placement.labels : [];
814
907
  const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
815
908
  const pool = labels.length ? matched : nodeInfos;
@@ -834,14 +927,13 @@ export async function createApp(options = {}) {
834
927
  nodes: selected.map((node) => node.name),
835
928
  });
836
929
  });
837
- get('/healthz', (req, res) => res.json({ ok: true }));
838
930
  api('post', '/api/v1/projects', (req) => String(req.body?.name || 'site'), async (req, res) => {
839
931
  const name = req.body.name || 'site';
840
932
  const ts = now();
841
933
  db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
842
934
  res.json(db.get('SELECT * FROM projects WHERE id=?', name));
843
935
  });
844
- api('get', '/api/v1/projects/:project', (req) => req.params.project, async (req, res) => res.json(db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
936
+ api('get', '/api/v1/projects/:project', (req) => String(req.params.project), async (req, res) => res.json(db.get('SELECT * FROM projects WHERE id=?', String(req.params.project))));
845
937
  /** A node whose agent has been seen before but not within TUG_STALE_SECONDS; never-seen (just registered) nodes stay schedulable. */
846
938
  const isStale = (row) => !!row.last_seen_at &&
847
939
  Date.parse(row.last_seen_at) < Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000;
@@ -880,20 +972,12 @@ export async function createApp(options = {}) {
880
972
  db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
881
973
  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());
882
974
  }
883
- async function registerNode({ name, address, publicAddress, wgPublicKey, labels, }) {
884
- if (wgPublicKey !== undefined && !isWgKey(wgPublicKey))
885
- throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
886
- const agentToken = token('tug_agent');
887
- const ts = now();
888
- db.run(`INSERT INTO nodes(id,name,address,public_address,status,labels_json,agent_token_hash,created_at,updated_at)
889
- VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,labels_json=excluded.labels_json,agent_token_hash=excluded.agent_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', JSON.stringify(Array.isArray(labels) ? labels : []), hashToken(agentToken), ts, ts);
890
- ensureOverlayIp(name);
891
- syncWgPeer(name, wgPublicKey);
892
- wakeAll();
893
- const row = db.get(NODE_INFO_SQL, name);
975
+ const NODE_RENEW_MIN_INTERVAL_MS = () => Number(process.env.TUG_RENEW_MIN_INTERVAL_MS ?? 60 * 60 * 1000);
976
+ const nodeInfo = (nodeId) => {
977
+ const row = db.get(NODE_INFO_SQL, nodeId);
894
978
  if (!row)
895
- throw httpError(500, 'node not found after registration');
896
- const node = {
979
+ throw httpError(500, 'node not found');
980
+ return {
897
981
  id: row.id,
898
982
  name: row.name,
899
983
  address: row.address,
@@ -904,75 +988,198 @@ export async function createApp(options = {}) {
904
988
  created_at: row.created_at,
905
989
  updated_at: row.updated_at,
906
990
  };
907
- return { node, agentToken };
991
+ };
992
+ const NODE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/;
993
+ /** Inserts a node bound to a key. Synchronous so the existence check and the write cannot interleave. */
994
+ function insertNode(input) {
995
+ if (!NODE_NAME.test(input.name))
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');
1001
+ if (input.wgPublicKey !== undefined && !isWgKey(input.wgPublicKey))
1002
+ throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
1003
+ const ts = now();
1004
+ db.run(`INSERT INTO nodes(id,name,address,public_address,status,labels_json,key_hash,cert_not_after,created_at,updated_at)
1005
+ 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);
1006
+ ensureOverlayIp(input.id);
1007
+ syncWgPeer(input.id, input.wgPublicKey);
1008
+ wakeAll();
908
1009
  }
909
- api('post', '/api/v1/nodes/register', null, async (req, res) => {
910
- const name = req.body.name;
911
- if (!name)
912
- return res.status(400).json({ error: 'name required' });
1010
+ const identityResponse = (nodeId, cert) => ({
1011
+ node: nodeInfo(nodeId),
1012
+ certificate: cert,
1013
+ ca: pki.caPem,
1014
+ url: advertiseUrl(),
1015
+ });
1016
+ // The node generates its key and CSR; an admin posts the CSR here. The private key is never transmitted.
1017
+ api('post', '/api/v1/nodes/:name/identity', null, async (req, res) => {
1018
+ const body = req.body ?? {};
1019
+ const name = String(req.params.name);
1020
+ if (body.mode !== 'create' && body.mode !== 'replace')
1021
+ return res.status(400).json({ error: "mode must be 'create' or 'replace'" });
913
1022
  try {
914
- res.json(await registerNode({
915
- name,
916
- address: req.body.address,
917
- publicAddress: req.body.publicAddress,
918
- wgPublicKey: req.body.wgPublicKey,
919
- labels: req.body.labels,
920
- }));
1023
+ // The certificate names the immutable node ID: the existing node's, or a fresh one for a new name. A name freed by
1024
+ // node-remove therefore never inherits the old ID, and certificates for the removed node cannot match the new one.
1025
+ let nodeId = db.get('SELECT * FROM nodes WHERE name=?', name)?.id ?? id('node');
1026
+ let signed = await pki.signClientCsr(body.csr, identityUri('node', nodeId), NODE_LIFETIME_MS);
1027
+ // Signing is asynchronous: re-read, and re-sign once if a concurrent request created the node meanwhile.
1028
+ const current = db.get('SELECT * FROM nodes WHERE name=?', name);
1029
+ if (current && current.id !== nodeId) {
1030
+ nodeId = current.id;
1031
+ signed = await pki.signClientCsr(body.csr, identityUri('node', nodeId), NODE_LIFETIME_MS);
1032
+ }
1033
+ const existing = db.get('SELECT * FROM nodes WHERE name=?', name);
1034
+ if (existing && existing.id !== nodeId)
1035
+ throw httpError(409, `node ${name} was created concurrently; retry`);
1036
+ if (body.mode === 'create') {
1037
+ if (existing) {
1038
+ if (existing.key_hash !== signed.keyHash)
1039
+ return res.status(409).json({
1040
+ error: `node ${name} already exists; use node-replace-identity to install a new key`,
1041
+ });
1042
+ }
1043
+ else {
1044
+ insertNode({
1045
+ id: nodeId,
1046
+ name,
1047
+ keyHash: signed.keyHash,
1048
+ notAfter: signed.notAfter,
1049
+ address: body.address,
1050
+ publicAddress: body.publicAddress,
1051
+ labels: body.labels,
1052
+ });
1053
+ }
1054
+ }
1055
+ else {
1056
+ if (!existing)
1057
+ return res.status(404).json({ error: 'node not found' });
1058
+ 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);
1059
+ audit(req.actor, {
1060
+ type: 'node.replaced',
1061
+ message: `node ${name} key replaced by ${req.actor.name}`,
1062
+ metadata: { node: name },
1063
+ });
1064
+ reconcileSockets();
1065
+ }
1066
+ res.json(identityResponse(nodeId, signed.cert));
921
1067
  }
922
1068
  catch (e) {
923
1069
  sendError(res, e);
924
1070
  }
925
1071
  });
926
- // Self-service enrollment: operator mints a short-lived, limited-use key; the agent
927
- // exchanges it for durable credentials on first boot (Portainer Edge-style join).
928
- api('post', '/api/v1/nodes/enroll-token', null, async (req, res) => {
929
- const name = req.body.name || null;
930
- const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? 3600));
931
- const uses = Math.max(1, Number(req.body.uses ?? 1));
932
- const enrollToken = token('tug_enroll');
933
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
934
- db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
935
- res.json({ enrollToken, name, usesRemaining: uses, expiresAt });
936
- });
937
- post('/agent/v1/enroll', async (req, res) => {
938
- const body = req.body;
939
- const raw = String(body.enrollToken || (req.headers.authorization || '').replace(/^Bearer /, '')).trim();
940
- if (!raw)
941
- return res.status(400).json({ error: 'enrollToken required' });
942
- const row = db.get('SELECT * FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
943
- if (!row || row.uses_remaining < 1 || row.expires_at < now()) {
944
- if (row)
945
- db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
946
- return res.status(401).json({ error: 'invalid or expired enroll token' });
947
- }
948
- const name = row.name || body.name;
949
- if (!name)
1072
+ // Self-enrollment: an admin mints a short-lived, single-use, name-bound bootstrap certificate that reaches one route.
1073
+ api('post', '/api/v1/nodes/enrollment-credential', null, async (req, res) => {
1074
+ const name = req.body?.name;
1075
+ if (typeof name !== 'string' || !NODE_NAME.test(name))
950
1076
  return res
951
1077
  .status(400)
952
- .json({ error: 'node name required (bind one to the enroll token or send name)' });
953
- let result;
1078
+ .json({ error: 'name required: a valid node name to bind the join key to' });
1079
+ const labels = req.body.labels;
1080
+ if (labels !== undefined &&
1081
+ (!Array.isArray(labels) || labels.some((l) => typeof l !== 'string')))
1082
+ return res.status(400).json({ error: 'labels must be a list of strings' });
1083
+ const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? ENROLL_DEFAULT_TTL_MS / 1000));
1084
+ const rowId = id('enr');
1085
+ const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, advertiseUrl());
1086
+ const expiresAt = issued.notAfter.toISOString();
1087
+ db.run('DELETE FROM enrollment_credentials WHERE expires_at < ?', new Date(Date.now() - 86_400_000).toISOString());
1088
+ // Minting again for a name revokes its previous unconsumed key.
1089
+ db.run('UPDATE enrollment_credentials SET revoked_at=? WHERE name=? AND node_id IS NULL AND revoked_at IS NULL', now(), name);
1090
+ db.run('INSERT INTO enrollment_credentials(id,key_hash,name,labels_json,expires_at,reserved_node_id,created_at) VALUES(?,?,?,?,?,?,?)', rowId, issued.keyHash, name, JSON.stringify(labels ?? []), expiresAt, id('node'), now());
1091
+ reconcileSockets();
1092
+ const out = {
1093
+ credential: issued.credential,
1094
+ name,
1095
+ labels: labels ?? [],
1096
+ expiresAt,
1097
+ };
1098
+ res.json(out);
1099
+ });
1100
+ post('/agent/v1/enroll', async (req, res) => {
1101
+ const row = req.identity?.klass === 'enroll' ? req.identity.row : null;
1102
+ if (!row)
1103
+ return res.status(403).json({ error: 'bootstrap credentials required' });
1104
+ const body = req.body ?? {};
954
1105
  try {
955
- result = await registerNode({
956
- name,
957
- address: body.address,
958
- publicAddress: body.publicAddress,
959
- wgPublicKey: body.wgPublicKey,
960
- labels: body.labels,
961
- });
1106
+ const signed = await pki.signClientCsr(body.csr, identityUri('node', row.reserved_node_id), NODE_LIFETIME_MS);
1107
+ // Re-read the row: the checks and writes below are synchronous, so concurrent enrollments cannot both create a node.
1108
+ const fresh = db.get('SELECT * FROM enrollment_credentials WHERE id=?', row.id);
1109
+ if (!fresh || fresh.revoked_at || fresh.expires_at <= now())
1110
+ return res.status(401).json({ error: 'join key is expired or revoked' });
1111
+ if (fresh.node_id) {
1112
+ if (fresh.node_key_hash !== signed.keyHash)
1113
+ return res.status(403).json({ error: 'join key was already used by a different key' });
1114
+ }
1115
+ else {
1116
+ if (db.get('SELECT 1 FROM nodes WHERE name=?', fresh.name))
1117
+ return res
1118
+ .status(409)
1119
+ .json({ error: `node ${fresh.name} already exists; a join key cannot replace a node` });
1120
+ insertNode({
1121
+ id: fresh.reserved_node_id,
1122
+ name: fresh.name,
1123
+ keyHash: signed.keyHash,
1124
+ notAfter: signed.notAfter,
1125
+ address: body.address,
1126
+ publicAddress: body.publicAddress,
1127
+ wgPublicKey: body.wgPublicKey,
1128
+ labels: fresh.labels_json ? JSON.parse(fresh.labels_json) : [],
1129
+ });
1130
+ db.run('UPDATE enrollment_credentials SET node_id=?,node_key_hash=? WHERE id=?', fresh.reserved_node_id, signed.keyHash, fresh.id);
1131
+ recordEvent(db, {
1132
+ actor: `enroll:${fresh.id}`,
1133
+ type: 'node.enrolled',
1134
+ message: `node ${fresh.name} enrolled`,
1135
+ });
1136
+ }
1137
+ const enrolled = identityResponse(fresh.reserved_node_id, signed.cert);
1138
+ res.json(enrolled);
962
1139
  }
963
1140
  catch (e) {
964
- return sendError(res, e);
1141
+ sendError(res, e);
1142
+ }
1143
+ });
1144
+ // Same-key renewal: another certificate for the stored key. Nothing is stored beyond an informational expiry.
1145
+ post('/agent/v1/renew', authAgent, async (req, res) => {
1146
+ const node = req.node;
1147
+ try {
1148
+ const signed = await pki.signClientCsr(req.body?.csr, identityUri('node', node.id), NODE_LIFETIME_MS);
1149
+ if (signed.keyHash !== node.key_hash)
1150
+ return res.status(403).json({
1151
+ error: 'renewal requires a CSR for the node key; use node-replace-identity to change keys',
1152
+ });
1153
+ const last = node.last_renewed_at ? Date.parse(node.last_renewed_at) : 0;
1154
+ if (Date.now() - last < NODE_RENEW_MIN_INTERVAL_MS())
1155
+ return res.status(429).json({ error: 'certificate renewed too recently' });
1156
+ db.run('UPDATE nodes SET cert_not_after=?,last_renewed_at=? WHERE id=?', signed.notAfter.toISOString(), now(), node.id);
1157
+ const renewed = { certificate: signed.cert };
1158
+ res.json(renewed);
1159
+ }
1160
+ catch (e) {
1161
+ sendError(res, e);
965
1162
  }
966
- const remaining = row.uses_remaining - 1;
967
- if (remaining < 1)
968
- db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
969
- else
970
- db.run('UPDATE enroll_tokens SET uses_remaining=? WHERE token_hash=?', remaining, hashToken(raw));
971
- const enrolled = { ...result };
972
- res.json(enrolled);
1163
+ });
1164
+ // Blocks a node's key without deleting its metadata or changing placement. It returns only through node-replace-identity.
1165
+ api('post', '/api/v1/nodes/:name/disable', null, async (req, res) => {
1166
+ const node = findNode(String(req.params.name));
1167
+ if (!node)
1168
+ return res.status(404).json({ error: 'node not found' });
1169
+ db.run("UPDATE nodes SET disabled_at=COALESCE(disabled_at,?),status='disabled',updated_at=? WHERE id=?", now(), now(), node.id);
1170
+ audit(req.actor, {
1171
+ type: 'node.disabled',
1172
+ message: `node ${node.name} disabled by ${req.actor.name}`,
1173
+ metadata: { node: node.name },
1174
+ });
1175
+ reconcileSockets();
1176
+ // Healthy peers drop the node from their mesh and ingress configuration on their next work fetch.
1177
+ endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1178
+ wakeAll();
1179
+ res.json({ ok: true, node: node.name });
973
1180
  });
974
1181
  api('get', '/api/v1/nodes', null, async (req, res) => {
975
- const rows = db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,labels_json,last_seen_at,created_at,updated_at FROM nodes ORDER BY name');
1182
+ 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');
976
1183
  res.json({
977
1184
  nodes: rows.map((row) => {
978
1185
  const { labels_json, ...rest } = row;
@@ -995,15 +1202,15 @@ export async function createApp(options = {}) {
995
1202
  api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
996
1203
  if (!MESH)
997
1204
  return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
998
- const node = findNode(req.params.name);
1205
+ const node = findNode(String(req.params.name));
999
1206
  if (!node)
1000
1207
  return res.status(404).json({ error: 'node not found' });
1001
1208
  res.json({ commandId: queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
1002
1209
  });
1003
- // Removes a retired node: its token, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1210
+ // Removes a retired node: its identity, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1004
1211
  // something on it (redeploy without it first) or while its agent is still reporting, unless force=true.
1005
1212
  api('delete', '/api/v1/nodes/:name', null, async (req, res) => {
1006
- const node = db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
1213
+ const node = db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', String(req.params.name), String(req.params.name));
1007
1214
  if (!node)
1008
1215
  return res.status(404).json({ error: 'node not found' });
1009
1216
  const referenced = [];
@@ -1027,6 +1234,8 @@ export async function createApp(options = {}) {
1027
1234
  db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
1028
1235
  db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
1029
1236
  db.run('DELETE FROM nodes WHERE id=?', node.id);
1237
+ db.run('UPDATE enrollment_credentials SET revoked_at=COALESCE(revoked_at,?) WHERE name=? AND node_id IS NULL', now(), node.name);
1238
+ reconcileSockets();
1030
1239
  audit(req.actor, {
1031
1240
  type: 'node.removed',
1032
1241
  message: `node ${node.name} removed`,
@@ -1036,7 +1245,7 @@ export async function createApp(options = {}) {
1036
1245
  res.json({ ok: true, node: node.name });
1037
1246
  });
1038
1247
  api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
1039
- const node = findNode(req.params.name);
1248
+ const node = findNode(String(req.params.name));
1040
1249
  if (!node)
1041
1250
  return res.status(404).json({ error: 'node not found' });
1042
1251
  const enabled = req.body.enabled !== false;
@@ -1059,12 +1268,12 @@ export async function createApp(options = {}) {
1059
1268
  sendError(res, isUniqueViolation(e) ? httpError(409, 'public key already registered to another node') : e);
1060
1269
  }
1061
1270
  });
1062
- api('post', '/api/v1/tokens', null, async (req, res) => {
1063
- const request = parseTokenRequest(req.body);
1064
- const created = createToken(db, request, req.actor.name);
1271
+ api('post', '/api/v1/credentials', null, async (req, res) => {
1272
+ const request = parseCredentialRequest(req.body);
1273
+ const created = await createCredential(db, pki, advertiseUrl(), request, req.actor.name);
1065
1274
  audit(req.actor, {
1066
- type: 'token.create',
1067
- message: `token ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
1275
+ type: 'credential.create',
1276
+ message: `credential ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
1068
1277
  metadata: {
1069
1278
  name: created.name,
1070
1279
  role: created.role,
@@ -1072,18 +1281,19 @@ export async function createApp(options = {}) {
1072
1281
  expiresAt: created.expiresAt,
1073
1282
  },
1074
1283
  });
1075
- res.json(created);
1284
+ res.json({ ...created, credential: encodeCredential(created.credential) });
1076
1285
  });
1077
- api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: listTokens(db) }));
1078
- api('delete', '/api/v1/tokens/:name', null, async (req, res) => {
1079
- if (!revokeToken(db, req.params.name))
1080
- return res.status(404).json({ error: 'token not found' });
1286
+ api('get', '/api/v1/credentials', null, async (_req, res) => res.json({ credentials: listCredentials(db) }));
1287
+ api('delete', '/api/v1/credentials/:name', null, async (req, res) => {
1288
+ if (!revokeCredential(db, String(req.params.name)))
1289
+ return res.status(404).json({ error: 'credential not found' });
1290
+ reconcileSockets();
1081
1291
  audit(req.actor, {
1082
- type: 'token.revoke',
1083
- message: `token ${req.params.name} revoked by ${req.actor.name}`,
1084
- metadata: { name: req.params.name },
1292
+ type: 'credential.revoke',
1293
+ message: `credential ${String(req.params.name)} revoked by ${req.actor.name}`,
1294
+ metadata: { name: String(req.params.name) },
1085
1295
  });
1086
- res.json({ ok: true, name: req.params.name });
1296
+ res.json({ ok: true, name: String(req.params.name) });
1087
1297
  });
1088
1298
  const backupDestination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
1089
1299
  const backupEnv = () => {
@@ -1173,13 +1383,13 @@ export async function createApp(options = {}) {
1173
1383
  });
1174
1384
  api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({ restores: restores.list(String(req.query.project || 'site')) }));
1175
1385
  api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
1176
- const result = restores.releaseInterrupted(req.params.id);
1386
+ const result = restores.releaseInterrupted(String(req.params.id));
1177
1387
  if ('error' in result)
1178
1388
  return res.status(result.status).json({ error: result.error });
1179
1389
  res.json(result);
1180
1390
  });
1181
1391
  api('post', '/api/v1/restores/:id/resume', null, async (req, res) => {
1182
- const result = restores.resume(req.params.id);
1392
+ const result = restores.resume(String(req.params.id));
1183
1393
  if ('error' in result)
1184
1394
  return res.status(result.status).json({ error: result.error });
1185
1395
  res.json(result);
@@ -1245,7 +1455,7 @@ export async function createApp(options = {}) {
1245
1455
  });
1246
1456
  res.json({ ok: true, removed: removed.changes > 0 });
1247
1457
  });
1248
- api('get', '/api/v1/backup/key', null, async (req, res) => {
1458
+ api('get', '/api/v1/backup/repository-password', null, async (req, res) => {
1249
1459
  const project = String(req.query.project || '');
1250
1460
  if (!PROJECT_NAME_PATTERN.test(project) || isReservedProject(project))
1251
1461
  return res.status(400).json({ error: 'project is required' });
@@ -1301,14 +1511,14 @@ export async function createApp(options = {}) {
1301
1511
  const project = String(req.query.project || 'site');
1302
1512
  if (isReservedProject(project))
1303
1513
  return res.status(400).json({ error: 'project names starting with _ are reserved' });
1304
- const removed = db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
1514
+ const removed = db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, String(req.params.name));
1305
1515
  if (removed.changes) {
1306
1516
  wakeAll(project);
1307
1517
  audit(req.actor, {
1308
1518
  type: 'secret.delete',
1309
1519
  project,
1310
- message: `secret ${req.params.name} deleted by ${req.actor.name}`,
1311
- metadata: { name: req.params.name },
1520
+ message: `secret ${String(req.params.name)} deleted by ${req.actor.name}`,
1521
+ metadata: { name: String(req.params.name) },
1312
1522
  });
1313
1523
  }
1314
1524
  res.json({ ok: true });
@@ -1384,7 +1594,7 @@ export async function createApp(options = {}) {
1384
1594
  throw httpError(400, 'invalid project or version');
1385
1595
  if (version.startsWith(ROLLBACK_VERSION_PREFIX))
1386
1596
  throw httpError(400, `version cannot start with "${ROLLBACK_VERSION_PREFIX}" (reserved for rollback deployments)`);
1387
- const nodeRows = db.all('SELECT name,labels_json,last_seen_at,created_at FROM nodes ORDER BY created_at,name');
1597
+ const nodeRows = db.all('SELECT name,labels_json,last_seen_at,created_at FROM nodes WHERE disabled_at IS NULL ORDER BY created_at,name');
1388
1598
  // Stale nodes (agent not seen recently) are not scheduled onto, so a dead host cannot hold a rollout open.
1389
1599
  const nodeInfos = nodeRows
1390
1600
  .filter((row) => !isStale(row))
@@ -1397,7 +1607,7 @@ export async function createApp(options = {}) {
1397
1607
  for (const svc of Object.values(compose.services || {})) {
1398
1608
  const ext = (svc['x-tug'] ||= {});
1399
1609
  const placement = ext.placement || {};
1400
- const global = ext.global === true || placement.global === true;
1610
+ const global = ext.global === true;
1401
1611
  const labels = Array.isArray(placement.labels) ? placement.labels : [];
1402
1612
  if (!placement.nodes?.length && (labels.length || global)) {
1403
1613
  const matched = nodeInfos
@@ -1417,12 +1627,17 @@ export async function createApp(options = {}) {
1417
1627
  }
1418
1628
  }
1419
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`);
1420
1634
  try {
1421
1635
  validateCompose(compose, nodes.map((n) => n.name));
1422
1636
  }
1423
1637
  catch (e) {
1424
1638
  throw httpError(400, errMessage(e));
1425
1639
  }
1640
+ assertIngressHostsFree(project, compose);
1426
1641
  // The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
1427
1642
  const referenced = sentinelNames(compose);
1428
1643
  if (referenced.includes(REGISTRY_AUTH_SECRET))
@@ -1631,11 +1846,11 @@ export async function createApp(options = {}) {
1631
1846
  }
1632
1847
  get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
1633
1848
  const states = retainedStates();
1634
- const allowed = states.some((state) => Object.values(state.services).some((service) => service.artifact?.buildId === req.params.buildId &&
1849
+ const allowed = states.some((state) => Object.values(state.services).some((service) => service.artifact?.buildId === String(req.params.buildId) &&
1635
1850
  (!service.placement.nodes?.length || service.placement.nodes.includes(req.node.name))));
1636
1851
  if (!allowed)
1637
1852
  return res.status(404).json({ error: 'image not found' });
1638
- const row = db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
1853
+ const row = db.get('SELECT * FROM builds WHERE id=? AND state=?', String(req.params.buildId), 'complete');
1639
1854
  if (!row?.artifact_path)
1640
1855
  return res.status(404).json({ error: 'image not found' });
1641
1856
  try {
@@ -1653,14 +1868,14 @@ export async function createApp(options = {}) {
1653
1868
  res.status(404).json({ error: 'image artifact unavailable' });
1654
1869
  }
1655
1870
  });
1656
- api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
1871
+ api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(db.get('SELECT * FROM deployments WHERE id=?', String(req.params.id))));
1657
1872
  api('post', '/api/v1/deployments/:id/cancel', deploymentProject, async (req, res) => withPublication(() => {
1658
- const row = db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
1873
+ const row = db.get('SELECT * FROM deployments WHERE id=?', String(req.params.id));
1659
1874
  if (!row)
1660
1875
  return res.status(404).json({ error: 'deployment not found' });
1661
1876
  if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
1662
1877
  return res.status(409).json({ error: `deployment is ${row.state}` });
1663
- const result = db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), req.params.id);
1878
+ const result = db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), String(req.params.id));
1664
1879
  if (result.changes !== 1)
1665
1880
  return res.status(409).json({ error: 'deployment is no longer active' });
1666
1881
  audit(req.actor, {
@@ -1700,7 +1915,7 @@ export async function createApp(options = {}) {
1700
1915
  let lock;
1701
1916
  try {
1702
1917
  lock = deleteData ? acquireProjectLock(project, 'volume.delete') : null;
1703
- const volume = db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1918
+ const volume = db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, String(req.params.name));
1704
1919
  if (!volume)
1705
1920
  return res.status(404).json({ error: 'volume not found' });
1706
1921
  const config = JSON.parse(volume.config_json || '{}');
@@ -1712,7 +1927,7 @@ export async function createApp(options = {}) {
1712
1927
  if (ds) {
1713
1928
  const state = JSON.parse(ds.state_json);
1714
1929
  const inUse = Object.entries(state.compose?.services || {})
1715
- .filter(([, svc]) => (svc.volumes || []).some((v) => (typeof v === 'string' ? v.split(':')[0] : v.source) === req.params.name))
1930
+ .filter(([, svc]) => (svc.volumes || []).some((v) => (typeof v === 'string' ? v.split(':')[0] : v.source) === String(req.params.name)))
1716
1931
  .map(([name]) => name);
1717
1932
  if (inUse.length)
1718
1933
  return res
@@ -1723,10 +1938,10 @@ export async function createApp(options = {}) {
1723
1938
  if (deleteData && volume.node_id) {
1724
1939
  commandId = queueCommand(volume.node_id, project, 'volume.delete', {
1725
1940
  project,
1726
- volume: req.params.name,
1941
+ volume: String(req.params.name),
1727
1942
  });
1728
1943
  }
1729
- db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1944
+ db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, String(req.params.name));
1730
1945
  res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
1731
1946
  }
1732
1947
  catch (e) {
@@ -1738,25 +1953,42 @@ export async function createApp(options = {}) {
1738
1953
  });
1739
1954
  api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
1740
1955
  const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
1741
- await ensureDir(backupDir);
1956
+ // The archive holds the master key and the CA key, so nothing here is ever readable beyond the control user.
1957
+ await fs.mkdir(backupDir, { recursive: true, mode: 0o700 });
1958
+ await fs.chmod(backupDir, 0o700);
1742
1959
  const ts = now();
1743
1960
  const backupId = id('cbak');
1744
1961
  const staging = path.join(backupDir, `${backupId}-staging`);
1745
- await fs.rm(staging, { recursive: true, force: true });
1746
- await ensureDir(staging);
1747
- const snapshotPath = path.join(staging, 'tug.db');
1748
- db.run('VACUUM INTO ?', snapshotPath);
1749
- await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
1750
- await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
1751
- await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
1752
1962
  const artifactRef = path.resolve(backupDir, `${backupId}.tar.gz`);
1753
- await tar.c({ gzip: true, file: artifactRef, cwd: staging }, [
1754
- 'tug.db',
1755
- 'master.key',
1756
- 'metadata.json',
1757
- 'images',
1758
- ]);
1759
- await fs.rm(staging, { recursive: true, force: true });
1963
+ try {
1964
+ await fs.rm(staging, { recursive: true, force: true });
1965
+ await fs.mkdir(staging, { mode: 0o700 });
1966
+ const snapshotPath = path.join(staging, 'tug.db');
1967
+ db.run('VACUUM INTO ?', snapshotPath);
1968
+ await fs.chmod(snapshotPath, 0o600);
1969
+ await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
1970
+ await fs.chmod(path.join(staging, 'master.key'), 0o600);
1971
+ await fs.cp(path.join(DATA_DIR, 'pki'), path.join(staging, 'pki'), {
1972
+ recursive: true,
1973
+ filter: (src) => path.basename(src) !== 'server.json',
1974
+ });
1975
+ await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
1976
+ await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2), { mode: 0o600 });
1977
+ await tar.c({ gzip: true, file: artifactRef, cwd: staging, mode: 0o600 }, [
1978
+ 'tug.db',
1979
+ 'master.key',
1980
+ 'metadata.json',
1981
+ 'pki',
1982
+ 'images',
1983
+ ]);
1984
+ }
1985
+ catch (error) {
1986
+ await fs.rm(artifactRef, { force: true });
1987
+ throw error;
1988
+ }
1989
+ finally {
1990
+ await fs.rm(staging, { recursive: true, force: true });
1991
+ }
1760
1992
  db.run('INSERT INTO backups(id,project_id,target,node_id,state,artifact_ref,metadata_json,created_at,completed_at) VALUES(?,?,?,?,?,?,?,?,?)', backupId, '_control', 'control-plane', null, 'completed', artifactRef, JSON.stringify({ type: 'control' }), ts, now());
1761
1993
  res.json({ backupId, artifactRef, state: 'completed' });
1762
1994
  }));
@@ -1787,8 +2019,9 @@ export async function createApp(options = {}) {
1787
2019
  const integrity = snapshot.get('PRAGMA integrity_check');
1788
2020
  if (integrity?.integrity_check !== 'ok')
1789
2021
  throw new Error('backup database failed integrity check');
1790
- if (!snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'"))
1791
- throw new Error('backup database has no operator identity');
2022
+ await Pki.load(path.join(restoreDir, 'pki'), false).catch((e) => {
2023
+ throw new Error(`backup PKI is not usable: ${errMessage(e)}`, { cause: e });
2024
+ });
1792
2025
  for (const secret of snapshot.all('SELECT * FROM secrets'))
1793
2026
  decrypt(secret.encrypted_value, key);
1794
2027
  const states = snapshot.all('SELECT * FROM desired_states');
@@ -1820,6 +2053,13 @@ export async function createApp(options = {}) {
1820
2053
  await fs.cp(path.join(restoreDir, 'images'), artifactDir, { recursive: true });
1821
2054
  await fs.copyFile(path.join(restoreDir, 'tug.db'), dbFile);
1822
2055
  await fs.copyFile(path.join(restoreDir, 'master.key'), masterKeyFile);
2056
+ await fs.cp(path.join(restoreDir, 'pki'), path.join(DATA_DIR, 'pki'), {
2057
+ recursive: true,
2058
+ filter: (src) => path.basename(src) !== 'server.json',
2059
+ });
2060
+ // Authorization follows the restored database: older snapshots forget later principals and revive revoked ones.
2061
+ // Close every session, including this one, but only after the response has been delivered.
2062
+ res.once('finish', closeAllSockets);
1823
2063
  res.json({ ok: true, restored: true, restartRequired: true });
1824
2064
  }
1825
2065
  catch (e) {
@@ -1894,19 +2134,19 @@ export async function createApp(options = {}) {
1894
2134
  const cmdId = queueCommand(node.id, project, 'logs.fetch', { project, service, tail });
1895
2135
  res.json({ commandId: cmdId, nodeId: node.id });
1896
2136
  });
1897
- api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
2137
+ api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', String(req.params.id))));
1898
2138
  api('get', '/api/v1/status', null, async (req, res) => {
1899
2139
  const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
1900
2140
  const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
1901
- db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ?", now(), cutoff);
2141
+ db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ? AND disabled_at IS NULL", now(), cutoff);
1902
2142
  const projects = projectScope(req.actor, 'id');
1903
2143
  const deployments = projectScope(req.actor, 'project_id');
1904
- const statuses = projectScope(req.actor, 'project_id');
2144
+ const statuses = projectScope(req.actor, 's.project_id');
1905
2145
  res.json({
1906
2146
  projects: db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args),
1907
2147
  deployments: db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args),
1908
2148
  nodes: db.all('SELECT id,name,status,last_seen_at FROM nodes'),
1909
- statuses: db.all(`SELECT * FROM node_statuses WHERE ${statuses.where}`, ...statuses.args),
2149
+ statuses: db.all(`SELECT s.*,n.name AS node FROM node_statuses s LEFT JOIN nodes n ON n.id=s.node_id WHERE ${statuses.where}`, ...statuses.args),
1910
2150
  });
1911
2151
  });
1912
2152
  api('get', '/api/v1/events', null, async (req, res) => {
@@ -1914,7 +2154,7 @@ export async function createApp(options = {}) {
1914
2154
  if (project)
1915
2155
  assertProject(req.actor, project);
1916
2156
  const limit = Math.min(Number(req.query.limit || 100), 500);
1917
- const scope = projectScope(req.actor, 'project_id'); // scoped tokens never see node-level or other projects' events
2157
+ const scope = projectScope(req.actor, 'project_id'); // scoped credentials never see node-level or other projects' events
1918
2158
  const events = project
1919
2159
  ? db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
1920
2160
  : db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
@@ -1933,16 +2173,52 @@ export async function createApp(options = {}) {
1933
2173
  await check('sqlite', async () => ({
1934
2174
  value: db.get('SELECT COUNT(*) AS count FROM projects').count,
1935
2175
  }));
1936
- await check('operator_token', async () => ({
1937
- configured: Boolean(db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')),
1938
- }));
2176
+ await check('certificates', async () => {
2177
+ const nowMs = Date.now();
2178
+ const ca = certificateExpiry(pki.caNotAfter, nowMs, EXPIRY_WARN_DAYS.ca);
2179
+ const saved = JSON.parse(await fs.readFile(path.join(DATA_DIR, 'pki', 'server.json'), 'utf8'));
2180
+ const server = certificateExpiry(new Date(new crypto.X509Certificate(saved.cert).validTo), nowMs, EXPIRY_WARN_DAYS.server);
2181
+ const problems = [
2182
+ ...(ca.state === 'expired'
2183
+ ? ['the control CA has expired; see the CA-loss recovery runbook']
2184
+ : []),
2185
+ ...(server.state === 'expired' ? ['the control server certificate has expired'] : []),
2186
+ ];
2187
+ const warnings = [
2188
+ ...(ca.state === 'expiring'
2189
+ ? [`the control CA expires in ${ca.daysLeft} days; plan a CA rotation`]
2190
+ : []),
2191
+ ...(server.state === 'expiring'
2192
+ ? [`the server certificate expires in ${server.daysLeft} days and has not renewed`]
2193
+ : []),
2194
+ ];
2195
+ return {
2196
+ ok: problems.length === 0,
2197
+ ca,
2198
+ server,
2199
+ ...(problems.length && { error: problems.join('; ') }),
2200
+ ...(warnings.length && { warnings }),
2201
+ };
2202
+ });
2203
+ await check('nodes', async () => {
2204
+ const nowMs = Date.now();
2205
+ const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
2206
+ const nodes = db
2207
+ .all('SELECT * FROM nodes ORDER BY name')
2208
+ .map((n) => diagnoseNode(n, nowMs, staleSeconds));
2209
+ const problems = nodes.flatMap((n) => n.problems.map((p) => `${n.name}: ${p}`));
2210
+ const warnings = nodes.flatMap((n) => n.warnings.map((w) => `${n.name}: ${w}`));
2211
+ return {
2212
+ ok: problems.length === 0,
2213
+ nodes: nodes.map(({ problems: _p, warnings: _w, ...rest }) => rest),
2214
+ ...(problems.length && { error: problems.join('; ') }),
2215
+ ...(warnings.length && { warnings }),
2216
+ };
2217
+ });
1939
2218
  await check('master_key', async () => ({ configured: Boolean(masterKey) }));
1940
2219
  await check('docker', async () => ({
1941
2220
  output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim(),
1942
2221
  }));
1943
- await check('docker_compose', async () => ({
1944
- output: (await runCommand('docker', ['compose', 'version'])).out.trim(),
1945
- }));
1946
2222
  if (MESH) {
1947
2223
  await check('mesh', async () => {
1948
2224
  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');
@@ -1985,7 +2261,7 @@ export async function createApp(options = {}) {
1985
2261
  }
1986
2262
  function endpointsFor(states) {
1987
2263
  const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
1988
- 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');
2264
+ 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');
1989
2265
  const views = nodes.map((n) => ({
1990
2266
  name: n.name,
1991
2267
  overlayIp: MESH && n.has_key ? n.overlay_ip : null,
@@ -2004,7 +2280,7 @@ export async function createApp(options = {}) {
2004
2280
  function networkFor(node) {
2005
2281
  if (!MESH || !node.overlay_ip)
2006
2282
  return undefined;
2007
- 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 != ? ORDER BY n.name', node.id);
2283
+ 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);
2008
2284
  return {
2009
2285
  interface: WG_INTERFACE,
2010
2286
  overlayIp: node.overlay_ip,
@@ -2021,7 +2297,7 @@ export async function createApp(options = {}) {
2021
2297
  }
2022
2298
  /** Names of all nodes and of the ingress nodes, for port binding and upstream decisions. */
2023
2299
  function nodeTopology() {
2024
- const rows = db.all('SELECT name,ingress FROM nodes ORDER BY name');
2300
+ const rows = db.all('SELECT name,ingress FROM nodes WHERE disabled_at IS NULL ORDER BY name');
2025
2301
  return {
2026
2302
  nodes: rows.map((r) => r.name),
2027
2303
  ingressNodes: rows.filter((r) => r.ingress).map((r) => r.name),
@@ -2108,22 +2384,49 @@ export async function createApp(options = {}) {
2108
2384
  });
2109
2385
  post('/agent/v1/status', authAgent, async (req, res) => {
2110
2386
  const ts = now();
2111
- const report = req.body;
2112
- for (const st of report.projects || []) {
2113
- 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(st), st.health || 'unknown', ts);
2387
+ let report;
2388
+ try {
2389
+ report = parseStatusReport(req.body);
2390
+ }
2391
+ catch (e) {
2392
+ throw httpError(400, errMessage(e));
2393
+ }
2394
+ for (const st of report.projects) {
2395
+ // A node reports only the services currently placed on it; anything else would feed other nodes' work.
2396
+ const ds = currentDesired(st.project);
2397
+ const desired = ds ? JSON.parse(ds.state_json) : undefined;
2398
+ const placed = Object.entries(desired?.services || {})
2399
+ .filter(([, svc]) => svc.placement?.nodes?.includes(req.node.name))
2400
+ .map(([name]) => name);
2401
+ if (!placed.length)
2402
+ continue;
2403
+ const sanitized = {
2404
+ ...st,
2405
+ ...(st.services && { services: st.services.filter((name) => placed.includes(name)) }),
2406
+ ...(st.serviceStates && {
2407
+ serviceStates: Object.fromEntries(Object.entries(st.serviceStates).filter(([name]) => placed.includes(name))),
2408
+ }),
2409
+ };
2410
+ 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);
2114
2411
  updateDeploymentConvergence(st.project);
2115
2412
  }
2116
- if (report.jobs?.length)
2117
- backups.onHeartbeat(report.jobs);
2118
- for (const cr of (report.commandResults || [])) {
2119
- const result = cr.result || {};
2120
- if (cr.commandId && cr.state)
2121
- backups.onResult(cr.commandId, cr.state, result);
2122
- db.run('UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=?', cr.state || 'succeeded', JSON.stringify(result), ts, cr.commandId ?? null, req.node.id);
2413
+ // Commands are resolved under the calling node first: only a running command of this node can be
2414
+ // heartbeated or completed, so foreign and already-finished command ids change nothing.
2415
+ const runningHere = (commandId) => db.get("SELECT id FROM commands WHERE id=? AND node_id=? AND state='running'", commandId, req.node.id);
2416
+ const jobs = (report.jobs || []).filter((job) => runningHere(job.commandId));
2417
+ if (jobs.length)
2418
+ backups.onHeartbeat(jobs);
2419
+ for (const cr of report.commandResults) {
2420
+ if (!runningHere(cr.commandId))
2421
+ continue;
2422
+ backups.onResult(cr.commandId, cr.state, cr.result);
2423
+ 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);
2123
2424
  }
2124
2425
  if (report.ingress)
2125
2426
  db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
2126
2427
  db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
2428
+ if (report.sentAt)
2429
+ db.run('UPDATE nodes SET clock_skew_ms=?,clock_observed_at=? WHERE id=?', Date.parse(report.sentAt) - Date.parse(ts), ts, req.node.id);
2127
2430
  try {
2128
2431
  restores.advance();
2129
2432
  }
@@ -2164,6 +2467,14 @@ export async function createApp(options = {}) {
2164
2467
  return res.status(409).json({ error: `command is ${command.state}, not running` });
2165
2468
  if (!command.project_id || !backupDestination())
2166
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' });
2167
2478
  const response = {
2168
2479
  env: {
2169
2480
  ...backupEnv(),
@@ -2211,7 +2522,8 @@ export async function createApp(options = {}) {
2211
2522
  });
2212
2523
  return app;
2213
2524
  }
2214
- function attachAgentStream(app, server) {
2525
+ /** Attaches the agent and operator WebSocket upgrade handling to `server`; `listen` uses it on the mTLS listener. */
2526
+ export function attachAgentStream(app, server) {
2215
2527
  const runtime = streamRuntimes.get(app);
2216
2528
  if (!runtime)
2217
2529
  throw new Error('control app is missing its stream runtime');
@@ -2226,26 +2538,31 @@ function attachAgentStream(app, server) {
2226
2538
  reject(404, 'Not Found');
2227
2539
  return;
2228
2540
  }
2229
- const raw = String(request.headers.authorization || '').replace(/^Bearer /, '');
2230
2541
  void (async () => {
2542
+ // The same current-state check as every HTTP request.
2543
+ const identity = runtime.isRestoring() ? null : runtime.identify(request);
2544
+ if (!identity) {
2545
+ reject(401, 'Unauthorized');
2546
+ return;
2547
+ }
2231
2548
  if (pathname === '/api/v1/live') {
2232
- const actor = runtime.authenticateOperator(raw);
2233
- if (!actor) {
2234
- reject(401, 'Unauthorized');
2549
+ if (identity.klass !== 'operator') {
2550
+ reject(403, 'Forbidden');
2235
2551
  return;
2236
2552
  }
2237
- if (!hasRole(actor, 'deploy')) {
2553
+ if (!hasRole(identity.actor, 'deploy')) {
2238
2554
  reject(403, 'Forbidden');
2239
2555
  return;
2240
2556
  }
2557
+ const actor = identity.actor;
2241
2558
  wss.handleUpgrade(request, socket, head, (ws) => runtime.live.operator(actor, ws));
2242
2559
  }
2243
2560
  else {
2244
- const node = runtime.authenticate(raw);
2245
- if (!node) {
2246
- reject(401, 'Unauthorized');
2561
+ if (identity.klass !== 'node') {
2562
+ reject(403, 'Forbidden');
2247
2563
  return;
2248
2564
  }
2565
+ const node = identity.node;
2249
2566
  wss.handleUpgrade(request, socket, head, (ws) => {
2250
2567
  runtime.live.agent(node.id, ws);
2251
2568
  runtime.hub.connect(node.id, ws);
@@ -2255,23 +2572,105 @@ function attachAgentStream(app, server) {
2255
2572
  });
2256
2573
  server.once('close', () => wss.close());
2257
2574
  }
2258
- export async function listen(app, port = PORT) {
2259
- const certFile = process.env.TUG_TLS_CERT_FILE;
2260
- const keyFile = process.env.TUG_TLS_KEY_FILE;
2261
- if (certFile && keyFile) {
2262
- const [cert, key] = await Promise.all([fs.readFile(certFile), fs.readFile(keyFile)]);
2263
- const server = https.createServer({ cert, key }, app);
2264
- attachAgentStream(app, server);
2265
- return new Promise((resolve) => server.listen(port, () => {
2266
- console.log(`tug-control listening on ${port} (tls)`);
2267
- resolve(server);
2268
- }));
2575
+ /** Loads the persisted server credential, or issues one when it is missing, for different names or two thirds expired. */
2576
+ async function ensureServerCredential(pki, dir, names, force = false) {
2577
+ const file = path.join(dir, 'pki', 'server.json');
2578
+ try {
2579
+ const saved = JSON.parse(await fs.readFile(file, 'utf8'));
2580
+ const x = new (await import('node:crypto')).X509Certificate(saved.cert);
2581
+ const lifetime = Date.parse(x.validTo) - Date.parse(x.validFrom);
2582
+ const age = Date.now() - Date.parse(x.validFrom);
2583
+ const signedByCa = x.verify(new (await import('node:crypto')).X509Certificate(pki.caPem).publicKey);
2584
+ if (!force && signedByCa && saved.names === namesKey(names) && age < (lifetime * 2) / 3)
2585
+ return { cert: saved.cert, key: saved.key, renewed: false };
2269
2586
  }
2270
- console.warn('TUG_TLS_CERT_FILE/TUG_TLS_KEY_FILE not set; serving plain HTTP');
2271
- const server = http.createServer(app);
2587
+ catch {
2588
+ /* Missing or unreadable: issue a new one. */
2589
+ }
2590
+ const issued = await pki.issueServer(names);
2591
+ await writeFileDurable(file, JSON.stringify({ cert: issued.cert, key: issued.key, names: namesKey(names) }));
2592
+ return { cert: issued.cert, key: issued.key, renewed: true };
2593
+ }
2594
+ /**
2595
+ * Starts the single mTLS listener. Chain validation rejects missing or foreign client certificates during the handshake;
2596
+ * the secureConnection gate then checks current database state, and every request checks it again.
2597
+ */
2598
+ export async function listen(app, port = PORT) {
2599
+ const pki = app.locals.pki;
2600
+ const dataDir = app.locals.dataDir;
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);
2605
+ if (url.protocol !== 'https:' ||
2606
+ url.username ||
2607
+ url.password ||
2608
+ url.search ||
2609
+ url.hash ||
2610
+ url.pathname !== '/')
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(','));
2613
+ const credential = await ensureServerCredential(pki, dataDir, names);
2614
+ const server = https.createServer(serverTlsOptions({ cert: credential.cert, key: credential.key, ca: pki.caPem }), app);
2615
+ server.requestTimeout = 5 * 60_000;
2616
+ server.headersTimeout = 30_000;
2272
2617
  attachAgentStream(app, server);
2618
+ const runtime = streamRuntimes.get(app);
2619
+ const liveSockets = app.locals.liveSockets;
2620
+ server.on('tlsClientError', (error) => {
2621
+ console.warn(`tls handshake refused: ${errMessage(error)}`.slice(0, 200));
2622
+ });
2623
+ // Connection authorization gate: after a full handshake, refuse reused sessions and identities the database does not authorize.
2624
+ server.on('secureConnection', (socket) => {
2625
+ // After a restore the database is closed until restart: refuse everything, and never let the gate throw.
2626
+ let presented = null;
2627
+ try {
2628
+ if (!runtime.isRestoring() && !socket.isSessionReused())
2629
+ presented = peerIdentity(socket);
2630
+ if (presented && !runtime.resolvePrincipal(presented.uri, presented.keyHash))
2631
+ presented = null;
2632
+ }
2633
+ catch {
2634
+ presented = null;
2635
+ }
2636
+ if (!presented) {
2637
+ socket.destroy();
2638
+ return;
2639
+ }
2640
+ const entry = { socket, ...presented };
2641
+ liveSockets.add(entry);
2642
+ socket.once('close', () => liveSockets.delete(entry));
2643
+ });
2644
+ // Out-of-process changes (host-side root recovery) are noticed within a second; renewal reloads the secure context.
2645
+ const sweep = setInterval(() => {
2646
+ try {
2647
+ app.locals.reconcileSockets();
2648
+ }
2649
+ catch (e) {
2650
+ console.error(`session sweep failed: ${errMessage(e)}`);
2651
+ }
2652
+ }, 1000);
2653
+ const renew = setInterval(() => {
2654
+ ensureServerCredential(pki, dataDir, names)
2655
+ .then((next) => {
2656
+ if (next.renewed) {
2657
+ server.setSecureContext(serverTlsOptions({ cert: next.cert, key: next.key, ca: pki.caPem }));
2658
+ console.log('server certificate renewed');
2659
+ }
2660
+ const left = pki.caNotAfter.getTime() - Date.now();
2661
+ if (left < 90 * 86_400_000)
2662
+ console.warn(`control CA expires in ${Math.floor(left / 86_400_000)} days`);
2663
+ })
2664
+ .catch((e) => console.error(`server certificate renewal failed: ${errMessage(e)}`));
2665
+ }, Math.min(SERVER_LIFETIME_MS / 90, 3_600_000));
2666
+ sweep.unref();
2667
+ renew.unref();
2668
+ server.once('close', () => {
2669
+ clearInterval(sweep);
2670
+ clearInterval(renew);
2671
+ });
2273
2672
  return new Promise((resolve) => server.listen(port, () => {
2274
- console.log(`tug-control listening on ${port}`);
2673
+ console.log(`tug-control listening on ${port} (mTLS)`);
2275
2674
  resolve(server);
2276
2675
  }));
2277
2676
  }