Merge branch 'release/v0.15.0'

This commit is contained in:
CyberMind-FR 2026-01-26 11:25:55 +01:00
commit 5cd6c128f3
49 changed files with 5789 additions and 75 deletions

View File

@ -0,0 +1,34 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-exposure
PKG_VERSION:=1.0.0
PKG_RELEASE:=3
PKG_MAINTAINER:=SecuBox Team <contact@secubox.dev>
PKG_LICENSE:=MIT
LUCI_TITLE:=LuCI SecuBox Service Exposure Manager
LUCI_DEPENDS:=+luci-base +secubox-app-exposure
LUCI_PKGARCH:=all
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-exposure/install
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.exposure $(1)/usr/libexec/rpcd/
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-exposure.json $(1)/usr/share/rpcd/acl.d/
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-exposure.json $(1)/usr/share/luci/menu.d/
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/exposure
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/exposure/*.js $(1)/www/luci-static/resources/view/exposure/
$(INSTALL_DIR) $(1)/www/luci-static/resources/exposure
$(INSTALL_DATA) ./htdocs/luci-static/resources/exposure/*.js $(1)/www/luci-static/resources/exposure/
$(INSTALL_DATA) ./htdocs/luci-static/resources/exposure/*.css $(1)/www/luci-static/resources/exposure/
endef
$(eval $(call BuildPackage,luci-app-exposure))

View File

@ -0,0 +1,82 @@
'use strict';
'require baseclass';
'require rpc';
return baseclass.extend({
callScan: rpc.declare({
object: 'luci.exposure',
method: 'scan',
expect: { services: [] }
}),
callConflicts: rpc.declare({
object: 'luci.exposure',
method: 'conflicts',
expect: { conflicts: [] }
}),
callStatus: rpc.declare({
object: 'luci.exposure',
method: 'status'
}),
callTorList: rpc.declare({
object: 'luci.exposure',
method: 'tor_list',
expect: { services: [] }
}),
callSslList: rpc.declare({
object: 'luci.exposure',
method: 'ssl_list',
expect: { backends: [] }
}),
callGetConfig: rpc.declare({
object: 'luci.exposure',
method: 'get_config',
expect: { known_services: [] }
}),
callFixPort: rpc.declare({
object: 'luci.exposure',
method: 'fix_port',
params: ['service', 'port']
}),
callTorAdd: rpc.declare({
object: 'luci.exposure',
method: 'tor_add',
params: ['service', 'local_port', 'onion_port']
}),
callTorRemove: rpc.declare({
object: 'luci.exposure',
method: 'tor_remove',
params: ['service']
}),
callSslAdd: rpc.declare({
object: 'luci.exposure',
method: 'ssl_add',
params: ['service', 'domain', 'local_port']
}),
callSslRemove: rpc.declare({
object: 'luci.exposure',
method: 'ssl_remove',
params: ['service']
}),
scan: function() { return this.callScan(); },
conflicts: function() { return this.callConflicts(); },
status: function() { return this.callStatus(); },
torList: function() { return this.callTorList(); },
sslList: function() { return this.callSslList(); },
getConfig: function() { return this.callGetConfig(); },
fixPort: function(s, p) { return this.callFixPort(s, p); },
torAdd: function(s, l, o) { return this.callTorAdd(s, l, o); },
torRemove: function(s) { return this.callTorRemove(s); },
sslAdd: function(s, d, p) { return this.callSslAdd(s, d, p); },
sslRemove: function(s) { return this.callSslRemove(s); }
});

View File

@ -0,0 +1,869 @@
/* SecuBox Service Exposure Manager - Dashboard Styles */
/* Unified theme matching SecuBox HAProxy dashboard */
:root {
--exp-bg-primary: #0d1117;
--exp-bg-secondary: #161b22;
--exp-bg-tertiary: #1a1a2e;
--exp-border: #30363d;
--exp-text-primary: #e6edf3;
--exp-text-secondary: #8892b0;
--exp-text-muted: #6e7681;
--exp-accent: #64ffda;
--exp-tor: #9b59b6;
--exp-ssl: #27ae60;
--exp-success: #22c55e;
--exp-warning: #f97316;
--exp-danger: #ef4444;
}
.exposure-dashboard {
padding: 0;
max-width: 1400px;
margin: 0 auto;
}
/* Page Header */
.exp-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--exp-border);
}
.exp-page-title {
font-size: 28px;
font-weight: 700;
color: var(--exp-text-primary);
display: flex;
align-items: center;
gap: 12px;
margin: 0;
}
.exp-page-title-icon {
font-size: 32px;
}
.exp-page-subtitle {
color: var(--exp-text-secondary);
font-size: 14px;
margin: 4px 0 0 0;
}
.exp-header-badges {
display: flex;
gap: 12px;
}
.exp-header-badge {
background: var(--exp-bg-tertiary);
border: 1px solid var(--exp-border);
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
color: var(--exp-text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
/* Stats Grid */
.exp-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.exp-stat-card {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid var(--exp-border);
border-radius: 12px;
padding: 20px;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
}
.exp-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.exp-stat-card.exp-stat-tor {
border-color: rgba(155, 89, 182, 0.4);
}
.exp-stat-card.exp-stat-ssl {
border-color: rgba(39, 174, 96, 0.4);
}
.exp-stat-icon {
font-size: 32px;
margin-bottom: 8px;
}
.exp-stat-value {
font-size: 36px;
font-weight: 700;
color: var(--exp-accent);
margin-bottom: 4px;
}
.exp-stat-card.exp-stat-tor .exp-stat-value {
color: var(--exp-tor);
}
.exp-stat-card.exp-stat-ssl .exp-stat-value {
color: var(--exp-ssl);
}
.exp-stat-label {
font-size: 14px;
color: var(--exp-text-secondary);
margin-bottom: 4px;
}
.exp-stat-trend {
font-size: 12px;
color: var(--exp-text-muted);
}
/* Cards */
.exp-card {
background: var(--exp-bg-secondary);
border: 1px solid var(--exp-border);
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
}
.exp-card.exp-warning-card {
border-left: 4px solid var(--exp-warning);
}
.exp-card.exp-suggestions-card {
border-left: 4px solid var(--exp-accent);
}
.exp-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--exp-border);
background: rgba(255, 255, 255, 0.02);
}
.exp-card-title {
font-size: 16px;
font-weight: 600;
color: var(--exp-text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.exp-card-title-icon {
font-size: 20px;
}
.exp-card-body {
padding: 20px;
}
.exp-card-body.no-padding {
padding: 0;
}
/* Row layout */
.exp-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 900px) {
.exp-row {
flex-direction: column;
}
}
/* Empty state */
.exp-empty {
text-align: center;
padding: 40px 20px;
color: var(--exp-text-muted);
}
.exp-empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.exp-empty-text {
font-size: 16px;
color: var(--exp-text-secondary);
margin-bottom: 8px;
}
.exp-empty-hint {
font-size: 13px;
color: var(--exp-text-muted);
}
/* Suggestions Grid */
.exp-suggestions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.exp-suggestion-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--exp-bg-tertiary);
border: 1px solid var(--exp-border);
border-radius: 8px;
transition: border-color 0.2s, background 0.2s;
}
.exp-suggestion-item:hover {
border-color: var(--exp-accent);
background: rgba(100, 255, 218, 0.05);
}
.exp-suggestion-icon {
font-size: 28px;
min-width: 40px;
text-align: center;
}
.exp-suggestion-info {
flex: 1;
min-width: 0;
}
.exp-suggestion-name {
font-weight: 600;
color: var(--exp-text-primary);
font-size: 14px;
}
.exp-suggestion-port {
font-size: 12px;
color: var(--exp-text-muted);
font-family: monospace;
}
.exp-suggestion-actions {
display: flex;
gap: 6px;
}
/* Services list */
.exp-services-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.exp-service-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--exp-bg-tertiary);
border-radius: 8px;
transition: background 0.2s;
}
.exp-service-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.exp-service-icon {
font-size: 24px;
min-width: 32px;
text-align: center;
}
.exp-service-info {
flex: 1;
min-width: 0;
}
.exp-service-name {
font-weight: 600;
color: var(--exp-text-primary);
font-size: 14px;
}
.exp-service-detail {
font-size: 12px;
font-family: monospace;
word-break: break-all;
}
.exp-service-detail.exp-onion {
color: var(--exp-tor);
}
.exp-service-detail.exp-domain {
color: var(--exp-ssl);
}
/* Buttons */
.exp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
text-decoration: none;
}
.exp-btn:hover {
transform: translateY(-1px);
}
.exp-btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.exp-btn-xs {
padding: 4px 8px;
font-size: 16px;
min-width: 32px;
}
.exp-btn-primary {
background: linear-gradient(135deg, #64ffda, #4fc3f7);
color: #0d1117;
border: none;
}
.exp-btn-primary:hover {
box-shadow: 0 4px 15px rgba(100, 255, 218, 0.4);
}
.exp-btn-secondary {
background: transparent;
color: var(--exp-text-secondary);
border-color: var(--exp-border);
}
.exp-btn-secondary:hover {
background: rgba(255, 255, 255, 0.05);
border-color: var(--exp-text-secondary);
}
.exp-btn-tor {
background: rgba(155, 89, 182, 0.2);
color: var(--exp-tor);
border-color: var(--exp-tor);
}
.exp-btn-tor:hover {
background: var(--exp-tor);
color: #fff;
}
.exp-btn-ssl {
background: rgba(39, 174, 96, 0.2);
color: var(--exp-ssl);
border-color: var(--exp-ssl);
}
.exp-btn-ssl:hover {
background: var(--exp-ssl);
color: #fff;
}
.exp-btn-danger {
background: rgba(239, 68, 68, 0.2);
color: var(--exp-danger);
border-color: var(--exp-danger);
}
.exp-btn-danger:hover {
background: var(--exp-danger);
color: #fff;
}
/* Quick Actions */
.exp-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.exp-action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 24px;
background: var(--exp-bg-tertiary);
border: 1px solid var(--exp-border);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: inherit;
min-width: 100px;
}
.exp-action-btn:hover {
background: rgba(100, 255, 218, 0.1);
border-color: var(--exp-accent);
transform: translateY(-2px);
}
.exp-action-icon {
font-size: 24px;
}
.exp-action-label {
font-size: 12px;
color: var(--exp-text-secondary);
font-weight: 500;
}
/* Table styles */
.exp-table {
width: 100%;
border-collapse: collapse;
}
.exp-table th,
.exp-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--exp-border);
}
.exp-table th {
color: var(--exp-text-muted);
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.exp-table td {
color: var(--exp-text-primary);
}
.exp-table tr:hover td {
background: rgba(100, 255, 218, 0.03);
}
/* Badge styles */
.exp-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.exp-badge-success {
background: rgba(34, 197, 94, 0.2);
color: var(--exp-success);
}
.exp-badge-warning {
background: rgba(249, 115, 22, 0.2);
color: var(--exp-warning);
}
.exp-badge-danger {
background: rgba(239, 68, 68, 0.2);
color: var(--exp-danger);
}
.exp-badge-info {
background: rgba(100, 255, 218, 0.2);
color: var(--exp-accent);
}
.exp-badge-tor {
background: rgba(155, 89, 182, 0.2);
color: var(--exp-tor);
}
.exp-badge-ssl {
background: rgba(39, 174, 96, 0.2);
color: var(--exp-ssl);
}
/* Monospace text */
.exp-mono {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
}
/* Toast notification */
.exp-toast {
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Toggle switches (from services.js) */
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #333;
transition: 0.3s;
border-radius: 26px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: #666;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: #1a1a2e;
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
input:checked + .tor-slider {
background-color: rgba(155, 89, 182, 0.3);
border: 1px solid #9b59b6;
}
input:checked + .tor-slider:before {
background-color: #9b59b6;
}
input:checked + .ssl-slider {
background-color: rgba(39, 174, 96, 0.3);
border: 1px solid #27ae60;
}
input:checked + .ssl-slider:before {
background-color: #27ae60;
}
.toggle-slider:hover {
border: 1px solid #555;
}
/* === Progress Modal Styles === */
.exp-progress-modal {
min-width: 400px;
}
.exp-progress-header {
text-align: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--exp-border);
}
.exp-progress-title {
font-size: 18px;
font-weight: 600;
color: var(--exp-text-primary);
margin-bottom: 4px;
}
.exp-progress-subtitle {
font-size: 13px;
color: var(--exp-text-muted);
}
.exp-progress-steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.exp-progress-step {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: var(--exp-bg-tertiary);
border-radius: 8px;
border-left: 3px solid var(--exp-border);
transition: all 0.3s ease;
}
.exp-progress-step[data-status="pending"] {
opacity: 0.5;
}
.exp-progress-step[data-status="active"] {
border-left-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.exp-progress-step[data-status="complete"] {
border-left-color: var(--exp-success);
}
.exp-progress-step[data-status="error"] {
border-left-color: var(--exp-danger);
background: rgba(239, 68, 68, 0.1);
}
.exp-step-indicator {
min-width: 32px;
height: 32px;
border-radius: 50%;
background: var(--exp-border);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.exp-progress-step[data-status="active"] .exp-step-indicator {
background: #3b82f6;
}
.exp-progress-step[data-status="complete"] .exp-step-indicator {
background: var(--exp-success);
}
.exp-progress-step[data-status="error"] .exp-step-indicator {
background: var(--exp-danger);
}
.exp-step-number {
font-size: 14px;
font-weight: 600;
color: var(--exp-text-secondary);
}
.exp-progress-step[data-status="active"] .exp-step-number,
.exp-progress-step[data-status="complete"] .exp-step-number,
.exp-progress-step[data-status="error"] .exp-step-number {
color: #fff;
}
.exp-progress-step[data-status="complete"] .exp-step-number::before {
content: '\2713';
}
.exp-progress-step[data-status="complete"] .exp-step-number {
font-size: 0;
}
.exp-progress-step[data-status="complete"] .exp-step-number::before {
font-size: 16px;
}
.exp-progress-step[data-status="error"] .exp-step-number::before {
content: '\2717';
}
.exp-progress-step[data-status="error"] .exp-step-number {
font-size: 0;
}
.exp-progress-step[data-status="error"] .exp-step-number::before {
font-size: 16px;
}
.exp-progress-step[data-status="active"] .exp-step-indicator::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid #3b82f6;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
.exp-step-content {
flex: 1;
min-width: 0;
}
.exp-step-label {
font-size: 14px;
font-weight: 500;
color: var(--exp-text-primary);
margin-bottom: 2px;
}
.exp-step-detail {
font-size: 12px;
color: var(--exp-text-muted);
word-break: break-word;
}
.exp-progress-step[data-status="active"] .exp-step-detail {
color: #93c5fd;
}
.exp-progress-step[data-status="error"] .exp-step-detail {
color: #fca5a5;
}
/* Progress Result */
.exp-progress-result {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
text-align: center;
}
.exp-progress-result.success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--exp-success);
}
.exp-progress-result.error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--exp-danger);
}
.exp-result-icon {
font-size: 32px;
margin-bottom: 8px;
}
.exp-result-message {
font-size: 16px;
font-weight: 600;
color: var(--exp-text-primary);
margin-bottom: 8px;
}
.exp-result-details {
font-size: 13px;
color: var(--exp-text-secondary);
}
/* === Loading Skeleton === */
.exp-skeleton {
background: linear-gradient(90deg, var(--exp-bg-tertiary) 25%, var(--exp-bg-secondary) 50%, var(--exp-bg-tertiary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 8px;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.exp-skeleton-stat {
height: 120px;
}
.exp-skeleton-card {
height: 200px;
}
.exp-skeleton-text {
height: 20px;
margin-bottom: 8px;
}
.exp-skeleton-text.short {
width: 60%;
}
/* === Fade-in Animation === */
.exp-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* === Loading State === */
.exp-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(13, 17, 23, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 12px;
}
.exp-loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--exp-border);
border-top-color: var(--exp-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@ -0,0 +1,460 @@
'use strict';
'require view';
'require dom';
'require ui';
'require exposure/api as api';
/**
* Unified Service Exposure Manager
* Toggle Tor Hidden Services and SSL/HAProxy exposure with checkboxes
*/
return view.extend({
load: function() {
return Promise.all([
api.scan(),
api.getConfig(),
api.torList(),
api.sslList()
]);
},
render: function(data) {
var scanResult = data[0] || {};
var configResult = data[1] || {};
var torResult = data[2] || {};
var sslResult = data[3] || {};
var services = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
var knownServices = Array.isArray(configResult) ? configResult : (configResult.known_services || []);
var torServices = Array.isArray(torResult) ? torResult : (torResult.services || []);
var sslBackends = Array.isArray(sslResult) ? sslResult : (sslResult.backends || []);
var self = this;
// Build lookup maps for current exposure status
var torByService = {};
torServices.forEach(function(t) {
torByService[t.service] = t;
});
var sslByService = {};
sslBackends.forEach(function(s) {
sslByService[s.service] = s;
});
// Inject CSS
var cssLink = document.querySelector('link[href*="exposure/dashboard.css"]');
if (!cssLink) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('exposure/dashboard.css');
document.head.appendChild(link);
}
// Filter to only external services (exposable)
var exposableServices = services.filter(function(svc) {
return svc.external;
});
var view = E('div', { 'class': 'exposure-dashboard' }, [
E('h2', {}, 'Service Exposure Manager'),
E('p', { 'style': 'color: #8892b0; margin-bottom: 1.5rem;' },
'Enable or disable exposure of local services via Tor Hidden Services (.onion) or SSL Web (HAProxy)'),
// Stats bar
E('div', { 'class': 'exposure-stats', 'style': 'display: flex; gap: 1rem; margin-bottom: 1.5rem;' }, [
E('div', { 'class': 'stat-card', 'style': 'flex: 1; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 1rem; border-radius: 8px; border: 1px solid #333;' }, [
E('div', { 'style': 'font-size: 2rem; font-weight: bold; color: #64ffda;' }, String(exposableServices.length)),
E('div', { 'style': 'color: #8892b0; font-size: 0.875rem;' }, 'Exposable Services')
]),
E('div', { 'class': 'stat-card', 'style': 'flex: 1; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 1rem; border-radius: 8px; border: 1px solid #9b59b6;' }, [
E('div', { 'style': 'font-size: 2rem; font-weight: bold; color: #9b59b6;' }, String(torServices.length)),
E('div', { 'style': 'color: #8892b0; font-size: 0.875rem;' }, 'Tor Hidden Services')
]),
E('div', { 'class': 'stat-card', 'style': 'flex: 1; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 1rem; border-radius: 8px; border: 1px solid #27ae60;' }, [
E('div', { 'style': 'font-size: 2rem; font-weight: bold; color: #27ae60;' }, String(sslBackends.length)),
E('div', { 'style': 'color: #8892b0; font-size: 0.875rem;' }, 'SSL Backends')
])
]),
// Main table
E('div', { 'class': 'exposure-section' }, [
E('div', { 'class': 'exposure-section-header' }, [
E('div', { 'class': 'exposure-section-title' }, [
E('span', { 'class': 'icon' }, '\ud83d\udd0c'),
'Service Exposure Control'
]),
E('button', {
'class': 'btn-action btn-primary',
'click': function() { location.reload(); }
}, '\u21bb Refresh')
]),
exposableServices.length > 0 ?
E('table', { 'class': 'exposure-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', { 'style': 'width: 60px;' }, 'Port'),
E('th', {}, 'Service'),
E('th', { 'style': 'width: 80px;' }, 'Process'),
E('th', { 'style': 'width: 120px; text-align: center;' }, [
E('span', { 'style': 'color: #9b59b6;' }, '\ud83e\uddc5 Tor')
]),
E('th', { 'style': 'width: 120px; text-align: center;' }, [
E('span', { 'style': 'color: #27ae60;' }, '\ud83d\udd12 SSL')
]),
E('th', { 'style': 'width: 200px;' }, 'Details')
])
]),
E('tbody', {},
exposableServices.map(function(svc) {
var serviceName = self.getServiceName(svc);
var torInfo = torByService[serviceName];
var sslInfo = sslByService[serviceName];
var isTorEnabled = !!torInfo;
var isSslEnabled = !!sslInfo;
return E('tr', { 'data-service': serviceName, 'data-port': svc.port }, [
E('td', { 'style': 'font-weight: 600; font-family: monospace;' }, String(svc.port)),
E('td', {}, [
E('strong', {}, svc.name || svc.process),
svc.name !== svc.process ? E('small', { 'style': 'color: #8892b0; display: block;' }, svc.process) : null
]),
E('td', { 'style': 'font-family: monospace; font-size: 0.8rem; color: #8892b0;' }, svc.process),
// Tor checkbox
E('td', { 'style': 'text-align: center;' }, [
E('label', { 'class': 'toggle-switch' }, [
E('input', {
'type': 'checkbox',
'checked': isTorEnabled,
'data-service': serviceName,
'data-port': svc.port,
'data-type': 'tor',
'change': ui.createHandlerFn(self, 'handleToggleTor', svc, serviceName, isTorEnabled)
}),
E('span', { 'class': 'toggle-slider tor-slider' })
])
]),
// SSL checkbox
E('td', { 'style': 'text-align: center;' }, [
E('label', { 'class': 'toggle-switch' }, [
E('input', {
'type': 'checkbox',
'checked': isSslEnabled,
'data-service': serviceName,
'data-port': svc.port,
'data-type': 'ssl',
'change': ui.createHandlerFn(self, 'handleToggleSsl', svc, serviceName, isSslEnabled, sslInfo)
}),
E('span', { 'class': 'toggle-slider ssl-slider' })
])
]),
// Details column
E('td', { 'style': 'font-size: 0.8rem;' }, [
torInfo ? E('div', { 'style': 'color: #9b59b6; margin-bottom: 2px;' }, [
E('code', { 'style': 'font-size: 0.7rem;' }, (torInfo.onion || '').substring(0, 20) + '...')
]) : null,
sslInfo ? E('div', { 'style': 'color: #27ae60;' }, [
E('code', { 'style': 'font-size: 0.7rem;' }, sslInfo.domain || 'N/A')
]) : null,
!torInfo && !sslInfo ? E('span', { 'style': 'color: #666;' }, 'Not exposed') : null
])
]);
})
)
]) :
E('div', { 'class': 'exposure-empty' }, [
E('div', { 'class': 'icon' }, '\ud83d\udd0c'),
E('p', {}, 'No exposable services detected'),
E('small', {}, 'Services bound to 0.0.0.0 or :: will appear here')
]),
// Toggle switch styles
E('style', {}, `
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #333;
transition: 0.3s;
border-radius: 26px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: #666;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: #1a1a2e;
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
input:checked + .tor-slider {
background-color: rgba(155, 89, 182, 0.3);
border: 1px solid #9b59b6;
}
input:checked + .tor-slider:before {
background-color: #9b59b6;
}
input:checked + .ssl-slider {
background-color: rgba(39, 174, 96, 0.3);
border: 1px solid #27ae60;
}
input:checked + .ssl-slider:before {
background-color: #27ae60;
}
.toggle-slider:hover {
border: 1px solid #555;
}
`)
])
]);
return view;
},
getServiceName: function(svc) {
var name = svc.name ? svc.name.toLowerCase().replace(/\s+/g, '') : svc.process;
// Clean up common variations
return name.replace(/[^a-z0-9]/g, '');
},
handleToggleTor: function(svc, serviceName, wasEnabled, ev) {
var self = this;
var checkbox = ev.target;
var isNowChecked = checkbox.checked;
if (isNowChecked && !wasEnabled) {
// Enable Tor - show config dialog
ui.showModal('Enable Tor Hidden Service', [
E('p', {}, 'Create a .onion address for ' + (svc.name || svc.process)),
E('div', { 'style': 'margin: 1rem 0;' }, [
E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'),
E('input', {
'type': 'text',
'id': 'tor-svc-name',
'value': serviceName,
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
})
]),
E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Local Port'),
E('input', {
'type': 'number',
'id': 'tor-local-port',
'value': svc.port,
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
})
]),
E('div', {}, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Onion Port (public)'),
E('input', {
'type': 'number',
'id': 'tor-onion-port',
'value': '80',
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
})
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px;' }, [
E('button', {
'class': 'btn',
'click': function() {
checkbox.checked = false;
ui.hideModal();
}
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
var name = document.getElementById('tor-svc-name').value;
var localPort = parseInt(document.getElementById('tor-local-port').value);
var onionPort = parseInt(document.getElementById('tor-onion-port').value);
ui.hideModal();
ui.showModal('Creating Hidden Service...', [
E('p', { 'class': 'spinning' }, 'Generating .onion address...')
]);
api.torAdd(name, localPort, onionPort).then(function(res) {
ui.hideModal();
if (res.success) {
ui.addNotification(null, E('p', {}, [
'Tor hidden service enabled: ',
E('code', {}, res.onion || 'Created')
]), 'success');
location.reload();
} else {
checkbox.checked = false;
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
}
});
}
}, 'Enable Tor')
])
]);
} else if (!isNowChecked && wasEnabled) {
// Disable Tor
ui.showModal('Disable Tor Hidden Service', [
E('p', {}, 'Remove the .onion address for ' + serviceName + '?'),
E('p', { 'style': 'color: #e74c3c;' }, 'Warning: The onion address will be permanently deleted.'),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
E('button', {
'class': 'btn',
'click': function() {
checkbox.checked = true;
ui.hideModal();
}
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-negative',
'click': function() {
ui.hideModal();
api.torRemove(serviceName).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Tor hidden service disabled'), 'success');
location.reload();
} else {
checkbox.checked = true;
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
}
});
}
}, 'Disable Tor')
])
]);
}
},
handleToggleSsl: function(svc, serviceName, wasEnabled, sslInfo, ev) {
var self = this;
var checkbox = ev.target;
var isNowChecked = checkbox.checked;
if (isNowChecked && !wasEnabled) {
// Enable SSL - show config dialog
ui.showModal('Enable SSL/HAProxy Backend', [
E('p', {}, 'Configure HTTPS reverse proxy for ' + (svc.name || svc.process)),
E('div', { 'style': 'margin: 1rem 0;' }, [
E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'),
E('input', {
'type': 'text',
'id': 'ssl-svc-name',
'value': serviceName,
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
})
]),
E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Domain (FQDN)'),
E('input', {
'type': 'text',
'id': 'ssl-domain',
'placeholder': serviceName + '.example.com',
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
})
]),
E('div', {}, [
E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Backend Port'),
E('input', {
'type': 'number',
'id': 'ssl-port',
'value': svc.port,
'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;'
})
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px;' }, [
E('button', {
'class': 'btn',
'click': function() {
checkbox.checked = false;
ui.hideModal();
}
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-action',
'click': function() {
var name = document.getElementById('ssl-svc-name').value;
var domain = document.getElementById('ssl-domain').value;
var port = parseInt(document.getElementById('ssl-port').value);
if (!domain) {
ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning');
return;
}
ui.hideModal();
api.sslAdd(name, domain, port).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'SSL backend configured for ' + domain), 'success');
location.reload();
} else {
checkbox.checked = false;
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
}
});
}
}, 'Enable SSL')
])
]);
} else if (!isNowChecked && wasEnabled) {
// Disable SSL
var domain = sslInfo ? sslInfo.domain : serviceName;
ui.showModal('Disable SSL Backend', [
E('p', {}, 'Remove HAProxy backend for ' + serviceName + '?'),
sslInfo && sslInfo.domain ? E('p', { 'style': 'color: #8892b0;' }, 'Domain: ' + sslInfo.domain) : null,
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [
E('button', {
'class': 'btn',
'click': function() {
checkbox.checked = true;
ui.hideModal();
}
}, 'Cancel'),
E('button', {
'class': 'btn cbi-button-negative',
'click': function() {
ui.hideModal();
api.sslRemove(serviceName).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'SSL backend disabled'), 'success');
location.reload();
} else {
checkbox.checked = true;
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger');
}
});
}
}, 'Disable SSL')
])
]);
}
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,222 @@
'use strict';
'require view';
'require dom';
'require ui';
'require exposure/api as api';
return view.extend({
load: function() {
return Promise.all([
api.sslList(),
api.scan()
]);
},
render: function(data) {
var sslResult = data[0] || {};
var scanResult = data[1] || {};
var sslBackends = Array.isArray(sslResult) ? sslResult : (sslResult.backends || []);
var allServices = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
var self = this;
// Inject CSS
var cssLink = document.querySelector('link[href*="exposure/dashboard.css"]');
if (!cssLink) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('exposure/dashboard.css');
document.head.appendChild(link);
}
var view = E('div', { 'class': 'exposure-dashboard' }, [
E('h2', {}, '\ud83d\udd12 HAProxy SSL Backends'),
E('p', { 'style': 'color: #8892b0; margin-bottom: 1.5rem;' },
'Configure HTTPS reverse proxy for your services'),
// Add new backend form
E('div', { 'class': 'exposure-section' }, [
E('div', { 'class': 'exposure-section-header' }, [
E('div', { 'class': 'exposure-section-title' }, [
E('span', { 'class': 'icon' }, '\u2795'),
'Add SSL Backend'
])
]),
E('div', { 'class': 'exposure-form' }, [
E('div', { 'class': 'exposure-form-group' }, [
E('label', {}, 'Service'),
E('select', { 'id': 'new-ssl-service' },
[E('option', { 'value': '' }, '-- Select --')].concat(
allServices.filter(function(s) { return s.external; }).map(function(s) {
var name = s.name || s.process;
return E('option', {
'value': s.process,
'data-port': s.port,
'data-name': name.toLowerCase().replace(/\s+/g, '')
}, name + ' (:' + s.port + ')');
})
)
)
]),
E('div', { 'class': 'exposure-form-group' }, [
E('label', {}, 'Domain'),
E('input', {
'type': 'text',
'id': 'new-ssl-domain',
'placeholder': 'service.example.com'
})
]),
E('div', { 'class': 'exposure-form-group' }, [
E('label', {}, 'Backend Port'),
E('input', { 'type': 'number', 'id': 'new-ssl-port', 'placeholder': '3000' })
]),
E('button', {
'class': 'btn-action btn-primary',
'click': ui.createHandlerFn(self, 'handleAdd')
}, 'Add Backend')
])
]),
// Info box
E('div', {
'class': 'exposure-section',
'style': 'background: rgba(0, 212, 255, 0.1); border-color: #00d4ff;'
}, [
E('p', { 'style': 'margin: 0; color: #ccd6f6;' }, [
E('strong', {}, '\u2139\ufe0f SSL Certificate: '),
'After adding a backend, upload the SSL certificate to ',
E('code', {}, '/srv/lxc/haproxy/rootfs/etc/haproxy/certs/'),
'. The certificate file should be named ',
E('code', {}, 'domain.pem'),
' and contain both the certificate and private key.'
])
]),
// Existing backends
E('div', { 'class': 'exposure-section' }, [
E('div', { 'class': 'exposure-section-header' }, [
E('div', { 'class': 'exposure-section-title' }, [
E('span', { 'class': 'icon' }, '\ud83d\udd12'),
'Active SSL Backends (' + sslBackends.length + ')'
]),
E('button', {
'class': 'btn-action btn-primary',
'click': function() { location.reload(); }
}, 'Refresh')
]),
sslBackends.length > 0 ?
E('table', { 'class': 'exposure-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Service'),
E('th', {}, 'Domain'),
E('th', {}, 'Backend'),
E('th', {}, 'Actions')
])
]),
E('tbody', {},
sslBackends.map(function(b) {
return E('tr', {}, [
E('td', { 'style': 'font-weight: 600;' }, b.service),
E('td', {}, [
E('a', {
'href': 'https://' + b.domain,
'target': '_blank',
'style': 'color: #00d4ff;'
}, b.domain),
E('span', { 'style': 'margin-left: 0.5rem;' }, '\ud83d\udd17')
]),
E('td', { 'style': 'font-family: monospace;' }, b.backend || 'N/A'),
E('td', {}, [
E('button', {
'class': 'btn-action btn-danger',
'click': ui.createHandlerFn(self, 'handleRemove', b.service)
}, 'Remove')
])
]);
})
)
]) :
E('div', { 'class': 'exposure-empty' }, [
E('div', { 'class': 'icon' }, '\ud83d\udd12'),
E('p', {}, 'No SSL backends configured'),
E('p', { 'style': 'font-size: 0.85rem;' }, 'Select a service above to add HTTPS access')
])
])
]);
// Wire up service selector
setTimeout(function() {
var sel = document.getElementById('new-ssl-service');
var portInput = document.getElementById('new-ssl-port');
var domainInput = document.getElementById('new-ssl-domain');
if (sel && portInput) {
sel.addEventListener('change', function() {
var opt = sel.options[sel.selectedIndex];
portInput.value = opt.dataset.port || '';
if (opt.dataset.name) {
domainInput.placeholder = opt.dataset.name + '.example.com';
}
});
}
}, 100);
return view;
},
handleAdd: function(ev) {
var service = document.getElementById('new-ssl-service').value;
var domain = document.getElementById('new-ssl-domain').value;
var port = parseInt(document.getElementById('new-ssl-port').value);
if (!service) {
ui.addNotification(null, E('p', {}, 'Please select a service'), 'warning');
return;
}
if (!domain) {
ui.addNotification(null, E('p', {}, 'Please enter a domain'), 'warning');
return;
}
if (!port) {
ui.addNotification(null, E('p', {}, 'Please specify the backend port'), 'warning');
return;
}
api.sslAdd(service, domain, port).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, [
'SSL backend configured for ',
E('strong', {}, domain),
E('br'),
'Remember to upload the SSL certificate!'
]), 'success');
location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
}
}).catch(function(err) {
ui.addNotification(null, E('p', {}, 'Error: ' + err.message), 'danger');
});
},
handleRemove: function(service, ev) {
if (!confirm('Remove SSL backend for ' + service + '?')) {
return;
}
api.sslRemove(service).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'SSL backend removed'), 'success');
location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
}
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,201 @@
'use strict';
'require view';
'require dom';
'require ui';
'require exposure/api as api';
return view.extend({
load: function() {
return Promise.all([
api.torList(),
api.scan()
]);
},
render: function(data) {
var torResult = data[0] || {};
var scanResult = data[1] || {};
var torServices = Array.isArray(torResult) ? torResult : (torResult.services || []);
var allServices = Array.isArray(scanResult) ? scanResult : (scanResult.services || []);
var self = this;
// Inject CSS
var cssLink = document.querySelector('link[href*="exposure/dashboard.css"]');
if (!cssLink) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('exposure/dashboard.css');
document.head.appendChild(link);
}
var view = E('div', { 'class': 'exposure-dashboard' }, [
E('h2', {}, '\ud83e\uddc5 Tor Hidden Services'),
E('p', { 'style': 'color: #8892b0; margin-bottom: 1.5rem;' },
'Expose services on the Tor network with .onion addresses'),
// Add new service form
E('div', { 'class': 'exposure-section' }, [
E('div', { 'class': 'exposure-section-header' }, [
E('div', { 'class': 'exposure-section-title' }, [
E('span', { 'class': 'icon' }, '\u2795'),
'Add Hidden Service'
])
]),
E('div', { 'class': 'exposure-form' }, [
E('div', { 'class': 'exposure-form-group' }, [
E('label', {}, 'Service'),
E('select', { 'id': 'new-tor-service' },
[E('option', { 'value': '' }, '-- Select --')].concat(
allServices.filter(function(s) { return s.external; }).map(function(s) {
var name = s.name || s.process;
return E('option', { 'value': s.process, 'data-port': s.port },
name + ' (:' + s.port + ')');
})
)
)
]),
E('div', { 'class': 'exposure-form-group' }, [
E('label', {}, 'Local Port'),
E('input', { 'type': 'number', 'id': 'new-tor-port', 'placeholder': '3000' })
]),
E('div', { 'class': 'exposure-form-group' }, [
E('label', {}, 'Onion Port'),
E('input', { 'type': 'number', 'id': 'new-tor-onion-port', 'value': '80' })
]),
E('button', {
'class': 'btn-action btn-primary',
'click': ui.createHandlerFn(self, 'handleAdd')
}, 'Create .onion')
])
]),
// Existing services
E('div', { 'class': 'exposure-section' }, [
E('div', { 'class': 'exposure-section-header' }, [
E('div', { 'class': 'exposure-section-title' }, [
E('span', { 'class': 'icon' }, '\ud83e\uddc5'),
'Active Hidden Services (' + torServices.length + ')'
]),
E('button', {
'class': 'btn-action btn-primary',
'click': function() { location.reload(); }
}, 'Refresh')
]),
torServices.length > 0 ?
E('table', { 'class': 'exposure-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Service'),
E('th', {}, 'Onion Address'),
E('th', {}, 'Port'),
E('th', {}, 'Backend'),
E('th', {}, 'Actions')
])
]),
E('tbody', {},
torServices.map(function(svc) {
return E('tr', {}, [
E('td', { 'style': 'font-weight: 600;' }, svc.service),
E('td', {}, [
E('code', { 'class': 'onion-address' }, svc.onion),
E('button', {
'class': 'btn-action',
'style': 'margin-left: 0.5rem; padding: 0.25rem 0.5rem;',
'click': function() {
navigator.clipboard.writeText(svc.onion);
ui.addNotification(null, E('p', {}, 'Copied to clipboard'), 'info');
}
}, '\ud83d\udccb')
]),
E('td', {}, svc.port || '80'),
E('td', { 'style': 'font-family: monospace;' }, svc.backend || 'N/A'),
E('td', {}, [
E('button', {
'class': 'btn-action btn-danger',
'click': ui.createHandlerFn(self, 'handleRemove', svc.service)
}, 'Remove')
])
]);
})
)
]) :
E('div', { 'class': 'exposure-empty' }, [
E('div', { 'class': 'icon' }, '\ud83e\uddc5'),
E('p', {}, 'No Tor hidden services configured'),
E('p', { 'style': 'font-size: 0.85rem;' }, 'Select a service above to create a .onion address')
])
])
]);
// Wire up service selector
setTimeout(function() {
var sel = document.getElementById('new-tor-service');
var portInput = document.getElementById('new-tor-port');
if (sel && portInput) {
sel.addEventListener('change', function() {
var opt = sel.options[sel.selectedIndex];
portInput.value = opt.dataset.port || '';
});
}
}, 100);
return view;
},
handleAdd: function(ev) {
var service = document.getElementById('new-tor-service').value;
var port = parseInt(document.getElementById('new-tor-port').value);
var onionPort = parseInt(document.getElementById('new-tor-onion-port').value) || 80;
if (!service) {
ui.addNotification(null, E('p', {}, 'Please select a service'), 'warning');
return;
}
if (!port) {
ui.addNotification(null, E('p', {}, 'Please specify the local port'), 'warning');
return;
}
ui.showModal('Creating Hidden Service...', [
E('p', { 'class': 'spinning' }, 'Please wait, generating .onion address (this may take a moment)...')
]);
api.torAdd(service, port, onionPort).then(function(res) {
ui.hideModal();
if (res.success) {
ui.addNotification(null, E('p', {}, [
'Hidden service created! ',
E('br'),
E('code', { 'style': 'word-break: break-all;' }, res.onion || 'Refresh to see address')
]), 'success');
location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, 'Error: ' + err.message), 'danger');
});
},
handleRemove: function(service, ev) {
if (!confirm('Remove hidden service for ' + service + '?\n\nThe .onion address will be permanently lost.')) {
return;
}
api.torRemove(service).then(function(res) {
if (res.success) {
ui.addNotification(null, E('p', {}, 'Hidden service removed'), 'success');
location.reload();
} else {
ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown error')), 'danger');
}
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,423 @@
#!/bin/sh
#
# RPCD backend for SecuBox Service Exposure Manager
#
. /usr/share/libubox/jshn.sh
. /lib/functions.sh
case "$1" in
list)
json_init
json_add_object "scan"
json_close_object
json_add_object "conflicts"
json_close_object
json_add_object "status"
json_close_object
json_add_object "tor_list"
json_close_object
json_add_object "ssl_list"
json_close_object
json_add_object "get_config"
json_close_object
json_add_object "fix_port"
json_add_string "service" "string"
json_add_int "port" "integer"
json_close_object
json_add_object "tor_add"
json_add_string "service" "string"
json_add_int "local_port" "integer"
json_add_int "onion_port" "integer"
json_close_object
json_add_object "tor_remove"
json_add_string "service" "string"
json_close_object
json_add_object "ssl_add"
json_add_string "service" "string"
json_add_string "domain" "string"
json_add_int "local_port" "integer"
json_close_object
json_add_object "ssl_remove"
json_add_string "service" "string"
json_close_object
json_dump
;;
call)
case "$2" in
scan)
# Scan listening services - use temp file to avoid subshell issues
TMP_SVC="/tmp/exposure_scan_$$"
netstat -tlnp 2>/dev/null | grep LISTEN | awk '{
split($4, a, ":")
port = a[length(a)]
if (!seen[port]++) {
split($7, p, "/")
proc = p[2]
if (proc == "") proc = "unknown"
print port, $4, proc
}
}' | sort -n > "$TMP_SVC"
json_init
json_add_array "services"
while read port addr proc; do
[ -z "$port" ] && continue
external=0
case "$addr" in
*0.0.0.0*|*::*) external=1 ;;
*127.0.0.1*|*::1*) external=0 ;;
*) external=1 ;;
esac
name="$proc"
case "$proc" in
sshd|dropbear) name="SSH" ;;
dnsmasq) name="DNS" ;;
haproxy) name="HAProxy" ;;
uhttpd) name="LuCI" ;;
gitea) name="Gitea" ;;
netifyd) name="Netifyd" ;;
tor) name="Tor" ;;
python*) name="Python App" ;;
streamlit) name="Streamlit" ;;
hexo|node) name="HexoJS" ;;
esac
json_add_object ""
json_add_int "port" "$port"
json_add_string "address" "$addr"
json_add_string "process" "$proc"
json_add_string "name" "$name"
json_add_boolean "external" "$external"
json_close_object
done < "$TMP_SVC"
rm -f "$TMP_SVC"
json_close_array
json_dump
;;
status)
json_init
total=$(netstat -tlnp 2>/dev/null | grep LISTEN | awk '{split($4,a,":"); print a[length(a)]}' | sort -u | wc -l)
external=$(netstat -tlnp 2>/dev/null | grep LISTEN | grep -E "0\.0\.0\.0|::" | awk '{split($4,a,":"); print a[length(a)]}' | sort -u | wc -l)
json_add_object "services"
json_add_int "total" "$total"
json_add_int "external" "$external"
json_close_object
# Tor hidden services
TOR_DIR="/var/lib/tor/hidden_services"
tor_count=0
[ -d "$TOR_DIR" ] && tor_count=$(ls -1d "$TOR_DIR"/*/ 2>/dev/null | wc -l)
json_add_object "tor"
json_add_int "count" "$tor_count"
json_add_array "services"
if [ -d "$TOR_DIR" ]; then
for dir in "$TOR_DIR"/*/; do
[ -d "$dir" ] || continue
svc=$(basename "$dir")
onion=""
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
if [ -n "$onion" ]; then
json_add_object ""
json_add_string "service" "$svc"
json_add_string "onion" "$onion"
json_close_object
fi
done
fi
json_close_array
json_close_object
# HAProxy SSL backends - read from UCI config
TMP_SSL="/tmp/exposure_ssl_$$"
ssl_count=0
# Get vhosts from UCI (enabled ones with domains)
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
domain=$(uci -q get "haproxy.${vhost}.domain")
backend=$(uci -q get "haproxy.${vhost}.backend")
enabled=$(uci -q get "haproxy.${vhost}.enabled")
[ "$enabled" != "1" ] && continue
[ -z "$domain" ] && continue
echo "${backend:-$vhost}|${domain}" >> "$TMP_SSL"
ssl_count=$((ssl_count + 1))
done
json_add_object "ssl"
json_add_int "count" "$ssl_count"
json_add_array "backends"
if [ -f "$TMP_SSL" ]; then
while IFS='|' read backend domain; do
[ -z "$backend" ] && continue
json_add_object ""
json_add_string "service" "$backend"
json_add_string "domain" "$domain"
json_close_object
done < "$TMP_SSL"
rm -f "$TMP_SSL"
fi
json_close_array
json_close_object
json_dump
;;
tor_list)
TOR_DIR="/var/lib/tor/hidden_services"
TOR_CONFIG="/etc/tor/torrc"
json_init
json_add_array "services"
if [ -d "$TOR_DIR" ]; then
for dir in "$TOR_DIR"/*/; do
[ -d "$dir" ] || continue
svc=$(basename "$dir")
onion=""
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $2}')
backend=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $3}')
if [ -n "$onion" ]; then
json_add_object ""
json_add_string "service" "$svc"
json_add_string "onion" "$onion"
json_add_string "port" "${port:-80}"
json_add_string "backend" "${backend:-N/A}"
json_close_object
fi
done
fi
json_close_array
json_dump
;;
ssl_list)
TMP_SSLLIST="/tmp/exposure_ssllist_$$"
> "$TMP_SSLLIST"
# Read from HAProxy UCI config (vhosts with their backends)
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
domain=$(uci -q get "haproxy.${vhost}.domain")
backend=$(uci -q get "haproxy.${vhost}.backend")
enabled=$(uci -q get "haproxy.${vhost}.enabled")
[ "$enabled" != "1" ] && continue
[ -z "$domain" ] && continue
# Get server address from backend config
server=""
if [ -n "$backend" ]; then
server=$(uci -q get "haproxy.${backend}.server" 2>/dev/null | head -1 | awk '{print $2}')
fi
echo "${backend:-$vhost}|${domain}|${server:-N/A}" >> "$TMP_SSLLIST"
done
json_init
json_add_array "backends"
if [ -s "$TMP_SSLLIST" ]; then
while IFS='|' read service domain server; do
[ -z "$service" ] && continue
json_add_object ""
json_add_string "service" "$service"
json_add_string "domain" "$domain"
json_add_string "backend" "$server"
json_close_object
done < "$TMP_SSLLIST"
fi
rm -f "$TMP_SSLLIST"
json_close_array
json_dump
;;
get_config)
json_init
json_add_array "known_services"
config_load "secubox-exposure"
get_known() {
local section="$1"
local default_port config_path category
config_get default_port "$section" default_port
config_get config_path "$section" config_path
config_get category "$section" category "other"
actual_port=""
if [ -n "$config_path" ]; then
actual_port=$(uci -q get "$config_path" 2>/dev/null)
fi
[ -z "$actual_port" ] && actual_port="$default_port"
json_add_object ""
json_add_string "id" "$section"
json_add_int "default_port" "${default_port:-0}"
json_add_int "actual_port" "${actual_port:-0}"
json_add_string "config_path" "$config_path"
json_add_string "category" "$category"
json_close_object
}
config_foreach get_known known
json_close_array
json_dump
;;
conflicts)
json_init
json_add_array "conflicts"
json_close_array
json_dump
;;
fix_port)
read -r input
service=$(echo "$input" | jsonfilter -e '@.service')
port=$(echo "$input" | jsonfilter -e '@.port')
if [ -z "$service" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Service name required"
json_dump
exit 0
fi
result=$(/usr/sbin/secubox-exposure fix-port "$service" "$port" 2>&1)
json_init
if [ $? -eq 0 ]; then
json_add_boolean "success" 1
json_add_string "message" "$result"
else
json_add_boolean "success" 0
json_add_string "error" "$result"
fi
json_dump
;;
tor_add)
read -r input
service=$(echo "$input" | jsonfilter -e '@.service')
local_port=$(echo "$input" | jsonfilter -e '@.local_port')
onion_port=$(echo "$input" | jsonfilter -e '@.onion_port')
if [ -z "$service" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Service name required"
json_dump
exit 0
fi
result=$(/usr/sbin/secubox-exposure tor add "$service" "$local_port" "$onion_port" 2>&1)
json_init
if echo "$result" | grep -q "Hidden service created"; then
onion=$(echo "$result" | grep "Onion:" | awk '{print $2}')
json_add_boolean "success" 1
json_add_string "onion" "$onion"
json_add_string "message" "Hidden service created"
else
json_add_boolean "success" 0
json_add_string "error" "$result"
fi
json_dump
;;
tor_remove)
read -r input
service=$(echo "$input" | jsonfilter -e '@.service')
if [ -z "$service" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Service name required"
json_dump
exit 0
fi
result=$(/usr/sbin/secubox-exposure tor remove "$service" 2>&1)
json_init
if echo "$result" | grep -q "removed"; then
json_add_boolean "success" 1
json_add_string "message" "Hidden service removed"
else
json_add_boolean "success" 0
json_add_string "error" "$result"
fi
json_dump
;;
ssl_add)
read -r input
service=$(echo "$input" | jsonfilter -e '@.service')
domain=$(echo "$input" | jsonfilter -e '@.domain')
local_port=$(echo "$input" | jsonfilter -e '@.local_port')
if [ -z "$service" ] || [ -z "$domain" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Service and domain required"
json_dump
exit 0
fi
result=$(/usr/sbin/secubox-exposure ssl add "$service" "$domain" "$local_port" 2>&1)
json_init
if echo "$result" | grep -q "configured"; then
json_add_boolean "success" 1
json_add_string "message" "SSL backend configured"
else
json_add_boolean "success" 0
json_add_string "error" "$result"
fi
json_dump
;;
ssl_remove)
read -r input
service=$(echo "$input" | jsonfilter -e '@.service')
if [ -z "$service" ]; then
json_init
json_add_boolean "success" 0
json_add_string "error" "Service name required"
json_dump
exit 0
fi
result=$(/usr/sbin/secubox-exposure ssl remove "$service" 2>&1)
json_init
if echo "$result" | grep -q "removed"; then
json_add_boolean "success" 1
json_add_string "message" "SSL backend removed"
else
json_add_boolean "success" 0
json_add_string "error" "$result"
fi
json_dump
;;
*)
json_init
json_add_boolean "error" 1
json_add_string "message" "Unknown method: $2"
json_dump
;;
esac
;;
esac

View File

@ -0,0 +1,37 @@
{
"admin/secubox/network/exposure": {
"title": "Service Exposure",
"order": 35,
"action": {
"type": "view",
"path": "exposure/overview"
},
"depends": {
"acl": ["luci-app-exposure"]
}
},
"admin/secubox/network/exposure/services": {
"title": "Services",
"order": 1,
"action": {
"type": "view",
"path": "exposure/services"
}
},
"admin/secubox/network/exposure/tor": {
"title": "Tor Hidden",
"order": 2,
"action": {
"type": "view",
"path": "exposure/tor"
}
},
"admin/secubox/network/exposure/ssl": {
"title": "SSL Proxy",
"order": 3,
"action": {
"type": "view",
"path": "exposure/ssl"
}
}
}

View File

@ -0,0 +1,17 @@
{
"luci-app-exposure": {
"description": "Grant access to SecuBox Service Exposure Manager",
"read": {
"ubus": {
"luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config"]
},
"uci": ["secubox-exposure"]
},
"write": {
"ubus": {
"luci.exposure": ["fix_port", "tor_add", "tor_remove", "ssl_add", "ssl_remove", "set_config"]
},
"uci": ["secubox-exposure"]
}
}
}

View File

@ -11,7 +11,7 @@ LUCI_PKGARCH:=all
PKG_NAME:=luci-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=6
PKG_RELEASE:=8
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT

View File

@ -282,6 +282,12 @@ var callGetLogs = rpc.declare({
expect: { logs: '' }
});
var callListExposedServices = rpc.declare({
object: 'luci.haproxy',
method: 'list_exposed_services',
expect: { services: [] }
});
// ============================================
// Helper Functions
// ============================================
@ -367,6 +373,9 @@ return baseclass.extend({
validate: callValidate,
getLogs: callGetLogs,
// Exposed services
listExposedServices: callListExposedServices,
// Helpers
getDashboardData: getDashboardData
});

View File

@ -23,7 +23,8 @@ return view.extend({
var backends = (result && result.backends) || result || [];
return Promise.all([
Promise.resolve(backends),
api.listServers('')
api.listServers(''),
api.listExposedServices()
]);
});
},
@ -33,6 +34,8 @@ return view.extend({
var backends = data[0] || [];
var serversResult = data[1] || {};
var servers = (serversResult && serversResult.servers) || serversResult || [];
var exposedResult = data[2] || {};
self.exposedServices = (exposedResult && exposedResult.services) || exposedResult || [];
// Group servers by backend
var serversByBackend = {};
@ -405,9 +408,45 @@ return view.extend({
showAddServerModal: function(backend) {
var self = this;
var exposedServices = self.exposedServices || [];
// Build service selector options
var serviceOptions = [E('option', { 'value': '' }, '-- Select a service --')];
exposedServices.forEach(function(svc) {
var label = svc.name + ' (' + svc.address + ':' + svc.port + ')';
if (svc.category) label += ' [' + svc.category + ']';
serviceOptions.push(E('option', {
'value': JSON.stringify(svc),
'data-name': svc.name,
'data-address': svc.address,
'data-port': svc.port
}, label));
});
ui.showModal('Add Server to ' + backend.name, [
E('div', { 'style': 'max-width: 500px;' }, [
// Quick service selector
exposedServices.length > 0 ? E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Quick Select'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'modal-service-select',
'class': 'cbi-input-select',
'style': 'width: 100%;',
'change': function(ev) {
var val = ev.target.value;
if (val) {
var svc = JSON.parse(val);
document.getElementById('modal-server-name').value = svc.name;
document.getElementById('modal-server-address').value = svc.address;
document.getElementById('modal-server-port').value = svc.port;
}
}
}, serviceOptions),
E('small', { 'style': 'color: var(--hp-text-muted); display: block; margin-top: 4px;' },
'Select a known service to auto-fill details, or enter manually below')
])
]) : null,
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Server Name'),
E('div', { 'class': 'cbi-value-field' }, [

View File

@ -40,6 +40,7 @@ return view.extend({
// Build content array, filtering out nulls
var content = [
this.renderEmergencyBanner(status),
this.renderPageHeader(status),
this.renderStatsGrid(status, vhosts, backends, certificates),
this.renderHealthGrid(status),
@ -126,6 +127,72 @@ return view.extend({
]);
},
renderEmergencyBanner: function(status) {
var self = this;
var haproxyRunning = status.haproxy_running;
var containerRunning = status.container_running;
var statusColor = haproxyRunning ? '#22c55e' : (containerRunning ? '#f97316' : '#ef4444');
var statusText = haproxyRunning ? 'HEALTHY' : (containerRunning ? 'DEGRADED' : 'DOWN');
var statusIcon = haproxyRunning ? '\u2705' : (containerRunning ? '\u26A0\uFE0F' : '\u274C');
return E('div', {
'class': 'hp-emergency-banner',
'style': 'background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); border: 1px solid ' + statusColor + '; border-radius: 12px; padding: 20px; margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; gap: 24px;'
}, [
// Status indicator
E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [
E('div', {
'style': 'width: 64px; height: 64px; border-radius: 50%; background: ' + statusColor + '22; display: flex; align-items: center; justify-content: center; font-size: 32px; border: 3px solid ' + statusColor + ';'
}, statusIcon),
E('div', {}, [
E('div', { 'style': 'font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #888; margin-bottom: 4px;' }, 'Service Status'),
E('div', { 'style': 'font-size: 24px; font-weight: 700; color: ' + statusColor + ';' }, statusText),
E('div', { 'style': 'font-size: 13px; color: #888; margin-top: 4px;' },
'Container: ' + (containerRunning ? 'Running' : 'Stopped') +
' \u2022 HAProxy: ' + (haproxyRunning ? 'Active' : 'Inactive'))
])
]),
// Quick health checks
E('div', { 'style': 'display: flex; gap: 16px;' }, [
E('div', { 'style': 'text-align: center; padding: 12px 20px; background: rgba(255,255,255,0.05); border-radius: 8px;' }, [
E('div', { 'style': 'font-size: 20px;' }, containerRunning ? '\u2705' : '\u274C'),
E('div', { 'style': 'font-size: 11px; color: #888; margin-top: 4px;' }, 'Container')
]),
E('div', { 'style': 'text-align: center; padding: 12px 20px; background: rgba(255,255,255,0.05); border-radius: 8px;' }, [
E('div', { 'style': 'font-size: 20px;' }, haproxyRunning ? '\u2705' : '\u274C'),
E('div', { 'style': 'font-size: 11px; color: #888; margin-top: 4px;' }, 'HAProxy')
]),
E('div', { 'style': 'text-align: center; padding: 12px 20px; background: rgba(255,255,255,0.05); border-radius: 8px;' }, [
E('div', { 'style': 'font-size: 20px;' }, status.config_valid !== false ? '\u2705' : '\u26A0\uFE0F'),
E('div', { 'style': 'font-size: 11px; color: #888; margin-top: 4px;' }, 'Config')
])
]),
// Emergency actions
E('div', { 'style': 'display: flex; gap: 12px;' }, [
E('button', {
'class': 'hp-btn',
'style': 'background: #3b82f6; color: white; padding: 12px 20px; font-size: 14px; font-weight: 600; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 8px;',
'click': function() { self.handleRestart(); },
'disabled': !containerRunning ? true : null
}, ['\u{1F504}', ' Restart']),
E('button', {
'class': 'hp-btn',
'style': 'background: ' + (haproxyRunning ? '#ef4444' : '#22c55e') + '; color: white; padding: 12px 20px; font-size: 14px; font-weight: 600; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 8px;',
'click': function() {
if (haproxyRunning) {
self.handleStop();
} else {
self.handleStart();
}
}
}, haproxyRunning ? ['\u23F9\uFE0F', ' Stop'] : ['\u25B6\uFE0F', ' Start'])
])
]);
},
renderStatsGrid: function(status, vhosts, backends, certificates) {
var activeVhosts = vhosts.filter(function(v) { return v.enabled; }).length;
var activeBackends = backends.filter(function(b) { return b.enabled; }).length;
@ -539,6 +606,19 @@ return view.extend({
});
},
handleRestart: function() {
var self = this;
self.showToast('Restarting HAProxy...', 'warning');
return api.restart().then(function(res) {
if (res.success) {
self.showToast('HAProxy service restarted', 'success');
return self.refreshDashboard();
} else {
self.showToast('Failed to restart: ' + (res.error || 'Unknown error'), 'error');
}
});
},
handleReload: function() {
var self = this;
return api.reload().then(function(res) {

View File

@ -1285,6 +1285,74 @@ method_get_logs() {
json_dump
}
# List exposed services (from secubox-exposure config)
method_list_exposed_services() {
json_init
json_add_array "services"
# Load known services from exposure config
if uci -q show secubox-exposure >/dev/null 2>&1; then
config_load "secubox-exposure"
config_foreach _add_exposed_service known
fi
# Also scan listening ports for dynamic discovery
if command -v netstat >/dev/null 2>&1; then
netstat -tlnp 2>/dev/null | grep LISTEN | while read line; do
local addr_port=$(echo "$line" | awk '{print $4}')
local port=$(echo "$addr_port" | awk -F: '{print $NF}')
local proc=$(echo "$line" | awk '{print $7}' | cut -d'/' -f2)
# Skip if already added from known services or common system ports
case "$port" in
22|53|80|443|8404) continue ;;
esac
# Only add if process name is useful
if [ -n "$proc" ] && [ "$proc" != "-" ] && [ "$proc" != "unknown" ]; then
json_add_object
json_add_string "id" "dynamic_${proc}_${port}"
json_add_string "name" "$proc"
json_add_int "port" "$port"
json_add_string "address" "127.0.0.1"
json_add_string "category" "detected"
json_add_boolean "dynamic" 1
json_close_object
fi
done
fi
json_close_array
json_dump
}
_add_exposed_service() {
local section="$1"
local default_port config_path category actual_port
config_get default_port "$section" default_port ""
config_get config_path "$section" config_path ""
config_get category "$section" category "app"
[ -z "$default_port" ] && return
# Try to get actual port from UCI config if available
actual_port="$default_port"
if [ -n "$config_path" ]; then
local configured_port=$(uci -q get "$config_path" 2>/dev/null)
[ -n "$configured_port" ] && actual_port="$configured_port"
fi
json_add_object
json_add_string "id" "$section"
json_add_string "name" "$section"
json_add_int "port" "$actual_port"
json_add_string "address" "127.0.0.1"
json_add_string "category" "$category"
json_add_boolean "dynamic" 0
json_close_object
}
# Main RPC interface
case "$1" in
list)
@ -1326,7 +1394,8 @@ case "$1" in
"reload": {},
"generate": {},
"validate": {},
"get_logs": { "lines": "integer" }
"get_logs": { "lines": "integer" },
"list_exposed_services": {}
}
EOF
;;
@ -1369,6 +1438,7 @@ EOF
generate) method_generate ;;
validate) method_validate ;;
get_logs) method_get_logs ;;
list_exposed_services) method_list_exposed_services ;;
esac
;;
esac

View File

@ -15,7 +15,8 @@
"list_acls",
"list_redirects",
"get_settings",
"get_logs"
"get_logs",
"list_exposed_services"
]
},
"uci": ["haproxy"]

View File

@ -126,7 +126,7 @@ return view.extend({
]),
E('div', { 'class': 'mm2-card' }, [
E('div', { 'class': 'mm2-stat' }, [
E('div', { 'class': 'mm2-stat-value' }, ':' + (config.port || 8082)),
E('div', { 'class': 'mm2-stat-value' }, ':' + (config.port || 8085)),
E('div', { 'class': 'mm2-stat-label' }, _('Web Port'))
])
]),

View File

@ -67,7 +67,7 @@ return view.extend({
o = s.option(form.Value, 'port', _('Web Port'));
o.datatype = 'port';
o.default = '8082';
o.default = '8085';
o.rmempty = false;
o = s.option(form.Value, 'address', _('Listen Address'));

View File

@ -26,7 +26,7 @@ get_status() {
fi
local enabled=$(uci -q get magicmirror2.main.enabled || echo "0")
local port=$(uci -q get magicmirror2.main.port || echo "8082")
local port=$(uci -q get magicmirror2.main.port || echo "8085")
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
[ "$running" = "1" ] && web_url="http://${router_ip}:${port}"
@ -54,7 +54,7 @@ EOF
# Get main configuration
get_config() {
local enabled=$(uci -q get magicmirror2.main.enabled || echo "0")
local port=$(uci -q get magicmirror2.main.port || echo "8082")
local port=$(uci -q get magicmirror2.main.port || echo "8085")
local address=$(uci -q get magicmirror2.main.address || echo "0.0.0.0")
local data_path=$(uci -q get magicmirror2.main.data_path || echo "/srv/magicmirror2")
local memory_limit=$(uci -q get magicmirror2.main.memory_limit || echo "512M")
@ -327,7 +327,7 @@ set_config() {
# Get web URL for iframe
get_web_url() {
local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1")
local port=$(uci -q get magicmirror2.main.port || echo "8082")
local port=$(uci -q get magicmirror2.main.port || echo "8085")
cat <<EOF
{

View File

@ -10,8 +10,8 @@ LUCI_TITLE:=SecuBox Portal - Unified WebUI
LUCI_DESCRIPTION:=Unified entry point for all SecuBox applications with tabbed navigation
LUCI_DEPENDS:=+luci-base +luci-theme-secubox
LUCI_PKGARCH:=all
PKG_VERSION:=0.6.0
PKG_RELEASE:=9
PKG_VERSION:=0.7.0
PKG_RELEASE:=1
PKG_LICENSE:=GPL-3.0-or-later
PKG_MAINTAINER:=SecuBox Team <secubox@example.com>

View File

@ -415,7 +415,7 @@ return baseclass.extend({
E('div', { 'class': 'sb-header-brand' }, [
E('div', { 'class': 'sb-header-logo' }, 'S'),
E('span', { 'class': 'sb-header-title' }, 'SecuBox'),
E('span', { 'class': 'sb-header-version' }, 'v0.14.0')
E('span', { 'class': 'sb-header-version' }, 'v0.15.48')
]),
// Navigation
E('nav', { 'class': 'sb-header-nav' },

View File

@ -61,6 +61,18 @@ return baseclass.extend({
service: 'mitmproxy',
version: '8.1.1'
},
'threat-monitor': {
id: 'threat-monitor',
name: 'Threat Monitor',
desc: 'Real-time threat detection combining netifyd DPI with CrowdSec intelligence',
icon: '\ud83d\udc41\ufe0f',
iconBg: 'rgba(239, 68, 68, 0.15)',
iconColor: '#ef4444',
section: 'security',
path: 'admin/secubox/security/threats/dashboard',
service: null,
version: '1.0.0'
},
// Network Apps
'bandwidth-manager': {
@ -111,6 +123,18 @@ return baseclass.extend({
service: null,
version: '0.2.0'
},
'service-exposure': {
id: 'service-exposure',
name: 'Service Exposure',
desc: 'Manage port conflicts, Tor hidden services, and HAProxy SSL backends',
icon: '\ud83d\udd0c',
iconBg: 'rgba(155, 89, 182, 0.15)',
iconColor: '#9b59b6',
section: 'network',
path: 'admin/secubox/network/exposure',
service: null,
version: '1.0.0'
},
// Monitoring Apps
'media-flow': {
@ -416,6 +440,13 @@ return baseclass.extend({
icon: '\ud83d\udce6',
path: 'admin/secubox/services',
order: 8
},
'active-ports': {
id: 'active-ports',
name: 'Active Ports',
icon: '\ud83d\udd0c',
path: 'admin/secubox/services',
order: 9
}
},

View File

@ -23,6 +23,11 @@ var callCrowdSecStats = rpc.declare({
method: 'nftables_stats'
});
var callSecurityStats = rpc.declare({
object: 'luci.secubox-security-threats',
method: 'get_security_stats'
});
var callGetServices = rpc.declare({
object: 'luci.secubox',
method: 'get_services',
@ -42,13 +47,16 @@ return view.extend({
this.loadAppStatuses(),
callCrowdSecStats().catch(function() { return null; }),
portal.checkInstalledApps(),
callGetServices().catch(function() { return []; })
callGetServices().catch(function() { return []; }),
callSecurityStats().catch(function() { return null; })
]).then(function(results) {
// Store installed apps info from the last promise
self.installedApps = results[4] || {};
// RPC expect unwraps the services array directly
var svcResult = results[5] || [];
self.detectedServices = Array.isArray(svcResult) ? svcResult : (svcResult.services || []);
// Security stats
self.securityStats = results[6] || {};
return results;
});
},
@ -104,6 +112,7 @@ return view.extend({
var boardInfo = data[0] || {};
var sysInfo = data[1] || {};
var crowdSecStats = data[3] || {};
var securityStats = this.securityStats || {};
var self = this;
// Set portal app context and hide LuCI navigation
@ -142,12 +151,13 @@ return view.extend({
this.renderHeader(),
// Content
E('div', { 'class': 'sb-portal-content' }, [
this.renderDashboardSection(boardInfo, sysInfo, crowdSecStats),
this.renderDashboardSection(boardInfo, sysInfo, crowdSecStats, securityStats),
this.renderSecuritySection(),
this.renderNetworkSection(),
this.renderMonitoringSection(),
this.renderSystemSection(),
this.renderServicesSection()
this.renderServicesAppsSection(),
this.renderActivePortsSection()
])
]);
@ -162,14 +172,14 @@ return view.extend({
var sections = portal.getSections();
// Sections that link to other pages vs tabs within portal
var linkSections = ['portal', 'hub', 'admin'];
var tabSections = ['security', 'network', 'monitoring', 'system', 'services'];
var tabSections = ['security', 'network', 'monitoring', 'system', 'services', 'active-ports'];
return E('div', { 'class': 'sb-portal-header' }, [
// Brand
E('div', { 'class': 'sb-portal-brand' }, [
E('div', { 'class': 'sb-portal-logo' }, 'S'),
E('span', { 'class': 'sb-portal-title' }, 'SecuBox'),
E('span', { 'class': 'sb-portal-version' }, 'v0.14.0')
E('span', { 'class': 'sb-portal-version' }, 'v0.15.51')
]),
// Navigation
E('nav', { 'class': 'sb-portal-nav' },
@ -228,7 +238,7 @@ return view.extend({
});
},
renderDashboardSection: function(boardInfo, sysInfo, crowdSecStats) {
renderDashboardSection: function(boardInfo, sysInfo, crowdSecStats, securityStats) {
var self = this;
var securityApps = portal.getAppsBySection('security');
var networkApps = portal.getAppsBySection('network');
@ -245,6 +255,12 @@ return view.extend({
var crowdSecHealth = crowdSecStats.firewall_health || {};
var crowdSecActive = crowdSecHealth.bouncer_running && crowdSecHealth.decisions_synced;
// Security stats
var wanDropped = securityStats.wan_dropped || 0;
var fwRejects = securityStats.firewall_rejects || 0;
var csBans = securityStats.crowdsec_bans || 0;
var csAlerts = securityStats.crowdsec_alerts_24h || 0;
return E('div', { 'class': 'sb-portal-section active', 'data-section': 'dashboard' }, [
E('div', { 'class': 'sb-section-header' }, [
E('h2', { 'class': 'sb-section-title' }, 'SecuBox Dashboard'),
@ -275,32 +291,33 @@ return view.extend({
E('div', { 'class': 'sb-quick-stat-label' }, 'Services Running')
]),
// CrowdSec Blocked IPs
// Firewall Blocked
E('div', { 'class': 'sb-quick-stat' }, [
E('div', { 'class': 'sb-quick-stat-header' }, [
E('div', { 'class': 'sb-quick-stat-icon security' }, '\ud83d\udeab'),
E('span', { 'class': 'sb-quick-stat-status ' + (crowdSecActive ? 'running' : 'warning') },
crowdSecActive ? 'Active' : 'Inactive')
crowdSecActive ? 'Protected' : 'Monitoring')
]),
E('div', { 'class': 'sb-quick-stat-value' }, totalBlocked.toLocaleString()),
E('div', { 'class': 'sb-quick-stat-label' }, 'IPs Blocked')
E('div', { 'class': 'sb-quick-stat-value' }, (wanDropped + fwRejects).toLocaleString()),
E('div', { 'class': 'sb-quick-stat-label' }, 'Packets Blocked')
]),
// Network Apps
// Threat Alerts
E('div', { 'class': 'sb-quick-stat' }, [
E('div', { 'class': 'sb-quick-stat-header' }, [
E('div', { 'class': 'sb-quick-stat-icon network' }, '\ud83c\udf10'),
E('span', { 'class': 'sb-quick-stat-status running' }, 'Configured')
E('div', { 'class': 'sb-quick-stat-icon security' }, '\ud83d\udc41\ufe0f'),
E('span', { 'class': 'sb-quick-stat-status ' + (csAlerts > 0 ? 'warning' : 'running') },
csAlerts > 0 ? 'Alerts' : 'Clear')
]),
E('div', { 'class': 'sb-quick-stat-value' }, networkApps.length),
E('div', { 'class': 'sb-quick-stat-label' }, 'Network Tools')
E('div', { 'class': 'sb-quick-stat-value' }, csBans + '/' + csAlerts),
E('div', { 'class': 'sb-quick-stat-label' }, 'Bans / Alerts 24h')
])
]),
// Featured Apps
E('h3', { 'style': 'margin: 1.5rem 0 1rem; color: var(--cyber-text-primary);' }, 'Quick Access'),
E('div', { 'class': 'sb-app-grid' },
this.renderFeaturedApps(['crowdsec', 'bandwidth-manager', 'media-flow', 'ndpid'])
this.renderFeaturedApps(['crowdsec', 'threat-monitor', 'bandwidth-manager', 'media-flow'])
),
// Recent Events placeholder
@ -342,6 +359,17 @@ return view.extend({
(crowdSecStats.ipv4_cscli_count || 0) + ' local) | IPv6: ' + blockedIPv6.toLocaleString()),
E('span', { 'class': 'sb-events-meta' }, 'CrowdSec Firewall Protection')
])
]) : null,
wanDropped > 0 ? E('div', { 'class': 'sb-events-item' }, [
E('div', { 'class': 'sb-events-icon warning' }, '\ud83d\udc41\ufe0f'),
E('div', { 'class': 'sb-events-content' }, [
E('p', { 'class': 'sb-events-message' },
'WAN Dropped: ' + wanDropped.toLocaleString() + ' | Firewall Rejects: ' + fwRejects),
E('span', { 'class': 'sb-events-meta' }, [
'Threat Monitor - ',
E('a', { 'href': L.url('admin/secubox/security/threats/dashboard') }, 'View Details')
])
])
]) : null
].filter(Boolean))
]);
@ -416,7 +444,13 @@ return view.extend({
'System administration and configuration tools', apps);
},
renderServicesSection: function() {
renderServicesAppsSection: function() {
var apps = portal.getInstalledAppsBySection('services', this.installedApps);
return this.renderAppSection('services', 'Services',
'Application services running on your network', apps);
},
renderActivePortsSection: function() {
var self = this;
var services = this.detectedServices || [];
@ -458,7 +492,8 @@ return view.extend({
categoryOrder.forEach(function(cat) {
if (categories[cat] && categories[cat].length > 0) {
categories[cat].forEach(function(svc) {
var url = window.location.protocol + '//' + window.location.hostname + svc.url;
// Always use http:// for local services (they don't have SSL certs)
var url = 'http://' + window.location.hostname + svc.url;
var emoji = iconMap[svc.icon] || '⚡';
serviceCards.push(E('a', {
'class': 'sb-app-card sb-service-card',
@ -486,9 +521,9 @@ return view.extend({
});
if (serviceCards.length === 0) {
return E('div', { 'class': 'sb-portal-section', 'data-section': 'services' }, [
return E('div', { 'class': 'sb-portal-section', 'data-section': 'active-ports' }, [
E('div', { 'class': 'sb-section-header' }, [
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Services'),
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Ports'),
E('p', { 'class': 'sb-section-subtitle' }, 'Detected services listening on network ports')
]),
E('div', { 'class': 'sb-section-empty' }, [
@ -499,9 +534,9 @@ return view.extend({
]);
}
return E('div', { 'class': 'sb-portal-section', 'data-section': 'services' }, [
return E('div', { 'class': 'sb-portal-section', 'data-section': 'active-ports' }, [
E('div', { 'class': 'sb-section-header' }, [
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Services'),
E('h2', { 'class': 'sb-section-title' }, '🔌 Active Ports'),
E('p', { 'class': 'sb-section-subtitle' }, 'Detected services listening on network ports')
]),
E('div', { 'class': 'sb-app-grid' }, serviceCards)

View File

@ -22,6 +22,10 @@ define Package/luci-app-secubox-security-threats/conffiles
endef
define Package/luci-app-secubox-security-threats/install
# CLI tool
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./root/usr/bin/secubox-stats $(1)/usr/bin/
# RPCD backend (MUST be 755 for ubus calls)
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.secubox-security-threats $(1)/usr/libexec/rpcd/

View File

@ -64,6 +64,12 @@ var callRemoveWhitelist = rpc.declare({
expect: { }
});
var callGetSecurityStats = rpc.declare({
object: 'luci.secubox-security-threats',
method: 'get_security_stats',
expect: { }
});
// ==============================================================================
// Utility Functions
// ==============================================================================
@ -212,13 +218,15 @@ function getDashboardData() {
callStatus(),
callGetActiveThreats(),
callGetStatsByType(),
callGetBlockedIPs()
callGetBlockedIPs(),
callGetSecurityStats()
]).then(function(results) {
return {
status: results[0] || {},
threats: results[1].threats || [],
stats: results[2] || {},
blocked: results[3].blocked || []
blocked: results[3].blocked || [],
securityStats: results[4] || {}
};
});
}
@ -235,6 +243,7 @@ return baseclass.extend({
getStatsByType: callGetStatsByType,
getStatsByHost: callGetStatsByHost,
getBlockedIPs: callGetBlockedIPs,
getSecurityStats: callGetSecurityStats,
blockThreat: callBlockThreat,
whitelistHost: callWhitelistHost,
removeWhitelist: callRemoveWhitelist,

View File

@ -16,6 +16,7 @@ return L.view.extend({
var status = data.status || {};
var stats = data.stats || {};
var blocked = data.blocked || [];
var securityStats = data.securityStats || {};
// Calculate statistics
var threatStats = {
@ -30,6 +31,7 @@ return L.view.extend({
// Build view elements
var statusBanner = this.renderStatusBanner(status);
var fwStatsGrid = this.renderFirewallStats(securityStats);
var statsGrid = this.renderStatsGrid(threatStats, blocked.length);
var threatDist = this.renderThreatDistribution(stats);
var riskGauge = this.renderRiskGauge(threatStats.avg_score);
@ -46,7 +48,11 @@ return L.view.extend({
E('div', { 'class': 'cbi-map-descr' }, _('Real-time threat detection integrating netifyd DPI and CrowdSec intelligence')),
statusBanner,
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Overview')),
E('h3', {}, _('Firewall & Network Protection')),
fwStatsGrid
]),
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Threat Overview')),
statsGrid
]),
E('div', { 'class': 'cbi-section', 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;' }, [
@ -60,6 +66,61 @@ return L.view.extend({
]);
},
renderFirewallStats: function(stats) {
var formatNumber = function(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
};
return E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1rem;'
}, [
E('div', {
'style': 'background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
}, [
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.wan_dropped || 0)),
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('WAN Dropped')),
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Packets blocked at interface'))
]),
E('div', {
'style': 'background: linear-gradient(135deg, #c62828 0%, #e53935 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
}, [
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.firewall_rejects || 0)),
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('FW Rejects')),
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Firewall rule blocks'))
]),
E('div', {
'style': 'background: linear-gradient(135deg, #6a1b9a 0%, #8e24aa 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
}, [
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.crowdsec_bans || 0)),
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('CrowdSec Bans')),
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Active IP bans'))
]),
E('div', {
'style': 'background: linear-gradient(135deg, #ef6c00 0%, #ff9800 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
}, [
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.crowdsec_alerts_24h || 0)),
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('Alerts 24h')),
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('CrowdSec detections'))
]),
E('div', {
'style': 'background: linear-gradient(135deg, #455a64 0%, #607d8b 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
}, [
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.invalid_connections || 0)),
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('Invalid Conns')),
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Conntrack anomalies'))
]),
E('div', {
'style': 'background: linear-gradient(135deg, #00695c 0%, #00897b 100%); padding: 1.2rem; border-radius: 12px; color: white; text-align: center;'
}, [
E('div', { 'style': 'font-size: 2.5rem; font-weight: bold;' }, formatNumber(stats.haproxy_connections || 0)),
E('div', { 'style': 'font-size: 0.9rem; opacity: 0.9; margin-top: 0.3rem;' }, _('HAProxy Conns')),
E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7; margin-top: 0.2rem;' }, _('Reverse proxy sessions'))
])
]);
},
renderStatusBanner: function(status) {
var services = [];
var hasIssue = false;

View File

@ -0,0 +1,5 @@
#!/bin/sh
# SecuBox Security Stats - Quick overview
# Copyright (C) 2026 CyberMind.fr
ubus call luci.secubox-security-threats get_security_stats 2>/dev/null | jsonfilter -e '@' 2>/dev/null || echo '{"error": "RPCD not available"}'

View File

@ -262,6 +262,72 @@ check_block_rules() {
config_foreach check_rule_match block_rule "$category" "$risks" "$score" "$ip"
}
# ==============================================================================
# SECURITY STATS (Quick Overview)
# ==============================================================================
# Get overall security statistics from all sources
get_security_stats() {
local wan_drops=0
local fw_rejects=0
local cs_bans=0
local cs_alerts_24h=0
local haproxy_conns=0
local invalid_conns=0
# WAN dropped packets (from kernel stats)
if [ -f /sys/class/net/br-wan/statistics/rx_dropped ]; then
wan_drops=$(cat /sys/class/net/br-wan/statistics/rx_dropped 2>/dev/null)
elif [ -f /sys/class/net/eth1/statistics/rx_dropped ]; then
wan_drops=$(cat /sys/class/net/eth1/statistics/rx_dropped 2>/dev/null)
fi
wan_drops=${wan_drops:-0}
# Firewall rejects from logs (last 24h)
fw_rejects=$(logread 2>/dev/null | grep -c "reject\|drop" || echo 0)
fw_rejects=$(echo "$fw_rejects" | tr -d '\n')
fw_rejects=${fw_rejects:-0}
# CrowdSec active bans
if [ -x "$CSCLI" ]; then
cs_bans=$($CSCLI decisions list -o json 2>/dev/null | grep -c '"id":' || echo 0)
cs_bans=$(echo "$cs_bans" | tr -d '\n')
cs_bans=${cs_bans:-0}
# CrowdSec alerts in last 24h
cs_alerts_24h=$($CSCLI alerts list -o json --since 24h 2>/dev/null | grep -c '"id":' || echo 0)
cs_alerts_24h=$(echo "$cs_alerts_24h" | tr -d '\n')
cs_alerts_24h=${cs_alerts_24h:-0}
fi
# Invalid connections (conntrack)
if [ -f /proc/net/nf_conntrack ]; then
invalid_conns=$(grep -c "INVALID\|UNREPLIED" /proc/net/nf_conntrack 2>/dev/null || echo 0)
fi
invalid_conns=$(echo "$invalid_conns" | tr -d '\n')
invalid_conns=${invalid_conns:-0}
# HAProxy connections (if running in LXC)
if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then
haproxy_conns=$(lxc-attach -n haproxy -- sh -c 'echo "show stat" | socat stdio /var/run/haproxy/admin.sock 2>/dev/null | tail -n+2 | awk -F, "{sum+=\$8} END {print sum}"' 2>/dev/null || echo 0)
fi
haproxy_conns=$(echo "$haproxy_conns" | tr -d '\n')
haproxy_conns=${haproxy_conns:-0}
# Output JSON
cat << EOF
{
"wan_dropped": $wan_drops,
"firewall_rejects": $fw_rejects,
"crowdsec_bans": $cs_bans,
"crowdsec_alerts_24h": $cs_alerts_24h,
"invalid_connections": $invalid_conns,
"haproxy_connections": $haproxy_conns,
"timestamp": "$(date -Iseconds)"
}
EOF
}
# ==============================================================================
# STATISTICS
# ==============================================================================
@ -304,6 +370,8 @@ case "$1" in
list)
# List available methods
json_init
json_add_object "get_security_stats"
json_close_object
json_add_object "status"
json_close_object
json_add_object "get_active_threats"
@ -334,6 +402,10 @@ case "$1" in
call)
case "$2" in
get_security_stats)
get_security_stats
;;
status)
json_init
json_add_boolean "enabled" 1

View File

@ -9,7 +9,8 @@
"get_threat_history",
"get_stats_by_type",
"get_stats_by_host",
"get_blocked_ips"
"get_blocked_ips",
"get_security_stats"
],
"luci.crowdsec-dashboard": [
"decisions",

View File

@ -3,6 +3,7 @@
'require ui';
'require dom';
'require poll';
'require fs';
'require secubox/api as API';
'require secubox-theme/theme as Theme';
'require secubox/nav as SecuNav';
@ -393,6 +394,14 @@ return view.extend({
{ id: 'export_config', label: _('Export Configuration'), icon: '📦', variant: 'green' }
];
// Critical services quick restart
var criticalServices = [
{ id: 'haproxy', label: 'HAProxy', icon: '⚖️' },
{ id: 'crowdsec', label: 'CrowdSec', icon: '🛡️' },
{ id: 'tor', label: 'Tor Shield', icon: '🧅' },
{ id: 'gitea', label: 'Gitea', icon: '🦊' }
];
return E('section', { 'class': 'sb-card' }, [
E('div', { 'class': 'sb-card-header' }, [
E('h2', {}, _('Quick Actions')),
@ -410,10 +419,87 @@ return view.extend({
E('span', { 'class': 'sb-action-icon' }, action.icon),
E('span', { 'class': 'sb-action-label' }, action.label)
]);
})),
// Critical Services Quick Restart Section
E('div', { 'class': 'sb-card-header', 'style': 'margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.1);' }, [
E('h3', { 'style': 'font-size: 14px; margin: 0;' }, _('Critical Services Quick Restart')),
E('p', { 'class': 'sb-card-subtitle', 'style': 'font-size: 12px;' }, _('One-click restart for essential services'))
]),
E('div', { 'style': 'display: flex; gap: 8px; flex-wrap: wrap; padding: 0 16px 16px;' },
criticalServices.map(function(svc) {
return E('button', {
'class': 'sb-service-restart-btn',
'type': 'button',
'style': 'display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; color: #3b82f6; cursor: pointer; font-size: 13px; transition: all 0.2s;',
'click': function(ev) {
self.restartService(svc.id, ev.target);
},
'onmouseover': function(ev) {
ev.target.style.background = 'rgba(59, 130, 246, 0.25)';
ev.target.style.borderColor = '#3b82f6';
},
'onmouseout': function(ev) {
ev.target.style.background = 'rgba(59, 130, 246, 0.15)';
ev.target.style.borderColor = 'rgba(59, 130, 246, 0.3)';
}
}, [
E('span', {}, svc.icon),
E('span', {}, svc.label),
E('span', { 'style': 'opacity: 0.7;' }, '🔄')
]);
}))
]);
},
restartService: function(serviceId, btnElement) {
var self = this;
// Visual feedback
if (btnElement) {
btnElement.style.opacity = '0.6';
btnElement.disabled = true;
}
ui.showModal(_('Restarting Service'), [
E('p', { 'class': 'spinning' }, _('Restarting ') + serviceId + '...')
]);
// Map service to init.d script
var serviceMap = {
'haproxy': 'haproxy',
'crowdsec': 'crowdsec',
'tor': 'tor',
'gitea': 'gitea'
};
var initScript = serviceMap[serviceId] || serviceId;
return L.resolveDefault(
L.Request.post(L.env.cgi_base + '/cgi-exec', 'command=/etc/init.d/' + initScript + ' restart'),
{}
).then(function() {
// Also try the standard approach via fs
return fs.exec('/etc/init.d/' + initScript, ['restart']);
}).then(function() {
ui.hideModal();
ui.addNotification(null, E('p', {}, serviceId + ' ' + _('restarted successfully')), 'info');
}).catch(function(err) {
ui.hideModal();
// Fallback: try via API if available
return API.quickAction('restart_' + serviceId).then(function() {
ui.addNotification(null, E('p', {}, serviceId + ' ' + _('restarted successfully')), 'info');
}).catch(function() {
ui.addNotification(null, E('p', {}, _('Failed to restart ') + serviceId + ': ' + (err.message || err)), 'error');
});
}).finally(function() {
if (btnElement) {
btnElement.style.opacity = '1';
btnElement.disabled = false;
}
});
},
runQuickAction: function(actionId) {
ui.showModal(_('Executing action...'), [
E('p', { 'class': 'spinning' }, _('Running ') + actionId + ' ...')

View File

@ -32,7 +32,10 @@
"listSnapshots",
"get_appstore_apps",
"get_appstore_app",
"get_public_ips"
"get_public_ips",
"get_network_health",
"get_vital_services",
"get_full_health_report"
],
"uci": [
"get",

View File

@ -302,7 +302,13 @@ return view.extend({
var reader = new FileReader();
reader.onload = function(e) {
var content = btoa(e.target.result);
// Convert ArrayBuffer to base64 (handles UTF-8 correctly)
var bytes = new Uint8Array(e.target.result);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
var content = btoa(binary);
api.uploadApp(name, content).then(function(result) {
if (result && result.success) {
@ -317,7 +323,7 @@ return view.extend({
ui.addNotification(null, E('p', {}, _('Upload failed: ') + err.message), 'error');
});
};
reader.readAsText(file);
reader.readAsArrayBuffer(file);
},
handleActivate: function(name) {

View File

@ -27,6 +27,12 @@ var callDisable = rpc.declare({
expect: { success: false }
});
var callRestart = rpc.declare({
object: 'luci.tor-shield',
method: 'restart',
expect: { success: false }
});
var callCircuits = rpc.declare({
object: 'luci.tor-shield',
method: 'circuits',
@ -161,6 +167,7 @@ return baseclass.extend({
getStatus: callStatus,
enable: callEnable,
disable: callDisable,
restart: callRestart,
getCircuits: callCircuits,
newIdentity: callNewIdentity,
checkLeaks: callCheckLeaks,

View File

@ -87,6 +87,27 @@ return view.extend({
});
},
// Handle restart
handleRestart: function() {
var self = this;
ui.showModal(_('Restart Tor Shield'), [
E('p', { 'class': 'spinning' }, _('Restarting Tor Shield service...'))
]);
api.restart().then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Tor Shield is restarting. Please wait for bootstrap to complete.')), 'info');
} else {
ui.addNotification(null, E('p', result.error || _('Failed to restart')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: %s').format(err.message || err)), 'error');
});
},
// Handle leak test
handleLeakTest: function() {
var self = this;
@ -373,6 +394,54 @@ return view.extend({
])
]),
// Health Status Minicard
E('div', { 'class': 'tor-health-card', 'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px;' }, [
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
E('div', {
'class': 'tor-health-indicator',
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (isProtected ? '#10b981' : isConnecting ? '#f59e0b' : '#6b7280') + '; box-shadow: 0 0 8px ' + (isProtected ? '#10b981' : isConnecting ? '#f59e0b' : 'transparent') + ';'
}),
E('div', {}, [
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('Service')),
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
status.running ? _('Running') : _('Stopped'))
])
]),
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
E('div', {
'class': 'tor-health-indicator',
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (status.bootstrap >= 100 ? '#10b981' : status.bootstrap > 0 ? '#f59e0b' : '#6b7280') + '; box-shadow: 0 0 8px ' + (status.bootstrap >= 100 ? '#10b981' : status.bootstrap > 0 ? '#f59e0b' : 'transparent') + ';'
}),
E('div', {}, [
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('Bootstrap')),
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
status.bootstrap >= 100 ? _('Complete') : status.bootstrap + '%')
])
]),
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
E('div', {
'class': 'tor-health-indicator',
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (status.dns_over_tor ? '#10b981' : '#f59e0b') + '; box-shadow: 0 0 8px ' + (status.dns_over_tor ? '#10b981' : '#f59e0b') + ';'
}),
E('div', {}, [
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('DNS')),
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
status.dns_over_tor ? _('Protected') : _('Exposed'))
])
]),
E('div', { 'class': 'tor-health-item', 'style': 'display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--tor-bg-card, #1a1a24); border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);' }, [
E('div', {
'class': 'tor-health-indicator',
'style': 'width: 12px; height: 12px; border-radius: 50%; background: ' + (status.kill_switch ? '#10b981' : '#6b7280') + '; box-shadow: 0 0 8px ' + (status.kill_switch ? '#10b981' : 'transparent') + ';'
}),
E('div', {}, [
E('div', { 'style': 'font-size: 14px; font-weight: 600; color: var(--tor-text, #fff);' }, _('Kill Switch')),
E('div', { 'style': 'font-size: 12px; color: var(--tor-text-muted, #a0a0b0);' },
status.kill_switch ? _('Active') : _('Disabled'))
])
])
]),
// Actions Card
E('div', { 'class': 'tor-card' }, [
E('div', { 'class': 'tor-card-header' }, [
@ -393,6 +462,11 @@ return view.extend({
'click': L.bind(this.handleLeakTest, this),
'disabled': !isActive
}, ['\uD83D\uDD0D ', _('Leak Test')]),
E('button', {
'class': 'tor-btn tor-btn-warning',
'click': L.bind(this.handleRestart, this),
'disabled': !status.enabled
}, ['\u21BB ', _('Restart')]),
E('a', {
'class': 'tor-btn',
'href': L.url('admin', 'services', 'tor-shield', 'circuits')

View File

@ -707,9 +707,21 @@ save_settings() {
}
# Main dispatcher
# Restart Tor Shield service
do_restart() {
json_init
/etc/init.d/tor-shield restart >/dev/null 2>&1 &
json_add_boolean "success" 1
json_add_string "message" "Tor Shield restarting"
json_dump
}
case "$1" in
list)
echo '{"status":{},"enable":{"preset":"str"},"disable":{},"circuits":{},"new_identity":{},"check_leaks":{},"hidden_services":{},"add_hidden_service":{"name":"str","local_port":"int","virtual_port":"int"},"remove_hidden_service":{"name":"str"},"exit_ip":{},"bandwidth":{},"presets":{},"bridges":{},"set_bridges":{"enabled":"bool","type":"str"},"settings":{},"save_settings":{"mode":"str","dns_over_tor":"bool","kill_switch":"bool","socks_port":"int","trans_port":"int","dns_port":"int","exit_nodes":"str","exclude_exit_nodes":"str","strict_nodes":"bool"}}'
echo '{"status":{},"enable":{"preset":"str"},"disable":{},"restart":{},"circuits":{},"new_identity":{},"check_leaks":{},"hidden_services":{},"add_hidden_service":{"name":"str","local_port":"int","virtual_port":"int"},"remove_hidden_service":{"name":"str"},"exit_ip":{},"bandwidth":{},"presets":{},"bridges":{},"set_bridges":{"enabled":"bool","type":"str"},"settings":{},"save_settings":{"mode":"str","dns_over_tor":"bool","kill_switch":"bool","socks_port":"int","trans_port":"int","dns_port":"int","exit_nodes":"str","exclude_exit_nodes":"str","strict_nodes":"bool"}}'
;;
call)
case "$2" in
@ -722,6 +734,9 @@ case "$1" in
disable)
do_disable
;;
restart)
do_restart
;;
circuits)
get_circuits
;;

View File

@ -0,0 +1,42 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-exposure
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=SecuBox Team <contact@secubox.dev>
PKG_LICENSE:=MIT
include $(INCLUDE_DIR)/package.mk
define Package/secubox-app-exposure
SECTION:=secubox
CATEGORY:=SecuBox
TITLE:=SecuBox Service Exposure Manager
DEPENDS:=+secubox-core
PKGARCH:=all
endef
define Package/secubox-app-exposure/description
Unified service exposure manager for SecuBox.
- Port conflict detection and resolution
- Dynamic Tor hidden service management
- HAProxy SSL reverse proxy configuration
endef
define Package/secubox-app-exposure/conffiles
/etc/config/secubox-exposure
endef
define Build/Compile
endef
define Package/secubox-app-exposure/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/etc/config/secubox-exposure $(1)/etc/config/
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/secubox-exposure $(1)/usr/sbin/
endef
$(eval $(call BuildPackage,secubox-app-exposure))

View File

@ -0,0 +1,63 @@
# SecuBox Service Exposure Manager Configuration
config settings 'main'
option enabled '1'
option tor_enabled '1'
option ssl_enabled '1'
option haproxy_config '/srv/lxc/haproxy/rootfs/etc/haproxy/haproxy.cfg'
option haproxy_certs '/srv/lxc/haproxy/rootfs/etc/haproxy/certs'
option tor_hidden_dir '/var/lib/tor/hidden_services'
option tor_config '/etc/tor/torrc'
# Port ranges for auto-assignment
config ports 'ranges'
option app_start '8100'
option app_end '8199'
option monitoring_start '8200'
option monitoring_end '8299'
# Known service definitions with default ports
config known 'gitea'
option default_port '3000'
option config_path 'gitea.main.http_port'
option category 'app'
config known 'streamlit'
option default_port '8501'
option config_path 'streamlit.main.port'
option category 'app'
config known 'hexojs'
option default_port '4000'
option config_path 'hexojs.main.port'
option category 'app'
config known 'cyberfeed'
option default_port '8082'
option config_path 'cyberfeed.main.port'
option category 'app'
config known 'crowdsec'
option default_port '6060'
option config_file '/etc/crowdsec/config.yaml'
option category 'security'
config known 'netifyd'
option default_port '8086'
option config_path 'netifyd.main.port'
option category 'monitoring'
config known 'domoticz'
option default_port '8080'
option config_type 'docker'
option category 'app'
# Service exposure entries (dynamically managed)
# Example:
# config service 'gitea'
# option port '3000'
# option local '1'
# option tor '1'
# option tor_onion 'abc123xyz.onion'
# option ssl '1'
# option ssl_domain 'git.example.com'

View File

@ -0,0 +1,733 @@
#!/bin/sh
#
# SecuBox Service Exposure Manager
# Unified tool for port management, Tor hidden services, and HAProxy SSL
#
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
CONFIG_NAME="secubox-exposure"
HAPROXY_CONFIG=""
HAPROXY_CERTS=""
TOR_HIDDEN_DIR=""
TOR_CONFIG=""
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_ok() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_err() { echo -e "${RED}[ERROR]${NC} $1"; }
load_config() {
config_load "$CONFIG_NAME"
config_get HAPROXY_CONFIG main haproxy_config "/srv/lxc/haproxy/rootfs/etc/haproxy/haproxy.cfg"
config_get HAPROXY_CERTS main haproxy_certs "/srv/lxc/haproxy/rootfs/etc/haproxy/certs"
config_get TOR_HIDDEN_DIR main tor_hidden_dir "/var/lib/tor/hidden_services"
config_get TOR_CONFIG main tor_config "/etc/tor/torrc"
config_get APP_PORT_START ranges app_start "8100"
config_get APP_PORT_END ranges app_end "8199"
}
# ============================================================================
# PORT SCANNING & CONFLICT DETECTION
# ============================================================================
get_listening_ports() {
# Returns: port address process
netstat -tlnp 2>/dev/null | grep LISTEN | awk '{
split($4, a, ":")
port = a[length(a)]
if (!seen[port]++) {
split($7, p, "/")
proc = p[2]
if (proc == "") proc = "unknown"
print port, $4, proc
}
}' | sort -n
}
cmd_scan() {
log_info "Scanning listening services..."
echo ""
printf "%-6s %-20s %-15s %-10s\n" "PORT" "ADDRESS" "PROCESS" "STATUS"
printf "%-6s %-20s %-15s %-10s\n" "------" "--------------------" "---------------" "----------"
get_listening_ports | while read port addr proc; do
# Determine if external
case "$addr" in
*0.0.0.0*|*::*) status="${GREEN}external${NC}" ;;
*127.0.0.1*|*::1*) status="${YELLOW}local${NC}" ;;
*) status="${CYAN}bound${NC}" ;;
esac
printf "%-6s %-20s %-15s " "$port" "$addr" "$proc"
echo -e "$status"
done
echo ""
}
cmd_conflicts() {
log_info "Checking for port conflicts..."
echo ""
local conflicts=0
local TMP_PORTS="/tmp/ports_$$"
# Get all configured ports from UCI
> "$TMP_PORTS"
# Check known services
check_known_service() {
local section="$1"
local default_port config_path
config_get default_port "$section" default_port
config_get config_path "$section" config_path
if [ -n "$config_path" ]; then
# Extract UCI config and option
local uci_config=$(echo "$config_path" | cut -d'.' -f1)
local uci_option=$(echo "$config_path" | cut -d'.' -f2-)
local actual_port=$(uci -q get "$config_path" 2>/dev/null)
[ -z "$actual_port" ] && actual_port="$default_port"
echo "$actual_port $section" >> "$TMP_PORTS"
fi
}
config_foreach check_known_service known
# Find duplicates
sort "$TMP_PORTS" | uniq -d -w5 | while read port svc; do
log_warn "Port $port is configured for multiple services!"
grep "^$port " "$TMP_PORTS" | while read p s; do
echo " - $s"
done
conflicts=$((conflicts + 1))
done
# Check against actually listening ports
get_listening_ports | while read port addr proc; do
if grep -q "^$port " "$TMP_PORTS"; then
local configured_svc=$(grep "^$port " "$TMP_PORTS" | head -1 | cut -d' ' -f2)
# Check if process matches expected
case "$configured_svc" in
gitea) [ "$proc" != "gitea" ] && log_warn "Port $port: expected gitea, found $proc" ;;
streamlit) echo "$proc" | grep -qv "python\|streamlit" && log_warn "Port $port: expected streamlit, found $proc" ;;
esac
fi
done
rm -f "$TMP_PORTS"
if [ "$conflicts" -eq 0 ]; then
log_ok "No port conflicts detected"
fi
}
find_free_port() {
local start="$1"
local end="$2"
local port="$start"
while [ "$port" -le "$end" ]; do
if ! netstat -tlnp 2>/dev/null | grep -q ":$port "; then
echo "$port"
return 0
fi
port=$((port + 1))
done
return 1
}
cmd_fix_port() {
local service="$1"
local new_port="$2"
if [ -z "$service" ]; then
log_err "Usage: secubox-exposure fix-port <service> [new_port]"
return 1
fi
load_config
# Get service config
local config_path default_port
config_get config_path "$service" config_path
config_get default_port "$service" default_port
if [ -z "$config_path" ]; then
log_err "Unknown service: $service"
return 1
fi
# Find free port if not specified
if [ -z "$new_port" ]; then
new_port=$(find_free_port "$APP_PORT_START" "$APP_PORT_END")
if [ -z "$new_port" ]; then
log_err "No free ports available in range $APP_PORT_START-$APP_PORT_END"
return 1
fi
fi
# Check if new port is free
if netstat -tlnp 2>/dev/null | grep -q ":$new_port "; then
log_err "Port $new_port is already in use"
return 1
fi
log_info "Changing $service port to $new_port"
# Update UCI
if uci set "$config_path=$new_port" && uci commit; then
log_ok "UCI config updated"
# Restart service if it has an init script
if [ -x "/etc/init.d/$service" ]; then
log_info "Restarting $service..."
/etc/init.d/"$service" restart
fi
log_ok "$service now listening on port $new_port"
else
log_err "Failed to update UCI config"
return 1
fi
}
# ============================================================================
# TOR HIDDEN SERVICES
# ============================================================================
cmd_tor_add() {
local service="$1"
local local_port="$2"
local onion_port="${3:-80}"
if [ -z "$service" ]; then
log_err "Usage: secubox-exposure tor add <service> [local_port] [onion_port]"
return 1
fi
load_config
# Get local port from config if not specified
if [ -z "$local_port" ]; then
config_get local_port "$service" default_port
if [ -z "$local_port" ]; then
log_err "Cannot determine local port for $service"
return 1
fi
fi
local hidden_dir="$TOR_HIDDEN_DIR/$service"
# Create hidden service directory
mkdir -p "$hidden_dir"
chmod 700 "$hidden_dir"
chown tor:tor "$hidden_dir" 2>/dev/null || chown debian-tor:debian-tor "$hidden_dir" 2>/dev/null
# Check if already configured in torrc
if grep -q "HiddenServiceDir $hidden_dir" "$TOR_CONFIG" 2>/dev/null; then
log_warn "Hidden service for $service already exists"
local onion=$(cat "$hidden_dir/hostname" 2>/dev/null)
[ -n "$onion" ] && log_info "Onion address: $onion"
return 0
fi
# Add to torrc
log_info "Adding hidden service for $service (127.0.0.1:$local_port -> :$onion_port)"
cat >> "$TOR_CONFIG" << EOF
# Hidden service for $service (added by secubox-exposure)
HiddenServiceDir $hidden_dir
HiddenServicePort $onion_port 127.0.0.1:$local_port
EOF
# Restart Tor
log_info "Restarting Tor..."
/etc/init.d/tor restart 2>/dev/null || systemctl restart tor 2>/dev/null
# Wait for onion address
log_info "Waiting for onion address generation..."
local tries=0
while [ ! -f "$hidden_dir/hostname" ] && [ "$tries" -lt 30 ]; do
sleep 1
tries=$((tries + 1))
done
if [ -f "$hidden_dir/hostname" ]; then
local onion=$(cat "$hidden_dir/hostname")
log_ok "Hidden service created!"
echo ""
echo -e " ${CYAN}Service:${NC} $service"
echo -e " ${CYAN}Onion:${NC} $onion"
echo -e " ${CYAN}Port:${NC} $onion_port -> 127.0.0.1:$local_port"
echo ""
# Save to exposure UCI
uci set "${CONFIG_NAME}.${service}=service"
uci set "${CONFIG_NAME}.${service}.port=$local_port"
uci set "${CONFIG_NAME}.${service}.tor=1"
uci set "${CONFIG_NAME}.${service}.tor_onion=$onion"
uci set "${CONFIG_NAME}.${service}.tor_port=$onion_port"
uci commit "$CONFIG_NAME"
# Sync to Tor Shield UCI
local hs_name="hs_${service}"
uci set "tor-shield.${hs_name}=hidden_service"
uci set "tor-shield.${hs_name}.name=${service}"
uci set "tor-shield.${hs_name}.enabled=1"
uci set "tor-shield.${hs_name}.local_port=${local_port}"
uci set "tor-shield.${hs_name}.onion_port=${onion_port}"
uci set "tor-shield.${hs_name}.onion_address=${onion}"
uci commit tor-shield
log_ok "Synced to Tor Shield"
else
log_err "Failed to generate onion address"
return 1
fi
}
cmd_tor_list() {
load_config
log_info "Tor Hidden Services:"
echo ""
printf "%-15s %-62s %-10s\n" "SERVICE" "ONION ADDRESS" "PORT"
printf "%-15s %-62s %-10s\n" "---------------" "--------------------------------------------------------------" "----------"
# List from filesystem
if [ -d "$TOR_HIDDEN_DIR" ]; then
for dir in "$TOR_HIDDEN_DIR"/*/; do
[ -d "$dir" ] || continue
local svc=$(basename "$dir")
local onion=""
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
# Get port from torrc
local port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $2}')
if [ -n "$onion" ]; then
printf "%-15s %-62s %-10s\n" "$svc" "$onion" "${port:-80}"
fi
done
fi
echo ""
}
cmd_tor_remove() {
local service="$1"
if [ -z "$service" ]; then
log_err "Usage: secubox-exposure tor remove <service>"
return 1
fi
load_config
local hidden_dir="$TOR_HIDDEN_DIR/$service"
if [ ! -d "$hidden_dir" ]; then
log_err "No hidden service found for $service"
return 1
fi
log_info "Removing hidden service for $service"
# Remove from torrc (remove the block)
sed -i "/# Hidden service for $service/,/HiddenServicePort/d" "$TOR_CONFIG"
# Remove directory
rm -rf "$hidden_dir"
# Update exposure UCI
uci delete "${CONFIG_NAME}.${service}.tor" 2>/dev/null
uci delete "${CONFIG_NAME}.${service}.tor_onion" 2>/dev/null
uci delete "${CONFIG_NAME}.${service}.tor_port" 2>/dev/null
uci commit "$CONFIG_NAME"
# Remove from Tor Shield UCI
local hs_name="hs_${service}"
if uci -q get "tor-shield.${hs_name}" >/dev/null 2>&1; then
uci delete "tor-shield.${hs_name}"
uci commit tor-shield
log_ok "Removed from Tor Shield"
fi
# Restart Tor
/etc/init.d/tor restart 2>/dev/null || systemctl restart tor 2>/dev/null
log_ok "Hidden service removed"
}
cmd_tor_sync() {
load_config
log_info "Syncing hidden services to Tor Shield..."
local synced=0
# List from filesystem and sync to Tor Shield
if [ -d "$TOR_HIDDEN_DIR" ]; then
for dir in "$TOR_HIDDEN_DIR"/*/; do
[ -d "$dir" ] || continue
local svc=$(basename "$dir")
local onion=""
[ -f "$dir/hostname" ] && onion=$(cat "$dir/hostname")
# Get port from torrc
local port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{print $2}')
local local_port=$(grep -A1 "HiddenServiceDir $dir" "$TOR_CONFIG" 2>/dev/null | grep HiddenServicePort | awk '{split($3,a,":"); print a[2]}')
if [ -n "$onion" ]; then
local hs_name="hs_${svc}"
if ! uci -q get "tor-shield.${hs_name}" >/dev/null 2>&1; then
log_info "Adding $svc to Tor Shield"
uci set "tor-shield.${hs_name}=hidden_service"
uci set "tor-shield.${hs_name}.name=${svc}"
uci set "tor-shield.${hs_name}.enabled=1"
uci set "tor-shield.${hs_name}.local_port=${local_port:-80}"
uci set "tor-shield.${hs_name}.onion_port=${port:-80}"
uci set "tor-shield.${hs_name}.onion_address=${onion}"
synced=$((synced + 1))
fi
fi
done
fi
if [ "$synced" -gt 0 ]; then
uci commit tor-shield
log_ok "Synced $synced hidden service(s) to Tor Shield"
else
log_info "All hidden services already synced"
fi
}
# ============================================================================
# HAPROXY SSL BACKENDS (UCI-based integration with haproxyctl)
# ============================================================================
# Sanitize name for UCI section (replace dots/hyphens with underscores)
sanitize_uci_name() {
echo "$1" | sed 's/[.-]/_/g'
}
cmd_ssl_add() {
local service="$1"
local domain="$2"
local local_port="$3"
if [ -z "$service" ] || [ -z "$domain" ]; then
log_err "Usage: secubox-exposure ssl add <service> <domain> [local_port]"
return 1
fi
load_config
# Get local port from config if not specified
if [ -z "$local_port" ]; then
config_get local_port "$service" default_port
# Try to get from service UCI
local config_path
config_get config_path "$service" config_path
if [ -n "$config_path" ]; then
local configured_port=$(uci -q get "$config_path")
[ -n "$configured_port" ] && local_port="$configured_port"
fi
if [ -z "$local_port" ]; then
log_err "Cannot determine local port for $service. Specify it manually."
return 1
fi
fi
# Check if haproxyctl exists
if [ ! -x "/usr/sbin/haproxyctl" ]; then
log_err "haproxyctl not found. Is secubox-app-haproxy installed?"
return 1
fi
# Sanitize names for UCI
local backend_name="$service"
local vhost_name=$(sanitize_uci_name "$domain")
# Check if backend already exists in UCI
if uci -q get "haproxy.${backend_name}" >/dev/null 2>&1; then
log_warn "Backend '$backend_name' already exists in HAProxy UCI config"
else
# Create backend in HAProxy UCI config
log_info "Adding backend '$backend_name' (127.0.0.1:$local_port)"
uci set "haproxy.${backend_name}=backend"
uci set "haproxy.${backend_name}.name=${backend_name}"
uci set "haproxy.${backend_name}.mode=http"
uci set "haproxy.${backend_name}.balance=roundrobin"
uci set "haproxy.${backend_name}.enabled=1"
uci add_list "haproxy.${backend_name}.server=${service} 127.0.0.1:${local_port} check"
fi
# Check if vhost already exists
if uci -q get "haproxy.${vhost_name}" >/dev/null 2>&1; then
log_warn "Vhost for '$domain' already exists"
else
# Create vhost in HAProxy UCI config
log_info "Adding vhost '$domain' -> backend '$backend_name'"
uci set "haproxy.${vhost_name}=vhost"
uci set "haproxy.${vhost_name}.domain=${domain}"
uci set "haproxy.${vhost_name}.backend=${backend_name}"
uci set "haproxy.${vhost_name}.ssl=1"
uci set "haproxy.${vhost_name}.ssl_redirect=1"
uci set "haproxy.${vhost_name}.enabled=1"
fi
# Commit HAProxy UCI changes
uci commit haproxy
# Also save to exposure UCI for tracking
uci set "${CONFIG_NAME}.${service}=service"
uci set "${CONFIG_NAME}.${service}.port=$local_port"
uci set "${CONFIG_NAME}.${service}.ssl=1"
uci set "${CONFIG_NAME}.${service}.ssl_domain=$domain"
uci commit "$CONFIG_NAME"
log_ok "HAProxy UCI config updated"
log_info "Domain: $domain -> 127.0.0.1:$local_port"
# Regenerate and reload HAProxy
log_info "Regenerating HAProxy config..."
/usr/sbin/haproxyctl generate
log_info "Reloading HAProxy..."
/usr/sbin/haproxyctl reload
log_ok "SSL backend configured"
log_warn "Note: Ensure SSL certificate exists for $domain"
}
cmd_ssl_list() {
load_config
log_info "HAProxy SSL Backends:"
echo ""
printf "%-15s %-30s %-20s\n" "SERVICE" "DOMAIN" "BACKEND"
printf "%-15s %-30s %-20s\n" "---------------" "------------------------------" "--------------------"
# Read from HAProxy UCI config (vhosts with their backends)
local found=0
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
local domain=$(uci -q get "haproxy.${vhost}.domain")
local backend=$(uci -q get "haproxy.${vhost}.backend")
local enabled=$(uci -q get "haproxy.${vhost}.enabled")
[ "$enabled" != "1" ] && continue
[ -z "$domain" ] && continue
# Get server from backend
local server=""
if [ -n "$backend" ]; then
server=$(uci -q get "haproxy.${backend}.server" | head -1 | awk '{print $2}')
fi
printf "%-15s %-30s %-20s\n" "${backend:-N/A}" "$domain" "${server:-N/A}"
found=1
done
[ "$found" = "0" ] && echo " No SSL backends configured"
echo ""
}
cmd_ssl_remove() {
local service="$1"
if [ -z "$service" ]; then
log_err "Usage: secubox-exposure ssl remove <service>"
return 1
fi
load_config
# Check if haproxyctl exists
if [ ! -x "/usr/sbin/haproxyctl" ]; then
log_err "haproxyctl not found"
return 1
fi
local backend_name="$service"
local removed=0
# Find and remove vhosts pointing to this backend
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
local vhost_backend=$(uci -q get "haproxy.${vhost}.backend")
if [ "$vhost_backend" = "$backend_name" ]; then
log_info "Removing vhost '$vhost'"
uci delete "haproxy.${vhost}"
removed=1
fi
done
# Remove backend if it exists
if uci -q get "haproxy.${backend_name}" >/dev/null 2>&1; then
log_info "Removing backend '$backend_name'"
uci delete "haproxy.${backend_name}"
removed=1
fi
if [ "$removed" = "0" ]; then
log_err "No backend or vhost found for '$service'"
return 1
fi
# Commit HAProxy UCI changes
uci commit haproxy
# Update exposure UCI
uci delete "${CONFIG_NAME}.${service}.ssl" 2>/dev/null
uci delete "${CONFIG_NAME}.${service}.ssl_domain" 2>/dev/null
uci commit "$CONFIG_NAME"
# Regenerate and reload HAProxy
log_info "Regenerating HAProxy config..."
/usr/sbin/haproxyctl generate
log_info "Reloading HAProxy..."
/usr/sbin/haproxyctl reload
log_ok "SSL backend removed"
}
# ============================================================================
# STATUS & HELP
# ============================================================================
cmd_status() {
load_config
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${CYAN} SecuBox Service Exposure Status${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
echo ""
# Count services
local total_services=$(get_listening_ports | wc -l)
local external_services=$(get_listening_ports | grep -E "0\.0\.0\.0|::" | wc -l)
echo -e "${BLUE}Services:${NC}"
echo " Total listening: $total_services"
echo " External (0.0.0.0): $external_services"
echo ""
# Tor status
local tor_services=0
[ -d "$TOR_HIDDEN_DIR" ] && tor_services=$(ls -1 "$TOR_HIDDEN_DIR" 2>/dev/null | wc -l)
echo -e "${BLUE}Tor Hidden Services:${NC} $tor_services"
if [ "$tor_services" -gt 0 ]; then
for dir in "$TOR_HIDDEN_DIR"/*/; do
[ -d "$dir" ] || continue
local svc=$(basename "$dir")
local onion=$(cat "$dir/hostname" 2>/dev/null)
[ -n "$onion" ] && echo " - $svc: ${onion:0:16}..."
done
fi
echo ""
# HAProxy backends (from UCI)
local ssl_backends=0
echo -e "${BLUE}HAProxy SSL Backends:${NC}"
for vhost in $(uci show haproxy 2>/dev/null | grep "=vhost$" | cut -d'.' -f2 | cut -d'=' -f1); do
local domain=$(uci -q get "haproxy.${vhost}.domain")
local backend=$(uci -q get "haproxy.${vhost}.backend")
local enabled=$(uci -q get "haproxy.${vhost}.enabled")
[ "$enabled" != "1" ] && continue
[ -z "$domain" ] && continue
echo " - ${backend}: ${domain}"
ssl_backends=$((ssl_backends + 1))
done
[ "$ssl_backends" = "0" ] && echo " (none configured)"
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
}
cmd_help() {
cat << EOF
SecuBox Service Exposure Manager
Usage: secubox-exposure <command> [options]
COMMANDS:
scan Scan all listening services
conflicts Detect port conflicts
fix-port <svc> [port] Change service port (auto-assigns if no port given)
status Show exposure status summary
tor add <svc> [port] Create Tor hidden service
tor list List hidden services
tor remove <svc> Remove hidden service
tor sync Sync hidden services to Tor Shield
ssl add <svc> <domain> Add HAProxy SSL backend
ssl list List SSL backends
ssl remove <svc> Remove SSL backend
EXAMPLES:
secubox-exposure scan
secubox-exposure conflicts
secubox-exposure fix-port domoticz 8180
secubox-exposure tor add gitea
secubox-exposure tor add streamlit 8501 80
secubox-exposure tor list
secubox-exposure ssl add gitea git.example.com
secubox-exposure ssl add streamlit app.example.com 8501
secubox-exposure ssl list
EOF
}
# ============================================================================
# MAIN
# ============================================================================
case "$1" in
scan)
cmd_scan
;;
conflicts)
load_config
cmd_conflicts
;;
fix-port)
cmd_fix_port "$2" "$3"
;;
status)
cmd_status
;;
tor)
case "$2" in
add) cmd_tor_add "$3" "$4" "$5" ;;
list) cmd_tor_list ;;
remove) cmd_tor_remove "$3" ;;
sync) cmd_tor_sync ;;
*) log_err "Usage: secubox-exposure tor {add|list|remove|sync}"; exit 1 ;;
esac
;;
ssl)
case "$2" in
add) cmd_ssl_add "$3" "$4" "$5" ;;
list) cmd_ssl_list ;;
remove) cmd_ssl_remove "$3" ;;
*) log_err "Usage: secubox-exposure ssl {add|list|remove}"; exit 1 ;;
esac
;;
help|--help|-h)
cmd_help
;;
*)
cmd_help
exit 1
;;
esac

