@wpelevator/tug 0.7.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 (42) 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 +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/cli-build.js +70 -4
  10. package/dist/cli-config.d.ts +9 -5
  11. package/dist/cli-config.js +89 -34
  12. package/dist/cli-install.js +1 -1
  13. package/dist/cli-live-transport.d.ts +3 -2
  14. package/dist/cli-live-transport.js +9 -5
  15. package/dist/cli.js +265 -142
  16. package/dist/compose.d.ts +3 -1
  17. package/dist/compose.js +14 -2
  18. package/dist/control-credential.d.ts +12 -0
  19. package/dist/control-credential.js +46 -0
  20. package/dist/control.d.ts +18 -1
  21. package/dist/control.js +586 -202
  22. package/dist/credentials.d.ts +111 -0
  23. package/dist/{tokens.js → credentials.js} +70 -54
  24. package/dist/db.d.ts +20 -5
  25. package/dist/db.js +3 -3
  26. package/dist/diagnostics.d.ts +44 -0
  27. package/dist/diagnostics.js +65 -0
  28. package/dist/ingress.js +4 -3
  29. package/dist/main.js +35 -0
  30. package/dist/normalize.js +1 -1
  31. package/dist/pki.d.ts +119 -0
  32. package/dist/pki.js +339 -0
  33. package/dist/protocol.d.ts +51 -22
  34. package/dist/status-report.d.ts +11 -0
  35. package/dist/status-report.js +147 -0
  36. package/dist/templates/agent.env +1 -3
  37. package/dist/templates/control-compose.yml +4 -3
  38. package/dist/templates/control-install.sh +8 -2
  39. package/dist/tls-server.d.ts +11 -0
  40. package/dist/tls-server.js +23 -0
  41. package/package.json +10 -8
  42. package/dist/tokens.d.ts +0 -91
package/README.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  The name: tugboats pull ships into place, like the pull-based agents here; `tug` is the CLI.
4
4
 
5
- Deploy Docker Compose projects to one or more ordinary Linux VPSes through a small self-hosted control plane and a pull-based agent on each node, without Kubernetes or a hosted PaaS. What it is for, the deployment shapes it targets and the planned milestones are in the [Roadmap](docs/roadmap.md).
5
+ Deploy Docker Compose applications to one or more ordinary Linux VPSes through a small self-hosted control plane and a pull-based agent on each node, without Kubernetes or a hosted PaaS. Tug runs an agent on each node; the agents drive the node's Docker Engine API directly, and Compose is only used on your machine to read the file. What it is for, the deployment shapes it targets and the planned milestones are in the [Roadmap](docs/roadmap.md).
6
6
 
7
7
  ## Features
8
8
 
9
- - Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata through Node's built-in driver.
10
- - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with one agent token per node. Agents only dial out, so nodes behind NAT work.
9
+ - One mutual-TLS port: every operator, CI job and node authenticates with a certificate from the control plane's private CA, and control authorizes it by public key. Scoped `read`/`deploy`/`admin` operator credentials for CI and teammates, an audit trail naming who acted, and SQLite metadata through Node's built-in driver.
10
+ - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` after `node-prepare-enrollment`, each with its own certificate and an immutable node ID. Agents only dial out, so nodes behind NAT work.
11
11
  - Agents pull desired state from control, reconcile containers through the Docker Engine API, run TCP/HTTP/Docker health checks and report status. An outbound WebSocket wakes them as soon as control queues work and carries live `exec`, followed logs, `ps` and `inspect` sessions, with long-poll and periodic-poll fallbacks for ordinary work.
12
12
  - `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
13
13
  - Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control. Published images are pinned to registry digests at deployment creation.
@@ -15,22 +15,22 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
15
15
  - Deployment convergence, per-project operation locks, cancellation, rollback, per-service rollout events and log fetching through the agents.
