summaryrefslogtreecommitdiff
path: root/workers/apex/src
diff options
context:
space:
mode:
Diffstat (limited to 'workers/apex/src')
-rw-r--r--workers/apex/src/dispatch.ts15
-rw-r--r--workers/apex/src/index.ts159
-rw-r--r--workers/apex/src/manifest.ts55
-rw-r--r--workers/apex/src/redirects.ts38
-rw-r--r--workers/apex/src/special.ts55
-rw-r--r--workers/apex/src/uagate.ts18
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
+}