@statewalker/httpeers-share 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -2
- package/dist/bridge/bridge.d.ts +37 -0
- package/dist/bridge/bridge.d.ts.map +1 -0
- package/dist/bridge/bridge.js +195 -0
- package/dist/bridge/index.d.ts +8 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +7 -0
- package/dist/bridge/serve.d.ts +20 -0
- package/dist/bridge/serve.d.ts.map +1 -0
- package/dist/bridge/serve.js +66 -0
- package/dist/bridge/sockets.d.ts +47 -0
- package/dist/bridge/sockets.d.ts.map +1 -0
- package/dist/bridge/sockets.js +86 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/invite-link.d.ts +21 -0
- package/dist/invite-link.d.ts.map +1 -0
- package/dist/invite-link.js +38 -0
- package/dist/join-outcome.d.ts +16 -0
- package/dist/join-outcome.d.ts.map +1 -0
- package/dist/join-outcome.js +32 -0
- package/dist/lifecycle.d.ts +19 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +28 -0
- package/dist/policy.d.ts +19 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +40 -0
- package/dist/probe.d.ts +20 -0
- package/dist/probe.d.ts.map +1 -0
- package/dist/probe.js +60 -0
- package/dist/service-table.d.ts +33 -0
- package/dist/service-table.d.ts.map +1 -0
- package/dist/service-table.js +59 -0
- package/dist/share-router.d.ts +15 -0
- package/dist/share-router.d.ts.map +1 -0
- package/dist/share-router.js +31 -0
- package/dist/socket-frames.d.ts +30 -0
- package/dist/socket-frames.d.ts.map +1 -0
- package/dist/socket-frames.js +81 -0
- package/dist/socket-tunnel.d.ts +46 -0
- package/dist/socket-tunnel.d.ts.map +1 -0
- package/dist/socket-tunnel.js +157 -0
- package/dist/stamp-guest.d.ts +7 -0
- package/dist/stamp-guest.d.ts.map +1 -0
- package/dist/stamp-guest.js +17 -0
- package/dist/targets.d.ts +18 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +48 -0
- package/dist/upstreams.d.ts +47 -0
- package/dist/upstreams.d.ts.map +1 -0
- package/dist/upstreams.js +141 -0
- package/package.json +59 -3
- package/src/bridge/bridge.ts +229 -0
- package/src/bridge/index.ts +17 -0
- package/src/bridge/serve.ts +88 -0
- package/src/bridge/sockets.ts +119 -0
- package/src/index.ts +50 -0
- package/src/invite-link.ts +48 -0
- package/src/join-outcome.ts +33 -0
- package/src/lifecycle.ts +31 -0
- package/src/policy.ts +52 -0
- package/src/probe.ts +86 -0
- package/src/service-table.ts +91 -0
- package/src/share-router.ts +43 -0
- package/src/socket-frames.ts +83 -0
- package/src/socket-tunnel.ts +167 -0
- package/src/stamp-guest.ts +18 -0
- package/src/targets.ts +51 -0
- package/src/upstreams.ts +149 -0
package/src/probe.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Test again": one request through the same Upstream Guests use.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Upstream } from "@statewalker/webrun-http-proxy";
|
|
6
|
+
import {
|
|
7
|
+
BRIDGE_COOKIES_KEPT_HEADER,
|
|
8
|
+
BRIDGE_GUEST_HEADER,
|
|
9
|
+
BRIDGE_SET_COOKIE_HEADER,
|
|
10
|
+
PROBE_GUEST,
|
|
11
|
+
unreachable,
|
|
12
|
+
} from "./upstreams.js";
|
|
13
|
+
|
|
14
|
+
export type Health = "ok" | "slow" | "down";
|
|
15
|
+
export type ProbeCause = "service-down" | "bridge-down" | "cors-missing";
|
|
16
|
+
export type ProbeWarning = "ws-needed" | "cookies-set";
|
|
17
|
+
|
|
18
|
+
export interface ProbeReport {
|
|
19
|
+
health: Health;
|
|
20
|
+
cause?: ProbeCause;
|
|
21
|
+
warnings: ProbeWarning[];
|
|
22
|
+
ms: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What Guests notice: a page that takes longer than this feels broken. */
|
|
26
|
+
export const SLOW_MS = 1500;
|
|
27
|
+
|
|
28
|
+
export async function probe(
|
|
29
|
+
upstream: Upstream,
|
|
30
|
+
opts: { slowMs?: number; direct?: string } = {},
|
|
31
|
+
): Promise<ProbeReport> {
|
|
32
|
+
const started = performance.now();
|
|
33
|
+
// As a Guest of its own: a Bridge with cookie jars treats the Probe like anyone else.
|
|
34
|
+
const response = await upstream(
|
|
35
|
+
new Request("http://probe.peershare/", { headers: { [BRIDGE_GUEST_HEADER]: PROBE_GUEST } }),
|
|
36
|
+
);
|
|
37
|
+
const body = await response.text();
|
|
38
|
+
const ms = Math.round(performance.now() - started);
|
|
39
|
+
|
|
40
|
+
const down = unreachable(response);
|
|
41
|
+
if (down === "service" && opts.direct != null && (await answersWithoutCors(opts.direct))) {
|
|
42
|
+
return { health: "down", cause: "cors-missing", warnings: [], ms };
|
|
43
|
+
}
|
|
44
|
+
if (down != null) return { health: "down", cause: `${down}-down`, warnings: [], ms };
|
|
45
|
+
// A 404 at `/` is an app that answers; a 5xx is one that does not work.
|
|
46
|
+
if (response.status >= 500) return { health: "down", cause: "service-down", warnings: [], ms };
|
|
47
|
+
|
|
48
|
+
const health = ms >= (opts.slowMs ?? SLOW_MS) ? "slow" : "ok";
|
|
49
|
+
const warnings: ProbeWarning[] = [];
|
|
50
|
+
if (isHtml(response) && WEBSOCKET_CLIENTS.some((re) => re.test(body))) warnings.push("ws-needed");
|
|
51
|
+
// Only the Bridge's marker: a page never sees `Set-Cookie` itself.
|
|
52
|
+
// A cookie login breaks for Guests only when nothing keeps the cookies for them.
|
|
53
|
+
const kept = response.headers.get(BRIDGE_COOKIES_KEPT_HEADER) === "kept";
|
|
54
|
+
if (response.headers.has(BRIDGE_SET_COOKIE_HEADER) && !kept) warnings.push("cookies-set");
|
|
55
|
+
return { health, warnings, ms };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pages whose scripts open a WebSocket: dev servers' hot-reload clients, and
|
|
60
|
+
* pages that do it themselves. Guests' traffic is HTTP only, so these break.
|
|
61
|
+
* Matched in script tags and code, not in prose.
|
|
62
|
+
*/
|
|
63
|
+
const WEBSOCKET_CLIENTS = [
|
|
64
|
+
/<script[^>]+src="[^"]*\/@vite\/client"/,
|
|
65
|
+
/<script[^>]+src="[^"]*webpack-dev-server/,
|
|
66
|
+
/<script[^>]+src="[^"]*\/_next\/webpack-hmr/,
|
|
67
|
+
/new\s+WebSocket\s*\(/,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function isHtml(response: Response): boolean {
|
|
71
|
+
return (response.headers.get("content-type") ?? "").includes("text/html");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* In a browser a missing CORS header fails exactly like a dead server. A
|
|
76
|
+
* `no-cors` request is not subject to CORS: an (opaque) answer means the
|
|
77
|
+
* site is up, so what is missing is its permission for other origins.
|
|
78
|
+
*/
|
|
79
|
+
async function answersWithoutCors(target: string): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
await fetch(target, { mode: "no-cors" });
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sharer's Services as Guests see them: a live catalog at `/s/`, and
|
|
3
|
+
* each Service at `/s/<id>/…`.
|
|
4
|
+
*
|
|
5
|
+
* The hub's own advertisements are posted once, when it is created (there is
|
|
6
|
+
* no way to update them), so they cannot carry a Service that was added or
|
|
7
|
+
* paused later. One static advertisement says "Dirk shares services"; what
|
|
8
|
+
* they are, and which are paused, is read here, per request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { FetchHandler, Upstream } from "@statewalker/webrun-http-proxy";
|
|
12
|
+
import { shareRouter } from "./share-router.js";
|
|
13
|
+
import { SOCKET_HEADER } from "./socket-frames.js";
|
|
14
|
+
import { sharerSocket } from "./socket-tunnel.js";
|
|
15
|
+
|
|
16
|
+
export interface ServiceEntry {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
upstream: Upstream;
|
|
20
|
+
/** The Service's WebSocket at `path` (M9); without it, sockets are refused. */
|
|
21
|
+
socket?: (path: string, protocols: string[]) => WebSocket;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
isDefault: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CatalogEntry {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
isDefault: boolean;
|
|
30
|
+
paused: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Marks the table's own answers (paused, unknown), so they are never taken for a Service's. */
|
|
34
|
+
export const SERVICE_MARKER = "x-peershare-service";
|
|
35
|
+
|
|
36
|
+
export function serviceTable(
|
|
37
|
+
services: () => readonly ServiceEntry[],
|
|
38
|
+
/** `sharerName` may be a function: read per request, so a name set later shows. */
|
|
39
|
+
opts: { sharerName?: string | (() => string) } = {},
|
|
40
|
+
): FetchHandler {
|
|
41
|
+
const nameOf =
|
|
42
|
+
typeof opts.sharerName === "function"
|
|
43
|
+
? opts.sharerName
|
|
44
|
+
: () => opts.sharerName as string | undefined;
|
|
45
|
+
const router = shareRouter({ services: () => services().filter((s) => s.enabled) });
|
|
46
|
+
|
|
47
|
+
return async (request) => {
|
|
48
|
+
const sharer = nameOf() || "The Sharer";
|
|
49
|
+
const { pathname } = new URL(request.url);
|
|
50
|
+
if (pathname === "/s/" || pathname === "/s") {
|
|
51
|
+
const catalog: CatalogEntry[] = [...services()]
|
|
52
|
+
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault))
|
|
53
|
+
.map((s) => ({ id: s.id, name: s.name, isDefault: s.isDefault, paused: !s.enabled }));
|
|
54
|
+
return Response.json(catalog);
|
|
55
|
+
}
|
|
56
|
+
const id = pathname.split("/")[2] ?? "";
|
|
57
|
+
const service = services().find((s) => s.id === id);
|
|
58
|
+
if (service == null) return own(404, "unknown", `There is no service "${id}" here.`);
|
|
59
|
+
if (!service.enabled) {
|
|
60
|
+
return own(
|
|
61
|
+
503,
|
|
62
|
+
"paused",
|
|
63
|
+
`${sharer} paused ${service.name}. It is back when ${sharer} turns it on again.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const protocols = request.headers.get(SOCKET_HEADER);
|
|
67
|
+
if (protocols != null) {
|
|
68
|
+
if (service.socket == null)
|
|
69
|
+
return own(501, "no-socket", `${service.name} cannot carry live connections.`);
|
|
70
|
+
const url = new URL(request.url);
|
|
71
|
+
const rest = url.pathname.slice(`/s/${id}`.length) || "/";
|
|
72
|
+
const open = service.socket;
|
|
73
|
+
return sharerSocket(request, () =>
|
|
74
|
+
open(`${rest}${url.search}`, protocols.split(",").filter(Boolean)),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return router(request);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function own(status: number, marker: string, message: string): Response {
|
|
82
|
+
const body = `<!doctype html><meta charset="utf-8"><title>${escapeHtml(message)}</title><p>${escapeHtml(message)}</p>`;
|
|
83
|
+
return new Response(body, {
|
|
84
|
+
status,
|
|
85
|
+
headers: { "content-type": "text/html; charset=utf-8", [SERVICE_MARKER]: marker },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeHtml(text: string): string {
|
|
90
|
+
return text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
91
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Sharer's member mounts for Guests: `/s/<serviceId>/<path>`.
|
|
3
|
+
*
|
|
4
|
+
* The router knows nothing about upstream kinds: it strips the mount, hands
|
|
5
|
+
* the rest of the path to the Service's `Upstream`, and that is all.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { bodyOf } from "@statewalker/httpeers-core";
|
|
9
|
+
import type { FetchHandler, Upstream } from "@statewalker/webrun-http-proxy";
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
|
|
12
|
+
export interface SharedService {
|
|
13
|
+
id: string;
|
|
14
|
+
upstream: Upstream;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function shareRouter(init: { services: () => readonly SharedService[] }): FetchHandler {
|
|
18
|
+
const app = new Hono();
|
|
19
|
+
|
|
20
|
+
app.all("/s/:id/*", async (c) => {
|
|
21
|
+
const id = c.req.param("id");
|
|
22
|
+
// Read per request, so adding or pausing a Service needs no rebuild.
|
|
23
|
+
const service = init.services().find((s) => s.id === id);
|
|
24
|
+
if (service == null) return c.notFound();
|
|
25
|
+
|
|
26
|
+
const url = new URL(c.req.url);
|
|
27
|
+
const rest = url.pathname.slice(`/s/${id}`.length) || "/";
|
|
28
|
+
// Through `bodyOf`, not `request.body`: Firefox has no `Request.body`,
|
|
29
|
+
// and a rewritten request silently lost its body there.
|
|
30
|
+
const body = await bodyOf(c.req.raw);
|
|
31
|
+
return service.upstream(
|
|
32
|
+
new Request(new URL(`${rest}${url.search}`, url.origin), {
|
|
33
|
+
method: c.req.method,
|
|
34
|
+
headers: c.req.raw.headers,
|
|
35
|
+
body,
|
|
36
|
+
...(body instanceof ReadableStream ? { duplex: "half" as const } : {}),
|
|
37
|
+
signal: c.req.raw.signal,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return async (request) => app.fetch(request);
|
|
43
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket messages inside one streaming HTTP request over the mesh (rung 15:
|
|
3
|
+
* a mesh request is full-duplex). Each frame: 1 byte type, 4 bytes length
|
|
4
|
+
* (big-endian), payload.
|
|
5
|
+
*
|
|
6
|
+
* text (1): UTF-8
|
|
7
|
+
* binary (2): bytes
|
|
8
|
+
* close (3): 2 bytes code, then the reason in UTF-8
|
|
9
|
+
* open (4): the subprotocol the app accepted, in UTF-8 (first frame back)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type SocketFrame =
|
|
13
|
+
| { type: "text"; data: string }
|
|
14
|
+
| { type: "binary"; data: Uint8Array }
|
|
15
|
+
| { type: "close"; code: number; reason: string }
|
|
16
|
+
| { type: "open"; protocol: string };
|
|
17
|
+
|
|
18
|
+
/** Asks a Service's upstream for a socket instead of a response: the header names the subprotocols. */
|
|
19
|
+
export const SOCKET_HEADER = "x-peershare-websocket";
|
|
20
|
+
|
|
21
|
+
const TEXT = 1;
|
|
22
|
+
const BINARY = 2;
|
|
23
|
+
const CLOSE = 3;
|
|
24
|
+
const OPEN = 4;
|
|
25
|
+
const HEAD = 5;
|
|
26
|
+
|
|
27
|
+
export function encodeFrame(frame: SocketFrame): Uint8Array {
|
|
28
|
+
const enc = new TextEncoder();
|
|
29
|
+
let type: number;
|
|
30
|
+
let payload: Uint8Array;
|
|
31
|
+
if (frame.type === "text") {
|
|
32
|
+
type = TEXT;
|
|
33
|
+
payload = enc.encode(frame.data);
|
|
34
|
+
} else if (frame.type === "binary") {
|
|
35
|
+
type = BINARY;
|
|
36
|
+
payload = frame.data;
|
|
37
|
+
} else if (frame.type === "open") {
|
|
38
|
+
type = OPEN;
|
|
39
|
+
payload = enc.encode(frame.protocol);
|
|
40
|
+
} else {
|
|
41
|
+
type = CLOSE;
|
|
42
|
+
const reason = enc.encode(frame.reason);
|
|
43
|
+
payload = new Uint8Array(2 + reason.length);
|
|
44
|
+
new DataView(payload.buffer).setUint16(0, frame.code);
|
|
45
|
+
payload.set(reason, 2);
|
|
46
|
+
}
|
|
47
|
+
const out = new Uint8Array(HEAD + payload.length);
|
|
48
|
+
const view = new DataView(out.buffer);
|
|
49
|
+
view.setUint8(0, type);
|
|
50
|
+
view.setUint32(1, payload.length);
|
|
51
|
+
out.set(payload, HEAD);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bytes, chunked any way, to frames. */
|
|
56
|
+
export function decodeFrames(): TransformStream<Uint8Array, SocketFrame> {
|
|
57
|
+
let buffer = new Uint8Array(0);
|
|
58
|
+
const dec = new TextDecoder();
|
|
59
|
+
return new TransformStream({
|
|
60
|
+
transform(chunk, controller) {
|
|
61
|
+
const joined = new Uint8Array(buffer.length + chunk.length);
|
|
62
|
+
joined.set(buffer);
|
|
63
|
+
joined.set(chunk, buffer.length);
|
|
64
|
+
buffer = joined;
|
|
65
|
+
while (buffer.length >= HEAD) {
|
|
66
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
67
|
+
const length = view.getUint32(1);
|
|
68
|
+
if (buffer.length < HEAD + length) break;
|
|
69
|
+
const type = view.getUint8(0);
|
|
70
|
+
const payload = buffer.slice(HEAD, HEAD + length);
|
|
71
|
+
buffer = buffer.slice(HEAD + length);
|
|
72
|
+
if (type === TEXT) controller.enqueue({ type: "text", data: dec.decode(payload) });
|
|
73
|
+
else if (type === BINARY) controller.enqueue({ type: "binary", data: payload });
|
|
74
|
+
else if (type === CLOSE) {
|
|
75
|
+
const code = new DataView(payload.buffer, payload.byteOffset).getUint16(0);
|
|
76
|
+
controller.enqueue({ type: "close", code, reason: dec.decode(payload.slice(2)) });
|
|
77
|
+
} else if (type === OPEN)
|
|
78
|
+
controller.enqueue({ type: "open", protocol: dec.decode(payload) });
|
|
79
|
+
else controller.error(new Error(`unknown socket frame type ${type}`));
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WebSocket inside one request over the mesh (M9; rung 15 measured that a
|
|
3
|
+
* mesh request is full-duplex). The Guest's `openTunnel` sends frames in the
|
|
4
|
+
* request body and reads them from the response body; the Sharer's
|
|
5
|
+
* `sharerSocket` relays them to the Service's own socket.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FetchHandler } from "@statewalker/webrun-http-proxy";
|
|
9
|
+
import { decodeFrames, encodeFrame, SOCKET_HEADER, type SocketFrame } from "./socket-frames.js";
|
|
10
|
+
|
|
11
|
+
/** The Sharer's side: answer a tunnel request by relaying to `open()`'s socket. */
|
|
12
|
+
export function sharerSocket(request: Request, open: () => WebSocket): Response {
|
|
13
|
+
const ws = open();
|
|
14
|
+
ws.binaryType = "arraybuffer";
|
|
15
|
+
const held: Array<string | ArrayBuffer> = [];
|
|
16
|
+
let out!: ReadableStreamDefaultController<Uint8Array>;
|
|
17
|
+
let done = false;
|
|
18
|
+
const emit = (frame: SocketFrame) => {
|
|
19
|
+
if (!done) out.enqueue(encodeFrame(frame));
|
|
20
|
+
};
|
|
21
|
+
const end = (code: number, reason: string) => {
|
|
22
|
+
emit({ type: "close", code, reason });
|
|
23
|
+
if (!done) {
|
|
24
|
+
done = true;
|
|
25
|
+
out.close();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const body = new ReadableStream<Uint8Array>({
|
|
29
|
+
start(controller) {
|
|
30
|
+
out = controller;
|
|
31
|
+
ws.onopen = () => {
|
|
32
|
+
emit({ type: "open", protocol: ws.protocol });
|
|
33
|
+
for (const data of held.splice(0)) ws.send(data);
|
|
34
|
+
};
|
|
35
|
+
ws.onmessage = (e) =>
|
|
36
|
+
emit(
|
|
37
|
+
typeof e.data === "string"
|
|
38
|
+
? { type: "text", data: e.data }
|
|
39
|
+
: { type: "binary", data: new Uint8Array(e.data as ArrayBuffer) },
|
|
40
|
+
);
|
|
41
|
+
ws.onclose = (e) => end(e.code, e.reason);
|
|
42
|
+
ws.onerror = () => end(1011, "the Service's socket failed");
|
|
43
|
+
},
|
|
44
|
+
cancel() {
|
|
45
|
+
done = true;
|
|
46
|
+
ws.close();
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The Guest's frames, as they arrive: the request is still open.
|
|
51
|
+
void (async () => {
|
|
52
|
+
if (request.body == null) return;
|
|
53
|
+
for await (const frame of request.body.pipeThrough(decodeFrames())) {
|
|
54
|
+
if (frame.type === "close") {
|
|
55
|
+
ws.close(frame.code === 1000 || frame.code >= 3000 ? frame.code : 1000, frame.reason);
|
|
56
|
+
} else if (frame.type === "text" || frame.type === "binary") {
|
|
57
|
+
const data = frame.type === "text" ? frame.data : frame.data.slice().buffer;
|
|
58
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
59
|
+
else held.push(data);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// A client may close only with 1000 or 3000–4999 (1011 throws `InvalidAccessError`).
|
|
63
|
+
})().catch(() => ws.close(1000, "the Guest's stream ended abnormally"));
|
|
64
|
+
|
|
65
|
+
return new Response(body, { headers: { "content-type": "application/octet-stream" } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Listener<E> = ((event: E) => void) | null;
|
|
69
|
+
|
|
70
|
+
/** What a Guest holds: shaped like a browser WebSocket, carried over `fetch`. */
|
|
71
|
+
export class TunnelSocket {
|
|
72
|
+
static readonly CONNECTING = 0;
|
|
73
|
+
static readonly OPEN = 1;
|
|
74
|
+
static readonly CLOSING = 2;
|
|
75
|
+
static readonly CLOSED = 3;
|
|
76
|
+
|
|
77
|
+
readyState = TunnelSocket.CONNECTING;
|
|
78
|
+
protocol = "";
|
|
79
|
+
onopen: Listener<{ type: "open" }> = null;
|
|
80
|
+
onmessage: Listener<{ type: "message"; data: string | ArrayBuffer }> = null;
|
|
81
|
+
onclose: Listener<{ type: "close"; code: number; reason: string; wasClean: boolean }> = null;
|
|
82
|
+
onerror: Listener<{ type: "error" }> = null;
|
|
83
|
+
|
|
84
|
+
private out!: ReadableStreamDefaultController<Uint8Array>;
|
|
85
|
+
private sentClose = false;
|
|
86
|
+
|
|
87
|
+
constructor(fetch: FetchHandler, path: string, protocols: string[]) {
|
|
88
|
+
const body = new ReadableStream<Uint8Array>({
|
|
89
|
+
start: (controller) => {
|
|
90
|
+
this.out = controller;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
// `path` may be a full URL (`http://local/peers/<sharer>/s/<id>/live`), so
|
|
94
|
+
// the caller's `fetch` gets the Request as built, streaming body and all.
|
|
95
|
+
void fetch(
|
|
96
|
+
new Request(/^https?:/.test(path) ? path : `http://tunnel${path}`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { [SOCKET_HEADER]: protocols.join(",") },
|
|
99
|
+
body,
|
|
100
|
+
duplex: "half",
|
|
101
|
+
} as RequestInit),
|
|
102
|
+
)
|
|
103
|
+
.then((res) => this.read(res))
|
|
104
|
+
.catch(() => this.closed(1006, "the tunnel failed"));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void {
|
|
108
|
+
if (this.readyState !== TunnelSocket.OPEN) throw new Error("the socket is not open");
|
|
109
|
+
const frame: SocketFrame =
|
|
110
|
+
typeof data === "string"
|
|
111
|
+
? { type: "text", data }
|
|
112
|
+
: {
|
|
113
|
+
type: "binary",
|
|
114
|
+
data:
|
|
115
|
+
data instanceof ArrayBuffer
|
|
116
|
+
? new Uint8Array(data)
|
|
117
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
118
|
+
};
|
|
119
|
+
this.out.enqueue(encodeFrame(frame));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close(code = 1000, reason = ""): void {
|
|
123
|
+
if (this.readyState >= TunnelSocket.CLOSING) return;
|
|
124
|
+
this.readyState = TunnelSocket.CLOSING;
|
|
125
|
+
this.endRequest(code, reason);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private endRequest(code: number, reason: string): void {
|
|
129
|
+
if (this.sentClose) return;
|
|
130
|
+
this.sentClose = true;
|
|
131
|
+
try {
|
|
132
|
+
this.out.enqueue(encodeFrame({ type: "close", code, reason }));
|
|
133
|
+
this.out.close();
|
|
134
|
+
} catch {
|
|
135
|
+
/* already closed */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async read(res: Response): Promise<void> {
|
|
140
|
+
if (!res.ok || res.body == null) {
|
|
141
|
+
this.onerror?.({ type: "error" });
|
|
142
|
+
return this.closed(1011, `refused: ${res.status}`);
|
|
143
|
+
}
|
|
144
|
+
for await (const frame of res.body.pipeThrough(decodeFrames())) {
|
|
145
|
+
if (frame.type === "open") {
|
|
146
|
+
this.protocol = frame.protocol;
|
|
147
|
+
this.readyState = TunnelSocket.OPEN;
|
|
148
|
+
this.onopen?.({ type: "open" });
|
|
149
|
+
} else if (frame.type === "text") this.onmessage?.({ type: "message", data: frame.data });
|
|
150
|
+
else if (frame.type === "binary")
|
|
151
|
+
this.onmessage?.({ type: "message", data: frame.data.slice().buffer });
|
|
152
|
+
else return this.closed(frame.code, frame.reason);
|
|
153
|
+
}
|
|
154
|
+
this.closed(1006, "the tunnel ended");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private closed(code: number, reason: string): void {
|
|
158
|
+
if (this.readyState === TunnelSocket.CLOSED) return;
|
|
159
|
+
this.readyState = TunnelSocket.CLOSED;
|
|
160
|
+
this.endRequest(1000, "");
|
|
161
|
+
this.onclose?.({ type: "close", code, reason, wasClean: code === 1000 });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function openTunnel(fetch: FetchHandler, path: string, protocols: string[]): TunnelSocket {
|
|
166
|
+
return new TunnelSocket(fetch, path, protocols);
|
|
167
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sharer's half of the cookie jars: name the Guest on every request to
|
|
3
|
+
* `/s`, from the peer id the mesh proved, never from what the Guest sent.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ANONYMOUS, type FetchHandler, lookupPeer } from "@statewalker/httpeers-core";
|
|
7
|
+
import { BRIDGE_GUEST_HEADER } from "./upstreams.js";
|
|
8
|
+
|
|
9
|
+
export function stampGuest(handler: FetchHandler): FetchHandler {
|
|
10
|
+
return (request) => {
|
|
11
|
+
const peer = lookupPeer(request) ?? ANONYMOUS;
|
|
12
|
+
const headers = new Headers(request.headers);
|
|
13
|
+
// Overwritten, not merged: a Guest's own value would let it pick a jar.
|
|
14
|
+
headers.delete(BRIDGE_GUEST_HEADER);
|
|
15
|
+
if (peer !== ANONYMOUS) headers.set(BRIDGE_GUEST_HEADER, peer);
|
|
16
|
+
return handler(new Request(request, { headers }));
|
|
17
|
+
};
|
|
18
|
+
}
|
package/src/targets.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Bridge may relay: the Sharer's own machine, LAN and tailnet,
|
|
3
|
+
* nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Literal addresses are checked as they are. A DNS name is decided by whoever
|
|
6
|
+
* controls its zone at request time (DNS rebinding), so names are refused,
|
|
7
|
+
* with two exceptions: `localhost`, and mDNS `.local` names, which are
|
|
8
|
+
* resolved FIRST and allowed only if every address they resolve to is
|
|
9
|
+
* private. (Some company networks serve `.local` from ordinary DNS, so the
|
|
10
|
+
* name alone proves nothing.)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type TargetVerdict = "allowed" | "forbidden" | { resolve: string };
|
|
14
|
+
|
|
15
|
+
export function checkTarget(target: string): TargetVerdict {
|
|
16
|
+
let url: URL;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(target);
|
|
19
|
+
} catch {
|
|
20
|
+
return "forbidden";
|
|
21
|
+
}
|
|
22
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "forbidden";
|
|
23
|
+
|
|
24
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
25
|
+
if (host === "localhost") return "allowed";
|
|
26
|
+
if (isAddress(host)) return isPrivateAddress(host) ? "allowed" : "forbidden";
|
|
27
|
+
if (/^[a-z0-9-]+(\.[a-z0-9-]+)*\.local$/.test(host)) return { resolve: host };
|
|
28
|
+
return "forbidden";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isAddress(host: string): boolean {
|
|
32
|
+
return /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Loopback, RFC 1918, carrier-grade NAT (Tailscale), IPv6 loopback and unique-local. */
|
|
36
|
+
export function isPrivateAddress(address: string): boolean {
|
|
37
|
+
const v4 = address.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
38
|
+
if (v4 != null) {
|
|
39
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
40
|
+
return (
|
|
41
|
+
a === 127 ||
|
|
42
|
+
a === 10 ||
|
|
43
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
44
|
+
(a === 192 && b === 168) ||
|
|
45
|
+
(a === 100 && b >= 64 && b <= 127)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
// Link-local (fe80::/10) and IPv4-mapped addresses are refused.
|
|
49
|
+
const v6 = address.toLowerCase();
|
|
50
|
+
return v6 === "::1" || /^f[cd][0-9a-f]{0,2}:/.test(v6);
|
|
51
|
+
}
|