@wpelevator/tug 0.8.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.
- package/README.md +7 -7
- package/dist/build.d.ts +6 -0
- package/dist/build.js +27 -7
- package/dist/cli-build.js +8 -1
- package/dist/cli.js +5 -4
- package/dist/compose.d.ts +2 -0
- package/dist/compose.js +9 -0
- package/dist/control-credential.js +4 -4
- package/dist/control.d.ts +1 -1
- package/dist/control.js +34 -19
- package/dist/live.d.ts +2 -0
- package/dist/live.js +9 -1
- package/dist/main.js +1 -1
- package/dist/mesh.d.ts +4 -0
- package/dist/mesh.js +17 -0
- package/dist/pki.d.ts +14 -3
- package/dist/pki.js +24 -7
- package/dist/templates/control-compose.yml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,19 +56,19 @@ Install the CLI (Node >= 22.18), or run it without installing through `npx @wpel
|
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
npm install -g @wpelevator/tug
|
|
59
|
+
tug control-install root@203.0.113.5 --advertise-url https://203.0.113.5:8443 --install-docker
|
|
60
|
+
tug init tug-root.credential.json
|
|
61
|
+
tug node-install root@198.51.100.7 --name app1 --install-docker
|
|
62
|
+
tug deploy -f docker-compose.yml
|
|
59
63
|
```
|
|
60
64
|
|
|
61
|
-
The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
|
|
65
|
+
The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts. Control serves mutual TLS directly on its port (`8443`): every operator, CI job and node authenticates with a certificate from a private CA created at install, and clients verify a name generated from that CA, so the address can be a bare IP and can change later. Do not terminate TLS in front of it.
|
|
62
66
|
|
|
63
|
-
|
|
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
|
-
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 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`.
|
|
67
|
+
The full walkthrough, including nodes you cannot SSH to (`tug node-prepare-enrollment`), CI credentials (`tug credential-create`), moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
|
|
69
68
|
|
|
70
69
|
## Documentation
|
|
71
70
|
|
|
71
|
+
- [Getting started](docs/getting-started.md): install control, add nodes, deploy, give CI a credential, move control, recover.
|
|
72
72
|
- [Terminology](docs/terminology.md): core concepts, the naming review (which recommendations are implemented), industry comparisons and the remaining proposed interface improvements.
|
|
73
73
|
- [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
|
|
74
74
|
- [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
|
package/dist/build.d.ts
CHANGED
|
@@ -5,7 +5,13 @@ export interface RunResult {
|
|
|
5
5
|
export interface RunOptions {
|
|
6
6
|
cwd?: string;
|
|
7
7
|
env?: NodeJS.ProcessEnv;
|
|
8
|
+
/** Kills the process after this long; defaults to {@link RUN_TIMEOUT_MS}. */
|
|
9
|
+
timeoutMs?: number;
|
|
8
10
|
}
|
|
11
|
+
/** Longest a child process may run before it is killed, so a stalled build cannot hold the control writer forever. */
|
|
12
|
+
export declare const RUN_TIMEOUT_MS: number;
|
|
13
|
+
/** Per-stream cap on retained output; the tail is kept because failures are reported last. */
|
|
14
|
+
export declare const RUN_OUTPUT_LIMIT: number;
|
|
9
15
|
export interface BuildSpec {
|
|
10
16
|
context?: string;
|
|
11
17
|
dockerfile?: string;
|
package/dist/build.js
CHANGED
|
@@ -4,16 +4,36 @@ import path from 'node:path';
|
|
|
4
4
|
import * as tar from 'tar';
|
|
5
5
|
import { scanArchive } from "./archive.js";
|
|
6
6
|
import { ensureDir, exists } from "./util.js";
|
|
7
|
+
/** Longest a child process may run before it is killed, so a stalled build cannot hold the control writer forever. */
|
|
8
|
+
export const RUN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
9
|
+
/** Per-stream cap on retained output; the tail is kept because failures are reported last. */
|
|
10
|
+
export const RUN_OUTPUT_LIMIT = 1024 * 1024;
|
|
11
|
+
const tail = (text, chunk) => (text + chunk).length > RUN_OUTPUT_LIMIT ? (text + chunk).slice(-RUN_OUTPUT_LIMIT) : text + chunk;
|
|
7
12
|
export function run(cmd, args, opts = {}) {
|
|
8
13
|
return new Promise((resolve, reject) => {
|
|
9
|
-
const
|
|
14
|
+
const { timeoutMs = RUN_TIMEOUT_MS, ...spawnOptions } = opts;
|
|
15
|
+
const p = spawn(cmd, args, { ...spawnOptions, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
10
16
|
let out = '', err = '';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
let timedOut = false;
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
timedOut = true;
|
|
20
|
+
p.kill('SIGKILL');
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
p.stdout.on('data', (d) => (out = tail(out, String(d))));
|
|
23
|
+
p.stderr.on('data', (d) => (err = tail(err, String(d))));
|
|
24
|
+
p.on('error', (error) => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
reject(error);
|
|
27
|
+
});
|
|
28
|
+
p.on('close', (code) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
if (timedOut)
|
|
31
|
+
reject(new Error(`${cmd} ${args.join(' ')} timed out after ${timeoutMs} ms\n${out}\n${err}`));
|
|
32
|
+
else if (code === 0)
|
|
33
|
+
resolve({ out, err });
|
|
34
|
+
else
|
|
35
|
+
reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${out}\n${err}`));
|
|
36
|
+
});
|
|
17
37
|
});
|
|
18
38
|
}
|
|
19
39
|
export async function unpackContext(tarball, dest) {
|
package/dist/cli-build.js
CHANGED
|
@@ -32,7 +32,7 @@ export function sourceArchiveFilter(entry, configPaths, ignores) {
|
|
|
32
32
|
/** Credential documents written by `control-install --credential-out` (default `tug-root.credential.json`). */
|
|
33
33
|
const CREDENTIAL_FILE = /\.credential\.json$/;
|
|
34
34
|
const ignoreCache = new WeakMap();
|
|
35
|
-
/** Translates one `.dockerignore` pattern:
|
|
35
|
+
/** Translates one `.dockerignore` pattern: `*`, `?` and `[...]` classes stay within a path segment, `**` crosses them. */
|
|
36
36
|
function ignorePattern(pattern) {
|
|
37
37
|
let source = '';
|
|
38
38
|
for (let i = 0; i < pattern.length; i++) {
|
|
@@ -51,6 +51,13 @@ function ignorePattern(pattern) {
|
|
|
51
51
|
source += '[^/]*';
|
|
52
52
|
else if (char === '?')
|
|
53
53
|
source += '[^/]';
|
|
54
|
+
else if (char === '[' && pattern.indexOf(']', i + 2) > 0) {
|
|
55
|
+
const close = pattern.indexOf(']', i + 2);
|
|
56
|
+
const body = pattern.slice(i + 1, close);
|
|
57
|
+
const negated = body[0] === '^' || body[0] === '!';
|
|
58
|
+
source += `[${negated ? '^' : ''}${(negated ? body.slice(1) : body).replace(/[\\\]^]/g, '\\$&')}]`;
|
|
59
|
+
i = close;
|
|
60
|
+
}
|
|
54
61
|
else if (char === '\\' && i + 1 < pattern.length)
|
|
55
62
|
source += pattern[++i].replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
|
56
63
|
else
|
package/dist/cli.js
CHANGED
|
@@ -272,7 +272,7 @@ program
|
|
|
272
272
|
program
|
|
273
273
|
.command('control-install <sshTarget>')
|
|
274
274
|
.description('install the control plane on a Docker-capable host over SSH and issue the root credential into a local 0600 file; it listens for mTLS only, so front it with TCP passthrough rather than a terminating proxy')
|
|
275
|
-
.requiredOption('--
|
|
275
|
+
.requiredOption('--advertise-url <url>', 'credential-free https origin clients use, including the published port')
|
|
276
276
|
.option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
|
|
277
277
|
.option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
278
278
|
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
@@ -282,7 +282,7 @@ program
|
|
|
282
282
|
.option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
|
|
283
283
|
.option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
|
|
284
284
|
.action(async (sshTarget, o) => {
|
|
285
|
-
const
|
|
285
|
+
const advertiseUrl = normalizeControlUrl(o.advertiseUrl);
|
|
286
286
|
if (o.installDocker)
|
|
287
287
|
await bootstrapDocker(sshTarget, true);
|
|
288
288
|
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
|
|
@@ -292,7 +292,7 @@ program
|
|
|
292
292
|
const yamlValue = (value) => JSON.stringify(value);
|
|
293
293
|
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
294
294
|
IMAGE: yamlValue(image),
|
|
295
|
-
|
|
295
|
+
ADVERTISE_URL: yamlValue(new URL(advertiseUrl).origin),
|
|
296
296
|
EXTRA_SANS: yamlValue(o.extraSan.join(',')),
|
|
297
297
|
PORT: o.port,
|
|
298
298
|
MESH: yamlValue(o.mesh ? 'true' : 'false'),
|
|
@@ -316,7 +316,7 @@ program
|
|
|
316
316
|
await writeFileDurable(o.credentialOut, `${String(out.stdout).trim()}\n`, 0o600);
|
|
317
317
|
console.error(`root credential written to ${o.credentialOut}; import it with: tug init ${o.credentialOut}`);
|
|
318
318
|
}
|
|
319
|
-
console.log(JSON.stringify({ ok: true, sshTarget, url:
|
|
319
|
+
console.log(JSON.stringify({ ok: true, sshTarget, url: advertiseUrl }, null, 2));
|
|
320
320
|
});
|
|
321
321
|
const AGENT_STATE = '/var/lib/tug';
|
|
322
322
|
function addNodeIdentityOptions(command) {
|
|
@@ -730,6 +730,7 @@ async function makeContext() {
|
|
|
730
730
|
await createSourceArchive(process.cwd(), tmp, [
|
|
731
731
|
resolvedConfig.file,
|
|
732
732
|
resolvedConfig.localFile,
|
|
733
|
+
...(resolvedConfig.credentialFile ? [resolvedConfig.credentialFile] : []),
|
|
733
734
|
]);
|
|
734
735
|
}
|
|
735
736
|
catch (error) {
|
package/dist/compose.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export declare function isRolling(svc: ComposeService | undefined): boolean;
|
|
|
36
36
|
export declare const SUPPORTED_SERVICE_KEYS: readonly string[];
|
|
37
37
|
/** Lower-cased ingress hostnames a Compose definition claims, by `service` name. */
|
|
38
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;
|
|
39
41
|
export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
|
|
40
42
|
export interface BuildDesiredStateOptions {
|
|
41
43
|
project: string;
|
package/dist/compose.js
CHANGED
|
@@ -289,6 +289,15 @@ export function ingressHostsOf(compose) {
|
|
|
289
289
|
}
|
|
290
290
|
return hosts;
|
|
291
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
|
+
}
|
|
292
301
|
export function validateCompose(compose, nodes = []) {
|
|
293
302
|
if (!compose?.services || typeof compose.services !== 'object')
|
|
294
303
|
throw new Error('compose file must define services');
|
|
@@ -25,15 +25,15 @@ export function parseIssueArgs(argv) {
|
|
|
25
25
|
*/
|
|
26
26
|
export async function issueHostCredential(args) {
|
|
27
27
|
const dataDir = process.env.TUG_DATA_DIR || './data';
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
throw new Error('
|
|
28
|
+
const advertiseUrl = process.env.TUG_ADVERTISE_URL;
|
|
29
|
+
if (!advertiseUrl)
|
|
30
|
+
throw new Error('TUG_ADVERTISE_URL is required');
|
|
31
31
|
const db = await openDb(path.join(dataDir, 'tug.db'));
|
|
32
32
|
try {
|
|
33
33
|
const fresh = !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes');
|
|
34
34
|
const pki = await Pki.load(path.join(dataDir, 'pki'), fresh);
|
|
35
35
|
const request = parseCredentialRequest({ name: args.name, role: args.role });
|
|
36
|
-
const created = await createCredential(db, pki, new URL(
|
|
36
|
+
const created = await createCredential(db, pki, new URL(advertiseUrl).origin, request, 'host', {
|
|
37
37
|
replace: args.replace,
|
|
38
38
|
});
|
|
39
39
|
if (args.replace)
|
package/dist/control.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare function createApp(options?: {
|
|
|
27
27
|
openDatabase?: typeof openDb;
|
|
28
28
|
/** Minimum time between event prunes triggered by agent reports; default one hour. */ eventPruneIntervalMs?: number;
|
|
29
29
|
/** Override the live open/agent startup timeout in milliseconds. */ liveStartTimeoutMs?: number;
|
|
30
|
-
/** Credential-free HTTPS origin clients use to reach control; defaults to `
|
|
30
|
+
/** Credential-free HTTPS origin clients use to reach control; defaults to `TUG_ADVERTISE_URL`. */ advertiseUrl?: string;
|
|
31
31
|
/**
|
|
32
32
|
* Replaces certificate-based identity resolution. Only for in-process tests that skip TLS; production never sets it.
|
|
33
33
|
* It returns the principal URI and key hash a connection would have presented.
|
package/dist/control.js
CHANGED
|
@@ -16,14 +16,14 @@ import * as tar from 'tar';
|
|
|
16
16
|
import { openDb, openSnapshotDb } from "./db.js";
|
|
17
17
|
import { isUniqueViolation } from "./db-driver.js";
|
|
18
18
|
import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
|
|
19
|
-
import { buildDesiredState, ingressHostsOf, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
|
|
19
|
+
import { buildDesiredState, ingressHostsOf, ingressHostsOverlap, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
|
|
20
20
|
import { buildImage, unpackContext, run as runCommand } from "./build.js";
|
|
21
21
|
import { sentinelNames } from "./normalize.js";
|
|
22
22
|
import { ensureDir, errMessage, id, now } from "./util.js";
|
|
23
23
|
import { ENROLL_DEFAULT_TTL_MS, NODE_LIFETIME_MS, Pki, SERVER_LIFETIME_MS, encodeCredential, identityUri, namesKey, parseIdentityUri, peerIdentity, serverNames, writeFileDurable, } from "./pki.js";
|
|
24
24
|
import { EXPIRY_WARN_DAYS, certificateExpiry, diagnoseNode } from "./diagnostics.js";
|
|
25
25
|
import { serverTlsOptions } from "./tls-server.js";
|
|
26
|
-
import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } from "./mesh.js";
|
|
26
|
+
import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, isNodeEndpoint, isNodeHost, } from "./mesh.js";
|
|
27
27
|
import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
|
|
28
28
|
import { parseStatusReport } from "./status-report.js";
|
|
29
29
|
import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, BACKUP_COMMAND_TYPES, } from "./protocol.js";
|
|
@@ -479,10 +479,10 @@ export async function createApp(options = {}) {
|
|
|
479
479
|
const masterKey = await loadOrCreateMasterKey(masterKeyFile);
|
|
480
480
|
// A CA is created only for a fresh installation; existing state with missing or mismatched CA material is a startup error.
|
|
481
481
|
const pki = await Pki.load(path.join(DATA_DIR, 'pki'), !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes'));
|
|
482
|
-
const
|
|
483
|
-
const url = options.
|
|
482
|
+
const advertiseUrl = () => {
|
|
483
|
+
const url = options.advertiseUrl ?? process.env.TUG_ADVERTISE_URL;
|
|
484
484
|
if (!url)
|
|
485
|
-
throw httpError(500, '
|
|
485
|
+
throw httpError(500, 'TUG_ADVERTISE_URL is not set');
|
|
486
486
|
return new URL(url).origin;
|
|
487
487
|
};
|
|
488
488
|
const app = express();
|
|
@@ -708,11 +708,10 @@ export async function createApp(options = {}) {
|
|
|
708
708
|
for (const other of currentDesiredStates()) {
|
|
709
709
|
if (other.project === project)
|
|
710
710
|
continue;
|
|
711
|
-
for (const [host, service] of ingressHostsOf(other.compose))
|
|
712
|
-
const mine
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
711
|
+
for (const [host, service] of ingressHostsOf(other.compose))
|
|
712
|
+
for (const [wantedHost, mine] of wanted)
|
|
713
|
+
if (ingressHostsOverlap(wantedHost, host))
|
|
714
|
+
throw httpError(409, `${mine}: ingress.host ${wantedHost} ${wantedHost === host ? 'is' : `overlaps ${host},`} already routed by project ${other.project} (${service})`);
|
|
716
715
|
}
|
|
717
716
|
}
|
|
718
717
|
function createRollback(project, opts) {
|
|
@@ -995,6 +994,10 @@ export async function createApp(options = {}) {
|
|
|
995
994
|
function insertNode(input) {
|
|
996
995
|
if (!NODE_NAME.test(input.name))
|
|
997
996
|
throw httpError(400, 'node name must be 1-63 letters, numbers, dots, underscores or hyphens');
|
|
997
|
+
if (input.address && !isNodeHost(input.address))
|
|
998
|
+
throw httpError(400, 'address must be an IP address or hostname without a port');
|
|
999
|
+
if (input.publicAddress && !isNodeEndpoint(input.publicAddress))
|
|
1000
|
+
throw httpError(400, 'publicAddress must be an IP address or hostname with an optional port');
|
|
998
1001
|
if (input.wgPublicKey !== undefined && !isWgKey(input.wgPublicKey))
|
|
999
1002
|
throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
|
|
1000
1003
|
const ts = now();
|
|
@@ -1008,7 +1011,7 @@ export async function createApp(options = {}) {
|
|
|
1008
1011
|
node: nodeInfo(nodeId),
|
|
1009
1012
|
certificate: cert,
|
|
1010
1013
|
ca: pki.caPem,
|
|
1011
|
-
url:
|
|
1014
|
+
url: advertiseUrl(),
|
|
1012
1015
|
});
|
|
1013
1016
|
// The node generates its key and CSR; an admin posts the CSR here. The private key is never transmitted.
|
|
1014
1017
|
api('post', '/api/v1/nodes/:name/identity', null, async (req, res) => {
|
|
@@ -1079,7 +1082,7 @@ export async function createApp(options = {}) {
|
|
|
1079
1082
|
return res.status(400).json({ error: 'labels must be a list of strings' });
|
|
1080
1083
|
const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? ENROLL_DEFAULT_TTL_MS / 1000));
|
|
1081
1084
|
const rowId = id('enr');
|
|
1082
|
-
const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000,
|
|
1085
|
+
const issued = await pki.issueClient(identityUri('enroll', rowId), ttlSeconds * 1000, advertiseUrl());
|
|
1083
1086
|
const expiresAt = issued.notAfter.toISOString();
|
|
1084
1087
|
db.run('DELETE FROM enrollment_credentials WHERE expires_at < ?', new Date(Date.now() - 86_400_000).toISOString());
|
|
1085
1088
|
// Minting again for a name revokes its previous unconsumed key.
|
|
@@ -1267,7 +1270,7 @@ export async function createApp(options = {}) {
|
|
|
1267
1270
|
});
|
|
1268
1271
|
api('post', '/api/v1/credentials', null, async (req, res) => {
|
|
1269
1272
|
const request = parseCredentialRequest(req.body);
|
|
1270
|
-
const created = await createCredential(db, pki,
|
|
1273
|
+
const created = await createCredential(db, pki, advertiseUrl(), request, req.actor.name);
|
|
1271
1274
|
audit(req.actor, {
|
|
1272
1275
|
type: 'credential.create',
|
|
1273
1276
|
message: `credential ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
|
|
@@ -1624,6 +1627,10 @@ export async function createApp(options = {}) {
|
|
|
1624
1627
|
}
|
|
1625
1628
|
}
|
|
1626
1629
|
const nodes = nodeRows.map((row) => ({ name: row.name }));
|
|
1630
|
+
for (const [name, svc] of Object.entries(compose.services || {}))
|
|
1631
|
+
for (const node of svc['x-tug']?.placement?.nodes || [])
|
|
1632
|
+
if (!nodes.some((n) => n.name === node))
|
|
1633
|
+
throw httpError(400, `${name}: placement node ${node} is not an enabled node`);
|
|
1627
1634
|
try {
|
|
1628
1635
|
validateCompose(compose, nodes.map((n) => n.name));
|
|
1629
1636
|
}
|
|
@@ -2460,6 +2467,14 @@ export async function createApp(options = {}) {
|
|
|
2460
2467
|
return res.status(409).json({ error: `command is ${command.state}, not running` });
|
|
2461
2468
|
if (!command.project_id || !backupDestination())
|
|
2462
2469
|
return res.status(409).json({ error: 'no backup destination is configured' });
|
|
2470
|
+
// A command that outlived the project's placement on this node (a newer version moved it) gets nothing.
|
|
2471
|
+
const desiredRow = currentDesired(command.project_id);
|
|
2472
|
+
const desiredNow = desiredRow
|
|
2473
|
+
? JSON.parse(desiredRow.state_json)
|
|
2474
|
+
: undefined;
|
|
2475
|
+
const placedHere = Object.values(desiredNow?.services || {}).some((svc) => svc.placement?.nodes?.includes(req.node.name));
|
|
2476
|
+
if (!placedHere)
|
|
2477
|
+
return res.status(403).json({ error: 'project is no longer placed on this node' });
|
|
2463
2478
|
const response = {
|
|
2464
2479
|
env: {
|
|
2465
2480
|
...backupEnv(),
|
|
@@ -2583,18 +2598,18 @@ async function ensureServerCredential(pki, dir, names, force = false) {
|
|
|
2583
2598
|
export async function listen(app, port = PORT) {
|
|
2584
2599
|
const pki = app.locals.pki;
|
|
2585
2600
|
const dataDir = app.locals.dataDir;
|
|
2586
|
-
const
|
|
2587
|
-
if (!
|
|
2588
|
-
throw new Error('
|
|
2589
|
-
const url = new URL(
|
|
2601
|
+
const advertiseUrl = process.env.TUG_ADVERTISE_URL;
|
|
2602
|
+
if (!advertiseUrl)
|
|
2603
|
+
throw new Error('TUG_ADVERTISE_URL is required: the credential-free https origin clients connect to');
|
|
2604
|
+
const url = new URL(advertiseUrl);
|
|
2590
2605
|
if (url.protocol !== 'https:' ||
|
|
2591
2606
|
url.username ||
|
|
2592
2607
|
url.password ||
|
|
2593
2608
|
url.search ||
|
|
2594
2609
|
url.hash ||
|
|
2595
2610
|
url.pathname !== '/')
|
|
2596
|
-
throw new Error('
|
|
2597
|
-
const names = serverNames(
|
|
2611
|
+
throw new Error('TUG_ADVERTISE_URL must be a credential-free https origin such as https://control.example.com:8443');
|
|
2612
|
+
const names = serverNames(advertiseUrl, (process.env.TUG_EXTRA_SANS || '').split(','));
|
|
2598
2613
|
const credential = await ensureServerCredential(pki, dataDir, names);
|
|
2599
2614
|
const server = https.createServer(serverTlsOptions({ cert: credential.cert, key: credential.key, ca: pki.caPem }), app);
|
|
2600
2615
|
server.requestTimeout = 5 * 60_000;
|
package/dist/live.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface LiveEngineOptions {
|
|
|
62
62
|
queryTimeoutMs?: number;
|
|
63
63
|
}
|
|
64
64
|
/** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
65
|
+
/** Keeps environment variable names but not values, so routine `inspect` output does not leak secrets by accident. */
|
|
66
|
+
export declare function redactInspect(inspected: Record<string, unknown>): Record<string, unknown>;
|
|
65
67
|
export declare class LiveOperationEngine {
|
|
66
68
|
private readonly options;
|
|
67
69
|
private readonly sessions;
|
package/dist/live.js
CHANGED
|
@@ -48,6 +48,14 @@ function validate(value) {
|
|
|
48
48
|
throw new Error('Invalid projects');
|
|
49
49
|
}
|
|
50
50
|
/** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
51
|
+
/** Keeps environment variable names but not values, so routine `inspect` output does not leak secrets by accident. */
|
|
52
|
+
export function redactInspect(inspected) {
|
|
53
|
+
const config = inspected.Config;
|
|
54
|
+
if (!config || !Array.isArray(config.Env))
|
|
55
|
+
return inspected;
|
|
56
|
+
const Env = config.Env.map((entry) => typeof entry === 'string' ? `${entry.split('=', 1)[0]}=<redacted>` : entry);
|
|
57
|
+
return { ...inspected, Config: { ...config, Env } };
|
|
58
|
+
}
|
|
51
59
|
export class LiveOperationEngine {
|
|
52
60
|
options;
|
|
53
61
|
sessions = new Map();
|
|
@@ -155,7 +163,7 @@ export class LiveOperationEngine {
|
|
|
155
163
|
result = lists.flat();
|
|
156
164
|
}
|
|
157
165
|
else
|
|
158
|
-
result = [await this.options.docker.inspectContainer(request.container)];
|
|
166
|
+
result = [redactInspect(await this.options.docker.inspectContainer(request.container))];
|
|
159
167
|
const frame = { v: 1, type: 'live.result', sessionId: id, result };
|
|
160
168
|
if (Buffer.byteLength(JSON.stringify(frame)) > LIVE_MAX_FRAME_BYTES)
|
|
161
169
|
throw new Error('Live result exceeds serialized frame size limit');
|
package/dist/main.js
CHANGED
|
@@ -9,7 +9,7 @@ async function readStdin() {
|
|
|
9
9
|
return Buffer.concat(chunks).toString('utf8');
|
|
10
10
|
}
|
|
11
11
|
const usage = `Usage: tug-runtime <control|agent> [command]
|
|
12
|
-
control run the control plane (needs
|
|
12
|
+
control run the control plane (needs TUG_ADVERTISE_URL)
|
|
13
13
|
control issue-credential issue an operator credential on the control-plane host (--name, --role, --replace)
|
|
14
14
|
agent run the node agent
|
|
15
15
|
agent identity print the CSR for this node's pending key
|
package/dist/mesh.d.ts
CHANGED
|
@@ -34,6 +34,10 @@ export declare function parseCidr(cidr: string): {
|
|
|
34
34
|
};
|
|
35
35
|
/** First free host address in the mesh CIDR (never the network or broadcast address). */
|
|
36
36
|
export declare function allocateOverlayIp(cidr: string, used: Iterable<string>): string;
|
|
37
|
+
/** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
|
|
38
|
+
export declare const isNodeHost: (value: unknown) => value is string;
|
|
39
|
+
/** A node host with an optional port: `host`, `host:port` or `[ipv6]:port`. */
|
|
40
|
+
export declare const isNodeEndpoint: (value: unknown) => value is string;
|
|
37
41
|
/** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
|
|
38
42
|
export declare function wgEndpoint(publicAddress: string | null | undefined, port: number): string | undefined;
|
|
39
43
|
/** `wg syncconf` input: interface key and port plus peers (no wg-quick-only keys such as Address). */
|
package/dist/mesh.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// and the idempotent `wg0` reconcile (agent). All shell-outs go through an injectable HostRunner.
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { ensureDir, errMessage } from "./util.js";
|
|
7
8
|
/** Default mesh CIDR. Chosen to avoid the integration subnet 10.89.240.0/24, Docker's 172.16/12 and common home/VPN ranges. */
|
|
@@ -96,6 +97,22 @@ export function allocateOverlayIp(cidr, used) {
|
|
|
96
97
|
}
|
|
97
98
|
throw new Error(`mesh CIDR ${cidr} has no free overlay addresses`);
|
|
98
99
|
}
|
|
100
|
+
const HOST_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
101
|
+
/** An IP literal or DNS hostname with no port, brackets, whitespace or other syntax that could reach generated configuration. */
|
|
102
|
+
export const isNodeHost = (value) => typeof value === 'string' &&
|
|
103
|
+
value.length <= 253 &&
|
|
104
|
+
(isIP(value) !== 0 || value.split('.').every((label) => HOST_LABEL.test(label)));
|
|
105
|
+
/** A node host with an optional port: `host`, `host:port` or `[ipv6]:port`. */
|
|
106
|
+
export const isNodeEndpoint = (value) => {
|
|
107
|
+
if (typeof value !== 'string')
|
|
108
|
+
return false;
|
|
109
|
+
const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value);
|
|
110
|
+
const parts = bracketed ?? (isIP(value) ? null : /^([^:]+)(?::(\d{1,5}))?$/.exec(value));
|
|
111
|
+
const [host, port] = parts ? [parts[1], parts[2]] : [value, undefined];
|
|
112
|
+
if (bracketed ? isIP(host) !== 6 : !isNodeHost(host))
|
|
113
|
+
return false;
|
|
114
|
+
return port === undefined || (Number(port) >= 1 && Number(port) <= 65535);
|
|
115
|
+
};
|
|
99
116
|
/** `host:port`, adding the WireGuard port when the public address has none. IPv6 literals are bracketed. */
|
|
100
117
|
export function wgEndpoint(publicAddress, port) {
|
|
101
118
|
const addr = publicAddress?.trim();
|
package/dist/pki.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface SubjectNames {
|
|
|
59
59
|
ip: string[];
|
|
60
60
|
}
|
|
61
61
|
/** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
|
|
62
|
-
export declare function serverNames(
|
|
62
|
+
export declare function serverNames(advertiseUrl: string, extras?: string[]): SubjectNames;
|
|
63
63
|
export declare const namesKey: (names: SubjectNames) => string;
|
|
64
64
|
export declare class Pki {
|
|
65
65
|
readonly caPem: string;
|
|
@@ -95,7 +95,9 @@ export declare class Pki {
|
|
|
95
95
|
notAfter: Date;
|
|
96
96
|
}>;
|
|
97
97
|
/** The server certificate and key for the given names. */
|
|
98
|
-
|
|
98
|
+
get controlName(): string;
|
|
99
|
+
/** The server certificate always carries the generated control name, plus the given addresses. */
|
|
100
|
+
issueServer(addresses: SubjectNames): Promise<{
|
|
99
101
|
cert: string;
|
|
100
102
|
key: string;
|
|
101
103
|
notAfter: Date;
|
|
@@ -108,7 +110,16 @@ export declare function validateInstalledCertificate(options: {
|
|
|
108
110
|
keyPem: string;
|
|
109
111
|
uri: string;
|
|
110
112
|
}): void;
|
|
111
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* The generated name control answers to, derived from its CA key: stable for the life of the CA and independent of any
|
|
115
|
+
* address. Control puts it in its server certificate and clients recompute it from the CA they pin, so moving control to
|
|
116
|
+
* another IP or DNS name needs no new credentials.
|
|
117
|
+
*/
|
|
118
|
+
export declare function controlNameFromCa(caPem: string): string;
|
|
119
|
+
/**
|
|
120
|
+
* TLS client options pinned to the credential's CA. The server is verified by its generated control name, never by the
|
|
121
|
+
* address the connection goes to, so an address override (a tunnel, a private IP, a moved host) keeps full verification.
|
|
122
|
+
*/
|
|
112
123
|
export declare function clientTlsOptions(credential: Credential): tls.ConnectionOptions;
|
|
113
124
|
/** The peer identity presented on a TLS socket, or null when none can be established. */
|
|
114
125
|
export declare function peerIdentity(socket: tls.TLSSocket): {
|
package/dist/pki.js
CHANGED
|
@@ -149,9 +149,9 @@ export async function verifyCsr(csrPem) {
|
|
|
149
149
|
}
|
|
150
150
|
const serial = () => `0${crypto.randomBytes(15).toString('hex')}`;
|
|
151
151
|
/** The DNS names and IP addresses a server certificate covers, from the public URL plus optional extras. */
|
|
152
|
-
export function serverNames(
|
|
152
|
+
export function serverNames(advertiseUrl, extras = []) {
|
|
153
153
|
const names = { dns: [], ip: [] };
|
|
154
|
-
const host = new URL(
|
|
154
|
+
const host = new URL(advertiseUrl).hostname.replace(/^\[|\]$/g, '');
|
|
155
155
|
for (const entry of [host, ...extras.map((e) => e.trim()).filter(Boolean)]) {
|
|
156
156
|
const bucket = net.isIP(entry) ? names.ip : names.dns;
|
|
157
157
|
if (!bucket.includes(entry))
|
|
@@ -280,7 +280,12 @@ export class Pki {
|
|
|
280
280
|
return { credential, keyHash: certKeyHash(cert), notAfter };
|
|
281
281
|
}
|
|
282
282
|
/** The server certificate and key for the given names. */
|
|
283
|
-
|
|
283
|
+
get controlName() {
|
|
284
|
+
return controlNameFromCa(this.caPem);
|
|
285
|
+
}
|
|
286
|
+
/** The server certificate always carries the generated control name, plus the given addresses. */
|
|
287
|
+
async issueServer(addresses) {
|
|
288
|
+
const names = { dns: [this.controlName, ...addresses.dns], ip: addresses.ip };
|
|
284
289
|
const keys = await generateKeyPair();
|
|
285
290
|
const { cert, notAfter } = await this.signLeaf({
|
|
286
291
|
publicKey: keys.publicKey,
|
|
@@ -301,16 +306,28 @@ export function validateInstalledCertificate(options) {
|
|
|
301
306
|
if (!(cert.subjectAltName ?? '').split(/,\s*/).includes(`URI:${options.uri}`))
|
|
302
307
|
throw new Error(`certificate does not name ${options.uri}`);
|
|
303
308
|
}
|
|
304
|
-
/**
|
|
309
|
+
/**
|
|
310
|
+
* The generated name control answers to, derived from its CA key: stable for the life of the CA and independent of any
|
|
311
|
+
* address. Control puts it in its server certificate and clients recompute it from the CA they pin, so moving control to
|
|
312
|
+
* another IP or DNS name needs no new credentials.
|
|
313
|
+
*/
|
|
314
|
+
export function controlNameFromCa(caPem) {
|
|
315
|
+
const spki = new crypto.X509Certificate(caPem).publicKey.export({ type: 'spki', format: 'der' });
|
|
316
|
+
return `control-${hashSpki(spki).slice(0, 12)}.tug`;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* TLS client options pinned to the credential's CA. The server is verified by its generated control name, never by the
|
|
320
|
+
* address the connection goes to, so an address override (a tunnel, a private IP, a moved host) keeps full verification.
|
|
321
|
+
*/
|
|
305
322
|
export function clientTlsOptions(credential) {
|
|
306
|
-
const
|
|
323
|
+
const name = controlNameFromCa(credential.ca);
|
|
307
324
|
return {
|
|
308
325
|
ca: credential.ca,
|
|
309
326
|
cert: credential.cert,
|
|
310
327
|
key: credential.key,
|
|
311
328
|
minVersion: 'TLSv1.3',
|
|
312
|
-
|
|
313
|
-
checkServerIdentity: (_host, cert) => tls.checkServerIdentity(
|
|
329
|
+
servername: name,
|
|
330
|
+
checkServerIdentity: (_host, cert) => tls.checkServerIdentity(name, cert),
|
|
314
331
|
};
|
|
315
332
|
}
|
|
316
333
|
/** The peer identity presented on a TLS socket, or null when none can be established. */
|