From 60bb6abe4f07ccc18497e892df85bc5a5fafb7c8 Mon Sep 17 00:00:00 2001 From: gitea Date: Fri, 20 Feb 2026 18:49:15 +0000 Subject: [PATCH] Add two-tier IP exemption system; fix scan button to use /api/exempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EXEMPT_FILE, readExemptions/saveExemptions/isExempt/addExemption/removeExemption - Filter exempt IPs from scan results (still monitored by fail2ban) - buildBanList() appends exempt entries with jail:'exempt' - API: POST /api/exempt, DELETE /api/exempt/:ip, GET /api/exemptions - Frontend: [EXEMPT] filter tab, exempt jail color, REMOVE/ARREST/THREAT actions - Scan card [WHITELIST] button → [EXEMPT] calling /api/exempt (not ignoreip) - Fix isWhitelisted() to read only jail.local with proper CIDR matching - Fix readIgnoreIP() regex: \s* → [ \t]* to prevent cross-line capture Co-Authored-By: Claude Sonnet 4.6 --- dashboard/public/index.html | 23 +++++++++++---- dashboard/server.js | 56 +++++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/dashboard/public/index.html b/dashboard/public/index.html index a43a3a5..f6a9b1f 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -45,6 +45,7 @@ + @@ -119,6 +120,7 @@ async function api(method, url, body) { function scoreColor(score, jail) { if (jail === 'manual-bans') return '#8B4513'; if (jail === 'whitelist') return 'var(--green)'; + if (jail === 'exempt') return 'var(--dim)'; if (score == null) return 'var(--dim)'; if (score >= 90) return 'var(--red)'; if (score <= 20) return 'var(--green2)'; @@ -328,8 +330,8 @@ function makeBanCard(b) { ? `${b.score}` : ''; - let meta = `JAIL: ${esc(b.jail)}`; - if (b.jail !== 'whitelist') { + let meta = `JAIL: ${esc(b.jail.toUpperCase())}`; + if (b.jail !== 'whitelist' && b.jail !== 'exempt') { meta += `BANNED: ${b.banTime ? esc(b.banTime.slice(5,16)) : '—'}`; meta += `EXPIRES: ${b.unbanTime ? esc(b.unbanTime.slice(5,16)) : '—'}`; } @@ -338,6 +340,10 @@ function makeBanCard(b) { const actions = b.jail === 'whitelist' ? `` + : b.jail === 'exempt' + ? ` + + ` : ` [RECORDS] @@ -364,7 +370,7 @@ function makeScanCard(d) { `
[RECORDS] - +
`; return card; @@ -386,6 +392,11 @@ async function removeWhitelist(ip) { catch (e) { alert('Remove failed: ' + e.message); } } +async function removeExemption(ip) { + try { await api('DELETE', `/api/exempt/${encodeURIComponent(ip)}`); await loadBans(); } + catch (e) { alert('Remove failed: ' + e.message); } +} + async function abuseCheck(ip, btn) { btn.disabled = true; btn.textContent = '[…]'; try { @@ -444,11 +455,11 @@ async function scanBan(ip, btn) { async function scanWL(ip, btn) { btn.disabled = true; btn.textContent = '[…]'; try { - await api('POST', '/api/whitelist', { ip }); + await api('POST', '/api/exempt', { ip }); btn.closest('.card').style.opacity = '.4'; - btn.textContent = '[LISTED]'; + btn.textContent = '[EXEMPTED]'; loadBans(); - } catch (e) { alert('Whitelist failed: ' + e.message); btn.disabled = false; btn.textContent = '[WHITELIST]'; } + } catch (e) { alert('Exempt failed: ' + e.message); btn.disabled = false; btn.textContent = '[EXEMPT]'; } } async function scanAbuse(ip, btn) { diff --git a/dashboard/server.js b/dashboard/server.js index 93b4539..19996e8 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -19,6 +19,7 @@ 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; @@ -133,7 +134,7 @@ async function buildBanList() { return { ...b, score, country }; }); - // Append whitelist IPs as a virtual "jail" + // Append fail2ban ignoreip as "whitelist" (trusted — f2b won't monitor) whitelist.forEach(ip => { data.push({ ip, jail: 'whitelist', duration: -1, @@ -144,6 +145,17 @@ async function buildBanList() { }); }); + // 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; } @@ -216,6 +228,32 @@ async function refreshBanHistory() { } 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(); @@ -243,7 +281,7 @@ async function processLogFile(file, cutoff) { const ipM = line.match(/\[Client ([^\]]+)\]/); if (!ipM) return; const ip = ipM[1]; - if (isWhitelisted(ip)) return; + 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+/); @@ -360,6 +398,20 @@ app.delete('/api/whitelist/:ip', async (req, res) => { 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)); }