@wpelevator/tug 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/control.js CHANGED
@@ -23,8 +23,8 @@ import { ensureDir, errMessage, id, now } from "./util.js";
23
23
  import { ENROLL_DEFAULT_TTL_MS, NODE_LIFETIME_MS, Pki, SERVER_LIFETIME_MS, encodeCredential, identityUri, namesKey, parseIdentityUri, peerIdentity, serverNames, writeFileDurable, } from "./pki.js";
24
24
  import { EXPIRY_WARN_DAYS, certificateExpiry, diagnoseNode } from "./diagnostics.js";
25
25
  import { serverTlsOptions } from "./tls-server.js";
26
- import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, isNodeEndpoint, isNodeHost, } from "./mesh.js";
27
- import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
26
+ import { isNodeHost } from "./node-address.js";
27
+ import { buildIngressWork, ingressConfigHash, resolveServiceEndpoints } from "./ingress.js";
28
28
  import { parseStatusReport } from "./status-report.js";
29
29
  import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, BACKUP_COMMAND_TYPES, } from "./protocol.js";
30
30
  import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
@@ -431,14 +431,8 @@ export async function createApp(options = {}) {
431
431
  });
432
432
  const dbFile = path.join(DATA_DIR, 'tug.db');
433
433
  const masterKeyFile = path.join(DATA_DIR, 'master.key');
434
- // Networking layers are opt-in; with all of them off the work response is unchanged.
435
- const MESH = process.env.TUG_MESH === 'true';
436
- const MESH_CIDR = process.env.TUG_MESH_CIDR || DEFAULT_MESH_CIDR;
437
- const WG_PORT = Number(process.env.TUG_WG_PORT || DEFAULT_WG_PORT);
438
- const SERVICE_DNS = (process.env.TUG_SERVICE_NAMES ?? String(MESH)) === 'true';
434
+ const SERVICE_DNS = process.env.TUG_SERVICE_NAMES !== 'false';
439
435
  const ACME_EMAIL = process.env.TUG_ACME_EMAIL || undefined;
440
- if (MESH)
441
- parseCidr(MESH_CIDR);
442
436
  const db = await (options.openDatabase || openDb)(dbFile);
443
437
  const wakeHub = new WakeHub();
444
438
  // Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
