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:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in new issue
Block a user