From 4c8799d5209390ed92712ab7c7d45907448d5bba Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 4 Feb 2026 14:15:04 +0100 Subject: [PATCH] feat(exposure): KISS redesign with enriched service names and vhost integration Collapse 4-tab UI into single-table view. Enrich scan with real names from uhttpd UCI, streamlit UCI, docker containers, glances and Lyrion. Add vhost_list RPCD method to show HAProxy domains and uhttpd instances. Fix RPC expect unwrapping, trim CSS from 870 to 178 lines. Co-Authored-By: Claude Opus 4.5 --- .../luci-static/resources/exposure/api.js | 138 ++- .../resources/exposure/dashboard.css | 912 +++--------------- .../resources/view/exposure/services.js | 713 ++++++-------- .../root/usr/libexec/rpcd/luci.exposure | 155 ++- .../share/luci/menu.d/luci-app-exposure.json | 26 +- .../share/rpcd/acl.d/luci-app-exposure.json | 2 +- 6 files changed, 597 insertions(+), 1349 deletions(-) diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js index a00f7471..4028bbce 100644 --- a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/exposure/api.js @@ -2,81 +2,65 @@ '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); } +var callScan = rpc.declare({ + object: 'luci.exposure', + method: 'scan', + expect: {} +}); + +var callTorList = rpc.declare({ + object: 'luci.exposure', + method: 'tor_list', + expect: {} +}); + +var callSslList = rpc.declare({ + object: 'luci.exposure', + method: 'ssl_list', + expect: {} +}); + +var callTorAdd = rpc.declare({ + object: 'luci.exposure', + method: 'tor_add', + params: ['service', 'local_port', 'onion_port'], + expect: {} +}); + +var callTorRemove = rpc.declare({ + object: 'luci.exposure', + method: 'tor_remove', + params: ['service'], + expect: {} +}); + +var callSslAdd = rpc.declare({ + object: 'luci.exposure', + method: 'ssl_add', + params: ['service', 'domain', 'local_port'], + expect: {} +}); + +var callSslRemove = rpc.declare({ + object: 'luci.exposure', + method: 'ssl_remove', + params: ['service'], + expect: {} +}); + +var callVhostList = rpc.declare({ + object: 'luci.exposure', + method: 'vhost_list', + expect: {} +}); + +return baseclass.extend({ + scan: function() { return callScan(); }, + torList: function() { return callTorList(); }, + sslList: function() { return callSslList(); }, + vhostList: function() { return callVhostList(); }, + torAdd: function(s, l, o) { return callTorAdd(s, l, o); }, + torRemove: function(s) { return callTorRemove(s); }, + sslAdd: function(s, d, p) { return callSslAdd(s, d, p); }, + sslRemove: function(s) { return callSslRemove(s); } }); 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 e43c1dec..8edec572 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,869 +1,177 @@ -/* SecuBox Service Exposure Manager - Dashboard Styles */ -/* Unified theme matching SecuBox HAProxy dashboard */ +/* SecuBox Service Exposure - KISS theme */ :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; + --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-danger: #ef4444; } .exposure-dashboard { - padding: 0; - max-width: 1400px; - margin: 0 auto; + padding: 0; + max-width: 1200px; + margin: 0 auto; } -/* Page Header */ +/* 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); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + 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 */ +/* Table */ .exp-table { - width: 100%; - border-collapse: collapse; + width: 100%; + border-collapse: collapse; + background: var(--exp-bg-secondary); + border: 1px solid var(--exp-border); + border-radius: 8px; + overflow: hidden; } .exp-table th, .exp-table td { - padding: 12px 16px; - text-align: left; - border-bottom: 1px solid var(--exp-border); + padding: 10px 14px; + 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; + color: var(--exp-text-muted); + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(255, 255, 255, 0.02); } .exp-table td { - color: var(--exp-text-primary); + color: var(--exp-text-primary); + font-size: 13px; } .exp-table tr:hover td { - background: rgba(100, 255, 218, 0.03); + background: rgba(100, 255, 218, 0.03); } -/* Badge styles */ +.exp-row-internal td { + opacity: 0.45; +} + +/* Helpers */ +.exp-mono { font-family: 'SF Mono', Monaco, monospace; } +.exp-text-muted { color: var(--exp-text-muted); } +.exp-small { font-size: 12px; } + +/* Badges */ .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); + display: inline-block; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + margin-right: 4px; } .exp-badge-tor { - background: rgba(155, 89, 182, 0.2); - color: var(--exp-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); + background: rgba(39, 174, 96, 0.2); + color: var(--exp-ssl); } -/* Monospace text */ -.exp-mono { - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace; +/* Buttons */ +.exp-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s; + text-decoration: none; } -/* Toast notification */ -.exp-toast { - animation: slideInRight 0.3s ease-out; +.exp-btn-secondary { + background: transparent; + color: var(--exp-text-secondary); + border-color: var(--exp-border); } -@keyframes slideInRight { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } +.exp-btn-secondary:hover { + background: rgba(255, 255, 255, 0.05); + border-color: var(--exp-text-secondary); } -/* Toggle switches (from services.js) */ +/* Toggle switches */ .toggle-switch { - position: relative; - display: inline-block; - width: 50px; - height: 26px; + position: relative; + display: inline-block; + width: 44px; + height: 24px; } .toggle-switch input { - opacity: 0; - width: 0; - height: 0; + 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; + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background-color: #333; + transition: 0.2s; + border-radius: 24px; } .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; + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: #666; + transition: 0.2s; + border-radius: 50%; } input:checked + .toggle-slider:before { - transform: translateX(24px); + transform: translateX(20px); } input:checked + .tor-slider { - background-color: rgba(155, 89, 182, 0.3); - border: 1px solid #9b59b6; + background-color: rgba(155, 89, 182, 0.3); + border: 1px solid #9b59b6; } input:checked + .tor-slider:before { - background-color: #9b59b6; + background-color: #9b59b6; } input:checked + .ssl-slider { - background-color: rgba(39, 174, 96, 0.3); - border: 1px solid #27ae60; + 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); } + background-color: #27ae60; } diff --git a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js index ef04743e..ebdc4fdc 100644 --- a/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js +++ b/package/secubox/luci-app-exposure/htdocs/luci-static/resources/view/exposure/services.js @@ -4,457 +4,310 @@ '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() - ]); - }, + load: function() { + return Promise.all([ + api.scan(), + api.torList(), + api.sslList(), + api.vhostList() + ]); + }, - render: function(data) { - var scanResult = data[0] || {}; - var configResult = data[1] || {}; - var torResult = data[2] || {}; - var sslResult = data[3] || {}; + render: function(data) { + var scanResult = data[0] || {}; + var torResult = data[1] || {}; + var sslResult = data[2] || {}; + var vhostResult = 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; + var services = scanResult.services || []; + var torServices = torResult.services || []; + var sslBackends = sslResult.backends || []; + var haproxyVhosts = vhostResult.haproxy || []; + var uhttpdVhosts = vhostResult.uhttpd || []; + var self = this; - // Build lookup maps for current exposure status - var torByService = {}; - torServices.forEach(function(t) { - torByService[t.service] = t; - }); + // Build tor lookup by port (with name fallback) + var torByPort = {}; + torServices.forEach(function(t) { + var port = self.parseBackendPort(t.backend); + if (port) torByPort[port] = t; + }); + var torByName = {}; + torServices.forEach(function(t) { torByName[t.service] = t; }); - var sslByService = {}; - sslBackends.forEach(function(s) { - sslByService[s.service] = s; - }); + // Build ssl lookup by port (with name fallback) + var sslByPort = {}; + sslBackends.forEach(function(s) { + var port = self.parseBackendPort(s.backend); + if (port) sslByPort[port] = s; + }); + var sslByName = {}; + sslBackends.forEach(function(s) { sslByName[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); - } + // Build uhttpd name lookup by port + var uhttpdByPort = {}; + uhttpdVhosts.forEach(function(u) { + if (u.port) uhttpdByPort[u.port] = u; + }); - // Filter to only external services (exposable) - var exposableServices = services.filter(function(svc) { - return svc.external; - }); + // Build HAProxy domains lookup by backend_port (multiple domains per port) + var domainsByPort = {}; + haproxyVhosts.forEach(function(v) { + if (!v.enabled || !v.backend_port || !v.domain) return; + if (!domainsByPort[v.backend_port]) domainsByPort[v.backend_port] = []; + domainsByPort[v.backend_port].push(v); + }); - 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)'), + // Inject CSS + if (!document.querySelector('link[href*="exposure/dashboard.css"]')) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = L.resource('exposure/dashboard.css'); + document.head.appendChild(link); + } - // 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') - ]) - ]), + var torCount = torServices.length; + var sslCount = sslBackends.length; + var domainCount = haproxyVhosts.filter(function(v) { return v.enabled; }).length; - // 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') - ]), + var rows = services.map(function(svc) { + var torInfo = torByPort[svc.port] || torByName[svc.name] || torByName[svc.process] || null; + var sslInfo = sslByPort[svc.port] || sslByName[svc.name] || sslByName[svc.process] || null; + var uhttpdInfo = uhttpdByPort[svc.port] || null; + var domains = domainsByPort[svc.port] || []; + var isExternal = svc.external; - 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; + // Display name comes from enriched scan; show process as subtitle + var displayName = svc.name || svc.process; + var subName = (svc.name && svc.name !== svc.process) ? svc.process : null; - 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') - ]), + // Exposure info fragments + var infoItems = []; + if (torInfo && torInfo.onion) { + var onion = torInfo.onion; + infoItems.push(E('span', { 'class': 'exp-badge exp-badge-tor', 'title': onion }, + onion.substring(0, 16) + '...')); + } + domains.forEach(function(v) { + infoItems.push(E('span', { + 'class': 'exp-badge exp-badge-ssl', + 'title': v.domain + (v.acme ? ' (ACME)' : '') + }, v.domain)); + }); + if (infoItems.length === 0 && sslInfo && sslInfo.domain) { + infoItems.push(E('span', { 'class': 'exp-badge exp-badge-ssl' }, sslInfo.domain)); + } - // 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 E('tr', { 'class': isExternal ? '' : 'exp-row-internal' }, [ + E('td', { 'class': 'exp-mono' }, String(svc.port)), + E('td', {}, [ + E('strong', {}, displayName), + subName ? E('span', { 'class': 'exp-text-muted exp-small' }, ' (' + subName + ')') : null + ]), + E('td', { 'class': 'exp-mono exp-text-muted' }, + svc.address.replace(/^.*:/, '').length < 4 ? svc.address : (isExternal ? '0.0.0.0' : '127.0.0.1')), + // Tor toggle + E('td', { 'style': 'text-align: center;' }, + isExternal ? E('label', { 'class': 'toggle-switch' }, [ + E('input', { + 'type': 'checkbox', + 'checked': !!torInfo, + 'change': ui.createHandlerFn(self, 'handleTorToggle', svc, torInfo) + }), + E('span', { 'class': 'toggle-slider tor-slider' }) + ]) : E('span', { 'class': 'exp-text-muted' }, '-') + ), + // SSL toggle + E('td', { 'style': 'text-align: center;' }, + isExternal ? E('label', { 'class': 'toggle-switch' }, [ + E('input', { + 'type': 'checkbox', + 'checked': !!(sslInfo || domains.length > 0), + 'change': ui.createHandlerFn(self, 'handleSslToggle', svc, sslInfo, domains) + }), + E('span', { 'class': 'toggle-slider ssl-slider' }) + ]) : E('span', { 'class': 'exp-text-muted' }, '-') + ), + // Exposure info + E('td', {}, infoItems.length > 0 ? infoItems : + (isExternal ? E('span', { 'class': 'exp-text-muted' }, 'Not exposed') : E('span', { 'class': 'exp-text-muted' }, 'Local only'))) + ]); + }); - return view; - }, + return E('div', { 'class': 'exposure-dashboard' }, [ + E('div', { 'class': 'exp-page-header' }, [ + E('h2', { 'style': 'margin: 0; color: var(--exp-text-primary);' }, 'Service Exposure'), + E('div', { 'style': 'display: flex; gap: 12px; align-items: center;' }, [ + E('span', { 'class': 'exp-badge exp-badge-tor' }, torCount + ' Tor'), + E('span', { 'class': 'exp-badge exp-badge-ssl' }, domainCount + ' Domains'), + E('button', { + 'class': 'exp-btn exp-btn-secondary', + 'click': function() { window.location.reload(); } + }, 'Refresh') + ]) + ]), - 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, ''); - }, + services.length > 0 ? + E('table', { 'class': 'exp-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', { 'style': 'width: 70px;' }, 'Port'), + E('th', {}, 'Service'), + E('th', { 'style': 'width: 100px;' }, 'Bind'), + E('th', { 'style': 'width: 70px; text-align: center;' }, 'Tor'), + E('th', { 'style': 'width: 70px; text-align: center;' }, 'SSL'), + E('th', {}, 'Exposure') + ]) + ]), + E('tbody', {}, rows) + ]) : + E('p', { 'class': 'exp-text-muted', 'style': 'text-align: center; padding: 2rem;' }, + 'No listening services detected.') + ]); + }, - handleToggleTor: function(svc, serviceName, wasEnabled, ev) { - var self = this; - var checkbox = ev.target; - var isNowChecked = checkbox.checked; + parseBackendPort: function(backend) { + if (!backend) return null; + var m = backend.match(/:(\d+)$/); + return m ? parseInt(m[1]) : null; + }, - 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); + handleTorToggle: function(svc, torInfo, ev) { + var self = this; + var cb = ev.target; - ui.hideModal(); - ui.showModal('Creating Hidden Service...', [ - E('p', { 'class': 'spinning' }, 'Generating .onion address...') - ]); + if (cb.checked && !torInfo) { + var serviceName = (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, ''); - 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') - ]) - ]); - } - }, + ui.showModal('Enable Tor Hidden Service', [ + E('p', {}, 'Create .onion address for ' + (svc.name || svc.process) + ' (port ' + svc.port + ')'), + E('div', { 'style': 'margin: 1rem 0;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'), + E('input', { + 'type': 'text', 'id': 'tor-name', 'value': serviceName, + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px; margin-bottom: 12px;' + }), + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Onion Port'), + 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() { cb.checked = false; ui.hideModal(); } }, 'Cancel'), + E('button', { 'class': 'btn cbi-button-action', 'click': function() { + var name = document.getElementById('tor-name').value; + var onionPort = parseInt(document.getElementById('tor-onion-port').value) || 80; + ui.hideModal(); + ui.showModal('Creating...', [E('p', { 'class': 'spinning' }, 'Creating Tor hidden service...')]); + api.torAdd(name, svc.port, onionPort).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', {}, 'Tor hidden service created' + (res.onion ? ': ' + res.onion : '')), 'info'); + window.location.reload(); + } else { + cb.checked = false; + ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger'); + } + }).catch(function() { cb.checked = false; ui.hideModal(); }); + }}, 'Enable') + ]) + ]); + } else if (!cb.checked && torInfo) { + ui.showModal('Disable Tor', [ + E('p', {}, 'Remove hidden service for ' + torInfo.service + '?'), + E('p', { 'style': 'color: #e74c3c;' }, '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() { cb.checked = true; ui.hideModal(); } }, 'Cancel'), + E('button', { 'class': 'btn cbi-button-negative', 'click': function() { + ui.hideModal(); + api.torRemove(torInfo.service).then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', {}, 'Tor hidden service removed'), 'info'); + window.location.reload(); + } else { + cb.checked = true; + ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger'); + } + }).catch(function() { cb.checked = true; }); + }}, 'Remove') + ]) + ]); + } + }, - handleToggleSsl: function(svc, serviceName, wasEnabled, sslInfo, ev) { - var self = this; - var checkbox = ev.target; - var isNowChecked = checkbox.checked; + handleSslToggle: function(svc, sslInfo, domains, ev) { + var self = this; + var cb = ev.target; - 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 (cb.checked && !sslInfo && (!domains || domains.length === 0)) { + var serviceName = (svc.name || svc.process).toLowerCase().replace(/[^a-z0-9]/g, ''); - if (!domain) { - ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning'); - return; - } + ui.showModal('Enable SSL Backend', [ + E('p', {}, 'Configure HTTPS reverse proxy for ' + (svc.name || svc.process) + ' (port ' + svc.port + ')'), + E('div', { 'style': 'margin: 1rem 0;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, 'Service Name'), + E('input', { + 'type': 'text', 'id': 'ssl-name', 'value': serviceName, + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px; margin-bottom: 12px;' + }), + E('label', { 'style': 'display: block; margin-bottom: 4px; color: #ccc;' }, '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', { 'style': 'display: flex; justify-content: flex-end; gap: 8px;' }, [ + E('button', { 'class': 'btn', 'click': function() { cb.checked = false; ui.hideModal(); } }, 'Cancel'), + E('button', { 'class': 'btn cbi-button-action', 'click': function() { + var name = document.getElementById('ssl-name').value; + var domain = document.getElementById('ssl-domain').value; + if (!domain) { + ui.addNotification(null, E('p', {}, 'Domain is required'), 'warning'); + return; + } + ui.hideModal(); + ui.showModal('Configuring...', [E('p', { 'class': 'spinning' }, 'Setting up SSL backend...')]); + api.sslAdd(name, domain, svc.port).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', {}, 'SSL backend configured for ' + domain), 'info'); + window.location.reload(); + } else { + cb.checked = false; + ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger'); + } + }).catch(function() { cb.checked = false; ui.hideModal(); }); + }}, 'Enable') + ]) + ]); + } else if (!cb.checked && (sslInfo || (domains && domains.length > 0))) { + var backendName = sslInfo ? sslInfo.service : domains[0].backend; + var domainName = (sslInfo && sslInfo.domain) ? sslInfo.domain : (domains && domains.length > 0 ? domains[0].domain : ''); + ui.showModal('Disable SSL Backend', [ + E('p', {}, 'Remove HAProxy backend for ' + backendName + '?'), + domainName ? E('p', { 'style': 'color: #8892b0;' }, 'Domain: ' + domainName) : null, + E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 1rem;' }, [ + E('button', { 'class': 'btn', 'click': function() { cb.checked = true; ui.hideModal(); } }, 'Cancel'), + E('button', { 'class': 'btn cbi-button-negative', 'click': function() { + ui.hideModal(); + api.sslRemove(backendName).then(function(res) { + if (res.success) { + ui.addNotification(null, E('p', {}, 'SSL backend removed'), 'info'); + window.location.reload(); + } else { + cb.checked = true; + ui.addNotification(null, E('p', {}, 'Error: ' + (res.error || 'Unknown')), 'danger'); + } + }).catch(function() { cb.checked = true; }); + }}, 'Remove') + ]) + ]); + } + }, - 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 + handleSaveApply: null, + handleSave: null, + handleReset: null }); diff --git a/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure b/package/secubox/luci-app-exposure/root/usr/libexec/rpcd/luci.exposure index c8f3d82b..10de2ba7 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 @@ -41,6 +41,8 @@ case "$1" in json_add_object "ssl_remove" json_add_string "service" "string" json_close_object + json_add_object "vhost_list" + json_close_object json_dump ;; @@ -49,6 +51,9 @@ case "$1" in scan) # Scan listening services - use temp file to avoid subshell issues TMP_SVC="/tmp/exposure_scan_$$" + TMP_NAMES="/tmp/exposure_names_$$" + > "$TMP_NAMES" + netstat -tlnp 2>/dev/null | grep LISTEN | awk '{ split($4, a, ":") port = a[length(a)] @@ -60,6 +65,47 @@ case "$1" in } }' | sort -n > "$TMP_SVC" + # Build port->name enrichment from component configs + + # uhttpd instances + for _s in $(uci show uhttpd 2>/dev/null | grep "=uhttpd$" | cut -d'.' -f2 | cut -d'=' -f1); do + _listen=$(uci -q get "uhttpd.${_s}.listen_http") + [ -z "$_listen" ] && continue + _p=$(echo "$_listen" | grep -o '[0-9]*$') + case "$_s" in + main) echo "$_p|LuCI" >> "$TMP_NAMES" ;; + acme) echo "$_p|ACME Challenge" >> "$TMP_NAMES" ;; + metablog_site_*) echo "$_p|Metablog: $(echo "$_s" | sed 's/^metablog_site_//')" >> "$TMP_NAMES" ;; + p2p_api) echo "$_p|P2P API" >> "$TMP_NAMES" ;; + *) echo "$_p|uhttpd: $_s" >> "$TMP_NAMES" ;; + esac + done + + # Streamlit instances + for _s in $(uci show streamlit 2>/dev/null | grep "\.port=" | cut -d'.' -f2); do + _p=$(uci -q get "streamlit.${_s}.port") + _n=$(uci -q get "streamlit.${_s}.name") + [ -n "$_p" ] && echo "$_p|Streamlit: ${_n:-$_s}" >> "$TMP_NAMES" + done + + # Docker containers + docker ps --format '{{.Ports}}|{{.Names}}' 2>/dev/null | while IFS='|' read _ports _cname; do + [ -z "$_cname" ] && continue + echo "$_ports" | tr ',' '\n' | while read _bind; do + _hp=$(echo "$_bind" | sed -n 's/.*:\([0-9]*\)->.*/\1/p') + [ -n "$_hp" ] && echo "$_hp|Docker: $_cname" >> "$TMP_NAMES" + done + done + + # Glances + _gp=$(uci -q get glances.main.web_port) + [ -n "$_gp" ] && echo "$_gp|Glances" >> "$TMP_NAMES" + + # Known services by port + echo "9000|Lyrion" >> "$TMP_NAMES" + echo "3483|Lyrion Discovery" >> "$TMP_NAMES" + echo "9090|Lyrion CLI" >> "$TMP_NAMES" + json_init json_add_array "services" @@ -73,19 +119,23 @@ case "$1" in *) 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 + # Try enriched name first, fallback to process-based mapping + name=$(grep "^${port}|" "$TMP_NAMES" | head -1 | cut -d'|' -f2) + if [ -z "$name" ]; then + 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" ;; + *) name="$proc" ;; + esac + fi json_add_object "" json_add_int "port" "$port" @@ -96,7 +146,7 @@ case "$1" in json_close_object done < "$TMP_SVC" - rm -f "$TMP_SVC" + rm -f "$TMP_SVC" "$TMP_NAMES" json_close_array json_dump ;; @@ -412,6 +462,83 @@ case "$1" in json_dump ;; + vhost_list) + json_init + + # HAProxy vhosts (domain -> backend with resolved port) + json_add_array "haproxy" + 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") + ssl=$(uci -q get "haproxy.${vhost}.ssl") + acme=$(uci -q get "haproxy.${vhost}.acme") + + [ -z "$domain" ] && continue + + # Check for original_backend (when mitmproxy is intercepting) + original_backend=$(uci -q get "haproxy.${vhost}.original_backend") + resolve_backend="${original_backend:-$backend}" + + # Resolve backend port from the target backend + backend_port="" + if [ -n "$resolve_backend" ]; then + # Try inline server option: 'name IP:PORT check' + server_line=$(uci -q get "haproxy.${resolve_backend}.server" 2>/dev/null) + if [ -n "$server_line" ]; then + backend_port=$(echo "$server_line" | awk '{print $2}' | grep -o ':[0-9]*' | tr -d ':') + fi + # Try server sections referencing this backend + if [ -z "$backend_port" ]; then + for srv in $(uci show haproxy 2>/dev/null | grep "=server$" | cut -d'.' -f2 | cut -d'=' -f1); do + srv_backend=$(uci -q get "haproxy.${srv}.backend") + if [ "$srv_backend" = "$resolve_backend" ]; then + backend_port=$(uci -q get "haproxy.${srv}.port") + break + fi + done + fi + fi + + json_add_object "" + json_add_string "id" "$vhost" + json_add_string "domain" "$domain" + json_add_string "backend" "${resolve_backend:-${backend:-}}" + json_add_int "backend_port" "${backend_port:-0}" + json_add_boolean "ssl" "${ssl:-0}" + json_add_boolean "acme" "${acme:-0}" + json_add_boolean "enabled" "${enabled:-0}" + json_close_object + done + json_close_array + + # uhttpd vhosts (non-main instances) + json_add_array "uhttpd" + for section in $(uci show uhttpd 2>/dev/null | grep "=uhttpd$" | cut -d'.' -f2 | cut -d'=' -f1); do + [ "$section" = "main" ] && continue + [ "$section" = "acme" ] && continue + + listen=$(uci -q get "uhttpd.${section}.listen_http") + home=$(uci -q get "uhttpd.${section}.home") + [ -z "$listen" ] && continue + + port=$(echo "$listen" | grep -o '[0-9]*$') + + # Derive friendly name from section id + fname=$(echo "$section" | sed 's/^metablog_site_//' | sed 's/_/ /g') + + json_add_object "" + json_add_string "id" "$section" + json_add_int "port" "${port:-0}" + json_add_string "name" "$fname" + json_add_string "home" "${home:-}" + json_close_object + done + json_close_array + + json_dump + ;; + *) json_init json_add_boolean "error" 1 diff --git a/package/secubox/luci-app-exposure/root/usr/share/luci/menu.d/luci-app-exposure.json b/package/secubox/luci-app-exposure/root/usr/share/luci/menu.d/luci-app-exposure.json index 92516b46..88152ddd 100644 --- 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 @@ -4,34 +4,10 @@ "order": 35, "action": { "type": "view", - "path": "exposure/overview" + "path": "exposure/services" }, "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 index 8825e107..18a93bb1 100644 --- a/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json +++ b/package/secubox/luci-app-exposure/root/usr/share/rpcd/acl.d/luci-app-exposure.json @@ -3,7 +3,7 @@ "description": "Grant access to SecuBox Service Exposure Manager", "read": { "ubus": { - "luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config"] + "luci.exposure": ["scan", "conflicts", "status", "tor_list", "ssl_list", "get_config", "vhost_list"] }, "uci": ["secubox-exposure"] },