@wpelevator/tug 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +63 -0
  2. package/dist/agent.d.ts +40 -0
  3. package/dist/agent.js +547 -0
  4. package/dist/archive.d.ts +2 -0
  5. package/dist/archive.js +20 -0
  6. package/dist/artifacts.d.ts +23 -0
  7. package/dist/artifacts.js +238 -0
  8. package/dist/build.d.ts +22 -0
  9. package/dist/build.js +44 -0
  10. package/dist/cli-build.d.ts +15 -0
  11. package/dist/cli-build.js +50 -0
  12. package/dist/cli-config.d.ts +20 -0
  13. package/dist/cli-config.js +47 -0
  14. package/dist/cli-install.d.ts +7 -0
  15. package/dist/cli-install.js +31 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +422 -0
  18. package/dist/compose.d.ts +33 -0
  19. package/dist/compose.js +260 -0
  20. package/dist/control.d.ts +24 -0
  21. package/dist/control.js +1105 -0
  22. package/dist/db.d.ts +117 -0
  23. package/dist/db.js +51 -0
  24. package/dist/image.d.ts +9 -0
  25. package/dist/image.js +20 -0
  26. package/dist/ingress.d.ts +50 -0
  27. package/dist/ingress.js +180 -0
  28. package/dist/main.d.ts +2 -0
  29. package/dist/main.js +22 -0
  30. package/dist/mesh.d.ts +70 -0
  31. package/dist/mesh.js +183 -0
  32. package/dist/protocol.d.ts +326 -0
  33. package/dist/protocol.js +8 -0
  34. package/dist/rollout.d.ts +72 -0
  35. package/dist/rollout.js +319 -0
  36. package/dist/runners.d.ts +12 -0
  37. package/dist/runners.js +10 -0
  38. package/dist/secrets.d.ts +3 -0
  39. package/dist/secrets.js +29 -0
  40. package/dist/templates/agent.env +6 -0
  41. package/dist/templates/control-compose.yml +18 -0
  42. package/dist/templates/control-install.sh +7 -0
  43. package/dist/templates/install-docker.sh +85 -0
  44. package/dist/templates/node-install.sh +8 -0
  45. package/dist/tokens.d.ts +91 -0
  46. package/dist/tokens.js +165 -0
  47. package/dist/util.d.ts +15 -0
  48. package/dist/util.js +35 -0
  49. package/package.json +62 -3
