@wpelevator/tug 0.7.0 → 0.9.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 (48) hide show
  1. package/README.md +26 -24
  2. package/dist/agent-credential.d.ts +39 -0
  3. package/dist/agent-credential.js +135 -0
  4. package/dist/agent.d.ts +8 -9
  5. package/dist/agent.js +100 -102
  6. package/dist/backup-config.js +1 -1
  7. package/dist/backup-destination.d.ts +1 -1
  8. package/dist/backup-destination.js +1 -1
  9. package/dist/build.d.ts +6 -0
  10. package/dist/build.js +27 -7
  11. package/dist/cli-build.js +77 -4
  12. package/dist/cli-config.d.ts +9 -5
  13. package/dist/cli-config.js +89 -34
  14. package/dist/cli-install.js +1 -1
  15. package/dist/cli-live-transport.d.ts +3 -2
  16. package/dist/cli-live-transport.js +9 -5
  17. package/dist/cli.js +266 -142
  18. package/dist/compose.d.ts +5 -1
  19. package/dist/compose.js +23 -2
  20. package/dist/control-credential.d.ts +12 -0
  21. package/dist/control-credential.js +46 -0
  22. package/dist/control.d.ts +18 -1
  23. package/dist/control.js +602 -203
  24. package/dist/credentials.d.ts +111 -0
  25. package/dist/{tokens.js → credentials.js} +70 -54
  26. package/dist/db.d.ts +20 -5
  27. package/dist/db.js +3 -3
  28. package/dist/diagnostics.d.ts +44 -0
  29. package/dist/diagnostics.js +65 -0
  30. package/dist/ingress.js +4 -3
  31. package/dist/live.d.ts +2 -0
  32. package/dist/live.js +9 -1
  33. package/dist/main.js +35 -0
  34. package/dist/mesh.d.ts +4 -0
  35. package/dist/mesh.js +17 -0
  36. package/dist/normalize.js +1 -1
  37. package/dist/pki.d.ts +130 -0
  38. package/dist/pki.js +356 -0
  39. package/dist/protocol.d.ts +51 -22
  40. package/dist/status-report.d.ts +11 -0
  41. package/dist/status-report.js +147 -0
  42. package/dist/templates/agent.env +1 -3
  43. package/dist/templates/control-compose.yml +4 -3
  44. package/dist/templates/control-install.sh +8 -2
  45. package/dist/tls-server.d.ts +11 -0
  46. package/dist/tls-server.js +23 -0
  47. package/package.json +10 -8
  48. package/dist/tokens.d.ts +0 -91
