feat(isolir): generator script cloud sederhana
All checks were successful
CI / test (push) Successful in 1m38s

- tab Cloud di modal Script Isolir
- resolve semua A record, warn Cloudflare/CDN
- hook PPPoE, DNS redirect, NAT HTTP, filter allow/reject/drop
- API /admin/api/isolir-cloud-script
- versi 0.6.7
This commit is contained in:
oxpoe
2026-11-06 05:49:15 +07:00
parent 0f7affc498
commit 094c47c15d
7 changed files with 207 additions and 21 deletions

View File

@@ -2,6 +2,15 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.6.7] — 2026-11-06 — "isolir-cloud"
### Ditambahkan
- Tab **Cloud (Sederhana)** pada modal Script Isolir (Manajemen Router): generate script RouterOS siap tempel untuk billing yang berjalan di cloud.
- Input domain/IP publik billing, port redirector, dan profil PPPoE isolir.
- Berisi: address-list BILLING (semua hasil resolve A), hook profil PPPoE, redirect DNS, NAT HTTP ke redirector, serta filter allow/reject/drop.
- Deteksi Cloudflare/CDN + peringatan bila port redirector tidak ikut diproxy.
- Endpoint `GET /admin/api/isolir-cloud-script`.
## [0.6.6] — 2026-11-06 — "qr-custom-bulk"
### Ditambahkan

View File

@@ -11,7 +11,7 @@
<p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.6.6_qr--bulk-ADFF2F" alt="Versi 0.6.6 (qr-custom-bulk)">
<img src="https://img.shields.io/badge/versi-0.6.7_isolir--cloud-ADFF2F" alt="Versi 0.6.7 (isolir-cloud)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>

View File