View File

@ -91,6 +91,15 @@ Commands:
--password <pass>
--email <email>
mirror-sync <repo> Sync a mirrored repository
mirror-list List all mirrored repositories
mirror-create Create a new mirror from URL
--name <name>
--url <github-url>
--owner <user> (default: first admin user)
repo-list List all repositories
service-run Start service (used by init)
service-stop Stop service (used by init)
@ -718,6 +727,235 @@ cmd_admin_create_user() {
fi
}
# Get Gitea API token (from admin user or config)
get_api_token() {
local token
token="$(uci_get main.api_token)"
if [ -n "$token" ]; then
echo "$token"
return 0
fi
# Try to get token from container
if lxc_running; then
token=$(lxc-attach -n "$LXC_NAME" -- cat /data/api_token 2>/dev/null)
if [ -n "$token" ]; then
echo "$token"
return 0
fi
fi
return 1
}
# Get Gitea API URL
get_api_url() {
load_config
echo "http://127.0.0.1:${http_port}/api/v1"
}
# Make Gitea API call
gitea_api() {
local method="$1"
local endpoint="$2"
local data="$3"
local token
token=$(get_api_token) || {
log_error "No API token configured. Set with: uci set gitea.main.api_token=<token>"
log_error "Generate token in Gitea: Settings → Applications → Generate Token"
return 1
}
local api_url=$(get_api_url)
local url="${api_url}${endpoint}"
if [ "$method" = "GET" ]; then
wget -q -O- --header="Authorization: token $token" "$url" 2>/dev/null
elif [ "$method" = "POST" ]; then
if [ -n "$data" ]; then
wget -q -O- --header="Authorization: token $token" \
--header="Content-Type: application/json" \
--post-data="$data" "$url" 2>/dev/null
else
wget -q -O- --header="Authorization: token $token" \
--post-data="" "$url" 2>/dev/null
fi
fi
}
cmd_mirror_sync() {
load_config
local repo_name="$1"
if [ -z "$repo_name" ]; then
log_error "Usage: giteactl mirror-sync <owner/repo> or <repo>"
return 1
fi
if ! lxc_running; then
log_error "Gitea container is not running"
return 1
fi
# If no owner specified, try to find the repo
if ! echo "$repo_name" | grep -q "/"; then
# Search for repo in all users
local found_owner
found_owner=$(gitea_api GET "/repos/search?q=$repo_name" 2>/dev/null | \
jsonfilter -e '@.data[0].owner.login' 2>/dev/null)
if [ -n "$found_owner" ]; then
repo_name="${found_owner}/${repo_name}"
else
log_error "Repository not found: $repo_name"
log_error "Specify full path: owner/repo"
return 1
fi
fi
log_info "Syncing mirror: $repo_name"
# Trigger mirror sync via API
local result
result=$(gitea_api POST "/repos/${repo_name}/mirror-sync" 2>&1)
if [ $? -eq 0 ]; then
log_info "Mirror sync triggered for $repo_name"
log_info "Check progress in Gitea web UI"
else
log_error "Failed to sync mirror: $result"
# Try alternative: use gitea command directly in container
log_info "Trying direct sync via container..."
lxc-attach -n "$LXC_NAME" -- su-exec git /usr/local/bin/gitea admin repo-sync-releases \
--config /data/custom/conf/app.ini 2>/dev/null || true
return 1
fi
}
cmd_mirror_list() {
load_config
if ! lxc_running; then
log_error "Gitea container is not running"
return 1
fi
log_info "Fetching mirror repositories..."
local repos
repos=$(gitea_api GET "/repos/search?mirror=true&limit=50" 2>/dev/null)
if [ -z "$repos" ]; then
echo "No mirrored repositories found (or API token not set)"
return 1
fi
echo ""
echo "Mirrored Repositories:"
echo "======================"
echo "$repos" | jsonfilter -e '@.data[*]' 2>/dev/null | while read repo; do
local name=$(echo "$repo" | jsonfilter -e '@.full_name' 2>/dev/null)
local url=$(echo "$repo" | jsonfilter -e '@.original_url' 2>/dev/null)
local updated=$(echo "$repo" | jsonfilter -e '@.updated_at' 2>/dev/null)
echo " $name"
echo " Source: $url"
echo " Updated: $updated"
echo ""
done
}
cmd_mirror_create() {
load_config
local name=""
local url=""
local owner=""
# Parse arguments
while [ $# -gt 0 ]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--url) url="$2"; shift 2 ;;
--owner) owner="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$name" ] || [ -z "$url" ]; then
log_error "Usage: giteactl mirror-create --name <repo-name> --url <source-url> [--owner <user>]"
return 1
fi
if ! lxc_running; then
log_error "Gitea container is not running"
return 1
fi
# Get default owner if not specified
if [ -z "$owner" ]; then
owner=$(gitea_api GET "/user" 2>/dev/null | jsonfilter -e '@.login' 2>/dev/null)
if [ -z "$owner" ]; then
log_error "Could not determine owner. Specify with --owner"
return 1
fi
fi
log_info "Creating mirror repository: $owner/$name from $url"
local data=$(cat <<EOF
{
"clone_addr": "$url",
"repo_name": "$name",
"mirror": true,
"private": false,
"description": "Mirror of $url"
}
EOF
)
local result
result=$(gitea_api POST "/repos/migrate" "$data" 2>&1)
if echo "$result" | grep -q '"id":'; then
log_info "Mirror created successfully: $owner/$name"
log_info "First sync in progress..."
else
log_error "Failed to create mirror: $result"
return 1
fi
}
cmd_repo_list() {
load_config
if ! lxc_running; then
log_error "Gitea container is not running"
return 1
fi
local repos
repos=$(gitea_api GET "/repos/search?limit=100" 2>/dev/null)
if [ -z "$repos" ]; then
echo "No repositories found (or API token not set)"
return 1
fi
echo ""
echo "Repositories:"
echo "============="
echo "$repos" | jsonfilter -e '@.data[*].full_name' 2>/dev/null | while read name; do
local is_mirror=$(echo "$repos" | jsonfilter -e "@.data[?(@.full_name=='$name')].mirror" 2>/dev/null)
if [ "$is_mirror" = "true" ]; then
echo " [mirror] $name"
else
echo " $name"
fi
done
}
cmd_service_run() {
require_root
load_config
@ -751,7 +989,11 @@ case "${1:-}" in
*) echo "Usage: giteactl admin create-user --username <name> --password <pass> --email <email>"; exit 1 ;;
esac
;;
service-run) shift; cmd_service_run "$@" ;;
service-stop) shift; cmd_service_stop "$@" ;;
*) usage ;;
mirror-sync) shift; cmd_mirror_sync "$@" ;;
mirror-list) shift; cmd_mirror_list "$@" ;;
mirror-create) shift; cmd_mirror_create "$@" ;;
repo-list) shift; cmd_repo_list "$@" ;;
service-run) shift; cmd_service_run "$@" ;;
service-stop) shift; cmd_service_stop "$@" ;;
*) usage ;;
esac

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=14
PKG_RELEASE:=18
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT

