import "server-only"; import { isIP } from "node:net"; import { getEnv } from "@autoscholar/core"; /** Client IP: honours X-Forwarded-For only when TRUST_PROXY=1 (behind the MacLustr Tunnel Caddy). */ export function clientIp(req: Request): string | null { const env = getEnv(); const h = req.headers; if (env.TRUST_PROXY === 1) { const xff = h.get("x-forwarded-for"); if (xff) { const first = xff.split(",")[0]?.trim() ?? ""; if (isIP(first)) return first; } const xr = h.get("x-real-ip"); if (xr && isIP(xr)) return xr; } const direct = h.get("x-autoscholar-remote-addr"); // set by nothing public; Next does not expose the socket address if (direct && isIP(direct)) return direct; return null; } /** * Source IP as seen by the HTTP layer. `next start` populates X-Forwarded-For with the socket address for direct * connections; the MacLustr gateway (Caddy) sets it to the public client address. Returns the first hop or null. */ export function sourceIp(req: Request): string | null { const xff = req.headers.get("x-forwarded-for"); const first = xff?.split(",")[0]?.trim() ?? ""; if (first && isIP(first)) return first; const xr = req.headers.get("x-real-ip"); if (xr && isIP(xr)) return xr; return null; } /** True when the request arrived through the public tunnel: the source address is not private/loopback. */ export function viaPublicProxy(req: Request): boolean { const ip = sourceIp(req); if (!ip) return false; return !ipInCidrs( ip, "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,fd00::/8,fe80::/10", ); } function ipToInt(ip: string): bigint | null { if (isIP(ip) === 4) return ip.split(".").reduce((acc, o) => (acc << 8n) + BigInt(Number(o)), 0n); if (isIP(ip) === 6) { const parts = ip.split("::"); const head = parts[0] ? parts[0].split(":") : []; const tail = parts[1] ? parts[1].split(":") : []; const fill = 8 - head.length - tail.length; const groups = [...head, ...Array(Math.max(fill, 0)).fill("0"), ...tail]; return groups.reduce((acc, g) => (acc << 16n) + BigInt(Number.parseInt(g || "0", 16)), 0n); } return null; } export function ipInCidrs(ip: string | null, cidrs: string): boolean { if (!ip) return false; const v = isIP(ip); const n = ipToInt(ip); if (!n) return false; for (const raw of cidrs .split(",") .map((s) => s.trim()) .filter(Boolean)) { const [base, bitsStr] = raw.split("/"); if (!base || isIP(base) !== v) continue; const bits = Number(bitsStr ?? (v === 4 ? 32 : 128)); const total = v === 4 ? 32 : 128; const b = ipToInt(base); if (b === null) continue; const shift = BigInt(total - bits); if (n >> shift === b >> shift) return true; } return false; }