feat(security): auto-block brute force MikroTik + hotspot MAC
All checks were successful
CI / test (push) Successful in 1m44s
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:
@@ -2,6 +2,15 @@
|
||||
|
||||
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"
|
||||
|
||||
### Diperbaiki
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<p align="center">
|
||||
<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/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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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)",
|
||||
"main": "app-customer.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -180,7 +180,8 @@ class SecurityWatchdogService {
|
||||
processedSet.add(logId);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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) ||
|
||||
msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i);
|
||||
|
||||
if (match) {
|
||||
const targetUser = match[1] || 'unknown';
|
||||
const attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim();
|
||||
const service = match[3] || 'router-service';
|
||||
// Format hotspot: "10.5.50.2 (26:BD:26:2A:C3:47): login failed: invalid username or password"
|
||||
const hotspotMatch = msg.match(/(?:->:\s*)?([0-9a-fA-F.:]+)\s*\(([0-9a-fA-F:]+)\):\s*login failed/i);
|
||||
|
||||
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)) {
|
||||
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 {
|
||||
const item = failuresByIp.get(attackerIp);
|
||||
item.count++;
|
||||
item.targets.add(targetUser);
|
||||
if (mac) item.macs.add(mac);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,6 +270,16 @@ class SecurityWatchdogService {
|
||||
|
||||
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
|
||||
this._recordAudit({
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.6.16
|
||||
0.6.17
|
||||
@@ -564,6 +564,20 @@
|
||||
<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 || '' %>">
|
||||
</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>
|
||||
|
||||
<!-- Fitur Opsional -->
|
||||
|
||||
Reference in New Issue
Block a user