feat(exposure): Add Mesh column and Emancipate modal to dashboard
- Add emancipate/revoke/get_emancipated RPCD methods - Add Mesh toggle column with blue theme styling - Add Emancipate button in header with multi-channel modal - Modal allows selecting Tor/DNS/Mesh channels - Add mesh badge to header stats - Update ACL and API wrapper for new methods Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
53af5ac2e9
commit
b75fbd516c
@ -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(); }
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user