@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.1
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 +384 -7
- package/CONTRIBUTING.md +14 -0
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +180 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +100 -53
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +6 -6
- package/src/agent/agent.mjs +152 -5
- package/src/agent/allowed-commands.mjs +539 -15
- package/src/agent/crypto.mjs +238 -40
- package/src/agent/exec.mjs +60 -4
- package/src/agent/p2p-node.mjs +40 -0
- package/src/agent/p2p.mjs +323 -30
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +319 -56
- package/src/relay/server.mjs +74 -11
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
* GET /api/w2m/target?session_id=… the fleet and this session's selection, as one snapshot
|
|
35
35
|
* POST /api/w2m/target { session_id, machine_id|null } → the same snapshot
|
|
36
36
|
*
|
|
37
|
+
* A refusal is `{ok: false, error, reason_code, detail}` with a `{@link TARGET_REFUSAL}` code and a
|
|
38
|
+
* `detail` that says what would change it; a `PUT`/`DELETE`/`PATCH` is a `405` rather than a write
|
|
39
|
+
* (only exactly `GET` and `POST` are answered), a machine the relay does not list is a `409` exactly
|
|
40
|
+
* like one it lists as offline, and `session_id` is capped at {@link MAX_SESSION_ID_LENGTH} characters
|
|
41
|
+
* on both verbs because the store keys on the whole string.
|
|
42
|
+
*
|
|
37
43
|
* Plain `Response.json`, no Typert descriptor: this package is dependency-free, and a generated
|
|
38
44
|
* strict Remote contribution is neither reachable from plain JavaScript nor worth a codec for one
|
|
39
45
|
* object. Nothing else in the protocol changes — no new relay endpoint, no new tool parameter, no
|
|
@@ -76,6 +82,49 @@ export const NO_SESSION = '(no-session)';
|
|
|
76
82
|
/** Session buckets retained before the oldest is evicted; a browse session cannot grow this. */
|
|
77
83
|
export const MAX_SELECTIONS = 512;
|
|
78
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Longest `session_id` this route accepts, in characters (UTF-16 code units).
|
|
87
|
+
*
|
|
88
|
+
* `MAX_SELECTIONS` bounds how many buckets exist; it says nothing about how large one key is, and the
|
|
89
|
+
* store keeps the whole string. A caller that sends a 200,000-character id therefore allocates as much
|
|
90
|
+
* as it likes per request and gets it echoed back in the snapshot — the count cap bounds the *number*
|
|
91
|
+
* of sessions, never the *bytes*. 200 is a bound with room to spare: a DSH session id is a UUID (36
|
|
92
|
+
* characters), and 200 leaves room for a ULID, a path-shaped id or a test's own naming without
|
|
93
|
+
* inviting an unbounded key.
|
|
94
|
+
*/
|
|
95
|
+
export const MAX_SESSION_ID_LENGTH = 200;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `/api/w2m/target` route's refusals, in the house `{reason_code, detail}` shape.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately a map of its own rather than a widening of `tools.mjs`'s `REFUSAL_CODE`: those codes name
|
|
101
|
+
* refusals on the *dispatch* path (which the eight tools report), while these name refusals at the
|
|
102
|
+
* route the browser calls, and `fleet-target.mjs` is imported *by* `tools.mjs` — importing back would
|
|
103
|
+
* be a cycle. `reason_code` is the greppable, stable identifier and `detail` says what would change it.
|
|
104
|
+
*/
|
|
105
|
+
export const TARGET_REFUSAL = Object.freeze({
|
|
106
|
+
/** A verb this route does not answer. Only exactly `GET` and `POST` are read or written. */
|
|
107
|
+
METHOD_NOT_ALLOWED: 'TARGET_METHOD_NOT_ALLOWED',
|
|
108
|
+
/** `GET` carried no `session_id`, so there is no session to answer for. */
|
|
109
|
+
SESSION_ID_REQUIRED: 'TARGET_SESSION_ID_REQUIRED',
|
|
110
|
+
/** `session_id` is longer than {@link MAX_SESSION_ID_LENGTH}. */
|
|
111
|
+
SESSION_ID_TOO_LONG: 'TARGET_SESSION_ID_TOO_LONG',
|
|
112
|
+
/** A `POST` body that could not be parsed as JSON at all. */
|
|
113
|
+
BODY_NOT_JSON: 'TARGET_BODY_NOT_JSON',
|
|
114
|
+
/** A `POST` body that is not a JSON object. */
|
|
115
|
+
BODY_NOT_AN_OBJECT: 'TARGET_BODY_NOT_AN_OBJECT',
|
|
116
|
+
/** `machine_id` was neither a string nor null. */
|
|
117
|
+
MACHINE_ID_NOT_A_STRING: 'TARGET_MACHINE_ID_NOT_A_STRING',
|
|
118
|
+
/** `machine_name` was neither a string nor null. */
|
|
119
|
+
MACHINE_NAME_NOT_A_STRING: 'TARGET_MACHINE_NAME_NOT_A_STRING',
|
|
120
|
+
/** The named machine is not one the relay lists at all. */
|
|
121
|
+
MACHINE_UNKNOWN: 'TARGET_MACHINE_UNKNOWN',
|
|
122
|
+
/** The named machine exists, and the relay does not call it online. */
|
|
123
|
+
MACHINE_OFFLINE: 'TARGET_MACHINE_OFFLINE',
|
|
124
|
+
/** The fleet could not be read, so no selection could be checked against it. */
|
|
125
|
+
FLEET_UNAVAILABLE: 'TARGET_FLEET_UNAVAILABLE',
|
|
126
|
+
});
|
|
127
|
+
|
|
79
128
|
/** How long the fleet roster may be reused by the control before it is asked for again. */
|
|
80
129
|
export const FLEET_CACHE_MS = 10_000;
|
|
81
130
|
|
|
@@ -415,25 +464,55 @@ export function buildUiState(deps) {
|
|
|
415
464
|
/**
|
|
416
465
|
* Parse the JSON body of a selection write, refusing anything outside the declared shape.
|
|
417
466
|
*
|
|
467
|
+
* Every refusal is a *value with a name*: `reason_code` is stable and greppable, and `detail` says what
|
|
468
|
+
* would have to change for the same request to be accepted. The route turns both into the response body
|
|
469
|
+
* verbatim, so the browser can show the reason without re-deriving it.
|
|
470
|
+
*
|
|
418
471
|
* @param {unknown} body
|
|
419
|
-
* @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}
|
|
472
|
+
* @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}
|
|
473
|
+
* |{ok: false, error: string, reason_code: string, detail: string}}
|
|
420
474
|
*/
|
|
421
475
|
export function parseSelectionWrite(body) {
|
|
476
|
+
const refuse = (error, reason_code, detail) => ({ ok: false, error, reason_code, detail });
|
|
422
477
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
423
|
-
return
|
|
478
|
+
return refuse(
|
|
479
|
+
'body must be a JSON object',
|
|
480
|
+
TARGET_REFUSAL.BODY_NOT_AN_OBJECT,
|
|
481
|
+
'send `{"session_id": "<id>", "machine_id": "<id>|null"}`',
|
|
482
|
+
);
|
|
424
483
|
}
|
|
425
484
|
const record = /** @type {Record<string, unknown>} */ (body);
|
|
426
485
|
const sessionId = record.session_id;
|
|
427
486
|
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
428
|
-
return
|
|
487
|
+
return refuse(
|
|
488
|
+
'`session_id` must be a non-empty string',
|
|
489
|
+
TARGET_REFUSAL.SESSION_ID_REQUIRED,
|
|
490
|
+
'send the id of the session this selection belongs to',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
494
|
+
return refuse(
|
|
495
|
+
`\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
|
|
496
|
+
TARGET_REFUSAL.SESSION_ID_TOO_LONG,
|
|
497
|
+
`send the id the host gave this session (a UUID is 36 characters); anything longer than `
|
|
498
|
+
+ `${MAX_SESSION_ID_LENGTH} characters is not a session id`,
|
|
499
|
+
);
|
|
429
500
|
}
|
|
430
501
|
const machineId = record.machine_id;
|
|
431
502
|
if (machineId !== null && machineId !== undefined && typeof machineId !== 'string') {
|
|
432
|
-
return
|
|
503
|
+
return refuse(
|
|
504
|
+
'`machine_id` must be a string or null',
|
|
505
|
+
TARGET_REFUSAL.MACHINE_ID_NOT_A_STRING,
|
|
506
|
+
'send a `machine_id` from the roster this route reports, or null to clear the selection',
|
|
507
|
+
);
|
|
433
508
|
}
|
|
434
509
|
const machineName = record.machine_name;
|
|
435
510
|
if (machineName !== null && machineName !== undefined && typeof machineName !== 'string') {
|
|
436
|
-
return
|
|
511
|
+
return refuse(
|
|
512
|
+
'`machine_name` must be a string or null',
|
|
513
|
+
TARGET_REFUSAL.MACHINE_NAME_NOT_A_STRING,
|
|
514
|
+
'send the machine\'s display name as a string, or leave it out and the relay\'s name is used',
|
|
515
|
+
);
|
|
437
516
|
}
|
|
438
517
|
return {
|
|
439
518
|
ok: true,
|
|
@@ -603,11 +682,51 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
603
682
|
fetch: async (request) => {
|
|
604
683
|
const url = new URL(request.url);
|
|
605
684
|
const noStore = { 'cache-control': 'no-store' };
|
|
685
|
+
/**
|
|
686
|
+
* One refusal shape for the whole route: `{ok:false, error, reason_code, detail}`.
|
|
687
|
+
*
|
|
688
|
+
* @param {number} status
|
|
689
|
+
* @param {string} reasonCode A {@link TARGET_REFUSAL} value.
|
|
690
|
+
* @param {string} error What happened, in the host's own words (the control prints this).
|
|
691
|
+
* @param {string} detail What would change it.
|
|
692
|
+
* @returns {Response}
|
|
693
|
+
*/
|
|
694
|
+
const refuse = (status, reasonCode, error, detail) =>
|
|
695
|
+
Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status, headers: noStore });
|
|
696
|
+
|
|
697
|
+
// Defence in depth, and the one place it is cheap. The registration above declares
|
|
698
|
+
// `methods: ['GET', 'POST']`, so the host should never route a `PUT`/`DELETE`/`PATCH` here at all
|
|
699
|
+
// — but the handler used to treat *anything* that was not a GET as a write, which means a
|
|
700
|
+
// registration mistake, a host that forwards other verbs, or a future `methods` change would
|
|
701
|
+
// silently turn `DELETE /api/w2m/target` into "clear this selection". A route that writes must
|
|
702
|
+
// say which verbs write; everything else is refused by name rather than guessed at.
|
|
703
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
704
|
+
return refuse(
|
|
705
|
+
405,
|
|
706
|
+
TARGET_REFUSAL.METHOD_NOT_ALLOWED,
|
|
707
|
+
`${request.method} is not a method this route answers`,
|
|
708
|
+
'GET reads the fleet and this session\'s selection and POST writes one; any other verb is refused '
|
|
709
|
+
+ 'rather than treated as a write',
|
|
710
|
+
);
|
|
711
|
+
}
|
|
606
712
|
|
|
607
713
|
if (request.method === 'GET') {
|
|
608
714
|
const sessionId = url.searchParams.get('session_id');
|
|
609
715
|
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
610
|
-
return
|
|
716
|
+
return refuse(
|
|
717
|
+
400,
|
|
718
|
+
TARGET_REFUSAL.SESSION_ID_REQUIRED,
|
|
719
|
+
'`session_id` query parameter is required',
|
|
720
|
+
'add `?session_id=<the session the control belongs to>`',
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
724
|
+
return refuse(
|
|
725
|
+
400,
|
|
726
|
+
TARGET_REFUSAL.SESSION_ID_TOO_LONG,
|
|
727
|
+
`\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
|
|
728
|
+
`send the id the host gave this session (a UUID is 36 characters)`,
|
|
729
|
+
);
|
|
611
730
|
}
|
|
612
731
|
try {
|
|
613
732
|
return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
|
|
@@ -623,10 +742,17 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
623
742
|
try {
|
|
624
743
|
body = await request.json();
|
|
625
744
|
} catch {
|
|
626
|
-
return
|
|
745
|
+
return refuse(
|
|
746
|
+
400,
|
|
747
|
+
TARGET_REFUSAL.BODY_NOT_JSON,
|
|
748
|
+
'body is not JSON',
|
|
749
|
+
'send `{"session_id": "<id>", "machine_id": "<id>|null"}` as a JSON object',
|
|
750
|
+
);
|
|
627
751
|
}
|
|
628
752
|
const parsed = parseSelectionWrite(body);
|
|
629
|
-
if (!parsed.ok)
|
|
753
|
+
if (!parsed.ok) {
|
|
754
|
+
return refuse(400, parsed.reason_code, parsed.error, parsed.detail);
|
|
755
|
+
}
|
|
630
756
|
const { session_id: sessionId, machine_id: machineId, machine_name: machineName } = parsed.value;
|
|
631
757
|
|
|
632
758
|
if (machineId === null) {
|
|
@@ -642,16 +768,35 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
642
768
|
const cfg = deps.cfg();
|
|
643
769
|
const fleet = await fleetFor(request.signal);
|
|
644
770
|
if (fleet.ok !== true) {
|
|
645
|
-
return
|
|
646
|
-
|
|
647
|
-
|
|
771
|
+
return refuse(
|
|
772
|
+
503,
|
|
773
|
+
TARGET_REFUSAL.FLEET_UNAVAILABLE,
|
|
774
|
+
`the fleet could not be read, so no target was changed: ${fleet.error ?? 'unknown cause'}`,
|
|
775
|
+
'bring the relay back (or fix `rabbitUrl`) and choose again; nothing was selected in the meantime',
|
|
648
776
|
);
|
|
649
777
|
}
|
|
650
778
|
const machine = fleet.machines.find((entry) => entry.machine_id === machineId) ?? null;
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
779
|
+
// A machine the relay does not list *at all* is refused in exactly the same breath as one it lists
|
|
780
|
+
// as offline. The comment above always claimed this and the code did not do it: `machine === null`
|
|
781
|
+
// fell through to `store.set`, so `{"machine_id":"NOT-A-MACHINE"}` was accepted with a 200 and a
|
|
782
|
+
// selection the fleet could never honour. Dispatch re-validates, which is what bounded the impact
|
|
783
|
+
// — but a control whose whole promise is "you cannot select what the fleet cannot run" has to
|
|
784
|
+
// refuse the id it has never heard of too.
|
|
785
|
+
if (machine === null) {
|
|
786
|
+
return refuse(
|
|
787
|
+
409,
|
|
788
|
+
TARGET_REFUSAL.MACHINE_UNKNOWN,
|
|
789
|
+
`${machineId} is not a machine this relay lists`,
|
|
790
|
+
'pick a machine from the roster this route reports; if it has just paired, reload the list and '
|
|
791
|
+
+ 'choose it again',
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (machine.online !== true) {
|
|
795
|
+
return refuse(
|
|
796
|
+
409,
|
|
797
|
+
TARGET_REFUSAL.MACHINE_OFFLINE,
|
|
798
|
+
`${machine.name ?? machineId} is listed as ${machine.state}, so it cannot be selected`,
|
|
799
|
+
'the machine must be online: wait for its next heartbeat, or select another machine',
|
|
655
800
|
);
|
|
656
801
|
}
|
|
657
802
|
deps.store.set(sessionId, {
|