@huynle/hub 0.8.6 → 0.9.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/CHANGELOG.md +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The per-user budget of the hub's web fetches (design §4): 30 fetches a minute and 4 at once per user, ONE budget
|
|
2
|
+
// for GET /api/media/remote and GET /api/media/unfurl together (http.ts hands both handlers the same one), so the two
|
|
3
|
+
// routes cannot add up to more than either allows. Cache hits and requests refused before fetching never take from
|
|
4
|
+
// it. In memory, per pod in cluster mode (like the routes' caches).
|
|
5
|
+
|
|
6
|
+
const WINDOW_MS = 60_000
|
|
7
|
+
export const FETCH_BUDGET_DEFAULTS = { perMinute: 30, concurrent: 4 } as const
|
|
8
|
+
|
|
9
|
+
export type BudgetTake =
|
|
10
|
+
| { readonly ok: true; readonly release: () => void }
|
|
11
|
+
/** `retryAfter`: seconds until a fetch may start again. */
|
|
12
|
+
| { readonly ok: false; readonly reason: "minute" | "concurrent"; readonly limit: number; readonly retryAfter: number }
|
|
13
|
+
|
|
14
|
+
export interface FetchBudget {
|
|
15
|
+
/** One fetch for `userID` now, or why not; release() (idempotent) when the fetch ends, failures included. */
|
|
16
|
+
take(userID: string): BudgetTake
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FetchBudgetOptions {
|
|
20
|
+
readonly now?: () => number
|
|
21
|
+
readonly perMinute?: number
|
|
22
|
+
readonly concurrent?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createFetchBudget(options: FetchBudgetOptions = {}): FetchBudget {
|
|
26
|
+
const now = options.now ?? Date.now
|
|
27
|
+
const perMinute = options.perMinute ?? FETCH_BUDGET_DEFAULTS.perMinute
|
|
28
|
+
const concurrent = options.concurrent ?? FETCH_BUDGET_DEFAULTS.concurrent
|
|
29
|
+
const users = new Map<string, { starts: number[]; active: number }>()
|
|
30
|
+
|
|
31
|
+
function take(userID: string): BudgetTake {
|
|
32
|
+
const t = now()
|
|
33
|
+
const user = users.get(userID) ?? { starts: [], active: 0 }
|
|
34
|
+
users.set(userID, user)
|
|
35
|
+
user.starts = user.starts.filter((start) => start > t - WINDOW_MS)
|
|
36
|
+
const idle = () => {
|
|
37
|
+
if (user.active === 0 && user.starts.length === 0) users.delete(userID)
|
|
38
|
+
}
|
|
39
|
+
if (user.starts.length >= perMinute) {
|
|
40
|
+
idle()
|
|
41
|
+
return { ok: false, reason: "minute", limit: perMinute, retryAfter: Math.max(1, Math.ceil((user.starts[0]! + WINDOW_MS - t) / 1000)) }
|
|
42
|
+
}
|
|
43
|
+
if (user.active >= concurrent) return { ok: false, reason: "concurrent", limit: concurrent, retryAfter: 1 }
|
|
44
|
+
user.starts.push(t)
|
|
45
|
+
user.active++
|
|
46
|
+
let released = false
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
release: () => {
|
|
50
|
+
if (released) return
|
|
51
|
+
released = true
|
|
52
|
+
user.active--
|
|
53
|
+
idle()
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { take }
|
|
59
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// The safe outbound fetcher (design §4): the hub fetches user-chosen web URLs (remote images, link previews) for
|
|
2
|
+
// signed-in users only. Without care that is SSRF: the hub would reach into its own network (loopback, the pod and
|
|
3
|
+
// service networks, the cloud metadata IP). Rules:
|
|
4
|
+
// - http/https on their default ports only, no userinfo; numeric host tricks (2130706433, 0177.0.0.1, 0x7f.1, …)
|
|
5
|
+
// are normalised by the WHATWG URL parser and then checked as addresses.
|
|
6
|
+
// - The host is resolved once per hop with the injected resolver, and refused when ANY answer is in a denied range
|
|
7
|
+
// (DENIED below, plus HUB_FETCH_DENY_CIDRS). IPv4-mapped, IPv4-compatible and NAT64 IPv6 are refused outright.
|
|
8
|
+
// - The connection goes to the exact address that was checked (DNS rebinding): Bun fetch on an IP-literal URL with
|
|
9
|
+
// Host = the hostname. Bun takes SNI and certificate verification from Host (quirk w8towhww), so Host never
|
|
10
|
+
// carries a port (only default ports are allowed anyway), and keepalive is off: a pooled socket for that IP would
|
|
11
|
+
// otherwise skip certificate verification for a different hostname.
|
|
12
|
+
// - Redirects are followed manually, at most MAX_REDIRECTS, each hop fully re-checked. No cookies or credentials;
|
|
13
|
+
// User-Agent hub-preview/1. Timeouts: connect (until the response headers) and total (DNS, hops and body).
|
|
14
|
+
// - The body is streamed and capped: over the cap the request is aborted (or truncated, when asked). It is never
|
|
15
|
+
// decompressed (decompress: false, Accept-Encoding: identity, any other Content-Encoding refused): Bun inflates
|
|
16
|
+
// before the cap would see the bytes, so a 1 MiB gzip bomb could grow to gigabytes.
|
|
17
|
+
// Errors carry the host, never the path or query (a URL may hold leaked data).
|
|
18
|
+
import { isIPv4, isIPv6 } from "node:net"
|
|
19
|
+
|
|
20
|
+
export type ResolveHost = (host: string) => Promise<readonly string[]>
|
|
21
|
+
|
|
22
|
+
export type FetchErrorKind = "bad_url" | "refused" | "too_large" | "upstream" | "timeout"
|
|
23
|
+
|
|
24
|
+
const STATUS: Record<FetchErrorKind, number> = { bad_url: 400, refused: 403, too_large: 413, upstream: 502, timeout: 504 }
|
|
25
|
+
|
|
26
|
+
/** Why a fetch did not happen or failed; `status` is the HTTP status a route answers with. */
|
|
27
|
+
export class FetchError extends Error {
|
|
28
|
+
readonly status: number
|
|
29
|
+
constructor(
|
|
30
|
+
readonly kind: FetchErrorKind,
|
|
31
|
+
message: string,
|
|
32
|
+
readonly host?: string,
|
|
33
|
+
readonly upstreamStatus?: number,
|
|
34
|
+
) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = "FetchError"
|
|
37
|
+
this.status = STATUS[kind]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An address block: family, network (masked) and prefix length. */
|
|
42
|
+
export interface Cidr {
|
|
43
|
+
readonly v6: boolean
|
|
44
|
+
readonly net: bigint
|
|
45
|
+
readonly bits: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const USER_AGENT = "hub-preview/1"
|
|
49
|
+
export const MAX_REDIRECTS = 3
|
|
50
|
+
export const MAX_URL_CHARS = 2048
|
|
51
|
+
const DEFAULT_TIMEOUTS = { connectMs: 3000, totalMs: 8000 } as const
|
|
52
|
+
|
|
53
|
+
// ---------- addresses ----------
|
|
54
|
+
|
|
55
|
+
const V4_BITS = 32
|
|
56
|
+
const V6_BITS = 128
|
|
57
|
+
|
|
58
|
+
function v4Value(ip: string): bigint {
|
|
59
|
+
return ip.split(".").reduce((n, part) => (n << 8n) | BigInt(Number(part)), 0n)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The 128-bit value of a valid IPv6 address (no zone), embedded dotted IPv4 tails included. */
|
|
63
|
+
function v6Value(ip: string): bigint {
|
|
64
|
+
let text = ip.toLowerCase()
|
|
65
|
+
const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text)
|
|
66
|
+
if (tail) {
|
|
67
|
+
const n = v4Value(tail[1]!)
|
|
68
|
+
text = `${text.slice(0, -tail[1]!.length)}${(n >> 16n).toString(16)}:${(n & 0xffffn).toString(16)}`
|
|
69
|
+
}
|
|
70
|
+
const [head, rest] = text.split("::") as [string, string | undefined]
|
|
71
|
+
const left = head ? head.split(":") : []
|
|
72
|
+
const right = rest === undefined ? [] : rest ? rest.split(":") : []
|
|
73
|
+
const groups = rest === undefined ? left : [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right]
|
|
74
|
+
return groups.reduce((n, g) => (n << 16n) | BigInt(parseInt(g, 16)), 0n)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An IP address as {v6, value}, or undefined when the text is not one (zones are not addresses here). */
|
|
78
|
+
function parseIP(text: string): { v6: boolean; value: bigint } | undefined {
|
|
79
|
+
if (text.includes("%")) return undefined
|
|
80
|
+
if (isIPv4(text)) return { v6: false, value: v4Value(text) }
|
|
81
|
+
if (isIPv6(text)) return { v6: true, value: v6Value(text) }
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cidr(net: string, bits: number): Cidr {
|
|
86
|
+
const ip = parseIP(net)!
|
|
87
|
+
const size = ip.v6 ? V6_BITS : V4_BITS
|
|
88
|
+
return { v6: ip.v6, net: mask(ip.value, bits, size), bits }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mask = (value: bigint, bits: number, size: number): bigint => (bits === 0 ? 0n : (value >> BigInt(size - bits)) << BigInt(size - bits))
|
|
92
|
+
|
|
93
|
+
const contains = (block: Cidr, ip: { v6: boolean; value: bigint }) =>
|
|
94
|
+
block.v6 === ip.v6 && mask(ip.value, block.bits, block.v6 ? V6_BITS : V4_BITS) === block.net
|
|
95
|
+
|
|
96
|
+
/** Never a public service. */
|
|
97
|
+
const DENIED: readonly Cidr[] = [
|
|
98
|
+
cidr("0.0.0.0", 8), // "this network"
|
|
99
|
+
cidr("10.0.0.0", 8), // private
|
|
100
|
+
cidr("100.64.0.0", 10), // CGNAT
|
|
101
|
+
cidr("127.0.0.0", 8), // loopback
|
|
102
|
+
cidr("169.254.0.0", 16), // link-local, cloud metadata
|
|
103
|
+
cidr("172.16.0.0", 12), // private
|
|
104
|
+
cidr("192.0.0.0", 24), // IETF protocol assignments
|
|
105
|
+
cidr("192.0.2.0", 24), // TEST-NET-1
|
|
106
|
+
cidr("192.88.99.0", 24), // 6to4 relay anycast (deprecated)
|
|
107
|
+
cidr("192.168.0.0", 16), // private
|
|
108
|
+
cidr("198.18.0.0", 15), // benchmarking
|
|
109
|
+
cidr("198.51.100.0", 24), // TEST-NET-2
|
|
110
|
+
cidr("203.0.113.0", 24), // TEST-NET-3
|
|
111
|
+
cidr("224.0.0.0", 4), // multicast
|
|
112
|
+
cidr("240.0.0.0", 4), // reserved, broadcast (255.255.255.255 included)
|
|
113
|
+
cidr("::", 96), // unspecified, loopback, IPv4-compatible (deprecated)
|
|
114
|
+
cidr("::ffff:0:0", 96), // IPv4-mapped: refused whatever the embedded address
|
|
115
|
+
cidr("::ffff:0:0:0", 96), // IPv4-translated (SIIT)
|
|
116
|
+
cidr("64:ff9b::", 96), // NAT64 well-known prefix
|
|
117
|
+
cidr("64:ff9b:1::", 48), // NAT64 local-use prefix
|
|
118
|
+
cidr("100::", 64), // discard-only
|
|
119
|
+
cidr("2001::", 32), // Teredo: tunnels to an obfuscated IPv4 address
|
|
120
|
+
cidr("2001:db8::", 32), // documentation
|
|
121
|
+
cidr("fc00::", 7), // unique local
|
|
122
|
+
cidr("fe80::", 10), // link-local
|
|
123
|
+
cidr("fec0::", 10), // site-local (deprecated)
|
|
124
|
+
cidr("ff00::", 8), // multicast
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
/** 6to4 (2002::/16) tunnels to the IPv4 address in bits 16-48: refused when that address is. */
|
|
128
|
+
const SIX_TO_FOUR = cidr("2002::", 16)
|
|
129
|
+
|
|
130
|
+
/** True when the hub must not connect to `address` (anything that is not a plain IP address counts as denied). */
|
|
131
|
+
export function isDeniedAddress(address: string, extra: readonly Cidr[] = []): boolean {
|
|
132
|
+
const ip = parseIP(address.trim().replace(/^\[(.*)\]$/, "$1"))
|
|
133
|
+
if (!ip) return true
|
|
134
|
+
if (DENIED.some((block) => contains(block, ip)) || extra.some((block) => contains(block, ip))) return true
|
|
135
|
+
if (contains(SIX_TO_FOUR, ip)) {
|
|
136
|
+
const v4 = { v6: false, value: (ip.value >> 80n) & 0xffffffffn }
|
|
137
|
+
return DENIED.some((block) => contains(block, v4)) || extra.some((block) => contains(block, v4))
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** HUB_FETCH_DENY_CIDRS: a comma list of CIDRs or bare addresses (v4 or v6). Throws on any invalid entry. */
|
|
143
|
+
export function parseDenyCidrs(text: string | undefined): Cidr[] {
|
|
144
|
+
const out: Cidr[] = []
|
|
145
|
+
for (const raw of (text ?? "").split(",")) {
|
|
146
|
+
const entry = raw.trim()
|
|
147
|
+
if (!entry) continue
|
|
148
|
+
const invalid = () => new Error(`HUB_FETCH_DENY_CIDRS: ${JSON.stringify(entry)} is not an IPv4 or IPv6 CIDR (e.g. 10.42.0.0/16, fd00::/8)`)
|
|
149
|
+
const match = /^([^/\s]+)(?:\/(\d{1,3}))?$/.exec(entry)
|
|
150
|
+
if (!match) throw invalid()
|
|
151
|
+
const ip = parseIP(match[1]!)
|
|
152
|
+
if (!ip) throw invalid()
|
|
153
|
+
const size = ip.v6 ? V6_BITS : V4_BITS
|
|
154
|
+
const bits = match[2] === undefined ? size : Number(match[2])
|
|
155
|
+
if (bits > size) throw invalid()
|
|
156
|
+
out.push({ v6: ip.v6, net: mask(ip.value, bits, size), bits })
|
|
157
|
+
}
|
|
158
|
+
return out
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------- URLs ----------
|
|
162
|
+
|
|
163
|
+
/** A URL the fetcher may request: http(s) on the default port, no userinfo, no fragment. Throws FetchError bad_url. */
|
|
164
|
+
export function checkURL(raw: string | URL): URL {
|
|
165
|
+
const text = String(raw)
|
|
166
|
+
if (text.length > MAX_URL_CHARS) throw new FetchError("bad_url", `The URL is longer than ${MAX_URL_CHARS} characters`)
|
|
167
|
+
let url: URL
|
|
168
|
+
try {
|
|
169
|
+
url = new URL(text)
|
|
170
|
+
} catch {
|
|
171
|
+
throw new FetchError("bad_url", "Not a valid absolute URL")
|
|
172
|
+
}
|
|
173
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new FetchError("bad_url", "Only http and https URLs can be fetched")
|
|
174
|
+
if (url.username || url.password) throw new FetchError("bad_url", "URLs with credentials are refused", url.hostname)
|
|
175
|
+
// WHATWG drops the scheme's default port, so any port left is a non-default one (https on 80 included).
|
|
176
|
+
if (url.port !== "") throw new FetchError("bad_url", "Only the default ports (80 for http, 443 for https) are allowed", url.hostname)
|
|
177
|
+
if (!url.hostname) throw new FetchError("bad_url", "The URL has no host")
|
|
178
|
+
url.hash = ""
|
|
179
|
+
return url
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------- the fetcher ----------
|
|
183
|
+
|
|
184
|
+
export interface FetcherOptions {
|
|
185
|
+
/** DNS: every address of a host (production: node:dns lookup with all:true; tests: a fake). */
|
|
186
|
+
readonly resolve: ResolveHost
|
|
187
|
+
/** Denied on top of the built-in ranges (HUB_FETCH_DENY_CIDRS, see parseDenyCidrs). Never removes any. */
|
|
188
|
+
readonly denyCidrs?: readonly Cidr[]
|
|
189
|
+
/** The HTTP client (default Bun's fetch). It receives the pinned IP-literal URL. */
|
|
190
|
+
readonly fetchImpl?: typeof fetch
|
|
191
|
+
readonly timeouts?: { readonly connectMs?: number; readonly totalMs?: number }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface FetchOptions {
|
|
195
|
+
/** Bytes of the (decoded) body read at most. */
|
|
196
|
+
readonly maxBytes: number
|
|
197
|
+
/** Over maxBytes: keep the first maxBytes (truncated: true) instead of failing too_large. */
|
|
198
|
+
readonly truncate?: boolean
|
|
199
|
+
/** The Accept header (default *\/*). */
|
|
200
|
+
readonly accept?: string
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface Fetched {
|
|
204
|
+
/** The final URL (after redirects). */
|
|
205
|
+
readonly url: URL
|
|
206
|
+
/** The address that was checked and connected to for the final hop. */
|
|
207
|
+
readonly address: string
|
|
208
|
+
readonly status: number
|
|
209
|
+
readonly headers: Headers
|
|
210
|
+
readonly body: Uint8Array<ArrayBuffer>
|
|
211
|
+
readonly truncated: boolean
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface Fetcher {
|
|
215
|
+
readonly timeouts: { readonly connectMs: number; readonly totalMs: number }
|
|
216
|
+
/** GET `raw` safely; throws FetchError. */
|
|
217
|
+
fetch(raw: string | URL, options: FetchOptions): Promise<Fetched>
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const isRedirect = (status: number) => status === 301 || status === 302 || status === 303 || status === 307 || status === 308
|
|
221
|
+
|
|
222
|
+
export function createFetcher(options: FetcherOptions): Fetcher {
|
|
223
|
+
const extra = options.denyCidrs ?? []
|
|
224
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
225
|
+
const timeouts = { connectMs: options.timeouts?.connectMs ?? DEFAULT_TIMEOUTS.connectMs, totalMs: options.timeouts?.totalMs ?? DEFAULT_TIMEOUTS.totalMs }
|
|
226
|
+
|
|
227
|
+
/** The checked address to connect to for `url`'s host. */
|
|
228
|
+
async function pin(url: URL, signal: AbortSignal): Promise<string> {
|
|
229
|
+
const host = url.hostname
|
|
230
|
+
const literal = host.startsWith("[") ? host.slice(1, -1) : isIPv4(host) ? host : undefined
|
|
231
|
+
if (literal !== undefined) {
|
|
232
|
+
if (isDeniedAddress(literal, extra)) throw new FetchError("refused", `${host} is a refused address`, host)
|
|
233
|
+
return literal
|
|
234
|
+
}
|
|
235
|
+
const name = host.toLowerCase().replace(/\.$/, "")
|
|
236
|
+
if (name === "localhost" || name.endsWith(".localhost")) throw new FetchError("refused", `${host} is a local name`, host)
|
|
237
|
+
let addresses: readonly string[]
|
|
238
|
+
try {
|
|
239
|
+
addresses = await abortable(options.resolve(name), signal)
|
|
240
|
+
} catch (cause) {
|
|
241
|
+
if (signal.aborted) throw cause
|
|
242
|
+
throw new FetchError("upstream", `${host} does not resolve`, host)
|
|
243
|
+
}
|
|
244
|
+
if (addresses.length === 0) throw new FetchError("upstream", `${host} does not resolve`, host)
|
|
245
|
+
if (addresses.some((a) => isDeniedAddress(a, extra))) throw new FetchError("refused", `${host} resolves to a refused address`, host)
|
|
246
|
+
return addresses[0]!.replace(/^\[(.*)\]$/, "$1")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function run(raw: string | URL, request: FetchOptions): Promise<Fetched> {
|
|
250
|
+
const controller = new AbortController()
|
|
251
|
+
let timedOut = false
|
|
252
|
+
const expire = () => {
|
|
253
|
+
timedOut = true
|
|
254
|
+
controller.abort()
|
|
255
|
+
}
|
|
256
|
+
const total = setTimeout(expire, timeouts.totalMs)
|
|
257
|
+
let connect: ReturnType<typeof setTimeout> | undefined
|
|
258
|
+
let host: string | undefined
|
|
259
|
+
try {
|
|
260
|
+
let url = checkURL(raw)
|
|
261
|
+
for (let hop = 0; ; hop++) {
|
|
262
|
+
host = url.hostname
|
|
263
|
+
const address = await pin(url, controller.signal)
|
|
264
|
+
const target = new URL(url)
|
|
265
|
+
target.hostname = isIPv6(address) ? `[${address}]` : address
|
|
266
|
+
connect = setTimeout(expire, timeouts.connectMs)
|
|
267
|
+
// Raced with the signal too, so the timeouts hold even for a client that ignores it.
|
|
268
|
+
// TLS: Bun verifies the certificate against Host (the SNI name). Known Bun bug, not fixable here: it accepts
|
|
269
|
+
// a wildcard certificate across several labels (*.w.test matches y.x.w.test), for every fetch in the hub.
|
|
270
|
+
const init: RequestInit & { decompress: boolean } = {
|
|
271
|
+
method: "GET",
|
|
272
|
+
// Only default ports reach here, so Host has no port, and a single trailing dot is dropped (the checked
|
|
273
|
+
// name and address stay as they are): Host is also the SNI name (never "name:443" or "name.").
|
|
274
|
+
headers: { host: url.host.replace(/\.$/, ""), "user-agent": USER_AGENT, accept: request.accept ?? "*/*", "accept-encoding": "identity" },
|
|
275
|
+
redirect: "manual",
|
|
276
|
+
keepalive: false,
|
|
277
|
+
credentials: "omit",
|
|
278
|
+
decompress: false,
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
}
|
|
281
|
+
const response = await abortable(fetchImpl(target.href, init), controller.signal)
|
|
282
|
+
clearTimeout(connect)
|
|
283
|
+
if (isRedirect(response.status)) {
|
|
284
|
+
await response.body?.cancel().catch(() => {})
|
|
285
|
+
const location = response.headers.get("location")
|
|
286
|
+
if (!location) throw new FetchError("upstream", `${host} answered a redirect without a location`, host, response.status)
|
|
287
|
+
if (hop >= MAX_REDIRECTS) throw new FetchError("upstream", `More than ${MAX_REDIRECTS} redirects`, host, response.status)
|
|
288
|
+
let next: URL
|
|
289
|
+
try {
|
|
290
|
+
next = new URL(location, url)
|
|
291
|
+
} catch {
|
|
292
|
+
throw new FetchError("upstream", `${host} answered an invalid redirect`, host, response.status)
|
|
293
|
+
}
|
|
294
|
+
url = checkURL(next)
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
if (response.status < 200 || response.status > 299) {
|
|
298
|
+
await response.body?.cancel().catch(() => {})
|
|
299
|
+
throw new FetchError("upstream", `${host} answered HTTP ${response.status}`, host, response.status)
|
|
300
|
+
}
|
|
301
|
+
const encoding = response.headers.get("content-encoding")?.trim().toLowerCase()
|
|
302
|
+
if (encoding && encoding !== "identity") {
|
|
303
|
+
await response.body?.cancel().catch(() => {})
|
|
304
|
+
throw new FetchError("upstream", `${host} sent a compressed response (content-encoding ${encoding}), which is refused`, host)
|
|
305
|
+
}
|
|
306
|
+
const declared = Number(response.headers.get("content-length") ?? NaN)
|
|
307
|
+
if (!request.truncate && Number.isFinite(declared) && declared > request.maxBytes) {
|
|
308
|
+
await response.body?.cancel().catch(() => {})
|
|
309
|
+
throw new FetchError("too_large", `The response from ${host} is larger than ${request.maxBytes} bytes`, host)
|
|
310
|
+
}
|
|
311
|
+
const { body, truncated } = await readCapped(response, request, host, controller)
|
|
312
|
+
return { url, address, status: response.status, headers: response.headers, body, truncated }
|
|
313
|
+
}
|
|
314
|
+
} catch (cause) {
|
|
315
|
+
if (timedOut) throw new FetchError("timeout", `${host ?? "The site"} did not answer in time`, host)
|
|
316
|
+
if (cause instanceof FetchError) throw cause
|
|
317
|
+
throw new FetchError("upstream", `Could not fetch from ${host ?? "the site"}`, host)
|
|
318
|
+
} finally {
|
|
319
|
+
clearTimeout(total)
|
|
320
|
+
clearTimeout(connect)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { timeouts, fetch: run }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readCapped(response: Response, request: FetchOptions, host: string, controller: AbortController): Promise<{ body: Uint8Array<ArrayBuffer>; truncated: boolean }> {
|
|
328
|
+
if (!response.body) return { body: new Uint8Array(0), truncated: false }
|
|
329
|
+
const reader = response.body.getReader()
|
|
330
|
+
const chunks: Uint8Array[] = []
|
|
331
|
+
let size = 0
|
|
332
|
+
while (true) {
|
|
333
|
+
const { done, value } = await abortable(reader.read(), controller.signal).catch(async (cause: unknown) => {
|
|
334
|
+
await reader.cancel().catch(() => {})
|
|
335
|
+
throw cause
|
|
336
|
+
})
|
|
337
|
+
if (done) break
|
|
338
|
+
if (size + value.byteLength > request.maxBytes) {
|
|
339
|
+
await reader.cancel().catch(() => {})
|
|
340
|
+
controller.abort()
|
|
341
|
+
if (!request.truncate) throw new FetchError("too_large", `The response from ${host} is larger than ${request.maxBytes} bytes`, host)
|
|
342
|
+
chunks.push(value.subarray(0, request.maxBytes - size))
|
|
343
|
+
return { body: concat(chunks, request.maxBytes), truncated: true }
|
|
344
|
+
}
|
|
345
|
+
chunks.push(value)
|
|
346
|
+
size += value.byteLength
|
|
347
|
+
}
|
|
348
|
+
return { body: concat(chunks, size), truncated: false }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function concat(chunks: readonly Uint8Array[], size: number): Uint8Array<ArrayBuffer> {
|
|
352
|
+
const out = new Uint8Array(size)
|
|
353
|
+
let at = 0
|
|
354
|
+
for (const chunk of chunks) {
|
|
355
|
+
out.set(chunk, at)
|
|
356
|
+
at += chunk.byteLength
|
|
357
|
+
}
|
|
358
|
+
return out
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** `promise`, rejected early when `signal` aborts (a slow resolver counts against the total timeout). */
|
|
362
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
363
|
+
if (signal.aborted) return Promise.reject(new Error("aborted"))
|
|
364
|
+
return new Promise<T>((resolve, reject) => {
|
|
365
|
+
const onAbort = () => reject(new Error("aborted"))
|
|
366
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
367
|
+
promise.then(
|
|
368
|
+
(value) => (signal.removeEventListener("abort", onAbort), resolve(value)),
|
|
369
|
+
(cause) => (signal.removeEventListener("abort", onAbort), reject(cause)),
|
|
370
|
+
)
|
|
371
|
+
})
|
|
372
|
+
}
|