@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.
Files changed (45) hide show
  1. package/README.md +21 -15
  2. package/dist/agent.d.ts +5 -7
  3. package/dist/agent.js +44 -66
  4. package/dist/backup-job.js +17 -5
  5. package/dist/backup-restore.js +4 -4
  6. package/dist/backup-scheduler.js +2 -0
  7. package/dist/build.d.ts +6 -0
  8. package/dist/build.js +27 -7
  9. package/dist/cli-build.d.ts +2 -0
  10. package/dist/cli-build.js +22 -2
  11. package/dist/cli-config.d.ts +4 -1
  12. package/dist/cli-config.js +28 -23
  13. package/dist/cli-install.d.ts +7 -4
  14. package/dist/cli-install.js +21 -23
  15. package/dist/cli-status.d.ts +29 -0
  16. package/dist/cli-status.js +61 -0
  17. package/dist/cli.js +60 -66
  18. package/dist/compose.d.ts +2 -0
  19. package/dist/compose.js +9 -0
  20. package/dist/container-spec.d.ts +5 -0
  21. package/dist/container-spec.js +11 -2
  22. package/dist/control-credential.d.ts +2 -0
  23. package/dist/control-credential.js +4 -4
  24. package/dist/control.d.ts +1 -1
  25. package/dist/control.js +109 -141
  26. package/dist/credentials.js +1 -1
  27. package/dist/db.d.ts +0 -10
  28. package/dist/db.js +1 -2
  29. package/dist/ingress.d.ts +1 -2
  30. package/dist/ingress.js +1 -1
  31. package/dist/live.d.ts +2 -0
  32. package/dist/live.js +9 -1
  33. package/dist/main.js +1 -1
  34. package/dist/node-address.d.ts +2 -0
  35. package/dist/node-address.js +6 -0
  36. package/dist/pki.d.ts +14 -3
  37. package/dist/pki.js +24 -7
  38. package/dist/protocol.d.ts +2 -34
  39. package/dist/reconciler.js +40 -7
  40. package/dist/runners.d.ts +0 -6
  41. package/dist/runners.js +1 -6
  42. package/dist/templates/control-compose.yml +1 -2
  43. package/package.json +1 -1
  44. package/dist/mesh.d.ts +0 -70
  45. 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
- /** Reachable mesh address; null when the mesh is off or the node has not reported a key yet. */
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 && (node.overlayIp || node.address);
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 TUG_PUBLIC_URL)
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,2 @@
1
+ /** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
2
+ export declare const isNodeHost: (value: unknown) => value is string;
@@ -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(publicUrl: string, extras?: string[]): SubjectNames;
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
- issueServer(names: SubjectNames): Promise<{
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
- /** TLS client options pinned to the credential's CA. Verification is always against the public host, even through a tunnel. */
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(publicUrl, extras = []) {
152
+ export function serverNames(controlUrl, extras = []) {
153
153
  const names = { dns: [], ip: [] };
154
- const host = new URL(publicUrl).hostname.replace(/^\[|\]$/g, '');
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
- async issueServer(names) {
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
- /** TLS client options pinned to the credential's CA. Verification is always against the public host, even through a tunnel. */
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 publicHost = new URL(credential.url).hostname.replace(/^\[|\]$/g, '');
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
- ...(net.isIP(publicHost) ? {} : { servername: publicHost }),
313
- checkServerIdentity: (_host, cert) => tls.checkServerIdentity(publicHost, cert),
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. */
@@ -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' | 'wg.rotate-key' | 'backup.check' | 'backup.run' | 'backup.restore' | 'backup.list';
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 overlay IP with the mesh, otherwise its `address` (an IP or a hostname the agent resolves); null binds to `127.0.0.1`. */
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;
@@ -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
- if (!(await docker.inspectNetwork(plan.network)))
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
- if (await docker.inspectVolume(volume.name))
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
- await docker.createVolume(volume.name, {
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
- // Injectable seams for the agent's side effects so rollout/health/secret logic can be unit-tested
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 {};
@@ -6,9 +6,8 @@ services:
6
6
  environment:
7
7
  PORT: 8443
8
8
  TUG_DATA_DIR: /data
9
- TUG_PUBLIC_URL: {{PUBLIC_URL}}
9
+ TUG_CONTROL_URL: {{CONTROL_URL}}
10
10
  TUG_EXTRA_SANS: {{EXTRA_SANS}}
11
- TUG_MESH: {{MESH}}
12
11
  ports:
13
12
  - "{{PORT}}:8443"
14
13
  volumes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
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
- }