agentgather 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 +21 -0
- package/README.md +418 -0
- package/SECURITY.md +104 -0
- package/dist/src/auth/index.js +1 -0
- package/dist/src/auth/tokens.js +12 -0
- package/dist/src/browser/room.css +666 -0
- package/dist/src/browser/room.html +80 -0
- package/dist/src/browser/room.js +435 -0
- package/dist/src/cli/args.js +29 -0
- package/dist/src/cli/commands/attend/index.js +26 -0
- package/dist/src/cli/commands/broker/index.js +61 -0
- package/dist/src/cli/commands/doctor/index.js +93 -0
- package/dist/src/cli/commands/export/index.js +42 -0
- package/dist/src/cli/commands/handoff/index.js +41 -0
- package/dist/src/cli/commands/instructions/index.js +7 -0
- package/dist/src/cli/commands/message/index.js +50 -0
- package/dist/src/cli/commands/message/transport.js +108 -0
- package/dist/src/cli/commands/room/index.js +350 -0
- package/dist/src/cli/commands/tunnel/index.js +131 -0
- package/dist/src/cli/commands/watch/index.js +16 -0
- package/dist/src/cli/context.js +9 -0
- package/dist/src/cli/help.js +53 -0
- package/dist/src/cli/index.js +63 -0
- package/dist/src/cli/state.js +40 -0
- package/dist/src/protocol/attendance.js +20 -0
- package/dist/src/protocol/index.js +7 -0
- package/dist/src/protocol/instructions.js +29 -0
- package/dist/src/protocol/mentions.js +48 -0
- package/dist/src/protocol/messages.js +71 -0
- package/dist/src/protocol/types.js +1 -0
- package/dist/src/protocol/urls.js +9 -0
- package/dist/src/protocol/validation.js +21 -0
- package/dist/src/server/errors.js +12 -0
- package/dist/src/server/http.js +583 -0
- package/dist/src/server/index.js +2 -0
- package/dist/src/server/wait.js +44 -0
- package/dist/src/storage/index.js +4 -0
- package/dist/src/storage/lock.js +93 -0
- package/dist/src/storage/paths.js +18 -0
- package/dist/src/storage/room-store.js +302 -0
- package/dist/src/storage/secure-fs.js +28 -0
- package/dist/src/tunnel/broker.js +440 -0
- package/dist/src/tunnel/client.js +144 -0
- package/dist/src/tunnel/forwarding.js +176 -0
- package/dist/src/tunnel/host-session.js +133 -0
- package/dist/src/tunnel/index.js +8 -0
- package/dist/src/tunnel/limits.js +81 -0
- package/dist/src/tunnel/logging.js +70 -0
- package/dist/src/tunnel/protocol.js +46 -0
- package/dist/src/tunnel/relay.js +106 -0
- package/docs/FOUNDING-TICKETS.md +759 -0
- package/docs/PROPOSAL.md +2120 -0
- package/docs/agentgather-dev-deployment-guide.md +305 -0
- package/docs/agentgather-dev-tunnel-architecture.md +349 -0
- package/docs/deploy-rooms-agentgather-dev.md +152 -0
- package/docs/dogfood/release-dogfood.md +61 -0
- package/docs/dogfood/sanitized-room-log.jsonl +6 -0
- package/docs/host-guide.md +282 -0
- package/docs/operator-runbook.md +248 -0
- package/docs/remote-exposure.md +269 -0
- package/docs/room-brief-and-attend-card.md +110 -0
- package/package.json +49 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Local tunnel broker harness.
|
|
2
|
+
//
|
|
3
|
+
// The broker registers a single host route per slug and exposes a local HTTP
|
|
4
|
+
// listener that resolves participant requests to route status. It does not
|
|
5
|
+
// forward real room endpoints yet (that is ticket #36) and never opens a public
|
|
6
|
+
// network connection. The only state it keeps is ephemeral RouteMetadata.
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { URL } from "node:url";
|
|
10
|
+
import { TunnelError } from "./protocol.js";
|
|
11
|
+
import { forwardToHost } from "./forwarding.js";
|
|
12
|
+
import { BROKER_LIMITS, BrokerGuards } from "./limits.js";
|
|
13
|
+
import { BrokerLogger, classifyPath, routeHash } from "./logging.js";
|
|
14
|
+
import { RelayHub } from "./relay.js";
|
|
15
|
+
const DEFAULT_CLAIM_TIMEOUT_MS = 10_000;
|
|
16
|
+
const DEFAULT_RESPONSE_TIMEOUT_MS = 35_000;
|
|
17
|
+
const DEFAULT_ROUTE_TTL_MS = BROKER_LIMITS.idleTimeoutMs;
|
|
18
|
+
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
19
|
+
const LOCAL_TARGET_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
20
|
+
/**
|
|
21
|
+
* Validate a forwarding target before the broker will store and fetch it. This
|
|
22
|
+
* ticket is local-only, so the target must be a loopback http(s) URL. Rejecting
|
|
23
|
+
* non-local hosts blocks server-side request forgery to internal-network or
|
|
24
|
+
* cloud-metadata addresses through the unauthenticated register endpoint.
|
|
25
|
+
* Authenticated host registration and egress allowlists for non-local targets
|
|
26
|
+
* are deferred to the #37 hardening ticket.
|
|
27
|
+
*/
|
|
28
|
+
function assertLocalTarget(target) {
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(target);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new TunnelError("invalid_registration", 400, "target is not a valid URL");
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
37
|
+
throw new TunnelError("invalid_registration", 400, "target must use http or https");
|
|
38
|
+
}
|
|
39
|
+
if (!LOCAL_TARGET_HOSTS.has(url.hostname)) {
|
|
40
|
+
throw new TunnelError("invalid_registration", 400, "target must be a local address");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* In-memory tunnel broker. One active route per slug; routes expire after a TTL
|
|
45
|
+
* unless refreshed by a heartbeat, and can be closed explicitly by the host.
|
|
46
|
+
*/
|
|
47
|
+
export class TunnelBroker {
|
|
48
|
+
now;
|
|
49
|
+
routeTtlMs;
|
|
50
|
+
maxRouteLifetimeMs;
|
|
51
|
+
limits;
|
|
52
|
+
guards;
|
|
53
|
+
logger;
|
|
54
|
+
relay;
|
|
55
|
+
routes = new Map();
|
|
56
|
+
// Optional local room server URL per slug for direct-fetch mode (local tests).
|
|
57
|
+
// Managed relay mode registers no target and never stores one.
|
|
58
|
+
targets = new Map();
|
|
59
|
+
constructor(options = {}) {
|
|
60
|
+
this.now = options.now ?? (() => Date.now());
|
|
61
|
+
this.limits = { ...BROKER_LIMITS, ...options.limits };
|
|
62
|
+
this.routeTtlMs = options.routeTtlMs ?? this.limits.idleTimeoutMs;
|
|
63
|
+
this.maxRouteLifetimeMs = options.maxRouteLifetimeMs ?? this.limits.maxRouteLifetimeMs;
|
|
64
|
+
this.guards = new BrokerGuards(this.now, this.limits);
|
|
65
|
+
this.logger = new BrokerLogger(options.logSink);
|
|
66
|
+
this.relay = new RelayHub({
|
|
67
|
+
claimTimeoutMs: options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS,
|
|
68
|
+
responseTimeoutMs: options.responseTimeoutMs ?? DEFAULT_RESPONSE_TIMEOUT_MS,
|
|
69
|
+
responseBodyBytes: this.limits.responseBodyBytes
|
|
70
|
+
}, () => mintId("req"));
|
|
71
|
+
}
|
|
72
|
+
/** Register a route for a slug. Rejects a duplicate active slug. */
|
|
73
|
+
register(registration) {
|
|
74
|
+
const slug = registration.route_slug;
|
|
75
|
+
if (typeof slug !== "string" || !SLUG_PATTERN.test(slug)) {
|
|
76
|
+
throw new TunnelError("invalid_registration", 400, "route slug is missing or malformed");
|
|
77
|
+
}
|
|
78
|
+
if (registration.target !== undefined)
|
|
79
|
+
assertLocalTarget(registration.target);
|
|
80
|
+
const existing = this.currentRoute(slug);
|
|
81
|
+
if (existing && existing.status === "active") {
|
|
82
|
+
throw new TunnelError("route_slug_taken", 409, "an active route already exists for this slug");
|
|
83
|
+
}
|
|
84
|
+
const nowMs = this.now();
|
|
85
|
+
const route = {
|
|
86
|
+
route_slug: slug,
|
|
87
|
+
route_id: mintId("rte"),
|
|
88
|
+
host_connection_id: mintId("conn"),
|
|
89
|
+
created_at: isoFrom(nowMs),
|
|
90
|
+
last_seen_at: isoFrom(nowMs),
|
|
91
|
+
expires_at: isoFrom(nowMs + this.routeTtlMs),
|
|
92
|
+
status: "active"
|
|
93
|
+
};
|
|
94
|
+
this.routes.set(slug, route);
|
|
95
|
+
// Always re-sync the target so a re-registration without a target cannot
|
|
96
|
+
// inherit a previous route's forwarding host.
|
|
97
|
+
if (registration.target !== undefined) {
|
|
98
|
+
this.targets.set(slug, registration.target);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this.targets.delete(slug);
|
|
102
|
+
}
|
|
103
|
+
return { ...route };
|
|
104
|
+
}
|
|
105
|
+
/** Local room server URL a slug forwards to, if one was registered. */
|
|
106
|
+
target(slug) {
|
|
107
|
+
return this.targets.get(slug);
|
|
108
|
+
}
|
|
109
|
+
/** Refresh an active route's last-seen and expiry timestamps. */
|
|
110
|
+
heartbeat(beat) {
|
|
111
|
+
if (typeof beat.route_id !== "string" || typeof beat.host_connection_id !== "string") {
|
|
112
|
+
throw new TunnelError("invalid_heartbeat", 400, "heartbeat is missing route identifiers");
|
|
113
|
+
}
|
|
114
|
+
const route = this.findByConnection(beat.route_id, beat.host_connection_id);
|
|
115
|
+
if (route.status === "closed") {
|
|
116
|
+
throw new TunnelError("route_closed", 410, "this route has been closed");
|
|
117
|
+
}
|
|
118
|
+
if (route.status === "expired") {
|
|
119
|
+
throw new TunnelError("route_expired", 410, "this route has expired");
|
|
120
|
+
}
|
|
121
|
+
const nowMs = this.now();
|
|
122
|
+
route.last_seen_at = isoFrom(nowMs);
|
|
123
|
+
route.expires_at = isoFrom(nowMs + this.routeTtlMs);
|
|
124
|
+
return { ...route };
|
|
125
|
+
}
|
|
126
|
+
/** Close a route. Idempotent identifiers must match the registered route. */
|
|
127
|
+
closeRoute(request) {
|
|
128
|
+
const route = this.findByConnection(request.route_id, request.host_connection_id);
|
|
129
|
+
route.status = "closed";
|
|
130
|
+
this.targets.delete(route.route_slug);
|
|
131
|
+
this.relay.closeRoute(route.route_slug);
|
|
132
|
+
return { ok: true, route_slug: route.route_slug, status: "closed" };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a participant-facing slug to its active route, or throw a stable
|
|
136
|
+
* tunnel error. Returns a copy so callers cannot mutate stored metadata.
|
|
137
|
+
*/
|
|
138
|
+
resolve(slug) {
|
|
139
|
+
const route = this.currentRoute(slug);
|
|
140
|
+
if (!route) {
|
|
141
|
+
throw new TunnelError("route_not_found", 404, "no route is registered for this slug");
|
|
142
|
+
}
|
|
143
|
+
if (route.status === "closed") {
|
|
144
|
+
throw new TunnelError("route_closed", 410, "this route has been closed");
|
|
145
|
+
}
|
|
146
|
+
if (route.status === "expired") {
|
|
147
|
+
throw new TunnelError("route_expired", 410, "this route has expired");
|
|
148
|
+
}
|
|
149
|
+
return { ...route };
|
|
150
|
+
}
|
|
151
|
+
/** Copy of all stored route metadata, with lazy expiry applied. */
|
|
152
|
+
snapshot() {
|
|
153
|
+
return [...this.routes.values()].map((route) => ({ ...this.refresh(route) }));
|
|
154
|
+
}
|
|
155
|
+
/** Host claims the next pending relay request for its route, or null. */
|
|
156
|
+
claimRelay(routeId, hostConnectionId) {
|
|
157
|
+
const route = this.findByConnection(routeId, hostConnectionId);
|
|
158
|
+
if (route.status !== "active") {
|
|
159
|
+
throw new TunnelError(route.status === "closed" ? "route_closed" : "route_expired", 410, "route is not active");
|
|
160
|
+
}
|
|
161
|
+
this.touch(route.route_slug);
|
|
162
|
+
return this.relay.claim(route.route_slug);
|
|
163
|
+
}
|
|
164
|
+
/** Host posts the response for exactly one in-flight relay request id. */
|
|
165
|
+
respondRelay(routeId, hostConnectionId, requestId, response) {
|
|
166
|
+
this.findByConnection(routeId, hostConnectionId);
|
|
167
|
+
this.relay.respond(requestId, response);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Forward a participant request under `/<slug>/`, applying concurrency and
|
|
171
|
+
* rate guards and emitting a redaction-safe access log. A route registered
|
|
172
|
+
* with a local target is fetched directly (local tests); otherwise the
|
|
173
|
+
* request is held for the host tunnel client to claim and answer. The caller
|
|
174
|
+
* must have resolved the slug as an active route first.
|
|
175
|
+
*/
|
|
176
|
+
async forward(slug, url, req, res) {
|
|
177
|
+
const forwardPath = url.pathname.slice(`/${slug}`.length) || "/";
|
|
178
|
+
const pathClass = classifyPath(forwardPath);
|
|
179
|
+
const isWait = pathClass === "wait";
|
|
180
|
+
const method = req.method ?? "GET";
|
|
181
|
+
const startedAt = this.now();
|
|
182
|
+
let release;
|
|
183
|
+
try {
|
|
184
|
+
release = this.guards.enter({
|
|
185
|
+
routeSlug: slug,
|
|
186
|
+
clientIp: req.socket.remoteAddress ?? "unknown",
|
|
187
|
+
isWait,
|
|
188
|
+
authenticated: typeof req.headers.authorization === "string"
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
this.logger.log({ event: "limit_rejected", route_hash: routeHash(slug), method, path_class: pathClass, error: errorCode(error) });
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
this.touch(slug);
|
|
196
|
+
try {
|
|
197
|
+
const target = this.targets.get(slug);
|
|
198
|
+
const result = target !== undefined
|
|
199
|
+
? await this.forwardDirect(target, forwardPath, url, req, res)
|
|
200
|
+
: await this.forwardViaRelay(slug, forwardPath, url, req, res);
|
|
201
|
+
this.logger.log({
|
|
202
|
+
event: "forward",
|
|
203
|
+
route_hash: routeHash(slug),
|
|
204
|
+
method,
|
|
205
|
+
path_class: pathClass,
|
|
206
|
+
status: result.status,
|
|
207
|
+
duration_ms: this.now() - startedAt,
|
|
208
|
+
bytes_in: result.bytesIn,
|
|
209
|
+
bytes_out: result.bytesOut,
|
|
210
|
+
...(isWait ? { wait_held_ms: this.now() - startedAt } : {})
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
this.logger.log({ event: "forward_error", route_hash: routeHash(slug), method, path_class: pathClass, error: errorCode(error) });
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
release();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async forwardDirect(target, forwardPath, url, req, res) {
|
|
222
|
+
return forwardToHost({
|
|
223
|
+
target,
|
|
224
|
+
path: `${forwardPath}${url.search}`,
|
|
225
|
+
req,
|
|
226
|
+
res,
|
|
227
|
+
requestBodyBytes: this.limits.requestBodyBytes,
|
|
228
|
+
responseBodyBytes: this.limits.responseBodyBytes
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async forwardViaRelay(slug, forwardPath, url, req, res) {
|
|
232
|
+
const body = await readBody(req, this.limits.requestBodyBytes);
|
|
233
|
+
const response = await this.relay.enqueue(slug, {
|
|
234
|
+
route_slug: slug,
|
|
235
|
+
method: req.method ?? "GET",
|
|
236
|
+
path: `${forwardPath}${url.search}`,
|
|
237
|
+
headers: selectEnvelopeHeaders(req),
|
|
238
|
+
...(body !== undefined ? { body_base64: body.toString("base64") } : {})
|
|
239
|
+
});
|
|
240
|
+
const responseBody = typeof response.body_base64 === "string" ? Buffer.from(response.body_base64, "base64") : Buffer.alloc(0);
|
|
241
|
+
const responseHeaders = {};
|
|
242
|
+
const contentType = response.headers["content-type"] ?? response.headers["Content-Type"];
|
|
243
|
+
if (typeof contentType === "string")
|
|
244
|
+
responseHeaders["content-type"] = contentType;
|
|
245
|
+
res.writeHead(response.status, responseHeaders);
|
|
246
|
+
res.end(responseBody);
|
|
247
|
+
return { status: response.status, bytesIn: body?.length ?? 0, bytesOut: responseBody.length };
|
|
248
|
+
}
|
|
249
|
+
touch(slug) {
|
|
250
|
+
const route = this.routes.get(slug);
|
|
251
|
+
if (route && route.status === "active") {
|
|
252
|
+
const nowMs = this.now();
|
|
253
|
+
route.last_seen_at = isoFrom(nowMs);
|
|
254
|
+
route.expires_at = isoFrom(nowMs + this.routeTtlMs);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
currentRoute(slug) {
|
|
258
|
+
const route = this.routes.get(slug);
|
|
259
|
+
return route ? this.refresh(route) : undefined;
|
|
260
|
+
}
|
|
261
|
+
refresh(route) {
|
|
262
|
+
if (route.status === "active") {
|
|
263
|
+
const nowMs = this.now();
|
|
264
|
+
const idleExpired = Date.parse(route.expires_at) <= nowMs;
|
|
265
|
+
const lifetimeExceeded = Date.parse(route.created_at) + this.maxRouteLifetimeMs <= nowMs;
|
|
266
|
+
if (idleExpired || lifetimeExceeded)
|
|
267
|
+
route.status = "expired";
|
|
268
|
+
}
|
|
269
|
+
return route;
|
|
270
|
+
}
|
|
271
|
+
findByConnection(routeId, connectionId) {
|
|
272
|
+
for (const route of this.routes.values()) {
|
|
273
|
+
if (route.route_id === routeId && route.host_connection_id === connectionId) {
|
|
274
|
+
return this.refresh(route);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
throw new TunnelError("route_not_found", 404, "no route matches these identifiers");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Reserved path prefix for host control requests. The slug pattern forbids the
|
|
281
|
+
// underscore, so this prefix can never collide with a participant route slug.
|
|
282
|
+
const HOST_PREFIX = "_host";
|
|
283
|
+
const MAX_HOST_CONTROL_BODY_BYTES = 16_000;
|
|
284
|
+
const MAX_HOST_RESPOND_BODY_BYTES = Math.ceil((BROKER_LIMITS.responseBodyBytes * 4) / 3) + 16_000;
|
|
285
|
+
/**
|
|
286
|
+
* Create a local HTTP listener for the broker. Host control requests use the
|
|
287
|
+
* reserved `/_host/<action>` prefix (register, heartbeat, close); every other
|
|
288
|
+
* path is treated as a participant request whose first segment is the route
|
|
289
|
+
* slug. Active slugs return route status; unknown, expired, or closed slugs
|
|
290
|
+
* return a stable tunnel error. The listener never forwards to a host room
|
|
291
|
+
* server in this ticket (forwarding lands with #36).
|
|
292
|
+
*/
|
|
293
|
+
export function createBrokerHttpServer(broker) {
|
|
294
|
+
return createServer((req, res) => {
|
|
295
|
+
void routeBrokerRequest(broker, req, res);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async function routeBrokerRequest(broker, req, res) {
|
|
299
|
+
try {
|
|
300
|
+
const url = new URL(req.url ?? "/", "http://broker.local");
|
|
301
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
302
|
+
if (segments[0] === HOST_PREFIX) {
|
|
303
|
+
await handleHostRequest(broker, segments[1], req, res);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
await handleParticipantRequest(broker, url, req, res);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
sendTunnelError(res, error);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function handleParticipantRequest(broker, url, req, res) {
|
|
313
|
+
const slug = url.pathname.split("/").filter(Boolean)[0];
|
|
314
|
+
if (slug === undefined) {
|
|
315
|
+
throw new TunnelError("unsupported_route", 404, "request path does not name a route");
|
|
316
|
+
}
|
|
317
|
+
const route = broker.resolve(slug);
|
|
318
|
+
// A bare `/<slug>` with no trailing slash is a route-status probe; anything
|
|
319
|
+
// under `/<slug>/` is forwarded to the host room server.
|
|
320
|
+
if (url.pathname === `/${slug}`) {
|
|
321
|
+
sendJson(res, 200, { ok: true, route_slug: route.route_slug, status: route.status });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
await broker.forward(slug, url, req, res);
|
|
325
|
+
}
|
|
326
|
+
async function handleHostRequest(broker, action, req, res) {
|
|
327
|
+
if (req.method !== "POST") {
|
|
328
|
+
throw new TunnelError("unsupported_route", 405, "host control requires POST");
|
|
329
|
+
}
|
|
330
|
+
const body = await readJsonBody(req, action === "respond" ? MAX_HOST_RESPOND_BODY_BYTES : MAX_HOST_CONTROL_BODY_BYTES);
|
|
331
|
+
if (action === "register") {
|
|
332
|
+
const route = broker.register({
|
|
333
|
+
route_slug: body.route_slug,
|
|
334
|
+
...(typeof body.target === "string" ? { target: body.target } : {})
|
|
335
|
+
});
|
|
336
|
+
sendJson(res, 200, { ok: true, route });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (action === "heartbeat") {
|
|
340
|
+
const route = broker.heartbeat({
|
|
341
|
+
route_id: body.route_id,
|
|
342
|
+
host_connection_id: body.host_connection_id
|
|
343
|
+
});
|
|
344
|
+
sendJson(res, 200, { ok: true, route });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (action === "close") {
|
|
348
|
+
const result = broker.closeRoute({
|
|
349
|
+
route_id: body.route_id,
|
|
350
|
+
host_connection_id: body.host_connection_id
|
|
351
|
+
});
|
|
352
|
+
sendJson(res, 200, result);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (action === "poll") {
|
|
356
|
+
const request = broker.claimRelay(body.route_id, body.host_connection_id);
|
|
357
|
+
sendJson(res, 200, { ok: true, request });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (action === "respond") {
|
|
361
|
+
broker.respondRelay(body.route_id, body.host_connection_id, body.request_id, body.response);
|
|
362
|
+
sendJson(res, 200, { ok: true });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
throw new TunnelError("unsupported_route", 404, "unknown host control action");
|
|
366
|
+
}
|
|
367
|
+
async function readJsonBody(req, limitBytes) {
|
|
368
|
+
const chunks = [];
|
|
369
|
+
let total = 0;
|
|
370
|
+
for await (const chunk of req) {
|
|
371
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
372
|
+
total += buffer.length;
|
|
373
|
+
if (total > limitBytes) {
|
|
374
|
+
throw new TunnelError("invalid_registration", 413, "host control body is too large");
|
|
375
|
+
}
|
|
376
|
+
chunks.push(buffer);
|
|
377
|
+
}
|
|
378
|
+
if (total === 0)
|
|
379
|
+
return {};
|
|
380
|
+
try {
|
|
381
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
382
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return {};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function sendTunnelError(res, error) {
|
|
389
|
+
if (res.headersSent) {
|
|
390
|
+
res.destroy();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (error instanceof TunnelError) {
|
|
394
|
+
sendJson(res, error.status, error.body());
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
sendJson(res, 500, { ok: false, error: "internal_error", message: "internal tunnel error" });
|
|
398
|
+
}
|
|
399
|
+
function sendJson(res, status, body) {
|
|
400
|
+
const payload = JSON.stringify(body);
|
|
401
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
402
|
+
res.end(payload);
|
|
403
|
+
}
|
|
404
|
+
function mintId(prefix) {
|
|
405
|
+
return `${prefix}_${randomBytes(12).toString("base64url")}`;
|
|
406
|
+
}
|
|
407
|
+
function isoFrom(ms) {
|
|
408
|
+
return new Date(ms).toISOString();
|
|
409
|
+
}
|
|
410
|
+
function errorCode(error) {
|
|
411
|
+
return error instanceof TunnelError ? error.code : "internal_error";
|
|
412
|
+
}
|
|
413
|
+
const ENVELOPE_HEADERS = new Set(["authorization", "content-type", "accept", "origin", "referer"]);
|
|
414
|
+
function selectEnvelopeHeaders(req) {
|
|
415
|
+
const headers = {};
|
|
416
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
417
|
+
if (value === undefined)
|
|
418
|
+
continue;
|
|
419
|
+
const lower = name.toLowerCase();
|
|
420
|
+
if (ENVELOPE_HEADERS.has(lower))
|
|
421
|
+
headers[lower] = Array.isArray(value) ? value.join(", ") : value;
|
|
422
|
+
}
|
|
423
|
+
return headers;
|
|
424
|
+
}
|
|
425
|
+
async function readBody(req, limitBytes) {
|
|
426
|
+
const method = req.method ?? "GET";
|
|
427
|
+
if (method === "GET" || method === "HEAD")
|
|
428
|
+
return undefined;
|
|
429
|
+
const chunks = [];
|
|
430
|
+
let total = 0;
|
|
431
|
+
for await (const chunk of req) {
|
|
432
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
433
|
+
total += buffer.length;
|
|
434
|
+
if (total > limitBytes) {
|
|
435
|
+
throw new TunnelError("request_too_large", 413, "request body exceeds the broker limit");
|
|
436
|
+
}
|
|
437
|
+
chunks.push(buffer);
|
|
438
|
+
}
|
|
439
|
+
return chunks.length === 0 ? undefined : Buffer.concat(chunks);
|
|
440
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Host tunnel client.
|
|
2
|
+
//
|
|
3
|
+
// Connects a host to a local tunnel broker over HTTP, registers a route for a
|
|
4
|
+
// room slug, and records the resulting public base URL on disk so the running
|
|
5
|
+
// room server and the CLI can advertise it. The host tunnel session is kept
|
|
6
|
+
// distinct from participant tokens: the broker-minted host_connection_id is a
|
|
7
|
+
// routing credential, never a participant bearer token.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ensureSecureDir, writeSecureFile } from "../storage/index.js";
|
|
11
|
+
import { normalizeBaseUrl, roomUrl } from "../protocol/index.js";
|
|
12
|
+
import { relayToLocalServer } from "./forwarding.js";
|
|
13
|
+
import { TunnelError } from "./protocol.js";
|
|
14
|
+
const RELAY_RESPONSE_BODY_BYTES = 1024 * 1024;
|
|
15
|
+
/** HTTP client for a local tunnel broker's host control endpoints. */
|
|
16
|
+
export class TunnelClient {
|
|
17
|
+
brokerBaseUrl;
|
|
18
|
+
constructor(brokerBaseUrl) {
|
|
19
|
+
this.brokerBaseUrl = normalizeBaseUrl(brokerBaseUrl);
|
|
20
|
+
}
|
|
21
|
+
/** Register a route for a slug and compute its public base URL. */
|
|
22
|
+
async register(slug, target) {
|
|
23
|
+
const payload = await this.post("/_host/register", {
|
|
24
|
+
route_slug: slug,
|
|
25
|
+
...(target === undefined ? {} : { target })
|
|
26
|
+
});
|
|
27
|
+
const route = payload.route;
|
|
28
|
+
return { route, publicBaseUrl: this.publicBaseUrlFor(route.route_slug) };
|
|
29
|
+
}
|
|
30
|
+
/** Refresh a route to keep the host session alive. */
|
|
31
|
+
async heartbeat(routeId, hostConnectionId) {
|
|
32
|
+
const payload = await this.post("/_host/heartbeat", {
|
|
33
|
+
route_id: routeId,
|
|
34
|
+
host_connection_id: hostConnectionId
|
|
35
|
+
});
|
|
36
|
+
return payload.route;
|
|
37
|
+
}
|
|
38
|
+
/** Close a route the host owns. */
|
|
39
|
+
async close(routeId, hostConnectionId) {
|
|
40
|
+
const payload = await this.post("/_host/close", {
|
|
41
|
+
route_id: routeId,
|
|
42
|
+
host_connection_id: hostConnectionId
|
|
43
|
+
});
|
|
44
|
+
return payload;
|
|
45
|
+
}
|
|
46
|
+
/** Claim the next pending relay request for the route, or null if none. */
|
|
47
|
+
async poll(routeId, hostConnectionId) {
|
|
48
|
+
const payload = await this.post("/_host/poll", { route_id: routeId, host_connection_id: hostConnectionId });
|
|
49
|
+
return payload.request ?? null;
|
|
50
|
+
}
|
|
51
|
+
/** Post the response for exactly one in-flight relay request id. */
|
|
52
|
+
async respond(routeId, hostConnectionId, requestId, response) {
|
|
53
|
+
await this.post("/_host/respond", {
|
|
54
|
+
route_id: routeId,
|
|
55
|
+
host_connection_id: hostConnectionId,
|
|
56
|
+
request_id: requestId,
|
|
57
|
+
response
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Attend the route once: claim a pending request, forward it to the local
|
|
62
|
+
* room server, and post the response back. Returns true if a request was
|
|
63
|
+
* handled, false if none were pending. This is the host outbound relay step;
|
|
64
|
+
* the broker never reaches the local server itself.
|
|
65
|
+
*/
|
|
66
|
+
async attendOnce(routeId, hostConnectionId, target) {
|
|
67
|
+
const request = await this.poll(routeId, hostConnectionId);
|
|
68
|
+
if (request === null)
|
|
69
|
+
return false;
|
|
70
|
+
await this.handleClaim(routeId, hostConnectionId, request, target);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Forward an already-claimed request to the local room server and post the
|
|
75
|
+
* response. Failures become a stable error response so the participant never
|
|
76
|
+
* hangs. Used by the foreground run loop to process claims concurrently.
|
|
77
|
+
*/
|
|
78
|
+
async handleClaim(routeId, hostConnectionId, request, target) {
|
|
79
|
+
let response;
|
|
80
|
+
try {
|
|
81
|
+
response = await relayToLocalServer(target, request, RELAY_RESPONSE_BODY_BYTES);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
response = {
|
|
85
|
+
status: error instanceof TunnelError ? error.status : 502,
|
|
86
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
87
|
+
body_base64: Buffer.from(JSON.stringify({ ok: false, error: error instanceof TunnelError ? error.code : "host_unavailable" })).toString("base64")
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
await this.respond(routeId, hostConnectionId, request.request_id, response);
|
|
91
|
+
}
|
|
92
|
+
publicBaseUrlFor(slug) {
|
|
93
|
+
return normalizeBaseUrl(roomUrl(this.brokerBaseUrl, slug));
|
|
94
|
+
}
|
|
95
|
+
async post(action, body) {
|
|
96
|
+
let response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(new URL(action, `${this.brokerBaseUrl}/`), {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify(body)
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new TunnelError("route_not_found", 502, "could not reach the tunnel broker");
|
|
106
|
+
}
|
|
107
|
+
const payload = await readJson(response);
|
|
108
|
+
if (!response.ok || payload.ok === false) {
|
|
109
|
+
const error = payload;
|
|
110
|
+
throw new TunnelError(error.error ?? "internal_error", response.status, typeof error.message === "string" ? error.message : "tunnel broker rejected the request");
|
|
111
|
+
}
|
|
112
|
+
return payload;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function readJson(response) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(await response.text());
|
|
118
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function tunnelStatePath(home, roomId) {
|
|
125
|
+
return path.join(home, "rooms", roomId, "tunnel.json");
|
|
126
|
+
}
|
|
127
|
+
export async function writeHostTunnelState(home, roomId, state) {
|
|
128
|
+
const file = tunnelStatePath(home, roomId);
|
|
129
|
+
await ensureSecureDir(path.dirname(file));
|
|
130
|
+
await writeSecureFile(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read the published public base URL for a room, or undefined if no tunnel has
|
|
134
|
+
* been started. Synchronous so the room server can resolve it per request.
|
|
135
|
+
*/
|
|
136
|
+
export function readPublicBaseUrl(home, roomId) {
|
|
137
|
+
try {
|
|
138
|
+
const state = JSON.parse(readFileSync(tunnelStatePath(home, roomId), "utf8"));
|
|
139
|
+
return typeof state.public_base_url === "string" ? state.public_base_url : undefined;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|