@@ -479,14 +473,16 @@ export async function createApp(options = {}) {
479
473
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
480
474
  // A CA is created only for a fresh installation; existing state with missing or mismatched CA material is a startup error.
481
475
  const pki = await Pki.load(path.join(DATA_DIR, 'pki'), !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes'));
482
- const advertiseUrl = () => {
483
- const url = options.advertiseUrl ?? process.env.TUG_ADVERTISE_URL;
476
+ const configuredControlUrl = options.controlUrl ?? process.env.TUG_CONTROL_URL;
477
+ const controlUrl = () => {
478
+ const url = configuredControlUrl;
484
479
  if (!url)
485
- throw httpError(500, 'TUG_ADVERTISE_URL is not set');
480
+ throw httpError(500, 'TUG_CONTROL_URL is not set');
486
481
  return new URL(url).origin;
487
482
  };
488
483
  const app = express();
489
484
  app.locals.pki = pki;
485
+ app.locals.controlUrl = configuredControlUrl;
490
486
  app.locals.dataDir = DATA_DIR;
491
487
  /** Resolves a presented certificate (principal URI and key hash) to a principal from current database state. */
492
488
  function resolvePrincipal(uri, keyHash) {
@@ -685,7 +681,40 @@ export async function createApp(options = {}) {
685
681
  ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision
686
682
  : byNode.get(node)?.observed_version === state.version) &&
687
683
  ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
688
- if (ok)
684
+ // For projects using managed ingress, wait until each enabled ingress node has applied
685
+ // the current routing configuration. Container health alone does not prove a switch.
686
+ const ingressServices = Object.entries(state.compose.services || {})
687
+ .filter(([, service]) => service['x-tug']?.ingress)
688
+ .map(([name]) => name);
689
+ // A revision that removes its last route must wait for that removal too.
690
+ const previous = db.get(`SELECT ds.state_json FROM desired_states ds JOIN deployments d ON d.id=ds.deployment_id
691
+ WHERE d.project_id=? AND d.id<>? AND d.state='succeeded' ORDER BY d.created_at DESC LIMIT 1`, projectId, project.current_deployment_id);
692
+ const previouslyRouted = previous
693
+ ? Object.values(JSON.parse(previous.state_json).compose.services || {}).some((service) => service['x-tug']?.ingress)
694
+ : false;
695
+ const ingressNodes = ingressServices.length || previouslyRouted
696
+ ? db.all('SELECT * FROM nodes WHERE ingress=1 AND disabled_at IS NULL')
697
+ : [];
698
+ const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
699
+ const ingressReady = !ingressNodes.length ||
700
+ ingressNodes.every((node) => {
701
+ const work = ingressFor(node, currentDesiredStates());
702
+ if (!ingressServices.every((service) => work.routes.some((route) => route.project === projectId && route.service === service && route.upstreams.length)))
703
+ return false;
704
+ if (!node.ingress_status_json)
705
+ return false;
706
+ try {
707
+ const status = JSON.parse(node.ingress_status_json);
708
+ return (status.state === 'serving' &&
709
+ status.configHash === ingressConfigHash(work) &&
710
+ typeof status.reportedAt === 'string' &&
711
+ status.reportedAt >= staleBefore);
712
+ }
713
+ catch {
714
+ return false;
715
+ }
716
+ });
717
+ if (ok && ingressReady)
689
718
  db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
690
719
  }
691
720
  /** Events control writes when it rolls back on its own; they are attributed to a reserved system actor. */
@@ -937,41 +966,7 @@ export async function createApp(options = {}) {
937
966
  /** A node whose agent has been seen before but not within TUG_STALE_SECONDS; never-seen (just registered) nodes stay schedulable. */
938
967
  const isStale = (row) => !!row.last_seen_at &&
939
968
  Date.parse(row.last_seen_at) < Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000;
940
- const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,labels_json,status,created_at,updated_at FROM nodes WHERE id=?';
941
- /** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
942
- function ensureOverlayIp(nodeId) {
943
- if (!MESH)
944
- return;
945
- for (let attempt = 0; attempt < 5; attempt++) {
946
- const row = db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
947
- if (row?.overlay_ip)
948
- return;
949
- const used = db
950
- .all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')
951
- .map((r) => r.overlay_ip);
952
- try {
953
- db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
954
- return;
955
- }
956
- catch (e) {
957
- if (!isUniqueViolation(e))
958
- throw e;
959
- }
960
- }
961
- throw new Error('could not allocate an overlay address');
962
- }
963
- /** Stores (or refreshes) the node's WireGuard peer entry; the private key never reaches control. */
964
- function syncWgPeer(nodeId, publicKey) {
965
- if (!MESH)
966
- return;
967
- const node = db.get('SELECT * FROM nodes WHERE id=?', nodeId);
968
- const existing = db.get('SELECT * FROM wg_peers WHERE node_id=?', nodeId);
969
- const key = publicKey ?? existing?.public_key;
970
- if (!node?.overlay_ip || !key)
971
- return;
972
- db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
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());
974
- }
969
+ const NODE_INFO_SQL = 'SELECT id,name,address,labels_json,status,created_at,updated_at FROM nodes WHERE id=?';
975
970
  const NODE_RENEW_MIN_INTERVAL_MS = () => Number(process.env.TUG_RENEW_MIN_INTERVAL_MS ?? 60 * 60 * 1000);
976
971
  const nodeInfo = (nodeId) => {
977
972
  const row = db.get(NODE_INFO_SQL, nodeId);
@@ -981,8 +976,6 @@ export async function createApp(options = {}) {
981
976
  id: row.id,
982
977
  name: row.name,
983
978
  address: row.address,
984
- public_address: row.public_address,
985
- overlay_ip: row.overlay_ip,
986
979
  labels: row.labels_json ? JSON.parse(row.labels_json) : [],
987
980
  status: row.status,
988
981
  created_at: row.created_at,
@@ -996,22 +989,16 @@ export async function createApp(options = {}) {
996
989
  throw httpError(400, 'node name must be 1-63 letters, numbers, dots, underscores or hyphens');
997
990
  if (input.address && !isNodeHost(input.address))
998
991
  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
992
  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);
993
+ db.run(`INSERT INTO nodes(id,name,address,status,labels_json,key_hash,cert_not_after,created_at,updated_at)
994
+ VALUES(?,?,?,?,?,?,?,?,?)`, input.id, input.name, input.address || null, 'registered', JSON.stringify(Array.isArray(input.labels) ? input.labels : []), input.keyHash, input.notAfter.toISOString(), ts, ts);
1008
995
  wakeAll();
1009
996
  }
1010
997
  const identityResponse = (nodeId, cert) => ({
1011
998
  node: nodeInfo(nodeId),
1012
999
  certificate: cert,
1013
1000
  ca: pki.caPem,
1014
- url: advertiseUrl(),
1001
+ url: controlUrl(),
1015
1002
  });
1016
1003
  // The node generates its key and CSR; an admin posts the CSR here. The private key is never transmitted.
1017
1004
  api('post', '/api/v1/nodes/:name/identity', null, async (req, res) => {
@@ -1047,7 +1034,6 @@ export async function createApp(options = {}) {
1047
1034
  keyHash: signed.keyHash,
1048
1035
  notAfter: signed.notAfter,
1049
1036
  address: body.address,
1050
- publicAddress: body.publicAddress,
1051
1037
  labels: body.labels,
1052
1038
  });
1053
1039
  }
@@ -1055,13 +1041,18 @@ export async function createApp(options = {}) {
1055
1041
  else {
1056
1042
  if (!existing)
1057
1043
  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);
1044
+ if (body.address !== undefined && !isNodeHost(body.address))
1045
+ throw httpError(400, 'address must be an IP address or hostname without a port');
1046
+ const address = body.address === undefined ? existing.address : body.address;
1047
+ db.run("UPDATE nodes SET key_hash=?,disabled_at=NULL,status='registered',cert_not_after=?,address=?,updated_at=? WHERE id=?", signed.keyHash, signed.notAfter.toISOString(), address || null, now(), existing.id);
1059
1048
  audit(req.actor, {
1060
1049
  type: 'node.replaced',
1061
1050
  message: `node ${name} key replaced by ${req.actor.name}`,
1062
1051
  metadata: { node: name },
1063
1052
  });
1064
1053
  reconcileSockets();
1054
+ endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1055
+ wakeAll();
1065
1056
  }
1066
1057
  res.json(identityResponse(nodeId, signed.cert));
1067
1058
  }
@@ -1082,7 +1073,7 @@ export async function createApp(options = {}) {
1082
1073
  return res.status(400).json({ error: 'labels must be a list of strings' });
1083
1074
  const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? ENROLL_DEFAULT_TTL_MS / 1000));
1084
1075
  const rowId = id('enr');
1085
- const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, advertiseUrl());
1076
+ const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, controlUrl());
1086
1077
  const expiresAt = issued.notAfter.toISOString();
