@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.
- package/README.md +24 -22
- package/dist/agent-credential.d.ts +39 -0
- package/dist/agent-credential.js +135 -0
- package/dist/agent.d.ts +8 -9
- package/dist/agent.js +100 -102
- package/dist/backup-config.js +1 -1
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/cli-build.js +70 -4
- package/dist/cli-config.d.ts +9 -5
- package/dist/cli-config.js +89 -34
- package/dist/cli-install.js +1 -1
- package/dist/cli-live-transport.d.ts +3 -2
- package/dist/cli-live-transport.js +9 -5
- package/dist/cli.js +265 -142
- package/dist/compose.d.ts +3 -1
- package/dist/compose.js +14 -2
- package/dist/control-credential.d.ts +12 -0
- package/dist/control-credential.js +46 -0
- package/dist/control.d.ts +18 -1
- package/dist/control.js +586 -202
- package/dist/credentials.d.ts +111 -0
- package/dist/{tokens.js → credentials.js} +70 -54
- package/dist/db.d.ts +20 -5
- package/dist/db.js +3 -3
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +65 -0
- package/dist/ingress.js +4 -3
- package/dist/main.js +35 -0
- package/dist/normalize.js +1 -1
- package/dist/pki.d.ts +119 -0
- package/dist/pki.js +339 -0
- package/dist/protocol.d.ts +51 -22
- package/dist/status-report.d.ts +11 -0
- package/dist/status-report.js +147 -0
- package/dist/templates/agent.env +1 -3
- package/dist/templates/control-compose.yml +4 -3
- package/dist/templates/control-install.sh +8 -2
- package/dist/tls-server.d.ts +11 -0
- package/dist/tls-server.js +23 -0
- package/package.json +10 -8
- 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
|
|
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
|
-
-
|
|
10
|
-
- Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run`
|
|
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
|
-
-
|
|
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 (
|
|
25
|
+
tug CLI / CI (operator credential)
|
|
26
26
|
|
|
|
27
|
-
|
|
|
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
|
-
|
|
|
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
|
|
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
|
|
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.
|
|
64
|
-
2. Point the CLI at it: `tug init
|
|
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-
|
|
67
|
-
5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new
|
|
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
|
|
76
|
-
- [Access control](docs/access-control.md): scoped
|
|
77
|
-
- [
|
|
78
|
-
- [
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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:
|
|
39
|
-
nodeName:
|
|
42
|
+
http: transport.http,
|
|
43
|
+
nodeName: doc.nodeName,
|
|
40
44
|
stateDir: STATE_DIR,
|
|
41
45
|
apply: APPLY,
|
|
42
|
-
controlUrl:
|
|
43
|
-
|
|
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,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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)}`, {
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
-
|
|
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
|
|
1255
|
-
|
|
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(
|
|
1261
|
-
|
|
1232
|
+
throw new Error(describeMissingIdentity());
|
|
1233
|
+
doc = await enroll(joinKey);
|
|
1262
1234
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
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
|
}
|
package/dist/backup-config.js
CHANGED
|
@@ -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
|
|
113
|
+
global: lp.global === true,
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
function mountedVolumes(svc) {
|