@wpelevator/tug 0.7.0 → 0.8.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 (42) hide show
  1. package/README.md +24 -22
  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/cli-build.js +70 -4
  10. package/dist/cli-config.d.ts +9 -5
  11. package/dist/cli-config.js +89 -34
  12. package/dist/cli-install.js +1 -1
  13. package/dist/cli-live-transport.d.ts +3 -2
  14. package/dist/cli-live-transport.js +9 -5
  15. package/dist/cli.js +265 -142
  16. package/dist/compose.d.ts +3 -1
  17. package/dist/compose.js +14 -2
  18. package/dist/control-credential.d.ts +12 -0
  19. package/dist/control-credential.js +46 -0
  20. package/dist/control.d.ts +18 -1
  21. package/dist/control.js +586 -202
  22. package/dist/credentials.d.ts +111 -0
  23. package/dist/{tokens.js → credentials.js} +70 -54
  24. package/dist/db.d.ts +20 -5
  25. package/dist/db.js +3 -3
  26. package/dist/diagnostics.d.ts +44 -0
  27. package/dist/diagnostics.js +65 -0
  28. package/dist/ingress.js +4 -3
  29. package/dist/main.js +35 -0
  30. package/dist/normalize.js +1 -1
  31. package/dist/pki.d.ts +119 -0
  32. package/dist/pki.js +339 -0
  33. package/dist/protocol.d.ts +51 -22
  34. package/dist/status-report.d.ts +11 -0
  35. package/dist/status-report.js +147 -0
  36. package/dist/templates/agent.env +1 -3
  37. package/dist/templates/control-compose.yml +4 -3
  38. package/dist/templates/control-install.sh +8 -2
  39. package/dist/tls-server.d.ts +11 -0
  40. package/dist/tls-server.js +23 -0
  41. package/package.json +10 -8
  42. 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, 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 { 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";
22
26
  import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } 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 publicUrl = () => {
483
+ const url = options.publicUrl ?? process.env.TUG_PUBLIC_URL;
484
+ if (!url)
485
+ throw httpError(500, 'TUG_PUBLIC_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,24 @@ 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
+ 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
+ }
716
+ }
717
+ }
626
718
  function createRollback(project, opts) {
627
719
  let lock;
628
720
  try {
@@ -641,6 +733,7 @@ export async function createApp(options = {}) {
641
733
  const ts = now();
642
734
  const depId = id('dep');
643
735
  const previousState = JSON.parse(previousDs.state_json);
736
+ assertIngressHostsFree(project, previousState.compose);
644
737
  previousState.revision = depId;
645
738
  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
739
  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 +827,10 @@ export async function createApp(options = {}) {
734
827
  }, options.liveStartTimeoutMs);
735
828
  streamRuntimes.set(app, {
736
829
  hub: wakeHub,
737
- authenticate: authenticateAgent,
738
- authenticateOperator: (raw) => authenticate(db, raw),
830
+ identify,
831
+ resolvePrincipal,
739
832
  live,
833
+ isRestoring: () => restartRequired,
740
834
  });
741
835
  api('get', '/api/v1/live', null, (_req, res) => {
742
836
  res.status(426).json({ error: 'WebSocket upgrade required' });
@@ -799,7 +893,7 @@ export async function createApp(options = {}) {
799
893
  const state = JSON.parse(dsRow.state_json);
800
894
  if (!state.services?.[service])
801
895
  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');
896
+ const nodeRows = db.all('SELECT name,labels_json,last_seen_at FROM nodes WHERE disabled_at IS NULL ORDER BY created_at,name');
803
897
  const nodeInfos = nodeRows
804
898
  .filter((row) => !isStale(row))
805
899
  .map((row) => ({
@@ -809,7 +903,7 @@ export async function createApp(options = {}) {
809
903
  const composeSvc = state.compose?.services?.[service] || {};
810
904
  const ext = composeSvc['x-tug'] || {};
811
905
  const placement = ext.placement || {};
812
- const global = ext.global === true || placement.global === true;
906
+ const global = ext.global === true;
813
907
  const labels = Array.isArray(placement.labels) ? placement.labels : [];
814
908
  const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
815
909
  const pool = labels.length ? matched : nodeInfos;
@@ -834,14 +928,13 @@ export async function createApp(options = {}) {
834
928
  nodes: selected.map((node) => node.name),
835
929
  });
836
930
  });
837
- get('/healthz', (req, res) => res.json({ ok: true }));
838
931
  api('post', '/api/v1/projects', (req) => String(req.body?.name || 'site'), async (req, res) => {
839
932
  const name = req.body.name || 'site';
840
933
  const ts = now();
841
934
  db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
842
935
  res.json(db.get('SELECT * FROM projects WHERE id=?', name));
843
936
  });
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)));
937
+ 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
938
  /** A node whose agent has been seen before but not within TUG_STALE_SECONDS; never-seen (just registered) nodes stay schedulable. */
846
939
  const isStale = (row) => !!row.last_seen_at &&
847
940
  Date.parse(row.last_seen_at) < Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000;
@@ -880,20 +973,12 @@ export async function createApp(options = {}) {
880
973
  db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
881
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());
882
975
  }
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);
976
+ const NODE_RENEW_MIN_INTERVAL_MS = () => Number(process.env.TUG_RENEW_MIN_INTERVAL_MS ?? 60 * 60 * 1000);
977
+ const nodeInfo = (nodeId) => {
978
+ const row = db.get(NODE_INFO_SQL, nodeId);
894
979
  if (!row)
895
- throw httpError(500, 'node not found after registration');
896
- const node = {
980
+ throw httpError(500, 'node not found');
981
+ return {
897
982
  id: row.id,
898
983
  name: row.name,
899
984
  address: row.address,
@@ -904,75 +989,194 @@ export async function createApp(options = {}) {
904
989
  created_at: row.created_at,
905
990
  updated_at: row.updated_at,
906
991
  };
907
- return { node, agentToken };
992
+ };
993
+ const NODE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/;
994
+ /** Inserts a node bound to a key. Synchronous so the existence check and the write cannot interleave. */
995
+ function insertNode(input) {
996
+ if (!NODE_NAME.test(input.name))
997
+ 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');
1000
+ 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);
1005
+ wakeAll();
908
1006
  }
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' });
1007
+ const identityResponse = (nodeId, cert) => ({
1008
+ node: nodeInfo(nodeId),
1009
+ certificate: cert,
1010
+ ca: pki.caPem,
1011
+ url: publicUrl(),
1012
+ });
1013
+ // The node generates its key and CSR; an admin posts the CSR here. The private key is never transmitted.
1014
+ api('post', '/api/v1/nodes/:name/identity', null, async (req, res) => {
1015
+ const body = req.body ?? {};
1016
+ const name = String(req.params.name);
1017
+ if (body.mode !== 'create' && body.mode !== 'replace')
1018
+ return res.status(400).json({ error: "mode must be 'create' or 'replace'" });
913
1019
  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
- }));
1020
+ // The certificate names the immutable node ID: the existing node's, or a fresh one for a new name. A name freed by
1021
+ // node-remove therefore never inherits the old ID, and certificates for the removed node cannot match the new one.
1022
+ let nodeId = db.get('SELECT * FROM nodes WHERE name=?', name)?.id ?? id('node');
1023
+ let signed = await pki.signClientCsr(body.csr, identityUri('node', nodeId), NODE_LIFETIME_MS);
1024
+ // Signing is asynchronous: re-read, and re-sign once if a concurrent request created the node meanwhile.
1025
+ const current = db.get('SELECT * FROM nodes WHERE name=?', name);
1026
+ if (current && current.id !== nodeId) {
1027
+ nodeId = current.id;
1028
+ signed = await pki.signClientCsr(body.csr, identityUri('node', nodeId), NODE_LIFETIME_MS);
1029
+ }
1030
+ const existing = db.get('SELECT * FROM nodes WHERE name=?', name);
1031
+ if (existing && existing.id !== nodeId)
1032
+ throw httpError(409, `node ${name} was created concurrently; retry`);
1033
+ if (body.mode === 'create') {
1034
+ if (existing) {
1035
+ if (existing.key_hash !== signed.keyHash)
1036
+ return res.status(409).json({
1037
+ error: `node ${name} already exists; use node-replace-identity to install a new key`,
1038
+ });
1039
+ }
1040
+ else {
1041
+ insertNode({
1042
+ id: nodeId,
1043
+ name,
1044
+ keyHash: signed.keyHash,
1045
+ notAfter: signed.notAfter,
1046
+ address: body.address,
1047
+ publicAddress: body.publicAddress,
1048
+ labels: body.labels,
1049
+ });
1050
+ }
1051
+ }
1052
+ else {
1053
+ if (!existing)
1054
+ 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);
1056
+ audit(req.actor, {
1057
+ type: 'node.replaced',
1058
+ message: `node ${name} key replaced by ${req.actor.name}`,
1059
+ metadata: { node: name },
1060
+ });
1061
+ reconcileSockets();
1062
+ }
1063
+ res.json(identityResponse(nodeId, signed.cert));
921
1064
  }
