@aywengo/mercury-fleet 0.0.1-bootstrap
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/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/auth.js +89 -0
- package/dist/bindings.js +201 -0
- package/dist/child.js +137 -0
- package/dist/cli.js +371 -0
- package/dist/config.js +80 -0
- package/dist/credentials.js +93 -0
- package/dist/db.js +162 -0
- package/dist/dispatch.js +176 -0
- package/dist/events.js +114 -0
- package/dist/http.js +91 -0
- package/dist/interact.js +88 -0
- package/dist/logger.js +50 -0
- package/dist/metrics.js +226 -0
- package/dist/probe.js +224 -0
- package/dist/prober.js +94 -0
- package/dist/redact.js +72 -0
- package/dist/registry.js +197 -0
- package/dist/routing.js +219 -0
- package/dist/server.js +540 -0
- package/dist/stream.js +132 -0
- package/dist/sweep.js +183 -0
- package/dist/version.js +7 -0
- package/package.json +25 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fleet CLI: host registry, probe and credential inspection.
|
|
4
|
+
*
|
|
5
|
+
* Dispatch, routing, event aggregation, interaction and the metrics rollup are NOT here. They live on the
|
|
6
|
+
* service (docs/fleet-design.md section 12, phases 1-6), because a Run binding has to survive the process
|
|
7
|
+
* that created it: a CLI that submitted work would have to stay alive to reconcile it, which is the
|
|
8
|
+
* durability property Fleet exists to provide.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* fleet hosts add <id> --url <base> --credential <ref> [--label k=v] [--path <abs>] [--disabled]
|
|
12
|
+
* fleet hosts list [--json] [--live]
|
|
13
|
+
* fleet hosts enable|disable <id>
|
|
14
|
+
* fleet hosts rm <id>
|
|
15
|
+
* fleet hosts probe [<id>] [--json]
|
|
16
|
+
* fleet probe --watch run the sweep on FLEET_PROBE_INTERVAL_MS until interrupted
|
|
17
|
+
fleet serve run the service; binds 127.0.0.1:3100 by default
|
|
18
|
+
* fleet credentials list credential NAMES only; values are never printed
|
|
19
|
+
*
|
|
20
|
+
* No command accepts a child credential as an argument. argv is world-readable through ps, so the secret
|
|
21
|
+
* lives in the credential file and only its name travels (design section 9).
|
|
22
|
+
*/
|
|
23
|
+
import { loadConfig } from "./config.js";
|
|
24
|
+
import { openFleetDb } from "./db.js";
|
|
25
|
+
import { HostRegistry, RegistryError } from "./registry.js";
|
|
26
|
+
import { loadCredentials, CredentialError } from "./credentials.js";
|
|
27
|
+
import { createProber } from "./prober.js";
|
|
28
|
+
import { probeAndRecord } from "./probe.js";
|
|
29
|
+
import { createFleetServer } from "./server.js";
|
|
30
|
+
import { assertServeable } from "./config.js";
|
|
31
|
+
import { createServiceRedactor } from "./redact.js";
|
|
32
|
+
import { createLogger } from "./logger.js";
|
|
33
|
+
import { parseCallerTokens } from "./auth.js";
|
|
34
|
+
import { FLEET_VERSION } from "./version.js";
|
|
35
|
+
const USAGE = `fleet -- manage multiple Mercury instances
|
|
36
|
+
|
|
37
|
+
fleet hosts add <id> --url <base> --credential <ref> [--label k=v] [--path <abs>] [--disabled]
|
|
38
|
+
fleet hosts list [--json] [--live]
|
|
39
|
+
fleet hosts enable|disable <id>
|
|
40
|
+
fleet hosts rm <id>
|
|
41
|
+
fleet hosts probe [<id>] [--json]
|
|
42
|
+
fleet probe --watch
|
|
43
|
+
fleet serve run the service (binds FLEET_BIND_HOST:FLEET_PORT)
|
|
44
|
+
fleet credentials list
|
|
45
|
+
fleet --version print mercury-fleet <version>
|
|
46
|
+
`;
|
|
47
|
+
/** Human label per outcome. Kept distinct because each one sends the operator somewhere different. */
|
|
48
|
+
const STATE_LABEL = {
|
|
49
|
+
ok: 'up',
|
|
50
|
+
unreachable: 'down',
|
|
51
|
+
unauthorized: 'auth-fail',
|
|
52
|
+
not_mercury: 'not-mercury',
|
|
53
|
+
not_serving: 'no-worker',
|
|
54
|
+
http_error: 'http-error',
|
|
55
|
+
timeout: 'timeout',
|
|
56
|
+
};
|
|
57
|
+
function parseArgs(argv) {
|
|
58
|
+
const positionals = [];
|
|
59
|
+
const flags = new Map();
|
|
60
|
+
const multi = new Map();
|
|
61
|
+
const repeatable = new Set(['label', 'path']);
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
if (!a.startsWith('--')) {
|
|
65
|
+
positionals.push(a);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const key = a.slice(2);
|
|
69
|
+
const next = argv[i + 1];
|
|
70
|
+
const takesValue = next !== undefined && !next.startsWith('--');
|
|
71
|
+
if (!takesValue) {
|
|
72
|
+
flags.set(key, true);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
i++;
|
|
76
|
+
if (repeatable.has(key)) {
|
|
77
|
+
const list = multi.get(key) ?? [];
|
|
78
|
+
list.push(next);
|
|
79
|
+
multi.set(key, list);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
flags.set(key, next);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { positionals, flags, multi };
|
|
86
|
+
}
|
|
87
|
+
function ago(iso) {
|
|
88
|
+
if (!iso)
|
|
89
|
+
return 'never';
|
|
90
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
91
|
+
if (!Number.isFinite(ms))
|
|
92
|
+
return 'never';
|
|
93
|
+
if (ms < 0)
|
|
94
|
+
return 'now';
|
|
95
|
+
const s = Math.floor(ms / 1000);
|
|
96
|
+
if (s < 60)
|
|
97
|
+
return `${s}s`;
|
|
98
|
+
if (s < 3600)
|
|
99
|
+
return `${Math.floor(s / 60)}m`;
|
|
100
|
+
if (s < 86400)
|
|
101
|
+
return `${Math.floor(s / 3600)}h`;
|
|
102
|
+
return `${Math.floor(s / 86400)}d`;
|
|
103
|
+
}
|
|
104
|
+
function stateOf(view) {
|
|
105
|
+
if (!view.enabled)
|
|
106
|
+
return 'disabled';
|
|
107
|
+
if (!view.probe)
|
|
108
|
+
return 'never-probed';
|
|
109
|
+
return STATE_LABEL[view.probe.outcome] ?? view.probe.outcome;
|
|
110
|
+
}
|
|
111
|
+
function renderTable(rows) {
|
|
112
|
+
const header = ['ID', 'STATE', 'SEEN', 'WORKERS', 'RUNS', 'QUEUE', 'AGENTS', 'URL'];
|
|
113
|
+
const body = rows.map((r) => {
|
|
114
|
+
const p = r.probe;
|
|
115
|
+
return [
|
|
116
|
+
r.id,
|
|
117
|
+
stateOf(r),
|
|
118
|
+
ago(p?.probedAt ?? r.lastSeenAt),
|
|
119
|
+
p?.workerCount === null || p?.workerCount === undefined ? '-' : String(p.workerCount),
|
|
120
|
+
p?.activeRuns === null || p?.activeRuns === undefined ? '-' : String(p.activeRuns),
|
|
121
|
+
p?.queueDepth === null || p?.queueDepth === undefined ? '-' : String(p.queueDepth),
|
|
122
|
+
p?.agents?.length ? String(p.agents.length) : r.agentsCache.length ? String(r.agentsCache.length) : '-',
|
|
123
|
+
r.baseUrl,
|
|
124
|
+
];
|
|
125
|
+
});
|
|
126
|
+
const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => (row[i] ?? '').length)));
|
|
127
|
+
const line = (cells) => cells.map((c, i) => (c ?? '').padEnd(widths[i])).join(' ').trimEnd();
|
|
128
|
+
const out = [line(header)];
|
|
129
|
+
// Detail lines carry the diagnosis, which is the part a fixed-width table cannot hold.
|
|
130
|
+
for (let i = 0; i < rows.length; i++) {
|
|
131
|
+
out.push(line(body[i]));
|
|
132
|
+
const p = rows[i].probe;
|
|
133
|
+
if (p?.detail && p.outcome !== 'ok')
|
|
134
|
+
out.push(` ${rows[i].id}: ${p.detail}`);
|
|
135
|
+
}
|
|
136
|
+
return out.join('\n');
|
|
137
|
+
}
|
|
138
|
+
function parseLabels(pairs) {
|
|
139
|
+
const labels = {};
|
|
140
|
+
for (const pair of pairs) {
|
|
141
|
+
const eq = pair.indexOf('=');
|
|
142
|
+
if (eq <= 0)
|
|
143
|
+
throw new RegistryError(`--label expects key=value, got ${JSON.stringify(pair)}`);
|
|
144
|
+
labels[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
145
|
+
}
|
|
146
|
+
return labels;
|
|
147
|
+
}
|
|
148
|
+
function openStore(config) {
|
|
149
|
+
return loadCredentials(config.credentialsFile, config.allowInsecureCredentials);
|
|
150
|
+
}
|
|
151
|
+
export async function main(argv) {
|
|
152
|
+
if (argv[0] === '--version' || argv[0] === '-V') {
|
|
153
|
+
process.stdout.write(`mercury-fleet ${FLEET_VERSION}\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (argv.length === 0 || argv[0] === 'help' || argv[0] === '--help') {
|
|
157
|
+
process.stdout.write(USAGE);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
const config = loadConfig();
|
|
161
|
+
const { positionals, flags, multi } = parseArgs(argv);
|
|
162
|
+
const [group, action] = positionals;
|
|
163
|
+
const asJson = flags.get('json') === true;
|
|
164
|
+
const withRegistry = async (fn) => {
|
|
165
|
+
const { db } = openFleetDb(config.dbPath);
|
|
166
|
+
try {
|
|
167
|
+
return await fn(new HostRegistry(db), db);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
db.close();
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
if (group === 'credentials') {
|
|
174
|
+
if (action !== 'list') {
|
|
175
|
+
process.stderr.write('usage: fleet credentials list\n');
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
// Names only. Printing values here would put every child credential in terminal scrollback and any
|
|
179
|
+
// captured log, which is the exposure the file's 0600 mode exists to prevent.
|
|
180
|
+
const store = openStore(config);
|
|
181
|
+
const names = store.names();
|
|
182
|
+
process.stdout.write(asJson ? JSON.stringify({ credentialsFile: config.credentialsFile, refs: names }, null, 2) + '\n'
|
|
183
|
+
: names.length ? names.join('\n') + '\n' : `(no credentials in ${config.credentialsFile})\n`);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
if (group === 'serve') {
|
|
187
|
+
// Startup order from design section 15.6: configuration, then credentials, then the database, then the
|
|
188
|
+
// server binds. Binding last means a client never reaches an endpoint whose registry is still loading.
|
|
189
|
+
assertServeable(config);
|
|
190
|
+
return await withRegistry(async (_registry, db) => {
|
|
191
|
+
const store = openStore(config);
|
|
192
|
+
// Seeded from all three secret classes: child credentials, caller tokens, and the admin token. A
|
|
193
|
+
// pattern pass cannot recognise a bare token it has no label for, and a caller or admin token in a
|
|
194
|
+
// log is the credential that reaches a whole fleet.
|
|
195
|
+
const callers = parseCallerTokens(config.apiTokens);
|
|
196
|
+
const redactor = createServiceRedactor({
|
|
197
|
+
childSecrets: store.secrets(),
|
|
198
|
+
callerTokens: callers.secrets(),
|
|
199
|
+
adminToken: config.adminToken,
|
|
200
|
+
});
|
|
201
|
+
const logger = createLogger(redactor, config.logLevel);
|
|
202
|
+
const server = createFleetServer({ db, config, credentials: store, logger });
|
|
203
|
+
const addr = await server.listen();
|
|
204
|
+
logger.info('fleet api listening', {
|
|
205
|
+
host: addr.host, port: addr.port, tls: addr.tls,
|
|
206
|
+
callers: callers.size, unrestricted: callers.unrestrictedOwners(),
|
|
207
|
+
seededSecrets: redactor.seededCount,
|
|
208
|
+
});
|
|
209
|
+
const unrestricted = callers.unrestrictedOwners();
|
|
210
|
+
if (unrestricted.length) {
|
|
211
|
+
logger.warn('caller tokens grant access to every host', { owners: unrestricted,
|
|
212
|
+
hint: 'scope with token:owner:host1+host2 to limit blast radius' });
|
|
213
|
+
}
|
|
214
|
+
await new Promise((resolve) => {
|
|
215
|
+
const done = () => { void server.close().then(resolve); };
|
|
216
|
+
process.once('SIGINT', done);
|
|
217
|
+
process.once('SIGTERM', done);
|
|
218
|
+
});
|
|
219
|
+
logger.info('fleet stopped');
|
|
220
|
+
return 0;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (group === 'probe') {
|
|
224
|
+
return withRegistry(async (registry) => {
|
|
225
|
+
const store = openStore(config);
|
|
226
|
+
const prober = createProber({
|
|
227
|
+
registry,
|
|
228
|
+
resolveToken: (ref) => store.secret(ref),
|
|
229
|
+
intervalMs: config.probeIntervalMs,
|
|
230
|
+
timeoutMs: config.probeTimeoutMs,
|
|
231
|
+
});
|
|
232
|
+
if (flags.get('once') === true || flags.get('watch') !== true) {
|
|
233
|
+
const results = await prober.sweepOnce();
|
|
234
|
+
process.stdout.write(`probed ${results.length} enabled host(s)\n`);
|
|
235
|
+
process.stdout.write(renderTable(registry.listWithProbe()) + '\n');
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
// --watch: sweep immediately so the operator sees state, then keep sweeping on the timer.
|
|
239
|
+
await prober.sweepOnce();
|
|
240
|
+
prober.start();
|
|
241
|
+
process.stdout.write(`sweeping every ${Math.round(config.probeIntervalMs / 1000)}s; Ctrl-C to stop\n`);
|
|
242
|
+
await new Promise((resolve) => {
|
|
243
|
+
const done = () => {
|
|
244
|
+
prober.stop();
|
|
245
|
+
resolve();
|
|
246
|
+
};
|
|
247
|
+
process.once('SIGINT', done);
|
|
248
|
+
process.once('SIGTERM', done);
|
|
249
|
+
});
|
|
250
|
+
return 0;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (group !== 'hosts') {
|
|
254
|
+
process.stderr.write(`unknown command group ${JSON.stringify(group ?? '')}\n\n${USAGE}`);
|
|
255
|
+
return 2;
|
|
256
|
+
}
|
|
257
|
+
return withRegistry((registry) => {
|
|
258
|
+
if (action === 'add') {
|
|
259
|
+
const id = positionals[2];
|
|
260
|
+
const url = flags.get('url');
|
|
261
|
+
const cred = flags.get('credential');
|
|
262
|
+
if (!id || typeof url !== 'string' || typeof cred !== 'string') {
|
|
263
|
+
process.stderr.write('usage: fleet hosts add <id> --url <base> --credential <ref> [--label k=v] [--path <abs>]\n' +
|
|
264
|
+
' --credential names a ref in the credential file; the secret itself is never an argument.\n');
|
|
265
|
+
return 2;
|
|
266
|
+
}
|
|
267
|
+
// Validate the ref resolves NOW. Accepting a typo here would defer the failure to a probe that
|
|
268
|
+
// reports the host as auth-fail, pointing at the host when the mistake is in this command.
|
|
269
|
+
const store = openStore(config);
|
|
270
|
+
store.secret(cred);
|
|
271
|
+
registry.add({
|
|
272
|
+
id,
|
|
273
|
+
baseUrl: url,
|
|
274
|
+
credentialRef: cred,
|
|
275
|
+
labels: parseLabels(multi.get('label') ?? []),
|
|
276
|
+
localPaths: multi.get('path') ?? [],
|
|
277
|
+
enabled: flags.get('disabled') !== true,
|
|
278
|
+
});
|
|
279
|
+
process.stdout.write(`added ${id}\n`);
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
if (action === 'list') {
|
|
283
|
+
return (async () => {
|
|
284
|
+
if (flags.get('live') === true) {
|
|
285
|
+
const store = openStore(config);
|
|
286
|
+
const prober = createProber({
|
|
287
|
+
registry,
|
|
288
|
+
resolveToken: (ref) => store.secret(ref),
|
|
289
|
+
intervalMs: config.probeIntervalMs,
|
|
290
|
+
timeoutMs: config.probeTimeoutMs,
|
|
291
|
+
});
|
|
292
|
+
await prober.sweepOnce();
|
|
293
|
+
}
|
|
294
|
+
const rows = registry.listWithProbe();
|
|
295
|
+
if (asJson) {
|
|
296
|
+
process.stdout.write(JSON.stringify({ hosts: rows }, null, 2) + '\n');
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
process.stdout.write((rows.length ? renderTable(rows) : '(no hosts; add one with `fleet hosts add`)') + '\n');
|
|
300
|
+
return 0;
|
|
301
|
+
})();
|
|
302
|
+
}
|
|
303
|
+
if (action === 'enable' || action === 'disable') {
|
|
304
|
+
const id = positionals[2];
|
|
305
|
+
if (!id) {
|
|
306
|
+
process.stderr.write(`usage: fleet hosts ${action} <id>\n`);
|
|
307
|
+
return 2;
|
|
308
|
+
}
|
|
309
|
+
registry.setEnabled(id, action === 'enable');
|
|
310
|
+
process.stdout.write(`${id} ${action}d\n`);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
if (action === 'rm' || action === 'remove') {
|
|
314
|
+
const id = positionals[2];
|
|
315
|
+
if (!id) {
|
|
316
|
+
process.stderr.write('usage: fleet hosts rm <id>\n');
|
|
317
|
+
return 2;
|
|
318
|
+
}
|
|
319
|
+
const removed = registry.remove(id);
|
|
320
|
+
process.stdout.write(removed ? `removed ${id}\n` : `no such host: ${id}\n`);
|
|
321
|
+
return removed ? 0 : 1;
|
|
322
|
+
}
|
|
323
|
+
if (action === 'probe') {
|
|
324
|
+
return (async () => {
|
|
325
|
+
const store = openStore(config);
|
|
326
|
+
const only = positionals[2];
|
|
327
|
+
const prober = createProber({
|
|
328
|
+
registry,
|
|
329
|
+
resolveToken: (ref) => store.secret(ref),
|
|
330
|
+
intervalMs: config.probeIntervalMs,
|
|
331
|
+
timeoutMs: config.probeTimeoutMs,
|
|
332
|
+
});
|
|
333
|
+
if (!only) {
|
|
334
|
+
await prober.sweepOnce();
|
|
335
|
+
const rows = registry.listWithProbe();
|
|
336
|
+
process.stdout.write(asJson ? JSON.stringify({ hosts: rows }, null, 2) + '\n' : renderTable(rows) + '\n');
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
const host = registry.get(only);
|
|
340
|
+
if (!host) {
|
|
341
|
+
process.stderr.write(`no such host: ${only}\n`);
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
const rec = await probeAndRecord({
|
|
345
|
+
hostId: host.id, baseUrl: host.baseUrl, token: store.secret(host.credentialRef),
|
|
346
|
+
timeoutMs: config.probeTimeoutMs,
|
|
347
|
+
});
|
|
348
|
+
registry.recordProbe(rec);
|
|
349
|
+
process.stdout.write(asJson ? JSON.stringify(rec, null, 2) + '\n' : renderTable(registry.listWithProbe()) + '\n');
|
|
350
|
+
// Exit non-zero when the host is not usable, so this composes in a readiness check.
|
|
351
|
+
return rec.outcome === 'ok' ? 0 : 1;
|
|
352
|
+
})();
|
|
353
|
+
}
|
|
354
|
+
process.stderr.write(`unknown hosts subcommand ${JSON.stringify(action ?? '')}\n\n${USAGE}`);
|
|
355
|
+
return 2;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
main(process.argv.slice(2))
|
|
359
|
+
.then((code) => {
|
|
360
|
+
process.exitCode = code;
|
|
361
|
+
})
|
|
362
|
+
.catch((err) => {
|
|
363
|
+
const e = err;
|
|
364
|
+
if (e instanceof RegistryError || e instanceof CredentialError) {
|
|
365
|
+
process.stderr.write(`fleet: ${e.message}\n`);
|
|
366
|
+
process.exitCode = 2;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
process.stderr.write(`fleet: unexpected error: ${e.stack ?? e.message}\n`);
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet configuration. Fleet is a federation layer over independent Mercury instances and must never
|
|
3
|
+
* import from Mercury's src/ (docs/fleet-design.md section 11), so everything Fleet needs is parsed here
|
|
4
|
+
* from its own environment. Node builtins and declared dependencies only.
|
|
5
|
+
*/
|
|
6
|
+
function num(raw, fallback) {
|
|
7
|
+
if (raw === undefined || raw.trim() === '')
|
|
8
|
+
return fallback;
|
|
9
|
+
const n = Number(raw);
|
|
10
|
+
// Reject NaN, negatives and zero explicitly: a zero probe interval would spin the event loop hot.
|
|
11
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
12
|
+
return fallback;
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse a TCP port. Unlike an interval or a timeout, 0 is meaningful here -- it asks the OS for an
|
|
17
|
+
* ephemeral port -- so it must not fall back the way `num` does. Silently turning 0 into 3100 made every
|
|
18
|
+
* test instance fight over one fixed port.
|
|
19
|
+
*/
|
|
20
|
+
function port(raw, fallback) {
|
|
21
|
+
if (raw === undefined || raw.trim() === '')
|
|
22
|
+
return fallback;
|
|
23
|
+
const n = Number(raw);
|
|
24
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535)
|
|
25
|
+
return fallback;
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
/** Default credential location is outside the working tree so it cannot be committed by accident. */
|
|
29
|
+
function defaultCredentialsFile() {
|
|
30
|
+
const home = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '.';
|
|
31
|
+
return home + '/.fleet/credentials.json';
|
|
32
|
+
}
|
|
33
|
+
export function loadConfig(env = process.env) {
|
|
34
|
+
return {
|
|
35
|
+
dbPath: env['FLEET_DB'] ?? 'fleet.db',
|
|
36
|
+
credentialsFile: env['FLEET_CREDENTIALS_FILE'] ?? defaultCredentialsFile(),
|
|
37
|
+
probeIntervalMs: num(env['FLEET_PROBE_INTERVAL_MS'], 15_000),
|
|
38
|
+
probeTimeoutMs: num(env['FLEET_PROBE_TIMEOUT_MS'], 5_000),
|
|
39
|
+
sweepIntervalMs: num(env['FLEET_SWEEP_INTERVAL_MS'], 10_000),
|
|
40
|
+
streamPollMs: num(env['FLEET_STREAM_POLL_MS'], 1000),
|
|
41
|
+
repoUrlsFile: env.FLEET_REPO_URLS_FILE ?? null,
|
|
42
|
+
allowInsecureCredentials: env['FLEET_ALLOW_INSECURE_CREDENTIALS'] === '1',
|
|
43
|
+
bindHost: env['FLEET_BIND_HOST'] ?? '127.0.0.1',
|
|
44
|
+
port: port(env['FLEET_PORT'], 3100),
|
|
45
|
+
apiTokens: env['FLEET_API_TOKENS'],
|
|
46
|
+
adminToken: env['FLEET_ADMIN_TOKEN'] || null,
|
|
47
|
+
tlsCert: env['FLEET_TLS_CERT'] || null,
|
|
48
|
+
tlsKey: env['FLEET_TLS_KEY'] || null,
|
|
49
|
+
logLevel: level(env['FLEET_LOG_LEVEL']),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function level(raw) {
|
|
53
|
+
const v = (raw ?? 'info').toLowerCase();
|
|
54
|
+
return v === 'debug' || v === 'warn' || v === 'error' ? v : 'info';
|
|
55
|
+
}
|
|
56
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
57
|
+
/**
|
|
58
|
+
* Refuse to start in a configuration that would leak caller credentials.
|
|
59
|
+
*
|
|
60
|
+
* Binding beyond loopback without TLS puts every caller's bearer token on the wire in plaintext, and Fleet
|
|
61
|
+
* tokens are the ones that reach a whole fleet. The safe default is to fail at startup with an explanation
|
|
62
|
+
* rather than serve insecurely and leave discovery to an audit.
|
|
63
|
+
*/
|
|
64
|
+
export function assertServeable(config) {
|
|
65
|
+
// Half a TLS configuration is checked first: telling someone to "set FLEET_TLS_CERT and FLEET_TLS_KEY"
|
|
66
|
+
// when they set exactly one of them is a message that cannot be acted on.
|
|
67
|
+
if (Boolean(config.tlsCert) !== Boolean(config.tlsKey)) {
|
|
68
|
+
throw new Error('FLEET_TLS_CERT and FLEET_TLS_KEY must both be set or both unset');
|
|
69
|
+
}
|
|
70
|
+
const tls = Boolean(config.tlsCert && config.tlsKey);
|
|
71
|
+
if (!LOOPBACK.has(config.bindHost) && !tls) {
|
|
72
|
+
throw new Error(`refusing to bind ${config.bindHost}:${config.port} without TLS. Caller bearer tokens would cross ` +
|
|
73
|
+
`the network in plaintext, and a Fleet token reaches every Mercury it manages. Set FLEET_TLS_CERT ` +
|
|
74
|
+
`and FLEET_TLS_KEY, or bind 127.0.0.1 and terminate TLS in a reverse proxy.`);
|
|
75
|
+
}
|
|
76
|
+
if (!config.apiTokens && !config.adminToken) {
|
|
77
|
+
throw new Error('no caller tokens configured: set FLEET_API_TOKENS (token:owner[:hosts]) or FLEET_ADMIN_TOKEN, ' +
|
|
78
|
+
'otherwise every request would be rejected and the service would look broken.');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child credentials, read from a file and referenced by name (docs/fleet-design.md section 9).
|
|
3
|
+
*
|
|
4
|
+
* Two rules drive this whole module. A credential is never a command-line argument, because argv is
|
|
5
|
+
* world-readable through ps. And the file must not be readable by anyone but its owner, because Fleet
|
|
6
|
+
* holds a credential for every Mercury it can reach -- one exposed file compromises the whole fleet.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { basename } from 'node:path';
|
|
10
|
+
export class CredentialError extends Error {
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reject any file whose mode grants access to group or other.
|
|
14
|
+
*
|
|
15
|
+
* Only the low nine permission bits are examined: the file mode also carries type bits (a socket or
|
|
16
|
+
* symlink target would report them), and masking to 0o777 keeps the check about access rather than
|
|
17
|
+
* file type.
|
|
18
|
+
*/
|
|
19
|
+
function assertPrivateMode(path, mode, allowInsecure) {
|
|
20
|
+
const access = mode & 0o777;
|
|
21
|
+
if ((access & 0o077) === 0)
|
|
22
|
+
return;
|
|
23
|
+
const octal = access.toString(8).padStart(3, '0');
|
|
24
|
+
const message = `credential file ${path} is mode ${octal}; group or other can read it. ` +
|
|
25
|
+
`Fleet holds a credential for every Mercury it manages, so this file must be owner-only. ` +
|
|
26
|
+
`Fix with: chmod 600 ${path}`;
|
|
27
|
+
if (!allowInsecure)
|
|
28
|
+
throw new CredentialError(message);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Load the credential file.
|
|
32
|
+
*
|
|
33
|
+
* Shape is a flat JSON object of name -> secret. Anything else is refused rather than coerced: a nested
|
|
34
|
+
* object would silently yield "[object Object]" as a bearer token and every probe would fail with 401,
|
|
35
|
+
* which reads like a credential problem rather than the file-format problem it is.
|
|
36
|
+
*/
|
|
37
|
+
export function loadCredentials(path, allowInsecure = false) {
|
|
38
|
+
let stat;
|
|
39
|
+
try {
|
|
40
|
+
stat = statSync(path);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new CredentialError(`credential file ${path} not found. Create it as a JSON object of {"ref": "secret"} ` +
|
|
44
|
+
`and chmod 600 it, or set FLEET_CREDENTIALS_FILE.`);
|
|
45
|
+
}
|
|
46
|
+
assertPrivateMode(path, stat.mode, allowInsecure);
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = readFileSync(path, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new CredentialError(`cannot read credential file ${path}: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(raw);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
throw new CredentialError(`credential file ${path} is not valid JSON: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
62
|
+
throw new CredentialError(`credential file ${path} must contain a JSON object of {"ref": "secret"}; ` +
|
|
63
|
+
`got ${Array.isArray(parsed) ? 'an array' : typeof parsed}`);
|
|
64
|
+
}
|
|
65
|
+
const map = new Map();
|
|
66
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
67
|
+
if (typeof value !== 'string') {
|
|
68
|
+
throw new CredentialError(`credential "${key}" in ${basename(path)} is ${value === null ? 'null' : typeof value}, ` +
|
|
69
|
+
`not a string. A non-string would be sent as a bearer token and rejected with 401.`);
|
|
70
|
+
}
|
|
71
|
+
if (value.length === 0) {
|
|
72
|
+
throw new CredentialError(`credential "${key}" in ${basename(path)} is empty`);
|
|
73
|
+
}
|
|
74
|
+
map.set(key, value);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
secret(ref) {
|
|
78
|
+
const found = map.get(ref);
|
|
79
|
+
if (found === undefined) {
|
|
80
|
+
// Names only in the message. Listing values would put secrets in logs and terminal scrollback.
|
|
81
|
+
const known = [...map.keys()].sort().join(', ') || '(none)';
|
|
82
|
+
throw new CredentialError(`unknown credential ref "${ref}"; known refs: ${known}`);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
},
|
|
86
|
+
names() {
|
|
87
|
+
return [...map.keys()].sort();
|
|
88
|
+
},
|
|
89
|
+
secrets() {
|
|
90
|
+
return [...map.values()];
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|