@passioncode-ai/fabric-agent-adapter 0.76.0 → 0.78.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/CHANGELOG.md +126 -0
- package/README.md +22 -6
- package/SECURITY.md +25 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +33 -44
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +69 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +325 -24
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +198 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +44 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +310 -23
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +208 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +209 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +2 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +5 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +179 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/channels.md +129 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/cycle.md +129 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/scripts/observer_inventory.py +259 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -36
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +55 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/when-to-use.md +5 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -9,8 +9,10 @@ import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabri
|
|
|
9
9
|
import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
|
|
10
10
|
import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
|
|
11
11
|
import { URI } from './fabric-backups.mjs';
|
|
12
|
-
import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED
|
|
12
|
+
import { CheckedAgent, LSOF, ListenerRefused, PS, SERVER_NOT_OWN, TABLE_RESTRICTED, UNKNOWN, parseLsofListeners, parsePsUid,
|
|
13
|
+
runProgram, serverEnd } from './fabric-peer-owner.mjs';
|
|
13
14
|
import http from 'node:http';
|
|
15
|
+
import net from 'node:net';
|
|
14
16
|
|
|
15
17
|
export const DEFAULT_EXIT_TIMEOUT = 15;
|
|
16
18
|
|
|
@@ -263,13 +265,15 @@ function plistValue(value, depth) {
|
|
|
263
265
|
* FABRIC_SERVICE_SUPERVISOR=launchd tells the lock to back off instead of exiting into a respawn loop (DEC-0057).
|
|
264
266
|
* No secret is written: an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or an
|
|
265
267
|
* argument shaped like a credential is refused. Returns the XML text, the same bytes Python's plistlib writes.
|
|
268
|
+
* `userName` writes UserName, the account a LaunchDaemon (launchd's system domain, FAA-278) runs as: a macOS short name.
|
|
266
269
|
*/
|
|
267
|
-
export function launchdPlist(label, programArguments, { workingDirectory, stdoutPath, environment = {}, exitTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
|
|
270
|
+
export function launchdPlist(label, programArguments, { workingDirectory, stdoutPath, environment = {}, exitTimeout = DEFAULT_EXIT_TIMEOUT, userName = null } = {}) {
|
|
268
271
|
launchdLabel(label);
|
|
269
272
|
const argv = programArguments.map(String);
|
|
270
273
|
if (!argv.length) throw new ServiceError('A launchd job starts a program; ProgramArguments is empty.');
|
|
271
274
|
if (!(exitTimeout > DRAIN_SECONDS)) throw new ServiceError(`ExitTimeOut must outlast the ${DRAIN_SECONDS} s drain.`);
|
|
272
275
|
if (workingDirectory === undefined || stdoutPath === undefined) throw new ServiceError('A launchd job names its working directory and its log file.');
|
|
276
|
+
if (userName !== null && userName !== undefined && !USER_NAME.test(String(userName))) throw new ServiceError(`UserName must be a macOS short name matching ${USER_NAME.source}`);
|
|
273
277
|
const env = { ...environment };
|
|
274
278
|
for (const [name, value] of Object.entries(env)) {
|
|
275
279
|
if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
|
|
@@ -295,25 +299,168 @@ export function launchdPlist(label, programArguments, { workingDirectory, stdout
|
|
|
295
299
|
StandardErrorPath: String(stdoutPath),
|
|
296
300
|
EnvironmentVariables: env,
|
|
297
301
|
};
|
|
302
|
+
if (userName !== null && userName !== undefined) job.UserName = String(userName); // a LaunchDaemon's account (FAA-278)
|
|
298
303
|
return '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
299
304
|
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
300
305
|
+ `<plist version="1.0">\n${plistValue(job, 0)}</plist>\n`;
|
|
301
306
|
}
|
|
302
307
|
|
|
303
|
-
function
|
|
308
|
+
function guiDomain(platform, uid) {
|
|
304
309
|
// DEC-0032: launchd is macOS's supervisor; Linux has systemd and Windows Task Scheduler.
|
|
305
310
|
if (platform !== 'darwin') throw new ServiceError('launchd is the supervisor on macOS only; use systemdInstall on Linux and taskInstall on Windows.');
|
|
306
311
|
return `gui/${uid ?? process.getuid()}`;
|
|
307
312
|
}
|
|
308
313
|
|
|
309
|
-
/**
|
|
310
|
-
export
|
|
311
|
-
|
|
314
|
+
/** Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278). */
|
|
315
|
+
export const SYSTEM_DAEMONS = '/Library/LaunchDaemons';
|
|
316
|
+
const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The launchd domain a job lives in: `system` for a LaunchDaemon (`domain: 'system'`, or a plist in
|
|
320
|
+
* /Library/LaunchDaemons), else this user's `gui/<uid>` (FAA-278). A LaunchDaemon starts at boot, before any login, so it
|
|
321
|
+
* is the form for a headless Mac that a FileVault restart leaves with no GUI session.
|
|
322
|
+
*/
|
|
323
|
+
export function launchdDomain(plistPath = null, { domain = null, platform = process.platform, uid } = {}) {
|
|
324
|
+
if (![null, undefined, 'gui', 'system'].includes(domain)) throw new ServiceError('domain must be gui or system');
|
|
325
|
+
let which = domain;
|
|
326
|
+
if ((which === null || which === undefined) && plistPath !== null && plistPath !== undefined) {
|
|
327
|
+
const paths = platform === 'darwin' ? path.posix : path; // a macOS path, whatever host reads it
|
|
328
|
+
which = paths.dirname(paths.resolve(String(plistPath))).toLowerCase() === SYSTEM_DAEMONS.toLowerCase() ? 'system' : 'gui'; // APFS ignores case
|
|
329
|
+
}
|
|
330
|
+
if (which === 'system') {
|
|
331
|
+
if (platform !== 'darwin') throw new ServiceError('launchd is the supervisor on macOS only; use systemdInstall on Linux and taskInstall on Windows.');
|
|
332
|
+
return 'system';
|
|
333
|
+
}
|
|
334
|
+
return guiDomain(platform, uid);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// A system-domain job is installed and removed by root: refuse before anything is written (FAA-278).
|
|
338
|
+
function needRoot(domain, label, plistPath, install, euid) {
|
|
339
|
+
if (domain !== 'system' || euid === 0) return;
|
|
340
|
+
if (install) {
|
|
341
|
+
throw new ServiceError(`A LaunchDaemon (launchd's system domain) is installed by root, and this process is not root: run launchdInstall as root (the release, descriptor and token stay its UserName's, lifecycle reference, The system domain), or as root write ${plistPath} and run \`launchctl bootstrap system ${plistPath}\`. Status and pid read without root.`);
|
|
342
|
+
}
|
|
343
|
+
throw new ServiceError(`A LaunchDaemon (launchd's system domain) is removed by root, and this process is not root: run launchdUninstall as root, or as root run \`launchctl bootout system/${label}\`, remove ${plistPath} and run \`launchctl enable system/${label}\` (so a later install is not kept off). Status and pid read without root.`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The top-level string values of an XML plist's dict, read as CoreFoundation does: comments, the prolog and DOCTYPE
|
|
347
|
+
// skipped, entities, character references and CDATA decoded in keys and values, a repeated key's last value kept, a key
|
|
348
|
+
// nested deeper (an EnvironmentVariables entry named UserName) never read. null when the text cannot be read so.
|
|
349
|
+
const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
350
|
+
function decodeXml(text) {
|
|
351
|
+
return text.replace(/&(?:(amp|lt|gt|quot|apos)|#([0-9]+)|#x([0-9a-fA-F]+));/g, (_, e, d, h) => {
|
|
352
|
+
if (e) return XML_ENTITIES[e];
|
|
353
|
+
const code = d ? Number(d) : parseInt(h, 16);
|
|
354
|
+
// XML 1.0's Char, as expat (Python's plistlib) reads it: a reference outside it makes the file unreadable.
|
|
355
|
+
const xmlChar = code === 0x9 || code === 0xa || code === 0xd || (code >= 0x20 && code <= 0xd7ff)
|
|
356
|
+
|| (code >= 0xe000 && code <= 0xfffd) || (code >= 0x10000 && code <= 0x10ffff);
|
|
357
|
+
if (!xmlChar) throw new RangeError('character reference outside XML 1.0');
|
|
358
|
+
return String.fromCodePoint(code);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function topStrings(text) {
|
|
362
|
+
const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z][\w.:-]*)\s*[^>]*?(\/?)>|([^<]+)/g;
|
|
363
|
+
const values = Object.create(null);
|
|
364
|
+
let depth = 0;
|
|
365
|
+
let key = null;
|
|
366
|
+
let capture = null;
|
|
367
|
+
let buffer = '';
|
|
368
|
+
try {
|
|
369
|
+
for (let m = token.exec(String(text ?? '')); m; m = token.exec(String(text ?? ''))) {
|
|
370
|
+
const [, cdata, closing, name, selfClosing, chars] = m;
|
|
371
|
+
if (cdata !== undefined || chars !== undefined) {
|
|
372
|
+
if (capture) buffer += cdata !== undefined ? cdata : decodeXml(chars);
|
|
373
|
+
} else if (name === 'dict' || name === 'array') {
|
|
374
|
+
if (selfClosing) {
|
|
375
|
+
if (depth === 1 && key !== null) { delete values[key]; key = null; }
|
|
376
|
+
} else if (closing) {
|
|
377
|
+
depth -= 1;
|
|
378
|
+
} else {
|
|
379
|
+
if (depth === 1 && key !== null) { delete values[key]; key = null; } // a container is this key's value
|
|
380
|
+
depth += 1;
|
|
381
|
+
}
|
|
382
|
+
} else if (name && depth === 1) {
|
|
383
|
+
if (selfClosing) {
|
|
384
|
+
if (key !== null) {
|
|
385
|
+
if (name === 'string') values[key] = '';
|
|
386
|
+
else delete values[key]; // <true/>, <false/>: the last value is not a string
|
|
387
|
+
}
|
|
388
|
+
key = name === 'key' ? '' : null;
|
|
389
|
+
} else if (!closing && (name === 'key' || name === 'string')) {
|
|
390
|
+
capture = name;
|
|
391
|
+
buffer = '';
|
|
392
|
+
} else if (closing && name === capture) {
|
|
393
|
+
if (capture === 'key') key = buffer;
|
|
394
|
+
else if (key !== null) { values[key] = buffer; key = null; }
|
|
395
|
+
capture = null;
|
|
396
|
+
} else if (closing) {
|
|
397
|
+
if (key !== null) delete values[key]; // another value (integer, date, data, real) replaces the earlier string
|
|
398
|
+
key = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return values;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function topKey(text, key) {
|
|
409
|
+
const values = topStrings(text);
|
|
410
|
+
return values && Object.hasOwn(values, key) ? values[key] : null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const topUserName = (text) => topKey(text, 'UserName');
|
|
414
|
+
|
|
415
|
+
// A plist file's text as XML, a binary plist converted with plutil; null when it cannot be read.
|
|
416
|
+
function plistXml(file, run) {
|
|
417
|
+
let text;
|
|
418
|
+
try { text = fs.readFileSync(file, 'latin1'); } catch { return null; }
|
|
419
|
+
if (!text.startsWith('bplist')) return Buffer.from(text, 'latin1').toString('utf8');
|
|
420
|
+
const converted = run(['plutil', '-convert', 'xml1', '-o', '-', String(file)]);
|
|
421
|
+
return converted?.status === 0 ? String(converted.stdout ?? '') : null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Other plists that hold launchd label `label` in the system domain (FAA-278): the plist launchd loaded the job from,
|
|
425
|
+
// when it is not `plistPath`, and any plist of that label beside `plistPath`. Reading needs no root.
|
|
426
|
+
function labelHolders(label, plistPath, run) {
|
|
427
|
+
const real = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } }; // the case on disk
|
|
428
|
+
const own = real(String(plistPath));
|
|
429
|
+
const found = [];
|
|
430
|
+
const loaded = run(['launchctl', 'print', `system/${label}`]);
|
|
431
|
+
if (loaded?.status === 0) {
|
|
432
|
+
const hit = /^\s*path\s*=\s*(.+?)\s*$/m.exec(String(loaded.stdout ?? ''));
|
|
433
|
+
if (hit) found.push(hit[1]);
|
|
434
|
+
}
|
|
435
|
+
const folder = path.dirname(String(plistPath));
|
|
436
|
+
let names = [];
|
|
437
|
+
try { names = fs.readdirSync(folder).filter((name) => name.endsWith('.plist')).sort(); } catch { names = []; }
|
|
438
|
+
for (const name of names) {
|
|
439
|
+
const file = path.join(folder, name);
|
|
440
|
+
if (topKey(plistXml(file, run), 'Label') === label) found.push(file);
|
|
441
|
+
}
|
|
442
|
+
return [...new Set(found)].filter((file) => real(file) !== own);
|
|
312
443
|
}
|
|
313
444
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
445
|
+
function refuseHeldLabel(label, plistPath, run) {
|
|
446
|
+
const holders = labelHolders(label, plistPath, run);
|
|
447
|
+
if (holders.length) {
|
|
448
|
+
const owner = topUserName(plistXml(holders[0], run));
|
|
449
|
+
throw new ServiceError(`Label ${label} is already held by ${holders[0]}${owner ? ` (${owner}'s)` : ''}: the system domain is shared by every account of this Mac, so a label is the machine's and one plist holds it. Choose a label of your own (for example with the account in it), or remove that LaunchDaemon as root first.`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const ownEuid = () => (typeof process.geteuid === 'function' ? process.geteuid() : null);
|
|
454
|
+
|
|
455
|
+
/** True when launchd has the job loaded (`launchctl print gui/<uid>/<label>`, or `system/<label>` with domain 'system'). */
|
|
456
|
+
export function launchdLoaded(label, { run = defaultRun, platform = process.platform, uid, domain = null } = {}) {
|
|
457
|
+
return run(['launchctl', 'print', `${launchdDomain(null, { domain, platform, uid })}/${launchdLabel(label)}`])?.status === 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The running pid of this account's launchd job `label` (with domain 'system', of the LaunchDaemon), or null (not
|
|
461
|
+
// running, or not readable). Reading needs no root.
|
|
462
|
+
export function launchdPid(label, { run = defaultRun, platform = process.platform, uid, domain = null } = {}) {
|
|
463
|
+
const result = run(['launchctl', 'print', `${launchdDomain(null, { domain, platform, uid })}/${launchdLabel(label)}`]);
|
|
317
464
|
if (result?.status !== 0) return null;
|
|
318
465
|
const hit = /^\s*pid\s*=\s*([0-9]+)\s*$/m.exec(String(result.stdout ?? ''));
|
|
319
466
|
return hit ? Number(hit[1]) : null;
|
|
@@ -325,8 +472,8 @@ const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
|
|
|
325
472
|
* The operator's launchd override for `label`: 'disabled', 'enabled', null when none is recorded (a first install), or
|
|
326
473
|
* 'unknown' when the table cannot be read. macOS 14+ prints `=> disabled|enabled`, older releases `=> true|false`.
|
|
327
474
|
*/
|
|
328
|
-
export function launchdOverride(label, { run = defaultRun, platform = process.platform, uid } = {}) {
|
|
329
|
-
const result = run(['launchctl', 'print-disabled', launchdDomain(platform, uid)]);
|
|
475
|
+
export function launchdOverride(label, { run = defaultRun, platform = process.platform, uid, domain = null } = {}) {
|
|
476
|
+
const result = run(['launchctl', 'print-disabled', launchdDomain(null, { domain, platform, uid })]);
|
|
330
477
|
if (result?.status !== 0) return 'unknown';
|
|
331
478
|
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
332
479
|
const match = OVERRIDE_ROW.exec(line);
|
|
@@ -355,9 +502,14 @@ export async function wellKnownOf(origin, { vouchPid = null } = {}) {
|
|
|
355
502
|
}
|
|
356
503
|
// A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
|
|
357
504
|
// answer, so a poll leaves no connection open behind it.
|
|
358
|
-
|
|
505
|
+
return loopbackWellKnown(Number(m[1]), new CheckedAgent({ keepAlive: false, vouchPid }));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// The well-known answer at 127.0.0.1:<port> over `agent`, whose connections check their server end: null when nothing
|
|
509
|
+
// answers or the answer is not JSON, a ForeignUserAnswer when the agent refused the listener.
|
|
510
|
+
function loopbackWellKnown(port, agent) {
|
|
359
511
|
return new Promise((resolve) => {
|
|
360
|
-
const req = http.request({ host: '127.0.0.1', port
|
|
512
|
+
const req = http.request({ host: '127.0.0.1', port, path: '/.well-known/fabric-service', agent,
|
|
361
513
|
timeout: 2000 }, (res) => {
|
|
362
514
|
const chunks = [];
|
|
363
515
|
res.on('data', (c) => chunks.push(c));
|
|
@@ -374,6 +526,95 @@ export async function wellKnownOf(origin, { vouchPid = null } = {}) {
|
|
|
374
526
|
}).finally(() => agent.destroy());
|
|
375
527
|
}
|
|
376
528
|
|
|
529
|
+
// Whether launchd's job `pid` listens on exactly 127.0.0.1:<port>, never the wildcard, asked of the process itself (`lsof -p`),
|
|
530
|
+
// which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named.
|
|
531
|
+
async function jobListens(pid, port, run = runProgram) {
|
|
532
|
+
if (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
|
|
533
|
+
try {
|
|
534
|
+
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
535
|
+
return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port); // exact bind, as RT-3
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The uid account `name` has on this machine (`id -u`), or null when it has none.
|
|
542
|
+
function accountUid(name, run) {
|
|
543
|
+
if (!USER_NAME.test(String(name))) return null; // `id -u 501` answers a uid, but launchd looks UserName up by name
|
|
544
|
+
const result = run(['id', '-u', String(name)]);
|
|
545
|
+
const uid = result?.status === 0 ? String(result.stdout ?? '').trim() : '';
|
|
546
|
+
return /^[0-9]+$/.test(uid) ? Number(uid) : null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// An http.Agent whose connections must be accepted by launchd's job `jobPid`, running as `jobUid`, its UserName's uid: a
|
|
550
|
+
// LaunchDaemon runs as its UserName, so the installer, root, reads it as another account's, and the job's own pid and
|
|
551
|
+
// uid are the proof (FAA-278; contract DEC-0161, proposed: RT-3 extended to launchd's system domain).
|
|
552
|
+
class JobAgent extends http.Agent {
|
|
553
|
+
constructor({ jobPid, jobUid, ask = {}, ...options } = {}) {
|
|
554
|
+
if (!Number.isInteger(jobPid) || jobPid <= 1) throw new ServiceError("A LaunchDaemon's answer is read only on its job's pid, a positive number.");
|
|
555
|
+
if (!Number.isInteger(jobUid) || jobUid < 0) throw new ServiceError("A LaunchDaemon's answer is read only from its UserName's uid, a number.");
|
|
556
|
+
super(options);
|
|
557
|
+
this.jobPid = jobPid;
|
|
558
|
+
this.jobUid = jobUid;
|
|
559
|
+
this.ask = ask;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
createConnection(options, callback) {
|
|
563
|
+
const port = Number(options.port);
|
|
564
|
+
const socket = net.connect({ host: '127.0.0.1', port, noDelay: true });
|
|
565
|
+
const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), options.timeout ?? 10000);
|
|
566
|
+
socket.once('error', (err) => { clearTimeout(timer); callback(err); });
|
|
567
|
+
socket.once('connect', async () => {
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
socket.pause();
|
|
570
|
+
let who;
|
|
571
|
+
try {
|
|
572
|
+
who = await serverEnd(socket, this.ask);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
who = { kind: UNKNOWN, uid: null, pid: null, reason: `check: ${err?.name ?? 'Error'}` };
|
|
575
|
+
}
|
|
576
|
+
if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run)) {
|
|
577
|
+
socket.destroy();
|
|
578
|
+
// The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
|
|
579
|
+
callback(new ListenerRefused(SERVER_NOT_OWN, `the LaunchDaemon's pid ${this.jobPid} is not shown listening on exactly 127.0.0.1:${port} by lsof (a wildcard is never vouched for); nothing was sent`));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const proved = who.pid === this.jobPid || (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED)
|
|
583
|
+
&& await jobListens(this.jobPid, port, this.ask.run));
|
|
584
|
+
if (!proved) {
|
|
585
|
+
socket.destroy();
|
|
586
|
+
callback(new ListenerRefused(SERVER_NOT_OWN, `the server on port ${port} is not the LaunchDaemon's pid ${this.jobPid} (${who.reason ?? who.kind}): another process holds the port, or the job's program starts the server as a child (start it with exec); nothing was sent`, who));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
let runsAs = null;
|
|
590
|
+
try { runsAs = parsePsUid(await (this.ask.run ?? runProgram)(PS, ['-o', 'uid=', '-p', String(this.jobPid)])); } catch { runsAs = null; }
|
|
591
|
+
if (runsAs !== this.jobUid) {
|
|
592
|
+
socket.destroy();
|
|
593
|
+
// Not the restricted owner: the pid was proved, only its uid is wrong, and the error says so.
|
|
594
|
+
callback(new ListenerRefused(SERVER_NOT_OWN, `the LaunchDaemon's pid ${this.jobPid} runs as uid ${runsAs}, not its UserName's uid ${this.jobUid}; nothing was sent`));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
socket.removeAllListeners('error');
|
|
598
|
+
socket.resume();
|
|
599
|
+
callback(null, socket);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The port of exactly http://127.0.0.1:<port> (1 to 65535, no leading zero), else null.
|
|
605
|
+
function loopbackPort(origin) {
|
|
606
|
+
const m = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/.exec(String(origin));
|
|
607
|
+
return m && Number(m[1]) <= 65535 ? Number(m[1]) : null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// As wellKnownOf, for a LaunchDaemon: a loopback origin is read only when launchd's job `jobPid`, running as `jobUid`,
|
|
611
|
+
// accepted the connection.
|
|
612
|
+
export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
|
|
613
|
+
const port = loopbackPort(origin);
|
|
614
|
+
if (port === null) throw new ServiceError(`A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid prove it; ${origin} is not.`);
|
|
615
|
+
return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid }));
|
|
616
|
+
}
|
|
617
|
+
|
|
377
618
|
/**
|
|
378
619
|
* Write and lint the plist, bootout and WAIT for the unload, bootstrap (retrying launchd's EIO 5 times), then poll the
|
|
379
620
|
* well-known document until it answers with this identity; resolves to that document.
|
|
@@ -382,17 +623,52 @@ export async function wellKnownOf(origin, { vouchPid = null } = {}) {
|
|
|
382
623
|
* operator disabled stays disabled and unloaded — the plist is still rewritten, so the new release is what starts once
|
|
383
624
|
* they turn it back on — and the answer is {disabled: true, label, plist, loaded}. `forceEnable` is for an operator
|
|
384
625
|
* who asked for the service to be switched on.
|
|
626
|
+
*
|
|
627
|
+
* A plist in /Library/LaunchDaemons (or `domain: 'system'`) is a LaunchDaemon in launchd's system domain, started at boot
|
|
628
|
+
* with no login (FAA-278): only root installs it, refused before anything is written otherwise, and the answer counts
|
|
629
|
+
* only when launchd's own job accepted the connection. A gui/<uid> bootstrap with no GUI session says so.
|
|
385
630
|
*/
|
|
386
631
|
export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
|
|
387
|
-
run = defaultRun, platform = process.platform, uid,
|
|
388
|
-
|
|
632
|
+
run = defaultRun, platform = process.platform, uid, domain = null, euid = ownEuid(), fetchWellKnown = wellKnownOf,
|
|
633
|
+
fetchJobWellKnown = jobWellKnownOf, sleep = pause } = {}) {
|
|
634
|
+
const dom = launchdDomain(plistPath, { domain, platform, uid });
|
|
389
635
|
launchdLabel(label);
|
|
390
636
|
if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
|
|
391
|
-
|
|
637
|
+
needRoot(dom, label, plistPath, true, euid);
|
|
638
|
+
if (dom === 'system' && loopbackPort(origin) === null) {
|
|
639
|
+
throw new ServiceError(`A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by the job's pid and uid (FAA-278); ${origin} is not.`);
|
|
640
|
+
}
|
|
641
|
+
const named = dom === 'system' ? topUserName(plistText) : null;
|
|
642
|
+
if (dom === 'system' && named) {
|
|
643
|
+
refuseHeldLabel(label, plistPath, run);
|
|
644
|
+
const installedText = plistXml(plistPath, run);
|
|
645
|
+
const installedLabel = topKey(installedText, 'Label');
|
|
646
|
+
if (installedLabel !== null && installedLabel !== label) {
|
|
647
|
+
throw new ServiceError(`${plistPath} holds launchd label ${installedLabel}, not ${label}: writing over it would leave that job loaded from a plist that no longer describes it. Uninstall it as root first, or write this one at /Library/LaunchDaemons/${label}.plist.`);
|
|
648
|
+
}
|
|
649
|
+
const installed = topUserName(installedText);
|
|
650
|
+
if (installed && installed !== named) {
|
|
651
|
+
throw new ServiceError(`${plistPath} is ${installed}'s LaunchDaemon: the system domain is shared by every account of this Mac, so a label is the machine's. Choose a label of your own (for example with the account in it), or uninstall that one as root first.`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (dom === 'system' && named && !USER_NAME.test(named)) {
|
|
655
|
+
throw new ServiceError(`UserName ${named} is not a macOS short name (${USER_NAME.source}): launchd takes the account's short name.`);
|
|
656
|
+
}
|
|
657
|
+
const jobUid = named ? accountUid(named, run) : null;
|
|
658
|
+
if (dom === 'system' && named && jobUid === null) {
|
|
659
|
+
throw new ServiceError(`UserName ${named} is no account of this Mac: launchd would not start the job. Name the account whose Fabric and agents call the service.`);
|
|
660
|
+
}
|
|
661
|
+
if (dom === 'system' && jobUid === 0) {
|
|
662
|
+
throw new ServiceError(`UserName ${named} is root: the job would run as root, keep its data in root's home, and every client refuses root's listener (DEC-0154 HA-2). Name the account whose Fabric and agents call the service.`);
|
|
663
|
+
}
|
|
664
|
+
if (dom === 'system' && !named) {
|
|
665
|
+
throw new ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with launchdPlist(..., { userName: <the account whose Fabric and agents call it> }).");
|
|
666
|
+
}
|
|
667
|
+
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
392
668
|
atomicWrite(plistPath, plistText, 0o644);
|
|
393
669
|
const lint = run(['plutil', '-lint', String(plistPath)]);
|
|
394
670
|
if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
|
|
395
|
-
const target = `${
|
|
671
|
+
const target = `${dom}/${label}`;
|
|
396
672
|
const override = launchdOverride(label, opts);
|
|
397
673
|
if (forceEnable || override === null) run(['launchctl', 'enable', target]);
|
|
398
674
|
else if (override === 'disabled') return { disabled: true, label, plist: String(plistPath), loaded: launchdLoaded(label, opts) };
|
|
@@ -401,13 +677,33 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
401
677
|
while (launchdLoaded(label, opts) && Date.now() < unloaded) await sleep(250);
|
|
402
678
|
let last = '';
|
|
403
679
|
for (let attempt = 0; ; attempt += 1) {
|
|
404
|
-
const result = run(['launchctl', 'bootstrap',
|
|
680
|
+
const result = run(['launchctl', 'bootstrap', dom, String(plistPath)]);
|
|
405
681
|
if (result?.status === 0) break;
|
|
406
682
|
last = String(result?.stderr || result?.stdout || '').trim();
|
|
407
|
-
if (attempt === 4)
|
|
683
|
+
if (attempt === 4) {
|
|
684
|
+
if (dom !== 'system' && /:\s*125:|Domain does not support|Could not find domain/.test(last)) {
|
|
685
|
+
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}. This user has no GUI login session (an SSH session, or a headless Mac after a FileVault restart), so ${dom} does not run jobs: remove the LaunchAgent this attempt wrote (launchdUninstall), then install it as a LaunchDaemon in the system domain (a plist in ${SYSTEM_DAEMONS} with UserName, as root; FAA-278).`);
|
|
686
|
+
}
|
|
687
|
+
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
|
|
688
|
+
}
|
|
408
689
|
await sleep(1000);
|
|
409
690
|
}
|
|
410
691
|
const deadline = Date.now() + timeout * 1000;
|
|
692
|
+
if (dom === 'system') {
|
|
693
|
+
let refused = null;
|
|
694
|
+
while (Date.now() < deadline) {
|
|
695
|
+
const jobPid = launchdPid(label, opts);
|
|
696
|
+
const doc = jobPid ? await fetchJobWellKnown(origin, { jobPid, jobUid }) : null;
|
|
697
|
+
const now = doc instanceof ForeignUserAnswer ? doc.reason : null;
|
|
698
|
+
refused = now ?? refused; // the last refusal stays the reason, but only this round's answer decides
|
|
699
|
+
if (!now && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
700
|
+
await sleep(500);
|
|
701
|
+
}
|
|
702
|
+
const loaded = `; the job stays loaded and its plist starts it again at boot: as root, \`launchctl bootout system/${label}\` and removing ${plistPath} (or launchdUninstall) take it away`;
|
|
703
|
+
if (refused?.startsWith(TABLE_RESTRICTED)) throw new ServiceError(`${origin} could not be proved this LaunchDaemon's: ${refused}; the job's pid did not show the listener: start the server with exec (no wrapper child), or read the job's log${loaded}.`);
|
|
704
|
+
if (refused) throw new ServiceError(`${origin} is not this LaunchDaemon's: ${refused}${loaded}.`);
|
|
705
|
+
throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log${loaded}.`);
|
|
706
|
+
}
|
|
411
707
|
let foreign = null;
|
|
412
708
|
let vouchPid = null;
|
|
413
709
|
while (Date.now() < deadline) {
|
|
@@ -432,15 +728,20 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
432
728
|
* override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
|
|
433
729
|
* fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go, and `instance` which
|
|
434
730
|
* instance's, so one context's purge never reaches another's, FAA-125). A job still
|
|
435
|
-
* loaded at `timeout` seconds throws, leaving the plist and the data in place.
|
|
731
|
+
* loaded at `timeout` seconds throws, leaving the plist and the data in place. A LaunchDaemon (a plist in
|
|
732
|
+
* /Library/LaunchDaemons, or `domain: 'system'`) is removed only by root, and its data belongs to its UserName, so
|
|
733
|
+
* `purge` is refused for it (FAA-278).
|
|
436
734
|
*/
|
|
437
|
-
export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, instance = 'default', run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
|
|
438
|
-
const
|
|
735
|
+
export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, instance = 'default', run = defaultRun, platform = process.platform, uid, domain = null, euid = ownEuid(), sleep = pause } = {}) {
|
|
736
|
+
const dom = launchdDomain(plistPath, { domain, platform, uid });
|
|
439
737
|
launchdLabel(label);
|
|
440
738
|
if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
|
|
739
|
+
needRoot(dom, label, plistPath, false, euid);
|
|
740
|
+
if (purge && dom === 'system') throw new ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then remove the data as that user.");
|
|
741
|
+
if (dom === 'system') refuseHeldLabel(label, plistPath, run); // a bootout would stop another plist's job (FAA-278)
|
|
441
742
|
const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
|
|
442
|
-
const opts = { run, platform, uid };
|
|
443
|
-
const target = `${
|
|
743
|
+
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
744
|
+
const target = `${dom}/${label}`;
|
|
444
745
|
run(['launchctl', 'bootout', target]);
|
|
445
746
|
const deadline = Date.now() + timeout * 1000;
|
|
446
747
|
while (launchdLoaded(label, opts)) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Who is calling an agent that sits behind `tailscale serve`, and what that person may open (FAA-297). The twin of
|
|
2
|
+
// fabric_tailnet.py; both answer test/fixtures/tailnet/vectors.json alike. No dependencies beyond node:crypto and
|
|
3
|
+
// node:net.
|
|
4
|
+
//
|
|
5
|
+
// The header `Tailscale-User-Login` is believed only on the agent's own serve listener (a loopback port or a Unix
|
|
6
|
+
// socket that only serve reaches) and only from a loopback peer; on any other listener a request carrying it is
|
|
7
|
+
// refused. What a person may open comes from a `tailnet-perimeter/1` file generated from the organization's access
|
|
8
|
+
// registry (it names its source in `generatedFrom`); MCP also takes a personal key, stored as its SHA-256, owned by one
|
|
9
|
+
// login and no wider than that login's groups. Refusals are 401 or 403 with a stable `code`.
|
|
10
|
+
|
|
11
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { isIP } from 'node:net';
|
|
13
|
+
|
|
14
|
+
export const PERIMETER_SCHEMA = 'tailnet-perimeter/1';
|
|
15
|
+
export const IDENTITY_HEADER = 'tailscale-user-login';
|
|
16
|
+
export const SERVE_HEADERS = Object.freeze(['tailscale-user-login', 'tailscale-user-name', 'tailscale-user-profile-pic']);
|
|
17
|
+
export const LISTENER_SERVE = 'serve';
|
|
18
|
+
export const SURFACES = Object.freeze(['dashboard', 'mcp']);
|
|
19
|
+
|
|
20
|
+
const LOGIN = /^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}$/;
|
|
21
|
+
const NAME = /^[a-z][a-z0-9._:/-]{0,127}$/;
|
|
22
|
+
const KEY_HASH = /^sha256:[0-9a-f]{64}$/;
|
|
23
|
+
const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
24
|
+
|
|
25
|
+
export const REFUSALS = Object.freeze({
|
|
26
|
+
'serve-header-off-serve': 403,
|
|
27
|
+
'peer-not-loopback': 403,
|
|
28
|
+
'identity-missing': 401,
|
|
29
|
+
'identity-ambiguous': 401,
|
|
30
|
+
'identity-malformed': 401,
|
|
31
|
+
'not-in-perimeter': 403,
|
|
32
|
+
'dashboard-not-granted': 403,
|
|
33
|
+
'capability-not-granted': 403,
|
|
34
|
+
'key-required': 401,
|
|
35
|
+
'key-unknown': 401,
|
|
36
|
+
'key-not-yours': 403,
|
|
37
|
+
'key-wider-than-group': 403,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** A perimeter or key list that cannot be used: `code` names why, `where` the place, never a value. */
|
|
41
|
+
export class PerimeterError extends Error {
|
|
42
|
+
constructor(code, where = '') {
|
|
43
|
+
super(`${code}${where ? ` at ${where}` : ''}`);
|
|
44
|
+
this.name = 'PerimeterError';
|
|
45
|
+
this.code = code;
|
|
46
|
+
this.where = where;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The stored form of a personal key: `sha256:<hex>` of its UTF-8 bytes. */
|
|
51
|
+
export function hashKey(value) {
|
|
52
|
+
if (typeof value !== 'string' || !value) throw new TypeError('a key is a non-empty string');
|
|
53
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sortedSet = (values) => [...new Set(values)].sort();
|
|
57
|
+
const isRecordValue = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
const pyKeySort = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
59
|
+
|
|
60
|
+
function names(value, where) {
|
|
61
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && NAME.test(v))) {
|
|
62
|
+
throw new PerimeterError('perimeter-invalid', where);
|
|
63
|
+
}
|
|
64
|
+
return sortedSet(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check a parsed `tailnet-perimeter/1` document and answer its normalized form; throw PerimeterError otherwise. */
|
|
68
|
+
export function loadPerimeter(doc) {
|
|
69
|
+
if (!isRecordValue(doc) || doc.schema !== PERIMETER_SCHEMA) throw new PerimeterError('perimeter-schema', 'schema');
|
|
70
|
+
const source = doc.generatedFrom;
|
|
71
|
+
if (!isRecordValue(source) || !['registry', 'revision', 'at'].every((k) => typeof source[k] === 'string' && source[k])) {
|
|
72
|
+
throw new PerimeterError('perimeter-not-generated', 'generatedFrom');
|
|
73
|
+
}
|
|
74
|
+
const groups = doc.groups;
|
|
75
|
+
if (!isRecordValue(groups) || Object.keys(groups).length === 0) throw new PerimeterError('perimeter-invalid', 'groups');
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const name of Object.keys(groups).sort(pyKeySort)) {
|
|
78
|
+
const group = groups[name];
|
|
79
|
+
const where = NAME.test(name) ? `groups.${name}` : 'groups';
|
|
80
|
+
if (!NAME.test(name) || !isRecordValue(group)) throw new PerimeterError('perimeter-invalid', where);
|
|
81
|
+
const members = group.members;
|
|
82
|
+
if (!Array.isArray(members) || !members.every((m) => typeof m === 'string' && LOGIN.test(m))) {
|
|
83
|
+
throw new PerimeterError('perimeter-invalid', `${where}.members`);
|
|
84
|
+
}
|
|
85
|
+
out[name] = {
|
|
86
|
+
members: sortedSet(members.map((m) => m.toLowerCase())),
|
|
87
|
+
dashboards: names(group.dashboards ?? [], `${where}.dashboards`),
|
|
88
|
+
capabilities: names(group.capabilities ?? [], `${where}.capabilities`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
schema: PERIMETER_SCHEMA,
|
|
93
|
+
generatedFrom: { registry: source.registry, revision: source.revision, at: source.at },
|
|
94
|
+
groups: out,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Check a list of personal keys `{id, login, sha256, capabilities}` (the hash only, never the key). */
|
|
99
|
+
export function loadKeys(keys) {
|
|
100
|
+
if (!Array.isArray(keys)) throw new PerimeterError('keys-invalid', 'keys');
|
|
101
|
+
const out = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
keys.forEach((key, i) => {
|
|
104
|
+
const where = `keys[${i}]`;
|
|
105
|
+
if (!isRecordValue(key) || typeof key.id !== 'string' || !KEY_ID.test(key.id)
|
|
106
|
+
|| typeof key.login !== 'string' || !LOGIN.test(key.login)
|
|
107
|
+
|| typeof key.sha256 !== 'string' || !KEY_HASH.test(key.sha256)) {
|
|
108
|
+
throw new PerimeterError('keys-invalid', where);
|
|
109
|
+
}
|
|
110
|
+
if (seen.has(key.id) || out.some((k) => k.sha256 === key.sha256)) throw new PerimeterError('keys-duplicate', where);
|
|
111
|
+
seen.add(key.id);
|
|
112
|
+
out.push({ id: key.id, login: key.login.toLowerCase(), sha256: key.sha256,
|
|
113
|
+
capabilities: names(key.capabilities ?? [], `${where}.capabilities`) });
|
|
114
|
+
});
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function headerValues(headers, name) {
|
|
119
|
+
// A list of pairs, a Map or a fetch Headers (anything with entries()), or a plain object such as Node's req.headers.
|
|
120
|
+
const plain = headers === null || headers === undefined || Object.getPrototypeOf(headers) === Object.prototype
|
|
121
|
+
|| Object.getPrototypeOf(headers) === null;
|
|
122
|
+
const pairs = Array.isArray(headers) ? headers
|
|
123
|
+
: !plain && typeof headers.entries === 'function' ? [...headers.entries()]
|
|
124
|
+
: Object.entries(headers ?? {});
|
|
125
|
+
const found = [];
|
|
126
|
+
for (const [key, value] of pairs) {
|
|
127
|
+
if (typeof key === 'string' && key.toLowerCase() === name) found.push(...(Array.isArray(value) ? value : [value]));
|
|
128
|
+
}
|
|
129
|
+
return found.map((v) => (typeof v === 'string' ? v : ''));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isLoopback(address) {
|
|
133
|
+
if (address === 'unix') return true;
|
|
134
|
+
if (typeof address !== 'string') return false;
|
|
135
|
+
const ip = address.split('%', 1)[0];
|
|
136
|
+
const family = isIP(ip);
|
|
137
|
+
if (family === 4) return ip.split('.')[0] === '127';
|
|
138
|
+
if (family !== 6) return false;
|
|
139
|
+
const host = new URL(`http://[${ip}]/`).hostname; // the canonical compressed form: [::1], [::ffff:7f00:1]
|
|
140
|
+
return host === '[::1]' || /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const refuse = (code, login = null, groups = []) => ({ allowed: false, status: REFUSALS[code], code, login, groups });
|
|
144
|
+
|
|
145
|
+
function bearer(headers) {
|
|
146
|
+
const values = headerValues(headers, 'authorization');
|
|
147
|
+
if (values.length !== 1) return null;
|
|
148
|
+
const text = values[0].trim();
|
|
149
|
+
const space = text.indexOf(' ');
|
|
150
|
+
if (space < 0) return null;
|
|
151
|
+
const scheme = text.slice(0, space);
|
|
152
|
+
const rest = text.slice(space + 1).trim();
|
|
153
|
+
return scheme.toLowerCase() === 'bearer' && rest && !rest.includes(' ') ? rest : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sameDigest(a, b) {
|
|
157
|
+
const x = Buffer.from(a, 'utf8');
|
|
158
|
+
const y = Buffer.from(b, 'utf8');
|
|
159
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide one request. `listener` is which of the agent's listeners took it (`serve` for serve's own); `peer` the
|
|
164
|
+
* connecting address (`unix` for a Unix socket); `perimeter` and `keys` as loadPerimeter and loadKeys answer;
|
|
165
|
+
* `surface` `dashboard` or `mcp`; `target` the dashboard's or the capability's name. Answers
|
|
166
|
+
* {allowed, status, code, login, groups}: status 200 with code `ok`, else 401 or 403 with the refusal's code.
|
|
167
|
+
*/
|
|
168
|
+
export function authorize({ listener, peer, headers, perimeter, surface, target, keys = null }) {
|
|
169
|
+
if (!SURFACES.includes(surface)) throw new TypeError(`surface is one of ${SURFACES.join(', ')}`);
|
|
170
|
+
if (listener !== LISTENER_SERVE) {
|
|
171
|
+
if (SERVE_HEADERS.some((h) => headerValues(headers, h).length)) return refuse('serve-header-off-serve');
|
|
172
|
+
throw new TypeError("authorize decides serve's listener; the Fabric listener keeps its session key");
|
|
173
|
+
}
|
|
174
|
+
if (!isLoopback(peer)) return refuse('peer-not-loopback');
|
|
175
|
+
const logins = headerValues(headers, IDENTITY_HEADER);
|
|
176
|
+
if (logins.length === 0) return refuse('identity-missing');
|
|
177
|
+
if (logins.length > 1) return refuse('identity-ambiguous');
|
|
178
|
+
let login = logins[0].trim();
|
|
179
|
+
if (!LOGIN.test(login)) return refuse('identity-malformed');
|
|
180
|
+
login = login.toLowerCase();
|
|
181
|
+
const groups = Object.keys(perimeter.groups).filter((n) => perimeter.groups[n].members.includes(login)).sort(pyKeySort);
|
|
182
|
+
if (groups.length === 0) return refuse('not-in-perimeter', login);
|
|
183
|
+
const dashboards = new Set(groups.flatMap((n) => perimeter.groups[n].dashboards));
|
|
184
|
+
const capabilities = new Set(groups.flatMap((n) => perimeter.groups[n].capabilities));
|
|
185
|
+
if (surface === 'dashboard') {
|
|
186
|
+
if (!dashboards.has(target)) return refuse('dashboard-not-granted', login, groups);
|
|
187
|
+
return { allowed: true, status: 200, code: 'ok', login, groups };
|
|
188
|
+
}
|
|
189
|
+
const presented = bearer(headers);
|
|
190
|
+
if (presented === null) return refuse('key-required', login, groups);
|
|
191
|
+
const digest = hashKey(presented);
|
|
192
|
+
const key = (keys ?? []).find((k) => sameDigest(k.sha256, digest));
|
|
193
|
+
if (!key) return refuse('key-unknown', login, groups);
|
|
194
|
+
if (key.login !== login) return refuse('key-not-yours', login, groups);
|
|
195
|
+
if (!key.capabilities.every((c) => capabilities.has(c))) return refuse('key-wider-than-group', login, groups);
|
|
196
|
+
if (!key.capabilities.includes(target)) return refuse('capability-not-granted', login, groups);
|
|
197
|
+
return { allowed: true, status: 200, code: 'ok', login, groups, key: key.id };
|
|
198
|
+
}
|