@rynx-ai/server 0.1.11-beta.5 → 0.2.0-beta.2
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/dist/control-web/assets/{highlighted-body-OFNGDK62-B4mcMjmm.js → highlighted-body-OFNGDK62-IdclXfSt.js} +1 -1
- package/dist/control-web/assets/index-DhsO7WjD.js +709 -0
- package/dist/control-web/assets/{mermaid-GHXKKRXX-DnL0iDTi.js → mermaid-GHXKKRXX-BqMPrbIZ.js} +3 -3
- package/dist/control-web/index.html +1 -1
- package/dist/control-web-dist.d.ts +1 -1
- package/dist/control-web-dist.js +4 -2
- package/dist/server.d.ts +12 -1
- package/dist/server.js +112 -41
- package/dist/session-portal.d.ts +42 -0
- package/dist/session-portal.js +746 -0
- package/dist/terminal-ws.js +8 -6
- package/package.json +7 -7
- package/dist/control-web/assets/index-D56JQ9oi.js +0 -709
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { request as httpRequest, } from "node:http";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import Router from "@koa/router";
|
|
4
|
+
import Koa from "koa";
|
|
5
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
6
|
+
import { sendSpaFile } from "./control-web-dist.js";
|
|
7
|
+
export const SESSION_PORTAL_HEADER = "x-rynx-session-portal";
|
|
8
|
+
export const SESSION_PORTAL_QUERY = "portal";
|
|
9
|
+
const MAX_TICKET_BYTES = 256;
|
|
10
|
+
const MAX_FILTERED_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
const MAX_PENDING_SOCKET_FRAMES = 128;
|
|
12
|
+
const MAX_PENDING_SOCKET_BYTES = 1024 * 1024;
|
|
13
|
+
const MAX_SOCKET_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
14
|
+
const PORTAL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
15
|
+
const RUNTIME_SESSION_PATH = /^\/runtimes\/local\/sessions(?:\/([^/]+))?\/?$/;
|
|
16
|
+
const RUNTIME_SESSION_API_PATH = /^\/api\/runtimes\/local\/sessions(?:\/([^/]+))?(?:\/|$)/;
|
|
17
|
+
const SESSION_COLLECTION_API_PATH = "/api/runtimes/local/sessions";
|
|
18
|
+
const TERMINAL_SOCKET_PATH = /^\/api\/runtimes\/([^/]+)\/sessions\/([^/]+)\/terminal$/;
|
|
19
|
+
const BROWSER_SOCKET_PATH = /^\/api\/runtimes\/([^/]+)\/sessions\/([^/]+)\/browser\/pages\/[^/]+\/surface$/;
|
|
20
|
+
const EMULATOR_SOCKET_PATH = /^\/api\/runtimes\/([^/]+)\/sessions\/([^/]+)\/emulator\/surface$/;
|
|
21
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
22
|
+
"connection",
|
|
23
|
+
"keep-alive",
|
|
24
|
+
"proxy-authenticate",
|
|
25
|
+
"proxy-authorization",
|
|
26
|
+
"proxy-connection",
|
|
27
|
+
"te",
|
|
28
|
+
"trailer",
|
|
29
|
+
"transfer-encoding",
|
|
30
|
+
"upgrade",
|
|
31
|
+
]);
|
|
32
|
+
/** Session Portal is a narrow adapter in front of the one Control service. It
|
|
33
|
+
* serves the native SPA, validates Portal grants, and forwards only authorized
|
|
34
|
+
* Session HTTP calls to Control; it owns no Session or Runtime handlers. */
|
|
35
|
+
export function createSessionPortalApp(options) {
|
|
36
|
+
const app = new Koa();
|
|
37
|
+
const bootstrapRouter = new Router();
|
|
38
|
+
app.use(async (ctx, next) => {
|
|
39
|
+
ctx.set("Cache-Control", "no-store");
|
|
40
|
+
ctx.set("Referrer-Policy", "no-referrer");
|
|
41
|
+
ctx.set("X-Content-Type-Options", "nosniff");
|
|
42
|
+
ctx.set("X-Frame-Options", "DENY");
|
|
43
|
+
ctx.set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; " +
|
|
44
|
+
"connect-src 'self' ws: wss:; img-src 'self' data: blob:; font-src 'self' data:; " +
|
|
45
|
+
"base-uri 'none'; form-action 'self'; frame-ancestors 'none'");
|
|
46
|
+
if (options.shutdownSignal?.aborted) {
|
|
47
|
+
serviceUnavailable(ctx);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await next();
|
|
51
|
+
});
|
|
52
|
+
bootstrapRouter.get("/health", (ctx) => {
|
|
53
|
+
ctx.body = { ok: true, surface: "session-portal" };
|
|
54
|
+
});
|
|
55
|
+
bootstrapRouter.get("/session-portal/redeem", (ctx) => {
|
|
56
|
+
ctx.type = "html";
|
|
57
|
+
ctx.body = redeemPage();
|
|
58
|
+
});
|
|
59
|
+
bootstrapRouter.post("/session-portal/redeem", async (ctx) => {
|
|
60
|
+
const body = await readJsonBody(ctx.req).catch(() => undefined);
|
|
61
|
+
const ticket = typeof body?.ticket === "string" ? body.ticket : "";
|
|
62
|
+
if (!ticket || Buffer.byteLength(ticket, "utf8") > MAX_TICKET_BYTES) {
|
|
63
|
+
ctx.status = 400;
|
|
64
|
+
ctx.body = { error: "invalid_ticket" };
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const grant = options.authorization.redeem(ticket);
|
|
68
|
+
if (!grant) {
|
|
69
|
+
ctx.status = 401;
|
|
70
|
+
ctx.body = { error: "ticket_expired_or_used" };
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
ctx.cookies.set(cookieName(grant.portalId), grant.secret, {
|
|
74
|
+
httpOnly: true,
|
|
75
|
+
sameSite: "strict",
|
|
76
|
+
secure: options.scheme === "https",
|
|
77
|
+
overwrite: true,
|
|
78
|
+
path: "/",
|
|
79
|
+
expires: new Date(grant.expiresAt),
|
|
80
|
+
});
|
|
81
|
+
const path = grant.scope === "single_session"
|
|
82
|
+
? `/runtimes/local/sessions/${encodeURIComponent(grant.sessionId)}`
|
|
83
|
+
: "/runtimes/local/sessions";
|
|
84
|
+
ctx.body = {
|
|
85
|
+
location: `${path}?${SESSION_PORTAL_QUERY}=${encodeURIComponent(grant.portalId)}`,
|
|
86
|
+
expiresAt: grant.expiresAt,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
app.use(bootstrapRouter.routes());
|
|
90
|
+
app.use(bootstrapRouter.allowedMethods());
|
|
91
|
+
app.use(async (ctx, next) => {
|
|
92
|
+
if (isStaticAssetRequest(ctx))
|
|
93
|
+
return next();
|
|
94
|
+
const grant = authorizeHttp(ctx, options.authorization);
|
|
95
|
+
if (!grant)
|
|
96
|
+
return;
|
|
97
|
+
ctx.state.sessionPortal = grant;
|
|
98
|
+
expireHttpResponseAtGrantDeadline(ctx, grant.expiresAt);
|
|
99
|
+
if (ctx.path === "/api/session-portal/access") {
|
|
100
|
+
if (ctx.method !== "GET")
|
|
101
|
+
return methodNotAllowed(ctx);
|
|
102
|
+
ctx.body = publicAuthorization(grant);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!authorizePortalRoute(ctx, grant, options.authorization))
|
|
106
|
+
return;
|
|
107
|
+
if (ctx.path.startsWith("/api/")) {
|
|
108
|
+
await proxyControlHttp(ctx, grant, options);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await next();
|
|
112
|
+
});
|
|
113
|
+
app.use(async (ctx, next) => {
|
|
114
|
+
if (ctx.method !== "GET" && ctx.method !== "HEAD")
|
|
115
|
+
return next();
|
|
116
|
+
if (ctx.path.startsWith("/api/") || ctx.path.startsWith("/session-portal/"))
|
|
117
|
+
return next();
|
|
118
|
+
const grant = ctx.state.sessionPortal;
|
|
119
|
+
await sendSpaFile(ctx, options.controlWebDist, "/", grant
|
|
120
|
+
? (html) => injectPortalBootstrap(html, grant)
|
|
121
|
+
: undefined);
|
|
122
|
+
});
|
|
123
|
+
return app;
|
|
124
|
+
}
|
|
125
|
+
export async function startSessionPortal(options) {
|
|
126
|
+
if (options.shutdownSignal?.aborted)
|
|
127
|
+
throw new Error("Control service is shutting down");
|
|
128
|
+
const lifecycle = new AbortController();
|
|
129
|
+
let shutdownPortal;
|
|
130
|
+
const stopForControlShutdown = () => {
|
|
131
|
+
lifecycle.abort(options.shutdownSignal?.reason ?? new Error("Control service is shutting down"));
|
|
132
|
+
void shutdownPortal?.().catch(() => undefined);
|
|
133
|
+
};
|
|
134
|
+
options.shutdownSignal?.addEventListener("abort", stopForControlShutdown, { once: true });
|
|
135
|
+
const app = createSessionPortalApp({ ...options, shutdownSignal: lifecycle.signal });
|
|
136
|
+
let server;
|
|
137
|
+
try {
|
|
138
|
+
server = await new Promise((resolve, reject) => {
|
|
139
|
+
const candidate = app.listen(options.port, options.host, () => {
|
|
140
|
+
candidate.off("error", reject);
|
|
141
|
+
resolve(candidate);
|
|
142
|
+
});
|
|
143
|
+
candidate.once("error", reject);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
options.shutdownSignal?.removeEventListener("abort", stopForControlShutdown);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
let sockets;
|
|
151
|
+
try {
|
|
152
|
+
sockets = attachPortalWebSocketProxy(server, {
|
|
153
|
+
authorization: options.authorization,
|
|
154
|
+
controlOrigin: options.controlOrigin,
|
|
155
|
+
shutdownSignal: lifecycle.signal,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
options.shutdownSignal?.removeEventListener("abort", stopForControlShutdown);
|
|
160
|
+
await closeHttpServerImmediately(server);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
let shutdownPromise;
|
|
164
|
+
const shutdown = () => {
|
|
165
|
+
if (shutdownPromise)
|
|
166
|
+
return shutdownPromise;
|
|
167
|
+
lifecycle.abort(new Error("Control service is shutting down"));
|
|
168
|
+
for (const client of sockets.external.clients)
|
|
169
|
+
client.terminate();
|
|
170
|
+
for (const upstream of sockets.upstream)
|
|
171
|
+
upstream.terminate();
|
|
172
|
+
shutdownPromise = Promise.all([
|
|
173
|
+
closeWebSocketServer(sockets.external),
|
|
174
|
+
closeHttpServerImmediately(server),
|
|
175
|
+
]).then(() => {
|
|
176
|
+
options.shutdownSignal?.removeEventListener("abort", stopForControlShutdown);
|
|
177
|
+
});
|
|
178
|
+
return shutdownPromise;
|
|
179
|
+
};
|
|
180
|
+
shutdownPortal = shutdown;
|
|
181
|
+
if (lifecycle.signal.aborted) {
|
|
182
|
+
await shutdown();
|
|
183
|
+
throw new Error("Control service is shutting down");
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
server,
|
|
187
|
+
stopAccepting: () => { void shutdown().catch(() => undefined); },
|
|
188
|
+
shutdown,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
export function authorizeSessionPortalWebSocket(request, ports, runtimeSelector, sessionId) {
|
|
192
|
+
let url;
|
|
193
|
+
try {
|
|
194
|
+
url = new URL(request.url ?? "", "http://localhost");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const portalId = url.searchParams.get(SESSION_PORTAL_QUERY) ?? "";
|
|
200
|
+
if (!PORTAL_ID_PATTERN.test(portalId))
|
|
201
|
+
return undefined;
|
|
202
|
+
const secret = cookieValue(request.headers.cookie, cookieName(portalId));
|
|
203
|
+
if (!secret)
|
|
204
|
+
return undefined;
|
|
205
|
+
const grant = ports.authorize(portalId, secret);
|
|
206
|
+
if (!grant)
|
|
207
|
+
return undefined;
|
|
208
|
+
if (runtimeSelector !== "local")
|
|
209
|
+
return undefined;
|
|
210
|
+
if (grant.scope === "single_session" && grant.sessionId !== sessionId)
|
|
211
|
+
return undefined;
|
|
212
|
+
if (!ports.ownsSession(grant.pluginId, sessionId))
|
|
213
|
+
return undefined;
|
|
214
|
+
return grant;
|
|
215
|
+
}
|
|
216
|
+
export function rejectSessionPortalUpgrade(socket) {
|
|
217
|
+
rejectUpgrade(socket, 401, "session portal grant expired or invalid");
|
|
218
|
+
}
|
|
219
|
+
function authorizeHttp(ctx, ports) {
|
|
220
|
+
const queryPortal = typeof ctx.query[SESSION_PORTAL_QUERY] === "string"
|
|
221
|
+
? ctx.query[SESSION_PORTAL_QUERY]
|
|
222
|
+
: "";
|
|
223
|
+
const candidate = ctx.path.startsWith("/api/")
|
|
224
|
+
? ctx.get(SESSION_PORTAL_HEADER) || queryPortal
|
|
225
|
+
: queryPortal;
|
|
226
|
+
const portalId = candidate.trim();
|
|
227
|
+
const secret = PORTAL_ID_PATTERN.test(portalId)
|
|
228
|
+
? ctx.cookies.get(cookieName(portalId))
|
|
229
|
+
: undefined;
|
|
230
|
+
const grant = secret ? ports.authorize(portalId, secret) : undefined;
|
|
231
|
+
if (!grant) {
|
|
232
|
+
ctx.status = 401;
|
|
233
|
+
ctx.body = { error: "session_portal_grant_expired_or_invalid" };
|
|
234
|
+
}
|
|
235
|
+
return grant;
|
|
236
|
+
}
|
|
237
|
+
function authorizePortalRoute(ctx, grant, ports) {
|
|
238
|
+
if (ctx.path === "/") {
|
|
239
|
+
const target = grant.scope === "single_session"
|
|
240
|
+
? `/runtimes/local/sessions/${encodeURIComponent(grant.sessionId)}`
|
|
241
|
+
: "/runtimes/local/sessions";
|
|
242
|
+
ctx.redirect(`${target}?${SESSION_PORTAL_QUERY}=${encodeURIComponent(grant.portalId)}`);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const pageMatch = RUNTIME_SESSION_PATH.exec(ctx.path);
|
|
246
|
+
if (pageMatch) {
|
|
247
|
+
if (ctx.method !== "GET" && ctx.method !== "HEAD")
|
|
248
|
+
return methodNotAllowed(ctx);
|
|
249
|
+
const requested = decoded(pageMatch[1]);
|
|
250
|
+
if (grant.scope === "single_session" && requested && requested !== grant.sessionId)
|
|
251
|
+
return notFound(ctx);
|
|
252
|
+
if (requested && !ports.ownsSession(grant.pluginId, requested))
|
|
253
|
+
return notFound(ctx);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (ctx.path === "/api/runtimes/capability" || ctx.path === "/api/runtimes") {
|
|
257
|
+
if (ctx.method !== "GET")
|
|
258
|
+
return methodNotAllowed(ctx);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (ctx.path === SESSION_COLLECTION_API_PATH) {
|
|
262
|
+
return ctx.method === "GET" ? true : methodNotAllowed(ctx);
|
|
263
|
+
}
|
|
264
|
+
const apiMatch = RUNTIME_SESSION_API_PATH.exec(ctx.path);
|
|
265
|
+
const sessionId = decoded(apiMatch?.[1]);
|
|
266
|
+
if (sessionId) {
|
|
267
|
+
if (grant.scope === "single_session" && sessionId !== grant.sessionId)
|
|
268
|
+
return notFound(ctx);
|
|
269
|
+
if (!ports.ownsSession(grant.pluginId, sessionId))
|
|
270
|
+
return notFound(ctx);
|
|
271
|
+
if (ctx.method === "GET" || ctx.method === "HEAD")
|
|
272
|
+
return true;
|
|
273
|
+
if (!isAllowedSessionMutation(ctx, apiMatch[1]))
|
|
274
|
+
return notFound(ctx);
|
|
275
|
+
return denyMutation(ctx, grant);
|
|
276
|
+
}
|
|
277
|
+
return notFound(ctx);
|
|
278
|
+
}
|
|
279
|
+
async function proxyControlHttp(ctx, grant, options) {
|
|
280
|
+
let response;
|
|
281
|
+
try {
|
|
282
|
+
response = await openControlRequest(ctx, options.controlOrigin, options.shutdownSignal);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (options.shutdownSignal?.aborted)
|
|
286
|
+
return serviceUnavailable(ctx);
|
|
287
|
+
ctx.status = 502;
|
|
288
|
+
ctx.body = { error: "control_service_unavailable" };
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (shouldFilterControlResponse(ctx)) {
|
|
292
|
+
try {
|
|
293
|
+
const data = await readBoundedResponse(response, MAX_FILTERED_RESPONSE_BYTES, options.shutdownSignal);
|
|
294
|
+
applyControlResponseHeaders(ctx, response.headers, true);
|
|
295
|
+
ctx.status = response.statusCode ?? 502;
|
|
296
|
+
const body = JSON.parse(data.toString("utf8"));
|
|
297
|
+
narrowPortalResponse(ctx.path, body, grant, options.authorization);
|
|
298
|
+
ctx.body = body;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
if (options.shutdownSignal?.aborted)
|
|
302
|
+
return serviceUnavailable(ctx);
|
|
303
|
+
ctx.status = 502;
|
|
304
|
+
ctx.body = { error: "invalid_control_response" };
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
ctx.status = response.statusCode ?? 502;
|
|
309
|
+
applyControlResponseHeaders(ctx, response.headers, false);
|
|
310
|
+
ctx.respond = false;
|
|
311
|
+
ctx.res.statusCode = ctx.status;
|
|
312
|
+
try {
|
|
313
|
+
await pipeline(response, ctx.res, ...(options.shutdownSignal
|
|
314
|
+
? [{ signal: options.shutdownSignal }]
|
|
315
|
+
: []));
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
if (!ctx.res.destroyed)
|
|
319
|
+
ctx.res.destroy();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function openControlRequest(ctx, controlOrigin, signal) {
|
|
323
|
+
const target = new URL(`${ctx.path}${ctx.search}`, controlOrigin);
|
|
324
|
+
target.searchParams.delete(SESSION_PORTAL_QUERY);
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const request = httpRequest(target, {
|
|
327
|
+
method: ctx.method,
|
|
328
|
+
headers: controlRequestHeaders(ctx.req.headers, target),
|
|
329
|
+
...(signal ? { signal } : {}),
|
|
330
|
+
}, resolve);
|
|
331
|
+
request.once("error", reject);
|
|
332
|
+
ctx.req.once("aborted", () => request.destroy(new Error("Portal request aborted")));
|
|
333
|
+
ctx.req.pipe(request);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
function controlRequestHeaders(headers, target) {
|
|
337
|
+
const forwarded = {};
|
|
338
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
339
|
+
const lower = name.toLowerCase();
|
|
340
|
+
if (value === undefined ||
|
|
341
|
+
HOP_BY_HOP_HEADERS.has(lower) ||
|
|
342
|
+
lower === "cookie" ||
|
|
343
|
+
lower === "host" ||
|
|
344
|
+
lower === "origin" ||
|
|
345
|
+
lower === "referer" ||
|
|
346
|
+
lower === "accept-encoding" ||
|
|
347
|
+
lower === SESSION_PORTAL_HEADER ||
|
|
348
|
+
lower.startsWith("forwarded") ||
|
|
349
|
+
lower.startsWith("x-forwarded-"))
|
|
350
|
+
continue;
|
|
351
|
+
forwarded[lower] = value;
|
|
352
|
+
}
|
|
353
|
+
forwarded.host = target.host;
|
|
354
|
+
forwarded.origin = target.origin;
|
|
355
|
+
forwarded["sec-fetch-site"] = "same-origin";
|
|
356
|
+
forwarded["accept-encoding"] = "identity";
|
|
357
|
+
return forwarded;
|
|
358
|
+
}
|
|
359
|
+
function applyControlResponseHeaders(ctx, headers, bodyChanged) {
|
|
360
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
361
|
+
const lower = name.toLowerCase();
|
|
362
|
+
if (value === undefined ||
|
|
363
|
+
HOP_BY_HOP_HEADERS.has(lower) ||
|
|
364
|
+
lower === "set-cookie" ||
|
|
365
|
+
(bodyChanged && (lower === "content-length" || lower === "content-encoding")))
|
|
366
|
+
continue;
|
|
367
|
+
ctx.set(name, Array.isArray(value) ? value : String(value));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function shouldFilterControlResponse(ctx) {
|
|
371
|
+
return ctx.method === "GET" && (ctx.path === "/api/runtimes" || ctx.path === SESSION_COLLECTION_API_PATH);
|
|
372
|
+
}
|
|
373
|
+
function narrowPortalResponse(path, body, grant, ports) {
|
|
374
|
+
if (!body || typeof body !== "object")
|
|
375
|
+
return;
|
|
376
|
+
if (path === "/api/runtimes") {
|
|
377
|
+
const value = body;
|
|
378
|
+
if (Array.isArray(value.runtimes)) {
|
|
379
|
+
value.runtimes = value.runtimes.filter((runtime) => runtime.selector === "local");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (path === SESSION_COLLECTION_API_PATH) {
|
|
383
|
+
const value = body;
|
|
384
|
+
if (Array.isArray(value.sessions)) {
|
|
385
|
+
value.sessions = value.sessions.filter((session) => typeof session.id === "string" &&
|
|
386
|
+
ports.ownsSession(grant.pluginId, session.id) &&
|
|
387
|
+
(grant.scope !== "single_session" || session.id === grant.sessionId));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function attachPortalWebSocketProxy(server, options) {
|
|
392
|
+
const external = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
393
|
+
const upstreamSockets = new Set();
|
|
394
|
+
server.on("upgrade", (request, socket, head) => {
|
|
395
|
+
if (options.shutdownSignal.aborted)
|
|
396
|
+
return rejectUpgrade(socket, 503, "Control service is shutting down");
|
|
397
|
+
const route = portalSocketRoute(request.url);
|
|
398
|
+
if (!route)
|
|
399
|
+
return rejectUpgrade(socket, 404, "Session Portal socket not found");
|
|
400
|
+
const grant = authorizeSessionPortalWebSocket(request, options.authorization, route.runtimeSelector, route.sessionId);
|
|
401
|
+
if (!grant || !readOnlySocketRoleAllowed(route.kind, request.url, grant)) {
|
|
402
|
+
rejectSessionPortalUpgrade(socket);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
external.handleUpgrade(request, socket, head, (client) => {
|
|
407
|
+
bridgePortalWebSocket(client, route.kind, request.url, grant, {
|
|
408
|
+
controlOrigin: options.controlOrigin,
|
|
409
|
+
shutdownSignal: options.shutdownSignal,
|
|
410
|
+
upstreamSockets,
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
rejectUpgrade(socket, 400, "invalid Session Portal WebSocket upgrade");
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
return { external, upstream: upstreamSockets };
|
|
419
|
+
}
|
|
420
|
+
function bridgePortalWebSocket(client, kind, requestUrl, grant, options) {
|
|
421
|
+
const externalTarget = new URL(requestUrl, "http://session-portal.invalid");
|
|
422
|
+
const target = new URL(`${externalTarget.pathname}${externalTarget.search}`, options.controlOrigin);
|
|
423
|
+
target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
|
|
424
|
+
target.searchParams.delete(SESSION_PORTAL_QUERY);
|
|
425
|
+
const upstream = new WebSocket(target, {
|
|
426
|
+
origin: options.controlOrigin,
|
|
427
|
+
headers: { "sec-fetch-site": "same-origin" },
|
|
428
|
+
perMessageDeflate: false,
|
|
429
|
+
maxPayload: MAX_SOCKET_BUFFERED_BYTES,
|
|
430
|
+
});
|
|
431
|
+
options.upstreamSockets.add(upstream);
|
|
432
|
+
const pending = [];
|
|
433
|
+
let pendingBytes = 0;
|
|
434
|
+
let closed = false;
|
|
435
|
+
const clearExpiry = scheduleSessionPortalExpiry(grant.expiresAt, () => {
|
|
436
|
+
closeSocket(client, 4403, "Session Portal grant expired");
|
|
437
|
+
closeSocket(upstream, 4403, "Session Portal grant expired");
|
|
438
|
+
});
|
|
439
|
+
const stopForShutdown = () => {
|
|
440
|
+
client.terminate();
|
|
441
|
+
upstream.terminate();
|
|
442
|
+
};
|
|
443
|
+
options.shutdownSignal.addEventListener("abort", stopForShutdown, { once: true });
|
|
444
|
+
const cleanup = () => {
|
|
445
|
+
if (closed)
|
|
446
|
+
return;
|
|
447
|
+
closed = true;
|
|
448
|
+
clearExpiry();
|
|
449
|
+
pending.length = 0;
|
|
450
|
+
pendingBytes = 0;
|
|
451
|
+
options.upstreamSockets.delete(upstream);
|
|
452
|
+
options.shutdownSignal.removeEventListener("abort", stopForShutdown);
|
|
453
|
+
};
|
|
454
|
+
client.on("message", (data, isBinary) => {
|
|
455
|
+
if (grant.access === "read_only") {
|
|
456
|
+
if (kind === "terminal")
|
|
457
|
+
return;
|
|
458
|
+
if (!allowedReadOnlySocketFrame(kind, data, isBinary)) {
|
|
459
|
+
closeSocket(client, 4403, "Session Portal is read only");
|
|
460
|
+
closeSocket(upstream, 4403, "Session Portal is read only");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const frame = rawDataBuffer(data);
|
|
465
|
+
if (upstream.readyState === WebSocket.OPEN) {
|
|
466
|
+
forwardSocketFrame(client, upstream, frame, isBinary);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (pending.length >= MAX_PENDING_SOCKET_FRAMES ||
|
|
470
|
+
pendingBytes + frame.byteLength > MAX_PENDING_SOCKET_BYTES) {
|
|
471
|
+
closeSocket(client, 4408, "Session Portal socket queue is full");
|
|
472
|
+
upstream.terminate();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
pending.push({ data: frame, isBinary });
|
|
476
|
+
pendingBytes += frame.byteLength;
|
|
477
|
+
});
|
|
478
|
+
upstream.on("open", () => {
|
|
479
|
+
for (const frame of pending.splice(0)) {
|
|
480
|
+
pendingBytes -= frame.data.byteLength;
|
|
481
|
+
if (!forwardSocketFrame(client, upstream, frame.data, frame.isBinary))
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
upstream.on("message", (data, isBinary) => {
|
|
486
|
+
forwardSocketFrame(upstream, client, data, isBinary);
|
|
487
|
+
});
|
|
488
|
+
client.on("close", (code, reason) => {
|
|
489
|
+
closeSocket(upstream, normalizedCloseCode(code), reason.toString());
|
|
490
|
+
cleanup();
|
|
491
|
+
});
|
|
492
|
+
upstream.on("close", (code, reason) => {
|
|
493
|
+
closeSocket(client, normalizedCloseCode(code), reason.toString());
|
|
494
|
+
cleanup();
|
|
495
|
+
});
|
|
496
|
+
client.on("error", () => {
|
|
497
|
+
upstream.terminate();
|
|
498
|
+
cleanup();
|
|
499
|
+
});
|
|
500
|
+
upstream.on("error", () => {
|
|
501
|
+
closeSocket(client, 1011, "Control service unavailable");
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
function portalSocketRoute(rawUrl) {
|
|
505
|
+
let pathname;
|
|
506
|
+
try {
|
|
507
|
+
pathname = new URL(rawUrl ?? "", "http://localhost").pathname;
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
for (const [kind, pattern] of [
|
|
513
|
+
["terminal", TERMINAL_SOCKET_PATH],
|
|
514
|
+
["browser", BROWSER_SOCKET_PATH],
|
|
515
|
+
["emulator", EMULATOR_SOCKET_PATH],
|
|
516
|
+
]) {
|
|
517
|
+
const match = pattern.exec(pathname);
|
|
518
|
+
if (!match)
|
|
519
|
+
continue;
|
|
520
|
+
const runtimeSelector = decoded(match[1]);
|
|
521
|
+
const sessionId = decoded(match[2]);
|
|
522
|
+
if (runtimeSelector && sessionId)
|
|
523
|
+
return { kind, runtimeSelector, sessionId };
|
|
524
|
+
}
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
function readOnlySocketRoleAllowed(kind, rawUrl, grant) {
|
|
528
|
+
if (grant.access !== "read_only" || kind === "emulator")
|
|
529
|
+
return true;
|
|
530
|
+
const role = new URL(rawUrl ?? "", "http://localhost").searchParams.get("role");
|
|
531
|
+
return kind === "terminal" ? role === "read-only" : role === "view";
|
|
532
|
+
}
|
|
533
|
+
function allowedReadOnlySocketFrame(kind, data, isBinary) {
|
|
534
|
+
if (kind !== "browser" || isBinary)
|
|
535
|
+
return false;
|
|
536
|
+
try {
|
|
537
|
+
const value = JSON.parse(rawDataBuffer(data).toString("utf8"));
|
|
538
|
+
return value.type === "browser.surface.frame.ack";
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function forwardSocketFrame(source, target, data, isBinary) {
|
|
545
|
+
const bytes = rawDataBytes(data);
|
|
546
|
+
if (target.readyState !== WebSocket.OPEN ||
|
|
547
|
+
target.bufferedAmount + bytes > MAX_SOCKET_BUFFERED_BYTES) {
|
|
548
|
+
closeSocket(source, 4408, "Session Portal socket is too slow");
|
|
549
|
+
closeSocket(target, 4408, "Session Portal socket is too slow");
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
target.send(data, { binary: isBinary }, (error) => {
|
|
553
|
+
if (error) {
|
|
554
|
+
source.terminate();
|
|
555
|
+
target.terminate();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
function rawDataBuffer(data) {
|
|
561
|
+
if (Buffer.isBuffer(data))
|
|
562
|
+
return Buffer.from(data);
|
|
563
|
+
if (data instanceof ArrayBuffer)
|
|
564
|
+
return Buffer.from(data);
|
|
565
|
+
if (Array.isArray(data))
|
|
566
|
+
return Buffer.concat(data);
|
|
567
|
+
throw new TypeError("Unsupported WebSocket frame payload");
|
|
568
|
+
}
|
|
569
|
+
function rawDataBytes(data) {
|
|
570
|
+
if (Buffer.isBuffer(data))
|
|
571
|
+
return data.byteLength;
|
|
572
|
+
if (data instanceof ArrayBuffer)
|
|
573
|
+
return data.byteLength;
|
|
574
|
+
if (Array.isArray(data))
|
|
575
|
+
return data.reduce((total, part) => total + part.byteLength, 0);
|
|
576
|
+
throw new TypeError("Unsupported WebSocket frame payload");
|
|
577
|
+
}
|
|
578
|
+
function closeSocket(socket, code, reason) {
|
|
579
|
+
if (socket.readyState === WebSocket.CONNECTING) {
|
|
580
|
+
socket.terminate();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
584
|
+
socket.close(code, reason.slice(0, 120));
|
|
585
|
+
}
|
|
586
|
+
function normalizedCloseCode(code) {
|
|
587
|
+
return code >= 1000 && code <= 4999 && code !== 1005 && code !== 1006 ? code : 1011;
|
|
588
|
+
}
|
|
589
|
+
function rejectUpgrade(socket, status, message) {
|
|
590
|
+
if (socket.destroyed)
|
|
591
|
+
return;
|
|
592
|
+
const body = `${message}\n`;
|
|
593
|
+
const statusText = status === 401
|
|
594
|
+
? "Unauthorized"
|
|
595
|
+
: status === 404
|
|
596
|
+
? "Not Found"
|
|
597
|
+
: status === 503
|
|
598
|
+
? "Service Unavailable"
|
|
599
|
+
: "Bad Request";
|
|
600
|
+
socket.end(`HTTP/1.1 ${status} ${statusText}\r\nConnection: close\r\n` +
|
|
601
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
602
|
+
`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
603
|
+
}
|
|
604
|
+
function isAllowedSessionMutation(ctx, encodedSessionId) {
|
|
605
|
+
const prefix = `${SESSION_COLLECTION_API_PATH}/${encodedSessionId}`;
|
|
606
|
+
const suffix = ctx.path.slice(prefix.length);
|
|
607
|
+
if (ctx.method === "POST") {
|
|
608
|
+
return /^(?:\/interrupt|\/messages(?:\/queue)?|\/terminal\/start|\/resources|\/interactions\/[^/]+\/resolve|\/browser\/(?:open|close|pages)|\/browser\/pages\/[^/]+\/(?:close|activate|back|forward|reload|navigate)|\/emulator\/(?:attach|detach|tap|button|type|rotate|launch|gesture))$/.test(suffix);
|
|
609
|
+
}
|
|
610
|
+
return ctx.method === "DELETE" && /^\/resources\/[^/]+$/.test(suffix);
|
|
611
|
+
}
|
|
612
|
+
function denyMutation(ctx, grant) {
|
|
613
|
+
if (grant.access === "read_write")
|
|
614
|
+
return true;
|
|
615
|
+
ctx.status = 403;
|
|
616
|
+
ctx.body = { error: "session_portal_read_only" };
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
function expireHttpResponseAtGrantDeadline(ctx, expiresAt) {
|
|
620
|
+
const clear = scheduleSessionPortalExpiry(expiresAt, () => {
|
|
621
|
+
if (!ctx.res.writableEnded)
|
|
622
|
+
ctx.res.destroy(new Error("Session Portal grant expired"));
|
|
623
|
+
});
|
|
624
|
+
ctx.res.once("finish", clear);
|
|
625
|
+
ctx.res.once("close", clear);
|
|
626
|
+
}
|
|
627
|
+
export function scheduleSessionPortalExpiry(expiresAt, onExpire) {
|
|
628
|
+
const delay = Math.max(0, Date.parse(expiresAt) - Date.now());
|
|
629
|
+
const timer = setTimeout(onExpire, delay);
|
|
630
|
+
timer.unref?.();
|
|
631
|
+
return () => clearTimeout(timer);
|
|
632
|
+
}
|
|
633
|
+
function publicAuthorization(grant) {
|
|
634
|
+
return {
|
|
635
|
+
portalId: grant.portalId,
|
|
636
|
+
scope: grant.scope,
|
|
637
|
+
access: grant.access,
|
|
638
|
+
...(grant.sessionId ? { sessionId: grant.sessionId } : {}),
|
|
639
|
+
expiresAt: grant.expiresAt,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function injectPortalBootstrap(html, grant) {
|
|
643
|
+
const value = JSON.stringify(publicAuthorization(grant)).replace(/</g, "\\u003c");
|
|
644
|
+
return html.replace("</head>", `<script>window.rynxSessionPortal=${value}</script></head>`);
|
|
645
|
+
}
|
|
646
|
+
function isStaticAssetRequest(ctx) {
|
|
647
|
+
return (ctx.method === "GET" || ctx.method === "HEAD") &&
|
|
648
|
+
(ctx.path.startsWith("/assets/") || ctx.path === "/favicon.png" || ctx.path === "/favicon.svg");
|
|
649
|
+
}
|
|
650
|
+
function cookieName(portalId) {
|
|
651
|
+
return `rynx_session_portal_${portalId}`;
|
|
652
|
+
}
|
|
653
|
+
function cookieValue(raw, name) {
|
|
654
|
+
for (const part of raw?.split(";") ?? []) {
|
|
655
|
+
const index = part.indexOf("=");
|
|
656
|
+
if (index < 0 || part.slice(0, index).trim() !== name)
|
|
657
|
+
continue;
|
|
658
|
+
try {
|
|
659
|
+
return decodeURIComponent(part.slice(index + 1).trim());
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
function decoded(value) {
|
|
668
|
+
if (!value)
|
|
669
|
+
return undefined;
|
|
670
|
+
try {
|
|
671
|
+
return decodeURIComponent(value);
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function notFound(ctx) {
|
|
678
|
+
ctx.status = 404;
|
|
679
|
+
ctx.body = { error: "session_not_found" };
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
function methodNotAllowed(ctx) {
|
|
683
|
+
ctx.status = 405;
|
|
684
|
+
ctx.body = { error: "method_not_allowed" };
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
function serviceUnavailable(ctx) {
|
|
688
|
+
ctx.status = 503;
|
|
689
|
+
ctx.body = { error: "control_service_shutting_down" };
|
|
690
|
+
}
|
|
691
|
+
async function readJsonBody(request) {
|
|
692
|
+
const chunks = [];
|
|
693
|
+
let bytes = 0;
|
|
694
|
+
for await (const chunk of request) {
|
|
695
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
696
|
+
bytes += buffer.length;
|
|
697
|
+
if (bytes > 4_096)
|
|
698
|
+
throw new Error("request body too large");
|
|
699
|
+
chunks.push(buffer);
|
|
700
|
+
}
|
|
701
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
702
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
703
|
+
throw new Error("request body must be an object");
|
|
704
|
+
}
|
|
705
|
+
return parsed;
|
|
706
|
+
}
|
|
707
|
+
async function readBoundedResponse(response, maxBytes, signal) {
|
|
708
|
+
const chunks = [];
|
|
709
|
+
let bytes = 0;
|
|
710
|
+
const abort = () => response.destroy(signal?.reason instanceof Error ? signal.reason : undefined);
|
|
711
|
+
if (signal?.aborted)
|
|
712
|
+
abort();
|
|
713
|
+
else
|
|
714
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
715
|
+
try {
|
|
716
|
+
for await (const chunk of response) {
|
|
717
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
718
|
+
bytes += buffer.byteLength;
|
|
719
|
+
if (bytes > maxBytes)
|
|
720
|
+
throw new Error("Control response is too large");
|
|
721
|
+
chunks.push(buffer);
|
|
722
|
+
}
|
|
723
|
+
return Buffer.concat(chunks);
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
signal?.removeEventListener("abort", abort);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function closeWebSocketServer(server) {
|
|
730
|
+
return new Promise((resolve, reject) => {
|
|
731
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function closeHttpServerImmediately(server) {
|
|
735
|
+
return new Promise((resolve, reject) => {
|
|
736
|
+
if (!server.listening) {
|
|
737
|
+
resolve();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
741
|
+
server.closeAllConnections?.();
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function redeemPage() {
|
|
745
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rynx Sessions</title><style>body{font:14px Inter,system-ui;margin:0;display:grid;min-height:100vh;place-items:center;background:#f6f6f8;color:#17171b}.box{text-align:center}.spin{width:22px;height:22px;margin:0 auto 14px;border:2px solid #d8d8df;border-top-color:#6d5ce7;border-radius:50%;animation:s .8s linear infinite}@keyframes s{to{transform:rotate(360deg)}}</style></head><body><main class="box"><div class="spin"></div><p id="status">正在打开 Rynx Sessions…</p></main><script>(()=>{const el=document.getElementById('status');const p=new URLSearchParams(location.hash.slice(1));const ticket=p.get('ticket');history.replaceState(null,'',location.pathname);if(!ticket){el.textContent='访问链接缺少票据。';return}fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ticket})}).then(async r=>{const b=await r.json();if(!r.ok)throw new Error(b.error||'票据兑换失败');location.replace(b.location)}).catch(e=>{el.textContent='无法打开:'+e.message})})();</script></body></html>`;
|
|
746
|
+
}
|