@wpelevator/tug 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +24 -22
  2. package/dist/agent-credential.d.ts +39 -0
  3. package/dist/agent-credential.js +135 -0
  4. package/dist/agent.d.ts +15 -9
  5. package/dist/agent.js +126 -104
  6. package/dist/backup-check.d.ts +11 -0
  7. package/dist/backup-check.js +10 -0
  8. package/dist/backup-config.d.ts +5 -0
  9. package/dist/backup-config.js +23 -3
  10. package/dist/backup-destination.d.ts +1 -1
  11. package/dist/backup-destination.js +1 -1
  12. package/dist/backup-restore.js +28 -8
  13. package/dist/backup-scheduler.d.ts +7 -1
  14. package/dist/backup-scheduler.js +21 -6
  15. package/dist/cli-build.js +70 -4
  16. package/dist/cli-config.d.ts +9 -5
  17. package/dist/cli-config.js +89 -34
  18. package/dist/cli-install.js +1 -1
  19. package/dist/cli-live-transport.d.ts +3 -2
  20. package/dist/cli-live-transport.js +9 -5
  21. package/dist/cli.js +276 -142
  22. package/dist/compose.d.ts +3 -1
  23. package/dist/compose.js +14 -2
  24. package/dist/control-credential.d.ts +12 -0
  25. package/dist/control-credential.js +46 -0
  26. package/dist/control.d.ts +18 -1
  27. package/dist/control.js +586 -202
  28. package/dist/credentials.d.ts +111 -0
  29. package/dist/{tokens.js → credentials.js} +70 -54
  30. package/dist/db.d.ts +20 -5
  31. package/dist/db.js +3 -3
  32. package/dist/diagnostics.d.ts +44 -0
  33. package/dist/diagnostics.js +65 -0
  34. package/dist/ingress.js +4 -3
  35. package/dist/main.js +35 -0
  36. package/dist/normalize.d.ts +6 -0
  37. package/dist/normalize.js +16 -2
  38. package/dist/pki.d.ts +119 -0
  39. package/dist/pki.js +339 -0
  40. package/dist/protocol.d.ts +53 -22
  41. package/dist/status-report.d.ts +11 -0
  42. package/dist/status-report.js +147 -0
  43. package/dist/templates/agent.env +1 -3
  44. package/dist/templates/control-compose.yml +4 -3
  45. package/dist/templates/control-install.sh +8 -2
  46. package/dist/tls-server.d.ts +11 -0
  47. package/dist/tls-server.js +23 -0
  48. package/package.json +10 -8
  49. package/dist/tokens.d.ts +0 -91
