@wpelevator/tug 0.6.0 → 0.8.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 +24 -22
  2. package/dist/agent-credential.d.ts +39 -0
  3. package/dist/agent-credential.js +135 -0
  4. package/dist/agent.d.ts +15 -9
  5. package/dist/agent.js +126 -104
  6. package/dist/backup-check.d.ts +11 -0
  7. package/dist/backup-check.js +10 -0
  8. package/dist/backup-config.d.ts +5 -0
  9. package/dist/backup-config.js +23 -3
  10. package/dist/backup-destination.d.ts +1 -1
  11. package/dist/backup-destination.js +1 -1
  12. package/dist/backup-restore.js +28 -8
  13. package/dist/backup-scheduler.d.ts +7 -1
  14. package/dist/backup-scheduler.js +21 -6
  15. package/dist/cli-build.js +70 -4
  16. package/dist/cli-config.d.ts +9 -5
  17. package/dist/cli-config.js +89 -34
  18. package/dist/cli-install.js +1 -1
  19. package/dist/cli-live-transport.d.ts +3 -2
  20. package/dist/cli-live-transport.js +9 -5
  21. package/dist/cli.js +276 -142
  22. package/dist/compose.d.ts +3 -1
  23. package/dist/compose.js +14 -2
  24. package/dist/control-credential.d.ts +12 -0
  25. package/dist/control-credential.js +46 -0
  26. package/dist/control.d.ts +18 -1
  27. package/dist/control.js +586 -202
  28. package/dist/credentials.d.ts +111 -0
  29. package/dist/{tokens.js → credentials.js} +70 -54
  30. package/dist/db.d.ts +20 -5
  31. package/dist/db.js +3 -3
  32. package/dist/diagnostics.d.ts +44 -0
  33. package/dist/diagnostics.js +65 -0
  34. package/dist/ingress.js +4 -3
  35. package/dist/main.js +35 -0
  36. package/dist/normalize.d.ts +6 -0
  37. package/dist/normalize.js +16 -2
  38. package/dist/pki.d.ts +119 -0
  39. package/dist/pki.js +339 -0
  40. package/dist/protocol.d.ts +53 -22
  41. package/dist/status-report.d.ts +11 -0
  42. package/dist/status-report.js +147 -0
  43. package/dist/templates/agent.env +1 -3
  44. package/dist/templates/control-compose.yml +4 -3
  45. package/dist/templates/control-install.sh +8 -2
  46. package/dist/tls-server.d.ts +11 -0
  47. package/dist/tls-server.js +23 -0
  48. package/package.json +10 -8
  49. package/dist/tokens.d.ts +0 -91
