@wpelevator/tug 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -15
- package/dist/agent.d.ts +5 -7
- package/dist/agent.js +44 -66
- package/dist/backup-job.js +17 -5
- package/dist/backup-restore.js +4 -4
- package/dist/backup-scheduler.js +2 -0
- package/dist/build.d.ts +6 -0
- package/dist/build.js +27 -7
- package/dist/cli-build.d.ts +2 -0
- package/dist/cli-build.js +22 -2
- package/dist/cli-config.d.ts +4 -1
- package/dist/cli-config.js +28 -23
- package/dist/cli-install.d.ts +7 -4
- package/dist/cli-install.js +21 -23
- package/dist/cli-status.d.ts +29 -0
- package/dist/cli-status.js +61 -0
- package/dist/cli.js +60 -66
- package/dist/compose.d.ts +2 -0
- package/dist/compose.js +9 -0
- package/dist/container-spec.d.ts +5 -0
- package/dist/container-spec.js +11 -2
- package/dist/control-credential.d.ts +2 -0
- package/dist/control-credential.js +4 -4
- package/dist/control.d.ts +1 -1
- package/dist/control.js +109 -141
- package/dist/credentials.js +1 -1
- package/dist/db.d.ts +0 -10
- package/dist/db.js +1 -2
- package/dist/ingress.d.ts +1 -2
- package/dist/ingress.js +1 -1
- package/dist/live.d.ts +2 -0
- package/dist/live.js +9 -1
- package/dist/main.js +1 -1
- package/dist/node-address.d.ts +2 -0
- package/dist/node-address.js +6 -0
- package/dist/pki.d.ts +14 -3
- package/dist/pki.js +24 -7
- package/dist/protocol.d.ts +2 -34
- package/dist/reconciler.js +40 -7
- package/dist/runners.d.ts +0 -6
- package/dist/runners.js +1 -6
- package/dist/templates/control-compose.yml +1 -2
- package/package.json +1 -1
- package/dist/mesh.d.ts +0 -70
- package/dist/mesh.js +0 -202
package/dist/ingress.d.ts
CHANGED
|
@@ -4,8 +4,7 @@ export declare const CADDY_CONTAINER = "tug-caddy";
|
|
|
4
4
|
export declare const DEFAULT_CADDY_IMAGE = "caddy:2-alpine";
|
|
5
5
|
export interface NodeView {
|
|
6
6
|
name: string;
|
|
7
|
-
/**
|
|
8
|
-
overlayIp: string | null;
|
|
7
|
+
/** Operator-provided address other nodes use to reach this node's published ports. */
|
|
9
8
|
address: string | null;
|
|
10
9
|
lastSeenAt: string | null;
|
|
11
10
|
}
|
package/dist/ingress.js
CHANGED
|
@@ -26,7 +26,7 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
|
|
|
26
26
|
const endpoints = [];
|
|
27
27
|
for (const nodeName of spec.placement?.nodes || []) {
|
|
28
28
|
const node = byName.get(nodeName);
|
|
29
|
-
const address = node
|
|
29
|
+
const address = node?.address;
|
|
30
30
|
if (!node || !address || !node.lastSeenAt || node.lastSeenAt < staleBefore)
|
|
31
31
|
continue;
|
|
32
32
|
const serviceState = reported.get(`${nodeName}\0${state.project}`)?.serviceStates?.[service];
|
package/dist/live.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface LiveEngineOptions {
|
|
|
62
62
|
queryTimeoutMs?: number;
|
|
63
63
|
}
|
|
64
64
|
/** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
65
|
+
/** Keeps environment variable names but not values, so routine `inspect` output does not leak secrets by accident. */
|
|
66
|
+
export declare function redactInspect(inspected: Record<string, unknown>): Record<string, unknown>;
|
|
65
67
|
export declare class LiveOperationEngine {
|
|
66
68
|
private readonly options;
|
|
67
69
|
private readonly sessions;
|
package/dist/live.js
CHANGED
|
@@ -48,6 +48,14 @@ function validate(value) {
|
|
|
48
48
|
throw new Error('Invalid projects');
|
|
49
49
|
}
|
|
50
50
|
/** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
51
|
+
/** Keeps environment variable names but not values, so routine `inspect` output does not leak secrets by accident. */
|
|
52
|
+
export function redactInspect(inspected) {
|
|
53
|
+
const config = inspected.Config;
|
|
54
|
+
if (!config || !Array.isArray(config.Env))
|
|
55
|
+
return inspected;
|
|
56
|
+
const Env = config.Env.map((entry) => typeof entry === 'string' ? `${entry.split('=', 1)[0]}=<redacted>` : entry);
|
|
57
|
+
return { ...inspected, Config: { ...config, Env } };
|
|
58
|
+
}
|
|
51
59
|
export class LiveOperationEngine {
|
|
52
60
|
options;
|
|
53
61
|
sessions = new Map();
|
|
@@ -155,7 +163,7 @@ export class LiveOperationEngine {
|
|
|
155
163
|
result = lists.flat();
|
|
156
164
|
}
|
|
157
165
|
else
|
|
158
|
-
result = [await this.options.docker.inspectContainer(request.container)];
|
|
166
|
+
result = [redactInspect(await this.options.docker.inspectContainer(request.container))];
|
|
159
167
|
const frame = { v: 1, type: 'live.result', sessionId: id, result };
|
|
160
168
|
if (Buffer.byteLength(JSON.stringify(frame)) > LIVE_MAX_FRAME_BYTES)
|
|
161
169
|
throw new Error('Live result exceeds serialized frame size limit');
|
package/dist/main.js
CHANGED
|
@@ -9,7 +9,7 @@ async function readStdin() {
|
|
|
9
9
|
return Buffer.concat(chunks).toString('utf8');
|
|
10
10
|
}
|
|
11
11
|
const usage = `Usage: tug-runtime <control|agent> [command]
|
|
12
|
-
control run the control plane (needs
|
|
12
|
+
control run the control plane (needs TUG_CONTROL_URL)
|
|
13
13
|
control issue-credential issue an operator credential on the control-plane host (--name, --role, --replace)
|
|
14
14
|
agent run the node agent
|
|
15
15
|
agent identity print the CSR for this node's pending key
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
const HOST_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
3
|
+
/** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
|
|
4
|
+
export const isNodeHost = (value) => typeof value === 'string' &&
|
|
5
|
+
value.length <= 253 &&
|
|
6
|
+
(isIP(value) !== 0 || value.split('.').every((label) => HOST_LABEL.test(label)));
|
package/dist/pki.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface SubjectNames {
|
|
|
59
59
|
ip: string[];
|
|
60
60
|
}
|
|
61
61
|
/** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
|
|
62
|
-
export declare function serverNames(
|
|
62
|
+
export declare function serverNames(controlUrl: string, extras?: string[]): SubjectNames;
|
|
63
63
|
export declare const namesKey: (names: SubjectNames) => string;
|
|
64
64
|
export declare class Pki {
|
|
65
65
|
readonly caPem: string;
|
|
@@ -95,7 +95,9 @@ export declare class Pki {
|
|
|
95
95
|
notAfter: Date;
|
|
96
96
|
}>;
|
|
97
97
|
/** The server certificate and key for the given names. */
|
|
98
|
-
|
|
98
|
+
get controlName(): string;
|
|
99
|
+
/** The server certificate always carries the generated control name, plus the given addresses. */
|
|
100
|
+
issueServer(addresses: SubjectNames): Promise<{
|
|
99
101
|
cert: string;
|
|
100
102
|
key: string;
|
|
101
103
|
notAfter: Date;
|
|
@@ -108,7 +110,16 @@ export declare function validateInstalledCertificate(options: {
|
|
|
108
110
|
keyPem: string;
|
|
109
111
|
uri: string;
|
|
110
112
|
}): void;
|
|
111
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* The generated name control answers to, derived from its CA key: stable for the life of the CA and independent of any
|
|
115
|
+
* address. Control puts it in its server certificate and clients recompute it from the CA they pin, so moving control to
|
|
116
|
+
* another IP or DNS name needs no new credentials.
|
|
117
|
+
*/
|
|
118
|
+
export declare function controlNameFromCa(caPem: string): string;
|
|
119
|
+
/**
|
|
120
|
+
* TLS client options pinned to the credential's CA. The server is verified by its generated control name, never by the
|
|
121
|
+
* address the connection goes to, so an address override (a tunnel, a private IP, a moved host) keeps full verification.
|
|
122
|
+
*/
|
|
112
123
|
export declare function clientTlsOptions(credential: Credential): tls.ConnectionOptions;
|
|
113
124
|
/** The peer identity presented on a TLS socket, or null when none can be established. */
|
|
114
125
|
export declare function peerIdentity(socket: tls.TLSSocket): {
|
package/dist/pki.js
CHANGED
|
@@ -149,9 +149,9 @@ export async function verifyCsr(csrPem) {
|
|
|
149
149
|
}
|
|
150
150
|
const serial = () => `0${crypto.randomBytes(15).toString('hex')}`;
|
|
151
151
|
/** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
|
|
152
|
-
export function serverNames(
|
|
152
|
+
export function serverNames(controlUrl, extras = []) {
|
|
153
153
|
const names = { dns: [], ip: [] };
|
|
154
|
-
const host = new URL(
|
|
154
|
+
const host = new URL(controlUrl).hostname.replace(/^\[|\]$/g, '');
|
|
155
155
|
for (const entry of [host, ...extras.map((e) => e.trim()).filter(Boolean)]) {
|
|
156
156
|
const bucket = net.isIP(entry) ? names.ip : names.dns;
|
|
157
157
|
if (!bucket.includes(entry))
|
|
@@ -280,7 +280,12 @@ export class Pki {
|
|
|
280
280
|
return { credential, keyHash: certKeyHash(cert), notAfter };
|
|
281
281
|
}
|
|
282
282
|
/** The server certificate and key for the given names. */
|
|
283
|
-
|
|
283
|
+
get controlName() {
|
|
284
|
+
return controlNameFromCa(this.caPem);
|
|
285
|
+
}
|
|
286
|
+
/** The server certificate always carries the generated control name, plus the given addresses. */
|
|
287
|
+
async issueServer(addresses) {
|
|
288
|
+
const names = { dns: [this.controlName, ...addresses.dns], ip: addresses.ip };
|
|
284
289
|
const keys = await generateKeyPair();
|
|
285
290
|
const { cert, notAfter } = await this.signLeaf({
|
|
286
291
|
publicKey: keys.publicKey,
|
|
@@ -301,16 +306,28 @@ export function validateInstalledCertificate(options) {
|
|
|
301
306
|
if (!(cert.subjectAltName ?? '').split(/,\s*/).includes(`URI:${options.uri}`))
|
|
302
307
|
throw new Error(`certificate does not name ${options.uri}`);
|
|
303
308
|
}
|
|
304
|
-
/**
|
|
309
|
+
/**
|
|
310
|
+
* The generated name control answers to, derived from its CA key: stable for the life of the CA and independent of any
|
|
311
|
+
* address. Control puts it in its server certificate and clients recompute it from the CA they pin, so moving control to
|
|
312
|
+
* another IP or DNS name needs no new credentials.
|
|
313
|
+
*/
|
|
314
|
+
export function controlNameFromCa(caPem) {
|
|
315
|
+
const spki = new crypto.X509Certificate(caPem).publicKey.export({ type: 'spki', format: 'der' });
|
|
316
|
+
return `control-${hashSpki(spki).slice(0, 12)}.tug`;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* TLS client options pinned to the credential's CA. The server is verified by its generated control name, never by the
|
|
320
|
+
* address the connection goes to, so an address override (a tunnel, a private IP, a moved host) keeps full verification.
|
|
321
|
+
*/
|
|
305
322
|
export function clientTlsOptions(credential) {
|
|
306
|
-
const
|
|
323
|
+
const name = controlNameFromCa(credential.ca);
|
|
307
324
|
return {
|
|
308
325
|
ca: credential.ca,
|
|
309
326
|
cert: credential.cert,
|
|
310
327
|
key: credential.key,
|
|
311
328
|
minVersion: 'TLSv1.3',
|
|
312
|
-
|
|
313
|
-
checkServerIdentity: (_host, cert) => tls.checkServerIdentity(
|
|
329
|
+
servername: name,
|
|
330
|
+
checkServerIdentity: (_host, cert) => tls.checkServerIdentity(name, cert),
|
|
314
331
|
};
|
|
315
332
|
}
|
|
316
333
|
/** The peer identity presented on a TLS socket, or null when none can be established. */
|
package/dist/protocol.d.ts
CHANGED
|
@@ -165,7 +165,7 @@ export declare const ACTIVE_DEPLOYMENT_STATES: readonly ["uploading", "building"
|
|
|
165
165
|
/** Deployment states a CLI wait loop stops on. */
|
|
166
166
|
export declare const TERMINAL_DEPLOYMENT_STATES: readonly ["succeeded", "failed", "build_failed", "cancelled"];
|
|
167
167
|
export type DeploymentState = (typeof ACTIVE_DEPLOYMENT_STATES)[number] | (typeof TERMINAL_DEPLOYMENT_STATES)[number];
|
|
168
|
-
export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | '
|
|
168
|
+
export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'backup.check' | 'backup.run' | 'backup.restore' | 'backup.list';
|
|
169
169
|
/** Commands whose helper containers need the backup destination's credentials. */
|
|
170
170
|
export declare const BACKUP_COMMAND_TYPES: readonly CommandType[];
|
|
171
171
|
/** `GET /agent/v1/backup/credentials`: environment for the restic helper of one running backup command. */
|
|
@@ -273,8 +273,6 @@ export type CommandResultPayload = {
|
|
|
273
273
|
project: string;
|
|
274
274
|
volume: string;
|
|
275
275
|
deleted: true;
|
|
276
|
-
} | {
|
|
277
|
-
publicKey: string;
|
|
278
276
|
} | BackupJobResult | {
|
|
279
277
|
error: string;
|
|
280
278
|
};
|
|
@@ -423,8 +421,6 @@ export interface WorkResponse {
|
|
|
423
421
|
desiredStates: DesiredState[];
|
|
424
422
|
commands: Command[];
|
|
425
423
|
retainedImages?: string[];
|
|
426
|
-
/** WireGuard mesh config for this node; present only when the mesh is enabled on control. */
|
|
427
|
-
network?: NetworkConfig;
|
|
428
424
|
/** Healthy cross-node endpoints per project and service; present only when service DNS is enabled. */
|
|
429
425
|
serviceEndpoints?: ServiceEndpoints;
|
|
430
426
|
/** Edge proxy config; present only for nodes marked as ingress nodes. */
|
|
@@ -439,33 +435,11 @@ export interface WorkResponse {
|
|
|
439
435
|
* explicit host IP (including `0.0.0.0`) are never changed.
|
|
440
436
|
*/
|
|
441
437
|
export interface PortBinding {
|
|
442
|
-
/** The node's
|
|
438
|
+
/** The node's `address` (an IP or a hostname the agent resolves); null binds to `127.0.0.1`. */
|
|
443
439
|
address: string | null;
|
|
444
440
|
/** Per project, services whose ingress port only the managed Caddy on this node dials; that port binds to `127.0.0.1`. */
|
|
445
441
|
loopback: Record<string, string[]>;
|
|
446
442
|
}
|
|
447
|
-
export interface WgPeer {
|
|
448
|
-
node: string;
|
|
449
|
-
publicKey: string;
|
|
450
|
-
/** `host:port` the peer can be dialed on; absent for nodes behind NAT (they dial out instead). */
|
|
451
|
-
endpoint?: string;
|
|
452
|
-
allowedIps: string[];
|
|
453
|
-
persistentKeepalive: number;
|
|
454
|
-
}
|
|
455
|
-
/** The `network` block of `GET /agent/v1/work`: everything an agent needs to reconcile `wg0`. */
|
|
456
|
-
export interface NetworkConfig {
|
|
457
|
-
interface: string;
|
|
458
|
-
/** This node's overlay address, e.g. `10.210.0.2`. */
|
|
459
|
-
overlayIp: string;
|
|
460
|
-
/** Mesh CIDR, e.g. `10.210.0.0/16`; `overlayIp` is assigned to `wg0` with this prefix length. */
|
|
461
|
-
cidr: string;
|
|
462
|
-
listenPort: number;
|
|
463
|
-
peers: WgPeer[];
|
|
464
|
-
}
|
|
465
|
-
/** Body of `POST /agent/v1/wireguard/key` (also accepted as `wgPublicKey` on register and enroll). */
|
|
466
|
-
export interface WgKeyRequest {
|
|
467
|
-
publicKey: string;
|
|
468
|
-
}
|
|
469
443
|
export interface ServiceEndpoint {
|
|
470
444
|
node: string;
|
|
471
445
|
address: string;
|
|
@@ -552,8 +526,6 @@ export interface NodeInfo {
|
|
|
552
526
|
id: string;
|
|
553
527
|
name: string;
|
|
554
528
|
address: string | null;
|
|
555
|
-
public_address: string | null;
|
|
556
|
-
overlay_ip?: string | null;
|
|
557
529
|
labels?: string[];
|
|
558
530
|
status: string;
|
|
559
531
|
created_at: string;
|
|
@@ -565,7 +537,6 @@ export interface IdentityRequest {
|
|
|
565
537
|
/** `create` makes a new node (idempotent for the identical key); `replace` installs a new key on an existing node. */
|
|
566
538
|
mode: 'create' | 'replace';
|
|
567
539
|
address?: string;
|
|
568
|
-
publicAddress?: string;
|
|
569
540
|
labels?: string[];
|
|
570
541
|
}
|
|
571
542
|
/** Response of `POST /api/v1/nodes/:name/identity` and `POST /agent/v1/enroll`. */
|
|
@@ -581,9 +552,6 @@ export interface IdentityResponse {
|
|
|
581
552
|
export interface EnrollRequest {
|
|
582
553
|
csr: string;
|
|
583
554
|
address?: string;
|
|
584
|
-
publicAddress?: string;
|
|
585
|
-
/** Base64 WireGuard public key; the private key never leaves the node. */
|
|
586
|
-
wgPublicKey?: string;
|
|
587
555
|
}
|
|
588
556
|
/** Response of `POST /agent/v1/enroll`. */
|
|
589
557
|
export type EnrollResponse = IdentityResponse;
|
package/dist/reconciler.js
CHANGED
|
@@ -41,18 +41,51 @@ async function removeByName(docker, name) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Creates the project network and its volumes; volumes are never removed by reconciliation. */
|
|
43
43
|
export async function ensureProjectResources(docker, project, plan) {
|
|
44
|
-
|
|
44
|
+
// Check all volumes before creating resources or replacing containers. An older owned volume must
|
|
45
|
+
// never be silently replaced by a new, empty volume after the naming scheme changes.
|
|
46
|
+
for (const volume of plan.volumes) {
|
|
47
|
+
if (volume.external || (await docker.inspectVolume(volume.name)))
|
|
48
|
+
continue;
|
|
49
|
+
const volumeKey = volume.labels['tug.volume'];
|
|
50
|
+
const legacyName = `tug_${project}_${volumeKey}`;
|
|
51
|
+
const legacy = await docker.inspectVolume(legacyName);
|
|
52
|
+
if (legacy?.Labels?.[LABEL_PROJECT] === project && legacy.Labels?.['tug.volume'] === volumeKey)
|
|
53
|
+
throw new Error(`managed volume ${legacyName} uses an earlier Tug name; refusing to create empty volume ${volume.name}. Recover or copy its data before deploying`);
|
|
54
|
+
}
|
|
55
|
+
let network = await docker.inspectNetwork(plan.network);
|
|
56
|
+
if (!network) {
|
|
45
57
|
await docker.createNetwork(plan.network, { [LABEL_PROJECT]: project });
|
|
58
|
+
network = await docker.inspectNetwork(plan.network);
|
|
59
|
+
}
|
|
60
|
+
if (!network)
|
|
61
|
+
throw new Error(`network ${plan.network} could not be inspected after creation`);
|
|
62
|
+
if (network && network.Labels?.[LABEL_PROJECT] !== project)
|
|
63
|
+
throw new Error(`network ${plan.network} is not owned by project ${project}`);
|
|
46
64
|
for (const volume of plan.volumes) {
|
|
47
|
-
|
|
65
|
+
let existing = await docker.inspectVolume(volume.name);
|
|
66
|
+
if (!existing && !volume.external) {
|
|
67
|
+
await docker.createVolume(volume.name, {
|
|
68
|
+
driver: volume.driver,
|
|
69
|
+
opts: volume.opts,
|
|
70
|
+
labels: volume.labels,
|
|
71
|
+
});
|
|
72
|
+
existing = await docker.inspectVolume(volume.name);
|
|
73
|
+
}
|
|
74
|
+
if (existing) {
|
|
75
|
+
if (!volume.external) {
|
|
76
|
+
const sameOptions = (a, b) => Object.keys(a).length === Object.keys(b).length &&
|
|
77
|
+
Object.entries(a).every(([key, value]) => b[key] === value);
|
|
78
|
+
if (existing.Labels?.[LABEL_PROJECT] !== project ||
|
|
79
|
+
existing.Labels?.['tug.volume'] !== volume.labels['tug.volume'] ||
|
|
80
|
+
existing.Driver !== volume.driver ||
|
|
81
|
+
!sameOptions(existing.Options || {}, volume.opts))
|
|
82
|
+
throw new Error(`volume ${volume.name} does not match the project's owned volume definition`);
|
|
83
|
+
}
|
|
48
84
|
continue;
|
|
85
|
+
}
|
|
49
86
|
if (volume.external)
|
|
50
87
|
throw new Error(`external volume ${volume.name} does not exist on this node`);
|
|
51
|
-
|
|
52
|
-
driver: volume.driver,
|
|
53
|
-
opts: volume.opts,
|
|
54
|
-
labels: volume.labels,
|
|
55
|
-
});
|
|
88
|
+
throw new Error(`volume ${volume.name} could not be inspected after creation`);
|
|
56
89
|
}
|
|
57
90
|
}
|
|
58
91
|
/** Makes sure a service's container exists for `spec`, running, replacing any container with another hash. */
|
package/dist/runners.d.ts
CHANGED
|
@@ -1,7 +1 @@
|
|
|
1
|
-
import type { RunResult } from './build.ts';
|
|
2
1
|
export type HttpClient = typeof fetch;
|
|
3
|
-
/** Runs a host binary (`wg`, `ip`); rejects on a non-zero exit. */
|
|
4
|
-
export type HostRunner = (cmd: string, args: string[], opts?: {
|
|
5
|
-
cwd?: string;
|
|
6
|
-
}) => Promise<RunResult>;
|
|
7
|
-
export declare function createHostRunner(): HostRunner;
|
package/dist/runners.js
CHANGED
|
@@ -1,6 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// without a real Docker daemon or control plane. Docker itself is the `DockerApi` in `docker-api.ts`.
|
|
3
|
-
import { run } from "./build.js";
|
|
4
|
-
export function createHostRunner() {
|
|
5
|
-
return (cmd, args, opts) => run(cmd, args, opts);
|
|
6
|
-
}
|
|
1
|
+
export {};
|
package/package.json
CHANGED
package/dist/mesh.d.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { NetworkConfig, WgPeer } from './protocol.ts';
|
|
2
|
-
import type { HostRunner } from './runners.ts';
|
|
3
|
-
/** Default mesh CIDR. Chosen to avoid the integration subnet 10.89.240.0/24, Docker's 172.16/12 and common home/VPN ranges. */
|
|
4
|
-
export declare const DEFAULT_MESH_CIDR = "10.210.0.0/16";
|
|
5
|
-
export declare const DEFAULT_WG_PORT = 51820;
|
|
6
|
-
export declare const WG_INTERFACE = "wg0";
|
|
7
|
-
export declare const WG_KEEPALIVE = 25;
|
|
8
|
-
export declare const WG_KEY_RE: RegExp;
|
|
9
|
-
export declare const isWgKey: (value: unknown) => value is string;
|
|
10
|
-
/** Public key for a base64 private key (X25519, as `wg pubkey` computes it). */
|
|
11
|
-
export declare function publicKeyFromPrivate(privateKey: string): string;
|
|
12
|
-
export declare function generateWgKeyPair(): {
|
|
13
|
-
privateKey: string;
|
|
14
|
-
publicKey: string;
|
|
15
|
-
};
|
|
16
|
-
/** Loads the node's keypair from the state dir, generating it on first use. The private key never leaves this directory. */
|
|
17
|
-
export declare function ensureWgKeys(stateDir: string): Promise<{
|
|
18
|
-
privateKey: string;
|
|
19
|
-
publicKey: string;
|
|
20
|
-
}>;
|
|
21
|
-
export declare function rotateWgKeys(stateDir: string): Promise<{
|
|
22
|
-
privateKey: string;
|
|
23
|
-
publicKey: string;
|
|
24
|
-
}>;
|
|
25
|
-
/** The public key control last accepted, so the agent only re-reports when it changed. */
|
|
26
|
-
export declare function readReportedKey(stateDir: string): Promise<string | null>;
|
|
27
|
-
export declare function writeReportedKey(stateDir: string, publicKey: string): Promise<void>;
|
|
28
|
-
export declare function ipToInt(ip: string): number;
|
|
29
|
-
export declare const intToIp: (n: number) => string;
|
|
30
|
-
export declare function parseCidr(cidr: string): {
|
|
31
|
-
base: number;
|
|
32
|
-
prefix: number;
|
|
33
|
-
size: number;
|
|
34
|
-
};
|
|
35
|
-
/** First free host address in the mesh CIDR (never the network or broadcast address). */
|
|
36
|
-
export declare function allocateOverlayIp(cidr: string, used: Iterable<string>): string;
|
|
37
|
-
/** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
|
|
38
|
-
export declare function wgEndpoint(publicAddress: string | null | undefined, port: number): string | undefined;
|
|
39
|
-
/** `wg syncconf` input: interface key and port plus peers (no wg-quick-only keys such as Address). */
|
|
40
|
-
export declare function renderWgConfig(privateKey: string, listenPort: number, peers: WgPeer[]): string;
|
|
41
|
-
export interface AppliedPeer {
|
|
42
|
-
publicKey: string;
|
|
43
|
-
endpoint?: string;
|
|
44
|
-
allowedIps: string[];
|
|
45
|
-
keepalive: number;
|
|
46
|
-
}
|
|
47
|
-
export interface AppliedInterface {
|
|
48
|
-
publicKey: string;
|
|
49
|
-
listenPort: number;
|
|
50
|
-
peers: AppliedPeer[];
|
|
51
|
-
}
|
|
52
|
-
/** Parses `wg show <iface> dump` (tab separated; first line is the interface, then one line per peer). */
|
|
53
|
-
export declare function parseWgDump(dump: string): AppliedInterface;
|
|
54
|
-
export interface PeerDiff {
|
|
55
|
-
add: string[];
|
|
56
|
-
remove: string[];
|
|
57
|
-
update: string[];
|
|
58
|
-
}
|
|
59
|
-
/** Compares desired peers with what `wg` reports. A desired endpoint is authoritative; a peer without one keeps whatever it roamed to. */
|
|
60
|
-
export declare function diffPeers(desired: WgPeer[], applied: AppliedPeer[]): PeerDiff;
|
|
61
|
-
export interface MeshResult {
|
|
62
|
-
created: boolean;
|
|
63
|
-
synced: boolean;
|
|
64
|
-
diff: PeerDiff;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Brings `wg0` in line with the control-provided network block without tearing it down: creates the
|
|
68
|
-
* link and address only when missing, and calls `wg syncconf` only when keys, port or peers differ.
|
|
69
|
-
*/
|
|
70
|
-
export declare function reconcileMesh(host: HostRunner, stateDir: string, network: NetworkConfig): Promise<MeshResult>;
|
package/dist/mesh.js
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// WireGuard mesh: overlay IP allocation and key handling (control), config rendering, peer diffing
|
|
2
|
-
// and the idempotent `wg0` reconcile (agent). All shell-outs go through an injectable HostRunner.
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import fs from 'node:fs/promises';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { ensureDir, errMessage } from "./util.js";
|
|
7
|
-
/** Default mesh CIDR. Chosen to avoid the integration subnet 10.89.240.0/24, Docker's 172.16/12 and common home/VPN ranges. */
|
|
8
|
-
export const DEFAULT_MESH_CIDR = '10.210.0.0/16';
|
|
9
|
-
export const DEFAULT_WG_PORT = 51820;
|
|
10
|
-
export const WG_INTERFACE = 'wg0';
|
|
11
|
-
export const WG_KEEPALIVE = 25;
|
|
12
|
-
/** Below the usual 1500 path MTU minus WireGuard's 60 byte overhead (80 with IPv6 endpoints). */
|
|
13
|
-
const WG_MTU = 1420;
|
|
14
|
-
// ---- Keys --------------------------------------------------------------------------------------
|
|
15
|
-
export const WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
|
|
16
|
-
export const isWgKey = (value) => typeof value === 'string' && WG_KEY_RE.test(value);
|
|
17
|
-
const PKCS8_X25519_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
18
|
-
/** Public key for a base64 private key (X25519, as `wg pubkey` computes it). */
|
|
19
|
-
export function publicKeyFromPrivate(privateKey) {
|
|
20
|
-
const key = crypto.createPrivateKey({
|
|
21
|
-
key: Buffer.concat([PKCS8_X25519_PREFIX, Buffer.from(privateKey, 'base64')]),
|
|
22
|
-
format: 'der',
|
|
23
|
-
type: 'pkcs8',
|
|
24
|
-
});
|
|
25
|
-
const jwk = crypto.createPublicKey(key).export({ format: 'jwk' });
|
|
26
|
-
return Buffer.from(jwk.x, 'base64url').toString('base64');
|
|
27
|
-
}
|
|
28
|
-
export function generateWgKeyPair() {
|
|
29
|
-
const jwk = crypto.generateKeyPairSync('x25519').privateKey.export({ format: 'jwk' });
|
|
30
|
-
const privateKey = Buffer.from(jwk.d, 'base64url').toString('base64');
|
|
31
|
-
return { privateKey, publicKey: Buffer.from(jwk.x, 'base64url').toString('base64') };
|
|
32
|
-
}
|
|
33
|
-
const wgDir = (stateDir) => path.join(stateDir, 'wireguard');
|
|
34
|
-
/** Loads the node's keypair from the state dir, generating it on first use. The private key never leaves this directory. */
|
|
35
|
-
export async function ensureWgKeys(stateDir) {
|
|
36
|
-
const file = path.join(wgDir(stateDir), 'private.key');
|
|
37
|
-
try {
|
|
38
|
-
const privateKey = (await fs.readFile(file, 'utf8')).trim();
|
|
39
|
-
if (isWgKey(privateKey))
|
|
40
|
-
return { privateKey, publicKey: publicKeyFromPrivate(privateKey) };
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
/* first boot */
|
|
44
|
-
}
|
|
45
|
-
return rotateWgKeys(stateDir);
|
|
46
|
-
}
|
|
47
|
-
export async function rotateWgKeys(stateDir) {
|
|
48
|
-
const pair = generateWgKeyPair();
|
|
49
|
-
await ensureDir(wgDir(stateDir));
|
|
50
|
-
await fs.writeFile(path.join(wgDir(stateDir), 'private.key'), `${pair.privateKey}\n`, {
|
|
51
|
-
mode: 0o600,
|
|
52
|
-
});
|
|
53
|
-
return pair;
|
|
54
|
-
}
|
|
55
|
-
/** The public key control last accepted, so the agent only re-reports when it changed. */
|
|
56
|
-
export async function readReportedKey(stateDir) {
|
|
57
|
-
try {
|
|
58
|
-
return (await fs.readFile(path.join(wgDir(stateDir), 'reported.pub'), 'utf8')).trim() || null;
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
export async function writeReportedKey(stateDir, publicKey) {
|
|
65
|
-
await ensureDir(wgDir(stateDir));
|
|
66
|
-
await fs.writeFile(path.join(wgDir(stateDir), 'reported.pub'), `${publicKey}\n`);
|
|
67
|
-
}
|
|
68
|
-
// ---- Overlay addressing (control) --------------------------------------------------------------
|
|
69
|
-
export function ipToInt(ip) {
|
|
70
|
-
const parts = ip.split('.').map(Number);
|
|
71
|
-
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
|
|
72
|
-
throw new Error(`invalid IPv4 address ${ip}`);
|
|
73
|
-
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
74
|
-
}
|
|
75
|
-
export const intToIp = (n) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
|
|
76
|
-
export function parseCidr(cidr) {
|
|
77
|
-
const [ip, bits, extra] = cidr.split('/');
|
|
78
|
-
const prefix = Number(bits);
|
|
79
|
-
if (extra !== undefined ||
|
|
80
|
-
bits === undefined ||
|
|
81
|
-
!Number.isInteger(prefix) ||
|
|
82
|
-
prefix < 8 ||
|
|
83
|
-
prefix > 29)
|
|
84
|
-
throw new Error(`invalid mesh CIDR ${cidr} (IPv4, prefix /8 to /29)`);
|
|
85
|
-
const size = 2 ** (32 - prefix);
|
|
86
|
-
const base = (ipToInt(ip) & ~(size - 1)) >>> 0;
|
|
87
|
-
return { base, prefix, size };
|
|
88
|
-
}
|
|
89
|
-
/** First free host address in the mesh CIDR (never the network or broadcast address). */
|
|
90
|
-
export function allocateOverlayIp(cidr, used) {
|
|
91
|
-
const { base, size } = parseCidr(cidr);
|
|
92
|
-
const taken = new Set([...used].map(ipToInt));
|
|
93
|
-
for (let offset = 1; offset < size - 1; offset++) {
|
|
94
|
-
if (!taken.has(base + offset))
|
|
95
|
-
return intToIp(base + offset);
|
|
96
|
-
}
|
|
97
|
-
throw new Error(`mesh CIDR ${cidr} has no free overlay addresses`);
|
|
98
|
-
}
|
|
99
|
-
/** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
|
|
100
|
-
export function wgEndpoint(publicAddress, port) {
|
|
101
|
-
const addr = publicAddress?.trim();
|
|
102
|
-
if (!addr)
|
|
103
|
-
return undefined;
|
|
104
|
-
if (addr.startsWith('['))
|
|
105
|
-
return /\]:\d+$/.test(addr) ? addr : `${addr}:${port}`;
|
|
106
|
-
if (addr.includes(':') && addr.split(':').length > 2)
|
|
107
|
-
return `[${addr}]:${port}`;
|
|
108
|
-
return /:\d+$/.test(addr) ? addr : `${addr}:${port}`;
|
|
109
|
-
}
|
|
110
|
-
// ---- Config rendering and diffing (agent) ------------------------------------------------------
|
|
111
|
-
/** `wg syncconf` input: interface key and port plus peers (no wg-quick-only keys such as Address). */
|
|
112
|
-
export function renderWgConfig(privateKey, listenPort, peers) {
|
|
113
|
-
const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `ListenPort = ${listenPort}`];
|
|
114
|
-
for (const peer of [...peers].sort((a, b) => a.publicKey.localeCompare(b.publicKey))) {
|
|
115
|
-
lines.push('', '[Peer]', `PublicKey = ${peer.publicKey}`, `AllowedIPs = ${[...peer.allowedIps].sort().join(', ')}`);
|
|
116
|
-
if (peer.endpoint)
|
|
117
|
-
lines.push(`Endpoint = ${peer.endpoint}`);
|
|
118
|
-
lines.push(`PersistentKeepalive = ${peer.persistentKeepalive}`);
|
|
119
|
-
}
|
|
120
|
-
return `${lines.join('\n')}\n`;
|
|
121
|
-
}
|
|
122
|
-
/** Parses `wg show <iface> dump` (tab separated; first line is the interface, then one line per peer). */
|
|
123
|
-
export function parseWgDump(dump) {
|
|
124
|
-
const [header, ...rest] = dump.split('\n').filter(Boolean);
|
|
125
|
-
const h = (header || '').split('\t');
|
|
126
|
-
return {
|
|
127
|
-
publicKey: h[1] || '',
|
|
128
|
-
listenPort: Number(h[2]) || 0,
|
|
129
|
-
peers: rest.map((line) => {
|
|
130
|
-
const f = line.split('\t');
|
|
131
|
-
return {
|
|
132
|
-
publicKey: f[0],
|
|
133
|
-
endpoint: f[2] && f[2] !== '(none)' ? f[2] : undefined,
|
|
134
|
-
allowedIps: f[3] && f[3] !== '(none)' ? f[3].split(',') : [],
|
|
135
|
-
keepalive: f[7] && f[7] !== 'off' ? Number(f[7]) : 0,
|
|
136
|
-
};
|
|
137
|
-
}),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
/** Compares desired peers with what `wg` reports. A desired endpoint is authoritative; a peer without one keeps whatever it roamed to. */
|
|
141
|
-
export function diffPeers(desired, applied) {
|
|
142
|
-
const have = new Map(applied.map((p) => [p.publicKey, p]));
|
|
143
|
-
const want = new Map(desired.map((p) => [p.publicKey, p]));
|
|
144
|
-
const sameSet = (a, b) => a.length === b.length && [...a].sort().every((v, i) => v === [...b].sort()[i]);
|
|
145
|
-
const diff = { add: [], remove: [], update: [] };
|
|
146
|
-
for (const [key, peer] of want) {
|
|
147
|
-
const cur = have.get(key);
|
|
148
|
-
if (!cur)
|
|
149
|
-
diff.add.push(key);
|
|
150
|
-
else if (!sameSet(peer.allowedIps, cur.allowedIps) ||
|
|
151
|
-
peer.persistentKeepalive !== cur.keepalive ||
|
|
152
|
-
(peer.endpoint && peer.endpoint !== cur.endpoint))
|
|
153
|
-
diff.update.push(key);
|
|
154
|
-
}
|
|
155
|
-
for (const key of have.keys())
|
|
156
|
-
if (!want.has(key))
|
|
157
|
-
diff.remove.push(key);
|
|
158
|
-
return diff;
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Brings `wg0` in line with the control-provided network block without tearing it down: creates the
|
|
162
|
-
* link and address only when missing, and calls `wg syncconf` only when keys, port or peers differ.
|
|
163
|
-
*/
|
|
164
|
-
export async function reconcileMesh(host, stateDir, network) {
|
|
165
|
-
const { privateKey, publicKey } = await ensureWgKeys(stateDir);
|
|
166
|
-
const iface = network.interface;
|
|
167
|
-
const prefix = parseCidr(network.cidr).prefix;
|
|
168
|
-
let created = false;
|
|
169
|
-
try {
|
|
170
|
-
await host('ip', ['link', 'show', iface]);
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
await host('ip', ['link', 'add', iface, 'type', 'wireguard']);
|
|
174
|
-
created = true;
|
|
175
|
-
}
|
|
176
|
-
await host('ip', ['address', 'replace', `${network.overlayIp}/${prefix}`, 'dev', iface]);
|
|
177
|
-
if (created)
|
|
178
|
-
await host('ip', ['link', 'set', 'mtu', String(WG_MTU), 'up', 'dev', iface]);
|
|
179
|
-
let applied = { publicKey: '', listenPort: 0, peers: [] };
|
|
180
|
-
if (!created) {
|
|
181
|
-
try {
|
|
182
|
-
applied = parseWgDump((await host('wg', ['show', iface, 'dump'])).out);
|
|
183
|
-
}
|
|
184
|
-
catch (e) {
|
|
185
|
-
throw new Error(`wg show ${iface} failed: ${errMessage(e)}`, { cause: e });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
const diff = diffPeers(network.peers, applied.peers);
|
|
189
|
-
const synced = created ||
|
|
190
|
-
applied.publicKey !== publicKey ||
|
|
191
|
-
applied.listenPort !== network.listenPort ||
|
|
192
|
-
diff.add.length + diff.remove.length + diff.update.length > 0;
|
|
193
|
-
if (synced) {
|
|
194
|
-
const file = path.join(wgDir(stateDir), `${iface}.conf`);
|
|
195
|
-
await ensureDir(wgDir(stateDir));
|
|
196
|
-
await fs.writeFile(file, renderWgConfig(privateKey, network.listenPort, network.peers), {
|
|
197
|
-
mode: 0o600,
|
|
198
|
-
});
|
|
199
|
-
await host('wg', ['syncconf', iface, file]);
|
|
200
|
-
}
|
|
201
|
-
return { created, synced, diff };
|
|
202
|
-
}
|