@wpelevator/tug 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +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/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 +265 -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.js +1 -1
- package/dist/pki.d.ts +119 -0
- package/dist/pki.js +339 -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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from 'express';
|
|
2
|
+
import type { Db, EnrollmentCredentialRow, NodeRow } from './db.ts';
|
|
3
|
+
import type { Credential, Pki } from './pki.ts';
|
|
4
|
+
/** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
|
|
5
|
+
export declare const ROLES: readonly ["read", "deploy", "admin"];
|
|
6
|
+
export type Role = (typeof ROLES)[number];
|
|
7
|
+
export type RouteRole = Role | 'public';
|
|
8
|
+
/** Name of the admin credential issued on the control host at installation. It is an ordinary named admin. */
|
|
9
|
+
export declare const ROOT_ACTOR = "root";
|
|
10
|
+
/** Reserved actor for rollbacks control starts on its own. Credential names cannot contain a colon, so none can use it. */
|
|
11
|
+
export declare const AUTO_ROLLBACK_ACTOR = "system:auto-rollback";
|
|
12
|
+
export declare const CREDENTIAL_NAME_PATTERN: RegExp;
|
|
13
|
+
export declare const PROJECT_NAME_PATTERN: RegExp;
|
|
14
|
+
/**
|
|
15
|
+
* The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
|
|
16
|
+
* register an unlisted route, and a test compares this table with the live router. docs/access-control.md mirrors this table.
|
|
17
|
+
*/
|
|
18
|
+
export declare const ROUTE_ROLES: Record<string, RouteRole>;
|
|
19
|
+
/** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
|
|
20
|
+
export interface Actor {
|
|
21
|
+
name: string;
|
|
22
|
+
role: Role;
|
|
23
|
+
projects: string[] | null;
|
|
24
|
+
}
|
|
25
|
+
export interface CredentialRow {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
key_hash: string;
|
|
29
|
+
role: Role;
|
|
30
|
+
projects_json: string | null;
|
|
31
|
+
created_at: string;
|
|
32
|
+
created_by: string;
|
|
33
|
+
last_used_at: string | null;
|
|
34
|
+
expires_at: string | null;
|
|
35
|
+
revoked_at: string | null;
|
|
36
|
+
}
|
|
37
|
+
export interface CredentialInfo {
|
|
38
|
+
name: string;
|
|
39
|
+
role: Role;
|
|
40
|
+
projects: string[] | null;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
createdBy: string;
|
|
43
|
+
lastUsedAt: string | null;
|
|
44
|
+
expiresAt: string | null;
|
|
45
|
+
revokedAt: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** The principal behind a request, resolved from the client certificate and current database state. */
|
|
48
|
+
export type Identity = {
|
|
49
|
+
klass: 'node';
|
|
50
|
+
node: NodeRow;
|
|
51
|
+
} | {
|
|
52
|
+
klass: 'operator';
|
|
53
|
+
actor: Actor;
|
|
54
|
+
credentialId: string;
|
|
55
|
+
} | {
|
|
56
|
+
klass: 'enroll';
|
|
57
|
+
row: EnrollmentCredentialRow;
|
|
58
|
+
};
|
|
59
|
+
declare module 'express-serve-static-core' {
|
|
60
|
+
interface Request {
|
|
61
|
+
identity?: Identity;
|
|
62
|
+
actor?: Actor;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export declare const isRole: (value: unknown) => value is Role;
|
|
66
|
+
export declare const hasRole: (actor: Actor, required: Role) => boolean;
|
|
67
|
+
/** Validates a credential request body, throwing a 400 error describing the first problem. */
|
|
68
|
+
export declare function parseCredentialRequest(body: unknown): {
|
|
69
|
+
name: string;
|
|
70
|
+
role: Role;
|
|
71
|
+
projects: string[] | null;
|
|
72
|
+
expiresAt: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Creates an operator credential. Control generates the keypair, stores only the key hash and returns the credential once;
|
|
76
|
+
* the certificate and the database row expire together.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createCredential(db: Db, pki: Pki, url: string, request: ReturnType<typeof parseCredentialRequest>, createdBy: string, options?: {
|
|
79
|
+
replace?: boolean;
|
|
80
|
+
}): Promise<CredentialInfo & {
|
|
81
|
+
credential: Credential;
|
|
82
|
+
}>;
|
|
83
|
+
export declare function listCredentials(db: Db): CredentialInfo[];
|
|
84
|
+
/** Revokes a credential immediately. Returns false if no such credential exists; revoking twice keeps the first revocation time. */
|
|
85
|
+
export declare function revokeCredential(db: Db, name: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Resolves an operator certificate identity to an actor from current database state: the principal must exist, the
|
|
88
|
+
* certificate's key must be the stored key, and the credential must be neither revoked nor expired.
|
|
89
|
+
*/
|
|
90
|
+
export declare function authenticate(db: Db, credentialId: string, keyHash: string): Actor | null;
|
|
91
|
+
/**
|
|
92
|
+
* Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
|
|
93
|
+
* resolved (for example an unknown ID). Unresolvable targets are denied to scoped credentials without saying why, so they
|
|
94
|
+
* cannot probe which IDs or projects exist.
|
|
95
|
+
*/
|
|
96
|
+
export type ProjectResolver = (req: Request) => string | null | undefined | Promise<string | null | undefined>;
|
|
97
|
+
export declare const bodyProject: ProjectResolver;
|
|
98
|
+
export declare const queryProject: ProjectResolver;
|
|
99
|
+
/** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
|
|
100
|
+
export declare function assertProject(actor: Actor, project: string): void;
|
|
101
|
+
/** Builds the request guard: read the operator identity resolved by the class gate (401), check the role (403), then check project scope (403). */
|
|
102
|
+
export declare function createGuard(): (required: Role, resolve?: ProjectResolver) => RequestHandler;
|
|
103
|
+
/** Appends a control-originated audit event naming the acting credential. */
|
|
104
|
+
export declare function recordEvent(db: Db, event: {
|
|
105
|
+
actor: string;
|
|
106
|
+
type: string;
|
|
107
|
+
message: string;
|
|
108
|
+
project?: string | null;
|
|
109
|
+
deploymentId?: string | null;
|
|
110
|
+
metadata?: Record<string, unknown>;
|
|
111
|
+
}): void;
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { isUniqueViolation } from "./db-driver.js";
|
|
2
|
-
import {
|
|
2
|
+
import { id, now } from "./util.js";
|
|
3
|
+
import { OPERATOR_MAX_LIFETIME_MS, identityUri } from "./pki.js";
|
|
3
4
|
/** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
|
|
4
5
|
export const ROLES = ['read', 'deploy', 'admin'];
|
|
5
6
|
const RANK = { read: 1, deploy: 2, admin: 3 };
|
|
6
|
-
/** Name
|
|
7
|
-
export const
|
|
8
|
-
/** Reserved actor for rollbacks control starts on its own.
|
|
7
|
+
/** Name of the admin credential issued on the control host at installation. It is an ordinary named admin. */
|
|
8
|
+
export const ROOT_ACTOR = 'root';
|
|
9
|
+
/** Reserved actor for rollbacks control starts on its own. Credential names cannot contain a colon, so none can use it. */
|
|
9
10
|
export const AUTO_ROLLBACK_ACTOR = 'system:auto-rollback';
|
|
10
|
-
export const
|
|
11
|
-
export const TOKEN_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
11
|
+
export const CREDENTIAL_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
12
12
|
export const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
13
|
-
/** `last_used_at` is refreshed at most this often per
|
|
13
|
+
/** `last_used_at` is refreshed at most this often per credential, so reads do not turn into writes. */
|
|
14
14
|
const LAST_USED_INTERVAL_MS = 60_000;
|
|
15
15
|
/**
|
|
16
16
|
* The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
|
|
17
|
-
* register an unlisted route, and a test compares this table with the live router.
|
|
18
|
-
* credential check and are not behind the token guard. docs/access-control.md mirrors this table.
|
|
17
|
+
* register an unlisted route, and a test compares this table with the live router. docs/access-control.md mirrors this table.
|
|
19
18
|
*/
|
|
20
19
|
export const ROUTE_ROLES = {
|
|
21
20
|
'GET /api/v1/live': 'deploy',
|
|
@@ -37,8 +36,9 @@ export const ROUTE_ROLES = {
|
|
|
37
36
|
'DELETE /api/v1/secrets/:name': 'admin',
|
|
38
37
|
'DELETE /api/v1/volumes/:name': 'admin',
|
|
39
38
|
'GET /api/v1/nodes': 'admin',
|
|
40
|
-
'POST /api/v1/nodes/
|
|
41
|
-
'POST /api/v1/nodes/
|
|
39
|
+
'POST /api/v1/nodes/enrollment-credential': 'admin',
|
|
40
|
+
'POST /api/v1/nodes/:name/identity': 'admin',
|
|
41
|
+
'POST /api/v1/nodes/:name/disable': 'admin',
|
|
42
42
|
'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
|
|
43
43
|
'POST /api/v1/nodes/:name/ingress': 'admin',
|
|
44
44
|
'DELETE /api/v1/nodes/:name': 'admin',
|
|
@@ -51,26 +51,24 @@ export const ROUTE_ROLES = {
|
|
|
51
51
|
'GET /api/v1/backup/destination': 'admin',
|
|
52
52
|
'POST /api/v1/backup/destination': 'admin',
|
|
53
53
|
'DELETE /api/v1/backup/destination': 'admin',
|
|
54
|
-
'GET /api/v1/backup/
|
|
54
|
+
'GET /api/v1/backup/repository-password': 'admin',
|
|
55
55
|
'POST /api/v1/control/backups/run': 'admin',
|
|
56
56
|
'POST /api/v1/control/restore': 'admin',
|
|
57
57
|
'GET /api/v1/diagnostics': 'admin',
|
|
58
58
|
'POST /api/v1/diagnostics/nodes': 'admin',
|
|
59
|
-
'POST /api/v1/
|
|
60
|
-
'GET /api/v1/
|
|
61
|
-
'DELETE /api/v1/
|
|
59
|
+
'POST /api/v1/credentials': 'admin',
|
|
60
|
+
'GET /api/v1/credentials': 'admin',
|
|
61
|
+
'DELETE /api/v1/credentials/:name': 'admin',
|
|
62
62
|
};
|
|
63
63
|
const statusError = (status, message) => Object.assign(new Error(message), { status });
|
|
64
64
|
export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
|
|
65
65
|
export const hasRole = (actor, required) => RANK[actor.role] >= RANK[required];
|
|
66
|
-
/** Validates a
|
|
67
|
-
export function
|
|
66
|
+
/** Validates a credential request body, throwing a 400 error describing the first problem. */
|
|
67
|
+
export function parseCredentialRequest(body) {
|
|
68
68
|
const input = (body && typeof body === 'object' ? body : {});
|
|
69
69
|
const { name, role, projects, expiresInSeconds } = input;
|
|
70
|
-
if (typeof name !== 'string' || !
|
|
71
|
-
throw statusError(400, '
|
|
72
|
-
if (name === OPERATOR_ACTOR)
|
|
73
|
-
throw statusError(400, `token name ${OPERATOR_ACTOR} is reserved for the root operator token`);
|
|
70
|
+
if (typeof name !== 'string' || !CREDENTIAL_NAME_PATTERN.test(name))
|
|
71
|
+
throw statusError(400, 'credential name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
|
|
74
72
|
if (!isRole(role))
|
|
75
73
|
throw statusError(400, `role must be one of ${ROLES.join(', ')}`);
|
|
76
74
|
let scope = null;
|
|
@@ -81,16 +79,16 @@ export function parseTokenRequest(body) {
|
|
|
81
79
|
projects.some((p) => typeof p !== 'string' || !PROJECT_NAME_PATTERN.test(p)))
|
|
82
80
|
throw statusError(400, 'projects must be a nonempty list of valid project names, or omitted for all projects');
|
|
83
81
|
if (role === 'admin')
|
|
84
|
-
throw statusError(400, 'admin
|
|
82
|
+
throw statusError(400, 'admin credentials manage the whole control plane and cannot be limited to projects');
|
|
85
83
|
scope = [...new Set(projects)].sort();
|
|
86
84
|
}
|
|
87
|
-
let expiresAt =
|
|
85
|
+
let expiresAt = new Date(Date.now() + OPERATOR_MAX_LIFETIME_MS).toISOString();
|
|
88
86
|
if (expiresInSeconds !== undefined && expiresInSeconds !== null) {
|
|
89
87
|
if (typeof expiresInSeconds !== 'number' ||
|
|
90
88
|
!Number.isSafeInteger(expiresInSeconds) ||
|
|
91
89
|
expiresInSeconds < 1 ||
|
|
92
|
-
expiresInSeconds >
|
|
93
|
-
throw statusError(400, 'expiresInSeconds must be a positive integer of at most
|
|
90
|
+
expiresInSeconds > OPERATOR_MAX_LIFETIME_MS / 1000)
|
|
91
|
+
throw statusError(400, 'expiresInSeconds must be a positive integer of at most one year');
|
|
94
92
|
expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
|
95
93
|
}
|
|
96
94
|
return { name, role, projects: scope, expiresAt };
|
|
@@ -105,48 +103,66 @@ const info = (row) => ({
|
|
|
105
103
|
expiresAt: row.expires_at,
|
|
106
104
|
revokedAt: row.revoked_at,
|
|
107
105
|
});
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Creates an operator credential. Control generates the keypair, stores only the key hash and returns the credential once;
|
|
108
|
+
* the certificate and the database row expire together.
|
|
109
|
+
*/
|
|
110
|
+
export async function createCredential(db, pki, url, request, createdBy, options = {}) {
|
|
111
|
+
const credentialId = id('cred');
|
|
112
|
+
const lifetime = Math.min(Date.parse(request.expiresAt) - Date.now(), OPERATOR_MAX_LIFETIME_MS);
|
|
113
|
+
const issued = await pki.issueClient(identityUri('operator', credentialId), lifetime, url);
|
|
114
|
+
const expiresAt = issued.notAfter.toISOString();
|
|
111
115
|
try {
|
|
112
|
-
|
|
116
|
+
if (options.replace) {
|
|
117
|
+
const existing = db.get('SELECT * FROM operator_credentials WHERE name=?', request.name);
|
|
118
|
+
if (existing) {
|
|
119
|
+
db.run('UPDATE operator_credentials SET id=?,key_hash=?,role=?,projects_json=?,created_at=?,created_by=?,last_used_at=NULL,expires_at=?,revoked_at=NULL WHERE name=?', credentialId, issued.keyHash, request.role, request.projects ? JSON.stringify(request.projects) : null, now(), createdBy, expiresAt, request.name);
|
|
120
|
+
return {
|
|
121
|
+
...info(db.get('SELECT * FROM operator_credentials WHERE name=?', request.name)),
|
|
122
|
+
credential: issued.credential,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
db.run('INSERT INTO operator_credentials(id,name,key_hash,role,projects_json,created_at,created_by,expires_at) VALUES(?,?,?,?,?,?,?,?)', credentialId, request.name, issued.keyHash, request.role, request.projects ? JSON.stringify(request.projects) : null, now(), createdBy, expiresAt);
|
|
113
127
|
}
|
|
114
128
|
catch (e) {
|
|
115
129
|
if (isUniqueViolation(e))
|
|
116
|
-
throw statusError(409, `
|
|
130
|
+
throw statusError(409, `credential name ${request.name} is already used (revoked credentials keep their names for the audit trail)`);
|
|
117
131
|
throw e;
|
|
118
132
|
}
|
|
119
133
|
return {
|
|
120
|
-
...info(db.get('SELECT * FROM
|
|
121
|
-
|
|
134
|
+
...info(db.get('SELECT * FROM operator_credentials WHERE name=?', request.name)),
|
|
135
|
+
credential: issued.credential,
|
|
122
136
|
};
|
|
123
137
|
}
|
|
124
|
-
export function
|
|
125
|
-
return db
|
|
138
|
+
export function listCredentials(db) {
|
|
139
|
+
return db
|
|
140
|
+
.all('SELECT * FROM operator_credentials ORDER BY created_at, name')
|
|
141
|
+
.map(info);
|
|
126
142
|
}
|
|
127
|
-
/** Revokes a
|
|
128
|
-
export function
|
|
129
|
-
const row = db.get('SELECT * FROM
|
|
143
|
+
/** Revokes a credential immediately. Returns false if no such credential exists; revoking twice keeps the first revocation time. */
|
|
144
|
+
export function revokeCredential(db, name) {
|
|
145
|
+
const row = db.get('SELECT * FROM operator_credentials WHERE name=?', name);
|
|
130
146
|
if (!row)
|
|
131
147
|
return false;
|
|
132
|
-
db.run('UPDATE
|
|
148
|
+
db.run('UPDATE operator_credentials SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
|
|
133
149
|
return true;
|
|
134
150
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Resolves an operator certificate identity to an actor from current database state: the principal must exist, the
|
|
153
|
+
* certificate's key must be the stored key, and the credential must be neither revoked nor expired.
|
|
154
|
+
*/
|
|
155
|
+
export function authenticate(db, credentialId, keyHash) {
|
|
156
|
+
const row = db.get('SELECT * FROM operator_credentials WHERE id=?', credentialId);
|
|
157
|
+
if (!row ||
|
|
158
|
+
row.key_hash !== keyHash ||
|
|
159
|
+
row.revoked_at ||
|
|
160
|
+
(row.expires_at && row.expires_at <= now()))
|
|
145
161
|
return null;
|
|
146
162
|
// Best effort and throttled: bookkeeping failures must never fail authentication.
|
|
147
163
|
if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
|
|
148
164
|
try {
|
|
149
|
-
db.run('UPDATE
|
|
165
|
+
db.run('UPDATE operator_credentials SET last_used_at=? WHERE id=?', now(), row.id);
|
|
150
166
|
}
|
|
151
167
|
catch {
|
|
152
168
|
/* Best-effort bookkeeping. */
|
|
@@ -159,17 +175,17 @@ export function authenticate(db, raw) {
|
|
|
159
175
|
}
|
|
160
176
|
export const bodyProject = (req) => String(req.body?.project || 'site');
|
|
161
177
|
export const queryProject = (req) => String(req.query.project || 'site');
|
|
162
|
-
const FORBIDDEN_PROJECT = 'forbidden: this
|
|
178
|
+
const FORBIDDEN_PROJECT = 'forbidden: this credential may not access that project';
|
|
163
179
|
/** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
|
|
164
180
|
export function assertProject(actor, project) {
|
|
165
181
|
if (actor.projects && !actor.projects.includes(project))
|
|
166
182
|
throw statusError(403, FORBIDDEN_PROJECT);
|
|
167
183
|
}
|
|
168
|
-
/** Builds the request guard:
|
|
169
|
-
export function createGuard(
|
|
184
|
+
/** Builds the request guard: read the operator identity resolved by the class gate (401), check the role (403), then check project scope (403). */
|
|
185
|
+
export function createGuard() {
|
|
170
186
|
return (required, resolve) => async (req, res, next) => {
|
|
171
187
|
try {
|
|
172
|
-
const actor =
|
|
188
|
+
const actor = req.identity?.klass === 'operator' ? req.identity.actor : null;
|
|
173
189
|
if (!actor) {
|
|
174
190
|
res.status(401).json({ error: 'unauthorized' });
|
|
175
191
|
return;
|
|
@@ -198,7 +214,7 @@ export function createGuard(db) {
|
|
|
198
214
|
}
|
|
199
215
|
};
|
|
200
216
|
}
|
|
201
|
-
/** Appends a control-originated audit event naming the acting
|
|
217
|
+
/** Appends a control-originated audit event naming the acting credential. */
|
|
202
218
|
export function recordEvent(db, event) {
|
|
203
219
|
db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), event.project ?? null, event.deploymentId ?? null, null, 'info', event.type, event.message, JSON.stringify(event.metadata ?? {}), now(), event.actor);
|
|
204
220
|
}
|
package/dist/db.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface ProjectRow {
|
|
|
9
9
|
updated_at: string;
|
|
10
10
|
}
|
|
11
11
|
export interface NodeRow {
|
|
12
|
+
/** Immutable and random (`node_...`): never derived from the name and never reused after the node is removed. */
|
|
12
13
|
id: string;
|
|
13
14
|
name: string;
|
|
14
15
|
address: string | null;
|
|
@@ -18,8 +19,15 @@ export interface NodeRow {
|
|
|
18
19
|
ingress_status_json: string | null;
|
|
19
20
|
status: string;
|
|
20
21
|
labels_json: string | null;
|
|
21
|
-
|
|
22
|
+
/** SHA-256 of the node key's SubjectPublicKeyInfo: the identity the node is authorized by. */
|
|
23
|
+
key_hash: string | null;
|
|
24
|
+
disabled_at: string | null;
|
|
25
|
+
cert_not_after: string | null;
|
|
26
|
+
last_renewed_at: string | null;
|
|
22
27
|
last_seen_at: string | null;
|
|
28
|
+
/** The node clock minus the control clock when its last status report arrived, in milliseconds (includes request latency). */
|
|
29
|
+
clock_skew_ms: number | null;
|
|
30
|
+
clock_observed_at: string | null;
|
|
23
31
|
created_at: string;
|
|
24
32
|
updated_at: string;
|
|
25
33
|
}
|
|
@@ -31,11 +39,18 @@ export interface WgPeerRow {
|
|
|
31
39
|
allowed_ips: string;
|
|
32
40
|
updated_at: string;
|
|
33
41
|
}
|
|
34
|
-
export interface
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
export interface EnrollmentCredentialRow {
|
|
43
|
+
id: string;
|
|
44
|
+
key_hash: string;
|
|
45
|
+
name: string;
|
|
46
|
+
labels_json: string | null;
|
|
38
47
|
expires_at: string;
|
|
48
|
+
revoked_at: string | null;
|
|
49
|
+
/** Set when the key is consumed: the node created from it. Null while unconsumed. */
|
|
50
|
+
node_id: string | null;
|
|
51
|
+
/** The immutable node ID this key will create, assigned when the key is minted so the bootstrap flow signs a stable identity. */
|
|
52
|
+
reserved_node_id: string;
|
|
53
|
+
node_key_hash: string | null;
|
|
39
54
|
created_at: string;
|
|
40
55
|
}
|
|
41
56
|
export interface DeploymentRow {
|
package/dist/db.js
CHANGED
|
@@ -54,9 +54,9 @@ function initializeControl(db) {
|
|
|
54
54
|
db.exec(`
|
|
55
55
|
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
56
56
|
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
|
|
57
|
-
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT,
|
|
57
|
+
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, key_hash TEXT, disabled_at TEXT, cert_not_after TEXT, last_renewed_at TEXT, last_seen_at TEXT, clock_skew_ms INTEGER, clock_observed_at TEXT, created_at TEXT, updated_at TEXT);
|
|
58
58
|
CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
|
|
59
|
-
CREATE TABLE IF NOT EXISTS
|
|
59
|
+
CREATE TABLE IF NOT EXISTS enrollment_credentials(id TEXT PRIMARY KEY, key_hash TEXT UNIQUE NOT NULL, name TEXT NOT NULL, labels_json TEXT, expires_at TEXT NOT NULL, revoked_at TEXT, node_id TEXT, reserved_node_id TEXT NOT NULL, node_key_hash TEXT, created_at TEXT);
|
|
60
60
|
CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
|
|
61
61
|
CREATE TABLE IF NOT EXISTS desired_states(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, version TEXT, state_json TEXT, created_at TEXT);
|
|
62
62
|
CREATE TABLE IF NOT EXISTS node_statuses(id TEXT PRIMARY KEY, node_id TEXT, project_id TEXT, observed_version TEXT, status_json TEXT, health TEXT, updated_at TEXT, UNIQUE(node_id, project_id));
|
|
@@ -67,7 +67,7 @@ CREATE TABLE IF NOT EXISTS backups(id TEXT PRIMARY KEY, project_id TEXT, target
|
|
|
67
67
|
CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY, node_id TEXT, project_id TEXT, type TEXT, state TEXT, payload_json TEXT, result_json TEXT, attempts INTEGER DEFAULT 0, run_after TEXT, deadline TEXT, created_at TEXT, updated_at TEXT);
|
|
68
68
|
CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, node_id TEXT, level TEXT, type TEXT, message TEXT, metadata_json TEXT, created_at TEXT, actor TEXT);
|
|
69
69
|
CREATE INDEX IF NOT EXISTS events_project_created ON events(project_id, created_at);
|
|
70
|
-
CREATE TABLE IF NOT EXISTS
|
|
70
|
+
CREATE TABLE IF NOT EXISTS operator_credentials(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, key_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL, projects_json TEXT, created_at TEXT, created_by TEXT, last_used_at TEXT, expires_at TEXT, revoked_at TEXT);
|
|
71
71
|
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
72
72
|
CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
|
|
73
73
|
CREATE TABLE IF NOT EXISTS backup_destination(id TEXT PRIMARY KEY CHECK(id='default'), url TEXT NOT NULL, env_names_json TEXT NOT NULL, checked_at TEXT, check_result_json TEXT, updated_at TEXT);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Days of remaining validity below which each certificate kind is reported as expiring (renewal is overdue). */
|
|
2
|
+
export declare const EXPIRY_WARN_DAYS: {
|
|
3
|
+
readonly ca: 90;
|
|
4
|
+
readonly server: 30;
|
|
5
|
+
readonly node: 14;
|
|
6
|
+
};
|
|
7
|
+
/** A clock this far from control's is worth a look; it is far below the five-minute certificate back-dating. */
|
|
8
|
+
export declare const SKEW_WARN_MS = 30000;
|
|
9
|
+
/** At this skew new certificates look not yet valid (or old ones expired) to the node, so renewal and TLS break. */
|
|
10
|
+
export declare const SKEW_FAIL_MS: number;
|
|
11
|
+
export type ExpiryState = 'ok' | 'expiring' | 'expired';
|
|
12
|
+
export interface CertificateExpiry {
|
|
13
|
+
notAfter: string;
|
|
14
|
+
daysLeft: number;
|
|
15
|
+
state: ExpiryState;
|
|
16
|
+
}
|
|
17
|
+
export declare function certificateExpiry(notAfter: Date, nowMs: number, warnDays: number): CertificateExpiry;
|
|
18
|
+
/** Clock skew is only known for a node that is reporting now; an offline node has an unknown clock, not a zero skew. */
|
|
19
|
+
export type ClockSkew = {
|
|
20
|
+
state: 'observed';
|
|
21
|
+
ms: number;
|
|
22
|
+
observedAt: string;
|
|
23
|
+
} | {
|
|
24
|
+
state: 'unknown';
|
|
25
|
+
reason: 'offline' | 'never reported' | 'disabled';
|
|
26
|
+
};
|
|
27
|
+
export type Connectivity = 'online' | 'stale' | 'never-seen' | 'disabled';
|
|
28
|
+
export interface NodeDiagnosticInput {
|
|
29
|
+
name: string;
|
|
30
|
+
disabled_at: string | null;
|
|
31
|
+
last_seen_at: string | null;
|
|
32
|
+
cert_not_after: string | null;
|
|
33
|
+
clock_skew_ms: number | null;
|
|
34
|
+
clock_observed_at: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface NodeDiagnostic {
|
|
37
|
+
name: string;
|
|
38
|
+
connectivity: Connectivity;
|
|
39
|
+
certificate: CertificateExpiry | null;
|
|
40
|
+
clockSkew: ClockSkew;
|
|
41
|
+
problems: string[];
|
|
42
|
+
warnings: string[];
|
|
43
|
+
}
|
|
44
|
+
export declare function diagnoseNode(node: NodeDiagnosticInput, nowMs: number, staleSeconds: number): NodeDiagnostic;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { BACKDATE_MS } from "./pki.js";
|
|
2
|
+
const DAY = 86_400_000;
|
|
3
|
+
/** Days of remaining validity below which each certificate kind is reported as expiring (renewal is overdue). */
|
|
4
|
+
export const EXPIRY_WARN_DAYS = { ca: 90, server: 30, node: 14 };
|
|
5
|
+
/** A clock this far from control's is worth a look; it is far below the five-minute certificate back-dating. */
|
|
6
|
+
export const SKEW_WARN_MS = 30_000;
|
|
7
|
+
/** At this skew new certificates look not yet valid (or old ones expired) to the node, so renewal and TLS break. */
|
|
8
|
+
export const SKEW_FAIL_MS = BACKDATE_MS;
|
|
9
|
+
export function certificateExpiry(notAfter, nowMs, warnDays) {
|
|
10
|
+
const left = notAfter.getTime() - nowMs;
|
|
11
|
+
return {
|
|
12
|
+
notAfter: notAfter.toISOString(),
|
|
13
|
+
daysLeft: Math.floor(left / DAY),
|
|
14
|
+
state: left <= 0 ? 'expired' : left < warnDays * DAY ? 'expiring' : 'ok',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function diagnoseNode(node, nowMs, staleSeconds) {
|
|
18
|
+
const staleBefore = nowMs - staleSeconds * 1000;
|
|
19
|
+
const fresh = (iso) => !!iso && Date.parse(iso) >= staleBefore;
|
|
20
|
+
const connectivity = node.disabled_at
|
|
21
|
+
? 'disabled'
|
|
22
|
+
: !node.last_seen_at
|
|
23
|
+
? 'never-seen'
|
|
24
|
+
: fresh(node.last_seen_at)
|
|
25
|
+
? 'online'
|
|
26
|
+
: 'stale';
|
|
27
|
+
const certificate = node.cert_not_after
|
|
28
|
+
? certificateExpiry(new Date(node.cert_not_after), nowMs, EXPIRY_WARN_DAYS.node)
|
|
29
|
+
: null;
|
|
30
|
+
let clockSkew;
|
|
31
|
+
if (connectivity === 'online' && node.clock_skew_ms !== null && fresh(node.clock_observed_at))
|
|
32
|
+
clockSkew = {
|
|
33
|
+
state: 'observed',
|
|
34
|
+
ms: node.clock_skew_ms,
|
|
35
|
+
observedAt: node.clock_observed_at,
|
|
36
|
+
};
|
|
37
|
+
else
|
|
38
|
+
clockSkew = {
|
|
39
|
+
state: 'unknown',
|
|
40
|
+
reason: connectivity === 'disabled'
|
|
41
|
+
? 'disabled'
|
|
42
|
+
: connectivity === 'online'
|
|
43
|
+
? 'never reported'
|
|
44
|
+
: connectivity === 'never-seen'
|
|
45
|
+
? 'never reported'
|
|
46
|
+
: 'offline',
|
|
47
|
+
};
|
|
48
|
+
const problems = [];
|
|
49
|
+
const warnings = [];
|
|
50
|
+
// A disabled node's certificate is irrelevant until it is replaced, so it never fails the diagnostics.
|
|
51
|
+
const report = connectivity === 'disabled' ? warnings : problems;
|
|
52
|
+
if (certificate?.state === 'expired')
|
|
53
|
+
report.push(`certificate expired ${certificate.notAfter}; run node-replace-identity ${node.name}`);
|
|
54
|
+
else if (certificate?.state === 'expiring')
|
|
55
|
+
warnings.push(`certificate expires in ${certificate.daysLeft} days and has not renewed; check the agent logs on node ${node.name}`);
|
|
56
|
+
if (clockSkew.state === 'observed') {
|
|
57
|
+
const abs = Math.abs(clockSkew.ms);
|
|
58
|
+
const seconds = Math.round(clockSkew.ms / 1000);
|
|
59
|
+
if (abs >= SKEW_FAIL_MS)
|
|
60
|
+
problems.push(`clock differs from the control plane by ${seconds}s (certificates are back-dated ${BACKDATE_MS / 60_000} minutes); fix time sync on node ${node.name}`);
|
|
61
|
+
else if (abs >= SKEW_WARN_MS)
|
|
62
|
+
warnings.push(`clock differs from the control plane by ${seconds}s`);
|
|
63
|
+
}
|
|
64
|
+
return { name: node.name, connectivity, certificate, clockSkew, problems, warnings };
|
|
65
|
+
}
|
package/dist/ingress.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// Service endpoints (
|
|
1
|
+
// Service endpoints (the source of cross-node service names), ingress route resolution and Caddyfile rendering
|
|
2
2
|
// on the control side; the managed Caddy container reconcile on the agent side. Docker access goes
|
|
3
|
-
// through the injectable
|
|
3
|
+
// through the injectable DockerApi.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { loopbackIngressServices, publishedPort } from "./compose.js";
|
|
8
8
|
import { ensureDir, errMessage } from "./util.js";
|
|
9
|
+
import { isPort } from "./status-report.js";
|
|
9
10
|
import * as tar from 'tar';
|
|
10
11
|
import { isNotFound } from "./docker-api.js";
|
|
11
12
|
export const CADDY_CONTAINER = 'tug-caddy';
|
|
@@ -35,7 +36,7 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
|
|
|
35
36
|
endpoints.push({
|
|
36
37
|
node: nodeName,
|
|
37
38
|
address,
|
|
38
|
-
...(serviceState.port ? { port: serviceState.port } : {}),
|
|
39
|
+
...(isPort(serviceState.port) ? { port: serviceState.port } : {}),
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
if (endpoints.length)
|
package/dist/main.js
CHANGED
|
@@ -1,14 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Single entrypoint for the control plane and agent images; role picked via TUG_ROLE or argv.
|
|
3
3
|
const role = process.argv[2] || process.env.TUG_ROLE;
|
|
4
|
+
const command = process.argv[3];
|
|
5
|
+
async function readStdin() {
|
|
6
|
+
const chunks = [];
|
|
7
|
+
for await (const chunk of process.stdin)
|
|
8
|
+
chunks.push(Buffer.from(chunk));
|
|
9
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
10
|
+
}
|
|
11
|
+
const usage = `Usage: tug-runtime <control|agent> [command]
|
|
12
|
+
control run the control plane (needs TUG_PUBLIC_URL)
|
|
13
|
+
control issue-credential issue an operator credential on the control-plane host (--name, --role, --replace)
|
|
14
|
+
agent run the node agent
|
|
15
|
+
agent identity print the CSR for this node's pending key
|
|
16
|
+
agent install-cert install an identity response read from stdin`;
|
|
17
|
+
if (process.argv.slice(2).some((a) => a === '--help' || a === '-h' || a === 'help')) {
|
|
18
|
+
console.log(usage);
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
4
21
|
switch (role) {
|
|
5
22
|
case 'control': {
|
|
23
|
+
if (command === 'issue-credential') {
|
|
24
|
+
const { issueHostCredential, parseIssueArgs } = await import("./control-credential.js");
|
|
25
|
+
process.stdout.write(`${await issueHostCredential(parseIssueArgs(process.argv.slice(4)))}\n`);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
6
28
|
const { createApp, listen } = await import("./control.js");
|
|
7
29
|
const app = await createApp();
|
|
8
30
|
await listen(app);
|
|
9
31
|
break;
|
|
10
32
|
}
|
|
11
33
|
case 'agent': {
|
|
34
|
+
if (command === 'identity') {
|
|
35
|
+
// One-shot: print the CSR for the pending key (generated into the state volume on first use). Safe to repeat.
|
|
36
|
+
const { agentIdentityCsr } = await import("./agent-credential.js");
|
|
37
|
+
process.stdout.write(await agentIdentityCsr(process.env.TUG_STATE_DIR || '/var/lib/tug'));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (command === 'install-cert') {
|
|
41
|
+
// One-shot: read the identity response from stdin, validate it against the pending key and install it durably.
|
|
42
|
+
const { installCertFromText } = await import("./agent-credential.js");
|
|
43
|
+
const doc = await installCertFromText(process.env.TUG_STATE_DIR || '/var/lib/tug', await readStdin());
|
|
44
|
+
console.log(`installed identity for node ${doc.nodeName}`);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
12
47
|
const { main } = await import("./agent.js");
|
|
13
48
|
await main();
|
|
14
49
|
break;
|
package/dist/normalize.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// (`x-tug`), while the YAML rendering keeps them.
|
|
5
5
|
// Control secrets are not resolved on the operator's machine: each one is interpolated as a sentinel
|
|
6
6
|
// that the agent replaces with the node's released value when it creates the container.
|
|
7
|
-
import yaml from 'js-yaml';
|
|
7
|
+
import * as yaml from 'js-yaml';
|
|
8
8
|
import { run } from "./build.js";
|
|
9
9
|
const SECRET_NAME = '[A-Za-z_][A-Za-z0-9_]*';
|
|
10
10
|
const SENTINEL = new RegExp(`@@tug:secret:(${SECRET_NAME})@@`, 'g');
|