package/dist/tokens.js ADDED
@@ -0,0 +1,165 @@
1
+ import { hashToken, id, now, token } from "./util.js";
2
+ /** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
3
+ export const ROLES = ['read', 'deploy', 'admin'];
4
+ const RANK = { read: 1, deploy: 2, admin: 3 };
5
+ /** Name recorded in the audit trail for the root operator token. */
6
+ export const OPERATOR_ACTOR = 'operator';
7
+ /** Reserved actor for rollbacks control starts on its own. Token names cannot contain a colon, so no token can use it. */
8
+ export const AUTO_ROLLBACK_ACTOR = 'system:auto-rollback';
9
+ export const TOKEN_PREFIX = 'tug_tok';
10
+ export const TOKEN_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
11
+ export const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
12
+ /** `last_used_at` is refreshed at most this often per token, so reads do not turn into writes. */
13
+ const LAST_USED_INTERVAL_MS = 60_000;
14
+ /**
15
+ * The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
16
+ * register an unlisted route, and a test compares this table with the live router. `public` routes do their own
17
+ * credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
18
+ */
19
+ export const ROUTE_ROLES = {
20
+ 'GET /api/v1/status': 'read',
21
+ 'GET /api/v1/events': 'read',
22
+ 'GET /api/v1/projects/:project': 'read',
23
+ 'GET /api/v1/deployments/:id': 'read',
24
+ 'GET /api/v1/volumes': 'read',
25
+ 'GET /api/v1/secrets': 'read',
26
+ 'POST /api/v1/projects': 'deploy',
27
+ 'POST /api/v1/deployments': 'deploy',
28
+ 'POST /api/v1/deployments/:id/cancel': 'deploy',
29
+ 'POST /api/v1/rollback': 'deploy',
30
+ 'POST /api/v1/restart': 'deploy',
31
+ 'POST /api/v1/logs': 'deploy',
32
+ 'GET /api/v1/commands/:id': 'deploy',
33
+ 'POST /api/v1/secrets': 'admin',
34
+ 'DELETE /api/v1/secrets/:name': 'admin',
35
+ 'DELETE /api/v1/volumes/:name': 'admin',
36
+ 'GET /api/v1/nodes': 'admin',
37
+ 'POST /api/v1/nodes/register': 'admin',
38
+ 'POST /api/v1/nodes/enroll-token': 'admin',
39
+ 'POST /api/v1/nodes/:name/rotate-registry-token': 'admin',
40
+ 'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
41
+ 'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
42
+ 'POST /api/v1/nodes/:name/ingress': 'admin',
43
+ 'POST /api/v1/control/backups/run': 'admin',
44
+ 'POST /api/v1/control/restore': 'admin',
45
+ 'GET /api/v1/diagnostics': 'admin',
46
+ 'POST /api/v1/diagnostics/nodes': 'admin',
47
+ 'POST /api/v1/tokens': 'admin',
48
+ 'GET /api/v1/tokens': 'admin',
49
+ 'DELETE /api/v1/tokens/:name': 'admin',
50
+ 'POST /api/v1/registry/auth': 'public'
51
+ };
52
+ const statusError = (status, message) => Object.assign(new Error(message), { status });
53
+ export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
54
+ export const hasRole = (actor, required) => RANK[actor.role] >= RANK[required];
55
+ /** Validates a token request body, throwing a 400 error describing the first problem. */
56
+ export function parseTokenRequest(body) {
57
+ const input = (body && typeof body === 'object' ? body : {});
58
+ const { name, role, projects, expiresInSeconds } = input;
59
+ if (typeof name !== 'string' || !TOKEN_NAME_PATTERN.test(name))
60
+ throw statusError(400, 'token name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
61
+ if (name === OPERATOR_ACTOR)
62
+ throw statusError(400, `token name ${OPERATOR_ACTOR} is reserved for the root operator token`);
63
+ if (!isRole(role))
64
+ throw statusError(400, `role must be one of ${ROLES.join(', ')}`);
65
+ let scope = null;
66
+ if (projects !== undefined && projects !== null) {
67
+ if (!Array.isArray(projects) || !projects.length || projects.length > 100 || projects.some(p => typeof p !== 'string' || !PROJECT_NAME_PATTERN.test(p)))
68
+ throw statusError(400, 'projects must be a nonempty list of valid project names, or omitted for all projects');
69
+ if (role === 'admin')
70
+ throw statusError(400, 'admin tokens manage the whole control plane and cannot be limited to projects');
71
+ scope = [...new Set(projects)].sort();
72
+ }
73
+ let expiresAt = null;
74
+ if (expiresInSeconds !== undefined && expiresInSeconds !== null) {
75
+ if (typeof expiresInSeconds !== 'number' || !Number.isSafeInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 10 * 365 * 86400)
76
+ throw statusError(400, 'expiresInSeconds must be a positive integer of at most ten years');
77
+ expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
78
+ }
79
+ return { name, role, projects: scope, expiresAt };
80
+ }
81
+ const info = (row) => ({ name: row.name, role: row.role, projects: row.projects_json ? JSON.parse(row.projects_json) : null, createdAt: row.created_at, createdBy: row.created_by, lastUsedAt: row.last_used_at, expiresAt: row.expires_at, revokedAt: row.revoked_at });
82
+ /** Creates a token. The plaintext is returned once; only its hash is stored. */
83
+ export async function createToken(db, request, createdBy) {
84
+ const value = token(TOKEN_PREFIX);
85
+ try {
86
+ await db.run('INSERT INTO api_tokens(id,name,token_hash,role,projects_json,created_at,created_by,expires_at) VALUES(?,?,?,?,?,?,?,?)', id('tok'), request.name, hashToken(value), request.role, request.projects ? JSON.stringify(request.projects) : null, now(), createdBy, request.expiresAt);
87
+ }
88
+ catch (e) {
89
+ if (/UNIQUE constraint/.test(e.message))
90
+ throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
91
+ throw e;
92
+ }
93
+ return { ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))), token: value };
94
+ }
95
+ export async function listTokens(db) {
96
+ return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
97
+ }
98
+ /** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
99
+ export async function revokeToken(db, name) {
100
+ const row = await db.get('SELECT * FROM api_tokens WHERE name=?', name);
101
+ if (!row)
102
+ return false;
103
+ await db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
104
+ return true;
105
+ }
106
+ /** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
107
+ export async function authenticate(db, raw) {
108
+ if (!raw)
109
+ return null;
110
+ const hash = hashToken(raw);
111
+ const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
112
+ if (operator && hash === operator.value)
113
+ return { name: OPERATOR_ACTOR, role: 'admin', projects: null };
114
+ const row = await db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
115
+ if (!row || row.revoked_at || (row.expires_at && row.expires_at <= now()))
116
+ return null;
117
+ // Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
118
+ if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
119
+ void db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id).catch(() => { });
120
+ return { name: row.name, role: row.role, projects: row.projects_json ? JSON.parse(row.projects_json) : null };
121
+ }
122
+ export const bodyProject = req => String(req.body?.project || 'site');
123
+ export const queryProject = req => String(req.query.project || 'site');
124
+ const FORBIDDEN_PROJECT = 'forbidden: this token may not access that project';
125
+ /** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
126
+ export function assertProject(actor, project) {
127
+ if (actor.projects && !actor.projects.includes(project))
128
+ throw statusError(403, FORBIDDEN_PROJECT);
129
+ }
130
+ /** Builds the request guard: authenticate (401), check the role (403), then check project scope (403). */
131
+ export function createGuard(db) {
132
+ return (required, resolve) => async (req, res, next) => {
133
+ try {
134
+ const actor = await authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
135
+ if (!actor) {
136
+ res.status(401).json({ error: 'unauthorized' });
137
+ return;
138
+ }
139
+ if (!hasRole(actor, required)) {
140
+ res.status(403).json({ error: `forbidden: this action requires the ${required} role` });
141
+ return;
142
+ }
143
+ if (resolve) {
144
+ const project = await resolve(req);
145
+ if (project === null && !hasRole(actor, 'admin')) {
146
+ res.status(403).json({ error: 'forbidden: this action requires the admin role' });
147
+ return;
148
+ }
149
+ if (actor.projects && (project === undefined || project === null || !actor.projects.includes(project))) {
150
+ res.status(403).json({ error: FORBIDDEN_PROJECT });
151
+ return;
152
+ }
153
+ }
154
+ req.actor = actor;
155
+ next();
156
+ }
157
+ catch (error) {
158
+ next(error);
159
+ }
160
+ };
161
+ }
162
+ /** Appends a control-originated audit event naming the acting token. */
163
+ export async function recordEvent(db, event) {
164
+ await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), event.project ?? null, event.deploymentId ?? null, null, 'info', event.type, event.message, JSON.stringify(event.metadata ?? {}), now(), event.actor);
165
+ }
package/dist/util.d.ts ADDED
@@ -0,0 +1,15 @@
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;
13
+ /** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
14
+ export declare function parseDuration(value: string | number, fallback?: number): number;
15
+ export declare const isDuration: (value: unknown) => boolean;
package/dist/util.js ADDED
@@ -0,0 +1,35 @@
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));
25
+ /** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
26
+ export function parseDuration(value, fallback = 2000) {
27
+ if (typeof value === 'number')
28
+ return value;
29
+ const m = String(value).match(/^(\d+)(ms|s|m)?$/);
30
+ if (!m)
31
+ return fallback;
32
+ const n = Number(m[1]);
33
+ return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
34
+ }
35
+ export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) || (typeof value === 'string' && /^\d+(ms|s|m)?$/.test(value));
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.0.0",
4
- "description": "Placeholder for the Tug CLI",
5
- "repository": { "type": "git", "url": "git+https://github.com/wpelevator/tug.git" }
3
+ "version": "0.2.0",
4
+ "description": "Portable VPS deployments with Docker Compose",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/wpelevator/tug.git"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "tug": "./dist/cli.js",
12
+ "tug-node": "./dist/main.js"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "start:control": "node src/main.ts control",
22
+ "start:agent": "node src/main.ts agent",
23
+ "build": "tsc -p tsconfig.build.json && node tools/scripts/copy-templates.js",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "prepack": "npm run build",
26
+ "test": "node --test 'src/**/*.test.ts'",
27
+ "lint": "eslint .",
28
+ "format": "eslint . --fix",
29
+ "test:integration": "bash tools/scripts/integration-test.sh",
30
+ "test:integration:network": "bash tools/scripts/integration-network-test.sh",
31
+ "test:integration:agent": "node --test 'tests/integration/**/*.test.ts'",
32
+ "test:smoke:dist": "npm run build && CLI_ENTRY=dist/cli.js node --test src/cli.templates.test.ts",
33
+ "changeset": "changeset",
34
+ "version-packages": "changeset version && npm install --package-lock-only --ignore-scripts",
35
+ "release": "changeset publish",
36
+ "test:integration:rollout": "node tools/scripts/integration-rollout.js",
37
+ "test:integration:no-registry": "node tools/scripts/integration-no-registry.js",
38
+ "test:smoke:image": "node tools/scripts/smoke-runtime-image.js"
39
+ },
40
+ "dependencies": {
41
+ "commander": "^12.1.0",
42
+ "dotenv": "^16.4.7",
43
+ "express": "^4.21.2",
44
+ "js-yaml": "^4.1.0",
45
+ "multer": "^2.2.0",
46
+ "sqlite": "^5.1.1",
47
+ "sqlite3": "^5.1.7",
48
+ "tar": "^7.4.3"
49
+ },
50
+ "devDependencies": {
51
+ "@changesets/cli": "^3.0.3",
52
+ "@eslint/js": "^10.0.1",
53
+ "@types/express": "^4.17.25",
54
+ "@types/js-yaml": "^4.0.9",
55
+ "@types/multer": "^2.3.0",
56
+ "@types/node": "^22.20.5",
57
+ "eslint": "^10.5.0",
58
+ "globals": "^17.7.0",
59
+ "typescript": "^6.0.3",
60
+ "typescript-eslint": "^8.72.1"
61
+ },
62
+ "engines": {
63
+ "node": ">=22.18"
64
+ }
6
65
  }