package/dist/cli-build.js CHANGED
@@ -21,11 +21,78 @@ export function sourceArchiveFilter(entry, configPaths, ignores) {
21
21
  .replace(/^(\.\/)+/, '')
22
22
  .replace(/\/$/, '');
23
23
  const parts = relative.split('/');
24
- if (parts.some((part) => ALWAYS_EXCLUDED.includes(part)))
24
+ if (parts.some((part) => ALWAYS_EXCLUDED.includes(part) || CREDENTIAL_FILE.test(part)))
25
25
  return false;
26
26
  if (parts.length === 1 && CONFIG_NAMES.includes(relative))
27
27
  return false;
28
- return ![...configPaths, ...ignores].some((ignore) => relative === ignore || relative.startsWith(`${ignore}/`));
28
+ if (configPaths.some((file) => relative === file || relative.startsWith(`${file}/`)))
29
+ return false;
30
+ return !compileIgnore(ignores)(relative);
31
+ }
32
+ /** Credential documents written by `control-install --credential-out` (default `tug-root.credential.json`). */
33
+ const CREDENTIAL_FILE = /\.credential\.json$/;
34
+ const ignoreCache = new WeakMap();
35
+ /** Translates one `.dockerignore` pattern: `*` and `?` stay within a path segment, `**` crosses them. */
36
+ function ignorePattern(pattern) {
37
+ let source = '';
38
+ for (let i = 0; i < pattern.length; i++) {
39
+ const char = pattern[i];
40
+ if (char === '*' && pattern[i + 1] === '*') {
41
+ if (pattern[i + 2] === '/') {
42
+ source += '(?:.*/)?';
43
+ i += 2;
44
+ }
45
+ else {
46
+ source += '.*';
47
+ i += 1;
48
+ }
49
+ }
50
+ else if (char === '*')
51
+ source += '[^/]*';
52
+ else if (char === '?')
53
+ source += '[^/]';
54
+ else if (char === '\\' && i + 1 < pattern.length)
55
+ source += pattern[++i].replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
56
+ else
57
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
58
+ }
59
+ return new RegExp(`^${source}$`);
60
+ }
61
+ /**
62
+ * Compiles `.dockerignore` lines with Docker's semantics: patterns are rooted at the context, a pattern that
63
+ * matches a directory excludes everything below it, and the last matching rule wins, so `!` re-includes.
64
+ */
65
+ function compileIgnore(lines) {
66
+ const cached = ignoreCache.get(lines);
67
+ if (cached)
68
+ return cached;
69
+ const rules = lines
70
+ .map((line) => line.trim())
71
+ .filter((line) => line && !line.startsWith('#'))
72
+ .map((line) => {
73
+ const negated = line.startsWith('!');
74
+ const pattern = path.posix
75
+ .normalize((negated ? line.slice(1) : line).trim())
76
+ .replace(/^\/+/, '')
77
+ .replace(/\/$/, '');
78
+ return { negated, pattern: ignorePattern(pattern), valid: pattern !== '' && pattern !== '.' };
79
+ })
80
+ .filter((rule) => rule.valid);
81
+ const matcher = (relative) => {
82
+ const segments = relative.split('/');
83
+ let ignored = false;
84
+ for (const { negated, pattern } of rules) {
85
+ for (let end = 1; end <= segments.length; end++) {
86
+ if (pattern.test(segments.slice(0, end).join('/'))) {
87
+ ignored = !negated;
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ return ignored;
93
+ };
94
+ ignoreCache.set(lines, matcher);
95
+ return matcher;
29
96
  }
30
97
  export async function createSourceArchive(contextDir, archive, configFiles) {
31
98
  const configPaths = configFiles
@@ -35,8 +102,7 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
35
102
  try {
36
103
  ignores.push(...(await fs.readFile(path.join(contextDir, '.dockerignore'), 'utf8'))
37
104
  .split(/\r?\n/)
38
- .map((line) => line.trim().replace(/\/$/, ''))
39
- .filter((line) => line && !line.startsWith('#') && !line.startsWith('!')));
105
+ .map((line) => line.trim()));
40
106
  }
41
107
  catch (error) {
42
108
  if (error.code !== 'ENOENT')
@@ -1,3 +1,4 @@
1
+ import type { Credential } from './pki.ts';
1
2
  export type SettingType = 'string' | 'boolean' | 'port' | 'list' | 'path';
2
3
  export interface Setting {
3
4
  type: SettingType;
@@ -17,15 +18,18 @@ export interface ConnectionOptions {
17
18
  config?: string;
18
19
  controlUrl?: string;
19
20
  project?: string;
21
+ credential?: string;
20
22
  }
21
23
  export interface ResolvedCliConfig {
22
24
  /** The tracked config: selected, discovered, or where `init` creates it. */
23
25
  file: string;
24
26
  fileExists: boolean;
25
- /** The untracked overlay next to `file`, the only place a token may be stored. */
27
+ /** The untracked overlay next to `file`, the only place a credential path may be stored. */
26
28
  localFile: string;
29
+ /** Where connections go: the credential's public URL unless overridden (an override changes the connect target only). */
27
30
  controlUrl: string;
28
- token?: string;
31
+ credential?: Credential;
32
+ credentialFile?: string;
29
33
  project: string;
30
34
  commandDefaults: SettingValues;
31
35
  nodeDefaults: SettingValues;
@@ -50,11 +54,11 @@ interface OptionSource {
50
54
  */
51
55
  export declare function applyConfigDefaults(command: OptionSource, values: object, settings: SettingValues): void;
52
56
  /**
53
- * `tug init`: create the tracked file when it is missing and always store the token in the untracked
54
- * overlay, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
57
+ * `tug init`: create the tracked file when it is missing and always point the untracked
58
+ * overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
55
59
  * connection flags passed to `init` are saved to the overlay instead.
56
60
  */
57
- export declare function writeInitConfig(config: ResolvedCliConfig, token: string, explicit: ConnectionOptions): Promise<string[]>;
61
+ export declare function writeInitConfig(config: ResolvedCliConfig, credentialFile: string, explicit: ConnectionOptions): Promise<string[]>;
58
62
  export declare function normalizeControlUrl(value: string): string;
59
63
  export declare function integerOption(value: string, name: string, minimum?: number, maximum?: number): number;
60
64
  export {};
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
- import yaml from 'js-yaml';
4
+ import * as yaml from 'js-yaml';
5
+ import { decodeCredential } from "./pki.js";
6
+ import { errMessage } from "./util.js";
5
7
  /** Defaults for options of commands that work on the project (top level of the config file). */
6
8
  export const COMMAND_SETTINGS = {
7
9
  file: { type: 'path', flag: '--file', commands: ['deploy'] },
@@ -10,7 +12,11 @@ export const COMMAND_SETTINGS = {
10
12
  };
11
13
  /** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
12
14
  export const NODE_SETTINGS = {
13
- ssh: { type: 'string', flag: '<ssh-target>', commands: ['node-install'] },
15
+ ssh: {
16
+ type: 'string',
17
+ flag: '<ssh-target>',
18
+ commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
19
+ },
14
20
  address: { type: 'string', flag: '--address', commands: ['node-install', 'node-register'] },
15
21
  publicAddress: {
16
22
  type: 'string',
@@ -20,27 +26,53 @@ export const NODE_SETTINGS = {
20
26
  label: {
21
27
  type: 'list',
22
28
  flag: '--label',
23
- commands: ['node-install', 'node-register', 'node-enroll'],
29
+ commands: ['node-install', 'node-register', 'node-prepare-enrollment'],
30
+ },
31
+ mesh: {
32
+ type: 'boolean',
33
+ flag: '--mesh',
34
+ commands: [
35
+ 'node-install',
36
+ 'node-replace-identity',
37
+ 'node-repair-identity',
38
+ 'node-prepare-enrollment',
39
+ ],
40
+ },
41
+ wgPort: {
42
+ type: 'port',
43
+ flag: '--wg-port',
44
+ commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
24
45
  },
25
- mesh: { type: 'boolean', flag: '--mesh', commands: ['node-install', 'node-enroll'] },
26
- wgPort: { type: 'port', flag: '--wg-port', commands: ['node-install'] },
27
46
  installDocker: {
28
47
  type: 'boolean',
29
48
  flag: '--install-docker',
30
- commands: ['node-install', 'node-enroll'],
49
+ commands: [
50
+ 'node-install',
51
+ 'node-replace-identity',
52
+ 'node-repair-identity',
53
+ 'node-prepare-enrollment',
54
+ ],
55
+ },
56
+ image: {
57
+ type: 'string',
58
+ flag: '--image',
59
+ commands: [
60
+ 'node-install',
61
+ 'node-replace-identity',
62
+ 'node-repair-identity',
63
+ 'node-prepare-enrollment',
64
+ ],
31
65
  },
32
- image: { type: 'string', flag: '--image', commands: ['node-install', 'node-enroll'] },
33
66
  agentControlUrl: {
34
67
  type: 'string',
35
68
  flag: '--agent-control-url',
36
- commands: ['node-install', 'node-enroll'],
37
- },
38
- httpOnlyDev: {
39
- type: 'boolean',
40
- flag: '--http-only-dev',
41
- commands: ['node-install', 'node-enroll'],
69
+ commands: [
70
+ 'node-install',
71
+ 'node-replace-identity',
72
+ 'node-repair-identity',
73
+ 'node-prepare-enrollment',
74
+ ],
42
75
  },
43
- ca: { type: 'path', flag: '--ca', commands: ['node-enroll'] },
44
76
  };
45
77
  const NODE_SPECIFIC_KEYS = ['ssh', 'address', 'publicAddress'];
46
78
  const SETTINGS = { ...COMMAND_SETTINGS, ...NODE_SETTINGS };
@@ -53,7 +85,7 @@ export function localConfigPath(file) {
53
85
  }
54
86
  /** Resolve adapter settings once; domain operations receive these explicit values. */
55
87
  export async function resolveCliConfig(options, env, cwd, creating = false) {
56
- for (const key of ['config', 'controlUrl', 'project'])
88
+ for (const key of ['config', 'controlUrl', 'project', 'credential'])
57
89
  if (options[key] !== undefined && (!options[key].trim() || /[\r\n\0]/.test(options[key])))
58
90
  throw new Error(`--${key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())} must be a nonempty single-line value`);
59
91
  const selected = options.config || env.TUG_CONFIG;
@@ -64,13 +96,40 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
64
96
  throw new Error(`Cannot read config ${file}: ENOENT: no such file`);
65
97
  const overlay = await readConfigFile(localFile, true);
66
98
  const config = mergeConfigs(base ?? emptyConfig(), overlay ?? emptyConfig());
67
- const controlUrl = options.controlUrl || env.TUG_CONTROL_URL || config.controlUrl || 'http://localhost:8080';
99
+ if (env.TUG_CREDENTIAL && env.TUG_CREDENTIAL_FILE)
100
+ throw new Error('set only one of TUG_CREDENTIAL and TUG_CREDENTIAL_FILE');
101
+ // Precedence: --credential, then the environment, then the untracked overlay's credential path.
102
+ let credentialFile;
103
+ let credentialText;
104
+ if (options.credential)
105
+ credentialFile = path.resolve(cwd, options.credential);
106
+ else if (env.TUG_CREDENTIAL)
107
+ credentialText = env.TUG_CREDENTIAL;
108
+ else if (env.TUG_CREDENTIAL_FILE)
109
+ credentialFile = path.resolve(cwd, env.TUG_CREDENTIAL_FILE);
110
+ else if (config.credential)
111
+ credentialFile = config.credential;
112
+ let credential;
113
+ if (credentialFile || credentialText)
114
+ try {
115
+ credential = decodeCredential(credentialText ?? (await fs.readFile(credentialFile, 'utf8')));
116
+ }
117
+ catch (e) {
118
+ if (!creating)
119
+ throw new Error(`cannot use credential ${credentialFile ?? 'from TUG_CREDENTIAL'}: ${errMessage(e)}`, { cause: e });
120
+ }
121
+ const controlUrl = options.controlUrl ||
122
+ env.TUG_CONTROL_URL ||
123
+ config.controlUrl ||
124
+ credential?.url ||
125
+ 'https://localhost:8443';
68
126
  return {
69
127
  file,
70
128
  fileExists: Boolean(base),
71
129
  localFile,
72
130
  controlUrl: normalizeControlUrl(controlUrl),
73
- token: env.TUG_TOKEN || config.token,
131
+ credential,
132
+ credentialFile,
74
133
  project: options.project || env.TUG_PROJECT || config.project || 'site',
75
134
  commandDefaults: config.commandDefaults,
76
135
  nodeDefaults: config.nodeDefaults,
@@ -181,10 +240,10 @@ function parseConfig(input, dir, overlay) {
181
240
  for (const [key, value] of Object.entries(input)) {
182
241
  if (key === 'controlUrl' || key === 'project')
183
242
  config[key] = text(value, key);
184
- else if (key === 'token') {
243
+ else if (key === 'credential') {
185
244
  if (overlay)
186
- throw new Error(`token must not be set in a tracked file; put it in ${overlay} or TUG_TOKEN`);
187
- config.token = text(value, key);
245
+ throw new Error(`credential must not be set in a tracked file; put it in ${overlay}, or use TUG_CREDENTIAL_FILE`);
246
+ config.credential = path.resolve(dir, text(value, key));
188
247
  }
189
248
  else if (COMMAND_SETTINGS[key])
190
249
  config.commandDefaults[key] = settingValue(COMMAND_SETTINGS[key], value, key, dir);
@@ -200,7 +259,7 @@ function parseConfig(input, dir, overlay) {
200
259
  const known = [
201
260
  'controlUrl',
202
261
  'project',
203
- ...(overlay ? [] : ['token']),
262
+ ...(overlay ? [] : ['credential']),
204
263
  ...Object.keys(COMMAND_SETTINGS),
205
264
  'nodeDefaults',
206
265
  'nodes',
@@ -218,7 +277,7 @@ function mergeConfigs(base, overlay) {
218
277
  return {
219
278
  controlUrl: overlay.controlUrl ?? base.controlUrl,
220
279
  project: overlay.project ?? base.project,
221
- token: overlay.token ?? base.token,
280
+ credential: overlay.credential ?? base.credential,
222
281
  commandDefaults: { ...base.commandDefaults, ...overlay.commandDefaults },
223
282
  nodeDefaults: { ...base.nodeDefaults, ...overlay.nodeDefaults },
224
283
  nodes,
@@ -230,15 +289,15 @@ function serialize(file, data, notice) {
230
289
  return `${notice ? `# ${notice}\n` : ''}${yaml.dump(data)}`;
231
290
  }
232
291
  /**
233
- * `tug init`: create the tracked file when it is missing and always store the token in the untracked
234
- * overlay, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
292
+ * `tug init`: create the tracked file when it is missing and always point the untracked
293
+ * overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
235
294
  * connection flags passed to `init` are saved to the overlay instead.
236
295
  */
237
- export async function writeInitConfig(config, token, explicit) {
296
+ export async function writeInitConfig(config, credentialFile, explicit) {
238
297
  const written = [];
239
298
  if (!config.fileExists) {
240
299
  await fs.mkdir(path.dirname(config.file), { recursive: true });
241
- await fs.writeFile(config.file, serialize(config.file, { controlUrl: config.controlUrl, project: config.project }, `Safe to commit: keep the API token out of this file (use TUG_TOKEN or ${path.basename(config.localFile)}).`));
300
+ await fs.writeFile(config.file, serialize(config.file, { controlUrl: config.controlUrl, project: config.project }, `Safe to commit: keep credentials out of this file (use TUG_CREDENTIAL_FILE or ${path.basename(config.localFile)}).`));
242
301
  written.push(config.file);
243
302
  }
244
303
  let local = {};
@@ -251,7 +310,7 @@ export async function writeInitConfig(config, token, explicit) {
251
310
  if (error.code !== 'ENOENT')
252
311
  throw error;
253
312
  }
254
- local.token = token;
313
+ local.credential = credentialFile;
255
314
  if (config.fileExists) {
256
315
  if (explicit.controlUrl)
257
316
  local.controlUrl = config.controlUrl;
@@ -289,14 +348,10 @@ export function normalizeControlUrl(value) {
289
348
  url = new URL(value);
290
349
  }
291
350
  catch {
292
- throw new Error('control URL must be an absolute http:// or https:// URL');
351
+ throw new Error('control URL must be an absolute https:// URL');
293
352
  }
294
- if (!['http:', 'https:'].includes(url.protocol) ||
295
- url.username ||
296
- url.password ||
297
- url.search ||
298
- url.hash)
299
- throw new Error('control URL must use http:// or https:// without credentials, query or fragment');
353
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
354
+ throw new Error('control URL must use https:// without credentials, query or fragment');
300
355
  return value.replace(/\/+$/, '');
301
356
  }
302
357
  export function integerOption(value, name, minimum = 1, maximum = Number.MAX_SAFE_INTEGER) {
@@ -1,4 +1,4 @@
1
- import yaml from 'js-yaml';
1
+ import * as yaml from 'js-yaml';
2
2
  /** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
3
3
  export const HOST_NETWORK_FLAG = '--network host';
4
4
  export const MESH_RUN_FLAGS = `${HOST_NETWORK_FLAG} --cap-add NET_ADMIN`;
@@ -1,3 +1,4 @@
1
1
  import type { CliLiveTransport } from './cli-live.ts';
2
- /** Credentials travel only in the upgrade header, never a URL or frame. */
3
- export declare function createCliLiveTransport(controlUrl: string, token?: string): CliLiveTransport;
2
+ import type { Credential } from './pki.ts';
3
+ /** The client certificate authenticates the upgrade; `controlUrl` only picks the connect target, the credential's public URL is verified. */
4
+ export declare function createCliLiveTransport(credential: Credential | undefined, controlUrl: string): CliLiveTransport;
@@ -1,12 +1,15 @@
1
1
  import WebSocket from 'ws';
2
2
  import { CliLiveError } from "./cli-live.js";
3
+ import { clientTlsOptions } from "./pki.js";
3
4
  import { LIVE_MAX_FRAME_BYTES as MAX_FRAME, LIVE_MAX_BUFFERED_BYTES as MAX_BUFFERED, LIVE_START_TIMEOUT_MS, } from "./protocol.js";
4
5
  const MAX_EVENTS = 1024;
5
- /** Credentials travel only in the upgrade header, never a URL or frame. */
6
- export function createCliLiveTransport(controlUrl, token) {
6
+ /** The client certificate authenticates the upgrade; `controlUrl` only picks the connect target, the credential's public URL is verified. */
7
+ export function createCliLiveTransport(credential, controlUrl) {
8
+ if (!credential)
9
+ throw new CliLiveError('no credential: run `tug init <credential-file>`');
7
10
  const url = new URL(controlUrl);
8
- if (url.protocol !== 'http:' && url.protocol !== 'https:')
9
- throw new CliLiveError('live control URL must use http or https');
11
+ if (url.protocol !== 'https:')
12
+ throw new CliLiveError('live control URL must use https');
10
13
  if (url.username || url.password || url.search || url.hash)
11
14
  throw new CliLiveError('live control URL must not contain credentials, query or fragment');
12
15
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -19,7 +22,8 @@ export function createCliLiveTransport(controlUrl, token) {
19
22
  return;
20
23
  }
21
24
  const socket = new WebSocket(url, {
22
- headers: token ? { Authorization: `Bearer ${token}` } : {},
25
+ ...clientTlsOptions(credential),
26
+ followRedirects: false,
23
27
  maxPayload: MAX_FRAME,
24
28
  handshakeTimeout: LIVE_START_TIMEOUT_MS,
25
29
  perMessageDeflate: false,