@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/README.md +59 -0
- 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 +59 -3
package/dist/control.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import multer from 'multer';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import https from 'node:https';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import * as tar from 'tar';
|
|
8
|
+
import { openDb } from "./db.js";
|
|
9
|
+
import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
|
|
10
|
+
import { buildDesiredState, serviceSecretNames, validateCompose } from "./compose.js";
|
|
11
|
+
import { buildAndPush, unpackContext, run as runCommand } from "./build.js";
|
|
12
|
+
import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
|
|
13
|
+
import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint } from "./mesh.js";
|
|
14
|
+
import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
|
|
15
|
+
import { ACTIVE_DEPLOYMENT_STATES } from "./protocol.js";
|
|
16
|
+
const httpError = (status, message) => Object.assign(new Error(message), { status });
|
|
17
|
+
const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
|
|
18
|
+
const PORT = Number(process.env.PORT || 8080);
|
|
19
|
+
export async function createApp() {
|
|
20
|
+
const DATA_DIR = process.env.TUG_DATA_DIR || './data';
|
|
21
|
+
const upload = multer({ dest: process.env.TUG_UPLOAD_DIR || path.join(DATA_DIR, 'uploads') });
|
|
22
|
+
const dbFile = path.join(DATA_DIR, 'tug.db');
|
|
23
|
+
const masterKeyFile = path.join(DATA_DIR, 'master.key');
|
|
24
|
+
const REGISTRY = process.env.TUG_REGISTRY || 'localhost:5000';
|
|
25
|
+
const REGISTRY_AUTH_PATH = process.env.TUG_REGISTRY_AUTH_PATH || null;
|
|
26
|
+
const REGISTRY_CONTAINER = process.env.TUG_REGISTRY_CONTAINER || null;
|
|
27
|
+
// Networking layers are opt-in; with all of them off the work response is unchanged.
|
|
28
|
+
const MESH = process.env.TUG_MESH === 'true';
|
|
29
|
+
const MESH_CIDR = process.env.TUG_MESH_CIDR || DEFAULT_MESH_CIDR;
|
|
30
|
+
const WG_PORT = Number(process.env.TUG_WG_PORT || DEFAULT_WG_PORT);
|
|
31
|
+
const SERVICE_DNS = (process.env.TUG_SERVICE_DNS ?? String(MESH)) === 'true';
|
|
32
|
+
const ACME_EMAIL = process.env.TUG_ACME_EMAIL || undefined;
|
|
33
|
+
if (MESH)
|
|
34
|
+
parseCidr(MESH_CIDR);
|
|
35
|
+
let htpasswdQueue = Promise.resolve();
|
|
36
|
+
function withHtpasswdLock(fn) {
|
|
37
|
+
const next = htpasswdQueue.catch(() => { }).then(fn);
|
|
38
|
+
htpasswdQueue = next.then(() => { }, () => { });
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
async function reloadRegistry() {
|
|
42
|
+
if (!REGISTRY_CONTAINER)
|
|
43
|
+
return;
|
|
44
|
+
await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch(e => console.error(`registry reload failed: ${e.message}`));
|
|
45
|
+
}
|
|
46
|
+
async function upsertRegistryCredential(name, password) {
|
|
47
|
+
if (!REGISTRY_AUTH_PATH)
|
|
48
|
+
return;
|
|
49
|
+
await withHtpasswdLock(async () => {
|
|
50
|
+
const { out } = await runCommand('htpasswd', ['-nbB', name, password]);
|
|
51
|
+
const line = out.trim();
|
|
52
|
+
let existing = '';
|
|
53
|
+
try {
|
|
54
|
+
existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
const lines = existing.split('\n').filter(Boolean).filter(l => !l.startsWith(`${name}:`));
|
|
58
|
+
lines.push(line);
|
|
59
|
+
await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
|
|
60
|
+
await fs.writeFile(REGISTRY_AUTH_PATH, `${lines.join('\n')}\n`);
|
|
61
|
+
});
|
|
62
|
+
await reloadRegistry();
|
|
63
|
+
}
|
|
64
|
+
async function removeRegistryCredential(name) {
|
|
65
|
+
if (!REGISTRY_AUTH_PATH)
|
|
66
|
+
return;
|
|
67
|
+
await withHtpasswdLock(async () => {
|
|
68
|
+
let existing = '';
|
|
69
|
+
try {
|
|
70
|
+
existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
const lines = existing.split('\n').filter(Boolean).filter(l => !l.startsWith(`${name}:`));
|
|
74
|
+
await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
|
|
75
|
+
await fs.writeFile(REGISTRY_AUTH_PATH, lines.length ? `${lines.join('\n')}\n` : '');
|
|
76
|
+
});
|
|
77
|
+
await reloadRegistry();
|
|
78
|
+
}
|
|
79
|
+
const db = await openDb(dbFile);
|
|
80
|
+
const masterKey = await loadOrCreateMasterKey(masterKeyFile);
|
|
81
|
+
const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
|
|
82
|
+
if (!operator) {
|
|
83
|
+
const t = process.env.TUG_OPERATOR_TOKEN || token('tug_op');
|
|
84
|
+
await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
|
|
85
|
+
await fs.writeFile(path.join(DATA_DIR, 'operator.token'), `${t}\n`, { mode: 0o600 }).catch(() => { });
|
|
86
|
+
console.log(`operator token: ${t}`);
|
|
87
|
+
}
|
|
88
|
+
const app = express();
|
|
89
|
+
app.use(express.json({ limit: '20mb' }));
|
|
90
|
+
const authOperator = async (req, res, next) => {
|
|
91
|
+
const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
|
|
92
|
+
const row = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
|
|
93
|
+
if (!raw || hashToken(raw) !== row?.value)
|
|
94
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
95
|
+
next();
|
|
96
|
+
};
|
|
97
|
+
const authAgent = async (req, res, next) => {
|
|
98
|
+
const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
|
|
99
|
+
const node = raw && await db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw));
|
|
100
|
+
if (!node)
|
|
101
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
102
|
+
req.node = node;
|
|
103
|
+
next();
|
|
104
|
+
};
|
|
105
|
+
async function updateDeploymentConvergence(projectId) {
|
|
106
|
+
const project = await db.get('SELECT * FROM projects WHERE id=?', projectId);
|
|
107
|
+
if (!project?.current_deployment_id)
|
|
108
|
+
return;
|
|
109
|
+
const ds = await db.get('SELECT * FROM desired_states WHERE deployment_id=?', project.current_deployment_id);
|
|
110
|
+
if (!ds)
|
|
111
|
+
return;
|
|
112
|
+
const state = JSON.parse(ds.state_json);
|
|
113
|
+
const required = new Set();
|
|
114
|
+
for (const svc of Object.values(state.services || {}))
|
|
115
|
+
for (const node of svc.placement?.nodes || [])
|
|
116
|
+
required.add(node);
|
|
117
|
+
if (!required.size)
|
|
118
|
+
return;
|
|
119
|
+
const statuses = await db.all('SELECT node_id,observed_version,health FROM node_statuses WHERE project_id=?', projectId);
|
|
120
|
+
const byNode = new Map(statuses.map(s => [s.node_id, s]));
|
|
121
|
+
const failed = [...required].some(node => byNode.get(node)?.observed_version === state.version && byNode.get(node)?.health === 'failed');
|
|
122
|
+
if (failed)
|
|
123
|
+
await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', 'one or more assigned nodes reported failed health', now(), project.current_deployment_id, 'rolling_out');
|
|
124
|
+
const ok = [...required].every(node => byNode.get(node)?.observed_version === state.version && ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
|
|
125
|
+
if (ok)
|
|
126
|
+
await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
|
|
127
|
+
}
|
|
128
|
+
async function currentDesired(projectId) {
|
|
129
|
+
return db.get('SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id WHERE p.id=?', projectId);
|
|
130
|
+
}
|
|
131
|
+
async function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
|
|
132
|
+
const name = `project:${project}`;
|
|
133
|
+
const ts = now();
|
|
134
|
+
await db.run('DELETE FROM locks WHERE expires_at < ?', ts);
|
|
135
|
+
const owner = `${operation}:${id('lock')}`;
|
|
136
|
+
try {
|
|
137
|
+
await db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', name, owner, new Date(Date.now() + ttlMs).toISOString(), ts);
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
if (!/UNIQUE constraint/.test(errMessage(e)))
|
|
141
|
+
throw e;
|
|
142
|
+
const existing = await db.get('SELECT * FROM locks WHERE name=?', name);
|
|
143
|
+
const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
|
|
144
|
+
err.status = 409;
|
|
145
|
+
err.lock = existing;
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
return { name, owner };
|
|
149
|
+
}
|
|
150
|
+
async function releaseLock(lock) {
|
|
151
|
+
if (lock)
|
|
152
|
+
await db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner).catch(() => { });
|
|
153
|
+
}
|
|
154
|
+
function sendError(res, e) { const err = e; res.status(err.status || 500).json({ error: err.message, lock: err.lock || undefined }); }
|
|
155
|
+
app.get('/healthz', (req, res) => res.json({ ok: true }));
|
|
156
|
+
app.post('/api/v1/projects', authOperator, async (req, res) => {
|
|
157
|
+
const name = req.body.name || 'site';
|
|
158
|
+
const ts = now();
|
|
159
|
+
await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
|
|
160
|
+
res.json(await db.get('SELECT * FROM projects WHERE id=?', name));
|
|
161
|
+
});
|
|
162
|
+
app.get('/api/v1/projects/:project', authOperator, async (req, res) => res.json(await db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
|
|
163
|
+
const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,status,created_at,updated_at FROM nodes WHERE id=?';
|
|
164
|
+
/** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
|
|
165
|
+
async function ensureOverlayIp(nodeId) {
|
|
166
|
+
if (!MESH)
|
|
167
|
+
return;
|
|
168
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
169
|
+
const row = await db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
|
|
170
|
+
if (row?.overlay_ip)
|
|
171
|
+
return;
|
|
172
|
+
const used = (await db.all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')).map(r => r.overlay_ip);
|
|
173
|
+
try {
|
|
174
|
+
await db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
if (!/UNIQUE constraint/.test(errMessage(e)))
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
throw new Error('could not allocate an overlay address');
|
|
183
|
+
}
|
|
184
|
+
/** Stores (or refreshes) the node's WireGuard peer entry; the private key never reaches control. */
|
|
185
|
+
async function syncWgPeer(nodeId, publicKey) {
|
|
186
|
+
if (!MESH)
|
|
187
|
+
return;
|
|
188
|
+
const node = await db.get('SELECT * FROM nodes WHERE id=?', nodeId);
|
|
189
|
+
const existing = await db.get('SELECT * FROM wg_peers WHERE node_id=?', nodeId);
|
|
190
|
+
const key = publicKey ?? existing?.public_key;
|
|
191
|
+
if (!node?.overlay_ip || !key)
|
|
192
|
+
return;
|
|
193
|
+
await db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
|
|
194
|
+
ON CONFLICT(node_id) DO UPDATE SET public_key=excluded.public_key,overlay_ip=excluded.overlay_ip,endpoint=excluded.endpoint,allowed_ips=excluded.allowed_ips,updated_at=excluded.updated_at`, nodeId, key, node.overlay_ip, wgEndpoint(node.public_address, WG_PORT) ?? null, `${node.overlay_ip}/32`, now());
|
|
195
|
+
}
|
|
196
|
+
async function registerNode({ name, address, publicAddress, wgPublicKey }) {
|
|
197
|
+
if (wgPublicKey !== undefined && !isWgKey(wgPublicKey))
|
|
198
|
+
throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
|
|
199
|
+
const agentToken = token('tug_agent');
|
|
200
|
+
const registryToken = token('tug_reg');
|
|
201
|
+
const ts = now();
|
|
202
|
+
await db.run(`INSERT INTO nodes(id,name,address,public_address,status,agent_token_hash,registry_token_hash,created_at,updated_at)
|
|
203
|
+
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,agent_token_hash=excluded.agent_token_hash,registry_token_hash=excluded.registry_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', hashToken(agentToken), hashToken(registryToken), ts, ts);
|
|
204
|
+
await ensureOverlayIp(name);
|
|
205
|
+
await syncWgPeer(name, wgPublicKey);
|
|
206
|
+
await upsertRegistryCredential(name, registryToken);
|
|
207
|
+
const node = (await db.get(NODE_INFO_SQL, name));
|
|
208
|
+
return { node, agentToken, registryToken };
|
|
209
|
+
}
|
|
210
|
+
app.post('/api/v1/nodes/register', authOperator, async (req, res) => {
|
|
211
|
+
const name = req.body.name;
|
|
212
|
+
if (!name)
|
|
213
|
+
return res.status(400).json({ error: 'name required' });
|
|
214
|
+
try {
|
|
215
|
+
res.json(await registerNode({ name, address: req.body.address, publicAddress: req.body.publicAddress, wgPublicKey: req.body.wgPublicKey }));
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
sendError(res, e);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
// Self-service enrollment: operator mints a short-lived, limited-use key; the agent
|
|
222
|
+
// exchanges it for durable credentials on first boot (Portainer Edge-style join).
|
|
223
|
+
app.post('/api/v1/nodes/enroll-token', authOperator, async (req, res) => {
|
|
224
|
+
const name = req.body.name || null;
|
|
225
|
+
const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? 3600));
|
|
226
|
+
const uses = Math.max(1, Number(req.body.uses ?? 1));
|
|
227
|
+
const enrollToken = token('tug_enroll');
|
|
228
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
229
|
+
await db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
|
|
230
|
+
res.json({ enrollToken, name, usesRemaining: uses, expiresAt, registry: REGISTRY });
|
|
231
|
+
});
|
|
232
|
+
app.post('/agent/v1/enroll', async (req, res) => {
|
|
233
|
+
const body = req.body;
|
|
234
|
+
const raw = String(body.enrollToken || (req.headers.authorization || '').replace(/^Bearer /, '')).trim();
|
|
235
|
+
if (!raw)
|
|
236
|
+
return res.status(400).json({ error: 'enrollToken required' });
|
|
237
|
+
const row = await db.get('SELECT * FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
238
|
+
if (!row || row.uses_remaining < 1 || row.expires_at < now()) {
|
|
239
|
+
if (row)
|
|
240
|
+
await db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
241
|
+
return res.status(401).json({ error: 'invalid or expired enroll token' });
|
|
242
|
+
}
|
|
243
|
+
const name = row.name || body.name;
|
|
244
|
+
if (!name)
|
|
245
|
+
return res.status(400).json({ error: 'node name required (bind one to the enroll token or send name)' });
|
|
246
|
+
let result;
|
|
247
|
+
try {
|
|
248
|
+
result = await registerNode({ name, address: body.address, publicAddress: body.publicAddress, wgPublicKey: body.wgPublicKey });
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return sendError(res, e);
|
|
252
|
+
}
|
|
253
|
+
const remaining = row.uses_remaining - 1;
|
|
254
|
+
if (remaining < 1)
|
|
255
|
+
await db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
256
|
+
else
|
|
257
|
+
await db.run('UPDATE enroll_tokens SET uses_remaining=? WHERE token_hash=?', remaining, hashToken(raw));
|
|
258
|
+
const enrolled = { ...result, registry: REGISTRY };
|
|
259
|
+
res.json(enrolled);
|
|
260
|
+
});
|
|
261
|
+
app.get('/api/v1/nodes', authOperator, async (req, res) => res.json({ nodes: await db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,last_seen_at,created_at,updated_at FROM nodes ORDER BY name') }));
|
|
262
|
+
app.post('/api/v1/nodes/:name/rotate-registry-token', authOperator, async (req, res) => {
|
|
263
|
+
const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
|
|
264
|
+
if (!node)
|
|
265
|
+
return res.status(404).json({ error: 'node not found' });
|
|
266
|
+
const registryToken = token('tug_reg');
|
|
267
|
+
await db.run('UPDATE nodes SET registry_token_hash=?,updated_at=? WHERE id=?', hashToken(registryToken), now(), node.id);
|
|
268
|
+
await upsertRegistryCredential(node.name, registryToken);
|
|
269
|
+
res.json({ ok: true, node: node.name, registryToken });
|
|
270
|
+
});
|
|
271
|
+
app.post('/api/v1/nodes/:name/revoke-registry-token', authOperator, async (req, res) => {
|
|
272
|
+
const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
|
|
273
|
+
if (!node)
|
|
274
|
+
return res.status(404).json({ error: 'node not found' });
|
|
275
|
+
await db.run('UPDATE nodes SET registry_token_hash=NULL,updated_at=? WHERE id=?', now(), node.id);
|
|
276
|
+
await removeRegistryCredential(node.name);
|
|
277
|
+
res.json({ ok: true, node: node.name, revoked: true });
|
|
278
|
+
});
|
|
279
|
+
async function findNode(nameOrId) { return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId); }
|
|
280
|
+
function queueCommand(nodeId, projectId, type, payload = {}) {
|
|
281
|
+
const commandId = id('cmd');
|
|
282
|
+
const ts = now();
|
|
283
|
+
return db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts, null, ts, ts).then(() => commandId);
|
|
284
|
+
}
|
|
285
|
+
// The private key lives only on the node, so rotation is a command the agent executes: it
|
|
286
|
+
// generates a new keypair, reports the public key and peers pick it up from their next work response.
|
|
287
|
+
app.post('/api/v1/nodes/:name/rotate-wg-key', authOperator, async (req, res) => {
|
|
288
|
+
if (!MESH)
|
|
289
|
+
return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
|
|
290
|
+
const node = await findNode(req.params.name);
|
|
291
|
+
if (!node)
|
|
292
|
+
return res.status(404).json({ error: 'node not found' });
|
|
293
|
+
res.json({ commandId: await queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
|
|
294
|
+
});
|
|
295
|
+
app.post('/api/v1/nodes/:name/ingress', authOperator, async (req, res) => {
|
|
296
|
+
const node = await findNode(req.params.name);
|
|
297
|
+
if (!node)
|
|
298
|
+
return res.status(404).json({ error: 'node not found' });
|
|
299
|
+
const enabled = req.body.enabled !== false;
|
|
300
|
+
await db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
|
|
301
|
+
res.json({ ok: true, node: node.name, ingress: enabled });
|
|
302
|
+
});
|
|
303
|
+
app.post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
|
|
304
|
+
if (!MESH)
|
|
305
|
+
return res.status(409).json({ error: 'the WireGuard mesh is not enabled' });
|
|
306
|
+
const { publicKey } = req.body;
|
|
307
|
+
if (!isWgKey(publicKey))
|
|
308
|
+
return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
|
|
309
|
+
try {
|
|
310
|
+
await syncWgPeer(req.node.id, publicKey);
|
|
311
|
+
res.json({ ok: true });
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
sendError(res, /UNIQUE constraint/.test(errMessage(e)) ? httpError(409, 'public key already registered to another node') : e);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
app.post('/api/v1/registry/auth', async (req, res) => {
|
|
318
|
+
const username = req.body.username;
|
|
319
|
+
const password = req.body.password;
|
|
320
|
+
const node = username && password ? await db.get('SELECT id,name FROM nodes WHERE (name=? OR id=?) AND registry_token_hash IS NOT NULL AND registry_token_hash=?', username, username, hashToken(password)) : null;
|
|
321
|
+
if (!node)
|
|
322
|
+
return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
|
|
323
|
+
res.json({ ok: true, node: node.name });
|
|
324
|
+
});
|
|
325
|
+
app.post('/api/v1/secrets', authOperator, async (req, res) => {
|
|
326
|
+
const { project = 'site', name, value } = req.body;
|
|
327
|
+
if (!name || value == null)
|
|
328
|
+
return res.status(400).json({ error: 'name and value required' });
|
|
329
|
+
let lock;
|
|
330
|
+
try {
|
|
331
|
+
lock = await acquireProjectLock(project, 'secret.set');
|
|
332
|
+
const old = await db.get('SELECT version FROM secrets WHERE project_id=? AND name=?', project, name);
|
|
333
|
+
const ts = now();
|
|
334
|
+
await db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
|
|
335
|
+
ON CONFLICT(project_id,name) DO UPDATE SET encrypted_value=excluded.encrypted_value,version=excluded.version,updated_at=excluded.updated_at`, id('sec'), project, name, encrypt(value, masterKey), (old?.version || 0) + 1, ts, ts);
|
|
336
|
+
res.json({ ok: true, name, version: (old?.version || 0) + 1 });
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
sendError(res, e);
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
await releaseLock(lock);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
app.get('/api/v1/secrets', authOperator, async (req, res) => res.json({ secrets: await db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
|
|
346
|
+
app.delete('/api/v1/secrets/:name', authOperator, async (req, res) => { await db.run('DELETE FROM secrets WHERE project_id=? AND name=?', req.query.project || 'site', req.params.name); res.json({ ok: true }); });
|
|
347
|
+
app.post('/api/v1/deployments', authOperator, upload.single('context'), async (req, res) => {
|
|
348
|
+
const body = req.file ? JSON.parse(req.body.payload) : req.body;
|
|
349
|
+
const project = body.project || 'site';
|
|
350
|
+
const compose = body.compose;
|
|
351
|
+
const version = body.version || `git-${Date.now()}`;
|
|
352
|
+
const nodes = await db.all('SELECT name FROM nodes');
|
|
353
|
+
try {
|
|
354
|
+
validateCompose(compose, nodes.map(n => n.name));
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
return res.status(400).json({ error: errMessage(e) });
|
|
358
|
+
}
|
|
359
|
+
for (const [svcName, svc] of Object.entries(compose.services)) {
|
|
360
|
+
const ing = svc['x-tug']?.ingress;
|
|
361
|
+
if (ing?.tls !== 'custom')
|
|
362
|
+
continue;
|
|
363
|
+
for (const secret of [ing.cert, ing.key]) {
|
|
364
|
+
if (!await db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret))
|
|
365
|
+
return res.status(400).json({ error: `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret:set first` });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const active = await db.get(`SELECT id,state FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) ORDER BY created_at DESC LIMIT 1`, project);
|
|
369
|
+
if (active)
|
|
370
|
+
return res.status(409).json({ error: `deployment ${active.id} is already ${active.state}` });
|
|
371
|
+
const depId = id('dep');
|
|
372
|
+
const ts = now();
|
|
373
|
+
let lock;
|
|
374
|
+
try {
|
|
375
|
+
lock = await acquireProjectLock(project, 'deploy');
|
|
376
|
+
await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
|
|
377
|
+
await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at) VALUES(?,?,?,?,?,?)', depId, project, version, 'building', ts, ts);
|
|
378
|
+
const desired = buildDesiredState({ project, version, compose, registry: REGISTRY, defaultNode: nodes[0]?.name || null });
|
|
379
|
+
if (req.file) {
|
|
380
|
+
const contextDir = path.resolve(DATA_DIR, 'build-contexts', depId);
|
|
381
|
+
await unpackContext(req.file.path, contextDir);
|
|
382
|
+
for (const [svc, spec] of Object.entries(compose.services))
|
|
383
|
+
if (spec.build)
|
|
384
|
+
await buildAndPush({ contextDir, service: svc, buildSpec: spec.build, imageRef: desired.services[svc].image, push: process.env.TUG_PUSH !== 'false' });
|
|
385
|
+
}
|
|
386
|
+
await db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, version, JSON.stringify(desired), ts);
|
|
387
|
+
for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
|
|
388
|
+
const lp = volumeSpec?.['x-tug'] || {};
|
|
389
|
+
const nodeName = lp.placement?.nodes?.[0] || null;
|
|
390
|
+
const node = nodeName ? await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName) : null;
|
|
391
|
+
await db.run(`INSERT INTO volumes(id,project_id,name,type,driver,node_id,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)
|
|
392
|
+
ON CONFLICT(project_id,name) DO UPDATE SET type=excluded.type,driver=excluded.driver,node_id=excluded.node_id,config_json=excluded.config_json,updated_at=excluded.updated_at`, id('vol'), project, volumeName, lp.type || 'local', lp.driver || null, node?.id || null, JSON.stringify(lp), ts, ts);
|
|
393
|
+
}
|
|
394
|
+
await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
|
|
395
|
+
await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=?', 'rolling_out', now(), depId);
|
|
396
|
+
res.json({ deployment: { id: depId, project, version, state: 'rolling_out' }, desiredState: desired });
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=?', 'build_failed', errMessage(e), now(), depId).catch(() => { });
|
|
400
|
+
sendError(res, e);
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await releaseLock(lock);
|
|
404
|
+
if (req.file)
|
|
405
|
+
await fs.rm(req.file.path, { force: true }).catch(() => { });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
app.get('/api/v1/deployments/:id', authOperator, async (req, res) => res.json(await db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
|
|
409
|
+
app.post('/api/v1/deployments/:id/cancel', authOperator, async (req, res) => {
|
|
410
|
+
const row = await db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
|
|
411
|
+
if (!row)
|
|
412
|
+
return res.status(404).json({ error: 'deployment not found' });
|
|
413
|
+
if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
|
|
414
|
+
return res.status(409).json({ error: `deployment is ${row.state}` });
|
|
415
|
+
await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=?', 'cancelled', now(), req.params.id);
|
|
416
|
+
res.json({ ok: true });
|
|
417
|
+
});
|
|
418
|
+
app.post('/api/v1/rollback', authOperator, async (req, res) => {
|
|
419
|
+
const project = req.body.project || 'site';
|
|
420
|
+
let lock;
|
|
421
|
+
try {
|
|
422
|
+
lock = await acquireProjectLock(project, 'rollback');
|
|
423
|
+
const active = await db.get(`SELECT id,state FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) ORDER BY created_at DESC LIMIT 1`, project);
|
|
424
|
+
if (active && !req.body.force)
|
|
425
|
+
return res.status(409).json({ error: `deployment ${active.id} is already ${active.state}; retry with force to rollback anyway` });
|
|
426
|
+
const current = await db.get('SELECT current_deployment_id FROM projects WHERE id=?', project);
|
|
427
|
+
const previousDs = await db.get(`SELECT ds.*, d.version AS deployment_version FROM desired_states ds JOIN deployments d ON d.id=ds.deployment_id
|
|
428
|
+
WHERE ds.project_id=? AND ds.deployment_id != ? AND d.state IN ('succeeded','rolling_out') ORDER BY ds.created_at DESC LIMIT 1`, project, current?.current_deployment_id || '');
|
|
429
|
+
if (!previousDs)
|
|
430
|
+
return res.status(404).json({ error: 'no previous deployment found' });
|
|
431
|
+
const ts = now();
|
|
432
|
+
const depId = id('dep');
|
|
433
|
+
const previousState = JSON.parse(previousDs.state_json);
|
|
434
|
+
await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at) VALUES(?,?,?,?,?,?)', depId, project, `rollback-${previousState.version}-${Date.now()}`, 'rolling_out', ts, ts);
|
|
435
|
+
await db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, previousState.version, JSON.stringify(previousState), ts);
|
|
436
|
+
await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
|
|
437
|
+
res.json({ deployment: await db.get('SELECT * FROM deployments WHERE id=?', depId), desiredState: previousState });
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
sendError(res, e);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
await releaseLock(lock);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
app.get('/api/v1/volumes', authOperator, async (req, res) => res.json({ volumes: await db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
|
|
447
|
+
app.delete('/api/v1/volumes/:name', authOperator, async (req, res) => {
|
|
448
|
+
const project = String(req.query.project || 'site');
|
|
449
|
+
const deleteData = String(req.query.deleteData || 'false') === 'true';
|
|
450
|
+
let lock;
|
|
451
|
+
try {
|
|
452
|
+
lock = deleteData ? await acquireProjectLock(project, 'volume.delete') : null;
|
|
453
|
+
const volume = await db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
|
|
454
|
+
if (!volume)
|
|
455
|
+
return res.status(404).json({ error: 'volume not found' });
|
|
456
|
+
const config = JSON.parse(volume.config_json || '{}');
|
|
457
|
+
if ((config.persistent || volume.type !== 'ephemeral') && !deleteData)
|
|
458
|
+
return res.status(409).json({ error: 'refusing to delete persistent volume without deleteData=true' });
|
|
459
|
+
const ds = await currentDesired(project);
|
|
460
|
+
if (ds) {
|
|
461
|
+
const state = JSON.parse(ds.state_json);
|
|
462
|
+
const inUse = Object.entries(state.compose?.services || {}).filter(([, svc]) => (svc.volumes || []).some(v => (typeof v === 'string' ? v.split(':')[0] : v.source) === req.params.name)).map(([name]) => name);
|
|
463
|
+
if (inUse.length)
|
|
464
|
+
return res.status(409).json({ error: `volume is still referenced by services: ${inUse.join(', ')}` });
|
|
465
|
+
}
|
|
466
|
+
let commandId = null;
|
|
467
|
+
if (deleteData && volume.node_id) {
|
|
468
|
+
commandId = id('cmd');
|
|
469
|
+
const ts = now();
|
|
470
|
+
await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, volume.node_id, project, 'volume.delete', 'pending', JSON.stringify({ project, volume: req.params.name }), 0, ts, null, ts, ts);
|
|
471
|
+
}
|
|
472
|
+
await db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
|
|
473
|
+
res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
sendError(res, e);
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
await releaseLock(lock);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
app.post('/api/v1/control/backups/run', authOperator, async (req, res) => {
|
|
483
|
+
const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
|
|
484
|
+
await ensureDir(backupDir);
|
|
485
|
+
const ts = now();
|
|
486
|
+
const backupId = id('cbak');
|
|
487
|
+
const staging = path.join(backupDir, `${backupId}-staging`);
|
|
488
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
489
|
+
await ensureDir(staging);
|
|
490
|
+
await db.exec('PRAGMA wal_checkpoint(FULL);');
|
|
491
|
+
await fs.copyFile(dbFile, path.join(staging, 'tug.db'));
|
|
492
|
+
await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
|
|
493
|
+
await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
|
|
494
|
+
const artifactRef = path.resolve(backupDir, `${backupId}.tar.gz`);
|
|
495
|
+
await tar.c({ gzip: true, file: artifactRef, cwd: staging }, ['tug.db', 'master.key', 'metadata.json']);
|
|
496
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
497
|
+
await db.run('INSERT INTO backups(id,project_id,target,node_id,state,artifact_ref,metadata_json,created_at,completed_at) VALUES(?,?,?,?,?,?,?,?,?)', backupId, '_control', 'control-plane', null, 'completed', artifactRef, JSON.stringify({ type: 'control' }), ts, now());
|
|
498
|
+
res.json({ backupId, artifactRef, state: 'completed' });
|
|
499
|
+
});
|
|
500
|
+
app.post('/api/v1/control/restore', authOperator, async (req, res) => {
|
|
501
|
+
if (!req.body.confirm)
|
|
502
|
+
return res.status(400).json({ error: 'confirm=true is required; restore overwrites control database and master key, then requires process restart' });
|
|
503
|
+
const artifactRef = req.body.artifactRef;
|
|
504
|
+
if (!artifactRef)
|
|
505
|
+
return res.status(400).json({ error: 'artifactRef required' });
|
|
506
|
+
const restoreDir = process.env.TUG_CONTROL_RESTORE_DIR || path.join(DATA_DIR, 'control-restore');
|
|
507
|
+
await fs.rm(restoreDir, { recursive: true, force: true });
|
|
508
|
+
await ensureDir(restoreDir);
|
|
509
|
+
await tar.x({ file: artifactRef, cwd: restoreDir });
|
|
510
|
+
await fs.copyFile(path.join(restoreDir, 'tug.db'), dbFile);
|
|
511
|
+
await fs.copyFile(path.join(restoreDir, 'master.key'), masterKeyFile);
|
|
512
|
+
res.json({ ok: true, restored: true, restartRequired: true });
|
|
513
|
+
});
|
|
514
|
+
async function commandForService({ project, service, type, payload }) {
|
|
515
|
+
if (!service)
|
|
516
|
+
throw httpError(400, 'service required');
|
|
517
|
+
const ds = await currentDesired(project);
|
|
518
|
+
if (!ds)
|
|
519
|
+
throw httpError(404, 'project has no desired state');
|
|
520
|
+
const state = JSON.parse(ds.state_json);
|
|
521
|
+
const svc = state.services?.[service];
|
|
522
|
+
if (!svc)
|
|
523
|
+
throw httpError(404, 'service not found');
|
|
524
|
+
const nodeName = svc.placement?.nodes?.[0];
|
|
525
|
+
if (!nodeName)
|
|
526
|
+
throw httpError(400, 'service has no explicit node placement');
|
|
527
|
+
const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
|
|
528
|
+
if (!node)
|
|
529
|
+
throw httpError(404, 'node not found');
|
|
530
|
+
const cmdId = id('cmd');
|
|
531
|
+
const ts = now();
|
|
532
|
+
await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, project, type, 'pending', JSON.stringify({ project, service, ...payload }), 0, ts, null, ts, ts);
|
|
533
|
+
return { commandId: cmdId, nodeId: node.id };
|
|
534
|
+
}
|
|
535
|
+
app.post('/api/v1/restart', authOperator, async (req, res) => {
|
|
536
|
+
try {
|
|
537
|
+
res.json(await commandForService({ project: req.body.project || 'site', service: req.body.service, type: 'service.restart', payload: {} }));
|
|
538
|
+
}
|
|
539
|
+
catch (e) {
|
|
540
|
+
res.status(e.status || 500).json({ error: errMessage(e) });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
app.post('/api/v1/logs', authOperator, async (req, res) => {
|
|
544
|
+
const project = req.body.project || 'site';
|
|
545
|
+
const service = req.body.service;
|
|
546
|
+
const tail = Number(req.body.tail || 200);
|
|
547
|
+
if (!service)
|
|
548
|
+
return res.status(400).json({ error: 'service required' });
|
|
549
|
+
const ds = await currentDesired(project);
|
|
550
|
+
if (!ds)
|
|
551
|
+
return res.status(404).json({ error: 'project has no desired state' });
|
|
552
|
+
const state = JSON.parse(ds.state_json);
|
|
553
|
+
const svc = state.services?.[service];
|
|
554
|
+
if (!svc)
|
|
555
|
+
return res.status(404).json({ error: 'service not found' });
|
|
556
|
+
const nodeName = svc.placement?.nodes?.[0];
|
|
557
|
+
if (!nodeName)
|
|
558
|
+
return res.status(400).json({ error: 'service has no explicit node placement' });
|
|
559
|
+
const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
|
|
560
|
+
if (!node)
|
|
561
|
+
return res.status(404).json({ error: 'node not found' });
|
|
562
|
+
const cmdId = id('cmd');
|
|
563
|
+
const ts = now();
|
|
564
|
+
await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, project, 'logs.fetch', 'pending', JSON.stringify({ project, service, tail }), 0, ts, null, ts, ts);
|
|
565
|
+
res.json({ commandId: cmdId, nodeId: node.id });
|
|
566
|
+
});
|
|
567
|
+
app.get('/api/v1/commands/:id', authOperator, async (req, res) => res.json(await db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
|
|
568
|
+
app.get('/api/v1/status', authOperator, async (req, res) => {
|
|
569
|
+
const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
|
|
570
|
+
const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
|
|
571
|
+
await db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ?", now(), cutoff);
|
|
572
|
+
res.json({ projects: await db.all('SELECT * FROM projects'), deployments: await db.all('SELECT * FROM deployments ORDER BY created_at DESC LIMIT 20'), nodes: await db.all('SELECT id,name,status,last_seen_at FROM nodes'), statuses: await db.all('SELECT * FROM node_statuses') });
|
|
573
|
+
});
|
|
574
|
+
app.get('/api/v1/events', authOperator, async (req, res) => {
|
|
575
|
+
const project = req.query.project || null;
|
|
576
|
+
const limit = Math.min(Number(req.query.limit || 100), 500);
|
|
577
|
+
const events = project
|
|
578
|
+
? await db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
|
|
579
|
+
: await db.all('SELECT * FROM events ORDER BY created_at DESC LIMIT ?', limit);
|
|
580
|
+
res.json({ events });
|
|
581
|
+
});
|
|
582
|
+
app.get('/api/v1/diagnostics', authOperator, async (req, res) => {
|
|
583
|
+
const checks = [];
|
|
584
|
+
async function check(name, fn) {
|
|
585
|
+
try {
|
|
586
|
+
checks.push({ name, ok: true, ...(await fn()) });
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
checks.push({ name, ok: false, error: errMessage(e) });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
await check('sqlite', async () => ({ value: (await db.get('SELECT COUNT(*) AS count FROM projects')).count }));
|
|
593
|
+
await check('operator_token', async () => ({ configured: Boolean(await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')) }));
|
|
594
|
+
await check('master_key', async () => ({ configured: Boolean(masterKey) }));
|
|
595
|
+
await check('docker', async () => ({ output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim() }));
|
|
596
|
+
await check('docker_compose', async () => ({ output: (await runCommand('docker', ['compose', 'version'])).out.trim() }));
|
|
597
|
+
if (MESH) {
|
|
598
|
+
await check('mesh', async () => {
|
|
599
|
+
const nodes = await db.all('SELECT n.name, n.overlay_ip, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
|
|
600
|
+
const missing = nodes.filter(n => !n.has_key).map(n => n.name);
|
|
601
|
+
if (missing.length)
|
|
602
|
+
throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
|
|
603
|
+
return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const ingressNodes = await db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
|
|
607
|
+
if (ingressNodes.length) {
|
|
608
|
+
await check('ingress', async () => {
|
|
609
|
+
const bad = ingressNodes.filter(n => (n.ingress_status_json ? JSON.parse(n.ingress_status_json).state : 'unreported') !== 'serving').map(n => n.name);
|
|
610
|
+
if (bad.length)
|
|
611
|
+
throw new Error(`ingress nodes not serving: ${bad.join(', ')}`);
|
|
612
|
+
return { nodes: ingressNodes.map(n => n.name) };
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
res.json({ ok: checks.every(c => c.ok), registry: REGISTRY, checks });
|
|
616
|
+
});
|
|
617
|
+
app.post('/api/v1/diagnostics/nodes', authOperator, async (req, res) => {
|
|
618
|
+
const nodes = req.body.node ? await db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node) : await db.all('SELECT id FROM nodes');
|
|
619
|
+
const ts = now();
|
|
620
|
+
const commands = [];
|
|
621
|
+
for (const node of nodes) {
|
|
622
|
+
const cmdId = id('cmd');
|
|
623
|
+
await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, null, 'diagnostics.run', 'pending', '{}', 0, ts, null, ts, ts);
|
|
624
|
+
commands.push({ commandId: cmdId, nodeId: node.id });
|
|
625
|
+
}
|
|
626
|
+
res.json({ commands });
|
|
627
|
+
});
|
|
628
|
+
async function currentDesiredStates() {
|
|
629
|
+
const rows = await db.all(`SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id ORDER BY ds.created_at DESC`);
|
|
630
|
+
return rows.map((s) => JSON.parse(s.state_json));
|
|
631
|
+
}
|
|
632
|
+
async function endpointsFor(states) {
|
|
633
|
+
const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
|
|
634
|
+
const nodes = await db.all('SELECT n.*, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
|
|
635
|
+
const views = nodes.map(n => ({ name: n.name, overlayIp: MESH && n.has_key ? n.overlay_ip : null, address: n.address, lastSeenAt: n.last_seen_at }));
|
|
636
|
+
const statuses = (await db.all('SELECT node_id,project_id,status_json FROM node_statuses'))
|
|
637
|
+
.map(r => ({ node: nodes.find(n => n.id === r.node_id)?.name ?? r.node_id, project: r.project_id, status: JSON.parse(r.status_json) }));
|
|
638
|
+
return resolveServiceEndpoints(states, views, statuses, staleBefore);
|
|
639
|
+
}
|
|
640
|
+
async function networkFor(node) {
|
|
641
|
+
if (!MESH || !node.overlay_ip)
|
|
642
|
+
return undefined;
|
|
643
|
+
const peers = await db.all('SELECT w.*, n.name FROM wg_peers w JOIN nodes n ON n.id=w.node_id WHERE w.node_id != ? ORDER BY n.name', node.id);
|
|
644
|
+
return {
|
|
645
|
+
interface: WG_INTERFACE, overlayIp: node.overlay_ip, cidr: MESH_CIDR, listenPort: WG_PORT,
|
|
646
|
+
peers: peers.map(p => ({ node: p.name, publicKey: p.public_key, endpoint: p.endpoint ?? undefined, allowedIps: p.allowed_ips.split(','), persistentKeepalive: WG_KEEPALIVE }))
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
async function ingressFor(node, states) {
|
|
650
|
+
const secrets = new Map();
|
|
651
|
+
for (const state of states) {
|
|
652
|
+
for (const svc of Object.values(state.compose.services || {})) {
|
|
653
|
+
const ing = svc['x-tug']?.ingress;
|
|
654
|
+
if (ing?.tls !== 'custom')
|
|
655
|
+
continue;
|
|
656
|
+
for (const name of [ing.cert, ing.key]) {
|
|
657
|
+
const row = await db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', state.project, name);
|
|
658
|
+
if (row)
|
|
659
|
+
secrets.set(`${state.project}/${name}`, decrypt(row.encrypted_value, masterKey));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return buildIngressWork({ states, endpoints: await endpointsFor(states), node: node.name, secrets, acmeEmail: ACME_EMAIL });
|
|
664
|
+
}
|
|
665
|
+
app.get('/agent/v1/work', authAgent, async (req, res) => {
|
|
666
|
+
await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
|
|
667
|
+
const node = (await db.get('SELECT * FROM nodes WHERE id=?', req.node.id));
|
|
668
|
+
const allStates = await currentDesiredStates();
|
|
669
|
+
const desiredStates = allStates.filter(st => Object.values(st.services).some(svc => (svc.placement?.nodes || []).includes(node.name) || !(svc.placement?.nodes?.length)));
|
|
670
|
+
const commands = await db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
|
|
671
|
+
if (commands.length)
|
|
672
|
+
await db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
|
|
673
|
+
const work = { serverTime: now(), nodeId: node.id, desiredStates, commands };
|
|
674
|
+
const network = await networkFor(node);
|
|
675
|
+
if (network)
|
|
676
|
+
work.network = network;
|
|
677
|
+
if (SERVICE_DNS) {
|
|
678
|
+
const all = await endpointsFor(allStates);
|
|
679
|
+
work.serviceEndpoints = Object.fromEntries(desiredStates.map(st => [st.project, all[st.project] || {}]));
|
|
680
|
+
}
|
|
681
|
+
if (node.ingress)
|
|
682
|
+
work.ingress = await ingressFor(node, allStates);
|
|
683
|
+
res.json(work);
|
|
684
|
+
});
|
|
685
|
+
app.post('/agent/v1/status', authAgent, async (req, res) => {
|
|
686
|
+
const ts = now();
|
|
687
|
+
const report = req.body;
|
|
688
|
+
for (const st of report.projects || []) {
|
|
689
|
+
await db.run(`INSERT INTO node_statuses(id,node_id,project_id,observed_version,status_json,health,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(node_id,project_id) DO UPDATE SET observed_version=excluded.observed_version,status_json=excluded.status_json,health=excluded.health,updated_at=excluded.updated_at`, id('ns'), req.node.id, st.project, st.observedVersion, JSON.stringify(st), st.health || 'unknown', ts);
|
|
690
|
+
await updateDeploymentConvergence(st.project);
|
|
691
|
+
}
|
|
692
|
+
for (const cr of (report.commandResults || [])) {
|
|
693
|
+
const result = cr.result || {};
|
|
694
|
+
await db.run('UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=?', cr.state || 'succeeded', JSON.stringify(result), ts, cr.commandId, req.node.id);
|
|
695
|
+
}
|
|
696
|
+
if (report.ingress)
|
|
697
|
+
await db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
|
|
698
|
+
await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
|
|
699
|
+
res.json({ ok: true });
|
|
700
|
+
});
|
|
701
|
+
app.post('/agent/v1/events', authAgent, async (req, res) => { const evs = req.body.events || [req.body]; for (const ev of evs)
|
|
702
|
+
await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)', id('evt'), ev.projectId || null, ev.deploymentId || null, req.node.id, ev.level || 'info', ev.type || 'event', ev.message || '', JSON.stringify(ev.metadata || {}), now()); res.json({ ok: true }); });
|
|
703
|
+
app.get('/agent/v1/secrets', authAgent, async (req, res) => {
|
|
704
|
+
const project = String(req.query.project || 'site');
|
|
705
|
+
let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
|
|
706
|
+
if (req.query.services) {
|
|
707
|
+
const ds = await currentDesired(project);
|
|
708
|
+
const names = String(req.query.services).split(',').map(s => s.trim()).filter(Boolean);
|
|
709
|
+
const allowed = ds ? serviceSecretNames(JSON.parse(ds.state_json).compose, names) : new Set();
|
|
710
|
+
rows = rows.filter(r => allowed.has(r.name));
|
|
711
|
+
}
|
|
712
|
+
const secrets = { secrets: Object.fromEntries(rows.map(r => [r.name, decrypt(r.encrypted_value, masterKey)])), versions: Object.fromEntries(rows.map(r => [r.name, r.version])) };
|
|
713
|
+
res.json(secrets);
|
|
714
|
+
});
|
|
715
|
+
return app;
|
|
716
|
+
}
|
|
717
|
+
export async function listen(app, port = PORT) {
|
|
718
|
+
const certFile = process.env.TUG_TLS_CERT_FILE;
|
|
719
|
+
const keyFile = process.env.TUG_TLS_KEY_FILE;
|
|
720
|
+
if (certFile && keyFile) {
|
|
721
|
+
const [cert, key] = await Promise.all([fs.readFile(certFile), fs.readFile(keyFile)]);
|
|
722
|
+
const server = https.createServer({ cert, key }, app);
|
|
723
|
+
return new Promise(resolve => server.listen(port, () => { console.log(`tug-control listening on ${port} (tls)`); resolve(server); }));
|
|
724
|
+
}
|
|
725
|
+
console.warn('TUG_TLS_CERT_FILE/TUG_TLS_KEY_FILE not set; serving plain HTTP');
|
|
726
|
+
return new Promise(resolve => { const server = app.listen(port, () => { console.log(`tug-control listening on ${port}`); resolve(server); }); });
|
|
727
|
+
}
|
|
728
|
+
if (import.meta.url === `file://${process.argv[1]}`)
|
|
729
|
+
createApp().then(app => listen(app));
|