import type { Passport } from "@assistant-ui/passport"; import { validateNamespace } from "./router.js"; const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; const IPV6_PATTERN = /^((?=.*::)(?!.*::.+::)(::)?([\dA-Fa-f]{1,4}:(:|\b)|){5}|([\dA-Fa-f]{1,4}:){6})((([\dA-Fa-f]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/; export const isIP = (value: string): 0 | 4 | 6 => IPV4_PATTERN.test(value) ? 4 : IPV6_PATTERN.test(value) ? 6 : 0; export type HostRule = | { kind: "any" } | { kind: "cidr"; source: string; base: number; mask: number } | { kind: "suffix"; suffix: string } | { kind: "ip"; ip: string } | { kind: "hostname"; hostname: string }; export type RegistryPolicy = { name: string; namespaces: string[]; backendHosts: HostRule[]; /** Registerable hostnames/envs, hostname-aware mode only. */ hostnames: HostRule[]; envs: HostRule[]; }; const ipv4ToInt = (ip: string): number => ip.split(".").reduce((acc, octet) => acc * 256 + Number(octet), 0); export const classifyHost = (raw: string): HostRule => { if (raw === "*") return { kind: "any" }; if (raw.includes("/")) { const [ip = "", len = "", ...extra] = raw.split("/"); if (ip.includes(":")) { throw new Error(`IPv6 CIDRs are not supported: ${JSON.stringify(raw)}`); } const bits = Number(len); if ( extra.length > 0 || isIP(ip) !== 4 || !Number.isInteger(bits) || bits < 0 || bits > 32 ) { throw new Error(`invalid CIDR: ${JSON.stringify(raw)}`); } const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; return { kind: "cidr", source: raw, base: (ipv4ToInt(ip) & mask) >>> 0, mask, }; } if (raw.startsWith(".")) { if (raw.length < 2) { throw new Error(`invalid domain suffix: ${JSON.stringify(raw)}`); } return { kind: "suffix", suffix: raw.toLowerCase() }; } if (isIP(raw) !== 0) return { kind: "ip", ip: raw.toLowerCase() }; if (raw === "" || /[\s:]/.test(raw)) { throw new Error(`invalid backend host: ${JSON.stringify(raw)}`); } return { kind: "hostname", hostname: raw.toLowerCase() }; }; export const hostAllowed = ( rules: HostRule[], rawHostname: string, ): boolean => { const hostname = rawHostname.replace(/^\[|\]$/g, "").toLowerCase(); return rules.some((rule) => { switch (rule.kind) { case "any": return true; case "cidr": return ( isIP(hostname) === 4 && (ipv4ToInt(hostname) & rule.mask) >>> 0 === rule.base ); case "suffix": return hostname.endsWith(rule.suffix); case "ip": return hostname === rule.ip; case "hostname": return hostname === rule.hostname; } }); }; const namespacePattern = (raw: string, name: string): string => { if (raw === "*") return "*"; try { return validateNamespace(raw); } catch { throw new Error( `account ${JSON.stringify(name)}: invalid namespace ${JSON.stringify(raw)}`, ); } }; export const registryPolicy = ( account: Passport.Principal, role: "pinboard:operator" | "pinboard:worker", hostnameAware = false, ): RegistryPolicy => { const fail = (message: string): never => { throw new Error(`account ${JSON.stringify(account.sub)}: ${message}`); }; const rawPinboard = account.claims["pinboard"]; if ( rawPinboard !== undefined && (typeof rawPinboard !== "object" || rawPinboard === null || Array.isArray(rawPinboard)) ) { fail("claims.pinboard must be an object"); } const pinboard = (rawPinboard ?? {}) as Record; const rawNamespaces = pinboard["namespaces"] ?? ["*"]; if ( !Array.isArray(rawNamespaces) || rawNamespaces.length === 0 || !rawNamespaces.every((value) => typeof value === "string") ) { fail("namespaces must be a non-empty array of strings"); } const rawHosts = pinboard["allowedBackendHosts"]; if ( rawHosts !== undefined && (!Array.isArray(rawHosts) || rawHosts.length === 0 || !rawHosts.every((value) => typeof value === "string")) ) { fail("allowedBackendHosts must be a non-empty array of strings"); } if (role === "pinboard:worker" && rawHosts === undefined) { fail("allowedBackendHosts is required for pinboard:worker"); } const ruleList = (key: "hostnames" | "envs"): HostRule[] => { const raw = pinboard[key]; if ( raw !== undefined && (!Array.isArray(raw) || raw.length === 0 || !raw.every((value) => typeof value === "string")) ) { fail(`${key} must be a non-empty array of strings`); } if (hostnameAware && role === "pinboard:worker" && raw === undefined) { fail(`${key} is required for pinboard:worker in hostname-aware mode`); } return ((raw as string[] | undefined) ?? []).map(classifyHost); }; return { name: account.sub, namespaces: (rawNamespaces as string[]).map((namespace) => namespacePattern(namespace, account.sub), ), backendHosts: ((rawHosts as string[] | undefined) ?? []).map(classifyHost), hostnames: ruleList("hostnames"), envs: ruleList("envs"), }; }; export const namespaceAllowed = (entry: RegistryPolicy, ns: string): boolean => entry.namespaces.includes("*") || entry.namespaces.includes(ns);