922
1065
  catch (e) {
923
1066
  sendError(res, e);
924
1067
  }
925
1068
  });
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)
1069
+ // Self-enrollment: an admin mints a short-lived, single-use, name-bound bootstrap certificate that reaches one route.
1070
+ api('post', '/api/v1/nodes/enrollment-credential', null, async (req, res) => {
1071
+ const name = req.body?.name;
1072
+ if (typeof name !== 'string' || !NODE_NAME.test(name))
950
1073
  return res
951
1074
  .status(400)
952
- .json({ error: 'node name required (bind one to the enroll token or send name)' });
953
- let result;
1075
+ .json({ error: 'name required: a valid node name to bind the join key to' });
1076
+ const labels = req.body.labels;
1077
+ if (labels !== undefined &&
1078
+ (!Array.isArray(labels) || labels.some((l) => typeof l !== 'string')))
1079
+ return res.status(400).json({ error: 'labels must be a list of strings' });
1080
+ const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? ENROLL_DEFAULT_TTL_MS / 1000));
1081
+ const rowId = id('enr');
1082
+ const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, publicUrl());
1083
+ const expiresAt = issued.notAfter.toISOString();
1084
+ db.run('DELETE FROM enrollment_credentials WHERE expires_at < ?', new Date(Date.now() - 86_400_000).toISOString());
1085
+ // Minting again for a name revokes its previous unconsumed key.
1086
+ db.run('UPDATE enrollment_credentials SET revoked_at=? WHERE name=? AND node_id IS NULL AND revoked_at IS NULL', now(), name);
1087
+ 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());
1088
+ reconcileSockets();
1089
+ const out = {
1090
+ credential: issued.credential,
1091
+ name,
1092
+ labels: labels ?? [],
1093
+ expiresAt,
1094
+ };
1095
+ res.json(out);
1096
+ });
1097
+ post('/agent/v1/enroll', async (req, res) => {
1098
+ const row = req.identity?.klass === 'enroll' ? req.identity.row : null;
1099
+ if (!row)
1100
+ return res.status(403).json({ error: 'bootstrap credentials required' });
1101
+ const body = req.body ?? {};
954
1102
  try {
955
- result = await registerNode({
956
- name,
957
- address: body.address,
958
- publicAddress: body.publicAddress,
959
- wgPublicKey: body.wgPublicKey,
960
- labels: body.labels,
961
- });
1103
+ const signed = await pki.signClientCsr(body.csr, identityUri('node', row.reserved_node_id), NODE_LIFETIME_MS);
1104
+ // Re-read the row: the checks and writes below are synchronous, so concurrent enrollments cannot both create a node.
1105
+ const fresh = db.get('SELECT * FROM enrollment_credentials WHERE id=?', row.id);
1106
+ if (!fresh || fresh.revoked_at || fresh.expires_at <= now())
1107
+ return res.status(401).json({ error: 'join key is expired or revoked' });
1108
+ if (fresh.node_id) {
1109
+ if (fresh.node_key_hash !== signed.keyHash)
1110
+ return res.status(403).json({ error: 'join key was already used by a different key' });
1111
+ }
1112
+ else {
1113
+ if (db.get('SELECT 1 FROM nodes WHERE name=?', fresh.name))
1114
+ return res
1115
+ .status(409)
1116
+ .json({ error: `node ${fresh.name} already exists; a join key cannot replace a node` });
1117
+ insertNode({
1118
+ id: fresh.reserved_node_id,
1119
+ name: fresh.name,
1120
+ keyHash: signed.keyHash,
1121
+ notAfter: signed.notAfter,
1122
+ address: body.address,
1123
+ publicAddress: body.publicAddress,
1124
+ wgPublicKey: body.wgPublicKey,
1125
+ labels: fresh.labels_json ? JSON.parse(fresh.labels_json) : [],
1126
+ });
1127
+ db.run('UPDATE enrollment_credentials SET node_id=?,node_key_hash=? WHERE id=?', fresh.reserved_node_id, signed.keyHash, fresh.id);
1128
+ recordEvent(db, {
1129
+ actor: `enroll:${fresh.id}`,
1130
+ type: 'node.enrolled',
1131
+ message: `node ${fresh.name} enrolled`,
1132
+ });
1133
+ }
1134
+ const enrolled = identityResponse(fresh.reserved_node_id, signed.cert);
1135
+ res.json(enrolled);
962
1136
  }
963
1137
  catch (e) {
964
- return sendError(res, e);
1138
+ sendError(res, e);
965
1139
  }
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);
1140
+ });
1141
+ // Same-key renewal: another certificate for the stored key. Nothing is stored beyond an informational expiry.
1142
+ post('/agent/v1/renew', authAgent, async (req, res) => {
1143
+ const node = req.node;
1144
+ try {
1145
+ const signed = await pki.signClientCsr(req.body?.csr, identityUri('node', node.id), NODE_LIFETIME_MS);
1146
+ if (signed.keyHash !== node.key_hash)
1147
+ return res.status(403).json({
1148
+ error: 'renewal requires a CSR for the node key; use node-replace-identity to change keys',
1149
+ });
1150
+ const last = node.last_renewed_at ? Date.parse(node.last_renewed_at) : 0;
1151
+ if (Date.now() - last < NODE_RENEW_MIN_INTERVAL_MS())
1152
+ return res.status(429).json({ error: 'certificate renewed too recently' });
1153
+ db.run('UPDATE nodes SET cert_not_after=?,last_renewed_at=? WHERE id=?', signed.notAfter.toISOString(), now(), node.id);
1154
+ const renewed = { certificate: signed.cert };
1155
+ res.json(renewed);
1156
+ }
1157
+ catch (e) {
1158
+ sendError(res, e);
1159
+ }
1160
+ });
1161
+ // Blocks a node's key without deleting its metadata or changing placement. It returns only through node-replace-identity.
1162
+ api('post', '/api/v1/nodes/:name/disable', null, async (req, res) => {
1163
+ const node = findNode(String(req.params.name));
1164
+ if (!node)
1165
+ return res.status(404).json({ error: 'node not found' });
1166
+ db.run("UPDATE nodes SET disabled_at=COALESCE(disabled_at,?),status='disabled',updated_at=? WHERE id=?", now(), now(), node.id);
1167
+ audit(req.actor, {
1168
+ type: 'node.disabled',
1169
+ message: `node ${node.name} disabled by ${req.actor.name}`,
1170
+ metadata: { node: node.name },
1171
+ });
1172
+ reconcileSockets();
1173
+ // Healthy peers drop the node from their mesh and ingress configuration on their next work fetch.
1174
+ endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1175
+ wakeAll();
1176
+ res.json({ ok: true, node: node.name });
973
1177
  });
