470 lines
14 KiB
JavaScript
470 lines
14 KiB
JavaScript
import { registerPlugin, loadConfig, rootDir } from '../index.js';
|
|
import fs from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import maxmind from 'maxmind';
|
|
import { AhoCorasick } from 'string-dsa';
|
|
import { getRealIP } from '../utils/network.js';
|
|
import { createGunzip } from 'zlib';
|
|
import tarStream from 'tar-stream';
|
|
import { Buffer } from 'buffer';
|
|
import * as logs from '../utils/logs.js';
|
|
import { recordEvent } from './stats.js';
|
|
|
|
const cfg = {};
|
|
await loadConfig('ipfilter', cfg);
|
|
|
|
// Map configuration to internal structure
|
|
const enabled = cfg.Core.Enabled;
|
|
const accountId = cfg.Core.AccountID || process.env.MAXMIND_ACCOUNT_ID;
|
|
const licenseKey = cfg.Core.LicenseKey || process.env.MAXMIND_LICENSE_KEY;
|
|
const dbUpdateInterval = cfg.Core.DBUpdateIntervalHours;
|
|
|
|
const ipBlockCacheTTL = cfg.Cache.IPBlockCacheTTLSec * 1000;
|
|
const ipBlockCacheMaxEntries = cfg.Cache.IPBlockCacheMaxEntries;
|
|
|
|
const blockedCountryCodes = new Set(cfg.Blocking.CountryCodes);
|
|
const blockedContinentCodes = new Set(cfg.Blocking.ContinentCodes);
|
|
const defaultBlockPage = cfg.Blocking.DefaultBlockPage;
|
|
|
|
// Process ASN blocks
|
|
const blockedASNs = {};
|
|
const asnGroupBlockPages = {};
|
|
for (const [group, config] of Object.entries(cfg.ASN || {})) {
|
|
blockedASNs[group] = config.Numbers || [];
|
|
asnGroupBlockPages[group] = config.BlockPage;
|
|
}
|
|
|
|
// Process ASN name blocks
|
|
const blockedASNNames = {};
|
|
for (const [group, config] of Object.entries(cfg.ASNNames || {})) {
|
|
blockedASNNames[group] = config.Patterns || [];
|
|
if (config.BlockPage) {
|
|
asnGroupBlockPages[group] = config.BlockPage;
|
|
}
|
|
}
|
|
|
|
const countryBlockPages = cfg.CountryBlockPages || {};
|
|
const continentBlockPages = cfg.ContinentBlockPages || {};
|
|
|
|
const ipBlockCache = new Map();
|
|
|
|
const blockPageCache = new Map();
|
|
async function loadBlockPage(filePath) {
|
|
if (!blockPageCache.has(filePath)) {
|
|
try {
|
|
const txt = await fs.promises.readFile(filePath, 'utf8');
|
|
blockPageCache.set(filePath, txt);
|
|
} catch {
|
|
blockPageCache.set(filePath, null);
|
|
}
|
|
}
|
|
return blockPageCache.get(filePath);
|
|
}
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
const geoIPCountryDBPath = join(rootDir, 'data/GeoLite2-Country.mmdb');
|
|
const geoIPASNDBPath = join(rootDir, 'data/GeoLite2-ASN.mmdb');
|
|
const updateTimestampPath = join(rootDir, 'data/ipfilter_update.json');
|
|
|
|
let geoipCountryReader, geoipASNReader;
|
|
|
|
let isReloading = false;
|
|
let reloadLock = Promise.resolve();
|
|
|
|
async function getLastUpdateTimestamp() {
|
|
try {
|
|
if (fs.existsSync(updateTimestampPath)) {
|
|
const data = await fs.promises.readFile(updateTimestampPath, 'utf8');
|
|
const json = JSON.parse(data);
|
|
return json.lastUpdated || 0;
|
|
}
|
|
} catch (err) {
|
|
logs.warn('ipfilter', `Failed to read last update timestamp: ${err}`);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
async function saveUpdateTimestamp() {
|
|
try {
|
|
const timestamp = Date.now();
|
|
await fs.promises.writeFile(
|
|
updateTimestampPath,
|
|
JSON.stringify({ lastUpdated: timestamp }),
|
|
'utf8',
|
|
);
|
|
return timestamp;
|
|
} catch (err) {
|
|
logs.error('ipfilter', `Failed to save update timestamp: ${err}`);
|
|
return Date.now();
|
|
}
|
|
}
|
|
|
|
// Ensure the update timestamp file exists on first run
|
|
if (!fs.existsSync(updateTimestampPath)) {
|
|
try {
|
|
await saveUpdateTimestamp();
|
|
} catch (err) {
|
|
logs.error('ipfilter', `Failed to initialize update timestamp file: ${err}`);
|
|
}
|
|
}
|
|
|
|
// Download GeoIP databases if missing
|
|
async function downloadGeoIPDatabases() {
|
|
if (!licenseKey || !accountId) {
|
|
logs.warn(
|
|
'ipfilter',
|
|
'No MaxMind credentials found; skipping GeoIP database download. Please set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY environment variables or add AccountID and LicenseKey to config/ipfilter.toml',
|
|
);
|
|
return;
|
|
}
|
|
const editions = [
|
|
{ id: 'GeoLite2-Country', filePath: geoIPCountryDBPath },
|
|
{ id: 'GeoLite2-ASN', filePath: geoIPASNDBPath },
|
|
];
|
|
for (const { id, filePath } of editions) {
|
|
if (!fs.existsSync(filePath)) {
|
|
logs.plugin('ipfilter', `Downloading ${id} database...`);
|
|
const url = `https://download.maxmind.com/app/geoip_download?edition_id=${id}&license_key=${licenseKey}&suffix=tar.gz`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
logs.error(
|
|
'ipfilter',
|
|
`Failed to download ${id} database: ${res.status} ${res.statusText}`,
|
|
);
|
|
continue;
|
|
}
|
|
const tempTar = join(rootDir, 'data', `${id}.tar.gz`);
|
|
// write response body into a .tar.gz file
|
|
const arrayBuf = await res.arrayBuffer();
|
|
await fs.promises.writeFile(tempTar, Buffer.from(arrayBuf));
|
|
// extract .mmdb files from the downloaded tar.gz
|
|
const extract = tarStream.extract();
|
|
extract.on('entry', (header, stream, next) => {
|
|
if (header.name.endsWith('.mmdb')) {
|
|
const filename = header.name.split('/').pop();
|
|
const outPath = join(rootDir, 'data', filename);
|
|
const ws = fs.createWriteStream(outPath);
|
|
stream
|
|
.pipe(ws)
|
|
.on('finish', next)
|
|
.on('error', (err) => {
|
|
logs.error('ipfilter', `Extraction error: ${err}`);
|
|
next();
|
|
});
|
|
} else {
|
|
stream.resume();
|
|
next();
|
|
}
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
fs.createReadStream(tempTar)
|
|
.pipe(createGunzip())
|
|
.pipe(extract)
|
|
.on('finish', resolve)
|
|
.on('error', reject);
|
|
});
|
|
await fs.promises.unlink(tempTar);
|
|
logs.plugin('ipfilter', `${id} database downloaded and extracted.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await downloadGeoIPDatabases();
|
|
|
|
async function loadGeoDatabases() {
|
|
if (isReloading) {
|
|
await reloadLock;
|
|
return true;
|
|
}
|
|
|
|
isReloading = true;
|
|
let lockResolve;
|
|
reloadLock = new Promise((resolve) => {
|
|
lockResolve = resolve;
|
|
});
|
|
|
|
try {
|
|
const countryStats = fs.statSync(geoIPCountryDBPath);
|
|
const asnStats = fs.statSync(geoIPASNDBPath);
|
|
|
|
if (countryStats.size > 1024 && asnStats.size > 1024) {
|
|
logs.plugin('ipfilter', 'Initializing GeoIP databases from disk...');
|
|
const newCountryReader = await maxmind.open(geoIPCountryDBPath);
|
|
const newASNReader = await maxmind.open(geoIPASNDBPath);
|
|
|
|
try {
|
|
const testIP = '8.8.8.8';
|
|
const countryTest = newCountryReader.get(testIP);
|
|
const asnTest = newASNReader.get(testIP);
|
|
|
|
if (!countryTest || !asnTest) {
|
|
throw new Error('Database validation failed: test lookups returned empty results');
|
|
}
|
|
} catch (validationErr) {
|
|
logs.error('ipfilter', `GeoIP database validation failed: ${validationErr}`);
|
|
|
|
try {
|
|
await newCountryReader.close();
|
|
} catch (e) {}
|
|
try {
|
|
await newASNReader.close();
|
|
} catch (e) {}
|
|
throw new Error('Database validation failed');
|
|
}
|
|
|
|
const oldCountryReader = geoipCountryReader;
|
|
const oldASNReader = geoipASNReader;
|
|
|
|
geoipCountryReader = newCountryReader;
|
|
geoipASNReader = newASNReader;
|
|
if (oldCountryReader || oldASNReader) {
|
|
logs.plugin('ipfilter', 'GeoIP databases reloaded and active');
|
|
} else {
|
|
logs.plugin('ipfilter', 'GeoIP databases loaded and active');
|
|
}
|
|
|
|
ipBlockCache.clear();
|
|
|
|
await saveUpdateTimestamp();
|
|
|
|
if (oldCountryReader || oldASNReader) {
|
|
setTimeout(async () => {
|
|
if (oldCountryReader) {
|
|
try {
|
|
await oldCountryReader.close();
|
|
} catch (e) {}
|
|
}
|
|
if (oldASNReader) {
|
|
try {
|
|
await oldASNReader.close();
|
|
} catch (e) {}
|
|
}
|
|
logs.plugin('ipfilter', 'Old GeoIP database instances closed successfully');
|
|
}, 5000);
|
|
}
|
|
|
|
return true;
|
|
} else {
|
|
logs.warn(
|
|
'ipfilter',
|
|
'GeoIP database files are empty or too small. IP filtering will be disabled.',
|
|
);
|
|
return false;
|
|
}
|
|
} catch (err) {
|
|
logs.error('ipfilter', `Failed to load GeoIP databases: ${err}`);
|
|
return false;
|
|
} finally {
|
|
isReloading = false;
|
|
lockResolve();
|
|
}
|
|
}
|
|
|
|
async function checkAndUpdateDatabases() {
|
|
if (isReloading) return false;
|
|
|
|
const lastUpdate = await getLastUpdateTimestamp();
|
|
const now = Date.now();
|
|
const hoursSinceUpdate = (now - lastUpdate) / (1000 * 60 * 60);
|
|
|
|
if (hoursSinceUpdate >= dbUpdateInterval) {
|
|
logs.plugin(
|
|
'ipfilter',
|
|
`GeoIP databases last updated ${hoursSinceUpdate.toFixed(1)} hours ago, reloading...`,
|
|
);
|
|
return await loadGeoDatabases();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function startPeriodicDatabaseUpdates() {
|
|
// Calculate interval in milliseconds
|
|
const intervalMs = dbUpdateInterval * 60 * 60 * 1000;
|
|
|
|
// Schedule periodic updates
|
|
setInterval(async () => {
|
|
try {
|
|
await checkAndUpdateDatabases();
|
|
} catch (err) {
|
|
logs.error('ipfilter', `Failed during periodic database update: ${err}`);
|
|
}
|
|
}, intervalMs);
|
|
|
|
logs.plugin('ipfilter', `Scheduled GeoIP database updates every ${dbUpdateInterval} hours`);
|
|
}
|
|
|
|
await loadGeoDatabases();
|
|
|
|
startPeriodicDatabaseUpdates();
|
|
|
|
const asnNameMatchers = new Map();
|
|
for (const [group, names] of Object.entries(blockedASNNames)) {
|
|
asnNameMatchers.set(group, new AhoCorasick(names));
|
|
}
|
|
|
|
function cacheAndReturn(ip, blocked, blockType, blockValue, customPage, asnOrgName) {
|
|
const expiresAt = Date.now() + ipBlockCacheTTL;
|
|
ipBlockCache.set(ip, { blocked, blockType, blockValue, customPage, asnOrgName, expiresAt });
|
|
// Enforce maximum cache size
|
|
if (ipBlockCacheMaxEntries > 0 && ipBlockCache.size > ipBlockCacheMaxEntries) {
|
|
// Remove the oldest entry (first key in insertion order)
|
|
const oldestKey = ipBlockCache.keys().next().value;
|
|
ipBlockCache.delete(oldestKey);
|
|
}
|
|
return [blocked, blockType, blockValue, customPage, asnOrgName];
|
|
}
|
|
|
|
function isBlockedIPExtended(ip) {
|
|
const now = Date.now();
|
|
const entry = ipBlockCache.get(ip);
|
|
if (entry) {
|
|
if (entry.expiresAt > now) {
|
|
// Refresh recency by re-inserting entry
|
|
ipBlockCache.delete(ip);
|
|
ipBlockCache.set(ip, entry);
|
|
return [entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName];
|
|
} else {
|
|
// Entry expired, remove it
|
|
ipBlockCache.delete(ip);
|
|
}
|
|
}
|
|
|
|
const countryReader = geoipCountryReader;
|
|
const asnReader = geoipASNReader;
|
|
|
|
if (!countryReader || !asnReader) {
|
|
return [false, '', '', '', ''];
|
|
}
|
|
|
|
let countryInfo;
|
|
try {
|
|
countryInfo = countryReader.get(ip);
|
|
} catch (e) {}
|
|
if (countryInfo?.country && blockedCountryCodes.has(countryInfo.country.iso_code)) {
|
|
const page = countryBlockPages[countryInfo.country.iso_code] || defaultBlockPage;
|
|
return cacheAndReturn(ip, true, 'country', countryInfo.country.iso_code, page, '');
|
|
}
|
|
|
|
if (countryInfo?.continent && blockedContinentCodes.has(countryInfo.continent.code)) {
|
|
const page = continentBlockPages[countryInfo.continent.code] || defaultBlockPage;
|
|
return cacheAndReturn(ip, true, 'continent', countryInfo.continent.code, page, '');
|
|
}
|
|
|
|
let asnInfo;
|
|
try {
|
|
asnInfo = asnReader.get(ip);
|
|
} catch (e) {}
|
|
if (asnInfo?.autonomous_system_number) {
|
|
const asn = asnInfo.autonomous_system_number;
|
|
const orgName = asnInfo.autonomous_system_organization || '';
|
|
|
|
for (const [group, arr] of Object.entries(blockedASNs)) {
|
|
if (arr.includes(asn)) {
|
|
const page = asnGroupBlockPages[group] || defaultBlockPage;
|
|
return cacheAndReturn(ip, true, 'asn', group, page, orgName);
|
|
}
|
|
}
|
|
|
|
for (const [group, matcher] of asnNameMatchers.entries()) {
|
|
const matches = matcher.find(orgName);
|
|
if (matches.length) {
|
|
const page = asnGroupBlockPages[group] || defaultBlockPage;
|
|
return cacheAndReturn(ip, true, 'asn', group, page, orgName);
|
|
}
|
|
}
|
|
}
|
|
|
|
return cacheAndReturn(ip, false, '', '', '', '');
|
|
}
|
|
|
|
function IPBlockMiddleware() {
|
|
return async (request, server) => {
|
|
const clientIP = getRealIP(request, server);
|
|
logs.plugin('ipfilter', `Incoming request from IP: ${clientIP}`);
|
|
const [blocked, blockType, blockValue, customPage, asnOrgName] = isBlockedIPExtended(clientIP);
|
|
|
|
if (blocked) {
|
|
recordEvent('ipfilter.block', {
|
|
type: blockType,
|
|
value: blockValue,
|
|
asn_org: asnOrgName,
|
|
ip: clientIP, // Include the IP address for stats
|
|
});
|
|
const url = new URL(request.url);
|
|
|
|
if (url.pathname.startsWith('/api')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Access denied from your location or network.',
|
|
reason: 'geoip',
|
|
type: blockType,
|
|
value: blockValue,
|
|
asn_org: asnOrgName,
|
|
}),
|
|
{
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
// Normalize page paths by stripping leading slash
|
|
const cleanCustomPage = customPage.replace(/^\/+/, '');
|
|
const cleanDefaultPage = defaultBlockPage.replace(/^\/+/, '');
|
|
|
|
let html = '';
|
|
logs.plugin(
|
|
'ipfilter',
|
|
`Block pages: custom="${cleanCustomPage}", default="${cleanDefaultPage}"`,
|
|
);
|
|
logs.plugin('ipfilter', 'Searching for block page in the following locations:');
|
|
const paths = [
|
|
// allow absolute paths relative to project root first
|
|
join(rootDir, cleanCustomPage),
|
|
];
|
|
// Fallback to default block page if custom page isn't found
|
|
if (customPage !== defaultBlockPage) {
|
|
paths.push(
|
|
// check default page at root directory
|
|
join(rootDir, cleanDefaultPage),
|
|
);
|
|
}
|
|
|
|
for (const p of paths) {
|
|
logs.plugin('ipfilter', `Trying block page at: ${p}`);
|
|
const content = await loadBlockPage(p);
|
|
logs.plugin('ipfilter', `Load result for ${p}: ${content ? 'FOUND' : 'NOT FOUND'}`);
|
|
if (content) {
|
|
html = content;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (html) {
|
|
const output = html.replace('{{.ASNName}}', asnOrgName || 'Blocked Network');
|
|
return new Response(output, {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
});
|
|
} else {
|
|
return new Response('Access denied from your location or network.', {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
});
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
}
|
|
|
|
if (enabled) {
|
|
registerPlugin('ipfilter', IPBlockMiddleware());
|
|
} else {
|
|
logs.plugin('ipfilter', 'IP filter plugin disabled via config');
|
|
}
|
|
|
|
export { checkAndUpdateDatabases, loadGeoDatabases };
|