TCP 연결만 확인하면, 대상이 부팅 중이라 포트는 열렸지만 실제 서비스가 아직 요청을 못 받는 순간에도 "살아있다"고 오판해서 실제 프록시를 시도하다 자체 타임아웃으로 "Bad gateway: read ETIMEDOUT"를 반환하는 문제가 있었음(HTML 깨우는 페이지 대신 이 502가 나가면 호출부가 재시도 로직을 못 탐) — 지서버 wol-gate에서 실제로 반복 관측됨(2026-08-08). 실제 HTTP GET으로 응답을 받는지 확인하도록 바꿔서, 상태코드 상관없이 진짜 HTTP 응답이 와야만 "살아있다"고 판단하게 수정. 두 인스턴스(클로서버 8099, 지서버 8100) 모두 재빌드·재배포 후 정상 동작 확인. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
266 lines
9.5 KiB
JavaScript
266 lines
9.5 KiB
JavaScript
#!/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 `<!doctype html>
|
|
<html lang="ko"><head><meta charset="utf-8">
|
|
<meta http-equiv="refresh" content="${REFRESH_SECONDS}">
|
|
<title>서버 깨우는 중...</title>
|
|
<style>
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#111;color:#eee;
|
|
display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0;gap:16px}
|
|
.spinner{width:40px;height:40px;border:4px solid #333;border-top-color:#7c5c2e;border-radius:50%;
|
|
animation:spin 0.9s linear infinite}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
p{color:#999;font-size:14px}
|
|
</style></head>
|
|
<body>
|
|
<div class="spinner"></div>
|
|
<div>서버를 깨우는 중입니다...</div>
|
|
<p>${REFRESH_SECONDS}초마다 자동으로 다시 확인합니다</p>
|
|
</body></html>`;
|
|
}
|
|
|
|
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}`);
|
|
});
|