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