974
1178
  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');
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');
976
1180
  res.json({
977
1181
  nodes: rows.map((row) => {
978
1182
  const { labels_json, ...rest } = row;
@@ -995,15 +1199,15 @@ export async function createApp(options = {}) {
995
1199
  api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
996
1200
  if (!MESH)
997
1201
  return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
998
- const node = findNode(req.params.name);
1202
+ const node = findNode(String(req.params.name));
999
1203
  if (!node)
1000
1204
  return res.status(404).json({ error: 'node not found' });
1001
1205
  res.json({ commandId: queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
1002
1206
  });
1003
- // Removes a retired node: its token, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1207
+ // Removes a retired node: its identity, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1004
1208
  // something on it (redeploy without it first) or while its agent is still reporting, unless force=true.
1005
1209
  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);
1210
+ 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
1211
  if (!node)
1008
1212
  return res.status(404).json({ error: 'node not found' });
1009
1213
  const referenced = [];
@@ -1027,6 +1231,8 @@ export async function createApp(options = {}) {
1027
1231
  db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
1028
1232
  db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
1029
1233
  db.run('DELETE FROM nodes WHERE id=?', node.id);
1234
+ db.run('UPDATE enrollment_credentials SET revoked_at=COALESCE(revoked_at,?) WHERE name=? AND node_id IS NULL', now(), node.name);
1235
+ reconcileSockets();
1030
1236
  audit(req.actor, {
1031
1237
  type: 'node.removed',
1032
1238
  message: `node ${node.name} removed`,
@@ -1036,7 +1242,7 @@ export async function createApp(options = {}) {
1036
1242
  res.json({ ok: true, node: node.name });
1037
1243
  });
1038
1244
  api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
1039
- const node = findNode(req.params.name);
1245
+ const node = findNode(String(req.params.name));
1040
1246
  if (!node)
1041
1247
  return res.status(404).json({ error: 'node not found' });
1042
1248
  const enabled = req.body.enabled !== false;
@@ -1059,12 +1265,12 @@ export async function createApp(options = {}) {
1059
1265
  sendError(res, isUniqueViolation(e) ? httpError(409, 'public key already registered to another node') : e);
1060
1266
  }
1061
1267
  });
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);
1268
+ api('post', '/api/v1/credentials', null, async (req, res) => {
1269
+ const request = parseCredentialRequest(req.body);
1270
+ const created = await createCredential(db, pki, publicUrl(), request, req.actor.name);
1065
1271
  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}`,
1272
+ type: 'credential.create',
1273
+ message: `credential ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
1068
1274
  metadata: {
1069
1275
  name: created.name,
1070
1276
  role: created.role,
@@ -1072,18 +1278,19 @@ export async function createApp(options = {}) {
1072
1278
  expiresAt: created.expiresAt,
1073
1279
  },
1074
1280
  });
1075
- res.json(created);
1281
+ res.json({ ...created, credential: encodeCredential(created.credential) });
1076
1282
  });
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' });
1283
+ api('get', '/api/v1/credentials', null, async (_req, res) => res.json({ credentials: listCredentials(db) }));
1284
+ api('delete', '/api/v1/credentials/:name', null, async (req, res) => {
1285
+ if (!revokeCredential(db, String(req.params.name)))
1286
+ return res.status(404).json({ error: 'credential not found' });
1287
+ reconcileSockets();
1081
1288
  audit(req.actor, {
1082
- type: 'token.revoke',
1083
- message: `token ${req.params.name} revoked by ${req.actor.name}`,
1084
- metadata: { name: req.params.name },
1289
+ type: 'credential.revoke',
1290
+ message: `credential ${String(req.params.name)} revoked by ${req.actor.name}`,
1291
+ metadata: { name: String(req.params.name) },
1085
1292
  });
1086
- res.json({ ok: true, name: req.params.name });
1293
+ res.json({ ok: true, name: String(req.params.name) });
1087
1294
  });
1088
1295
  const backupDestination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
1089
1296
  const backupEnv = () => {
@@ -1173,13 +1380,13 @@ export async function createApp(options = {}) {
1173
1380
  });
1174
1381
  api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({ restores: restores.list(String(req.query.project || 'site')) }));
