Files
open-isp/services/mikrotikService.js
T

2185 lines
78 KiB
JavaScript

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: OPEN-ISP Billing 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
};