@agentproto/pairing-host 0.1.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/LICENSE +202 -0
- package/README.md +32 -0
- package/dist/index.d.ts +350 -0
- package/dist/index.mjs +874 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual, createHmac } from 'crypto';
|
|
2
|
+
import { readFile, stat, mkdir, writeFile, chmod, rename } from 'fs/promises';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join, dirname, basename } from 'path';
|
|
5
|
+
import { wrapWebSocket, createTunnelServer, DEFAULT_HTTP_FORWARD_TIMEOUT_MS, daemonHandshakeOverSink } from '@agentproto/acp/tunnel';
|
|
6
|
+
import { PairingError, deriveOfferTokens, encodeOfferUrl, OFFER_VERSION, currentEpoch, deriveEpochTokens, HOSTED_RENDEZVOUS_URL, decodePairingHello, respondToLegacyHandshake, respondToHandshake, encodePairingMessage, PAIRING_PROTOCOL_OUTDATED_MESSAGE, derivePairRoot } from '@agentproto/secrets/pairing';
|
|
7
|
+
import { identityFingerprint } from '@agentproto/secrets/identity';
|
|
8
|
+
import WebSocket from 'ws';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @agentproto/pairing-host
|
|
12
|
+
* Host side of AIP-59 pairing as a library (registry, rendezvous dialer,
|
|
13
|
+
* loopback HTTP forwarder, local-device bearer).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
// src/reconnect-log-gate.ts
|
|
18
|
+
function createReconnectLogGate(opts = {}) {
|
|
19
|
+
const windowMs = opts.windowMs ?? 6e4;
|
|
20
|
+
const now = opts.now ?? Date.now;
|
|
21
|
+
const state = /* @__PURE__ */ new Map();
|
|
22
|
+
return {
|
|
23
|
+
onFailure(key, message) {
|
|
24
|
+
const ts = now();
|
|
25
|
+
const prev = state.get(key);
|
|
26
|
+
if (prev && ts - prev.lastLoggedAt < windowMs) {
|
|
27
|
+
prev.suppressed += 1;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const suffix = prev && prev.suppressed > 0 ? ` (${prev.suppressed} failure${prev.suppressed === 1 ? "" : "s"} suppressed)` : "";
|
|
31
|
+
state.set(key, { lastLoggedAt: ts, suppressed: 0 });
|
|
32
|
+
return `${message}${suffix}`;
|
|
33
|
+
},
|
|
34
|
+
onSuccess(key) {
|
|
35
|
+
state.delete(key);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
var LOCAL_BEARER_PREFIX = "apd1";
|
|
40
|
+
var MAC_CONTEXT = "agentproto/local-device/v1|";
|
|
41
|
+
function newLocalDevice(name, createdAt) {
|
|
42
|
+
return {
|
|
43
|
+
fingerprint: randomBytes(16).toString("hex"),
|
|
44
|
+
name,
|
|
45
|
+
createdAt,
|
|
46
|
+
secret: randomBytes(32).toString("base64url")
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function macOf(rec) {
|
|
50
|
+
return createHmac("sha256", Buffer.from(rec.secret, "base64url")).update(MAC_CONTEXT + rec.fingerprint, "utf8").digest();
|
|
51
|
+
}
|
|
52
|
+
function deriveLocalBearer(rec) {
|
|
53
|
+
return `${LOCAL_BEARER_PREFIX}.${rec.fingerprint}.${macOf(rec).toString("base64url")}`;
|
|
54
|
+
}
|
|
55
|
+
function parseLocalBearer(bearer) {
|
|
56
|
+
if (typeof bearer !== "string" || bearer.length > 256) return null;
|
|
57
|
+
const parts = bearer.split(".");
|
|
58
|
+
if (parts.length !== 3 || parts[0] !== LOCAL_BEARER_PREFIX) return null;
|
|
59
|
+
const [, fingerprint, mac] = parts;
|
|
60
|
+
if (!/^[0-9a-f]{32}$/.test(fingerprint) || !/^[A-Za-z0-9_-]+$/.test(mac)) return null;
|
|
61
|
+
return { fingerprint, mac };
|
|
62
|
+
}
|
|
63
|
+
function verifyLocalBearer(rec, parsed) {
|
|
64
|
+
const target = rec ?? { fingerprint: "0".repeat(32), secret: randomBytes(32).toString("base64url") };
|
|
65
|
+
const expected = macOf(target).toString("base64url");
|
|
66
|
+
const presented = parsed?.mac ?? "";
|
|
67
|
+
const sameLength = presented.length === expected.length;
|
|
68
|
+
const equal = timingSafeEqual(
|
|
69
|
+
Buffer.from(expected),
|
|
70
|
+
Buffer.from(sameLength ? presented : "0".repeat(expected.length))
|
|
71
|
+
);
|
|
72
|
+
return Boolean(rec) && parsed !== null && sameLength && equal;
|
|
73
|
+
}
|
|
74
|
+
function isLocalDeviceRecord(v) {
|
|
75
|
+
if (typeof v !== "object" || v === null) return false;
|
|
76
|
+
const r = v;
|
|
77
|
+
return typeof r["fingerprint"] === "string" && /^[0-9a-f]{32}$/.test(r["fingerprint"]) && typeof r["name"] === "string" && typeof r["createdAt"] === "string" && typeof r["secret"] === "string" && r["secret"].length > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/pairing-registry.ts
|
|
81
|
+
var PAIRINGS_VERSION = 2;
|
|
82
|
+
var LEGACY_PAIRINGS_VERSION = 1;
|
|
83
|
+
var PAIRING_REVOKED_CODE = "pairing_revoked";
|
|
84
|
+
function revokedFrame() {
|
|
85
|
+
return {
|
|
86
|
+
t: "error",
|
|
87
|
+
code: PAIRING_REVOKED_CODE,
|
|
88
|
+
message: "this device was unpaired from the daemon (agentproto pair revoke); pair again with a new offer"
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
var DEFAULT_TTL_MS = 10 * 6e4;
|
|
92
|
+
var DEFAULT_RECONNECT_MIN_MS = 1e3;
|
|
93
|
+
var DEFAULT_RECONNECT_MAX_MS = 3e4;
|
|
94
|
+
var DEFAULT_HANDSHAKE_TIMEOUT_MS = 11e4;
|
|
95
|
+
var DEFAULT_REVOKED_GRACE_MS = 14 * 864e5;
|
|
96
|
+
function defaultPairingsPath() {
|
|
97
|
+
return join(homedir(), ".agentproto", "pairings.json");
|
|
98
|
+
}
|
|
99
|
+
async function readPairingsSnapshot(path) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(path ?? defaultPairingsPath(), "utf8");
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
if (!isPairingsFile(parsed)) return [];
|
|
104
|
+
const fromV1 = parsed.v === LEGACY_PAIRINGS_VERSION;
|
|
105
|
+
return parsed.pairings.map((rec) => fromV1 ? { ...rec, legacy: true } : rec);
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function b64url(bytes) {
|
|
111
|
+
return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
112
|
+
}
|
|
113
|
+
function constantTimeEqual(a, b) {
|
|
114
|
+
const key = randomBytes(32);
|
|
115
|
+
const da = createHmac("sha256", key).update(a, "utf8").digest();
|
|
116
|
+
const db = createHmac("sha256", key).update(b, "utf8").digest();
|
|
117
|
+
return timingSafeEqual(da, db);
|
|
118
|
+
}
|
|
119
|
+
var OUTDATED_HELLO_VERSION = `pair/v2 required \u2014 ${PAIRING_PROTOCOL_OUTDATED_MESSAGE}`;
|
|
120
|
+
function createPairingRegistry(deps) {
|
|
121
|
+
const pairingsPath = deps.pairingsPath ?? defaultPairingsPath();
|
|
122
|
+
const now = deps.now ?? Date.now;
|
|
123
|
+
const log = deps.log ?? (() => {
|
|
124
|
+
});
|
|
125
|
+
const reconnectMinMs = deps.reconnectMinMs ?? DEFAULT_RECONNECT_MIN_MS;
|
|
126
|
+
const reconnectMaxMs = deps.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS;
|
|
127
|
+
const handshakeTimeoutMs = deps.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
|
128
|
+
const revokedGraceMs = deps.revokedGraceMs ?? DEFAULT_REVOKED_GRACE_MS;
|
|
129
|
+
const dialFailureGate = createReconnectLogGate({ now });
|
|
130
|
+
const pairings = /* @__PURE__ */ new Map();
|
|
131
|
+
const onlineCounts = /* @__PURE__ */ new Map();
|
|
132
|
+
const revoked = /* @__PURE__ */ new Map();
|
|
133
|
+
const offers = /* @__PURE__ */ new Map();
|
|
134
|
+
const loops = /* @__PURE__ */ new Map();
|
|
135
|
+
const channels = /* @__PURE__ */ new Set();
|
|
136
|
+
const localDevices = /* @__PURE__ */ new Map();
|
|
137
|
+
let fileSig = "";
|
|
138
|
+
let shuttingDown = false;
|
|
139
|
+
async function sigOfFile() {
|
|
140
|
+
try {
|
|
141
|
+
const st = await stat(pairingsPath);
|
|
142
|
+
return `${st.mtimeMs}:${st.size}:${st.ino}`;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (isEnoent(err)) return null;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function replaceLocalDevices(records) {
|
|
149
|
+
localDevices.clear();
|
|
150
|
+
if (!Array.isArray(records)) return;
|
|
151
|
+
for (const rec of records) if (isLocalDeviceRecord(rec)) localDevices.set(rec.fingerprint, rec);
|
|
152
|
+
}
|
|
153
|
+
async function refreshLocalDevices() {
|
|
154
|
+
let sig;
|
|
155
|
+
try {
|
|
156
|
+
sig = await sigOfFile();
|
|
157
|
+
} catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (sig === null) {
|
|
161
|
+
localDevices.clear();
|
|
162
|
+
fileSig = "";
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (sig === fileSig) return;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(await readFile(pairingsPath, "utf8"));
|
|
168
|
+
if (isPairingsFile(parsed)) {
|
|
169
|
+
replaceLocalDevices(parsed.localDevices);
|
|
170
|
+
fileSig = sig;
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let loadPromise = null;
|
|
176
|
+
function ensureLoaded() {
|
|
177
|
+
loadPromise ??= doLoad();
|
|
178
|
+
return loadPromise;
|
|
179
|
+
}
|
|
180
|
+
async function doLoad() {
|
|
181
|
+
try {
|
|
182
|
+
const sig = await sigOfFile();
|
|
183
|
+
const raw = await readFile(pairingsPath, "utf8");
|
|
184
|
+
const parsed = JSON.parse(raw);
|
|
185
|
+
if (isPairingsFile(parsed)) {
|
|
186
|
+
if (sig !== null) fileSig = sig;
|
|
187
|
+
replaceLocalDevices(parsed.localDevices);
|
|
188
|
+
const fromV1 = parsed.v === LEGACY_PAIRINGS_VERSION;
|
|
189
|
+
for (const rec of parsed.pairings) {
|
|
190
|
+
pairings.set(rec.fingerprint, fromV1 ? { ...rec, legacy: true } : rec);
|
|
191
|
+
}
|
|
192
|
+
for (const rec of parsed.revoked ?? []) {
|
|
193
|
+
if (isRevokedRecord(rec) && tombstoneLive(rec)) revoked.set(rec.fingerprint, rec);
|
|
194
|
+
}
|
|
195
|
+
const legacy = Array.from(pairings.values()).filter((r) => r.legacy);
|
|
196
|
+
if (legacy.length > 0) {
|
|
197
|
+
log(
|
|
198
|
+
`[pairing] ${legacy.length} pairing(s) in ${pairingsPath} use the retired pair/v1 protocol and will not be served (${legacy.map((r) => r.name).join(", ")}). Re-pair each client \u2014 run \`agentproto pair offer\` \u2014 then \`agentproto pair revoke <name>\` the legacy entry.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
log(`[pairing] ignoring ${pairingsPath}: unrecognised format`);
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (!isEnoent(err)) {
|
|
206
|
+
log(`[pairing] could not read ${pairingsPath}: ${errMsg(err)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
let persistChain = Promise.resolve();
|
|
211
|
+
function persist() {
|
|
212
|
+
const run = persistChain.then(() => doPersist());
|
|
213
|
+
persistChain = run.catch(() => {
|
|
214
|
+
});
|
|
215
|
+
return run;
|
|
216
|
+
}
|
|
217
|
+
function mutateLocal(apply) {
|
|
218
|
+
const run = persistChain.then(async () => {
|
|
219
|
+
await refreshLocalDevices();
|
|
220
|
+
apply();
|
|
221
|
+
await doPersist(false);
|
|
222
|
+
});
|
|
223
|
+
persistChain = run.catch(() => {
|
|
224
|
+
});
|
|
225
|
+
return run;
|
|
226
|
+
}
|
|
227
|
+
async function doPersist(refresh = true) {
|
|
228
|
+
if (refresh) await refreshLocalDevices();
|
|
229
|
+
for (const [fp, rec] of revoked) if (!tombstoneLive(rec)) revoked.delete(fp);
|
|
230
|
+
const file = {
|
|
231
|
+
v: PAIRINGS_VERSION,
|
|
232
|
+
pairings: Array.from(pairings.values()),
|
|
233
|
+
...revoked.size > 0 ? { revoked: Array.from(revoked.values()) } : {},
|
|
234
|
+
...localDevices.size > 0 ? { localDevices: Array.from(localDevices.values()) } : {}
|
|
235
|
+
};
|
|
236
|
+
const dir = dirname(pairingsPath);
|
|
237
|
+
await mkdir(dir, { recursive: true });
|
|
238
|
+
const tmp = join(dir, `.${basename(pairingsPath)}.tmp-${process.pid}`);
|
|
239
|
+
await writeFile(tmp, JSON.stringify(file, null, 2) + "\n", { encoding: "utf8", mode: 384 });
|
|
240
|
+
await chmod(tmp, 384).catch(() => {
|
|
241
|
+
});
|
|
242
|
+
await rename(tmp, pairingsPath);
|
|
243
|
+
fileSig = await sigOfFile() ?? "";
|
|
244
|
+
}
|
|
245
|
+
function startLoop(spec) {
|
|
246
|
+
if (loops.has(spec.key)) return;
|
|
247
|
+
const abort = new AbortController();
|
|
248
|
+
const done = runLoop(spec, abort.signal).catch((err) => {
|
|
249
|
+
log(`[pairing] loop ${spec.key} ended: ${errMsg(err)}`);
|
|
250
|
+
});
|
|
251
|
+
loops.set(spec.key, { abort, done });
|
|
252
|
+
}
|
|
253
|
+
async function stopLoop(key, reason) {
|
|
254
|
+
const loop = loops.get(key);
|
|
255
|
+
if (!loop) return;
|
|
256
|
+
loops.delete(key);
|
|
257
|
+
loop.abort.abort(reason);
|
|
258
|
+
await loop.done.catch(() => {
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async function runLoop(spec, signal) {
|
|
262
|
+
let backoff = reconnectMinMs;
|
|
263
|
+
while (!signal.aborted && spec.shouldContinue()) {
|
|
264
|
+
const expected = await spec.tokens();
|
|
265
|
+
let sink;
|
|
266
|
+
try {
|
|
267
|
+
sink = await deps.dial(dialUrl(spec.rendezvousUrl, expected.route), signal);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (signal.aborted) break;
|
|
270
|
+
const line = dialFailureGate.onFailure(
|
|
271
|
+
spec.key,
|
|
272
|
+
`[pairing] dial ${spec.key} failed: ${errMsg(err)}`
|
|
273
|
+
);
|
|
274
|
+
if (line) log(line);
|
|
275
|
+
await sleep(backoff, signal);
|
|
276
|
+
backoff = Math.min(backoff * 2, reconnectMaxMs);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!sink.isOpen) {
|
|
280
|
+
if (signal.aborted) break;
|
|
281
|
+
await sleep(backoff, signal);
|
|
282
|
+
backoff = Math.min(backoff * 2, reconnectMaxMs);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
dialFailureGate.onSuccess(spec.key);
|
|
286
|
+
let serving = false;
|
|
287
|
+
const onAbort = () => {
|
|
288
|
+
if (serving && signal.reason === REVOKED) return;
|
|
289
|
+
try {
|
|
290
|
+
sink.close("registry shutdown");
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
295
|
+
try {
|
|
296
|
+
let capturedSession = null;
|
|
297
|
+
let capturedHello = null;
|
|
298
|
+
let legacyPeer = false;
|
|
299
|
+
let wrapped;
|
|
300
|
+
try {
|
|
301
|
+
const identity = await deps.loadIdentity();
|
|
302
|
+
wrapped = await daemonHandshakeOverSink(
|
|
303
|
+
sink,
|
|
304
|
+
async (helloBytes) => {
|
|
305
|
+
let hello;
|
|
306
|
+
try {
|
|
307
|
+
hello = decodePairingHello(helloBytes);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (!(err instanceof PairingError) || err.code !== "pairing_protocol_outdated") throw err;
|
|
310
|
+
legacyPeer = true;
|
|
311
|
+
return respondToLegacyHandshake(helloBytes, identity);
|
|
312
|
+
}
|
|
313
|
+
const result = await respondToHandshake(hello, {
|
|
314
|
+
identity,
|
|
315
|
+
verifyAuthToken: (presented) => spec.verify(presented, expected)
|
|
316
|
+
});
|
|
317
|
+
capturedSession = result.session;
|
|
318
|
+
capturedHello = hello;
|
|
319
|
+
return { reply: encodePairingMessage(result.reply), keys: result.session };
|
|
320
|
+
},
|
|
321
|
+
{ timeoutMs: handshakeTimeoutMs }
|
|
322
|
+
);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (signal.aborted) break;
|
|
325
|
+
void err;
|
|
326
|
+
await sleep(backoff, signal);
|
|
327
|
+
backoff = Math.min(backoff * 2, reconnectMaxMs);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (legacyPeer) {
|
|
331
|
+
sendOutdatedNotice(wrapped);
|
|
332
|
+
log(`[pairing] told a pair/v1 client on ${spec.key} to re-pair`);
|
|
333
|
+
await sleep(backoff, signal);
|
|
334
|
+
backoff = Math.min(backoff * 2, reconnectMaxMs);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
backoff = reconnectMinMs;
|
|
338
|
+
if (!capturedSession || !capturedHello) {
|
|
339
|
+
wrapped.close("internal: missing session");
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (spec.reject) {
|
|
343
|
+
wrapped.send(spec.reject);
|
|
344
|
+
wrapped.close(spec.reject.code);
|
|
345
|
+
log(`[pairing] refused revoked pairing via ${spec.key}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
let ctx;
|
|
349
|
+
try {
|
|
350
|
+
ctx = await spec.onPaired(capturedSession, capturedHello);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
log(`[pairing] persist for ${spec.key} failed: ${errMsg(err)}`);
|
|
353
|
+
wrapped.close("persist failed");
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
serving = true;
|
|
357
|
+
const handle = deps.serve(wrapped, ctx);
|
|
358
|
+
channels.add(handle);
|
|
359
|
+
onlineCounts.set(ctx.fingerprint, (onlineCounts.get(ctx.fingerprint) ?? 0) + 1);
|
|
360
|
+
log(`[pairing] channel up (${ctx.mode}) for ${ctx.fingerprint} via ${spec.key}`);
|
|
361
|
+
await waitClosed(wrapped, signal);
|
|
362
|
+
channels.delete(handle);
|
|
363
|
+
await handle.close().catch(() => {
|
|
364
|
+
});
|
|
365
|
+
const remaining = (onlineCounts.get(ctx.fingerprint) ?? 1) - 1;
|
|
366
|
+
if (remaining > 0) onlineCounts.set(ctx.fingerprint, remaining);
|
|
367
|
+
else onlineCounts.delete(ctx.fingerprint);
|
|
368
|
+
log(`[pairing] channel closed (${ctx.mode}) for ${ctx.fingerprint}`);
|
|
369
|
+
if (spec.singleUse) break;
|
|
370
|
+
} finally {
|
|
371
|
+
signal.removeEventListener("abort", onAbort);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function createOffer(input = {}) {
|
|
376
|
+
await ensureLoaded();
|
|
377
|
+
let rendezvousUrl;
|
|
378
|
+
let rendezvousIsHostedDefault = false;
|
|
379
|
+
if (input.rendezvousUrl) {
|
|
380
|
+
rendezvousUrl = input.rendezvousUrl;
|
|
381
|
+
} else if (deps.defaultRendezvousUrl !== void 0) {
|
|
382
|
+
if (deps.defaultRendezvousUrl === "") {
|
|
383
|
+
throw new PairingError(
|
|
384
|
+
"malformed_offer",
|
|
385
|
+
'rendezvous disabled \u2014 pairing.rendezvous is set to "" (explicit opt-out); pass --rendezvous to route this offer through a specific broker'
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
rendezvousUrl = deps.defaultRendezvousUrl;
|
|
389
|
+
} else {
|
|
390
|
+
rendezvousUrl = HOSTED_RENDEZVOUS_URL;
|
|
391
|
+
rendezvousIsHostedDefault = true;
|
|
392
|
+
}
|
|
393
|
+
const identity = await deps.loadIdentity();
|
|
394
|
+
const secret = b64url(randomBytes(16));
|
|
395
|
+
const { route, auth } = await deriveOfferTokens(secret);
|
|
396
|
+
const ttlMs = input.ttlMs ?? DEFAULT_TTL_MS;
|
|
397
|
+
const exp = Math.floor((now() + ttlMs) / 1e3);
|
|
398
|
+
const scope = input.scope;
|
|
399
|
+
offers.set(route, { route, auth, exp, spent: false, rendezvousUrl, ...scope ? { scope } : {} });
|
|
400
|
+
const fingerprint = await identityFingerprint(identity.x25519.pub);
|
|
401
|
+
const url = encodeOfferUrl({
|
|
402
|
+
v: OFFER_VERSION,
|
|
403
|
+
rendezvousUrl,
|
|
404
|
+
fingerprint,
|
|
405
|
+
daemonX25519Pub: identity.x25519.pub,
|
|
406
|
+
daemonEd25519Pub: identity.ed25519.pub,
|
|
407
|
+
secret,
|
|
408
|
+
exp,
|
|
409
|
+
...scope ? { scope } : {}
|
|
410
|
+
});
|
|
411
|
+
startOfferLoop({ route, auth }, rendezvousUrl);
|
|
412
|
+
log(
|
|
413
|
+
`[pairing] offer minted (exp ${new Date(exp * 1e3).toISOString()}) via ${rendezvousUrl}` + (rendezvousIsHostedDefault ? " (hosted default)" : "") + (scope === "host" ? " [HOST-SCOPED]" : "")
|
|
414
|
+
);
|
|
415
|
+
return { secret, exp, url, fingerprint, rendezvousUrl, rendezvousIsHostedDefault, ...scope ? { scope } : {} };
|
|
416
|
+
}
|
|
417
|
+
function offerValid(route) {
|
|
418
|
+
const entry = offers.get(route);
|
|
419
|
+
return !!entry && !entry.spent && entry.exp * 1e3 > now();
|
|
420
|
+
}
|
|
421
|
+
function startOfferLoop(tokens, rendezvousUrl) {
|
|
422
|
+
const { route } = tokens;
|
|
423
|
+
startLoop({
|
|
424
|
+
key: `offer:${route}`,
|
|
425
|
+
rendezvousUrl,
|
|
426
|
+
tokens: () => tokens,
|
|
427
|
+
shouldContinue: () => offerValid(route),
|
|
428
|
+
singleUse: true,
|
|
429
|
+
verify: (presented) => {
|
|
430
|
+
const entry = offers.get(route);
|
|
431
|
+
if (!entry || entry.spent || entry.exp * 1e3 <= now()) return false;
|
|
432
|
+
if (!constantTimeEqual(presented, entry.auth)) return false;
|
|
433
|
+
entry.spent = true;
|
|
434
|
+
return true;
|
|
435
|
+
},
|
|
436
|
+
onPaired: async (session, hello) => {
|
|
437
|
+
const pairRoot = await derivePairRoot(session);
|
|
438
|
+
const fingerprint = session.peerFingerprint;
|
|
439
|
+
const nowIso = new Date(now()).toISOString();
|
|
440
|
+
const entry = offers.get(route);
|
|
441
|
+
const record = {
|
|
442
|
+
clientPub: hello.ePub,
|
|
443
|
+
name: session.clientName ?? fingerprint,
|
|
444
|
+
fingerprint,
|
|
445
|
+
createdAt: pairings.get(fingerprint)?.createdAt ?? nowIso,
|
|
446
|
+
lastSeen: nowIso,
|
|
447
|
+
pairRoot,
|
|
448
|
+
rendezvousUrl,
|
|
449
|
+
...entry?.scope ? { scope: entry.scope } : {}
|
|
450
|
+
};
|
|
451
|
+
pairings.set(fingerprint, record);
|
|
452
|
+
revoked.delete(fingerprint);
|
|
453
|
+
await persist();
|
|
454
|
+
startReconnectLoops(record);
|
|
455
|
+
return { mode: "offer", fingerprint, name: record.name, ...record.scope ? { scope: record.scope } : {} };
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function startReconnectLoops(record) {
|
|
460
|
+
for (const slot of ["cur", "prev"]) {
|
|
461
|
+
const epochOf = () => slot === "cur" ? currentEpoch(now()) : currentEpoch(now()) - 1;
|
|
462
|
+
startLoop({
|
|
463
|
+
key: `pair:${record.fingerprint}:${slot}`,
|
|
464
|
+
rendezvousUrl: record.rendezvousUrl,
|
|
465
|
+
tokens: () => deriveEpochTokens(record.pairRoot, epochOf()),
|
|
466
|
+
shouldContinue: () => pairings.has(record.fingerprint),
|
|
467
|
+
singleUse: false,
|
|
468
|
+
// A legacy (v1) pairing is parked on only so its client can be told to
|
|
469
|
+
// re-pair; it never authenticates, whatever it presents.
|
|
470
|
+
verify: (presented, expected) => !record.legacy && constantTimeEqual(presented, expected.auth),
|
|
471
|
+
onPaired: async (session, _hello) => {
|
|
472
|
+
const existing = pairings.get(record.fingerprint);
|
|
473
|
+
if (existing) {
|
|
474
|
+
existing.lastSeen = new Date(now()).toISOString();
|
|
475
|
+
await persist().catch((err) => log(`[pairing] lastSeen persist failed: ${errMsg(err)}`));
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
mode: "reconnect",
|
|
479
|
+
fingerprint: record.fingerprint,
|
|
480
|
+
name: existing?.name ?? record.name,
|
|
481
|
+
...record.scope ? { scope: record.scope } : {}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function startTombstoneLoops(rec) {
|
|
488
|
+
for (const slot of ["cur", "prev"]) {
|
|
489
|
+
const epochOf = () => slot === "cur" ? currentEpoch(now()) : currentEpoch(now()) - 1;
|
|
490
|
+
const tokensFor = () => rec.routes.find((r) => r.epoch === epochOf());
|
|
491
|
+
startLoop({
|
|
492
|
+
key: `revoked:${rec.fingerprint}:${slot}`,
|
|
493
|
+
rendezvousUrl: rec.rendezvousUrl,
|
|
494
|
+
tokens: () => tokensFor() ?? { route: "", auth: "" },
|
|
495
|
+
shouldContinue: () => revoked.get(rec.fingerprint) === rec && tombstoneLive(rec) && tokensFor() !== void 0,
|
|
496
|
+
singleUse: false,
|
|
497
|
+
// The AUTH token, never the route: the broker knows every route, and a
|
|
498
|
+
// tombstone must not become a way for it to probe revocation state.
|
|
499
|
+
verify: (presented, expected) => expected.auth !== "" && constantTimeEqual(presented, expected.auth),
|
|
500
|
+
reject: revokedFrame(),
|
|
501
|
+
onPaired: async () => {
|
|
502
|
+
throw new Error("unreachable: a tombstone never serves");
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function tombstoneLive(rec) {
|
|
508
|
+
return Date.parse(rec.expiresAt) > now();
|
|
509
|
+
}
|
|
510
|
+
async function startAutoconnect() {
|
|
511
|
+
await ensureLoaded();
|
|
512
|
+
for (const record of pairings.values()) {
|
|
513
|
+
startReconnectLoops(record);
|
|
514
|
+
}
|
|
515
|
+
for (const rec of revoked.values()) {
|
|
516
|
+
startTombstoneLoops(rec);
|
|
517
|
+
}
|
|
518
|
+
if (pairings.size > 0) {
|
|
519
|
+
log(`[pairing] autoconnect: standing connections for ${pairings.size} pairing(s)`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function revoke(idOrName) {
|
|
523
|
+
await ensureLoaded();
|
|
524
|
+
let target = pairings.get(idOrName);
|
|
525
|
+
if (!target) {
|
|
526
|
+
for (const rec of pairings.values()) {
|
|
527
|
+
if (rec.name === idOrName) {
|
|
528
|
+
target = rec;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (!target) {
|
|
534
|
+
await refreshLocalDevices();
|
|
535
|
+
const device = findLocalDevice(idOrName);
|
|
536
|
+
if (!device) return false;
|
|
537
|
+
await mutateLocal(() => void localDevices.delete(device.fingerprint));
|
|
538
|
+
log(`[pairing] revoked local device ${device.fingerprint} (${device.name})`);
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
pairings.delete(target.fingerprint);
|
|
542
|
+
let tombstone = null;
|
|
543
|
+
if (revokedGraceMs > 0 && !target.legacy) {
|
|
544
|
+
const at = now();
|
|
545
|
+
const expiresAt = at + revokedGraceMs;
|
|
546
|
+
const routes = [];
|
|
547
|
+
for (let e = currentEpoch(at) - 1; e <= currentEpoch(expiresAt); e++) {
|
|
548
|
+
routes.push({ epoch: e, ...await deriveEpochTokens(target.pairRoot, e) });
|
|
549
|
+
}
|
|
550
|
+
tombstone = {
|
|
551
|
+
fingerprint: target.fingerprint,
|
|
552
|
+
name: target.name,
|
|
553
|
+
rendezvousUrl: target.rendezvousUrl,
|
|
554
|
+
revokedAt: new Date(at).toISOString(),
|
|
555
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
556
|
+
routes
|
|
557
|
+
};
|
|
558
|
+
revoked.set(target.fingerprint, tombstone);
|
|
559
|
+
}
|
|
560
|
+
await persist();
|
|
561
|
+
await stopLoop(`pair:${target.fingerprint}:cur`, REVOKED);
|
|
562
|
+
await stopLoop(`pair:${target.fingerprint}:prev`, REVOKED);
|
|
563
|
+
if (tombstone) startTombstoneLoops(tombstone);
|
|
564
|
+
log(`[pairing] revoked ${target.fingerprint} (${target.name})`);
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
async function list() {
|
|
568
|
+
await ensureLoaded();
|
|
569
|
+
await refreshLocalDevices();
|
|
570
|
+
return [
|
|
571
|
+
...Array.from(pairings.values()).map((r) => ({ ...r })),
|
|
572
|
+
...Array.from(localDevices.values()).map(localDeviceView)
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
function findLocalDevice(idOrName) {
|
|
576
|
+
const byFp = localDevices.get(idOrName);
|
|
577
|
+
if (byFp) return byFp;
|
|
578
|
+
for (const rec of localDevices.values()) if (rec.name === idOrName) return rec;
|
|
579
|
+
return void 0;
|
|
580
|
+
}
|
|
581
|
+
function localDeviceView(rec) {
|
|
582
|
+
return {
|
|
583
|
+
clientPub: "",
|
|
584
|
+
name: rec.name,
|
|
585
|
+
fingerprint: rec.fingerprint,
|
|
586
|
+
createdAt: rec.createdAt,
|
|
587
|
+
lastSeen: rec.createdAt,
|
|
588
|
+
pairRoot: "",
|
|
589
|
+
rendezvousUrl: "",
|
|
590
|
+
local: true
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async function mintLocalDevice(input) {
|
|
594
|
+
const name = input.name.trim();
|
|
595
|
+
if (!name) throw new Error("device name must not be empty");
|
|
596
|
+
await ensureLoaded();
|
|
597
|
+
const rec = newLocalDevice(name, new Date(now()).toISOString());
|
|
598
|
+
await mutateLocal(() => void localDevices.set(rec.fingerprint, rec));
|
|
599
|
+
log(`[pairing] minted local device ${rec.fingerprint} (${rec.name})`);
|
|
600
|
+
return {
|
|
601
|
+
bearer: deriveLocalBearer(rec),
|
|
602
|
+
fingerprint: rec.fingerprint,
|
|
603
|
+
name: rec.name,
|
|
604
|
+
createdAt: rec.createdAt
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
async function verifyDeviceBearer(bearer) {
|
|
608
|
+
await ensureLoaded();
|
|
609
|
+
await refreshLocalDevices();
|
|
610
|
+
const parsed = parseLocalBearer(bearer);
|
|
611
|
+
const rec = parsed ? localDevices.get(parsed.fingerprint) : void 0;
|
|
612
|
+
return verifyLocalBearer(rec, parsed) && rec ? { fingerprint: rec.fingerprint, name: rec.name } : null;
|
|
613
|
+
}
|
|
614
|
+
async function renamePairing(idOrName, newName) {
|
|
615
|
+
const trimmed = newName.trim();
|
|
616
|
+
if (!trimmed) throw new Error("device name must not be empty");
|
|
617
|
+
await ensureLoaded();
|
|
618
|
+
let target = pairings.get(idOrName);
|
|
619
|
+
if (!target) {
|
|
620
|
+
for (const rec of pairings.values()) {
|
|
621
|
+
if (rec.name === idOrName) {
|
|
622
|
+
target = rec;
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!target) {
|
|
628
|
+
await refreshLocalDevices();
|
|
629
|
+
const device = findLocalDevice(idOrName);
|
|
630
|
+
if (!device) return false;
|
|
631
|
+
await mutateLocal(() => {
|
|
632
|
+
const current = localDevices.get(device.fingerprint);
|
|
633
|
+
if (current) current.name = trimmed;
|
|
634
|
+
});
|
|
635
|
+
log(`[pairing] renamed ${device.fingerprint} to "${trimmed}"`);
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
target.name = trimmed;
|
|
639
|
+
await persist();
|
|
640
|
+
log(`[pairing] renamed ${target.fingerprint} to "${trimmed}"`);
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
function isOnline(fingerprint) {
|
|
644
|
+
return (onlineCounts.get(fingerprint) ?? 0) > 0;
|
|
645
|
+
}
|
|
646
|
+
async function shutdown() {
|
|
647
|
+
if (shuttingDown) return;
|
|
648
|
+
shuttingDown = true;
|
|
649
|
+
for (const key of Array.from(loops.keys())) {
|
|
650
|
+
await stopLoop(key);
|
|
651
|
+
}
|
|
652
|
+
for (const handle of Array.from(channels)) {
|
|
653
|
+
await handle.close().catch(() => {
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
channels.clear();
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
createOffer,
|
|
660
|
+
list,
|
|
661
|
+
rename: renamePairing,
|
|
662
|
+
revoke,
|
|
663
|
+
isOnline,
|
|
664
|
+
startAutoconnect,
|
|
665
|
+
shutdown,
|
|
666
|
+
mintLocalDevice,
|
|
667
|
+
verifyDeviceBearer
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function dialUrl(rendezvousUrl, token) {
|
|
671
|
+
const sep = rendezvousUrl.includes("?") ? "&" : "?";
|
|
672
|
+
return `${rendezvousUrl}${sep}side=daemon&t=${encodeURIComponent(token)}`;
|
|
673
|
+
}
|
|
674
|
+
function sendOutdatedNotice(sink) {
|
|
675
|
+
sink.send({ t: "error", code: "pairing_protocol_outdated", message: PAIRING_PROTOCOL_OUTDATED_MESSAGE });
|
|
676
|
+
sink.send({
|
|
677
|
+
t: "hello",
|
|
678
|
+
// Deliberately not TUNNEL_VERSION: a v1 client reports an unknown version
|
|
679
|
+
// verbatim, which is how the notice reaches its user (see
|
|
680
|
+
// OUTDATED_HELLO_VERSION).
|
|
681
|
+
version: OUTDATED_HELLO_VERSION,
|
|
682
|
+
capabilities: { pty: false },
|
|
683
|
+
label: "pairing_protocol_outdated"
|
|
684
|
+
});
|
|
685
|
+
sink.close("pairing_protocol_outdated");
|
|
686
|
+
}
|
|
687
|
+
var REVOKED = /* @__PURE__ */ Symbol("pairing revoked");
|
|
688
|
+
function waitClosed(sink, signal) {
|
|
689
|
+
return new Promise((resolve) => {
|
|
690
|
+
if (!sink.isOpen) {
|
|
691
|
+
resolve();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
let settled = false;
|
|
695
|
+
const finish = () => {
|
|
696
|
+
if (settled) return;
|
|
697
|
+
settled = true;
|
|
698
|
+
resolve();
|
|
699
|
+
};
|
|
700
|
+
sink.onClose(() => finish());
|
|
701
|
+
signal.addEventListener("abort", () => {
|
|
702
|
+
if (signal.reason === REVOKED) {
|
|
703
|
+
sink.send(revokedFrame());
|
|
704
|
+
sink.close(PAIRING_REVOKED_CODE);
|
|
705
|
+
} else {
|
|
706
|
+
sink.close("registry shutdown");
|
|
707
|
+
}
|
|
708
|
+
finish();
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
function sleep(ms, signal) {
|
|
713
|
+
return new Promise((resolve) => {
|
|
714
|
+
if (signal.aborted) return resolve();
|
|
715
|
+
const timer = setTimeout(resolve, ms);
|
|
716
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
717
|
+
signal.addEventListener("abort", () => {
|
|
718
|
+
clearTimeout(timer);
|
|
719
|
+
resolve();
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
function isPairingsFile(v) {
|
|
724
|
+
if (typeof v !== "object" || v === null) return false;
|
|
725
|
+
const rec = v;
|
|
726
|
+
if (rec["v"] !== PAIRINGS_VERSION && rec["v"] !== LEGACY_PAIRINGS_VERSION) return false;
|
|
727
|
+
if (!Array.isArray(rec["pairings"])) return false;
|
|
728
|
+
return rec["pairings"].every(isPairingRecord);
|
|
729
|
+
}
|
|
730
|
+
function isPairingRecord(v) {
|
|
731
|
+
if (typeof v !== "object" || v === null) return false;
|
|
732
|
+
const r = v;
|
|
733
|
+
return typeof r["clientPub"] === "string" && typeof r["name"] === "string" && typeof r["fingerprint"] === "string" && typeof r["createdAt"] === "string" && typeof r["lastSeen"] === "string" && typeof r["pairRoot"] === "string" && typeof r["rendezvousUrl"] === "string";
|
|
734
|
+
}
|
|
735
|
+
function isRevokedRecord(v) {
|
|
736
|
+
if (typeof v !== "object" || v === null) return false;
|
|
737
|
+
const r = v;
|
|
738
|
+
return typeof r["fingerprint"] === "string" && typeof r["name"] === "string" && typeof r["rendezvousUrl"] === "string" && typeof r["revokedAt"] === "string" && typeof r["expiresAt"] === "string" && Array.isArray(r["routes"]) && r["routes"].every((x) => {
|
|
739
|
+
if (typeof x !== "object" || x === null) return false;
|
|
740
|
+
const t = x;
|
|
741
|
+
return typeof t["epoch"] === "number" && typeof t["route"] === "string" && typeof t["auth"] === "string";
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function isEnoent(err) {
|
|
745
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
746
|
+
}
|
|
747
|
+
function errMsg(err) {
|
|
748
|
+
return err instanceof Error ? err.message : String(err);
|
|
749
|
+
}
|
|
750
|
+
async function dialRendezvous(url, signal, opts = {}) {
|
|
751
|
+
const ws = new WebSocket(url, opts.agent ? { agent: opts.agent } : void 0);
|
|
752
|
+
await new Promise((resolve, reject) => {
|
|
753
|
+
const cleanup = () => {
|
|
754
|
+
ws.off("open", onOpen);
|
|
755
|
+
ws.off("error", onError);
|
|
756
|
+
signal.removeEventListener("abort", onAbort);
|
|
757
|
+
};
|
|
758
|
+
const onOpen = () => {
|
|
759
|
+
cleanup();
|
|
760
|
+
resolve();
|
|
761
|
+
};
|
|
762
|
+
const onError = (err) => {
|
|
763
|
+
cleanup();
|
|
764
|
+
reject(err);
|
|
765
|
+
};
|
|
766
|
+
const onAbort = () => {
|
|
767
|
+
cleanup();
|
|
768
|
+
try {
|
|
769
|
+
ws.close();
|
|
770
|
+
} catch {
|
|
771
|
+
}
|
|
772
|
+
reject(new Error("dial aborted"));
|
|
773
|
+
};
|
|
774
|
+
if (signal.aborted) {
|
|
775
|
+
onAbort();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
ws.once("open", onOpen);
|
|
779
|
+
ws.once("error", onError);
|
|
780
|
+
signal.addEventListener("abort", onAbort);
|
|
781
|
+
});
|
|
782
|
+
return wrapWebSocket(ws);
|
|
783
|
+
}
|
|
784
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
785
|
+
function isLoopback(url) {
|
|
786
|
+
return LOOPBACK_HOSTS.has(url.hostname) || /^127\.\d+\.\d+\.\d+$/.test(url.hostname);
|
|
787
|
+
}
|
|
788
|
+
function safePathname(path) {
|
|
789
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return null;
|
|
790
|
+
if (/%2e|%2f|%5c/i.test(path)) return null;
|
|
791
|
+
const end = path.search(/[?#]/);
|
|
792
|
+
return end === -1 ? path : path.slice(0, end);
|
|
793
|
+
}
|
|
794
|
+
function pathAllowed(pathname, allow) {
|
|
795
|
+
return allow.some((rule) => {
|
|
796
|
+
if (rule instanceof RegExp) {
|
|
797
|
+
rule.lastIndex = 0;
|
|
798
|
+
return rule.test(pathname);
|
|
799
|
+
}
|
|
800
|
+
if (rule.endsWith("/*")) {
|
|
801
|
+
const base = rule.slice(0, -2);
|
|
802
|
+
return pathname === base || pathname.startsWith(`${base}/`);
|
|
803
|
+
}
|
|
804
|
+
return pathname === rule;
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
function serveLoopbackHttp(opts) {
|
|
808
|
+
const target = typeof opts.target === "string" ? new URL(opts.target) : opts.target;
|
|
809
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
810
|
+
throw new Error(`serveLoopbackHttp: target must be http(s), got ${target.protocol}`);
|
|
811
|
+
}
|
|
812
|
+
if (!isLoopback(target)) {
|
|
813
|
+
throw new Error(`serveLoopbackHttp: target must be a loopback address, got ${target.hostname}`);
|
|
814
|
+
}
|
|
815
|
+
const httpUpstream = target.origin + target.pathname.replace(/\/$/, "");
|
|
816
|
+
const allow = opts.allowPaths;
|
|
817
|
+
return (sink) => {
|
|
818
|
+
const guarded = allow ? guardSink(sink, allow) : sink;
|
|
819
|
+
const server = createTunnelServer({
|
|
820
|
+
sink: guarded,
|
|
821
|
+
label: opts.label ?? "loopback-http",
|
|
822
|
+
pty: false,
|
|
823
|
+
authorize: () => null,
|
|
824
|
+
httpUpstream,
|
|
825
|
+
...opts.injectHeaders && Object.keys(opts.injectHeaders).length > 0 ? { httpInjectHeaders: opts.injectHeaders } : {},
|
|
826
|
+
httpForwardTimeoutMs: opts.httpForwardTimeoutMs ?? DEFAULT_HTTP_FORWARD_TIMEOUT_MS,
|
|
827
|
+
httpStreamIdleTimeoutMs: 12e4
|
|
828
|
+
});
|
|
829
|
+
return { close: () => server.close() };
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function guardSink(sink, allow) {
|
|
833
|
+
const denied = /* @__PURE__ */ new Set();
|
|
834
|
+
const deny = (reqId, path) => {
|
|
835
|
+
denied.add(reqId);
|
|
836
|
+
sink.send({
|
|
837
|
+
t: "http_response",
|
|
838
|
+
reqId,
|
|
839
|
+
status: 403,
|
|
840
|
+
error: { code: "path_not_allowed", message: `path '${path}' is not allowed on this host` }
|
|
841
|
+
});
|
|
842
|
+
};
|
|
843
|
+
return {
|
|
844
|
+
send: (frame) => sink.send(frame),
|
|
845
|
+
close: (reason) => sink.close(reason),
|
|
846
|
+
onClose: (handler) => sink.onClose(handler),
|
|
847
|
+
get isOpen() {
|
|
848
|
+
return sink.isOpen;
|
|
849
|
+
},
|
|
850
|
+
get sentCount() {
|
|
851
|
+
return sink.sentCount;
|
|
852
|
+
},
|
|
853
|
+
get recvCount() {
|
|
854
|
+
return sink.recvCount;
|
|
855
|
+
},
|
|
856
|
+
onFrame: (handler) => sink.onFrame((frame) => {
|
|
857
|
+
if (frame.t === "http_request") {
|
|
858
|
+
const pathname = safePathname(frame.path);
|
|
859
|
+
if (pathname === null || !pathAllowed(pathname, allow)) {
|
|
860
|
+
deny(frame.reqId, pathname ?? "(invalid)");
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
} else if (frame.t === "http_request_chunk" && denied.has(frame.reqId)) {
|
|
864
|
+
if (frame.end) denied.delete(frame.reqId);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
handler(frame);
|
|
868
|
+
})
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export { PAIRINGS_VERSION, PAIRING_REVOKED_CODE, createPairingRegistry, createReconnectLogGate, dialRendezvous, readPairingsSnapshot, serveLoopbackHttp };
|
|
873
|
+
//# sourceMappingURL=index.mjs.map
|
|
874
|
+
//# sourceMappingURL=index.mjs.map
|