1175
1382
  api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
1176
- const result = restores.releaseInterrupted(req.params.id);
1383
+ const result = restores.releaseInterrupted(String(req.params.id));
1177
1384
  if ('error' in result)
1178
1385
  return res.status(result.status).json({ error: result.error });
1179
1386
  res.json(result);
1180
1387
  });
1181
1388
  api('post', '/api/v1/restores/:id/resume', null, async (req, res) => {
1182
- const result = restores.resume(req.params.id);
1389
+ const result = restores.resume(String(req.params.id));
1183
1390
  if ('error' in result)
1184
1391
  return res.status(result.status).json({ error: result.error });
1185
1392
  res.json(result);
@@ -1245,7 +1452,7 @@ export async function createApp(options = {}) {
1245
1452
  });
1246
1453
  res.json({ ok: true, removed: removed.changes > 0 });
1247
1454
  });
1248
- api('get', '/api/v1/backup/key', null, async (req, res) => {
1455
+ api('get', '/api/v1/backup/repository-password', null, async (req, res) => {
1249
1456
  const project = String(req.query.project || '');
1250
1457
  if (!PROJECT_NAME_PATTERN.test(project) || isReservedProject(project))
1251
1458
  return res.status(400).json({ error: 'project is required' });
@@ -1301,14 +1508,14 @@ export async function createApp(options = {}) {
1301
1508
  const project = String(req.query.project || 'site');
1302
1509
  if (isReservedProject(project))
1303
1510
  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);
1511
+ const removed = db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, String(req.params.name));
1305
1512
  if (removed.changes) {
1306
1513
  wakeAll(project);
1307
1514
  audit(req.actor, {
1308
1515
  type: 'secret.delete',
1309
1516
  project,
1310
- message: `secret ${req.params.name} deleted by ${req.actor.name}`,
1311
- metadata: { name: req.params.name },
1517
+ message: `secret ${String(req.params.name)} deleted by ${req.actor.name}`,
1518
+ metadata: { name: String(req.params.name) },
1312
1519
  });
1313
1520
  }
1314
1521
  res.json({ ok: true });
@@ -1384,7 +1591,7 @@ export async function createApp(options = {}) {
1384
1591
  throw httpError(400, 'invalid project or version');
1385
1592
  if (version.startsWith(ROLLBACK_VERSION_PREFIX))
1386
1593
  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');
1594
+ 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
1595
  // Stale nodes (agent not seen recently) are not scheduled onto, so a dead host cannot hold a rollout open.
1389
1596
  const nodeInfos = nodeRows
1390
1597
  .filter((row) => !isStale(row))
@@ -1397,7 +1604,7 @@ export async function createApp(options = {}) {
1397
1604
  for (const svc of Object.values(compose.services || {})) {
1398
1605
  const ext = (svc['x-tug'] ||= {});
1399
1606
  const placement = ext.placement || {};
1400
- const global = ext.global === true || placement.global === true;
1607
+ const global = ext.global === true;
1401
1608
  const labels = Array.isArray(placement.labels) ? placement.labels : [];
1402
1609
  if (!placement.nodes?.length && (labels.length || global)) {
1403
1610
  const matched = nodeInfos
@@ -1423,6 +1630,7 @@ export async function createApp(options = {}) {
1423
1630
  catch (e) {
1424
1631
  throw httpError(400, errMessage(e));
1425
1632
  }
1633
+ assertIngressHostsFree(project, compose);
1426
1634
  // The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
1427
1635
  const referenced = sentinelNames(compose);
1428
1636
  if (referenced.includes(REGISTRY_AUTH_SECRET))
@@ -1631,11 +1839,11 @@ export async function createApp(options = {}) {
1631
1839
  }
1632
1840
  get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
1633
1841
  const states = retainedStates();
1634
- const allowed = states.some((state) => Object.values(state.services).some((service) => service.artifact?.buildId === req.params.buildId &&
1842
+ const allowed = states.some((state) => Object.values(state.services).some((service) => service.artifact?.buildId === String(req.params.buildId) &&
1635
1843
  (!service.placement.nodes?.length || service.placement.nodes.includes(req.node.name))));
1636
1844
  if (!allowed)
1637
1845
  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');
1846
+ const row = db.get('SELECT * FROM builds WHERE id=? AND state=?', String(req.params.buildId), 'complete');
1639
1847
  if (!row?.artifact_path)
1640
1848
  return res.status(404).json({ error: 'image not found' });
1641
1849
  try {
@@ -1653,14 +1861,14 @@ export async function createApp(options = {}) {
1653
1861
  res.status(404).json({ error: 'image artifact unavailable' });
1654
1862
  }
1655
1863
  });
1656
- api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
1864
+ api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(db.get('SELECT * FROM deployments WHERE id=?', String(req.params.id))));
1657
1865
  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);
1866
+ const row = db.get('SELECT * FROM deployments WHERE id=?', String(req.params.id));
1659
1867
  if (!row)
1660
1868
  return res.status(404).json({ error: 'deployment not found' });
1661
1869
  if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
1662
1870
  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);
1871
+ 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
1872
  if (result.changes !== 1)
1665
1873
  return res.status(409).json({ error: 'deployment is no longer active' });
1666
1874
  audit(req.actor, {
@@ -1700,7 +1908,7 @@ export async function createApp(options = {}) {
1700
1908
  let lock;
1701
1909
  try {
1702
1910
  lock = deleteData ? acquireProjectLock(project, 'volume.delete') : null;
1703
- const volume = db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1911
+ const volume = db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, String(req.params.name));
1704
1912
  if (!volume)
1705
1913
  return res.status(404).json({ error: 'volume not found' });
1706
1914
  const config = JSON.parse(volume.config_json || '{}');
@@ -1712,7 +1920,7 @@ export async function createApp(options = {}) {
1712
1920
  if (ds) {
1713
1921
  const state = JSON.parse(ds.state_json);
1714
1922
  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))
1923
+ .filter(([, svc]) => (svc.volumes || []).some((v) => (typeof v === 'string' ? v.split(':')[0] : v.source) === String(req.params.name)))
1716
1924
  .map(([name]) => name);
1717
1925
  if (inUse.length)
1718
1926
  return res
@@ -1723,10 +1931,10 @@ export async function createApp(options = {}) {
1723
1931
  if (deleteData && volume.node_id) {
1724
1932
  commandId = queueCommand(volume.node_id, project, 'volume.delete', {
1725
1933
  project,
1726
- volume: req.params.name,
1934
+ volume: String(req.params.name),
1727
1935
  });
1728
1936
  }
1729
- db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1937
+ db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, String(req.params.name));
1730
1938
  res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
1731
1939
  }
1732
1940
  catch (e) {
@@ -1738,25 +1946,42 @@ export async function createApp(options = {}) {
1738
1946
  });
1739
1947
  api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
1740
1948
  const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
1741
- await ensureDir(backupDir);
1949
+ // The archive holds the master key and the CA key, so nothing here is ever readable beyond the control user.
1950
+ await fs.mkdir(backupDir, { recursive: true, mode: 0o700 });
1951
+ await fs.chmod(backupDir, 0o700);
1742
1952
  const ts = now();
1743
1953
  const backupId = id('cbak');
1744
1954
  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
1955
  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 });
1956
+ try {
1957
+ await fs.rm(staging, { recursive: true, force: true });
1958
+ await fs.mkdir(staging, { mode: 0o700 });
1959
+ const snapshotPath = path.join(staging, 'tug.db');
1960
+ db.run('VACUUM INTO ?', snapshotPath);
1961
+ await fs.chmod(snapshotPath, 0o600);
1962
+ await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
1963
+ await fs.chmod(path.join(staging, 'master.key'), 0o600);
1964
+ await fs.cp(path.join(DATA_DIR, 'pki'), path.join(staging, 'pki'), {
1965
+ recursive: true,
1966
+ filter: (src) => path.basename(src) !== 'server.json',
1967
+ });
1968
+ await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
1969
+ await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2), { mode: 0o600 });
1970
+ await tar.c({ gzip: true, file: artifactRef, cwd: staging, mode: 0o600 }, [
1971
+ 'tug.db',
1972
+ 'master.key',
1973
+ 'metadata.json',
1974
+ 'pki',
1975
+ 'images',
1976
+ ]);
1977
+ }
1978
+ catch (error) {
1979
+ await fs.rm(artifactRef, { force: true });
1980
+ throw error;
1981
+ }
1982
+ finally {
1983
+ await fs.rm(staging, { recursive: true, force: true });
1984
+ }
1760
1985
  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
1986
  res.json({ backupId, artifactRef, state: 'completed' });
1762
1987
  }));
@@ -1787,8 +2012,9 @@ export async function createApp(options = {}) {
1787
2012
  const integrity = snapshot.get('PRAGMA integrity_check');
1788
2013
  if (integrity?.integrity_check !== 'ok')
1789
2014
  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');
2015
+ await Pki.load(path.join(restoreDir, 'pki'), false).catch((e) => {
2016
+ throw new Error(`backup PKI is not usable: ${errMessage(e)}`, { cause: e });
2017
+ });
1792
2018
  for (const secret of snapshot.all('SELECT * FROM secrets'))
1793
2019
  decrypt(secret.encrypted_value, key);
1794
2020
  const states = snapshot.all('SELECT * FROM desired_states');
@@ -1820,6 +2046,13 @@ export async function createApp(options = {}) {
1820
2046
  await fs.cp(path.join(restoreDir, 'images'), artifactDir, { recursive: true });
1821
2047
  await fs.copyFile(path.join(restoreDir, 'tug.db'), dbFile);
1822
2048
  await fs.copyFile(path.join(restoreDir, 'master.key'), masterKeyFile);
2049
+ await fs.cp(path.join(restoreDir, 'pki'), path.join(DATA_DIR, 'pki'), {
2050
+ recursive: true,
2051
+ filter: (src) => path.basename(src) !== 'server.json',
2052
+ });
2053
+ // Authorization follows the restored database: older snapshots forget later principals and revive revoked ones.
2054
+ // Close every session, including this one, but only after the response has been delivered.
2055
+ res.once('finish', closeAllSockets);
1823
2056
  res.json({ ok: true, restored: true, restartRequired: true });
1824
2057
  }
1825
2058
  catch (e) {
@@ -1894,19 +2127,19 @@ export async function createApp(options = {}) {
1894
2127
  const cmdId = queueCommand(node.id, project, 'logs.fetch', { project, service, tail });
1895
2128
  res.json({ commandId: cmdId, nodeId: node.id });
1896
2129
  });
1897
- api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
2130
+ api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', String(req.params.id))));
1898
2131
  api('get', '/api/v1/status', null, async (req, res) => {
1899
2132
  const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
1900
2133
  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);
2134
+ 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
2135
  const projects = projectScope(req.actor, 'id');
1903
2136
  const deployments = projectScope(req.actor, 'project_id');
1904
- const statuses = projectScope(req.actor, 'project_id');
2137
+ const statuses = projectScope(req.actor, 's.project_id');
1905
2138
  res.json({
1906
2139
  projects: db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args),
1907
2140
  deployments: db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args),
1908
2141
  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),
2142
+ 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
2143
  });
