Files
open-isp/routes/acsPortal.js
T

1925 lines
79 KiB
JavaScript

const express = require('express');
const router = express.Router();
const rawAxios = require('axios');
const db = require('../config/database');
const { getSetting, getSettings } = require('../config/settingsManager');
const sidebarMenuSvc = require('../services/sidebarMenuService');
const customerDevice = require('../services/customerDeviceService');
const mikrotikSvc = require('../services/mikrotikService');
const fs = require('fs');
const path = require('path');
const { createAxiosInstance, isBuiltinAcsEnabled } = require('../config/genieacs');
// Proxy axios to support local built-in ACS proxy
const axios = {
get: async (url, config = {}) => {
if (isBuiltinAcsEnabled() && (url.startsWith('local/') || url === 'local')) {
const path = url.replace(/^local/, '');
const instance = createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.get(path, config);
}
return rawAxios.get(url, config);
},
post: async (url, data, config = {}) => {
if (isBuiltinAcsEnabled() && url.startsWith('local/')) {
const path = url.replace(/^local/, '');
const instance = createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.post(path, data, config);
}
return rawAxios.post(url, data, config);
},
delete: async (url, config = {}) => {
if (isBuiltinAcsEnabled() && url.startsWith('local/')) {
const path = url.replace(/^local/, '');
const instance = createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.delete(path, config);
}
return rawAxios.delete(url, config);
},
put: async (url, data, config = {}) => {
if (isBuiltinAcsEnabled() && url.startsWith('local/')) {
const path = url.replace(/^local/, '');
const instance = createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.put(path, data, config);
}
return rawAxios.put(url, data, config);
}
};
// Helper for DB queries (using better-sqlite3)
function getACSServers(id = null) {
if (isBuiltinAcsEnabled()) {
const builtinServer = {
id: 'builtin',
name: 'Built-in ACS',
url: 'local',
status: 'active'
};
if (id && id !== 'all') {
return id === 'builtin' ? [builtinServer] : [];
}
return [builtinServer];
}
const legacyACS = getLegacyACS();
const legacyServer = legacyACS.acs_url ? {
id: 'legacy',
name: 'Default ACS',
url: legacyACS.acs_url,
username: legacyACS.acs_user,
password: legacyACS.acs_pass
} : null;
if (id === 'legacy') return legacyServer ? [legacyServer] : [];
let query = 'SELECT * FROM genieacs_servers';
let params = [];
if (id && id !== 'all') {
query += ' WHERE id = ?';
params.push(id);
const row = db.prepare(query).get(params);
return row ? [row] : [];
}
const rows = db.prepare(query).all(params);
return legacyServer ? [legacyServer, ...rows] : rows;
}
function getLegacyACS() {
return {
acs_url: getSetting('genieacs_url', ''),
acs_user: getSetting('genieacs_username', ''),
acs_pass: getSetting('genieacs_password', ''),
acs_vparams: '', // Default empty for now
acs_path_pppoe: 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
acs_path_ip: 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress'
};
}
function getAxiosConfig(server) {
const config = {
timeout: 15000,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
};
if (server.username && server.password) {
config.auth = {
username: server.username,
password: server.password
};
}
return config;
}
// Helper to normalize URL
function normalizeUrl(url) {
if (!url) return '';
return url.endsWith('/') ? url.slice(0, -1) : url;
}
function toBool(value) {
return value === true || value === 'true' || value === 'on' || value === 1 || value === '1';
}
function normalizeSelectionArray(value) {
const items = Array.isArray(value) ? value : (value ? [value] : []);
return Array.from(new Set(
items.map(v => String(v || '').trim()).filter(Boolean)
));
}
function buildSingleParamTasks(parameterValues) {
return (parameterValues || [])
.filter(pv => Array.isArray(pv) && pv.length >= 2 && pv[0])
.map(pv => ({
name: 'setParameterValues',
payload: { parameterValues: [pv] }
}));
}
function attachWorkflowMeta(taskSpec, workflowMeta) {
if (!taskSpec || typeof taskSpec !== 'object') return taskSpec;
const meta = workflowMeta && typeof workflowMeta === 'object' ? workflowMeta : {};
const next = { ...taskSpec };
if (next.payload && typeof next.payload === 'object' && !Array.isArray(next.payload)) {
next.payload = { ...next.payload };
}
if (meta.workflowId) {
next.workflowId = meta.workflowId;
if (next.payload) next.payload.workflowId = meta.workflowId;
}
if (meta.workflowType) {
next.workflowType = meta.workflowType;
if (next.payload) next.payload.workflowType = meta.workflowType;
}
if (meta.workflowLabel) {
next.workflowLabel = meta.workflowLabel;
if (next.payload) next.payload.workflowLabel = meta.workflowLabel;
}
if (Array.isArray(next.followup)) {
next.followup = next.followup.map(item => attachWorkflowMeta(item, meta));
}
if (next.payload && Array.isArray(next.payload.followup)) {
next.payload.followup = next.payload.followup.map(item => attachWorkflowMeta(item, meta));
}
return next;
}
function describeAddWanTask(task) {
const name = String(task?.name || '').trim();
const payload = task && task.payload && typeof task.payload === 'object' ? task.payload : {};
if (name === 'addObject') {
const objectName = String(payload.objectName || payload.object || '');
if (/WANConnectionDevice\.\{\{wanDeviceInstance\}\}\.(WANPPPConnection|WANIPConnection)/.test(objectName)) {
return 'Membuat koneksi WAN';
}
if (objectName.includes('WANConnectionDevice')) {
return 'Membuat slot WAN';
}
}
if (name === 'setParameterValues') {
const firstParam = Array.isArray(payload.parameterValues) && payload.parameterValues[0]
? String(payload.parameterValues[0][0] || '')
: '';
if (firstParam.includes('DHCPServerEnable')) return 'Mengatur DHCP LAN';
if (firstParam.includes('WLANConfiguration')) return 'Mengatur Wi-Fi';
if (/(VLAN|LANBind|SSIDBind)/.test(firstParam)) return 'Mengatur VLAN dan binding';
if (/(ConnectionType|NATEnabled|Username|Password|Enable)/.test(firstParam)) return 'Mengatur koneksi WAN';
return 'Menerapkan parameter WAN';
}
if (name === 'getParameterValues') return 'Verifikasi hasil provisioning';
if (name === 'refreshObject') return 'Menyegarkan data perangkat';
return name || 'Task ACS';
}
function buildBuiltinAddWanWorkflow({
mode,
parsedVlan,
pppoeUser,
pppoePass,
dhcp,
lanPorts,
wlanSsids,
configureWifi,
wifiSsid24,
wifiPass24,
wifiSsid5,
wifiPass5,
manufacturer,
wlanConfig,
workflowMeta
}) {
const isPppoe = mode === 'pppoe';
const connectionType = isPppoe ? 'WANPPPConnection' : 'WANIPConnection';
const lanPortsArray = normalizeSelectionArray(lanPorts);
const wlanSsidsArray = normalizeSelectionArray(wlanSsids);
const baseConnPath = `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.{{wanDeviceInstance}}.${connectionType}.{{wanConnectionInstance}}`;
const followup = [];
const baseParamValues = [
[`${baseConnPath}.Enable`, true, 'xsd:boolean'],
[`${baseConnPath}.ConnectionType`, isPppoe ? 'IP_Routed' : 'Bridged', 'xsd:string']
];
if (isPppoe) {
baseParamValues.push(
[`${baseConnPath}.NATEnabled`, true, 'xsd:boolean'],
[`${baseConnPath}.Username`, pppoeUser, 'xsd:string'],
[`${baseConnPath}.Password`, pppoePass, 'xsd:string']
);
}
followup.push(...buildSingleParamTasks(baseParamValues));
const vendorParamValues = [];
if (manufacturer.includes('huawei')) {
vendorParamValues.push(
[`${baseConnPath}.X_HW_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANMark`, true, 'xsd:boolean'],
[`${baseConnPath}.X_HW_WANMode`, isPppoe ? 'WAN_PPPOE' : 'WAN_BRIDGE', 'xsd:string']
);
if (lanPortsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_HW_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_HW_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else if (manufacturer.includes('zte')) {
vendorParamValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_ZTE_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
if (lanPortsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_ZTE_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_ZTE_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else {
vendorParamValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
}
followup.push(...buildSingleParamTasks(vendorParamValues));
followup.push({
name: 'setParameterValues',
payload: {
parameterValues: [[
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DHCPServerEnable',
toBool(dhcp),
'xsd:boolean'
]]
}
});
const verifyNames = [
`${baseConnPath}.Enable`,
`${baseConnPath}.ConnectionType`,
`${baseConnPath}.ExternalIPAddress`,
`${baseConnPath}.Uptime`
];
if (isPppoe) {
verifyNames.push(`${baseConnPath}.Username`, `${baseConnPath}.NATEnabled`);
}
const wifiParamValues = [];
const wlanObj = wlanConfig || {};
if (toBool(configureWifi)) {
if (wlanObj['1'] && wifiSsid24) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID`, wifiSsid24, 'xsd:string']);
verifyNames.push('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID');
if (wifiPass24) {
wifiParamValues.push(
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', wifiPass24, 'xsd:string'],
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', wifiPass24, 'xsd:string']
);
}
}
const fiveGIndex = wlanObj['5'] ? '5' : (wlanObj['2'] ? '2' : null);
if (fiveGIndex && wifiSsid5) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.SSID`, wifiSsid5, 'xsd:string']);
verifyNames.push(`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.SSID`);
if (wifiPass5) {
wifiParamValues.push(
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.PreSharedKey.1.PreSharedKey`, wifiPass5, 'xsd:string'],
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.KeyPassphrase`, wifiPass5, 'xsd:string']
);
}
}
}
followup.push(...buildSingleParamTasks(wifiParamValues));
followup.push({
name: 'getParameterValues',
payload: {
parameterNames: Array.from(new Set(verifyNames))
}
});
return attachWorkflowMeta({
name: 'addObject',
objectName: 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice',
instanceVariable: 'wanDeviceInstance',
followup: [{
name: 'addObject',
payload: {
objectName: `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.{{wanDeviceInstance}}.${connectionType}`,
instanceVariable: 'wanConnectionInstance',
followup
}
}]
}, workflowMeta);
}
// Helper to get nested value like genieacs.js
function getNestedValue(obj, path) {
try {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (!current) return null;
current = current[part];
}
if (current && typeof current === 'object' && '_value' in current) {
return current._value;
}
if (current && typeof current === 'object' && current.hasOwnProperty('_value')) {
return current._value;
}
return current;
} catch (e) {
return null;
}
}
// Standard paths for RX Power
const RX_POWER_PATHS = [
'VirtualParameters.RXPower',
'VirtualParameters.RXpower',
'VirtualParameters.rx_power',
'VirtualParameters.redaman',
'InternetGatewayDevice.WANDevice.1.WANPONInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANOAM.RXPower',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.X_HW_OpticalSignal.RXPower',
'InternetGatewayDevice.WANDevice.1.X_GponInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_GponInterfaceConfig.RxPower',
'InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RxPower',
'InternetGatewayDevice.WANDevice.1.X_ZTE_GponInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_ZTE_GponInterfaceConfig.RxPower',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.X_ZTE_OpticalSignal.RXPower',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.X_ZTE_OpticalSignal.RxPower',
'InternetGatewayDevice.WANDevice.1.X_HW_GponInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_HW_GponInterfaceConfig.RxPower',
'InternetGatewayDevice.WANDevice.1.X_ZTE-COM_WANPONInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_FH_GponInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_CMCC_EponInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_CMCC_GponInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_CT-COM_EponInterfaceConfig.RXPower',
'InternetGatewayDevice.WANDevice.1.X_CT-COM_GponInterfaceConfig.RXPower',
'InternetGatewayDevice.DeviceInfo.XponInterface.RXPower',
'InternetGatewayDevice.DeviceInfo.XponInterface.RxPower',
'InternetGatewayDevice.DeviceInfo.OpticalInterface.RXPower',
'InternetGatewayDevice.WANDevice.1.X_CU_WANEPONInterfaceConfig.OpticalTransceiver.RXPower',
'Device.Optical.Interface.1.OpticalSignalLevel',
'Device.XPON.Interface.1.Stats.RXPower',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANDSLDiagnostics.FECOutput' // some devices
];
// PPPoE IP search keys matching user's template
const PPPOE_IP_KEYS = [
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.1.WANPPPConnection.2.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.2.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.3.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.4.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.5.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANPPPConnection.*.ExternalIPAddress',
'Device.PPP.Interface.1.ExternalIPAddress',
'Device.IP.Interface.1.IPv4Address.1.IPAddress'
];
// PPPoE Username search keys matching user's template
const PPPOE_USER_KEYS = [
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.1.WANPPPConnection.2.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.2.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.3.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.3.WANPPPConnection.2.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.4.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.4.WANPPPConnection.2.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.5.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANPPPConnection.*.Username',
'Device.PPP.Interface.1.Username',
'Device.PPP.Interface.2.Username',
'Device.PPP.Interface.3.Username'
];
function getWildcardMatches(device, path) {
const parts = path.split('.');
const results = [];
function recurse(current, index, currentPathParts) {
if (current === undefined || current === null) return;
if (index === parts.length) {
let val = current;
if (typeof current === 'object' && '_value' in current) {
val = current._value;
}
results.push({
path: currentPathParts.join('.'),
value: val
});
return;
}
const part = parts[index];
if (part === '*') {
if (typeof current === 'object') {
for (const key of Object.keys(current)) {
if (!key.startsWith('_')) {
recurse(current[key], index + 1, [...currentPathParts, key]);
}
}
}
} else {
if (typeof current === 'object') {
const targetLower = part.toLowerCase();
for (const key of Object.keys(current)) {
if (key.toLowerCase() === targetLower) {
recurse(current[key], index + 1, [...currentPathParts, key]);
}
}
}
}
}
recurse(device, 0, []);
return results;
}
function getDeviceParameterValue(device, keys, filterFn) {
for (const key of keys) {
const matches = getWildcardMatches(device, key);
for (const match of matches) {
if (filterFn) {
if (filterFn(match.path, match.value, device)) {
return match.value;
}
} else if (match.value !== undefined && match.value !== null && match.value !== '') {
return match.value;
}
}
}
return '';
}
function extractPppoeIp(d) {
const ip = getDeviceParameterValue(d, PPPOE_IP_KEYS, (matchedPath, value, device) => {
if (!value || value === '0.0.0.0' || value === '-') return false;
if (matchedPath.includes('WANPPPConnection.')) {
const connectionTypePath = matchedPath.replace('ExternalIPAddress', 'ConnectionType');
const connTypeMatches = getWildcardMatches(device, connectionTypePath);
if (connTypeMatches.length > 0 && connTypeMatches[0].value === 'bridge') {
return false;
}
}
return true;
});
if (ip) return ip;
if (d._ip && d._ip !== '-' && d._ip !== '0.0.0.0') return d._ip;
return '-';
}
function extractPppoeUser(d) {
const user = getDeviceParameterValue(d, PPPOE_USER_KEYS, (matchedPath, value, device) => {
if (!value || value === '-') return false;
// Abaikan nilai generik yang bukan user PPPoE pelanggan
const v = String(value).trim();
if (v.toLowerCase() === 'default' || v.toLowerCase() === 'admin' || v.toLowerCase() === 'user') return false;
if (matchedPath.includes('WANPPPConnection.')) {
const connectionTypePath = matchedPath.replace('Username', 'ConnectionType');
const connTypeMatches = getWildcardMatches(device, connectionTypePath);
if (connTypeMatches.length > 0 && connTypeMatches[0].value === 'PPPoE_Bridged') {
return false;
}
}
return true;
});
return user || '-';
}
function formatUptime(seconds) {
if (!seconds || seconds === 'N/A' || seconds === '-') return seconds || '-';
if (typeof seconds === 'string' && (seconds.includes('d') || seconds.includes(':')) && isNaN(seconds)) {
return seconds;
}
const totalSecs = parseInt(seconds, 10);
if (isNaN(totalSecs)) return seconds || '-';
const days = Math.floor(totalSecs / 86400);
const rem = totalSecs % 86400;
let hrs = Math.floor(rem / 3600);
if (hrs < 10) hrs = "0" + hrs;
const rem2 = rem % 3600;
let mins = Math.floor(rem2 / 60);
if (mins < 10) mins = "0" + mins;
let secs = rem2 % 60;
if (secs < 10) secs = "0" + secs;
return days + "d " + hrs + ":" + mins + ":" + secs;
}
const PPPOE_UPTIME_KEYS = [
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Uptime',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.1.WANPPPConnection.2.Uptime',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.Uptime',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.2.Uptime',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.3.WANPPPConnection.1.Uptime',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.4.WANPPPConnection.1.Uptime',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.5.WANPPPConnection.1.Uptime',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANPPPConnection.*.Uptime',
'Device.PPP.Interface.1.UpTime'
];
function extractPppoeUptime(d) {
let uptimeVal = getDeviceParameterValue(d, PPPOE_UPTIME_KEYS, (matchedPath, value, device) => {
if (value === undefined || value === null || value === '' || value === '-') return false;
if (matchedPath.toLowerCase().includes('wanpppconnection')) {
const connTypePath = matchedPath.substring(0, matchedPath.toLowerCase().lastIndexOf('.uptime')) + '.ConnectionType';
const connTypeMatches = getWildcardMatches(device, connTypePath);
if (connTypeMatches.length > 0 && connTypeMatches[0].value === 'PPPoE_Bridged') {
return false;
}
}
return true;
});
if (!uptimeVal || uptimeVal === '-') {
const UPTIME_PATHS = [
'VirtualParameters.getdeviceuptime',
'InternetGatewayDevice.DeviceInfo.UpTime',
'Device.DeviceInfo.UpTime'
];
for (const path of UPTIME_PATHS) {
const val = getNestedValue(d, path);
if (val && val !== '-' && val !== '') {
uptimeVal = val;
break;
}
}
}
if (uptimeVal) {
return formatUptime(uptimeVal);
}
return '-';
}
function formatRxPower(val) {
if (val === undefined || val === null || val === '-' || val === '') return '-';
const num = parseFloat(val);
if (isNaN(num)) return val;
if (num > 0) {
const dbVal = 30 + (Math.log10(num * Math.pow(10, -7)) * 10);
return (Math.ceil(dbVal * 100) / 100).toFixed(2);
}
return String(num);
}
function extractRxPower(d) {
let rxPower = '-';
for (const path of RX_POWER_PATHS) {
const val = getNestedValue(d, path);
if (val && val !== '-') {
rxPower = val;
break;
}
}
return formatRxPower(rxPower);
}
function extractSsid(d) {
const SSID_PATHS = [
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
'Device.WiFi.SSID.1.SSID',
'Device.WiFi.SSID.2.SSID'
];
for (const path of SSID_PATHS) {
const val = getNestedValue(d, path);
if (val && val !== '-' && val !== '') return val;
}
return '-';
}
function extractSoftwareVersion(d) {
const SW_PATHS = [
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
'Device.DeviceInfo.SoftwareVersion'
];
for (const path of SW_PATHS) {
const val = getNestedValue(d, path);
if (val && val !== '-' && val !== '') return val;
}
return '-';
}
function extractUptime(d) {
const UPTIME_PATHS = [
'VirtualParameters.getdeviceuptime',
'InternetGatewayDevice.DeviceInfo.UpTime',
'Device.DeviceInfo.UpTime'
];
for (const path of UPTIME_PATHS) {
const val = getNestedValue(d, path);
if (val && val !== '-' && val !== '') {
if (typeof val === 'string' && (val.includes('d') || val.includes(':')) && isNaN(val)) {
return val;
}
const totalSecs = parseInt(val, 10);
if (isNaN(totalSecs)) return val;
const days = Math.floor(totalSecs / 86400);
const rem = totalSecs % 86400;
let hrs = Math.floor(rem / 3600);
if (hrs < 10) hrs = "0" + hrs;
const rem2 = rem % 3600;
let mins = Math.floor(rem2 / 60);
if (mins < 10) mins = "0" + mins;
let secs = rem2 % 60;
if (secs < 10) secs = "0" + secs;
return days + "d " + hrs + ":" + mins + ":" + secs;
}
}
return '-';
}
// Middleware: Require Admin Session
const requireAdmin = (req, res, next) => {
if (req.session && req.session.isAdmin) {
return next();
}
res.status(403).json({ success: false, message: 'Forbidden' });
};
const requireAdminSession = (req, res, next) => {
if (req.session && req.session.isAdmin) {
return next();
}
res.redirect('/admin/login');
};
function company() { return getSetting('company_header', 'ISP Admin'); }
function requireSidebarMenuAccess(menuKey) {
return (req, res, next) => {
const access = sidebarMenuSvc.evaluateMenuAccess(menuKey, req.session);
if (access.allowed) return next();
if (access.reason === 'hidden') {
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" sedang disembunyikan dari sidebar.` };
return res.redirect('/admin');
}
if (access.reason === 'locked') {
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password.` };
return res.redirect('/admin/sidebar-settings');
}
req.session._msg = { type: 'error', text: 'Anda tidak memiliki akses ke menu ini.' };
return res.redirect('/admin');
};
}
router.use((req, res, next) => {
res.locals.session = req.session;
res.locals.sidebarSections = sidebarMenuSvc.getSidebarSections(req.session);
res.locals.sidebarBottomNavItems = sidebarMenuSvc.getBottomNavItems(req.session);
res.locals.settings = getSettings();
res.locals.company = company();
next();
});
router.use(requireAdminSession, requireSidebarMenuAccess('acs_pro'));
async function getLANHosts(deviceId, serverConfig) {
try {
const baseUrl = normalizeUrl(serverConfig.url);
const [hostsResponse, wifiResponse] = await Promise.all([
axios.get(`${baseUrl}/devices/`, {
...getAxiosConfig(serverConfig),
params: {
query: JSON.stringify({ _id: deviceId }),
projection: 'InternetGatewayDevice.LANDevice.1.Hosts'
}
}).catch(() => ({ data: [] })),
axios.get(`${baseUrl}/devices/`, {
...getAxiosConfig(serverConfig),
params: {
query: JSON.stringify({ _id: deviceId }),
projection: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.AssociatedDevice,InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.AssociatedDevice'
}
}).catch(() => ({ data: [] }))
]);
const device = Array.isArray(hostsResponse.data) && hostsResponse.data.length > 0 ? hostsResponse.data[0] : null;
if (!device) return [];
const hostsData = device.InternetGatewayDevice?.LANDevice?.['1']?.Hosts;
if (!hostsData) return [];
let hostArray = [];
if (hostsData.Host) {
if (Array.isArray(hostsData.Host)) {
hostArray = hostsData.Host;
} else if (typeof hostsData.Host === 'object') {
hostArray = Object.values(hostsData.Host).filter(v => v && typeof v === 'object');
}
}
const wifiRssiMap = new Map();
const wifiDevice = Array.isArray(wifiResponse.data) && wifiResponse.data.length > 0 ? wifiResponse.data[0] : null;
if (wifiDevice) {
const wlanConfig = wifiDevice.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration;
if (wlanConfig) {
for (const bandKey of ['1', '5']) {
const band = wlanConfig[bandKey];
if (!band || !band.AssociatedDevice) continue;
let devArray = [];
if (Array.isArray(band.AssociatedDevice)) {
devArray = band.AssociatedDevice;
} else if (typeof band.AssociatedDevice === 'object') {
devArray = Object.values(band.AssociatedDevice).filter(v => v && typeof v === 'object');
}
const bandLabel = bandKey === '5' ? '5GHz' : '2.4GHz';
devArray.forEach(dev => {
const mac = dev.AssociatedDeviceMACAddress?._value || dev.MACAddress?._value || null;
const rssi = dev.X_HW_RSSI?._value || dev.SignalStrength?._value || null;
const rate = dev.LastDataTransmitRate?._value || dev.X_HW_TxRate?._value || null;
if (mac) {
wifiRssiMap.set(mac.toString().toLowerCase(), {
rssi: rssi !== null ? parseInt(rssi) : null,
rate: rate,
band: bandLabel
});
}
});
}
}
}
return hostArray.map((host, index) => {
const getHostVal = (key) => {
const val = host[key];
if (val && typeof val === 'object' && '_value' in val) return val._value;
return val;
};
const mac = getHostVal('MACAddress') || '-';
const ip = getHostVal('IPAddress') || '-';
const hostname = getHostVal('HostName') || 'Unknown';
const activeRaw = getHostVal('Active');
const interfaceType = getHostVal('InterfaceType') || '';
const layer2Interface = getHostVal('Layer2Interface') || '';
let bytesReceived = 0;
let bytesSent = 0;
const stats = host['X_HW_Stats'];
if (stats && typeof stats === 'object') {
bytesReceived = parseInt(stats.BytesReceived?._value || stats.BytesReceived || 0);
bytesSent = parseInt(stats.BytesSent?._value || stats.BytesSent || 0);
}
const l2Str = layer2Interface.toString().toLowerCase();
const isWiFi = interfaceType.toString().toLowerCase().includes('802.11') || l2Str.includes('wlan') || l2Str.includes('wifi');
let finalRssi = null;
let band = l2Str.includes('5') ? '5GHz' : '2.4GHz';
const macLower = mac.toString().toLowerCase();
if (isWiFi && wifiRssiMap.has(macLower)) {
const wifiInfo = wifiRssiMap.get(macLower);
finalRssi = wifiInfo.rssi;
band = wifiInfo.band;
}
return {
index: index + 1,
mac, ip, hostname,
active: activeRaw === true || activeRaw === 'true' || activeRaw === 1,
isWiFi, band, rssi: finalRssi,
bytesReceived, bytesSent
};
});
} catch (err) {
console.error(`[getLANHosts] Error:`, err.message);
return [];
}
}
// ============================================
// DEVICE FETCH HELPERS
// ============================================
async function fetchDevicesFromACS(server, vParams = [], paths = {}, options = {}) {
const { page = 1, limit = null, activeSessionsMap = null } = options;
try {
const baseUrl = normalizeUrl(server.url);
// Gabungkan proyeksi dasar dengan path pencarian
let projection = '_id,_lastInform,_ip,_deviceId._Manufacturer,_deviceId._ProductClass,_deviceId._SerialNumber,VirtualParameters,InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1';
const params = { projection };
if (limit !== null) {
params.limit = limit + 1;
params.skip = (page - 1) * limit;
}
const response = await axios.get(`${baseUrl}/devices`, {
...getAxiosConfig(server),
params
});
if (!Array.isArray(response.data)) return { server, devices: [], hasMore: false };
const hasMore = limit !== null && response.data.length > limit;
const devicesData = hasMore ? response.data.slice(0, limit) : response.data;
const sessionsMap = activeSessionsMap || (await mikrotikSvc.getActivePppoeSessionsMap().catch(() => new Map()));
const devices = devicesData.map(d => {
// Fallback PPPoE
let pppoeUser = extractPppoeUser(d);
// Fallback RX Power
let rxPower = extractRxPower(d);
// Fallback IP
let ip = extractPppoeIp(d);
// Customer Name
const customerName = getNestedValue(d, 'VirtualParameters.CustomerName') ||
getNestedValue(d, 'VirtualParameters.customer_name') ||
'-';
const isOnline = (d._lastInform && (Date.now() - new Date(d._lastInform).getTime() < 900000)) ||
(pppoeUser && pppoeUser !== '-' && sessionsMap.has(pppoeUser.toLowerCase()));
return {
id: d._id,
sn: d._deviceId?._SerialNumber || d._id,
last_inform: d._lastInform,
isOnline: isOnline,
manufacturer: d._deviceId?._Manufacturer || '-',
model: d._deviceId?._ProductClass || '-',
customer_name: customerName,
rx_power: rxPower,
pppoe_user: pppoeUser,
ip: ip,
acs_server_name: server.name,
acs_server_id: server.id
};
});
return { server, devices, hasMore };
} catch (err) {
console.error(`[fetchDevicesFromACS] Error on ${server.name}:`, err.message);
return { server, devices: [], hasMore: false, error: err.message };
}
}
function enrichDevicesWithCustomerNames(devices) {
if (!Array.isArray(devices) || devices.length === 0) return devices;
let customers = [];
try {
customers = db.prepare(`
SELECT id, name, genieacs_tag, pppoe_username, hotspot_username, static_ip
FROM customers
`).all();
} catch (e) {
console.error('[enrichDevicesWithCustomerNames] Error loading customers:', e.message);
return devices;
}
const byTag = new Map();
const byPppoe = new Map();
const byHotspot = new Map();
const byIp = new Map();
for (const c of customers) {
if (!c.name) continue;
const name = String(c.name).trim();
if (c.genieacs_tag) byTag.set(String(c.genieacs_tag).trim().toLowerCase(), name);
if (c.pppoe_username) byPppoe.set(String(c.pppoe_username).trim().toLowerCase(), name);
if (c.hotspot_username) byHotspot.set(String(c.hotspot_username).trim().toLowerCase(), name);
if (c.static_ip) byIp.set(String(c.static_ip).trim().toLowerCase(), name);
}
return devices.map(d => {
let matchedName = null;
// 1. PPPoE Username Match
const pppUser = String(d.pppoe_user || d.pppoeUser || d.pppoeUsername || '').trim().toLowerCase();
if (pppUser && pppUser !== '-' && pppUser !== 'n/a' && byPppoe.has(pppUser)) {
matchedName = byPppoe.get(pppUser);
}
// 2. Serial Number, Device ID, or Tags Match
if (!matchedName) {
const sn = String(d.sn || d.serialNumber || '').trim().toLowerCase();
const devId = String(d.id || d.phone || '').trim().toLowerCase();
if (sn && byTag.has(sn)) matchedName = byTag.get(sn);
else if (devId && byTag.has(devId)) matchedName = byTag.get(devId);
else if (Array.isArray(d.tags)) {
for (const t of d.tags) {
const tagKey = String(t || '').trim().toLowerCase();
if (byTag.has(tagKey)) {
matchedName = byTag.get(tagKey);
break;
}
}
}
}
// 3. Hotspot Username Match
if (!matchedName && pppUser && byHotspot.has(pppUser)) {
matchedName = byHotspot.get(pppUser);
}
// 4. IP Address Match
if (!matchedName) {
const ip = String(d.ip || d.pppoe_ip || d.pppoeIP || '').trim().toLowerCase();
if (ip && ip !== '-' && byIp.has(ip)) {
matchedName = byIp.get(ip);
}
}
const finalName = matchedName || (d.customer_name && d.customer_name !== '-' ? d.customer_name : (d.customerName && d.customerName !== '-' ? d.customerName : '-'));
return {
...d,
customer_name: finalName,
customerName: finalName
};
});
}
// ============================================
// ROUTES
// ============================================
router.get('/', async (req, res) => {
try {
const searchQuery = String(req.query.q || '').trim() || null;
const acsServers = getACSServers();
const legacyACS = getLegacyACS();
const activeSessionsMap = await mikrotikSvc.getActivePppoeSessionsMap().catch(() => new Map());
const activeServers = acsServers.length > 0 ? acsServers :
(legacyACS.acs_url ? [{ id: 'legacy', name: 'Default ACS', url: legacyACS.acs_url, username: legacyACS.acs_user, password: legacyACS.acs_pass }] : []);
const selectedAcsId = req.query.acs || (activeServers[0]?.id);
const targetServers = selectedAcsId && selectedAcsId !== 'all' ? activeServers.filter(s => String(s.id) === String(selectedAcsId)) : activeServers;
let allDevices = [];
if (targetServers.length > 0) {
if (searchQuery) {
// Search mode: query devices with search filter
const query = JSON.stringify({
$or: [
{ '_deviceId._SerialNumber': { $regex: searchQuery, $options: 'i' } },
{ 'VirtualParameters.CustomerName': { $regex: searchQuery, $options: 'i' } },
{ 'VirtualParameters.customer_name': { $regex: searchQuery, $options: 'i' } },
{ 'VirtualParameters.PPPoEUser': { $regex: searchQuery, $options: 'i' } },
{ '_tags': searchQuery }
]
});
for (const server of targetServers) {
try {
const baseUrl = normalizeUrl(server.url);
const response = await axios.get(`${baseUrl}/devices`, {
...getAxiosConfig(server),
params: { query }
});
if (Array.isArray(response.data)) {
const devices = response.data.map(d => {
let rxPower = extractRxPower(d);
let pppoeUser = extractPppoeUser(d);
let ip = extractPppoeIp(d);
const customerName = getNestedValue(d, 'VirtualParameters.CustomerName') ||
getNestedValue(d, 'VirtualParameters.customer_name') || '-';
const isOnline = (d._lastInform && (Date.now() - new Date(d._lastInform).getTime() < 900000)) ||
(pppoeUser && pppoeUser !== '-' && activeSessionsMap.has(pppoeUser.toLowerCase()));
return {
id: d._id,
sn: d._deviceId?._SerialNumber || d._id,
last_inform: d._lastInform,
isOnline: isOnline,
manufacturer: d._deviceId?._Manufacturer || '-',
model: d._deviceId?._ProductClass || '-',
rx_power: rxPower,
pppoe_ip: ip,
ip: ip,
pppoe_user: pppoeUser,
customer_name: customerName,
acs_server_id: server.id,
acs_server_name: server.name
};
});
allDevices = allDevices.concat(devices);
}
} catch (err) {
console.error(`Search error on server ${server.name}:`, err.message);
}
}
} else {
// Normal mode: fetch all devices
const results = await Promise.allSettled(targetServers.map(s => fetchDevicesFromACS(s, [], legacyACS, { activeSessionsMap })));
results.forEach(r => { if (r.status === 'fulfilled') allDevices = allDevices.concat(r.value.devices); });
}
}
// Enrich devices with matching customer names from Billing DB
allDevices = enrichDevicesWithCustomerNames(allDevices);
if (searchQuery) {
const qLower = searchQuery.toLowerCase();
allDevices = allDevices.filter(d =>
String(d.sn || '').toLowerCase().includes(qLower) ||
String(d.id || '').toLowerCase().includes(qLower) ||
String(d.customer_name || '').toLowerCase().includes(qLower) ||
String(d.pppoe_user || '').toLowerCase().includes(qLower) ||
String(d.ip || d.pppoe_ip || '').toLowerCase().includes(qLower)
);
}
// ── Filter lanjutan: status online/offline & vendor ──
const filterDevStatus = String(req.query.dev_status || '').trim();
const filterVendor = String(req.query.vendor || '').trim();
const vendors = Array.from(new Set(allDevices.map(d => String(d.vendor || '').trim()).filter(Boolean))).sort();
if (filterDevStatus === 'online') allDevices = allDevices.filter(d => !!d.isOnline);
else if (filterDevStatus === 'offline') allDevices = allDevices.filter(d => !d.isOnline);
if (filterVendor) allDevices = allDevices.filter(d => String(d.vendor || '').trim().toLowerCase() === filterVendor.toLowerCase());
let pppoeProfiles = [];
try {
// Get routerId dari query parameter jika ada (untuk multi-router support)
const selectedRouterId = req.query.router_id ? Number(req.query.router_id) : null;
pppoeProfiles = await mikrotikSvc.getPppoeProfiles(selectedRouterId);
} catch (e) {
console.error('Failed to load PPPoE profiles from MikroTik:', e.message);
}
res.render('admin/acs', {
user: req.session,
devices: allDevices,
acsServers: activeServers,
selectedAcsId,
searchQuery,
pppoeProfiles,
currentPage: 'acs_pro',
filterDevStatus,
filterVendor,
vendors
});
} catch (err) {
console.error('ACS page error:', err);
res.render('admin/acs', { user: req.session, devices: [], acsServers: [], selectedAcsId: null, searchQuery: null, pppoeProfiles: [], currentPage: 'acs_pro' });
}
});
// ── Log aktivitas ACS per server (built-in: riwayat acs_tasks; GenieACS: NBI /faults) ──
function _acsTaskSummary(name, status, result) {
if (!result) return status === 'completed' ? 'OK' : String(status || '');
let obj = null;
try { obj = JSON.parse(result); } catch (e) { return String(result).slice(0, 160); }
if (obj && obj.faultString) return (obj.code ? obj.code + ': ' : '') + obj.faultString;
if (obj && Array.isArray(obj.names)) return obj.names.length + ' parameter';
if (obj && obj._value !== undefined) return String(obj._value).slice(0, 140);
const keys = Object.keys(obj || {});
if (keys.length) return keys.slice(0, 2).join(', ') + (keys.length > 2 ? ' (+' + (keys.length - 2) + ')' : '');
return String(status || '');
}
router.get('/logs', async (req, res) => {
try {
const serverId = String(req.query.server || 'all');
const onlyError = String(req.query.onlyError || '') === '1';
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 1), 1000);
const servers = getACSServers(serverId);
const out = [];
for (const s of servers) {
const entries = [];
if (s.url === 'local' || String(s.id) === 'builtin') {
const where = onlyError ? "WHERE t.status IN ('skipped','failed')" : '';
const rows = db.prepare(
"SELECT t.id, t.device_id, t.name, t.status, t.result, t.created_at, t.updated_at, " +
"d.serial_number, d.manufacturer, d.product_class " +
"FROM acs_tasks t LEFT JOIN acs_devices d ON d.id = t.device_id " +
where + " ORDER BY t.id DESC LIMIT ?"
).all(limit);
for (const r of rows) {
entries.push({
time: r.updated_at || r.created_at,
device: r.serial_number || r.device_id,
device_id: r.device_id,
action: r.name,
status: r.status,
message: _acsTaskSummary(r.name, r.status, r.result)
});
}
} else {
try {
const instance = createAxiosInstance(s);
const resp = await instance.get('/faults');
const arr = Array.isArray(resp.data) ? resp.data : ((resp.data && resp.data.results) || []);
for (const f of arr.slice(0, limit)) {
entries.push({
time: f.timestamp || f._timestamp || null,
device: f.device || f._device || (f._id ? String(f._id).split(':')[0] : '-'),
device_id: f.device || '',
action: f.channel || 'fault',
status: 'fault',
message: [f.code, f.message].filter(Boolean).join(' — ') || String(f._id || '')
});
}
} catch (e) {
entries.push({ time: null, device: '-', action: 'connect', status: 'error', message: 'Gagal ambil /faults: ' + e.message });
}
}
out.push({ id: s.id, name: s.name, url: s.url, entries });
}
res.json({ ok: true, servers: out });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.get('/search', async (req, res, next) => {
req.url = '/';
return router.handle(req, res, next);
});
router.get('/device/:deviceId', async (req, res) => {
try {
const acsId = String(req.query.acsId || req.query.acs || '').trim() || null;
const deviceToken = String(req.params.deviceId || '');
const legacyData = await customerDevice.getCustomerDeviceData(deviceToken);
if (legacyData && legacyData.phone) {
const isOnline = String(legacyData.status || '').toLowerCase() === 'online';
const clients = Array.isArray(legacyData.connectedUsers) ? legacyData.connectedUsers : [];
return res.render('admin/acs_device', {
user: req.session,
device: legacyData,
clients,
isOnline,
acsId: acsId || 'legacy',
acsName: 'Default ACS',
currentPage: 'acs_pro'
});
}
const servers = getACSServers(acsId);
if (servers.length === 0) return res.status(404).send('ACS Server not found');
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const deviceId = deviceToken;
const response = await axios.get(`${baseUrl}/devices`, {
...getAxiosConfig(server),
params: {
query: JSON.stringify({ _id: deviceId }),
projection: '_id,_lastInform,_deviceId,_registered,_ip,_tags,_events,VirtualParameters,InternetGatewayDevice'
}
});
const deviceData = Array.isArray(response.data) && response.data.length > 0 ? response.data[0] : null;
if (!deviceData) return res.status(404).send('Device not found');
let pppoeUser = extractPppoeUser(deviceData);
const activeSessionsMap = await mikrotikSvc.getActivePppoeSessionsMap().catch(() => new Map());
const lastInform = deviceData._lastInform;
const isOnline = (lastInform && (Date.now() - new Date(lastInform).getTime() < 900000)) ||
(pppoeUser && pppoeUser !== '-' && activeSessionsMap.has(pppoeUser.toLowerCase()));
// Fallbacks for detail page using the same logic as listing
let rxPower = extractRxPower(deviceData);
const customerName = getNestedValue(deviceData, 'VirtualParameters.CustomerName') ||
getNestedValue(deviceData, 'VirtualParameters.customer_name') ||
'-';
let ip = extractPppoeIp(deviceData);
const rawClients = await getLANHosts(deviceData._id, server);
const clients = (Array.isArray(rawClients) ? rawClients : []).map((c) => ({
hostname: c.hostname || 'Unknown',
ip: c.ip || '-',
mac: c.mac || '-',
iface: c.isWiFi ? `WiFi ${c.band || ''}`.trim() : 'LAN',
status: c.active ? 'Online' : 'Offline',
rssi: typeof c.rssi === 'number' ? c.rssi : null
}));
res.render('admin/acs_device', {
user: req.session,
device: {
phone: deviceData._id,
serialNumber: deviceData._deviceId?._SerialNumber || deviceData._id,
model: deviceData._deviceId?._ProductClass || '-',
softwareVersion: extractSoftwareVersion(deviceData),
status: isOnline ? 'Online' : 'Offline',
lastInform: lastInform || null,
rxPower: rxPower,
pppoeIP: ip,
pppoeUsername: pppoeUser,
pppoeUptime: extractPppoeUptime(deviceData),
ssid: extractSsid(deviceData),
uptime: extractUptime(deviceData)
},
clients,
isOnline,
acsId: server.id,
acsName: server.name,
currentPage: 'acs_pro'
});
} catch (err) {
res.status(500).send(err.message);
}
});
// POST /admin/acs/api/servers
router.post('/api/servers', requireAdmin, async (req, res) => {
const { name, url, username, password, location } = req.body;
try {
db.prepare(
'INSERT INTO genieacs_servers (name, url, username, password, location) VALUES (?, ?, ?, ?, ?)'
).run(name, url, username || null, password || null, location || '');
res.json({ success: true, message: 'ACS server added' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// PUT /admin/acs/api/servers/legacy (Default ACS)
router.put('/api/servers/legacy', requireAdmin, express.json(), async (req, res) => {
try {
const url = String(req.body?.url || '').trim();
if (!url) return res.status(400).json({ success: false, message: 'URL wajib diisi' });
if (!/^https?:\/\//i.test(url)) return res.status(400).json({ success: false, message: 'URL harus diawali http:// atau https://' });
const clearUsername = Boolean(req.body?.clear_username);
const clearPassword = Boolean(req.body?.clear_password);
const usernameInput = String(req.body?.username || '').trim();
const passwordInput = String(req.body?.password || '');
const currentSettings = getSettings();
currentSettings.genieacs_url = url;
if (clearUsername) currentSettings.genieacs_username = '';
else if (usernameInput) currentSettings.genieacs_username = usernameInput;
if (clearPassword) currentSettings.genieacs_password = '';
else if (String(passwordInput || '').trim()) currentSettings.genieacs_password = String(passwordInput || '');
const settingsPath = require('../config/paths').settingsPath;
fs.writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), 'utf8');
res.json({ success: true, message: 'Default ACS berhasil diperbarui.' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// PUT /admin/acs/api/servers/:id
router.put('/api/servers/:id', requireAdmin, async (req, res) => {
try {
const id = String(req.params.id || '').trim();
if (!id) return res.status(400).json({ success: false, message: 'Invalid server id' });
const name = String(req.body?.name || '').trim();
const url = String(req.body?.url || '').trim();
if (!name || !url) return res.status(400).json({ success: false, message: 'Name and URL are required' });
const existing = db.prepare('SELECT id, password FROM genieacs_servers WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ success: false, message: 'ACS server not found' });
const usernameRaw = req.body?.username;
const passwordRaw = req.body?.password;
const location = String(req.body?.location || '');
const clearUsername = Boolean(req.body?.clear_username);
const clearPassword = Boolean(req.body?.clear_password);
const username = clearUsername ? null : (String(usernameRaw || '').trim() || null);
let password = existing.password || null;
if (clearPassword) password = null;
else if (String(passwordRaw || '').trim()) password = String(passwordRaw || '');
db.prepare(
'UPDATE genieacs_servers SET name = ?, url = ?, username = ?, password = ?, location = ? WHERE id = ?'
).run(name, url, username, password, location, id);
res.json({ success: true, message: 'ACS server updated' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// DELETE /admin/acs/api/servers/:id
router.delete('/api/servers/:id', requireAdmin, async (req, res) => {
try {
db.prepare('DELETE FROM genieacs_servers WHERE id = ?').run(req.params.id);
res.json({ success: true, message: 'ACS server deleted' });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// DELETE /admin/acs/api/device/:deviceId
router.delete('/api/device/:deviceId', requireAdmin, async (req, res) => {
try {
const { acsId } = req.body;
const servers = getACSServers(acsId);
if (servers.length === 0) return res.json({ success: false, message: 'ACS not found' });
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const deviceId = String(req.params.deviceId || '');
await axios.delete(
`${baseUrl}/devices/${encodeURIComponent(deviceId)}`,
getAxiosConfig(server)
);
res.json({ success: true, message: 'Device deleted' });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// POST /admin/acs/api/remote-enable/:deviceId
router.post('/api/remote-enable/:deviceId', requireAdmin, async (req, res) => {
try {
const { acsId } = req.body;
const servers = getACSServers(acsId);
if (servers.length === 0) return res.json({ success: false, message: 'ACS not found' });
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const deviceId = String(req.params.deviceId || '');
await axios.post(
`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`,
{
name: 'setParameterValues',
parameterValues: [['InternetGatewayDevice.X_HW_Security.AclServices.HTTPWanEnable', true, 'xsd:boolean']]
},
{ ...getAxiosConfig(server), timeout: 10000 }
);
res.json({ success: true, message: 'Remote WAN enabled' });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// POST /admin/acs/api/reboot/:deviceId
router.post('/api/reboot/:deviceId', requireAdmin, async (req, res) => {
try {
const { acsId } = req.body;
const servers = getACSServers(acsId);
if (servers.length === 0) return res.json({ success: false, message: 'ACS not found' });
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const deviceId = String(req.params.deviceId || '');
await axios.post(
`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`,
{ name: 'reboot' },
{ ...getAxiosConfig(server), timeout: 10000 }
);
res.json({ success: true, message: 'Reboot command sent' });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// POST /admin/acs/api/refresh/:deviceId
router.post('/api/refresh/:deviceId', requireAdmin, async (req, res) => {
try {
const deviceId = String(req.params.deviceId || '');
const result = await customerDevice.requestRefresh(deviceId, {
type: 'admin',
id: req.session?.adminId || null,
name: req.session?.username || req.session?.name || 'Admin',
ip: req.ip,
userAgent: req.headers['user-agent']
});
res.json({ success: !!result.ok, message: result.message });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// POST /admin/acs/api/bulk/refresh
router.post('/api/bulk/refresh', requireAdmin, async (req, res) => {
try {
const { devices } = req.body;
if (!Array.isArray(devices) || devices.length === 0) {
return res.status(400).json({ success: false, message: 'Daftar perangkat wajib diisi' });
}
const promises = devices.map(async (d) => {
const deviceId = String(d.id || '');
const result = await customerDevice.requestRefresh(deviceId, {
type: 'admin',
id: req.session?.adminId || null,
name: req.session?.username || req.session?.name || 'Admin',
ip: req.ip,
userAgent: req.headers['user-agent']
});
return { id: deviceId, success: !!result.ok, message: result.message };
});
await Promise.allSettled(promises);
res.json({ success: true, message: `Berhasil mengirim perintah summon untuk ${devices.length} perangkat.` });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// POST /admin/acs/api/bulk/delete
router.post('/api/bulk/delete', requireAdmin, async (req, res) => {
try {
const { devices } = req.body;
if (!Array.isArray(devices) || devices.length === 0) {
return res.status(400).json({ success: false, message: 'Daftar perangkat wajib diisi' });
}
const promises = devices.map(async (d) => {
const deviceId = String(d.id || '');
const acsId = String(d.acsId || '');
const servers = getACSServers(acsId);
if (servers.length === 0) return { id: deviceId, success: false, message: 'ACS tidak ditemukan' };
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
await axios.delete(
`${baseUrl}/devices/${encodeURIComponent(deviceId)}`,
getAxiosConfig(server)
);
return { id: deviceId, success: true };
});
await Promise.allSettled(promises);
res.json({ success: true, message: `Berhasil menghapus ${devices.length} perangkat.` });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// POST /admin/acs/api/sync/all
router.post('/api/sync/all', requireAdmin, async (req, res) => {
try {
const servers = getACSServers();
if (servers.length === 0) return res.json({ success: true, message: 'No servers to sync' });
let total = 0;
for (const s of servers) {
const result = await fetchDevicesFromACS(s, [], {});
total += result.devices.length;
db.prepare('UPDATE genieacs_servers SET device_count = ?, last_sync = (NOW_LOCAL()) WHERE id = ?').run(result.devices.length, s.id);
}
res.json({ success: true, message: `Sync complete. Total ${total} devices.` });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// POST /admin/acs/api/sync/tags — sinkron Tag ONU pelanggan dari device.id ACS
router.post('/api/sync/tags', requireAdmin, async (req, res) => {
try {
const r = require('../services/acsServerService').syncCustomerTagsFromAcs();
res.json({ success: true, message: `Sync Tag ONU selesai: ${r.matched} tag cocok, ${r.updated} diperbarui (dari ${r.scanned} device).`, data: r });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// GET /api/clients/:deviceId
router.get('/api/clients/:deviceId', requireAdmin, async (req, res) => {
try {
const { deviceId } = req.params;
const { acsId } = req.query;
const servers = getACSServers(acsId);
if (servers.length === 0) return res.json({ success: false });
const hosts = await getLANHosts(String(deviceId || ''), servers[0]);
res.json({ success: true, data: hosts });
} catch (err) {
res.json({ success: false, error: err.message });
}
});
// GET /admin/acs/api/wifi-settings/:deviceId
router.get('/api/wifi-settings/:deviceId', requireAdmin, async (req, res) => {
try {
const { deviceId } = req.params;
const { acsId } = req.query;
const servers = getACSServers(acsId);
if (servers.length === 0) return res.status(404).json({ success: false, message: 'ACS Server not found' });
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const response = await axios.get(`${baseUrl}/devices`, {
...getAxiosConfig(server),
params: {
query: JSON.stringify({ _id: deviceId }),
projection: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration'
}
});
const deviceData = Array.isArray(response.data) && response.data.length > 0 ? response.data[0] : null;
if (!deviceData) return res.status(404).json({ success: false, message: 'Device not found' });
const wlanConfig = deviceData.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration || {};
const bands = [];
// Return all SSID indices (1 to 8) that exist on the ONU
for (let i = 1; i <= 8; i++) {
if (wlanConfig[String(i)]) {
bands.push({
index: String(i),
ssid: getNestedValue(wlanConfig[String(i)], 'SSID') || `SSID ${i}`,
name: i <= 4 ? `Wi-Fi 2.4GHz (SSID ${i})` : `Wi-Fi 5GHz (SSID ${i})`
});
}
}
res.json({ success: true, bands });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
router.get('/api/add-wan-status/:deviceId', requireAdmin, async (req, res) => {
try {
const { deviceId } = req.params;
const workflowId = String(req.query.workflowId || '').trim();
const rootTaskId = parseInt(req.query.rootTaskId, 10);
if (!workflowId && !Number.isFinite(rootTaskId)) {
return res.status(400).json({ success: false, message: 'workflowId atau rootTaskId wajib diisi' });
}
let rows = [];
if (workflowId) {
rows = db.prepare(
`SELECT id, name, payload, status, result, updated_at
FROM acs_tasks
WHERE device_id = ?
AND payload LIKE ?
ORDER BY id ASC
LIMIT 200`
).all(deviceId, `%"workflowId":"${workflowId}"%`);
} else {
rows = db.prepare(
`SELECT id, name, payload, status, result, updated_at
FROM acs_tasks
WHERE device_id = ?
AND id >= ?
ORDER BY id ASC
LIMIT 200`
).all(deviceId, rootTaskId);
}
const tasks = rows.map(row => {
let payload = {};
try { payload = JSON.parse(row.payload || '{}'); } catch (_) { payload = {}; }
return {
id: Number(row.id),
name: String(row.name || ''),
status: String(row.status || 'pending'),
updatedAt: row.updated_at || null,
label: describeAddWanTask({ name: row.name, payload }),
payload
};
});
const total = tasks.length;
const pending = tasks.filter(t => t.status === 'pending').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const completed = tasks.filter(t => t.status === 'completed').length;
const failed = tasks.filter(t => t.status === 'failed').length;
const done = total > 0 && pending === 0 && inProgress === 0;
const summary = failed > 0
? `Ada ${failed} task yang gagal dari ${total} task workflow.`
: done
? `Workflow selesai. ${completed}/${total} task selesai.`
: `Workflow berjalan. ${completed}/${total} task selesai.`;
return res.json({
success: true,
supported: true,
workflowId: workflowId || null,
rootTaskId: Number.isFinite(rootTaskId) ? rootTaskId : null,
totals: { total, pending, inProgress, completed, failed },
done,
hasFailure: failed > 0,
summary,
tasks: tasks.map(t => ({
id: t.id,
name: t.name,
status: t.status,
label: t.label,
updatedAt: t.updatedAt
}))
});
} catch (err) {
return res.status(500).json({ success: false, message: err.message });
}
});
// POST /admin/acs/api/add-wan/:deviceId
router.post('/api/add-wan/:deviceId', requireAdmin, async (req, res) => {
try {
const { deviceId } = req.params;
const {
acsId,
mode,
vlanId,
pppoeUser,
pppoePass,
pppoeProfile,
autoCreateMikrotik,
lanPorts,
wlanSsids,
configureWifi,
wifiSsid24,
wifiPass24,
wifiSsid5,
wifiPass5,
dhcp
} = req.body;
// 1. Validasi awal
const normalizedMode = String(mode || '').trim().toLowerCase();
if (!['pppoe', 'bridge'].includes(normalizedMode)) {
return res.json({ success: false, message: 'Mode WAN tidak valid' });
}
const parsedVlan = parseInt(vlanId, 10);
if (isNaN(parsedVlan) || parsedVlan < 1 || parsedVlan > 4094) {
return res.json({ success: false, message: 'VLAN ID tidak valid (harus 1-4094)' });
}
const trimmedPppoeUser = String(pppoeUser || '').trim();
const trimmedPppoePass = String(pppoePass || '').trim();
if (normalizedMode === 'pppoe' && (!trimmedPppoeUser || !trimmedPppoePass)) {
return res.json({ success: false, message: 'Username dan password PPPoE wajib diisi untuk mode PPPoE' });
}
const servers = getACSServers(acsId);
if (servers.length === 0) return res.json({ success: false, message: 'ACS Server tidak ditemukan' });
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const config = getAxiosConfig(server);
// 2. Jika Auto-create MikroTik diaktifkan
if (normalizedMode === 'pppoe' && toBool(autoCreateMikrotik)) {
try {
await mikrotikSvc.createPppoeSecret({
username: trimmedPppoeUser,
password: trimmedPppoePass,
profile: pppoeProfile || 'default'
});
} catch (mErr) {
console.error('[AddWAN] Failed to create PPPoE Secret in MikroTik:', mErr.message);
return res.json({ success: false, message: `Gagal membuat akun PPPoE di MikroTik: ${mErr.message}` });
}
}
// 3. Ambil data instansi WANConnectionDevice saat ini untuk menghitung nextInstance
const getDeviceRes = await axios.get(`${baseUrl}/devices`, {
...config,
params: {
query: JSON.stringify({ _id: deviceId }),
projection: '_id,_deviceId.Manufacturer,_deviceId._Manufacturer,InternetGatewayDevice.WANDevice.1.WANConnectionDevice,InternetGatewayDevice.LANDevice.1.WLANConfiguration'
}
});
const deviceData = Array.isArray(getDeviceRes.data) && getDeviceRes.data.length > 0 ? getDeviceRes.data[0] : null;
if (!deviceData) return res.json({ success: false, message: 'CPE/Device tidak ditemukan di GenieACS' });
const manufacturer = (deviceData._deviceId?._Manufacturer || deviceData._deviceId?.Manufacturer || '').toLowerCase();
const wlanConfig = deviceData.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration || {};
const isBuiltinServer = String(server.id || '').trim() === 'builtin' || baseUrl === 'local';
if (isBuiltinServer) {
const workflowId = `addwan:${deviceId}:${Date.now()}`;
const task = buildBuiltinAddWanWorkflow({
mode: normalizedMode,
parsedVlan,
pppoeUser: trimmedPppoeUser,
pppoePass: trimmedPppoePass,
dhcp,
lanPorts,
wlanSsids,
configureWifi,
wifiSsid24,
wifiPass24,
wifiSsid5,
wifiPass5,
manufacturer,
wlanConfig,
workflowMeta: {
workflowId,
workflowType: 'add_wan',
workflowLabel: 'Add WAN'
}
});
const taskRes = await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, task, config);
const rootTaskId = parseInt(taskRes?.data?._id, 10);
return res.json({
success: true,
message: 'Workflow Add WAN built-in berhasil dikirim. Progress akan dimonitor otomatis.',
trackingSupported: true,
workflowId,
rootTaskId: Number.isFinite(rootTaskId) ? rootTaskId : null
});
}
const wanConnObj = deviceData.InternetGatewayDevice?.WANDevice?.['1']?.WANConnectionDevice || {};
const existingKeys = Object.keys(wanConnObj).map(Number).filter(n => !isNaN(n));
const nextInstance = existingKeys.length > 0 ? Math.max(...existingKeys) + 1 : 2;
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'addObject',
objectName: 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.'
}, config);
const connectionType = normalizedMode === 'pppoe' ? 'WANPPPConnection' : 'WANIPConnection';
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'addObject',
objectName: `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${nextInstance}.${connectionType}.`
}, config);
const paramValues = [];
const baseConnPath = `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${nextInstance}.${connectionType}.1`;
paramValues.push([`${baseConnPath}.Enable`, true, 'xsd:boolean']);
if (normalizedMode === 'pppoe') {
paramValues.push(
[`${baseConnPath}.ConnectionType`, 'IP_Routed', 'xsd:string'],
[`${baseConnPath}.NATEnabled`, true, 'xsd:boolean'],
[`${baseConnPath}.Username`, trimmedPppoeUser, 'xsd:string'],
[`${baseConnPath}.Password`, trimmedPppoePass, 'xsd:string']
);
} else {
paramValues.push([`${baseConnPath}.ConnectionType`, 'Bridged', 'xsd:string']);
}
axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'setParameterValues',
parameterValues: [[`InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DHCPServerEnable`, toBool(dhcp), 'xsd:boolean']]
}, config).catch(() => {});
const lanPortsArray = normalizeSelectionArray(lanPorts);
const wlanSsidsArray = normalizeSelectionArray(wlanSsids);
if (manufacturer.includes('huawei')) {
paramValues.push(
[`${baseConnPath}.X_HW_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANMark`, true, 'xsd:boolean'],
[`${baseConnPath}.X_HW_WANMode`, normalizedMode === 'pppoe' ? 'WAN_PPPOE' : 'WAN_BRIDGE', 'xsd:string']
);
if (lanPortsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_HW_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_HW_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else if (manufacturer.includes('zte')) {
paramValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_ZTE_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
if (lanPortsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_ZTE_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_ZTE_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else {
paramValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
}
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'setParameterValues',
parameterValues: paramValues
}, config);
if (toBool(configureWifi)) {
const wifiParamValues = [];
if (wlanConfig['1'] && wifiSsid24) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID`, wifiSsid24, 'xsd:string']);
if (wifiPass24) {
wifiParamValues.push(
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey`, wifiPass24, 'xsd:string'],
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase`, wifiPass24, 'xsd:string']
);
}
}
const fiveGIndex = wlanConfig['5'] ? '5' : (wlanConfig['2'] ? '2' : null);
if (fiveGIndex && wifiSsid5) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.SSID`, wifiSsid5, 'xsd:string']);
if (wifiPass5) {
wifiParamValues.push(
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.PreSharedKey.1.PreSharedKey`, wifiPass5, 'xsd:string'],
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.KeyPassphrase`, wifiPass5, 'xsd:string']
);
}
}
if (wifiParamValues.length > 0) {
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'setParameterValues',
parameterValues: wifiParamValues
}, config);
}
}
axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'refreshObject',
objectName: ''
}, config).catch(() => {});
res.json({
success: true,
message: 'Semua antrean tugas Add WAN (dan Wi-Fi) berhasil dikirimkan ke GenieACS.',
trackingSupported: false
});
} catch (err) {
res.json({ success: false, message: err.message });
}
});
// GET /admin/acs/search - Redirect to main page with query params
router.get('/search', requireAdminSession, async (req, res) => {
const { q, acs } = req.query;
const params = new URLSearchParams();
if (q) params.append('q', q);
if (acs) params.append('acs', acs);
res.redirect(`/admin/acs?${params.toString()}`);
});
module.exports = router;