@dmsdc-ai/aigentry-telepty 0.5.9 → 0.6.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 +59 -0
- package/cli.js +392 -30
- package/cross-machine.js +124 -1
- package/daemon-control.js +9 -0
- package/daemon.js +415 -17
- package/install.js +156 -26
- package/package.json +5 -5
- package/src/audit/inject-log.js +234 -0
- package/src/protocol/http-auth.js +36 -1
- package/src/submit-gate.js +130 -5
- package/src/transport/broker-client.js +498 -0
- package/src/transport/broker-protocol.js +155 -0
- package/src/transport/broker-server.js +505 -0
- package/src/win-resolve-executable.js +6 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function buildInjectEnvelope(input) {
|
|
4
|
+
const payload = input.payload || {};
|
|
5
|
+
const toNode = requireString(input.to_node, 'to_node');
|
|
6
|
+
const toSession = requireString(input.to_session, 'to_session');
|
|
7
|
+
const fromNode = requireString(input.from_node, 'from_node');
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
type: 'inject',
|
|
11
|
+
message_id: requireString(input.message_id, 'message_id'),
|
|
12
|
+
inject_id: requireString(input.inject_id, 'inject_id'),
|
|
13
|
+
target: input.target || `${toSession}@${toNode}`,
|
|
14
|
+
to_node: toNode,
|
|
15
|
+
to_session: toSession,
|
|
16
|
+
from_node: fromNode,
|
|
17
|
+
source_host: input.source_host || fromNode,
|
|
18
|
+
payload: {
|
|
19
|
+
prompt: payload.prompt,
|
|
20
|
+
from: payload.from,
|
|
21
|
+
reply_to: payload.reply_to,
|
|
22
|
+
no_enter: payload.no_enter === true,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseInjectEnvelope(value) {
|
|
28
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
29
|
+
if (!parsed || parsed.type !== 'inject') {
|
|
30
|
+
throw new Error('Expected inject envelope');
|
|
31
|
+
}
|
|
32
|
+
return buildInjectEnvelope(parsed);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createMessageIdDeduper(options = {}) {
|
|
36
|
+
const seen = options.seen || new Set();
|
|
37
|
+
const maxSize = options.maxSize || 10000;
|
|
38
|
+
const trimSize = options.trimSize || 5000;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
accept(messageOrId) {
|
|
42
|
+
const messageId = getMessageId(messageOrId);
|
|
43
|
+
if (!messageId) return false;
|
|
44
|
+
if (seen.has(messageId)) return false;
|
|
45
|
+
seen.add(messageId);
|
|
46
|
+
|
|
47
|
+
if (seen.size > maxSize) {
|
|
48
|
+
const ids = [...seen];
|
|
49
|
+
ids.splice(trimSize);
|
|
50
|
+
for (const id of ids) seen.delete(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
has(messageOrId) {
|
|
56
|
+
return seen.has(getMessageId(messageOrId));
|
|
57
|
+
},
|
|
58
|
+
get size() {
|
|
59
|
+
return seen.size;
|
|
60
|
+
},
|
|
61
|
+
seen,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createSseSequencer(options = {}) {
|
|
66
|
+
let current = parseLastEventId(options.initial) || 0;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
next() {
|
|
70
|
+
current += 1;
|
|
71
|
+
return current;
|
|
72
|
+
},
|
|
73
|
+
get current() {
|
|
74
|
+
return current;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseLastEventId(value) {
|
|
80
|
+
if (value === null || value === undefined || value === '') return null;
|
|
81
|
+
const seq = Number(value);
|
|
82
|
+
if (!Number.isSafeInteger(seq) || seq < 0) return null;
|
|
83
|
+
return seq;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildSseInjectFrame(seq, envelope) {
|
|
87
|
+
const eventId = parseLastEventId(seq);
|
|
88
|
+
if (eventId === null) throw new Error('Invalid SSE sequence id');
|
|
89
|
+
return `id: ${eventId}\nevent: inject\ndata: ${JSON.stringify(envelope)}\n\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseSseFrame(frame) {
|
|
93
|
+
const fields = { id: null, event: null, data: [] };
|
|
94
|
+
for (const line of String(frame).split(/\r?\n/)) {
|
|
95
|
+
if (!line || line.startsWith(':')) continue;
|
|
96
|
+
const delimiter = line.indexOf(':');
|
|
97
|
+
const name = delimiter === -1 ? line : line.slice(0, delimiter);
|
|
98
|
+
let value = delimiter === -1 ? '' : line.slice(delimiter + 1);
|
|
99
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
100
|
+
|
|
101
|
+
if (name === 'id') fields.id = value;
|
|
102
|
+
if (name === 'event') fields.event = value;
|
|
103
|
+
if (name === 'data') fields.data.push(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const data = fields.data.join('\n');
|
|
107
|
+
return {
|
|
108
|
+
id: fields.id,
|
|
109
|
+
event: fields.event,
|
|
110
|
+
data: parseFrameData(data),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildAck(input) {
|
|
115
|
+
return {
|
|
116
|
+
type: 'ack',
|
|
117
|
+
inject_id: requireString(input.inject_id, 'inject_id'),
|
|
118
|
+
success: input.success === true,
|
|
119
|
+
code: input.code || null,
|
|
120
|
+
error: input.error || null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getMessageId(messageOrId) {
|
|
125
|
+
if (typeof messageOrId === 'string') return messageOrId;
|
|
126
|
+
if (messageOrId && typeof messageOrId.message_id === 'string') return messageOrId.message_id;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseFrameData(data) {
|
|
131
|
+
if (!data) return null;
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(data);
|
|
134
|
+
} catch {
|
|
135
|
+
return data;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function requireString(value, name) {
|
|
140
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
141
|
+
throw new Error(`Missing ${name}`);
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
buildAck,
|
|
148
|
+
buildInjectEnvelope,
|
|
149
|
+
buildSseInjectFrame,
|
|
150
|
+
createMessageIdDeduper,
|
|
151
|
+
createSseSequencer,
|
|
152
|
+
parseInjectEnvelope,
|
|
153
|
+
parseLastEventId,
|
|
154
|
+
parseSseFrame,
|
|
155
|
+
};
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Broker-side HTTP surface (spec §2(B) + §3.1 + §4). Mounted only in broker mode;
|
|
4
|
+
// the daemon (W3/T5) wires the returned `handler` onto its TLS server. This module
|
|
5
|
+
// is a self-contained Node `http` request handler — no framework, no external dep
|
|
6
|
+
// (§17). It REUSES the W1 primitives verbatim:
|
|
7
|
+
// - src/transport/broker-protocol.js : envelope build, SSE frame, ack shape, seq, dedup
|
|
8
|
+
// - src/protocol/http-auth.js : createVerifyJwt, signNodeJwt, createBrokerAcl,
|
|
9
|
+
// isRevokedNode (no reimplementation here)
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
buildInjectEnvelope,
|
|
15
|
+
buildSseInjectFrame,
|
|
16
|
+
createSseSequencer,
|
|
17
|
+
parseLastEventId,
|
|
18
|
+
} = require('./broker-protocol');
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
createBrokerAcl,
|
|
22
|
+
createVerifyJwt,
|
|
23
|
+
isRevokedNode,
|
|
24
|
+
signNodeJwt,
|
|
25
|
+
} = require('../protocol/http-auth');
|
|
26
|
+
|
|
27
|
+
const DAY_SECONDS = 24 * 60 * 60;
|
|
28
|
+
|
|
29
|
+
// Constant-time secret compare (§4.6a). Hash both sides to a fixed length first so
|
|
30
|
+
// neither the timing nor the length of the comparison leaks the secret.
|
|
31
|
+
function constantTimeEqual(a, b) {
|
|
32
|
+
const ha = crypto.createHash('sha256').update(String(a == null ? '' : a)).digest();
|
|
33
|
+
const hb = crypto.createHash('sha256').update(String(b == null ? '' : b)).digest();
|
|
34
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getClientIp(req) {
|
|
38
|
+
const raw = (req.socket && req.socket.remoteAddress) || '';
|
|
39
|
+
return raw.replace('::ffff:', '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractBearer(req) {
|
|
43
|
+
const auth = req.headers['authorization'] || '';
|
|
44
|
+
return auth.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readJsonBody(req, { limit = 1 << 20 } = {}) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let size = 0;
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let aborted = false;
|
|
52
|
+
req.on('data', (chunk) => {
|
|
53
|
+
if (aborted) return;
|
|
54
|
+
size += chunk.length;
|
|
55
|
+
if (size > limit) {
|
|
56
|
+
aborted = true;
|
|
57
|
+
resolve({ tooLarge: true });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
chunks.push(chunk);
|
|
61
|
+
});
|
|
62
|
+
req.on('end', () => {
|
|
63
|
+
if (aborted) return;
|
|
64
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
65
|
+
if (!raw) return resolve({ value: {} });
|
|
66
|
+
try {
|
|
67
|
+
resolve({ value: JSON.parse(raw) });
|
|
68
|
+
} catch {
|
|
69
|
+
resolve({ invalid: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
req.on('error', () => resolve({ invalid: true }));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sendJson(res, status, body) {
|
|
77
|
+
if (res.writableEnded) return;
|
|
78
|
+
const payload = JSON.stringify(body);
|
|
79
|
+
res.writeHead(status, {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
82
|
+
});
|
|
83
|
+
res.end(payload);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createBrokerServer(options = {}) {
|
|
87
|
+
const {
|
|
88
|
+
jwtSecret,
|
|
89
|
+
enrollSecret,
|
|
90
|
+
fleet = 'default',
|
|
91
|
+
broadcastBusEvent = () => {},
|
|
92
|
+
// §4.5/§4.6b abuse controls
|
|
93
|
+
enrollRatePerMin = 5,
|
|
94
|
+
maxNodes = Number(process.env.TELEPTY_ENROLL_MAX_NODES) || 256,
|
|
95
|
+
jwtTtlSeconds = 30 * DAY_SECONDS,
|
|
96
|
+
// §3.1/§3.3 delivery
|
|
97
|
+
injectTimeoutMs = 15000,
|
|
98
|
+
maxQueue = 100,
|
|
99
|
+
heartbeatMs = 22000,
|
|
100
|
+
replayBufferMax = 100,
|
|
101
|
+
// TLS gate (§3.0 / §4.4): when the broker is TLS-configured, reject plaintext.
|
|
102
|
+
requireTls = false,
|
|
103
|
+
// injectable for determinism / wiring
|
|
104
|
+
now = () => Date.now(),
|
|
105
|
+
randomUUID = () => crypto.randomUUID(),
|
|
106
|
+
onAudit = null,
|
|
107
|
+
} = options;
|
|
108
|
+
|
|
109
|
+
if (!jwtSecret) throw new Error('createBrokerServer requires jwtSecret');
|
|
110
|
+
if (!enrollSecret) throw new Error('createBrokerServer requires enrollSecret');
|
|
111
|
+
|
|
112
|
+
// Mutable so the daemon / `telepty broker allow|deny|revoke` admin commands and
|
|
113
|
+
// tests can grant/revoke at runtime. ACL defaults to empty → default-deny (§4.1).
|
|
114
|
+
const aclTable = options.aclTable || {};
|
|
115
|
+
const revokedNodes = options.revokedNodes || new Set();
|
|
116
|
+
const acl = createBrokerAcl(aclTable);
|
|
117
|
+
const verifyJwt = createVerifyJwt(jwtSecret);
|
|
118
|
+
|
|
119
|
+
// Broker state ----------------------------------------------------------------
|
|
120
|
+
const nodes = new Map(); // name -> node state
|
|
121
|
+
const pending = new Map(); // inject_id -> held /broker/inject response
|
|
122
|
+
const enrollWindows = new Map(); // ip -> [timestamps]
|
|
123
|
+
const auditLog = [];
|
|
124
|
+
let closed = false;
|
|
125
|
+
|
|
126
|
+
function nodeState(name) {
|
|
127
|
+
let node = nodes.get(name);
|
|
128
|
+
if (!node) {
|
|
129
|
+
node = {
|
|
130
|
+
name,
|
|
131
|
+
sub: name,
|
|
132
|
+
sessions: [],
|
|
133
|
+
lastSeen: now(),
|
|
134
|
+
stream: null,
|
|
135
|
+
heartbeatTimer: null,
|
|
136
|
+
seq: createSseSequencer(),
|
|
137
|
+
replay: [], // [{ seq, frame }]
|
|
138
|
+
inflight: [], // inject_ids awaiting ack (bounded by maxQueue)
|
|
139
|
+
};
|
|
140
|
+
nodes.set(name, node);
|
|
141
|
+
}
|
|
142
|
+
return node;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function audit(entry) {
|
|
146
|
+
const record = { timestamp: new Date(now()).toISOString(), ...entry };
|
|
147
|
+
auditLog.push(record);
|
|
148
|
+
if (typeof onAudit === 'function') {
|
|
149
|
+
try { onAudit(record); } catch { /* audit sink must never break enroll */ }
|
|
150
|
+
}
|
|
151
|
+
// §4.6b — emit a broker_enroll bus event (reuse the daemon bus broadcaster).
|
|
152
|
+
try {
|
|
153
|
+
broadcastBusEvent({
|
|
154
|
+
type: 'broker_enroll',
|
|
155
|
+
node: entry.node,
|
|
156
|
+
source_host: entry.ip,
|
|
157
|
+
result: entry.result,
|
|
158
|
+
timestamp: record.timestamp,
|
|
159
|
+
});
|
|
160
|
+
} catch { /* bus broadcast is best-effort */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Auth gate for every /broker/* except enroll: verify node-JWT + revocation set.
|
|
164
|
+
function authNode(req, res) {
|
|
165
|
+
const token = extractBearer(req);
|
|
166
|
+
if (!token) {
|
|
167
|
+
sendJson(res, 401, { ok: false, code: 'UNAUTHORIZED', error: 'Missing node JWT' });
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const decoded = verifyJwt(token);
|
|
171
|
+
if (!decoded) {
|
|
172
|
+
sendJson(res, 401, { ok: false, code: 'UNAUTHORIZED', error: 'Invalid or expired node JWT' });
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (isRevokedNode(revokedNodes, decoded)) {
|
|
176
|
+
sendJson(res, 401, { ok: false, code: 'REVOKED', error: 'Node JWT has been revoked' });
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
return decoded;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Per-IP sliding-window rate limit for /broker/enroll (§4.6b).
|
|
183
|
+
function enrollRateExceeded(ip) {
|
|
184
|
+
const ts = now();
|
|
185
|
+
const windowStart = ts - 60_000;
|
|
186
|
+
const hits = (enrollWindows.get(ip) || []).filter((t) => t > windowStart);
|
|
187
|
+
if (hits.length >= enrollRatePerMin) {
|
|
188
|
+
enrollWindows.set(ip, hits);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
hits.push(ts);
|
|
192
|
+
enrollWindows.set(ip, hits);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function settlePending(injectId, status, ackBody) {
|
|
197
|
+
const held = pending.get(injectId);
|
|
198
|
+
if (!held || held.settled) return;
|
|
199
|
+
held.settled = true;
|
|
200
|
+
clearTimeout(held.timer);
|
|
201
|
+
pending.delete(injectId);
|
|
202
|
+
const node = nodes.get(held.toNode);
|
|
203
|
+
if (node) {
|
|
204
|
+
const idx = node.inflight.indexOf(injectId);
|
|
205
|
+
if (idx !== -1) node.inflight.splice(idx, 1);
|
|
206
|
+
}
|
|
207
|
+
const body = {
|
|
208
|
+
ok: status === 'ack',
|
|
209
|
+
status,
|
|
210
|
+
inject_id: injectId,
|
|
211
|
+
};
|
|
212
|
+
if (ackBody) {
|
|
213
|
+
body.success = ackBody.success === true;
|
|
214
|
+
body.code = ackBody.code || null;
|
|
215
|
+
body.error = ackBody.error || null;
|
|
216
|
+
}
|
|
217
|
+
sendJson(held.res, 200, body);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pushReplay(node, seq, frame) {
|
|
221
|
+
node.replay.push({ seq, frame });
|
|
222
|
+
if (node.replay.length > replayBufferMax) node.replay.shift();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Endpoint handlers -----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async function handleEnroll(req, res) {
|
|
228
|
+
const ip = getClientIp(req);
|
|
229
|
+
|
|
230
|
+
// Rate-limit BEFORE any work so spam can never reach name processing (§4.6b).
|
|
231
|
+
if (enrollRateExceeded(ip)) {
|
|
232
|
+
audit({ node: null, ip, result: 'rate_limited' });
|
|
233
|
+
return sendJson(res, 429, { ok: false, code: 'RATE_LIMITED', error: 'Enroll rate limit exceeded' });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const parsed = await readJsonBody(req);
|
|
237
|
+
if (parsed.tooLarge) return sendJson(res, 413, { ok: false, code: 'PAYLOAD_TOO_LARGE', error: 'Body too large' });
|
|
238
|
+
if (parsed.invalid) return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: 'Invalid JSON body' });
|
|
239
|
+
|
|
240
|
+
const body = parsed.value || {};
|
|
241
|
+
const node = typeof body.node === 'string' ? body.node.trim() : '';
|
|
242
|
+
if (!node) {
|
|
243
|
+
audit({ node: null, ip, result: 'bad_request' });
|
|
244
|
+
return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: 'Missing node name' });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Ownership / rotation path (§4.6a, §4.6c): a re-enroll carrying the current
|
|
248
|
+
// valid JWT for this name proves ownership — no enroll-secret needed.
|
|
249
|
+
const bearer = extractBearer(req);
|
|
250
|
+
const bearerClaims = bearer ? verifyJwt(bearer) : false;
|
|
251
|
+
const isOwner = !!bearerClaims
|
|
252
|
+
&& bearerClaims.sub === node
|
|
253
|
+
&& !isRevokedNode(revokedNodes, bearerClaims);
|
|
254
|
+
|
|
255
|
+
if (!isOwner && !constantTimeEqual(enrollSecret, req.headers['x-telepty-enroll'])) {
|
|
256
|
+
audit({ node, ip, result: 'unauthorized' });
|
|
257
|
+
return sendJson(res, 401, { ok: false, code: 'UNAUTHORIZED', error: 'Invalid enroll secret' });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const existing = nodes.has(node);
|
|
261
|
+
|
|
262
|
+
// Anti-squat (§4.6a): an existing name can only be re-claimed by its owner.
|
|
263
|
+
if (existing && !isOwner) {
|
|
264
|
+
audit({ node, ip, result: 'duplicate' });
|
|
265
|
+
return sendJson(res, 409, { ok: false, code: 'NAME_TAKEN', error: 'Node name already enrolled' });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Global fleet cap (§4.6b) — only new identities count against it.
|
|
269
|
+
if (!existing && nodes.size >= maxNodes) {
|
|
270
|
+
audit({ node, ip, result: 'capped' });
|
|
271
|
+
return sendJson(res, 429, { ok: false, code: 'ENROLL_CAP', error: 'Fleet enroll cap reached' });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const iat = Math.floor(now() / 1000);
|
|
275
|
+
const exp = iat + jwtTtlSeconds;
|
|
276
|
+
const jwt = signNodeJwt(jwtSecret, { sub: node, fleet, iat, exp });
|
|
277
|
+
|
|
278
|
+
// Register identity + write an EMPTY ACL entry → default-deny: enrolled ≠
|
|
279
|
+
// authorized (§4.6 core safety argument). Only on first enroll.
|
|
280
|
+
nodeState(node);
|
|
281
|
+
if (!Array.isArray(aclTable[node])) aclTable[node] = [];
|
|
282
|
+
|
|
283
|
+
audit({ node, ip, result: existing ? 'rotated' : 'enrolled' });
|
|
284
|
+
return sendJson(res, 200, { ok: true, node, jwt, exp, fleet });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function handleRegister(req, res) {
|
|
288
|
+
const decoded = authNode(req, res);
|
|
289
|
+
if (!decoded) return;
|
|
290
|
+
const parsed = await readJsonBody(req);
|
|
291
|
+
if (parsed.invalid) return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: 'Invalid JSON body' });
|
|
292
|
+
const node = nodeState(decoded.sub);
|
|
293
|
+
const sessions = Array.isArray(parsed.value && parsed.value.sessions) ? parsed.value.sessions : [];
|
|
294
|
+
node.sessions = sessions;
|
|
295
|
+
node.lastSeen = now();
|
|
296
|
+
return sendJson(res, 200, { ok: true, node: decoded.sub, since: node.lastSeen });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function handleStream(req, res) {
|
|
300
|
+
const decoded = authNode(req, res);
|
|
301
|
+
if (!decoded) return;
|
|
302
|
+
const node = nodeState(decoded.sub);
|
|
303
|
+
|
|
304
|
+
res.writeHead(200, {
|
|
305
|
+
'Content-Type': 'text/event-stream',
|
|
306
|
+
'Cache-Control': 'no-cache',
|
|
307
|
+
Connection: 'keep-alive',
|
|
308
|
+
'X-Accel-Buffering': 'no',
|
|
309
|
+
});
|
|
310
|
+
res.write(': connected\n\n');
|
|
311
|
+
|
|
312
|
+
node.stream = res;
|
|
313
|
+
node.lastSeen = now();
|
|
314
|
+
|
|
315
|
+
// Reconnect replay (§3.3, at-least-once): redeliver buffered frames after the
|
|
316
|
+
// given Last-Event-ID; the receiving node dedups by message_id.
|
|
317
|
+
const lastEventId = parseLastEventId(req.headers['last-event-id']);
|
|
318
|
+
if (lastEventId !== null) {
|
|
319
|
+
for (const buffered of node.replay) {
|
|
320
|
+
if (buffered.seq > lastEventId) res.write(buffered.frame);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Heartbeat comment so EDR/proxies keep the chunked stream open (§3.3).
|
|
325
|
+
node.heartbeatTimer = setInterval(() => {
|
|
326
|
+
if (!res.writableEnded) res.write(': ping\n\n');
|
|
327
|
+
}, heartbeatMs);
|
|
328
|
+
if (node.heartbeatTimer.unref) node.heartbeatTimer.unref();
|
|
329
|
+
|
|
330
|
+
const cleanup = () => {
|
|
331
|
+
if (node.heartbeatTimer) {
|
|
332
|
+
clearInterval(node.heartbeatTimer);
|
|
333
|
+
node.heartbeatTimer = null;
|
|
334
|
+
}
|
|
335
|
+
if (node.stream === res) node.stream = null;
|
|
336
|
+
};
|
|
337
|
+
req.on('close', cleanup);
|
|
338
|
+
req.on('error', cleanup);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function handleInject(req, res) {
|
|
342
|
+
const decoded = authNode(req, res);
|
|
343
|
+
if (!decoded) return;
|
|
344
|
+
const fromNode = decoded.sub;
|
|
345
|
+
|
|
346
|
+
const parsed = await readJsonBody(req);
|
|
347
|
+
if (parsed.invalid) return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: 'Invalid JSON body' });
|
|
348
|
+
const body = parsed.value || {};
|
|
349
|
+
const toNode = typeof body.to_node === 'string' ? body.to_node : '';
|
|
350
|
+
const toSession = typeof body.to_session === 'string' ? body.to_session : '';
|
|
351
|
+
if (!toNode || !toSession) {
|
|
352
|
+
return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: 'Missing to_node/to_session' });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Authorize: default-deny ACL (§4.1). A stolen token reaches only granted
|
|
356
|
+
// targets — closes T2 fleet-wide escalation.
|
|
357
|
+
if (!acl.canInject(fromNode, toNode)) {
|
|
358
|
+
return sendJson(res, 403, { ok: false, code: 'FORBIDDEN', status: 'forbidden', error: `Not authorized to inject ${toNode}` });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const target = nodes.get(toNode);
|
|
362
|
+
if (!target || !target.stream || target.stream.writableEnded) {
|
|
363
|
+
return sendJson(res, 200, { ok: false, status: 'unreachable', error: `Target node ${toNode} not connected` });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const messageId = typeof body.message_id === 'string' && body.message_id ? body.message_id : randomUUID();
|
|
367
|
+
const injectId = typeof body.inject_id === 'string' && body.inject_id ? body.inject_id : randomUUID();
|
|
368
|
+
|
|
369
|
+
let envelope;
|
|
370
|
+
try {
|
|
371
|
+
envelope = buildInjectEnvelope({
|
|
372
|
+
message_id: messageId,
|
|
373
|
+
inject_id: injectId,
|
|
374
|
+
to_node: toNode,
|
|
375
|
+
to_session: toSession,
|
|
376
|
+
from_node: fromNode,
|
|
377
|
+
target: body.target,
|
|
378
|
+
source_host: fromNode,
|
|
379
|
+
payload: body.payload || {},
|
|
380
|
+
});
|
|
381
|
+
} catch (err) {
|
|
382
|
+
return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: err.message });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Backpressure (§3.3): bounded per-node in-flight queue. On overflow drop the
|
|
386
|
+
// oldest held inject (resolve it node_backlogged) so the new one can be served
|
|
387
|
+
// — request/reply parity means the originator is never silently dropped.
|
|
388
|
+
while (target.inflight.length >= maxQueue) {
|
|
389
|
+
const oldest = target.inflight.shift();
|
|
390
|
+
settlePending(oldest, 'node_backlogged');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const seq = target.seq.next();
|
|
394
|
+
const frame = buildSseInjectFrame(seq, envelope);
|
|
395
|
+
pushReplay(target, seq, frame);
|
|
396
|
+
target.stream.write(frame);
|
|
397
|
+
|
|
398
|
+
// Hold the response until the target acks or the 15s timeout (§3.1 sync parity).
|
|
399
|
+
const timer = setTimeout(() => settlePending(injectId, 'timeout'), injectTimeoutMs);
|
|
400
|
+
if (timer.unref) timer.unref();
|
|
401
|
+
pending.set(injectId, { res, timer, toNode, settled: false });
|
|
402
|
+
target.inflight.push(injectId);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function handleAck(req, res) {
|
|
406
|
+
const decoded = authNode(req, res);
|
|
407
|
+
if (!decoded) return;
|
|
408
|
+
const parsed = await readJsonBody(req);
|
|
409
|
+
if (parsed.invalid) return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: 'Invalid JSON body' });
|
|
410
|
+
const body = parsed.value || {};
|
|
411
|
+
const injectId = typeof body.inject_id === 'string' ? body.inject_id : '';
|
|
412
|
+
if (!injectId) return sendJson(res, 400, { ok: false, code: 'BAD_REQUEST', error: 'Missing inject_id' });
|
|
413
|
+
|
|
414
|
+
settlePending(injectId, body.success === true ? 'ack' : 'failed', {
|
|
415
|
+
success: body.success,
|
|
416
|
+
code: body.code,
|
|
417
|
+
error: body.error,
|
|
418
|
+
});
|
|
419
|
+
return sendJson(res, 200, { ok: true, inject_id: injectId });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function handleHeartbeat(req, res) {
|
|
423
|
+
const decoded = authNode(req, res);
|
|
424
|
+
if (!decoded) return;
|
|
425
|
+
const parsed = await readJsonBody(req);
|
|
426
|
+
const node = nodeState(decoded.sub);
|
|
427
|
+
node.lastSeen = now();
|
|
428
|
+
if (parsed.value && Array.isArray(parsed.value.sessions)) node.sessions = parsed.value.sessions;
|
|
429
|
+
return sendJson(res, 200, { ok: true, node: decoded.sub, ts: node.lastSeen });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function handleSessions(req, res) {
|
|
433
|
+
const decoded = authNode(req, res);
|
|
434
|
+
if (!decoded) return;
|
|
435
|
+
const aggregate = [];
|
|
436
|
+
for (const node of nodes.values()) {
|
|
437
|
+
for (const session of node.sessions) {
|
|
438
|
+
const base = (session && typeof session === 'object') ? session : { id: session };
|
|
439
|
+
aggregate.push({ ...base, peerName: node.name, host: node.name });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return sendJson(res, 200, { ok: true, sessions: aggregate });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Router ----------------------------------------------------------------------
|
|
446
|
+
function handler(req, res) {
|
|
447
|
+
if (closed) return sendJson(res, 503, { ok: false, code: 'CLOSED', error: 'Broker shutting down' });
|
|
448
|
+
|
|
449
|
+
// Reject plaintext when TLS is configured (§4.4) — the daemon mounts this on an
|
|
450
|
+
// HTTPS server; a request that is not encrypted is rejected outright.
|
|
451
|
+
if (requireTls && !(req.socket && req.socket.encrypted)) {
|
|
452
|
+
return sendJson(res, 400, { ok: false, code: 'TLS_REQUIRED', error: 'TLS is required for /broker/*' });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const url = (req.url || '').split('?')[0];
|
|
456
|
+
const method = req.method;
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
if (url === '/broker/enroll' && method === 'POST') return handleEnroll(req, res);
|
|
460
|
+
if (url === '/broker/register' && method === 'POST') return handleRegister(req, res);
|
|
461
|
+
if (url === '/broker/stream' && method === 'GET') return handleStream(req, res);
|
|
462
|
+
if (url === '/broker/inject' && method === 'POST') return handleInject(req, res);
|
|
463
|
+
if (url === '/broker/ack' && method === 'POST') return handleAck(req, res);
|
|
464
|
+
if (url === '/broker/heartbeat' && method === 'POST') return handleHeartbeat(req, res);
|
|
465
|
+
if (url === '/broker/sessions' && method === 'GET') return handleSessions(req, res);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return sendJson(res, 500, { ok: false, code: 'INTERNAL', error: err.message });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return sendJson(res, 404, { ok: false, code: 'NOT_FOUND', error: 'Unknown broker route' });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function close() {
|
|
474
|
+
closed = true;
|
|
475
|
+
for (const held of pending.values()) {
|
|
476
|
+
clearTimeout(held.timer);
|
|
477
|
+
if (!held.res.writableEnded) {
|
|
478
|
+
try { sendJson(held.res, 200, { ok: false, status: 'shutdown' }); } catch { /* ignore */ }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
pending.clear();
|
|
482
|
+
for (const node of nodes.values()) {
|
|
483
|
+
if (node.heartbeatTimer) clearInterval(node.heartbeatTimer);
|
|
484
|
+
if (node.stream && !node.stream.writableEnded) {
|
|
485
|
+
try { node.stream.end(); } catch { /* ignore */ }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
handler,
|
|
492
|
+
close,
|
|
493
|
+
// exposed for the daemon wiring (W3/T5) + tests
|
|
494
|
+
aclTable,
|
|
495
|
+
revokedNodes,
|
|
496
|
+
auditLog,
|
|
497
|
+
nodes,
|
|
498
|
+
grant(fromNode, toNode) {
|
|
499
|
+
if (!Array.isArray(aclTable[fromNode])) aclTable[fromNode] = [];
|
|
500
|
+
if (!aclTable[fromNode].includes(toNode)) aclTable[fromNode].push(toNode);
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
module.exports = { createBrokerServer };
|
|
@@ -52,7 +52,7 @@ function resolveWindowsExecutable(command, env, opts) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Bare name → walk PATH × PATHEXT.
|
|
55
|
-
const exts =
|
|
55
|
+
const exts = parseExtsForPathWalk(e.PATHEXT || DEFAULT_PATHEXT);
|
|
56
56
|
const dirs = (e.PATH || '').split(';').filter(Boolean);
|
|
57
57
|
for (const dir of dirs) {
|
|
58
58
|
for (const ext of exts) {
|
|
@@ -84,4 +84,9 @@ function parseExts(pathext) {
|
|
|
84
84
|
return ['', ...list];
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function parseExtsForPathWalk(pathext) {
|
|
88
|
+
const list = pathext.split(';').map((s) => s.trim()).filter(Boolean);
|
|
89
|
+
return [...list, ''];
|
|
90
|
+
}
|
|
91
|
+
|
|
87
92
|
module.exports = { resolveWindowsExecutable };
|