feat(security): auto-block brute force MikroTik + hotspot MAC
All checks were successful
CI / test (push) Successful in 1m44s

- deteksi log hotspot login failed (IP + MAC)
- auto-block opsional ke BLACKLIST_BRUTEFORCE 1 hari + drop rule
- MAC hotspot dimasukkan ip-binding type blocked
- ambang & toggle di Pengaturan > Keamanan
- versi 0.6.17
This commit is contained in:
oxpoe
2026-11-06 11:36:20 +07:00
parent 06a6eb23f2
commit c74c7d51a3
6 changed files with 109 additions and 10 deletions

View File

@@ -2,6 +2,15 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/). Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.6.17] — 2026-11-06 — "security-block"
### Ditambahkan
- **Auto-block brute force MikroTik** (opsional, default nonaktif):
- percobaan login gagal berulang dimasukkan ke firewall `BLACKLIST_BRUTEFORCE` selama 1 hari,
- rule `drop` untuk chain `input` dan `forward` ditambahkan otomatis,
- hotspot brute force juga memblokir MAC penyerang via Hotspot IP-Binding (`type: blocked`).
- Ambang percobaan dapat diatur di **Pengaturan → Keamanan**.
## [0.6.16] — 2026-11-06 — "wifi-sync" ## [0.6.16] — 2026-11-06 — "wifi-sync"
### Diperbaiki ### Diperbaiki

View File

@@ -11,7 +11,7 @@
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC"> <img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.6.16_wifi--sync-ADFF2F" alt="Versi 0.6.16 (wifi-sync)"> <img src="https://img.shields.io/badge/versi-0.6.17_security--block-ADFF2F" alt="Versi 0.6.17 (security-block)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20"> <img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker"> <img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a> <a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>

View File

