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:
+1
-1
@@ -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
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user