@tesser-sh/gateway 0.0.1 → 0.53.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/index.js +397 -254
- 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) {
|
|
@@ -220,6 +217,12 @@ function parseGatewayLabel(label) {
|
|
|
220
217
|
return null;
|
|
221
218
|
return { boxId, port: Number(port) };
|
|
222
219
|
}
|
|
220
|
+
function gatewayHostBox(gatewayHost, hostname) {
|
|
221
|
+
let suffix = `.${gatewayHost.toLowerCase()}`, host = hostname.toLowerCase().replace(/\.$/, "");
|
|
222
|
+
if (!host.endsWith(suffix))
|
|
223
|
+
return null;
|
|
224
|
+
return parseGatewayLabel(host.slice(0, -suffix.length).split(".").pop())?.boxId ?? null;
|
|
225
|
+
}
|
|
223
226
|
|
|
224
227
|
// ../protocol/src/mux.ts
|
|
225
228
|
var MUX_WINDOW = 262144, MUX_CHUNK = 65536;
|
|
@@ -317,6 +320,68 @@ function decodeFrame(buf) {
|
|
|
317
320
|
}
|
|
318
321
|
|
|
319
322
|
// src/cloudflare.ts
|
|
323
|
+
import { Effect as Effect4, Schema as Schema3 } from "effect";
|
|
324
|
+
|
|
325
|
+
// src/jwt.ts
|
|
326
|
+
import { Clock, Effect, Option } from "effect";
|
|
327
|
+
var KEYS_TTL_MS = 600000, REFETCH_MIN_MS = 30000, CLOCK_SKEW_S = 60, keySets = /* @__PURE__ */ new Map;
|
|
328
|
+
function base64url(text) {
|
|
329
|
+
let b64 = text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=");
|
|
330
|
+
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
331
|
+
}
|
|
332
|
+
function json2(part) {
|
|
333
|
+
try {
|
|
334
|
+
let value = JSON.parse((/* @__PURE__ */ new TextDecoder()).decode(base64url(part)));
|
|
335
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
var fetchKeys = (jwksUri, now) => Effect.tryPromise(async () => {
|
|
341
|
+
let res = await fetch(jwksUri);
|
|
342
|
+
if (!res.ok)
|
|
343
|
+
throw Error(`${jwksUri} answered ${res.status}`);
|
|
344
|
+
let body = await res.json(), keys = /* @__PURE__ */ new Map;
|
|
345
|
+
for (let jwk of body.keys ?? []) {
|
|
346
|
+
if (typeof jwk.kid !== "string" || jwk.kty !== "RSA")
|
|
347
|
+
continue;
|
|
348
|
+
keys.set(jwk.kid, await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, !1, ["verify"]));
|
|
349
|
+
}
|
|
350
|
+
let set = { keys, fetchedAt: now };
|
|
351
|
+
return keySets.set(jwksUri, set), set;
|
|
352
|
+
}).pipe(Effect.option), keyFor = Effect.fn("Jwt.keyFor")(function* (jwksUri, kid, now) {
|
|
353
|
+
let set = keySets.get(jwksUri);
|
|
354
|
+
if (set === void 0 || now - set.fetchedAt > KEYS_TTL_MS)
|
|
355
|
+
set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
|
|
356
|
+
if (set !== void 0 && !set.keys.has(kid) && now - set.fetchedAt > REFETCH_MIN_MS)
|
|
357
|
+
set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
|
|
358
|
+
return set?.keys.get(kid) ?? null;
|
|
359
|
+
}), verifyRs256 = Effect.fn("Jwt.verifyRs256")(function* (token, issuer, jwksUri) {
|
|
360
|
+
let parts = token.split(".");
|
|
361
|
+
if (parts.length !== 3)
|
|
362
|
+
return null;
|
|
363
|
+
let [h, p, s] = parts, header = json2(h), claims = json2(p);
|
|
364
|
+
if (header === null || claims === null || header.alg !== "RS256" || typeof header.kid !== "string" || claims.iss !== issuer)
|
|
365
|
+
return null;
|
|
366
|
+
let nowMs = yield* Clock.currentTimeMillis, key = yield* keyFor(jwksUri, header.kid, nowMs);
|
|
367
|
+
if (key === null)
|
|
368
|
+
return null;
|
|
369
|
+
let signature = base64url(s);
|
|
370
|
+
if (!(yield* Effect.tryPromise(() => crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, (/* @__PURE__ */ new TextEncoder()).encode(`${h}.${p}`))).pipe(Effect.orElseSucceed(() => !1))))
|
|
371
|
+
return null;
|
|
372
|
+
let now = Math.floor(nowMs / 1000);
|
|
373
|
+
if (typeof claims.exp !== "number" || claims.exp + CLOCK_SKEW_S < now)
|
|
374
|
+
return null;
|
|
375
|
+
if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_S > now)
|
|
376
|
+
return null;
|
|
377
|
+
return claims;
|
|
378
|
+
});
|
|
379
|
+
function audienceIncludes(aud, expected) {
|
|
380
|
+
return aud === expected || Array.isArray(aud) && aud.includes(expected);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// src/relay.ts
|
|
384
|
+
import { DurableObject } from "cloudflare:workers";
|
|
320
385
|
import { Clock as Clock3, Duration as Duration2, Effect as Effect3, Layer, Schema as Schema2 } from "effect";
|
|
321
386
|
|
|
322
387
|
// ../front/src/csp.ts
|
|
@@ -368,10 +433,10 @@ var HOP_BY_HOP = [
|
|
|
368
433
|
"upgrade"
|
|
369
434
|
];
|
|
370
435
|
function withoutHopByHop(source) {
|
|
371
|
-
let
|
|
436
|
+
let headers = new Headers(source), connectionHeaders = headers.get("connection")?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
|
|
372
437
|
for (let name of [...HOP_BY_HOP, ...connectionHeaders])
|
|
373
|
-
|
|
374
|
-
return
|
|
438
|
+
headers.delete(name);
|
|
439
|
+
return headers;
|
|
375
440
|
}
|
|
376
441
|
function wantsDocument(request) {
|
|
377
442
|
return request.method === "GET" && (request.headers.get("sec-fetch-mode") === "navigate" || request.headers.get("accept")?.toLowerCase().includes("text/html") === !0);
|
|
@@ -396,7 +461,16 @@ function rewriteUrl(input, ws, ctx) {
|
|
|
396
461
|
let port = Number(url.port || (url.protocol === "https:" || url.protocol === "wss:" ? 443 : 80));
|
|
397
462
|
if (!ctx.ports.includes(port))
|
|
398
463
|
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
|
|
464
|
+
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;
|
|
465
|
+
}
|
|
466
|
+
function rewriteSrcset(value, to) {
|
|
467
|
+
let changed = !1, candidates = value.split(",").map((candidate) => {
|
|
468
|
+
let [url = "", ...descriptors] = candidate.trim().split(/\s+/), rewritten = to(url);
|
|
469
|
+
if (rewritten !== url)
|
|
470
|
+
changed = !0;
|
|
471
|
+
return [rewritten, ...descriptors].join(" ");
|
|
472
|
+
});
|
|
473
|
+
return changed ? candidates.join(", ") : value;
|
|
400
474
|
}
|
|
401
475
|
function shimSource() {
|
|
402
476
|
return `(() => {
|
|
@@ -410,6 +484,7 @@ function shimSource() {
|
|
|
410
484
|
const port = Number(params.get("port"));
|
|
411
485
|
const rewriteUrl = ${rewriteUrl.toString()};
|
|
412
486
|
const to = (input, ws) => rewriteUrl(String(input), ws, { href: location.href, host, ports, port });
|
|
487
|
+
const rewriteSrcset = ${rewriteSrcset.toString()};
|
|
413
488
|
const fetch = window.fetch;
|
|
414
489
|
window.fetch = function (input, init) {
|
|
415
490
|
if (!(input instanceof Request)) return fetch.call(this, to(input, false), init);
|
|
@@ -437,6 +512,51 @@ function shimSource() {
|
|
|
437
512
|
};
|
|
438
513
|
wrap("WebSocket", true);
|
|
439
514
|
wrap("EventSource", false);
|
|
515
|
+
const loads = [
|
|
516
|
+
["HTMLImageElement", ["src", "srcset"]],
|
|
517
|
+
["HTMLSourceElement", ["src", "srcset"]],
|
|
518
|
+
["HTMLScriptElement", ["src"]],
|
|
519
|
+
["HTMLIFrameElement", ["src"]],
|
|
520
|
+
["HTMLMediaElement", ["src"]],
|
|
521
|
+
["HTMLVideoElement", ["poster"]],
|
|
522
|
+
["HTMLEmbedElement", ["src"]],
|
|
523
|
+
["HTMLTrackElement", ["src"]],
|
|
524
|
+
["HTMLLinkElement", ["href"]],
|
|
525
|
+
].filter(([name]) => window[name]);
|
|
526
|
+
const fix = (name, value) => name === "srcset" ? rewriteSrcset(String(value), (url) => to(url, false)) : to(value, false);
|
|
527
|
+
const loaded = (element, name) => loads.some(([type, names]) => element instanceof window[type] && names.includes(name));
|
|
528
|
+
const setAttribute = Element.prototype.setAttribute;
|
|
529
|
+
Element.prototype.setAttribute = function (name, value) {
|
|
530
|
+
const lower = String(name).toLowerCase();
|
|
531
|
+
return setAttribute.call(this, name, loaded(this, lower) ? fix(lower, value) : value);
|
|
532
|
+
};
|
|
533
|
+
for (const [type, names] of loads) {
|
|
534
|
+
for (const name of names) {
|
|
535
|
+
const proto = window[type].prototype;
|
|
536
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
|
|
537
|
+
if (!descriptor?.set) continue;
|
|
538
|
+
Object.defineProperty(proto, name, { ...descriptor, set(value) { descriptor.set.call(this, fix(name, value)); } });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const selector = "img, source, script, iframe, video, audio, embed, track, link";
|
|
542
|
+
const sweep = (element) => {
|
|
543
|
+
for (const name of ["src", "srcset", "poster", "href"]) {
|
|
544
|
+
const value = element.getAttribute(name);
|
|
545
|
+
if (value !== null && loaded(element, name)) {
|
|
546
|
+
const rewritten = fix(name, value);
|
|
547
|
+
if (rewritten !== value) setAttribute.call(element, name, rewritten);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
new MutationObserver((records) => {
|
|
552
|
+
for (const record of records) {
|
|
553
|
+
for (const node of record.addedNodes) {
|
|
554
|
+
if (!(node instanceof Element)) continue;
|
|
555
|
+
if (node.matches(selector)) sweep(node);
|
|
556
|
+
for (const element of node.querySelectorAll(selector)) sweep(element);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}).observe(document, { childList: true, subtree: true });
|
|
440
560
|
const follow = (element, attribute) => {
|
|
441
561
|
const value = element?.getAttribute(attribute);
|
|
442
562
|
if (!value) return;
|
|
@@ -459,10 +579,10 @@ function shimTag(params, nonce) {
|
|
|
459
579
|
}
|
|
460
580
|
|
|
461
581
|
// src/core.ts
|
|
462
|
-
import { Clock, Context, Duration, Effect, Schedule, Schema } from "effect";
|
|
582
|
+
import { Clock as Clock2, Context, Duration, Effect as Effect2, Schedule, Schema } from "effect";
|
|
463
583
|
|
|
464
584
|
// src/route.ts
|
|
465
|
-
var PORT_PATH = /^\/__tesser\/(\d{1,5})(\/.*)?$/;
|
|
585
|
+
var PORT_PATH = /^\/__tesser\/(?:([a-z0-9-]+(?:\.[a-z0-9-]+)*)\.)?(\d{1,5})(\/.*)?$/;
|
|
466
586
|
function parseTarget(gatewayHost, url) {
|
|
467
587
|
let bare = url.hostname.toLowerCase().replace(/\.$/, ""), suffix = `.${gatewayHost.toLowerCase()}`;
|
|
468
588
|
if (!bare.endsWith(suffix))
|
|
@@ -470,14 +590,14 @@ function parseTarget(gatewayHost, url) {
|
|
|
470
590
|
let labels = bare.slice(0, -suffix.length).split("."), label = labels[labels.length - 1], parsed = parseGatewayLabel(label);
|
|
471
591
|
if (parsed === null)
|
|
472
592
|
return null;
|
|
473
|
-
let gateway = `${gatewayHost}${url.port ? `:${url.port}` : ""}`,
|
|
593
|
+
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
594
|
if (match === null)
|
|
475
595
|
return { ...base, port: null, path: `${url.pathname}${url.search}` };
|
|
476
|
-
return { ...base, port: Number(match[
|
|
596
|
+
return { ...base, sub: match[1] ?? base.sub, port: Number(match[2]), path: `${match[3] ?? "/"}${url.search}` };
|
|
477
597
|
}
|
|
478
|
-
function parseUrl(raw) {
|
|
598
|
+
function parseUrl(raw, base) {
|
|
479
599
|
try {
|
|
480
|
-
return new URL(raw);
|
|
600
|
+
return new URL(raw, base);
|
|
481
601
|
} catch {
|
|
482
602
|
return null;
|
|
483
603
|
}
|
|
@@ -510,7 +630,7 @@ class Waker extends Context.Service()("@tesser/gateway/Waker") {
|
|
|
510
630
|
|
|
511
631
|
class Upstream extends Context.Service()("@tesser/gateway/Upstream") {
|
|
512
632
|
}
|
|
513
|
-
var WAKING_WINDOW = Duration.minutes(5), startingRetry = { schedule: Schedule.spaced(Duration.seconds(1)), times: 60 }, respond = (response) => ({ _tag: "response", response });
|
|
633
|
+
var VIEWER_COOKIE = "__Host-tesser-viewer", GATEWAY_COOKIES = /* @__PURE__ */ new Set(["CF_Authorization", VIEWER_COOKIE]), WAKING_WINDOW = Duration.minutes(5), startingRetry = { schedule: Schedule.spaced(Duration.seconds(1)), times: 60 }, respond = (response) => ({ _tag: "response", response });
|
|
514
634
|
function page(request, state) {
|
|
515
635
|
let status = viewerStatus(state);
|
|
516
636
|
if (wantsDocument(request))
|
|
@@ -522,21 +642,21 @@ function viewerBox(box) {
|
|
|
522
642
|
return { name: box.name, sharedBy: box.sharedBy };
|
|
523
643
|
}
|
|
524
644
|
function upstreamHeaders(request, target, port, root) {
|
|
525
|
-
let
|
|
526
|
-
for (let name of [...
|
|
645
|
+
let headers = withoutHopByHop(request.headers);
|
|
646
|
+
for (let name of [...headers.keys()])
|
|
527
647
|
if (name.startsWith("cf-access-") || name.startsWith("x-forwarded-"))
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
let cookie =
|
|
648
|
+
headers.delete(name);
|
|
649
|
+
headers.delete("forwarded"), headers.delete("x-tesser-viewer"), headers.delete("x-tesser-ticket");
|
|
650
|
+
let cookie = headers.get("cookie")?.split(";").filter((c) => !GATEWAY_COOKIES.has(c.trim().split("=")[0])).join(";").trim();
|
|
531
651
|
if (cookie)
|
|
532
|
-
|
|
652
|
+
headers.set("cookie", cookie);
|
|
533
653
|
else
|
|
534
|
-
|
|
535
|
-
|
|
654
|
+
headers.delete("cookie");
|
|
655
|
+
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
656
|
let origin = request.headers.get("origin");
|
|
537
657
|
if (origin !== null && parseUrl(origin)?.host === new URL(request.url).host)
|
|
538
|
-
|
|
539
|
-
return
|
|
658
|
+
headers.set("origin", `http://${presentedHost(target.pageSub, root)}`);
|
|
659
|
+
return headers;
|
|
540
660
|
}
|
|
541
661
|
function isHtmlDocument(request, response) {
|
|
542
662
|
if (!wantsDocument(request) || response.status < 200 || response.status >= 300)
|
|
@@ -544,62 +664,62 @@ function isHtmlDocument(request, response) {
|
|
|
544
664
|
return (response.headers.get("content-type")?.toLowerCase() ?? "").includes("text/html") && !response.headers.has("content-range");
|
|
545
665
|
}
|
|
546
666
|
function downstream(request, response, upstreamUrl, ctx, shownOrigin) {
|
|
547
|
-
let
|
|
548
|
-
|
|
549
|
-
let location =
|
|
667
|
+
let headers = withoutHopByHop(response.headers);
|
|
668
|
+
headers.delete("content-encoding"), headers.delete("content-length");
|
|
669
|
+
let location = headers.get("location");
|
|
550
670
|
if (location !== null)
|
|
551
|
-
|
|
671
|
+
headers.set("location", rewriteUrl(parseUrl(location) === null ? new URL(location, upstreamUrl).href : location, !1, ctx));
|
|
552
672
|
let origin = request.headers.get("origin");
|
|
553
|
-
if (origin !== null && shownOrigin !== null &&
|
|
554
|
-
|
|
555
|
-
let init = { status: response.status, statusText: response.statusText, headers
|
|
673
|
+
if (origin !== null && shownOrigin !== null && headers.get("access-control-allow-origin") === shownOrigin)
|
|
674
|
+
headers.set("access-control-allow-origin", origin);
|
|
675
|
+
let init = { status: response.status, statusText: response.statusText, headers };
|
|
556
676
|
if (!isHtmlDocument(request, response))
|
|
557
677
|
return new Response(response.body, init);
|
|
558
|
-
let csp =
|
|
678
|
+
let csp = headers.get("content-security-policy");
|
|
559
679
|
if (csp !== null)
|
|
560
|
-
|
|
680
|
+
headers.set("content-security-policy", allowSelfScripts(csp));
|
|
561
681
|
let tag = shimTag({ host: ctx.host, ports: ctx.ports, port: ctx.port }, csp === null ? void 0 : scriptNonce(csp)), placed = !1, inject = {
|
|
562
682
|
element(element) {
|
|
563
683
|
if (placed)
|
|
564
684
|
return;
|
|
565
685
|
placed = !0, element.prepend(tag, { html: !0 });
|
|
566
686
|
}
|
|
567
|
-
}, link = (attribute) => ({
|
|
687
|
+
}, link = (attribute, rewrite = (value) => rewriteUrl(value, !1, ctx)) => ({
|
|
568
688
|
element(element) {
|
|
569
689
|
let value = element.getAttribute(attribute);
|
|
570
690
|
if (value === null)
|
|
571
691
|
return;
|
|
572
|
-
let rewritten =
|
|
692
|
+
let rewritten = rewrite(value);
|
|
573
693
|
if (rewritten !== value)
|
|
574
694
|
element.setAttribute(attribute, rewritten);
|
|
575
695
|
}
|
|
576
696
|
});
|
|
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));
|
|
697
|
+
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
698
|
}
|
|
579
|
-
var wakingPage =
|
|
580
|
-
let now = yield*
|
|
699
|
+
var wakingPage = Effect2.fn("Gateway.wakingPage")(function* (request, box, port, stage, since) {
|
|
700
|
+
let now = yield* Clock2.currentTimeMillis;
|
|
581
701
|
return page(request, { kind: "waking", box: viewerBox(box), stage, port, seconds: Math.max(0, Math.round((now - since) / 1000)) });
|
|
582
|
-
}), refused =
|
|
583
|
-
let status = yield* (yield* Waker).status(box.boxId), now = yield*
|
|
702
|
+
}), refused = Effect2.fn("Gateway.refused")(function* (request, box, port) {
|
|
703
|
+
let status = yield* (yield* Waker).status(box.boxId), now = yield* Clock2.currentTimeMillis;
|
|
584
704
|
if (box.app === "exited")
|
|
585
705
|
return page(request, { kind: "stopped", box: viewerBox(box) });
|
|
586
706
|
if (status !== null && now - status.since < Duration.toMillis(WAKING_WINDOW))
|
|
587
707
|
return yield* wakingPage(request, box, port, box.app === "running" ? "port" : "app", status.since);
|
|
588
708
|
return page(request, { kind: "not-answering", box: viewerBox(box), port });
|
|
589
|
-
}), serve =
|
|
590
|
-
let
|
|
709
|
+
}), serve = Effect2.fn("Gateway.serve")(function* (request, target, box, port, root) {
|
|
710
|
+
let headers = upstreamHeaders(request, target, port, root);
|
|
591
711
|
if (request.headers.get("upgrade")?.toLowerCase() === "websocket")
|
|
592
|
-
return { _tag: "websocket", box, port, path: target.path, headers
|
|
593
|
-
let ctx = { href: request.url, host: target.boxHost, ports: box.ports, port: root }, upstreamUrl = `http://${
|
|
712
|
+
return { _tag: "websocket", box, port, path: target.path, headers };
|
|
713
|
+
let ctx = { href: request.url, host: target.boxHost, ports: box.ports, port: root }, upstreamUrl = `http://${headers.get("host")}${target.path}`, shownOrigin = headers.get("origin"), replayable = request.method === "GET" || request.method === "HEAD", document = wantsDocument(request), upstream = yield* Upstream, attempt = Effect2.suspend(() => upstream.fetch(box, port, new Request(upstreamUrl, {
|
|
594
714
|
method: request.method,
|
|
595
|
-
headers
|
|
715
|
+
headers,
|
|
596
716
|
body: replayable ? void 0 : request.body,
|
|
597
717
|
redirect: "manual",
|
|
598
718
|
signal: request.signal,
|
|
599
719
|
...replayable ? {} : { duplex: "half" }
|
|
600
720
|
})));
|
|
601
|
-
return yield* (document || !replayable ? attempt : attempt.pipe(
|
|
602
|
-
}), handle =
|
|
721
|
+
return yield* (document || !replayable ? attempt : attempt.pipe(Effect2.retry(startingRetry))).pipe(Effect2.map((response) => respond(downstream(request, response, upstreamUrl, ctx, shownOrigin))), Effect2.catchTag("UpstreamRefused", () => document ? refused(request, box, port) : Effect2.succeed(page(request, { kind: "not-answering", box: viewerBox(box), port }))));
|
|
722
|
+
}), handle = Effect2.fn("Gateway.handle")(function* (request) {
|
|
603
723
|
let directory = yield* BoxDirectory, waker = yield* Waker, url = new URL(request.url), host = yield* directory.host, target = parseTarget(host, url);
|
|
604
724
|
if (target === null)
|
|
605
725
|
return page(request, { kind: "not-shared" });
|
|
@@ -625,75 +745,17 @@ var wakingPage = Effect.fn("Gateway.wakingPage")(function* (request, box, port,
|
|
|
625
745
|
if (wantsDocument(request)) {
|
|
626
746
|
if (box.power === "launching" && status === null)
|
|
627
747
|
return page(request, { kind: "booting", box: viewerBox(box) });
|
|
628
|
-
return yield* wakingPage(request, box, port, "machine", status?.since ?? (yield*
|
|
748
|
+
return yield* wakingPage(request, box, port, "machine", status?.since ?? (yield* Clock2.currentTimeMillis));
|
|
629
749
|
}
|
|
630
750
|
return yield* serve(request, target, yield* waker.awake(box.boxId), port, root);
|
|
631
751
|
}
|
|
632
752
|
return yield* serve(request, target, box, port, root);
|
|
633
|
-
}, (effect, request) => effect.pipe(
|
|
634
|
-
BoxNotShared: () =>
|
|
635
|
-
DirectoryUnavailable: () =>
|
|
636
|
-
WakeFailed: (e) =>
|
|
753
|
+
}, (effect, request) => effect.pipe(Effect2.catchTags({
|
|
754
|
+
BoxNotShared: () => Effect2.succeed(page(request, { kind: "not-shared" })),
|
|
755
|
+
DirectoryUnavailable: () => Effect2.succeed(page(request, { kind: "unavailable" })),
|
|
756
|
+
WakeFailed: (e) => Effect2.succeed(page(request, { kind: "wake-failed", box: { name: e.boxId, sharedBy: null }, reason: e.reason }))
|
|
637
757
|
})));
|
|
638
758
|
|
|
639
|
-
// src/jwt.ts
|
|
640
|
-
import { Clock as Clock2, Effect as Effect2, Option } from "effect";
|
|
641
|
-
var KEYS_TTL_MS = 600000, REFETCH_MIN_MS = 30000, CLOCK_SKEW_S = 60, keySets = /* @__PURE__ */ new Map;
|
|
642
|
-
function base64url(text2) {
|
|
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
|
-
}
|
|
696
|
-
|
|
697
759
|
// src/tunnel.ts
|
|
698
760
|
var UPGRADE_TIMEOUT_MS = 30000, NULL_BODY = /* @__PURE__ */ new Set([101, 204, 205, 304]);
|
|
699
761
|
|
|
@@ -708,12 +770,12 @@ class Tunnel {
|
|
|
708
770
|
this.socket = socket;
|
|
709
771
|
this.inUse = inUse;
|
|
710
772
|
}
|
|
711
|
-
send(
|
|
773
|
+
send(frame) {
|
|
712
774
|
let ws = this.socket();
|
|
713
775
|
if (ws === null)
|
|
714
776
|
return !1;
|
|
715
777
|
try {
|
|
716
|
-
return ws.send(encodeFrame(
|
|
778
|
+
return ws.send(encodeFrame(frame)), !0;
|
|
717
779
|
} catch {
|
|
718
780
|
return !1;
|
|
719
781
|
}
|
|
@@ -728,14 +790,14 @@ class Tunnel {
|
|
|
728
790
|
async fetch(port, request) {
|
|
729
791
|
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
792
|
this.streams.set(stream, s);
|
|
731
|
-
let
|
|
732
|
-
if (!this.send({ type: "open", stream, port, method: request.method, path: `${url.pathname}${url.search}`, headers
|
|
793
|
+
let headers = [...request.headers];
|
|
794
|
+
if (!this.send({ type: "open", stream, port, method: request.method, path: `${url.pathname}${url.search}`, headers, ws: !1 }))
|
|
733
795
|
throw this.streams.delete(stream), new StreamRefused("the box is not connected");
|
|
734
796
|
request.signal.addEventListener("abort", () => this.abort(stream, "the viewer went away")), this.pump(stream, s, request.body);
|
|
735
797
|
let h = await head;
|
|
736
798
|
if (NULL_BODY.has(h.status) || request.method === "HEAD")
|
|
737
799
|
return this.abort(stream, "no body expected"), new Response(null, { status: h.status, statusText: h.statusText, headers: h.headers });
|
|
738
|
-
let
|
|
800
|
+
let body = new ReadableStream({
|
|
739
801
|
start: (controller) => {
|
|
740
802
|
s.controller = controller;
|
|
741
803
|
for (let chunk of s.buffered.splice(0))
|
|
@@ -746,11 +808,11 @@ class Tunnel {
|
|
|
746
808
|
pull: () => this.credit(stream, s),
|
|
747
809
|
cancel: () => this.abort(stream, "the viewer stopped reading")
|
|
748
810
|
}, { highWaterMark: MUX_WINDOW, size: (chunk) => chunk.byteLength });
|
|
749
|
-
return new Response(
|
|
811
|
+
return new Response(body, { status: h.status, statusText: h.statusText, headers: h.headers });
|
|
750
812
|
}
|
|
751
|
-
async upgrade(port, path,
|
|
813
|
+
async upgrade(port, path, headers) {
|
|
752
814
|
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
|
|
815
|
+
if (!this.send({ type: "open", stream, port, method: "GET", path, headers, ws: !0 }))
|
|
754
816
|
throw this.streams.delete(stream), new StreamRefused("the box is not connected");
|
|
755
817
|
let timeout = setTimeout(() => {
|
|
756
818
|
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 +823,12 @@ class Tunnel {
|
|
|
761
823
|
clearTimeout(timeout);
|
|
762
824
|
}
|
|
763
825
|
}
|
|
764
|
-
async pump(stream, s,
|
|
765
|
-
if (
|
|
826
|
+
async pump(stream, s, body) {
|
|
827
|
+
if (body === null) {
|
|
766
828
|
this.send({ type: "end", stream });
|
|
767
829
|
return;
|
|
768
830
|
}
|
|
769
|
-
let reader =
|
|
831
|
+
let reader = body.getReader();
|
|
770
832
|
try {
|
|
771
833
|
for (;; ) {
|
|
772
834
|
let { done, value } = await reader.read();
|
|
@@ -815,41 +877,41 @@ class Tunnel {
|
|
|
815
877
|
s.controller?.error(err);
|
|
816
878
|
} catch {}
|
|
817
879
|
}
|
|
818
|
-
onFrame(
|
|
819
|
-
let s = this.streams.get(
|
|
880
|
+
onFrame(frame) {
|
|
881
|
+
let s = this.streams.get(frame.stream);
|
|
820
882
|
if (s === void 0)
|
|
821
883
|
return !1;
|
|
822
884
|
if (s.kind === "ws") {
|
|
823
|
-
if (
|
|
824
|
-
this.streams.delete(
|
|
825
|
-
else if (
|
|
826
|
-
this.streams.delete(
|
|
885
|
+
if (frame.type === "head")
|
|
886
|
+
this.streams.delete(frame.stream), s.resolve(frame);
|
|
887
|
+
else if (frame.type === "reset")
|
|
888
|
+
this.streams.delete(frame.stream), s.reject(new StreamRefused(frame.reason));
|
|
827
889
|
return !0;
|
|
828
890
|
}
|
|
829
|
-
switch (
|
|
891
|
+
switch (frame.type) {
|
|
830
892
|
case "head":
|
|
831
|
-
return s.head?.resolve(
|
|
893
|
+
return s.head?.resolve(frame), s.head = null, !0;
|
|
832
894
|
case "data":
|
|
833
895
|
if (s.controller === null)
|
|
834
|
-
return s.buffered.push(
|
|
896
|
+
return s.buffered.push(frame.data), s.owed += frame.data.byteLength, !0;
|
|
835
897
|
try {
|
|
836
|
-
s.controller.enqueue(
|
|
898
|
+
s.controller.enqueue(frame.data);
|
|
837
899
|
} catch {
|
|
838
900
|
return !0;
|
|
839
901
|
}
|
|
840
|
-
if (s.owed +=
|
|
841
|
-
this.credit(
|
|
902
|
+
if (s.owed += frame.data.byteLength, (s.controller.desiredSize ?? 0) > 0)
|
|
903
|
+
this.credit(frame.stream, s);
|
|
842
904
|
return !0;
|
|
843
905
|
case "end":
|
|
844
|
-
this.streams.delete(
|
|
906
|
+
this.streams.delete(frame.stream), s.closed = !0;
|
|
845
907
|
try {
|
|
846
908
|
s.controller?.close();
|
|
847
909
|
} catch {}
|
|
848
910
|
return !0;
|
|
849
911
|
case "reset":
|
|
850
|
-
return this.fail(
|
|
912
|
+
return this.fail(frame.stream, new StreamRefused(frame.reason)), !0;
|
|
851
913
|
case "credit":
|
|
852
|
-
return s.window +=
|
|
914
|
+
return s.window += frame.bytes, s.onCredit?.(), s.onCredit = null, !0;
|
|
853
915
|
default:
|
|
854
916
|
return !0;
|
|
855
917
|
}
|
|
@@ -860,8 +922,9 @@ class Tunnel {
|
|
|
860
922
|
}
|
|
861
923
|
}
|
|
862
924
|
|
|
863
|
-
// src/
|
|
864
|
-
var TICKET_HEADER = "x-tesser-ticket",
|
|
925
|
+
// src/relay.ts
|
|
926
|
+
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({
|
|
927
|
+
org: Schema2.String,
|
|
865
928
|
box: Schema2.String,
|
|
866
929
|
name: Schema2.String,
|
|
867
930
|
sharedBy: Schema2.NullOr(Schema2.String),
|
|
@@ -878,63 +941,18 @@ var TICKET_HEADER = "x-tesser-ticket", ACCESS_HEADER = "cf-access-jwt-assertion"
|
|
|
878
941
|
app: Schema2.NullOr(Schema2.Literals(["none", "running", "unsupervised", "exited"]))
|
|
879
942
|
})
|
|
880
943
|
}), ErrorBody = Schema2.Struct({ error: Schema2.Struct({ message: Schema2.String }) });
|
|
881
|
-
function plain(status,
|
|
882
|
-
return new Response(`${
|
|
944
|
+
function plain(status, text) {
|
|
945
|
+
return new Response(`${text}
|
|
883
946
|
`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
|
|
884
947
|
}
|
|
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
948
|
|
|
933
|
-
class
|
|
949
|
+
class BoxRelay extends DurableObject {
|
|
934
950
|
tunnel = new Tunnel(() => this.boxSocket(), (stream) => this.ctx.getWebSockets(`v:${stream}`).length > 0);
|
|
935
951
|
waking = null;
|
|
936
952
|
connected = [];
|
|
937
|
-
verifiedAt =
|
|
953
|
+
verifiedAt = /* @__PURE__ */ new Map;
|
|
954
|
+
unstored = /* @__PURE__ */ new Map;
|
|
955
|
+
failingSince = /* @__PURE__ */ new Map;
|
|
938
956
|
constructor(ctx, env) {
|
|
939
957
|
super(ctx, env);
|
|
940
958
|
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(MUX_PING, MUX_PONG));
|
|
@@ -963,9 +981,13 @@ class GatewayBoxes extends DurableObject {
|
|
|
963
981
|
}
|
|
964
982
|
async fetch(request) {
|
|
965
983
|
let ticket = request.headers.get(TICKET_HEADER);
|
|
966
|
-
if (ticket !== null)
|
|
967
|
-
|
|
968
|
-
|
|
984
|
+
if (ticket !== null) {
|
|
985
|
+
let decoded = Schema2.decodeUnknownOption(Ticket)(JSON.parse(ticket));
|
|
986
|
+
if (decoded._tag === "None" || !this.admits(decoded.value))
|
|
987
|
+
return plain(403, "this box answers for another org here");
|
|
988
|
+
return this.acceptBox(decoded.value);
|
|
989
|
+
}
|
|
990
|
+
let handled = await Effect3.runPromise(handle(request).pipe(Effect3.provide(this.layer(this.viewer(request)))));
|
|
969
991
|
if (handled._tag === "response")
|
|
970
992
|
return handled.response;
|
|
971
993
|
return this.bridge(request, handled);
|
|
@@ -975,32 +997,99 @@ class GatewayBoxes extends DurableObject {
|
|
|
975
997
|
if (previous !== null)
|
|
976
998
|
this.retire(previous, 4000, "replaced by a newer connection");
|
|
977
999
|
let [client, server] = Object.values(new WebSocketPair);
|
|
978
|
-
this.ctx.acceptWebSocket(server, ["box"]), server.serializeAttachment({ ticket }), this.waking = null, this.verifiedAt
|
|
1000
|
+
this.ctx.acceptWebSocket(server, ["box"]), server.serializeAttachment({ ticket }), this.waking = null, this.verifiedAt.clear(), this.failingSince.clear();
|
|
979
1001
|
for (let resolve of this.connected.splice(0))
|
|
980
1002
|
resolve();
|
|
981
|
-
return this.ctx.waitUntil(this.
|
|
1003
|
+
return this.ctx.waitUntil(this.armAlarm(ticket)), new Response(null, { status: 101, webSocket: client });
|
|
1004
|
+
}
|
|
1005
|
+
socketViewer(ws) {
|
|
1006
|
+
let { stream, viewer } = ws.deserializeAttachment();
|
|
1007
|
+
return { stream, viewer: viewer ?? this.unstored.get(stream) ?? null };
|
|
1008
|
+
}
|
|
1009
|
+
viewers() {
|
|
1010
|
+
return new Set(this.ctx.getWebSockets("viewer").flatMap((ws) => this.socketViewer(ws).viewer ?? []));
|
|
1011
|
+
}
|
|
1012
|
+
async armAlarm(ticket) {
|
|
1013
|
+
let want = this.viewers().size > 0 ? Math.min(ticket.exp * 1000, Date.now() + RECHECK_MS) : ticket.exp * 1000, pending = await this.ctx.storage.getAlarm();
|
|
1014
|
+
if (pending === null || pending <= Date.now() || want < pending)
|
|
1015
|
+
await this.ctx.storage.setAlarm(want);
|
|
1016
|
+
}
|
|
1017
|
+
closeViewer(viewer, reason) {
|
|
1018
|
+
for (let ws of this.ctx.getWebSockets("viewer")) {
|
|
1019
|
+
let { stream, viewer: who } = this.socketViewer(ws);
|
|
1020
|
+
if (who !== viewer)
|
|
1021
|
+
continue;
|
|
1022
|
+
this.tunnel.send({ type: "close", stream, code: 1008, reason });
|
|
1023
|
+
try {
|
|
1024
|
+
ws.close(1008, reason);
|
|
1025
|
+
} catch {}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
verdict(viewer, answer, now, open) {
|
|
1029
|
+
if (answer?.status === 200) {
|
|
1030
|
+
for (let [key, at] of this.verifiedAt)
|
|
1031
|
+
if (now - at > STALE_MS)
|
|
1032
|
+
this.verifiedAt.delete(key);
|
|
1033
|
+
return this.verifiedAt.set(viewer, now), this.failingSince.delete(viewer), "ok";
|
|
1034
|
+
}
|
|
1035
|
+
if (answer?.status === 404 || answer?.status === 403)
|
|
1036
|
+
this.verifiedAt.delete(viewer), this.failingSince.delete(viewer);
|
|
1037
|
+
let box = this.boxSocket();
|
|
1038
|
+
if (answer?.status === 404) {
|
|
1039
|
+
if (box !== null)
|
|
1040
|
+
this.retire(box, 4003, "share revoked");
|
|
1041
|
+
return "revoked";
|
|
1042
|
+
}
|
|
1043
|
+
if (answer?.status === 403)
|
|
1044
|
+
return this.closeViewer(viewer, "access ended"), "refused";
|
|
1045
|
+
let verified = this.verifiedAt.get(viewer);
|
|
1046
|
+
if (open && verified === void 0 && !this.failingSince.has(viewer))
|
|
1047
|
+
this.failingSince.set(viewer, now);
|
|
1048
|
+
let since = verified ?? (open ? this.failingSince.get(viewer) : 0);
|
|
1049
|
+
if (now - since > STALE_MS) {
|
|
1050
|
+
if (open)
|
|
1051
|
+
this.closeViewer(viewer, "tesser can't confirm access right now");
|
|
1052
|
+
return "stale";
|
|
1053
|
+
}
|
|
1054
|
+
return "unknown";
|
|
982
1055
|
}
|
|
983
1056
|
async alarm() {
|
|
984
1057
|
let ws = this.boxSocket();
|
|
985
1058
|
if (ws === null)
|
|
986
1059
|
return;
|
|
987
1060
|
let { ticket } = ws.deserializeAttachment();
|
|
988
|
-
if (ticket.exp * 1000 <= Date.now())
|
|
1061
|
+
if (ticket.exp * 1000 <= Date.now()) {
|
|
989
1062
|
this.retire(ws, 4001, "share expired");
|
|
990
|
-
|
|
991
|
-
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
for (let viewer of this.viewers()) {
|
|
1066
|
+
let answer = await this.lookupBox(ticket.box, viewer).catch(() => null);
|
|
1067
|
+
if (this.verdict(viewer, answer, Date.now(), !0) === "revoked")
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
await this.armAlarm(ticket);
|
|
992
1071
|
}
|
|
993
1072
|
async bridge(request, target) {
|
|
994
|
-
let
|
|
1073
|
+
let headers = [...target.headers].filter(([name]) => !/^sec-websocket-(key|version|extensions)$/i.test(name)), upgraded;
|
|
995
1074
|
try {
|
|
996
|
-
upgraded = await this.tunnel.upgrade(target.port, target.path,
|
|
1075
|
+
upgraded = await this.tunnel.upgrade(target.port, target.path, headers);
|
|
997
1076
|
} catch (err) {
|
|
998
1077
|
return plain(502, `${target.box.name} refused the websocket: ${err instanceof Error ? err.message : String(err)}`);
|
|
999
1078
|
}
|
|
1000
1079
|
if (upgraded.head.status !== 101)
|
|
1001
1080
|
return plain(502, `${target.box.name} answered ${upgraded.head.status} to the websocket`);
|
|
1002
1081
|
let [client, server] = Object.values(new WebSocketPair);
|
|
1003
|
-
this.ctx.acceptWebSocket(server, ["viewer", `v:${upgraded.stream}`])
|
|
1082
|
+
this.ctx.acceptWebSocket(server, ["viewer", `v:${upgraded.stream}`]);
|
|
1083
|
+
let viewer = this.viewer(request);
|
|
1084
|
+
try {
|
|
1085
|
+
server.serializeAttachment({ stream: upgraded.stream, viewer });
|
|
1086
|
+
} catch {
|
|
1087
|
+
if (server.serializeAttachment({ stream: upgraded.stream, viewer: null }), viewer !== null)
|
|
1088
|
+
this.unstored.set(upgraded.stream, viewer);
|
|
1089
|
+
}
|
|
1090
|
+
let ticket = this.live();
|
|
1091
|
+
if (ticket !== null)
|
|
1092
|
+
await this.armAlarm(ticket);
|
|
1004
1093
|
let protocol = upgraded.head.headers.find(([name]) => name.toLowerCase() === "sec-websocket-protocol")?.[1];
|
|
1005
1094
|
return new Response(null, { status: 101, webSocket: client, headers: protocol === void 0 ? {} : { "sec-websocket-protocol": protocol } });
|
|
1006
1095
|
}
|
|
@@ -1008,29 +1097,29 @@ class GatewayBoxes extends DurableObject {
|
|
|
1008
1097
|
if (this.ctx.getTags(ws).includes("box")) {
|
|
1009
1098
|
if (typeof message === "string")
|
|
1010
1099
|
return;
|
|
1011
|
-
let
|
|
1100
|
+
let frame;
|
|
1012
1101
|
try {
|
|
1013
|
-
|
|
1102
|
+
frame = decodeFrame(new Uint8Array(message));
|
|
1014
1103
|
} catch {
|
|
1015
1104
|
return;
|
|
1016
1105
|
}
|
|
1017
|
-
if (this.tunnel.onFrame(
|
|
1106
|
+
if (this.tunnel.onFrame(frame))
|
|
1018
1107
|
return;
|
|
1019
|
-
let viewer = this.ctx.getWebSockets(`v:${
|
|
1108
|
+
let viewer = this.ctx.getWebSockets(`v:${frame.stream}`)[0];
|
|
1020
1109
|
if (viewer === void 0) {
|
|
1021
|
-
if (
|
|
1022
|
-
this.tunnel.send({ type: "close", stream:
|
|
1110
|
+
if (frame.type === "text" || frame.type === "binary")
|
|
1111
|
+
this.tunnel.send({ type: "close", stream: frame.stream, code: 1001, reason: "the viewer went away" });
|
|
1023
1112
|
return;
|
|
1024
1113
|
}
|
|
1025
1114
|
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,
|
|
1115
|
+
if (frame.type === "text")
|
|
1116
|
+
viewer.send(frame.text);
|
|
1117
|
+
else if (frame.type === "binary")
|
|
1118
|
+
viewer.send(frame.data);
|
|
1119
|
+
else if (frame.type === "close")
|
|
1120
|
+
viewer.close(frame.code, frame.reason);
|
|
1121
|
+
else if (frame.type === "reset")
|
|
1122
|
+
viewer.close(1011, frame.reason.slice(0, 120));
|
|
1034
1123
|
} catch {}
|
|
1035
1124
|
return;
|
|
1036
1125
|
}
|
|
@@ -1044,52 +1133,43 @@ class GatewayBoxes extends DurableObject {
|
|
|
1044
1133
|
this.retire(ws, 1000, "box hung up");
|
|
1045
1134
|
return;
|
|
1046
1135
|
}
|
|
1047
|
-
let { stream } = ws.deserializeAttachment()
|
|
1136
|
+
let { stream } = ws.deserializeAttachment();
|
|
1137
|
+
this.unstored.delete(stream);
|
|
1138
|
+
let sendable = code === 1005 ? 1000 : code === 1004 || code === 1006 || code === 1015 || code < 1000 || code > 4999 ? 1001 : code;
|
|
1048
1139
|
this.tunnel.send({ type: "close", stream, code: sendable, reason: reason.slice(0, 120) });
|
|
1049
1140
|
}
|
|
1050
1141
|
webSocketError(ws) {
|
|
1051
1142
|
this.webSocketClose(ws, 1006, "socket error");
|
|
1052
1143
|
}
|
|
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
|
-
});
|
|
1144
|
+
ask(call) {
|
|
1145
|
+
return Effect3.tryPromise({ try: call, catch: (err) => new DirectoryUnavailable({ reason: err instanceof Error ? err.message : String(err) }) });
|
|
1062
1146
|
}
|
|
1063
|
-
layer(
|
|
1064
|
-
let self = this, remote = Effect3.fn("
|
|
1065
|
-
let { status
|
|
1066
|
-
if (
|
|
1147
|
+
layer(viewer) {
|
|
1148
|
+
let self = this, remote = Effect3.fn("Relay.remote")(function* (boxId) {
|
|
1149
|
+
let { status, body } = yield* self.ask(() => self.lookupBox(boxId, viewer));
|
|
1150
|
+
if (status === 403 || status === 404)
|
|
1067
1151
|
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 ${
|
|
1152
|
+
if (status !== 200) {
|
|
1153
|
+
let message = Schema2.decodeUnknownOption(ErrorBody)(body);
|
|
1154
|
+
return yield* new DirectoryUnavailable({ reason: message._tag === "Some" ? message.value.error.message : `tesser answered ${status}` });
|
|
1071
1155
|
}
|
|
1072
|
-
return (yield* Schema2.decodeUnknownEffect(BoxResponse)(
|
|
1073
|
-
}), lookup = Effect3.fn("
|
|
1156
|
+
return (yield* Schema2.decodeUnknownEffect(BoxResponse)(body).pipe(Effect3.mapError(() => new DirectoryUnavailable({ reason: "tesser answered with a box this gateway can't read" })))).box;
|
|
1157
|
+
}), lookup = Effect3.fn("Relay.lookup")(function* (boxId) {
|
|
1074
1158
|
let ticket = self.live();
|
|
1075
1159
|
if (ticket === null) {
|
|
1076
1160
|
let box = yield* remote(boxId);
|
|
1077
1161
|
return { ...box, power: box.power === "awake" ? "launching" : box.power };
|
|
1078
1162
|
}
|
|
1079
1163
|
let now = yield* Clock3.currentTimeMillis;
|
|
1080
|
-
if (
|
|
1081
|
-
let
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
if (ws !== null)
|
|
1087
|
-
self.retire(ws, 4003, "share revoked");
|
|
1088
|
-
return yield* checked.failure;
|
|
1089
|
-
}
|
|
1164
|
+
if (viewer !== null && now - (self.verifiedAt.get(viewer) ?? 0) > RECHECK_MS) {
|
|
1165
|
+
let answer = yield* self.ask(() => self.lookupBox(boxId, viewer)).pipe(Effect3.orElseSucceed(() => null)), verdict = self.verdict(viewer, answer, now, !1);
|
|
1166
|
+
if (verdict === "revoked" || verdict === "refused")
|
|
1167
|
+
return yield* new BoxNotShared({ boxId });
|
|
1168
|
+
if (verdict === "stale")
|
|
1169
|
+
return yield* new DirectoryUnavailable({ reason: "tesser didn't answer whether you can still open this box" });
|
|
1090
1170
|
}
|
|
1091
1171
|
return { boxId, name: ticket.name, sharedBy: ticket.sharedBy, privateIp: "", ports: ticket.ports, power: "awake", app: "running" };
|
|
1092
|
-
}), wake = Effect3.fn("
|
|
1172
|
+
}), wake = Effect3.fn("Relay.wake")(function* (boxId, retry) {
|
|
1093
1173
|
let now = yield* Clock3.currentTimeMillis, current = self.waking;
|
|
1094
1174
|
if (current?.inflight)
|
|
1095
1175
|
return;
|
|
@@ -1099,20 +1179,20 @@ class GatewayBoxes extends DurableObject {
|
|
|
1099
1179
|
return;
|
|
1100
1180
|
let waking = { since: current === null || current.failure !== null || now - current.since > Duration2.toMillis(WAKE_TIMEOUT) ? now : current.since, kickedAt: now, failure: null, inflight: !0 };
|
|
1101
1181
|
self.waking = waking;
|
|
1102
|
-
let kicked = yield* self.
|
|
1182
|
+
let kicked = yield* self.ask(() => self.wakeBox(boxId, viewer)).pipe(Effect3.result, Effect3.ensuring(Effect3.sync(() => waking.inflight = !1)));
|
|
1103
1183
|
if (kicked._tag === "Failure")
|
|
1104
1184
|
return;
|
|
1105
|
-
let { status
|
|
1106
|
-
if (
|
|
1185
|
+
let { status, body } = kicked.success;
|
|
1186
|
+
if (status < 300 || status >= 500 || RETRYABLE.has(status))
|
|
1107
1187
|
return;
|
|
1108
|
-
let message = Schema2.decodeUnknownOption(ErrorBody)(
|
|
1109
|
-
waking.failure = message._tag === "Some" ? message.value.error.message : `tesser answered ${
|
|
1110
|
-
}), status = Effect3.fn("
|
|
1188
|
+
let message = Schema2.decodeUnknownOption(ErrorBody)(body);
|
|
1189
|
+
waking.failure = message._tag === "Some" ? message.value.error.message : `tesser answered ${status}`;
|
|
1190
|
+
}), status = Effect3.fn("Relay.status")(function* (_boxId) {
|
|
1111
1191
|
let now = yield* Clock3.currentTimeMillis, w = self.waking;
|
|
1112
1192
|
if (w === null || w.failure === null && now - w.since > Duration2.toMillis(WAKE_TIMEOUT))
|
|
1113
1193
|
return null;
|
|
1114
1194
|
return { since: w.since, failure: w.failure };
|
|
1115
|
-
}), dialed = (boxId, within, reason) => Effect3.promise(() => new Promise((resolve) => self.connected.push(resolve))).pipe(Effect3.timeoutOrElse({ duration: within, orElse: () => Effect3.fail(new WakeFailed({ boxId, reason })) })), awake = Effect3.fn("
|
|
1195
|
+
}), dialed = (boxId, within, reason) => Effect3.promise(() => new Promise((resolve) => self.connected.push(resolve))).pipe(Effect3.timeoutOrElse({ duration: within, orElse: () => Effect3.fail(new WakeFailed({ boxId, reason })) })), awake = Effect3.fn("Relay.awake")(function* (boxId) {
|
|
1116
1196
|
if (self.live() === null)
|
|
1117
1197
|
if ((yield* remote(boxId)).power === "awake")
|
|
1118
1198
|
yield* dialed(boxId, DIAL_GRACE, "the box is up but hasn't connected to this gateway; its boxd may need an update");
|
|
@@ -1125,10 +1205,73 @@ class GatewayBoxes extends DurableObject {
|
|
|
1125
1205
|
}
|
|
1126
1206
|
return yield* lookup(boxId);
|
|
1127
1207
|
}), upstream = (box, port, request) => Effect3.tryPromise({ try: () => self.tunnel.fetch(port, request), catch: () => new UpstreamRefused({ boxId: box.boxId, port }) });
|
|
1128
|
-
return Layer.mergeAll(Layer.succeed(BoxDirectory)({ host: Effect3.succeed(self.
|
|
1208
|
+
return Layer.mergeAll(Layer.succeed(BoxDirectory)({ host: Effect3.succeed(self.host()), lookup }), Layer.succeed(Waker)({ wake, status, awake }), Layer.succeed(Upstream)({ fetch: upstream }));
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/cloudflare.ts
|
|
1213
|
+
var ACCESS_HEADER = "cf-access-jwt-assertion", verifyTicket = Effect4.fn("Gateway.verifyTicket")(function* (env, request) {
|
|
1214
|
+
let token = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1];
|
|
1215
|
+
if (token === void 0)
|
|
1216
|
+
return null;
|
|
1217
|
+
let issuer = env.TESSER_URL.replace(/\/$/, ""), claims = yield* verifyRs256(token, issuer, `${issuer}/.well-known/jwks.json`);
|
|
1218
|
+
if (claims === null || !audienceIncludes(claims.aud, `https://${env.GATEWAY_HOST}`) || claims.org !== env.TESSER_ORG)
|
|
1219
|
+
return null;
|
|
1220
|
+
if (claims.sub !== `box:${env.TESSER_ORG}:${String(claims.box)}`)
|
|
1221
|
+
return null;
|
|
1222
|
+
return yield* Schema3.decodeUnknownEffect(Ticket)(claims).pipe(Effect4.option);
|
|
1223
|
+
}), verifyViewer = Effect4.fn("Gateway.verifyViewer")(function* (env, request) {
|
|
1224
|
+
let token = request.headers.get(ACCESS_HEADER);
|
|
1225
|
+
if (token === null)
|
|
1226
|
+
return null;
|
|
1227
|
+
let issuer = `https://${env.ACCESS_TEAM}`, claims = yield* verifyRs256(token, issuer, `${issuer}/cdn-cgi/access/certs`);
|
|
1228
|
+
return claims !== null && audienceIncludes(claims.aud, env.ACCESS_AUD) ? claims : null;
|
|
1229
|
+
}), cloudflare_default = {
|
|
1230
|
+
async fetch(request, env) {
|
|
1231
|
+
let url = new URL(request.url), apex = url.hostname.toLowerCase() === env.GATEWAY_HOST.toLowerCase();
|
|
1232
|
+
if (apex && url.pathname === GATEWAY_DIAL_PATH) {
|
|
1233
|
+
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket")
|
|
1234
|
+
return plain(426, "boxes dial this with a websocket");
|
|
1235
|
+
let ticket = await Effect4.runPromise(verifyTicket(env, request));
|
|
1236
|
+
if (ticket === null || ticket._tag === "None")
|
|
1237
|
+
return plain(401, "a box dials in with a share ticket signed by tesser");
|
|
1238
|
+
let headers = new Headers(request.headers);
|
|
1239
|
+
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 }));
|
|
1240
|
+
}
|
|
1241
|
+
if (apex && url.pathname === "/__tesser/health")
|
|
1242
|
+
return plain(200, "ok");
|
|
1243
|
+
let boxId = gatewayHostBox(env.GATEWAY_HOST, url.hostname);
|
|
1244
|
+
if (boxId === null)
|
|
1245
|
+
return new Response(viewerPage({ kind: "not-shared" }), { status: 404, headers: STATUS_HEADERS });
|
|
1246
|
+
if (await Effect4.runPromise(verifyViewer(env, request)) === null)
|
|
1247
|
+
return plain(403, "this gateway only answers requests Cloudflare Access let through");
|
|
1248
|
+
let headers = new Headers(request.headers);
|
|
1249
|
+
return headers.delete(TICKET_HEADER), env.BOXES.get(env.BOXES.idFromName(boxId)).fetch(new Request(request, { headers }));
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
class GatewayBoxes extends BoxRelay {
|
|
1254
|
+
host() {
|
|
1255
|
+
return this.env.GATEWAY_HOST;
|
|
1256
|
+
}
|
|
1257
|
+
viewer(request) {
|
|
1258
|
+
return request.headers.get(ACCESS_HEADER);
|
|
1259
|
+
}
|
|
1260
|
+
admits(ticket) {
|
|
1261
|
+
return ticket.org === this.env.TESSER_ORG;
|
|
1262
|
+
}
|
|
1263
|
+
lookupBox(boxId, viewer) {
|
|
1264
|
+
return this.tesser("GET", `/gateway/boxes/${boxId}`, viewer);
|
|
1265
|
+
}
|
|
1266
|
+
wakeBox(boxId, viewer) {
|
|
1267
|
+
return this.tesser("POST", `/gateway/boxes/${boxId}/wake`, viewer);
|
|
1268
|
+
}
|
|
1269
|
+
async tesser(method, path, jwt) {
|
|
1270
|
+
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);
|
|
1271
|
+
return { status: res.status, body };
|
|
1129
1272
|
}
|
|
1130
1273
|
}
|
|
1131
1274
|
export {
|
|
1132
|
-
|
|
1133
|
-
|
|
1275
|
+
GatewayBoxes,
|
|
1276
|
+
cloudflare_default as default
|
|
1134
1277
|
};
|