import dns from 'dns/promises' // OFFICE_IP_CHECK: a comma-separated list of matchers — a request is "onsite" // if ANY matcher matches. Each matcher can be: // 'disabled' → onsite check off (everyone allowed) // → exact static public IP // → e.g. 10.4.0.0/22 (matches onsite LAN clients) // → resolved via DNS (dynamic public IP via a DDNS service) // 'auto' → detect the SITE's own public IP (no DDNS service needed) // // Typical dynamic-IP config: "10.4.0.0/22,auto" // - LAN clients match the CIDR (onsite via internal DNS / LAN) // - clients arriving via the public IP (NAT hairpin) match 'auto' // - offsite clients (different public IP) match neither → blocked const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim() const matchers = raw.split(',').map(s => s.trim()).filter(Boolean) const TTL = 5 * 60 * 1000 // 5 min cache for dynamic lookups (auto / ddns) const cache = new Map() // key → { ip, expiry } // External "what's my IP" endpoints, tried in order until one answers. const PUBLIC_IP_URLS = [ 'https://api.ipify.org', 'https://ifconfig.co/ip', 'https://icanhazip.com', ] function normalizeIP(ip) { // strip IPv4-mapped IPv6 prefix (::ffff:1.2.3.4) return ip?.startsWith('::ffff:') ? ip.slice(7) : ip } function isIPv4(s) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(s) } function ipInCidr(ip, cidr) { const [range, bits] = cidr.split('/') if (!isIPv4(ip) || !isIPv4(range)) return false const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0 return (toInt(ip) & mask) === (toInt(range) & mask) } async function fetchPublicIP() { for (const url of PUBLIC_IP_URLS) { try { const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), 4000) const res = await fetch(url, { signal: ctrl.signal }) clearTimeout(timer) if (!res.ok) continue const ip = (await res.text()).trim() if (isIPv4(ip)) return ip } catch { // try the next endpoint } } return null } // Resolve a dynamic matcher (auto/ddns) with caching + graceful fallback: // on lookup failure, keep the last known good value rather than locking out. async function resolveDynamic(key, resolver) { const hit = cache.get(key) if (hit && Date.now() < hit.expiry) return hit.ip const ip = await resolver() if (ip) { cache.set(key, { ip, expiry: Date.now() + TTL }) return ip } return hit ? hit.ip : null // stale-but-usable fallback } export async function isOnsite(requestIP) { if (matchers.length === 0 || matchers.includes('disabled')) return true const ip = normalizeIP(requestIP) if (!ip) return false for (const m of matchers) { if (m === 'auto') { const pub = await resolveDynamic('auto', fetchPublicIP) if (pub && ip === pub) return true } else if (m.includes('/')) { if (ipInCidr(ip, m)) return true } else if (/[a-zA-Z]/.test(m)) { // DDNS hostname const resolved = await resolveDynamic(m, async () => { try { return (await dns.resolve4(m))[0] } catch { return null } }) if (resolved && ip === resolved) return true } else { // exact static IP if (ip === m) return true } } return false }