@@ -0,0 +1,147 @@
1
+ const HEALTH = ['serving', 'skipped', 'failed'];
2
+ const ROLLOUT_STATES = [
3
+ 'pending',
4
+ 'pulling',
5
+ 'starting',
6
+ 'health_checking',
7
+ 'serving',
8
+ 'held',
9
+ ];
10
+ const JOB_PHASES = [
11
+ 'queued',
12
+ 'pulling',
13
+ 'checking',
14
+ 'dumping',
15
+ 'uploading',
16
+ 'forgetting',
17
+ 'restoring',
18
+ ];
19
+ const MAX_ENTRIES = 1000;
20
+ export class StatusReportError extends Error {
21
+ }
22
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ function text(value, path, optional = false) {
24
+ if (value === undefined && optional)
25
+ return undefined;
26
+ if (typeof value !== 'string')
27
+ throw new StatusReportError(`${path} must be a string`);
28
+ return value;
29
+ }
30
+ function list(value, path) {
31
+ if (value === undefined)
32
+ return [];
33
+ if (!Array.isArray(value) || value.length > MAX_ENTRIES)
34
+ throw new StatusReportError(`${path} must be an array of at most ${MAX_ENTRIES} entries`);
35
+ return value;
36
+ }
37
+ /** A TCP port: an integer in 1..65535, never a string, so it cannot carry configuration syntax. */
38
+ export function isPort(value) {
39
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535;
40
+ }
41
+ function serviceState(value, path) {
42
+ if (!isObject(value))
43
+ throw new StatusReportError(`${path} must be an object`);
44
+ if (!ROLLOUT_STATES.includes(value.rolloutState))
45
+ throw new StatusReportError(`${path}.rolloutState must be one of ${ROLLOUT_STATES.join(', ')}`);
46
+ if (value.port !== undefined && !isPort(value.port))
47
+ throw new StatusReportError(`${path}.port must be an integer from 1 to 65535`);
48
+ return {
49
+ rolloutState: value.rolloutState,
50
+ ...(value.image !== undefined && { image: text(value.image, `${path}.image`) }),
51
+ ...(value.healthyAt !== undefined && { healthyAt: text(value.healthyAt, `${path}.healthyAt`) }),
52
+ ...(value.port !== undefined && { port: value.port }),
53
+ ...(value.rolloutRevision !== undefined && {
54
+ rolloutRevision: text(value.rolloutRevision, `${path}.rolloutRevision`),
55
+ }),
56
+ ...(value.rolloutError !== undefined && {
57
+ rolloutError: text(value.rolloutError, `${path}.rolloutError`),
58
+ }),
59
+ };
60
+ }
61
+ function projectStatus(value, path) {
62
+ if (!isObject(value))
63
+ throw new StatusReportError(`${path} must be an object`);
64
+ if (!HEALTH.includes(value.health))
65
+ throw new StatusReportError(`${path}.health must be one of ${HEALTH.join(', ')}`);
66
+ const states = value.serviceStates;
67
+ if (states !== undefined && !isObject(states))
68
+ throw new StatusReportError(`${path}.serviceStates must be an object`);
69
+ return {
70
+ project: text(value.project, `${path}.project`),
71
+ observedVersion: text(value.observedVersion, `${path}.observedVersion`),
72
+ ...(value.observedRevision !== undefined && {
73
+ observedRevision: text(value.observedRevision, `${path}.observedRevision`),
74
+ }),
75
+ health: value.health,
76
+ ...(value.services !== undefined && {
77
+ services: list(value.services, `${path}.services`).map((name, i) => text(name, `${path}.services[${i}]`)),
78
+ }),
79
+ ...(states && {
80
+ serviceStates: Object.fromEntries(Object.entries(states).map(([name, state]) => [
81
+ name,
82
+ serviceState(state, `${path}.serviceStates.${name}`),
83
+ ])),
84
+ }),
85
+ ...(value.error !== undefined && { error: text(value.error, `${path}.error`) }),
86
+ };
87
+ }
88
+ function commandResult(value, path) {
89
+ if (!isObject(value))
90
+ throw new StatusReportError(`${path} must be an object`);
91
+ if (value.state !== 'succeeded' && value.state !== 'failed')
92
+ throw new StatusReportError(`${path}.state must be succeeded or failed`);
93
+ if (value.result !== undefined && !isObject(value.result))
94
+ throw new StatusReportError(`${path}.result must be an object`);
95
+ return {
96
+ commandId: text(value.commandId, `${path}.commandId`),
97
+ state: value.state,
98
+ result: (value.result ?? {}),
99
+ };
100
+ }
101
+ function job(value, path) {
102
+ if (!isObject(value))
103
+ throw new StatusReportError(`${path} must be an object`);
104
+ if (!JOB_PHASES.includes(value.phase))
105
+ throw new StatusReportError(`${path}.phase must be one of ${JOB_PHASES.join(', ')}`);
106
+ return {
107
+ commandId: text(value.commandId, `${path}.commandId`),
108
+ runId: text(value.runId, `${path}.runId`),
109
+ phase: value.phase,
110
+ startedAt: text(value.startedAt, `${path}.startedAt`),
111
+ ...(typeof value.bytes === 'number' && { bytes: value.bytes }),
112
+ };
113
+ }
114
+ function ingressStatus(value) {
115
+ if (!isObject(value))
116
+ throw new StatusReportError('ingress must be an object');
117
+ if (value.state !== 'serving' && value.state !== 'failed')
118
+ throw new StatusReportError('ingress.state must be serving or failed');
119
+ if (typeof value.routes !== 'number' || !Number.isInteger(value.routes) || value.routes < 0)
120
+ throw new StatusReportError('ingress.routes must be a non-negative integer');
121
+ return {
122
+ state: value.state,
123
+ configHash: text(value.configHash, 'ingress.configHash'),
124
+ routes: value.routes,
125
+ ...(value.error !== undefined && { error: text(value.error, 'ingress.error') }),
126
+ };
127
+ }
128
+ /**
129
+ * Validates and normalizes a node's `POST /agent/v1/status` body before anything is stored. Reported values
130
+ * flow into other nodes' work responses and the shared ingress configuration, so unknown fields are dropped
131
+ * and every kept field is checked against its protocol type. Throws `StatusReportError` for a malformed report.
132
+ */
133
+ export function parseStatusReport(body) {
134
+ if (!isObject(body))
135
+ throw new StatusReportError('status report must be an object');
136
+ return {
137
+ node: typeof body.node === 'string' ? body.node : '',
138
+ ...(typeof body.sentAt === 'string' &&
139
+ Number.isFinite(Date.parse(body.sentAt)) && { sentAt: body.sentAt }),
140
+ projects: list(body.projects, 'projects').map((p, i) => projectStatus(p, `projects[${i}]`)),
141
+ commandResults: list(body.commandResults, 'commandResults').map((r, i) => commandResult(r, `commandResults[${i}]`)),
142
+ ...(body.ingress !== undefined && { ingress: ingressStatus(body.ingress) }),
143
+ ...(body.jobs !== undefined && {
144
+ jobs: list(body.jobs, 'jobs').map((j, i) => job(j, `jobs[${i}]`)),
145
+ }),
146
+ };
147
+ }
@@ -1,6 +1,4 @@
1
- TUG_CONTROL_URL={{CONTROL_URL}}
2
- TUG_NODE_NAME={{NODE_NAME}}
3
- TUG_AGENT_TOKEN={{AGENT_TOKEN}}
1
+ {{CONTROL_URL_LINE}}TUG_NODE_NAME={{NODE_NAME}}
4
2
  TUG_STATE_DIR=/var/lib/tug
