require('dotenv').config(); const express = require('express'); const fs = require('fs'); const path = require('path'); const rl = require('readline'); const net = require('net'); const { exec } = require('child_process'); const ipaddr = require('ipaddr.js'); const fetch = require('node-fetch'); const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); // ── Config ──────────────────────────────────────────────────────────────────── const FAIL2BAN_LOG = process.env.FAIL2BAN_LOG || '/var/log/fail2ban.log'; const LOG_DIR = process.env.LOG_DIR || '/nginx-logs'; const JAIL_LOCAL = process.env.JAIL_LOCAL || '/etc/fail2ban/jail.local'; const CF_SYNC = process.env.CF_SYNC || '/usr/local/bin/cloudflare-whitelist-sync.sh'; const MANUAL_JAIL = process.env.MANUAL_JAIL || 'manual-bans'; const BAN_HIST_FILE = process.env.BAN_HIST_FILE || '/data/ban-history.json'; const EXEMPT_FILE = process.env.EXEMPT_FILE || '/data/exemptions.json'; const DEFAULT_DAYS = 3; const ABUSE_KEY = process.env.ABUSEIPDB_API_KEY; const AUTOBAN_THR = 75; // ── In-memory state ─────────────────────────────────────────────────────────── const abuseCache = new Map(); // ip → { score, country, ts } const banHistory = new Map(); // ip → { firstSeen, lastSeen, banCount } let banCache = null; // { data, ts } — 10s cache for ban list let f2bPos = 0; let f2bInode = 0; // scan state (per-scan, cleared each call) let ipHits = new Map(); let ipSites = new Map(); let ipLogs = new Map(); // ── Utilities ───────────────────────────────────────────────────────────────── function run(cmd) { return new Promise((resolve, reject) => exec(cmd, { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => err ? reject(new Error(stderr || err.message)) : resolve(stdout) ) ); } // ── Jail.local helpers ──────────────────────────────────────────────────────── function readIgnoreIP() { try { const content = fs.readFileSync(JAIL_LOCAL, 'utf8'); const match = content.match(/^ignoreip[ \t]*=[ \t]*(.*)$/m); if (!match) return []; return match[1].split(/\s+/).filter(s => s && !s.startsWith('#')); } catch { return []; } } function getWhitelistNote(ip) { try { const content = fs.readFileSync(JAIL_LOCAL, 'utf8'); const m = content.match(new RegExp(String.raw`${ip.replace('.', '\\.')}\\s*#\\s*(.+?)(?:\\n|$)`)); return m ? m[1].trim() : null; } catch { return null; } } async function addWhitelist(ip, note) { const lines = fs.readFileSync(JAIL_LOCAL, 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) { if (/^ignoreip[ \t]*=/.test(lines[i]) && !lines[i].includes(ip)) { lines[i] = lines[i].trimEnd() + ` ${ip}${note ? ` # ${note}` : ''}\n`; break; } } fs.writeFileSync(JAIL_LOCAL, lines.join('\n')); // Live-update running fail2ban (no reload needed) const jails = await getJails(); await Promise.all(jails.map(j => Promise.all([ run(`fail2ban-client set ${j} addignoreip ${ip}`).catch(() => {}), run(`fail2ban-client set ${j} unbanip ${ip}`).catch(() => {}), ]))); if (fs.existsSync(CF_SYNC)) exec(`${CF_SYNC} &`); } async function removeWhitelist(ip) { const content = fs.readFileSync(JAIL_LOCAL, 'utf8'); const updated = content.replace( new RegExp(`\\s*${ip.replace(/\./g, '\\.')}(?:\\s*#[^\\n]*)?`, 'g'), '' ); fs.writeFileSync(JAIL_LOCAL, updated); // Live-update running fail2ban (no reload needed) const jails = await getJails(); await Promise.all(jails.map(j => run(`fail2ban-client set ${j} delignoreip ${ip}`).catch(() => {}) )); if (fs.existsSync(CF_SYNC)) exec(`${CF_SYNC} &`); } // ── Fail2ban queries ────────────────────────────────────────────────────────── async function getJails() { const out = await run('fail2ban-client status'); const m = out.match(/Jail list:\s*(.*)/); if (!m) return []; return m[1].split(',').map(j => j.trim()).filter(j => j && j !== 'recidive'); } async function getBanEntries(jail) { try { const out = await run(`fail2ban-client get ${jail} banip --with-time`); return out.trim().split('\n').filter(Boolean).map(line => { const parts = line.split(/\s+/); if (parts.length < 7) return null; const ip = parts[0]; const duration = parseInt(parts[4]); const banTime = `${parts[1]} ${parts[2]}`; const unbanTime = `${parts[parts.length - 2]} ${parts[parts.length - 1]}`; return { ip, jail, duration, banTime, unbanTime }; }).filter(Boolean); } catch { return []; } } async function buildBanList() { if (banCache && Date.now() - banCache.ts < 10_000) return banCache.data; const jails = await getJails(); const entries = await Promise.all(jails.map(jail => getBanEntries(jail))); const flat = entries.flat(); const whitelist = readIgnoreIP(); // Attach cached abuse scores const data = flat.map(b => { const cached = abuseCache.get(b.ip); const score = cached?.score ?? null; const country = cached?.country ?? null; return { ...b, score, country }; }); // Append fail2ban ignoreip as "whitelist" (trusted — f2b won't monitor) whitelist.forEach(ip => { data.push({ ip, jail: 'whitelist', duration: -1, banTime: null, unbanTime: null, score: abuseCache.get(ip)?.score ?? null, country: abuseCache.get(ip)?.country ?? null, note: getWhitelistNote(ip), }); }); // Append scan exemptions (reviewed — hidden from scan, f2b still watches) readExemptions().forEach(({ ip, note }) => { data.push({ ip, jail: 'exempt', duration: -1, banTime: null, unbanTime: null, score: abuseCache.get(ip)?.score ?? null, country: abuseCache.get(ip)?.country ?? null, note, }); }); banCache = { data, ts: Date.now() }; return data; } async function banIP(ip) { await run(`fail2ban-client set ${MANUAL_JAIL} banip ${ip}`); banCache = null; } async function unbanIP(ip, jail) { await run(`fail2ban-client set ${jail} unbanip ${ip}`); banCache = null; } async function unbanAll(ip) { const jails = await getJails(); await Promise.all(jails.map(j => run(`fail2ban-client set ${j} unbanip ${ip}`).catch(() => {}))); banCache = null; } // ── AbuseIPDB ───────────────────────────────────────────────────────────────── async function checkAbuse(ip) { const WEEK = 7 * 24 * 3600 * 1000; const cached = abuseCache.get(ip); if (cached && Date.now() - cached.ts < WEEK) return cached; if (!ABUSE_KEY) return { score: null, country: null }; try { const r = await fetch( `https://api.abuseipdb.com/api/v2/check?ipAddress=${ip}&maxAgeInDays=90`, { headers: { Key: ABUSE_KEY, Accept: 'application/json' } } ); const { data } = await r.json(); const entry = { score: data.abuseConfidenceScore, country: data.countryCode, ts: Date.now() }; abuseCache.set(ip, entry); banCache = null; // invalidate so next /api/bans gets fresh scores return entry; } catch { return { score: null, country: null }; } } // ── Ban history (for log scanner) ───────────────────────────────────────────── function loadBanHistory() { try { if (fs.existsSync(BAN_HIST_FILE)) Object.entries(JSON.parse(fs.readFileSync(BAN_HIST_FILE, 'utf8'))) .forEach(([k, v]) => banHistory.set(k, v)); } catch {} } function saveBanHistory() { try { fs.writeFileSync(BAN_HIST_FILE, JSON.stringify(Object.fromEntries(banHistory), null, 2)); } catch {} } async function refreshBanHistory() { try { const jails = await getJails(); const entries = await Promise.all(jails.map(getBanEntries)); const now = new Date().toISOString(); entries.flat().forEach(({ ip }) => { if (banHistory.has(ip)) { banHistory.get(ip).lastSeen = now; banHistory.get(ip).banCount++; } else { banHistory.set(ip, { firstSeen: now, lastSeen: now, banCount: 1 }); } }); saveBanHistory(); } catch (e) { console.error('ban history refresh:', e.message); } } // ── Exemptions (scan-level: hide from scan results, fail2ban still watches) ─── function readExemptions() { try { if (!fs.existsSync(EXEMPT_FILE)) return []; return JSON.parse(fs.readFileSync(EXEMPT_FILE, 'utf8')); } catch { return []; } } function saveExemptions(list) { fs.writeFileSync(EXEMPT_FILE, JSON.stringify(list, null, 2)); } function isExempt(ip) { return readExemptions().some(e => e.ip === ip); } function addExemption(ip, note = '') { const list = readExemptions().filter(e => e.ip !== ip); list.push({ ip, note, addedAt: new Date().toISOString() }); saveExemptions(list); } function removeExemption(ip) { saveExemptions(readExemptions().filter(e => e.ip !== ip)); } // ── Nginx log scanner ───────────────────────────────────────────────────────── function isWhitelisted(ip) { const wl = readIgnoreIP(); try { return wl.some(entry => { if (entry.includes('/')) { const [range, bits] = ipaddr.parseCIDR(entry); return ipaddr.parse(ip).match(range, bits); } return entry === ip; }); } catch { return false; } } async function processLogFile(file, cutoff) { return new Promise((resolve, reject) => { const stream = fs.createReadStream(file); const reader = rl.createInterface({ input: stream, crlfDelay: Infinity }); reader.on('line', line => { const tm = line.match(/\[(\d{2})\/(\w{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})/); if (tm) { const months = {Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11}; if (new Date(tm[3], months[tm[2]], tm[1], tm[4], tm[5], tm[6]).getTime() < cutoff) return; } const ipM = line.match(/\[Client ([^\]]+)\]/); if (!ipM) return; const ip = ipM[1]; if (isWhitelisted(ip) || isExempt(ip)) return; const stM = line.match(/\s(\d{3})\s/); if (!stM || stM[1] === '200') return; const parts = line.split(/\s+/); const ui = parts.findIndex(p => p === 'http' || p === 'https'); if (ui === -1 || ui + 1 >= parts.length) return; const host = parts[ui + 1]; ipHits.set(ip, (ipHits.get(ip) || 0) + 1); if (!ipSites.has(ip)) ipSites.set(ip, new Set()); ipSites.get(ip).add(host); if (!ipLogs.has(ip)) ipLogs.set(ip, []); ipLogs.get(ip).push(line); }); reader.on('close', resolve); reader.on('error', reject); }); } async function scanNginxLogs(days = DEFAULT_DAYS) { ipHits.clear(); ipSites.clear(); ipLogs.clear(); await refreshBanHistory(); const cutoff = Date.now() - days * 86_400_000; const jails = await getJails(); const entries = await Promise.all(jails.map(getBanEntries)); const banned = new Set(entries.flat().map(e => e.ip)); const files = fs.readdirSync(LOG_DIR) .filter(f => f.startsWith('proxy-host-') && f.endsWith('_access.log')) .map(f => path.join(LOG_DIR, f)); await Promise.all(files.map(f => processLogFile(f, cutoff))); return Array.from(ipHits.entries()) .filter(([ip]) => !banned.has(ip)) .map(([ip, hits]) => { const hist = banHistory.get(ip); return { ip, hits, sites: Array.from(ipSites.get(ip) || []), previouslyBanned: banHistory.has(ip), banCount: hist?.banCount || 0, lastBanned: hist?.lastSeen || null, }; }) .sort((a, b) => b.hits - a.hits); } // ── F2B log tail ─────────────────────────────────────────────────────────────── function seedF2bPos() { try { const s = fs.statSync(FAIL2BAN_LOG); f2bPos = s.size; f2bInode = s.ino; } catch {} } function f2bRecentLines(n = 50) { try { return fs.readFileSync(FAIL2BAN_LOG, 'utf8') .split('\n').filter(l => l.trim()) .slice(-n) .filter(l => !l.includes('Ignore') && !l.includes('Unban')); } catch { return []; } } function f2bNewLines() { try { const s = fs.statSync(FAIL2BAN_LOG); if (s.ino !== f2bInode) { f2bInode = s.ino; f2bPos = 0; } if (s.size <= f2bPos) return []; const fd = fs.openSync(FAIL2BAN_LOG, 'r'); const buf = Buffer.alloc(s.size - f2bPos); fs.readSync(fd, buf, 0, buf.length, f2bPos); fs.closeSync(fd); f2bPos = s.size; return buf.toString('utf8').split('\n').map(l => l.trim()).filter(l => l && !l.includes('Ignore') && !l.includes('Unban')); } catch { return []; } } // ── Routes: F2B ban management ──────────────────────────────────────────────── app.get('/api/bans', async (req, res) => { try { res.json(await buildBanList()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/ban', async (req, res) => { const { ip } = req.body; if (!ip || (!net.isIPv4(ip) && !net.isIPv6(ip))) return res.status(400).send('Invalid IP'); try { await banIP(ip); res.send(`${ip} banned.`); } catch (e) { res.status(500).send(e.message); } }); app.post('/api/unban', async (req, res) => { const { ip, jail } = req.body; if (!ip || !jail) return res.status(400).send('ip and jail required'); try { await unbanIP(ip, jail); res.send(`${ip} unbanned from ${jail}.`); } catch (e) { res.status(500).send(e.message); } }); app.post('/api/unban-all', async (req, res) => { const { ip } = req.body; if (!ip) return res.status(400).send('ip required'); try { await unbanAll(ip); res.send(`${ip} unbanned from all jails.`); } catch (e) { res.status(500).send(e.message); } }); app.post('/api/whitelist', async (req, res) => { const { ip, note } = req.body; if (!ip) return res.status(400).send('ip required'); try { await addWhitelist(ip, note || ''); banCache = null; res.send(`${ip} whitelisted.`); } catch (e) { res.status(500).send(e.message); } }); app.delete('/api/whitelist/:ip', async (req, res) => { try { await removeWhitelist(req.params.ip); banCache = null; res.send(`${req.params.ip} removed from whitelist.`); } catch (e) { res.status(500).send(e.message); } }); app.post('/api/exempt', (req, res) => { const { ip, note } = req.body; if (!ip) return res.status(400).send('ip required'); try { addExemption(ip, note || ''); banCache = null; res.send(`${ip} exempted from scan.`); } catch (e) { res.status(500).send(e.message); } }); app.delete('/api/exempt/:ip', (req, res) => { try { removeExemption(req.params.ip); banCache = null; res.send(`${req.params.ip} removed from exemptions.`); } catch (e) { res.status(500).send(e.message); } }); app.get('/api/exemptions', (req, res) => res.json(readExemptions())); app.get('/api/check-abuse/:ip', async (req, res) => { if (!ABUSE_KEY) return res.status(503).send('AbuseIPDB key not configured'); try { res.json(await checkAbuse(req.params.ip)); } catch (e) { res.status(500).send(e.message); } }); app.post('/api/force-abuse-check', async (req, res) => { if (!ABUSE_KEY) return res.status(503).send('AbuseIPDB key not configured'); res.send('Running abuse checks in background…'); (async () => { const bans = await buildBanList(); for (const { ip, jail, score } of bans) { if (score == null && jail !== 'whitelist') { await checkAbuse(ip); await new Promise(r => setTimeout(r, 1200)); } } banCache = null; })(); }); // ── Routes: Log scanner (async job) ────────────────────────────────────────── let scanJob = { running: false, done: false, results: [], error: null }; app.post('/api/scan/start', (req, res) => { const days = parseInt(req.query.days || DEFAULT_DAYS); if (scanJob.running) return res.json({ running: true }); scanJob = { running: true, done: false, results: [], error: null }; res.json({ running: true }); scanNginxLogs(days) .then(results => { scanJob = { running: false, done: true, results, error: null }; }) .catch(e => { scanJob = { running: false, done: true, results: [], error: e.message }; }); }); app.get('/api/scan/results', (req, res) => res.json(scanJob)); app.post('/api/auto-ban', async (req, res) => { if (!ABUSE_KEY) return res.status(503).send('AbuseIPDB key not configured'); const threshold = parseInt(req.body.threshold ?? AUTOBAN_THR); const days = parseInt(req.body.days ?? DEFAULT_DAYS); res.send('Auto-ban running in background…'); (async () => { const results = await scanNginxLogs(days); for (const { ip, hits } of results) { if (hits < 3) continue; const { score } = await checkAbuse(ip); if (score != null && score >= threshold) { await banIP(ip).catch(() => {}); console.log(`[auto-ban] ${ip} score=${score} threshold=${threshold}`); } await new Promise(r => setTimeout(r, 1000)); } console.log('[auto-ban] complete'); })(); }); // ── Routes: Purge nginx logs ────────────────────────────────────────────────── app.post('/api/purge-logs', (req, res) => { try { const files = fs.readdirSync(LOG_DIR) .filter(f => f.startsWith('proxy-host-') && f.endsWith('_access.log')); files.forEach(f => fs.writeFileSync(path.join(LOG_DIR, f), '')); ipHits.clear(); ipSites.clear(); ipLogs.clear(); res.send(`Purged ${files.length} log file(s).`); } catch (e) { res.status(500).send(e.message); } }); // ── Routes: F2B log tail ────────────────────────────────────────────────────── app.get('/api/f2b/init', (req, res) => { seedF2bPos(); res.json({ lines: f2bRecentLines(50) }); }); app.get('/api/f2b/poll', (req, res) => { res.json({ lines: f2bNewLines() }); }); // ── Routes: Nginx log viewer ────────────────────────────────────────────────── app.get('/logs/:ip', (req, res) => { const ip = req.params.ip; const logs = ipLogs.get(ip) || []; const hist = banHistory.get(ip); const esc = s => String(s).replace(/&/g,'&').replace(//g,'>'); let badge = ''; if (hist) badge = `
F2B // IP LOOKUP
Entries in current scan window: ${logs.length}
${logs.map(esc).join('\n') || '(no entries — run a scan first)'}