1911
2144
  });
1912
2145
  api('get', '/api/v1/events', null, async (req, res) => {
@@ -1914,7 +2147,7 @@ export async function createApp(options = {}) {
1914
2147
  if (project)
1915
2148
  assertProject(req.actor, project);
1916
2149
  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
2150
+ const scope = projectScope(req.actor, 'project_id'); // scoped credentials never see node-level or other projects' events
1918
2151
  const events = project
1919
2152
  ? db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
1920
2153
  : db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
@@ -1933,16 +2166,52 @@ export async function createApp(options = {}) {
1933
2166
  await check('sqlite', async () => ({
1934
2167
  value: db.get('SELECT COUNT(*) AS count FROM projects').count,
1935
2168
  }));
1936
- await check('operator_token', async () => ({
1937
- configured: Boolean(db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')),
1938
- }));
2169
+ await check('certificates', async () => {
2170
+ const nowMs = Date.now();
2171
+ const ca = certificateExpiry(pki.caNotAfter, nowMs, EXPIRY_WARN_DAYS.ca);
2172
+ const saved = JSON.parse(await fs.readFile(path.join(DATA_DIR, 'pki', 'server.json'), 'utf8'));
2173
+ const server = certificateExpiry(new Date(new crypto.X509Certificate(saved.cert).validTo), nowMs, EXPIRY_WARN_DAYS.server);
2174
+ const problems = [
2175
+ ...(ca.state === 'expired'
2176
+ ? ['the control CA has expired; see the CA-loss recovery runbook']
2177
+ : []),
2178
+ ...(server.state === 'expired' ? ['the control server certificate has expired'] : []),
2179
+ ];
2180
+ const warnings = [
2181
+ ...(ca.state === 'expiring'
2182
+ ? [`the control CA expires in ${ca.daysLeft} days; plan a CA rotation`]
2183
+ : []),
2184
+ ...(server.state === 'expiring'
2185
+ ? [`the server certificate expires in ${server.daysLeft} days and has not renewed`]
2186
+ : []),
2187
+ ];
2188
+ return {
2189
+ ok: problems.length === 0,
2190
+ ca,
2191
+ server,
2192
+ ...(problems.length && { error: problems.join('; ') }),
2193
+ ...(warnings.length && { warnings }),
2194
+ };
2195
+ });
2196
+ await check('nodes', async () => {
2197
+ const nowMs = Date.now();
2198
+ const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
2199
+ const nodes = db
2200
+ .all('SELECT * FROM nodes ORDER BY name')
2201
+ .map((n) => diagnoseNode(n, nowMs, staleSeconds));
2202
+ const problems = nodes.flatMap((n) => n.problems.map((p) => `${n.name}: ${p}`));
2203
+ const warnings = nodes.flatMap((n) => n.warnings.map((w) => `${n.name}: ${w}`));
2204
+ return {
2205
+ ok: problems.length === 0,
2206
+ nodes: nodes.map(({ problems: _p, warnings: _w, ...rest }) => rest),
2207
+ ...(problems.length && { error: problems.join('; ') }),
2208
+ ...(warnings.length && { warnings }),
2209
+ };
2210
+ });
1939
2211
  await check('master_key', async () => ({ configured: Boolean(masterKey) }));
1940
2212
  await check('docker', async () => ({
1941
2213
  output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim(),
1942
2214
  }));
1943
- await check('docker_compose', async () => ({
1944
- output: (await runCommand('docker', ['compose', 'version'])).out.trim(),
1945
- }));
1946
2215
  if (MESH) {
1947
2216
  await check('mesh', async () => {
1948
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');
@@ -1985,7 +2254,7 @@ export async function createApp(options = {}) {
1985
2254
  }
1986
2255
  function endpointsFor(states) {
1987
2256
  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');
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');
1989
2258
  const views = nodes.map((n) => ({
1990
2259
  name: n.name,
1991
2260
  overlayIp: MESH && n.has_key ? n.overlay_ip : null,
@@ -2004,7 +2273,7 @@ export async function createApp(options = {}) {
2004
2273
  function networkFor(node) {
2005
2274
  if (!MESH || !node.overlay_ip)
2006
2275
  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);
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);
2008
2277
  return {
2009
2278
  interface: WG_INTERFACE,
2010
2279
  overlayIp: node.overlay_ip,
@@ -2021,7 +2290,7 @@ export async function createApp(options = {}) {
2021
2290
  }
2022
2291
  /** Names of all nodes and of the ingress nodes, for port binding and upstream decisions. */
2023
2292
  function nodeTopology() {
2024
- const rows = db.all('SELECT name,ingress FROM nodes ORDER BY name');
2293
+ const rows = db.all('SELECT name,ingress FROM nodes WHERE disabled_at IS NULL ORDER BY name');
2025
2294
  return {
2026
2295
  nodes: rows.map((r) => r.name),
2027
2296
  ingressNodes: rows.filter((r) => r.ingress).map((r) => r.name),
@@ -2108,22 +2377,49 @@ export async function createApp(options = {}) {
2108
2377
  });
2109
2378
  post('/agent/v1/status', authAgent, async (req, res) => {
2110
2379
  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);
2380
+ let report;
2381
+ try {
2382
+ report = parseStatusReport(req.body);
2383
+ }
2384
+ catch (e) {
2385
+ throw httpError(400, errMessage(e));
2386
+ }
2387
+ for (const st of report.projects) {
2388
+ // A node reports only the services currently placed on it; anything else would feed other nodes' work.
2389
+ const ds = currentDesired(st.project);
2390
+ const desired = ds ? JSON.parse(ds.state_json) : undefined;
2391
+ const placed = Object.entries(desired?.services || {})
2392
+ .filter(([, svc]) => svc.placement?.nodes?.includes(req.node.name))
2393
+ .map(([name]) => name);
2394
+ if (!placed.length)
2395
+ continue;
2396
+ const sanitized = {
2397
+ ...st,
2398
+ ...(st.services && { services: st.services.filter((name) => placed.includes(name)) }),
2399
+ ...(st.serviceStates && {
2400
+ serviceStates: Object.fromEntries(Object.entries(st.serviceStates).filter(([name]) => placed.includes(name))),
2401
+ }),
2402
+ };
2403
+ 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
2404
  updateDeploymentConvergence(st.project);
2115
2405
  }
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);
2406
+ // Commands are resolved under the calling node first: only a running command of this node can be
2407
+ // heartbeated or completed, so foreign and already-finished command ids change nothing.
2408
+ const runningHere = (commandId) => db.get("SELECT id FROM commands WHERE id=? AND node_id=? AND state='running'", commandId, req.node.id);
2409
+ const jobs = (report.jobs || []).filter((job) => runningHere(job.commandId));
2410
+ if (jobs.length)
2411
+ backups.onHeartbeat(jobs);
2412
+ for (const cr of report.commandResults) {
2413
+ if (!runningHere(cr.commandId))
2414
+ continue;
2415
+ backups.onResult(cr.commandId, cr.state, cr.result);
2416
+ 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
2417
  }
2124
2418
  if (report.ingress)
2125
2419
  db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
2126
2420
  db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
2421
+ if (report.sentAt)
2422
+ 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
2423
  try {
2128
2424
  restores.advance();
2129
2425
  }
@@ -2211,7 +2507,8 @@ export async function createApp(options = {}) {
2211
2507
  });
2212
2508
  return app;
2213
2509
  }
2214
- function attachAgentStream(app, server) {
2510
+ /** Attaches the agent and operator WebSocket upgrade handling to `server`; `listen` uses it on the mTLS listener. */
2511
+ export function attachAgentStream(app, server) {
2215
2512
  const runtime = streamRuntimes.get(app);
2216
2513
  if (!runtime)
2217
2514
  throw new Error('control app is missing its stream runtime');
@@ -2226,26 +2523,31 @@ function attachAgentStream(app, server) {
2226
2523
  reject(404, 'Not Found');
2227
2524
  return;
2228
2525
  }
2229
- const raw = String(request.headers.authorization || '').replace(/^Bearer /, '');
2230
2526
  void (async () => {
2527
+ // The same current-state check as every HTTP request.
2528
+ const identity = runtime.isRestoring() ? null : runtime.identify(request);
2529
+ if (!identity) {
2530
+ reject(401, 'Unauthorized');
2531
+ return;
2532
+ }
2231
2533
  if (pathname === '/api/v1/live') {
2232
- const actor = runtime.authenticateOperator(raw);
2233
- if (!actor) {
2234
- reject(401, 'Unauthorized');
2534
+ if (identity.klass !== 'operator') {
2535
+ reject(403, 'Forbidden');
2235
2536
  return;
2236
2537
  }
2237
- if (!hasRole(actor, 'deploy')) {
2538
+ if (!hasRole(identity.actor, 'deploy')) {
2238
2539
  reject(403, 'Forbidden');
2239
2540
  return;
2240
2541
  }
2542
+ const actor = identity.actor;
2241
2543
  wss.handleUpgrade(request, socket, head, (ws) => runtime.live.operator(actor, ws));
2242
2544
  }
2243
2545
  else {
2244
- const node = runtime.authenticate(raw);
2245
- if (!node) {
2246
- reject(401, 'Unauthorized');
2546
+ if (identity.klass !== 'node') {
2547
+ reject(403, 'Forbidden');
2247
2548
  return;
2248
2549
  }
2550
+ const node = identity.node;
2249
2551
  wss.handleUpgrade(request, socket, head, (ws) => {
2250
2552
  runtime.live.agent(node.id, ws);
2251
2553
  runtime.hub.connect(node.id, ws);
@@ -2255,23 +2557,105 @@ function attachAgentStream(app, server) {
2255
2557
  });
2256
2558
  server.once('close', () => wss.close());
2257
2559
  }
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
- }));
2560
+ /** Loads the persisted server credential, or issues one when it is missing, for different names or two thirds expired. */
2561
+ async function ensureServerCredential(pki, dir, names, force = false) {
2562
+ const file = path.join(dir, 'pki', 'server.json');
2563
+ try {
2564
+ const saved = JSON.parse(await fs.readFile(file, 'utf8'));
2565
+ const x = new (await import('node:crypto')).X509Certificate(saved.cert);
2566
+ const lifetime = Date.parse(x.validTo) - Date.parse(x.validFrom);
2567
+ const age = Date.now() - Date.parse(x.validFrom);
2568
+ const signedByCa = x.verify(new (await import('node:crypto')).X509Certificate(pki.caPem).publicKey);
2569
+ if (!force && signedByCa && saved.names === namesKey(names) && age < (lifetime * 2) / 3)
2570
+ return { cert: saved.cert, key: saved.key, renewed: false };
2269
2571
  }
2270
- console.warn('TUG_TLS_CERT_FILE/TUG_TLS_KEY_FILE not set; serving plain HTTP');
2271
- const server = http.createServer(app);
2572
+ catch {
2573
+ /* Missing or unreadable: issue a new one. */
2574
+ }
2575
+ const issued = await pki.issueServer(names);
2576
+ await writeFileDurable(file, JSON.stringify({ cert: issued.cert, key: issued.key, names: namesKey(names) }));
2577
+ return { cert: issued.cert, key: issued.key, renewed: true };
2578
+ }
2579
+ /**
2580
+ * Starts the single mTLS listener. Chain validation rejects missing or foreign client certificates during the handshake;
2581
+ * the secureConnection gate then checks current database state, and every request checks it again.
2582
+ */
2583
+ export async function listen(app, port = PORT) {
2584
+ const pki = app.locals.pki;
2585
+ 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);
2590
+ if (url.protocol !== 'https:' ||
2591
+ url.username ||
2592
+ url.password ||
2593
+ url.search ||
2594
+ url.hash ||
2595
+ 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(','));
2598
+ const credential = await ensureServerCredential(pki, dataDir, names);
2599
+ const server = https.createServer(serverTlsOptions({ cert: credential.cert, key: credential.key, ca: pki.caPem }), app);
2600
+ server.requestTimeout = 5 * 60_000;
2601
+ server.headersTimeout = 30_000;
2272
2602
  attachAgentStream(app, server);
2603
+ const runtime = streamRuntimes.get(app);
2604
+ const liveSockets = app.locals.liveSockets;
2605
+ server.on('tlsClientError', (error) => {
2606
+ console.warn(`tls handshake refused: ${errMessage(error)}`.slice(0, 200));
2607
+ });
2608
+ // Connection authorization gate: after a full handshake, refuse reused sessions and identities the database does not authorize.
2609
+ server.on('secureConnection', (socket) => {
2610
+ // After a restore the database is closed until restart: refuse everything, and never let the gate throw.
2611
+ let presented = null;
2612
+ try {
2613
+ if (!runtime.isRestoring() && !socket.isSessionReused())
2614
+ presented = peerIdentity(socket);
2615
+ if (presented && !runtime.resolvePrincipal(presented.uri, presented.keyHash))
2616
+ presented = null;
2617
+ }
2618
+ catch {
2619
+ presented = null;
2620
+ }
2621
+ if (!presented) {
2622
+ socket.destroy();
2623
+ return;
2624
+ }
2625
+ const entry = { socket, ...presented };
2626
+ liveSockets.add(entry);
2627
+ socket.once('close', () => liveSockets.delete(entry));
2628
+ });
2629
+ // Out-of-process changes (host-side root recovery) are noticed within a second; renewal reloads the secure context.
2630
+ const sweep = setInterval(() => {
2631
+ try {
2632
+ app.locals.reconcileSockets();
2633
+ }
2634
+ catch (e) {
2635
+ console.error(`session sweep failed: ${errMessage(e)}`);
2636
+ }
2637
+ }, 1000);
2638
+ const renew = setInterval(() => {
2639
+ ensureServerCredential(pki, dataDir, names)
2640
+ .then((next) => {
2641
+ if (next.renewed) {
2642
+ server.setSecureContext(serverTlsOptions({ cert: next.cert, key: next.key, ca: pki.caPem }));
2643
+ console.log('server certificate renewed');
2644
+ }
2645
+ const left = pki.caNotAfter.getTime() - Date.now();
2646
+ if (left < 90 * 86_400_000)
2647
+ console.warn(`control CA expires in ${Math.floor(left / 86_400_000)} days`);
2648
+ })
2649
+ .catch((e) => console.error(`server certificate renewal failed: ${errMessage(e)}`));
2650
+ }, Math.min(SERVER_LIFETIME_MS / 90, 3_600_000));
2651
+ sweep.unref();
2652
+ renew.unref();
2653
+ server.once('close', () => {
2654
+ clearInterval(sweep);
2655
+ clearInterval(renew);
2656
+ });
2273
2657
  return new Promise((resolve) => server.listen(port, () => {
2274
- console.log(`tug-control listening on ${port}`);
2658
+ console.log(`tug-control listening on ${port} (mTLS)`);
2275
2659
  resolve(server);
2276
2660
  }));
2277
2661
  }