1087
1078
  db.run('DELETE FROM enrollment_credentials WHERE expires_at < ?', new Date(Date.now() - 86_400_000).toISOString());
1088
1079
  // Minting again for a name revokes its previous unconsumed key.
@@ -1123,8 +1114,6 @@ export async function createApp(options = {}) {
1123
1114
  keyHash: signed.keyHash,
1124
1115
  notAfter: signed.notAfter,
1125
1116
  address: body.address,
1126
- publicAddress: body.publicAddress,
1127
- wgPublicKey: body.wgPublicKey,
1128
1117
  labels: fresh.labels_json ? JSON.parse(fresh.labels_json) : [],
1129
1118
  });
1130
1119
  db.run('UPDATE enrollment_credentials SET node_id=?,node_key_hash=? WHERE id=?', fresh.reserved_node_id, signed.keyHash, fresh.id);
@@ -1173,13 +1162,13 @@ export async function createApp(options = {}) {
1173
1162
  metadata: { node: node.name },
1174
1163
  });
1175
1164
  reconcileSockets();
1176
- // Healthy peers drop the node from their mesh and ingress configuration on their next work fetch.
1165
+ // Other nodes drop the node from service endpoints and ingress configuration on their next work fetch.
1177
1166
  endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1178
1167
  wakeAll();
1179
1168
  res.json({ ok: true, node: node.name });
1180
1169
  });
