diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js index 4028bbce..1f34fed1 100644 --- a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js @@ -54,6 +54,26 @@ var callVhostList = rpc.declare({ expect: {} }); +var callEmancipate = rpc.declare({ + object: 'luci.exposure', + method: 'emancipate', + params: ['service', 'port', 'domain', 'tor', 'dns', 'mesh'], + expect: {} +}); + +var callRevoke = rpc.declare({ + object: 'luci.exposure', + method: 'revoke', + params: ['service', 'tor', 'dns', 'mesh'], + expect: {} +}); + +var callGetEmancipated = rpc.declare({ + object: 'luci.exposure', + method: 'get_emancipated', + expect: {} +}); + return baseclass.extend({ scan: function() { return callScan(); }, torList: function() { return callTorList(); }, @@ -62,5 +82,8 @@ return baseclass.extend({ torAdd: function(s, l, o) { return callTorAdd(s, l, o); }, torRemove: function(s) { return callTorRemove(s); }, sslAdd: function(s, d, p) { return callSslAdd(s, d, p); }, - sslRemove: function(s) { return callSslRemove(s); } + sslRemove: function(s) { return callSslRemove(s); }, + emancipate: function(svc, port, domain, tor, dns, mesh) { return callEmancipate(svc, port, domain, tor, dns, mesh); }, + revoke: function(svc, tor, dns, mesh) { return callRevoke(svc, tor, dns, mesh); }, + getEmancipated: function() { return callGetEmancipated(); } }); diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css index 8edec572..358736ab 100644 --- a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css @@ -94,6 +94,11 @@ color: var(--exp-ssl); } +.exp-badge-mesh { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + /* Buttons */ .exp-btn { display: inline-flex; @@ -175,3 +180,24 @@ input:checked + .ssl-slider { input:checked + .ssl-slider:before { background-color: #27ae60; } + +input:checked + .mesh-slider { + background-color: rgba(59, 130, 246, 0.3); + border: 1px solid #3b82f6; +} + +input:checked + .mesh-slider:before { + background-color: #3b82f6; +} + +/* Action button */ +.exp-btn-action { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + color: white; + border-color: transparent; +} + +.exp-btn-action:hover { + background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js index 5a5e03de..e143e538 100644 --- a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js @@ -10,7 +10,8 @@ return view.extend({ api.scan(), api.torList(), api.sslList(), - api.vhostList() + api.vhostList(), + api.getEmancipated() ]); }, @@ -19,14 +20,22 @@ return view.extend({ var torResult = data[1] || {}; var sslResult = data[2] || {}; var vhostResult = data[3] || {}; + var emancipatedResult = data[4] || {}; var services = scanResult.services || []; var torServices = torResult.services || []; var sslBackends = sslResult.backends || []; var haproxyVhosts = vhostResult.haproxy || []; var uhttpdVhosts = vhostResult.uhttpd || []; + var emancipatedServices = emancipatedResult.services || []; var self = this; + // Build emancipated lookup by name + var emancipatedByName = {}; + emancipatedServices.forEach(function(e) { + emancipatedByName[e.name] = e; + }); + // Build tor lookup by port (with name fallback) var torByPort = {}; torServices.forEach(function(t) { @@ -70,6 +79,7 @@ return view.extend({ var torCount = torServices.length; var sslCount = sslBackends.length; var domainCount = haproxyVhosts.filter(function(v) { return v.enabled; }).length; + var meshCount = emancipatedServices.filter(function(e) { return e.mesh; }).length; // Sort: services with DNS domains first (alphabetically), then by port services.sort(function(a, b) { @@ -94,6 +104,9 @@ return view.extend({ var uhttpdInfo = uhttpdByPort[svc.port] || null; var domains = domainsByPort[svc.port] || []; var isExternal = svc.external; + var serviceName = (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, ''); + var emancipatedInfo = emancipatedByName[serviceName] || null; + var isMeshPublished = emancipatedInfo && emancipatedInfo.mesh; // Display name comes from enriched scan; show process as subtitle var displayName = svc.name || svc.process; @@ -136,6 +149,12 @@ return view.extend({ ui.createHandlerFn(self, 'handleSslToggle', svc, sslInfo, domains) ) : E('span', { 'class': 'exp-text-muted' }, '-') ), + // Mesh toggle + E('td', { 'style': 'text-align: center;' }, + isExternal ? self.makeToggle(!!isMeshPublished, 'mesh-slider', + ui.createHandlerFn(self, 'handleMeshToggle', svc, emancipatedInfo) + ) : E('span', { 'class': 'exp-text-muted' }, '-') + ), // Exposure info E('td', {}, infoItems.length > 0 ? infoItems : (isExternal ? E('span', { 'class': 'exp-text-muted' }, 'Not exposed') : E('span', { 'class': 'exp-text-muted' }, 'Local only'))) @@ -148,6 +167,11 @@ return view.extend({ E('div', { 'style': 'display: flex; gap: 12px; align-items: center;' }, [ E('span', { 'class': 'exp-badge exp-badge-tor' }, torCount + ' Tor'), E('span', { 'class': 'exp-badge exp-badge-ssl' }, domainCount + ' Domains'), + E('span', { 'class': 'exp-badge exp-badge-mesh' }, meshCount + ' Mesh'), + E('button', { + 'class': 'exp-btn exp-btn-action', + 'click': ui.createHandlerFn(self, 'showEmancipateModal', null) + }, [E('span', {}, '\u{1F680}'), ' Emancipate']), E('button', { 'class': 'exp-btn exp-btn-secondary', 'click': function() { window.location.reload(); } @@ -164,6 +188,7 @@ return view.extend({ E('th', { 'style': 'width: 100px;' }, 'Bind'), E('th', { 'style': 'width: 70px; text-align: center;' }, 'Tor'), E('th', { 'style': 'width: 70px; text-align: center;' }, 'SSL'), + E('th', { 'style': 'width: 70px; text-align: center;' }, 'Mesh'), E('th', {}, 'Exposure') ]) ]), @@ -323,6 +348,135 @@ return view.extend({ } }, + handleMeshToggle: function(svc, emancipatedInfo, ev) { + var self = this; + var cb = ev.target; + var serviceName = (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, ''); + + if (cb.checked && (!emancipatedInfo || !emancipatedInfo.mesh)) { + // Enable mesh - show emancipate modal with mesh pre-selected + self.showEmancipateModal(svc, true); + cb.checked = false; // Reset until modal confirms + } else if (!cb.checked && emancipatedInfo && emancipatedInfo.mesh) { + ui.showModal('Disable Mesh', [ + E('p', {}, 'Remove mesh publishing for ' + serviceName + '?'), + E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [ + E('button', { 'class': 'btn', 'click': function() { cb.checked = true; ui.hideModal(); } }, 'Cancel'), + E('button', { 'class': 'btn cbi-button-negative', 'click': function() { + ui.hideModal(); + ui.showModal('Revoking...', [E('p', { 'class': 'spinning' }, 'Removing mesh exposure...')]); + api.revoke(serviceName, false, false, true).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', {}, 'Mesh exposure removed'), 'info'); + window.location.reload(); + } else { + cb.checked = true; + ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger'); + } + }).catch(function() { cb.checked = true; ui.hideModal(); }); + }}, 'Remove') + ]) + ]); + } + }, + + showEmancipateModal: function(svc, meshOnly) { + var self = this; + var serviceName = svc ? (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, '') : ''; + var servicePort = svc ? svc.port : ''; + + var content = E('div', { 'class': 'exp-modal-content' }, [ + E('p', {}, 'Expose this service through multiple channels:'), + + // Service/Port inputs (if not pre-filled) + !svc ? E('div', { 'class': 'exp-field', 'style': 'margin: 1rem 0;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'), + E('input', { + 'type': 'text', 'id': 'eman-service', 'placeholder': 'gitea', + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px; margin-bottom: 12px;' + }), + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Port'), + E('input', { + 'type': 'number', 'id': 'eman-port', 'placeholder': '3000', + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;' + }) + ]) : E('p', { 'style': 'color: #8892b0;' }, 'Service: ' + serviceName + ' (port ' + servicePort + ')'), + + // Domain input (required for DNS) + E('div', { 'class': 'exp-field', 'style': 'margin: 1rem 0;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Domain (for DNS/SSL)'), + E('input', { + 'type': 'text', 'id': 'eman-domain', 'placeholder': serviceName + '.example.com', + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;' + }) + ]), + + // Channel toggles + E('div', { 'class': 'exp-channels', 'style': 'display: flex; flex-direction: column; gap: 12px; margin: 16px 0; padding: 16px; background: rgba(255,255,255,0.05); border-radius: 8px;' }, [ + E('h4', { 'style': 'margin: 0 0 8px 0; color: var(--exp-text-primary);' }, 'Exposure Channels'), + E('label', { 'class': 'exp-channel-toggle', 'style': 'display: flex; align-items: center; gap: 12px; cursor: pointer;' }, [ + E('input', { 'id': 'eman-tor', 'type': 'checkbox', 'checked': !meshOnly, 'style': 'width: 20px; height: 20px;' }), + E('span', { 'class': 'exp-badge exp-badge-tor' }, '\u{1F9C5} Tor') + ]), + E('label', { 'class': 'exp-channel-toggle', 'style': 'display: flex; align-items: center; gap: 12px; cursor: pointer;' }, [ + E('input', { 'id': 'eman-dns', 'type': 'checkbox', 'checked': !meshOnly, 'style': 'width: 20px; height: 20px;' }), + E('span', { 'class': 'exp-badge exp-badge-ssl' }, '\u{1F310} DNS/SSL') + ]), + E('label', { 'class': 'exp-channel-toggle', 'style': 'display: flex; align-items: center; gap: 12px; cursor: pointer;' }, [ + E('input', { 'id': 'eman-mesh', 'type': 'checkbox', 'checked': true, 'style': 'width: 20px; height: 20px;' }), + E('span', { 'class': 'exp-badge exp-badge-mesh' }, '\u{1F517} Mesh') + ]) + ]) + ]); + + ui.showModal('Emancipate Service', [ + content, + E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-action', + 'click': ui.createHandlerFn(self, 'doEmancipate', svc) + }, 'Emancipate') + ]) + ]); + }, + + doEmancipate: function(svc) { + var service = svc ? (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, '') : document.getElementById('eman-service').value; + var port = svc ? svc.port : parseInt(document.getElementById('eman-port').value); + var domain = document.getElementById('eman-domain').value || ''; + var tor = document.getElementById('eman-tor').checked; + var dns = document.getElementById('eman-dns').checked; + var mesh = document.getElementById('eman-mesh').checked; + + if (!service || !port) { + ui.addNotification(null, E('p', {}, 'Service name and port are required'), 'warning'); + return; + } + + if (dns && !domain) { + ui.addNotification(null, E('p', {}, 'Domain is required for DNS/SSL channel'), 'warning'); + return; + } + + ui.hideModal(); + ui.showModal('Emancipating...', [E('p', { 'class': 'spinning' }, 'Setting up exposure channels...')]); + + api.emancipate(service, port, domain, tor, dns, mesh).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', {}, 'Service emancipated successfully'), 'info'); + window.location.reload(); + } else { + ui.addNotification(null, E('p', {}, 'Emancipation failed: ' + (res.error || 'Unknown')), 'danger'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Error: ' + err.message), 'danger'); + }); + }, + handleSaveApply: null, handleSave: null, handleReset: null diff --git a/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure b/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure index 10de2ba7..b9df5123 100755 --- a/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure +++ b/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure @@ -43,6 +43,22 @@ case "$1" in json_close_object json_add_object "vhost_list" json_close_object + json_add_object "emancipate" + json_add_string "service" "string" + json_add_int "port" "integer" + json_add_string "domain" "string" + json_add_boolean "tor" "boolean" + json_add_boolean "dns" "boolean" + json_add_boolean "mesh" "boolean" + json_close_object + json_add_object "revoke" + json_add_string "service" "string" + json_add_boolean "tor" "boolean" + json_add_boolean "dns" "boolean" + json_add_boolean "mesh" "boolean" + json_close_object + json_add_object "get_emancipated" + json_close_object json_dump ;; @@ -539,6 +555,109 @@ case "$1" in json_dump ;; + emancipate) + read -r input + service=$(echo "$input" | jsonfilter -e '@.service') + port=$(echo "$input" | jsonfilter -e '@.port') + domain=$(echo "$input" | jsonfilter -e '@.domain') + tor=$(echo "$input" | jsonfilter -e '@.tor') + dns=$(echo "$input" | jsonfilter -e '@.dns') + mesh=$(echo "$input" | jsonfilter -e '@.mesh') + + if [ -z "$service" ] || [ -z "$port" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Service and port required" + json_dump + exit 0 + fi + + flags="" + [ "$tor" = "true" ] || [ "$tor" = "1" ] && flags="$flags --tor" + [ "$dns" = "true" ] || [ "$dns" = "1" ] && flags="$flags --dns" + [ "$mesh" = "true" ] || [ "$mesh" = "1" ] && flags="$flags --mesh" + [ -z "$flags" ] && flags="--all" + + result=$(/usr/sbin/secubox-exposure emancipate "$service" "$port" "$domain" $flags 2>&1) + rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Service emancipated" + json_add_string "output" "$result" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump + ;; + + revoke) + read -r input + service=$(echo "$input" | jsonfilter -e '@.service') + tor=$(echo "$input" | jsonfilter -e '@.tor') + dns=$(echo "$input" | jsonfilter -e '@.dns') + mesh=$(echo "$input" | jsonfilter -e '@.mesh') + + if [ -z "$service" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Service name required" + json_dump + exit 0 + fi + + flags="" + [ "$tor" = "true" ] || [ "$tor" = "1" ] && flags="$flags --tor" + [ "$dns" = "true" ] || [ "$dns" = "1" ] && flags="$flags --dns" + [ "$mesh" = "true" ] || [ "$mesh" = "1" ] && flags="$flags --mesh" + [ -z "$flags" ] && flags="--all" + + result=$(/usr/sbin/secubox-exposure revoke "$service" $flags 2>&1) + rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Service revoked" + json_add_string "output" "$result" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump + ;; + + get_emancipated) + json_init + json_add_array "services" + + # Read emancipated services from UCI + for svc in $(uci show secubox-exposure 2>/dev/null | grep "=service$" | cut -d'.' -f2 | cut -d'=' -f1); do + emancipated=$(uci -q get "secubox-exposure.$svc.emancipated") + [ "$emancipated" != "1" ] && continue + + port=$(uci -q get "secubox-exposure.$svc.port") + domain=$(uci -q get "secubox-exposure.$svc.domain") + tor=$(uci -q get "secubox-exposure.$svc.tor") + dns=$(uci -q get "secubox-exposure.$svc.dns") + mesh=$(uci -q get "secubox-exposure.$svc.mesh") + + json_add_object "" + json_add_string "name" "$svc" + json_add_int "port" "${port:-0}" + json_add_string "domain" "${domain:-}" + json_add_boolean "tor" "${tor:-0}" + json_add_boolean "dns" "${dns:-0}" + json_add_boolean "mesh" "${mesh:-0}" + json_close_object + done + + json_close_array + json_dump + ;; + *) json_init json_add_boolean "error" 1 diff --git a/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json b/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json index 18a93bb1..30c57dc8 100644 --- a/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json +++ b/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json @@ -3,13 +3,13 @@ "description": "Grant access to SecuBox Service Exposure Manager", "read": { "ubus": { - "luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config", "vhost_list"] + "luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config", "vhost_list", "get_emancipated"] }, "uci": ["secubox-exposure"] }, "write": { "ubus": { - "luci.exposure": ["fix_port", "tor_add", "tor_remove", "ssl_add", "ssl_remove", "set_config"] + "luci.exposure": ["fix_port", "tor_add", "tor_remove", "ssl_add", "ssl_remove", "set_config", "emancipate", "revoke"] }, "uci": ["secubox-exposure"] }