#!/usr/bin/env node 'use strict'; // Reverse-proxy gate: if the backend is asleep, send a WOL magic packet and // show a "waking up" page instead of proxying, until the backend answers. // Must run on an always-on host with L2 access to the target's broadcast // domain (host networking) so the magic packet actually reaches the target. const http = require('http'); const dgram = require('dgram'); const krCidrs = require('./kr-cidrs.json'); const TARGET_HOST = process.env.TARGET_HOST || '192.168.0.5'; const TARGET_PORT = parseInt(process.env.TARGET_PORT || '18789', 10); const TARGET_MAC = process.env.TARGET_MAC || '64:00:6a:53:fd:8f'; const BROADCAST_ADDR = process.env.BROADCAST_ADDR || '192.168.0.255'; const WOL_PORT = parseInt(process.env.WOL_PORT || '9', 10); const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || '8099', 10); const HEALTH_TIMEOUT_MS = parseInt(process.env.HEALTH_TIMEOUT_MS || '1200', 10); const WAKE_COOLDOWN_MS = parseInt(process.env.WAKE_COOLDOWN_MS || '30000', 10); const REFRESH_SECONDS = parseInt(process.env.REFRESH_SECONDS || '5', 10); let lastWakeAt = 0; // --- Domestic (KR) IP gating for wake triggers ----------------------------- // CIDR list is generated from APNIC's delegated-apnic-latest (KR allocations). // LAN/loopback ranges are always allowed too, since a home NAT hairpin can // make the requester's own visit show up as a private address (see // project_wol_gate / reference_truenas_fail2ban memory for the same issue // tripping up fail2ban's npm-scan jail). function ipToInt(ip) { const parts = ip.split('.').map(Number); if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null; return parts[0] * 2 ** 24 + parts[1] * 2 ** 16 + parts[2] * 2 ** 8 + parts[3]; } function cidrToRangeV4(cidr) { const [base, bits] = cidr.split('/'); const baseInt = ipToInt(base); const maskBits = parseInt(bits, 10); const size = 2 ** (32 - maskBits); return [baseInt, baseInt + size - 1]; } function ipv6ToBigInt(ip) { ip = ip.split('%')[0]; let head = ip; let tail = ''; if (ip.includes('::')) { const idx = ip.indexOf('::'); head = ip.slice(0, idx); tail = ip.slice(idx + 2); } const expandV4Tail = (parts) => { if (parts.length && parts[parts.length - 1].includes('.')) { const v4 = parts.pop().split('.').map(Number); parts.push((((v4[0] << 8) | v4[1]) >>> 0).toString(16)); parts.push((((v4[2] << 8) | v4[3]) >>> 0).toString(16)); } return parts; }; const h = expandV4Tail(head ? head.split(':') : []); const t = expandV4Tail(tail ? tail.split(':') : []); const missing = 8 - h.length - t.length; const full = [...h, ...Array(Math.max(missing, 0)).fill('0'), ...t]; let result = 0n; for (const group of full) result = (result << 16n) | BigInt(parseInt(group || '0', 16)); return result; } function cidrToRangeV6(cidr) { const [base, bits] = cidr.split('/'); const prefixLen = BigInt(parseInt(bits, 10)); const baseInt = ipv6ToBigInt(base); const hostBits = 128n - prefixLen; const mask = (1n << hostBits) - 1n; const start = baseInt & ~mask; return [start, start | mask]; } function inSortedRanges(value, ranges) { let lo = 0; let hi = ranges.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; const [start, end] = ranges[mid]; if (value < start) hi = mid - 1; else if (value > end) lo = mid + 1; else return true; } return false; } const PRIVATE_V4_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8'] .map(cidrToRangeV4) .sort((a, b) => a[0] - b[0]); const KR_V4_RANGES = krCidrs.ipv4.map(cidrToRangeV4).sort((a, b) => a[0] - b[0]); const KR_V6_RANGES = krCidrs.ipv6.map(cidrToRangeV6).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); function extractClientIp(req) { const xff = req.headers['x-forwarded-for']; let ip = (xff ? xff.split(',')[0].trim() : req.socket.remoteAddress) || ''; if (ip.startsWith('::ffff:')) ip = ip.slice(7); return ip; } function isDomesticIp(ip) { if (!ip) return false; if (ip.includes(':')) { try { const big = ipv6ToBigInt(ip); if (big === 1n) return true; // ::1 loopback return inSortedRanges(big, KR_V6_RANGES); } catch { return false; } } const n = ipToInt(ip); if (n === null) return false; return inSortedRanges(n, PRIVATE_V4_RANGES) || inSortedRanges(n, KR_V4_RANGES); } // ----------------------------------------------------------------------------- function sendMagicPacket(mac, broadcastAddr, port) { const macBytes = mac.split(/[:-]/).map(h => parseInt(h, 16)); if (macBytes.length !== 6 || macBytes.some(Number.isNaN)) { throw new Error('Invalid MAC address: ' + mac); } const packet = Buffer.alloc(6 + 16 * 6); packet.fill(0xff, 0, 6); for (let i = 0; i < 16; i++) Buffer.from(macBytes).copy(packet, 6 + i * 6); const socket = dgram.createSocket('udp4'); socket.bind(() => { socket.setBroadcast(true); socket.send(packet, 0, packet.length, port, broadcastAddr, (err) => { socket.close(); if (err) console.error('WOL send error:', err.message); else console.log(`[wol-gate] magic packet sent to ${mac} via ${broadcastAddr}:${port}`); }); }); } // HTTP-level check, not just TCP connect: a target mid-boot can have its listener socket // open (TCP connect succeeds) well before the actual service behind it is answering requests // (e.g. Ollama's port opens before the model/runtime is ready) — a bare TCP check reports // "alive" during that gap, so proxyRequest() gets sent through and times out with its own // "Bad gateway: read ETIMEDOUT" instead of the caller seeing the (retryable) waking-up page. // Observed live 2026-08-08 on a wol-gate-fronted Ollama target. Any real HTTP response // (regardless of status code — even a 404 proves the app layer is serving, not just the // socket) counts as alive; only a connection error/timeout counts as still-waking. function checkTargetAlive() { return new Promise((resolve) => { let done = false; const finish = (alive) => { if (done) return; done = true; resolve(alive); }; const req = http.request({ host: TARGET_HOST, port: TARGET_PORT, method: 'GET', path: '/', timeout: HEALTH_TIMEOUT_MS, }, (res) => { res.destroy(); finish(true); }); req.on('timeout', () => req.destroy()); req.on('error', () => finish(false)); req.end(); }); } function renderWakingPage() { return `
${REFRESH_SECONDS}초마다 자동으로 다시 확인합니다
`; } function describeRequester(req) { const ip = extractClientIp(req) || '-'; const ua = req.headers['user-agent'] || '-'; const host = req.headers['host'] || '-'; return `${ip} "${req.method} ${host}${req.url}" UA="${ua}"`; } function maybeWake(req) { const ip = extractClientIp(req); if (!isDomesticIp(ip)) { console.log(`[wol-gate] wake blocked (non-KR) - triggered by ${describeRequester(req)}`); return; } const now = Date.now(); if (now - lastWakeAt <= WAKE_COOLDOWN_MS) { console.log(`[wol-gate] wake suppressed (cooldown) - triggered by ${describeRequester(req)}`); return; } lastWakeAt = now; console.log(`[wol-gate] waking - triggered by ${describeRequester(req)}`); try { sendMagicPacket(TARGET_MAC, BROADCAST_ADDR, WOL_PORT); } catch (e) { console.error('[wol-gate]', e.message); } } function proxyRequest(req, res) { const proxyReq = http.request({ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers: req.headers, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); proxyReq.on('error', (err) => { res.writeHead(502); res.end('Bad gateway: ' + err.message); }); req.pipe(proxyReq); } const server = http.createServer(async (req, res) => { if (await checkTargetAlive()) { proxyRequest(req, res); return; } maybeWake(req); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(renderWakingPage()); }); // Forward WebSocket upgrades too, once awake (homeclaw uses WS heavily). server.on('upgrade', async (req, socket, head) => { if (!(await checkTargetAlive())) { maybeWake(req); socket.destroy(); return; } const proxyReq = http.request({ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers: req.headers, }); proxyReq.on('upgrade', (proxyRes, proxySocket) => { const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n'); socket.write(`HTTP/1.1 101 Switching Protocols\r\n${headerLines}\r\n\r\n`); proxySocket.pipe(socket); socket.pipe(proxySocket); }); proxyReq.on('error', () => socket.destroy()); proxyReq.end(); }); server.listen(LISTEN_PORT, () => { console.log(`[wol-gate] listening on :${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}, WOL target ${TARGET_MAC} via ${BROADCAST_ADDR}:${WOL_PORT}`); });