diff --git a/package/secubox/luci-app-exposure/Makefile b/package/secubox/luci-app-exposure/Makefile index cb329e0a..837d8ee2 100644 --- a/package/secubox/luci-app-exposure/Makefile +++ b/package/secubox/luci-app-exposure/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-exposure PKG_VERSION:=1.0.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_MAINTAINER:=SecuBox Team PKG_LICENSE:=MIT diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css index bce30695..e43c1dec 100644 --- a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/dashboard.css @@ -1,241 +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: 1rem; + padding: 0; + max-width: 1400px; + margin: 0 auto; } -.exposure-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 2rem; -} - -.exposure-stat-card { - background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); - border: 1px solid #0f3460; - border-radius: 12px; - padding: 1.5rem; - text-align: center; -} - -.exposure-stat-icon { - font-size: 2.5rem; - margin-bottom: 0.5rem; -} - -.exposure-stat-value { - font-size: 2rem; - font-weight: 700; - color: #00d4ff; -} - -.exposure-stat-label { - font-size: 0.85rem; - color: #8892b0; - margin-top: 0.25rem; -} - -.exposure-section { - background: #16213e; - border: 1px solid #0f3460; - border-radius: 12px; - padding: 1.5rem; - margin-bottom: 1.5rem; -} - -.exposure-section-header { +/* Page Header */ +.exp-page-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid #0f3460; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--exp-border); } -.exposure-section-title { - font-size: 1.1rem; - font-weight: 600; - color: #ccd6f6; +.exp-page-title { + font-size: 28px; + font-weight: 700; + color: var(--exp-text-primary); display: flex; align-items: center; - gap: 0.5rem; + gap: 12px; + margin: 0; } -.exposure-section-title .icon { - font-size: 1.25rem; +.exp-page-title-icon { + font-size: 32px; } -/* Service Table */ -.exposure-table { +.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; } -.exposure-table th, -.exposure-table td { - padding: 0.75rem 1rem; +.exp-table th, +.exp-table td { + padding: 12px 16px; text-align: left; - border-bottom: 1px solid #0f3460; + border-bottom: 1px solid var(--exp-border); } -.exposure-table th { - color: #8892b0; +.exp-table th { + color: var(--exp-text-muted); font-weight: 500; - font-size: 0.85rem; + 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; } -.exposure-table td { - color: #ccd6f6; +.exp-badge-success { + background: rgba(34, 197, 94, 0.2); + color: var(--exp-success); } -.exposure-table tr:hover { - background: rgba(0, 212, 255, 0.05); +.exp-badge-warning { + background: rgba(249, 115, 22, 0.2); + color: var(--exp-warning); } -/* Status badges */ -.badge { - display: inline-block; - padding: 0.25rem 0.75rem; - border-radius: 20px; - font-size: 0.75rem; - font-weight: 600; +.exp-badge-danger { + background: rgba(239, 68, 68, 0.2); + color: var(--exp-danger); } -.badge-external { - background: rgba(0, 212, 255, 0.15); - color: #00d4ff; +.exp-badge-info { + background: rgba(100, 255, 218, 0.2); + color: var(--exp-accent); } -.badge-local { - background: rgba(255, 193, 7, 0.15); - color: #ffc107; -} - -.badge-tor { - background: rgba(116, 78, 182, 0.15); - color: #9b59b6; -} - -.badge-ssl { - background: rgba(46, 204, 113, 0.15); - color: #2ecc71; -} - -/* Onion address */ -.onion-address { - font-family: monospace; - font-size: 0.85rem; - color: #9b59b6; - word-break: break-all; -} - -/* Buttons */ -.btn-action { - padding: 0.5rem 1rem; - border-radius: 6px; - border: none; - cursor: pointer; - font-size: 0.85rem; - transition: all 0.2s; -} - -.btn-primary { - background: linear-gradient(135deg, #00d4ff, #0099cc); - color: #fff; -} - -.btn-primary:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3); -} - -.btn-danger { - background: rgba(231, 76, 60, 0.2); - color: #e74c3c; - border: 1px solid #e74c3c; -} - -.btn-danger:hover { - background: #e74c3c; - color: #fff; -} - -.btn-tor { +.exp-badge-tor { background: rgba(155, 89, 182, 0.2); - color: #9b59b6; + 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; } -.btn-ssl { - background: rgba(46, 204, 113, 0.2); - color: #2ecc71; - border: 1px solid #2ecc71; +input:checked + .tor-slider:before { + background-color: #9b59b6; } -/* Forms */ -.exposure-form { - display: flex; - gap: 1rem; - align-items: flex-end; - flex-wrap: wrap; +input:checked + .ssl-slider { + background-color: rgba(39, 174, 96, 0.3); + border: 1px solid #27ae60; } -.exposure-form-group { +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: 0.25rem; + gap: 12px; } -.exposure-form-group label { - font-size: 0.85rem; - color: #8892b0; +.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; } -.exposure-form-group input, -.exposure-form-group select { - padding: 0.5rem 0.75rem; - border-radius: 6px; - border: 1px solid #0f3460; - background: #1a1a2e; - color: #ccd6f6; - font-size: 0.9rem; -} - -.exposure-form-group input:focus, -.exposure-form-group select:focus { - outline: none; - border-color: #00d4ff; -} - -/* Empty state */ -.exposure-empty { - text-align: center; - padding: 2rem; - color: #8892b0; -} - -.exposure-empty .icon { - font-size: 3rem; - margin-bottom: 1rem; +.exp-progress-step[data-status="pending"] { opacity: 0.5; } -/* Port conflict warning */ -.conflict-warning { - background: rgba(231, 76, 60, 0.1); - border: 1px solid #e74c3c; - border-radius: 8px; - padding: 1rem; - margin-bottom: 1rem; +.exp-progress-step[data-status="active"] { + border-left-color: #3b82f6; + background: rgba(59, 130, 246, 0.1); } -.conflict-warning-header { +.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; - gap: 0.5rem; - color: #e74c3c; - font-weight: 600; - margin-bottom: 0.5rem; + 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 index 388e4d4f..72ea627b 100644 --- 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 @@ -2,150 +2,1087 @@ '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({ - load: function() { - return Promise.all([ - api.status(), - api.conflicts() - ]); + 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); + } }, - render: function(data) { - var status = data[0] || {}; - var conflictsResult = data[1] || {}; + load: function() { + var self = this; + this.loadStartTime = Date.now(); + this.log('INIT', 'Starting dashboard load'); - // Handle both direct array and wrapped object responses - var conflicts = Array.isArray(conflictsResult) ? conflictsResult : (conflictsResult.conflicts || []); - var services = status.services || {}; - var tor = status.tor || {}; - var ssl = status.ssl || {}; - - // Inject CSS + // 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'); } - var view = E('div', { 'class': 'exposure-dashboard' }, [ - E('h2', {}, 'Service Exposure Manager'), - E('p', { 'style': 'color: #8892b0; margin-bottom: 1.5rem;' }, - 'Manage port conflicts, Tor hidden services, and HAProxy SSL backends'), + // Progressive data loading with individual error handling + return this.loadDataProgressively(); + }, - // Stats cards - E('div', { 'class': 'exposure-stats' }, [ - E('div', { 'class': 'exposure-stat-card' }, [ - E('div', { 'class': 'exposure-stat-icon' }, '\ud83d\udd0c'), - E('div', { 'class': 'exposure-stat-value' }, String(services.total || 0)), - E('div', { 'class': 'exposure-stat-label' }, 'Total Services') + 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('div', { 'class': 'exposure-stat-card' }, [ - E('div', { 'class': 'exposure-stat-icon' }, '\ud83c\udf10'), - E('div', { 'class': 'exposure-stat-value' }, String(services.external || 0)), - E('div', { 'class': 'exposure-stat-label' }, 'External (0.0.0.0)') + 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': 'exposure-stat-card' }, [ - E('div', { 'class': 'exposure-stat-icon' }, '\ud83e\uddc5'), - E('div', { 'class': 'exposure-stat-value' }, String(tor.count || 0)), - E('div', { 'class': 'exposure-stat-label' }, 'Tor Hidden Services') + E('div', { 'class': 'exp-header-badge' }, [ + E('span', { 'style': 'color: #9b59b6;' }, String(torData.count || 0)), + ' Tor' ]), - E('div', { 'class': 'exposure-stat-card' }, [ - E('div', { 'class': 'exposure-stat-icon' }, '\ud83d\udd12'), - E('div', { 'class': 'exposure-stat-value' }, String(ssl.count || 0)), - E('div', { 'class': 'exposure-stat-label' }, 'SSL Backends') + 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' ]) ]), - - // Conflicts warning - conflicts.length > 0 ? E('div', { 'class': 'conflict-warning' }, [ - E('div', { 'class': 'conflict-warning-header' }, [ - '\u26a0\ufe0f Port Conflicts Detected' - ]), - E('ul', {}, - conflicts.map(function(c) { - return E('li', {}, - 'Port ' + c.port + ': ' + (c.services || []).join(', ') - ); - }) - ) - ]) : null, - - // Tor Hidden Services section - E('div', { 'class': 'exposure-section' }, [ - E('div', { 'class': 'exposure-section-header' }, [ - E('div', { 'class': 'exposure-section-title' }, [ - E('span', { 'class': 'icon' }, '\ud83e\uddc5'), - 'Tor Hidden Services' + 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': 'btn-action btn-primary' - }, 'Manage') - ]), - (tor.services && tor.services.length > 0) ? - E('table', { 'class': 'exposure-table' }, [ - E('thead', {}, [ - E('tr', {}, [ - E('th', {}, 'Service'), - E('th', {}, 'Onion Address') - ]) - ]), - E('tbody', {}, - tor.services.map(function(svc) { - return E('tr', {}, [ - E('td', {}, svc.service), - E('td', { 'class': 'onion-address' }, svc.onion) - ]); - }) - ) - ]) : - E('div', { 'class': 'exposure-empty' }, [ - E('div', { 'class': 'icon' }, '\ud83e\uddc5'), - E('p', {}, 'No Tor hidden services configured') - ]) - ]), - - // SSL Backends section - E('div', { 'class': 'exposure-section' }, [ - E('div', { 'class': 'exposure-section-header' }, [ - E('div', { 'class': 'exposure-section-title' }, [ - E('span', { 'class': 'icon' }, '\ud83d\udd12'), - 'HAProxy SSL Backends' + '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': 'btn-action btn-primary' - }, 'Manage') - ]), - (ssl.backends && ssl.backends.length > 0) ? - E('table', { 'class': 'exposure-table' }, [ - E('thead', {}, [ - E('tr', {}, [ - E('th', {}, 'Service'), - E('th', {}, 'Domain') - ]) - ]), - E('tbody', {}, - ssl.backends.map(function(b) { - return E('tr', {}, [ - E('td', {}, b.service), - E('td', {}, b.domain) - ]); - }) - ) - ]) : - E('div', { 'class': 'exposure-empty' }, [ - E('div', { 'class': 'icon' }, '\ud83d\udd12'), - E('p', {}, 'No SSL backends configured') + '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') ]) + ]) ]) - ].filter(Boolean)); + ]); + }, - return view; + // === 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, diff --git a/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure b/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure index d828fed1..c8f3d82b 100755 --- a/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure +++ b/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure @@ -137,25 +137,26 @@ case "$1" in json_close_array json_close_object - # HAProxy SSL backends - use temp file to avoid subshell - HAPROXY_CONFIG="/srv/lxc/haproxy/rootfs/etc/haproxy/haproxy.cfg" - ssl_count=0 - [ -f "$HAPROXY_CONFIG" ] && ssl_count=$(grep -c "^backend.*_backend$" "$HAPROXY_CONFIG" 2>/dev/null || echo 0) - + # HAProxy SSL backends - read from UCI config TMP_SSL="/tmp/exposure_ssl_$$" - if [ -f "$HAPROXY_CONFIG" ]; then - grep -E "^backend .+_backend$" "$HAPROXY_CONFIG" 2>/dev/null | while read line; do - backend=$(echo "$line" | awk '{print $2}' | sed 's/_backend$//') - domain=$(grep "acl host_${backend} " "$HAPROXY_CONFIG" 2>/dev/null | awk '{print $NF}') - echo "$backend ${domain:-N/A}" - done > "$TMP_SSL" - fi + 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 read backend domain; do + while IFS='|' read backend domain; do [ -z "$backend" ] && continue json_add_object "" json_add_string "service" "$backend" @@ -203,24 +204,31 @@ case "$1" in ;; ssl_list) - HAPROXY_CONFIG="/srv/lxc/haproxy/rootfs/etc/haproxy/haproxy.cfg" TMP_SSLLIST="/tmp/exposure_ssllist_$$" + > "$TMP_SSLLIST" - # Extract backend info to temp file to avoid subshell issues - if [ -f "$HAPROXY_CONFIG" ]; then - grep -E "^backend .+_backend$" "$HAPROXY_CONFIG" 2>/dev/null | while read line; do - backend=$(echo "$line" | awk '{print $2}') - service=$(echo "$backend" | sed 's/_backend$//') - domain=$(grep "acl host_${service} " "$HAPROXY_CONFIG" 2>/dev/null | awk '{print $NF}') - server=$(grep -A5 "backend $backend" "$HAPROXY_CONFIG" 2>/dev/null | grep "server " | awk '{print $3}') - echo "$service|${domain:-N/A}|${server:-N/A}" - done > "$TMP_SSLLIST" - fi + # 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 [ -f "$TMP_SSLLIST" ]; then + if [ -s "$TMP_SSLLIST" ]; then while IFS='|' read service domain server; do [ -z "$service" ] && continue json_add_object "" @@ -229,8 +237,8 @@ case "$1" in json_add_string "backend" "$server" json_close_object done < "$TMP_SSLLIST" - rm -f "$TMP_SSLLIST" fi + rm -f "$TMP_SSLLIST" json_close_array json_dump 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-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 07e6f043..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 @@ -179,7 +179,7 @@ return view.extend({ 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.15.48') + E('span', { 'class': 'sb-portal-version' }, 'v0.15.51') ]), // Navigation E('nav', { 'class': 'sb-portal-nav' }, 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/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/secubox-app-haproxy/Makefile b/package/secubox/secubox-app-haproxy/Makefile index 52ef93de..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:=16 +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 c9986fb3..14d702f5 100644 --- a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl +++ b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl @@ -246,17 +246,14 @@ echo "Config: $CONFIG_FILE" ls -la /opt/haproxy/ ls -la /opt/haproxy/certs/ 2>/dev/null || echo "No certs dir" -# Fix certificate key naming for HAProxy compatibility -# HAProxy expects .crt.key when it finds a .crt file in the directory +# 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 crt in /opt/haproxy/certs/*.crt; do - [ -f "$crt" ] || continue - base="${crt%.crt}" - # If .key exists but .crt.key doesn't, rename it - if [ -f "${base}.key" ] && [ ! -f "${crt}.key" ]; then - echo "[haproxy] Renaming ${base}.key -> ${crt}.key" - mv "${base}.key" "${crt}.key" - fi + 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 @@ -517,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" @@ -783,12 +796,9 @@ cmd_cert_add() { cat "$CERTS_PATH/$domain.fullchain.pem" "$CERTS_PATH/$domain.key" > "$CERTS_PATH/$domain.pem" chmod 600 "$CERTS_PATH/$domain.pem" - # HAProxy expects key files named .key when loading .crt files from directory - # Rename the key file to match the .crt file naming convention - if [ -f "$CERTS_PATH/$domain.crt" ] && [ -f "$CERTS_PATH/$domain.key" ]; then - mv "$CERTS_PATH/$domain.key" "$CERTS_PATH/$domain.crt.key" - chmod 600 "$CERTS_PATH/$domain.crt.key" - fi + # 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 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 69a375d2..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