@wpelevator/tug 0.6.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.
- package/README.md +24 -22
- package/dist/agent-credential.d.ts +39 -0
- package/dist/agent-credential.js +135 -0
- package/dist/agent.d.ts +15 -9
- package/dist/agent.js +126 -104
- package/dist/backup-check.d.ts +11 -0
- package/dist/backup-check.js +10 -0
- package/dist/backup-config.d.ts +5 -0
- package/dist/backup-config.js +23 -3
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.js +28 -8
- package/dist/backup-scheduler.d.ts +7 -1
- package/dist/backup-scheduler.js +21 -6
- package/dist/cli-build.js +70 -4
- package/dist/cli-config.d.ts +9 -5
- package/dist/cli-config.js +89 -34
- package/dist/cli-install.js +1 -1
- package/dist/cli-live-transport.d.ts +3 -2
- package/dist/cli-live-transport.js +9 -5
- package/dist/cli.js +276 -142
- package/dist/compose.d.ts +3 -1
- package/dist/compose.js +14 -2
- package/dist/control-credential.d.ts +12 -0
- package/dist/control-credential.js +46 -0
- package/dist/control.d.ts +18 -1
- package/dist/control.js +586 -202
- package/dist/credentials.d.ts +111 -0
- package/dist/{tokens.js → credentials.js} +70 -54
- package/dist/db.d.ts +20 -5
- package/dist/db.js +3 -3
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +65 -0
- package/dist/ingress.js +4 -3
- package/dist/main.js +35 -0
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +16 -2
- package/dist/pki.d.ts +119 -0
- package/dist/pki.js +339 -0
- package/dist/protocol.d.ts +53 -22
- package/dist/status-report.d.ts +11 -0
- package/dist/status-report.js +147 -0
- package/dist/templates/agent.env +1 -3
- package/dist/templates/control-compose.yml +4 -3
- package/dist/templates/control-install.sh +8 -2
- package/dist/tls-server.d.ts +11 -0
- package/dist/tls-server.js +23 -0
- package/package.json +10 -8
- 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,
|
|
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,
|
|
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.
|
|
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 ||
|
|
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.
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
538
|
-
const guard = createGuard(
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
598
|
-
const
|
|
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
|
-
|
|
738
|
-
|
|
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
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
896
|
-
|
|
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
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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: '
|
|
953
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
1138
|
+
sendError(res, e);
|
|
965
1139
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
|
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/
|
|
1063
|
-
const request =
|
|
1064
|
-
const created =
|
|
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: '
|
|
1067
|
-
message: `
|
|
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/
|
|
1078
|
-
api('delete', '/api/v1/
|
|
1079
|
-
if (!
|
|
1080
|
-
return res.status(404).json({ error: '
|
|
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: '
|
|
1083
|
-
message: `
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
'
|
|
1757
|
-
'
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
1791
|
-
throw new Error(
|
|
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
|
|
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
|
|
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('
|
|
1937
|
-
|
|
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
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2233
|
-
|
|
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
|
-
|
|
2245
|
-
|
|
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
|
-
|
|
2259
|
-
|
|
2260
|
-
const
|
|
2261
|
-
|
|
2262
|
-
const
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
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
|
-
|
|
2271
|
-
|
|
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
|
}
|