@wpelevator/tug 0.9.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.
@@ -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_ADVERTISE_URL: {{ADVERTISE_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.9.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,74 +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
- /** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
38
- export declare const isNodeHost: (value: unknown) => value is string;
39
- /** A node host with an optional port: `host`, `host:port` or `[ipv6]:port`. */
40
- export declare const isNodeEndpoint: (value: unknown) => value is string;
41
- /** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
42
- export declare function wgEndpoint(publicAddress: string | null | undefined, port: number): string | undefined;
43
- /** `wg syncconf` input: interface key and port plus peers (no wg-quick-only keys such as Address). */
44
- export declare function renderWgConfig(privateKey: string, listenPort: number, peers: WgPeer[]): string;
45
- export interface AppliedPeer {
46
- publicKey: string;
47
- endpoint?: string;
48
- allowedIps: string[];
49
- keepalive: number;
50
- }
51
- export interface AppliedInterface {
52
- publicKey: string;
53
- listenPort: number;
54
- peers: AppliedPeer[];
55
- }
56
- /** Parses `wg show <iface> dump` (tab separated; first line is the interface, then one line per peer). */
57
- export declare function parseWgDump(dump: string): AppliedInterface;
58
- export interface PeerDiff {
59
- add: string[];
60
- remove: string[];
61
- update: string[];
62
- }
63
- /** Compares desired peers with what `wg` reports. A desired endpoint is authoritative; a peer without one keeps whatever it roamed to. */
64
- export declare function diffPeers(desired: WgPeer[], applied: AppliedPeer[]): PeerDiff;
65
- export interface MeshResult {
66
- created: boolean;
67
- synced: boolean;
68
- diff: PeerDiff;
69
- }
70
- /**
71
- * Brings `wg0` in line with the control-provided network block without tearing it down: creates the
72
- * link and address only when missing, and calls `wg syncconf` only when keys, port or peers differ.
73
- */
74
- export declare function reconcileMesh(host: HostRunner, stateDir: string, network: NetworkConfig): Promise<MeshResult>;
package/dist/mesh.js DELETED
@@ -1,219 +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 { isIP } from 'node:net';
6
- import path from 'node:path';
7
- import { ensureDir, errMessage } from "./util.js";
8
- /** Default mesh CIDR. Chosen to avoid the integration subnet 10.89.240.0/24, Docker's 172.16/12 and common home/VPN ranges. */
9
- export const DEFAULT_MESH_CIDR = '10.210.0.0/16';
10
- export const DEFAULT_WG_PORT = 51820;
11
- export const WG_INTERFACE = 'wg0';
12
- export const WG_KEEPALIVE = 25;
13
- /** Below the usual 1500 path MTU minus WireGuard's 60 byte overhead (80 with IPv6 endpoints). */
14
- const WG_MTU = 1420;
15
- // ---- Keys --------------------------------------------------------------------------------------
16
- export const WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
17
- export const isWgKey = (value) => typeof value === 'string' && WG_KEY_RE.test(value);
18
- const PKCS8_X25519_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
19
- /** Public key for a base64 private key (X25519, as `wg pubkey` computes it). */
20
- export function publicKeyFromPrivate(privateKey) {
21
- const key = crypto.createPrivateKey({
22
- key: Buffer.concat([PKCS8_X25519_PREFIX, Buffer.from(privateKey, 'base64')]),
23
- format: 'der',
24
- type: 'pkcs8',
25
- });
26
- const jwk = crypto.createPublicKey(key).export({ format: 'jwk' });
27
- return Buffer.from(jwk.x, 'base64url').toString('base64');
28
- }
29
- export function generateWgKeyPair() {
30
- const jwk = crypto.generateKeyPairSync('x25519').privateKey.export({ format: 'jwk' });
31
- const privateKey = Buffer.from(jwk.d, 'base64url').toString('base64');
32
- return { privateKey, publicKey: Buffer.from(jwk.x, 'base64url').toString('base64') };
33
- }
34
- const wgDir = (stateDir) => path.join(stateDir, 'wireguard');
35
- /** Loads the node's keypair from the state dir, generating it on first use. The private key never leaves this directory. */
36
- export async function ensureWgKeys(stateDir) {
37
- const file = path.join(wgDir(stateDir), 'private.key');
38
- try {
39
- const privateKey = (await fs.readFile(file, 'utf8')).trim();
40
- if (isWgKey(privateKey))
41
- return { privateKey, publicKey: publicKeyFromPrivate(privateKey) };
42
- }
43
- catch {
44
- /* first boot */
45
- }
46
- return rotateWgKeys(stateDir);
47
- }
48
- export async function rotateWgKeys(stateDir) {
49
- const pair = generateWgKeyPair();
50
- await ensureDir(wgDir(stateDir));
51
- await fs.writeFile(path.join(wgDir(stateDir), 'private.key'), `${pair.privateKey}\n`, {
52
- mode: 0o600,
53
- });
54
- return pair;
55
- }
56
- /** The public key control last accepted, so the agent only re-reports when it changed. */
57
- export async function readReportedKey(stateDir) {
58
- try {
59
- return (await fs.readFile(path.join(wgDir(stateDir), 'reported.pub'), 'utf8')).trim() || null;
60
- }
61
- catch {
62
- return null;
63
- }
64
- }
65
- export async function writeReportedKey(stateDir, publicKey) {
66
- await ensureDir(wgDir(stateDir));
67
- await fs.writeFile(path.join(wgDir(stateDir), 'reported.pub'), `${publicKey}\n`);
68
- }
69
- // ---- Overlay addressing (control) --------------------------------------------------------------
70
- export function ipToInt(ip) {
71
- const parts = ip.split('.').map(Number);
72
- if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
73
- throw new Error(`invalid IPv4 address ${ip}`);
74
- return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
75
- }
76
- export const intToIp = (n) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
77
- export function parseCidr(cidr) {
78
- const [ip, bits, extra] = cidr.split('/');
79
- const prefix = Number(bits);
80
- if (extra !== undefined ||
81
- bits === undefined ||
82
- !Number.isInteger(prefix) ||
83
- prefix < 8 ||
84
- prefix > 29)
85
- throw new Error(`invalid mesh CIDR ${cidr} (IPv4, prefix /8 to /29)`);
86
- const size = 2 ** (32 - prefix);
87
- const base = (ipToInt(ip) & ~(size - 1)) >>> 0;
88
- return { base, prefix, size };
89
- }
90
- /** First free host address in the mesh CIDR (never the network or broadcast address). */
91
- export function allocateOverlayIp(cidr, used) {
92
- const { base, size } = parseCidr(cidr);
93
- const taken = new Set([...used].map(ipToInt));
94
- for (let offset = 1; offset < size - 1; offset++) {
95
- if (!taken.has(base + offset))
96
- return intToIp(base + offset);
97
- }
98
- throw new Error(`mesh CIDR ${cidr} has no free overlay addresses`);
99
- }
100
- const HOST_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
101
- /** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
102
- export const isNodeHost = (value) => typeof value === 'string' &&
103
- value.length <= 253 &&
104
- (isIP(value) !== 0 || value.split('.').every((label) => HOST_LABEL.test(label)));
105
- /** A node host with an optional port: `host`, `host:port` or `[ipv6]:port`. */
106
- export const isNodeEndpoint = (value) => {
107
- if (typeof value !== 'string')
108
- return false;
109
- const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value);
110
- const parts = bracketed ?? (isIP(value) ? null : /^([^:]+)(?::(\d{1,5}))?$/.exec(value));
111
- const [host, port] = parts ? [parts[1], parts[2]] : [value, undefined];
112
- if (bracketed ? isIP(host) !== 6 : !isNodeHost(host))
113
- return false;
114
- return port === undefined || (Number(port) >= 1 && Number(port) <= 65535);
115
- };
116
- /** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
117
- export function wgEndpoint(publicAddress, port) {
118
- const addr = publicAddress?.trim();
119
- if (!addr)
120
- return undefined;
121
- if (addr.startsWith('['))
122
- return /\]:\d+$/.test(addr) ? addr : `${addr}:${port}`;
123
- if (addr.includes(':') && addr.split(':').length > 2)
124
- return `[${addr}]:${port}`;
125
- return /:\d+$/.test(addr) ? addr : `${addr}:${port}`;
126
- }
127
- // ---- Config rendering and diffing (agent) ------------------------------------------------------
128
- /** `wg syncconf` input: interface key and port plus peers (no wg-quick-only keys such as Address). */
129
- export function renderWgConfig(privateKey, listenPort, peers) {
130
- const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `ListenPort = ${listenPort}`];
131
- for (const peer of [...peers].sort((a, b) => a.publicKey.localeCompare(b.publicKey))) {
132
- lines.push('', '[Peer]', `PublicKey = ${peer.publicKey}`, `AllowedIPs = ${[...peer.allowedIps].sort().join(', ')}`);
133
- if (peer.endpoint)
134
- lines.push(`Endpoint = ${peer.endpoint}`);
135
- lines.push(`PersistentKeepalive = ${peer.persistentKeepalive}`);
136
- }
137
- return `${lines.join('\n')}\n`;
138
- }
139
- /** Parses `wg show <iface> dump` (tab separated; first line is the interface, then one line per peer). */
140
- export function parseWgDump(dump) {
141
- const [header, ...rest] = dump.split('\n').filter(Boolean);
142
- const h = (header || '').split('\t');
143
- return {
144
- publicKey: h[1] || '',
145
- listenPort: Number(h[2]) || 0,
146
- peers: rest.map((line) => {
147
- const f = line.split('\t');
148
- return {
149
- publicKey: f[0],
150
- endpoint: f[2] && f[2] !== '(none)' ? f[2] : undefined,
151
- allowedIps: f[3] && f[3] !== '(none)' ? f[3].split(',') : [],
152
- keepalive: f[7] && f[7] !== 'off' ? Number(f[7]) : 0,
153
- };
154
- }),
155
- };
156
- }
157
- /** Compares desired peers with what `wg` reports. A desired endpoint is authoritative; a peer without one keeps whatever it roamed to. */
158
- export function diffPeers(desired, applied) {
159
- const have = new Map(applied.map((p) => [p.publicKey, p]));
160
- const want = new Map(desired.map((p) => [p.publicKey, p]));
161
- const sameSet = (a, b) => a.length === b.length && [...a].sort().every((v, i) => v === [...b].sort()[i]);
162
- const diff = { add: [], remove: [], update: [] };
163
- for (const [key, peer] of want) {
164
- const cur = have.get(key);
165
- if (!cur)
166
- diff.add.push(key);
167
- else if (!sameSet(peer.allowedIps, cur.allowedIps) ||
168
- peer.persistentKeepalive !== cur.keepalive ||
169
- (peer.endpoint && peer.endpoint !== cur.endpoint))
170
- diff.update.push(key);
171
- }
172
- for (const key of have.keys())
173
- if (!want.has(key))
174
- diff.remove.push(key);
175
- return diff;
176
- }
177
- /**
178
- * Brings `wg0` in line with the control-provided network block without tearing it down: creates the
179
- * link and address only when missing, and calls `wg syncconf` only when keys, port or peers differ.
180
- */
181
- export async function reconcileMesh(host, stateDir, network) {
182
- const { privateKey, publicKey } = await ensureWgKeys(stateDir);
183
- const iface = network.interface;
184
- const prefix = parseCidr(network.cidr).prefix;
185
- let created = false;
186
- try {
187
- await host('ip', ['link', 'show', iface]);
188
- }
189
- catch {
190
- await host('ip', ['link', 'add', iface, 'type', 'wireguard']);
191
- created = true;
192
- }
193
- await host('ip', ['address', 'replace', `${network.overlayIp}/${prefix}`, 'dev', iface]);
194
- if (created)
195
- await host('ip', ['link', 'set', 'mtu', String(WG_MTU), 'up', 'dev', iface]);
196
- let applied = { publicKey: '', listenPort: 0, peers: [] };
197
- if (!created) {
198
- try {
199
- applied = parseWgDump((await host('wg', ['show', iface, 'dump'])).out);
200
- }
201
- catch (e) {
202
- throw new Error(`wg show ${iface} failed: ${errMessage(e)}`, { cause: e });
203
- }
204
- }
205
- const diff = diffPeers(network.peers, applied.peers);
206
- const synced = created ||
207
- applied.publicKey !== publicKey ||
208
- applied.listenPort !== network.listenPort ||
209
- diff.add.length + diff.remove.length + diff.update.length > 0;
210
- if (synced) {
211
- const file = path.join(wgDir(stateDir), `${iface}.conf`);
212
- await ensureDir(wgDir(stateDir));
213
- await fs.writeFile(file, renderWgConfig(privateKey, network.listenPort, network.peers), {
214
- mode: 0o600,
215
- });
216
- await host('wg', ['syncconf', iface, file]);
217
- }
218
- return { created, synced, diff };
219
- }