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
346 lines
16 KiB
JavaScript
346 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* OLT Probe — deteksi cepat kompatibilitas OLT baru (multi-vendor).
|
||
*
|
||
* Tujuan: sebelum/saat membeli OLT merk baru, jalankan probe ini untuk tahu:
|
||
* 1. SNMP hidup? vendor-nya siapa (dari sysObjectID / enterprise OID)?
|
||
* 2. Profil brand bawaan mana yang cocok (hioso/hsgq/cdata/vsol/generic/...)?
|
||
* 3. Telnet/SSH/Web UI terbuka? keluarga CLI-nya apa?
|
||
* 4. Rekomendasi: brand yang dipilih di aplikasi + apakah rename perlu adapter.
|
||
*
|
||
* HANYA perintah BACA (read-only). Tidak mengubah konfigurasi apa pun.
|
||
*
|
||
* Contoh:
|
||
* node scripts/olt-probe.js --from-db 1
|
||
* node scripts/olt-probe.js --host 192.168.16.2 --user admin --pass admin
|
||
* node scripts/olt-probe.js --host 10.0.0.9 --community public --cmds "show version,show onu"
|
||
* node scripts/olt-probe.js --from-db 1 --json /tmp/probe-olt1.json
|
||
*/
|
||
const net = require('net');
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const snmp = require('net-snmp');
|
||
const oltSvc = require('../services/oltService');
|
||
|
||
/* ── Pemetaan enterprise OID → vendor (untuk tebakan cepat) ───────────── */
|
||
const ENTERPRISE_VENDOR = {
|
||
'25355': 'Hioso',
|
||
'3320': 'CTC-style EPON (umum di OLT China)',
|
||
'3902': 'ZTE',
|
||
'2011': 'Huawei',
|
||
'34592': 'C-Data',
|
||
'37950': 'V-SOL',
|
||
'27332': 'FiberHome',
|
||
'35048': 'BDCOM?',
|
||
'13158': 'Cisco (umum)',
|
||
'8072': 'Net-SNMP generik — vendor tidak menyetel sysObjectID (banyak OLT embedded Linux); penentuan pakai hasil "Profil brand" di bawah',
|
||
};
|
||
|
||
/* ── util ─────────────────────────────────────────────────────────────── */
|
||
function arg(name, def = null) {
|
||
const i = process.argv.indexOf('--' + name);
|
||
if (i === -1) return def;
|
||
const v = process.argv[i + 1];
|
||
return (v && !v.startsWith('--')) ? v : true;
|
||
}
|
||
function tcpCheck(host, port, timeout = 3000) {
|
||
return new Promise((resolve) => {
|
||
const s = new net.Socket();
|
||
const done = (ok) => { try { s.destroy(); } catch (e) {} resolve(ok); };
|
||
s.setTimeout(timeout);
|
||
s.once('connect', () => done(true));
|
||
s.once('timeout', () => done(false));
|
||
s.once('error', () => done(false));
|
||
s.connect(port, host);
|
||
});
|
||
}
|
||
function snmpValueToString(value) {
|
||
try {
|
||
let v = value;
|
||
if (Buffer.isBuffer(v)) v = v.toString('utf8');
|
||
v = String(v == null ? '' : v).replace(/\0/g, '').trim();
|
||
// beberapa perangkat mengembalikan string ber-quote
|
||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1).trim();
|
||
return v;
|
||
} catch (e) { return String(value); }
|
||
}
|
||
function looksLikeOidString(s) {
|
||
return /^\d+(\.\d+){4,}$/.test(String(s || '').trim());
|
||
}
|
||
function decodeOid(buf) {
|
||
try {
|
||
const b = Buffer.from(buf);
|
||
if (!b.length) return '';
|
||
const out = [Math.floor(b[0] / 40), b[0] % 40];
|
||
let val = 0;
|
||
for (let i = 1; i < b.length; i++) {
|
||
val = (val << 7) | (b[i] & 0x7f);
|
||
if (!(b[i] & 0x80)) { out.push(val); val = 0; }
|
||
}
|
||
return out.join('.');
|
||
} catch (e) { return String(buf); }
|
||
}
|
||
function snmpGet(session, oids, timeout = 4000) {
|
||
return new Promise((resolve) => {
|
||
let finished = false;
|
||
const t = setTimeout(() => { if (!finished) { finished = true; resolve(null); } }, timeout);
|
||
try {
|
||
session.get(oids, (err, vbs) => {
|
||
if (finished) return;
|
||
finished = true; clearTimeout(t);
|
||
resolve(err ? null : vbs);
|
||
});
|
||
} catch (e) { if (!finished) { finished = true; clearTimeout(t); resolve(null); } }
|
||
});
|
||
}
|
||
function snmpSubtreeCount(session, oid, limit = 8, timeout = 5000) {
|
||
return new Promise((resolve) => {
|
||
const rows = [];
|
||
let finished = false;
|
||
const done = () => { if (!finished) { finished = true; resolve(rows); } };
|
||
const t = setTimeout(done, timeout);
|
||
try {
|
||
session.subtree(oid, 20, (vbs) => {
|
||
for (const vb of vbs) {
|
||
if (rows.length >= limit) break;
|
||
let val = vb.value;
|
||
if (Buffer.isBuffer(val)) {
|
||
const s = val.toString('utf8').replace(/\0/g, '').trim();
|
||
val = /^[\x20-\x7E]+$/.test(s) && s.length >= 2 ? s : val.toString('hex').toUpperCase();
|
||
}
|
||
rows.push({ oid: vb.oid, value: String(val).slice(0, 60) });
|
||
}
|
||
if (rows.length >= limit) { try { session.close(); } catch (e) {} }
|
||
}, () => { clearTimeout(t); done(); });
|
||
} catch (e) { clearTimeout(t); done(); }
|
||
});
|
||
}
|
||
function httpProbe(url, timeout = 5000, allowSelfSigned = false) {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
const u = new URL(url);
|
||
const lib = u.protocol === 'https:' ? https : http;
|
||
const req = lib.request({
|
||
host: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname,
|
||
method: 'GET', timeout,
|
||
rejectUnauthorized: false,
|
||
headers: { 'User-Agent': 'OPEN-ISP-OLT-Probe/1.0' },
|
||
}, (res) => {
|
||
let body = '';
|
||
res.on('data', (d) => { if (body.length < 4000) body += d.toString('utf8'); });
|
||
res.on('end', () => {
|
||
const title = (body.match(/<title[^>]*>([^<]{0,80})</i) || [])[1] || '';
|
||
resolve({ ok: true, status: res.statusCode, server: res.headers.server || '', title: title.trim() });
|
||
});
|
||
});
|
||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, why: 'timeout' }); });
|
||
req.on('error', (e) => resolve({ ok: false, why: e.code || e.message }));
|
||
req.end();
|
||
} catch (e) { resolve({ ok: false, why: e.message }); }
|
||
});
|
||
}
|
||
/* Fallback telnet yang tahan terhadap prompt aneh (mis. '#' tanpa newline) */
|
||
async function rawTelnetRun(host, port, user, pass, cmds, waitMs = 2200) {
|
||
const sock = new net.Socket();
|
||
let buf = '';
|
||
sock.setTimeout(15000);
|
||
sock.on('data', (d) => { buf += d.toString('utf8'); });
|
||
await new Promise((resolve, reject) => {
|
||
sock.connect(port, host, resolve);
|
||
sock.once('error', reject);
|
||
});
|
||
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||
const drain = () => { const x = buf; buf = ''; return x; };
|
||
const clean = (t) => String(t).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\0/g, '').replace(/[^\x20-\x7E\r\n]/g, '');
|
||
await wait(2500);
|
||
let banner = clean(drain());
|
||
if (/(login|username)\s*[:>]/i.test(banner)) { sock.write(String(user || 'admin') + '\r\n'); await wait(1600); banner += clean(drain()); }
|
||
if (/(password|passwd)\s*[:>]/i.test(banner)) { sock.write(String(pass || 'admin') + '\r\n'); await wait(1800); banner += clean(drain()); }
|
||
sock.write('\r\n'); await wait(800); banner += clean(drain());
|
||
const outputs = [];
|
||
for (const cmd of cmds) {
|
||
sock.write(cmd + '\r\n');
|
||
await wait(waitMs);
|
||
outputs.push(clean(drain()));
|
||
}
|
||
try { sock.end(); sock.destroy(); } catch (e) {}
|
||
return { banner: banner.trim(), outputs };
|
||
}
|
||
|
||
function truncate(s, n) { s = String(s || ''); return s.length > n ? (s.slice(0, n) + '\n…[dipotong]') : s; }
|
||
|
||
/* ── main ─────────────────────────────────────────────────────────────── */
|
||
(async () => {
|
||
const fromDb = arg('from-db');
|
||
let host = arg('host');
|
||
let community = arg('community', 'public');
|
||
let snmpPort = Number(arg('snmp-port', 161));
|
||
let user = arg('user', 'admin');
|
||
let pass = arg('pass', 'admin');
|
||
let telnetPort = Number(arg('telnet-port', 23));
|
||
let cmds = String(arg('cmds', 'show version')).split(',').map(s => s.trim()).filter(Boolean);
|
||
const jsonOut = arg('json');
|
||
const enablePassword = arg('enable-password');
|
||
|
||
if (fromDb) {
|
||
const db = require('../config/database');
|
||
const olt = db.prepare('SELECT * FROM olts WHERE id = ?').get(Number(fromDb));
|
||
if (!olt) { console.error('OLT id=' + fromDb + ' tidak ditemukan di database'); process.exit(1); }
|
||
host = host || olt.host;
|
||
community = (community === 'public') ? (olt.snmp_community || community) : community;
|
||
snmpPort = snmpPort || Number(olt.snmp_port || 161);
|
||
user = (user === 'admin') ? (olt.web_user || user) : user;
|
||
pass = (pass === 'admin') ? (olt.web_password || pass) : pass;
|
||
telnetPort = telnetPort || Number(olt.telnet_port || 23);
|
||
console.log(`[i] Memakai data OLT dari DB: id=${olt.id} "${olt.name}" brand=${olt.brand} host=${host}`);
|
||
}
|
||
|
||
if (!host) {
|
||
console.log('Penggunaan: node scripts/olt-probe.js --host <ip> [--community public] [--user admin --pass admin]\n' +
|
||
' node scripts/olt-probe.js --from-db <id-olt> [--json hasil.json]');
|
||
process.exit(1);
|
||
}
|
||
|
||
const result = { host, at: new Date().toISOString(), snmp: null, tcp: {}, profiles: [], telnet: null, web: null, verdict: [] };
|
||
console.log(`\n=== OLT PROBE — ${host} ===\n`);
|
||
|
||
/* 1) cek port TCP */
|
||
console.log('-- Konektivitas --');
|
||
for (const p of [23, 22, 80, 443]) {
|
||
const ok = await tcpCheck(host, p);
|
||
result.tcp[p] = ok;
|
||
console.log(` port ${String(p).padEnd(3)} : ${ok ? 'TERBUKA' : 'tertutup/tidak merespons'}`);
|
||
}
|
||
|
||
/* 2) SNMP: identitas */
|
||
console.log('\n-- SNMP --');
|
||
const session = snmp.createSession(host, community, { port: snmpPort, version: snmp.Version2c, timeout: 4000, retries: 1 });
|
||
const idVbs = await snmpGet(session, ['1.3.6.1.2.1.1.1.0', '1.3.6.1.2.1.1.2.0', '1.3.6.1.2.1.1.5.0', '1.3.6.1.2.1.1.3.0'], 5000);
|
||
if (!idVbs) {
|
||
console.log(' SNMP: TIDAK MERESPONS (community salah / SNMP mati / port beda)');
|
||
result.snmp = { ok: false };
|
||
} else {
|
||
const sysDescr = idVbs[0] && idVbs[0].value != null ? snmpValueToString(idVbs[0].value) : '-';
|
||
const sysObjRaw = idVbs[1] && idVbs[1].value != null ? snmpValueToString(idVbs[1].value) : '';
|
||
const sysObjFromBuf = idVbs[1] && idVbs[1].value != null ? decodeOid(idVbs[1].value) : '-';
|
||
const sysObj = looksLikeOidString(sysObjRaw) ? sysObjRaw : (looksLikeOidString(sysObjFromBuf) ? sysObjFromBuf : sysObjFromBuf);
|
||
const sysName = idVbs[2] && idVbs[2].value != null ? snmpValueToString(idVbs[2].value) : '-';
|
||
const uptime = idVbs[3] && idVbs[3].value != null ? String(idVbs[3].value) : '-';
|
||
const ent = (sysObj.split('.')[6] || '');
|
||
let vendor = ENTERPRISE_VENDOR[ent] || '';
|
||
if (!vendor) {
|
||
// fallback: cari enterprise di daftar berdasarkan kemunculan substring OID
|
||
for (const [k, v] of Object.entries(ENTERPRISE_VENDOR)) {
|
||
if (sysObj.includes('4.1.' + k + '.')) { vendor = v + ' (terdeteksi dari OID)'; break; }
|
||
}
|
||
}
|
||
if (!vendor) vendor = ent ? `(enterprise ${ent} — belum terdaftar, laporkan ke developer)` : '-';
|
||
result.snmp = { ok: true, sysDescr, sysObjectID: sysObj, sysName, uptime, enterprise: ent, vendorGuess: vendor };
|
||
console.log(` sysDescr : ${truncate(sysDescr, 120)}`);
|
||
console.log(` sysObjectID : ${sysObj}`);
|
||
console.log(` enterprise : ${ent} → tebakan vendor: ${vendor}`);
|
||
console.log(` sysName : ${sysName}`);
|
||
}
|
||
|
||
/* 3) probe semua profil brand bawaan */
|
||
if (result.snmp && result.snmp.ok) {
|
||
console.log('\n-- Profil brand bawaan (uji OID probe) --');
|
||
for (const brandKey of Object.keys(oltSvc.BRAND_PROFILES || {})) {
|
||
for (const prof of (oltSvc.BRAND_PROFILES[brandKey] || [])) {
|
||
const probe = prof.probe_oid;
|
||
if (!probe) continue;
|
||
const vbs = await snmpGet(session, [probe], 3500);
|
||
if (vbs) {
|
||
const sample = await snmpSubtreeCount(session, prof.status_table || probe, 5, 5000);
|
||
result.profiles.push({ brandKey, profile: prof.name, ok: true, sample: sample.slice(0, 5) });
|
||
console.log(` ✅ ${brandKey.toUpperCase()} / ${prof.name} — HIDUP (contoh ${sample.length} entri: ${sample[0] ? sample[0].value : '-'} ...)`);
|
||
} else {
|
||
result.profiles.push({ brandKey, profile: prof.name, ok: false });
|
||
}
|
||
}
|
||
}
|
||
if (!result.profiles.some(p => p.ok)) console.log(' (tidak ada profil yang cocok — kemungkinan MIB privat; kirim hasil JSON ini ke developer)');
|
||
}
|
||
try { session.close(); } catch (e) {}
|
||
|
||
/* 4) Telnet (read-only) */
|
||
console.log('\n-- Telnet CLI --');
|
||
try {
|
||
const out = await oltSvc.telnetLoginAndRun(host, user, pass, cmds, {
|
||
port: telnetPort,
|
||
enablePassword: enablePassword !== null ? String(enablePassword) : null,
|
||
});
|
||
result.telnet = { ok: true, cmds, output: truncate(out, 2500) };
|
||
const head = String(out).split('\n').map(l => l.trim()).filter(Boolean).slice(0, 12).join('\n ');
|
||
console.log(` ✅ Login telnet BERHASIL (${cmds.join(' | ')}) — cuplikan:\n ${head}`);
|
||
const hay = String(out).toLowerCase();
|
||
const fam = hay.includes('hioso') ? 'Hioso' : hay.includes('hsgq') ? 'HSGQ' : hay.includes('c-data') || hay.includes('cdata') ? 'C-Data' : hay.includes('vsol') ? 'V-SOL' : null;
|
||
if (fam) console.log(` [i] Keluarga CLI terdeteksi: ${fam}`);
|
||
} catch (e) {
|
||
result.telnet = { ok: false, error: e.message || String(e) };
|
||
console.log(' ✖ Telnet gagal: ' + (e.message || String(e)));
|
||
// Diagnostik: tangkap banner mentah 4 detik agar terlihat prompt/flow-nya
|
||
try {
|
||
const raw = await new Promise((resolve) => {
|
||
const sock = new net.Socket();
|
||
let buf = '';
|
||
const t = setTimeout(() => { try { sock.destroy(); } catch (e2) {} resolve(buf); }, 4000);
|
||
sock.setTimeout(4000);
|
||
sock.on('data', (d) => { buf += d.toString('utf8').replace(/\0/g, ''); });
|
||
sock.once('error', () => { clearTimeout(t); resolve(buf); });
|
||
sock.once('timeout', () => { clearTimeout(t); try { sock.destroy(); } catch (e2) {} resolve(buf); });
|
||
sock.connect(telnetPort, host, () => {});
|
||
});
|
||
const clean = String(raw).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/[^\x20-\x7E\r\n]/g, '').trim();
|
||
result.telnet.rawBanner = truncate(clean, 600);
|
||
console.log(' [i] Banner telnet mentah:\n ' + truncate(clean, 400).split('\n').join('\n '));
|
||
} catch (e2) {}
|
||
|
||
// Fallback: mode raw (prompt longgar) — tetap READ-ONLY
|
||
try {
|
||
console.log(' [i] Mencoba mode telnet raw (prompt longgar)…');
|
||
const raw = await rawTelnetRun(host, telnetPort, user, pass, cmds);
|
||
result.telnetRaw = { ok: true, banner: truncate(raw.banner, 500), outputs: raw.outputs.map(o => truncate(o, 1200)) };
|
||
const joined = (raw.banner + '\n' + raw.outputs.join('\n'));
|
||
const head = String(joined).split('\n').map(l => l.trim()).filter(Boolean).slice(0, 10).join('\n ');
|
||
console.log(' ✅ Telnet RAW berhasil — cuplikan:\n ' + head);
|
||
const hay = joined.toLowerCase();
|
||
const fam = hay.includes('hioso') ? 'Hioso' : hay.includes('hsgq') ? 'HSGQ' : hay.includes('c-data') || hay.includes('cdata') ? 'C-Data' : hay.includes('vsol') ? 'V-SOL' : hay.includes('gpon') ? 'GPON-generik' : hay.includes('epon') ? 'EPON-generik' : null;
|
||
if (fam) console.log(' [i] Keluarga CLI terdeteksi (raw): ' + fam);
|
||
} catch (e3) {
|
||
console.log(' ✖ Telnet raw juga gagal: ' + (e3.message || String(e3)));
|
||
}
|
||
}
|
||
|
||
/* 5) Web UI */
|
||
console.log('\n-- Web UI --');
|
||
for (const url of [`http://${host}/`, `https://${host}/`]) {
|
||
const w = await httpProbe(url, 5000);
|
||
if (w.ok) {
|
||
result.web = { url, ...w };
|
||
console.log(` ${url} → HTTP ${w.status} | Server: ${w.server || '-'} | Title: ${w.title || '-'}`);
|
||
break;
|
||
} else {
|
||
console.log(` ${url} → ${w.why}`);
|
||
}
|
||
}
|
||
|
||
/* 6) kesimpulan */
|
||
console.log('\n=== KESIMPULAN ===');
|
||
const live = [...new Set(result.profiles.filter(p => p.ok).map(p => p.brandKey))];
|
||
if (result.snmp && result.snmp.ok) {
|
||
if (live.length) console.log(`✅ Pilih brand di aplikasi: ${live.map(s => s.toUpperCase()).join(' / ')} (monitoring SNMP siap)`);
|
||
else console.log('⚠️ SNMP hidup tapi tidak ada profil cocok → perlu profil baru (kirim JSON hasil ini).');
|
||
} else {
|
||
console.log('⚠️ SNMP mati/kosong. Jika telnet hidup, monitoring bisa dibuat via driver CLI (kirim cuplikan telnet ini).');
|
||
}
|
||
if (result.telnet && result.telnet.ok) console.log('ℹ️ Telnet hidup → jalur rename/authorize CLI memungkinkan (tergantung keluarga CLI).');
|
||
console.log('ℹ️ Rename nama pelanggan→OLT saat ini: hioso/hsgq (SNMP) & zte/huawei (CLI). Brand lain perlu adapter.');
|
||
|
||
if (jsonOut) {
|
||
require('fs').writeFileSync(String(jsonOut), JSON.stringify(result, null, 2));
|
||
console.log(`\n[i] Hasil lengkap disimpan: ${jsonOut} (kirim file ini untuk analisis lanjutan)`);
|
||
}
|
||
process.exit(0);
|
||
})().catch((e) => { console.error('Probe error:', e); process.exit(1); });
|