16
16
  - Opt-in zero-downtime rolling rollouts for ingress-fronted services (`x-tug.rollout`) and automatic rollback of failed deployments (`x-tug.rollback: auto`). See [Compose extensions](docs/compose.md#rolling-rollouts).
17
17
  - Private published images use encrypted project credentials for digest resolution and Engine API pulls.
18
- - Optional WireGuard mesh between nodes, health-aware cross-node service names and a managed Caddy ingress with automatic HTTPS, each opt-in. See [Networking](docs/networking.md).
18
+ - Optional WireGuard mesh between nodes, health-aware cross-node service names (hosts-file entries injected into containers) and a managed Caddy ingress with automatic HTTPS, each opt-in. See [Networking](docs/networking.md).
19
19
  - Private-by-default published ports: generated Compose binds them to the overlay IP, loopback or the node address, never every interface unless you write `0.0.0.0`. See [Networking](docs/networking.md#published-ports-private-by-default).
20
- - Control-plane backup and restore, and `tug doctor` diagnostics.
20
+ - Workload backups of service commands and volumes with restic, a separate control-plane backup and restore, and `tug doctor` diagnostics for certificate expiry, node connectivity and clock skew.
21
21
 
22
22
  ## How it fits together
23
23
 
24
24
  ```text
25
- tug CLI / CI (API token)
25
+ tug CLI / CI (operator credential)
26
26
  |
27
- | HTTPS: deploy, status, logs, exec
27
+ | mTLS: deploy, status, logs, exec
28
28
  v
29
29
  +-------------------------------------+
30
- | Control plane |
30
+ | Control plane (one mTLS port) |
31
31
  | Control API (role-checked) |
32
32
  | SQLite: desired states, secrets |
33
- | image builds, saved images |
33
+ | private CA, image builds |
34
34
  +-------------------------------------+
35
35
  ^ ^
36
36
  | agents dial out: work, status,
@@ -39,7 +39,7 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
39
39
  +------+------------+ +------+------------------+
40
40
  | Node app1 | | Node app2 (ingress node)|
41
41
  | Agent |<->| Agent |
42
- | Docker Compose | | Docker Compose |
42
+ | Docker Engine | | Docker Engine |
43
43
  | services | | services |
44
44
  | | | managed Caddy ingress |
45
45
  +-------------------+ +-------------------------+
@@ -48,7 +48,7 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
48
48
  SSH is used only by the CLI to install the control plane and nodes.
49
49
  ```
50
50
 
51
- Operators and CI talk to the control API. The control plane stores desired state and builds images; each agent dials out to fetch the work for its node, applies it with Docker Compose and reports health back, so nodes behind NAT need no inbound ports. SSH is used only to install control and agents. The optional WireGuard mesh and Caddy ingress are described in [Networking](docs/networking.md), and every flow in detail in [Architecture](docs/architecture.md).
51
+ Operators and CI talk to the control API. The control plane stores desired state and builds images; each agent dials out to fetch the work for its node, applies it through the Docker Engine API and reports health back, so nodes behind NAT need no inbound ports. SSH is used only to install the control plane and its nodes. The optional WireGuard mesh and Caddy ingress are described in [Networking](docs/networking.md), and every flow in detail in [Architecture](docs/architecture.md).
52
52
 
53
53
  ## Quick start
54
54
 
@@ -60,29 +60,31 @@ npm install -g @wpelevator/tug
60
60
 
61
61
  The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
62
62
 
63
- 1. Install the control plane: `tug control-install root@control.example.com --install-docker`. The flag installs missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts; omit it when Docker is ready. Put HTTPS in front of the control API as described in [Control plane](docs/control-plane.md).
64
- 2. Point the CLI at it: `tug init --control-url https://control.example.com --token "$TUG_TOKEN"`. It writes `tug.yml` (commit it, and list your servers in it) and keeps the token in the git-ignored `tug.local.yml`; see [CLI reference](docs/cli.md#config-file).
63
+ 1. Install the control plane: `tug control-install root@control.example.com --public-url https://control.example.com:8443 --install-docker`. The flag installs missing Docker Engine and the Compose plugin that runs the control container on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts; omit it when Docker is ready. Control serves mutual TLS directly on its published port (`8443`); do not terminate TLS in front of it ([Control plane](docs/control-plane.md#authentication-and-pki)).
64
+ 2. Point the CLI at it: `tug init tug-root.credential.json` (the file `control-install` wrote). It writes `tug.yml` (commit it, and list your servers in it), copies the credential to a `0600` file in `~/.config/tug/credentials/` and points the git-ignored `tug.local.yml` at it; see [CLI reference](docs/cli.md#config-file).
65
65
  3. Set the secrets your services need: `tug secret-set DB_PASSWORD`.
66
- 4. Add nodes with `tug node-install root@app1.example.com --name app1 --install-docker`, or `tug node-enroll --name app1` and run the printed Docker command. For a bare cloud host, `tug node-enroll --name app1 --install-docker` prints cloud-init user-data.
67
- 5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new version healthy.
68
- 6. Operate with `tug status`, `events`, `exec`, `run`, `logs`, `ps`, `inspect`, `restart`, `rollback`, `backup-run`, `restore` and `doctor`.
66
+ 4. Add nodes with `tug node-install root@app1.example.com --name app1 --install-docker`, or run `tug node-prepare-enrollment --name app1` and then the printed Docker command on the host. Nodes need Docker Engine only. For a bare cloud host, `tug node-prepare-enrollment --name app1 --install-docker` prints cloud-init user-data.
67
+ 5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new desired-state revision healthy.
68
+ 6. Operate with `tug status`, `events`, `exec`, `run`, `logs`, `ps`, `inspect`, `restart`, `rollback`, `backup-run`, `restore` and `doctor`. Create more operator credentials for CI and teammates with `tug credential-create`.
69
69
 
70
70
  ## Documentation
71
71
 
72
+ - [Terminology](docs/terminology.md): core concepts, the naming review (which recommendations are implemented), industry comparisons and the remaining proposed interface improvements.
72
73
  - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
73
74
  - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
74
- - [Control plane](docs/control-plane.md): install, TLS, image delivery, backup and restore.
75
- - [Backups](docs/backups.md): scheduled restic backups with `x-tug.backup`, restore, recipes for common databases, and a restore drill. See also the [WordPress example](examples/wordpress/README.md).
76
- - [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
77
- - [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
78
- - [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
75
+ - [Control plane](docs/control-plane.md): install, mutual TLS and the private CA, image delivery, control-plane backup and restore.
76
+ - [Backups](docs/backups.md): scheduled workload backups with `x-tug.backup` (restic), restore, recipes for common databases, and a restore drill. See also the [WordPress example](examples/wordpress/README.md).
77
+ - [Access control](docs/access-control.md): certificate identity classes, scoped operator credentials, roles, audit trail and a GitHub Actions deploy example.
78
+ - [Security](docs/security.md): deployment trust boundaries, security audit findings and validation limits.
79
+ - [Networking](docs/networking.md): WireGuard mesh, cross-node service names, ingress and TLS, firewall notes.
80
+ - [Nodes](docs/nodes.md): node names and IDs, SSH install, self-service enrollment, certificate renewal and recovery, registration and status.
79
81
  - [CLI reference](docs/cli.md): every `tug` command and option.
80
82
  - [Configuration](docs/configuration.md): CLI, control and agent environment variables.
81
83
  - [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
82
84
  - [Testing](docs/testing.md): test layers, rollout metrics, mocked test backlog, and the [Caddy/PHP and static-file benchmark](docs/testing.md#caddy-proxy-overhead-benchmark).
83
85
  - [Observability](docs/observability.md): what can be observed today, and the proposed logs, metrics and alerting.
84
86
  - [Releases](docs/releases.md): changesets, npm and Docker image publishing.
85
- - [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
87
+ - [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution, with the mTLS dependency findings.
86
88
  - [Roadmap](docs/roadmap.md): goals, principles, the reference WordPress topology and planned milestones.
87
89
 
88
90
  ## Development
@@ -0,0 +1,39 @@
1
+ import { Agent } from 'undici';
2
+ import type { Credential, PersistHooks } from './pki.ts';
3
+ import type { IdentityResponse } from './protocol.ts';
4
+ /** The agent's persisted identity: the common credential plus the node it names. */
5
+ export interface AgentDocument extends Credential {
6
+ nodeId: string;
7
+ nodeName: string;
8
+ }
9
+ export declare const agentFile: (stateDir: string) => string;
10
+ export declare const pendingFile: (stateDir: string) => string;
11
+ export declare function loadAgentDocument(stateDir: string): Promise<AgentDocument | null>;
12
+ /** `agent identity`: reuse or generate the pending key and return its CSR. Safe to repeat. */
13
+ export declare function agentIdentityCsr(stateDir: string): Promise<string>;
14
+ /** Validates an identity response against the pending key and durably installs it as the agent document. */
15
+ export declare function installIdentity(stateDir: string, response: IdentityResponse, hooks?: PersistHooks): Promise<AgentDocument>;
16
+ /** `agent install-cert`: reads an identity response from stdin text. */
17
+ export declare function installCertFromText(stateDir: string, text: string): Promise<AgentDocument>;
18
+ export declare function describeMissingIdentity(): string;
19
+ export type HttpClient = (url: string, init?: RequestInit) => Promise<Response>;
20
+ /**
21
+ * A client pinned to the credential's CA that presents its certificate. `connectUrl` only changes where the TCP
22
+ * connection goes; the server name is still verified against the credential's public host. Redirects are refused.
23
+ */
24
+ export declare function createTransport(credential: Credential, connectUrl?: string): {
25
+ http: HttpClient;
26
+ base: string;
27
+ dispatcher: Agent;
28
+ close: () => Promise<void>;
29
+ };
30
+ export declare function certificateWindow(certPem: string): {
31
+ notBefore: number;
32
+ notAfter: number;
33
+ };
34
+ /** When to renew: two thirds of the lifetime plus up to ten percent random jitter, so a fleet does not sign together. */
35
+ export declare function renewalTime(certPem: string, random?: number): number;
36
+ /** Sends a CSR for the existing key, validates the result, saves it durably and returns the new document. */
37
+ export declare function renewCertificate(stateDir: string, doc: AgentDocument, post: (csr: string) => Promise<{
38
+ certificate: string;
39
+ }>, hooks?: PersistHooks): Promise<AgentDocument>;
@@ -0,0 +1,135 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Agent, fetch as undiciFetch } from 'undici';
5
+ import { clientTlsOptions, createCsr, generateKeyPair, identityUri, parseCredential, validateInstalledCertificate, writeFileDurable, } from "./pki.js";
6
+ export const agentFile = (stateDir) => path.join(stateDir, 'agent.json');
7
+ export const pendingFile = (stateDir) => path.join(stateDir, 'agent-pending.json');
8
+ const NO_AGENT_IDENTITY = 'this node has no identity: run `tug node-install`, `tug node-prepare-enrollment` or `tug node-repair-identity` from an operator machine';
9
+ async function readJson(file) {
10
+ let text;
11
+ try {
12
+ text = await fs.readFile(file, 'utf8');
13
+ }
14
+ catch (e) {
15
+ if (e.code === 'ENOENT')
16
+ return null;
17
+ throw e;
18
+ }
19
+ try {
20
+ return JSON.parse(text);
21
+ }
22
+ catch (e) {
23
+ throw new Error(`${file} is corrupt; run \`tug node-repair-identity\` to replace this node's identity`, {
24
+ cause: e,
25
+ });
26
+ }
27
+ }
28
+ export async function loadAgentDocument(stateDir) {
29
+ const raw = await readJson(agentFile(stateDir));
30
+ if (raw === null)
31
+ return null;
32
+ try {
33
+ const credential = parseCredential(raw);
34
+ const { nodeId, nodeName } = raw;
35
+ if (typeof nodeId !== 'string' || typeof nodeName !== 'string')
36
+ throw new Error('missing node identity');
37
+ return { ...credential, nodeId, nodeName };
38
+ }
39
+ catch (e) {
40
+ throw new Error(`${agentFile(stateDir)} is invalid (${e instanceof Error ? e.message : String(e)}); run \`tug node-repair-identity\``, { cause: e });
41
+ }
42
+ }
43
+ async function pendingKey(stateDir) {
44
+ const existing = (await readJson(pendingFile(stateDir)));
45
+ if (existing?.key)
46
+ return existing.key;
47
+ const keys = await generateKeyPair();
48
+ await writeFileDurable(pendingFile(stateDir), JSON.stringify({ v: 1, key: keys.privateKeyPem }));
49
+ return keys.privateKeyPem;
50
+ }
51
+ /** `agent identity`: reuse or generate the pending key and return its CSR. Safe to repeat. */
52
+ export async function agentIdentityCsr(stateDir) {
53
+ return createCsr(await pendingKey(stateDir));
54
+ }
55
+ /** Validates an identity response against the pending key and durably installs it as the agent document. */
56
+ export async function installIdentity(stateDir, response, hooks = {}) {
57
+ const pending = (await readJson(pendingFile(stateDir)));
58
+ if (!pending?.key)
59
+ throw new Error('no pending key: run `agent identity` first');
60
+ validateInstalledCertificate({
61
+ cert: response.certificate,
62
+ ca: response.ca,
63
+ keyPem: pending.key,
64
+ uri: identityUri('node', response.node.id),
65
+ });
66
+ const doc = {
67
+ ...parseCredential({
68
+ v: 1,
69
+ url: response.url,
70
+ ca: response.ca,
71
+ cert: response.certificate,
72
+ key: pending.key,
73
+ }),
74
+ nodeId: response.node.id,
75
+ nodeName: response.node.name,
76
+ };
77
+ await writeFileDurable(agentFile(stateDir), JSON.stringify(doc), 0o600, hooks);
78
+ await fs.rm(pendingFile(stateDir), { force: true });
79
+ return doc;
80
+ }
81
+ /** `agent install-cert`: reads an identity response from stdin text. */
82
+ export async function installCertFromText(stateDir, text) {
83
+ return installIdentity(stateDir, JSON.parse(text));
84
+ }
85
+ export function describeMissingIdentity() {
86
+ return NO_AGENT_IDENTITY;
87
+ }
88
+ /**
89
+ * A client pinned to the credential's CA that presents its certificate. `connectUrl` only changes where the TCP
90
+ * connection goes; the server name is still verified against the credential's public host. Redirects are refused.
91
+ */
92
+ export function createTransport(credential, connectUrl) {
93
+ const dispatcher = new Agent({ connect: clientTlsOptions(credential) });
94
+ const base = new URL(connectUrl ?? credential.url).origin;
95
+ const http = (url, init) => {
96
+ let request = init;
97
+ // The runtime's FormData is not the undici package's FormData, which would stringify it. Serialize it with the
98
+ // runtime's own Response and stream the result.
99
+ if (typeof FormData !== 'undefined' && init?.body instanceof FormData) {
100
+ const encoded = new Response(init.body);
101
+ const headers = new Headers(init.headers);
102
+ headers.set('content-type', encoded.headers.get('content-type') ?? 'multipart/form-data');
103
+ request = { ...init, headers, body: encoded.body, duplex: 'half' };
104
+ }
105
+ return undiciFetch(url, {
106
+ ...request,
107
+ redirect: 'error',
108
+ dispatcher,
109
+ });
110
+ };
111
+ return { http, base, dispatcher, close: () => dispatcher.close() };
112
+ }
113
+ export function certificateWindow(certPem) {
114
+ const x = new crypto.X509Certificate(certPem);
115
+ return { notBefore: Date.parse(x.validFrom), notAfter: Date.parse(x.validTo) };
116
+ }
117
+ /** When to renew: two thirds of the lifetime plus up to ten percent random jitter, so a fleet does not sign together. */
118
+ export function renewalTime(certPem, random = Math.random()) {
119
+ const { notBefore, notAfter } = certificateWindow(certPem);
120
+ const life = notAfter - notBefore;
121
+ return notBefore + (life * 2) / 3 + random * life * 0.1;
122
+ }
123
+ /** Sends a CSR for the existing key, validates the result, saves it durably and returns the new document. */
124
+ export async function renewCertificate(stateDir, doc, post, hooks = {}) {
125
+ const { certificate } = await post(await createCsr(doc.key));
126
+ validateInstalledCertificate({
127
+ cert: certificate,
128
+ ca: doc.ca,
129
+ keyPem: doc.key,
130
+ uri: identityUri('node', doc.nodeId),
131
+ });
132
+ const next = { ...doc, cert: certificate };
133
+ await writeFileDurable(agentFile(stateDir), JSON.stringify(next), 0o600, hooks);
134
+ return next;
135
+ }
package/dist/agent.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { WebSocket } from 'ws';
3
+ import type { AgentDocument } from './agent-credential.ts';
4
+ import type { Credential } from './pki.ts';
3
5
  import type { ContainerSummary, DockerApi } from './docker-api.ts';
4
6
  import type { AgentLiveCommand, AgentLiveFrame } from './protocol.ts';
5
7
  import type { LiveEngineOptions } from './live.ts';
@@ -13,7 +15,8 @@ export interface AgentContext {
13
15
  stateDir: string;
14
16
  apply: boolean;
15
17
  controlUrl: string;
16
- token: string | undefined;
18
+ /** The node credential presented to control; absent only in tests with a fake `http`. */
19
+ credential?: Credential;
17
20
  /** Runs `wg` / `ip` for the mesh. */
18
21
  host?: HostRunner;
19
22
  /** Manage the WireGuard mesh (generate a key, report it, reconcile `wg0`). */
@@ -22,14 +25,14 @@ export interface AgentContext {
22
25
  /** Host port range (`LOW-HIGH`) for rolling-rollout revisions; default 20000-20999. */
23
26
  rolloutPorts?: string;
24
27
  }
25
- export declare function defaultContext(): AgentContext;
28
+ export declare function defaultContext(doc?: AgentDocument | undefined): AgentContext;
26
29
  export type AgentLiveControlFrame = AgentLiveCommand;
27
30
  export type AgentLiveOutputFrame = AgentLiveFrame;
28
31
  interface LiveChannelHandlers {
29
32
  onLive?: (frame: AgentLiveControlFrame) => void | Promise<void>;
30
33
  onDisconnect?: () => void;
31
34
  }
32
- type WebSocketFactory = (url: string, token: string) => WebSocket;
35
+ type WebSocketFactory = (url: string, credential: Credential | undefined) => WebSocket;
33
36
  /** Keeps one outbound stream open and falls back to a bounded HTTP wait when an intermediary rejects WebSockets. */
34
37
  export declare class AgentWakeChannel {
35
38
  private readonly ctx;
@@ -96,10 +99,6 @@ export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext
96
99
  /** Reports this node's WireGuard public key when control has not seen the current one yet. */
97
100
  export declare function reportWgKey(ctx: AgentContext): Promise<void>;
98
101
  export declare function pollOnce(ctx?: AgentContext): Promise<ProjectStatus[]>;
99
- interface Creds {
100
- agentToken: string;
101
- nodeName?: string;
102
- controlUrl?: string;
103
- }
104
- export declare function loadOrEnroll(): Promise<Creds>;
102
+ /** Loads the persisted identity, or enrolls with a join key. Without either the agent refuses to start. */
103
+ export declare function loadOrEnroll(): Promise<AgentDocument>;
105
104
  export declare function main(): Promise<void>;
package/dist/agent.js CHANGED
@@ -6,10 +6,11 @@ import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { validateImageArchive, verifyDownload } from "./artifacts.js";
8
8
  import path from 'node:path';
9
- import { spawnSync } from 'node:child_process';
10
9
  import net from 'node:net';
11
10
  import dns from 'node:dns/promises';
12
11
  import { WebSocket } from 'ws';
12
+ import { agentIdentityCsr, createTransport, describeMissingIdentity, installIdentity, loadAgentDocument, renewCertificate, renewalTime, } from "./agent-credential.js";
13
+ import { clientTlsOptions, parseCredential } from "./pki.js";
13
14
  import { createHash } from 'node:crypto';
14
15
  import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
15
16
  import { LABEL_PROJECT, LABEL_SERVICE, containerSpecs, networkName, volumeName, } from "./container-spec.js";
@@ -23,24 +24,27 @@ import { openAgentDb } from "./db.js";
23
24
  import { createHostRunner } from "./runners.js";
24
25
  import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
25
26
  import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey, } from "./mesh.js";
26
- const CONTROL_URL_FROM_ENV = Boolean(process.env.TUG_CONTROL_URL);
27
- let CONTROL_URL = process.env.TUG_CONTROL_URL || 'http://localhost:8080';
28
- let TOKEN = process.env.TUG_AGENT_TOKEN;
27
+ // Connect-only override (for example an SSH tunnel); the certificate is still verified against the credential's public URL.
28
+ const CONTROL_URL_OVERRIDE = process.env.TUG_CONTROL_URL;
29
29
  let NODE_NAME = process.env.TUG_NODE_NAME || process.env.HOSTNAME || 'node1';
30
30
  const STATE_DIR = process.env.TUG_STATE_DIR || '/var/lib/tug';
31
31
  const APPLY = process.env.TUG_APPLY !== 'false';
32
32
  const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
33
33
  // Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
34
34
  const MESH = process.env.TUG_MESH === 'true';
35
- export function defaultContext() {
35
+ let activeDocument;
36
+ export function defaultContext(doc = activeDocument) {
37
+ if (!doc)
38
+ throw new Error(describeMissingIdentity());
39
+ const transport = createTransport(doc, CONTROL_URL_OVERRIDE);
36
40
  return {
37
41
  docker: new DockerEngine(),
38
- http: fetch,
39
- nodeName: NODE_NAME,
42
+ http: transport.http,
43
+ nodeName: doc.nodeName,
40
44
  stateDir: STATE_DIR,
41
45
  apply: APPLY,
42
- controlUrl: CONTROL_URL,
43
- token: TOKEN,
46
+ controlUrl: transport.base,
47
+ credential: doc,
44
48
  host: createHostRunner(),
45
49
  mesh: MESH,
46
50
  caddyImage: process.env.TUG_CADDY_IMAGE,
@@ -74,18 +78,10 @@ async function agentDb(stateDir) {
74
78
  }
75
79
  return db;
76
80
  }
77
- function authHeaders(ctx) {
78
- if (!ctx.token)
79
- throw new Error('TUG_AGENT_TOKEN required');
80
- return { Authorization: `Bearer ${ctx.token}` };
81
- }
82
81
  async function api(ctx, route, opts = {}) {
83
82
  const res = await ctx.http(`${ctx.controlUrl}${route}`, {
84
83
  ...opts,
85
- headers: {
86
- ...authHeaders(ctx),
87
- ...(opts.headers || {}),
88
- },
84
+ headers: opts.headers || {},
89
85
  });
90
86
  if (!res.ok)
91
87
  throw new Error(`${opts.method || 'GET'} ${route}: ${res.status} ${await res.text()}`);
@@ -100,10 +96,15 @@ export class AgentWakeChannel {
100
96
  pending = false;
101
97
  waiter;
102
98
  handlers;
103
- constructor(ctx, createSocket = (url, token) => new WebSocket(url, {
104
- headers: { Authorization: `Bearer ${token}` },
105
- maxPayload: LIVE_MAX_FRAME_BYTES,
106
- }), handlers = {}) {
99
+ constructor(ctx, createSocket = (url, credential) => {
100
+ if (!credential)
101
+ throw new Error('agent credential required');
102
+ return new WebSocket(url, {
103
+ ...clientTlsOptions(credential),
104
+ maxPayload: LIVE_MAX_FRAME_BYTES,
105
+ followRedirects: false,
106
+ });
107
+ }, handlers = {}) {
107
108
  this.ctx = ctx;
108
109
  this.createSocket = createSocket;
109
110
  this.handlers = handlers;
@@ -115,13 +116,11 @@ export class AgentWakeChannel {
115
116
  async connect() {
116
117
  if (this.socket?.readyState === WebSocket.OPEN)
117
118
  return true;
118
- if (!this.ctx.token)
119
- throw new Error('TUG_AGENT_TOKEN required');
120
119
  const url = new URL('/agent/v1/stream', this.ctx.controlUrl);
121
120
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
122
121
  return new Promise((resolve) => {
123
122
  let settled = false;
124
- const socket = this.createSocket(url.toString(), this.ctx.token);
123
+ const socket = this.createSocket(url.toString(), this.ctx.credential);
125
124
  this.socket = socket;
126
125
  const finish = (connected) => {
127
126
  if (settled)
@@ -786,7 +785,7 @@ export async function ensureArtifact(ctx, artifact) {
786
785
  const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
787
786
  const file = path.join(downloadDir, 'image.tar.gz');
788
787
  try {
789
- const response = await ctx.http(`${ctx.controlUrl}/agent/v1/images/${encodeURIComponent(artifact.buildId)}`, { headers: authHeaders(ctx), redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000) });
788
+ const response = await ctx.http(`${ctx.controlUrl}/agent/v1/images/${encodeURIComponent(artifact.buildId)}`, { redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000) });
790
789
  if (!response.ok || !response.body)
791
790
  throw new Error(`image download failed: HTTP ${response.status}`);
792
791
  await pipeline(Readable.fromWeb(response.body), verifyDownload(artifact), createWriteStream(file, { mode: 0o600 }));
@@ -1149,7 +1148,12 @@ export async function pollOnce(ctx = defaultContext()) {
1149
1148
  }
1150
1149
  const jobResults = jobs.pending();
1151
1150
  commandResults.push(...jobResults);
1152
- const report = { node: ctx.nodeName, projects, commandResults };
1151
+ const report = {
1152
+ node: ctx.nodeName,
1153
+ sentAt: new Date().toISOString(),
1154
+ projects,
1155
+ commandResults,
1156
+ };
1153
1157
  const running = jobs.statuses();
1154
1158
  if (running.length)
1155
1159
  report.jobs = running;
@@ -1165,19 +1169,6 @@ export async function pollOnce(ctx = defaultContext()) {
1165
1169
  await jobs.acknowledge(jobResults);
1166
1170
  return projects;
1167
1171
  }
1168
- const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
1169
- async function loadCreds() {
1170
- try {
1171
- return JSON.parse(await fs.readFile(CREDS_FILE(), 'utf8'));
1172
- }
1173
- catch {
1174
- return null;
1175
- }
1176
- }
1177
- async function saveCreds(creds) {
1178
- await ensureDir(STATE_DIR);
1179
- await fs.writeFile(CREDS_FILE(), JSON.stringify(creds), { mode: 0o600 });
1180
- }
1181
1172
  async function readJoinKey() {
1182
1173
  if (process.env.TUG_JOIN_KEY)
1183
1174
  return process.env.TUG_JOIN_KEY.trim();
@@ -1192,84 +1183,90 @@ async function readJoinKey() {
1192
1183
  }
1193
1184
  return null;
1194
1185
  }
1186
+ /** Enrolls with a single-use bootstrap certificate; a retry reuses the pending key, which control accepts by key. */
1195
1187
  async function enroll(joinKey) {
1196
1188
  let decoded;
1197
1189
  try {
1198
1190
  decoded = JSON.parse(Buffer.from(joinKey, 'base64url').toString('utf8'));
1191
+ if (decoded.v !== 2)
1192
+ throw new Error('unsupported join key version');
1193
+ parseCredential(decoded.credential);
1199
1194
  }
1200
1195
  catch (e) {
1201
- throw new Error('invalid TUG_JOIN_KEY (expected base64url-encoded JSON)', { cause: e });
1202
- }
1203
- if (!decoded.enrollToken)
1204
- throw new Error('join key is missing enrollToken');
1205
- const controlUrl = !CONTROL_URL_FROM_ENV && decoded.controlUrl ? decoded.controlUrl : CONTROL_URL;
1206
- // node's fetch reads NODE_EXTRA_CA_CERTS only at startup, so apply an embedded CA by
1207
- // persisting it and re-execing ourselves once with the env var set.
1208
- if (decoded.ca && !process.env.NODE_EXTRA_CA_CERTS) {
1209
- await ensureDir(STATE_DIR);
1210
- const caPath = path.join(STATE_DIR, 'control-ca.pem');
1211
- await fs.writeFile(caPath, decoded.ca);
1212
- const r = spawnSync(process.execPath, process.argv.slice(1), {
1213
- stdio: 'inherit',
1214
- env: { ...process.env, NODE_EXTRA_CA_CERTS: caPath },
1215
- });
1216
- process.exit(r.status ?? 1);
1196
+ throw new Error('invalid join key (expected a base64url-encoded v2 join key)', { cause: e });
1217
1197
  }
1218
- console.log(`enrolling with ${controlUrl} as ${NODE_NAME}`);
1198
+ const transport = createTransport(decoded.credential, CONTROL_URL_OVERRIDE || decoded.connectUrl);
1199
+ console.log(`enrolling with ${decoded.credential.url} as ${decoded.name}`);
1200
+ const csr = await agentIdentityCsr(STATE_DIR);
1219
1201
  const wgPublicKey = MESH ? (await ensureWgKeys(STATE_DIR)).publicKey : undefined;
1220
- const res = await fetch(`${controlUrl}/agent/v1/enroll`, {
1221
- method: 'POST',
1222
- headers: { 'content-type': 'application/json' },
1223
- body: JSON.stringify({
1224
- enrollToken: decoded.enrollToken,
1225
- name: NODE_NAME,
1226
- address: process.env.TUG_NODE_ADDRESS,
1227
- publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS,
1228
- wgPublicKey,
1229
- labels: decoded.labels ||
1230
- (process.env.TUG_LABELS
1231
- ? process.env.TUG_LABELS.split(',')
1232
- .map((s) => s.trim())
1233
- .filter(Boolean)
1234
- : undefined),
1235
- }),
1236
- });
1237
- if (!res.ok)
1238
- throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
1239
- const out = (await res.json());
1240
- const creds = {
1241
- agentToken: out.agentToken,
1242
- nodeName: out.node?.name || NODE_NAME,
1243
- controlUrl,
1202
+ const body = {
1203
+ csr,
1204
+ address: process.env.TUG_NODE_ADDRESS,
1205
+ publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS,
1206
+ wgPublicKey,
1244
1207
  };
1245
- await saveCreds(creds);
1246
- if (wgPublicKey)
1247
- await writeReportedKey(STATE_DIR, wgPublicKey);
1248
- console.log(`enrolled as node ${creds.nodeName}`);
1249
- return creds;
1208
+ try {
1209
+ const res = await transport.http(`${transport.base}/agent/v1/enroll`, {
1210
+ method: 'POST',
1211
+ headers: { 'content-type': 'application/json' },
1212
+ body: JSON.stringify(body),
1213
+ });
1214
+ if (!res.ok)
1215
+ throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
1216
+ const doc = await installIdentity(STATE_DIR, (await res.json()));
1217
+ if (wgPublicKey)
1218
+ await writeReportedKey(STATE_DIR, wgPublicKey);
1219
+ console.log(`enrolled as node ${doc.nodeName}`);
1220
+ return doc;
1221
+ }
1222
+ finally {
1223
+ void transport.close();
1224
+ }
1250
1225
  }
1251
- // Establish durable credentials before polling: explicit env token (SSH/push install) wins,
1252
- // then credentials persisted on the state volume, then a one-time enroll via the join key.
1226
+ /** Loads the persisted identity, or enrolls with a join key. Without either the agent refuses to start. */
1253
1227
  export async function loadOrEnroll() {
1254
- let creds = TOKEN
1255
- ? { agentToken: TOKEN, nodeName: NODE_NAME, controlUrl: CONTROL_URL }
1256
- : await loadCreds();
1257
- if (!creds) {
1228
+ let doc = await loadAgentDocument(STATE_DIR);
1229
+ if (!doc) {
1258
1230
  const joinKey = await readJoinKey();
1259
1231
  if (!joinKey)
1260
- throw new Error('no TUG_AGENT_TOKEN, no persisted credentials, and no TUG_JOIN_KEY to enroll with');
1261
- creds = await enroll(joinKey);
1232
+ throw new Error(describeMissingIdentity());
1233
+ doc = await enroll(joinKey);
1262
1234
  }
1263
- TOKEN = creds.agentToken;
1264
- NODE_NAME = creds.nodeName || NODE_NAME;
1265
- if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
1266
- CONTROL_URL = creds.controlUrl;
1267
- return creds;
1235
+ NODE_NAME = doc.nodeName;
1236
+ activeDocument = doc;
1237
+ return doc;
1268
1238
  }
1239
+ const RENEW_RETRY_MS = 15 * 60_000;
1269
1240
  export async function main() {
1270
- await loadOrEnroll();
1271
- console.log(`tug-agent node=${NODE_NAME} control=${CONTROL_URL} apply=${APPLY}`);
1272
- const ctx = defaultContext();
1241
+ const doc = await loadOrEnroll();
1242
+ const ctx = defaultContext(doc);
1243
+ console.log(`tug-agent node=${NODE_NAME} control=${ctx.controlUrl} apply=${APPLY}`);
1244
+ let renewAt = renewalTime(doc.cert);
1245
+ /** Renews with the same key, saves, then swaps the client and reconnects. Failure keeps the old identity until it expires. */
1246
+ const renewIfDue = async () => {
1247
+ if (Date.now() < renewAt || !ctx.credential)
1248
+ return;
1249
+ try {
1250
+ const current = ctx.credential;
1251
+ const next = await renewCertificate(STATE_DIR, current, (csr) => api(ctx, '/agent/v1/renew', {
1252
+ method: 'POST',
1253
+ headers: { 'content-type': 'application/json' },
1254
+ body: JSON.stringify({ csr }),
1255
+ }));
1256
+ const transport = createTransport(next, CONTROL_URL_OVERRIDE);
1257
+ const previous = ctx.http;
1258
+ ctx.http = transport.http;
1259
+ ctx.credential = next;
1260
+ void previous;
1261
+ renewAt = renewalTime(next.cert);
1262
+ wake.close();
1263
+ console.log('node certificate renewed');
1264
+ }
1265
+ catch (e) {
1266
+ renewAt = Date.now() + RENEW_RETRY_MS;
1267
+ console.error(`certificate renewal failed (keeping the current one): ${errMessage(e)}`);
1268
+ }
1269
+ };
1273
1270
  const wake = new AgentWakeChannel(ctx, undefined, {
1274
1271
  onLive: (frame) => live.handle(frame),
1275
1272
  onDisconnect: () => live.dispose(),
@@ -1278,6 +1275,7 @@ export async function main() {
1278
1275
  wake.send(frame);
1279
1276
  });
1280
1277
  for (;;) {
1278
+ await renewIfDue();
1281
1279
  try {
1282
1280
  await pollOnce(ctx);
1283
1281
  }
@@ -110,7 +110,7 @@ function serviceNodes(svc) {
110
110
  return {
111
111
  nodes: placement.nodes || [],
112
112
  dynamic: Array.isArray(placement.labels) && placement.labels.length > 0,
113
- global: lp.global === true || placement.global === true,
113
+ global: lp.global === true,
114
114
  };
115
115
  }
116
116
  function mountedVolumes(svc) {