const dns = require('dns'); const net = require('net'); const fs = require('fs'); const path = require('path'); const { URL } = require('url'); const { RouterOSClient } = require('routeros-client'); const { getSettingsWithCache } = require('../config/settingsManager'); const { logger } = require('../config/logger'); const db = require('../config/database'); // Runtime patches for node-routeros to prevent uncaught exceptions under RouterOS v7 try { const { Channel } = require('node-routeros/dist/Channel'); const { Receiver } = require('node-routeros/dist/connector/Receiver'); const { RosException } = require('node-routeros/dist/RosException'); // 1. Prevent onUnknown from throwing a synchronous exception which crashes the process. Channel.prototype.onUnknown = function(reply) { logger.warn(`[MikroTik Channel] Received unknown reply: ${reply}`); }; // 2. Reject the write promise when receiving an unknown reply, so it can be handled by try-catch. const originalWrite = Channel.prototype.write; Channel.prototype.write = function(params, isStream = false, returnPromise = true) { if (returnPromise) { this.streaming = isStream; params.push('.tag=' + this.id); this.on('data', (packet) => this.data.push(packet)); return new Promise((resolve, reject) => { this.once('done', (data) => resolve(data)); this.once('trap', (data) => reject(new Error(data.message))); this.once('unknown', (reply) => { reject(new RosException('UNKNOWNREPLY', { reply: reply })); }); this.readAndWrite(params); }); } return originalWrite.call(this, params, isStream, returnPromise); }; // 3. Prevent sendTagData from throwing a synchronous exception when tag is unregistered. Receiver.prototype.sendTagData = function(currentTag) { const tag = this.tags.get(currentTag); if (tag) { tag.callback(this.currentPacket); } else { logger.warn(`[MikroTik Receiver] Received data for unregistered tag: ${currentTag}`); } this.cleanUp(); }; } catch (e) { logger.error(`[MikroTik Patch] Failed to apply runtime patches: ${e.message}`); } const connectionProbeCache = new Map(); const listCache = new Map(); // CACHE DISABLED untuk data yang selalu akurat const CACHE_ENABLED = false; // Set false untuk disable cache function cacheKey(routerId, name) { const rid = routerId == null || String(routerId).trim() === '' ? 'default' : String(routerId).trim(); return `${name}:${rid}`; } function getCachedList(key, ttlMs) { if (!CACHE_ENABLED) return null; // Skip cache jika disabled const hit = listCache.get(key); if (!hit) return null; const age = Date.now() - Number(hit.ts || 0); if (age >= Math.max(0, Number(ttlMs) || 0)) return null; return hit.data; } function setCachedList(key, data) { if (!CACHE_ENABLED) return; // Skip cache jika disabled listCache.set(key, { ts: Date.now(), data }); } function clearCachedByPrefix(prefix) { for (const k of listCache.keys()) { if (String(k).startsWith(prefix)) listCache.delete(k); } } function withTimeout(promise, timeoutMs, label) { const ms = Math.max(200, Number(timeoutMs) || 0); if (!ms) return promise; return Promise.race([ promise, new Promise((_, reject) => { const t = setTimeout(() => { clearTimeout(t); reject(new Error(`Timeout ${ms}ms${label ? `: ${label}` : ''}`)); }, ms); }) ]); } async function canConnectTcp(host, port, timeoutMs = 1200) { const h = String(host || '').trim(); const p = Number(port) || 0; if (!h || !p) return false; return await new Promise((resolve) => { const socket = net.connect({ host: h, port: p }); const done = (ok) => { try { socket.destroy(); } catch {} resolve(Boolean(ok)); }; socket.setTimeout(Math.max(200, Number(timeoutMs) || 1200)); socket.once('connect', () => done(true)); socket.once('timeout', () => done(false)); socket.once('error', () => done(false)); }); } async function resolveIpv4(hostname) { const host = String(hostname || '').trim(); if (!host) return ''; if (/^(\d{1,3}\.){3}\d{1,3}$/.test(host)) return host; if (dns.promises && typeof dns.promises.lookup === 'function') { const res = await dns.promises.lookup(host, { family: 4 }); return res && res.address ? String(res.address) : ''; } return await new Promise((resolve, reject) => { dns.lookup(host, { family: 4 }, (err, address) => { if (err) return reject(err); resolve(String(address || '')); }); }); } function toKebabCase(key) { const s = String(key || '').trim(); if (!s) return s; if (s.includes('-') || s.startsWith('.') || s.startsWith('=') || s.startsWith('?')) return s; return s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); } function toCamelCaseKey(key) { return String(key || '').replace(/-([a-z0-9])/g, (_, c) => String(c).toUpperCase()); } function augmentRow(row) { if (!row || typeof row !== 'object') return row; for (const [k, v] of Object.entries(row)) { if (!k || k.startsWith('.') || k.includes('-') === false) continue; const camel = toCamelCaseKey(k); if (camel && row[camel] === undefined) row[camel] = v; } return row; } class MenuAdapter { constructor(api, basePath, filters = []) { this.api = api; this.basePath = basePath; this.filters = filters; } where(keyOrObj, value) { const next = [...this.filters]; if (keyOrObj && typeof keyOrObj === 'object') { for (const [k, v] of Object.entries(keyOrObj)) next.push(this.#toQueryWord(k, v)); } else { next.push(this.#toQueryWord(keyOrObj, value)); } return new MenuAdapter(this.api, this.basePath, next); } async get() { const words = [`${this.basePath}/print`, ...this.filters]; const res = await this.api.send(words); return Array.isArray(res) ? res.map(augmentRow) : []; } async getOnly() { const rows = await this.get(); return rows && rows.length ? rows[0] : null; } async add(data) { const words = [`${this.basePath}/add`, ...this.#toSetWords(data)]; const res = await this.api.send(words); return res; } async set(data, id) { const rid = String(id || '').trim(); const words = [`${this.basePath}/set`, `=.id=${rid}`, ...this.#toSetWords(data)]; const res = await this.api.send(words); return res; } async remove(id) { const rid = String(id || '').trim(); const words = [`${this.basePath}/remove`, `=.id=${rid}`]; const res = await this.api.send(words); return res; } async update(data) { const rows = await this.get(); for (const r of rows) { const rid = String(r?.['.id'] || '').trim(); if (!rid) continue; await this.set(data, rid); } return []; } async exec(command, params) { const cmd = String(command || '').trim(); if (!cmd) throw new Error('Command is required'); const path = this.basePath === '/' ? `/${cmd}` : `${this.basePath}/${cmd}`; const words = [path, ...this.#toSetWords(params)]; return await this.api.send(words); } #toQueryWord(key, value) { const k = String(key || '').trim(); const v = value === undefined || value === null ? '' : String(value); const kk = k === 'id' ? '.id' : toKebabCase(k); return `?${kk}=${v}`; } #toSetWords(data) { const out = []; if (!data || typeof data !== 'object') return out; for (const [kRaw, vRaw] of Object.entries(data)) { if (vRaw === undefined) continue; const k = String(kRaw || '').trim(); if (!k) continue; const kk = k === 'id' ? '.id' : toKebabCase(k); const v = vRaw === null ? '' : String(vRaw); out.push(`=${kk}=${v}`); } return out; } } class ClientAdapter { constructor(api) { this.api = api; } menu(path) { const raw = String(path || '').trim(); const normalized = raw ? ('/' + raw.replace(/^\/+/, '').replace(/\s+/g, '/').replace(/\/+$/g, '')) : '/'; return new MenuAdapter(this.api, normalized); } } async function getConnection(routerId = null) { let host, port, user, password; if (routerId === 'settings_json' || routerId === 'settings') { const settings = getSettingsWithCache(); host = settings.mikrotik_host; port = settings.mikrotik_port || 8728; user = settings.mikrotik_user; password = settings.mikrotik_password; logger.info('[MikroTik] Using router from settings.json (explicitly requested)'); } else if (routerId) { const router = db.prepare('SELECT * FROM routers WHERE id = ?').get(routerId); if (!router) throw new Error(`Router with ID ${routerId} not found`); host = router.host; port = router.port || 8728; user = router.user; password = router.password; } else { // Tidak ada routerId, coba cari router default dari database const defaultRouter = db.prepare('SELECT * FROM routers WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get(); if (defaultRouter) { // Ada router aktif di database, gunakan itu host = defaultRouter.host; port = defaultRouter.port || 8728; user = defaultRouter.user; password = defaultRouter.password; logger.info(`[MikroTik] Using default router from database: ${defaultRouter.name} (${defaultRouter.host})`); } else { // Fallback ke settings.json untuk backward compatibility const settings = getSettingsWithCache(); host = settings.mikrotik_host; port = settings.mikrotik_port || 8728; user = settings.mikrotik_user; password = settings.mikrotik_password; logger.info('[MikroTik] Using router from settings.json (no routers in database)'); } } if (!host || !user) { throw new Error('MikroTik settings not configured'); } const configuredPort = Number(port) || 8728; const tlsSetting = getSettingsWithCache().mikrotik_tls === true; const fallbackPort = configuredPort === 8728 ? 8729 : 8728; const candidates = configuredPort === fallbackPort ? [configuredPort] : [configuredPort, fallbackPort]; const cacheKey = String(host); const now = Date.now(); const cached = connectionProbeCache.get(cacheKey); if (cached && cached.failUntil && now < cached.failUntil) { const e = new Error(cached.failMessage || `Tidak bisa konek ke MikroTik ${host}:${configuredPort}.`); e.code = 'ECONNREFUSED'; throw e; } let selectedPort = (cached && cached.okUntil && now < cached.okUntil && cached.port) ? Number(cached.port) : 0; if (!selectedPort) { for (const p of candidates) { const ok = await canConnectTcp(host, p, 1200); if (ok) { selectedPort = p; break; } } if (!selectedPort) { const failMessage = `Tidak bisa konek ke MikroTik ${host}:${configuredPort} (juga sudah coba ${fallbackPort}). Pastikan IP/port benar dan service API (8728) atau API-SSL (8729) aktif di MikroTik.`; connectionProbeCache.set(cacheKey, { port: 0, okUntil: 0, failUntil: now + 5000, failMessage }); const e = new Error(failMessage); e.code = 'ECONNREFUSED'; throw e; } } try { const useTls = selectedPort === 8729 || tlsSetting === true; const api = new RouterOSClient({ host, port: selectedPort, user, password, tls: useTls ? { rejectUnauthorized: false } : false, // dukung self-signed cert (port 8729 api-ssl) timeout: 5000 }); // Attach defensive error listener to prevent unhandled 'error' events // from crashing the process when connection is refused or drops if (typeof api.on === 'function') { api.on('error', (err) => { logger.error(`[MikroTik] Connection error event (${host}): ${err?.message || err}`); }); } if (api.rosApi && typeof api.rosApi.on === 'function') { api.rosApi.on('error', (err) => { logger.error(`[MikroTik Raw API] Connection or parser error (${host}): ${err?.message || err}`); }); } await api.connect(); connectionProbeCache.set(cacheKey, { port: selectedPort, okUntil: Date.now() + 30000, failUntil: 0, failMessage: '' }); // Adapt api so that it exposes the expected .send method and correct close logic api.send = async (words) => { if (!api.rosApi || typeof api.rosApi.write !== 'function') { throw new Error('MikroTik API connection is not established'); } return await api.rosApi.write(words); }; const originalClose = typeof api.close === 'function' ? api.close.bind(api) : null; api.close = async () => { try { if (originalClose) return await originalClose(); } catch {} return undefined; }; if (typeof api.disconnect !== 'function') api.disconnect = api.close; const client = new ClientAdapter(api); return { client, api }; } catch (err) { const msg = String(err?.message || err || ''); if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT') || msg.includes('EHOSTUNREACH')) { connectionProbeCache.set(cacheKey, { port: 0, okUntil: 0, failUntil: Date.now() + 5000, failMessage: `Tidak bisa konek ke MikroTik ${host}:${selectedPort}. ${msg}` }); } logger.error(`Failed to connect to MikroTik (${host}): ${err?.message || err}`); throw err; } } async function checkConnection(routerId = null) { let conn = null; try { conn = await getConnection(routerId); const identity = await conn.client.menu('/system/identity').getOnly(); return Boolean(identity && (identity.name || identity['name'])); } catch (e) { const msg = String(e?.message || ''); if (msg.includes('not found')) throw e; return false; } finally { if (conn && conn.api) conn.api.close(); } } async function getSystemIdentity(routerId = null) { let conn = null; try { conn = await getConnection(routerId); const identity = await conn.client.menu('/system/identity').getOnly(); return identity ? (identity.name || identity['name'] || 'MikroTik') : 'MikroTik'; } catch (e) { return null; } finally { if (conn && conn.api) conn.api.close(); } } async function getPppoeProfiles(routerId = null) { const ck = cacheKey(routerId, 'pppoeProfiles'); const cached = getCachedList(ck, 15000); if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); const start = Date.now(); const raw = await conn.api.send([ '/ppp/profile/print', '=.proplist=.id,name,local-address,remote-address,rate-limit,comment' ]); const ms = Date.now() - start; if (ms > 1200) logger.warn(`[MikroTik] Slow /ppp/profile/print (${ms}ms)`); const results = Array.isArray(raw) ? raw.map(augmentRow) : []; const mapped = results.map(r => ({ id: r['.id'] || r.id, name: r.name, localAddress: r.localAddress || r['local-address'] || '-', remoteAddress: r.remoteAddress || r['remote-address'] || '-', rateLimit: r.rateLimit || r['rate-limit'] || '-', comment: r.comment || '' })); setCachedList(ck, mapped); return mapped; } catch (e) { logger.error('Error getting PPPoE profiles:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function getPppoeUsers(routerId = null) { let conn = null; try { conn = await getConnection(routerId); // Only get secrets for pppoe service const results = await conn.client.menu('/ppp/secret').where('service', 'pppoe').get(); return results.map(r => ({ id: r['.id'], name: r.name, profile: r.profile, disabled: r.disabled === 'true' })); } catch (e) { logger.error('Error getting PPPoE users:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } // Function to isolate a user async function setPppoeProfile(username, profileName, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const secretMenu = conn.client.menu('/ppp/secret'); const secrets = await secretMenu.where('name', username).get(); if (!secrets || secrets.length === 0) { throw new Error(`PPPoE User ${username} not found in MikroTik`); } const secret = secrets[0]; const secretId = secret['.id'] || secret.id; if (!secretId) { throw new Error(`PPPoE secret ID not found for user ${username}`); } const currentProfile = secret.profile; // Hanya update dan kick jika profil berubah if (currentProfile !== profileName) { logger.info(`[MikroTik] Changing profile for ${username}: ${currentProfile} -> ${profileName}`); await secretMenu.set({ profile: profileName }, secretId); // Disconnect active connection so they reconnect with new profile await kickPppoeUser(username, routerId); } else { logger.info(`[MikroTik] Profile for ${username} is already ${profileName}. Skipping update and kick.`); } return true; } catch (e) { logger.error(`Error setting PPPoE profile for ${username}:`, e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function kickPppoeUser(username, routerId = null) { const normalizedUsername = String(username || '').trim(); if (!normalizedUsername) { logger.warn('[MikroTik] kickPppoeUser called without username. Skipping.'); return false; } let conn = null; try { conn = await getConnection(routerId); const sessions = await conn.client.menu('/ppp/active').where('name', normalizedUsername).get(); if (sessions.length > 0) { logger.info(`[MikroTik] Kicking ${sessions.length} active session(s) for user: ${normalizedUsername}`); for (const s of sessions) { const sessionId = s['.id'] || s.id; if (!sessionId) { logger.warn(`[MikroTik] Skipping PPPoE active remove because session id missing for user: ${normalizedUsername}`); continue; } await conn.client.menu('/ppp/active').remove(sessionId); } activeSessionsMapCache = { ts: 0, data: new Map() }; return true; } logger.info(`[MikroTik] No active PPPoE session found for user: ${normalizedUsername}`); return false; } catch (e) { logger.error(`Error kicking PPPoE user ${normalizedUsername}:`, e); return false; } finally { if (conn && conn.api) conn.api.close(); } } async function kickHotspotUser(username, routerId = null) { const normalizedUsername = String(username || '').trim(); if (!normalizedUsername) return false; let conn = null; try { conn = await getConnection(routerId); const sessions = await conn.client.menu('/ip/hotspot/active').where('user', normalizedUsername).get(); if (sessions.length > 0) { logger.info(`[MikroTik] Kicking ${sessions.length} active hotspot session(s) for user: ${normalizedUsername}`); for (const s of sessions) { const sessionId = s['.id'] || s.id; if (!sessionId) { logger.warn(`[MikroTik] Skipping Hotspot active remove because session id missing for user: ${normalizedUsername}`); continue; } await conn.client.menu('/ip/hotspot/active').remove(sessionId); } return true; } return false; } catch (e) { logger.warn(`Could not kick active hotspot connection for ${normalizedUsername}: ${e.message}`); return false; } finally { if (conn && conn.api) conn.api.close(); } } async function getPppoeSecrets(routerId = null) { const ck = cacheKey(routerId, 'pppoeSecrets'); const cached = getCachedList(ck, 5000); // Reduced cache to 5s for real-time consistency if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); // Use proplist to only fetch needed fields for better performance let rows; try { rows = await withTimeout( conn.api.send([ '/ppp/secret/print', '?service=pppoe', // Only get PPPoE service '=.proplist=.id,name,profile,local-address,remote-address,disabled,service' ]), 25000, // Increased timeout to 25s for very slow routers 'getPppoeSecrets' ); } catch (timeoutErr) { // Fallback: try without proplist if timeout occurs logger.warn(`[MikroTik] Timeout with proplist, trying full query: ${timeoutErr.message}`); const allRows = await withTimeout( conn.client.menu('/ppp/secret').get(), 30000, // 30 second timeout for fallback 'getPppoeSecrets-fallback' ); // Filter only pppoe service rows = Array.isArray(allRows) ? allRows.filter(r => { const svc = String(r?.service || '').toLowerCase(); return svc === 'pppoe' || svc === 'any' || !svc; }) : []; } const mapped = Array.isArray(rows) ? rows.map(augmentRow) : []; setCachedList(ck, mapped); return mapped; } catch (e) { logger.error('Error getting PPPoE secrets:', e); // Return cached data if available, even if expired const staleCache = listCache.get(ck); if (staleCache && staleCache.data) { logger.warn('[MikroTik] Returning stale cache due to error'); return staleCache.data; } return []; } finally { if (conn && conn.api) { try { conn.api.close(); } catch (closeErr) { // Ignore close errors } } } } async function addPppoeSecret(data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ppp/secret').add(data); listCache.delete(cacheKey(routerId, 'pppoeSecrets')); listCache.delete(cacheKey(routerId, 'pppoeActive')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function updatePppoeSecret(id, data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ppp/secret').set(data, id); listCache.delete(cacheKey(routerId, 'pppoeSecrets')); listCache.delete(cacheKey(routerId, 'pppoeActive')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function deletePppoeSecret(id, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ppp/secret').remove(id); listCache.delete(cacheKey(routerId, 'pppoeSecrets')); listCache.delete(cacheKey(routerId, 'pppoeActive')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function createPppoeSecret({ username, password, profile, remoteAddress, routerId = null }) { let conn = null; try { conn = await getConnection(routerId); const secretData = { name: username, password: password, service: 'pppoe', profile: profile }; // Add remote address if provided if (remoteAddress && remoteAddress.trim()) { secretData['remote-address'] = remoteAddress.trim(); } const res = await conn.client.menu('/ppp/secret').add(secretData); listCache.delete(cacheKey(routerId, 'pppoeSecrets')); listCache.delete(cacheKey(routerId, 'pppoeActive')); logger.info(`[MikroTik] Created PPPoE secret: ${username} with profile ${profile}`); return res; } catch (e) { logger.error(`Error creating PPPoE secret for ${username}:`, e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function getPppoeActive(routerId = null) { const ck = cacheKey(routerId, 'pppoeActive'); const cached = getCachedList(ck, 2000); // Very short cache (2s) for real-time active sessions if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); let rows = await withTimeout( conn.client.menu('/ppp/active').get(), 10000, 'getPppoeActive' ).catch(() => []); if (Array.isArray(rows) && rows.length > 0) { try { const ifaces = await withTimeout( conn.client.menu('/interface').get(), 5000, 'getPppoeActive-interface' ).catch(() => []); if (Array.isArray(ifaces) && ifaces.length > 0) { const ifaceMap = new Map(); for (const f of ifaces) { const rawName = String(f.name || f['name'] || '').trim(); const cleanName = rawName.replace(/^<|>$/g, '').trim().toLowerCase(); const rx = Number(f['rx-byte'] || f['rx-bytes'] || f['rxByte'] || f['rxBytes'] || f['bytes-in'] || f['bytesIn'] || 0); const tx = Number(f['tx-byte'] || f['tx-bytes'] || f['txByte'] || f['txBytes'] || f['bytes-out'] || f['bytesOut'] || 0); const ifData = { rx, tx, rawName, cleanName }; ifaceMap.set(cleanName, ifData); ifaceMap.set(rawName.toLowerCase(), ifData); if (cleanName.startsWith('pppoe-')) { const uname = cleanName.substring(6); if (uname) ifaceMap.set(uname, ifData); } } for (const s of rows) { const uname = String(s.name || s.user || '').trim().toLowerCase(); const ifaceField = String(s.interface || s['interface'] || '').trim().toLowerCase().replace(/^<|>$/g, ''); const match = ifaceMap.get(uname) || ifaceMap.get(`pppoe-${uname}`) || (ifaceField ? ifaceMap.get(ifaceField) : null); if (match) { s['bytes-in'] = match.rx; s['bytes-out'] = match.tx; s['bytesIn'] = match.rx; s['bytesOut'] = match.tx; s['rx-byte'] = match.rx; s['tx-byte'] = match.tx; } } } } catch (ifErr) { logger.warn(`[MikroTik] Error augmenting /interface bytes: ${ifErr.message}`); } } const mapped = Array.isArray(rows) ? rows.map(augmentRow) : []; setCachedList(ck, mapped); return mapped; } catch (e) { logger.error('Error getting active PPPoE sessions:', e); // Return cached data if available, even if expired const staleCache = listCache.get(ck); if (staleCache && staleCache.data) { logger.warn('[MikroTik] Returning stale active sessions cache due to error'); return staleCache.data; } return []; } finally { if (conn && conn.api) { try { conn.api.close(); } catch (closeErr) { // Ignore close errors } } } } function radiusOnlineEnabled() { try { return String(getSettingsWithCache().radius_enabled || '0') === '1'; } catch (e) { return false; } } function buildRadiusOnlineMap() { const map = new Map(); try { const tz = (getSettingsWithCache().timezone) || 'Asia/Jakarta'; const d = new Date(Date.now() - 20 * 60 * 1000); let cutoff; try { const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const o = {}; f.formatToParts(d).forEach(function (x) { o[x.type] = x.value; }); cutoff = o.year + '-' + o.month + '-' + o.day + ' ' + o.hour + ':' + o.minute + ':' + o.second; } catch (e) { cutoff = d.toISOString().slice(0, 19).replace('T', ' '); } const rows = db.prepare("SELECT username, framed_ip, session_time, calling_station_id, input_octets, output_octets FROM radius_accounting WHERE status_type IN (1,3) AND updated_at >= ?").all(cutoff); for (const r of rows) { const u = String(r.username || '').trim().toLowerCase(); if (!u) continue; map.set(u, { username: r.username, ip: r.framed_ip || '-', uptime: (r.session_time ? String(r.session_time) + 's' : '-'), callerId: r.calling_station_id || '', bytesIn: Number(r.input_octets || 0), bytesOut: Number(r.output_octets || 0), type: 'pppoe', source: 'radius', routerId: null, routerName: 'RADIUS' }); } } catch (e) { try { logger.warn('[Online] buildRadiusOnlineMap: ' + e.message); } catch (_) {} } return map; } async function getActivePppoeSessionsMap() { if (radiusOnlineEnabled()) { const rm = buildRadiusOnlineMap(); if (rm.size > 0) { const out = new Map(); rm.forEach(function (v, k) { out.set(k, { ip: v.ip, uptime: v.uptime, callerId: v.callerId, routerId: null, routerName: 'RADIUS' }); }); return out; } } const routers = getAllRouters().filter(r => r.is_active === 1 || r.is_active === '1' || r.is_active === true); const sessionsMap = new Map(); for (const r of routers) { try { const actives = await getPppoeActive(r.id); for (const s of actives) { if (s.name) { sessionsMap.set(s.name.toLowerCase(), { ip: s.address, uptime: s.uptime, callerId: s['caller-id'] || '', routerId: r.id, routerName: r.name }); } } } catch (err) { logger.error(`[MikroTik] Failed to get active PPPoE sessions from router ${r.name}: ${err.message}`); } } return sessionsMap; } let activeSessionsMapCache = { ts: 0, data: new Map() }; async function getAllActiveSessionsMap(forceRefresh = false) { const now = Date.now(); if (!forceRefresh && activeSessionsMapCache.data && (now - activeSessionsMapCache.ts) < 3000 && activeSessionsMapCache.data.size > 0) { return activeSessionsMapCache.data; } // Sumber status online: accounting RADIUS bila aktif (fallback MikroTik bila kosong) if (radiusOnlineEnabled()) { const rm = buildRadiusOnlineMap(); if (rm.size > 0) { activeSessionsMapCache = { ts: now, data: rm }; return rm; } } const routers = getAllRouters().filter(r => r.is_active === 1 || r.is_active === '1' || r.is_active === true); const sessionsMap = new Map(); const withTimeout = (promise, ms = 4000) => { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms)) ]).catch(() => []); }; await Promise.all(routers.map(async (r) => { try { const pppoeActives = await withTimeout(getPppoeActive(r.id), 4000); if (Array.isArray(pppoeActives)) { for (const s of pppoeActives) { if (s && (s.name || s.user)) { const username = String(s.name || s.user).trim().toLowerCase(); const bIn = Number(s['bytes-in'] || s['bytesIn'] || s['bytes_in'] || s['rx-byte'] || s['rx_byte'] || s['bytes-in-count'] || 0); const bOut = Number(s['bytes-out'] || s['bytesOut'] || s['bytes_out'] || s['tx-byte'] || s['tx_byte'] || s['bytes-out-count'] || 0); sessionsMap.set(username, { username: s.name || s.user, ip: s.address || s['address'] || '-', uptime: s.uptime || s['uptime'] || '-', callerId: s['caller-id'] || s['callerId'] || s['mac-address'] || '', bytesIn: bIn, bytesOut: bOut, type: 'pppoe', routerId: r.id, routerName: r.name }); } } } } catch (err) {} try { const hsActives = await withTimeout(getHotspotActive(r.id), 4000); if (Array.isArray(hsActives)) { for (const s of hsActives) { if (s && (s.user || s.name)) { const username = String(s.user || s.name).trim().toLowerCase(); const bIn = Number(s['bytes-in'] || s['bytesIn'] || s['bytes_in'] || s['rx-byte'] || s['rx_byte'] || s['bytes-in-count'] || 0); const bOut = Number(s['bytes-out'] || s['bytesOut'] || s['bytes_out'] || s['tx-byte'] || s['tx_byte'] || s['bytes-out-count'] || 0); sessionsMap.set(username, { username: s.user || s.name, ip: s.address || s['address'] || '-', uptime: s.uptime || s['uptime'] || '-', callerId: s['mac-address'] || s['macAddress'] || s['caller-id'] || '', bytesIn: bIn, bytesOut: bOut, type: 'hotspot', routerId: r.id, routerName: r.name }); } } } } catch (err) {} })); if (sessionsMap.size > 0 || !activeSessionsMapCache.data || activeSessionsMapCache.data.size === 0) { activeSessionsMapCache = { ts: now, data: sessionsMap }; return sessionsMap; } return activeSessionsMapCache.data; } async function getHotspotActive(routerId = null) { const ck = cacheKey(routerId, 'hotspotActive'); const cached = getCachedList(ck, 5000); // Increased cache from 3s to 5s for better performance if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); const rows = await withTimeout( conn.client.menu('/ip/hotspot/active').get(), 5000, // 5 second timeout 'getHotspotActive' ); setCachedList(ck, rows); return rows; } catch (e) { logger.debug('getHotspotActive: ' + e.message); if (/unknown reply|!empty/i.test(String(e.message))) return []; throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function getIpPools(routerId = null) { const ck = cacheKey(routerId, 'ipPools'); const cached = getCachedList(ck, 30000); if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); const rows = await conn.api.send(['/ip/pool/print', '=.proplist=.id,name,ranges,comment']); const mapped = (Array.isArray(rows) ? rows : []) .map(augmentRow) .map((r) => ({ id: r['.id'] || r.id, name: r.name, ranges: r.ranges || r['ranges'] || '', comment: r.comment || '' })) .filter((p) => p && p.name); setCachedList(ck, mapped); return mapped; } catch (e) { logger.error('Error getting IP pools:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function addIpPool(data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/pool').add(data); listCache.delete(cacheKey(routerId, 'ipPools')); return res; } catch (e) { logger.error('Error adding IP pool:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function updateIpPool(id, data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/pool').set(data, id); listCache.delete(cacheKey(routerId, 'ipPools')); return res; } catch (e) { logger.error('Error updating IP pool:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function deleteIpPool(id, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/pool').remove(id); listCache.delete(cacheKey(routerId, 'ipPools')); return res; } catch (e) { logger.error('Error deleting IP pool:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function getQueueTypes(routerId = null) { let conn = null; try { conn = await getConnection(routerId); const rows = await conn.api.send(['/queue/type/print', '=.proplist=name']); return (Array.isArray(rows) ? rows : []).map(augmentRow).map((r) => r.name).filter(Boolean); } catch (e) { logger.error('Error getting queue types:', e); return []; } finally { if (conn && conn.api) conn.api.close(); } } async function getQueueParents(routerId = null) { let conn = null; const out = []; try { conn = await getConnection(routerId); try { const s1 = await conn.api.send(['/queue/simple/print', '=.proplist=name']); (Array.isArray(s1) ? s1 : []).map(augmentRow).forEach((r) => { if (r.name) out.push(r.name); }); } catch (e) {} try { const t1 = await conn.api.send(['/queue/tree/print', '=.proplist=name']); (Array.isArray(t1) ? t1 : []).map(augmentRow).forEach((r) => { if (r.name) out.push(r.name); }); } catch (e) {} try { const i1 = await conn.api.send(['/interface/print', '=.proplist=name']); (Array.isArray(i1) ? i1 : []).map(augmentRow).forEach((r) => { if (r.name) out.push(r.name); }); } catch (e) {} return Array.from(new Set(out)); } catch (e) { logger.error('Error getting queue parents:', e); return []; } finally { if (conn && conn.api) conn.api.close(); } } // PPPoE Profiles CRUD async function addPppoeProfile(data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ppp/profile').add(data); listCache.delete(cacheKey(routerId, 'pppoeProfiles')); return res; } catch (e) { logger.error('Error adding PPPoE profile:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function updatePppoeProfile(id, data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ppp/profile').set(data, id); listCache.delete(cacheKey(routerId, 'pppoeProfiles')); return res; } catch (e) { logger.error('Error updating PPPoE profile:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function deletePppoeProfile(id, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ppp/profile').remove(id); listCache.delete(cacheKey(routerId, 'pppoeProfiles')); return res; } catch (e) { logger.error('Error deleting PPPoE profile:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } // Hotspot Profiles CRUD (User Profiles) async function getHotspotUserProfiles(routerId = null) { const ck = cacheKey(routerId, 'hotspotUserProfiles'); const cached = getCachedList(ck, 15000); if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); const start = Date.now(); // Gunakan client.menu().get() karena dengan routeros-client sudah stabil dan tidak hang const rows = await conn.client.menu('/ip/hotspot/user/profile').get(); const ms = Date.now() - start; if (ms > 1200) logger.warn(`[MikroTik] Slow /ip/hotspot/user/profile.get (${ms}ms)`); setCachedList(ck, rows); return rows; } catch (e) { logger.error('Error getting Hotspot user profiles:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function getHotspotUserProfileById(id, routerId = null) { const rid = String(id || '').trim(); if (!rid) throw new Error('Hotspot profile id is required'); let conn = null; try { conn = await getConnection(routerId); const raw = await conn.api.send([ '/ip/hotspot/user/profile/print', `?.id=${rid}`, '=.proplist=.id,name,rate-limit,shared-users,session-timeout,on-login' ]); const rows = Array.isArray(raw) ? raw.map(augmentRow) : []; return rows.length ? rows[0] : null; } finally { if (conn && conn.api) conn.api.close(); } } async function addHotspotUserProfile(data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const name = String(data?.name || '').trim(); if (!name) throw new Error('Nama profile hotspot wajib diisi'); logger.info(`[MikroTik] add hotspot user profile start name="${name}" routerId=${routerId || ''}`); const menu = conn.client.menu('/ip/hotspot/user/profile'); const payload = { name, 'shared-users': data && data['shared-users'] != null ? data['shared-users'] : 1 }; if (data && data['rate-limit'] != null) { const rl = String(data['rate-limit'] || '').trim(); const first = rl ? rl.split(/\\s+/)[0] : ''; if (first) payload['rate-limit'] = first; } if (data && data['session-timeout'] != null) { const st = String(data['session-timeout'] || '').trim(); const first = st ? st.split(/\\s+/)[0] : ''; if (first) payload['session-timeout'] = first; } // Handle on-login (Mikhmon metadata) if (data && data['on-login'] != null) { const onLogin = String(data['on-login'] || '').trim(); if (onLogin) payload['on-login'] = onLogin; } const op = menu.add(payload); const res = await withTimeout(op, 8000, '/ip/hotspot/user/profile/add').catch(async (err) => { const msg = String(err?.message || err || ''); if (msg.includes('Timeout')) { try { const found = await withTimeout(menu.where('name', name).get(), 4000, '/ip/hotspot/user/profile/print(find-after-timeout)'); if (Array.isArray(found) && found.length) return { timeoutRecovered: true }; } catch {} } try { await conn.api.close(); } catch {} throw err; }); logger.info(`[MikroTik] add hotspot user profile done name="${name}" routerId=${routerId || ''}`); listCache.delete(cacheKey(routerId, 'hotspotUserProfiles')); return res; } catch (e) { logger.error('Error adding Hotspot user profile:', e); throw e; } finally { if (conn && conn.api) { try { await conn.api.close(); } catch {} } } } async function updateHotspotUserProfile(id, data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const safe = {}; if (data && data.name != null && String(data.name).trim()) safe.name = String(data.name).trim(); if (data && data['shared-users'] != null && String(data['shared-users']).trim()) safe['shared-users'] = String(data['shared-users']).trim(); if (data && data['rate-limit'] != null) { const rl = String(data['rate-limit'] || '').trim(); const first = rl ? rl.split(/\\s+/)[0] : ''; if (first) safe['rate-limit'] = first; } if (data && data['session-timeout'] != null) { const st = String(data['session-timeout'] || '').trim(); const first = st ? st.split(/\\s+/)[0] : ''; if (first) safe['session-timeout'] = first; } // Handle on-login (Mikhmon metadata) if (data && data['on-login'] != null) { const onLogin = String(data['on-login'] || '').trim(); if (onLogin) safe['on-login'] = onLogin; } const name = String(safe?.name || '').trim(); logger.info(`[MikroTik] update hotspot user profile start id="${String(id || '')}" name="${name}" routerId=${routerId || ''}`); const op = conn.client.menu('/ip/hotspot/user/profile').set(safe, id); const res = await withTimeout(op, 8000, '/ip/hotspot/user/profile/set').catch(async (err) => { try { await conn.api.close(); } catch {} throw err; }); logger.info(`[MikroTik] update hotspot user profile done id="${String(id || '')}" name="${name}" routerId=${routerId || ''}`); listCache.delete(cacheKey(routerId, 'hotspotUserProfiles')); return res; } catch (e) { logger.error('Error updating Hotspot user profile:', e); throw e; } finally { if (conn && conn.api) { try { await conn.api.close(); } catch {} } } } async function deleteHotspotUserProfile(id, routerId = null) { let conn = null; try { conn = await getConnection(routerId); logger.info(`[MikroTik] delete hotspot user profile start id="${String(id || '')}" routerId=${routerId || ''}`); const op = conn.client.menu('/ip/hotspot/user/profile').remove(id); const res = await withTimeout(op, 8000, '/ip/hotspot/user/profile/remove').catch(async (err) => { try { await conn.api.close(); } catch {} throw err; }); logger.info(`[MikroTik] delete hotspot user profile done id="${String(id || '')}" routerId=${routerId || ''}`); listCache.delete(cacheKey(routerId, 'hotspotUserProfiles')); return res; } catch (e) { logger.error('Error deleting Hotspot user profile:', e); throw e; } finally { if (conn && conn.api) { try { await conn.api.close(); } catch {} } } } async function getHotspotUsers(routerId = null) { const ck = cacheKey(routerId, 'hotspotUsers'); const cached = getCachedList(ck, 10000); // Reduced cache to 10s for more consistent data if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); // Use proplist to only fetch needed fields for better performance let rows; try { rows = await withTimeout( conn.api.send([ '/ip/hotspot/user/print', '=.proplist=.id,name,profile,mac-address,disabled,comment' ]), 20000, // Increased timeout to 20s for very slow routers 'getHotspotUsers' ); } catch (timeoutErr) { // Fallback: try without proplist if timeout occurs logger.warn(`[MikroTik] Timeout with proplist for hotspot users, trying full query: ${timeoutErr.message}`); rows = await withTimeout( conn.client.menu('/ip/hotspot/user').get(), 25000, // 25 second timeout for fallback 'getHotspotUsers-fallback' ); } const mapped = Array.isArray(rows) ? rows.map(augmentRow) : []; setCachedList(ck, mapped); return mapped; } catch (e) { logger.error('Error getting Hotspot users:', e.message); // Return empty array instead of throwing to prevent page crash return []; } finally { if (conn && conn.api) { try { conn.api.close(); } catch (closeErr) { // Ignore close errors } } } } async function addHotspotUser(data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/hotspot/user').add(data); listCache.delete(cacheKey(routerId, 'hotspotUsers')); listCache.delete(cacheKey(routerId, 'hotspotActive')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function updateHotspotUser(id, data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/hotspot/user').set(data, id); listCache.delete(cacheKey(routerId, 'hotspotUsers')); listCache.delete(cacheKey(routerId, 'hotspotActive')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function deleteHotspotUser(id, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/hotspot/user').remove(id); listCache.delete(cacheKey(routerId, 'hotspotUsers')); listCache.delete(cacheKey(routerId, 'hotspotActive')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function getHotspotUserByName(username, routerId = null) { const normalizedUsername = String(username || '').trim(); if (!normalizedUsername) throw new Error('Hotspot username wajib diisi'); let conn = null; try { conn = await getConnection(routerId); const rows = await conn.client.menu('/ip/hotspot/user').where('name', normalizedUsername).get(); if (!Array.isArray(rows) || rows.length === 0) return null; const row = rows[0] || null; if (!row) return null; return { ...row, id: row.id || row['.id'] }; } finally { if (conn && conn.api) conn.api.close(); } } async function setHotspotUserDisabled(username, disabled, routerId = null) { const normalizedUsername = String(username || '').trim(); if (!normalizedUsername) throw new Error('Hotspot username wajib diisi'); let conn = null; try { conn = await getConnection(routerId); const userMenu = conn.client.menu('/ip/hotspot/user'); const rows = await userMenu.where('name', normalizedUsername).get(); if (!Array.isArray(rows) || rows.length === 0) throw new Error(`Hotspot user "${normalizedUsername}" tidak ditemukan di MikroTik`); const row = rows[0] || null; const id = row ? (row['.id'] || row.id) : null; if (!id) throw new Error('ID hotspot user tidak ditemukan'); await userMenu.set({ disabled: disabled ? 'true' : 'false' }, id); listCache.delete(cacheKey(routerId, 'hotspotUsers')); if (disabled) { try { const sessions = await conn.client.menu('/ip/hotspot/active').where('user', normalizedUsername).get(); if (Array.isArray(sessions) && sessions.length) { for (const s of sessions) { const sid = s['.id'] || s.id; if (sid) await conn.client.menu('/ip/hotspot/active').remove(sid); } } } catch {} listCache.delete(cacheKey(routerId, 'hotspotActive')); } return true; } finally { if (conn && conn.api) conn.api.close(); } } async function upsertHotspotUser(data, routerId = null) { const username = String(data?.username || '').trim(); if (!username) throw new Error('Hotspot username wajib diisi'); const password = data?.password != null ? String(data.password) : ''; const profile = data?.profile != null ? String(data.profile).trim() : ''; const macAddress = data?.macAddress != null ? String(data.macAddress).trim() : ''; const disabled = data?.disabled != null ? !!data.disabled : false; let conn = null; try { conn = await getConnection(routerId); const userMenu = conn.client.menu('/ip/hotspot/user'); const existing = await userMenu.where('name', username).get(); const row = Array.isArray(existing) && existing.length ? existing[0] : null; const id = row ? (row['.id'] || row.id) : null; const payload = {}; if (!id) payload.name = username; if (password) payload.password = password; if (profile) payload.profile = profile; if (macAddress) payload['mac-address'] = macAddress; payload.disabled = disabled ? 'true' : 'false'; if (id) { await userMenu.set(payload, id); } else { const addPayload = { ...payload, name: username }; if (!addPayload.password) addPayload.password = username; await userMenu.add(addPayload); } listCache.delete(cacheKey(routerId, 'hotspotUsers')); if (disabled) { try { const sessions = await conn.client.menu('/ip/hotspot/active').where('user', username).get(); if (Array.isArray(sessions) && sessions.length) { for (const s of sessions) { const sid = s['.id'] || s.id; if (sid) await conn.client.menu('/ip/hotspot/active').remove(sid); } } } catch {} listCache.delete(cacheKey(routerId, 'hotspotActive')); } return { ok: true, updated: !!id }; } finally { if (conn && conn.api) conn.api.close(); } } async function getBackup(routerId = null) { let conn = null; try { conn = await getConnection(routerId); const result = await conn.client.menu('/').exec('export'); return result; } catch (e) { logger.error('Error exporting MikroTik config:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function getSystemScripts(routerId = null) { let conn = null; try { conn = await getConnection(routerId); return await conn.client.menu('/system/script').get(); } catch (e) { logger.error('Error getting MikroTik system scripts:', e); return []; } finally { if (conn && conn.api) conn.api.close(); } } async function getSystemResource(routerId = null) { let conn = null; try { conn = await getConnection(routerId); const result = await conn.client.menu('/system/resource').get(); return result[0]; } catch (e) { logger.error('Error getting MikroTik system resource:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function getHotspotProfiles(routerId = null) { const ck = cacheKey(routerId, 'hotspotProfiles'); const cached = getCachedList(ck, 30000); if (cached) return cached; let conn = null; try { conn = await getConnection(routerId); const rows = await conn.client.menu('/ip/hotspot/profile').get(); setCachedList(ck, rows); return rows; } catch (e) { logger.error('Error getting Hotspot profiles:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function addHotspotProfile(data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/hotspot/profile').add(data); listCache.delete(cacheKey(routerId, 'hotspotProfiles')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function updateHotspotProfile(id, data, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/hotspot/profile').set(data, id); listCache.delete(cacheKey(routerId, 'hotspotProfiles')); return res; } finally { if (conn && conn.api) conn.api.close(); } } async function deleteHotspotProfile(id, routerId = null) { let conn = null; try { conn = await getConnection(routerId); const res = await conn.client.menu('/ip/hotspot/profile').remove(id); listCache.delete(cacheKey(routerId, 'hotspotProfiles')); return res; } finally { if (conn && conn.api) conn.api.close(); } } // Router CRUD Services function getAllRouters() { return db.prepare('SELECT * FROM routers ORDER BY name ASC').all(); } function getRouterById(id) { return db.prepare('SELECT * FROM routers WHERE id = ?').get(id); } function createRouter(data) { return db.prepare(` INSERT INTO routers (name, host, port, user, password, description, is_active) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(data.name, data.host, data.port || 8728, data.user, data.password, data.description || '', data.is_active || 1); } function updateRouter(id, data) { return db.prepare(` UPDATE routers SET name=?, host=?, port=?, user=?, password=?, description=?, is_active=? WHERE id=? `).run(data.name, data.host, data.port || 8728, data.user, data.password, data.description || '', data.is_active || 1, id); } function deleteRouter(id) { return db.prepare('DELETE FROM routers WHERE id = ?').run(id); } /** * RouterOS (.rsc) untuk mengarahkan pelanggan di address-list LIST_ISOLIR ke portal billing * (HTTP/HTTPS ke IP server sesuai Pengaturan → app_url). Salin ke Terminal / Import. * PPPoE: set profil isolir on-up agar IP masuk LIST_ISOLIR (sama seperti tombol Setup Firewall di panel). */ async function generateIsolirPortalScript() { const settings = getSettingsWithCache(); const raw = String(settings.app_url || '').trim(); const normalized = raw && /^https?:\/\//i.test(raw) ? raw : (raw ? `https://${raw}` : ''); let hostname = ''; let port = 443; let isHttps = true; try { const u = new URL(normalized || 'http://127.0.0.1:4555'); hostname = u.hostname; port = u.port ? parseInt(u.port, 10) : (u.protocol === 'https:' ? 443 : 80); isHttps = u.protocol === 'https:'; } catch { hostname = 'GANTI-host-portal-billing'; port = 4555; isHttps = false; } let billingIp = hostname; if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) { try { billingIp = await resolveIpv4(hostname); } catch { billingIp = 'GANTI_IP_SERVER_PORTAL'; } } const httpServicePort = isHttps ? 80 : port; const httpsServicePort = isHttps ? port : 443; const isolirRedirectPort = String(settings.isolir_redirect_port || 8099); const lines = [ '# ============================================================', '# Script halaman isolir / portal penagihan (generate Billing)', `# Sumber URL: ${normalized || '(atur app_url di Pengaturan)'}`, `# Host: ${hostname} → IP NAT: ${billingIp}`, '# Address-list: LIST_ISOLIR — saat isolir, billing memasang on-up di profil PPPoE (nama = isolir_profile pelanggan).', '# Hapus rule lama dengan comment=BILLING_ISOLIR_* sebelum import ulang.', '# ============================================================', '', '# --- DNS ---', '/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=udp dst-port=53 action=accept comment="BILLING_ISOLIR_DNS"', '/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=53 action=accept comment="BILLING_ISOLIR_DNS_TCP"', '', '# --- Izinkan akses ke server portal billing ---', '/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR dst-address-list=BILLING action=accept comment="BILLING_ISOLIR_ALLOW"', '', '# --- Walled garden WhatsApp (agar pelanggan isolir tetap bisa terima OTP WhatsApp) ---', '/ip firewall address-list add list=WHATSAPP address=57.144.0.0/14 comment="Meta/WhatsApp" disabled=no', '/ip firewall address-list add list=WHATSAPP address=157.240.0.0/16 comment="Meta/WhatsApp" disabled=no', '/ip firewall address-list add list=WHATSAPP address=31.13.24.0/21 comment="Meta/WhatsApp" disabled=no', '/ip firewall address-list add list=WHATSAPP address=31.13.64.0/18 comment="Meta/WhatsApp" disabled=no', '/ip firewall address-list add list=WHATSAPP address=129.134.0.0/16 comment="Meta/WhatsApp" disabled=no', '/ip firewall address-list add list=WHATSAPP address=173.252.64.0/18 comment="Meta/WhatsApp" disabled=no', '/ip firewall address-list add list=WHATSAPP address=179.60.192.0/22 comment="Meta/WhatsApp" disabled=no', '/ip firewall address-list add list=WHATSAPP address=185.60.216.0/22 comment="Meta/WhatsApp" disabled=no', '/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR dst-address-list=WHATSAPP action=accept comment="BILLING_ISOLIR_WHATSAPP"', '', '# --- (Gateway legacy Tripay/Midtrans/Xendit/Duitku dihapus; pembayaran kini Mayar / QRIS statis) ---', '', '# --- NAT: HTTP/HTTPS pelanggan isolir dialihkan ke redirector (302 -> /isolated) ---', '# dst-address-list=!BILLING agar trafik ke server portal tidak ikut di-NAT (cegah loop).', `/ip firewall nat add chain=dstnat protocol=tcp dst-port=80 src-address-list=LIST_ISOLIR dst-address-list=!BILLING action=dst-nat to-addresses=${billingIp} to-ports=${isolirRedirectPort} comment="BILLING_ISOLIR_HTTP"`, '', `/ip firewall nat add chain=dstnat protocol=tcp dst-port=443 src-address-list=LIST_ISOLIR dst-address-list=!BILLING action=dst-nat to-addresses=${billingIp} to-ports=${isolirRedirectPort} comment="BILLING_ISOLIR_HTTPS"`, '', '# --- Blokir sisa traffic forward dari pelanggan terisolir (opsional; sesuaikan urutan) ---', '/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR action=drop comment="BILLING_ISOLIR_BLOCK_REST" disabled=yes', '', '# --- PPPoE: contoh memasukkan IP ke LIST_ISOLIR saat login (nama profil = isolir) ---', '# Jalankan sekali, atau salin ke on-up profil isolir di Winbox:', '# /ppp profile set [find name=isolir] on-up="/ip firewall address-list add list=LIST_ISOLIR address=$remote-address comment=$user timeout=23h"', '', ]; return { script: lines.join('\n'), appUrl: normalized || raw, billingHost: hostname, billingIp, httpNatPort: httpServicePort, httpsNatPort: httpsServicePort, }; } const ISOLIR_ADDR_LIST = 'LIST_ISOLIR'; /** Nama profil PPPoE isolir yang dipakai pelanggan di router ini (distinct dari DB). */ function getDistinctIsolirProfilesForRouter(routerId) { const rid = Number(routerId); if (!Number.isFinite(rid) || rid <= 0) return ['isolir']; const rows = db.prepare(` SELECT DISTINCT TRIM(COALESCE(isolir_profile, '')) AS n FROM customers WHERE router_id IS ? AND TRIM(COALESCE(pppoe_username, '')) != '' `).all(rid); const names = new Set(); for (const r of rows || []) { const n = String(r.n || '').trim(); names.add(n || 'isolir'); } if (names.size === 0) names.add('isolir'); return [...names]; } /** * Pasang on-up / on-down di profil PPPoE (mis. isolir) agar IP pelanggan masuk address-list LIST_ISOLIR * saat login — supaya NAT/firewall "halaman isolir" berlaku untuk trafik internet mereka. * @param {object|null} reuseConn - hasil getConnection() jika sudah terbuka (mis. dari setupIsolirFirewall). */ async function ensurePppProfileIsolirAddressListHook(profileName, routerId = null, reuseConn = null) { const name = String(profileName || 'isolir').trim() || 'isolir'; let conn = reuseConn; let ownConn = false; try { if (!conn) { conn = await getConnection(routerId); ownConn = true; } const menu = conn.client.menu('/ppp/profile'); const rows = await menu.get(); const list = Array.isArray(rows) ? rows : []; const prof = list.find((r) => String(r.name || '') === name); if (!prof) { const msg = `Profil PPPoE "${name}" tidak ada di router (buat profil isolir di MikroTik atau samakan nama dengan isolir_profile pelanggan).`; logger.warn(`[MikroTik] ${msg}`); return { ok: false, profile: name, message: msg }; } const id = prof['.id'] || prof.id; if (!id) { return { ok: false, profile: name, message: 'ID profil tidak ditemukan' }; } let onUp = prof['on-up'] != null ? String(prof['on-up']) : (prof.onUp != null ? String(prof.onUp) : ''); let onDown = prof['on-down'] != null ? String(prof['on-down']) : (prof.onDown != null ? String(prof.onDown) : ''); onUp = onUp.trim(); onDown = onDown.trim(); const hookUp = `/ip firewall address-list remove [find list=${ISOLIR_ADDR_LIST} address=$remote-address]; ` + `/ip firewall address-list add list=${ISOLIR_ADDR_LIST} address=$remote-address comment=$user timeout=23h`; const hookDown = `/ip firewall address-list remove [find list=${ISOLIR_ADDR_LIST} address=$remote-address]`; const addSnip = `address-list add list=${ISOLIR_ADDR_LIST}`; const remSnip = `remove [find list=${ISOLIR_ADDR_LIST}`; if (!onUp.includes(addSnip)) { onUp = onUp ? `${onUp}; ${hookUp}` : hookUp; } if (!onDown.includes(remSnip)) { onDown = onDown ? `${onDown}; ${hookDown}` : hookDown; } await menu.set({ 'on-up': onUp, 'on-down': onDown }, id); logger.info(`[MikroTik] Profil PPPoE "${name}": on-up/on-down diset untuk ${ISOLIR_ADDR_LIST} (isolir portal).`); return { ok: true, profile: name, message: `Profil "${name}" memasukkan IP ke ${ISOLIR_ADDR_LIST} saat PPP login.` }; } catch (e) { logger.error(`[MikroTik] ensurePppProfileIsolirAddressListHook(${name}):`, e); return { ok: false, profile: name, message: e.message || String(e) }; } finally { if (ownConn && conn && conn.api) conn.api.close(); } } // --- FIREWALL & ISOLIR STATIC IP --- async function setupIsolirFirewall(routerId = null) { let conn = null; try { conn = await getConnection(routerId); const settings = getSettingsWithCache(); /** IP portal (app_url) untuk rule allow/NAT — pelanggan isolir tetap bisa DNS + ke server billing saja */ let billingIp = ''; let httpNatPort = 80; let httpsNatPort = 443; try { const raw = String(settings.app_url || '').trim(); const normalized = raw && /^https?:\/\//i.test(raw) ? raw : (raw ? `https://${raw}` : ''); const u = new URL(normalized || 'http://127.0.0.1:4555'); const isHttps = u.protocol === 'https:'; const port = u.port ? parseInt(u.port, 10) : (isHttps ? 443 : 80); httpNatPort = isHttps ? 80 : port; httpsNatPort = isHttps ? port : 443; let host = u.hostname; if (host && !/^(\d{1,3}\.){3}\d{1,3}$/.test(host)) host = await resolveIpv4(host); billingIp = host || ''; } catch (e) { logger.warn('[setupIsolirFirewall] app_url tidak valid / tidak bisa di-resolve:', e.message); } // 1. NAT HTTP/HTTPS untuk mengarahkan pelanggan isolir ke portal billing const natMenu = conn.client.menu('/ip/firewall/nat'); const ensureNat = async (comment, dstPort, toPort) => { if (!billingIp) return; const desired = { chain: 'dstnat', 'src-address-list': 'LIST_ISOLIR', 'dst-address-list': '!BILLING', protocol: 'tcp', 'dst-port': String(dstPort), action: 'dst-nat', 'to-addresses': billingIp, 'to-ports': String(toPort), comment, }; const ex = await natMenu.where('comment', comment).get(); if (ex && ex.length > 0) { const id = ex[0]['.id'] || ex[0].id; await natMenu.set(desired, id); return; } if (comment === 'BILLING_API_ISOLIR_HTTP') { const legacy = await natMenu.where('comment', 'ISOLIR_REDIRECT').get(); if (legacy && legacy.length > 0) { const id = legacy[0]['.id'] || legacy[0].id; await natMenu.set(desired, id); return; } } await natMenu.add(desired); }; const isolirRedirectPort = String(settings.isolir_redirect_port || 8099); await ensureNat('BILLING_API_ISOLIR_HTTP', 80, isolirRedirectPort); await ensureNat('BILLING_API_ISOLIR_HTTPS', 443, isolirRedirectPort); const filterMenu = conn.client.menu('/ip/firewall/filter'); let blockRows = await filterMenu.where('comment', 'BLOCK_ISOLIR').get(); let blockId = blockRows[0] ? (blockRows[0]['.id'] || blockRows[0].id) : null; if (!blockId) { await filterMenu.add({ chain: 'forward', 'src-address-list': 'LIST_ISOLIR', action: 'drop', comment: 'BLOCK_ISOLIR', }); blockRows = await filterMenu.where('comment', 'BLOCK_ISOLIR').get(); blockId = blockRows[0] ? (blockRows[0]['.id'] || blockRows[0].id) : null; } const insertBeforeBlock = async (comment, fields) => { if (!blockId) return; const ex = await filterMenu.where('comment', comment).get(); if (ex && ex.length > 0) return; await filterMenu.add({ ...fields, comment, 'place-before': blockId }); }; await insertBeforeBlock('BILLING_API_ISOLIR_DNS', { chain: 'forward', 'src-address-list': 'LIST_ISOLIR', protocol: 'udp', 'dst-port': '53', action: 'accept', }); await insertBeforeBlock('BILLING_API_ISOLIR_DNS_TCP', { chain: 'forward', 'src-address-list': 'LIST_ISOLIR', protocol: 'tcp', 'dst-port': '53', action: 'accept', }); if (billingIp) { await insertBeforeBlock('BILLING_API_ISOLIR_ALLOW', { chain: 'forward', 'src-address-list': 'LIST_ISOLIR', 'dst-address-list': 'BILLING', action: 'accept', }); } const hookResults = []; for (const pname of getDistinctIsolirProfilesForRouter(routerId)) { hookResults.push(await ensurePppProfileIsolirAddressListHook(pname, routerId, conn)); } const okNames = hookResults.filter((h) => h.ok).map((h) => h.profile).join(', '); const bad = hookResults.filter((h) => !h.ok); const warn = bad.length ? ` Perhatian: ${bad.map((h) => `${h.profile} (${h.message})`).join('; ')}` : ''; return { success: true, message: `Firewall isolir + NAT siap. Profil PPPoE di-hook ke ${ISOLIR_ADDR_LIST}: ${okNames || '-'}.${warn}`, hooks: hookResults, }; } catch (e) { logger.error('Error setupIsolirFirewall:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function manageStaticIp(data, routerId = null) { const { ip, name, limit, isolate } = data; let conn = null; try { conn = await getConnection(routerId); // 1. Manage Simple Queue for Bandwidth const queueMenu = conn.client.menu('/queue/simple'); const existingQueue = await queueMenu.where('target', `${ip}/32`).get(); const safeName = String(name || '').trim().replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 40) || String(ip || '').trim(); const queueData = { name: `CUST-${safeName}`, target: `${ip}/32`, 'max-limit': limit || '5M/5M', comment: `Managed by Billing - ${String(name || '').trim()}` }; if (existingQueue.length > 0) { await queueMenu.set(queueData, existingQueue[0]['.id']); } else { await queueMenu.add(queueData); } // 2. Manage Address List for Isolation const addrListMenu = conn.client.menu('/ip/firewall/address-list'); const existingEntry = await addrListMenu.where('address', ip).where('list', 'LIST_ISOLIR').get(); if (isolate) { if (existingEntry.length === 0) { await addrListMenu.add({ list: 'LIST_ISOLIR', address: ip, comment: name }); } } else { if (existingEntry.length > 0) { await addrListMenu.remove(existingEntry[0]['.id']); } } return true; } catch (e) { logger.error('Error manageStaticIp:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } async function removeStaticIp(ip, routerId = null) { let conn = null; try { conn = await getConnection(routerId); // Remove Queue const queueMenu = conn.client.menu('/queue/simple'); const queues = await queueMenu.where('target', `${ip}/32`).get(); for (const q of queues) await queueMenu.remove(q['.id']); // Remove from Address List const addrListMenu = conn.client.menu('/ip/firewall/address-list'); const entries = await addrListMenu.where('address', ip).where('list', 'LIST_ISOLIR').get(); for (const e of entries) await addrListMenu.remove(e['.id']); return true; } catch (e) { logger.error('Error removeStaticIp:', e); throw e; } finally { if (conn && conn.api) conn.api.close(); } } function stripAnsi(str) { if (!str || typeof str !== 'string') return ''; return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); } async function getLiveMikrotikExportViaSsh(host, user, password, port = 22, timeoutMs = 30000) { const { Client } = require('ssh2'); return new Promise((resolve, reject) => { const conn = new Client(); let output = ''; let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; try { conn.end(); } catch {} reject(new Error(`Timeout (${timeoutMs}ms) saat mengambil export langsung dari MikroTik`)); } }, timeoutMs); conn.on('ready', () => { conn.exec('/export compact', (err, stream) => { if (err) { if (!settled) { settled = true; clearTimeout(timer); try { conn.end(); } catch {} reject(err); } return; } stream.on('data', (data) => { output += data.toString('utf8'); }); stream.stderr.on('data', (data) => { output += data.toString('utf8'); }); stream.on('close', (code) => { if (!settled) { settled = true; clearTimeout(timer); try { conn.end(); } catch {} const cleaned = stripAnsi(output).trim(); if (cleaned.length > 200) { resolve(cleaned); } else { reject(new Error('Hasil export MikroTik terlalu pendek atau kosong')); } } }); }); }); conn.on('error', (err) => { if (!settled) { settled = true; clearTimeout(timer); try { conn.end(); } catch {} reject(err); } }); conn.connect({ host, port: Number(port) || 22, username: user, password, readyTimeout: 10000, keepaliveInterval: 5000 }); }); } async function getBackup(routerId = null) { try { const rid = (routerId && routerId !== 'all') ? String(routerId) : null; let router = null; if (rid) { router = db.prepare('SELECT * FROM routers WHERE id = ?').get(rid); } if (!router) { router = db.prepare('SELECT * FROM routers WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get(); } const routerName = router ? router.name : 'MikroTik'; const routerHost = router ? router.host : '192.168.8.1'; const routerUser = router ? router.user : 'billing'; const routerPass = router ? router.password : '060111'; const sshPort = Number(router?.ssh_port || 22); // 1. PRIORITAS UTAMA: Tarik LIVE /export compact langsung dari console MikroTik via SSH try { logger.info(`[getBackup] Mencoba menarik LIVE backup RSC langsung dari MikroTik (${routerHost}:${sshPort})...`); const liveRsc = await getLiveMikrotikExportViaSsh(routerHost, routerUser, routerPass, sshPort, 25000); if (liveRsc && liveRsc.length > 300) { logger.info(`[getBackup] Berhasil menarik LIVE backup RSC langsung dari MikroTik (${liveRsc.length} bytes).`); return liveRsc; } } catch (sshErr) { logger.warn(`[getBackup] Live export via SSH tidak aktif atau gagal (${sshErr.message}). Beralih ke fallback file tersimpan / database...`); } // 2. FALLBACK KEDUA: Jika router 1 (atau default) dan file hasil kurasi ROS7 tersedia di server const ros7RscPath = path.join(__dirname, '..', 'backup_rb4011_ros7_ready.rsc'); const baseRscPath = path.join(__dirname, '..', 'backup_rb4011.rsc'); if ((!rid || rid === '1') && fs.existsSync(ros7RscPath)) { const savedContent = fs.readFileSync(ros7RscPath, 'utf8'); if (savedContent && savedContent.length > 500) { return savedContent; } } else if ((!rid || rid === '1') && fs.existsSync(baseRscPath)) { const savedContent = fs.readFileSync(baseRscPath, 'utf8'); if (savedContent && savedContent.length > 500) { return savedContent; } } // 3. FALLBACK KETIGA: Generate dinamis dari Database Billing (IP Pools, Secrets, Profiles, Queues, Isolir) const nowStr = new Date().toISOString().replace('T', ' ').slice(0, 19); const rscLines = []; rscLines.push(`# ===================================================`); rscLines.push(`# OPEN-ISP MIKROTIK RSC BACKUP EXPORT`); rscLines.push(`# Router : ${routerName} (${routerHost})`); rscLines.push(`# Tanggal : ${nowStr}`); rscLines.push(`# Format : RouterOS Script (.rsc) ROS7 Ready`); rscLines.push(`# Generator: Billing RTRW System`); rscLines.push(`# ===================================================\n`); // 1. IP Pools rscLines.push(`/ip pool`); rscLines.push(`add name=pool-pppoe ranges=192.168.10.10-192.168.10.250`); rscLines.push(`add name=pool-hotspot ranges=192.168.100.10-192.168.100.250`); rscLines.push(`add name=isolir ranges=192.168.205.10-192.168.205.250\n`); // 2. PPP Profiles dari packages const packages = db.prepare('SELECT * FROM packages WHERE is_active = 1').all(); rscLines.push(`/ppp profile`); rscLines.push(`add local-address=192.168.205.1 name=isolir rate-limit=2k/2k remote-address=isolir`); for (const pkg of packages) { const up = Number(pkg.speed_up || 0) || 0; const down = Number(pkg.speed_down || 0) || 0; const rateLimit = (up > 0 && down > 0) ? `${Math.round(up/1000)}M/${Math.round(down/1000)}M` : '5M/5M'; rscLines.push(`add local-address=192.168.10.1 name="${pkg.name}" rate-limit="${rateLimit}" remote-address=pool-pppoe`); } rscLines.push(''); // 3. PPP Secrets dari database customers const pppCustomers = db.prepare("SELECT * FROM customers WHERE connection_type = 'pppoe' AND pppoe_username IS NOT NULL AND pppoe_username != ''").all(); if (pppCustomers.length > 0) { rscLines.push(`/ppp secret`); for (const c of pppCustomers) { const pkg = packages.find(p => p.id === c.package_id); const profile = (c.status === 'suspended' || c.status === 'isolated') ? (c.isolir_profile || 'isolir') : (pkg ? pkg.name : 'default'); const pass = c.pppoe_password || c.pppoe_username; let parts = [`add name="${c.pppoe_username}" password="${pass}" profile="${profile}" service=pppoe`]; if (c.name) parts.push(`comment="${c.name.replace(/"/g, '')} - ${c.phone || ''}"`); if (c.status === 'inactive') parts.push(`disabled=yes`); rscLines.push(parts.join(' ')); } rscLines.push(''); } // 4. Hotspot Profiles & Users rscLines.push(`/ip hotspot user profile`); rscLines.push(`add name=default shared-users=1`); for (const pkg of packages) { const up = Number(pkg.speed_up || 0) || 0; const down = Number(pkg.speed_down || 0) || 0; const rateLimit = (up > 0 && down > 0) ? `${Math.round(up/1000)}M/${Math.round(down/1000)}M` : '5M/5M'; rscLines.push(`add name="${pkg.name}" rate-limit="${rateLimit}" shared-users=1`); } rscLines.push(''); const hotspotCustomers = db.prepare("SELECT * FROM customers WHERE connection_type = 'hotspot' AND hotspot_username IS NOT NULL AND hotspot_username != ''").all(); if (hotspotCustomers.length > 0) { rscLines.push(`/ip hotspot user`); for (const c of hotspotCustomers) { const pkg = packages.find(p => p.id === c.package_id); const profile = pkg ? pkg.name : 'default'; const pass = c.hotspot_password || c.hotspot_username; rscLines.push(`add name="${c.hotspot_username}" password="${pass}" profile="${profile}" comment="${c.name.replace(/"/g, '')}"`); } rscLines.push(''); } // 5. Static IP Queues const staticCustomers = db.prepare("SELECT * FROM customers WHERE connection_type = 'static' AND static_ip IS NOT NULL AND static_ip != ''").all(); if (staticCustomers.length > 0) { rscLines.push(`/queue simple`); for (const c of staticCustomers) { const pkg = packages.find(p => p.id === c.package_id); const up = Number(pkg?.speed_up || 0) || 0; const down = Number(pkg?.speed_down || 0) || 0; const rateLimit = (up > 0 && down > 0) ? `${Math.round(up/1000)}M/${Math.round(down/1000)}M` : '5M/5M'; rscLines.push(`add name="QUEUE_${c.name.replace(/"/g, '')}" target="${c.static_ip}/32" max-limit="${rateLimit}" comment="${c.name.replace(/"/g, '')}"`); } rscLines.push(''); } // 6. Routing & Isolir Rules (ROS7 Ready) rscLines.push(`/routing table\nadd disabled=no fib name=isolir\n`); rscLines.push(`/ip route rule\nadd src-address=192.168.205.0/24 table=isolir\n`); rscLines.push(`/ip firewall nat\nadd action=masquerade chain=srcnat comment="NAT MASQUERADE DEFAULT" out-interface-list=WAN\n`); return rscLines.join('\r\n'); } catch (err) { logger.error(`[getBackup] Error generating backup: ${err.message}`); return `# Gagal generate backup RSC: ${err.message}\r\n`; } } module.exports = { checkConnection, getConnection, getPppoeProfiles, getPppoeUsers, setPppoeProfile, getPppoeSecrets, addPppoeSecret, createPppoeSecret, updatePppoeSecret, deletePppoeSecret, getHotspotUsers, addHotspotUser, updateHotspotUser, deleteHotspotUser, getHotspotUserByName, setHotspotUserDisabled, upsertHotspotUser, getHotspotProfiles, getPppoeActive, getActivePppoeSessionsMap, getAllActiveSessionsMap, getHotspotActive, getIpPools, getQueueTypes, getQueueParents, addIpPool, updateIpPool, deleteIpPool, addPppoeProfile, updatePppoeProfile, deletePppoeProfile, getHotspotUserProfiles, getHotspotUserProfileById, addHotspotUserProfile, updateHotspotUserProfile, deleteHotspotUserProfile, getBackup, kickPppoeUser, kickHotspotUser, getSystemIdentity, getSystemResource, getSystemScripts, getAllRouters, getRouterById, createRouter, updateRouter, deleteRouter, setupIsolirFirewall, ensurePppProfileIsolirAddressListHook, generateIsolirPortalScript, manageStaticIp, removeStaticIp };