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:
527
dashboard/server.js
Normal file
527
dashboard/server.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
let badge = '';
|
||||
if (hist) badge = `<div class="hi" style="margin:.5rem 0">⚠ Previously banned ${hist.banCount}x — 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">█</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}`)
|
||||
);
|
||||
Reference in New Issue
Block a user