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

+87
View File
@@ -0,0 +1,87 @@
/**
* Mikhmon On-Login Script Parser (Shared Utility)
*
* Parse metadata harga, validity, dan cost dari on-login script profile hotspot MikroTik.
* Format yang didukung:
* 1. Mikhmon standar: :put (",rem,COST,VALIDITY,PRICE,...");
* 2. Comma-split lama: ...,rem,COST,VALIDITY,PRICE,...
*
* Digunakan oleh:
* - routes/customerPortal.js (route handler voucher)
* - services/voucherCacheWarmer.js (background cache warmer)
*/
'use strict';
/**
* @param {string} script - Isi field on-login dari hotspot user profile MikroTik
* @returns {{ validity: string, price: number, cost: number } | null}
*/
function parseMikhmonOnLogin(script) {
if (!script) return null;
const s = String(script).trim();
// Format 1: :put (",rem,COST,VALIDITY,PRICE,...)
// Support ROS6 dan ROS7 (script bisa berbeda struktur)
// Cari pattern :put (",rem, ... , ... , ...
// Updated regex untuk support format: :put (",rem,4000,2d,5000,,Disable,");
const putMatch = s.match(/:\s*put\s*\(\s*[",]rem[",]?\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)/i);
if (putMatch) {
const cost = String(putMatch[1] || '').trim();
const validity = String(putMatch[2] || '').trim();
const priceStr = String(putMatch[3] || '').trim();
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
if (validity && price > 0) {
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
}
}
// Format 2: $HARGA^VALIDITAS (shorthand Mikhmon v2 / custom)
// Contoh: $5000^1d atau $10000^7d
const shortMatch = s.match(/\$(\d+)\^([\w]+)/i);
if (shortMatch) {
const price = Number(shortMatch[1]) || 0;
const validity = String(shortMatch[2] || '').trim();
if (price > 0 && validity) {
return { price, validity, cost: 0 };
}
}
// Format 3: HARGA^VALIDITAS (tanpa dollar, untuk script custom)
// Contoh: 5000^1d atau 10000^7d dalam on-login
const bareMatch = s.match(/(?:^|[\s,;])(\d{3,})\^([\d]+[dhwm])/i);
if (bareMatch) {
const price = Number(bareMatch[1]) || 0;
const validity = String(bareMatch[2] || '').trim();
if (price > 0 && validity) {
return { price, validity, cost: 0 };
}
}
// Format 4: Fallback split by comma (untuk format lama)
const parts = s.split(',').map(p => String(p).trim());
let remIdx = -1;
for (let i = 0; i < parts.length; i++) {
const norm = String(parts[i] || '').toLowerCase().replace(/[^a-z]/g, '');
if (norm === 'rem') {
remIdx = i;
break;
}
}
if (remIdx >= 0 && remIdx + 3 < parts.length) {
const cost = String(parts[remIdx + 1] || '').trim();
const validity = String(parts[remIdx + 2] || '').trim();
const priceStr = String(parts[remIdx + 3] || '').trim();
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
if (validity && price > 0) {
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
}
}
return null;
}
module.exports = { parseMikhmonOnLogin };
+156
View File
@@ -0,0 +1,156 @@
const fs = require('fs');
const path = require('path');
const jimpMod = require('jimp');
const Jimp = jimpMod.Jimp || jimpMod;
const jsQR = require('jsqr');
const QRCode = require('qrcode');
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
function normalizeQrisPayload(raw) {
let s = String(raw || '').replace(/[\r\n\t]+/g, '').trim();
const idx = s.indexOf('000201');
if (idx > 0) s = s.slice(idx);
const lastCrc = s.lastIndexOf('6304');
if (lastCrc >= 0 && s.length >= lastCrc + 8) {
s = s.slice(0, lastCrc + 8);
}
return s;
}
function crc16CcittFalse(input) {
const s = String(input || '');
let crc = 0xffff;
for (let i = 0; i < s.length; i++) {
crc ^= (s.charCodeAt(i) & 0xff) << 8;
for (let b = 0; b < 8; b++) {
if (crc & 0x8000) crc = ((crc << 1) ^ 0x1021) & 0xffff;
else crc = (crc << 1) & 0xffff;
}
}
return crc & 0xffff;
}
function parseEmvTlvString(input) {
const raw = String(input || '').replace(/[\r\n\t]+/g, '').trim();
if (!raw) throw new Error('QRIS payload kosong');
if (raw.length < 8) throw new Error('QRIS payload terlalu pendek');
const items = [];
let i = 0;
while (i < raw.length) {
if (i + 4 > raw.length) throw new Error('QRIS payload TLV tidak valid');
const tag = raw.slice(i, i + 2);
const lenStr = raw.slice(i + 2, i + 4);
if (!/^\d{2}$/.test(lenStr)) throw new Error('QRIS payload TLV length tidak valid');
const len = Number(lenStr);
const start = i + 4;
const end = start + len;
if (end > raw.length) throw new Error('QRIS payload TLV length melebihi data');
const value = raw.slice(start, end);
items.push({ tag, value });
i = end;
}
return items;
}
function buildEmvTlvString(items) {
const list = Array.isArray(items) ? items : [];
let out = '';
for (const it of list) {
const tag = String(it?.tag || '');
const value = String(it?.value ?? '');
const len = value.length;
if (!/^\d{2}$/.test(tag)) throw new Error('Tag TLV tidak valid');
if (len > 99) throw new Error('TLV length > 99 tidak didukung');
out += tag + String(len).padStart(2, '0') + value;
}
return out;
}
function convertStaticQrisToDynamic(staticPayload, amount) {
const amt = Math.max(0, Math.floor(Number(amount || 0) || 0));
if (!amt) throw new Error('Nominal QRIS dinamis tidak valid');
const source = parseEmvTlvString(staticPayload)
.filter(x => x && x.tag)
.map(x => ({ tag: String(x.tag), value: String(x.value ?? '') }));
const managed = new Set(['54', '55', '56', '57', '63']);
const result = [];
let amountInserted = false;
for (const el of source) {
if (managed.has(el.tag)) continue;
if (el.tag === '01') {
result.push({ tag: '01', value: '12' });
continue;
}
if (el.tag === '58' && !amountInserted) {
result.push({ tag: '54', value: String(amt) });
amountInserted = true;
}
result.push(el);
}
if (!amountInserted) {
result.push({ tag: '54', value: String(amt) });
}
const body = buildEmvTlvString(result);
const partial = body + '6304';
const crc = crc16CcittFalse(partial).toString(16).toUpperCase().padStart(4, '0');
return partial + crc;
}
async function decodeQrisPayloadFromBuffer(buf) {
if (!buf || !buf.length) return '';
// 1. Try jsQR (Fast & reliable for screenshots)
try {
const img = await Jimp.read(buf);
const w = img.bitmap.width;
const h = img.bitmap.height;
const data = new Uint8ClampedArray(img.bitmap.data);
const code = jsQR(data, w, h);
if (code && code.data) {
const payload = normalizeQrisPayload(code.data);
if (payload && payload.startsWith('000201')) return payload;
}
} catch (e) {}
// 2. Fallback to @zxing/library
try {
const img = await Jimp.read(buf);
const rgba = new Uint8ClampedArray(img.bitmap.data.buffer, img.bitmap.data.byteOffset, img.bitmap.data.byteLength);
const source = new RGBLuminanceSource(rgba, img.bitmap.width, img.bitmap.height);
const bitmap = new BinaryBitmap(new HybridBinarizer(source));
const reader = new MultiFormatReader();
const hints = new Map();
hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.QR_CODE]);
hints.set(DecodeHintType.TRY_HARDER, true);
reader.setHints(hints);
const decoded = reader.decode(bitmap, hints);
const text = typeof decoded?.getText === 'function' ? decoded.getText() : String(decoded?.text || '');
const payload = normalizeQrisPayload(text);
if (payload && payload.startsWith('000201')) return payload;
} catch (e) {}
return '';
}
async function buildDynamicQrisJpgBuffer(staticPayload, amount) {
const dynamic = convertStaticQrisToDynamic(staticPayload, amount);
const png = await QRCode.toBuffer(dynamic, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' });
const img = await Jimp.read(png);
return await img.getBuffer('image/jpeg');
}
module.exports = {
normalizeQrisPayload,
crc16CcittFalse,
parseEmvTlvString,
buildEmvTlvString,
convertStaticQrisToDynamic,
decodeQrisPayloadFromBuffer,
buildDynamicQrisJpgBuffer
};
+286
View File
@@ -0,0 +1,286 @@
/**
* Encoder & Decoder RADIUS Protocol (RFC 2865 & RFC 2866)
* Zero-dependency, menggunakan modul bawaan Node.js (crypto & buffer)
*/
const crypto = require('crypto');
const CODES = {
ACCESS_REQUEST: 1,
ACCESS_ACCEPT: 2,
ACCESS_REJECT: 3,
ACCOUNTING_REQUEST: 4,
ACCOUNTING_RESPONSE: 5,
ACCESS_CHALLENGE: 11
};
const ATTR_TYPES = {
USER_NAME: 1,
USER_PASSWORD: 2,
CHAP_PASSWORD: 3,
NAS_IP_ADDRESS: 4,
NAS_PORT: 5,
SERVICE_TYPE: 6,
FRAMED_PROTOCOL: 7,
FRAMED_IP_ADDRESS: 8,
FRAMED_IP_NETMASK: 9,
FILTER_ID: 11,
FRAMED_MTU: 12,
STATE: 24,
CLASS: 25,
VENDOR_SPECIFIC: 26,
SESSION_TIMEOUT: 27,
IDLE_TIMEOUT: 28,
TERMINATE_ACTION: 29,
CALLED_STATION_ID: 30,
CALLING_STATION_ID: 31,
NAS_IDENTIFIER: 32,
ACCT_STATUS_TYPE: 40,
ACCT_DELAY_TIME: 41,
ACCT_INPUT_OCTETS: 42,
ACCT_OUTPUT_OCTETS: 43,
ACCT_SESSION_ID: 44,
ACCT_AUTHENTIC: 45,
ACCT_SESSION_TIME: 46,
ACCT_INPUT_PACKETS: 47,
ACCT_OUTPUT_PACKETS: 48,
ACCT_TERMINATE_CAUSE: 49,
ACCT_INPUT_GIGAWORDS: 52,
ACCT_OUTPUT_GIGAWORDS: 53,
CHAP_CHALLENGE: 60,
FRAMED_POOL: 88
};
const MIKROTIK_VENDOR_ID = 14988;
const MIKROTIK_VSAS = {
RECV_LIMIT: 1,
XMIT_LIMIT: 2,
GROUP: 7,
RATE_LIMIT: 8,
REALM: 9
};
function parseIp(buf) {
if (!buf || buf.length < 4) return '';
return `${buf[0]}.${buf[1]}.${buf[2]}.${buf[3]}`;
}
function encodeIp(ipStr) {
const parts = String(ipStr || '0.0.0.0').split('.').map(n => parseInt(n, 10) || 0);
return Buffer.from([parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] || 0]);
}
/**
* Decode paket RADIUS dari Buffer
*/
function decodePacket(buffer, secret) {
if (!Buffer.isBuffer(buffer) || buffer.length < 20) {
throw new Error('Paket RADIUS terlalu pendek (minimal 20 byte)');
}
const code = buffer.readUInt8(0);
const identifier = buffer.readUInt8(1);
const length = buffer.readUInt16BE(2);
if (buffer.length < length) {
throw new Error(`Paket RADIUS terpotong: diharapkan ${length} byte, diterima ${buffer.length} byte`);
}
const authenticator = buffer.slice(4, 20);
const attributes = [];
let offset = 20;
while (offset < length) {
if (offset + 2 > length) break;
const type = buffer.readUInt8(offset);
const attrLen = buffer.readUInt8(offset + 1);
if (attrLen < 2 || offset + attrLen > length) break;
const attrValBuf = buffer.slice(offset + 2, offset + attrLen);
attributes.push({ type, length: attrLen, value: attrValBuf });
offset += attrLen;
}
const parsedAttrs = {};
for (const attr of attributes) {
const { type, value } = attr;
if (type === ATTR_TYPES.USER_NAME) {
parsedAttrs.username = value.toString('utf8');
} else if (type === ATTR_TYPES.USER_PASSWORD && secret) {
parsedAttrs.password = decryptPapPassword(value, authenticator, secret);
} else if (type === ATTR_TYPES.CHAP_PASSWORD) {
parsedAttrs.chapPassword = value;
} else if (type === ATTR_TYPES.CHAP_CHALLENGE) {
parsedAttrs.chapChallenge = value;
} else if (type === ATTR_TYPES.NAS_IP_ADDRESS) {
parsedAttrs.nasIp = parseIp(value);
} else if (type === ATTR_TYPES.NAS_PORT) {
parsedAttrs.nasPort = value.length >= 4 ? value.readUInt32BE(0) : 0;
} else if (type === ATTR_TYPES.NAS_IDENTIFIER) {
parsedAttrs.nasIdentifier = value.toString('utf8');
} else if (type === ATTR_TYPES.FRAMED_IP_ADDRESS) {
parsedAttrs.framedIp = parseIp(value);
} else if (type === ATTR_TYPES.ACCT_STATUS_TYPE) {
parsedAttrs.acctStatusType = value.length >= 4 ? value.readUInt32BE(0) : value.readUInt8(0);
} else if (type === ATTR_TYPES.ACCT_SESSION_ID) {
parsedAttrs.acctSessionId = value.toString('utf8');
} else if (type === ATTR_TYPES.ACCT_INPUT_OCTETS) {
parsedAttrs.acctInputOctets = value.length >= 4 ? value.readUInt32BE(0) : 0;
} else if (type === ATTR_TYPES.ACCT_OUTPUT_OCTETS) {
parsedAttrs.acctOutputOctets = value.length >= 4 ? value.readUInt32BE(0) : 0;
} else if (type === ATTR_TYPES.ACCT_SESSION_TIME) {
parsedAttrs.acctSessionTime = value.length >= 4 ? value.readUInt32BE(0) : 0;
} else if (type === ATTR_TYPES.ACCT_INPUT_GIGAWORDS) {
parsedAttrs.acctInputGigawords = value.length >= 4 ? value.readUInt32BE(0) : 0;
} else if (type === ATTR_TYPES.ACCT_OUTPUT_GIGAWORDS) {
parsedAttrs.acctOutputGigawords = value.length >= 4 ? value.readUInt32BE(0) : 0;
} else if (type === ATTR_TYPES.ACCT_TERMINATE_CAUSE) {
parsedAttrs.acctTerminateCause = value.length >= 4 ? value.readUInt32BE(0) : 0;
} else if (type === ATTR_TYPES.CALLING_STATION_ID) {
parsedAttrs.callingStationId = value.toString('utf8');
} else if (type === ATTR_TYPES.CALLED_STATION_ID) {
parsedAttrs.calledStationId = value.toString('utf8');
}
}
return {
code,
identifier,
length,
authenticator,
attributes,
parsedAttrs
};
}
/**
* Dekripsi Password PAP RADIUS
*/
function decryptPapPassword(encryptedBuf, authenticator, secret) {
if (!encryptedBuf || encryptedBuf.length === 0) return '';
const secretBuf = Buffer.from(secret, 'utf8');
let lastBlock = authenticator;
let decrypted = Buffer.alloc(0);
for (let i = 0; i < encryptedBuf.length; i += 16) {
const hash = crypto.createHash('md5').update(secretBuf).update(lastBlock).digest();
const encChunk = encryptedBuf.slice(i, i + 16);
const decChunk = Buffer.alloc(encChunk.length);
for (let j = 0; j < encChunk.length; j++) {
decChunk[j] = encChunk[j] ^ hash[j];
}
decrypted = Buffer.concat([decrypted, decChunk]);
lastBlock = encChunk;
}
// Hilangkan NULL padding (0x00)
let nullIdx = decrypted.indexOf(0x00);
if (nullIdx !== -1) {
decrypted = decrypted.slice(0, nullIdx);
}
return decrypted.toString('utf8');
}
/**
* Encode paket balasan RADIUS (Access-Accept, Access-Reject, Accounting-Response)
*/
function encodeResponsePacket({ code, identifier, requestAuthenticator, attributes = [], secret }) {
const secretBuf = Buffer.from(secret, 'utf8');
// Hitung total panjang atribut
let attrBufList = [];
for (const attr of attributes) {
if (attr.type === ATTR_TYPES.VENDOR_SPECIFIC && attr.vendorId === MIKROTIK_VENDOR_ID) {
// Encode MikroTik VSA
const vsaValBuf = Buffer.from(attr.value);
const vsaHeader = Buffer.alloc(6);
vsaHeader.writeUInt32BE(MIKROTIK_VENDOR_ID, 0);
vsaHeader.writeUInt8(attr.vendorType, 4);
vsaHeader.writeUInt8(vsaValBuf.length + 2, 5);
const fullVsa = Buffer.concat([vsaHeader, vsaValBuf]);
const attrHeader = Buffer.alloc(2);
attrHeader.writeUInt8(ATTR_TYPES.VENDOR_SPECIFIC, 0);
attrHeader.writeUInt8(fullVsa.length + 2, 1);
attrBufList.push(Buffer.concat([attrHeader, fullVsa]));
} else {
let valBuf;
if (Buffer.isBuffer(attr.value)) {
valBuf = attr.value;
} else if (typeof attr.value === 'number') {
valBuf = Buffer.alloc(4);
valBuf.writeUInt32BE(attr.value, 0);
} else if (typeof attr.value === 'string' && attr.isIp) {
valBuf = encodeIp(attr.value);
} else {
valBuf = Buffer.from(String(attr.value || ''), 'utf8');
}
const attrHeader = Buffer.alloc(2);
attrHeader.writeUInt8(attr.type, 0);
attrHeader.writeUInt8(valBuf.length + 2, 1);
attrBufList.push(Buffer.concat([attrHeader, valBuf]));
}
}
const allAttrsBuf = Buffer.concat(attrBufList);
const packetLen = 20 + allAttrsBuf.length;
const headerBuf = Buffer.alloc(20);
headerBuf.writeUInt8(code, 0);
headerBuf.writeUInt8(identifier, 1);
headerBuf.writeUInt16BE(packetLen, 2);
requestAuthenticator.copy(headerBuf, 4, 0, 16);
// MD5 Authenticator = MD5(Code + Id + Length + RequestAuth + Attributes + Secret)
const md5Hash = crypto.createHash('md5')
.update(headerBuf)
.update(allAttrsBuf)
.update(secretBuf)
.digest();
md5Hash.copy(headerBuf, 4, 0, 16);
return Buffer.concat([headerBuf, allAttrsBuf]);
}
/**
* Verifikasi Password CHAP RFC 2865
* @param {Buffer} chapPasswordBuf - 17 bytes (1 byte ident + 16 bytes MD5 hash response)
* @param {string} userSecret - Password plaintext pelanggan/pengguna
* @param {Buffer} challengeBuf - Buffer challenge (dari atribut CHAP-Challenge atau request authenticator)
* @returns {boolean}
*/
function verifyChapPassword(chapPasswordBuf, userSecret, challengeBuf) {
if (!Buffer.isBuffer(chapPasswordBuf) || chapPasswordBuf.length < 17) {
return false;
}
if (!Buffer.isBuffer(challengeBuf) || challengeBuf.length === 0) {
return false;
}
const chapIdent = chapPasswordBuf[0];
const chapResponse = chapPasswordBuf.slice(1, 17);
// MD5(ident + userSecret + challenge)
const hash = crypto.createHash('md5')
.update(Buffer.from([chapIdent]))
.update(Buffer.from(String(userSecret || ''), 'utf8'))
.update(challengeBuf)
.digest();
try {
return crypto.timingSafeEqual(chapResponse, hash);
} catch (e) {
return false;
}
}
module.exports = {
CODES,
ATTR_TYPES,
MIKROTIK_VENDOR_ID,
MIKROTIK_VSAS,
decodePacket,
encodeResponsePacket,
verifyChapPassword
};
+71
View File
@@ -0,0 +1,71 @@
/**
* Token utility untuk public payment tokens
* Digunakan untuk polling status pembayaran tanpa memerlukan session login
*/
const crypto = require('crypto');
/**
* Sign public token untuk payment (HMAC-SHA256)
* @param {Object} payload - Data: {invoiceId, customerId, lookup, exp}
* @param {string} secret - Session secret
* @returns {string} Base64-encoded token
*/
function signPublicToken(payload, secret) {
try {
const json = JSON.stringify(payload);
const signature = crypto
.createHmac('sha256', secret || 'default-secret')
.update(json)
.digest('hex');
const tokenData = {
data: json,
sig: signature
};
return Buffer.from(JSON.stringify(tokenData)).toString('base64');
} catch (e) {
console.error('[signPublicToken] Error:', e.message);
return '';
}
}
/**
* Verify public token
* @param {string} token - Base64-encoded token
* @param {string} secret - Session secret
* @returns {Object|null} Payload if valid, null if invalid/expired
*/
function verifyPublicToken(token, secret) {
try {
const decoded = Buffer.from(token, 'base64').toString('utf8');
const tokenData = JSON.parse(decoded);
const { data, sig } = tokenData;
const expectedSig = crypto
.createHmac('sha256', secret || 'default-secret')
.update(data)
.digest('hex');
if (sig !== expectedSig) {
return null;
}
const payload = JSON.parse(data);
// Check expiry
if (payload.exp && Date.now() > payload.exp) {
return null;
}
return payload;
} catch (e) {
console.error('[verifyPublicToken] Error:', e.message);
return null;
}
}
module.exports = {
signPublicToken,
verifyPublicToken
};