From 4dfdb7c3e7c1cc551a238c4c7c9bc7ed17981938 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 12:57:57 +0000 Subject: [PATCH] ip-check: add 'auto' self public-IP mode + matcher list --- src/ip-check.js | 102 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 22 deletions(-) diff --git a/src/ip-check.js b/src/ip-check.js index 382f664..b317e2c 100644 --- a/src/ip-check.js +++ b/src/ip-check.js @@ -1,40 +1,98 @@ import dns from 'dns/promises' -// OFFICE_IP_CHECK supports: static IP, CIDR range, DDNS hostname, or 'disabled' -const config = process.env.OFFICE_IP_CHECK || 'disabled' -let cachedIP = null -let cacheExpiry = 0 +// 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 getOfficeIP() { - if (config === 'disabled') return null - if (Date.now() < cacheExpiry && cachedIP) return cachedIP - - const isHostname = /[a-zA-Z]/.test(config) && !config.includes('/') - if (isHostname) { +async function fetchPublicIP() { + for (const url of PUBLIC_IP_URLS) { try { - const addrs = await dns.resolve4(config) - cachedIP = addrs[0] - cacheExpiry = Date.now() + 5 * 60 * 1000 // 5 min TTL + 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 { - // DNS failed — keep last known good IP rather than locking everyone out + // try the next endpoint } - } else { - cachedIP = config - cacheExpiry = Infinity } - return cachedIP + 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 (config === 'disabled') return true - const officeIP = await getOfficeIP() - if (!officeIP) return true - return officeIP.includes('/') ? ipInCidr(requestIP, officeIP) : requestIP === officeIP + 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 }