Files
open-isp/services/radiusServerService.js
T
oxpoe eaf05f2811 OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor,
admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS.

Kredit & lisensi (ISC):
- Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak
  atas base kode dan repo yang bermanfaat.
- Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder.
- Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
2026-10-09 05:51:21 +07:00

813 lines
29 KiB
JavaScript

/**
* Service RADIUS Server (Authentication & Accounting UDP Service)
* Menggunakan SQLite database billing.db & modul radiusPacket
*/
const dgram = require('dgram');
const db = require('../config/database');
const { getSetting } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const {
CODES,
ATTR_TYPES,
MIKROTIK_VENDOR_ID,
MIKROTIK_VSAS,
decodePacket,
encodeResponsePacket,
verifyChapPassword
} = require('../utils/radiusPacket');
let authSocket = null;
let acctSocket = null;
let isRunning = false;
/**
* Mencari Secret NAS berdasarkan IP NAS
*/
function getNasSecret(nasIp) {
const defaultSecret = getSetting('radius_secret', 'secret123');
const cleanIp = String(nasIp || '').replace(/^::ffff:/, '').trim();
try {
const nasRow = db.prepare(`
SELECT secret FROM radius_nas
WHERE is_active = 1 AND (nasname = ? OR nasname = '0.0.0.0' OR nasname = '0.0.0.0/0')
ORDER BY CASE WHEN nasname = ? THEN 1 ELSE 2 END ASC, id DESC LIMIT 1
`).get(cleanIp, cleanIp);
if (nasRow && nasRow.secret) {
return nasRow.secret;
}
} catch (err) {
logger.error(`[RADIUS] Error getNasSecret: ${err.message}`);
}
return defaultSecret;
}
/**
* Mencari data pelanggan / pengguna dari SQLite DB
*/
function findUserCredentials(username) {
const cleanUsername = String(username || '').trim();
if (!cleanUsername) return null;
// 1. Cek tabel customers (pppoe_username atau name atau phone)
try {
const cust = db.prepare(`
SELECT c.id, c.name, c.pppoe_username, c.pppoe_password, c.hotspot_username, c.hotspot_password,
c.status, c.static_ip, c.pppoe_remote_address, c.package_id,
p.name as package_name, p.speed_up, p.speed_down, p.speed_up_upto, p.speed_down_upto, p.pool_name as pool_name
FROM customers c
LEFT JOIN packages p ON p.id = c.package_id
WHERE c.pppoe_username = ? OR c.hotspot_username = ? OR c.name = ? OR c.phone = ?
ORDER BY
CASE
WHEN c.pppoe_username = ? THEN 1
WHEN c.hotspot_username = ? THEN 2
ELSE 3
END ASC
LIMIT 1
`).get(cleanUsername, cleanUsername, cleanUsername, cleanUsername, cleanUsername, cleanUsername);
if (cust) {
// Pilih secret sesuai jenis username (PPPoE/Hotspot), fallback pppoe_users (kompat)
let secret = '';
if (cust.pppoe_username === cleanUsername) {
secret = cust.pppoe_password || '';
} else if (cust.hotspot_username === cleanUsername) {
secret = cust.hotspot_password || '';
} else {
secret = cust.pppoe_password || cust.hotspot_password || '';
}
if (!secret) {
try {
const pppoeUser = db.prepare(`SELECT secret FROM pppoe_users WHERE username = ? LIMIT 1`).get(cleanUsername);
if (pppoeUser) secret = pppoeUser.secret || '';
} catch (e) {}
}
const staticIp = (cust.static_ip && String(cust.static_ip).trim()) || (cust.pppoe_remote_address && String(cust.pppoe_remote_address).trim()) || '';
return {
type: 'customer',
id: cust.id,
username: cleanUsername,
secret: secret,
status: cust.status,
staticIp: staticIp,
speedUp: cust.speed_up || 0,
speedDown: cust.speed_down || 0,
speedUpUpto: cust.speed_up_upto || 0,
speedDownUpto: cust.speed_down_upto || 0,
packageName: cust.package_name || '',
poolName: cust.pool_name || ''
};
}
} catch (err) {
logger.error(`[RADIUS] Error findUserCredentials customers: ${err.message}`);
}
// 2. Cek tabel pppoe_users (jika ada)
try {
const pppoe = db.prepare(`
SELECT pu.id, pu.customer_id, pu.username, pu.secret, pu.status, pu.profile_name,
c.status as customer_status, c.static_ip,
p.speed_up, p.speed_down, p.speed_up_upto, p.speed_down_upto
FROM pppoe_users pu
LEFT JOIN customers c ON c.id = pu.customer_id
LEFT JOIN packages p ON p.id = c.package_id
WHERE pu.username = ? LIMIT 1
`).get(cleanUsername);
if (pppoe) {
const finalStatus = (pppoe.customer_status === 'suspended' || pppoe.status === 'disabled') ? 'suspended' : 'active';
return {
type: 'pppoe',
id: pppoe.id,
username: pppoe.username,
secret: pppoe.secret,
status: finalStatus,
staticIp: pppoe.static_ip,
speedUp: pppoe.speed_up || 0,
speedDown: pppoe.speed_down || 0,
speedUpUpto: pppoe.speed_up_upto || 0,
speedDownUpto: pppoe.speed_down_upto || 0,
packageName: pppoe.profile_name || ''
};
}
} catch (err) {
// Tabel pppoe_users mungkin tidak ada di skema tertentu
}
// 3. Cek tabel vouchers (Voucher Hotspot/PPPoE)
try {
const voucher = db.prepare(`
SELECT code, password, profile_name, status FROM vouchers WHERE code = ? LIMIT 1
`).get(cleanUsername);
if (voucher) {
return {
type: 'voucher',
id: voucher.code,
username: voucher.code,
secret: voucher.password,
status: voucher.status === 'used' || voucher.status === 'expired' ? 'suspended' : 'active',
speedUp: 0,
speedDown: 0,
speedUpUpto: 0,
speedDownUpto: 0,
packageName: voucher.profile_name || ''
};
}
} catch (err) {
logger.error(`[RADIUS] Error findUserCredentials vouchers: ${err.message}`);
}
return null;
}
/**
* Memproses RADIUS Access-Request (Authentication)
*/
function handleAuthMessage(msg, rinfo) {
const nasIp = rinfo.address;
const secret = getNasSecret(nasIp);
let reqPacket;
try {
reqPacket = decodePacket(msg, secret);
} catch (err) {
logger.warn(`[RADIUS Auth] Gagal decode paket dari ${nasIp}:${rinfo.port} - ${err.message}`);
return;
}
if (reqPacket.code !== CODES.ACCESS_REQUEST) {
return;
}
const username = reqPacket.parsedAttrs.username || '';
const inputPassword = reqPacket.parsedAttrs.password || '';
logger.info(`[RADIUS Auth] Request dari NAS ${nasIp} untuk user '${username}'`);
const user = findUserCredentials(username);
if (!user) {
logger.warn(`[RADIUS Auth] Reject '${username}' - User tidak ditemukan`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
// Verifikasi Password (Mendukung PAP dan CHAP RFC 2865)
if (reqPacket.parsedAttrs.chapPassword) {
const challenge = reqPacket.parsedAttrs.chapChallenge || reqPacket.authenticator;
const isChapValid = verifyChapPassword(reqPacket.parsedAttrs.chapPassword, user.secret, challenge);
if (!isChapValid) {
logger.warn(`[RADIUS Auth] Reject '${username}' - Password CHAP salah`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
} else if (reqPacket.parsedAttrs.password !== undefined) {
if (reqPacket.parsedAttrs.password !== user.secret) {
logger.warn(`[RADIUS Auth] Reject '${username}' - Password PAP salah (input: '${reqPacket.parsedAttrs.password}', expected: '${user.secret}')`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
} else {
logger.warn(`[RADIUS Auth] Reject '${username}' - Tidak ada atribut Password (PAP/CHAP) pada request`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
const isolirAction = getSetting('radius_isolir_action', 'pool');
const isolirPool = getSetting('radius_isolir_pool', 'isolir');
// Penanganan Status Terisolir / Non-Aktif
if (user.status === 'suspended' || user.status === 'isolir' || user.status === 'inactive') {
if (isolirAction === 'reject') {
logger.warn(`[RADIUS Auth] Reject '${username}' - Status terisolir`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
} else {
const isolirAttrs = [
{ type: ATTR_TYPES.SERVICE_TYPE, value: 2 },
{ type: ATTR_TYPES.FRAMED_PROTOCOL, value: 1 }
];
const isolirRateLimit = getSetting('radius_isolir_rate_limit', '512k/512k');
const isolirIpPoolEnabled = getSetting('radius_isolir_ip_pool_enabled', '1') === '1';
const isolirIpPoolStart = getSetting('radius_isolir_ip_pool_start', '10.10.99.2');
const isolirIpPoolEnd = getSetting('radius_isolir_ip_pool_end', '10.10.99.254');
let allocatedIsolirIp = null;
if (isolirIpPoolEnabled && isolirIpPoolStart && isolirIpPoolEnd) {
allocatedIsolirIp = allocateDynamicIp(username, isolirIpPoolStart, isolirIpPoolEnd, nasIp);
if (allocatedIsolirIp) {
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_IP_ADDRESS, value: allocatedIsolirIp, isIp: true });
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_IP_NETMASK, value: '255.255.255.255', isIp: true });
logger.info(`[RADIUS Auth] Isolir Dynamic IP '${allocatedIsolirIp}' dialokasikan untuk '${username}'`);
} else if (isolirPool) {
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: isolirPool });
}
} else if (isolirPool) {
isolirAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: isolirPool });
}
if (isolirRateLimit) {
isolirAttrs.push({
type: ATTR_TYPES.VENDOR_SPECIFIC,
vendorId: MIKROTIK_VENDOR_ID,
vendorType: MIKROTIK_VSAS.RATE_LIMIT,
value: isolirRateLimit
});
}
logger.info(`[RADIUS Auth] Accept (Isolir) '${username}' - Terisolir`);
sendAuthResponse(CODES.ACCESS_ACCEPT, reqPacket, isolirAttrs, secret, rinfo);
return;
}
}
// Hapus dummy auth-* session untuk user ini agar re-dial / re-connect tidak terblokir
try {
db.prepare(`
DELETE FROM radius_accounting
WHERE username = ? AND session_id LIKE 'auth-%'
`).run(username);
} catch (e) {}
// 3. Cek Batasan Sesi Login Ganda (Simultaneous-Use / Multi-Login)
const limitSimultaneous = getSetting('radius_limit_simultaneous', '1') === '1';
if (limitSimultaneous) {
const activeSession = db.prepare(`
SELECT COUNT(1) as c FROM radius_accounting
WHERE username = ? AND status_type IN (1, 3)
AND session_id NOT LIKE 'auth-%'
AND updated_at >= datetime('now', '-15 minutes')
`).get(username)?.c || 0;
if (activeSession >= 1) {
logger.warn(`[RADIUS Auth] Reject '${username}' - Sesi aktif ganda terdeteksi (User sudah online)`);
sendAuthResponse(CODES.ACCESS_REJECT, reqPacket, [], secret, rinfo);
return;
}
}
// Status Aktif -> Access-Accept dengan Atribut Kuota/Speed Limit
const responseAttrs = [
{ type: ATTR_TYPES.SERVICE_TYPE, value: 2 },
{ type: ATTR_TYPES.FRAMED_PROTOCOL, value: 1 }
];
// Mode profil MikroTik: RADIUS tidak kirim atribut IP/Rate-Limit (diambil dari profil PPP di router)
const useProfile = getSetting('radius_use_profile', '0') === '1';
// Dynamic IP Pool & Static IP Allocation
const ipPoolEnabled = getSetting('radius_ip_pool_enabled', '1') === '1';
const ipPoolStart = getSetting('radius_ip_pool_start', '10.10.10.2');
const ipPoolEnd = getSetting('radius_ip_pool_end', '10.10.10.254');
const framedPool = getSetting('radius_framed_pool', 'pool-pppoe');
let allocatedIp = null;
if (!useProfile && user.staticIp) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_ADDRESS, value: user.staticIp, isIp: true });
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_NETMASK, value: '255.255.255.255', isIp: true });
} else if (!useProfile && ipPoolEnabled && ipPoolStart && ipPoolEnd) {
allocatedIp = allocateDynamicIp(username, ipPoolStart, ipPoolEnd, nasIp);
if (allocatedIp) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_ADDRESS, value: allocatedIp, isIp: true });
responseAttrs.push({ type: ATTR_TYPES.FRAMED_IP_NETMASK, value: '255.255.255.255', isIp: true });
logger.info(`[RADIUS Auth] Dynamic IP '${allocatedIp}' dialokasikan untuk '${username}'`);
} else if (framedPool) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: framedPool });
}
} else if (!useProfile && user.poolName && String(user.poolName).trim()) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: String(user.poolName).trim() });
logger.info("[RADIUS Auth] Framed-Pool '" + String(user.poolName).trim() + "' (dari paket) untuk '" + username + "'");
} else if (!useProfile && framedPool) {
responseAttrs.push({ type: ATTR_TYPES.FRAMED_POOL, value: framedPool });
}
// Atribut Rate Limit (Mikrotik-Rate-Limit) & Mikrotik-Group (Profile)
const defaultRateLimit = getSetting('radius_default_rate_limit', '5M/10M');
const rateLimitStr = formatRateLimit(user.speedUp, user.speedDown, defaultRateLimit, user.speedUpUpto || 0, user.speedDownUpto || 0);
if (!useProfile && rateLimitStr) {
responseAttrs.push({
type: ATTR_TYPES.VENDOR_SPECIFIC,
vendorId: MIKROTIK_VENDOR_ID,
vendorType: MIKROTIK_VSAS.RATE_LIMIT,
value: rateLimitStr
});
logger.info(`[RADIUS Auth] Rate-limit '${rateLimitStr}' dikirim untuk '${username}'`);
}
// Kirimkan nama paket / profile ke MikroTik via Mikrotik-Group hanya jika diset & valid
const sendGroup = getSetting('radius_send_group', '0') === '1';
if ((useProfile || sendGroup) && user.packageName) {
responseAttrs.push({
type: ATTR_TYPES.VENDOR_SPECIFIC,
vendorId: MIKROTIK_VENDOR_ID,
vendorType: MIKROTIK_VSAS.GROUP,
value: String(user.packageName).trim()
});
}
logger.info(`[RADIUS Auth] Accept '${username}' - Berhasil diautentikasi`);
// Record instant online session entry upon Access-Accept
try {
const authSessionId = reqPacket.parsedAttrs.acctSessionId || `auth-${Date.now()}-${username}`;
db.prepare(`
INSERT INTO radius_accounting (
username, nas_ip, framed_ip, session_id, status_type,
calling_station_id, updated_at
) VALUES (?, ?, ?, ?, 1, ?, NOW_LOCAL())
`).run(
username,
nasIp,
user.staticIp || allocatedIp || '',
authSessionId,
reqPacket.parsedAttrs.callingStationId || ''
);
} catch (e) {}
sendAuthResponse(CODES.ACCESS_ACCEPT, reqPacket, responseAttrs, secret, rinfo);
}
function sendAuthResponse(code, reqPacket, attributes, secret, rinfo) {
try {
const resBuf = encodeResponsePacket({
code,
identifier: reqPacket.identifier,
requestAuthenticator: reqPacket.authenticator,
attributes,
secret
});
authSocket.send(resBuf, rinfo.port, rinfo.address);
} catch (err) {
logger.error(`[RADIUS Auth] Gagal me-reply response: ${err.message}`);
}
}
/**
* Memproses RADIUS Accounting-Request
*/
function handleAcctMessage(msg, rinfo) {
const nasIp = rinfo.address;
const secret = getNasSecret(nasIp);
let reqPacket;
try {
reqPacket = decodePacket(msg, secret);
} catch (err) {
logger.warn(`[RADIUS Acct] Gagal decode paket dari ${nasIp}:${rinfo.port} - ${err.message}`);
return;
}
if (reqPacket.code !== CODES.ACCOUNTING_REQUEST) {
return;
}
const {
username = '',
acctStatusType = 1,
acctSessionId = '',
framedIp = '',
acctInputOctets = 0,
acctOutputOctets = 0,
acctInputGigawords = 0,
acctOutputGigawords = 0,
acctSessionTime = 0,
acctTerminateCause = 0,
callingStationId = '',
calledStationId = ''
} = reqPacket.parsedAttrs;
if (username && acctSessionId) {
try {
let effectiveFramedIp = framedIp;
if (!effectiveFramedIp || effectiveFramedIp === '0.0.0.0') {
const recentAuth = db.prepare(`
SELECT framed_ip FROM radius_accounting
WHERE username = ? AND framed_ip IS NOT NULL AND framed_ip != '' AND framed_ip != '0.0.0.0'
ORDER BY id DESC LIMIT 1
`).get(username);
if (recentAuth && recentAuth.framed_ip) {
effectiveFramedIp = recentAuth.framed_ip;
} else {
const cust = db.prepare(`SELECT static_ip FROM customers WHERE pppoe_username = ? OR name = ? LIMIT 1`).get(username, username);
if (cust && cust.static_ip) {
effectiveFramedIp = cust.static_ip;
}
}
}
// Upsert ke radius_accounting
const stmt = db.prepare(`
INSERT INTO radius_accounting (
username, nas_ip, framed_ip, session_id, status_type,
input_octets, output_octets, input_gigawords, output_gigawords,
session_time, terminate_cause, calling_station_id, called_station_id,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW_LOCAL())
`);
// Cek apakah session_id sudah ada
const existing = db.prepare(`SELECT id FROM radius_accounting WHERE session_id = ? LIMIT 1`).get(acctSessionId);
if (existing) {
db.prepare(`
UPDATE radius_accounting SET
status_type = ?,
framed_ip = COALESCE(NULLIF(?, ''), framed_ip),
input_octets = ?,
output_octets = ?,
input_gigawords = ?,
output_gigawords = ?,
session_time = ?,
terminate_cause = ?,
updated_at = NOW_LOCAL()
WHERE session_id = ?
`).run(
acctStatusType,
effectiveFramedIp,
acctInputOctets,
acctOutputOctets,
acctInputGigawords,
acctOutputGigawords,
acctSessionTime,
acctTerminateCause,
acctSessionId
);
} else {
// Hapus entri auth-* lama untuk user ini ketika sesi accounting asli dimulai
try {
db.prepare(`DELETE FROM radius_accounting WHERE username = ? AND session_id LIKE 'auth-%'`).run(username);
db.prepare(`
UPDATE radius_accounting
SET status_type = 2, terminate_cause = 1, updated_at = NOW_LOCAL()
WHERE username = ? AND session_id != ? AND status_type IN (1, 3)
`).run(username, acctSessionId);
} catch (e) {}
stmt.run(
username, nasIp, effectiveFramedIp, acctSessionId, acctStatusType,
acctInputOctets, acctOutputOctets, acctInputGigawords, acctOutputGigawords,
acctSessionTime, acctTerminateCause, callingStationId, calledStationId
);
}
// Hitung total bytes termasuk Gigawords (untuk sesi > 4GB)
const totalBytesIn = (acctInputGigawords * 4294967296) + acctInputOctets;
const totalBytesOut = (acctOutputGigawords * 4294967296) + acctOutputOctets;
// Catat sampel trafik ke pppoe_traffic_samples jika tabel pppoe_users ada
try {
const pppoeUser = db.prepare(`SELECT id FROM pppoe_users WHERE username = ? LIMIT 1`).get(username);
if (pppoeUser) {
db.prepare(`
INSERT INTO pppoe_traffic_samples (pppoe_user_id, bytes_in, bytes_out)
VALUES (?, ?, ?)
`).run(pppoeUser.id, totalBytesIn, totalBytesOut);
}
} catch (e) {}
// Catat sampel pemakaian ke customer_usage jika terdaftar di customers
try {
const cust = db.prepare(`SELECT id FROM customers WHERE pppoe_username = ? OR name = ? LIMIT 1`).get(username, username);
if (cust) {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
db.prepare(`
INSERT INTO customer_usage (customer_id, period_month, period_year, bytes_in, bytes_out, updated_at)
VALUES (?, ?, ?, ?, ?, NOW_LOCAL())
ON CONFLICT(customer_id, period_month, period_year) DO UPDATE SET
bytes_in = MAX(customer_usage.bytes_in, excluded.bytes_in),
bytes_out = MAX(customer_usage.bytes_out, excluded.bytes_out),
updated_at = NOW_LOCAL()
`).run(cust.id, month, year, totalBytesIn, totalBytesOut);
}
} catch (e) {}
} catch (err) {
logger.error(`[RADIUS Acct] Gagal simpan accounting: ${err.message}`);
}
}
// Kirim Accounting-Response
try {
const resBuf = encodeResponsePacket({
code: CODES.ACCOUNTING_RESPONSE,
identifier: reqPacket.identifier,
requestAuthenticator: reqPacket.authenticator,
attributes: [],
secret
});
acctSocket.send(resBuf, rinfo.port, rinfo.address);
} catch (err) {
logger.error(`[RADIUS Acct] Gagal me-reply Accounting-Response: ${err.message}`);
}
}
/**
* Menjalankan Server RADIUS (UDP Auth & Acct)
*/
function start() {
if (isRunning) return;
const authPort = parseInt(getSetting('radius_auth_port', 1812), 10) || 1812;
const acctPort = parseInt(getSetting('radius_acct_port', 1813), 10) || 1813;
try {
authSocket = dgram.createSocket('udp4');
authSocket.on('message', handleAuthMessage);
authSocket.on('error', (err) => logger.error(`[RADIUS Auth Error] ${err.message}`));
authSocket.bind(authPort, () => {
logger.info(`[RADIUS] Auth Server mendengarkan pada port UDP ${authPort}`);
});
acctSocket = dgram.createSocket('udp4');
acctSocket.on('message', handleAcctMessage);
acctSocket.on('error', (err) => logger.error(`[RADIUS Acct Error] ${err.message}`));
acctSocket.bind(acctPort, () => {
logger.info(`[RADIUS] Accounting Server mendengarkan pada port UDP ${acctPort}`);
});
isRunning = true;
} catch (err) {
logger.error(`[RADIUS] Gagal menjalankan server RADIUS: ${err.message}`);
}
}
/**
* Menghentikan Server RADIUS
*/
function stop() {
if (!isRunning) return;
try {
if (authSocket) authSocket.close();
if (acctSocket) acctSocket.close();
} catch (e) {}
authSocket = null;
acctSocket = null;
isRunning = false;
logger.info(`[RADIUS] Server RADIUS telah dihentikan.`);
}
/**
* Format Mikrotik-Rate-Limit string dari nilai kecepatan paket.
*
* PENTING: Format MikroTik Rate-Limit menggunakan perspektif CLIENT:
* - RX (Receive) = Download = Data yang DITERIMA client dari internet
* - TX (Transmit) = Upload = Data yang DIKIRIM client ke internet
*
* Format standar: "rx-rate/tx-rate" atau lebih lengkap dengan burst:
* "rx-rate/tx-rate rx-burst/tx-burst rx-threshold/tx-threshold burst-time"
*
* Contoh: "10M/5M" = Download 10Mbps / Upload 5Mbps
* Contoh burst: "10M/5M 20M/10M 10M/5M 8" = CIR 10M/5M, Burst 20M/10M, Threshold 10M/5M, Time 8s
*
* @param {number} upVal - Kecepatan UPLOAD dalam Kbps (TX)
* @param {number} downVal - Kecepatan DOWNLOAD dalam Kbps (RX)
* @param {string} defaultVal - Nilai default jika speed tidak valid
* @param {number} uptoUp - Burst UPLOAD dalam Kbps (TX-burst)
* @param {number} uptoDown - Burst DOWNLOAD dalam Kbps (RX-burst)
* @returns {string} Format MikroTik Rate-Limit: "rx-rate/tx-rate" atau dengan burst
*/
function formatRateLimit(upVal, downVal, defaultVal = '5M/10M', uptoUp = 0, uptoDown = 0) {
function parseSpeed(v) {
if (!v) return 0;
const str = String(v).trim().toLowerCase();
// Jika sudah dalam Kbps mentah (angka tanpa satuan — dari DB yang menyimpan dalam Kbps)
if (str.endsWith('m')) return Math.round(parseFloat(str) * 1000);
if (str.endsWith('k')) return Math.round(parseFloat(str));
const num = parseFloat(str) || 0;
return num;
}
function kbpsToStr(kbps) {
if (kbps <= 0) return null;
if (kbps >= 1000 && kbps % 1000 === 0) return `${kbps / 1000}M`;
return `${kbps}k`;
}
const upKbps = parseSpeed(upVal); // TX (Upload)
const downKbps = parseSpeed(downVal); // RX (Download)
if (upKbps <= 0 || downKbps <= 0) {
return defaultVal;
}
// MikroTik format: RX/TX (Download/Upload)
const rxStr = kbpsToStr(upKbps); // RX = upload klien
const txStr = kbpsToStr(downKbps); // TX = download klien
// Tambahkan burst rate jika tersedia (Mikrotik-Rate-Limit format: rx/tx rx-burst/tx-burst rx-threshold/tx-threshold burst-time)
const uptoUpKbps = parseSpeed(uptoUp); // TX-burst
const uptoDownKbps = parseSpeed(uptoDown); // RX-burst
if (uptoUpKbps > 0 && uptoDownKbps > 0 && (uptoUpKbps > upKbps || uptoDownKbps > downKbps)) {
const rxBurstStr = kbpsToStr(uptoUpKbps); // RX-burst = upload burst
const txBurstStr = kbpsToStr(uptoDownKbps); // TX-burst = download burst
// Format MikroTik: "rx/tx rx-burst/tx-burst rx-threshold/tx-threshold burst-time"
// Threshold default 50% dari burst, waktu burst 8 detik
const rxThresholdStr = rxStr;
const txThresholdStr = txStr;
return `${rxStr}/${txStr} ${rxBurstStr}/${txBurstStr} ${rxThresholdStr}/${txThresholdStr} 8/8`;
}
// Format standar: rx/tx (Download/Upload)
return `${rxStr}/${txStr}`;
}
function ipToInt(ip) {
if (!ip) return 0;
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}
function intToIp(int) {
return [
(int >>> 24) & 255,
(int >>> 16) & 255,
(int >>> 8) & 255,
int & 255
].join('.');
}
function allocateDynamicIp(username, startIpStr, endIpStr, nasIp) {
try {
const startInt = ipToInt(startIpStr);
const endInt = ipToInt(endIpStr);
if (!startInt || !endInt || startInt > endInt) return null;
// Direct check if user has active session with framed_ip
const userSession = db.prepare(`
SELECT framed_ip FROM radius_accounting
WHERE username = ? AND status_type IN (1, 3) AND framed_ip IS NOT NULL AND framed_ip != ''
ORDER BY id DESC LIMIT 1
`).get(username);
if (userSession && userSession.framed_ip) {
const sessInt = ipToInt(userSession.framed_ip);
if (sessInt >= startInt && sessInt <= endInt) {
return userSession.framed_ip;
}
}
const assignedRows = db.prepare(`
SELECT framed_ip FROM radius_accounting
WHERE status_type IN (1, 3) AND framed_ip IS NOT NULL AND framed_ip != ''
`).all();
const usedIps = new Set(assignedRows.map(r => r.framed_ip));
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = (hash * 31 + username.charCodeAt(i)) >>> 0;
}
const range = (endInt - startInt + 1);
const preferredInt = startInt + (hash % range);
const preferredIp = intToIp(preferredInt);
if (!usedIps.has(preferredIp)) {
return preferredIp;
}
for (let current = startInt; current <= endInt; current++) {
const candidateIp = intToIp(current);
if (!usedIps.has(candidateIp)) {
return candidateIp;
}
}
} catch (err) {
logger.error(`[RADIUS Dynamic IP] Error allocating IP: ${err.message}`);
}
return null;
}
function getStatus() {
return {
enabled: getSetting('radius_enabled', '0') === '1',
running: isRunning,
authPort: parseInt(getSetting('radius_auth_port', 1812), 10) || 1812,
acctPort: parseInt(getSetting('radius_acct_port', 1813), 10) || 1813,
secret: getSetting('radius_secret', 'secret123'),
isolirAction: getSetting('radius_isolir_action', 'pool'),
isolirPool: getSetting('radius_isolir_pool', 'isolir'),
isolirRateLimit: getSetting('radius_isolir_rate_limit', '512k/512k'),
isolirIpPoolEnabled: getSetting('radius_isolir_ip_pool_enabled', '1') === '1',
isolirIpPoolStart: getSetting('radius_isolir_ip_pool_start', '10.10.99.2'),
isolirIpPoolEnd: getSetting('radius_isolir_ip_pool_end', '10.10.99.254'),
limitSimultaneous: getSetting('radius_limit_simultaneous', '1') === '1',
defaultRateLimit: getSetting('radius_default_rate_limit', '5M/10M'),
ipPoolEnabled: getSetting('radius_ip_pool_enabled', '1') === '1',
ipPoolStart: getSetting('radius_ip_pool_start', '10.10.10.2'),
ipPoolEnd: getSetting('radius_ip_pool_end', '10.10.10.254'),
framedPool: getSetting('radius_framed_pool', 'pool-pppoe')
};
}
function getOnlineSessions() {
try {
return db.prepare(`
SELECT ra.*, c.name as customer_name
FROM radius_accounting ra
LEFT JOIN customers c ON (c.pppoe_username = ra.username OR c.name = ra.username)
WHERE ra.status_type IN (1, 3)
AND ra.updated_at >= datetime('now', '-24 hours')
ORDER BY ra.updated_at DESC LIMIT 200
`).all();
} catch (e) {
return [];
}
}
function getAccountingLogs(limit = 100) {
try {
return db.prepare(`
SELECT * FROM radius_accounting
ORDER BY id DESC LIMIT ?
`).all(limit);
} catch (e) {
return [];
}
}
async function disconnectSession(username, sessionId, nasIp) {
try {
const mikrotikSvc = require('./mikrotikService');
// 1. Clear session entry from radius_accounting database table
if (sessionId) {
db.prepare(`
UPDATE radius_accounting
SET status_type = 2, updated_at = NOW_LOCAL()
WHERE session_id = ? OR (username = ? AND status_type IN (1, 3))
`).run(sessionId, username);
} else {
db.prepare(`
UPDATE radius_accounting
SET status_type = 2, updated_at = NOW_LOCAL()
WHERE username = ? AND status_type IN (1, 3)
`).run(username);
}
// 2. Disconnect active session from MikroTik via API
let routerId = null;
if (nasIp) {
const r = db.prepare('SELECT id FROM routers WHERE host = ?').get(nasIp);
if (r) routerId = r.id;
}
await mikrotikSvc.kickPppoeUser(username, routerId);
return true;
} catch (err) {
logger.error(`[RADIUS Disconnect] Error disconnecting user '${username}': ${err.message}`);
return false;
}
}
module.exports = {
start,
stop,
getStatus,
getOnlineSessions,
getAccountingLogs,
disconnectSession
};