@wpelevator/tug 0.0.0 → 0.1.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/dist/db.d.ts ADDED
@@ -0,0 +1,115 @@
1
+ import sqlite3 from 'sqlite3';
2
+ import type { Database } from 'sqlite';
3
+ export type Db = Database<sqlite3.Database, sqlite3.Statement>;
4
+ export interface ProjectRow {
5
+ id: string;
6
+ name: string;
7
+ current_deployment_id: string | null;
8
+ created_at: string;
9
+ updated_at: string;
10
+ }
11
+ export interface NodeRow {
12
+ id: string;
13
+ name: string;
14
+ address: string | null;
15
+ public_address: string | null;
16
+ overlay_ip: string | null;
17
+ ingress: number;
18
+ ingress_status_json: string | null;
19
+ status: string;
20
+ agent_token_hash: string | null;
21
+ registry_token_hash: string | null;
22
+ last_seen_at: string | null;
23
+ created_at: string;
24
+ updated_at: string;
25
+ }
26
+ export interface WgPeerRow {
27
+ node_id: string;
28
+ public_key: string;
29
+ overlay_ip: string;
30
+ endpoint: string | null;
31
+ allowed_ips: string;
32
+ updated_at: string;
33
+ }
34
+ export interface EnrollTokenRow {
35
+ token_hash: string;
36
+ name: string | null;
37
+ uses_remaining: number;
38
+ expires_at: string;
39
+ created_at: string;
40
+ }
41
+ export interface DeploymentRow {
42
+ id: string;
43
+ project_id: string;
44
+ version: string;
45
+ state: string;
46
+ created_at: string;
47
+ updated_at: string;
48
+ error_message: string | null;
49
+ }
50
+ export interface DesiredStateRow {
51
+ id: string;
52
+ project_id: string;
53
+ deployment_id: string;
54
+ version: string;
55
+ state_json: string;
56
+ created_at: string;
57
+ }
58
+ export interface NodeStatusRow {
59
+ id: string;
60
+ node_id: string;
61
+ project_id: string;
62
+ observed_version: string;
63
+ status_json: string;
64
+ health: string;
65
+ updated_at: string;
66
+ }
67
+ export interface SecretRow {
68
+ id: string;
69
+ project_id: string;
70
+ name: string;
71
+ encrypted_value: string;
72
+ version: number;
73
+ created_at: string;
74
+ updated_at: string;
75
+ }
76
+ export interface VolumeRow {
77
+ id: string;
78
+ project_id: string;
79
+ name: string;
80
+ type: string;
81
+ driver: string | null;
82
+ node_id: string | null;
83
+ config_json: string;
84
+ created_at: string;
85
+ updated_at: string;
86
+ }
87
+ export interface CommandRow {
88
+ id: string;
89
+ node_id: string;
90
+ project_id: string | null;
91
+ type: string;
92
+ state: string;
93
+ payload_json: string | null;
94
+ result_json: string | null;
95
+ attempts: number;
96
+ run_after: string | null;
97
+ deadline: string | null;
98
+ created_at: string;
99
+ updated_at: string;
100
+ }
101
+ export interface LockRow {
102
+ name: string;
103
+ owner: string;
104
+ expires_at: string;
105
+ created_at: string;
106
+ }
107
+ export interface AppliedStateRow {
108
+ project: string;
109
+ version: string;
110
+ applied_at: string;
111
+ service_states_json: string;
112
+ secret_versions_json: string;
113
+ }
114
+ export declare function openDb(filename: string): Promise<Db>;
115
+ export declare function openAgentDb(filename: string): Promise<Db>;
package/dist/db.js ADDED
@@ -0,0 +1,48 @@
1
+ import sqlite3 from 'sqlite3';
2
+ import { open } from 'sqlite';
3
+ import path from 'node:path';
4
+ import { ensureDir, now } from "./util.js";
5
+ export async function openDb(filename) {
6
+ await ensureDir(path.dirname(filename));
7
+ const db = await open({ filename, driver: sqlite3.Database });
8
+ await db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
9
+ await migrate(db);
10
+ return db;
11
+ }
12
+ export async function openAgentDb(filename) {
13
+ await ensureDir(path.dirname(filename));
14
+ const db = await open({ filename, driver: sqlite3.Database });
15
+ await db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
16
+ await db.exec(`
17
+ CREATE TABLE IF NOT EXISTS applied_state(
18
+ project TEXT PRIMARY KEY,
19
+ version TEXT,
20
+ applied_at TEXT,
21
+ service_states_json TEXT,
22
+ secret_versions_json TEXT
23
+ );
24
+ `);
25
+ return db;
26
+ }
27
+ async function migrate(db) {
28
+ await db.exec(`
29
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
30
+ CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
31
+ CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
32
+ CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
33
+ CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
34
+ CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT);
35
+ CREATE TABLE IF NOT EXISTS desired_states(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, version TEXT, state_json TEXT, created_at TEXT);
36
+ CREATE TABLE IF NOT EXISTS node_statuses(id TEXT PRIMARY KEY, node_id TEXT, project_id TEXT, observed_version TEXT, status_json TEXT, health TEXT, updated_at TEXT, UNIQUE(node_id, project_id));
37
+ CREATE TABLE IF NOT EXISTS builds(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, service TEXT, source_hash TEXT, image_ref TEXT, state TEXT, logs_path TEXT, created_at TEXT, updated_at TEXT);
38
+ CREATE TABLE IF NOT EXISTS secrets(id TEXT PRIMARY KEY, project_id TEXT, name TEXT, encrypted_value TEXT, version INTEGER, created_at TEXT, updated_at TEXT, UNIQUE(project_id, name));
39
+ CREATE TABLE IF NOT EXISTS volumes(id TEXT PRIMARY KEY, project_id TEXT, name TEXT, type TEXT, driver TEXT, node_id TEXT, config_json TEXT, created_at TEXT, updated_at TEXT, UNIQUE(project_id, name));
40
+ CREATE TABLE IF NOT EXISTS backups(id TEXT PRIMARY KEY, project_id TEXT, target TEXT, node_id TEXT, state TEXT, artifact_ref TEXT, metadata_json TEXT, created_at TEXT, completed_at TEXT);
41
+ CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY, node_id TEXT, project_id TEXT, type TEXT, state TEXT, payload_json TEXT, result_json TEXT, attempts INTEGER DEFAULT 0, run_after TEXT, deadline TEXT, created_at TEXT, updated_at TEXT);
42
+ CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, node_id TEXT, level TEXT, type TEXT, message TEXT, metadata_json TEXT, created_at TEXT);
43
+ CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
44
+ `);
45
+ const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
46
+ if (!row)
47
+ await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
48
+ }
@@ -0,0 +1,9 @@
1
+ /** Registry repository the release workflow publishes the Tug image to. */
2
+ export declare const IMAGE_REPOSITORY = "ghcr.io/wpelevator/tug";
3
+ /** Tag `--build-local-image` builds and ships when no `--image` is given; for development against a checkout. */
4
+ export declare const LOCAL_IMAGE = "tug:local";
5
+ export declare function packageVersion(): string;
6
+ /** The published image matching this CLI's version, so the CLI and the agents it installs stay in step. */
7
+ export declare function defaultImage(version?: string): string;
8
+ /** An explicit `--image` always wins; otherwise a locally built image is `tug:local` and anything else is the published image. */
9
+ export declare function resolveImage(image: string | undefined, buildLocalImage?: boolean, version?: string): string;
package/dist/image.js ADDED
@@ -0,0 +1,20 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /** Registry repository the release workflow publishes the Tug image to. */
5
+ export const IMAGE_REPOSITORY = 'ghcr.io/wpelevator/tug';
6
+ /** Tag `--build-local-image` builds and ships when no `--image` is given; for development against a checkout. */
7
+ export const LOCAL_IMAGE = 'tug:local';
8
+ // package.json sits one level above this file in src/ (dev), dist/ (build) and the installed package.
9
+ const packageJsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
10
+ export function packageVersion() {
11
+ return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
12
+ }
13
+ /** The published image matching this CLI's version, so the CLI and the agents it installs stay in step. */
14
+ export function defaultImage(version = packageVersion()) {
15
+ return `${IMAGE_REPOSITORY}:${version}`;
16
+ }
17
+ /** An explicit `--image` always wins; otherwise a locally built image is `tug:local` and anything else is the published image. */
18
+ export function resolveImage(image, buildLocalImage = false, version = packageVersion()) {
19
+ return image || (buildLocalImage ? LOCAL_IMAGE : defaultImage(version));
20
+ }
@@ -0,0 +1,50 @@
1
+ import type { DesiredState, IngressRoute, IngressStatus, IngressWork, ProjectStatus, ServiceEndpoints } from './protocol.ts';
2
+ import type { DockerRunner } from './runners.ts';
3
+ export declare const CADDY_CONTAINER = "tug-caddy";
4
+ export declare const DEFAULT_CADDY_IMAGE = "caddy:2-alpine";
5
+ export interface NodeView {
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;
9
+ address: string | null;
10
+ lastSeenAt: string | null;
11
+ }
12
+ export interface ReportedStatus {
13
+ node: string;
14
+ project: string;
15
+ status: ProjectStatus;
16
+ }
17
+ /**
18
+ * Where each service can be reached right now: one endpoint per placement node whose agent is
19
+ * fresh and reports that service `serving`. Withdrawn automatically when a node goes stale or its
20
+ * rollout state drops, which is what makes DNS and ingress health-aware.
21
+ */
22
+ export declare function resolveServiceEndpoints(states: DesiredState[], nodes: NodeView[], statuses: ReportedStatus[], staleBefore: string): ServiceEndpoints;
23
+ export interface IngressInput {
24
+ states: DesiredState[];
25
+ endpoints: ServiceEndpoints;
26
+ /** The ingress node being configured; its own upstreams are reached on loopback. */
27
+ node: string;
28
+ /** Decrypted project secrets for custom TLS, keyed `<project>/<secret name>`. */
29
+ secrets: Map<string, string>;
30
+ acmeEmail?: string;
31
+ }
32
+ export declare function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }: IngressInput): IngressWork;
33
+ export declare function renderCaddyfile(routes: IngressRoute[], opts?: {
34
+ acmeEmail?: string;
35
+ }): string;
36
+ export declare function ingressConfigHash(work: Pick<IngressWork, 'caddyfile' | 'files'>): string;
37
+ export interface IngressContext {
38
+ docker: DockerRunner;
39
+ stateDir: string;
40
+ caddyImage?: string;
41
+ }
42
+ /**
43
+ * Makes the Caddy container match the control-rendered config. Config is copied into the
44
+ * container (`docker cp`, so it works whether the agent runs on the host or in a container) and
45
+ * applied with `caddy reload`, which swaps config gracefully without dropping connections. If
46
+ * Caddy rejects the new config the previous one is restored and the error reported.
47
+ */
48
+ export declare function reconcileIngress(ctx: IngressContext, work: IngressWork): Promise<IngressStatus>;
49
+ /** Removes the Caddy container when a node stops being an ingress node; ACME data volumes are kept. */
50
+ export declare function removeIngress(ctx: IngressContext): Promise<boolean>;
@@ -0,0 +1,178 @@
1
+ // Service endpoints (health-aware DNS source), ingress route resolution and Caddyfile rendering
2
+ // on the control side; the managed Caddy container reconcile on the agent side. Docker access goes
3
+ // through the injectable DockerRunner.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { publishedPort } from "./compose.js";
8
+ import { ensureDir, errMessage } from "./util.js";
9
+ export const CADDY_CONTAINER = 'tug-caddy';
10
+ export const DEFAULT_CADDY_IMAGE = 'caddy:2-alpine';
11
+ const CADDY_ETC = '/etc/caddy';
12
+ /**
13
+ * Where each service can be reached right now: one endpoint per placement node whose agent is
14
+ * fresh and reports that service `serving`. Withdrawn automatically when a node goes stale or its
15
+ * rollout state drops, which is what makes DNS and ingress health-aware.
16
+ */
17
+ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
18
+ const byName = new Map(nodes.map(n => [n.name, n]));
19
+ const reported = new Map(statuses.map(s => [`${s.node}\0${s.project}`, s.status]));
20
+ const out = {};
21
+ for (const state of states) {
22
+ for (const [service, spec] of Object.entries(state.services)) {
23
+ const endpoints = [];
24
+ for (const nodeName of spec.placement?.nodes || []) {
25
+ const node = byName.get(nodeName);
26
+ const address = node && (node.overlayIp || node.address);
27
+ if (!node || !address || !node.lastSeenAt || node.lastSeenAt < staleBefore)
28
+ continue;
29
+ if (reported.get(`${nodeName}\0${state.project}`)?.serviceStates?.[service]?.rolloutState !== 'serving')
30
+ continue;
31
+ endpoints.push({ node: nodeName, address });
32
+ }
33
+ if (endpoints.length)
34
+ (out[state.project] ||= {})[service] = endpoints.sort((a, b) => a.node.localeCompare(b.node));
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+ const certFile = (host, ext) => `certs/${host.replace(/\*/g, '_wildcard_')}.${ext}`;
40
+ export function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }) {
41
+ const routes = [];
42
+ const files = {};
43
+ const warnings = [];
44
+ const seenHosts = new Map();
45
+ const tagged = [];
46
+ for (const state of states) {
47
+ for (const [service, svc] of Object.entries(state.compose.services || {})) {
48
+ const ingress = svc['x-tug']?.ingress;
49
+ if (ingress)
50
+ tagged.push({ project: state.project, service, ingress, port: publishedPort(svc, ingress.port) });
51
+ }
52
+ }
53
+ tagged.sort((a, b) => `${a.project}/${a.service}`.localeCompare(`${b.project}/${b.service}`));
54
+ for (const { project, service, ingress, port } of tagged) {
55
+ const label = `${project}/${service}`;
56
+ const host = ingress.host.toLowerCase();
57
+ const tls = ingress.tls || 'auto';
58
+ if (seenHosts.has(host)) {
59
+ warnings.push(`${label}: host ${host} is already routed by ${seenHosts.get(host)}; skipped`);
60
+ continue;
61
+ }
62
+ if (port == null) {
63
+ warnings.push(`${label}: ingress.port ${ingress.port} is not published; skipped`);
64
+ continue;
65
+ }
66
+ const upstreams = (endpoints[project]?.[service] || []).map(e => `${e.node === node ? '127.0.0.1' : e.address}:${port}`);
67
+ if (!upstreams.length) {
68
+ warnings.push(`${label}: no serving upstream for ${host}; route withheld`);
69
+ continue;
70
+ }
71
+ if (tls === 'custom') {
72
+ const cert = secrets.get(`${project}/${ingress.cert}`);
73
+ const key = secrets.get(`${project}/${ingress.key}`);
74
+ if (!cert || !key) {
75
+ warnings.push(`${label}: custom TLS secrets ${ingress.cert}/${ingress.key} not found in project ${project}; skipped`);
76
+ continue;
77
+ }
78
+ files[certFile(host, 'crt')] = cert.endsWith('\n') ? cert : `${cert}\n`;
79
+ files[certFile(host, 'key')] = key.endsWith('\n') ? key : `${key}\n`;
80
+ }
81
+ seenHosts.set(host, label);
82
+ routes.push({ project, service, host, tls, upstreams });
83
+ }
84
+ return { caddyfile: renderCaddyfile(routes, { acmeEmail }), files, routes, warnings };
85
+ }
86
+ export function renderCaddyfile(routes, opts = {}) {
87
+ const out = [];
88
+ if (opts.acmeEmail)
89
+ out.push('{', `\temail ${opts.acmeEmail}`, '}', '');
90
+ for (const route of routes) {
91
+ // An explicit http:// address turns off automatic HTTPS for that site.
92
+ out.push(`${route.tls === 'off' ? 'http://' : ''}${route.host} {`);
93
+ if (route.tls === 'custom')
94
+ out.push(`\ttls ${CADDY_ETC}/${certFile(route.host, 'crt')} ${CADDY_ETC}/${certFile(route.host, 'key')}`);
95
+ out.push(`\treverse_proxy ${route.upstreams.join(' ')} {`, '\t\tlb_policy round_robin', '\t}', '}', '');
96
+ }
97
+ return out.length ? out.join('\n') : '# no ingress routes\n';
98
+ }
99
+ // ---- Agent: managed Caddy container ------------------------------------------------------------
100
+ export function ingressConfigHash(work) {
101
+ const h = crypto.createHash('sha256').update(work.caddyfile);
102
+ for (const name of Object.keys(work.files).sort())
103
+ h.update('\0').update(name).update('\0').update(work.files[name]);
104
+ return h.digest('hex');
105
+ }
106
+ async function writeStage(dir, work) {
107
+ await fs.rm(dir, { recursive: true, force: true });
108
+ await ensureDir(dir);
109
+ await fs.writeFile(path.join(dir, 'Caddyfile'), work.caddyfile);
110
+ for (const [name, body] of Object.entries(work.files)) {
111
+ const file = path.join(dir, name);
112
+ await ensureDir(path.dirname(file));
113
+ await fs.writeFile(file, body, { mode: name.endsWith('.key') ? 0o600 : 0o644 });
114
+ }
115
+ }
116
+ /**
117
+ * Makes the Caddy container match the control-rendered config. Config is copied into the
118
+ * container (`docker cp`, so it works whether the agent runs on the host or in a container) and
119
+ * applied with `caddy reload`, which swaps config gracefully without dropping connections. If
120
+ * Caddy rejects the new config the previous one is restored and the error reported.
121
+ */
122
+ export async function reconcileIngress(ctx, work) {
123
+ const root = path.join(ctx.stateDir, 'ingress');
124
+ const stage = path.join(root, 'stage');
125
+ const applied = path.join(root, 'applied');
126
+ const configHash = ingressConfigHash(work);
127
+ const routes = work.routes.length;
128
+ const running = await ctx.docker(['container', 'inspect', '-f', '{{.State.Running}}', CADDY_CONTAINER])
129
+ .then(r => r.out.trim() === 'true', () => null);
130
+ const appliedHash = await fs.readFile(path.join(root, 'applied.hash'), 'utf8').then(s => s.trim(), () => '');
131
+ if (running && appliedHash === configHash)
132
+ return { state: 'serving', configHash, routes };
133
+ await writeStage(stage, work);
134
+ try {
135
+ if (running === null) {
136
+ await ctx.docker([
137
+ 'create', '--name', CADDY_CONTAINER, '--restart', 'unless-stopped', '--network', 'host',
138
+ '-v', 'tug-caddy-data:/data', '-v', 'tug-caddy-config:/config', '-v', `tug-caddy-etc:${CADDY_ETC}`,
139
+ ctx.caddyImage || DEFAULT_CADDY_IMAGE
140
+ ]);
141
+ await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
142
+ await ctx.docker(['start', CADDY_CONTAINER]);
143
+ }
144
+ else if (running === false) {
145
+ await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
146
+ await ctx.docker(['start', CADDY_CONTAINER]);
147
+ }
148
+ else {
149
+ try {
150
+ await ctx.docker(['exec', CADDY_CONTAINER, 'rm', '-rf', `${CADDY_ETC}/certs`]);
151
+ await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
152
+ await ctx.docker(['exec', CADDY_CONTAINER, 'caddy', 'reload', '--config', `${CADDY_ETC}/Caddyfile`, '--adapter', 'caddyfile']);
153
+ }
154
+ catch (e) {
155
+ if (await fs.stat(applied).then(() => true, () => false))
156
+ await ctx.docker(['cp', `${applied}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]).catch(() => { });
157
+ throw e;
158
+ }
159
+ }
160
+ }
161
+ catch (e) {
162
+ await fs.rm(stage, { recursive: true, force: true });
163
+ return { state: 'failed', configHash, routes, error: errMessage(e).slice(-1000) };
164
+ }
165
+ await fs.rm(applied, { recursive: true, force: true });
166
+ await fs.rename(stage, applied);
167
+ await fs.writeFile(path.join(root, 'applied.hash'), `${configHash}\n`);
168
+ return { state: 'serving', configHash, routes };
169
+ }
170
+ /** Removes the Caddy container when a node stops being an ingress node; ACME data volumes are kept. */
171
+ export async function removeIngress(ctx) {
172
+ const root = path.join(ctx.stateDir, 'ingress');
173
+ if (!(await fs.stat(root).then(() => true, () => false)))
174
+ return false;
175
+ await ctx.docker(['rm', '-f', CADDY_CONTAINER]).catch(() => { });
176
+ await fs.rm(root, { recursive: true, force: true });
177
+ return true;
178
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Single entrypoint for the control plane and agent images; role picked via TUG_ROLE or argv.
3
+ const role = process.argv[2] || process.env.TUG_ROLE;
4
+ switch (role) {
5
+ case 'control': {
6
+ const { createApp, listen } = await import("./control.js");
7
+ const app = await createApp();
8
+ await listen(app);
9
+ break;
10
+ }
11
+ case 'agent': {
12
+ const { main } = await import("./agent.js");
13
+ await main();
14
+ break;
15
+ }
16
+ default: {
17
+ const self = process.argv[1] ? `node ${process.argv[1].split('/').slice(-2).join('/')}` : 'main';
18
+ console.error(`Usage: TUG_ROLE=control|agent ${self}, or ${self} <control|agent>`);
19
+ process.exit(1);
20
+ }
21
+ }
22
+ export {};
package/dist/mesh.d.ts ADDED
@@ -0,0 +1,70 @@
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>;