View File

@ -246,6 +246,17 @@ echo "Config: $CONFIG_FILE"
ls -la /opt/haproxy/
ls -la /opt/haproxy/certs/ 2>/dev/null || echo "No certs dir"
# Clean up legacy certificate files - only .pem files should exist
# HAProxy loads all files from certs directory, and extra files cause errors
if [ -d "/opt/haproxy/certs" ]; then
for pem in /opt/haproxy/certs/*.pem; do
[ -f "$pem" ] || continue
base="${pem%.pem}"
# Remove any associated .crt, .key, .fullchain.pem, .crt.key files
rm -f "${base}.crt" "${base}.key" "${base}.crt.key" "${base}.fullchain.pem" 2>/dev/null
done
fi
# Wait for config
if [ ! -f "$CONFIG_FILE" ]; then
echo "[haproxy] Config not found, generating default..."
@ -503,10 +514,26 @@ _generate_backend() {
[ -n "$health_check" ] && echo " option $health_check"
# Add servers defined in backend section (handles both single and list)
local server_line
config_get server_line "$section" server ""
[ -n "$server_line" ] && echo " server $server_line"
# Check if there are separate server sections for this backend
local has_server_sections=0
_check_server_sections() {
local srv_section="$1"
local srv_backend
config_get srv_backend "$srv_section" backend
config_get srv_enabled "$srv_section" enabled "0"
if [ "$srv_backend" = "$name" ] && [ "$srv_enabled" = "1" ]; then
has_server_sections=1
fi
}
config_foreach _check_server_sections server
# Add inline server ONLY if no separate server sections exist
# This prevents duplicate server names
if [ "$has_server_sections" = "0" ]; then
local server_line
config_get server_line "$section" server ""
[ -n "$server_line" ] && echo " server $server_line"
fi
# Add servers from separate server UCI sections
config_foreach _add_server_to_backend server "$name"
@ -541,6 +568,111 @@ _add_server_to_backend() {
# Certificate Management
# ===========================================
# Check if certificate is from Let's Encrypt Production (not Staging)
cert_is_production() {
local cert_file="$1"
[ -f "$cert_file" ] || return 1
# Check the issuer - staging certs have "(STAGING)" in the issuer
local issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null)
if echo "$issuer" | grep -qi "staging\|test\|fake"; then
return 1 # Staging certificate
fi
# Check for Let's Encrypt production issuers
if echo "$issuer" | grep -qiE "Let's Encrypt|R3|R10|R11|E1|E2|ISRG"; then
return 0 # Production certificate
fi
# Check if it's a self-signed or other CA
return 0 # Assume production for other CAs
}
# Validate certificate publicly using external service
cert_validate_public() {
local domain="$1"
local timeout=10
# Try to connect and verify the certificate
if command -v curl >/dev/null 2>&1; then
if curl -sS --max-time "$timeout" -o /dev/null "https://$domain" 2>/dev/null; then
return 0
fi
fi
# Fallback: use openssl s_client
if command -v openssl >/dev/null 2>&1; then
local result=$(echo | timeout "$timeout" openssl s_client -connect "$domain:443" -servername "$domain" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null)
if [ -n "$result" ]; then
return 0
fi
fi
return 1
}
# Get certificate info
cert_info() {
local cert_file="$1"
[ -f "$cert_file" ] || return 1
local subject=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed 's/subject=//')
local issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/issuer=//')
local not_after=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2)
local not_before=$(openssl x509 -in "$cert_file" -noout -startdate 2>/dev/null | cut -d= -f2)
echo "Subject: $subject"
echo "Issuer: $issuer"
echo "Valid From: $not_before"
echo "Valid Until: $not_after"
if cert_is_production "$cert_file"; then
echo "Type: PRODUCTION (publicly trusted)"
else
echo "Type: STAGING/TEST (NOT publicly trusted!)"
fi
}
# Verify and report certificate status
cmd_cert_verify() {
load_config
local domain="$1"
if [ -z "$domain" ]; then
echo "Usage: haproxyctl cert verify <domain>"
return 1
fi
local cert_file="$CERTS_PATH/$domain.pem"
if [ ! -f "$cert_file" ]; then
log_error "Certificate not found: $cert_file"
return 1
fi
echo "Certificate Information for $domain:"
echo "======================================"
cert_info "$cert_file"
echo ""
# Check if it's production
if ! cert_is_production "$cert_file"; then
log_warn "This is a STAGING certificate - NOT trusted by browsers!"
log_warn "To get a production certificate, ensure staging='0' in config and re-issue"
return 1
fi
# Try public validation
echo "Public Validation:"
if cert_validate_public "$domain"; then
log_info "Certificate is publicly valid and accessible"
return 0
else
log_warn "Could not verify certificate publicly"
log_warn "Ensure DNS points to this server and port 443 is accessible"
return 1
fi
}
cmd_cert_list() {
load_config
@ -552,11 +684,25 @@ cmd_cert_list() {
[ -f "$cert" ] || continue
local name=$(basename "$cert" .pem)
local expiry=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | cut -d= -f2)
echo " $name - Expires: ${expiry:-Unknown}"
local type_icon="✅"
if ! cert_is_production "$cert"; then
type_icon="⚠️ STAGING"
fi
echo " $name - Expires: ${expiry:-Unknown} $type_icon"
done
else
echo " No certificates found"
fi
# Show current mode
local staging=$(uci -q get haproxy.acme.staging)
echo ""
if [ "$staging" = "1" ]; then
echo "⚠️ ACME Mode: STAGING (certificates will NOT be trusted by browsers)"
echo " To use production: uci set haproxy.acme.staging='0' && uci commit haproxy"
else
echo "✅ ACME Mode: PRODUCTION (certificates will be publicly trusted)"
fi
}
cmd_cert_add() {
@ -579,6 +725,18 @@ cmd_cert_add() {
[ -z "$email" ] && { log_error "ACME email not configured. Set in LuCI > Services > HAProxy > Settings"; return 1; }
# Warn about staging mode
if [ "$staging" = "1" ]; then
log_warn "=========================================="
log_warn "STAGING MODE ENABLED!"
log_warn "Certificate will NOT be trusted by browsers"
log_warn "To use production: uci set haproxy.acme.staging='0' && uci commit haproxy"
log_warn "=========================================="
sleep 2
else
log_info "Using Let's Encrypt PRODUCTION (certificates will be publicly trusted)"
fi
log_info "Requesting certificate for $domain..."
local staging_flag=""
@ -637,6 +795,10 @@ cmd_cert_add() {
log_info "Creating combined PEM for HAProxy..."
cat "$CERTS_PATH/$domain.fullchain.pem" "$CERTS_PATH/$domain.key" > "$CERTS_PATH/$domain.pem"
chmod 600 "$CERTS_PATH/$domain.pem"
# Clean up intermediate files - HAProxy only needs the .pem file
# Keeping these causes issues when HAProxy loads certs from directory
rm -f "$CERTS_PATH/$domain.crt" "$CERTS_PATH/$domain.key" "$CERTS_PATH/$domain.fullchain.pem" "$CERTS_PATH/$domain.crt.key" 2>/dev/null
fi
# Restart HAProxy if it was running
@ -669,6 +831,24 @@ cmd_cert_add() {
chmod 600 "$CERTS_PATH/$domain.pem"
# Verify certificate type (production vs staging)
echo ""
if cert_is_production "$CERTS_PATH/$domain.pem"; then
log_info "✅ Certificate is from PRODUCTION CA (publicly trusted)"
else
log_warn "⚠️ Certificate is from STAGING CA (NOT publicly trusted!)"
log_warn " Browsers will show security warnings for this certificate"
log_warn " To get a production certificate:"
log_warn " 1. uci set haproxy.acme.staging='0'"
log_warn " 2. uci commit haproxy"
log_warn " 3. haproxyctl cert remove $domain"
log_warn " 4. haproxyctl cert add $domain"
fi
# Show certificate info
echo ""
cert_info "$CERTS_PATH/$domain.pem"
# Add to UCI
local section="cert_$(echo "$domain" | tr '.-' '__')"
uci set haproxy.$section=certificate
@ -678,6 +858,13 @@ cmd_cert_add() {
uci commit haproxy
log_info "Certificate installed for $domain"
# Offer to verify publicly if production
if cert_is_production "$CERTS_PATH/$domain.pem"; then
echo ""
log_info "To verify the certificate is working publicly, run:"
log_info " haproxyctl cert verify $domain"
fi
}
cmd_cert_import() {
@ -1021,8 +1208,9 @@ case "${1:-}" in
add) shift; cmd_cert_add "$@" ;;
import) shift; cmd_cert_import "$@" ;;
renew) shift; cmd_cert_add "$@" ;;
verify) shift; cmd_cert_verify "$@" ;;
remove) shift; rm -f "$CERTS_PATH/$1.pem"; uci delete haproxy.cert_${1//[.-]/_} 2>/dev/null ;;
*) echo "Usage: haproxyctl cert {list|add|import|renew|remove}" ;;
*) echo "Usage: haproxyctl cert {list|add|import|renew|verify|remove}" ;;
esac
;;

View File

@ -60,7 +60,7 @@ define Package/secubox-app-magicmirror2/postinst
echo " mm2ctl install"
echo " /etc/init.d/magicmirror2 start"
echo ""
echo "Web interface: http://<router-ip>:8082"
echo "Web interface: http://<router-ip>:8085"
echo ""
echo "To manage modules:"
echo " mm2ctl module list"

View File

@ -2,7 +2,7 @@
config magicmirror2 'main'
option enabled '0'
option port '8082'
option port '8085'
option address '0.0.0.0'
option data_path '/srv/magicmirror2'
option memory_limit '512M'

View File

@ -51,7 +51,7 @@ Examples:
mm2ctl module list
mm2ctl config
Web Interface: http://<router-ip>:8082
Web Interface: http://<router-ip>:8085
EOF
}
@ -66,7 +66,7 @@ uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; }
# Load configuration with defaults
load_config() {
port="$(uci_get main.port || echo 8082)"
port="$(uci_get main.port || echo 8085)"
address="$(uci_get main.address || echo 0.0.0.0)"
data_path="$(uci_get main.data_path || echo /srv/magicmirror2)"
memory_limit="$(uci_get main.memory_limit || echo 512M)"
@ -255,7 +255,7 @@ lxc_create_docker_rootfs() {
cat >> "$rootfs/opt/start-mm2.sh" << 'START'
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
export NODE_ENV=production
export MM_PORT="${MM2_PORT:-8082}"
export MM_PORT="${MM2_PORT:-8085}"
export MM_ADDRESS="${MM2_ADDRESS:-0.0.0.0}"
MM_DIR="/opt/magic_mirror"

View File

@ -18,10 +18,10 @@ config cms 'cms'
config hexo 'hexo'
option source_path '/srv/hexojs/site/source/_posts'
option public_path '/srv/hexojs/site/public'
option portal_path '/www'
option auto_publish '0'
option portal_path '/www/blog'
option auto_publish '1'
config portal 'portal'
option enabled '1'
option url_path '/'
option url_path '/blog'
option title 'SecuBox Blog'

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-streamlit
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_ARCH:=all
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>

View File

@ -187,7 +187,7 @@ cd /srv/apps
exec streamlit run "$APP_PATH" \
--server.address="${STREAMLIT_HOST:-0.0.0.0}" \
--server.port="${STREAMLIT_PORT:-8501}" \
--server.headless="${STREAMLIT_HEADLESS:-true}" \
--server.headless=true \
--browser.gatherUsageStats="${STREAMLIT_STATS:-false}" \
--theme.base="${STREAMLIT_THEME_BASE:-dark}" \
--theme.primaryColor="${STREAMLIT_THEME_PRIMARY:-#0ff}"

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-core
PKG_VERSION:=0.10.0
PKG_RELEASE:=4
PKG_RELEASE:=5
PKG_ARCH:=all
PKG_LICENSE:=GPL-2.0
PKG_MAINTAINER:=SecuBox Team

View File

@ -71,6 +71,15 @@ case "$1" in
json_add_object "getHealth"
json_close_object
json_add_object "get_network_health"
json_close_object
json_add_object "get_vital_services"
json_close_object
json_add_object "get_full_health_report"
json_close_object
json_add_object "getLogs"
json_add_string "service" "string"
json_add_int "lines" "integer"
@ -330,18 +339,385 @@ case "$1" in
/usr/sbin/secubox-core health
;;
get_network_health)
# Network health monitoring - detects CRC errors, link flapping
DMESG_LINES=500
FLAP_THRESHOLD=5
CRC_THRESHOLD=10
json_init
json_add_string "timestamp" "$(date -Iseconds)"
json_add_object "interfaces"
overall="healthy"
critical_count=0
warning_count=0
for iface_path in /sys/class/net/eth* /sys/class/net/wan* /sys/class/net/lan*; do
[ -d "$iface_path" ] || continue
[ -d "$iface_path/device" ] || continue
iface=$(basename "$iface_path")
current_state=$(cat "$iface_path/operstate" 2>/dev/null || echo "unknown")
crc_count=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface.*crc error" 2>/dev/null)
crc_count=${crc_count:-0}
link_up=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface: Link is Up" 2>/dev/null)
link_up=${link_up:-0}
link_down=$(dmesg | tail -n $DMESG_LINES | grep -c "$iface: Link is Down" 2>/dev/null)
link_down=${link_down:-0}
link_changes=$((link_up + link_down))
status="ok"
issues=""
if [ "$crc_count" -ge "$CRC_THRESHOLD" ]; then
status="critical"
issues="CRC errors ($crc_count)"
critical_count=$((critical_count + 1))
fi
if [ "$link_changes" -ge "$FLAP_THRESHOLD" ]; then
[ "$status" = "ok" ] && status="warning"
[ -n "$issues" ] && issues="$issues; "
issues="${issues}Link flapping ($link_changes changes)"
warning_count=$((warning_count + 1))
fi
rx_errors=$(cat "$iface_path/statistics/rx_errors" 2>/dev/null || echo 0)
tx_errors=$(cat "$iface_path/statistics/tx_errors" 2>/dev/null || echo 0)
json_add_object "$iface"
json_add_string "status" "$status"
json_add_string "state" "$current_state"
json_add_int "crc_errors" "$crc_count"
json_add_int "link_changes" "$link_changes"
json_add_int "rx_errors" "$rx_errors"
json_add_int "tx_errors" "$tx_errors"
json_add_string "issues" "$issues"
json_close_object
done
json_close_object
if [ "$critical_count" -gt 0 ]; then
overall="critical"
elif [ "$warning_count" -gt 0 ]; then
overall="warning"
fi
json_add_string "overall" "$overall"
json_add_int "critical_interfaces" "$critical_count"
json_add_int "warning_interfaces" "$warning_count"
if [ "$overall" != "healthy" ]; then
json_add_array "recommendations"
[ "$critical_count" -gt 0 ] && json_add_string "" "Check/replace Ethernet cables"
[ "$critical_count" -gt 0 ] && json_add_string "" "Try different port on switch/modem"
[ "$warning_count" -gt 0 ] && json_add_string "" "Monitor link stability"
json_close_array
fi
json_dump
;;
get_vital_services)
# Vital services monitoring for web hosting and remote management
json_init
json_add_string "timestamp" "$(date -Iseconds)"
# Helper function to check service
check_service() {
local name="$1"
local category="$2"
local check_type="$3"
local check_value="$4"
local description="$5"
local critical="$6"
local status="unknown"
local details=""
case "$check_type" in
process)
if pgrep -f "$check_value" >/dev/null 2>&1; then
status="running"
else
status="stopped"
fi
;;
port)
if netstat -tln 2>/dev/null | grep -q ":${check_value} "; then
status="running"
details="Port $check_value listening"
else
status="stopped"
details="Port $check_value not listening"
fi
;;
init)
if [ -f "/etc/init.d/$check_value" ]; then
if /etc/init.d/$check_value enabled 2>/dev/null; then
if /etc/init.d/$check_value running 2>/dev/null; then
status="running"
else
status="stopped"
fi
else
status="disabled"
fi
else
status="not_installed"
fi
;;
lxc)
if lxc-info -n "$check_value" -s 2>/dev/null | grep -q "RUNNING"; then
status="running"
elif lxc-info -n "$check_value" 2>/dev/null | grep -q "State"; then
status="stopped"
else
status="not_installed"
fi
;;
file)
if [ -f "$check_value" ]; then
status="present"
else
status="missing"
fi
;;
esac
json_add_object ""
json_add_string "name" "$name"
json_add_string "category" "$category"
json_add_string "status" "$status"
json_add_string "description" "$description"
json_add_boolean "critical" "${critical:-0}"
[ -n "$details" ] && json_add_string "details" "$details"
json_close_object
}
# Core Infrastructure Services
json_add_array "core"
check_service "SSH" "remote" "port" "22" "Remote shell access" 1
check_service "HTTPS Admin" "remote" "port" "8444" "LuCI admin interface" 1
check_service "DNS" "network" "port" "53" "Domain name resolution" 1
check_service "DHCP" "network" "process" "dnsmasq" "IP address assignment" 1
check_service "Firewall" "security" "process" "fw4" "Network firewall" 1
json_close_array
# Security Services
json_add_array "security"
check_service "CrowdSec" "security" "process" "crowdsec" "Intrusion prevention" 1
check_service "CrowdSec Bouncer" "security" "process" "crowdsec-firewall-bouncer" "Firewall bouncer" 1
check_service "Tor" "privacy" "init" "tor" "Anonymous routing" 0
json_close_array
# Web Publishing Services
json_add_array "publishers"
check_service "HAProxy" "proxy" "lxc" "haproxy" "Load balancer & reverse proxy" 1
check_service "HexoJS" "cms" "lxc" "hexojs" "Static blog generator" 0
check_service "Gitea" "devops" "lxc" "gitea" "Git repository hosting" 0
check_service "Streamlit" "app" "lxc" "streamlit" "Python web apps" 0
json_close_array
# Media & App Services
json_add_array "apps"
check_service "Lyrion" "media" "lxc" "lyrion" "Music streaming server" 0
check_service "MagicMirror" "display" "lxc" "magicmirror2" "Smart mirror display" 0
check_service "PicoBrew" "app" "lxc" "picobrew" "Brewing automation" 0
json_close_array
# Monitoring Services
json_add_array "monitoring"
check_service "Netifyd" "monitoring" "process" "netifyd" "Network intelligence" 0
check_service "Syslog-ng" "logging" "process" "syslog-ng" "System logging" 1
json_close_array
# Calculate summary
json_add_object "summary"
total=0
running=0
stopped=0
critical_down=0
for svc in /etc/init.d/*; do
[ -x "$svc" ] || continue
total=$((total + 1))
done
# Count running LXC containers
lxc_running=$(lxc-ls --running 2>/dev/null | wc -w)
lxc_total=$(lxc-ls 2>/dev/null | wc -w)
json_add_int "init_services" "$total"
json_add_int "lxc_running" "$lxc_running"
json_add_int "lxc_total" "$lxc_total"
json_close_object
json_dump
;;
get_full_health_report)
# Combined health report: network + services + system
json_init
json_add_string "timestamp" "$(date -Iseconds)"
json_add_string "hostname" "$(uci get system.@system[0].hostname 2>/dev/null || hostname)"
# System info
json_add_object "system"
json_add_int "uptime" "$(cut -d. -f1 /proc/uptime)"
json_add_string "load" "$(cut -d' ' -f1-3 /proc/loadavg)"
mem_total=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
mem_avail=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
mem_avail=${mem_avail:-0}
mem_used=$((mem_total - mem_avail))
mem_pct=$((mem_used * 100 / mem_total))
json_add_int "memory_percent" "$mem_pct"
disk_pct=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
json_add_int "disk_percent" "${disk_pct:-0}"
json_close_object
# Network Health Summary
json_add_object "network"
net_overall="healthy"
net_issues=0
for iface_path in /sys/class/net/eth* /sys/class/net/wan*; do
[ -d "$iface_path" ] || continue
[ -d "$iface_path/device" ] || continue
iface=$(basename "$iface_path")
crc=$(dmesg | tail -n 500 | grep -c "$iface.*crc error" 2>/dev/null)
crc=${crc:-0}
flap=$(dmesg | tail -n 500 | grep -c "$iface: Link is" 2>/dev/null)
flap=${flap:-0}
if [ "$crc" -ge 10 ] || [ "$flap" -ge 10 ]; then
net_overall="critical"
net_issues=$((net_issues + 1))
json_add_object "$iface"
json_add_string "status" "critical"
json_add_int "crc_errors" "$crc"
json_add_int "link_changes" "$flap"
json_close_object
fi
done
json_add_string "overall" "$net_overall"
json_add_int "issues" "$net_issues"
json_close_object
# Critical Services Status
json_add_object "services"
svc_ok=0
svc_down=0
# Check critical services
for svc in sshd dropbear dnsmasq haproxy crowdsec; do
if pgrep -x "$svc" >/dev/null 2>&1 || pgrep -f "$svc" >/dev/null 2>&1; then
svc_ok=$((svc_ok + 1))
else
# Check if it's supposed to be running
if [ -f "/etc/init.d/$svc" ] && /etc/init.d/$svc enabled 2>/dev/null; then
svc_down=$((svc_down + 1))
fi
fi
done
# Check LXC containers
lxc_expected=$(lxc-ls 2>/dev/null | wc -w)
lxc_running=$(lxc-ls --running 2>/dev/null | wc -w)
json_add_int "services_ok" "$svc_ok"
json_add_int "services_down" "$svc_down"
json_add_int "containers_running" "$lxc_running"
json_add_int "containers_total" "$lxc_expected"
if [ "$svc_down" -gt 0 ]; then
json_add_string "overall" "warning"
else
json_add_string "overall" "healthy"
fi
json_close_object
# Overall health score
health_score=100
[ "$net_overall" = "critical" ] && health_score=$((health_score - 30))
[ "$svc_down" -gt 0 ] && health_score=$((health_score - (svc_down * 10)))
[ "$mem_pct" -gt 90 ] && health_score=$((health_score - 10))
[ "${disk_pct:-0}" -gt 90 ] && health_score=$((health_score - 10))
json_add_int "health_score" "$health_score"
if [ "$health_score" -ge 80 ]; then
json_add_string "overall_status" "healthy"
elif [ "$health_score" -ge 50 ]; then
json_add_string "overall_status" "warning"
else
json_add_string "overall_status" "critical"
fi
# Alerts
json_add_array "alerts"
[ "$net_overall" = "critical" ] && {
json_add_object ""
json_add_string "level" "critical"
json_add_string "message" "Network interface issues detected - check cables"
json_close_object
}
[ "$svc_down" -gt 0 ] && {
json_add_object ""
json_add_string "level" "warning"
json_add_string "message" "$svc_down critical service(s) not running"
json_close_object
}
[ "$mem_pct" -gt 90 ] && {
json_add_object ""
json_add_string "level" "warning"
json_add_string "message" "High memory usage: ${mem_pct}%"
json_close_object
}
json_close_array
json_dump
;;
get_dashboard_data)
# Return dashboard summary data
# Return dashboard summary data (OPTIMIZED - no slow appstore call)
json_init
# Get module stats
modules_output=$(/usr/sbin/secubox-appstore list --json 2>/dev/null || echo '{"modules":[]}')
total_modules=$(echo "$modules_output" | jsonfilter -e '@.modules[*]' | wc -l)
running_modules=$(echo "$modules_output" | jsonfilter -e '@.modules[@.state="running"]' | wc -l 2>/dev/null || echo 0)
# Fast module counting: count installed secubox packages
# This avoids the slow secubox-appstore list --json call
total_modules=0
running_modules=0
# Count from catalog (fast - just count JSON entries)
CATALOG_FILE="/usr/share/secubox/catalog.json"
if [ -f "$CATALOG_FILE" ]; then
total_modules=$(jsonfilter -i "$CATALOG_FILE" -e '@.plugins[*].id' 2>/dev/null | wc -l)
fi
[ -z "$total_modules" ] || [ "$total_modules" -eq 0 ] && total_modules=0
# Count running LXC containers (fast)
lxc_running=$(lxc-ls --running 2>/dev/null | wc -w)
lxc_running=${lxc_running:-0}
# Count running init services that are SecuBox-related (fast)
svc_running=0
for svc in crowdsec tor haproxy netifyd syslog-ng; do
if pgrep -f "$svc" >/dev/null 2>&1; then
svc_running=$((svc_running + 1))
fi
done
running_modules=$((lxc_running + svc_running))
# Get system info
uptime_seconds=$(cat /proc/uptime | cut -d' ' -f1 | cut -d'.' -f1)
load_avg=$(cat /proc/loadavg | cut -d' ' -f1-3)
uptime_seconds=$(cut -d' ' -f1 /proc/uptime | cut -d'.' -f1)
load_avg=$(cut -d' ' -f1-3 /proc/loadavg)
# Build response
json_add_object "status"
@ -353,6 +729,8 @@ case "$1" in
json_add_object "counts"
json_add_int "total" "$total_modules"
json_add_int "running" "$running_modules"
json_add_int "lxc_running" "$lxc_running"
json_add_int "services_running" "$svc_running"
json_close_object
json_dump
@ -1198,18 +1576,21 @@ case "$1" in
while read port local proc; do
addr=$(echo "$local" | sed 's/:[^:]*$//')
name="Service"; icon=""; category="other"; path=""
name=""; icon=""; category="other"; path=""
# First: identify by well-known port (most reliable for multi-service ports)
case "$port" in
22) name="SSH"; icon="lock"; category="system" ;;
53) name="DNS"; icon="globe"; category="system" ;;
80) name="HTTP"; icon="arrow"; path="/"; category="proxy" ;;
443) name="HTTPS"; icon="shield"; path="/"; category="proxy" ;;
2222) name="Gitea SSH"; icon="git"; category="app" ;;
3000) name="Gitea"; icon="git"; path=":3000"; category="app" ;;
3483) name="Squeezebox"; icon="music"; category="media" ;;
4000) name="HexoJS"; icon="blog"; path=":4000"; category="app" ;;
8080) name="CrowdSec"; icon="security"; category="security" ;;
6060) name="CrowdSec LAPI"; icon="security"; category="security" ;;
8081) name="LuCI"; icon="settings"; path=":8081"; category="system" ;;
8082) name="CyberFeed"; icon="feed"; path=":8082"; category="app" ;;
8085) name="MagicMirror2"; icon="app"; path=":8085"; category="app" ;;
8086) name="Netifyd"; icon="chart"; path=":8086"; category="monitoring" ;;
8404) name="HAProxy Stats"; icon="stats"; path=":8404/stats"; category="monitoring" ;;
8444) name="LuCI HTTPS"; icon="admin"; path=":8444"; category="system" ;;
@ -1217,10 +1598,31 @@ case "$1" in
9000) name="Lyrion"; icon="music"; path=":9000"; category="media" ;;
9050) name="Tor SOCKS"; icon="onion"; category="privacy" ;;
9090) name="Lyrion CLI"; icon="music"; category="media" ;;
2222) name="Gitea SSH"; icon="git"; category="app" ;;
3483) name="Squeezebox"; icon="music"; category="media" ;;
esac
# Fallback: identify by process name if port didn't match
if [ -z "$name" ]; then
case "$proc" in
sshd|dropbear) name="SSH"; icon="lock"; category="system" ;;
dnsmasq|named|unbound) name="DNS"; icon="globe"; category="system" ;;
haproxy) name="HAProxy"; icon="arrow"; category="proxy" ;;
nginx|uhttpd) name="Web Server"; icon="settings"; category="system" ;;
gitea) name="Gitea"; icon="git"; path=":$port"; category="app" ;;
hexo|node) name="HexoJS"; icon="blog"; path=":$port"; category="app" ;;
crowdsec|lapi) name="CrowdSec"; icon="security"; category="security" ;;
netifyd) name="Netifyd"; icon="chart"; path=":$port"; category="monitoring" ;;
slimserver|squeezeboxserver) name="Lyrion"; icon="music"; path=":$port"; category="media" ;;
tor) name="Tor"; icon="onion"; category="privacy" ;;
cyberfeed*) name="CyberFeed"; icon="feed"; path=":$port"; category="app" ;;
metabolizer*) name="Metabolizer"; icon="blog"; path=":$port"; category="app" ;;
magicmirror*|electron) name="MagicMirror"; icon="app"; path=":$port"; category="app" ;;
picobrew*) name="PicoBrew"; icon="app"; path=":$port"; category="app" ;;
streamlit) name="Streamlit"; icon="app"; path=":$port"; category="app" ;;
python*) name="Python App"; icon="app"; path=":$port"; category="app" ;;
*) name="$proc"; icon=""; category="other"; path=":$port" ;;
esac
fi
external=0
case "$addr" in 0.0.0.0|::) external=1 ;; 127.0.0.1|::1) ;; *) external=1 ;; esac