5
3
  TUG_APPLY=true
6
4
  TUG_POLL_INTERVAL_MS=5000
@@ -4,12 +4,13 @@ services:
4
4
  restart: unless-stopped
5
5
  command: control
6
6
  environment:
7
- PORT: 8080
7
+ PORT: 8443
8
8
  TUG_DATA_DIR: /data
9
- TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
9
+ TUG_PUBLIC_URL: {{PUBLIC_URL}}
10
+ TUG_EXTRA_SANS: {{EXTRA_SANS}}
10
11
  TUG_MESH: {{MESH}}
11
12
  ports:
12
- - "{{PORT}}:8080"
13
+ - "{{PORT}}:8443"
13
14
  volumes:
14
15
  - /var/lib/tug-control:/data
15
16
  - /var/run/docker.sock:/var/run/docker.sock
@@ -1,7 +1,13 @@
1
1
  set -eu
2
2
  mkdir -p /opt/tug-control /var/lib/tug-control
3
+ chmod 700 /var/lib/tug-control
3
4
  cat > /opt/tug-control/docker-compose.yml <<'YAML'
4
5
  {{COMPOSE}}YAML
5
6
  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
7
+ # Wait for the CA so credential issuance does not race the first start.
8
+ for i in $(seq 1 60); do
9
+ docker exec {{CONTAINER_SH}} test -s /data/pki/ca.crt 2>/dev/null && exit 0
10
+ sleep 1
11
+ done
12
+ echo 'control did not create its CA in time; check: docker logs {{CONTAINER_SH}}' >&2
13
+ exit 1
@@ -0,0 +1,11 @@
1
+ import type https from 'node:https';
2
+ /**
3
+ * TLS options for the single control listener: TLS 1.3, a client certificate from the pinned CA on every connection
4
+ * (failures end the handshake before any HTTP is parsed) and no session resumption, so each connection that carries
5
+ * requests has presented its certificate in a full handshake.
6
+ */
7
+ export declare function serverTlsOptions(options: {
8
+ cert: string;
9
+ key: string;
10
+ ca: string;
11
+ }): https.ServerOptions;
@@ -0,0 +1,23 @@
1
+ import crypto from 'node:crypto';
2
+ /**
3
+ * TLS options for the single control listener: TLS 1.3, a client certificate from the pinned CA on every connection
4
+ * (failures end the handshake before any HTTP is parsed) and no session resumption, so each connection that carries
5
+ * requests has presented its certificate in a full handshake.
6
+ */
7
+ export function serverTlsOptions(options) {
8
+ return {
9
+ cert: options.cert,
10
+ key: options.key,
11
+ ca: options.ca,
12
+ minVersion: 'TLSv1.3',
13
+ maxVersion: 'TLSv1.3',
14
+ requestCert: true,
15
+ rejectUnauthorized: true,
16
+ // OpenSSL can fall back to stateful TLS 1.3 tickets, so the ticket option alone is not enough: the empty session
17
+ // id context and zero timeout make the cache refuse everything, and the gate still closes any reused session.
18
+ secureOptions: crypto.constants.SSL_OP_NO_TICKET,
19
+ sessionTimeout: 0,
20
+ sessionIdContext: crypto.randomBytes(16).toString('hex'),
21
+ handshakeTimeout: 10_000,
22
+ };
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  "type": "module",
10
10
  "bin": {
11
11
  "tug": "./dist/cli.js",
12
- "tug-node": "./dist/main.js"
12
+ "tug-runtime": "./dist/main.js"
13
13
  },
14
14
  "files": [
15
15
  "dist"
@@ -39,19 +39,21 @@
39
39
  "test:smoke:image": "node tools/scripts/smoke-runtime-image.js"
40
40
  },
41
41
  "dependencies": {
42
- "commander": "^12.1.0",
43
- "dotenv": "^16.4.7",
44
- "express": "^4.21.2",
45
- "js-yaml": "^4.1.0",
42
+ "@peculiar/x509": "^3.1.0",
43
+ "commander": "^15.0.0",
44
+ "dotenv": "^18.0.9",
45
+ "express": "^5.3.0",
46
+ "js-yaml": "^5.5.0",
46
47
  "multer": "^2.2.0",
48
+ "reflect-metadata": "^0.2.2",
47
49
  "tar": "^7.4.3",
50
+ "undici": "^8.12.3",
48
51
  "ws": "^8.22.1"
49
52
  },
50
53
  "devDependencies": {
51
54
  "@changesets/cli": "^3.0.3",
52
55
  "@eslint/js": "^10.0.1",
53
- "@types/express": "^4.17.25",
54
- "@types/js-yaml": "^4.0.9",
56
+ "@types/express": "^5.0.6",
55
57
  "@types/multer": "^2.3.0",
56
58
  "@types/node": "^22.20.5",
57
59
  "@types/ws": "^8.18.2",
package/dist/tokens.d.ts DELETED
@@ -1,91 +0,0 @@
1
- import type { Request, RequestHandler } from 'express';
2
- import type { Db } from './db.ts';
3
- /** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
4
- export declare const ROLES: readonly ["read", "deploy", "admin"];
5
- export type Role = (typeof ROLES)[number];
6
- export type RouteRole = Role | 'public';
7
- /** Name recorded in the audit trail for the root operator token. */
8
- export declare const OPERATOR_ACTOR = "operator";
9
- /** Reserved actor for rollbacks control starts on its own. Token names cannot contain a colon, so no token can use it. */
10
- export declare const AUTO_ROLLBACK_ACTOR = "system:auto-rollback";
11
- export declare const TOKEN_PREFIX = "tug_tok";
12
- export declare const TOKEN_NAME_PATTERN: RegExp;
13
- export declare const PROJECT_NAME_PATTERN: RegExp;
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 and are not behind the token guard. docs/access-control.md mirrors this table.
18
- */
19
- export declare const ROUTE_ROLES: Record<string, RouteRole>;
20
- /** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
21
- export interface Actor {
22
- name: string;
23
- role: Role;
24
- projects: string[] | null;
25
- }
26
- export interface TokenRow {
27
- id: string;
28
- name: string;
29
- token_hash: string;
30
- role: Role;
31
- projects_json: string | null;
32
- created_at: string;
33
- created_by: string;
34
- last_used_at: string | null;
35
- expires_at: string | null;
36
- revoked_at: string | null;
37
- }
38
- export interface TokenInfo {
39
- name: string;
40
- role: Role;
41
- projects: string[] | null;
42
- createdAt: string;
43
- createdBy: string;
44
- lastUsedAt: string | null;
45
- expiresAt: string | null;
46
- revokedAt: string | null;
47
- }
48
- declare module 'express-serve-static-core' {
49
- interface Request {
50
- actor?: Actor;
51
- }
52
- }
53
- export declare const isRole: (value: unknown) => value is Role;
54
- export declare const hasRole: (actor: Actor, required: Role) => boolean;
55
- /** Validates a token request body, throwing a 400 error describing the first problem. */
56
- export declare function parseTokenRequest(body: unknown): {
57
- name: string;
58
- role: Role;
59
- projects: string[] | null;
60
- expiresAt: string | null;
61
- };
62
- /** Creates a token. The plaintext is returned once; only its hash is stored. */
63
- export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): TokenInfo & {
64
- token: string;
65
- };
66
- export declare function listTokens(db: Db): TokenInfo[];
67
- /** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
68
- export declare function revokeToken(db: Db, name: string): boolean;
69
- /** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
70
- export declare function authenticate(db: Db, raw: string): Actor | null;
71
- /**
72
- * Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
73
- * resolved (for example an unknown ID). Unresolvable targets are denied to scoped tokens without saying why, so they
74
- * cannot probe which IDs or projects exist.
75
- */
76
- export type ProjectResolver = (req: Request) => string | null | undefined | Promise<string | null | undefined>;
77
- export declare const bodyProject: ProjectResolver;
78
- export declare const queryProject: ProjectResolver;
79
- /** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
80
- export declare function assertProject(actor: Actor, project: string): void;
81
- /** Builds the request guard: authenticate (401), check the role (403), then check project scope (403). */
82
- export declare function createGuard(db: Db): (required: Role, resolve?: ProjectResolver) => RequestHandler;
83
- /** Appends a control-originated audit event naming the acting token. */
84
- export declare function recordEvent(db: Db, event: {
85
- actor: string;
86
- type: string;
87
- message: string;
88
- project?: string | null;
89
- deploymentId?: string | null;
90
- metadata?: Record<string, unknown>;
91
- }): void;