@tesser-sh/gateway 0.0.1 → 0.54.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/index.js +579 -373
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// src/cloudflare.ts
|
|
2
|
-
import { DurableObject } from "cloudflare:workers";
|
|
3
|
-
|
|
4
1
|
// ../front/src/page.ts
|
|
5
2
|
var FAVICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="4" fill="#000000"/><g transform="translate(2.4 2.4) scale(0.8)"><path fill="#f0f1e9" d="M11.5 11.5H2A9.5 9.5 0 0 1 11.5 2ZM12.5 12.5H22A9.5 9.5 0 0 1 12.5 22Z"/><path fill="#fbffc9" d="M12.5 2H22V11.5H12.5Z"/></g></svg>')}`;
|
|
6
3
|
function escapeHtml(text) {
|
|
@@ -46,10 +43,8 @@ li .name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
|
46
43
|
.ports a { color: inherit; text-decoration: none; }
|
|
47
44
|
.ports a:hover { color: var(--bone); text-decoration: underline; text-decoration-color: var(--remote); text-underline-offset: 3px; }
|
|
48
45
|
tesser-use { color: var(--faint); }
|
|
49
|
-
tesser-wake { display: block; margin-top: 24px; }
|
|
50
46
|
.bad { margin-top: 24px; color: var(--bad); font-size: 13px; }
|
|
51
47
|
.open { margin-top: 24px; padding: 9px 16px; background: var(--remote); color: #171717; border: 0; font: 500 14px var(--sans); cursor: pointer; }
|
|
52
|
-
.open:disabled { background: var(--rule); color: var(--dim); cursor: default; }
|
|
53
48
|
a.open { display: inline-block; text-decoration: none; }
|
|
54
49
|
.sub { margin-top: 8px; color: var(--dim); }
|
|
55
50
|
.steps { list-style: none; padding: 0; margin-top: 24px; }
|
|
@@ -101,9 +96,11 @@ document.addEventListener("click", (event) => {
|
|
|
101
96
|
// ../front/src/viewer.ts
|
|
102
97
|
var STATUS = {
|
|
103
98
|
waking: 503,
|
|
99
|
+
asleep: 503,
|
|
104
100
|
booting: 503,
|
|
105
101
|
"not-answering": 504,
|
|
106
102
|
stopped: 502,
|
|
103
|
+
idle: 502,
|
|
107
104
|
"wake-failed": 503,
|
|
108
105
|
"no-port": 404,
|
|
109
106
|
"not-shared": 404,
|
|
@@ -116,12 +113,16 @@ function viewerText(state) {
|
|
|
116
113
|
switch (state.kind) {
|
|
117
114
|
case "waking":
|
|
118
115
|
return `${state.box.name} is waking up; retry shortly`;
|
|
116
|
+
case "asleep":
|
|
117
|
+
return `${state.box.name} is asleep; open it in a browser to wake it`;
|
|
119
118
|
case "booting":
|
|
120
119
|
return `${state.box.name} is booting; retry shortly`;
|
|
121
120
|
case "not-answering":
|
|
122
121
|
return `${state.box.name} isn't answering on port ${state.port}`;
|
|
123
122
|
case "stopped":
|
|
124
123
|
return `${state.box.name} has stopped`;
|
|
124
|
+
case "idle":
|
|
125
|
+
return `nothing is running on ${state.box.name}`;
|
|
125
126
|
case "wake-failed":
|
|
126
127
|
return `${state.box.name} didn't wake up: ${state.reason}`;
|
|
127
128
|
case "no-port":
|
|
@@ -146,7 +147,10 @@ function steps(stage, port, seconds) {
|
|
|
146
147
|
function who(box) {
|
|
147
148
|
return box.sharedBy === null ? "" : `<p class="who">Shared by ${escapeHtml(box.sharedBy)}</p>`;
|
|
148
149
|
}
|
|
149
|
-
function
|
|
150
|
+
function cmd(command) {
|
|
151
|
+
return `<div class="cmd"><code>${escapeHtml(command)}</code><button type="button" data-copy="${escapeHtml(command)}">Copy</button></div>`;
|
|
152
|
+
}
|
|
153
|
+
function body(state, owner) {
|
|
150
154
|
switch (state.kind) {
|
|
151
155
|
case "waking":
|
|
152
156
|
return {
|
|
@@ -155,6 +159,13 @@ function body(state) {
|
|
|
155
159
|
retry: !0,
|
|
156
160
|
rest: `${who(state.box)}${steps(state.stage, state.port, state.seconds)}<p class="hint">Usually about 30 seconds. This page opens the app by itself.</p>`
|
|
157
161
|
};
|
|
162
|
+
case "asleep":
|
|
163
|
+
return {
|
|
164
|
+
headline: `${state.box.name} is asleep`,
|
|
165
|
+
spinner: !1,
|
|
166
|
+
retry: !1,
|
|
167
|
+
rest: `${who(state.box)}<p class="sub">It went to sleep while nobody was using it.</p><a class="open" href="">Wake it</a>`
|
|
168
|
+
};
|
|
158
169
|
case "booting":
|
|
159
170
|
return { headline: `${state.box.name} is booting`, spinner: !0, retry: !0, rest: `${who(state.box)}<p class="hint">A fresh machine takes a minute or two. This page opens the app by itself.</p>` };
|
|
160
171
|
case "not-answering":
|
|
@@ -162,21 +173,28 @@ function body(state) {
|
|
|
162
173
|
headline: `${state.box.name} isn't answering on port ${state.port}`,
|
|
163
174
|
spinner: !0,
|
|
164
175
|
retry: !0,
|
|
165
|
-
rest: `${who(state.box)}<p class="hint">The box is up but the app hasn't answered yet. Still trying.</p
|
|
176
|
+
rest: `${who(state.box)}<p class="hint">The box is up but the app hasn't answered yet. Still trying.</p>${owner ? cmd(`tesser logs ${state.box.boxId} -f`) : ""}`
|
|
166
177
|
};
|
|
167
178
|
case "stopped":
|
|
168
179
|
return {
|
|
169
180
|
headline: `${state.box.name} has stopped`,
|
|
170
181
|
spinner: !1,
|
|
171
182
|
retry: !0,
|
|
172
|
-
rest: `${who(state.box)}<p class="sub">The app on this box exited. ${state.box.sharedBy === null ? "Ask whoever shared it" : "Ask them"} to restart it.</p>`
|
|
183
|
+
rest: owner ? cmd(`tesser logs ${state.box.boxId}`) : `${who(state.box)}<p class="sub">The app on this box exited. ${state.box.sharedBy === null ? "Ask whoever shared it" : "Ask them"} to restart it.</p>`
|
|
184
|
+
};
|
|
185
|
+
case "idle":
|
|
186
|
+
return {
|
|
187
|
+
headline: `Nothing is running on ${state.box.name}`,
|
|
188
|
+
spinner: !1,
|
|
189
|
+
retry: !0,
|
|
190
|
+
rest: owner ? cmd("tesser dev") : `${who(state.box)}<p class="sub">${state.box.sharedBy === null ? "Ask whoever shared it" : "Ask them"} to start the app.</p>`
|
|
173
191
|
};
|
|
174
192
|
case "wake-failed":
|
|
175
193
|
return {
|
|
176
194
|
headline: `${state.box.name} didn't wake up`,
|
|
177
195
|
spinner: !1,
|
|
178
196
|
retry: !1,
|
|
179
|
-
rest: `${who(state.box)}<p class="bad">${escapeHtml(state.reason)}</p><
|
|
197
|
+
rest: `${who(state.box)}<p class="bad">${escapeHtml(state.reason)}</p><a class="open" href="">Try again</a>`
|
|
180
198
|
};
|
|
181
199
|
case "no-port":
|
|
182
200
|
return {
|
|
@@ -191,12 +209,12 @@ function body(state) {
|
|
|
191
209
|
return { headline: "Can't check what's shared", spinner: !0, retry: !0, rest: `<p class="sub">This gateway can't reach tesser right now. Retrying.</p>` };
|
|
192
210
|
}
|
|
193
211
|
}
|
|
194
|
-
function viewerPage(state) {
|
|
195
|
-
let { headline, spinner, retry, rest } = body(state), main = `<main>
|
|
212
|
+
function viewerPage(state, owner = !1) {
|
|
213
|
+
let { headline, spinner, retry, rest } = body(state, owner), main = `<main>
|
|
196
214
|
<h1>${spinner ? '<span class="sq"></span>' : ""}${escapeHtml(headline)}</h1>
|
|
197
215
|
${rest}
|
|
198
216
|
</main>`;
|
|
199
|
-
return shell(`${headline} · tesser`, main, retry ? RETRY : "",
|
|
217
|
+
return shell(`${headline} · tesser`, main, retry ? RETRY : "", owner);
|
|
200
218
|
}
|
|
201
219
|
|
|
202
220
|
// ../protocol/src/table.ts
|
|
@@ -220,6 +238,12 @@ function parseGatewayLabel(label) {
|
|
|
220
238
|
return null;
|
|
221
239
|
return { boxId, port: Number(port) };
|
|
222
240
|
}
|
|
241
|
+
function gatewayHostBox(gatewayHost, hostname) {
|
|
242
|
+
let suffix = `.${gatewayHost.toLowerCase()}`, host = hostname.toLowerCase().replace(/\.$/, "");
|
|
243
|
+
if (!host.endsWith(suffix))
|
|
244
|
+
return null;
|
|
245
|
+
return parseGatewayLabel(host.slice(0, -suffix.length).split(".").pop())?.boxId ?? null;
|
|
246
|
+
}
|
|
223
247
|
|
|
224
248
|
// ../protocol/src/mux.ts
|
|
225
249
|
var MUX_WINDOW = 262144, MUX_CHUNK = 65536;
|
|
@@ -317,7 +341,182 @@ function decodeFrame(buf) {
|
|
|
317
341
|
}
|
|
318
342
|
|
|
319
343
|
// src/cloudflare.ts
|
|
320
|
-
import {
|
|
344
|
+
import { Effect as Effect5, Schema as Schema3 } from "effect";
|
|
345
|
+
|
|
346
|
+
// src/jwt.ts
|
|
347
|
+
import { Clock, Effect, Option } from "effect";
|
|
348
|
+
var KEYS_TTL_MS = 600000, REFETCH_MIN_MS = 30000, CLOCK_SKEW_S = 60, keySets = /* @__PURE__ */ new Map;
|
|
349
|
+
function base64url(text) {
|
|
350
|
+
let b64 = text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=");
|
|
351
|
+
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
352
|
+
}
|
|
353
|
+
function json2(part) {
|
|
354
|
+
try {
|
|
355
|
+
let value = JSON.parse((/* @__PURE__ */ new TextDecoder()).decode(base64url(part)));
|
|
356
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
var fetchKeys = (jwksUri, now) => Effect.tryPromise(async () => {
|
|
362
|
+
let res = await fetch(jwksUri);
|
|
363
|
+
if (!res.ok)
|
|
364
|
+
throw Error(`${jwksUri} answered ${res.status}`);
|
|
365
|
+
let body = await res.json(), keys = /* @__PURE__ */ new Map;
|
|
366
|
+
for (let jwk of body.keys ?? []) {
|
|
367
|
+
if (typeof jwk.kid !== "string" || jwk.kty !== "RSA")
|
|
368
|
+
continue;
|
|
369
|
+
keys.set(jwk.kid, await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, !1, ["verify"]));
|
|
370
|
+
}
|
|
371
|
+
let set = { keys, fetchedAt: now };
|
|
372
|
+
return keySets.set(jwksUri, set), set;
|
|
373
|
+
}).pipe(Effect.option), keyFor = Effect.fn("Jwt.keyFor")(function* (jwksUri, kid, now) {
|
|
374
|
+
let set = keySets.get(jwksUri);
|
|
375
|
+
if (set === void 0 || now - set.fetchedAt > KEYS_TTL_MS)
|
|
376
|
+
set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
|
|
377
|
+
if (set !== void 0 && !set.keys.has(kid) && now - set.fetchedAt > REFETCH_MIN_MS)
|
|
378
|
+
set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
|
|
379
|
+
return set?.keys.get(kid) ?? null;
|
|
380
|
+
}), verifyRs256 = Effect.fn("Jwt.verifyRs256")(function* (token, issuer, jwksUri) {
|
|
381
|
+
let parts = token.split(".");
|
|
382
|
+
if (parts.length !== 3)
|
|
383
|
+
return null;
|
|
384
|
+
let [h, p, s] = parts, header = json2(h), claims = json2(p);
|
|
385
|
+
if (header === null || claims === null || header.alg !== "RS256" || typeof header.kid !== "string" || claims.iss !== issuer)
|
|
386
|
+
return null;
|
|
387
|
+
let nowMs = yield* Clock.currentTimeMillis, key = yield* keyFor(jwksUri, header.kid, nowMs);
|
|
388
|
+
if (key === null)
|
|
389
|
+
return null;
|
|
390
|
+
let signature = base64url(s);
|
|
391
|
+
if (!(yield* Effect.tryPromise(() => crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, (/* @__PURE__ */ new TextEncoder()).encode(`${h}.${p}`))).pipe(Effect.orElseSucceed(() => !1))))
|
|
392
|
+
return null;
|
|
393
|
+
let now = Math.floor(nowMs / 1000);
|
|
394
|
+
if (typeof claims.exp !== "number" || claims.exp + CLOCK_SKEW_S < now)
|
|
395
|
+
return null;
|
|
396
|
+
if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_S > now)
|
|
397
|
+
return null;
|
|
398
|
+
return claims;
|
|
399
|
+
});
|
|
400
|
+
function audienceIncludes(aud, expected) {
|
|
401
|
+
return aud === expected || Array.isArray(aud) && aud.includes(expected);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/relay.ts
|
|
405
|
+
import { DurableObject } from "cloudflare:workers";
|
|
406
|
+
import { Clock as Clock3, Duration as Duration2, Effect as Effect4, Layer as Layer2, Schema as Schema2 } from "effect";
|
|
407
|
+
|
|
408
|
+
// ../front/src/http.ts
|
|
409
|
+
var HOP_BY_HOP = [
|
|
410
|
+
"connection",
|
|
411
|
+
"keep-alive",
|
|
412
|
+
"proxy-authenticate",
|
|
413
|
+
"proxy-authorization",
|
|
414
|
+
"proxy-connection",
|
|
415
|
+
"te",
|
|
416
|
+
"trailer",
|
|
417
|
+
"transfer-encoding",
|
|
418
|
+
"upgrade"
|
|
419
|
+
];
|
|
420
|
+
function withoutHopByHop(source) {
|
|
421
|
+
let headers = new Headers(source), connectionHeaders = headers.get("connection")?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
|
|
422
|
+
for (let name of [...HOP_BY_HOP, ...connectionHeaders])
|
|
423
|
+
headers.delete(name);
|
|
424
|
+
return headers;
|
|
425
|
+
}
|
|
426
|
+
function wantsDocument(request) {
|
|
427
|
+
return request.method === "GET" && (request.headers.get("sec-fetch-mode") === "navigate" || request.headers.get("accept")?.toLowerCase().includes("text/html") === !0);
|
|
428
|
+
}
|
|
429
|
+
function userNavigation(request) {
|
|
430
|
+
return request.headers.get("sec-fetch-mode") === "navigate" && request.headers.get("sec-fetch-user") === "?1";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ../proxy/src/core.ts
|
|
434
|
+
import { Clock as Clock2, Context, Duration, Effect as Effect2, Schedule, Schema } from "effect";
|
|
435
|
+
|
|
436
|
+
class BoxNotShared extends Schema.TaggedError()("BoxNotShared", { boxId: Schema.String }) {
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
class DirectoryUnavailable extends Schema.TaggedError()("DirectoryUnavailable", { reason: Schema.String }) {
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
class BoxAsleep extends Schema.TaggedError()("BoxAsleep", { boxId: Schema.String }) {
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
class WakeFailed extends Schema.TaggedError()("WakeFailed", { boxId: Schema.String, reason: Schema.String }) {
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
class UpstreamRefused extends Schema.TaggedError()("UpstreamRefused", { boxId: Schema.String, port: Schema.Number }) {
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
class Resolver extends Context.Service()("@tesser/proxy/Resolver") {
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
class Waker extends Context.Service()("@tesser/proxy/Waker") {
|
|
455
|
+
}
|
|
456
|
+
var WAKING_WINDOW = Duration.minutes(5), startingRetry = { schedule: Schedule.spaced(Duration.seconds(1)), times: 60 }, respond = (response) => ({ _tag: "response", response });
|
|
457
|
+
function viewerResponse(request, state, owner = !1) {
|
|
458
|
+
let status = viewerStatus(state);
|
|
459
|
+
if (wantsDocument(request))
|
|
460
|
+
return new Response(viewerPage(state, owner), { status, headers: STATUS_HEADERS });
|
|
461
|
+
return new Response(`${viewerText(state)}
|
|
462
|
+
`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-tesser-status": "1" } });
|
|
463
|
+
}
|
|
464
|
+
var page = (request, state) => Effect2.map(Resolver, ({ owner }) => respond(viewerResponse(request, state, owner)));
|
|
465
|
+
function viewerBox(box) {
|
|
466
|
+
return { boxId: box.boxId, name: box.name, sharedBy: box.sharedBy };
|
|
467
|
+
}
|
|
468
|
+
var wakingPage = Effect2.fn("Proxy.wakingPage")(function* (request, box, port, stage, since) {
|
|
469
|
+
let now = yield* Clock2.currentTimeMillis;
|
|
470
|
+
return yield* page(request, { kind: "waking", box: viewerBox(box), stage, port, seconds: Math.max(0, Math.round((now - since) / 1000)) });
|
|
471
|
+
}), refused = Effect2.fn("Proxy.refused")(function* (request, box, port) {
|
|
472
|
+
let status = yield* (yield* Waker).status(box.boxId), now = yield* Clock2.currentTimeMillis;
|
|
473
|
+
if (box.app === "exited")
|
|
474
|
+
return yield* page(request, { kind: "stopped", box: viewerBox(box) });
|
|
475
|
+
if (status !== null && now - status.since < Duration.toMillis(WAKING_WINDOW))
|
|
476
|
+
return yield* wakingPage(request, box, port, box.app === "running" ? "port" : "app", status.since);
|
|
477
|
+
if (box.app === "none")
|
|
478
|
+
return yield* page(request, { kind: "idle", box: viewerBox(box) });
|
|
479
|
+
return yield* page(request, { kind: "not-answering", box: viewerBox(box), port });
|
|
480
|
+
}), serve = Effect2.fn("Proxy.serve")(function* (request, route, box) {
|
|
481
|
+
let { port, path, headers } = route;
|
|
482
|
+
if (request.headers.get("upgrade")?.toLowerCase() === "websocket")
|
|
483
|
+
return { _tag: "websocket", box, port, path, headers };
|
|
484
|
+
let replayable = request.method === "GET" || request.method === "HEAD", document = wantsDocument(request), attempt = Effect2.suspend(() => route.fetch(box, new Request(`http://${headers.get("host")}${path}`, {
|
|
485
|
+
method: request.method,
|
|
486
|
+
headers,
|
|
487
|
+
body: replayable ? void 0 : request.body,
|
|
488
|
+
redirect: "manual",
|
|
489
|
+
signal: request.signal,
|
|
490
|
+
...replayable ? {} : { duplex: "half" }
|
|
491
|
+
})));
|
|
492
|
+
return yield* (document || !replayable ? attempt : attempt.pipe(Effect2.retry(startingRetry))).pipe(Effect2.flatMap((response) => Effect2.map(route.present(box, response), respond)), Effect2.catchTag("UpstreamRefused", () => document ? refused(request, box, port) : page(request, { kind: "not-answering", box: viewerBox(box), port })));
|
|
493
|
+
}), handle = Effect2.fn("Proxy.handle")(function* (request) {
|
|
494
|
+
let resolved = yield* (yield* Resolver).resolve(request);
|
|
495
|
+
if (resolved instanceof Response)
|
|
496
|
+
return respond(resolved);
|
|
497
|
+
let { box, port } = resolved, waker = yield* Waker;
|
|
498
|
+
if (box.power !== "awake") {
|
|
499
|
+
if (box.power === "asleep" && userNavigation(request))
|
|
500
|
+
yield* waker.wake(box.boxId);
|
|
501
|
+
let status = yield* waker.status(box.boxId);
|
|
502
|
+
if (box.power === "asleep" && status?.failure)
|
|
503
|
+
return yield* page(request, { kind: "wake-failed", box: viewerBox(box), reason: status.failure });
|
|
504
|
+
if (box.power === "asleep" && status === null)
|
|
505
|
+
return yield* page(request, { kind: "asleep", box: viewerBox(box) });
|
|
506
|
+
if (wantsDocument(request)) {
|
|
507
|
+
if (box.power === "launching" && status === null)
|
|
508
|
+
return yield* page(request, { kind: "booting", box: viewerBox(box) });
|
|
509
|
+
return yield* wakingPage(request, box, port, "machine", status?.since ?? (yield* Clock2.currentTimeMillis));
|
|
510
|
+
}
|
|
511
|
+
return yield* serve(request, resolved, yield* waker.awake(box.boxId));
|
|
512
|
+
}
|
|
513
|
+
return yield* serve(request, resolved, box);
|
|
514
|
+
}, (effect, request) => effect.pipe(Effect2.catchTags({
|
|
515
|
+
BoxAsleep: (e) => page(request, { kind: "asleep", box: { boxId: e.boxId, name: e.boxId, sharedBy: null } }),
|
|
516
|
+
BoxNotShared: () => page(request, { kind: "not-shared" }),
|
|
517
|
+
DirectoryUnavailable: () => page(request, { kind: "unavailable" }),
|
|
518
|
+
WakeFailed: (e) => page(request, { kind: "wake-failed", box: { boxId: e.boxId, name: e.boxId, sharedBy: null }, reason: e.reason })
|
|
519
|
+
})));
|
|
321
520
|
|
|
322
521
|
// ../front/src/csp.ts
|
|
323
522
|
var SCRIPT_DIRECTIVES = ["connect-src", "script-src"];
|
|
@@ -355,28 +554,6 @@ function scriptNonce(policy) {
|
|
|
355
554
|
return;
|
|
356
555
|
}
|
|
357
556
|
|
|
358
|
-
// ../front/src/http.ts
|
|
359
|
-
var HOP_BY_HOP = [
|
|
360
|
-
"connection",
|
|
361
|
-
"keep-alive",
|
|
362
|
-
"proxy-authenticate",
|
|
363
|
-
"proxy-authorization",
|
|
364
|
-
"proxy-connection",
|
|
365
|
-
"te",
|
|
366
|
-
"trailer",
|
|
367
|
-
"transfer-encoding",
|
|
368
|
-
"upgrade"
|
|
369
|
-
];
|
|
370
|
-
function withoutHopByHop(source) {
|
|
371
|
-
let headers2 = new Headers(source), connectionHeaders = headers2.get("connection")?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
|
|
372
|
-
for (let name of [...HOP_BY_HOP, ...connectionHeaders])
|
|
373
|
-
headers2.delete(name);
|
|
374
|
-
return headers2;
|
|
375
|
-
}
|
|
376
|
-
function wantsDocument(request) {
|
|
377
|
-
return request.method === "GET" && (request.headers.get("sec-fetch-mode") === "navigate" || request.headers.get("accept")?.toLowerCase().includes("text/html") === !0);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
557
|
// ../front/src/shim.ts
|
|
381
558
|
function rewriteUrl(input, ws, ctx) {
|
|
382
559
|
let url;
|
|
@@ -396,7 +573,16 @@ function rewriteUrl(input, ws, ctx) {
|
|
|
396
573
|
let port = Number(url.port || (url.protocol === "https:" || url.protocol === "wss:" ? 443 : 80));
|
|
397
574
|
if (!ctx.ports.includes(port))
|
|
398
575
|
return input;
|
|
399
|
-
return url.pathname = `${port === ctx.port ? "" : `/__tesser/${port}`}${url.pathname}`, url.protocol = ws ? page.protocol === "https:" ? "wss:" : "ws:" : page.protocol, url.port = "", url.host = name === page.hostname
|
|
576
|
+
return url.pathname = `${sub !== null ? `/__tesser/${sub}.${port}` : port === ctx.port ? "" : `/__tesser/${port}`}${url.pathname}`, url.protocol = ws ? page.protocol === "https:" ? "wss:" : "ws:" : page.protocol, url.port = "", url.host = name === page.hostname || sub !== null ? page.host : ctx.host, url.href;
|
|
577
|
+
}
|
|
578
|
+
function rewriteSrcset(value, to) {
|
|
579
|
+
let changed = !1, candidates = value.split(",").map((candidate) => {
|
|
580
|
+
let [url = "", ...descriptors] = candidate.trim().split(/\s+/), rewritten = to(url);
|
|
581
|
+
if (rewritten !== url)
|
|
582
|
+
changed = !0;
|
|
583
|
+
return [rewritten, ...descriptors].join(" ");
|
|
584
|
+
});
|
|
585
|
+
return changed ? candidates.join(", ") : value;
|
|
400
586
|
}
|
|
401
587
|
function shimSource() {
|
|
402
588
|
return `(() => {
|
|
@@ -410,6 +596,7 @@ function shimSource() {
|
|
|
410
596
|
const port = Number(params.get("port"));
|
|
411
597
|
const rewriteUrl = ${rewriteUrl.toString()};
|
|
412
598
|
const to = (input, ws) => rewriteUrl(String(input), ws, { href: location.href, host, ports, port });
|
|
599
|
+
const rewriteSrcset = ${rewriteSrcset.toString()};
|
|
413
600
|
const fetch = window.fetch;
|
|
414
601
|
window.fetch = function (input, init) {
|
|
415
602
|
if (!(input instanceof Request)) return fetch.call(this, to(input, false), init);
|
|
@@ -437,6 +624,51 @@ function shimSource() {
|
|
|
437
624
|
};
|
|
438
625
|
wrap("WebSocket", true);
|
|
439
626
|
wrap("EventSource", false);
|
|
627
|
+
const loads = [
|
|
628
|
+
["HTMLImageElement", ["src", "srcset"]],
|
|
629
|
+
["HTMLSourceElement", ["src", "srcset"]],
|
|
630
|
+
["HTMLScriptElement", ["src"]],
|
|
631
|
+
["HTMLIFrameElement", ["src"]],
|
|
632
|
+
["HTMLMediaElement", ["src"]],
|
|
633
|
+
["HTMLVideoElement", ["poster"]],
|
|
634
|
+
["HTMLEmbedElement", ["src"]],
|
|
635
|
+
["HTMLTrackElement", ["src"]],
|
|
636
|
+
["HTMLLinkElement", ["href"]],
|
|
637
|
+
].filter(([name]) => window[name]);
|
|
638
|
+
const fix = (name, value) => name === "srcset" ? rewriteSrcset(String(value), (url) => to(url, false)) : to(value, false);
|
|
639
|
+
const loaded = (element, name) => loads.some(([type, names]) => element instanceof window[type] && names.includes(name));
|
|
640
|
+
const setAttribute = Element.prototype.setAttribute;
|
|
641
|
+
Element.prototype.setAttribute = function (name, value) {
|
|
642
|
+
const lower = String(name).toLowerCase();
|
|
643
|
+
return setAttribute.call(this, name, loaded(this, lower) ? fix(lower, value) : value);
|
|
644
|
+
};
|
|
645
|
+
for (const [type, names] of loads) {
|
|
646
|
+
for (const name of names) {
|
|
647
|
+
const proto = window[type].prototype;
|
|
648
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
|
|
649
|
+
if (!descriptor?.set) continue;
|
|
650
|
+
Object.defineProperty(proto, name, { ...descriptor, set(value) { descriptor.set.call(this, fix(name, value)); } });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const selector = "img, source, script, iframe, video, audio, embed, track, link";
|
|
654
|
+
const sweep = (element) => {
|
|
655
|
+
for (const name of ["src", "srcset", "poster", "href"]) {
|
|
656
|
+
const value = element.getAttribute(name);
|
|
657
|
+
if (value !== null && loaded(element, name)) {
|
|
658
|
+
const rewritten = fix(name, value);
|
|
659
|
+
if (rewritten !== value) setAttribute.call(element, name, rewritten);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
new MutationObserver((records) => {
|
|
664
|
+
for (const record of records) {
|
|
665
|
+
for (const node of record.addedNodes) {
|
|
666
|
+
if (!(node instanceof Element)) continue;
|
|
667
|
+
if (node.matches(selector)) sweep(node);
|
|
668
|
+
for (const element of node.querySelectorAll(selector)) sweep(element);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}).observe(document, { childList: true, subtree: true });
|
|
440
672
|
const follow = (element, attribute) => {
|
|
441
673
|
const value = element?.getAttribute(attribute);
|
|
442
674
|
if (!value) return;
|
|
@@ -458,11 +690,11 @@ function shimTag(params, nonce) {
|
|
|
458
690
|
return `<script${nonce ? ` nonce="${nonce}"` : ""} src="/__tesser/shim.js?${query.toString().replace(/&/g, "&")}"></script>`;
|
|
459
691
|
}
|
|
460
692
|
|
|
461
|
-
// src/
|
|
462
|
-
import {
|
|
693
|
+
// ../proxy/src/share.ts
|
|
694
|
+
import { Context as Context2, Effect as Effect3, Layer } from "effect";
|
|
463
695
|
|
|
464
|
-
// src/route.ts
|
|
465
|
-
var PORT_PATH = /^\/__tesser\/(\d{1,5})(\/.*)?$/;
|
|
696
|
+
// ../proxy/src/route.ts
|
|
697
|
+
var PORT_PATH = /^\/__tesser\/(?:([a-z0-9-]+(?:\.[a-z0-9-]+)*)\.)?(\d{1,5})(\/.*)?$/;
|
|
466
698
|
function parseTarget(gatewayHost, url) {
|
|
467
699
|
let bare = url.hostname.toLowerCase().replace(/\.$/, ""), suffix = `.${gatewayHost.toLowerCase()}`;
|
|
468
700
|
if (!bare.endsWith(suffix))
|
|
@@ -470,14 +702,14 @@ function parseTarget(gatewayHost, url) {
|
|
|
470
702
|
let labels = bare.slice(0, -suffix.length).split("."), label = labels[labels.length - 1], parsed = parseGatewayLabel(label);
|
|
471
703
|
if (parsed === null)
|
|
472
704
|
return null;
|
|
473
|
-
let gateway = `${gatewayHost}${url.port ? `:${url.port}` : ""}`,
|
|
705
|
+
let gateway = `${gatewayHost}${url.port ? `:${url.port}` : ""}`, sub = labels.slice(0, -1).join("."), base = { boxId: parsed.boxId, sub, pageSub: sub, boxHost: `${label}.${gateway}`, gateway, root: parsed.port }, match = PORT_PATH.exec(url.pathname);
|
|
474
706
|
if (match === null)
|
|
475
707
|
return { ...base, port: null, path: `${url.pathname}${url.search}` };
|
|
476
|
-
return { ...base, port: Number(match[
|
|
708
|
+
return { ...base, sub: match[1] ?? base.sub, port: Number(match[2]), path: `${match[3] ?? "/"}${url.search}` };
|
|
477
709
|
}
|
|
478
|
-
function parseUrl(raw) {
|
|
710
|
+
function parseUrl(raw, base) {
|
|
479
711
|
try {
|
|
480
|
-
return new URL(raw);
|
|
712
|
+
return new URL(raw, base);
|
|
481
713
|
} catch {
|
|
482
714
|
return null;
|
|
483
715
|
}
|
|
@@ -489,54 +721,29 @@ function portOrigin(protocol, target, port, first) {
|
|
|
489
721
|
return `${protocol}//${target.sub === "" ? "" : `${target.sub}.`}${gatewayLabel(target.boxId, port === first ? null : port)}.${target.gateway}`;
|
|
490
722
|
}
|
|
491
723
|
|
|
492
|
-
// src/
|
|
493
|
-
class
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
class DirectoryUnavailable extends Schema.TaggedError()("DirectoryUnavailable", { reason: Schema.String }) {
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
class WakeFailed extends Schema.TaggedError()("WakeFailed", { boxId: Schema.String, reason: Schema.String }) {
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
class UpstreamRefused extends Schema.TaggedError()("UpstreamRefused", { boxId: Schema.String, port: Schema.Number }) {
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
class BoxDirectory extends Context.Service()("@tesser/gateway/BoxDirectory") {
|
|
724
|
+
// ../proxy/src/share.ts
|
|
725
|
+
class BoxDirectory extends Context2.Service()("@tesser/proxy/BoxDirectory") {
|
|
506
726
|
}
|
|
507
727
|
|
|
508
|
-
class
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
class Upstream extends Context.Service()("@tesser/gateway/Upstream") {
|
|
512
|
-
}
|
|
513
|
-
var WAKING_WINDOW = Duration.minutes(5), startingRetry = { schedule: Schedule.spaced(Duration.seconds(1)), times: 60 }, respond = (response) => ({ _tag: "response", response });
|
|
514
|
-
function page(request, state) {
|
|
515
|
-
let status = viewerStatus(state);
|
|
516
|
-
if (wantsDocument(request))
|
|
517
|
-
return respond(new Response(viewerPage(state), { status, headers: STATUS_HEADERS }));
|
|
518
|
-
return respond(new Response(`${viewerText(state)}
|
|
519
|
-
`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-tesser-status": "1" } }));
|
|
520
|
-
}
|
|
521
|
-
function viewerBox(box) {
|
|
522
|
-
return { name: box.name, sharedBy: box.sharedBy };
|
|
728
|
+
class Upstream extends Context2.Service()("@tesser/proxy/Upstream") {
|
|
523
729
|
}
|
|
730
|
+
var VIEWER_COOKIE = "__Host-tesser-viewer", GATEWAY_COOKIES = /* @__PURE__ */ new Set(["CF_Authorization", VIEWER_COOKIE]);
|
|
524
731
|
function upstreamHeaders(request, target, port, root) {
|
|
525
|
-
let
|
|
526
|
-
for (let name of [...
|
|
732
|
+
let headers = withoutHopByHop(request.headers);
|
|
733
|
+
for (let name of [...headers.keys()])
|
|
527
734
|
if (name.startsWith("cf-access-") || name.startsWith("x-forwarded-"))
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
let cookie =
|
|
735
|
+
headers.delete(name);
|
|
736
|
+
headers.delete("forwarded"), headers.delete("x-tesser-viewer"), headers.delete("x-tesser-ticket");
|
|
737
|
+
let cookie = headers.get("cookie")?.split(";").filter((c) => !GATEWAY_COOKIES.has(c.trim().split("=")[0])).join(";").trim();
|
|
531
738
|
if (cookie)
|
|
532
|
-
|
|
739
|
+
headers.set("cookie", cookie);
|
|
533
740
|
else
|
|
534
|
-
|
|
535
|
-
|
|
741
|
+
headers.delete("cookie");
|
|
742
|
+
headers.set("host", presentedHost(target.sub, port)), headers.set("x-forwarded-for", "127.0.0.1"), headers.set("x-forwarded-proto", "http"), headers.set("accept-encoding", "identity");
|
|
536
743
|
let origin = request.headers.get("origin");
|
|
537
744
|
if (origin !== null && parseUrl(origin)?.host === new URL(request.url).host)
|
|
538
|
-
|
|
539
|
-
return
|
|
745
|
+
headers.set("origin", `http://${presentedHost(target.pageSub, root)}`);
|
|
746
|
+
return headers;
|
|
540
747
|
}
|
|
541
748
|
function isHtmlDocument(request, response) {
|
|
542
749
|
if (!wantsDocument(request) || response.status < 200 || response.status >= 300)
|
|
@@ -544,155 +751,67 @@ function isHtmlDocument(request, response) {
|
|
|
544
751
|
return (response.headers.get("content-type")?.toLowerCase() ?? "").includes("text/html") && !response.headers.has("content-range");
|
|
545
752
|
}
|
|
546
753
|
function downstream(request, response, upstreamUrl, ctx, shownOrigin) {
|
|
547
|
-
let
|
|
548
|
-
|
|
549
|
-
let location =
|
|
754
|
+
let headers = withoutHopByHop(response.headers);
|
|
755
|
+
headers.delete("content-encoding"), headers.delete("content-length");
|
|
756
|
+
let location = headers.get("location");
|
|
550
757
|
if (location !== null)
|
|
551
|
-
|
|
758
|
+
headers.set("location", rewriteUrl(parseUrl(location) === null ? new URL(location, upstreamUrl).href : location, !1, ctx));
|
|
552
759
|
let origin = request.headers.get("origin");
|
|
553
|
-
if (origin !== null && shownOrigin !== null &&
|
|
554
|
-
|
|
555
|
-
let init = { status: response.status, statusText: response.statusText, headers
|
|
760
|
+
if (origin !== null && shownOrigin !== null && headers.get("access-control-allow-origin") === shownOrigin)
|
|
761
|
+
headers.set("access-control-allow-origin", origin);
|
|
762
|
+
let init = { status: response.status, statusText: response.statusText, headers };
|
|
556
763
|
if (!isHtmlDocument(request, response))
|
|
557
764
|
return new Response(response.body, init);
|
|
558
|
-
let csp =
|
|
765
|
+
let csp = headers.get("content-security-policy");
|
|
559
766
|
if (csp !== null)
|
|
560
|
-
|
|
767
|
+
headers.set("content-security-policy", allowSelfScripts(csp));
|
|
561
768
|
let tag = shimTag({ host: ctx.host, ports: ctx.ports, port: ctx.port }, csp === null ? void 0 : scriptNonce(csp)), placed = !1, inject = {
|
|
562
769
|
element(element) {
|
|
563
770
|
if (placed)
|
|
564
771
|
return;
|
|
565
772
|
placed = !0, element.prepend(tag, { html: !0 });
|
|
566
773
|
}
|
|
567
|
-
}, link = (attribute) => ({
|
|
774
|
+
}, link = (attribute, rewrite = (value) => rewriteUrl(value, !1, ctx)) => ({
|
|
568
775
|
element(element) {
|
|
569
776
|
let value = element.getAttribute(attribute);
|
|
570
777
|
if (value === null)
|
|
571
778
|
return;
|
|
572
|
-
let rewritten =
|
|
779
|
+
let rewritten = rewrite(value);
|
|
573
780
|
if (rewritten !== value)
|
|
574
781
|
element.setAttribute(attribute, rewritten);
|
|
575
782
|
}
|
|
576
783
|
});
|
|
577
|
-
return new HTMLRewriter().on("head", inject).on("body", inject).on("a[href], area[href], link[href]", link("href")).on("form[action]", link("action")).on("button[formaction], input[formaction]", link("formaction")).on("script[src], img[src], iframe[src], source[src], video[src], audio[src], embed[src]", link("src")).transform(new Response(response.body, init));
|
|
784
|
+
return new HTMLRewriter().on("head", inject).on("body", inject).on("a[href], area[href], link[href]", link("href")).on("form[action]", link("action")).on("button[formaction], input[formaction]", link("formaction")).on("script[src], img[src], iframe[src], source[src], video[src], audio[src], embed[src]", link("src")).on("img[srcset], source[srcset]", link("srcset", (value) => rewriteSrcset(value, (url) => rewriteUrl(url, !1, ctx)))).transform(new Response(response.body, init));
|
|
578
785
|
}
|
|
579
|
-
var
|
|
580
|
-
let
|
|
581
|
-
return
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
method: request.method,
|
|
595
|
-
headers: headers2,
|
|
596
|
-
body: replayable ? void 0 : request.body,
|
|
597
|
-
redirect: "manual",
|
|
598
|
-
signal: request.signal,
|
|
599
|
-
...replayable ? {} : { duplex: "half" }
|
|
600
|
-
})));
|
|
601
|
-
return yield* (document || !replayable ? attempt : attempt.pipe(Effect.retry(startingRetry))).pipe(Effect.map((response) => respond(downstream(request, response, upstreamUrl, ctx, shownOrigin))), Effect.catchTag("UpstreamRefused", () => document ? refused(request, box, port) : Effect.succeed(page(request, { kind: "not-answering", box: viewerBox(box), port }))));
|
|
602
|
-
}), handle = Effect.fn("Gateway.handle")(function* (request) {
|
|
603
|
-
let directory = yield* BoxDirectory, waker = yield* Waker, url = new URL(request.url), host = yield* directory.host, target = parseTarget(host, url);
|
|
604
|
-
if (target === null)
|
|
605
|
-
return page(request, { kind: "not-shared" });
|
|
606
|
-
if (target.port === null && url.pathname === "/__tesser/shim.js")
|
|
607
|
-
return respond(new Response(shimSource(), { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=300" } }));
|
|
608
|
-
let box = yield* directory.lookup(target.boxId), first = box.ports[0];
|
|
609
|
-
if (first === void 0)
|
|
610
|
-
return page(request, { kind: "no-port", box: viewerBox(box), port: target.port ?? target.root ?? 0, ports: [] });
|
|
611
|
-
let root = target.root ?? first, port = target.port ?? root;
|
|
612
|
-
if (!box.ports.includes(root) || !box.ports.includes(port)) {
|
|
613
|
-
let ports = box.ports.map((p) => ({ port: p, href: `${portOrigin(url.protocol, target, p, first)}/` }));
|
|
614
|
-
return page(request, { kind: "no-port", box: viewerBox(box), port: box.ports.includes(root) ? port : root, ports });
|
|
615
|
-
}
|
|
616
|
-
if (target.port !== null && request.headers.get("sec-fetch-mode") === "navigate")
|
|
617
|
-
return respond(new Response(null, { status: 307, headers: { location: `${portOrigin(url.protocol, target, port, first)}${target.path}`, "cache-control": "no-store" } }));
|
|
618
|
-
if (box.power !== "awake") {
|
|
619
|
-
let navigate = request.headers.get("sec-fetch-mode") === "navigate";
|
|
620
|
-
if (box.power === "asleep")
|
|
621
|
-
yield* waker.wake(box.boxId, navigate);
|
|
622
|
-
let status = yield* waker.status(box.boxId);
|
|
623
|
-
if (status?.failure && box.power === "asleep")
|
|
624
|
-
return page(request, { kind: "wake-failed", box: viewerBox(box), reason: status.failure });
|
|
625
|
-
if (wantsDocument(request)) {
|
|
626
|
-
if (box.power === "launching" && status === null)
|
|
627
|
-
return page(request, { kind: "booting", box: viewerBox(box) });
|
|
628
|
-
return yield* wakingPage(request, box, port, "machine", status?.since ?? (yield* Clock.currentTimeMillis));
|
|
786
|
+
var shareResolver = Layer.effect(Resolver, Effect3.gen(function* () {
|
|
787
|
+
let directory = yield* BoxDirectory, upstream = yield* Upstream;
|
|
788
|
+
return { owner: !1, resolve: Effect3.fn("Share.resolve")(function* (request) {
|
|
789
|
+
let url = new URL(request.url), target = parseTarget(yield* directory.host, url);
|
|
790
|
+
if (target === null)
|
|
791
|
+
return viewerResponse(request, { kind: "not-shared" });
|
|
792
|
+
if (target.port === null && url.pathname === "/__tesser/shim.js")
|
|
793
|
+
return new Response(shimSource(), { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=300" } });
|
|
794
|
+
let box = yield* directory.lookup(target.boxId), first = box.ports[0];
|
|
795
|
+
if (first === void 0)
|
|
796
|
+
return viewerResponse(request, { kind: "no-port", box: viewerBox(box), port: target.port ?? target.root ?? 0, ports: [] });
|
|
797
|
+
let root = target.root ?? first, port = target.port ?? root;
|
|
798
|
+
if (!box.ports.includes(root) || !box.ports.includes(port)) {
|
|
799
|
+
let ports = box.ports.map((p) => ({ port: p, href: `${portOrigin(url.protocol, target, p, first)}/` }));
|
|
800
|
+
return viewerResponse(request, { kind: "no-port", box: viewerBox(box), port: box.ports.includes(root) ? port : root, ports });
|
|
629
801
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
let b64 = text2.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text2.length / 4) * 4, "=");
|
|
644
|
-
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
645
|
-
}
|
|
646
|
-
function json2(part) {
|
|
647
|
-
try {
|
|
648
|
-
let value = JSON.parse((/* @__PURE__ */ new TextDecoder()).decode(base64url(part)));
|
|
649
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
650
|
-
} catch {
|
|
651
|
-
return null;
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
var fetchKeys = (jwksUri, now) => Effect2.tryPromise(async () => {
|
|
655
|
-
let res = await fetch(jwksUri);
|
|
656
|
-
if (!res.ok)
|
|
657
|
-
throw Error(`${jwksUri} answered ${res.status}`);
|
|
658
|
-
let body2 = await res.json(), keys = /* @__PURE__ */ new Map;
|
|
659
|
-
for (let jwk of body2.keys ?? []) {
|
|
660
|
-
if (typeof jwk.kid !== "string" || jwk.kty !== "RSA")
|
|
661
|
-
continue;
|
|
662
|
-
keys.set(jwk.kid, await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, !1, ["verify"]));
|
|
663
|
-
}
|
|
664
|
-
let set = { keys, fetchedAt: now };
|
|
665
|
-
return keySets.set(jwksUri, set), set;
|
|
666
|
-
}).pipe(Effect2.option), keyFor = Effect2.fn("Jwt.keyFor")(function* (jwksUri, kid, now) {
|
|
667
|
-
let set = keySets.get(jwksUri);
|
|
668
|
-
if (set === void 0 || now - set.fetchedAt > KEYS_TTL_MS)
|
|
669
|
-
set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
|
|
670
|
-
if (set !== void 0 && !set.keys.has(kid) && now - set.fetchedAt > REFETCH_MIN_MS)
|
|
671
|
-
set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
|
|
672
|
-
return set?.keys.get(kid) ?? null;
|
|
673
|
-
}), verifyRs256 = Effect2.fn("Jwt.verifyRs256")(function* (token, issuer, jwksUri) {
|
|
674
|
-
let parts = token.split(".");
|
|
675
|
-
if (parts.length !== 3)
|
|
676
|
-
return null;
|
|
677
|
-
let [h, p, s] = parts, header = json2(h), claims = json2(p);
|
|
678
|
-
if (header === null || claims === null || header.alg !== "RS256" || typeof header.kid !== "string" || claims.iss !== issuer)
|
|
679
|
-
return null;
|
|
680
|
-
let nowMs = yield* Clock2.currentTimeMillis, key = yield* keyFor(jwksUri, header.kid, nowMs);
|
|
681
|
-
if (key === null)
|
|
682
|
-
return null;
|
|
683
|
-
let signature = base64url(s);
|
|
684
|
-
if (!(yield* Effect2.tryPromise(() => crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, (/* @__PURE__ */ new TextEncoder()).encode(`${h}.${p}`))).pipe(Effect2.orElseSucceed(() => !1))))
|
|
685
|
-
return null;
|
|
686
|
-
let now = Math.floor(nowMs / 1000);
|
|
687
|
-
if (typeof claims.exp !== "number" || claims.exp + CLOCK_SKEW_S < now)
|
|
688
|
-
return null;
|
|
689
|
-
if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_S > now)
|
|
690
|
-
return null;
|
|
691
|
-
return claims;
|
|
692
|
-
});
|
|
693
|
-
function audienceIncludes(aud, expected) {
|
|
694
|
-
return aud === expected || Array.isArray(aud) && aud.includes(expected);
|
|
695
|
-
}
|
|
802
|
+
if (target.port !== null && request.headers.get("sec-fetch-mode") === "navigate")
|
|
803
|
+
return new Response(null, { status: 307, headers: { location: `${portOrigin(url.protocol, target, port, first)}${target.path}`, "cache-control": "no-store" } });
|
|
804
|
+
let headers = upstreamHeaders(request, target, port, root), upstreamUrl = `http://${headers.get("host")}${target.path}`, shownOrigin = headers.get("origin");
|
|
805
|
+
return {
|
|
806
|
+
box,
|
|
807
|
+
port,
|
|
808
|
+
path: target.path,
|
|
809
|
+
headers,
|
|
810
|
+
fetch: (served, upstreamRequest) => upstream.fetch(served, port, upstreamRequest),
|
|
811
|
+
present: (served, response) => Effect3.sync(() => downstream(request, response, upstreamUrl, { href: request.url, host: target.boxHost, ports: served.ports, port: root }, shownOrigin))
|
|
812
|
+
};
|
|
813
|
+
}) };
|
|
814
|
+
}));
|
|
696
815
|
|
|
697
816
|
// src/tunnel.ts
|
|
698
817
|
var UPGRADE_TIMEOUT_MS = 30000, NULL_BODY = /* @__PURE__ */ new Set([101, 204, 205, 304]);
|
|
@@ -708,12 +827,12 @@ class Tunnel {
|
|
|
708
827
|
this.socket = socket;
|
|
709
828
|
this.inUse = inUse;
|
|
710
829
|
}
|
|
711
|
-
send(
|
|
830
|
+
send(frame) {
|
|
712
831
|
let ws = this.socket();
|
|
713
832
|
if (ws === null)
|
|
714
833
|
return !1;
|
|
715
834
|
try {
|
|
716
|
-
return ws.send(encodeFrame(
|
|
835
|
+
return ws.send(encodeFrame(frame)), !0;
|
|
717
836
|
} catch {
|
|
718
837
|
return !1;
|
|
719
838
|
}
|
|
@@ -728,14 +847,14 @@ class Tunnel {
|
|
|
728
847
|
async fetch(port, request) {
|
|
729
848
|
let url = new URL(request.url), stream = this.newStream(), s = { kind: "http", head: null, controller: null, buffered: [], owed: 0, window: MUX_WINDOW, onCredit: null, closed: !1 }, head = new Promise((resolve, reject) => s.head = { resolve, reject });
|
|
730
849
|
this.streams.set(stream, s);
|
|
731
|
-
let
|
|
732
|
-
if (!this.send({ type: "open", stream, port, method: request.method, path: `${url.pathname}${url.search}`, headers
|
|
850
|
+
let headers = [...request.headers];
|
|
851
|
+
if (!this.send({ type: "open", stream, port, method: request.method, path: `${url.pathname}${url.search}`, headers, ws: !1 }))
|
|
733
852
|
throw this.streams.delete(stream), new StreamRefused("the box is not connected");
|
|
734
853
|
request.signal.addEventListener("abort", () => this.abort(stream, "the viewer went away")), this.pump(stream, s, request.body);
|
|
735
854
|
let h = await head;
|
|
736
855
|
if (NULL_BODY.has(h.status) || request.method === "HEAD")
|
|
737
856
|
return this.abort(stream, "no body expected"), new Response(null, { status: h.status, statusText: h.statusText, headers: h.headers });
|
|
738
|
-
let
|
|
857
|
+
let body = new ReadableStream({
|
|
739
858
|
start: (controller) => {
|
|
740
859
|
s.controller = controller;
|
|
741
860
|
for (let chunk of s.buffered.splice(0))
|
|
@@ -746,11 +865,11 @@ class Tunnel {
|
|
|
746
865
|
pull: () => this.credit(stream, s),
|
|
747
866
|
cancel: () => this.abort(stream, "the viewer stopped reading")
|
|
748
867
|
}, { highWaterMark: MUX_WINDOW, size: (chunk) => chunk.byteLength });
|
|
749
|
-
return new Response(
|
|
868
|
+
return new Response(body, { status: h.status, statusText: h.statusText, headers: h.headers });
|
|
750
869
|
}
|
|
751
|
-
async upgrade(port, path,
|
|
870
|
+
async upgrade(port, path, headers) {
|
|
752
871
|
let stream = this.newStream(), head = new Promise((resolve, reject) => this.streams.set(stream, { kind: "ws", resolve, reject }));
|
|
753
|
-
if (!this.send({ type: "open", stream, port, method: "GET", path, headers
|
|
872
|
+
if (!this.send({ type: "open", stream, port, method: "GET", path, headers, ws: !0 }))
|
|
754
873
|
throw this.streams.delete(stream), new StreamRefused("the box is not connected");
|
|
755
874
|
let timeout = setTimeout(() => {
|
|
756
875
|
this.fail(stream, new StreamRefused("the app did not answer the websocket upgrade")), this.send({ type: "reset", stream, reason: "the upgrade timed out" });
|
|
@@ -761,12 +880,12 @@ class Tunnel {
|
|
|
761
880
|
clearTimeout(timeout);
|
|
762
881
|
}
|
|
763
882
|
}
|
|
764
|
-
async pump(stream, s,
|
|
765
|
-
if (
|
|
883
|
+
async pump(stream, s, body) {
|
|
884
|
+
if (body === null) {
|
|
766
885
|
this.send({ type: "end", stream });
|
|
767
886
|
return;
|
|
768
887
|
}
|
|
769
|
-
let reader =
|
|
888
|
+
let reader = body.getReader();
|
|
770
889
|
try {
|
|
771
890
|
for (;; ) {
|
|
772
891
|
let { done, value } = await reader.read();
|
|
@@ -815,41 +934,41 @@ class Tunnel {
|
|
|
815
934
|
s.controller?.error(err);
|
|
816
935
|
} catch {}
|
|
817
936
|
}
|
|
818
|
-
onFrame(
|
|
819
|
-
let s = this.streams.get(
|
|
937
|
+
onFrame(frame) {
|
|
938
|
+
let s = this.streams.get(frame.stream);
|
|
820
939
|
if (s === void 0)
|
|
821
940
|
return !1;
|
|
822
941
|
if (s.kind === "ws") {
|
|
823
|
-
if (
|
|
824
|
-
this.streams.delete(
|
|
825
|
-
else if (
|
|
826
|
-
this.streams.delete(
|
|
942
|
+
if (frame.type === "head")
|
|
943
|
+
this.streams.delete(frame.stream), s.resolve(frame);
|
|
944
|
+
else if (frame.type === "reset")
|
|
945
|
+
this.streams.delete(frame.stream), s.reject(new StreamRefused(frame.reason));
|
|
827
946
|
return !0;
|
|
828
947
|
}
|
|
829
|
-
switch (
|
|
948
|
+
switch (frame.type) {
|
|
830
949
|
case "head":
|
|
831
|
-
return s.head?.resolve(
|
|
950
|
+
return s.head?.resolve(frame), s.head = null, !0;
|
|
832
951
|
case "data":
|
|
833
952
|
if (s.controller === null)
|
|
834
|
-
return s.buffered.push(
|
|
953
|
+
return s.buffered.push(frame.data), s.owed += frame.data.byteLength, !0;
|
|
835
954
|
try {
|
|
836
|
-
s.controller.enqueue(
|
|
955
|
+
s.controller.enqueue(frame.data);
|
|
837
956
|
} catch {
|
|
838
957
|
return !0;
|
|
839
958
|
}
|
|
840
|
-
if (s.owed +=
|
|
841
|
-
this.credit(
|
|
959
|
+
if (s.owed += frame.data.byteLength, (s.controller.desiredSize ?? 0) > 0)
|
|
960
|
+
this.credit(frame.stream, s);
|
|
842
961
|
return !0;
|
|
843
962
|
case "end":
|
|
844
|
-
this.streams.delete(
|
|
963
|
+
this.streams.delete(frame.stream), s.closed = !0;
|
|
845
964
|
try {
|
|
846
965
|
s.controller?.close();
|
|
847
966
|
} catch {}
|
|
848
967
|
return !0;
|
|
849
968
|
case "reset":
|
|
850
|
-
return this.fail(
|
|
969
|
+
return this.fail(frame.stream, new StreamRefused(frame.reason)), !0;
|
|
851
970
|
case "credit":
|
|
852
|
-
return s.window +=
|
|
971
|
+
return s.window += frame.bytes, s.onCredit?.(), s.onCredit = null, !0;
|
|
853
972
|
default:
|
|
854
973
|
return !0;
|
|
855
974
|
}
|
|
@@ -860,8 +979,9 @@ class Tunnel {
|
|
|
860
979
|
}
|
|
861
980
|
}
|
|
862
981
|
|
|
863
|
-
// src/
|
|
864
|
-
var TICKET_HEADER = "x-tesser-ticket",
|
|
982
|
+
// src/relay.ts
|
|
983
|
+
var TICKET_HEADER = "x-tesser-ticket", WAKE_TIMEOUT = Duration2.minutes(5), WAKE_RETRY_MS = 30000, DIAL_GRACE = Duration2.seconds(30), RECHECK_MS = 60000, STALE_MS = 300000, RETRYABLE = /* @__PURE__ */ new Set([0, 408, 409, 429]), Ticket = Schema2.Struct({
|
|
984
|
+
org: Schema2.String,
|
|
865
985
|
box: Schema2.String,
|
|
866
986
|
name: Schema2.String,
|
|
867
987
|
sharedBy: Schema2.NullOr(Schema2.String),
|
|
@@ -878,63 +998,18 @@ var TICKET_HEADER = "x-tesser-ticket", ACCESS_HEADER = "cf-access-jwt-assertion"
|
|
|
878
998
|
app: Schema2.NullOr(Schema2.Literals(["none", "running", "unsupervised", "exited"]))
|
|
879
999
|
})
|
|
880
1000
|
}), ErrorBody = Schema2.Struct({ error: Schema2.Struct({ message: Schema2.String }) });
|
|
881
|
-
function plain(status,
|
|
882
|
-
return new Response(`${
|
|
1001
|
+
function plain(status, text) {
|
|
1002
|
+
return new Response(`${text}
|
|
883
1003
|
`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
|
|
884
1004
|
}
|
|
885
|
-
var verifyTicket = Effect3.fn("Gateway.verifyTicket")(function* (env, request) {
|
|
886
|
-
let token = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1];
|
|
887
|
-
if (token === void 0)
|
|
888
|
-
return null;
|
|
889
|
-
let issuer = env.TESSER_URL.replace(/\/$/, ""), claims = yield* verifyRs256(token, issuer, `${issuer}/.well-known/jwks.json`);
|
|
890
|
-
if (claims === null || !audienceIncludes(claims.aud, `https://${env.GATEWAY_HOST}`) || claims.org !== env.TESSER_ORG)
|
|
891
|
-
return null;
|
|
892
|
-
if (claims.sub !== `box:${env.TESSER_ORG}:${String(claims.box)}`)
|
|
893
|
-
return null;
|
|
894
|
-
return yield* Schema2.decodeUnknownEffect(Ticket)(claims).pipe(Effect3.option);
|
|
895
|
-
}), verifyViewer = Effect3.fn("Gateway.verifyViewer")(function* (env, request) {
|
|
896
|
-
let token = request.headers.get(ACCESS_HEADER);
|
|
897
|
-
if (token === null)
|
|
898
|
-
return null;
|
|
899
|
-
let issuer = `https://${env.ACCESS_TEAM}`, claims = yield* verifyRs256(token, issuer, `${issuer}/cdn-cgi/access/certs`);
|
|
900
|
-
return claims !== null && audienceIncludes(claims.aud, env.ACCESS_AUD) ? claims : null;
|
|
901
|
-
});
|
|
902
|
-
function boxIdFromHost(env, hostname) {
|
|
903
|
-
let suffix = `.${env.GATEWAY_HOST.toLowerCase()}`, host = hostname.toLowerCase();
|
|
904
|
-
if (!host.endsWith(suffix))
|
|
905
|
-
return null;
|
|
906
|
-
let label = host.slice(0, -suffix.length).split(".").pop(), match = /^box-([0-9a-z]+)(?:--\d{1,5})?$/.exec(label);
|
|
907
|
-
return match === null ? null : `box_${match[1]}`;
|
|
908
|
-
}
|
|
909
|
-
var cloudflare_default = {
|
|
910
|
-
async fetch(request, env) {
|
|
911
|
-
let url = new URL(request.url), apex = url.hostname.toLowerCase() === env.GATEWAY_HOST.toLowerCase();
|
|
912
|
-
if (apex && url.pathname === GATEWAY_DIAL_PATH) {
|
|
913
|
-
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket")
|
|
914
|
-
return plain(426, "boxes dial this with a websocket");
|
|
915
|
-
let ticket = await Effect3.runPromise(verifyTicket(env, request));
|
|
916
|
-
if (ticket === null || ticket._tag === "None")
|
|
917
|
-
return plain(401, "a box dials in with a share ticket signed by tesser");
|
|
918
|
-
let headers3 = new Headers(request.headers);
|
|
919
|
-
return headers3.delete("authorization"), headers3.set(TICKET_HEADER, JSON.stringify(ticket.value)), env.BOXES.get(env.BOXES.idFromName(ticket.value.box)).fetch(new Request(request, { headers: headers3 }));
|
|
920
|
-
}
|
|
921
|
-
if (apex && url.pathname === "/__tesser/health")
|
|
922
|
-
return plain(200, "ok");
|
|
923
|
-
let boxId = boxIdFromHost(env, url.hostname);
|
|
924
|
-
if (boxId === null)
|
|
925
|
-
return new Response(viewerPage({ kind: "not-shared" }), { status: 404, headers: STATUS_HEADERS });
|
|
926
|
-
if (await Effect3.runPromise(verifyViewer(env, request)) === null)
|
|
927
|
-
return plain(403, "this gateway only answers requests Cloudflare Access let through");
|
|
928
|
-
let headers2 = new Headers(request.headers);
|
|
929
|
-
return headers2.delete(TICKET_HEADER), env.BOXES.get(env.BOXES.idFromName(boxId)).fetch(new Request(request, { headers: headers2 }));
|
|
930
|
-
}
|
|
931
|
-
};
|
|
932
1005
|
|
|
933
|
-
class
|
|
1006
|
+
class BoxRelay extends DurableObject {
|
|
934
1007
|
tunnel = new Tunnel(() => this.boxSocket(), (stream) => this.ctx.getWebSockets(`v:${stream}`).length > 0);
|
|
935
1008
|
waking = null;
|
|
936
1009
|
connected = [];
|
|
937
|
-
verifiedAt =
|
|
1010
|
+
verifiedAt = /* @__PURE__ */ new Map;
|
|
1011
|
+
unstored = /* @__PURE__ */ new Map;
|
|
1012
|
+
failingSince = /* @__PURE__ */ new Map;
|
|
938
1013
|
constructor(ctx, env) {
|
|
939
1014
|
super(ctx, env);
|
|
940
1015
|
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(MUX_PING, MUX_PONG));
|
|
@@ -963,9 +1038,13 @@ class GatewayBoxes extends DurableObject {
|
|
|
963
1038
|
}
|
|
964
1039
|
async fetch(request) {
|
|
965
1040
|
let ticket = request.headers.get(TICKET_HEADER);
|
|
966
|
-
if (ticket !== null)
|
|
967
|
-
|
|
968
|
-
|
|
1041
|
+
if (ticket !== null) {
|
|
1042
|
+
let decoded = Schema2.decodeUnknownOption(Ticket)(JSON.parse(ticket));
|
|
1043
|
+
if (decoded._tag === "None" || !this.admits(decoded.value))
|
|
1044
|
+
return plain(403, "this box answers for another org here");
|
|
1045
|
+
return this.acceptBox(decoded.value);
|
|
1046
|
+
}
|
|
1047
|
+
let handled = await Effect4.runPromise(handle(request).pipe(Effect4.provide(this.layer(this.viewer(request)))));
|
|
969
1048
|
if (handled._tag === "response")
|
|
970
1049
|
return handled.response;
|
|
971
1050
|
return this.bridge(request, handled);
|
|
@@ -975,32 +1054,99 @@ class GatewayBoxes extends DurableObject {
|
|
|
975
1054
|
if (previous !== null)
|
|
976
1055
|
this.retire(previous, 4000, "replaced by a newer connection");
|
|
977
1056
|
let [client, server] = Object.values(new WebSocketPair);
|
|
978
|
-
this.ctx.acceptWebSocket(server, ["box"]), server.serializeAttachment({ ticket }), this.waking = null, this.verifiedAt
|
|
1057
|
+
this.ctx.acceptWebSocket(server, ["box"]), server.serializeAttachment({ ticket }), this.waking = null, this.verifiedAt.clear(), this.failingSince.clear();
|
|
979
1058
|
for (let resolve of this.connected.splice(0))
|
|
980
1059
|
resolve();
|
|
981
|
-
return this.ctx.waitUntil(this.
|
|
1060
|
+
return this.ctx.waitUntil(this.armAlarm(ticket)), new Response(null, { status: 101, webSocket: client });
|
|
1061
|
+
}
|
|
1062
|
+
socketViewer(ws) {
|
|
1063
|
+
let { stream, viewer } = ws.deserializeAttachment();
|
|
1064
|
+
return { stream, viewer: viewer ?? this.unstored.get(stream) ?? null };
|
|
1065
|
+
}
|
|
1066
|
+
viewers() {
|
|
1067
|
+
return new Set(this.ctx.getWebSockets("viewer").flatMap((ws) => this.socketViewer(ws).viewer ?? []));
|
|
1068
|
+
}
|
|
1069
|
+
async armAlarm(ticket) {
|
|
1070
|
+
let want = this.viewers().size > 0 ? Math.min(ticket.exp * 1000, Date.now() + RECHECK_MS) : ticket.exp * 1000, pending = await this.ctx.storage.getAlarm();
|
|
1071
|
+
if (pending === null || pending <= Date.now() || want < pending)
|
|
1072
|
+
await this.ctx.storage.setAlarm(want);
|
|
1073
|
+
}
|
|
1074
|
+
closeViewer(viewer, reason) {
|
|
1075
|
+
for (let ws of this.ctx.getWebSockets("viewer")) {
|
|
1076
|
+
let { stream, viewer: who } = this.socketViewer(ws);
|
|
1077
|
+
if (who !== viewer)
|
|
1078
|
+
continue;
|
|
1079
|
+
this.tunnel.send({ type: "close", stream, code: 1008, reason });
|
|
1080
|
+
try {
|
|
1081
|
+
ws.close(1008, reason);
|
|
1082
|
+
} catch {}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
verdict(viewer, answer, now, open) {
|
|
1086
|
+
if (answer?.status === 200) {
|
|
1087
|
+
for (let [key, at] of this.verifiedAt)
|
|
1088
|
+
if (now - at > STALE_MS)
|
|
1089
|
+
this.verifiedAt.delete(key);
|
|
1090
|
+
return this.verifiedAt.set(viewer, now), this.failingSince.delete(viewer), "ok";
|
|
1091
|
+
}
|
|
1092
|
+
if (answer?.status === 404 || answer?.status === 403)
|
|
1093
|
+
this.verifiedAt.delete(viewer), this.failingSince.delete(viewer);
|
|
1094
|
+
let box = this.boxSocket();
|
|
1095
|
+
if (answer?.status === 404) {
|
|
1096
|
+
if (box !== null)
|
|
1097
|
+
this.retire(box, 4003, "share revoked");
|
|
1098
|
+
return "revoked";
|
|
1099
|
+
}
|
|
1100
|
+
if (answer?.status === 403)
|
|
1101
|
+
return this.closeViewer(viewer, "access ended"), "refused";
|
|
1102
|
+
let verified = this.verifiedAt.get(viewer);
|
|
1103
|
+
if (open && verified === void 0 && !this.failingSince.has(viewer))
|
|
1104
|
+
this.failingSince.set(viewer, now);
|
|
1105
|
+
let since = verified ?? (open ? this.failingSince.get(viewer) : 0);
|
|
1106
|
+
if (now - since > STALE_MS) {
|
|
1107
|
+
if (open)
|
|
1108
|
+
this.closeViewer(viewer, "tesser can't confirm access right now");
|
|
1109
|
+
return "stale";
|
|
1110
|
+
}
|
|
1111
|
+
return "unknown";
|
|
982
1112
|
}
|
|
983
1113
|
async alarm() {
|
|
984
1114
|
let ws = this.boxSocket();
|
|
985
1115
|
if (ws === null)
|
|
986
1116
|
return;
|
|
987
1117
|
let { ticket } = ws.deserializeAttachment();
|
|
988
|
-
if (ticket.exp * 1000 <= Date.now())
|
|
1118
|
+
if (ticket.exp * 1000 <= Date.now()) {
|
|
989
1119
|
this.retire(ws, 4001, "share expired");
|
|
990
|
-
|
|
991
|
-
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
for (let viewer of this.viewers()) {
|
|
1123
|
+
let answer = await this.lookupBox(ticket.box, viewer).catch(() => null);
|
|
1124
|
+
if (this.verdict(viewer, answer, Date.now(), !0) === "revoked")
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
await this.armAlarm(ticket);
|
|
992
1128
|
}
|
|
993
1129
|
async bridge(request, target) {
|
|
994
|
-
let
|
|
1130
|
+
let headers = [...target.headers].filter(([name]) => !/^sec-websocket-(key|version|extensions)$/i.test(name)), upgraded;
|
|
995
1131
|
try {
|
|
996
|
-
upgraded = await this.tunnel.upgrade(target.port, target.path,
|
|
1132
|
+
upgraded = await this.tunnel.upgrade(target.port, target.path, headers);
|
|
997
1133
|
} catch (err) {
|
|
998
1134
|
return plain(502, `${target.box.name} refused the websocket: ${err instanceof Error ? err.message : String(err)}`);
|
|
999
1135
|
}
|
|
1000
1136
|
if (upgraded.head.status !== 101)
|
|
1001
1137
|
return plain(502, `${target.box.name} answered ${upgraded.head.status} to the websocket`);
|
|
1002
1138
|
let [client, server] = Object.values(new WebSocketPair);
|
|
1003
|
-
this.ctx.acceptWebSocket(server, ["viewer", `v:${upgraded.stream}`])
|
|
1139
|
+
this.ctx.acceptWebSocket(server, ["viewer", `v:${upgraded.stream}`]);
|
|
1140
|
+
let viewer = this.viewer(request);
|
|
1141
|
+
try {
|
|
1142
|
+
server.serializeAttachment({ stream: upgraded.stream, viewer });
|
|
1143
|
+
} catch {
|
|
1144
|
+
if (server.serializeAttachment({ stream: upgraded.stream, viewer: null }), viewer !== null)
|
|
1145
|
+
this.unstored.set(upgraded.stream, viewer);
|
|
1146
|
+
}
|
|
1147
|
+
let ticket = this.live();
|
|
1148
|
+
if (ticket !== null)
|
|
1149
|
+
await this.armAlarm(ticket);
|
|
1004
1150
|
let protocol = upgraded.head.headers.find(([name]) => name.toLowerCase() === "sec-websocket-protocol")?.[1];
|
|
1005
1151
|
return new Response(null, { status: 101, webSocket: client, headers: protocol === void 0 ? {} : { "sec-websocket-protocol": protocol } });
|
|
1006
1152
|
}
|
|
@@ -1008,29 +1154,29 @@ class GatewayBoxes extends DurableObject {
|
|
|
1008
1154
|
if (this.ctx.getTags(ws).includes("box")) {
|
|
1009
1155
|
if (typeof message === "string")
|
|
1010
1156
|
return;
|
|
1011
|
-
let
|
|
1157
|
+
let frame;
|
|
1012
1158
|
try {
|
|
1013
|
-
|
|
1159
|
+
frame = decodeFrame(new Uint8Array(message));
|
|
1014
1160
|
} catch {
|
|
1015
1161
|
return;
|
|
1016
1162
|
}
|
|
1017
|
-
if (this.tunnel.onFrame(
|
|
1163
|
+
if (this.tunnel.onFrame(frame))
|
|
1018
1164
|
return;
|
|
1019
|
-
let viewer = this.ctx.getWebSockets(`v:${
|
|
1165
|
+
let viewer = this.ctx.getWebSockets(`v:${frame.stream}`)[0];
|
|
1020
1166
|
if (viewer === void 0) {
|
|
1021
|
-
if (
|
|
1022
|
-
this.tunnel.send({ type: "close", stream:
|
|
1167
|
+
if (frame.type === "text" || frame.type === "binary")
|
|
1168
|
+
this.tunnel.send({ type: "close", stream: frame.stream, code: 1001, reason: "the viewer went away" });
|
|
1023
1169
|
return;
|
|
1024
1170
|
}
|
|
1025
1171
|
try {
|
|
1026
|
-
if (
|
|
1027
|
-
viewer.send(
|
|
1028
|
-
else if (
|
|
1029
|
-
viewer.send(
|
|
1030
|
-
else if (
|
|
1031
|
-
viewer.close(
|
|
1032
|
-
else if (
|
|
1033
|
-
viewer.close(1011,
|
|
1172
|
+
if (frame.type === "text")
|
|
1173
|
+
viewer.send(frame.text);
|
|
1174
|
+
else if (frame.type === "binary")
|
|
1175
|
+
viewer.send(frame.data);
|
|
1176
|
+
else if (frame.type === "close")
|
|
1177
|
+
viewer.close(frame.code, frame.reason);
|
|
1178
|
+
else if (frame.type === "reset")
|
|
1179
|
+
viewer.close(1011, frame.reason.slice(0, 120));
|
|
1034
1180
|
} catch {}
|
|
1035
1181
|
return;
|
|
1036
1182
|
}
|
|
@@ -1044,91 +1190,151 @@ class GatewayBoxes extends DurableObject {
|
|
|
1044
1190
|
this.retire(ws, 1000, "box hung up");
|
|
1045
1191
|
return;
|
|
1046
1192
|
}
|
|
1047
|
-
let { stream } = ws.deserializeAttachment()
|
|
1193
|
+
let { stream } = ws.deserializeAttachment();
|
|
1194
|
+
this.unstored.delete(stream);
|
|
1195
|
+
let sendable = code === 1005 ? 1000 : code === 1004 || code === 1006 || code === 1015 || code < 1000 || code > 4999 ? 1001 : code;
|
|
1048
1196
|
this.tunnel.send({ type: "close", stream, code: sendable, reason: reason.slice(0, 120) });
|
|
1049
1197
|
}
|
|
1050
1198
|
webSocketError(ws) {
|
|
1051
1199
|
this.webSocketClose(ws, 1006, "socket error");
|
|
1052
1200
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
return Effect3.tryPromise({
|
|
1056
|
-
try: async () => {
|
|
1057
|
-
let res = await fetch(`${env.TESSER_URL.replace(/\/$/, "")}/v1/${env.TESSER_ORG}${path}`, { method, headers: jwt === null ? {} : { [ACCESS_HEADER]: jwt } }), body2 = await res.json().catch(() => null);
|
|
1058
|
-
return { status: res.status, body: body2 };
|
|
1059
|
-
},
|
|
1060
|
-
catch: (err) => new DirectoryUnavailable({ reason: err instanceof Error ? err.message : String(err) })
|
|
1061
|
-
});
|
|
1201
|
+
ask(call) {
|
|
1202
|
+
return Effect4.tryPromise({ try: call, catch: (err) => new DirectoryUnavailable({ reason: err instanceof Error ? err.message : String(err) }) });
|
|
1062
1203
|
}
|
|
1063
|
-
layer(
|
|
1064
|
-
let self = this, remote =
|
|
1065
|
-
let { status
|
|
1066
|
-
if (
|
|
1204
|
+
layer(viewer) {
|
|
1205
|
+
let self = this, remote = Effect4.fn("Relay.remote")(function* (boxId) {
|
|
1206
|
+
let { status, body } = yield* self.ask(() => self.lookupBox(boxId, viewer));
|
|
1207
|
+
if (status === 403 || status === 404)
|
|
1067
1208
|
return yield* new BoxNotShared({ boxId });
|
|
1068
|
-
if (
|
|
1069
|
-
let message = Schema2.decodeUnknownOption(ErrorBody)(
|
|
1070
|
-
return yield* new DirectoryUnavailable({ reason: message._tag === "Some" ? message.value.error.message : `tesser answered ${
|
|
1071
|
-
}
|
|
1072
|
-
return (yield* Schema2.decodeUnknownEffect(BoxResponse)(body2).pipe(Effect3.mapError(() => new DirectoryUnavailable({ reason: "tesser answered with a box this gateway can't read" })))).box;
|
|
1073
|
-
}), lookup = Effect3.fn("Cloudflare.lookup")(function* (boxId) {
|
|
1074
|
-
let ticket = self.live();
|
|
1075
|
-
if (ticket === null) {
|
|
1076
|
-
let box = yield* remote(boxId);
|
|
1077
|
-
return { ...box, power: box.power === "awake" ? "launching" : box.power };
|
|
1078
|
-
}
|
|
1079
|
-
let now = yield* Clock3.currentTimeMillis;
|
|
1080
|
-
if (jwt !== null && now - self.verifiedAt > RECHECK_MS) {
|
|
1081
|
-
let checked = yield* remote(boxId).pipe(Effect3.result);
|
|
1082
|
-
if (checked._tag === "Success")
|
|
1083
|
-
self.verifiedAt = now;
|
|
1084
|
-
else if (checked.failure._tag === "BoxNotShared") {
|
|
1085
|
-
let ws = self.boxSocket();
|
|
1086
|
-
if (ws !== null)
|
|
1087
|
-
self.retire(ws, 4003, "share revoked");
|
|
1088
|
-
return yield* checked.failure;
|
|
1089
|
-
}
|
|
1209
|
+
if (status !== 200) {
|
|
1210
|
+
let message = Schema2.decodeUnknownOption(ErrorBody)(body);
|
|
1211
|
+
return yield* new DirectoryUnavailable({ reason: message._tag === "Some" ? message.value.error.message : `tesser answered ${status}` });
|
|
1090
1212
|
}
|
|
1091
|
-
return
|
|
1092
|
-
}),
|
|
1213
|
+
return (yield* Schema2.decodeUnknownEffect(BoxResponse)(body).pipe(Effect4.mapError(() => new DirectoryUnavailable({ reason: "tesser answered with a box this gateway can't read" })))).box;
|
|
1214
|
+
}), kick = Effect4.fn("Relay.kick")(function* (boxId, asked) {
|
|
1093
1215
|
let now = yield* Clock3.currentTimeMillis, current = self.waking;
|
|
1094
1216
|
if (current?.inflight)
|
|
1095
1217
|
return;
|
|
1096
|
-
if (current?.failure && !retry)
|
|
1097
|
-
return;
|
|
1098
1218
|
if (current !== null && current.failure === null && now - current.kickedAt < WAKE_RETRY_MS)
|
|
1099
1219
|
return;
|
|
1100
|
-
let
|
|
1220
|
+
let fresh = current === null || current.failure !== null || now - current.since > Duration2.toMillis(WAKE_TIMEOUT);
|
|
1221
|
+
if (fresh && !asked)
|
|
1222
|
+
return;
|
|
1223
|
+
let waking = { since: fresh ? now : current.since, kickedAt: now, failure: null, inflight: !0 };
|
|
1101
1224
|
self.waking = waking;
|
|
1102
|
-
let kicked = yield* self.
|
|
1225
|
+
let kicked = yield* self.ask(() => self.wakeBox(boxId, viewer)).pipe(Effect4.result, Effect4.ensuring(Effect4.sync(() => waking.inflight = !1)));
|
|
1103
1226
|
if (kicked._tag === "Failure")
|
|
1104
1227
|
return;
|
|
1105
|
-
let { status
|
|
1106
|
-
if (
|
|
1228
|
+
let { status, body } = kicked.success;
|
|
1229
|
+
if (status < 300 || status >= 500 || RETRYABLE.has(status))
|
|
1107
1230
|
return;
|
|
1108
|
-
let message = Schema2.decodeUnknownOption(ErrorBody)(
|
|
1109
|
-
waking.failure = message._tag === "Some" ? message.value.error.message : `tesser answered ${
|
|
1110
|
-
}),
|
|
1231
|
+
let message = Schema2.decodeUnknownOption(ErrorBody)(body);
|
|
1232
|
+
waking.failure = message._tag === "Some" ? message.value.error.message : `tesser answered ${status}`;
|
|
1233
|
+
}), wake = (boxId) => kick(boxId, !0), lookup = Effect4.fn("Relay.lookup")(function* (boxId) {
|
|
1234
|
+
let ticket = self.live();
|
|
1235
|
+
if (ticket === null) {
|
|
1236
|
+
let box = yield* remote(boxId);
|
|
1237
|
+
if (box.power === "asleep")
|
|
1238
|
+
yield* kick(boxId, !1);
|
|
1239
|
+
return { ...box, power: box.power === "awake" ? "launching" : box.power };
|
|
1240
|
+
}
|
|
1241
|
+
let now = yield* Clock3.currentTimeMillis;
|
|
1242
|
+
if (viewer !== null && now - (self.verifiedAt.get(viewer) ?? 0) > RECHECK_MS) {
|
|
1243
|
+
let answer = yield* self.ask(() => self.lookupBox(boxId, viewer)).pipe(Effect4.orElseSucceed(() => null)), verdict = self.verdict(viewer, answer, now, !1);
|
|
1244
|
+
if (verdict === "revoked" || verdict === "refused")
|
|
1245
|
+
return yield* new BoxNotShared({ boxId });
|
|
1246
|
+
if (verdict === "stale")
|
|
1247
|
+
return yield* new DirectoryUnavailable({ reason: "tesser didn't answer whether you can still open this box" });
|
|
1248
|
+
}
|
|
1249
|
+
return { boxId, name: ticket.name, sharedBy: ticket.sharedBy, privateIp: "", ports: ticket.ports, power: "awake", app: "running" };
|
|
1250
|
+
}), status = Effect4.fn("Relay.status")(function* (_boxId) {
|
|
1111
1251
|
let now = yield* Clock3.currentTimeMillis, w = self.waking;
|
|
1112
1252
|
if (w === null || w.failure === null && now - w.since > Duration2.toMillis(WAKE_TIMEOUT))
|
|
1113
1253
|
return null;
|
|
1114
1254
|
return { since: w.since, failure: w.failure };
|
|
1115
|
-
}), dialed = (boxId, within, reason) =>
|
|
1116
|
-
if (self.live() === null)
|
|
1117
|
-
|
|
1255
|
+
}), dialed = (boxId, within, reason) => Effect4.promise(() => new Promise((resolve) => self.connected.push(resolve))).pipe(Effect4.timeoutOrElse({ duration: within, orElse: () => Effect4.fail(new WakeFailed({ boxId, reason })) })), awake = Effect4.fn("Relay.awake")(function* (boxId) {
|
|
1256
|
+
if (self.live() === null) {
|
|
1257
|
+
let box = yield* remote(boxId);
|
|
1258
|
+
if (box.power === "awake")
|
|
1118
1259
|
yield* dialed(boxId, DIAL_GRACE, "the box is up but hasn't connected to this gateway; its boxd may need an update");
|
|
1119
1260
|
else {
|
|
1120
|
-
yield*
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1261
|
+
let waking = yield* status(boxId);
|
|
1262
|
+
if (waking?.failure)
|
|
1263
|
+
return yield* new WakeFailed({ boxId, reason: waking.failure });
|
|
1264
|
+
if (box.power === "asleep" && waking === null)
|
|
1265
|
+
return yield* new BoxAsleep({ boxId });
|
|
1124
1266
|
yield* dialed(boxId, WAKE_TIMEOUT, "it didn't wake up within 5 minutes");
|
|
1125
1267
|
}
|
|
1268
|
+
}
|
|
1126
1269
|
return yield* lookup(boxId);
|
|
1127
|
-
}), upstream = (box, port, request) =>
|
|
1128
|
-
return
|
|
1270
|
+
}), upstream = (box, port, request) => Effect4.tryPromise({ try: () => self.tunnel.fetch(port, request), catch: () => new UpstreamRefused({ boxId: box.boxId, port }) });
|
|
1271
|
+
return Layer2.merge(Layer2.provide(shareResolver, Layer2.merge(Layer2.succeed(BoxDirectory)({ host: Effect4.succeed(self.host()), lookup }), Layer2.succeed(Upstream)({ fetch: upstream }))), Layer2.succeed(Waker)({ wake, status, awake }));
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/cloudflare.ts
|
|
1276
|
+
var ACCESS_HEADER = "cf-access-jwt-assertion", verifyTicket = Effect5.fn("Gateway.verifyTicket")(function* (env, request) {
|
|
1277
|
+
let token = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1];
|
|
1278
|
+
if (token === void 0)
|
|
1279
|
+
return null;
|
|
1280
|
+
let issuer = env.TESSER_URL.replace(/\/$/, ""), claims = yield* verifyRs256(token, issuer, `${issuer}/.well-known/jwks.json`);
|
|
1281
|
+
if (claims === null || !audienceIncludes(claims.aud, `https://${env.GATEWAY_HOST}`) || claims.org !== env.TESSER_ORG)
|
|
1282
|
+
return null;
|
|
1283
|
+
if (claims.sub !== `box:${env.TESSER_ORG}:${String(claims.box)}`)
|
|
1284
|
+
return null;
|
|
1285
|
+
return yield* Schema3.decodeUnknownEffect(Ticket)(claims).pipe(Effect5.option);
|
|
1286
|
+
}), verifyViewer = Effect5.fn("Gateway.verifyViewer")(function* (env, request) {
|
|
1287
|
+
let token = request.headers.get(ACCESS_HEADER);
|
|
1288
|
+
if (token === null)
|
|
1289
|
+
return null;
|
|
1290
|
+
let issuer = `https://${env.ACCESS_TEAM}`, claims = yield* verifyRs256(token, issuer, `${issuer}/cdn-cgi/access/certs`);
|
|
1291
|
+
return claims !== null && audienceIncludes(claims.aud, env.ACCESS_AUD) ? claims : null;
|
|
1292
|
+
}), cloudflare_default = {
|
|
1293
|
+
async fetch(request, env) {
|
|
1294
|
+
let url = new URL(request.url), apex = url.hostname.toLowerCase() === env.GATEWAY_HOST.toLowerCase();
|
|
1295
|
+
if (apex && url.pathname === GATEWAY_DIAL_PATH) {
|
|
1296
|
+
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket")
|
|
1297
|
+
return plain(426, "boxes dial this with a websocket");
|
|
1298
|
+
let ticket = await Effect5.runPromise(verifyTicket(env, request));
|
|
1299
|
+
if (ticket === null || ticket._tag === "None")
|
|
1300
|
+
return plain(401, "a box dials in with a share ticket signed by tesser");
|
|
1301
|
+
let headers = new Headers(request.headers);
|
|
1302
|
+
return headers.delete("authorization"), headers.set(TICKET_HEADER, JSON.stringify(ticket.value)), env.BOXES.get(env.BOXES.idFromName(ticket.value.box)).fetch(new Request(request, { headers }));
|
|
1303
|
+
}
|
|
1304
|
+
if (apex && url.pathname === "/__tesser/health")
|
|
1305
|
+
return plain(200, "ok");
|
|
1306
|
+
let boxId = gatewayHostBox(env.GATEWAY_HOST, url.hostname);
|
|
1307
|
+
if (boxId === null)
|
|
1308
|
+
return new Response(viewerPage({ kind: "not-shared" }), { status: 404, headers: STATUS_HEADERS });
|
|
1309
|
+
if (await Effect5.runPromise(verifyViewer(env, request)) === null)
|
|
1310
|
+
return plain(403, "this gateway only answers requests Cloudflare Access let through");
|
|
1311
|
+
let headers = new Headers(request.headers);
|
|
1312
|
+
return headers.delete(TICKET_HEADER), env.BOXES.get(env.BOXES.idFromName(boxId)).fetch(new Request(request, { headers }));
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
class GatewayBoxes extends BoxRelay {
|
|
1317
|
+
host() {
|
|
1318
|
+
return this.env.GATEWAY_HOST;
|
|
1319
|
+
}
|
|
1320
|
+
viewer(request) {
|
|
1321
|
+
return request.headers.get(ACCESS_HEADER);
|
|
1322
|
+
}
|
|
1323
|
+
admits(ticket) {
|
|
1324
|
+
return ticket.org === this.env.TESSER_ORG;
|
|
1325
|
+
}
|
|
1326
|
+
lookupBox(boxId, viewer) {
|
|
1327
|
+
return this.tesser("GET", `/gateway/boxes/${boxId}`, viewer);
|
|
1328
|
+
}
|
|
1329
|
+
wakeBox(boxId, viewer) {
|
|
1330
|
+
return this.tesser("POST", `/gateway/boxes/${boxId}/wake`, viewer);
|
|
1331
|
+
}
|
|
1332
|
+
async tesser(method, path, jwt) {
|
|
1333
|
+
let res = await fetch(`${this.env.TESSER_URL.replace(/\/$/, "")}/v1/${this.env.TESSER_ORG}${path}`, { method, headers: jwt === null ? {} : { [ACCESS_HEADER]: jwt } }), body = await res.json().catch(() => null);
|
|
1334
|
+
return { status: res.status, body };
|
|
1129
1335
|
}
|
|
1130
1336
|
}
|
|
1131
1337
|
export {
|
|
1132
|
-
|
|
1133
|
-
|
|
1338
|
+
GatewayBoxes,
|
|
1339
|
+
cloudflare_default as default
|
|
1134
1340
|
};
|