package/dist/compose.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare function bindPorts(ports: PortEntry[], ip: string, loopbackPort?:
23
23
  * A service with no placement nodes runs on every node in `nodes`.
24
24
  */
25
25
  export declare function loopbackIngressServices(state: DesiredState, node: string, ingressNodes: readonly string[], nodes: readonly string[]): string[];
26
- /** Where `composeForNode` binds published ports that name no host IP. */
26
+ /** Where the agent's container definitions bind published ports that name no host IP. */
27
27
  export interface PortBind {
28
28
  address: string;
29
29
  loopback?: ReadonlySet<string>;
@@ -34,6 +34,10 @@ export declare function addressBoundServices(compose: ComposeFile, names: Iterab
34
34
  export declare function isRolling(svc: ComposeService | undefined): boolean;
35
35
  /** Service keys the agent can create a container from; anything else is rejected with its name. */
36
36
  export declare const SUPPORTED_SERVICE_KEYS: readonly string[];
37
+ /** Lower-cased ingress hostnames a Compose definition claims, by `service` name. */
38
+ export declare function ingressHostsOf(compose: ComposeFile): Map<string, string>;
39
+ /** True when two lowercase ingress hosts could route the same request: equal, or one is a `*.` wildcard covering the other. */
40
+ export declare function ingressHostsOverlap(a: string, b: string): boolean;
37
41
  export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
38
42
  export interface BuildDesiredStateOptions {
39
43
  project: string;
package/dist/compose.js CHANGED
@@ -279,6 +279,25 @@ function validateTopLevel(compose, errors) {
279
279
  errors.push(`volumes.${key}: ${field} is not supported`);
280
280
  }
281
281
  }
282
+ /** Lower-cased ingress hostnames a Compose definition claims, by `service` name. */
283
+ export function ingressHostsOf(compose) {
284
+ const hosts = new Map();
285
+ for (const [name, svc] of Object.entries(compose.services || {})) {
286
+ const host = svc['x-tug']?.ingress?.host;
287
+ if (typeof host === 'string')
288
+ hosts.set(host.toLowerCase(), name);
289
+ }
290
+ return hosts;
291
+ }
292
+ /** True when two lowercase ingress hosts could route the same request: equal, or one is a `*.` wildcard covering the other. */
293
+ export function ingressHostsOverlap(a, b) {
294
+ if (a === b)
295
+ return true;
296
+ const suffix = (host) => (host.startsWith('*.') ? host.slice(1) : undefined);
297
+ const wa = suffix(a);
298
+ const wb = suffix(b);
299
+ return (wa !== undefined && b.endsWith(wa)) || (wb !== undefined && a.endsWith(wb));
300
+ }
282
301
  export function validateCompose(compose, nodes = []) {
283
302
  if (!compose?.services || typeof compose.services !== 'object')
284
303
  throw new Error('compose file must define services');
@@ -290,7 +309,9 @@ export function validateCompose(compose, nodes = []) {
290
309
  const placement = lp.placement || {};
291
310
  const selected = placement.nodes || [];
292
311
  const hasLabels = Array.isArray(placement.labels) && placement.labels.length > 0;
293
- const isGlobal = lp.global === true || placement.global === true;
312
+ const isGlobal = lp.global === true;
313
+ if ('global' in placement)
314
+ errors.push(`${name}: x-tug.placement.global is not supported; use x-tug.global`);
294
315
  if (nodes.length > 1 && selected.length === 0 && !hasLabels && !isGlobal)
295
316
  errors.push(`${name}: multi-node deployments require x-tug.placement.nodes or placement.labels`);
296
317
  if (lp.replicas &&
@@ -313,7 +334,7 @@ export function buildDesiredState({ project, version, compose, revision, default
313
334
  for (const [name, src] of Object.entries(compose.services || {})) {
314
335
  const lp = src['x-tug'] || {};
315
336
  let placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
316
- const global = lp.global === true || placement.global === true;
337
+ const global = lp.global === true;
317
338
  const labels = placement.labels || [];
318
339
  if (nodes && (!placement.nodes || !placement.nodes.length) && (labels.length || global)) {
319
340
  const matched = nodes
@@ -0,0 +1,12 @@
1
+ export interface IssueArgs {
2
+ name: string;
3
+ role: string;
4
+ replace: boolean;
5
+ }
6
+ export declare function parseIssueArgs(argv: string[]): IssueArgs;
7
+ /**
8
+ * Host-side issuance (`docker exec ... control issue-credential`), independent of the API and of any running listener. It
9
+ * is create-only unless `replace` is set, which replaces the named credential's key; a running control closes the old
10
+ * key's sessions within a second. Returns the credential once; control keeps no private-key copy.
11
+ */
12
+ export declare function issueHostCredential(args: IssueArgs): Promise<string>;
@@ -0,0 +1,46 @@
1
+ import path from 'node:path';
2
+ import { openDb } from "./db.js";
3
+ import { Pki, encodeCredential } from "./pki.js";
4
+ import { ROLES, createCredential, isRole, parseCredentialRequest } from "./credentials.js";
5
+ export function parseIssueArgs(argv) {
6
+ const args = { name: 'root', role: 'admin', replace: false };
7
+ for (let i = 0; i < argv.length; i++) {
8
+ if (argv[i] === '--name')
9
+ args.name = argv[++i];
10
+ else if (argv[i] === '--role')
11
+ args.role = argv[++i];
12
+ else if (argv[i] === '--replace')
13
+ args.replace = true;
14
+ else
15
+ throw new Error(`unknown argument ${argv[i]}`);
16
+ }
17
+ if (!isRole(args.role))
18
+ throw new Error(`--role must be one of ${ROLES.join(', ')}`);
19
+ return args;
20
+ }
21
+ /**
22
+ * Host-side issuance (`docker exec ... control issue-credential`), independent of the API and of any running listener. It
23
+ * is create-only unless `replace` is set, which replaces the named credential's key; a running control closes the old
24
+ * key's sessions within a second. Returns the credential once; control keeps no private-key copy.
25
+ */
26
+ export async function issueHostCredential(args) {
27
+ const dataDir = process.env.TUG_DATA_DIR || './data';
28
+ const advertiseUrl = process.env.TUG_ADVERTISE_URL;
29
+ if (!advertiseUrl)
30
+ throw new Error('TUG_ADVERTISE_URL is required');
31
+ const db = await openDb(path.join(dataDir, 'tug.db'));
32
+ try {
33
+ const fresh = !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes');
34
+ const pki = await Pki.load(path.join(dataDir, 'pki'), fresh);
35
+ const request = parseCredentialRequest({ name: args.name, role: args.role });
36
+ const created = await createCredential(db, pki, new URL(advertiseUrl).origin, request, 'host', {
37
+ replace: args.replace,
38
+ });
39
+ if (args.replace)
40
+ db.run('INSERT INTO events(id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?)', `evt_${Date.now().toString(36)}`, 'info', 'credential.recover', `credential ${args.name} replaced on the control host`, '{}', new Date().toISOString(), 'host');
41
+ return encodeCredential(created.credential);
42
+ }
43
+ finally {
44
+ db.close();
45
+ }
46
+ }
package/dist/control.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import express from 'express';
3
3
  import { resolveImage } from './registry.ts';
4
+ import type { Request } from 'express';
4
5
  import { saveImageArchive, inspectImage, validateImageArchive } from './artifacts.ts';
5
6
  import https from 'node:https';
6
7
  import http from 'node:http';
7
8
  import { openDb } from './db.ts';
9
+ import type { Db } from './db.ts';
8
10
  import type { NodeRow } from './db.ts';
9
11
  import { buildImage, run as runCommand } from './build.ts';
10
12
  declare module 'express-serve-static-core' {
@@ -25,5 +27,20 @@ export declare function createApp(options?: {
25
27
  openDatabase?: typeof openDb;
26
28
  /** Minimum time between event prunes triggered by agent reports; default one hour. */ eventPruneIntervalMs?: number;
27
29
  /** Override the live open/agent startup timeout in milliseconds. */ liveStartTimeoutMs?: number;
30
+ /** Credential-free HTTPS origin clients use to reach control; defaults to `TUG_ADVERTISE_URL`. */ advertiseUrl?: string;
31
+ /**
32
+ * Replaces certificate-based identity resolution. Only for in-process tests that skip TLS; production never sets it.
33
+ * It returns the principal URI and key hash a connection would have presented.
34
+ */
35
+ identity?: (req: Request, db: Db) => {
36
+ uri: string;
37
+ keyHash: string;
38
+ } | null;
28
39
  }): Promise<import("express-serve-static-core").Express>;
29
- export declare function listen(app: express.Express, port?: number): Promise<http.Server | https.Server>;
40
+ /** Attaches the agent and operator WebSocket upgrade handling to `server`; `listen` uses it on the mTLS listener. */
41
+ export declare function attachAgentStream(app: express.Express, server: http.Server | https.Server): void;
42
+ /**
43
+ * Starts the single mTLS listener. Chain validation rejects missing or foreign client certificates during the handshake;
44
+ * the secureConnection gate then checks current database state, and every request checks it again.
45
+ */
46
+ export declare function listen(app: express.Express, port?: number): Promise<https.Server>;