@@ -1,6 +1,6 @@
{ {
"name": "open-isp", "name": "open-isp",
"version": "0.6.16", "version": "0.6.17",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)", "description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js", "main": "app-customer.js",
"scripts": { "scripts": {

View File

@@ -180,7 +180,8 @@ class SecurityWatchdogService {
processedSet.add(logId); processedSet.add(logId);
const msg = String(l.message || ''); const msg = String(l.message || '');
if (msg.toLowerCase().includes('login failure') || msg.toLowerCase().includes('failure')) { const low = msg.toLowerCase();
if (low.includes('login failure') || low.includes('failure') || low.includes('login failed')) {
failures.push(l); failures.push(l);
} }
} }
@@ -200,19 +201,31 @@ class SecurityWatchdogService {
const match = msg.match(/login failure for user\s+(\S+)\s+from\s+([0-9a-fA-F.:]+)(?:\s+via\s+(\S+))?/i) || const match = msg.match(/login failure for user\s+(\S+)\s+from\s+([0-9a-fA-F.:]+)(?:\s+via\s+(\S+))?/i) ||
msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i); msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i);
if (match) { // Format hotspot: "10.5.50.2 (26:BD:26:2A:C3:47): login failed: invalid username or password"
const targetUser = match[1] || 'unknown'; const hotspotMatch = msg.match(/(?:->:\s*)?([0-9a-fA-F.:]+)\s*\(([0-9a-fA-F:]+)\):\s*login failed/i);
const attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim();
const service = match[3] || 'router-service';
if (!attackerIp || attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue; let targetUser, attackerIp, service, mac;
if (hotspotMatch) {
attackerIp = String(hotspotMatch[1] || '').replace(/^::ffff:/, '').trim();
mac = String(hotspotMatch[2] || '').toUpperCase();
targetUser = 'voucher-hotspot';
service = 'hotspot';
} else if (match) {
targetUser = match[1] || 'unknown';
attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim();
service = match[3] || 'router-service';
}
if (attackerIp) {
if (attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue;
if (!failuresByIp.has(attackerIp)) { if (!failuresByIp.has(attackerIp)) {
failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service }); failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service, macs: new Set(mac ? [mac] : []) });
} else { } else {
const item = failuresByIp.get(attackerIp); const item = failuresByIp.get(attackerIp);
item.count++; item.count++;
item.targets.add(targetUser); item.targets.add(targetUser);
if (mac) item.macs.add(mac);
} }
} }
} }
@@ -257,6 +270,16 @@ class SecurityWatchdogService {
await this._notifyAdmin(waMsg); await this._notifyAdmin(waMsg);
// 2b. AUTO-BLOCK (opsional, default nonaktif)
if (String(getSetting('security_auto_block_enabled', 'false')) === 'true' && data.count >= (parseInt(getSetting('security_mikrotik_threshold', 5), 10) || 5)) {
try {
const actions = await this._blockMikrotikIp(conn, attackerIp, r.name, data);
if (actions.length) logger.warn('[SecurityWatchdog] Auto-block ' + attackerIp + ': ' + actions.join('; '));
} catch (blockErr) {
logger.error('[SecurityWatchdog] Auto-block gagal: ' + blockErr.message);
}
}
// 3. CATAT KE AUDIT TRAIL // 3. CATAT KE AUDIT TRAIL
this._recordAudit({ this._recordAudit({
action: 'ROUTER_BRUTE_FORCE', action: 'ROUTER_BRUTE_FORCE',
@@ -284,6 +307,59 @@ class SecurityWatchdogService {
} }
} }
/**
* Blokir IP penyerang di MikroTik (firewall address-list + drop rule).
* Hanya dipanggil bila security_auto_block_enabled=true.
*/
async _blockMikrotikIp(conn, ip, routerName, data) {
const actions = [];
const safeIp = String(ip || '').trim();
if (!safeIp || safeIp === '127.0.0.1' || safeIp === 'localhost') return actions;
// Hotspot brute force: blokir juga MAC di IP-Binding
if (data.macs && data.macs.size) {
const bindMenu = conn.client.menu('/ip/hotspot/ip-binding');
const bindings = await bindMenu.get();
for (const mac of data.macs) {
const found = bindings.find(b => String(b['mac-address'] || b.macAddress || '').toUpperCase() === mac);
if (!found) {
await bindMenu.add({ macAddress: mac, type: 'blocked', comment: 'AI_BLOCKED: hotspot brute force (' + data.count + 'x)' }).catch(function () {});
actions.push('MAC ' + mac + ' diblokir di Hotspot IP-Binding');
}
}
}
const listMenu = conn.client.menu('/ip/firewall/address-list');
const rows = await listMenu.get();
const exists = rows.find(a => a.address === safeIp && (a.list === 'BLACKLIST_BRUTEFORCE' || a.list === 'BLACKLIST_ANOMALY'));
if (!exists) {
await listMenu.add({
list: 'BLACKLIST_BRUTEFORCE',
address: safeIp,
timeout: '1d',
comment: 'AI_BLOCKED: ' + String(data.service || 'router').toUpperCase() + ' (' + data.count + 'x gagal)'
});
actions.push('IP ' + safeIp + ' masuk BLACKLIST_BRUTEFORCE (1 hari)');
}
// Pastikan rule drop pada chain input & forward
const filterMenu = conn.client.menu('/ip/firewall/filter');
const filters = await filterMenu.get();
const hasDrop = (chain) => filters.some(f => f.chain === chain && f.action === 'drop' &&
(f['src-address-list'] === 'BLACKLIST_BRUTEFORCE' || f.srcAddressList === 'BLACKLIST_BRUTEFORCE'));
for (const chain of ['input', 'forward']) {
if (!hasDrop(chain)) {
await filterMenu.add({
chain,
action: 'drop',
srcAddressList: 'BLACKLIST_BRUTEFORCE',
comment: 'AI_AUTO_DROP_BLACKLIST_BRUTEFORCE'
}).catch(function () {});
actions.push('rule drop ' + chain + ' ditambahkan');
}
}
return actions;
}
/** /**
* Ringkasan status keamanan saat ini * Ringkasan status keamanan saat ini
*/ */

View File

@@ -1 +1 @@
0.6.16 0.6.17

View File

@@ -564,6 +564,20 @@
<label class="fl"><%= t('admin.set.admin_key','Admin API Key (Untuk akses luar)') %></label> <label class="fl"><%= t('admin.set.admin_key','Admin API Key (Untuk akses luar)') %></label>
<input class="fc" name="admin_api_key" value="<%= settings.admin_api_key || '' %>"> <input class="fc" name="admin_api_key" value="<%= settings.admin_api_key || '' %>">
</div> </div>
<div class="form-row">
<div class="fg">
<label class="fl">Auto-Block Brute Force MikroTik</label>
<select class="fs" name="security_auto_block_enabled">
<option value="false" <%= String(settings.security_auto_block_enabled) !== 'true' ? 'selected' : '' %>>Nonaktif</option>
<option value="true" <%= String(settings.security_auto_block_enabled) === 'true' ? 'selected' : '' %>>Aktif (blokir IP otomatis)</option>
</select>
<div style="font-size:10px;color:var(--muted);margin-top:4px">Jika aktif, IP dengan percobaan login gagal berulang dimasukkan ke firewall blacklist 1 hari.</div>
</div>
<div class="fg">
<label class="fl">Ambang Percobaan</label>
<input class="fc" type="number" min="2" max="50" name="security_mikrotik_threshold" value="<%= settings.security_mikrotik_threshold || 5 %>">
</div>
</div>
</div> </div>
<!-- Fitur Opsional --> <!-- Fitur Opsional -->