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
This commit is contained in:
oxpoe committed 2026-10-09 05:51:21 +07:00
commit eaf05f2811
280 files changed
+123975

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
// Modul untuk menambahkan nomor pelanggan ke tag GenieACS
const axios = require('axios');
require('dotenv').config();
const { logger } = require('./logger');
let sock = null;
// Fungsi untuk set instance sock
function setSock(sockInstance) {
sock = sockInstance;
}
// Fungsi untuk menambahkan tag pelanggan ke perangkat GenieACS
async function addCustomerTag(remoteJid, params) {
try {
// Ekstrak parameter
if (params.length < 2) {
await sock.sendMessage(remoteJid, {
text: `❌ *Format Salah*\n\nFormat yang benar:\naddtag [device_id] [nomor_pelanggan]\n\nContoh:\naddtag 202BC1-BM632w-000000 081234567890`
});
return;
}
const [deviceId, customerNumber] = params;
// Validasi nomor pelanggan
if (!customerNumber || !/^\d{8,}$/.test(customerNumber)) {
await sock.sendMessage(remoteJid, {
text: `❌ *Nomor Pelanggan Tidak Valid*\n\nNomor pelanggan harus berupa angka minimal 8 digit.`
});
return;
}
// Dapatkan URL GenieACS
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
if (!genieacsUrl) {
await sock.sendMessage(remoteJid, {
text: `❌ *Konfigurasi tidak lengkap*\n\nURL GenieACS tidak dikonfigurasi`
});
return;
}
// Kirim pesan bahwa proses sedang berlangsung
await sock.sendMessage(remoteJid, {
text: `⏳ *Proses penambahan tag*\n\nSedang menambahkan nomor ${customerNumber} ke perangkat ${deviceId}...`
});
// Persiapkan URL untuk menambahkan tag
const tagUrl = `${genieacsUrl}/devices/${deviceId}/tags/${customerNumber}`;
// Kirim request ke GenieACS untuk menambahkan tag
try {
const response = await axios.post(
tagUrl,
{},
{
auth: {
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
}
}
);
logger.info(`Tag response: ${response.status}`);
// Kirim pesan sukses
let successMessage = `✅ *Tag berhasil ditambahkan*\n\n`;
successMessage += `📱 *Nomor Pelanggan:* ${customerNumber}\n`;
successMessage += `🖥️ *Device ID:* ${deviceId}\n\n`;
successMessage += `Pelanggan sekarang dapat menggunakan WhatsApp dan Web Portal dengan nomor tersebut.`;
await sock.sendMessage(remoteJid, { text: successMessage });
} catch (error) {
logger.error('Error adding tag to GenieACS:', error);
let errorMessage = `❌ *Gagal menambahkan tag*\n\n`;
if (error.response) {
errorMessage += `Status: ${error.response.status}\n`;
errorMessage += `Pesan: ${JSON.stringify(error.response.data)}\n`;
} else {
errorMessage += `Error: ${error.message}\n`;
}
await sock.sendMessage(remoteJid, { text: errorMessage });
}
} catch (error) {
logger.error('Error in addCustomerTag:', error);
await sock.sendMessage(remoteJid, {
text: `❌ *Error*\n\nTerjadi kesalahan saat menambahkan tag: ${error.message}`
});
}
}
// Fungsi untuk mencari perangkat berdasarkan ID
async function findDeviceById(deviceId) {
try {
// Dapatkan URL GenieACS
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
if (!genieacsUrl) {
logger.error('GenieACS URL not configured');
return null;
}
// Buat query untuk mencari perangkat berdasarkan ID
const queryObj = { "_id": deviceId };
const queryJson = JSON.stringify(queryObj);
const encodedQuery = encodeURIComponent(queryJson);
// Ambil perangkat dari GenieACS
const response = await axios.get(`${genieacsUrl}/devices/?query=${encodedQuery}`, {
auth: {
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
},
headers: {
'Accept': 'application/json'
}
});
if (response.data && response.data.length > 0) {
return response.data[0];
}
return null;
} catch (error) {
logger.error(`Error finding device by ID: ${error.message}`);
return null;
}
}
// Fungsi untuk menambahkan tag berdasarkan PPPoE Username
async function addTagByPPPoE(remoteJid, params, sock) {
try {
// Ekstrak parameter
if (params.length < 2) {
await sock.sendMessage(remoteJid, {
text: `❌ *Format Salah*\n\nFormat yang benar:\naddpppoe_tag [pppoe_username] [nomor_pelanggan]\n\nContoh:\naddpppoe_tag user123 081234567890`
});
return;
}
const [pppoeUsername, customerNumber] = params;
// Validasi nomor pelanggan
if (!customerNumber || !/^\d{8,}$/.test(customerNumber)) {
await sock.sendMessage(remoteJid, {
text: `❌ *Nomor Pelanggan Tidak Valid*\n\nNomor pelanggan harus berupa angka minimal 8 digit.`
});
return;
}
// Dapatkan URL GenieACS
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
if (!genieacsUrl) {
await sock.sendMessage(remoteJid, {
text: `❌ *Konfigurasi tidak lengkap*\n\nURL GenieACS tidak dikonfigurasi`
});
return;
}
// Kirim pesan bahwa proses sedang berlangsung
await sock.sendMessage(remoteJid, {
text: `⏳ *Proses pencarian perangkat*\n\nSedang mencari perangkat dengan PPPoE Username ${pppoeUsername}...`
});
// Cari perangkat berdasarkan PPPoE Username
const device = await findDeviceByPPPoE(pppoeUsername);
if (!device) {
await sock.sendMessage(remoteJid, {
text: `❌ *Perangkat Tidak Ditemukan*\n\nTidak dapat menemukan perangkat dengan PPPoE Username ${pppoeUsername}`
});
return;
}
// Dapatkan device ID
const deviceId = device._id;
// Kirim pesan bahwa proses sedang berlangsung
await sock.sendMessage(remoteJid, {
text: `⏳ *Proses penambahan tag*\n\nSedang menambahkan nomor ${customerNumber} ke perangkat ${deviceId}...`
});
// Persiapkan URL untuk menambahkan tag
const tagUrl = `${genieacsUrl}/devices/${deviceId}/tags/${customerNumber}`;
// Kirim request ke GenieACS untuk menambahkan tag
try {
const response = await axios.post(
tagUrl,
{},
{
auth: {
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
}
}
);
logger.info(`Tag response: ${response.status}`);
// Kirim pesan sukses
let successMessage = `✅ *Tag berhasil ditambahkan*\n\n`;
successMessage += `📱 *Nomor Pelanggan:* ${customerNumber}\n`;
successMessage += `👤 *PPPoE Username:* ${pppoeUsername}\n`;
successMessage += `🖥️ *Device ID:* ${deviceId}\n\n`;
successMessage += `Pelanggan sekarang dapat menggunakan WhatsApp dan Web Portal dengan nomor tersebut.`;
await sock.sendMessage(remoteJid, { text: successMessage });
} catch (error) {
logger.error('Error adding tag to GenieACS:', error);
let errorMessage = `❌ *Gagal menambahkan tag*\n\n`;
if (error.response) {
errorMessage += `Status: ${error.response.status}\n`;
errorMessage += `Pesan: ${JSON.stringify(error.response.data)}\n`;
} else {
errorMessage += `Error: ${error.message}\n`;
}
await sock.sendMessage(remoteJid, { text: errorMessage });
}
} catch (error) {
logger.error('Error in addTagByPPPoE:', error);
await sock.sendMessage(remoteJid, {
text: `❌ *Error*\n\nTerjadi kesalahan saat menambahkan tag: ${error.message}`
});
}
}
// Fungsi untuk mencari perangkat berdasarkan PPPoE Username
async function findDeviceByPPPoE(pppoeUsername) {
try {
// Dapatkan URL GenieACS
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
if (!genieacsUrl) {
logger.error('GenieACS URL not configured');
return null;
}
// Parameter paths untuk PPPoE Username
const pppUsernamePaths = [
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username._value',
'VirtualParameters.pppoeUsername',
'VirtualParameters.pppUsername'
];
// Buat query untuk mencari perangkat berdasarkan PPPoE Username
// Kita perlu membuat query yang mencari di semua kemungkinan path
const queryObj = { $or: [] };
// Tambahkan semua kemungkinan path ke query
for (const path of pppUsernamePaths) {
const pathQuery = {};
pathQuery[path] = pppoeUsername;
queryObj.$or.push(pathQuery);
}
const queryJson = JSON.stringify(queryObj);
const encodedQuery = encodeURIComponent(queryJson);
// Ambil perangkat dari GenieACS
const response = await axios.get(`${genieacsUrl}/devices/?query=${encodedQuery}`, {
auth: {
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
},
headers: {
'Accept': 'application/json'
}
});
if (response.data && response.data.length > 0) {
return response.data[0];
}
return null;
} catch (error) {
logger.error(`Error finding device by PPPoE Username: ${error.message}`);
return null;
}
}
module.exports = {
setSock,
addCustomerTag,
findDeviceById,
addTagByPPPoE
};
+1076
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1195
View File
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
const fs = require('fs');
const path = require('path');
const SUPPORTED_LANGS = new Set(['id', 'en']);
const FALLBACK_LANG = 'id';
function loadLocale(lang) {
try {
const filePath = path.join(__dirname, `../locales/${lang}.json`);
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return {};
}
}
const dictionaries = {
id: loadLocale('id'),
en: loadLocale('en')
};
function normalizeLang(input) {
const lang = String(input || '').trim().toLowerCase();
return SUPPORTED_LANGS.has(lang) ? lang : FALLBACK_LANG;
}
function getNestedValue(obj, keyPath) {
const keys = String(keyPath || '').split('.');
let cur = obj;
for (const k of keys) {
if (!cur || typeof cur !== 'object' || !(k in cur)) return undefined;
cur = cur[k];
}
return cur;
}
function t(lang, key, fallback = '') {
const useLang = normalizeLang(lang);
const fromLang = getNestedValue(dictionaries[useLang], key);
if (fromLang !== undefined) return fromLang;
const fromFallback = getNestedValue(dictionaries[FALLBACK_LANG], key);
if (fromFallback !== undefined) return fromFallback;
return fallback || key;
}
module.exports = {
SUPPORTED_LANGS,
FALLBACK_LANG,
normalizeLang,
t
};
+68
View File
@@ -0,0 +1,68 @@
// Modul logger untuk aplikasi
const winston = require('winston');
const path = require('path');
const fs = require('fs');
// Buat direktori logs jika belum ada
const logsDir = path.join(process.cwd(), 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Format untuk log
const logFormat = winston.format.printf(({ level, message, timestamp }) => {
return `${timestamp} ${level}: ${message}`;
});
// Konfigurasi logger
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
exitOnError: false, // Jangan exit saat uncaught exception (e.g. koneksi MikroTik gagal)
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
logFormat
),
transports: [
// Log ke file
new winston.transports.File({
filename: path.join(logsDir, 'error.log'),
level: 'error'
}),
new winston.transports.File({
filename: path.join(logsDir, 'combined.log')
}),
// Log ke console
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
logFormat
)
})
],
exceptionHandlers: [
new winston.transports.File({
filename: path.join(logsDir, 'exceptions.log')
}),
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
logFormat
)
})
],
rejectionHandlers: [
new winston.transports.File({
filename: path.join(logsDir, 'rejections.log')
})
]
});
module.exports = { logger };
+245
View File
@@ -0,0 +1,245 @@
/**
* Settings Audit Trail
* Log semua perubahan settings untuk compliance & debugging
*/
const fs = require('fs');
const path = require('path');
const { logger } = require('./logger');
const { isSensitiveField, maskValue } = require('./settingsEncryption');
const AUDIT_LOG_DIR = path.join(__dirname, '../logs/settings-audit');
const AUDIT_LOG_FILE = path.join(AUDIT_LOG_DIR, 'settings-changes.jsonl');
// Ensure audit log directory exists
if (!fs.existsSync(AUDIT_LOG_DIR)) {
fs.mkdirSync(AUDIT_LOG_DIR, { recursive: true });
}
/**
* Log settings change
*/
function logSettingsChange(actor, changes, metadata = {}) {
try {
const timestamp = new Date().toISOString();
// Mask sensitive values
const maskedChanges = {};
Object.keys(changes).forEach(field => {
if (isSensitiveField(field)) {
maskedChanges[field] = {
oldValue: maskValue(changes[field].oldValue),
newValue: maskValue(changes[field].newValue)
};
} else {
maskedChanges[field] = changes[field];
}
});
const logEntry = {
timestamp,
actor: actor || 'system',
changes: maskedChanges,
ip: metadata.ip || 'unknown',
userAgent: metadata.userAgent || 'unknown',
metadata
};
// Write to JSONL file
fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(logEntry) + '\n', 'utf-8');
logger.info(`[settings-audit] Settings changed by ${actor}`, {
changes: Object.keys(maskedChanges),
timestamp
});
return true;
} catch (error) {
logger.error(`[settings-audit] Error logging settings change: ${error.message}`);
return false;
}
}
/**
* Get settings change history
*/
function getChangeHistory(limit = 100) {
try {
if (!fs.existsSync(AUDIT_LOG_FILE)) return [];
const lines = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8').split('\n').filter(l => l.trim());
const entries = lines.map(line => JSON.parse(line));
// Return latest entries
return entries.slice(-limit).reverse();
} catch (error) {
logger.error(`[settings-audit] Error reading change history: ${error.message}`);
return [];
}
}
/**
* Get changes by actor
*/
function getChangesByActor(actor, limit = 50) {
try {
const history = getChangeHistory(1000);
return history.filter(entry => entry.actor === actor).slice(0, limit);
} catch (error) {
logger.error(`[settings-audit] Error filtering by actor: ${error.message}`);
return [];
}
}
/**
* Get changes by field
*/
function getChangesByField(field, limit = 50) {
try {
const history = getChangeHistory(1000);
return history.filter(entry => entry.changes[field]).slice(0, limit);
} catch (error) {
logger.error(`[settings-audit] Error filtering by field: ${error.message}`);
return [];
}
}
/**
* Get changes in date range
*/
function getChangesByDateRange(startDate, endDate, limit = 100) {
try {
const history = getChangeHistory(1000);
const start = new Date(startDate).getTime();
const end = new Date(endDate).getTime();
return history.filter(entry => {
const entryTime = new Date(entry.timestamp).getTime();
return entryTime >= start && entryTime <= end;
}).slice(0, limit);
} catch (error) {
logger.error(`[settings-audit] Error filtering by date range: ${error.message}`);
return [];
}
}
/**
* Export audit log
*/
function exportAuditLog(format = 'json') {
try {
const history = getChangeHistory(10000);
if (format === 'csv') {
// Convert to CSV
const headers = ['Timestamp', 'Actor', 'Field', 'Old Value', 'New Value', 'IP'];
const rows = [];
history.forEach(entry => {
Object.keys(entry.changes).forEach(field => {
rows.push([
entry.timestamp,
entry.actor,
field,
entry.changes[field].oldValue,
entry.changes[field].newValue,
entry.ip
]);
});
});
return {
headers,
rows,
data: [headers, ...rows].map(row => row.join(',')).join('\n')
};
}
// Default JSON format
return {
format: 'json',
count: history.length,
data: history
};
} catch (error) {
logger.error(`[settings-audit] Error exporting audit log: ${error.message}`);
return null;
}
}
/**
* Clear old audit logs (retention policy)
*/
function clearOldLogs(daysToKeep = 90) {
try {
if (!fs.existsSync(AUDIT_LOG_FILE)) return;
const lines = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8').split('\n').filter(l => l.trim());
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const recentLines = lines.filter(line => {
try {
const entry = JSON.parse(line);
return new Date(entry.timestamp) > cutoffDate;
} catch {
return false;
}
});
fs.writeFileSync(AUDIT_LOG_FILE, recentLines.join('\n') + '\n', 'utf-8');
logger.info(`[settings-audit] Cleared old logs, kept ${recentLines.length} entries`);
return true;
} catch (error) {
logger.error(`[settings-audit] Error clearing old logs: ${error.message}`);
return false;
}
}
/**
* Get audit statistics
*/
function getAuditStats() {
try {
const history = getChangeHistory(10000);
const stats = {
totalChanges: history.length,
uniqueActors: new Set(history.map(e => e.actor)).size,
changedFields: new Set(),
changesByActor: {},
changesByField: {},
lastChange: history[0] || null
};
history.forEach(entry => {
// Count by actor
stats.changesByActor[entry.actor] = (stats.changesByActor[entry.actor] || 0) + 1;
// Count by field
Object.keys(entry.changes).forEach(field => {
stats.changedFields.add(field);
stats.changesByField[field] = (stats.changesByField[field] || 0) + 1;
});
});
stats.changedFields = Array.from(stats.changedFields);
return stats;
} catch (error) {
logger.error(`[settings-audit] Error getting audit stats: ${error.message}`);
return null;
}
}
module.exports = {
logSettingsChange,
getChangeHistory,
getChangesByActor,
getChangesByField,
getChangesByDateRange,
exportAuditLog,
clearOldLogs,
getAuditStats,
AUDIT_LOG_FILE
};
+179
View File
@@ -0,0 +1,179 @@
/**
* Settings Encryption & Decryption
* Untuk encrypt/decrypt sensitive fields di settings.json
*/
const crypto = require('crypto');
const { logger } = require('./logger');
// Master key untuk encryption (bisa dari environment variable)
// PENTING: Ganti dengan key yang aman di production
const MASTER_KEY = process.env.SETTINGS_MASTER_KEY || 'default-master-key-change-this-in-production';
function getMasterKeyForString(keyStr) {
const hash = crypto.createHash('sha256');
hash.update(keyStr || '');
return hash.digest();
}
// Normalize master key ke 32 bytes untuk AES-256
function getMasterKey() {
return getMasterKeyForString(MASTER_KEY);
}
// List field yang harus di-encrypt
const SENSITIVE_FIELDS = [
'genieacs_password',
'admin_password',
'admin_api_key',
'mikrotik_password',
'telegram_bot_token',
'session_secret'
];
/**
* Encrypt value menggunakan AES-256-GCM
*/
function encryptValue(value) {
if (!value || typeof value !== 'string') return value;
try {
const masterKey = getMasterKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
} catch (error) {
logger.error(`[encryption] Error encrypting value: ${error.message}`);
return value;
}
}
function decryptWithKey(encryptedValue, key) {
const parts = encryptedValue.split(':');
if (parts.length !== 4) {
throw new Error('Invalid encrypted value format');
}
const [prefix, ivHex, authTagHex, encrypted] = parts;
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Decrypt value
*/
function decryptValue(encryptedValue) {
if (!encryptedValue || typeof encryptedValue !== 'string') return encryptedValue;
if (!encryptedValue.startsWith('enc:')) return encryptedValue; // Belum di-encrypt
const primaryKey = getMasterKey();
try {
return decryptWithKey(encryptedValue, primaryKey);
} catch (error) {
// If decryption fails, and primaryKey is not the default fallback key, try the fallback key
const defaultSecret = 'default-master-key-change-this-in-production';
if (MASTER_KEY !== defaultSecret) {
try {
const fallbackKey = getMasterKeyForString(defaultSecret);
const decrypted = decryptWithKey(encryptedValue, fallbackKey);
logger.info('[encryption] Successfully decrypted value using default fallback key. It will be re-encrypted using the new key upon next save.');
return decrypted;
} catch (fallbackError) {
// Both primary key and fallback key failed
logger.error(`[encryption] Error decrypting value (both primary and fallback keys failed): ${fallbackError.message}`);
}
} else {
logger.error(`[encryption] Error decrypting value: ${error.message}`);
}
return encryptedValue;
}
}
/**
* Encrypt sensitive fields dalam settings object
*/
function encryptSettings(settings) {
const encrypted = { ...settings };
SENSITIVE_FIELDS.forEach(field => {
if (encrypted[field]) {
encrypted[field] = encryptValue(encrypted[field]);
}
});
return encrypted;
}
/**
* Decrypt sensitive fields dalam settings object
*/
function decryptSettings(settings) {
const decrypted = { ...settings };
SENSITIVE_FIELDS.forEach(field => {
if (decrypted[field]) {
decrypted[field] = decryptValue(decrypted[field]);
}
});
return decrypted;
}
/**
* Mask sensitive values untuk display (show first 4 & last 4 chars)
*/
function maskValue(value) {
if (!value || typeof value !== 'string') return value;
if (value.length <= 8) return '****';
const first = value.substring(0, 4);
const last = value.substring(value.length - 4);
return `${first}****${last}`;
}
/**
* Get masked settings untuk display di UI
*/
function getMaskedSettings(settings) {
const masked = { ...settings };
SENSITIVE_FIELDS.forEach(field => {
if (masked[field]) {
masked[field] = maskValue(masked[field]);
}
});
return masked;
}
/**
* Check apakah field adalah sensitive
*/
function isSensitiveField(field) {
return SENSITIVE_FIELDS.includes(field);
}
module.exports = {
encryptValue,
decryptValue,
encryptSettings,
decryptSettings,
maskValue,
getMaskedSettings,
isSensitiveField,
SENSITIVE_FIELDS
};
+354
View File
@@ -0,0 +1,354 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { logger } = require('./logger');
let runtimeSessionSecret = null;
function getSecureSessionSecretFallback() {
if (!runtimeSessionSecret) {
runtimeSessionSecret = crypto.randomBytes(32).toString('hex');
}
return runtimeSessionSecret;
}
// Cache untuk settings dengan timestamp
let settingsCache = null;
let settingsCacheTime = 0;
const CACHE_DURATION = 2000; // 2 detik
// File system watcher untuk auto-reload settings
const settingsPath = path.join(__dirname, '../settings.json');
let watcher = null;
// Helper untuk baca settings.json secara dinamis
function getSettings() {
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) || {};
// Secure fallback for session_secret
const defaultSecret = 'rahasia-portal-pelanggan-default-ganti-ini';
if (!settings.session_secret || settings.session_secret === defaultSecret) {
settings.session_secret = getSecureSessionSecretFallback();
}
const fallbackTz = 'Asia/Jakarta';
const tz = typeof settings.timezone === 'string' ? settings.timezone.trim() : '';
if (!tz) {
settings.timezone = fallbackTz;
return settings;
}
try {
new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date());
settings.timezone = tz;
} catch (e) {
settings.timezone = fallbackTz;
}
return settings;
} catch (error) {
logger.error(`[settings] Error reading settings.json: ${error.message}`);
// Kembalikan cache terakhir yang valid (hindari kehilangan setting karena file setengah tertulis)
return settingsCache || {};
}
}
// Helper untuk baca settings.json dengan cache
function getSettingsWithCache() {
const now = Date.now();
if (!settingsCache || (now - settingsCacheTime) > CACHE_DURATION) {
settingsCache = getSettings();
settingsCacheTime = now;
}
return settingsCache;
}
// Helper untuk mendapatkan nilai setting dengan fallback
function getSetting(key, defaultValue = null) {
const settings = getSettingsWithCache();
return settings[key] !== undefined ? settings[key] : defaultValue;
}
// Helper untuk mendapatkan multiple settings
function getSettingsByKeys(keys) {
const settings = getSettingsWithCache();
const result = {};
keys.forEach(key => {
result[key] = settings[key];
});
return result;
}
// File system watcher untuk auto-reload settings
function startSettingsWatcher() {
try {
// Hapus watcher lama jika ada
if (watcher) {
watcher.close();
}
// Buat watcher baru
watcher = fs.watch(settingsPath, (eventType, filename) => {
if (eventType !== 'change') return;
// Di Windows `filename` sering null; hanya abaikan jika jelas bukan settings.json
if (filename != null && filename !== 'settings.json') return;
settingsCache = null;
settingsCacheTime = 0;
try {
const s = getSettingsWithCache();
const port = s.server_port ?? 4555;
const host = s.server_host || 'localhost';
const gurl = s.genieacs_url || '(tidak diatur)';
const company = s.company_header || '(default)';
logger.info(`[settings] settings.json dimuat ulang — port ${port}, host ${host}, company: ${company}, GenieACS: ${gurl}`);
} catch (error) {
logger.error(`[settings] Gagal memuat ulang settings.json: ${error.message}`);
}
});
logger.info('[settings] Memantau perubahan settings.json');
} catch (error) {
logger.error(`[settings] Error starting settings watcher: ${error.message}`);
}
}
// Mulai watcher saat modul dimuat
startSettingsWatcher();
// Menyimpan pengaturan ke settings.json
function saveSettings(newSettings) {
try {
const currentSettings = getSettings();
const updatedSettings = { ...currentSettings, ...newSettings };
// Cadangan rolling versi sebelumnya (mencegah kehilangan setting bila tertimpa salah)
try {
if (fs.existsSync(settingsPath)) {
fs.copyFileSync(settingsPath, settingsPath + '.prev');
}
} catch (e) { /* abaikan */ }
const tmpPath = settingsPath + '.tmp-' + process.pid;
fs.writeFileSync(tmpPath, JSON.stringify(updatedSettings, null, 2), 'utf-8');
fs.renameSync(tmpPath, settingsPath); // atomik: pembaca tidak pernah melihat file setengah jadi
settingsCache = updatedSettings;
settingsCacheTime = Date.now();
return true;
} catch (error) {
logger.error(`[settings] Error saving settings.json: ${error.message}`);
return false;
}
}
/**
* Helper untuk mendapatkan waktu sekarang dalam format lokal
* sesuai timezone yang diatur di settings.json
*/
function getNowLocal() {
const tz = getSetting('timezone', 'Asia/Jakarta');
const now = new Date();
const options = {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam)
};
const formatter = new Intl.DateTimeFormat('en-US', options);
const parts = formatter.formatToParts(now);
const p = {};
parts.forEach(part => p[part.type] = part.value);
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
}
/**
* Helper untuk mendapatkan objek Date yang sudah disesuaikan dengan timezone di settings.
* Mengembalikan objek Date yang "angkanya" sudah sesuai dengan waktu lokal.
*/
function getCurrentDateInTimezone() {
const tz = getSetting('timezone', 'Asia/Jakarta');
const now = new Date();
// Ambil string format ISO lokal
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam)
});
const parts = formatter.formatToParts(now);
const p = {};
parts.forEach(part => p[part.type] = part.value);
// Buat objek Date baru dengan nilai lokal tersebut
return new Date(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`);
}
/**
* Mendapatkan info waktu sekarang (year, month, day, dll) dalam timezone yang diatur.
*/
function getCurrentTimeInfo() {
const tz = getSetting('timezone', 'Asia/Jakarta');
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: 'numeric', second: 'numeric',
hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam)
});
const parts = formatter.formatToParts(now);
const p = {};
parts.forEach(part => p[part.type] = part.value);
return {
year: parseInt(p.year),
month: parseInt(p.month),
day: parseInt(p.day),
hour: parseInt(p.hour),
minute: parseInt(p.minute),
second: parseInt(p.second)
};
}
/**
* Mendapatkan string ISO-like tapi dalam waktu lokal (bukan UTC).
* Berguna untuk timestamp log/backup.
*/
function getNowLocalISO() {
const info = getCurrentTimeInfo();
const pad = (n) => String(n).padStart(2, '0');
return `${info.year}-${pad(info.month)}-${pad(info.day)}T${pad(info.hour)}:${pad(info.minute)}:${pad(info.second)}`;
}
/**
* Memparse string tanggal (YYYY-MM-DD HH:mm:ss) menjadi objek Date
* dengan asumsi string tersebut adalah waktu lokal sesuai setting timezone.
*/
function parseDateInTimezone(dateStr) {
if (!dateStr) return null;
const tz = getSetting('timezone', 'Asia/Jakarta');
const date = new Date(dateStr.replace(' ', 'T'));
if (isNaN(date.getTime())) return null;
const localDateStr = date.toLocaleString('en-US', { timeZone: tz, hourCycle: 'h23' });
const localDate = new Date(localDateStr);
const diff = localDate.getTime() - date.getTime();
return new Date(date.getTime() - diff);
}
function formatDateLocal(date) {
if (!date) return '-';
const tz = getSetting('timezone', 'Asia/Jakarta');
let d;
if (typeof date === 'string') {
d = parseDateInTimezone(date);
} else {
d = typeof date === 'number' ? new Date(date) : date;
}
if (!d || isNaN(d.getTime())) return '-';
return d.toLocaleString('id-ID', { timeZone: tz });
}
/**
* Helper untuk memformat objek Date menjadi string waktu lokal (hanya Jam:Menit)
*/
function formatTimeLocal(date) {
if (!date) return '-';
const tz = getSetting('timezone', 'Asia/Jakarta');
let d;
if (typeof date === 'string') {
d = parseDateInTimezone(date);
} else {
d = typeof date === 'number' ? new Date(date) : date;
}
if (!d || isNaN(d.getTime())) return '-';
return d.toLocaleTimeString('id-ID', { timeZone: tz, hour: '2-digit', minute: '2-digit' });
}
/**
* Memastikan semua setting wajib ada di settings.json dengan default value.
* Dijalankan saat startup aplikasi untuk migrasi/inisialisasi setting baru.
*/
function ensureDefaultSettings() {
try {
const currentSettings = getSettings();
let needsSave = false;
// Default settings yang wajib ada
const defaultSettings = {
// RADIUS Server settings (ditambahkan untuk update dari GitHub)
radius_enabled: '0',
radius_secret: 'secret123',
radius_auth_port: '1812',
radius_acct_port: '1813',
radius_isolir_action: 'pool',
radius_isolir_pool: 'isolir',
radius_limit_simultaneous: '1',
radius_default_rate_limit: '5M/10M',
radius_isolir_rate_limit: '512k/512k',
radius_isolir_ip_pool_enabled: '1',
radius_isolir_ip_pool_start: '10.10.99.2',
radius_isolir_ip_pool_end: '10.10.99.254',
radius_ip_pool_enabled: '1',
radius_ip_pool_start: '10.10.10.2',
radius_ip_pool_end: '10.10.10.254',
radius_framed_pool: 'pool-pppoe',
radius_send_group: '0',
webhook_notif_retention_enabled: '1',
webhook_notif_retention_days: '30',
finance_ppn_pct: '11',
finance_pph_pct: '22',
finance_uso_pct: '1.75',
finance_bhp_pct: '0.5',
finance_revenue_includes_ppn: '0'
};
// Cek dan tambahkan setting yang belum ada
for (const [key, defaultValue] of Object.entries(defaultSettings)) {
if (currentSettings[key] === undefined) {
currentSettings[key] = defaultValue;
needsSave = true;
logger.info(`[settings] Added missing setting: ${key} = ${defaultValue}`);
}
}
// Simpan jika ada perubahan
if (needsSave) {
fs.writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), 'utf-8');
settingsCache = currentSettings;
settingsCacheTime = Date.now();
logger.info('[settings] settings.json updated with default RADIUS settings');
}
return needsSave;
} catch (error) {
logger.error(`[settings] Error ensuring default settings: ${error.message}`);
return false;
}
}
module.exports = {
getSettings,
getSettingsWithCache,
getSetting,
getSettingsByKeys,
saveSettings,
getNowLocal,
formatDateLocal,
formatTimeLocal,
getCurrentDateInTimezone,
getCurrentTimeInfo,
getNowLocalISO,
parseDateInTimezone,
startSettingsWatcher,
ensureDefaultSettings
};
+278
View File
@@ -0,0 +1,278 @@
/**
* Settings Validation Schema
* Validate setiap field sebelum di-save ke settings.json
*/
const { logger } = require('./logger');
// Validation rules untuk setiap field
const VALIDATION_RULES = {
// Server Configuration
server_port: {
type: 'number',
min: 1024,
max: 65535,
required: true,
description: 'Port server (1024-65535)'
},
server_host: {
type: 'string',
required: true,
description: 'Host server'
},
session_secret: {
type: 'string',
minLength: 32,
required: true,
description: 'Session secret (min 32 karakter)'
},
// Company Information
company_header: {
type: 'string',
maxLength: 100,
required: true,
description: 'Nama perusahaan'
},
public_base_url: {
type: 'string',
pattern: /^https?:\/\/.+/,
description: 'URL publik aplikasi (http://... atau https://...)'
},
company_manager: {
type: 'string',
maxLength: 100,
description: 'Nama manager'
},
company_phone: {
type: 'string',
pattern: /^[0-9\-\+\s]+$/,
description: 'Nomor telepon perusahaan'
},
company_email: {
type: 'string',
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
description: 'Email perusahaan'
},
company_address: {
type: 'string',
maxLength: 500,
description: 'Alamat perusahaan'
},
footer_info: {
type: 'string',
maxLength: 200,
description: 'Footer info'
},
operational_hours: {
type: 'string',
maxLength: 200,
description: 'Jam operasional'
},
// Admin Credentials
admin_username: {
type: 'string',
minLength: 3,
maxLength: 50,
required: true,
description: 'Username admin'
},
admin_password: {
type: 'string',
minLength: 6,
required: true,
description: 'Password admin (min 6 karakter)'
},
admin_api_key: {
type: 'string',
minLength: 10,
description: 'API key admin'
},
// GenieACS Configuration
genieacs_url: {
type: 'string',
pattern: /^https?:\/\/.+/,
description: 'URL GenieACS (http://...)'
},
genieacs_username: {
type: 'string',
description: 'Username GenieACS'
},
genieacs_password: {
type: 'string',
description: 'Password GenieACS'
},
// MikroTik Configuration
mikrotik_host: {
type: 'string',
pattern: /^(\d{1,3}\.){3}\d{1,3}$|^[a-zA-Z0-9\-\.]+$/,
description: 'Host MikroTik (IP atau hostname)'
},
mikrotik_user: {
type: 'string',
minLength: 1,
description: 'Username MikroTik'
},
mikrotik_password: {
type: 'string',
minLength: 1,
description: 'Password MikroTik'
},
mikrotik_port: {
type: 'number',
min: 1024,
max: 65535,
description: 'Port MikroTik'
},
isolir_day: {
type: 'number',
min: 1,
max: 365,
description: 'Hari isolir (1-365)'
},
// WhatsApp Configuration
whatsapp_enabled: {
type: 'boolean',
description: 'Enable WhatsApp'
},
whatsapp_auth_folder: {
type: 'string',
description: 'Folder auth WhatsApp'
},
whatsapp_broadcast_delay: {
type: 'number',
min: 10,
max: 5000,
description: 'Delay broadcast WhatsApp (ms)'
},
// Telegram Configuration
telegram_enabled: {
type: 'boolean',
description: 'Enable Telegram'
},
telegram_bot_token: {
type: 'string',
description: 'Telegram bot token'
},
telegram_admin_id: {
type: 'string',
pattern: /^\d+$/,
description: 'Telegram admin ID (numeric)'
},
// Tripay Configuration
office_lat: {
type: 'string',
pattern: /^-?\d+(\.\d+)?$|^$/,
description: 'Latitude kantor'
},
office_lng: {
type: 'string',
pattern: /^-?\d+(\.\d+)?$|^$/,
description: 'Longitude kantor'
},
// Other
auto_backup_enabled: {
type: 'boolean',
description: 'Enable auto backup'
},
login_otp_enabled: {
type: 'boolean',
description: 'Enable OTP login'
}
};
/**
* Validate single value
*/
function validateValue(field, value, rule) {
// Trim string values
let trimmedValue = value;
if (typeof value === 'string') {
trimmedValue = value.trim();
}
// Check type
if (rule.type === 'number') {
if (typeof trimmedValue !== 'number') return `${field} harus berupa angka`;
if (rule.min !== undefined && trimmedValue < rule.min) return `${field} minimal ${rule.min}`;
if (rule.max !== undefined && trimmedValue > rule.max) return `${field} maksimal ${rule.max}`;
}
if (rule.type === 'string') {
if (typeof trimmedValue !== 'string') return `${field} harus berupa string`;
if (rule.minLength && trimmedValue.length < rule.minLength) return `${field} minimal ${rule.minLength} karakter`;
if (rule.maxLength && trimmedValue.length > rule.maxLength) return `${field} maksimal ${rule.maxLength} karakter`;
if (rule.pattern && !rule.pattern.test(trimmedValue)) return `${field} format tidak valid`;
if (rule.enum && !rule.enum.includes(trimmedValue)) return `${field} harus salah satu dari: ${rule.enum.join(', ')}`;
}
if (rule.type === 'boolean') {
if (typeof trimmedValue !== 'boolean') return `${field} harus berupa boolean`;
}
return null; // Valid
}
/**
* Validate settings object
*/
function validateSettings(settings) {
const errors = [];
Object.keys(settings).forEach(field => {
const value = settings[field];
const rule = VALIDATION_RULES[field];
// Skip jika tidak ada rule (field baru atau optional)
if (!rule) return;
// Check required
if (rule.required && (value === undefined || value === null || value === '')) {
errors.push(`${field} wajib diisi`);
return;
}
// Skip validation jika value kosong dan tidak required
if (!rule.required && (value === undefined || value === null || value === '')) {
return;
}
// Validate value
const error = validateValue(field, value, rule);
if (error) errors.push(error);
});
return {
valid: errors.length === 0,
errors
};
}
/**
* Get validation rule untuk field
*/
function getFieldRule(field) {
return VALIDATION_RULES[field] || null;
}
/**
* Get all validation rules
*/
function getAllRules() {
return VALIDATION_RULES;
}
module.exports = {
validateValue,
validateSettings,
getFieldRule,
getAllRules,
VALIDATION_RULES
};
+48
View File
@@ -0,0 +1,48 @@
/* config/sqliteSessionStore.js
Session store berbasis SQLite (better-sqlite3) agar sesi PERSISTEN
lintas restart proses — penting untuk alur OTP (pending_login di session)
dan agar pelanggan tidak terlempar ke halaman login saat server restart. */
const session = require('express-session');
module.exports = function createSqliteSessionStore(db) {
const Store = session.Store;
class SqliteSessionStore extends Store {
constructor() {
super();
db.exec(`CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at INTEGER
)`);
this._get = db.prepare('SELECT data, expires_at FROM sessions WHERE sid = ?');
this._set = db.prepare('INSERT OR REPLACE INTO sessions (sid, data, expires_at) VALUES (?, ?, ?)');
this._del = db.prepare('DELETE FROM sessions WHERE sid = ?');
this._clean = db.prepare('DELETE FROM sessions WHERE expires_at IS NOT NULL AND expires_at < ?');
const timer = setInterval(() => { try { this._clean.run(Date.now()); } catch (e) {} }, 15 * 60 * 1000);
if (timer.unref) timer.unref();
}
get(sid, cb) {
try {
const row = this._get.get(sid);
if (!row) return cb(null, null);
if (row.expires_at && row.expires_at < Date.now()) { this._del.run(sid); return cb(null, null); }
return cb(null, JSON.parse(row.data));
} catch (e) { return cb(e); }
}
set(sid, sess, cb) {
try {
let exp = null;
const c = sess && sess.cookie;
if (c && c.expires) exp = new Date(c.expires).getTime();
else if (c && c.maxAge) exp = Date.now() + c.maxAge;
this._set.run(sid, JSON.stringify(sess), exp);
if (cb) cb(null);
} catch (e) { if (cb) cb(e); }
}
destroy(sid, cb) { try { this._del.run(sid); if (cb) cb(null); } catch (e) { if (cb) cb(e); } }
touch(sid, sess, cb) { return this.set(sid, sess, cb); }
}
return new SqliteSessionStore();
};