@wpelevator/tug 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +24 -22
  2. package/dist/agent-credential.d.ts +39 -0
  3. package/dist/agent-credential.js +135 -0
  4. package/dist/agent.d.ts +15 -9
  5. package/dist/agent.js +126 -104
  6. package/dist/backup-check.d.ts +11 -0
  7. package/dist/backup-check.js +10 -0
  8. package/dist/backup-config.d.ts +5 -0
  9. package/dist/backup-config.js +23 -3
  10. package/dist/backup-destination.d.ts +1 -1
  11. package/dist/backup-destination.js +1 -1
  12. package/dist/backup-restore.js +28 -8
  13. package/dist/backup-scheduler.d.ts +7 -1
  14. package/dist/backup-scheduler.js +21 -6
  15. package/dist/cli-build.js +70 -4
  16. package/dist/cli-config.d.ts +9 -5
  17. package/dist/cli-config.js +89 -34
  18. package/dist/cli-install.js +1 -1
  19. package/dist/cli-live-transport.d.ts +3 -2
  20. package/dist/cli-live-transport.js +9 -5
  21. package/dist/cli.js +276 -142
  22. package/dist/compose.d.ts +3 -1
  23. package/dist/compose.js +14 -2
  24. package/dist/control-credential.d.ts +12 -0
  25. package/dist/control-credential.js +46 -0
  26. package/dist/control.d.ts +18 -1
  27. package/dist/control.js +586 -202
  28. package/dist/credentials.d.ts +111 -0
  29. package/dist/{tokens.js → credentials.js} +70 -54
  30. package/dist/db.d.ts +20 -5
  31. package/dist/db.js +3 -3
  32. package/dist/diagnostics.d.ts +44 -0
  33. package/dist/diagnostics.js +65 -0
  34. package/dist/ingress.js +4 -3
  35. package/dist/main.js +35 -0
  36. package/dist/normalize.d.ts +6 -0
  37. package/dist/normalize.js +16 -2
  38. package/dist/pki.d.ts +119 -0
  39. package/dist/pki.js +339 -0
  40. package/dist/protocol.d.ts +53 -22
  41. package/dist/status-report.d.ts +11 -0
  42. package/dist/status-report.js +147 -0
  43. package/dist/templates/agent.env +1 -3
  44. package/dist/templates/control-compose.yml +4 -3
  45. package/dist/templates/control-install.sh +8 -2
  46. package/dist/tls-server.d.ts +11 -0
  47. package/dist/tls-server.js +23 -0
  48. package/package.json +10 -8
  49. package/dist/tokens.d.ts +0 -91
