- securityWatchdogService: deteksi brute force login admin/kasir/teknisi/kolektor & log MikroTik, blokir IP 15 menit, alert suara + WA/Telegram - soundboxService + voiceTtsService (TTS Google Translate, cache) - hook login admin/teknisi/kolektor, hook soundbox saat invoice lunas - route /admin/api/soundbox/poll dan /test
370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
/**
|
|
* Service: AI Security & Brute-Force Anomaly Watchdog
|
|
* Mendeteksi anomali serangan brute force pada aplikasi billing dan router MikroTik.
|
|
* Mengumumkan peringatan darurat secara otomatis melalui Voice TTS (Soundbox)
|
|
* serta mengirim notifikasi siaga ke WhatsApp & Telegram Admin.
|
|
*/
|
|
const db = require('../config/database');
|
|
const { logger } = require('../config/logger');
|
|
const soundboxSvc = require('./soundboxService');
|
|
const { getSetting } = require('../config/settingsManager');
|
|
|
|
// In-memory state tracking
|
|
const failedAttemptsByIp = new Map();
|
|
const blockedIps = new Map(); // ip -> unblockTime
|
|
const mikrotikAlertCache = new Map(); // routerId:ip -> timestamp
|
|
const processedLogIdsByRouter = new Map(); // routerId -> Set of log IDs
|
|
|
|
const WINDOW_MS = 5 * 60 * 1000; // 5 menit
|
|
const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 menit
|
|
const WARNING_THRESHOLD = 3;
|
|
const CRITICAL_THRESHOLD = 5;
|
|
|
|
/**
|
|
* Mengonversi IP address menjadi format lisan ramah suara TTS
|
|
* Contoh: "192.168.8.50" -> "1 9 2 titik 1 6 8 titik 8 titik 5 0"
|
|
*/
|
|
function formatIpForSpeech(ip) {
|
|
if (!ip) return 'tidak diketahui';
|
|
const cleanIp = String(ip).replace(/^::ffff:/, '');
|
|
return cleanIp.split('.').map(octet => octet.split('').join(' ')).join(' titik ');
|
|
}
|
|
|
|
class SecurityWatchdogService {
|
|
/**
|
|
* Cek apakah IP sedang dalam masa blokir sementara
|
|
*/
|
|
isIpBlocked(rawIp) {
|
|
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
|
|
if (!ip) return false;
|
|
|
|
const unblockTime = blockedIps.get(ip);
|
|
if (unblockTime) {
|
|
if (Date.now() < unblockTime) {
|
|
return true;
|
|
} else {
|
|
blockedIps.delete(ip);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Buka blokir IP secara manual
|
|
*/
|
|
unblockIp(rawIp) {
|
|
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
|
|
if (!ip) return false;
|
|
blockedIps.delete(ip);
|
|
failedAttemptsByIp.delete(ip);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Mencatat percobaan login pada aplikasi (Admin / Kasir / Agent / Tech)
|
|
*/
|
|
async recordAppLoginAttempt({ type = 'admin', username = '', ip: rawIp = '127.0.0.1', success = false, userAgent = '' }) {
|
|
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
|
|
const now = Date.now();
|
|
|
|
// Jika login berhasil, reset kegagalan IP ini
|
|
if (success) {
|
|
failedAttemptsByIp.delete(ip);
|
|
return { allowed: true };
|
|
}
|
|
|
|
// Login Gagal: Ambil atau buat record tracking
|
|
let record = failedAttemptsByIp.get(ip);
|
|
if (!record || (now - record.lastAttempt > WINDOW_MS)) {
|
|
record = {
|
|
ip,
|
|
count: 1,
|
|
firstAttempt: now,
|
|
lastAttempt: now,
|
|
usernames: new Set([username]),
|
|
alerted: false
|
|
};
|
|
} else {
|
|
record.count++;
|
|
record.lastAttempt = now;
|
|
if (username) record.usernames.add(username);
|
|
}
|
|
failedAttemptsByIp.set(ip, record);
|
|
|
|
logger.warn(`[SecurityWatchdog] Login gagal ke ${type} dari IP ${ip} (Percobaan ke-${record.count}, target: ${username})`);
|
|
|
|
// Cek Ambang Batas Kritis (Brute Force Detected)
|
|
if (record.count >= CRITICAL_THRESHOLD && !record.alerted) {
|
|
record.alerted = true;
|
|
blockedIps.set(ip, now + BLOCK_DURATION_MS);
|
|
|
|
const targetList = Array.from(record.usernames).filter(Boolean).join(', ') || 'admin';
|
|
const ipSpoken = formatIpForSpeech(ip);
|
|
|
|
// 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN)
|
|
const speechText = `Peringatan keamanan! Terdeteksi anomali percobaan login gagal berulang kali ke aplikasi ${type} dari alamat i pe ${ipSpoken}. Sistem telah memblokir sementara akses tersebut.`;
|
|
|
|
soundboxSvc.queueSoundboxEvent({
|
|
type: 'security_alert',
|
|
title: 'PERINGATAN BRUTE FORCE APLIKASI',
|
|
speechText: speechText,
|
|
amount: 0,
|
|
customerName: `IP: ${ip} (Target: ${targetList})`
|
|
});
|
|
|
|
// 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN
|
|
const waMsg =
|
|
`🚨 *PERINGATAN KEAMANAN (BRUTE FORCE DETECTED)*\n\n` +
|
|
`Sistem mendeteksi percobaan login gagal berulang kali ke portal *${type.toUpperCase()}*:\n` +
|
|
`🌐 *Alamat IP:* \`${ip}\`\n` +
|
|
`👤 *Target Username:* ${targetList}\n` +
|
|
`📊 *Jumlah Kegagalan:* ${record.count} kali dalam 5 menit\n` +
|
|
`⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n` +
|
|
`🛡️ *Status:* IP telah diblokir sementara selama 15 menit.\n\n` +
|
|
`_Peringatan suara telah dibunyikan otomatis melalui Soundbox._`;
|
|
|
|
await this._notifyAdmin(waMsg);
|
|
|
|
// 3. CATAT KE AUDIT TRAIL
|
|
this._recordAudit({
|
|
action: 'BRUTE_FORCE_BLOCKED',
|
|
entity_type: 'app_login',
|
|
entity_id: ip,
|
|
details: {
|
|
portal: type,
|
|
failedCount: record.count,
|
|
targets: Array.from(record.usernames),
|
|
userAgent
|
|
},
|
|
ip_address: ip,
|
|
user_agent: userAgent
|
|
});
|
|
}
|
|
|
|
return {
|
|
allowed: !this.isIpBlocked(ip),
|
|
attempts: record.count,
|
|
remaining: Math.max(0, CRITICAL_THRESHOLD - record.count)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pindai log MikroTik untuk mendeteksi brute-force login SSH / Telnet / Winbox / WebFig
|
|
*/
|
|
async scanMikrotikLoginFailures() {
|
|
try {
|
|
const mt = require('./mikrotikService');
|
|
const routers = db.prepare("SELECT id, name, host FROM routers WHERE is_active = 1").all() || [];
|
|
|
|
for (const r of routers) {
|
|
try {
|
|
const conn = await mt.getConnection(r.id);
|
|
if (!conn || !conn.client) continue;
|
|
|
|
// Ambil log dari MikroTik
|
|
const logs = await conn.client.menu('/log').get();
|
|
if (!Array.isArray(logs) || logs.length === 0) continue;
|
|
|
|
// Ambil atau buat set log ID yang sudah diproses agar tidak duplikat
|
|
let processedSet = processedLogIdsByRouter.get(r.id);
|
|
if (!processedSet) {
|
|
processedSet = new Set();
|
|
processedLogIdsByRouter.set(r.id, processedSet);
|
|
}
|
|
|
|
// Filter log kegagalan login
|
|
const failures = [];
|
|
for (const l of logs) {
|
|
const logId = l['.id'] || l.id;
|
|
if (processedSet.has(logId)) continue;
|
|
processedSet.add(logId);
|
|
|
|
const msg = String(l.message || '');
|
|
if (msg.toLowerCase().includes('login failure') || msg.toLowerCase().includes('failure')) {
|
|
failures.push(l);
|
|
}
|
|
}
|
|
|
|
// Bersihkan cache log ID jika terlalu besar
|
|
if (processedSet.size > 2000) {
|
|
processedSet.clear();
|
|
}
|
|
|
|
if (failures.length === 0) continue;
|
|
|
|
// Kelompokkan kegagalan berdasarkan IP penyerang
|
|
const failuresByIp = new Map();
|
|
for (const f of failures) {
|
|
const msg = f.message || '';
|
|
// Regex MikroTik format: "login failure for user admin from 192.168.1.50 via winbox"
|
|
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';
|
|
|
|
if (!attackerIp || attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue;
|
|
|
|
if (!failuresByIp.has(attackerIp)) {
|
|
failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service });
|
|
} else {
|
|
const item = failuresByIp.get(attackerIp);
|
|
item.count++;
|
|
item.targets.add(targetUser);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Periksa setiap penyerang
|
|
const now = Date.now();
|
|
for (const [attackerIp, data] of failuresByIp.entries()) {
|
|
if (data.count >= 2) {
|
|
const cacheKey = `${r.id}:${attackerIp}`;
|
|
const lastAlert = mikrotikAlertCache.get(cacheKey) || 0;
|
|
|
|
// Debounce alert per IP router tiap 15 menit
|
|
if (now - lastAlert > 15 * 60 * 1000) {
|
|
mikrotikAlertCache.set(cacheKey, now);
|
|
|
|
const targetsStr = Array.from(data.targets).join(', ');
|
|
const ipSpoken = formatIpForSpeech(attackerIp);
|
|
|
|
logger.warn(`[SecurityWatchdog] Terdeteksi brute-force MikroTik '${r.name}' dari ${attackerIp} (${data.count}x via ${data.service})`);
|
|
|
|
// 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN)
|
|
const speechText = `Peringatan darurat! Terdeteksi serangan brute force ke router MikroTik ${r.name} dari alamat i pe ${ipSpoken} melalui ${data.service}. Segera periksa keamanan router Anda.`;
|
|
|
|
soundboxSvc.queueSoundboxEvent({
|
|
type: 'security_alert',
|
|
title: 'PERINGATAN BRUTE FORCE MIKROTIK',
|
|
speechText: speechText,
|
|
amount: 0,
|
|
customerName: `Router: ${r.name} | IP: ${attackerIp}`
|
|
});
|
|
|
|
// 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN
|
|
const waMsg =
|
|
`🚨 *PERINGATAN KEAMANAN (SERANGAN MIKROTIK)*\n\n` +
|
|
`Terdeteksi serangan brute force login pada router *${r.name}* (${r.host}):\n` +
|
|
`🌐 *IP Penyerang:* \`${attackerIp}\`\n` +
|
|
`🔌 *Layanan:* ${data.service.toUpperCase()}\n` +
|
|
`👤 *Target User:* ${targetsStr}\n` +
|
|
`📊 *Jumlah Percobaan Gagal:* ${data.count} kali\n` +
|
|
`⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n\n` +
|
|
`_Peringatan suara darurat telah dibunyikan melalui Soundbox._`;
|
|
|
|
await this._notifyAdmin(waMsg);
|
|
|
|
// 3. CATAT KE AUDIT TRAIL
|
|
this._recordAudit({
|
|
action: 'ROUTER_BRUTE_FORCE',
|
|
entity_type: 'mikrotik_login',
|
|
entity_id: `${r.name}:${attackerIp}`,
|
|
details: {
|
|
router: r.name,
|
|
routerHost: r.host,
|
|
attackerIp,
|
|
service: data.service,
|
|
count: data.count,
|
|
targets: Array.from(data.targets)
|
|
},
|
|
ip_address: attackerIp
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`[SecurityWatchdog] Scan router ${r.name} error: ${err.message}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logger.error(`[SecurityWatchdog] Scan MikroTik failures fatal: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ringkasan status keamanan saat ini
|
|
*/
|
|
getSecurityOverview() {
|
|
const blockedList = [];
|
|
const now = Date.now();
|
|
for (const [ip, unblockTime] of blockedIps.entries()) {
|
|
if (now < unblockTime) {
|
|
blockedList.push({ ip, remainingSeconds: Math.ceil((unblockTime - now) / 1000) });
|
|
}
|
|
}
|
|
|
|
let recentAudit = [];
|
|
try {
|
|
recentAudit = db.prepare(`
|
|
SELECT * FROM audit_trail
|
|
WHERE action IN ('BRUTE_FORCE_BLOCKED', 'ROUTER_BRUTE_FORCE')
|
|
ORDER BY id DESC LIMIT 10
|
|
`).all() || [];
|
|
} catch (_) {}
|
|
|
|
return {
|
|
status: blockedList.length > 0 ? 'warning' : 'secure',
|
|
activeBlockedIps: blockedList,
|
|
recentAlerts: recentAudit,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper: Kirim notifikasi ke WhatsApp dan Telegram Admin
|
|
*/
|
|
async _notifyAdmin(message) {
|
|
try {
|
|
const adminPhone = getSetting('admin_phone') || getSetting('company_phone');
|
|
if (adminPhone) {
|
|
const { sendWA, whatsappStatus } = await import('./whatsappBot.mjs');
|
|
if (whatsappStatus && whatsappStatus.connection === 'open') {
|
|
await sendWA(adminPhone, message);
|
|
}
|
|
}
|
|
|
|
// Telegram Admin Notification
|
|
const adminChatId = getSetting('telegram_admin_chat_id') || getSetting('telegram_chat_id');
|
|
const botToken = getSetting('telegram_bot_token');
|
|
if (adminChatId && botToken) {
|
|
const axios = require('axios');
|
|
await axios.post(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
chat_id: adminChatId,
|
|
text: message,
|
|
parse_mode: 'Markdown'
|
|
}).catch(() => {});
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`[SecurityWatchdog] Gagal kirim notifikasi siaga: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper: Catat audit log
|
|
*/
|
|
_recordAudit({ action, entity_type, entity_id, details, ip_address, user_agent }) {
|
|
try {
|
|
const audit = require('./auditTrailService');
|
|
if (audit && typeof audit.logAuditTrail === 'function') {
|
|
audit.logAuditTrail({
|
|
action,
|
|
entity_type,
|
|
entity_id,
|
|
actor_type: 'system',
|
|
actor_id: 'security-watchdog',
|
|
actor_name: 'AI Security Watchdog',
|
|
details,
|
|
ip_address,
|
|
user_agent
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
const securityWatchdogService = new SecurityWatchdogService();
|
|
module.exports = securityWatchdogService;
|