diff --git a/package/secubox/luci-app-exposure/Makefile b/package/secubox/luci-app-exposure/Makefile new file mode 100644 index 00000000..837d8ee2 --- /dev/null +++ b/package/secubox/luci-app-exposure/Makefile @@ -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 +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)) diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js new file mode 100644 index 00000000..a00f7471 --- /dev/null +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js @@ -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); } +}); diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css new file mode 100644 index 00000000..e43c1dec --- /dev/null +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css @@ -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); } +} diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/overview.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/overview.js new file mode 100644 index 00000000..72ea627b --- /dev/null +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/overview.js @@ -0,0 +1,1091 @@ +'use strict'; +'require view'; +'require dom'; +'require ui'; +'require poll'; +'require exposure/api as api'; + +/** + * SecuBox Service Exposure Manager - Overview Dashboard + * Manages Tor Hidden Services and HAProxy SSL backends + * Progressive loading with debug console output + * Copyright (C) 2025 CyberMind.fr + */ + +return view.extend({ + title: _('Service Exposure Manager'), + + data: null, + pollRegistered: false, + loadStartTime: null, + DEBUG: true, + + // === Debug Logging with timestamps === + log: function(step, message, data) { + if (!this.DEBUG) return; + var elapsed = this.loadStartTime ? (Date.now() - this.loadStartTime) + 'ms' : '0ms'; + var prefix = '%c[Exposure ' + elapsed + '] %c' + step + '%c'; + if (data !== undefined) { + console.log(prefix, 'color: #64ffda; font-weight: bold;', 'color: #9b59b6; font-weight: bold;', 'color: #8892b0;', message, data); + } else { + console.log(prefix, 'color: #64ffda; font-weight: bold;', 'color: #9b59b6; font-weight: bold;', 'color: #8892b0;', message); + } + }, + + load: function() { + var self = this; + this.loadStartTime = Date.now(); + this.log('INIT', 'Starting dashboard load'); + + // Load CSS first + 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); + this.log('CSS', 'Stylesheet injected'); + } + + // Progressive data loading with individual error handling + return this.loadDataProgressively(); + }, + + loadDataProgressively: function() { + var self = this; + + // Initialize result container + var result = { + status: { services: {}, tor: {}, ssl: {} }, + scan: { services: [] }, + conflicts: { conflicts: [] }, + tor: { services: [] }, + ssl: { backends: [] } + }; + + // Create promises with individual timing and error handling + // Priority: status > ssl > tor > scan > conflicts + + self.log('FETCH', '1/5 Status (priority: critical)'); + var p1 = api.status().then(function(data) { + result.status = data || result.status; + self.log('STATUS', 'Complete', { + total: (result.status.services || {}).total || 0, + external: (result.status.services || {}).external || 0, + tor: (result.status.tor || {}).count || 0, + ssl: (result.status.ssl || {}).count || 0 + }); + return data; + }).catch(function(err) { + self.log('ERROR', 'Status fetch failed: ' + err.message); + return result.status; + }); + + self.log('FETCH', '2/5 SSL Backends (priority: high)'); + var p2 = api.sslList().then(function(data) { + result.ssl = data || result.ssl; + var backends = result.ssl.backends || []; + self.log('SSL', 'Complete', { + count: backends.length, + domains: backends.map(function(b) { return b.domain; }) + }); + return data; + }).catch(function(err) { + self.log('ERROR', 'SSL list failed: ' + err.message); + return result.ssl; + }); + + self.log('FETCH', '3/5 Tor Services (priority: high)'); + var p3 = api.torList().then(function(data) { + result.tor = data || result.tor; + var services = result.tor.services || []; + self.log('TOR', 'Complete', { + count: services.length, + onions: services.map(function(s) { return s.service + ': ' + (s.onion || '').substring(0, 16) + '...'; }) + }); + return data; + }).catch(function(err) { + self.log('ERROR', 'Tor list failed: ' + err.message); + return result.tor; + }); + + self.log('FETCH', '4/5 Service Scan (priority: medium)'); + var p4 = api.scan().then(function(data) { + result.scan = data || result.scan; + var services = Array.isArray(data) ? data : (data.services || []); + var external = services.filter(function(s) { return s.external; }); + self.log('SCAN', 'Complete', { + total: services.length, + external: external.length, + processes: external.slice(0, 5).map(function(s) { return s.name || s.process; }) + }); + return data; + }).catch(function(err) { + self.log('ERROR', 'Scan failed: ' + err.message); + return result.scan; + }); + + self.log('FETCH', '5/5 Conflicts (priority: low)'); + var p5 = api.conflicts().then(function(data) { + result.conflicts = data || result.conflicts; + var conflicts = Array.isArray(data) ? data : (data.conflicts || []); + self.log('CONFLICTS', 'Complete', { count: conflicts.length }); + return data; + }).catch(function(err) { + self.log('ERROR', 'Conflicts check failed: ' + err.message); + return result.conflicts; + }); + + // Wait for all with graceful degradation + return Promise.all([p1, p2, p3, p4, p5]).then(function() { + var totalTime = Date.now() - self.loadStartTime; + self.log('LOADED', 'All data fetched in ' + totalTime + 'ms'); + return [result.status, result.scan, result.conflicts, result.tor, result.ssl]; + }); + }, + + render: function(data) { + var self = this; + var renderStart = Date.now(); + this.log('RENDER', 'Starting DOM construction'); + + var status = data[0] || {}; + var scanResult = data[1] || {}; + var conflictsResult = data[2] || {}; + var torResult = data[3] || {}; + var sslResult = data[4] || {}; + + // Normalize data + this.log('RENDER', 'Normalizing data structures'); + var services = Array.isArray(scanResult) ? scanResult : (scanResult.services || []); + var conflicts = Array.isArray(conflictsResult) ? conflictsResult : (conflictsResult.conflicts || []); + var torServices = Array.isArray(torResult) ? torResult : (torResult.services || []); + var sslBackends = Array.isArray(sslResult) ? sslResult : (sslResult.backends || []); + + this.log('DATA', 'Final counts', { + services: services.length, + conflicts: conflicts.length, + tor: torServices.length, + ssl: sslBackends.length + }); + + var exposableServices = services.filter(function(svc) { return svc.external; }); + + // Build exposure lookup maps + var exposedTor = {}; + var exposedSsl = {}; + torServices.forEach(function(t) { exposedTor[t.service] = t; }); + sslBackends.forEach(function(s) { exposedSsl[s.service] = s; }); + + // Find unexposed services (suggestions) + var suggestions = exposableServices.filter(function(svc) { + var name = self.getServiceName(svc); + return !exposedTor[name] && !exposedSsl[name]; + }); + + this.log('SUGGESTIONS', 'Unexposed services found', { + count: suggestions.length, + names: suggestions.slice(0, 5).map(function(s) { return s.name || s.process; }) + }); + + // Build view content progressively with timing + var content = []; + + this.log('DOM', '1/6 Page header'); + content.push(this.renderPageHeader(status)); + + if (conflicts.length > 0) { + this.log('DOM', '2/6 Conflicts banner (active)'); + content.push(this.renderConflictsBanner(conflicts)); + } else { + this.log('DOM', '2/6 Conflicts banner (skipped - none)'); + } + + this.log('DOM', '3/6 Stats grid'); + content.push(this.renderStatsGrid(status, torServices, sslBackends, exposableServices)); + + if (suggestions.length > 0) { + this.log('DOM', '4/6 Suggestions card (active)'); + content.push(this.renderSuggestionsCard(suggestions)); + } else { + this.log('DOM', '4/6 Suggestions card (skipped - none)'); + } + + this.log('DOM', '5/6 Service cards row'); + content.push(E('div', { 'class': 'exp-row' }, [ + E('div', { 'style': 'flex: 1' }, [ + this.renderTorServicesCard(torServices) + ]), + E('div', { 'style': 'flex: 1' }, [ + this.renderSslBackendsCard(sslBackends) + ]) + ])); + + this.log('DOM', '6/6 Quick actions'); + content.push(this.renderQuickActions()); + + // Filter nulls + content = content.filter(Boolean); + + // Main wrapper with animation + var view = E('div', { 'class': 'exposure-dashboard exp-fade-in' }, content); + + // Setup polling + if (!this.pollRegistered) { + this.pollRegistered = true; + this.log('POLL', 'Auto-refresh registered (30s interval)'); + poll.add(function() { + self.log('POLL', 'Refreshing dashboard...'); + return self.refreshDashboard(); + }, 30); + } + + var renderTime = Date.now() - renderStart; + var totalTime = Date.now() - this.loadStartTime; + this.log('COMPLETE', 'Dashboard ready', { + renderTime: renderTime + 'ms', + totalTime: totalTime + 'ms' + }); + + return view; + }, + + getServiceName: function(svc) { + var name = svc.name ? svc.name.toLowerCase().replace(/\s+/g, '') : svc.process; + return name.replace(/[^a-z0-9]/g, ''); + }, + + renderPageHeader: function(status) { + var servicesData = status.services || {}; + var torData = status.tor || {}; + var sslData = status.ssl || {}; + + return E('div', { 'class': 'exp-page-header' }, [ + E('div', {}, [ + E('h1', { 'class': 'exp-page-title' }, [ + E('span', { 'class': 'exp-page-title-icon' }, '\uD83D\uDD0C'), + 'Service Exposure Manager' + ]), + E('p', { 'class': 'exp-page-subtitle' }, + 'Expose local services via Tor Hidden Services (.onion) or HAProxy SSL reverse proxy') + ]), + E('div', { 'class': 'exp-header-badges' }, [ + E('div', { 'class': 'exp-header-badge' }, [ + E('span', { 'style': 'color: #64ffda;' }, String(servicesData.external || 0)), + ' Exposable' + ]), + E('div', { 'class': 'exp-header-badge' }, [ + E('span', { 'style': 'color: #9b59b6;' }, String(torData.count || 0)), + ' Tor' + ]), + E('div', { 'class': 'exp-header-badge' }, [ + E('span', { 'style': 'color: #27ae60;' }, String(sslData.count || 0)), + ' SSL' + ]) + ]) + ]); + }, + + renderConflictsBanner: function(conflicts) { + return E('div', { 'class': 'exp-card exp-warning-card' }, [ + E('div', { 'class': 'exp-card-body', 'style': 'display: flex; align-items: center; gap: 16px;' }, [ + E('span', { 'style': 'font-size: 32px;' }, '\u26A0\uFE0F'), + E('div', { 'style': 'flex: 1;' }, [ + E('div', { 'style': 'font-weight: 600; font-size: 16px; margin-bottom: 4px; color: #f39c12;' }, + conflicts.length + ' Port Conflict(s) Detected'), + E('div', { 'style': 'color: #8892b0;' }, + conflicts.map(function(c) { + return 'Port ' + c.port + ': ' + (c.services || []).join(', '); + }).join(' | ')) + ]) + ]) + ]); + }, + + renderStatsGrid: function(status, torServices, sslBackends, exposableServices) { + var servicesData = status.services || {}; + + return E('div', { 'class': 'exp-stats-grid' }, [ + E('div', { 'class': 'exp-stat-card' }, [ + E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDD0C'), + E('div', { 'class': 'exp-stat-value' }, String(servicesData.total || 0)), + E('div', { 'class': 'exp-stat-label' }, 'Total Services'), + E('div', { 'class': 'exp-stat-trend' }, (servicesData.external || 0) + ' external') + ]), + E('div', { 'class': 'exp-stat-card exp-stat-tor' }, [ + E('div', { 'class': 'exp-stat-icon' }, '\uD83E\uDDC5'), + E('div', { 'class': 'exp-stat-value' }, String(torServices.length)), + E('div', { 'class': 'exp-stat-label' }, 'Tor Hidden Services'), + E('div', { 'class': 'exp-stat-trend' }, torServices.length > 0 ? 'Active' : 'None configured') + ]), + E('div', { 'class': 'exp-stat-card exp-stat-ssl' }, [ + E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDD12'), + E('div', { 'class': 'exp-stat-value' }, String(sslBackends.length)), + E('div', { 'class': 'exp-stat-label' }, 'SSL Backends'), + E('div', { 'class': 'exp-stat-trend' }, sslBackends.length > 0 ? 'HAProxy' : 'None configured') + ]), + E('div', { 'class': 'exp-stat-card' }, [ + E('div', { 'class': 'exp-stat-icon' }, '\uD83D\uDCA1'), + E('div', { 'class': 'exp-stat-value' }, String(exposableServices.length)), + E('div', { 'class': 'exp-stat-label' }, 'Exposable'), + E('div', { 'class': 'exp-stat-trend' }, 'Ready for exposure') + ]) + ]); + }, + + renderSuggestionsCard: function(suggestions) { + var self = this; + + // Prioritize interesting services + var prioritized = suggestions.sort(function(a, b) { + var priority = { + 'streamlit': 1, 'Streamlit': 1, + 'gitea': 2, 'Gitea': 2, + 'hexo': 3, 'HexoJS': 3, + 'nextcloud': 4, + 'jupyter': 5, + 'flask': 6, + 'django': 7 + }; + var pA = priority[a.name] || priority[a.process] || 100; + var pB = priority[b.name] || priority[b.process] || 100; + return pA - pB; + }).slice(0, 6); + + return E('div', { 'class': 'exp-card exp-suggestions-card' }, [ + E('div', { 'class': 'exp-card-header' }, [ + E('div', { 'class': 'exp-card-title' }, [ + E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDCA1'), + 'Suggested Services to Expose' + ]), + E('a', { 'href': L.url('admin/secubox/network/exposure/services'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' }, + 'View All') + ]), + E('div', { 'class': 'exp-card-body' }, [ + E('div', { 'class': 'exp-suggestions-grid' }, prioritized.map(function(svc) { + var serviceName = self.getServiceName(svc); + var icon = self.getServiceIcon(svc); + + return E('div', { 'class': 'exp-suggestion-item' }, [ + E('div', { 'class': 'exp-suggestion-icon' }, icon), + E('div', { 'class': 'exp-suggestion-info' }, [ + E('div', { 'class': 'exp-suggestion-name' }, svc.name || svc.process), + E('div', { 'class': 'exp-suggestion-port' }, 'Port ' + svc.port) + ]), + E('div', { 'class': 'exp-suggestion-actions' }, [ + E('button', { + 'class': 'exp-btn exp-btn-tor exp-btn-xs', + 'title': 'Add Tor Hidden Service', + 'click': function() { self.handleAddTor(svc, serviceName); } + }, '\uD83E\uDDC5'), + E('button', { + 'class': 'exp-btn exp-btn-ssl exp-btn-xs', + 'title': 'Add SSL Backend', + 'click': function() { self.handleAddSsl(svc, serviceName); } + }, '\uD83D\uDD12') + ]) + ]); + })) + ]) + ]); + }, + + getServiceIcon: function(svc) { + var iconMap = { + 'streamlit': '\uD83D\uDCCA', + 'Streamlit': '\uD83D\uDCCA', + 'gitea': '\uD83D\uDC19', + 'Gitea': '\uD83D\uDC19', + 'hexo': '\uD83D\uDCDD', + 'HexoJS': '\uD83D\uDCDD', + 'jupyter': '\uD83D\uDCD3', + 'flask': '\uD83C\uDF76', + 'django': '\uD83E\uDD8E', + 'nextcloud': '\u2601\uFE0F', + 'SSH': '\uD83D\uDD11', + 'HAProxy': '\u2696\uFE0F', + 'DNS': '\uD83C\uDF10', + 'LuCI': '\uD83D\uDDA5\uFE0F', + 'python': '\uD83D\uDC0D' + }; + return iconMap[svc.name] || iconMap[svc.process] || '\uD83D\uDD0C'; + }, + + renderTorServicesCard: function(torServices) { + var self = this; + + if (torServices.length === 0) { + return E('div', { 'class': 'exp-card' }, [ + E('div', { 'class': 'exp-card-header' }, [ + E('div', { 'class': 'exp-card-title' }, [ + E('span', { 'class': 'exp-card-title-icon' }, '\uD83E\uDDC5'), + 'Tor Hidden Services' + ]), + E('a', { 'href': L.url('admin/secubox/network/exposure/tor'), 'class': 'exp-btn exp-btn-tor exp-btn-sm' }, + '+ Add') + ]), + E('div', { 'class': 'exp-card-body' }, [ + E('div', { 'class': 'exp-empty' }, [ + E('div', { 'class': 'exp-empty-icon' }, '\uD83E\uDDC5'), + E('div', { 'class': 'exp-empty-text' }, 'No Tor hidden services'), + E('div', { 'class': 'exp-empty-hint' }, 'Services are accessible via .onion addresses') + ]) + ]) + ]); + } + + var items = torServices.slice(0, 5).map(function(svc) { + var onion = svc.onion || ''; + var shortOnion = onion.length > 30 ? onion.substring(0, 28) + '...' : onion; + + return E('div', { 'class': 'exp-service-item' }, [ + E('div', { 'class': 'exp-service-icon' }, '\uD83E\uDDC5'), + E('div', { 'class': 'exp-service-info' }, [ + E('div', { 'class': 'exp-service-name' }, svc.service), + E('div', { 'class': 'exp-service-detail exp-onion' }, shortOnion) + ]), + E('button', { + 'class': 'exp-btn exp-btn-danger exp-btn-xs', + 'title': 'Remove', + 'click': function() { self.handleRemoveTor(svc.service); } + }, '\u2715') + ]); + }); + + var cardBody = [E('div', { 'class': 'exp-services-list' }, items)]; + + if (torServices.length > 5) { + cardBody.push(E('div', { 'style': 'text-align: center; margin-top: 12px;' }, + E('a', { 'href': L.url('admin/secubox/network/exposure/tor') }, + '+' + (torServices.length - 5) + ' more'))); + } + + return E('div', { 'class': 'exp-card' }, [ + E('div', { 'class': 'exp-card-header' }, [ + E('div', { 'class': 'exp-card-title' }, [ + E('span', { 'class': 'exp-card-title-icon' }, '\uD83E\uDDC5'), + 'Tor Hidden Services (' + torServices.length + ')' + ]), + E('a', { 'href': L.url('admin/secubox/network/exposure/tor'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' }, + 'Manage') + ]), + E('div', { 'class': 'exp-card-body' }, cardBody) + ]); + }, + + renderSslBackendsCard: function(sslBackends) { + var self = this; + + if (sslBackends.length === 0) { + return E('div', { 'class': 'exp-card' }, [ + E('div', { 'class': 'exp-card-header' }, [ + E('div', { 'class': 'exp-card-title' }, [ + E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDD12'), + 'SSL Backends' + ]), + E('a', { 'href': L.url('admin/secubox/network/exposure/ssl'), 'class': 'exp-btn exp-btn-ssl exp-btn-sm' }, + '+ Add') + ]), + E('div', { 'class': 'exp-card-body' }, [ + E('div', { 'class': 'exp-empty' }, [ + E('div', { 'class': 'exp-empty-icon' }, '\uD83D\uDD12'), + E('div', { 'class': 'exp-empty-text' }, 'No SSL backends'), + E('div', { 'class': 'exp-empty-hint' }, 'Add HTTPS reverse proxy via HAProxy') + ]) + ]) + ]); + } + + var items = sslBackends.slice(0, 5).map(function(backend) { + return E('div', { 'class': 'exp-service-item' }, [ + E('div', { 'class': 'exp-service-icon' }, '\uD83D\uDD12'), + E('div', { 'class': 'exp-service-info' }, [ + E('div', { 'class': 'exp-service-name' }, backend.service), + E('div', { 'class': 'exp-service-detail exp-domain' }, backend.domain) + ]), + E('button', { + 'class': 'exp-btn exp-btn-danger exp-btn-xs', + 'title': 'Remove', + 'click': function() { self.handleRemoveSsl(backend.service); } + }, '\u2715') + ]); + }); + + var cardBody = [E('div', { 'class': 'exp-services-list' }, items)]; + + if (sslBackends.length > 5) { + cardBody.push(E('div', { 'style': 'text-align: center; margin-top: 12px;' }, + E('a', { 'href': L.url('admin/secubox/network/exposure/ssl') }, + '+' + (sslBackends.length - 5) + ' more'))); + } + + return E('div', { 'class': 'exp-card' }, [ + E('div', { 'class': 'exp-card-header' }, [ + E('div', { 'class': 'exp-card-title' }, [ + E('span', { 'class': 'exp-card-title-icon' }, '\uD83D\uDD12'), + 'SSL Backends (' + sslBackends.length + ')' + ]), + E('a', { 'href': L.url('admin/secubox/network/exposure/ssl'), 'class': 'exp-btn exp-btn-secondary exp-btn-sm' }, + 'Manage') + ]), + E('div', { 'class': 'exp-card-body' }, cardBody) + ]); + }, + + renderQuickActions: function() { + var self = this; + + return E('div', { 'class': 'exp-card' }, [ + E('div', { 'class': 'exp-card-header' }, [ + E('div', { 'class': 'exp-card-title' }, [ + E('span', { 'class': 'exp-card-title-icon' }, '\u26A1'), + 'Quick Actions' + ]) + ]), + E('div', { 'class': 'exp-card-body' }, [ + E('div', { 'class': 'exp-quick-actions' }, [ + E('a', { + 'href': L.url('admin/secubox/network/exposure/services'), + 'class': 'exp-action-btn' + }, [ + E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD0C'), + E('span', { 'class': 'exp-action-label' }, 'All Services') + ]), + E('a', { + 'href': L.url('admin/secubox/network/exposure/tor'), + 'class': 'exp-action-btn' + }, [ + E('span', { 'class': 'exp-action-icon' }, '\uD83E\uDDC5'), + E('span', { 'class': 'exp-action-label' }, 'Tor Services') + ]), + E('a', { + 'href': L.url('admin/secubox/network/exposure/ssl'), + 'class': 'exp-action-btn' + }, [ + E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD12'), + E('span', { 'class': 'exp-action-label' }, 'SSL Backends') + ]), + E('button', { + 'class': 'exp-action-btn', + 'click': function() { self.refreshDashboard(); } + }, [ + E('span', { 'class': 'exp-action-icon' }, '\uD83D\uDD04'), + E('span', { 'class': 'exp-action-label' }, 'Refresh') + ]) + ]) + ]) + ]); + }, + + // === Progress Modal Helpers === + + createProgressModal: function(title, steps) { + var stepsContainer = E('div', { 'class': 'exp-progress-steps', 'id': 'progress-steps' }); + + steps.forEach(function(step, index) { + stepsContainer.appendChild(E('div', { + 'class': 'exp-progress-step', + 'id': 'step-' + index, + 'data-status': 'pending' + }, [ + E('div', { 'class': 'exp-step-indicator' }, [ + E('span', { 'class': 'exp-step-number' }, String(index + 1)), + E('span', { 'class': 'exp-step-icon' }) + ]), + E('div', { 'class': 'exp-step-content' }, [ + E('div', { 'class': 'exp-step-label' }, step.label), + E('div', { 'class': 'exp-step-detail', 'id': 'step-detail-' + index }, step.detail || '') + ]) + ])); + }); + + return E('div', { 'class': 'exp-progress-modal' }, [ + E('div', { 'class': 'exp-progress-header' }, [ + E('div', { 'class': 'exp-progress-title' }, title), + E('div', { 'class': 'exp-progress-subtitle', 'id': 'progress-subtitle' }, 'Initializing...') + ]), + stepsContainer, + E('div', { 'class': 'exp-progress-result', 'id': 'progress-result', 'style': 'display: none;' }) + ]); + }, + + updateStep: function(index, status, detail) { + var stepEl = document.getElementById('step-' + index); + var detailEl = document.getElementById('step-detail-' + index); + var subtitleEl = document.getElementById('progress-subtitle'); + + if (stepEl) { + stepEl.setAttribute('data-status', status); + if (detail && detailEl) { + detailEl.textContent = detail; + } + } + + if (subtitleEl && status === 'active') { + var labelEl = stepEl ? stepEl.querySelector('.exp-step-label') : null; + if (labelEl) { + subtitleEl.textContent = labelEl.textContent + '...'; + } + } + }, + + showProgressResult: function(success, message, details) { + var resultEl = document.getElementById('progress-result'); + var subtitleEl = document.getElementById('progress-subtitle'); + + if (resultEl) { + resultEl.style.display = 'block'; + resultEl.className = 'exp-progress-result ' + (success ? 'success' : 'error'); + resultEl.innerHTML = ''; + resultEl.appendChild(E('div', { 'class': 'exp-result-icon' }, success ? '\u2705' : '\u274C')); + resultEl.appendChild(E('div', { 'class': 'exp-result-message' }, message)); + if (details) { + resultEl.appendChild(E('div', { 'class': 'exp-result-details' }, details)); + } + } + + if (subtitleEl) { + subtitleEl.textContent = success ? 'Completed successfully' : 'Operation failed'; + } + }, + + // === Action Handlers === + + handleAddTor: function(svc, serviceName) { + var self = this; + + ui.showModal('Add 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.75rem;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Service Name'), + E('input', { + 'type': 'text', + 'id': 'tor-svc-name', + 'value': serviceName, + 'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;' + }) + ]), + E('div', { 'style': 'margin-bottom: 0.75rem;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Local Port'), + E('input', { + 'type': 'number', + 'id': 'tor-local-port', + 'value': svc.port, + 'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;' + }) + ]), + E('div', {}, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Onion Port (public)'), + E('input', { + 'type': 'number', + 'id': 'tor-onion-port', + 'value': '80', + 'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;' + }) + ]) + ]), + E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-action', + 'click': 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); + + if (!name) { + ui.addNotification(null, E('p', {}, 'Service name is required'), 'warning'); + return; + } + + ui.hideModal(); + + // Show progress modal + var progressContent = self.createProgressModal('Creating Tor Hidden Service', [ + { label: 'Validating configuration', detail: 'Checking service name and ports' }, + { label: 'Creating hidden service directory', detail: '/var/lib/tor/hidden_services/' + name }, + { label: 'Updating Tor configuration', detail: 'Adding HiddenServiceDir and HiddenServicePort' }, + { label: 'Restarting Tor daemon', detail: 'Applying configuration changes' }, + { label: 'Generating .onion address', detail: 'This may take up to 30 seconds' }, + { label: 'Finalizing', detail: 'Saving to UCI and syncing with Tor Shield' } + ]); + + ui.showModal('Creating Tor Hidden Service', [ + progressContent, + E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + ui.hideModal(); + self.refreshDashboard(); + } + }, 'Close') + ]) + ]); + + // Simulate step progression (actual API call happens in background) + self.updateStep(0, 'active', 'Validating ' + name + ':' + localPort + ' -> :' + onionPort); + + setTimeout(function() { + self.updateStep(0, 'complete', 'Configuration valid'); + self.updateStep(1, 'active'); + }, 500); + + setTimeout(function() { + self.updateStep(1, 'complete', 'Directory created'); + self.updateStep(2, 'active'); + }, 1000); + + setTimeout(function() { + self.updateStep(2, 'complete', 'torrc updated'); + self.updateStep(3, 'active'); + }, 1500); + + // Make actual API call + api.torAdd(name, localPort, onionPort).then(function(res) { + if (res.success) { + self.updateStep(3, 'complete', 'Tor restarted'); + self.updateStep(4, 'complete', 'Address generated'); + self.updateStep(5, 'complete', 'UCI and Tor Shield synced'); + + self.showProgressResult(true, + 'Hidden service created successfully!', + res.onion ? E('code', { 'style': 'color: #9b59b6; font-size: 12px; word-break: break-all;' }, res.onion) : null + ); + } else { + // Mark current step as error + for (var i = 3; i <= 5; i++) { + var stepEl = document.getElementById('step-' + i); + if (stepEl && stepEl.getAttribute('data-status') === 'active') { + self.updateStep(i, 'error', res.error || 'Failed'); + break; + } + } + self.showProgressResult(false, 'Failed to create hidden service', res.error || 'Unknown error'); + } + + document.getElementById('progress-actions').style.display = 'block'; + }).catch(function(err) { + self.updateStep(3, 'error', 'Connection failed'); + self.showProgressResult(false, 'API Error', err.message || 'Unknown error'); + document.getElementById('progress-actions').style.display = 'block'; + }); + } + }, 'Create Hidden Service') + ]) + ]); + }, + + handleAddSsl: function(svc, serviceName) { + var self = this; + + ui.showModal('Add SSL Backend', [ + E('p', {}, 'Configure HTTPS reverse proxy for ' + (svc.name || svc.process)), + E('div', { 'style': 'margin: 1rem 0;' }, [ + E('div', { 'style': 'margin-bottom: 0.75rem;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Service Name'), + E('input', { + 'type': 'text', + 'id': 'ssl-svc-name', + 'value': serviceName, + 'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;' + }) + ]), + E('div', { 'style': 'margin-bottom: 0.75rem;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Domain (FQDN)'), + E('input', { + 'type': 'text', + 'id': 'ssl-domain', + 'placeholder': serviceName + '.example.com', + 'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;' + }) + ]), + E('div', {}, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc; font-size: 0.9rem;' }, 'Backend Port'), + E('input', { + 'type': 'number', + 'id': 'ssl-port', + 'value': svc.port, + 'style': 'width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 6px;' + }) + ]) + ]), + E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-action', + 'click': 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(); + + // Show progress modal + var progressContent = self.createProgressModal('Configuring SSL Backend', [ + { label: 'Validating configuration', detail: 'Checking domain and backend port' }, + { label: 'Creating HAProxy backend', detail: 'Adding server 127.0.0.1:' + port }, + { label: 'Creating virtual host', detail: 'Domain: ' + domain }, + { label: 'Committing UCI changes', detail: 'Saving to /etc/config/haproxy' }, + { label: 'Regenerating HAProxy config', detail: 'Running haproxyctl generate' }, + { label: 'Reloading HAProxy', detail: 'Applying changes without downtime' } + ]); + + ui.showModal('Configuring SSL Backend', [ + progressContent, + E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + ui.hideModal(); + self.refreshDashboard(); + } + }, 'Close') + ]) + ]); + + // Simulate step progression + self.updateStep(0, 'active', 'Validating ' + name + ' -> ' + domain); + + setTimeout(function() { + self.updateStep(0, 'complete', 'Configuration valid'); + self.updateStep(1, 'active'); + }, 400); + + setTimeout(function() { + self.updateStep(1, 'complete', 'Backend ' + name + ' created'); + self.updateStep(2, 'active'); + }, 800); + + setTimeout(function() { + self.updateStep(2, 'complete', 'VHost for ' + domain + ' created'); + self.updateStep(3, 'active'); + }, 1200); + + // Make actual API call + api.sslAdd(name, domain, port).then(function(res) { + if (res.success) { + self.updateStep(3, 'complete', 'UCI committed'); + self.updateStep(4, 'complete', 'Config regenerated'); + self.updateStep(5, 'complete', 'HAProxy reloaded'); + + self.showProgressResult(true, + 'SSL backend configured successfully!', + E('div', {}, [ + E('div', { 'style': 'margin-bottom: 8px;' }, [ + 'Access via: ', + E('a', { + 'href': 'https://' + domain, + 'target': '_blank', + 'style': 'color: #27ae60;' + }, 'https://' + domain) + ]), + E('div', { 'style': 'font-size: 12px; color: #888;' }, + 'Note: Ensure SSL certificate is configured for ' + domain) + ]) + ); + } else { + for (var i = 3; i <= 5; i++) { + var stepEl = document.getElementById('step-' + i); + if (stepEl && stepEl.getAttribute('data-status') !== 'complete') { + self.updateStep(i, 'error', res.error || 'Failed'); + break; + } + } + self.showProgressResult(false, 'Failed to configure SSL backend', res.error || 'Unknown error'); + } + + document.getElementById('progress-actions').style.display = 'block'; + }).catch(function(err) { + self.updateStep(3, 'error', 'Connection failed'); + self.showProgressResult(false, 'API Error', err.message || 'Unknown error'); + document.getElementById('progress-actions').style.display = 'block'; + }); + } + }, 'Create SSL Backend') + ]) + ]); + }, + + handleRemoveTor: function(serviceName) { + var self = this; + + ui.showModal('Remove 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': ui.hideModal }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-negative', + 'click': function() { + ui.hideModal(); + + // Show progress modal + var progressContent = self.createProgressModal('Removing Tor Hidden Service', [ + { label: 'Removing from torrc', detail: 'Deleting HiddenServiceDir entry' }, + { label: 'Deleting hidden service directory', detail: '/var/lib/tor/hidden_services/' + serviceName }, + { label: 'Restarting Tor daemon', detail: 'Applying configuration changes' }, + { label: 'Updating UCI', detail: 'Removing from secubox-exposure and tor-shield' } + ]); + + ui.showModal('Removing Tor Hidden Service', [ + progressContent, + E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + ui.hideModal(); + self.refreshDashboard(); + } + }, 'Close') + ]) + ]); + + self.updateStep(0, 'active'); + + api.torRemove(serviceName).then(function(res) { + if (res.success) { + self.updateStep(0, 'complete'); + self.updateStep(1, 'complete'); + self.updateStep(2, 'complete'); + self.updateStep(3, 'complete'); + self.showProgressResult(true, 'Hidden service removed successfully'); + } else { + self.updateStep(0, 'error', res.error); + self.showProgressResult(false, 'Failed to remove hidden service', res.error || 'Unknown error'); + } + document.getElementById('progress-actions').style.display = 'block'; + }).catch(function(err) { + self.updateStep(0, 'error', 'Connection failed'); + self.showProgressResult(false, 'API Error', err.message); + document.getElementById('progress-actions').style.display = 'block'; + }); + } + }, 'Remove') + ]) + ]); + }, + + handleRemoveSsl: function(serviceName) { + var self = this; + + ui.showModal('Remove SSL Backend', [ + E('p', {}, 'Remove HAProxy backend for ' + serviceName + '?'), + E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-negative', + 'click': function() { + ui.hideModal(); + + // Show progress modal + var progressContent = self.createProgressModal('Removing SSL Backend', [ + { label: 'Removing virtual host', detail: 'Deleting vhost configuration' }, + { label: 'Removing backend', detail: 'Deleting backend ' + serviceName }, + { label: 'Committing UCI changes', detail: 'Saving to /etc/config/haproxy' }, + { label: 'Regenerating HAProxy config', detail: 'Running haproxyctl generate' }, + { label: 'Reloading HAProxy', detail: 'Applying changes' } + ]); + + ui.showModal('Removing SSL Backend', [ + progressContent, + E('div', { 'id': 'progress-actions', 'style': 'display: none; margin-top: 1rem; text-align: right;' }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + ui.hideModal(); + self.refreshDashboard(); + } + }, 'Close') + ]) + ]); + + self.updateStep(0, 'active'); + + api.sslRemove(serviceName).then(function(res) { + if (res.success) { + self.updateStep(0, 'complete'); + self.updateStep(1, 'complete'); + self.updateStep(2, 'complete'); + self.updateStep(3, 'complete'); + self.updateStep(4, 'complete'); + self.showProgressResult(true, 'SSL backend removed successfully'); + } else { + self.updateStep(0, 'error', res.error); + self.showProgressResult(false, 'Failed to remove SSL backend', res.error || 'Unknown error'); + } + document.getElementById('progress-actions').style.display = 'block'; + }).catch(function(err) { + self.updateStep(0, 'error', 'Connection failed'); + self.showProgressResult(false, 'API Error', err.message); + document.getElementById('progress-actions').style.display = 'block'; + }); + } + }, 'Remove') + ]) + ]); + }, + + refreshDashboard: function() { + var self = this; + return Promise.all([ + api.status(), + api.scan(), + api.conflicts(), + api.torList(), + api.sslList() + ]).then(function(data) { + var container = document.querySelector('.exposure-dashboard'); + if (container) { + var newView = self.render(data); + container.parentNode.replaceChild(newView, container); + } + }); + }, + + showToast: function(message, type) { + var existing = document.querySelector('.exp-toast'); + if (existing) existing.remove(); + + var iconMap = { + 'success': '\u2705', + 'error': '\u274C', + 'warning': '\u26A0\uFE0F' + }; + + var colorMap = { + 'success': '#22c55e', + 'error': '#ef4444', + 'warning': '#f97316' + }; + + var toast = E('div', { + 'class': 'exp-toast', + 'style': 'position: fixed; bottom: 24px; right: 24px; background: #1a1a2e; border: 1px solid ' + (colorMap[type] || '#333') + '; padding: 12px 20px; border-radius: 8px; color: #fff; z-index: 10000; display: flex; align-items: center; gap: 8px;' + }, [ + E('span', {}, iconMap[type] || '\u2139\uFE0F'), + message + ]); + document.body.appendChild(toast); + + setTimeout(function() { + toast.remove(); + }, 4000); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js new file mode 100644 index 00000000..ef04743e --- /dev/null +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js @@ -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 +}); diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/ssl.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/ssl.js new file mode 100644 index 00000000..6c752d54 --- /dev/null +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/ssl.js @@ -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 +}); diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/tor.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/tor.js new file mode 100644 index 00000000..686d70b2 --- /dev/null +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/tor.js @@ -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 +}); diff --git a/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure b/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure new file mode 100755 index 00000000..c8f3d82b --- /dev/null +++ b/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure @@ -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 diff --git a/package/secubox/luci-app-exposure/root/usr/share/luci/menu.d/luci-app-exposure.json b/package/secubox/luci-app-exposure/root/usr/share/luci/menu.d/luci-app-exposure.json new file mode 100644 index 00000000..92516b46 --- /dev/null +++ b/package/secubox/luci-app-exposure/root/usr/share/luci/menu.d/luci-app-exposure.json @@ -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" + } + } +} diff --git a/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json b/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json new file mode 100644 index 00000000..8825e107 --- /dev/null +++ b/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json @@ -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"] + } + } +} diff --git a/package/secubox/luci-app-haproxy/Makefile b/package/secubox/luci-app-haproxy/Makefile index 9f30b180..46dc0458 100644 --- a/package/secubox/luci-app-haproxy/Makefile +++ b/package/secubox/luci-app-haproxy/Makefile @@ -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 PKG_LICENSE:=MIT diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js index 9101378d..6d388deb 100644 --- a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/api.js @@ -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 }); diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js index 7bc7c5a6..92b360ce 100644 --- a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/backends.js @@ -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' }, [ diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js index cab58246..94e3b3cd 100644 --- a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js @@ -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) { diff --git a/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy b/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy index 4e82daa3..13a37c5e 100644 --- a/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy +++ b/package/secubox/luci-app-haproxy/root/usr/libexec/rpcd/luci.haproxy @@ -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 diff --git a/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json b/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json index d614c071..d6ea37ad 100644 --- a/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json +++ b/package/secubox/luci-app-haproxy/root/usr/share/rpcd/acl.d/luci-app-haproxy.json @@ -15,7 +15,8 @@ "list_acls", "list_redirects", "get_settings", - "get_logs" + "get_logs", + "list_exposed_services" ] }, "uci": ["haproxy"] diff --git a/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/dashboard.js b/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/dashboard.js index 737b170a..f72324a2 100644 --- a/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/dashboard.js +++ b/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/dashboard.js @@ -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')) ]) ]), diff --git a/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/settings.js b/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/settings.js index 49dd44e3..24d27aab 100644 --- a/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/settings.js +++ b/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/settings.js @@ -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')); diff --git a/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 b/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 index 6f307ef2..efe2bfdd 100644 --- a/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 +++ b/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 @@ -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 < diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/header.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/header.js index b0c62d0c..4cb76265 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/header.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/header.js @@ -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' }, diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js index 97fca8fe..aaad20fe 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js @@ -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 } }, diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js index 4de93749..19a10ea0 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/view/secubox-portal/index.js @@ -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) diff --git a/package/secubox/luci-app-secubox-security-threats/Makefile b/package/secubox/luci-app-secubox-security-threats/Makefile index d10ac075..ea0b769d 100644 --- a/package/secubox/luci-app-secubox-security-threats/Makefile +++ b/package/secubox/luci-app-secubox-security-threats/Makefile @@ -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/ diff --git a/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/secubox-security-threats/api.js b/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/secubox-security-threats/api.js index c65c94b2..4f458498 100644 --- a/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/secubox-security-threats/api.js +++ b/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/secubox-security-threats/api.js @@ -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, diff --git a/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/view/secubox-security-threats/dashboard.js b/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/view/secubox-security-threats/dashboard.js index b8a77119..d4a08c92 100644 --- a/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/view/secubox-security-threats/dashboard.js +++ b/package/secubox/luci-app-secubox-security-threats/htdocs/luci-static/resources/view/secubox-security-threats/dashboard.js @@ -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; diff --git a/package/secubox/luci-app-secubox-security-threats/root/usr/bin/secubox-stats b/package/secubox/luci-app-secubox-security-threats/root/usr/bin/secubox-stats new file mode 100644 index 00000000..920695a4 --- /dev/null +++ b/package/secubox/luci-app-secubox-security-threats/root/usr/bin/secubox-stats @@ -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"}' diff --git a/package/secubox/luci-app-secubox-security-threats/root/usr/libexec/rpcd/luci.secubox-security-threats b/package/secubox/luci-app-secubox-security-threats/root/usr/libexec/rpcd/luci.secubox-security-threats index 06836a57..eccc35c1 100755 --- a/package/secubox/luci-app-secubox-security-threats/root/usr/libexec/rpcd/luci.secubox-security-threats +++ b/package/secubox/luci-app-secubox-security-threats/root/usr/libexec/rpcd/luci.secubox-security-threats @@ -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 diff --git a/package/secubox/luci-app-secubox-security-threats/root/usr/share/rpcd/acl.d/luci-app-secubox-security-threats.json b/package/secubox/luci-app-secubox-security-threats/root/usr/share/rpcd/acl.d/luci-app-secubox-security-threats.json index bf2adada..02ed749e 100644 --- a/package/secubox/luci-app-secubox-security-threats/root/usr/share/rpcd/acl.d/luci-app-secubox-security-threats.json +++ b/package/secubox/luci-app-secubox-security-threats/root/usr/share/rpcd/acl.d/luci-app-secubox-security-threats.json @@ -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", diff --git a/package/secubox/luci-app-secubox/htdocs/luci-static/resources/view/secubox/dashboard.js b/package/secubox/luci-app-secubox/htdocs/luci-static/resources/view/secubox/dashboard.js index 419b9720..8d232011 100644 --- a/package/secubox/luci-app-secubox/htdocs/luci-static/resources/view/secubox/dashboard.js +++ b/package/secubox/luci-app-secubox/htdocs/luci-static/resources/view/secubox/dashboard.js @@ -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 + ' ...') diff --git a/package/secubox/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json b/package/secubox/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json index e1e8de48..9ec03d2b 100644 --- a/package/secubox/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json +++ b/package/secubox/luci-app-secubox/root/usr/share/rpcd/acl.d/luci-app-secubox.json @@ -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", diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js index 5500ed54..1294b045 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/apps.js @@ -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) { diff --git a/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/tor-shield/api.js b/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/tor-shield/api.js index 2f4b6a1d..fa58033e 100644 --- a/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/tor-shield/api.js +++ b/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/tor-shield/api.js @@ -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, diff --git a/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js b/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js index b4a2937e..c4a76f1e 100644 --- a/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js +++ b/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js @@ -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') diff --git a/package/secubox/luci-app-tor-shield/root/usr/libexec/rpcd/luci.tor-shield b/package/secubox/luci-app-tor-shield/root/usr/libexec/rpcd/luci.tor-shield index 2c49bbd3..55c207c6 100644 --- a/package/secubox/luci-app-tor-shield/root/usr/libexec/rpcd/luci.tor-shield +++ b/package/secubox/luci-app-tor-shield/root/usr/libexec/rpcd/luci.tor-shield @@ -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 ;; diff --git a/package/secubox/secubox-app-exposure/Makefile b/package/secubox/secubox-app-exposure/Makefile new file mode 100644 index 00000000..d9deacbf --- /dev/null +++ b/package/secubox/secubox-app-exposure/Makefile @@ -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 +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)) diff --git a/package/secubox/secubox-app-exposure/files/etc/config/secubox-exposure b/package/secubox/secubox-app-exposure/files/etc/config/secubox-exposure new file mode 100644 index 00000000..7912cebf --- /dev/null +++ b/package/secubox/secubox-app-exposure/files/etc/config/secubox-exposure @@ -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' diff --git a/package/secubox/secubox-app-exposure/files/usr/sbin/secubox-exposure b/package/secubox/secubox-app-exposure/files/usr/sbin/secubox-exposure new file mode 100755 index 00000000..a3fbb64b --- /dev/null +++ b/package/secubox/secubox-app-exposure/files/usr/sbin/secubox-exposure @@ -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 [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 [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 " + 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 [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 " + 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 [options] + +COMMANDS: + scan Scan all listening services + conflicts Detect port conflicts + fix-port [port] Change service port (auto-assigns if no port given) + status Show exposure status summary + + tor add [port] Create Tor hidden service + tor list List hidden services + tor remove Remove hidden service + tor sync Sync hidden services to Tor Shield + + ssl add Add HAProxy SSL backend + ssl list List SSL backends + ssl remove 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 diff --git a/package/secubox/secubox-app-gitea/files/usr/sbin/giteactl b/package/secubox/secubox-app-gitea/files/usr/sbin/giteactl index 18358d55..45cc1edb 100644 --- a/package/secubox/secubox-app-gitea/files/usr/sbin/giteactl +++ b/package/secubox/secubox-app-gitea/files/usr/sbin/giteactl @@ -91,6 +91,15 @@ Commands: --password --email + mirror-sync Sync a mirrored repository + mirror-list List all mirrored repositories + mirror-create Create a new mirror from URL + --name + --url + --owner (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=" + 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 or " + 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 --url [--owner ]" + 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 <&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 --password --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 diff --git a/package/secubox/secubox-app-haproxy/Makefile b/package/secubox/secubox-app-haproxy/Makefile index 78a65596..4b65f78a 100644 --- a/package/secubox/secubox-app-haproxy/Makefile +++ b/package/secubox/secubox-app-haproxy/Makefile @@ -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 PKG_LICENSE:=MIT diff --git a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl index a94ee7b6..14d702f5 100644 --- a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl +++ b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl @@ -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 " + 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 ;; diff --git a/package/secubox/secubox-app-magicmirror2/Makefile b/package/secubox/secubox-app-magicmirror2/Makefile index def56b65..11c1debd 100644 --- a/package/secubox/secubox-app-magicmirror2/Makefile +++ b/package/secubox/secubox-app-magicmirror2/Makefile @@ -60,7 +60,7 @@ define Package/secubox-app-magicmirror2/postinst echo " mm2ctl install" echo " /etc/init.d/magicmirror2 start" echo "" - echo "Web interface: http://:8082" + echo "Web interface: http://:8085" echo "" echo "To manage modules:" echo " mm2ctl module list" diff --git a/package/secubox/secubox-app-magicmirror2/files/etc/config/magicmirror2 b/package/secubox/secubox-app-magicmirror2/files/etc/config/magicmirror2 index e42cdf9b..6073e2a6 100644 --- a/package/secubox/secubox-app-magicmirror2/files/etc/config/magicmirror2 +++ b/package/secubox/secubox-app-magicmirror2/files/etc/config/magicmirror2 @@ -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' diff --git a/package/secubox/secubox-app-magicmirror2/files/usr/sbin/mm2ctl b/package/secubox/secubox-app-magicmirror2/files/usr/sbin/mm2ctl index 5127ebfc..0d46d91d 100644 --- a/package/secubox/secubox-app-magicmirror2/files/usr/sbin/mm2ctl +++ b/package/secubox/secubox-app-magicmirror2/files/usr/sbin/mm2ctl @@ -51,7 +51,7 @@ Examples: mm2ctl module list mm2ctl config -Web Interface: http://:8082 +Web Interface: http://: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" diff --git a/package/secubox/secubox-app-metabolizer/files/etc/config/metabolizer b/package/secubox/secubox-app-metabolizer/files/etc/config/metabolizer index 9c003dad..f68d1974 100644 --- a/package/secubox/secubox-app-metabolizer/files/etc/config/metabolizer +++ b/package/secubox/secubox-app-metabolizer/files/etc/config/metabolizer @@ -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' diff --git a/package/secubox/secubox-app-streamlit/Makefile b/package/secubox/secubox-app-streamlit/Makefile index 9fab0664..670d6e47 100644 --- a/package/secubox/secubox-app-streamlit/Makefile +++ b/package/secubox/secubox-app-streamlit/Makefile @@ -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 diff --git a/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl b/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl index dfb219a6..661eabcd 100644 --- a/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl +++ b/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl @@ -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}" diff --git a/package/secubox/secubox-core/Makefile b/package/secubox/secubox-core/Makefile index d1a1fbf6..72c6eb5a 100644 --- a/package/secubox/secubox-core/Makefile +++ b/package/secubox/secubox-core/Makefile @@ -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 diff --git a/package/secubox/secubox-core/root/usr/libexec/rpcd/luci.secubox b/package/secubox/secubox-core/root/usr/libexec/rpcd/luci.secubox index 685cddb3..e2205c8b 100755 --- a/package/secubox/secubox-core/root/usr/libexec/rpcd/luci.secubox +++ b/package/secubox/secubox-core/root/usr/libexec/rpcd/luci.secubox @@ -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