package/dist/pki.d.ts ADDED
@@ -0,0 +1,119 @@
1
+ import 'reflect-metadata';
2
+ import crypto from 'node:crypto';
3
+ import tls from 'node:tls';
4
+ type CryptoKey = crypto.webcrypto.CryptoKey;
5
+ type CryptoKeyPair = crypto.webcrypto.CryptoKeyPair;
6
+ /** `notBefore` is back-dated so a host whose clock runs slightly slow can still install and renew. */
7
+ export declare const BACKDATE_MS: number;
8
+ export declare const CA_LIFETIME_MS: number;
9
+ export declare const SERVER_LIFETIME_MS: number;
10
+ export declare const NODE_LIFETIME_MS: number;
11
+ export declare const OPERATOR_MAX_LIFETIME_MS: number;
12
+ export declare const ENROLL_DEFAULT_TTL_MS: number;
13
+ export type IdentityClass = 'node' | 'operator' | 'enroll';
14
+ export declare const identityUri: (klass: IdentityClass, id: string) => string;
15
+ export declare function parseIdentityUri(uri: string): {
16
+ klass: IdentityClass;
17
+ id: string;
18
+ } | null;
19
+ /** Everything a client needs to reach control: the public URL, the CA to trust and its own certificate and key. */
20
+ export interface Credential {
21
+ v: 1;
22
+ url: string;
23
+ ca: string;
24
+ cert: string;
25
+ key: string;
26
+ }
27
+ export declare const encodeCredential: (credential: Credential) => string;
28
+ /** Validates the shape and the HTTPS origin of an untrusted credential document, and that the key matches the certificate. */
29
+ export declare function parseCredential(value: unknown): Credential;
30
+ export declare function decodeCredential(text: string): Credential;
31
+ export declare function assertKeyMatchesCertificate(certPem: string, keyPem: string): void;
32
+ /** SHA-256 of the DER SubjectPublicKeyInfo: the identity a principal is authorized by. */
33
+ export declare const hashSpki: (spki: Uint8Array | Buffer) => string;
34
+ export declare const certKeyHash: (certPem: string) => string;
35
+ /** Durable write: temporary file in the same directory, flushed, renamed over the target, directory flushed. */
36
+ export interface PersistHooks {
37
+ /** Called after the temporary file is flushed and before the rename. Tests throw here to simulate a crash. */
38
+ afterFlush?: () => void;
39
+ /** Called after the rename and before the directory flush. */
40
+ afterRename?: () => void;
41
+ }
42
+ export declare function writeFileDurable(file: string, content: string, mode?: number, hooks?: PersistHooks): Promise<void>;
43
+ export interface KeyPair {
44
+ privateKeyPem: string;
45
+ publicKey: CryptoKey;
46
+ privateKey: CryptoKey;
47
+ }
48
+ export declare function generateKeyPair(): Promise<KeyPair>;
49
+ export declare function importPrivateKey(pem: string): Promise<CryptoKeyPair>;
50
+ /** A PEM CSR for the key; the requester's subject is irrelevant because control assigns identities. */
51
+ export declare function createCsr(privateKeyPem: string): Promise<string>;
52
+ /** Parses a CSR, verifies its signature and requires a P-256 key. Returns the key hash and public key. */
53
+ export declare function verifyCsr(csrPem: unknown): Promise<{
54
+ keyHash: string;
55
+ publicKey: CryptoKey;
56
+ }>;
57
+ export interface SubjectNames {
58
+ dns: string[];
59
+ ip: string[];
60
+ }
61
+ /** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
62
+ export declare function serverNames(publicUrl: string, extras?: string[]): SubjectNames;
63
+ export declare const namesKey: (names: SubjectNames) => string;
64
+ export declare class Pki {
65
+ readonly caPem: string;
66
+ private readonly caCert;
67
+ private readonly caKey;
68
+ private constructor();
69
+ get caNotAfter(): Date;
70
+ get caFingerprint(): string;
71
+ /** Loads the CA from `dir`, creating it only when `create` is set and no CA files exist. Corrupt or partial material fails closed. */
72
+ static load(dir: string, create: boolean): Promise<Pki>;
73
+ private static create;
74
+ /** Signs a leaf with an assigned profile. Requester-supplied subjects, SANs, validity and extensions are never used. */
75
+ signLeaf(options: {
76
+ publicKey: CryptoKey;
77
+ kind: 'client' | 'server';
78
+ uri?: string;
79
+ names?: SubjectNames;
80
+ lifetimeMs: number;
81
+ }): Promise<{
82
+ cert: string;
83
+ notAfter: Date;
84
+ }>;
85
+ /** Verifies a CSR and signs a client certificate naming `uri` for its key. */
86
+ signClientCsr(csrPem: unknown, uri: string, lifetimeMs: number): Promise<{
87
+ cert: string;
88
+ keyHash: string;
89
+ notAfter: Date;
90
+ }>;
91
+ /** A client keypair and certificate for `uri`, for credentials issued on control (operators, enrollment keys, root). */
92
+ issueClient(uri: string, lifetimeMs: number, url: string): Promise<{
93
+ credential: Credential;
94
+ keyHash: string;
95
+ notAfter: Date;
96
+ }>;
97
+ /** The server certificate and key for the given names. */
98
+ issueServer(names: SubjectNames): Promise<{
99
+ cert: string;
100
+ key: string;
101
+ notAfter: Date;
102
+ }>;
103
+ }
104
+ /** Checks that a certificate chains to `ca`, names `uri` and is bound to the key. Used by the agent before installing. */
105
+ export declare function validateInstalledCertificate(options: {
106
+ cert: string;
107
+ ca: string;
108
+ keyPem: string;
109
+ uri: string;
110
+ }): void;
111
+ /** TLS client options pinned to the credential's CA. Verification is always against the public host, even through a tunnel. */
112
+ export declare function clientTlsOptions(credential: Credential): tls.ConnectionOptions;
113
+ /** The peer identity presented on a TLS socket, or null when none can be established. */
114
+ export declare function peerIdentity(socket: tls.TLSSocket): {
115
+ uri: string;
116
+ keyHash: string;
117
+ notAfter: number;
118
+ } | null;
119
+ export {};
package/dist/pki.js ADDED
@@ -0,0 +1,339 @@
1
+ import 'reflect-metadata';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import tls from 'node:tls';
7
+ import * as x509 from '@peculiar/x509';
8
+ x509.cryptoProvider.set(crypto.webcrypto);
9
+ const SIGNING = { name: 'ECDSA', hash: 'SHA-256' };
10
+ const KEY_ALG = { name: 'ECDSA', namedCurve: 'P-256' };
11
+ const MINUTE = 60_000;
12
+ const DAY = 86_400_000;
13
+ /** `notBefore` is back-dated so a host whose clock runs slightly slow can still install and renew. */
14
+ export const BACKDATE_MS = 5 * MINUTE;
15
+ export const CA_LIFETIME_MS = 3650 * DAY;
16
+ export const SERVER_LIFETIME_MS = 90 * DAY;
17
+ export const NODE_LIFETIME_MS = 90 * DAY;
18
+ export const OPERATOR_MAX_LIFETIME_MS = 365 * DAY;
19
+ export const ENROLL_DEFAULT_TTL_MS = 60 * MINUTE;
20
+ const IDENTITY_URI = /^tug:(node|operator|enroll):([A-Za-z0-9_.-]+)$/;
21
+ export const identityUri = (klass, id) => `tug:${klass}:${id}`;
22
+ export function parseIdentityUri(uri) {
23
+ const m = IDENTITY_URI.exec(uri);
24
+ return m ? { klass: m[1], id: m[2] } : null;
25
+ }
26
+ export const encodeCredential = (credential) => Buffer.from(JSON.stringify(credential)).toString('base64url');
27
+ /** Validates the shape and the HTTPS origin of an untrusted credential document, and that the key matches the certificate. */
28
+ export function parseCredential(value) {
29
+ if (!value || typeof value !== 'object')
30
+ throw new Error('credential must be a JSON object');
31
+ const c = value;
32
+ if (c.v !== 1)
33
+ throw new Error('credential version must be 1');
34
+ for (const field of ['url', 'ca', 'cert', 'key'])
35
+ if (typeof c[field] !== 'string' || !c[field])
36
+ throw new Error(`credential is missing ${field}`);
37
+ const url = new URL(c.url);
38
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
39
+ throw new Error('credential url must be a credential-free https origin');
40
+ if (url.pathname !== '/')
41
+ throw new Error('credential url must be an origin without a path');
42
+ const credential = c;
43
+ assertKeyMatchesCertificate(credential.cert, credential.key);
44
+ return credential;
45
+ }
46
+ export function decodeCredential(text) {
47
+ const trimmed = text.trim();
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(trimmed.startsWith('{') ? trimmed : Buffer.from(trimmed, 'base64url').toString('utf8'));
51
+ }
52
+ catch (e) {
53
+ throw new Error('credential is not valid JSON or base64url-encoded JSON', { cause: e });
54
+ }
55
+ return parseCredential(parsed);
56
+ }
57
+ export function assertKeyMatchesCertificate(certPem, keyPem) {
58
+ const cert = new crypto.X509Certificate(certPem);
59
+ const derived = crypto.createPublicKey(crypto.createPrivateKey(keyPem));
60
+ if (!cert.checkPrivateKey(crypto.createPrivateKey(keyPem)) || !derived)
61
+ throw new Error('certificate does not match the private key');
62
+ }
63
+ /** SHA-256 of the DER SubjectPublicKeyInfo: the identity a principal is authorized by. */
64
+ export const hashSpki = (spki) => crypto.createHash('sha256').update(spki).digest('hex');
65
+ export const certKeyHash = (certPem) => hashSpki(new crypto.X509Certificate(certPem).publicKey.export({ type: 'spki', format: 'der' }));
66
+ export async function writeFileDurable(file, content, mode = 0o600, hooks = {}) {
67
+ const dir = path.dirname(file);
68
+ await fs.mkdir(dir, { recursive: true });
69
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
70
+ const handle = await fs.open(tmp, 'w', mode);
71
+ try {
72
+ await handle.writeFile(content);
73
+ await handle.sync();
74
+ }
75
+ finally {
76
+ await handle.close();
77
+ }
78
+ try {
79
+ hooks.afterFlush?.();
80
+ await fs.rename(tmp, file);
81
+ }
82
+ catch (e) {
83
+ // A crash leaves the temporary file behind; a failed write must not.
84
+ await fs.rm(tmp, { force: true });
85
+ throw e;
86
+ }
87
+ hooks.afterRename?.();
88
+ const dirHandle = await fs.open(dir, 'r').catch(() => null);
89
+ if (dirHandle) {
90
+ await dirHandle.sync().catch(() => { });
91
+ await dirHandle.close();
92
+ }
93
+ }
94
+ export async function generateKeyPair() {
95
+ const keys = (await crypto.webcrypto.subtle.generateKey(KEY_ALG, true, [
96
+ 'sign',
97
+ 'verify',
98
+ ]));
99
+ const pkcs8 = Buffer.from(await crypto.webcrypto.subtle.exportKey('pkcs8', keys.privateKey));
100
+ return {
101
+ privateKeyPem: toPem(pkcs8, 'PRIVATE KEY'),
102
+ publicKey: keys.publicKey,
103
+ privateKey: keys.privateKey,
104
+ };
105
+ }
106
+ export async function importPrivateKey(pem) {
107
+ const privateKey = await crypto.webcrypto.subtle.importKey('pkcs8', pemToDer(pem), KEY_ALG, true, ['sign']);
108
+ const jwk = await crypto.webcrypto.subtle.exportKey('jwk', privateKey);
109
+ const publicKey = await crypto.webcrypto.subtle.importKey('jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, KEY_ALG, true, ['verify']);
110
+ return { privateKey, publicKey };
111
+ }
112
+ const toPem = (der, label) => `-----BEGIN ${label}-----\n${der
113
+ .toString('base64')
114
+ .replace(/(.{64})/g, '$1\n')
115
+ .trimEnd()}\n-----END ${label}-----\n`;
116
+ function pemToDer(pem) {
117
+ return Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64');
118
+ }
119
+ /** A PEM CSR for the key; the requester's subject is irrelevant because control assigns identities. */
120
+ export async function createCsr(privateKeyPem) {
121
+ const keys = await importPrivateKey(privateKeyPem);
122
+ const csr = await x509.Pkcs10CertificateRequestGenerator.create({
123
+ name: 'CN=tug',
124
+ keys,
125
+ signingAlgorithm: SIGNING,
126
+ });
127
+ return csr.toString('pem');
128
+ }
129
+ /** Parses a CSR, verifies its signature and requires a P-256 key. Returns the key hash and public key. */
130
+ export async function verifyCsr(csrPem) {
131
+ if (typeof csrPem !== 'string' || csrPem.length > 8192)
132
+ throw new Error('csr must be a PEM string');
133
+ let csr;
134
+ try {
135
+ csr = new x509.Pkcs10CertificateRequest(csrPem);
136
+ }
137
+ catch (e) {
138
+ throw new Error('csr is not a valid PKCS#10 request', { cause: e });
139
+ }
140
+ if (!(await csr.verify()))
141
+ throw new Error('csr signature is invalid');
142
+ const alg = csr.publicKey.algorithm;
143
+ if (alg.name !== 'ECDSA' || alg.namedCurve !== 'P-256')
144
+ throw new Error('csr key must be ECDSA P-256');
145
+ return {
146
+ keyHash: hashSpki(new Uint8Array(csr.publicKey.rawData)),
147
+ publicKey: await csr.publicKey.export(KEY_ALG, ['verify']),
148
+ };
149
+ }
150
+ const serial = () => `0${crypto.randomBytes(15).toString('hex')}`;
151
+ /** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
152
+ export function serverNames(publicUrl, extras = []) {
153
+ const names = { dns: [], ip: [] };
154
+ const host = new URL(publicUrl).hostname.replace(/^\[|\]$/g, '');
155
+ for (const entry of [host, ...extras.map((e) => e.trim()).filter(Boolean)]) {
156
+ const bucket = net.isIP(entry) ? names.ip : names.dns;
157
+ if (!bucket.includes(entry))
158
+ bucket.push(entry);
159
+ }
160
+ return names;
161
+ }
162
+ export const namesKey = (names) => JSON.stringify([[...names.dns].sort(), [...names.ip].sort()]);
163
+ export class Pki {
164
+ caPem;
165
+ caCert;
166
+ caKey;
167
+ constructor(caPem, caCert, caKey) {
168
+ this.caPem = caPem;
169
+ this.caCert = caCert;
170
+ this.caKey = caKey;
171
+ }
172
+ get caNotAfter() {
173
+ return this.caCert.notAfter;
174
+ }
175
+ get caFingerprint() {
176
+ return hashSpki(new Uint8Array(this.caCert.publicKey.rawData));
177
+ }
178
+ /** Loads the CA from `dir`, creating it only when `create` is set and no CA files exist. Corrupt or partial material fails closed. */
179
+ static async load(dir, create) {
180
+ const keyFile = path.join(dir, 'ca.key');
181
+ const certFile = path.join(dir, 'ca.crt');
182
+ const [key, cert] = await Promise.all([
183
+ fs.readFile(keyFile, 'utf8').catch(() => null),
184
+ fs.readFile(certFile, 'utf8').catch(() => null),
185
+ ]);
186
+ if (key === null && cert === null) {
187
+ if (!create)
188
+ throw new Error(`control has existing state but its CA is missing from ${dir}; restore pki/ from a backup`);
189
+ return Pki.create(dir);
190
+ }
191
+ if (key === null || cert === null)
192
+ throw new Error(`incomplete CA material in ${dir}`);
193
+ try {
194
+ const caCert = new x509.X509Certificate(cert);
195
+ const keys = await importPrivateKey(key);
196
+ const same = hashSpki(new Uint8Array(caCert.publicKey.rawData)) ===
197
+ hashSpki(Buffer.from(await crypto.webcrypto.subtle.exportKey('spki', keys.publicKey)));
198
+ if (!same)
199
+ throw new Error('CA key does not match the CA certificate');
200
+ return new Pki(cert, caCert, keys.privateKey);
201
+ }
202
+ catch (e) {
203
+ throw new Error(`corrupt CA material in ${dir}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
204
+ }
205
+ }
206
+ static async create(dir) {
207
+ const keys = (await crypto.webcrypto.subtle.generateKey(KEY_ALG, true, [
208
+ 'sign',
209
+ 'verify',
210
+ ]));
211
+ const now = Date.now();
212
+ const cert = await x509.X509CertificateGenerator.createSelfSigned({
213
+ serialNumber: serial(),
214
+ name: 'CN=Tug CA',
215
+ notBefore: new Date(now - BACKDATE_MS),
216
+ notAfter: new Date(now + CA_LIFETIME_MS),
217
+ signingAlgorithm: SIGNING,
218
+ keys,
219
+ extensions: [
220
+ new x509.BasicConstraintsExtension(true, 0, true),
221
+ new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
222
+ await x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
223
+ ],
224
+ });
225
+ const pkcs8 = Buffer.from(await crypto.webcrypto.subtle.exportKey('pkcs8', keys.privateKey));
226
+ await writeFileDurable(path.join(dir, 'ca.key'), toPem(pkcs8, 'PRIVATE KEY'), 0o600);
227
+ await writeFileDurable(path.join(dir, 'ca.crt'), cert.toString('pem'), 0o644);
228
+ return Pki.load(dir, false);
229
+ }
230
+ /** Signs a leaf with an assigned profile. Requester-supplied subjects, SANs, validity and extensions are never used. */
231
+ async signLeaf(options) {
232
+ const now = Date.now();
233
+ const notAfter = new Date(Math.min(now + options.lifetimeMs, this.caCert.notAfter.getTime()));
234
+ const san = [];
235
+ if (options.uri)
236
+ san.push({ type: 'url', value: options.uri });
237
+ for (const dns of options.names?.dns ?? [])
238
+ san.push({ type: 'dns', value: dns });
239
+ for (const ip of options.names?.ip ?? [])
240
+ san.push({ type: 'ip', value: ip });
241
+ const cert = await x509.X509CertificateGenerator.create({
242
+ serialNumber: serial(),
243
+ subject: options.kind === 'server' ? 'CN=tug-control' : 'CN=tug-client',
244
+ issuer: this.caCert.subject,
245
+ notBefore: new Date(now - BACKDATE_MS),
246
+ notAfter,
247
+ signingAlgorithm: SIGNING,
248
+ publicKey: options.publicKey,
249
+ signingKey: this.caKey,
250
+ extensions: [
251
+ new x509.BasicConstraintsExtension(false, undefined, true),
252
+ new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
253
+ new x509.ExtendedKeyUsageExtension([
254
+ options.kind === 'server'
255
+ ? x509.ExtendedKeyUsage.serverAuth
256
+ : x509.ExtendedKeyUsage.clientAuth,
257
+ ], true),
258
+ new x509.SubjectAlternativeNameExtension(san, false),
259
+ await x509.AuthorityKeyIdentifierExtension.create(this.caCert, false),
260
+ ],
261
+ });
262
+ return { cert: cert.toString('pem'), notAfter };
263
+ }
264
+ /** Verifies a CSR and signs a client certificate naming `uri` for its key. */
265
+ async signClientCsr(csrPem, uri, lifetimeMs) {
266
+ const { keyHash, publicKey } = await verifyCsr(csrPem);
267
+ const { cert, notAfter } = await this.signLeaf({ publicKey, kind: 'client', uri, lifetimeMs });
268
+ return { cert, keyHash, notAfter };
269
+ }
270
+ /** A client keypair and certificate for `uri`, for credentials issued on control (operators, enrollment keys, root). */
271
+ async issueClient(uri, lifetimeMs, url) {
272
+ const keys = await generateKeyPair();
273
+ const { cert, notAfter } = await this.signLeaf({
274
+ publicKey: keys.publicKey,
275
+ kind: 'client',
276
+ uri,
277
+ lifetimeMs,
278
+ });
279
+ const credential = { v: 1, url, ca: this.caPem, cert, key: keys.privateKeyPem };
280
+ return { credential, keyHash: certKeyHash(cert), notAfter };
281
+ }
282
+ /** The server certificate and key for the given names. */
283
+ async issueServer(names) {
284
+ const keys = await generateKeyPair();
285
+ const { cert, notAfter } = await this.signLeaf({
286
+ publicKey: keys.publicKey,
287
+ kind: 'server',
288
+ names,
289
+ lifetimeMs: SERVER_LIFETIME_MS,
290
+ });
291
+ return { cert, key: keys.privateKeyPem, notAfter };
292
+ }
293
+ }
294
+ /** Checks that a certificate chains to `ca`, names `uri` and is bound to the key. Used by the agent before installing. */
295
+ export function validateInstalledCertificate(options) {
296
+ const cert = new crypto.X509Certificate(options.cert);
297
+ if (!cert.verify(new crypto.X509Certificate(options.ca).publicKey))
298
+ throw new Error('certificate is not signed by the supplied CA');
299
+ if (!cert.checkPrivateKey(crypto.createPrivateKey(options.keyPem)))
300
+ throw new Error('certificate does not match the pending private key');
301
+ if (!(cert.subjectAltName ?? '').split(/,\s*/).includes(`URI:${options.uri}`))
302
+ throw new Error(`certificate does not name ${options.uri}`);
303
+ }
304
+ /** TLS client options pinned to the credential's CA. Verification is always against the public host, even through a tunnel. */
305
+ export function clientTlsOptions(credential) {
306
+ const publicHost = new URL(credential.url).hostname.replace(/^\[|\]$/g, '');
307
+ return {
308
+ ca: credential.ca,
309
+ cert: credential.cert,
310
+ key: credential.key,
311
+ minVersion: 'TLSv1.3',
312
+ ...(net.isIP(publicHost) ? {} : { servername: publicHost }),
313
+ checkServerIdentity: (_host, cert) => tls.checkServerIdentity(publicHost, cert),
314
+ };
315
+ }
316
+ /** The peer identity presented on a TLS socket, or null when none can be established. */
317
+ export function peerIdentity(socket) {
318
+ if (!socket.authorized)
319
+ return null;
320
+ const peer = socket.getPeerCertificate(false);
321
+ if (!peer || !peer.raw)
322
+ return null;
323
+ const x = new crypto.X509Certificate(peer.raw);
324
+ const uri = (x.subjectAltName ?? '')
325
+ .split(/,\s*/)
326
+ .map((s) => s.trim())
327
+ .find((s) => s.startsWith('URI:'))
328
+ ?.slice(4);
329
+ if (!uri)
330
+ return null;
331
+ const notAfter = Date.parse(x.validTo);
332
+ if (!(notAfter > Date.now()) || !(Date.parse(x.validFrom) <= Date.now() + 60_000))
333
+ return null;
334
+ return {
335
+ uri,
336
+ keyHash: hashSpki(x.publicKey.export({ type: 'spki', format: 'der' })),
337
+ notAfter,
338
+ };
339
+ }
@@ -1,7 +1,6 @@
1
1
  export interface Placement {
2
2
  nodes?: string[];
3
3
  labels?: string[];
4
- global?: boolean;
5
4
  }
6
5
  export interface HealthCheck {
7
6
  type: 'docker' | 'tcp' | 'http';
@@ -44,6 +43,8 @@ export interface BackupSpec {
44
43
  jitter?: number;
45
44
  /** Hard limit for one run, such as `30m` or `2h`. */
46
45
  timeout?: string | number;
46
+ /** Memory limit of the restic helper container, such as `256m` (default `512m`). */
47
+ memory?: string;
47
48
  /** Services only: run in the service's container; its standard output is the backup. A string runs through `sh -c`. */
48
49
  command?: string | string[];
49
50
  /** Services only: run in the service container to restore; the stored file arrives on its standard input. */
@@ -523,6 +524,8 @@ export interface ProjectStatus {
523
524
  }
524
525
  export interface StatusReport {
525
526
  node: string;
527
+ /** The agent clock when it sent the report (ISO 8601); control derives clock skew from it. */
528
+ sentAt?: string;
526
529
  projects: ProjectStatus[];
527
530
  commandResults: CommandResult[];
528
531
  ingress?: IngressStatus;
@@ -556,38 +559,66 @@ export interface NodeInfo {
556
559
  created_at: string;
557
560
  updated_at: string;
558
561
  }
559
- /** Response of `POST /api/v1/nodes/register`. */
560
- export interface RegisterResponse {
562
+ /** Body of `POST /api/v1/nodes/:name/identity`, posted by an admin with a CSR the node generated for its own key. */
563
+ export interface IdentityRequest {
564
+ csr: string;
565
+ /** `create` makes a new node (idempotent for the identical key); `replace` installs a new key on an existing node. */
566
+ mode: 'create' | 'replace';
567
+ address?: string;
568
+ publicAddress?: string;
569
+ labels?: string[];
570
+ }
571
+ /** Response of `POST /api/v1/nodes/:name/identity` and `POST /agent/v1/enroll`. */
572
+ export interface IdentityResponse {
561
573
  node: NodeInfo;
562
- agentToken: string;
574
+ certificate: string;
575
+ /** PEM CA certificate every client pins. */
576
+ ca: string;
577
+ /** Credential-free HTTPS origin the certificate is verified against. */
578
+ url: string;
563
579
  }
564
- /** Body of `POST /agent/v1/enroll`. */
580
+ /** Body of `POST /agent/v1/enroll`, authenticated by the bootstrap certificate. Name and labels come from its row. */
565
581
  export interface EnrollRequest {
566
- enrollToken?: string;
567
- name?: string;
582
+ csr: string;
568
583
  address?: string;
569
584
  publicAddress?: string;
570
585
  /** Base64 WireGuard public key; the private key never leaves the node. */
571
586
  wgPublicKey?: string;
572
- /** Node labels (tags) used for label-based placement. */
573
- labels?: string[];
574
587
  }
575
588
  /** Response of `POST /agent/v1/enroll`. */
576
- export type EnrollResponse = RegisterResponse;
577
- /** Response of `POST /api/v1/nodes/enroll-token`. */
578
- export interface EnrollTokenResponse {
579
- enrollToken: string;
580
- name: string | null;
581
- usesRemaining: number;
589
+ export type EnrollResponse = IdentityResponse;
590
+ /** Body of `POST /agent/v1/renew`: a CSR for the node's existing key. */
591
+ export interface RenewRequest {
592
+ csr: string;
593
+ }
594
+ export interface RenewResponse {
595
+ certificate: string;
596
+ }
597
+ /** Response of `POST /api/v1/nodes/enrollment-credential`: the bootstrap credential, usable once for `POST /agent/v1/enroll`. */
598
+ export interface EnrollmentCredentialResponse {
599
+ credential: {
600
+ v: 1;
601
+ url: string;
602
+ ca: string;
603
+ cert: string;
604
+ key: string;
605
+ };
606
+ name: string;
607
+ labels: string[];
582
608
  expiresAt: string;
583
609
  }
584
610
  /** The base64url-encoded JSON payload of TUG_JOIN_KEY. */
585
611
  export interface JoinKey {
586
- v: number;
587
- controlUrl?: string;
588
- enrollToken: string;
589
- /** PEM CA certificate to trust for a private/self-signed control plane. */
590
- ca?: string;
591
- /** Node labels to register at enrollment. */
592
- labels?: string[];
612
+ v: 2;
613
+ /** The bootstrap credential. */
614
+ credential: {
615
+ v: 1;
616
+ url: string;
617
+ ca: string;
618
+ cert: string;
619
+ key: string;
620
+ };
621
+ name: string;
622
+ /** Connect-only target (for example a tunnel); verification always uses the credential's public url. */
623
+ connectUrl?: string;
593
624
  }
@@ -0,0 +1,11 @@
1
+ import type { StatusReport } from './protocol.ts';
2
+ export declare class StatusReportError extends Error {
3
+ }
4
+ /** A TCP port: an integer in 1..65535, never a string, so it cannot carry configuration syntax. */
5
+ export declare function isPort(value: unknown): value is number;
6
+ /**
7
+ * Validates and normalizes a node's `POST /agent/v1/status` body before anything is stored. Reported values
8
+ * flow into other nodes' work responses and the shared ingress configuration, so unknown fields are dropped
9
+ * and every kept field is checked against its protocol type. Throws `StatusReportError` for a malformed report.
10
+ */
11
+ export declare function parseStatusReport(body: unknown): StatusReport;