@wpelevator/tug 0.2.0 → 0.3.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 +39 -4
- package/dist/agent.d.ts +63 -2
- package/dist/agent.js +828 -77
- package/dist/archive.js +9 -3
- package/dist/artifacts.js +97 -34
- package/dist/build.d.ts +1 -1
- package/dist/build.js +17 -8
- package/dist/cli-build.js +34 -8
- package/dist/cli-config.js +16 -4
- package/dist/cli-install.d.ts +10 -0
- package/dist/cli-install.js +38 -4
- package/dist/cli-live-transport.d.ts +3 -0
- package/dist/cli-live-transport.js +230 -0
- package/dist/cli-live.d.ts +77 -0
- package/dist/cli-live.js +249 -0
- package/dist/cli.js +396 -85
- package/dist/compose.d.ts +46 -7
- package/dist/compose.js +173 -37
- package/dist/control.d.ts +4 -1
- package/dist/control.js +1103 -148
- package/dist/db.d.ts +7 -0
- package/dist/db.js +13 -1
- package/dist/ingress.d.ts +10 -2
- package/dist/ingress.js +57 -16
- package/dist/live.d.ts +80 -0
- package/dist/live.js +340 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/rollout.js +85 -19
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +35 -9
- package/dist/util.d.ts +7 -0
- package/dist/util.js +73 -16
- package/package.json +8 -4
package/dist/tokens.js
CHANGED
|
@@ -17,6 +17,7 @@ const LAST_USED_INTERVAL_MS = 60_000;
|
|
|
17
17
|
* credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
|
|
18
18
|
*/
|
|
19
19
|
export const ROUTE_ROLES = {
|
|
20
|
+
'GET /api/v1/live': 'deploy',
|
|
20
21
|
'GET /api/v1/status': 'read',
|
|
21
22
|
'GET /api/v1/events': 'read',
|
|
22
23
|
'GET /api/v1/projects/:project': 'read',
|
|
@@ -27,6 +28,7 @@ export const ROUTE_ROLES = {
|
|
|
27
28
|
'POST /api/v1/deployments': 'deploy',
|
|
28
29
|
'POST /api/v1/deployments/:id/cancel': 'deploy',
|
|
29
30
|
'POST /api/v1/rollback': 'deploy',
|
|
31
|
+
'POST /api/v1/scale': 'deploy',
|
|
30
32
|
'POST /api/v1/restart': 'deploy',
|
|
31
33
|
'POST /api/v1/logs': 'deploy',
|
|
32
34
|
'GET /api/v1/commands/:id': 'deploy',
|
|
@@ -40,6 +42,7 @@ export const ROUTE_ROLES = {
|
|
|
40
42
|
'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
|
|
41
43
|
'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
|
|
42
44
|
'POST /api/v1/nodes/:name/ingress': 'admin',
|
|
45
|
+
'DELETE /api/v1/nodes/:name': 'admin',
|
|
43
46
|
'POST /api/v1/control/backups/run': 'admin',
|
|
44
47
|
'POST /api/v1/control/restore': 'admin',
|
|
45
48
|
'GET /api/v1/diagnostics': 'admin',
|
|
@@ -47,7 +50,7 @@ export const ROUTE_ROLES = {
|
|
|
47
50
|
'POST /api/v1/tokens': 'admin',
|
|
48
51
|
'GET /api/v1/tokens': 'admin',
|
|
49
52
|
'DELETE /api/v1/tokens/:name': 'admin',
|
|
50
|
-
'POST /api/v1/registry/auth': 'public'
|
|
53
|
+
'POST /api/v1/registry/auth': 'public',
|
|
51
54
|
};
|
|
52
55
|
const statusError = (status, message) => Object.assign(new Error(message), { status });
|
|
53
56
|
export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
|
|
@@ -64,7 +67,10 @@ export function parseTokenRequest(body) {
|
|
|
64
67
|
throw statusError(400, `role must be one of ${ROLES.join(', ')}`);
|
|
65
68
|
let scope = null;
|
|
66
69
|
if (projects !== undefined && projects !== null) {
|
|
67
|
-
if (!Array.isArray(projects) ||
|
|
70
|
+
if (!Array.isArray(projects) ||
|
|
71
|
+
!projects.length ||
|
|
72
|
+
projects.length > 100 ||
|
|
73
|
+
projects.some((p) => typeof p !== 'string' || !PROJECT_NAME_PATTERN.test(p)))
|
|
68
74
|
throw statusError(400, 'projects must be a nonempty list of valid project names, or omitted for all projects');
|
|
69
75
|
if (role === 'admin')
|
|
70
76
|
throw statusError(400, 'admin tokens manage the whole control plane and cannot be limited to projects');
|
|
@@ -72,13 +78,25 @@ export function parseTokenRequest(body) {
|
|
|
72
78
|
}
|
|
73
79
|
let expiresAt = null;
|
|
74
80
|
if (expiresInSeconds !== undefined && expiresInSeconds !== null) {
|
|
75
|
-
if (typeof expiresInSeconds !== 'number' ||
|
|
81
|
+
if (typeof expiresInSeconds !== 'number' ||
|
|
82
|
+
!Number.isSafeInteger(expiresInSeconds) ||
|
|
83
|
+
expiresInSeconds < 1 ||
|
|
84
|
+
expiresInSeconds > 10 * 365 * 86400)
|
|
76
85
|
throw statusError(400, 'expiresInSeconds must be a positive integer of at most ten years');
|
|
77
86
|
expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
|
78
87
|
}
|
|
79
88
|
return { name, role, projects: scope, expiresAt };
|
|
80
89
|
}
|
|
81
|
-
const info = (row) => ({
|
|
90
|
+
const info = (row) => ({
|
|
91
|
+
name: row.name,
|
|
92
|
+
role: row.role,
|
|
93
|
+
projects: row.projects_json ? JSON.parse(row.projects_json) : null,
|
|
94
|
+
createdAt: row.created_at,
|
|
95
|
+
createdBy: row.created_by,
|
|
96
|
+
lastUsedAt: row.last_used_at,
|
|
97
|
+
expiresAt: row.expires_at,
|
|
98
|
+
revokedAt: row.revoked_at,
|
|
99
|
+
});
|
|
82
100
|
/** Creates a token. The plaintext is returned once; only its hash is stored. */
|
|
83
101
|
export async function createToken(db, request, createdBy) {
|
|
84
102
|
const value = token(TOKEN_PREFIX);
|
|
@@ -90,7 +108,10 @@ export async function createToken(db, request, createdBy) {
|
|
|
90
108
|
throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
|
|
91
109
|
throw e;
|
|
92
110
|
}
|
|
93
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))),
|
|
113
|
+
token: value,
|
|
114
|
+
};
|
|
94
115
|
}
|
|
95
116
|
export async function listTokens(db) {
|
|
96
117
|
return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
|
|
@@ -117,10 +138,14 @@ export async function authenticate(db, raw) {
|
|
|
117
138
|
// Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
|
|
118
139
|
if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
|
|
119
140
|
void db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id).catch(() => { });
|
|
120
|
-
return {
|
|
141
|
+
return {
|
|
142
|
+
name: row.name,
|
|
143
|
+
role: row.role,
|
|
144
|
+
projects: row.projects_json ? JSON.parse(row.projects_json) : null,
|
|
145
|
+
};
|
|
121
146
|
}
|
|
122
|
-
export const bodyProject = req => String(req.body?.project || 'site');
|
|
123
|
-
export const queryProject = req => String(req.query.project || 'site');
|
|
147
|
+
export const bodyProject = (req) => String(req.body?.project || 'site');
|
|
148
|
+
export const queryProject = (req) => String(req.query.project || 'site');
|
|
124
149
|
const FORBIDDEN_PROJECT = 'forbidden: this token may not access that project';
|
|
125
150
|
/** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
|
|
126
151
|
export function assertProject(actor, project) {
|
|
@@ -146,7 +171,8 @@ export function createGuard(db) {
|
|
|
146
171
|
res.status(403).json({ error: 'forbidden: this action requires the admin role' });
|
|
147
172
|
return;
|
|
148
173
|
}
|
|
149
|
-
if (actor.projects &&
|
|
174
|
+
if (actor.projects &&
|
|
175
|
+
(project === undefined || project === null || !actor.projects.includes(project))) {
|
|
150
176
|
res.status(403).json({ error: FORBIDDEN_PROJECT });
|
|
151
177
|
return;
|
|
152
178
|
}
|
package/dist/util.d.ts
CHANGED
|
@@ -13,3 +13,10 @@ export declare const errMessage: (e: unknown) => string;
|
|
|
13
13
|
/** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
|
|
14
14
|
export declare function parseDuration(value: string | number, fallback?: number): number;
|
|
15
15
|
export declare const isDuration: (value: unknown) => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* True for an IP address that is routable on the internet: anything outside loopback, private
|
|
18
|
+
* (RFC 1918, IPv6 ULA), link-local, CGNAT (`100.64.0.0/10`) and the unspecified address. The
|
|
19
|
+
* unspecified addresses `0.0.0.0` and `::` count as public, since listening there means every
|
|
20
|
+
* interface. Hostnames and other non-IP strings return false.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isPublicAddress(address: string): boolean;
|
package/dist/util.js
CHANGED
|
@@ -1,26 +1,48 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
4
5
|
export const now = () => new Date().toISOString();
|
|
5
6
|
export const id = (prefix) => `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
|
|
6
7
|
export const token = (prefix = 'tug') => `${prefix}_${crypto.randomBytes(24).toString('base64url')}`;
|
|
7
8
|
export const hashToken = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
8
|
-
export async function ensureDir(dir) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
export async function ensureDir(dir) {
|
|
10
|
+
await fs.mkdir(dir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
export async function exists(p) {
|
|
13
|
+
try {
|
|
14
|
+
await fs.access(p);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function requireEnv(name, fallback) {
|
|
22
|
+
const v = process.env[name] ?? fallback;
|
|
23
|
+
if (!v)
|
|
24
|
+
throw new Error(`Missing ${name}`);
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
export function projectRoot() {
|
|
28
|
+
return process.cwd();
|
|
29
|
+
}
|
|
30
|
+
export function safeJoin(base, child) {
|
|
31
|
+
const out = path.resolve(base, child);
|
|
32
|
+
if (!out.startsWith(path.resolve(base)))
|
|
33
|
+
throw new Error('unsafe path');
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export function renderTemplate(str, vars) {
|
|
37
|
+
return str.replace(/\{\{(\w+)\}\}/g, (m, key) => {
|
|
38
|
+
if (!(key in vars))
|
|
39
|
+
throw new Error(`missing template var ${key}`);
|
|
40
|
+
return vars[key];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export async function loadTemplate(templatesDir, name, vars) {
|
|
44
|
+
return renderTemplate(await fs.readFile(path.join(templatesDir, name), 'utf8'), vars);
|
|
12
45
|
}
|
|
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
46
|
export const errMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
25
47
|
/** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
|
|
26
48
|
export function parseDuration(value, fallback = 2000) {
|
|
@@ -32,4 +54,39 @@ export function parseDuration(value, fallback = 2000) {
|
|
|
32
54
|
const n = Number(m[1]);
|
|
33
55
|
return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
|
|
34
56
|
}
|
|
35
|
-
export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) ||
|
|
57
|
+
export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) ||
|
|
58
|
+
(typeof value === 'string' && /^\d+(ms|s|m)?$/.test(value));
|
|
59
|
+
/**
|
|
60
|
+
* True for an IP address that is routable on the internet: anything outside loopback, private
|
|
61
|
+
* (RFC 1918, IPv6 ULA), link-local, CGNAT (`100.64.0.0/10`) and the unspecified address. The
|
|
62
|
+
* unspecified addresses `0.0.0.0` and `::` count as public, since listening there means every
|
|
63
|
+
* interface. Hostnames and other non-IP strings return false.
|
|
64
|
+
*/
|
|
65
|
+
export function isPublicAddress(address) {
|
|
66
|
+
const ip = address.replace(/^\[|\]$/g, '');
|
|
67
|
+
const kind = isIP(ip);
|
|
68
|
+
if (kind === 4) {
|
|
69
|
+
const [a, b] = ip.split('.').map(Number);
|
|
70
|
+
if (ip === '0.0.0.0')
|
|
71
|
+
return true;
|
|
72
|
+
return !(a === 10 ||
|
|
73
|
+
a === 127 ||
|
|
74
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
75
|
+
(a === 192 && b === 168) ||
|
|
76
|
+
(a === 169 && b === 254) ||
|
|
77
|
+
(a === 100 && b >= 64 && b <= 127));
|
|
78
|
+
}
|
|
79
|
+
if (kind === 6) {
|
|
80
|
+
const lower = ip.toLowerCase();
|
|
81
|
+
if (lower === '::')
|
|
82
|
+
return true;
|
|
83
|
+
if (lower === '::1')
|
|
84
|
+
return false;
|
|
85
|
+
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
86
|
+
if (mapped)
|
|
87
|
+
return isPublicAddress(mapped[1]);
|
|
88
|
+
const first = Number.parseInt(lower.split(':')[0] || '0', 16);
|
|
89
|
+
return !((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80);
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wpelevator/tug",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Portable VPS deployments with Docker Compose",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
25
25
|
"prepack": "npm run build",
|
|
26
26
|
"test": "node --test 'src/**/*.test.ts'",
|
|
27
|
-
"lint": "eslint .",
|
|
28
|
-
"format": "eslint . --fix",
|
|
27
|
+
"lint": "eslint . && prettier --check .",
|
|
28
|
+
"format": "prettier --write . && eslint . --fix",
|
|
29
29
|
"test:integration": "bash tools/scripts/integration-test.sh",
|
|
30
30
|
"test:integration:network": "bash tools/scripts/integration-network-test.sh",
|
|
31
31
|
"test:integration:agent": "node --test 'tests/integration/**/*.test.ts'",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"multer": "^2.2.0",
|
|
46
46
|
"sqlite": "^5.1.1",
|
|
47
47
|
"sqlite3": "^5.1.7",
|
|
48
|
-
"tar": "^7.4.3"
|
|
48
|
+
"tar": "^7.4.3",
|
|
49
|
+
"ws": "^8.22.1"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@changesets/cli": "^3.0.3",
|
|
@@ -54,8 +55,11 @@
|
|
|
54
55
|
"@types/js-yaml": "^4.0.9",
|
|
55
56
|
"@types/multer": "^2.3.0",
|
|
56
57
|
"@types/node": "^22.20.5",
|
|
58
|
+
"@types/ws": "^8.18.2",
|
|
57
59
|
"eslint": "^10.5.0",
|
|
60
|
+
"eslint-config-prettier": "^10.1.8",
|
|
58
61
|
"globals": "^17.7.0",
|
|
62
|
+
"prettier": "^3.9.10",
|
|
59
63
|
"typescript": "^6.0.3",
|
|
60
64
|
"typescript-eslint": "^8.72.1"
|
|
61
65
|
},
|