wol-gate: 매직패킷 발송을 국내(KR) IP로 제한

해외 클라우드/스캐너 IP가 루트 경로 한 번 찔러보는 것만으로 매직패킷이
발사되는 사례(143.198.239.149 등)가 반복돼, 요청 IP가 APNIC KR 대역
(ipv4/ipv6, kr-cidrs.json)에 속할 때만 실제로 깨우도록 제한. LAN/루프백
대역은 홈 NAT 헤어핀으로 사설 IP로 잡히는 경우가 있어 항상 허용 유지.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-05 10:18:50 +09:00
co-authored by Claude Sonnet 5
parent bb7c6a2105
commit 675ee75de5
3 changed files with 108 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY server.js .
COPY server.js kr-cidrs.json .
ENV LISTEN_PORT=8099
EXPOSE 8099
CMD ["node", "server.js"]
File diff suppressed because one or more lines are too long
+106 -1
View File
@@ -7,6 +7,7 @@
const http = require('http');
const net = require('net');
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);
@@ -20,6 +21,105 @@ 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)) {
@@ -79,13 +179,18 @@ function renderWakingPage() {
}
function describeRequester(req) {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
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)}`);