1181
1170
  api('get', '/api/v1/nodes', null, async (req, res) => {
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');
1171
+ const rows = db.all('SELECT id,name,address,ingress,ingress_status_json,status,labels_json,last_seen_at,disabled_at,cert_not_after,created_at,updated_at FROM nodes ORDER BY name');
1183
1172
  res.json({
1184
1173
  nodes: rows.map((row) => {
1185
1174
  const { labels_json, ...rest } = row;
@@ -1197,17 +1186,7 @@ export async function createApp(options = {}) {
1197
1186
  wakeHub.wake(nodeId, projectId ?? undefined);
1198
1187
  return commandId;
1199
1188
  }
1200
- // The private key lives only on the node, so rotation is a command the agent executes: it
1201
- // generates a new keypair, reports the public key and peers pick it up from their next work response.
1202
- api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
1203
- if (!MESH)
1204
- return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
1205
- const node = findNode(String(req.params.name));
1206
- if (!node)
1207
- return res.status(404).json({ error: 'node not found' });
1208
- res.json({ commandId: queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
1209
- });
1210
- // Removes a retired node: its identity, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1189
+ // Removes a retired node: its identity, statuses and queued commands. Refused while a current deployment still places
1211
1190
  // something on it (redeploy without it first) or while its agent is still reporting, unless force=true.
1212
1191
  api('delete', '/api/v1/nodes/:name', null, async (req, res) => {
1213
1192
  const node = db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', String(req.params.name), String(req.params.name));
@@ -1232,7 +1211,6 @@ export async function createApp(options = {}) {
1232
1211
  });
1233
1212
  db.run('DELETE FROM commands WHERE node_id=?', node.id);
1234
1213
  db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
1235
- db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
1236
1214
  db.run('DELETE FROM nodes WHERE id=?', node.id);
1237
1215
  db.run('UPDATE enrollment_credentials SET revoked_at=COALESCE(revoked_at,?) WHERE name=? AND node_id IS NULL', now(), node.name);
1238
1216
  reconcileSockets();
@@ -1244,33 +1222,37 @@ export async function createApp(options = {}) {
1244
1222
  wakeAll();
1245
1223
  res.json({ ok: true, node: node.name });
1246
1224
  });
1225
+ api('post', '/api/v1/nodes/:name/address', null, async (req, res) => {
1226
+ const node = findNode(String(req.params.name));
1227
+ if (!node)
1228
+ return res.status(404).json({ error: 'node not found' });
1229
+ const { address } = req.body;
1230
+ if (!isNodeHost(address))
1231
+ return res
1232
+ .status(400)
1233
+ .json({ error: 'address must be an IP address or hostname without a port' });
1234
+ db.run('UPDATE nodes SET address=?,updated_at=? WHERE id=?', address, now(), node.id);
1235
+ audit(req.actor, {
1236
+ type: 'node.address',
1237
+ message: `node ${node.name} address changed`,
1238
+ metadata: { node: node.name, address },
1239
+ });
1240
+ endpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1241
+ wakeAll();
1242
+ res.json({ ok: true, node: node.name, address });
1243
+ });
1247
1244
  api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
1248
1245
  const node = findNode(String(req.params.name));
1249
1246
  if (!node)
1250
1247
  return res.status(404).json({ error: 'node not found' });
1251
1248
  const enabled = req.body.enabled !== false;
1252
- db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
1249
+ db.run('UPDATE nodes SET ingress_status_json=CASE WHEN ingress<>? THEN NULL ELSE ingress_status_json END,ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, enabled ? 1 : 0, now(), node.id);
1253
1250
  wakeHub.wake(node.id);
1254
1251
  res.json({ ok: true, node: node.name, ingress: enabled });
1255
1252
  });
1256
- post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
1257
- if (!MESH)
1258
- return res.status(409).json({ error: 'the WireGuard mesh is not enabled' });
1259
- const { publicKey } = req.body;
1260
- if (!isWgKey(publicKey))
1261
- return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
1262
- try {
1263
- syncWgPeer(req.node.id, publicKey);
1264
- wakeAll();
1265
- res.json({ ok: true });
1266
- }
1267
- catch (e) {
1268
- sendError(res, isUniqueViolation(e) ? httpError(409, 'public key already registered to another node') : e);
1269
- }
1270
- });
1271
1253
  api('post', '/api/v1/credentials', null, async (req, res) => {
1272
1254
  const request = parseCredentialRequest(req.body);
1273
- const created = await createCredential(db, pki, advertiseUrl(), request, req.actor.name);
1255
+ const created = await createCredential(db, pki, controlUrl(), request, req.actor.name);
1274
1256
  audit(req.actor, {
1275
1257
  type: 'credential.create',
1276
1258
  message: `credential ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
@@ -2219,15 +2201,6 @@ export async function createApp(options = {}) {
2219
2201
  await check('docker', async () => ({
2220
2202
  output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim(),
2221
2203
  }));
2222
- if (MESH) {
2223
- await check('mesh', async () => {
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');
2225
- const missing = nodes.filter((n) => !n.has_key).map((n) => n.name);
2226
- if (missing.length)
2227
- throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
2228
- return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
2229
- });
2230
- }
2231
2204
  const ingressNodes = db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
2232
2205
  if (ingressNodes.length) {
2233
2206
  await check('ingress', async () => {
@@ -2261,10 +2234,9 @@ export async function createApp(options = {}) {
2261
2234
  }
2262
2235
  function endpointsFor(states) {
2263
2236
  const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
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');
2237
+ const nodes = db.all('SELECT * FROM nodes WHERE disabled_at IS NULL');
2265
2238
  const views = nodes.map((n) => ({
2266
2239
  name: n.name,
2267
- overlayIp: MESH && n.has_key ? n.overlay_ip : null,
2268
2240
  address: n.address,
2269
2241
  lastSeenAt: n.last_seen_at,
2270
2242
  }));
@@ -2277,24 +2249,6 @@ export async function createApp(options = {}) {
2277
2249
  }));
2278
2250
  return resolveServiceEndpoints(states, views, statuses, staleBefore);
2279
2251
  }
2280
- function networkFor(node) {
2281
- if (!MESH || !node.overlay_ip)
2282
- return undefined;
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);
2284
- return {
2285
- interface: WG_INTERFACE,
2286
- overlayIp: node.overlay_ip,
2287
- cidr: MESH_CIDR,
2288
- listenPort: WG_PORT,
2289
- peers: peers.map((p) => ({
2290
- node: p.name,
2291
- publicKey: p.public_key,
2292
- endpoint: p.endpoint ?? undefined,
2293
- allowedIps: p.allowed_ips.split(','),
2294
- persistentKeepalive: WG_KEEPALIVE,
2295
- })),
2296
- };
2297
- }
2298
2252
  /** Names of all nodes and of the ingress nodes, for port binding and upstream decisions. */
2299
2253
  function nodeTopology() {
2300
2254
  const rows = db.all('SELECT name,ingress FROM nodes WHERE disabled_at IS NULL ORDER BY name');
@@ -2305,7 +2259,6 @@ export async function createApp(options = {}) {
2305
2259
  }
2306
2260
  /** Where this node's published ports listen: the address other nodes and the ingress reach it on, or loopback. */
2307
2261
  function portBindingFor(node, states) {
2308
- const hasKey = MESH && node.overlay_ip && db.get('SELECT 1 FROM wg_peers WHERE node_id=?', node.id);
2309
2262
  const { nodes, ingressNodes } = nodeTopology();
2310
2263
  const loopback = {};
2311
2264
  for (const state of states) {
@@ -2313,7 +2266,7 @@ export async function createApp(options = {}) {
2313
2266
  if (services.length)
2314
2267
  loopback[state.project] = services;
2315
2268
  }
2316
- return { address: (hasKey ? node.overlay_ip : node.address) || null, loopback };
2269
+ return { address: node.address || null, loopback };
2317
2270
  }
2318
2271
  function ingressFor(node, states) {
2319
2272
  const secrets = new Map();
@@ -2367,9 +2320,6 @@ export async function createApp(options = {}) {
2367
2320
  commands,
2368
2321
  retainedImages,
2369
2322
  };
2370
- const network = networkFor(node);
2371
- if (network)
2372
- work.network = network;
2373
2323
  work.ports = portBindingFor(node, desiredStates);
2374
2324
  const holds = restores.holds();
2375
2325
  if (Object.keys(holds).length)
@@ -2408,7 +2358,6 @@ export async function createApp(options = {}) {
2408
2358
  }),
2409
2359
  };
2410
2360
  db.run(`INSERT INTO node_statuses(id,node_id,project_id,observed_version,status_json,health,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(node_id,project_id) DO UPDATE SET observed_version=excluded.observed_version,status_json=excluded.status_json,health=excluded.health,updated_at=excluded.updated_at`, id('ns'), req.node.id, st.project, st.observedVersion, JSON.stringify(sanitized), st.health, ts);
2411
- updateDeploymentConvergence(st.project);
2412
2361
  }
2413
2362
  // Commands are resolved under the calling node first: only a running command of this node can be
2414
2363
  // heartbeated or completed, so foreign and already-finished command ids change nothing.
@@ -2423,10 +2372,14 @@ export async function createApp(options = {}) {
2423
2372
  db.run("UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=? AND state='running'", cr.state, JSON.stringify(cr.result), ts, cr.commandId, req.node.id);
2424
2373
  }
2425
2374
  if (report.ingress)
2426
- db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
2375
+ db.run('UPDATE nodes SET ingress_status_json=? WHERE id=? AND ingress=1 AND disabled_at IS NULL', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
2427
2376
  db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
2428
2377
  if (report.sentAt)
2429
2378
  db.run('UPDATE nodes SET clock_skew_ms=?,clock_observed_at=? WHERE id=?', Date.parse(report.sentAt) - Date.parse(ts), ts, req.node.id);
2379
+ // Ingress-only nodes can acknowledge routing without reporting any project; recheck
2380
+ // every active rollout after all project and ingress observations have been stored.
2381
+ for (const deployment of db.all("SELECT DISTINCT project_id FROM deployments WHERE state='rolling_out'"))
2382
+ updateDeploymentConvergence(deployment.project_id);
2430
2383
  try {
2431
2384
  restores.advance();
2432
2385
  }
@@ -2598,18 +2551,18 @@ async function ensureServerCredential(pki, dir, names, force = false) {
2598
2551
  export async function listen(app, port = PORT) {
2599
2552
  const pki = app.locals.pki;
2600
2553
  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);
2554
+ const advertised = app.locals.controlUrl;
2555
+ if (!advertised)
2556
+ throw new Error('TUG_CONTROL_URL is required: the credential-free https origin clients connect to');
2557
+ const url = new URL(advertised);
2605
2558
  if (url.protocol !== 'https:' ||
2606
2559
  url.username ||
2607
2560
  url.password ||
2608
2561
  url.search ||
2609
2562
  url.hash ||
2610
2563
  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(','));
2564
+ throw new Error('TUG_CONTROL_URL must be a credential-free https origin such as https://control.example.com:8443');
2565
+ const names = serverNames(advertised, (process.env.TUG_EXTRA_SANS || '').split(','));
2613
2566
  const credential = await ensureServerCredential(pki, dataDir, names);
2614
2567
  const server = https.createServer(serverTlsOptions({ cert: credential.cert, key: credential.key, ca: pki.caPem }), app);
2615
2568
  server.requestTimeout = 5 * 60_000;
@@ -39,7 +39,7 @@ export const ROUTE_ROLES = {
39
39
  'POST /api/v1/nodes/enrollment-credential': 'admin',
40
40
  'POST /api/v1/nodes/:name/identity': 'admin',
41
41
  'POST /api/v1/nodes/:name/disable': 'admin',
42
- 'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
42
+ 'POST /api/v1/nodes/:name/address': 'admin',
43
43
  'POST /api/v1/nodes/:name/ingress': 'admin',
44
44
  'DELETE /api/v1/nodes/:name': 'admin',
45
45
  'POST /api/v1/backups/run': 'deploy',
package/dist/db.d.ts CHANGED
@@ -13,8 +13,6 @@ export interface NodeRow {
13
13
  id: string;
14
14
  name: string;
15
15
  address: string | null;
16
- public_address: string | null;
17
- overlay_ip: string | null;
18
16
  ingress: number;
19
17
  ingress_status_json: string | null;
20
18
  status: string;
@@ -31,14 +29,6 @@ export interface NodeRow {
31
29
  created_at: string;
32
30
  updated_at: string;
33
31
  }
34
- export interface WgPeerRow {
35
- node_id: string;
36
- public_key: string;
37
- overlay_ip: string;
38
- endpoint: string | null;
39
- allowed_ips: string;
40
- updated_at: string;
41
- }
42
32
  export interface EnrollmentCredentialRow {
43
33
  id: string;
44
34
  key_hash: string;
package/dist/db.js CHANGED
@@ -54,8 +54,7 @@ function initializeControl(db) {
54
54
  db.exec(`
55
55
  CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
56
56
  CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
57
- CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, key_hash TEXT, disabled_at TEXT, cert_not_after TEXT, last_renewed_at TEXT, last_seen_at TEXT, clock_skew_ms INTEGER, clock_observed_at TEXT, created_at TEXT, updated_at TEXT);
58
- CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
57
+ CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, key_hash TEXT, disabled_at TEXT, cert_not_after TEXT, last_renewed_at TEXT, last_seen_at TEXT, clock_skew_ms INTEGER, clock_observed_at TEXT, created_at TEXT, updated_at TEXT);
59
58
  CREATE TABLE IF NOT EXISTS enrollment_credentials(id TEXT PRIMARY KEY, key_hash TEXT UNIQUE NOT NULL, name TEXT NOT NULL, labels_json TEXT, expires_at TEXT NOT NULL, revoked_at TEXT, node_id TEXT, reserved_node_id TEXT NOT NULL, node_key_hash TEXT, created_at TEXT);
60
59
  CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
61
60
  CREATE TABLE IF NOT EXISTS desired_states(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, version TEXT, state_json TEXT, created_at TEXT);
package/dist/ingress.d.ts CHANGED
@@ -4,8 +4,7 @@ export declare const CADDY_CONTAINER = "tug-caddy";
4
4
  export declare const DEFAULT_CADDY_IMAGE = "caddy:2-alpine";
5
5
  export interface NodeView {
6
6
  name: string;
7
- /** Reachable mesh address; null when the mesh is off or the node has not reported a key yet. */
8
- overlayIp: string | null;
7
+ /** Operator-provided address other nodes use to reach this node's published ports. */
9
8
  address: string | null;
10
9
  lastSeenAt: string | null;
11
10
  }
package/dist/ingress.js CHANGED
@@ -26,7 +26,7 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
26
26
  const endpoints = [];
27
27
  for (const nodeName of spec.placement?.nodes || []) {
28
28
  const node = byName.get(nodeName);
29
- const address = node && (node.overlayIp || node.address);
29
+ const address = node?.address;
30
30
  if (!node || !address || !node.lastSeenAt || node.lastSeenAt < staleBefore)
31
31
  continue;
32
32
  const serviceState = reported.get(`${nodeName}\0${state.project}`)?.serviceStates?.[service];
package/dist/main.js CHANGED
@@ -9,7 +9,7 @@ async function readStdin() {
9
9
  return Buffer.concat(chunks).toString('utf8');
10
10
  }
11
11
  const usage = `Usage: tug-runtime <control|agent> [command]
12
- control run the control plane (needs TUG_ADVERTISE_URL)
12
+ control run the control plane (needs TUG_CONTROL_URL)
13
13
  control issue-credential issue an operator credential on the control-plane host (--name, --role, --replace)
14
14
  agent run the node agent
15
15
  agent identity print the CSR for this node's pending key
@@ -0,0 +1,2 @@
1
+ /** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
2
+ export declare const isNodeHost: (value: unknown) => value is string;
@@ -0,0 +1,6 @@
1
+ import { isIP } from 'node:net';
2
+ const HOST_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
3
+ /** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
4
+ export const isNodeHost = (value) => typeof value === 'string' &&
5
+ value.length <= 253 &&
6
+ (isIP(value) !== 0 || value.split('.').every((label) => HOST_LABEL.test(label)));
package/dist/pki.d.ts CHANGED
@@ -59,7 +59,7 @@ export interface SubjectNames {
59
59
  ip: string[];
60
60
  }
61
61
  /** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
62
- export declare function serverNames(advertiseUrl: string, extras?: string[]): SubjectNames;
62
+ export declare function serverNames(controlUrl: string, extras?: string[]): SubjectNames;
63
63
  export declare const namesKey: (names: SubjectNames) => string;
64
64
  export declare class Pki {
65
65
  readonly caPem: string;
package/dist/pki.js CHANGED
@@ -149,9 +149,9 @@ export async function verifyCsr(csrPem) {
149
149
  }
150
150
  const serial = () => `0${crypto.randomBytes(15).toString('hex')}`;
151
151
  /** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
152
- export function serverNames(advertiseUrl, extras = []) {
152
+ export function serverNames(controlUrl, extras = []) {
153
153
  const names = { dns: [], ip: [] };
154
- const host = new URL(advertiseUrl).hostname.replace(/^\[|\]$/g, '');
154
+ const host = new URL(controlUrl).hostname.replace(/^\[|\]$/g, '');
155
155
  for (const entry of [host, ...extras.map((e) => e.trim()).filter(Boolean)]) {
156
156
  const bucket = net.isIP(entry) ? names.ip : names.dns;
157
157
  if (!bucket.includes(entry))
@@ -165,7 +165,7 @@ export declare const ACTIVE_DEPLOYMENT_STATES: readonly ["uploading", "building"
165
165
  /** Deployment states a CLI wait loop stops on. */
166
166
  export declare const TERMINAL_DEPLOYMENT_STATES: readonly ["succeeded", "failed", "build_failed", "cancelled"];
167
167
  export type DeploymentState = (typeof ACTIVE_DEPLOYMENT_STATES)[number] | (typeof TERMINAL_DEPLOYMENT_STATES)[number];
168
- export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'wg.rotate-key' | 'backup.check' | 'backup.run' | 'backup.restore' | 'backup.list';
168
+ export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'backup.check' | 'backup.run' | 'backup.restore' | 'backup.list';
169
169
  /** Commands whose helper containers need the backup destination's credentials. */
170
170
  export declare const BACKUP_COMMAND_TYPES: readonly CommandType[];
171
171
  /** `GET /agent/v1/backup/credentials`: environment for the restic helper of one running backup command. */
@@ -273,8 +273,6 @@ export type CommandResultPayload = {
273
273
  project: string;
274
274
  volume: string;
275
275
  deleted: true;
276
- } | {
277
- publicKey: string;
278
276
  } | BackupJobResult | {
279
277
  error: string;
280
278
  };
@@ -423,8 +421,6 @@ export interface WorkResponse {
423
421
  desiredStates: DesiredState[];
424
422
  commands: Command[];
425
423
  retainedImages?: string[];
426
- /** WireGuard mesh config for this node; present only when the mesh is enabled on control. */
427
- network?: NetworkConfig;
428
424
  /** Healthy cross-node endpoints per project and service; present only when service DNS is enabled. */
429
425
  serviceEndpoints?: ServiceEndpoints;
430
426
  /** Edge proxy config; present only for nodes marked as ingress nodes. */
@@ -439,33 +435,11 @@ export interface WorkResponse {
439
435
  * explicit host IP (including `0.0.0.0`) are never changed.
440
436
  */
441
437
  export interface PortBinding {
442
- /** The node's overlay IP with the mesh, otherwise its `address` (an IP or a hostname the agent resolves); null binds to `127.0.0.1`. */
438
+ /** The node's `address` (an IP or a hostname the agent resolves); null binds to `127.0.0.1`. */
443
439
  address: string | null;
444
440
  /** Per project, services whose ingress port only the managed Caddy on this node dials; that port binds to `127.0.0.1`. */
445
441
  loopback: Record<string, string[]>;
446
442
  }
447
- export interface WgPeer {
448
- node: string;
449
- publicKey: string;
450
- /** `host:port` the peer can be dialed on; absent for nodes behind NAT (they dial out instead). */
451
- endpoint?: string;
452
- allowedIps: string[];
453
- persistentKeepalive: number;
454
- }
455
- /** The `network` block of `GET /agent/v1/work`: everything an agent needs to reconcile `wg0`. */
456
- export interface NetworkConfig {
457
- interface: string;
458
- /** This node's overlay address, e.g. `10.210.0.2`. */
459
- overlayIp: string;
460
- /** Mesh CIDR, e.g. `10.210.0.0/16`; `overlayIp` is assigned to `wg0` with this prefix length. */
461
- cidr: string;
462
- listenPort: number;
463
- peers: WgPeer[];
464
- }
465
- /** Body of `POST /agent/v1/wireguard/key` (also accepted as `wgPublicKey` on register and enroll). */
466
- export interface WgKeyRequest {
467
- publicKey: string;
468
- }
469
443
  export interface ServiceEndpoint {
470
444
  node: string;
471
445
  address: string;
@@ -552,8 +526,6 @@ export interface NodeInfo {
552
526
  id: string;
553
527
  name: string;
554
528
  address: string | null;
555
- public_address: string | null;
556
- overlay_ip?: string | null;
557
529
  labels?: string[];
558
530
  status: string;
559
531
  created_at: string;
@@ -565,7 +537,6 @@ export interface IdentityRequest {
565
537
  /** `create` makes a new node (idempotent for the identical key); `replace` installs a new key on an existing node. */
566
538
  mode: 'create' | 'replace';
567
539
  address?: string;
568
- publicAddress?: string;
569
540
  labels?: string[];
570
541
  }
571
542
  /** Response of `POST /api/v1/nodes/:name/identity` and `POST /agent/v1/enroll`. */
@@ -581,9 +552,6 @@ export interface IdentityResponse {
581
552
  export interface EnrollRequest {
582
553
  csr: string;
583
554
  address?: string;
584
- publicAddress?: string;
585
- /** Base64 WireGuard public key; the private key never leaves the node. */
586
- wgPublicKey?: string;
587
555
  }
588
556
  /** Response of `POST /agent/v1/enroll`. */
589
557
  export type EnrollResponse = IdentityResponse;