@@ -1,6 +1,6 @@
{
"name": "open-isp",
"version": "0.6.6",
"version": "0.6.7",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js",
"scripts": {

View File

@@ -7673,9 +7673,12 @@ router.post('/whatsapp/repair-keys', requireAdminSession, async (req, res) => {
// ─── ROUTERS (MULTI-ROUTER) ──────────────────────────────────────────────────
router.get('/routers', requireAdminSession, requireSidebarMenuAccess('mikrotik'), (req, res) => {
const _settings = getSettings();
const defaultPublicBase = String(_settings.public_base_url || _settings.app_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
res.render('admin/routers', {
title: 'Manajemen Router', company: company(), activePage: 'mikrotik',
routers: mikrotikService.getAllRouters(), msg: flashMsg(req)
routers: mikrotikService.getAllRouters(), msg: flashMsg(req),
defaultPublicBase
});
});
@@ -7987,6 +7990,19 @@ router.post('/api/routers/:id/setup-firewall', requireAdmin, async (req, res) =>
}
});
router.get('/api/isolir-cloud-script', requireAdmin, async (req, res) => {
try {
const data = await mikrotikService.generateIsolirCloudScript({
host: req.query.host,
port: req.query.port,
profile: req.query.profile
});
res.json({ success: true, ...data });
} catch (e) {
res.json({ success: false, error: e.message });
}
});
router.get('/api/isolir-portal-script', requireAdmin, async (req, res) => {
try {
const data = await mikrotikService.generateIsolirPortalScript();

View File

@@ -1632,6 +1632,112 @@ async function generateIsolirPortalScript() {
};
}
/**
* Script isolir sederhana untuk billing yang berjalan di CLOUD.
* Asumsi: redirector billing (port isolir_redirect_port) terbuka dari publik.
*/
async function generateIsolirCloudScript(opts = {}) {
const settings = getSettingsWithCache() || {};
const rawHost = String(opts.host || settings.public_base_url || settings.app_url || '').trim();
if (!rawHost) throw new Error('Isi domain/IP publik billing terlebih dahulu.');
let url = null;
try {
url = new URL(/^https?:\/\//i.test(rawHost) ? rawHost : ('https://' + rawHost));
} catch (e) {
throw new Error('Domain/IP billing tidak valid.');
}
const hostname = url.hostname;
const baseUrl = url.origin;
let billingIps = [];
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
billingIps = [hostname];
} else {
try {
const list = await dns.promises.resolve4(hostname);
billingIps = (list || []).filter(Boolean);
} catch (e) {}
if (!billingIps.length) {
try { billingIps = [await resolveIpv4(hostname)]; } catch (e) { billingIps = []; }
}
}
if (!billingIps.length) throw new Error('IP publik billing tidak bisa di-resolve. Masukkan IP origin langsung.');
const billingIp = billingIps[0];
const cfRanges = ['104.16.', '104.17.', '104.18.', '104.19.', '104.20.', '104.21.', '104.22.', '104.23.', '104.24.', '104.25.', '104.26.', '104.27.', '172.64.', '172.65.', '172.66.', '172.67.', '162.158.', '173.245.', '103.21.', '103.22.', '103.31.', '141.101.', '108.162.', '190.93.', '188.114.', '197.234.', '198.41.', '131.0.72.'];
const isCdn = billingIps.some(ip => cfRanges.some(p => ip.startsWith(p)));
const redirectPort = Math.max(1, Math.min(65535, parseInt(opts.port, 10) || parseInt(settings.isolir_redirect_port, 10) || 8099));
const profile = String(opts.profile || 'isolir').trim() || 'isolir';
const isolirUrl = String(settings.isolir_portal_url || (baseUrl + '/isolated')).trim();
const onUp = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]; /ip firewall address-list add list=LIST_ISOLIR address=$\\"remote-address\\" comment=$user timeout=23h`;
const onDown = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]`;
const lines = [
'# ============================================================',
'# ISOLIR PAGE — BILLING CLOUD (script sederhana)',
'# Tempel di Terminal RouterOS. Ubah variabel di bawah bila perlu.',
'# ============================================================',
...(isCdn ? [
'# PERINGATAN: ' + hostname + ' memakai Cloudflare/CDN.',
'# Port ' + redirectPort + ' biasanya TIDAK ikut diproxy Cloudflare.',
'# Untuk NAT redirector, gunakan IP ORIGIN (DNS-only) atau pastikan',
'# port ' + redirectPort + ' terbuka langsung di server origin.',
'# ============================================================',
''
] : []),
':local billingIp "' + billingIp + '"',
':local isolirPort ' + redirectPort,
':local isolirProfile "' + profile + '"',
'',
'# --- Bersihkan rule lama buatan script ini ---',
'/ip firewall nat remove [find comment~"ISOLIR_CLOUD_"]',
'/ip firewall filter remove [find comment~"ISOLIR_CLOUD_"]',
'/ip firewall address-list remove [find list=BILLING comment="BILLING_ISOLIR"]',
'',
'# --- Izinkan billing (semua IP hasil resolve) ---',
...billingIps.map(function (ip) { return '/ip firewall address-list add list=BILLING address=' + ip + ' comment="BILLING_ISOLIR"'; }),
'',
'# --- Hook profil PPPoE isolir ---',
'/ppp profile set [find name=$isolirProfile] on-up="' + onUp + '" on-down="' + onDown + '"',
'',
'# --- DNS isolir diarahkan ke router ---',
'/ip firewall nat add chain=dstnat protocol=udp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_UDP"',
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_TCP"',
'',
'# --- HTTP pelanggan isolir -> redirector cloud ---',
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=80 src-address-list=LIST_ISOLIR dst-address-list=!BILLING action=dst-nat to-addresses=$billingIp to-ports=$isolirPort comment="ISOLIR_CLOUD_HTTP"',
'',
'# --- Filter ---',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=udp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS_TCP"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR dst-address-list=BILLING action=accept comment="ISOLIR_CLOUD_ALLOW_BILLING"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=443 action=reject comment="ISOLIR_CLOUD_REJECT_HTTPS"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR action=drop comment="ISOLIR_CLOUD_DROP"',
'',
'# ============================================================',
'# CATATAN BILLING CLOUD',
'# 1. Redirector billing harus bisa diakses: http://' + billingIp + ':' + redirectPort,
'# (buka port ' + redirectPort + ' di firewall/security group cloud).',
'# 2. Di billing set ISOLIR_REDIRECT_URL=' + isolirUrl,
'# atau setting isolir_portal_url.',
'# 3. Halaman isolir: ' + isolirUrl,
'# 4. Jika pakai Cloudflare/CDN, pastikan IP edge yang dipakai ada di',
'# address-list BILLING, atau gunakan IP origin langsung.',
'# ============================================================',
];
return {
script: lines.join('\n'),
billingHost: hostname,
billingIp,
billingIps,
isCdn,
redirectPort,
profile,
isolirUrl,
warning: isCdn ? ('Domain memakai Cloudflare/CDN. NAT redirector ke ' + billingIp + ':' + redirectPort + ' biasanya gagal — gunakan IP origin (DNS-only) atau buka port redirector di origin.') : ''
};
}
const ISOLIR_ADDR_LIST = 'LIST_ISOLIR';
/** Nama profil PPPoE isolir yang dipakai pelanggan di router ini (distinct dari DB). */
@@ -2220,6 +2326,7 @@ module.exports = {
setupIsolirFirewall,
ensurePppProfileIsolirAddressListHook,
generateIsolirPortalScript,
generateIsolirCloudScript,
manageStaticIp,
removeStaticIp
};

View File

@@ -1 +1 @@
0.6.6
0.6.7

View File

@@ -527,6 +527,9 @@
<button type="button" class="btn btn-sm btn-g" id="tab-btn-script" onclick="switchIsolirTab('script')">
<i class="bi bi-filetype-txt me-1"></i> Copy Script RouterOS (.rsc)
</button>
<button type="button" class="btn btn-sm btn-g" id="tab-btn-cloud" onclick="switchIsolirTab('cloud')">
<i class="bi bi-cloud-arrow-up me-1"></i> Cloud (Sederhana)
</button>
</div>
<!-- TAB 1: Auto Setup via API -->
@@ -554,6 +557,23 @@
<div id="isolir-auto-setup-result" style="display:none;"></div>
</div>
<!-- TAB 3: Billing Cloud (script sederhana) -->
<div id="isolir-tab-cloud" style="display:none;">
<div class="alert alert-info p-3 small mb-3" style="background:rgba(56,189,248,0.10); border:1px solid rgba(56,189,248,0.3); color:#e0f2fe;">
<h6 class="fw-bold mb-1"><i class="bi bi-cloud me-1"></i> Billing di Cloud</h6>
<p class="mb-1 small">Script sederhana: DNS diarahkan ke router, HTTP pelanggan isolir di-NAT ke <b>redirector billing</b>, HTTPS ditolak agar browser fallback ke HTTP.</p>
<p class="mb-0 small">Syarat: port redirector (default <b>8099</b>) harus bisa diakses dari publik, dan <code>ISOLIR_REDIRECT_URL</code> di billing diisi <code>https://domain/isolated</code>.</p>
</div>
<div class="form-row" style="display:flex;gap:8px;flex-wrap:wrap">
<div class="fg" style="flex:2;min-width:200px"><label class="fl">Domain / IP publik billing (disarankan IP origin, bukan CDN)</label><input class="fc" id="ic-host" value="<%= typeof defaultPublicBase !== 'undefined' ? defaultPublicBase : '' %>" placeholder="https://billing.domain.com"></div>
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Port redirector</label><input class="fc" id="ic-port" type="number" value="8099"></div>
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Profil PPPoE</label><input class="fc" id="ic-profile" value="isolir"></div>
</div>
<button type="button" class="btn btn-p btn-sm my-2" onclick="loadIsolirCloudScript(this)"><i class="bi bi-magic"></i> Generate Script Cloud</button>
<div id="isolir-cloud-meta" class="small text-muted mb-2"></div>
<textarea id="isolir-cloud-body" class="fc" readonly rows="18" style="width:100%;font-family:ui-monospace,monospace;font-size:12px;background:rgba(15,23,42,0.9);color:#818cf8;border-radius:8px;"></textarea>
</div>
<!-- TAB 2: RouterOS Manual Script (.rsc) -->
<div id="isolir-tab-script" style="display:none;">
<p class="small text-muted mb-2"><%= t('admin.rt.script_hint','Script ini di-generate otomatis berdasarkan IP server & URL billing (app_url). Anda dapat menyalin dan menempelkannya di Winbox Terminal MikroTik.') %></p>
@@ -563,6 +583,7 @@
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('isolirScriptModal')"><%= t('common.close', 'Tutup') %></button>
<button type="button" class="btn btn-p" id="btn-copy-cloud-modal" style="display:none;" onclick="copyIsolirCloudScript()"><i class="bi bi-clipboard me-1"></i> Salin Script Cloud</button>
<button type="button" class="btn btn-p" id="btn-copy-script-modal" style="display:none;" onclick="copyIsolirScript()"><i class="bi bi-clipboard me-1"></i> <%= t('admin.routers.isolir_script_copy', 'Salin script') %></button>
</div>
</div>
@@ -891,27 +912,60 @@ async function testConn(id) {
}
function switchIsolirTab(tab) {
const autoTab = document.getElementById('isolir-tab-auto');
const scriptTab = document.getElementById('isolir-tab-script');
const autoBtn = document.getElementById('tab-btn-auto');
const scriptBtn = document.getElementById('tab-btn-script');
const copyBtn = document.getElementById('btn-copy-script-modal');
const panels = {
auto: document.getElementById('isolir-tab-auto'),
script: document.getElementById('isolir-tab-script'),
cloud: document.getElementById('isolir-tab-cloud')
};
const btns = {
auto: document.getElementById('tab-btn-auto'),
script: document.getElementById('tab-btn-script'),
cloud: document.getElementById('tab-btn-cloud')
};
const copyScript = document.getElementById('btn-copy-script-modal');
const copyCloud = document.getElementById('btn-copy-cloud-modal');
Object.keys(panels).forEach(function (k) {
if (panels[k]) panels[k].style.display = (k === tab) ? 'block' : 'none';
if (btns[k]) btns[k].className = 'btn btn-sm ' + (k === tab ? 'btn-p' : 'btn-g');
});
if (copyScript) copyScript.style.display = tab === 'script' ? 'inline-block' : 'none';
if (copyCloud) copyCloud.style.display = tab === 'cloud' ? 'inline-block' : 'none';
}
if (tab === 'auto') {
autoTab.style.display = 'block';
scriptTab.style.display = 'none';
autoBtn.className = 'btn btn-sm btn-p';
scriptBtn.className = 'btn btn-sm btn-g';
if (copyBtn) copyBtn.style.display = 'none';
} else {
autoTab.style.display = 'none';
scriptTab.style.display = 'block';
autoBtn.className = 'btn btn-sm btn-g';
scriptBtn.className = 'btn btn-sm btn-p';
if (copyBtn) copyBtn.style.display = 'inline-block';
async function loadIsolirCloudScript(btn) {
const hostEl = document.getElementById('ic-host');
const portEl = document.getElementById('ic-port');
const profEl = document.getElementById('ic-profile');
const body = document.getElementById('isolir-cloud-body');
const meta = document.getElementById('isolir-cloud-meta');
const old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Membuat...'; }
if (body) body.value = '';
if (meta) meta.textContent = 'Membuat script...';
try {
const url = '/admin/api/isolir-cloud-script?host=' + encodeURIComponent(hostEl ? hostEl.value : '') +
'&port=' + encodeURIComponent(portEl ? portEl.value : '') +
'&profile=' + encodeURIComponent(profEl ? profEl.value : '');
const res = await fetch(url);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Gagal membuat script.');
if (body) body.value = data.script || '';
if (meta) meta.textContent = 'Billing: ' + (data.billingHost || '-') + ' → ' + ((data.billingIps || [data.billingIp]).join(', ')) + ' | redirect port ' + data.redirectPort + ' | ' + (data.isolirUrl || '') + (data.warning ? ' — ' + data.warning : '');
} catch (e) {
if (meta) meta.textContent = 'Error: ' + e.message;
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = old; }
}
}
function copyIsolirCloudScript() {
const ta = document.getElementById('isolir-cloud-body');
if (!ta || !ta.value) return;
ta.select();
try { document.execCommand('copy'); } catch (e) {}
alert('Script cloud disalin ke clipboard.');
}
async function setupFirewall(routerId) {
openIsolirScriptModal(routerId);
switchIsolirTab('auto');