offsite: use shared ip-check with 'auto' mode
This commit is contained in:
parent
d91e8ac96f
commit
803c92ec63
2 changed files with 96 additions and 47 deletions
|
|
@ -1,45 +1,9 @@
|
||||||
import { jwtVerify } from 'jose'
|
import { jwtVerify } from 'jose'
|
||||||
import dns from 'dns/promises'
|
import { isOnsite } from './ip-check.js'
|
||||||
|
|
||||||
const APP_SLUG = process.env.APP_SLUG || 'noticeboard'
|
const APP_SLUG = process.env.APP_SLUG || 'noticeboard'
|
||||||
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||||
|
|
||||||
// Offsite IP check — supports static IP, CIDR, DDNS hostname, or 'disabled'
|
|
||||||
const OFFICE_IP_CHECK = process.env.OFFICE_IP_CHECK || 'disabled'
|
|
||||||
|
|
||||||
let cachedOfficeIP = null
|
|
||||||
let cacheExpiry = 0
|
|
||||||
|
|
||||||
function ipInCidr(ip, cidr) {
|
|
||||||
const [range, bits] = cidr.split('/')
|
|
||||||
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
|
||||||
const ipInt = ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0
|
|
||||||
const rangeInt = range.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0
|
|
||||||
return (ipInt & mask) === (rangeInt & mask)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveOfficeIP() {
|
|
||||||
if (OFFICE_IP_CHECK === 'disabled') return null
|
|
||||||
if (Date.now() < cacheExpiry && cachedOfficeIP) return cachedOfficeIP
|
|
||||||
|
|
||||||
const isHostname = /[a-zA-Z]/.test(OFFICE_IP_CHECK) && !OFFICE_IP_CHECK.includes('/')
|
|
||||||
|
|
||||||
if (isHostname) {
|
|
||||||
try {
|
|
||||||
const addrs = await dns.resolve4(OFFICE_IP_CHECK)
|
|
||||||
cachedOfficeIP = addrs[0]
|
|
||||||
cacheExpiry = Date.now() + 5 * 60 * 1000
|
|
||||||
} catch {
|
|
||||||
// DNS failed — keep last known IP if we have one, don't lock everyone out
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cachedOfficeIP = OFFICE_IP_CHECK
|
|
||||||
cacheExpiry = Infinity
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedOfficeIP
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAuth(request, reply) {
|
export async function requireAuth(request, reply) {
|
||||||
const token = request.cookies?.hnf_session
|
const token = request.cookies?.hnf_session
|
||||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||||
|
|
@ -58,18 +22,11 @@ export async function requireAuth(request, reply) {
|
||||||
|
|
||||||
// Offsite check — only for users without offsite_allowed flag
|
// Offsite check — only for users without offsite_allowed flag
|
||||||
if (!payload.offsite_allowed) {
|
if (!payload.offsite_allowed) {
|
||||||
const officeIP = await resolveOfficeIP()
|
|
||||||
if (officeIP) {
|
|
||||||
const clientIP = request.headers['x-real-ip'] || request.ip
|
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||||
const onsite = officeIP.includes('/')
|
if (!(await isOnsite(clientIP))) {
|
||||||
? ipInCidr(clientIP, officeIP)
|
|
||||||
: clientIP === officeIP
|
|
||||||
|
|
||||||
if (!onsite) {
|
|
||||||
return reply.status(403).send({ error: 'Access restricted to site network' })
|
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
request.user = {
|
request.user = {
|
||||||
email: payload.sub,
|
email: payload.sub,
|
||||||
|
|
|
||||||
92
backend/src/ip-check.js
Normal file
92
backend/src/ip-check.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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)
|
||||||
|
// <ip> → exact static public IP
|
||||||
|
// <cidr> → e.g. 10.4.0.0/22 (matches onsite LAN clients)
|
||||||
|
// <ddns-hostname> → 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 }
|
||||||
|
|
||||||
|
const PUBLIC_IP_URLS = [
|
||||||
|
'https://api.ipify.org',
|
||||||
|
'https://ifconfig.co/ip',
|
||||||
|
'https://icanhazip.com',
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeIP(ip) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
const resolved = await resolveDynamic(m, async () => {
|
||||||
|
try { return (await dns.resolve4(m))[0] } catch { return null }
|
||||||
|
})
|
||||||
|
if (resolved && ip === resolved) return true
|
||||||
|
} else {
|
||||||
|
if (ip === m) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue