Initial release: F2B Control Center v1.0

Fail2Ban + Nginx Proxy Manager dashboard in a single Docker container.

Features:
- Auto-ban via badbot, http-errors, npm-probe, manual-bans, recidive jails
- Web dashboard: live ban grid, log scanner, per-IP access log viewer
- iptables-nft banning (DOCKER-USER + INPUT chains)
- Optional Cloudflare WAF banning
- Optional AbuseIPDB threat scoring
- Two-tier IP management: whitelist (trusted) vs exempt (reviewed)
- Auto log-file detection via logwatch (no restart needed for new NPM hosts)
This commit is contained in:
2026-02-20 18:59:56 +00:00
commit c104e27506
24 changed files with 3333 additions and 0 deletions

527
dashboard/server.js Normal file
View File

@@ -0,0 +1,527 @@
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let badge = '';
if (hist) badge = `<div class="hi" style="margin:.5rem 0">&#9888; Previously banned ${hist.banCount}x &mdash; last ${new Date(hist.lastSeen).toLocaleString()}</div>`;
res.send(`<!DOCTYPE html><html lang="en">
<head><meta charset="UTF-8"><title>Logs: ${esc(ip)}</title><link rel="stylesheet" href="/style.css"></head>
<body><div class="screen">
<pre class="ascii-banner"> F2B // IP LOOKUP</pre>
<div class="tagline">// NGINX ACCESS LOGS FOR ${esc(ip)}</div>
<h2 class="section">// ${esc(ip)}</h2>
<div class="box">
<div class="box-title">// LOG ENTRIES</div>
${badge}
<p class="muted">Entries in current scan window: ${logs.length}</p>
<pre>${logs.map(esc).join('\n') || '(no entries — run a scan first)'}</pre>
</div>
<div class="prompt">_ <span class="blink">&#9608;</span></div>
<footer>F2B Control Center | :${process.env.PORT || 4000}</footer>
</div></body></html>`);
});
// ── Boot ──────────────────────────────────────────────────────────────────────
loadBanHistory();
refreshBanHistory();
setInterval(refreshBanHistory, 6 * 3600 * 1000);
const PORT = process.env.PORT || 4000;
app.listen(PORT, '0.0.0.0', () =>
console.log(`[f2b-cc] Dashboard listening on :${PORT}`)
);