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)); }