@wpelevator/tug 0.0.0-stage → 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/README.md +58 -2
- package/dist/agent.d.ts +36 -0
- package/dist/agent.js +406 -0
- package/dist/build.d.ts +22 -0
- package/dist/build.js +29 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +333 -0
- package/dist/compose.d.ts +23 -0
- package/dist/compose.js +182 -0
- package/dist/control.d.ts +11 -0
- package/dist/control.js +729 -0
- package/dist/db.d.ts +115 -0
- package/dist/db.js +48 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +20 -0
- package/dist/ingress.d.ts +50 -0
- package/dist/ingress.js +178 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +22 -0
- package/dist/mesh.d.ts +70 -0
- package/dist/mesh.js +183 -0
- package/dist/protocol.d.ts +282 -0
- package/dist/protocol.js +8 -0
- package/dist/runners.d.ts +12 -0
- package/dist/runners.js +10 -0
- package/dist/secrets.d.ts +3 -0
- package/dist/secrets.js +29 -0
- package/dist/templates/agent.env +6 -0
- package/dist/templates/control-compose.yml +18 -0
- package/dist/templates/control-install.sh +7 -0
- package/dist/templates/node-install.sh +8 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +24 -0
- package/package.json +60 -4
package/dist/mesh.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
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({ key: Buffer.concat([PKCS8_X25519_PREFIX, Buffer.from(privateKey, 'base64')]), format: 'der', type: 'pkcs8' });
|
|
21
|
+
const jwk = crypto.createPublicKey(key).export({ format: 'jwk' });
|
|
22
|
+
return Buffer.from(jwk.x, 'base64url').toString('base64');
|
|
23
|
+
}
|
|
24
|
+
export function generateWgKeyPair() {
|
|
25
|
+
const jwk = crypto.generateKeyPairSync('x25519').privateKey.export({ format: 'jwk' });
|
|
26
|
+
const privateKey = Buffer.from(jwk.d, 'base64url').toString('base64');
|
|
27
|
+
return { privateKey, publicKey: Buffer.from(jwk.x, 'base64url').toString('base64') };
|
|
28
|
+
}
|
|
29
|
+
const wgDir = (stateDir) => path.join(stateDir, 'wireguard');
|
|
30
|
+
/** Loads the node's keypair from the state dir, generating it on first use. The private key never leaves this directory. */
|
|
31
|
+
export async function ensureWgKeys(stateDir) {
|
|
32
|
+
const file = path.join(wgDir(stateDir), 'private.key');
|
|
33
|
+
try {
|
|
34
|
+
const privateKey = (await fs.readFile(file, 'utf8')).trim();
|
|
35
|
+
if (isWgKey(privateKey))
|
|
36
|
+
return { privateKey, publicKey: publicKeyFromPrivate(privateKey) };
|
|
37
|
+
}
|
|
38
|
+
catch { /* first boot */ }
|
|
39
|
+
return rotateWgKeys(stateDir);
|
|
40
|
+
}
|
|
41
|
+
export async function rotateWgKeys(stateDir) {
|
|
42
|
+
const pair = generateWgKeyPair();
|
|
43
|
+
await ensureDir(wgDir(stateDir));
|
|
44
|
+
await fs.writeFile(path.join(wgDir(stateDir), 'private.key'), `${pair.privateKey}\n`, { mode: 0o600 });
|
|
45
|
+
return pair;
|
|
46
|
+
}
|
|
47
|
+
/** The public key control last accepted, so the agent only re-reports when it changed. */
|
|
48
|
+
export async function readReportedKey(stateDir) {
|
|
49
|
+
try {
|
|
50
|
+
return (await fs.readFile(path.join(wgDir(stateDir), 'reported.pub'), 'utf8')).trim() || null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function writeReportedKey(stateDir, publicKey) {
|
|
57
|
+
await ensureDir(wgDir(stateDir));
|
|
58
|
+
await fs.writeFile(path.join(wgDir(stateDir), 'reported.pub'), `${publicKey}\n`);
|
|
59
|
+
}
|
|
60
|
+
// ---- Overlay addressing (control) --------------------------------------------------------------
|
|
61
|
+
export function ipToInt(ip) {
|
|
62
|
+
const parts = ip.split('.').map(Number);
|
|
63
|
+
if (parts.length !== 4 || parts.some(p => !Number.isInteger(p) || p < 0 || p > 255))
|
|
64
|
+
throw new Error(`invalid IPv4 address ${ip}`);
|
|
65
|
+
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
66
|
+
}
|
|
67
|
+
export const intToIp = (n) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
|
|
68
|
+
export function parseCidr(cidr) {
|
|
69
|
+
const [ip, bits, extra] = cidr.split('/');
|
|
70
|
+
const prefix = Number(bits);
|
|
71
|
+
if (extra !== undefined || bits === undefined || !Number.isInteger(prefix) || prefix < 8 || prefix > 29)
|
|
72
|
+
throw new Error(`invalid mesh CIDR ${cidr} (IPv4, prefix /8 to /29)`);
|
|
73
|
+
const size = 2 ** (32 - prefix);
|
|
74
|
+
const base = (ipToInt(ip) & ~(size - 1)) >>> 0;
|
|
75
|
+
return { base, prefix, size };
|
|
76
|
+
}
|
|
77
|
+
/** First free host address in the mesh CIDR (never the network or broadcast address). */
|
|
78
|
+
export function allocateOverlayIp(cidr, used) {
|
|
79
|
+
const { base, size } = parseCidr(cidr);
|
|
80
|
+
const taken = new Set([...used].map(ipToInt));
|
|
81
|
+
for (let offset = 1; offset < size - 1; offset++) {
|
|
82
|
+
if (!taken.has(base + offset))
|
|
83
|
+
return intToIp(base + offset);
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`mesh CIDR ${cidr} has no free overlay addresses`);
|
|
86
|
+
}
|
|
87
|
+
/** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
|
|
88
|
+
export function wgEndpoint(publicAddress, port) {
|
|
89
|
+
const addr = publicAddress?.trim();
|
|
90
|
+
if (!addr)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (addr.startsWith('['))
|
|
93
|
+
return /\]:\d+$/.test(addr) ? addr : `${addr}:${port}`;
|
|
94
|
+
if (addr.includes(':') && addr.split(':').length > 2)
|
|
95
|
+
return `[${addr}]:${port}`;
|
|
96
|
+
return /:\d+$/.test(addr) ? addr : `${addr}:${port}`;
|
|
97
|
+
}
|
|
98
|
+
// ---- Config rendering and diffing (agent) ------------------------------------------------------
|
|
99
|
+
/** `wg syncconf` input: interface key and port plus peers (no wg-quick-only keys such as Address). */
|
|
100
|
+
export function renderWgConfig(privateKey, listenPort, peers) {
|
|
101
|
+
const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `ListenPort = ${listenPort}`];
|
|
102
|
+
for (const peer of [...peers].sort((a, b) => a.publicKey.localeCompare(b.publicKey))) {
|
|
103
|
+
lines.push('', '[Peer]', `PublicKey = ${peer.publicKey}`, `AllowedIPs = ${[...peer.allowedIps].sort().join(', ')}`);
|
|
104
|
+
if (peer.endpoint)
|
|
105
|
+
lines.push(`Endpoint = ${peer.endpoint}`);
|
|
106
|
+
lines.push(`PersistentKeepalive = ${peer.persistentKeepalive}`);
|
|
107
|
+
}
|
|
108
|
+
return `${lines.join('\n')}\n`;
|
|
109
|
+
}
|
|
110
|
+
/** Parses `wg show <iface> dump` (tab separated; first line is the interface, then one line per peer). */
|
|
111
|
+
export function parseWgDump(dump) {
|
|
112
|
+
const [header, ...rest] = dump.split('\n').filter(Boolean);
|
|
113
|
+
const h = (header || '').split('\t');
|
|
114
|
+
return {
|
|
115
|
+
publicKey: h[1] || '',
|
|
116
|
+
listenPort: Number(h[2]) || 0,
|
|
117
|
+
peers: rest.map(line => {
|
|
118
|
+
const f = line.split('\t');
|
|
119
|
+
return {
|
|
120
|
+
publicKey: f[0],
|
|
121
|
+
endpoint: f[2] && f[2] !== '(none)' ? f[2] : undefined,
|
|
122
|
+
allowedIps: f[3] && f[3] !== '(none)' ? f[3].split(',') : [],
|
|
123
|
+
keepalive: f[7] && f[7] !== 'off' ? Number(f[7]) : 0
|
|
124
|
+
};
|
|
125
|
+
})
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Compares desired peers with what `wg` reports. A desired endpoint is authoritative; a peer without one keeps whatever it roamed to. */
|
|
129
|
+
export function diffPeers(desired, applied) {
|
|
130
|
+
const have = new Map(applied.map(p => [p.publicKey, p]));
|
|
131
|
+
const want = new Map(desired.map(p => [p.publicKey, p]));
|
|
132
|
+
const sameSet = (a, b) => a.length === b.length && [...a].sort().every((v, i) => v === [...b].sort()[i]);
|
|
133
|
+
const diff = { add: [], remove: [], update: [] };
|
|
134
|
+
for (const [key, peer] of want) {
|
|
135
|
+
const cur = have.get(key);
|
|
136
|
+
if (!cur)
|
|
137
|
+
diff.add.push(key);
|
|
138
|
+
else if (!sameSet(peer.allowedIps, cur.allowedIps) || peer.persistentKeepalive !== cur.keepalive || (peer.endpoint && peer.endpoint !== cur.endpoint))
|
|
139
|
+
diff.update.push(key);
|
|
140
|
+
}
|
|
141
|
+
for (const key of have.keys())
|
|
142
|
+
if (!want.has(key))
|
|
143
|
+
diff.remove.push(key);
|
|
144
|
+
return diff;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Brings `wg0` in line with the control-provided network block without tearing it down: creates the
|
|
148
|
+
* link and address only when missing, and calls `wg syncconf` only when keys, port or peers differ.
|
|
149
|
+
*/
|
|
150
|
+
export async function reconcileMesh(host, stateDir, network) {
|
|
151
|
+
const { privateKey, publicKey } = await ensureWgKeys(stateDir);
|
|
152
|
+
const iface = network.interface;
|
|
153
|
+
const prefix = parseCidr(network.cidr).prefix;
|
|
154
|
+
let created = false;
|
|
155
|
+
try {
|
|
156
|
+
await host('ip', ['link', 'show', iface]);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
await host('ip', ['link', 'add', iface, 'type', 'wireguard']);
|
|
160
|
+
created = true;
|
|
161
|
+
}
|
|
162
|
+
await host('ip', ['address', 'replace', `${network.overlayIp}/${prefix}`, 'dev', iface]);
|
|
163
|
+
if (created)
|
|
164
|
+
await host('ip', ['link', 'set', 'mtu', String(WG_MTU), 'up', 'dev', iface]);
|
|
165
|
+
let applied = { publicKey: '', listenPort: 0, peers: [] };
|
|
166
|
+
if (!created) {
|
|
167
|
+
try {
|
|
168
|
+
applied = parseWgDump((await host('wg', ['show', iface, 'dump'])).out);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
throw new Error(`wg show ${iface} failed: ${errMessage(e)}`, { cause: e });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const diff = diffPeers(network.peers, applied.peers);
|
|
175
|
+
const synced = created || applied.publicKey !== publicKey || applied.listenPort !== network.listenPort || diff.add.length + diff.remove.length + diff.update.length > 0;
|
|
176
|
+
if (synced) {
|
|
177
|
+
const file = path.join(wgDir(stateDir), `${iface}.conf`);
|
|
178
|
+
await ensureDir(wgDir(stateDir));
|
|
179
|
+
await fs.writeFile(file, renderWgConfig(privateKey, network.listenPort, network.peers), { mode: 0o600 });
|
|
180
|
+
await host('wg', ['syncconf', iface, file]);
|
|
181
|
+
}
|
|
182
|
+
return { created, synced, diff };
|
|
183
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
export interface Placement {
|
|
2
|
+
nodes?: string[];
|
|
3
|
+
}
|
|
4
|
+
export interface HealthCheck {
|
|
5
|
+
type: 'docker' | 'tcp' | 'http';
|
|
6
|
+
port?: number | string;
|
|
7
|
+
path?: string;
|
|
8
|
+
/** Duration: number (ms) or "2s" / "500ms" / "1m". */
|
|
9
|
+
interval?: string | number;
|
|
10
|
+
timeout?: string | number;
|
|
11
|
+
retries?: number | string;
|
|
12
|
+
}
|
|
13
|
+
export type IngressTls = 'auto' | 'custom' | 'off';
|
|
14
|
+
/** `x-tug.ingress`: publish a service on a hostname through the managed Caddy edge. */
|
|
15
|
+
export interface IngressSpec {
|
|
16
|
+
host: string;
|
|
17
|
+
/** Container port (or the host port) of the service; must be published in the service's `ports`. */
|
|
18
|
+
port: number;
|
|
19
|
+
/** `auto` (ACME, default), `custom` (cert/key from project secrets) or `off` (plain HTTP). */
|
|
20
|
+
tls?: IngressTls;
|
|
21
|
+
/** Names of project secrets holding the PEM certificate chain and private key; required for `tls: custom`. */
|
|
22
|
+
cert?: string;
|
|
23
|
+
key?: string;
|
|
24
|
+
}
|
|
25
|
+
/** The `x-tug` extension on a Compose service. */
|
|
26
|
+
export interface ServiceExtension {
|
|
27
|
+
placement?: Placement;
|
|
28
|
+
replicas?: number;
|
|
29
|
+
health?: HealthCheck;
|
|
30
|
+
ingress?: IngressSpec;
|
|
31
|
+
}
|
|
32
|
+
/** The `x-tug` extension on a top-level Compose volume. */
|
|
33
|
+
export interface VolumeExtension {
|
|
34
|
+
type?: string;
|
|
35
|
+
driver?: string;
|
|
36
|
+
persistent?: boolean;
|
|
37
|
+
placement?: Placement;
|
|
38
|
+
}
|
|
39
|
+
export type BuildConfig = string | {
|
|
40
|
+
context?: string;
|
|
41
|
+
dockerfile?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
export type ComposeVolumeMount = string | {
|
|
45
|
+
source?: string;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
};
|
|
48
|
+
export interface ComposeService {
|
|
49
|
+
image?: string;
|
|
50
|
+
build?: BuildConfig;
|
|
51
|
+
environment?: Record<string, unknown> | string[];
|
|
52
|
+
volumes?: ComposeVolumeMount[];
|
|
53
|
+
env_file?: string | string[];
|
|
54
|
+
container_name?: string;
|
|
55
|
+
deploy?: unknown;
|
|
56
|
+
ports?: (string | number | {
|
|
57
|
+
target?: number;
|
|
58
|
+
published?: number | string;
|
|
59
|
+
[key: string]: unknown;
|
|
60
|
+
})[];
|
|
61
|
+
extra_hosts?: string[] | Record<string, string>;
|
|
62
|
+
'x-tug'?: ServiceExtension;
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
export interface ComposeVolume {
|
|
66
|
+
'x-tug'?: VolumeExtension;
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
}
|
|
69
|
+
export interface ComposeFile {
|
|
70
|
+
services: Record<string, ComposeService>;
|
|
71
|
+
volumes?: Record<string, ComposeVolume>;
|
|
72
|
+
[key: string]: unknown;
|
|
73
|
+
}
|
|
74
|
+
export interface ServiceSpec {
|
|
75
|
+
image?: string;
|
|
76
|
+
build: BuildConfig | null;
|
|
77
|
+
placement: Placement;
|
|
78
|
+
replicas: number;
|
|
79
|
+
health: HealthCheck | null;
|
|
80
|
+
}
|
|
81
|
+
export interface DesiredState {
|
|
82
|
+
project: string;
|
|
83
|
+
version: string;
|
|
84
|
+
services: Record<string, ServiceSpec>;
|
|
85
|
+
compose: ComposeFile;
|
|
86
|
+
}
|
|
87
|
+
/** Deployment states during which a project is considered busy. */
|
|
88
|
+
export declare const ACTIVE_DEPLOYMENT_STATES: readonly ["uploading", "building", "pending_dependencies", "rolling_out"];
|
|
89
|
+
/** Deployment states a CLI wait loop stops on. */
|
|
90
|
+
export declare const TERMINAL_DEPLOYMENT_STATES: readonly ["succeeded", "failed", "build_failed", "cancelled"];
|
|
91
|
+
export type DeploymentState = (typeof ACTIVE_DEPLOYMENT_STATES)[number] | (typeof TERMINAL_DEPLOYMENT_STATES)[number];
|
|
92
|
+
export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'wg.rotate-key';
|
|
93
|
+
/** A `commands` table row as delivered in `GET /agent/v1/work`. */
|
|
94
|
+
export interface Command {
|
|
95
|
+
id: string;
|
|
96
|
+
node_id?: string;
|
|
97
|
+
project_id?: string | null;
|
|
98
|
+
type: CommandType | (string & {});
|
|
99
|
+
state?: string;
|
|
100
|
+
payload_json?: string | null;
|
|
101
|
+
/** Already-parsed payload; takes effect only when `payload_json` is not a string. */
|
|
102
|
+
payload?: Record<string, unknown>;
|
|
103
|
+
result_json?: string | null;
|
|
104
|
+
attempts?: number;
|
|
105
|
+
run_after?: string | null;
|
|
106
|
+
deadline?: string | null;
|
|
107
|
+
created_at?: string;
|
|
108
|
+
updated_at?: string;
|
|
109
|
+
}
|
|
110
|
+
export interface DiagnosticCheck {
|
|
111
|
+
name: string;
|
|
112
|
+
ok: boolean;
|
|
113
|
+
stdout?: string;
|
|
114
|
+
stderr?: string;
|
|
115
|
+
error?: string;
|
|
116
|
+
}
|
|
117
|
+
export type CommandResultPayload = {
|
|
118
|
+
checks: DiagnosticCheck[];
|
|
119
|
+
node: string;
|
|
120
|
+
} | {
|
|
121
|
+
project: string;
|
|
122
|
+
service: string;
|
|
123
|
+
restarted: true;
|
|
124
|
+
} | {
|
|
125
|
+
stdout: string;
|
|
126
|
+
stderr: string;
|
|
127
|
+
} | {
|
|
128
|
+
project: string;
|
|
129
|
+
volume: string;
|
|
130
|
+
deleted: true;
|
|
131
|
+
} | {
|
|
132
|
+
publicKey: string;
|
|
133
|
+
} | {
|
|
134
|
+
error: string;
|
|
135
|
+
};
|
|
136
|
+
export interface CommandResult {
|
|
137
|
+
commandId: string;
|
|
138
|
+
state: 'succeeded' | 'failed';
|
|
139
|
+
result: CommandResultPayload;
|
|
140
|
+
}
|
|
141
|
+
export interface WorkResponse {
|
|
142
|
+
serverTime: string;
|
|
143
|
+
nodeId: string;
|
|
144
|
+
desiredStates: DesiredState[];
|
|
145
|
+
commands: Command[];
|
|
146
|
+
/** WireGuard mesh config for this node; present only when the mesh is enabled on control. */
|
|
147
|
+
network?: NetworkConfig;
|
|
148
|
+
/** Healthy cross-node endpoints per project and service; present only when service DNS is enabled. */
|
|
149
|
+
serviceEndpoints?: ServiceEndpoints;
|
|
150
|
+
/** Edge proxy config; present only for nodes marked as ingress nodes. */
|
|
151
|
+
ingress?: IngressWork;
|
|
152
|
+
}
|
|
153
|
+
export interface WgPeer {
|
|
154
|
+
node: string;
|
|
155
|
+
publicKey: string;
|
|
156
|
+
/** `host:port` the peer can be dialed on; absent for nodes behind NAT (they dial out instead). */
|
|
157
|
+
endpoint?: string;
|
|
158
|
+
allowedIps: string[];
|
|
159
|
+
persistentKeepalive: number;
|
|
160
|
+
}
|
|
161
|
+
/** The `network` block of `GET /agent/v1/work`: everything an agent needs to reconcile `wg0`. */
|
|
162
|
+
export interface NetworkConfig {
|
|
163
|
+
interface: string;
|
|
164
|
+
/** This node's overlay address, e.g. `10.210.0.2`. */
|
|
165
|
+
overlayIp: string;
|
|
166
|
+
/** Mesh CIDR, e.g. `10.210.0.0/16`; `overlayIp` is assigned to `wg0` with this prefix length. */
|
|
167
|
+
cidr: string;
|
|
168
|
+
listenPort: number;
|
|
169
|
+
peers: WgPeer[];
|
|
170
|
+
}
|
|
171
|
+
/** Body of `POST /agent/v1/wireguard/key` (also accepted as `wgPublicKey` on register and enroll). */
|
|
172
|
+
export interface WgKeyRequest {
|
|
173
|
+
publicKey: string;
|
|
174
|
+
}
|
|
175
|
+
export interface ServiceEndpoint {
|
|
176
|
+
node: string;
|
|
177
|
+
address: string;
|
|
178
|
+
}
|
|
179
|
+
/** project -> service -> endpoints of nodes whose copy of the service reports `serving`. */
|
|
180
|
+
export type ServiceEndpoints = Record<string, Record<string, ServiceEndpoint[]>>;
|
|
181
|
+
export interface IngressRoute {
|
|
182
|
+
project: string;
|
|
183
|
+
service: string;
|
|
184
|
+
host: string;
|
|
185
|
+
tls: IngressTls;
|
|
186
|
+
/** `host:port` upstreams, one per serving node (`127.0.0.1` for the ingress node itself). */
|
|
187
|
+
upstreams: string[];
|
|
188
|
+
}
|
|
189
|
+
/** Edge config rendered by control for one ingress node. */
|
|
190
|
+
export interface IngressWork {
|
|
191
|
+
caddyfile: string;
|
|
192
|
+
/** Extra files for the Caddy container, keyed by path relative to `/etc/caddy` (custom TLS certs and keys). */
|
|
193
|
+
files: Record<string, string>;
|
|
194
|
+
routes: IngressRoute[];
|
|
195
|
+
/** Routes control had to skip (no serving upstream, missing TLS secret), for operator visibility. */
|
|
196
|
+
warnings: string[];
|
|
197
|
+
}
|
|
198
|
+
export interface IngressStatus {
|
|
199
|
+
state: 'serving' | 'failed';
|
|
200
|
+
/** sha256 of the applied Caddyfile and files. */
|
|
201
|
+
configHash: string;
|
|
202
|
+
routes: number;
|
|
203
|
+
error?: string;
|
|
204
|
+
}
|
|
205
|
+
export type ProjectHealth = 'serving' | 'skipped' | 'failed';
|
|
206
|
+
export type RolloutState = 'pending' | 'pulling' | 'starting' | 'health_checking' | 'serving';
|
|
207
|
+
export interface ServiceState {
|
|
208
|
+
rolloutState: RolloutState;
|
|
209
|
+
image?: string;
|
|
210
|
+
healthyAt?: string;
|
|
211
|
+
}
|
|
212
|
+
export interface ProjectStatus {
|
|
213
|
+
project: string;
|
|
214
|
+
observedVersion: string;
|
|
215
|
+
health: ProjectHealth;
|
|
216
|
+
services?: string[];
|
|
217
|
+
serviceStates?: Record<string, ServiceState>;
|
|
218
|
+
error?: string;
|
|
219
|
+
}
|
|
220
|
+
export interface StatusReport {
|
|
221
|
+
node: string;
|
|
222
|
+
projects: ProjectStatus[];
|
|
223
|
+
commandResults: CommandResult[];
|
|
224
|
+
ingress?: IngressStatus;
|
|
225
|
+
}
|
|
226
|
+
export interface SecretsResponse {
|
|
227
|
+
secrets: Record<string, string>;
|
|
228
|
+
versions: Record<string, number>;
|
|
229
|
+
}
|
|
230
|
+
export interface AgentEvent {
|
|
231
|
+
projectId?: string | null;
|
|
232
|
+
deploymentId?: string | null;
|
|
233
|
+
level?: string;
|
|
234
|
+
type?: string;
|
|
235
|
+
message?: string;
|
|
236
|
+
metadata?: Record<string, unknown>;
|
|
237
|
+
}
|
|
238
|
+
export interface NodeInfo {
|
|
239
|
+
id: string;
|
|
240
|
+
name: string;
|
|
241
|
+
address: string | null;
|
|
242
|
+
public_address: string | null;
|
|
243
|
+
overlay_ip?: string | null;
|
|
244
|
+
status: string;
|
|
245
|
+
created_at: string;
|
|
246
|
+
updated_at: string;
|
|
247
|
+
}
|
|
248
|
+
/** Response of `POST /api/v1/nodes/register`. */
|
|
249
|
+
export interface RegisterResponse {
|
|
250
|
+
node: NodeInfo;
|
|
251
|
+
agentToken: string;
|
|
252
|
+
registryToken: string;
|
|
253
|
+
}
|
|
254
|
+
/** Body of `POST /agent/v1/enroll`. */
|
|
255
|
+
export interface EnrollRequest {
|
|
256
|
+
enrollToken?: string;
|
|
257
|
+
name?: string;
|
|
258
|
+
address?: string;
|
|
259
|
+
publicAddress?: string;
|
|
260
|
+
/** Base64 WireGuard public key; the private key never leaves the node. */
|
|
261
|
+
wgPublicKey?: string;
|
|
262
|
+
}
|
|
263
|
+
/** Response of `POST /agent/v1/enroll`. */
|
|
264
|
+
export interface EnrollResponse extends RegisterResponse {
|
|
265
|
+
registry: string;
|
|
266
|
+
}
|
|
267
|
+
/** Response of `POST /api/v1/nodes/enroll-token`. */
|
|
268
|
+
export interface EnrollTokenResponse {
|
|
269
|
+
enrollToken: string;
|
|
270
|
+
name: string | null;
|
|
271
|
+
usesRemaining: number;
|
|
272
|
+
expiresAt: string;
|
|
273
|
+
registry: string;
|
|
274
|
+
}
|
|
275
|
+
/** The base64url-encoded JSON payload of TUG_JOIN_KEY. */
|
|
276
|
+
export interface JoinKey {
|
|
277
|
+
v: number;
|
|
278
|
+
controlUrl?: string;
|
|
279
|
+
enrollToken: string;
|
|
280
|
+
/** PEM CA certificate to trust for a private/self-signed control plane. */
|
|
281
|
+
ca?: string;
|
|
282
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Single source of truth for everything that crosses the control <-> agent boundary and for the
|
|
2
|
+
// desired-state shape persisted in `desired_states.state_json`. Types plus a few shared constants;
|
|
3
|
+
// control.ts, agent.ts, compose.ts and cli.ts all import from here.
|
|
4
|
+
// ---- Deployments -------------------------------------------------------------------------------
|
|
5
|
+
/** Deployment states during which a project is considered busy. */
|
|
6
|
+
export const ACTIVE_DEPLOYMENT_STATES = ['uploading', 'building', 'pending_dependencies', 'rolling_out'];
|
|
7
|
+
/** Deployment states a CLI wait loop stops on. */
|
|
8
|
+
export const TERMINAL_DEPLOYMENT_STATES = ['succeeded', 'failed', 'build_failed', 'cancelled'];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RunResult } from './build.ts';
|
|
2
|
+
export type DockerRunner = (args: string[], opts?: {
|
|
3
|
+
cwd?: string;
|
|
4
|
+
}) => Promise<RunResult>;
|
|
5
|
+
export type HttpClient = typeof fetch;
|
|
6
|
+
/** Default runner: `docker <args>` via the shared `run()` helper. */
|
|
7
|
+
export declare function createDockerRunner(): DockerRunner;
|
|
8
|
+
/** Runs a host binary (`wg`, `ip`); rejects on a non-zero exit like the docker runner. */
|
|
9
|
+
export type HostRunner = (cmd: string, args: string[], opts?: {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}) => Promise<RunResult>;
|
|
12
|
+
export declare function createHostRunner(): HostRunner;
|
package/dist/runners.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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.
|
|
3
|
+
import { run } from "./build.js";
|
|
4
|
+
/** Default runner: `docker <args>` via the shared `run()` helper. */
|
|
5
|
+
export function createDockerRunner() {
|
|
6
|
+
return (args, opts) => run('docker', args, opts);
|
|
7
|
+
}
|
|
8
|
+
export function createHostRunner() {
|
|
9
|
+
return (cmd, args, opts) => run(cmd, args, opts);
|
|
10
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { ensureDir, exists } from "./util.js";
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export async function loadOrCreateMasterKey(file) {
|
|
6
|
+
await ensureDir(path.dirname(file));
|
|
7
|
+
if (await exists(file))
|
|
8
|
+
return (await fs.readFile(file, 'utf8')).trim();
|
|
9
|
+
const key = crypto.randomBytes(32).toString('base64');
|
|
10
|
+
await fs.writeFile(file, key, { mode: 0o600 });
|
|
11
|
+
return key;
|
|
12
|
+
}
|
|
13
|
+
function keyBytes(key) { return crypto.createHash('sha256').update(key).digest(); }
|
|
14
|
+
export function encrypt(value, masterKey) {
|
|
15
|
+
const iv = crypto.randomBytes(12);
|
|
16
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes(masterKey), iv);
|
|
17
|
+
const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
|
|
18
|
+
const tag = cipher.getAuthTag();
|
|
19
|
+
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
|
20
|
+
}
|
|
21
|
+
export function decrypt(payload, masterKey) {
|
|
22
|
+
const buf = Buffer.from(payload, 'base64');
|
|
23
|
+
const iv = buf.subarray(0, 12);
|
|
24
|
+
const tag = buf.subarray(12, 28);
|
|
25
|
+
const data = buf.subarray(28);
|
|
26
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBytes(masterKey), iv);
|
|
27
|
+
decipher.setAuthTag(tag);
|
|
28
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
services:
|
|
2
|
+
control:
|
|
3
|
+
image: {{IMAGE}}
|
|
4
|
+
restart: unless-stopped
|
|
5
|
+
command: control
|
|
6
|
+
environment:
|
|
7
|
+
PORT: 8080
|
|
8
|
+
TUG_DATA_DIR: /data
|
|
9
|
+
TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
|
|
10
|
+
TUG_REGISTRY: {{REGISTRY_HOST}}
|
|
11
|
+
TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
|
|
12
|
+
TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
|
|
13
|
+
ports:
|
|
14
|
+
- "{{PORT}}:8080"
|
|
15
|
+
volumes:
|
|
16
|
+
- /var/lib/tug-control:/data
|
|
17
|
+
- /var/lib/tug-registry-auth:/registry-auth
|
|
18
|
+
- /var/run/docker.sock:/var/run/docker.sock
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
set -euo pipefail
|
|
2
|
+
mkdir -p /opt/tug-control /var/lib/tug-control
|
|
3
|
+
cat > /opt/tug-control/docker-compose.yml <<'YAML'
|
|
4
|
+
{{COMPOSE}}YAML
|
|
5
|
+
docker compose -f /opt/tug-control/docker-compose.yml up -d
|
|
6
|
+
install -m 0600 /dev/null /var/lib/tug-control/operator.token
|
|
7
|
+
printf %s {{OPERATOR_TOKEN_SH}} > /var/lib/tug-control/operator.token
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
set -euo pipefail
|
|
2
|
+
mkdir -p /etc/tug /var/lib/tug
|
|
3
|
+
cat > /etc/tug/agent.env <<'ENV'
|
|
4
|
+
{{ENV}}ENV
|
|
5
|
+
chmod 0600 /etc/tug/agent.env
|
|
6
|
+
{{DOCKER_LOGIN}}
|
|
7
|
+
docker rm -f tug-agent >/dev/null 2>&1 || true
|
|
8
|
+
docker run -d --name tug-agent --restart unless-stopped --env-file /etc/tug/agent.env {{RUN_FLAGS}}-v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/tug:/var/lib/tug {{IMAGE_SH}} agent
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const now: () => string;
|
|
2
|
+
export declare const id: (prefix: string) => string;
|
|
3
|
+
export declare const token: (prefix?: string) => string;
|
|
4
|
+
export declare const hashToken: (value: string) => string;
|
|
5
|
+
export declare function ensureDir(dir: string): Promise<void>;
|
|
6
|
+
export declare function exists(p: string): Promise<boolean>;
|
|
7
|
+
export declare function requireEnv(name: string, fallback?: string): string;
|
|
8
|
+
export declare function projectRoot(): string;
|
|
9
|
+
export declare function safeJoin(base: string, child: string): string;
|
|
10
|
+
export declare function renderTemplate(str: string, vars: Record<string, string>): string;
|
|
11
|
+
export declare function loadTemplate(templatesDir: string, name: string, vars: Record<string, string>): Promise<string>;
|
|
12
|
+
export declare const errMessage: (e: unknown) => string;
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const now = () => new Date().toISOString();
|
|
5
|
+
export const id = (prefix) => `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
|
|
6
|
+
export const token = (prefix = 'tug') => `${prefix}_${crypto.randomBytes(24).toString('base64url')}`;
|
|
7
|
+
export const hashToken = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
8
|
+
export async function ensureDir(dir) { await fs.mkdir(dir, { recursive: true }); }
|
|
9
|
+
export async function exists(p) { try {
|
|
10
|
+
await fs.access(p);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
} }
|
|
16
|
+
export function requireEnv(name, fallback) { const v = process.env[name] ?? fallback; if (!v)
|
|
17
|
+
throw new Error(`Missing ${name}`); return v; }
|
|
18
|
+
export function projectRoot() { return process.cwd(); }
|
|
19
|
+
export function safeJoin(base, child) { const out = path.resolve(base, child); if (!out.startsWith(path.resolve(base)))
|
|
20
|
+
throw new Error('unsafe path'); return out; }
|
|
21
|
+
export function renderTemplate(str, vars) { return str.replace(/\{\{(\w+)\}\}/g, (m, key) => { if (!(key in vars))
|
|
22
|
+
throw new Error(`missing template var ${key}`); return vars[key]; }); }
|
|
23
|
+
export async function loadTemplate(templatesDir, name, vars) { return renderTemplate(await fs.readFile(path.join(templatesDir, name), 'utf8'), vars); }
|
|
24
|
+
export const errMessage = (e) => (e instanceof Error ? e.message : String(e));
|