diff options
Diffstat (limited to 'workers/apex/src')
| -rw-r--r-- | workers/apex/src/dispatch.ts | 15 | ||||
| -rw-r--r-- | workers/apex/src/index.ts | 159 | ||||
| -rw-r--r-- | workers/apex/src/manifest.ts | 55 | ||||
| -rw-r--r-- | workers/apex/src/redirects.ts | 38 | ||||
| -rw-r--r-- | workers/apex/src/special.ts | 55 | ||||
| -rw-r--r-- | workers/apex/src/uagate.ts | 18 |
6 files changed, 340 insertions, 0 deletions
diff --git a/workers/apex/src/dispatch.ts b/workers/apex/src/dispatch.ts new file mode 100644 index 00000000..9ad1f15d --- /dev/null +++ b/workers/apex/src/dispatch.ts @@ -0,0 +1,15 @@ +export function resolveVersion( + pathname: string, + dispatch: Map<string, string>, +): { slug: string; binding: string } | null { + const m = pathname.match(/^\/en\/([^/]+)\//); + if (!m) return null; + const binding = dispatch.get(m[1]); + return binding ? { slug: m[1], binding } : null; +} + +export function bindingGuard(env: Record<string, unknown>, binding: string): Fetcher | null { + const b = env[binding]; + if (b && typeof (b as Fetcher).fetch === "function") return b as Fetcher; + return null; +} diff --git a/workers/apex/src/index.ts b/workers/apex/src/index.ts new file mode 100644 index 00000000..278794e2 --- /dev/null +++ b/workers/apex/src/index.ts @@ -0,0 +1,159 @@ +import { loadManifest, buildDispatch } from "./manifest"; +import { resolveVersion, bindingGuard } from "./dispatch"; +import { redirectFor } from "./redirects"; +import { specialPathFor } from "./special"; +import { uaVerdict } from "./uagate"; +import policy from "../ua-policy.json"; + +export interface ApexEnv extends Record<string, unknown> { + ASSETS: Fetcher; + APEX_BUILD_SHA: string; + DOCS_ENV: "production" | "canary"; + DOCS_KB?: Fetcher; + // §5 apex PDF fallback — R2 bucket holding oversized legacy PDFs excluded from the + // content Worker's own asset tree (currently just the 1.3 PDF). Optional so the + // binding-guard path (503, not a crash) exercises on envs that omit it. + DOCS_PDFS?: R2Bucket; +} + +const manifest = loadManifest(); +const dispatch = buildDispatch(manifest); + +// Security headers only — safe on content pass-through (never touches +// Cache-Control or X-Docs-Build, which the content Worker owns). +function securityHeaders(resp: Response): Response { + const out = new Response(resp.body, resp); + out.headers.set("X-Content-Type-Options", "nosniff"); + out.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + out.headers.set("Content-Security-Policy-Report-Only", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"); + return out; +} + +const DEFAULT_CACHE_CLASS = "public, max-age=0, s-maxage=300, must-revalidate"; + +function apexHeaders(resp: Response, env: ApexEnv, cacheClass: string = DEFAULT_CACHE_CLASS): Response { + const out = securityHeaders(resp); + out.headers.set("X-Apex-Build", env.APEX_BUILD_SHA); + // §3.3 cache contract applies to apex-owned responses too. Error responses (4xx/5xx) + // must never carry the s-maxage cache class — the cache key excludes User-Agent, so a + // cached UA-gate 403 or themed 404/503 would poison the edge for every visitor for the + // full s-maxage window. Mirrors the branch worker's withDocsHeaders() precedence. + // `cacheClass` lets a specific caller (e.g. the §5 PDF R2 fallback) apply a + // differently-classed success cache-control; canary/error still always win. + out.headers.set( + "Cache-Control", + env.DOCS_ENV === "canary" || out.status >= 400 ? "no-store" : cacheClass, + ); + return out; +} + +async function themed(env: ApexEnv, status: 404 | 503): Promise<Response> { + const page = await env.ASSETS.fetch(new Request(`https://apex.internal/${status}.html`)); + return apexHeaders(new Response(page.body, { status, headers: { "content-type": "text/html; charset=utf-8" } }), env); +} + +export default { + async fetch(request: Request, env: ApexEnv): Promise<Response> { + const url = new URL(request.url); + + // 1. UA gate — production only (§3.2.1) + if (env.DOCS_ENV === "production") { + const v = uaVerdict(request.headers.get("user-agent") ?? "", policy); + if (v === "block") return apexHeaders(new Response("Forbidden", { status: 403 }), env); + if (v === "log") console.log(JSON.stringify({ event: "ua-log", ua: request.headers.get("user-agent"), path: url.pathname })); + } + + // 2. Special paths (§3.2.2) + const special = await specialPathFor(request, manifest, env as never); + if (special) return apexHeaders(special, env); + + // 2b. Legacy PDF R2 fallback (spec §5) — the 1.3 PDF (29.2 MiB) exceeds the 25 MiB + // static-asset cap and is excluded from the content Worker's own build. MUST run + // before version dispatch (step 6): the legacy Worker's asset tree lacks this file, + // so unconditional dispatch would 404 on the exact path the PDF 301 (§3.2.4) targets. + const pdfVersion = manifest.versions.find((v) => v.pdf_r2_key && v.pdf === url.pathname); + if (pdfVersion) { + const bucket = env.DOCS_PDFS; + if (!bucket) { + console.log(JSON.stringify({ event: "binding-missing", binding: "DOCS_PDFS" })); + return themed(env, 503); + } + let raw: R2ObjectBody | R2Object | null; + try { + // Forward Range + conditional (If-None-Match/If-Match/If-Modified-Since) headers + // straight through to R2 so a resumed download or a client with a fresh cached + // copy doesn't have to re-pull the full 29.2 MiB object. + raw = await bucket.get(pdfVersion.pdf_r2_key!, { + range: request.headers, + onlyIf: request.headers, + }); + } catch (e) { + console.log(JSON.stringify({ event: "binding-error", binding: "DOCS_PDFS", error: String(e) })); + return themed(env, 503); + } + if (!raw) return themed(env, 404); + + // Distinct (longer) cache class from apex's default control-response class — this + // is effectively content, just not content the legacy content Worker can serve. + // 304/206 are both <400 so apexHeaders() still applies this class, not "no-store". + const pdfCacheClass = "public, max-age=300, s-maxage=600, must-revalidate"; + const pdfHeaders: Record<string, string> = { + etag: raw.httpEtag, + "accept-ranges": "bytes", + }; + + // A satisfied onlyIf precondition (e.g. If-None-Match matched the R2 object's current + // ETag) makes R2 hand back a body-less R2Object — just the validators, no content. + if (!("body" in raw) || !raw.body) { + return apexHeaders(new Response(null, { status: 304, headers: pdfHeaders }), env, pdfCacheClass); + } + const obj = raw as R2ObjectBody; + pdfHeaders["content-type"] = "application/pdf"; + + // A satisfied Range request — R2 echoes the actually-served byte range on `obj.range`; + // its absence means either no Range header was sent or R2 served the full object. + const range = obj.range; + if (range && "offset" in range) { + const start = range.offset ?? 0; + const length = range.length ?? obj.size - start; + pdfHeaders["content-range"] = `bytes ${start}-${start + length - 1}/${obj.size}`; + pdfHeaders["content-length"] = String(length); + return apexHeaders(new Response(obj.body, { status: 206, headers: pdfHeaders }), env, pdfCacheClass); + } + + pdfHeaders["content-length"] = String(obj.size); + return apexHeaders(new Response(obj.body, { status: 200, headers: pdfHeaders }), env, pdfCacheClass); + } + + // 3+4. Trailing-slash + alias/codename/PDF 301s (§3.2.3-4) + const redir = redirectFor(url, manifest); + if (redir) return apexHeaders(redir, env); + + // 5. /kb seam (§3.2.5) + if (url.pathname.startsWith("/kb/") || url.pathname === "/kb") { + if (env.DOCS_KB) return securityHeaders(await env.DOCS_KB.fetch(request)); + return themed(env, 404); + } + + // 6. Version dispatch (§3.2.6) + const hit = resolveVersion(url.pathname, dispatch); + if (hit) { + const fetcher = bindingGuard(env, hit.binding); + if (!fetcher) { // 7. runtime binding guard (§3.2.7) + console.log(JSON.stringify({ event: "binding-missing", binding: hit.binding })); + return themed(env, 503); + } + try { + const resp = await fetcher.fetch(request); + if (resp.status === 404) return themed(env, 404); + return securityHeaders(resp); // §3.3: security headers at apex; cache + X-Docs-Build stay content-owned + } catch (e) { + console.log(JSON.stringify({ event: "binding-error", binding: hit.binding, error: String(e) })); + return themed(env, 503); + } + } + + // 7. Fallback + return themed(env, 404); + }, +} satisfies ExportedHandler<ApexEnv>; diff --git a/workers/apex/src/manifest.ts b/workers/apex/src/manifest.ts new file mode 100644 index 00000000..396f29ed --- /dev/null +++ b/workers/apex/src/manifest.ts @@ -0,0 +1,55 @@ +import raw from "../../versions.json"; + +export interface VersionEntry { + slug: string; label: string; + status: "dev" | "lts" | "eol"; + binding: string; aliases: string[]; + pdf: string | null; + // R2 object key for the apex PDF fallback (spec §5) — set only on versions whose PDF + // exceeds the 25 MiB static-asset cap and is therefore absent from the content + // Worker's own asset tree (currently just 1.3). Optional; most versions omit it. + pdf_r2_key?: string; +} +export interface Manifest { + schema_version: number; + default_lang: string; default_version: string; + languages: { code: string; label: string }[]; + versions: VersionEntry[]; +} + +// Split out from loadManifest() so tests can validate synthetic manifests without +// touching the real ../../versions.json import (which loadManifest() is hardwired to). +export function validateManifest(m: Manifest): Manifest { + if (m.schema_version !== 2) throw new Error(`versions.json schema_version ${m.schema_version} != 2`); + const slugs = new Set<string>(); + for (const v of m.versions) { + if (!/^DOCS_[A-Z0-9_]+$/.test(v.binding)) throw new Error(`bad binding for ${v.slug}`); + if (!["dev", "lts", "eol"].includes(v.status)) throw new Error(`bad status for ${v.slug}`); + if (slugs.has(v.slug)) throw new Error(`duplicate slug: ${v.slug}`); + // pdf_r2_key names an R2 fallback for the exact `pdf` URL — a null pdf has no URL + // for the fallback to ever be reached at, so the pairing is nonsensical. + if (v.pdf_r2_key && v.pdf === null) throw new Error(`pdf_r2_key set but pdf is null for ${v.slug}`); + slugs.add(v.slug); + } + // Separate pass: an alias must not collide with ANY canonical slug (not just an + // earlier one), so `slugs` must be fully populated before this check runs. + const aliases = new Set<string>(); + for (const v of m.versions) { + for (const alias of v.aliases) { + if (slugs.has(alias)) throw new Error(`alias ${alias} (on ${v.slug}) collides with a canonical slug`); + if (aliases.has(alias)) throw new Error(`duplicate alias: ${alias}`); + aliases.add(alias); + } + } + if (!m.versions.some((v) => v.slug === m.default_version)) + throw new Error(`default_version ${m.default_version} not in versions[]`); + return m; +} + +export function loadManifest(): Manifest { + return validateManifest(raw as Manifest); +} + +export function buildDispatch(m: Manifest): Map<string, string> { + return new Map(m.versions.map((v) => [v.slug, v.binding])); +} diff --git a/workers/apex/src/redirects.ts b/workers/apex/src/redirects.ts new file mode 100644 index 00000000..835b50f7 --- /dev/null +++ b/workers/apex/src/redirects.ts @@ -0,0 +1,38 @@ +import type { Manifest } from "./manifest"; + +function aliasMap(m: Manifest): Map<string, string> { + const map = new Map<string, string>(); + for (const v of m.versions) for (const a of v.aliases) map.set(a, v.slug); + return map; +} + +function r301(pathAndQuery: string): Response { + return new Response(null, { status: 301, headers: { Location: pathAndQuery } }); +} + +export function redirectFor(url: URL, m: Manifest): Response | null { + const { pathname, search } = url; // never touch url.hash — fragments don't reach the server + + // RTD PDF URLs: /_/downloads/en/<ver>/pdf/* → /en/<slug>/vyos-documentation.pdf + const pdf = pathname.match(/^\/_\/downloads\/en\/([^/]+)\/pdf(?:\/|$)/); + if (pdf) { + const slug = aliasMap(m).get(pdf[1]) ?? pdf[1]; + const entry = m.versions.find((v) => v.slug === slug); + // pdf: null means no PDF artifact exists for this version — don't 301 into a dead-end 404. + // The manifest's pdf value is the source of truth (not a hardcoded filename) so a future + // R2-fallback path for 1.3 (or any other version) can move the target without a code change. + if (entry && entry.pdf !== null) return r301(`${entry.pdf}${search}`); + } + + // Alias / codename prefixes: /en/<alias>/* → /en/<slug>/* + const seg = pathname.match(/^\/en\/([^/]+)(\/.*)?$/); + if (seg) { + const [, first, rest = ""] = seg; + const target = aliasMap(m).get(first); + if (target) return r301(`/en/${target}${rest || "/"}${search}`); + // Trailing-slash normalization on bare version roots: /en/<slug> → /en/<slug>/ + if (rest === "" && m.versions.some((v) => v.slug === first)) + return r301(`/en/${first}/${search}`); + } + return null; +} diff --git a/workers/apex/src/special.ts b/workers/apex/src/special.ts new file mode 100644 index 00000000..f1a843f0 --- /dev/null +++ b/workers/apex/src/special.ts @@ -0,0 +1,55 @@ +import type { Manifest } from "./manifest"; +import { bindingGuard } from "./dispatch"; + +// Static assets (404.html, 503.html, robots.txt, favicons, root) live in the apex +// ASSETS binding; versions.json is served from the imported manifest so the body +// always matches the dispatch map. +export async function specialPathFor( + request: Request, + m: Manifest, + env: Record<string, unknown> & { ASSETS: Fetcher }, +): Promise<Response | null> { + const url = new URL(request.url); + const p = url.pathname; + + if (p === "/") // default-version redirect (§3.2.2) — query preserved, like alias redirects + return new Response(null, { status: 301, headers: { Location: `/en/${m.default_version}/${url.search}` } }); + + if (p === "/versions.json") + return new Response(JSON.stringify(m), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + + if (p === "/healthz") + return new Response(JSON.stringify({ status: "ok", versions: m.versions.length }), { + headers: { "content-type": "application/json" }, + }); + + if (p === "/sitemap.xml") { + const entries = m.versions + .map((v) => `<sitemap><loc>https://docs.vyos.io/en/${v.slug}/sitemap.xml</loc></sitemap>`) + .join(""); + return new Response( + `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries}</sitemapindex>`, + { headers: { "content-type": "application/xml" } }, + ); + } + + if (p === "/llms.txt") { // direct body, not a redirect (§3.2.2) + const def = m.versions.find((v) => v.slug === m.default_version)!; + const b = bindingGuard(env, def.binding); + if (!b) // do NOT fall through — /llms.txt with a missing binding is a 503, not a 404 + return new Response("service unavailable", { status: 503, headers: { "content-type": "text/plain" } }); + // Forward the ORIGINAL request (method + conditional-GET headers), just retargeted + // to the versioned path — mirrors the robots.txt/favicon pass-through below. + const target = new URL(`/en/${def.slug}/llms.txt`, url); + return b.fetch(new Request(target, request)); + } + + if (p === "/robots.txt" || p === "/favicon.ico" || /^\/apple-touch-icon.*\.png$/.test(p)) + // Pass the ORIGINAL request through (not a re-synthesized `new Request(url)`) so + // conditional-GET headers (If-None-Match / If-Modified-Since) reach ASSETS intact. + return env.ASSETS.fetch(request); + + return null; +} diff --git a/workers/apex/src/uagate.ts b/workers/apex/src/uagate.ts new file mode 100644 index 00000000..822a7277 --- /dev/null +++ b/workers/apex/src/uagate.ts @@ -0,0 +1,18 @@ +export interface UaPolicy { + allow: string[]; + log: string[]; + block: string[]; +} + +export type UaVerdict = "allow" | "block" | "log"; + +export function uaVerdict(ua: string, policy: UaPolicy): UaVerdict { + const hit = (list: string[]) => list.some((n) => ua.toLowerCase().includes(n.toLowerCase())); + // Explicit blocks take precedence — a request-controlled UA string that spoofs an + // allow-listed substring (e.g. "Googlebot EvilScraper") must not be able to bypass a + // block entry just by also matching the allow list. + if (hit(policy.block)) return "block"; + if (hit(policy.allow)) return "allow"; + if (hit(policy.log)) return "log"; + return "allow"; // fail-open default +} |
