From ab8e0c44bcbbf150a8ae57a8cc35fc92cb8a7ea2 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 28 Jan 2026 07:01:27 +0100 Subject: [PATCH] feat(service-registry): Add dynamic health checks and URL readiness wizard - Add health check RPCD methods: - check_service_health: Check DNS, cert, firewall for single domain - check_all_health: Batch check all published services - Add URL Readiness Checker wizard card to dashboard: - Check if domain DNS resolves correctly - Verify firewall ports 80/443 are open - Check SSL certificate status - Show actionable recommendations - Display inline health status badges on service rows: - DNS resolution status (ok/failed) - Certificate expiry (ok/warning/critical/expired) - Add health summary bar showing overall system status - Add per-service health check button Co-Authored-By: Claude Opus 4.5 --- .../resources/service-registry/api.js | 45 +++ .../view/service-registry/overview.js | 295 +++++++++++++++- .../usr/libexec/rpcd/luci.service-registry | 325 +++++++++++++++++- .../rpcd/acl.d/luci-app-service-registry.json | 2 + 4 files changed, 650 insertions(+), 17 deletions(-) diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/api.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/api.js index 4e13a396..2735d499 100644 --- a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/api.js +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/service-registry/api.js @@ -87,6 +87,19 @@ var callSaveLandingConfig = rpc.declare({ expect: {} }); +var callCheckServiceHealth = rpc.declare({ + object: 'luci.service-registry', + method: 'check_service_health', + params: ['service_id', 'domain'], + expect: {} +}); + +var callCheckAllHealth = rpc.declare({ + object: 'luci.service-registry', + method: 'check_all_health', + expect: {} +}); + // HAProxy status for provider info var callHAProxyStatus = rpc.declare({ object: 'luci.haproxy', @@ -220,5 +233,37 @@ return baseclass.extend({ // Full publish with HAProxy + Tor fullPublish: function(name, port, domain) { return this.publishService(name, port, domain, true, 'services', ''); + }, + + // Check health of a single service + checkServiceHealth: function(serviceId, domain) { + return callCheckServiceHealth(serviceId || '', domain || ''); + }, + + // Check health of all published services + checkAllHealth: function() { + return callCheckAllHealth(); + }, + + // Get dashboard data with health status + getDashboardDataWithHealth: function() { + return Promise.all([ + callListServices().catch(function(e) { console.error('list_services failed:', e); return { services: [], providers: {} }; }), + callListCategories().catch(function(e) { console.error('list_categories failed:', e); return { categories: [] }; }), + callGetLandingConfig().catch(function(e) { console.error('get_landing_config failed:', e); return {}; }), + callHAProxyStatus().catch(function() { return { enabled: false }; }), + callTorStatus().catch(function() { return { enabled: false }; }), + callCheckAllHealth().catch(function(e) { console.error('check_all_health failed:', e); return { health: {} }; }) + ]).then(function(results) { + return { + services: results[0].services || [], + providers: results[0].providers || {}, + categories: results[1].categories || [], + landing: results[2], + haproxy: results[3], + tor: results[4], + health: results[5].health || {} + }; + }); } }); diff --git a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js index 62155e63..547ad6e2 100644 --- a/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js +++ b/package/secubox/luci-app-service-registry/htdocs/luci-static/resources/view/service-registry/overview.js @@ -12,6 +12,13 @@ var catIcons = { 'monitoring': '📊', 'other': '🔗' }; +// Health status icons +var healthIcons = { + 'dns': { 'ok': '🌐', 'failed': '❌', 'none': '⚪' }, + 'cert': { 'ok': '🔒', 'warning': '⚠️', 'critical': '🔴', 'expired': '💀', 'missing': '⚪', 'none': '⚪' }, + 'firewall': { 'ok': '✅', 'partial': '⚠️', 'closed': '🚫' } +}; + // Generate QR code using QR Server API (free, reliable) function generateQRCodeImg(data, size) { var url = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(data); @@ -21,9 +28,10 @@ function generateQRCodeImg(data, size) { return view.extend({ title: _('Service Registry'), pollInterval: 30, + healthData: null, load: function() { - return api.getDashboardData(); + return api.getDashboardDataWithHealth(); }, render: function(data) { @@ -31,6 +39,9 @@ return view.extend({ var services = data.services || []; var providers = data.providers || {}; + // Store health data for service lookups + this.healthData = data.health || {}; + // Load CSS var style = document.createElement('style'); style.textContent = this.getStyles(); @@ -41,12 +52,177 @@ return view.extend({ return E('div', { 'class': 'sr-compact' }, [ this.renderHeader(services, providers, data.haproxy, data.tor), + this.renderHealthSummary(data.health), + this.renderUrlChecker(), this.renderSection('📡 Published Services', published, true), this.renderSection('🔍 Discovered Services', unpublished, false), this.renderLandingLink(data.landing) ]); }, + renderHealthSummary: function(health) { + if (!health || !health.firewall) return E('div'); + + var firewallStatus = health.firewall.status || 'unknown'; + var firewallIcon = healthIcons.firewall[firewallStatus] || '❓'; + var haproxyStatus = health.haproxy && health.haproxy.status === 'running' ? '🟢' : '🔴'; + var torStatus = health.tor && health.tor.status === 'running' ? '🟢' : '🔴'; + + // Count service health + var services = health.services || []; + var dnsOk = services.filter(function(s) { return s.dns_status === 'ok'; }).length; + var certOk = services.filter(function(s) { return s.cert_status === 'ok'; }).length; + var certWarn = services.filter(function(s) { return s.cert_status === 'warning' || s.cert_status === 'critical'; }).length; + + return E('div', { 'class': 'sr-health-bar' }, [ + E('span', { 'class': 'sr-health-item', 'title': 'Firewall ports 80/443' }, + firewallIcon + ' Firewall: ' + firewallStatus), + E('span', { 'class': 'sr-health-item', 'title': 'HAProxy container' }, + haproxyStatus + ' HAProxy'), + E('span', { 'class': 'sr-health-item', 'title': 'Tor daemon' }, + torStatus + ' Tor'), + services.length > 0 ? E('span', { 'class': 'sr-health-item' }, + '🌐 DNS: ' + dnsOk + '/' + services.length) : null, + services.length > 0 ? E('span', { 'class': 'sr-health-item' }, + '🔒 Certs: ' + certOk + '/' + services.length + + (certWarn > 0 ? ' (⚠️ ' + certWarn + ')' : '')) : null + ].filter(Boolean)); + }, + + renderUrlChecker: function() { + var self = this; + return E('div', { 'class': 'sr-wizard-card' }, [ + E('div', { 'class': 'sr-wizard-header' }, [ + E('span', { 'class': 'sr-wizard-icon' }, '🔍'), + E('span', { 'class': 'sr-wizard-title' }, 'URL Readiness Checker'), + E('span', { 'class': 'sr-wizard-desc' }, 'Check if a domain is ready to be hosted') + ]), + E('div', { 'class': 'sr-wizard-form' }, [ + E('input', { + 'type': 'text', + 'id': 'url-check-domain', + 'placeholder': 'Enter domain (e.g., example.com)', + 'class': 'sr-wizard-input' + }), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, 'handleUrlCheck') + }, '🔍 Check') + ]), + E('div', { 'id': 'url-check-results', 'class': 'sr-check-results' }) + ]); + }, + + handleUrlCheck: function() { + var self = this; + var domain = document.getElementById('url-check-domain').value.trim(); + var resultsDiv = document.getElementById('url-check-results'); + + if (!domain) { + resultsDiv.innerHTML = '
Please enter a domain
'; + return; + } + + // Clean domain (remove protocol if present) + domain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + + resultsDiv.innerHTML = '
🔄 Checking ' + domain + '...
'; + + api.checkServiceHealth('', domain).then(function(result) { + if (!result.success) { + resultsDiv.innerHTML = '
❌ Check failed: ' + (result.error || 'Unknown error') + '
'; + return; + } + + var html = '
'; + + // DNS Status + var dnsStatus = result.dns || {}; + var dnsIcon = healthIcons.dns[dnsStatus.status] || '❓'; + var dnsClass = dnsStatus.status === 'ok' ? 'sr-check-ok' : 'sr-check-fail'; + html += '
'; + html += '' + dnsIcon + ''; + html += 'DNS Resolution'; + if (dnsStatus.status === 'ok') { + html += '✅ Resolves to ' + dnsStatus.resolved_ip + ''; + } else { + html += '❌ DNS not configured or not resolving'; + } + html += '
'; + + // Firewall Status + var fwStatus = result.firewall || {}; + var fwIcon = healthIcons.firewall[fwStatus.status] || '❓'; + var fwClass = fwStatus.status === 'ok' ? 'sr-check-ok' : (fwStatus.status === 'partial' ? 'sr-check-warn' : 'sr-check-fail'); + html += '
'; + html += '' + fwIcon + ''; + html += 'Firewall Ports'; + var ports = []; + if (fwStatus.http_open) ports.push('80'); + if (fwStatus.https_open) ports.push('443'); + html += '' + (ports.length ? 'Open: ' + ports.join(', ') : '❌ Ports 80/443 not open') + ''; + html += '
'; + + // Certificate Status + var certStatus = result.certificate || {}; + var certIcon = healthIcons.cert[certStatus.status] || '❓'; + var certClass = certStatus.status === 'ok' ? 'sr-check-ok' : (certStatus.status === 'warning' ? 'sr-check-warn' : 'sr-check-fail'); + html += '
'; + html += '' + certIcon + ''; + html += 'SSL Certificate'; + if (certStatus.status === 'ok' || certStatus.status === 'warning') { + html += '' + certStatus.days_left + ' days remaining'; + } else if (certStatus.status === 'expired') { + html += '❌ Certificate expired'; + } else if (certStatus.status === 'missing') { + html += '⚪ No certificate (request via HAProxy)'; + } else { + html += '⚪ Not applicable'; + } + html += '
'; + + // HAProxy Status + var haStatus = result.haproxy || {}; + var haIcon = haStatus.status === 'running' ? '🟢' : '🔴'; + var haClass = haStatus.status === 'running' ? 'sr-check-ok' : 'sr-check-fail'; + html += '
'; + html += '' + haIcon + ''; + html += 'HAProxy'; + html += '' + (haStatus.status === 'running' ? '✅ Running' : '❌ Not running') + ''; + html += '
'; + + html += '
'; + + // Summary and recommendation + var allOk = dnsStatus.status === 'ok' && fwStatus.status === 'ok' && haStatus.status === 'running'; + var needsCert = certStatus.status === 'missing'; + + html += '
'; + if (allOk && !needsCert) { + html += '
✅ ' + domain + ' is ready and serving!
'; + } else if (allOk && needsCert) { + html += '
⚠️ ' + domain + ' is ready - just need SSL certificate
'; + html += '📜 Request Certificate'; + } else { + html += '
❌ ' + domain + ' needs configuration
'; + if (dnsStatus.status !== 'ok') { + html += '
💡 Point DNS A record to your public IP
'; + } + if (fwStatus.status !== 'ok') { + html += '
💡 Open ports 80 and 443 in firewall
'; + } + if (haStatus.status !== 'running') { + html += '
💡 Start HAProxy container
'; + } + } + html += '
'; + + resultsDiv.innerHTML = html; + }).catch(function(err) { + resultsDiv.innerHTML = '
❌ Error: ' + err.message + '
'; + }); + }, + renderHeader: function(services, providers, haproxy, tor) { var published = services.filter(function(s) { return s.published; }).length; var running = services.filter(function(s) { return s.status === 'running'; }).length; @@ -135,20 +311,46 @@ return view.extend({ portDisplay = ':' + service.haproxy.backend_port; } - // SSL/Cert badge - var sslBadge = null; - if (service.haproxy) { + // Get health status for this domain (if available) + var healthBadges = []; + var domain = service.haproxy && service.haproxy.domain; + if (domain && this.healthData && this.healthData.services) { + var svcHealth = this.healthData.services.find(function(h) { + return h.domain === domain; + }); + if (svcHealth) { + // DNS badge + var dnsIcon = healthIcons.dns[svcHealth.dns_status] || '❓'; + var dnsTitle = svcHealth.dns_status === 'ok' ? + 'DNS OK: ' + svcHealth.dns_ip : 'DNS: ' + svcHealth.dns_status; + healthBadges.push(E('span', { + 'class': 'sr-badge sr-badge-dns sr-badge-' + svcHealth.dns_status, + 'title': dnsTitle + }, dnsIcon)); + + // Cert badge + var certIcon = healthIcons.cert[svcHealth.cert_status] || '❓'; + var certTitle = svcHealth.cert_status === 'ok' || svcHealth.cert_status === 'warning' ? + 'Cert: ' + svcHealth.cert_days + ' days' : 'Cert: ' + svcHealth.cert_status; + healthBadges.push(E('span', { + 'class': 'sr-badge sr-badge-cert sr-badge-' + svcHealth.cert_status, + 'title': certTitle + }, certIcon)); + } + } + + // SSL/Cert badge (fallback if no health data) + if (healthBadges.length === 0 && service.haproxy) { if (service.haproxy.acme) { - sslBadge = E('span', { 'class': 'sr-badge sr-badge-acme', 'title': 'ACME Certificate' }, '🔒'); + healthBadges.push(E('span', { 'class': 'sr-badge sr-badge-acme', 'title': 'ACME Certificate' }, '🔒')); } else if (service.haproxy.ssl) { - sslBadge = E('span', { 'class': 'sr-badge sr-badge-ssl', 'title': 'SSL Enabled' }, '🔐'); + healthBadges.push(E('span', { 'class': 'sr-badge sr-badge-ssl', 'title': 'SSL Enabled' }, '🔐')); } } // Tor badge - var torBadge = null; if (service.tor && service.tor.enabled) { - torBadge = E('span', { 'class': 'sr-badge sr-badge-tor', 'title': 'Tor Hidden Service' }, '🧅'); + healthBadges.push(E('span', { 'class': 'sr-badge sr-badge-tor', 'title': 'Tor Hidden Service' }, '🧅')); } // QR button for published services with URLs @@ -161,6 +363,16 @@ return view.extend({ }, '📱'); } + // Health check button for published services with domains + var checkBtn = null; + if (isPublished && domain) { + checkBtn = E('button', { + 'class': 'sr-btn sr-btn-check', + 'title': 'Check Health', + 'click': ui.createHandlerFn(this, 'handleServiceHealthCheck', service) + }, '🔍'); + } + // Action button var actionBtn; if (isPublished) { @@ -187,12 +399,24 @@ return view.extend({ E('span', { 'class': 'sr-col-url' }, urlDisplay ? E('a', { 'href': urlDisplay.startsWith('http') ? urlDisplay : 'http://' + urlDisplay, 'target': '_blank' }, urlDisplay) : '-' ), - E('span', { 'class': 'sr-col-badges' }, [sslBadge, torBadge].filter(Boolean)), - E('span', { 'class': 'sr-col-qr' }, qrBtn), + E('span', { 'class': 'sr-col-badges' }, healthBadges), + E('span', { 'class': 'sr-col-qr' }, [qrBtn, checkBtn].filter(Boolean)), E('span', { 'class': 'sr-col-action' }, actionBtn) ]); }, + handleServiceHealthCheck: function(service) { + var self = this; + var domain = service.haproxy && service.haproxy.domain; + if (!domain) return; + + document.getElementById('url-check-domain').value = domain; + this.handleUrlCheck(); + + // Scroll to the checker + document.querySelector('.sr-wizard-card').scrollIntoView({ behavior: 'smooth' }); + }, + handleShowQR: function(service) { var urls = service.urls || {}; var qrBoxes = []; @@ -321,6 +545,41 @@ return view.extend({ .sr-providers-bar { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; } .sr-provider-badge { background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 12px; font-size: 0.8em; } + /* Health Summary Bar */ + .sr-health-bar { display: flex; gap: 15px; margin-bottom: 15px; padding: 10px 15px; background: #f0f7ff; border-radius: 6px; border-left: 4px solid #0099cc; flex-wrap: wrap; } + @media (prefers-color-scheme: dark) { .sr-health-bar { background: #1a2a3e; } } + .sr-health-item { font-size: 0.9em; } + + /* URL Checker Wizard Card */ + .sr-wizard-card { background: linear-gradient(135deg, #0a192f 0%, #172a45 100%); border-radius: 12px; padding: 20px; margin-bottom: 25px; color: #fff; } + .sr-wizard-header { display: flex; align-items: center; gap: 12px; margin-bottom: 15px; } + .sr-wizard-icon { font-size: 1.8em; } + .sr-wizard-title { font-size: 1.2em; font-weight: 600; } + .sr-wizard-desc { font-size: 0.85em; opacity: 0.7; margin-left: auto; } + .sr-wizard-form { display: flex; gap: 10px; align-items: center; } + .sr-wizard-input { flex: 1; padding: 10px 15px; border: 1px solid #334155; border-radius: 6px; background: #0f172a; color: #fff; font-size: 1em; } + .sr-wizard-input::placeholder { color: #64748b; } + + /* Health Check Results */ + .sr-check-results { margin-top: 15px; } + .sr-check-loading { text-align: center; padding: 20px; font-size: 1.1em; } + .sr-check-error { background: #450a0a; padding: 12px 15px; border-radius: 6px; color: #fca5a5; } + .sr-check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; } + .sr-check-item { background: #0f172a; padding: 12px 15px; border-radius: 8px; display: flex; align-items: center; gap: 10px; border-left: 3px solid #334155; } + .sr-check-item.sr-check-ok { border-left-color: #22c55e; } + .sr-check-item.sr-check-warn { border-left-color: #eab308; } + .sr-check-item.sr-check-fail { border-left-color: #ef4444; } + .sr-check-icon { font-size: 1.3em; } + .sr-check-label { font-weight: 600; font-size: 0.9em; min-width: 100px; } + .sr-check-value { font-size: 0.85em; opacity: 0.8; } + .sr-check-summary { margin-top: 15px; padding: 15px; background: #0f172a; border-radius: 8px; text-align: center; } + .sr-check-ready { font-size: 1.1em; color: #22c55e; font-weight: 600; } + .sr-check-almost { font-size: 1.1em; color: #eab308; font-weight: 600; } + .sr-check-notready { font-size: 1.1em; color: #ef4444; font-weight: 600; margin-bottom: 10px; } + .sr-check-tip { font-size: 0.85em; opacity: 0.8; margin-top: 5px; } + .sr-check-action { display: inline-block; margin-top: 10px; padding: 8px 16px; background: #0099cc; color: #fff; text-decoration: none; border-radius: 6px; font-size: 0.9em; } + .sr-check-action:hover { background: #00b3e6; } + .sr-section { margin-bottom: 25px; } .sr-section-title { font-size: 1.1em; margin: 0 0 10px 0; padding-bottom: 8px; border-bottom: 2px solid #0ff; color: #0ff; } .sr-empty-msg { color: #888; font-style: italic; padding: 15px; } @@ -345,11 +604,17 @@ return view.extend({ .sr-col-url { flex: 2; min-width: 150px; font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sr-col-url a { color: #0099cc; text-decoration: none; } .sr-col-url a:hover { text-decoration: underline; } - .sr-col-badges { width: 50px; display: flex; gap: 4px; } - .sr-col-qr { width: 36px; } + .sr-col-badges { width: 80px; display: flex; gap: 4px; } + .sr-col-qr { width: 60px; display: flex; gap: 4px; } .sr-col-action { width: 36px; } - .sr-badge { font-size: 0.85em; } + .sr-badge { font-size: 0.85em; cursor: help; } + .sr-badge-ok { opacity: 1; } + .sr-badge-warning { animation: pulse 2s infinite; } + .sr-badge-critical, .sr-badge-expired { animation: pulse 1s infinite; } + .sr-badge-missing, .sr-badge-none { opacity: 0.5; } + .sr-badge-failed { opacity: 1; } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .sr-btn { border: none; background: transparent; cursor: pointer; font-size: 1em; padding: 4px 8px; border-radius: 4px; transition: all 0.15s; } .sr-btn:hover { background: rgba(0,0,0,0.1); } @@ -359,6 +624,8 @@ return view.extend({ .sr-btn-unpublish:hover { background: rgba(239,68,68,0.15); } .sr-btn-qr { color: #0099cc; } .sr-btn-qr:hover { background: rgba(0,153,204,0.15); } + .sr-btn-check { color: #8b5cf6; font-size: 0.9em; } + .sr-btn-check:hover { background: rgba(139,92,246,0.15); } .sr-btn-regen { margin-left: 10px; font-size: 0.85em; } .sr-qr-modal { display: flex; gap: 30px; justify-content: center; flex-wrap: wrap; padding: 20px 0; } @@ -380,6 +647,8 @@ return view.extend({ @media (max-width: 768px) { .sr-row { flex-wrap: wrap; } .sr-col-url { flex-basis: 100%; order: 10; margin-top: 5px; } + .sr-wizard-form { flex-direction: column; } + .sr-wizard-input { width: 100%; } } `; } diff --git a/package/secubox/luci-app-service-registry/root/usr/libexec/rpcd/luci.service-registry b/package/secubox/luci-app-service-registry/root/usr/libexec/rpcd/luci.service-registry index 65b62876..979e4714 100644 --- a/package/secubox/luci-app-service-registry/root/usr/libexec/rpcd/luci.service-registry +++ b/package/secubox/luci-app-service-registry/root/usr/libexec/rpcd/luci.service-registry @@ -880,6 +880,305 @@ _add_category() { json_close_object } +# Helper: Check DNS resolution for a domain +check_dns_resolution() { + local domain="$1" + local expected_ip="$2" + + # Try nslookup first (most common on OpenWrt) + local resolved_ip + if command -v nslookup >/dev/null 2>&1; then + resolved_ip=$(nslookup "$domain" 2>/dev/null | grep -A1 "Name:" | grep "Address" | head -1 | awk '{print $2}') + [ -z "$resolved_ip" ] && resolved_ip=$(nslookup "$domain" 2>/dev/null | grep "Address" | tail -1 | awk '{print $2}' | grep -v "^$") + elif command -v host >/dev/null 2>&1; then + resolved_ip=$(host "$domain" 2>/dev/null | grep "has address" | head -1 | awk '{print $4}') + fi + + if [ -n "$resolved_ip" ]; then + echo "$resolved_ip" + return 0 + fi + return 1 +} + +# Helper: Get WAN IP address +get_wan_ip() { + local wan_ip="" + # Try to get WAN IP from interface + wan_ip=$(uci -q get network.wan.ipaddr) + if [ -z "$wan_ip" ]; then + # Try to get from ip command + wan_ip=$(ip -4 addr show dev eth0 2>/dev/null | grep -oE 'inet [0-9.]+' | head -1 | awk '{print $2}') + fi + if [ -z "$wan_ip" ]; then + # Try to get public IP (uses local IP for comparison) + wan_ip=$(ifconfig 2>/dev/null | grep -E "inet addr:[0-9]" | grep -v "127.0.0.1" | head -1 | sed 's/.*inet addr:\([0-9.]*\).*/\1/') + fi + echo "$wan_ip" +} + +# Helper: Check certificate expiry +check_cert_expiry() { + local domain="$1" + local cert_file="/srv/haproxy/certs/${domain}.pem" + + if [ ! -f "$cert_file" ]; then + # Try the ACME path + cert_file="/etc/acme/${domain}_ecc/${domain}.cer" + [ ! -f "$cert_file" ] && cert_file="/etc/acme/${domain}/${domain}.cer" + fi + + if [ -f "$cert_file" ]; then + # Get expiry date using openssl + local expiry_date + expiry_date=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2) + if [ -n "$expiry_date" ]; then + # Convert to epoch + local expiry_epoch + expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null) + local now_epoch + now_epoch=$(date +%s) + local days_left + if [ -n "$expiry_epoch" ]; then + days_left=$(( (expiry_epoch - now_epoch) / 86400 )) + echo "$days_left" + return 0 + fi + fi + fi + return 1 +} + +# Helper: Check if external port is accessible (basic check via firewall rules) +check_port_firewall_open() { + local port="$1" + local rule_name="" + + case "$port" in + 80) rule_name="HAProxy-HTTP" ;; + 443) rule_name="HAProxy-HTTPS" ;; + esac + + # Check if firewall rule exists and is enabled + local i=0 + while uci -q get firewall.@rule[$i] >/dev/null 2>&1; do + local name=$(uci -q get firewall.@rule[$i].name) + local enabled=$(uci -q get firewall.@rule[$i].enabled) + local dest_port=$(uci -q get firewall.@rule[$i].dest_port) + if [ "$name" = "$rule_name" ] || [ "$dest_port" = "$port" ]; then + if [ "$enabled" != "0" ]; then + return 0 + fi + fi + i=$((i + 1)) + done + return 1 +} + +# Check health status for a service +method_check_service_health() { + local service_id domain + + read -r input + json_load "$input" + json_get_var service_id service_id + json_get_var domain domain "" + + json_init + + if [ -z "$service_id" ] && [ -z "$domain" ]; then + json_add_boolean "success" 0 + json_add_string "error" "service_id or domain is required" + json_dump + return + fi + + # If only service_id provided, get domain from config + if [ -z "$domain" ] && [ -n "$service_id" ]; then + config_load "$UCI_CONFIG" + config_get domain "$service_id" haproxy_domain "" + fi + + json_add_boolean "success" 1 + json_add_string "service_id" "$service_id" + json_add_string "domain" "$domain" + + # DNS check + json_add_object "dns" + if [ -n "$domain" ]; then + local resolved_ip + resolved_ip=$(check_dns_resolution "$domain") + if [ -n "$resolved_ip" ]; then + json_add_string "status" "ok" + json_add_string "resolved_ip" "$resolved_ip" + else + json_add_string "status" "failed" + json_add_string "error" "DNS resolution failed" + fi + else + json_add_string "status" "none" + fi + json_close_object + + # Certificate check + json_add_object "certificate" + if [ -n "$domain" ]; then + local days_left + days_left=$(check_cert_expiry "$domain") + if [ -n "$days_left" ]; then + if [ "$days_left" -lt 0 ]; then + json_add_string "status" "expired" + elif [ "$days_left" -lt 7 ]; then + json_add_string "status" "critical" + elif [ "$days_left" -lt 30 ]; then + json_add_string "status" "warning" + else + json_add_string "status" "ok" + fi + json_add_int "days_left" "$days_left" + else + json_add_string "status" "missing" + fi + else + json_add_string "status" "none" + fi + json_close_object + + # Port/Firewall check + json_add_object "firewall" + local http_open=0 + local https_open=0 + check_port_firewall_open 80 && http_open=1 + check_port_firewall_open 443 && https_open=1 + + if [ "$http_open" = "1" ] && [ "$https_open" = "1" ]; then + json_add_string "status" "ok" + elif [ "$http_open" = "1" ] || [ "$https_open" = "1" ]; then + json_add_string "status" "partial" + else + json_add_string "status" "closed" + fi + json_add_boolean "http_open" "$http_open" + json_add_boolean "https_open" "$https_open" + json_close_object + + # HAProxy status + json_add_object "haproxy" + if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then + json_add_string "status" "running" + else + json_add_string "status" "stopped" + fi + json_close_object + + json_dump +} + +# Batch health check for all published services (for dashboard) +method_check_all_health() { + json_init + json_add_object "health" + + local wan_ip + wan_ip=$(get_wan_ip) + json_add_string "wan_ip" "$wan_ip" + + # Check HAProxy status + json_add_object "haproxy" + if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then + json_add_string "status" "running" + else + json_add_string "status" "stopped" + fi + json_close_object + + # Check Tor status + json_add_object "tor" + if pgrep -f "/usr/sbin/tor" >/dev/null 2>&1; then + json_add_string "status" "running" + else + json_add_string "status" "stopped" + fi + json_close_object + + # Check firewall ports + json_add_object "firewall" + local http_open=0 + local https_open=0 + check_port_firewall_open 80 && http_open=1 + check_port_firewall_open 443 && https_open=1 + json_add_boolean "http_open" "$http_open" + json_add_boolean "https_open" "$https_open" + if [ "$http_open" = "1" ] && [ "$https_open" = "1" ]; then + json_add_string "status" "ok" + elif [ "$http_open" = "1" ] || [ "$https_open" = "1" ]; then + json_add_string "status" "partial" + else + json_add_string "status" "closed" + fi + json_close_object + + # Check individual services with domains + json_add_array "services" + + # Get all published services with domains from HAProxy + local vhosts_json + vhosts_json=$(ubus call luci.haproxy list_vhosts 2>/dev/null) + if [ -n "$vhosts_json" ]; then + local count + count=$(echo "$vhosts_json" | jsonfilter -e '@.vhosts[*].domain' 2>/dev/null | wc -l) + local i=0 + while [ $i -lt "$count" ]; do + local domain enabled + domain=$(echo "$vhosts_json" | jsonfilter -e "@.vhosts[$i].domain" 2>/dev/null) + enabled=$(echo "$vhosts_json" | jsonfilter -e "@.vhosts[$i].enabled" 2>/dev/null) + + i=$((i + 1)) + + [ -z "$domain" ] && continue + [ "$enabled" = "false" ] || [ "$enabled" = "0" ] && continue + + json_add_object + json_add_string "domain" "$domain" + + # DNS check + local resolved_ip + resolved_ip=$(check_dns_resolution "$domain") + if [ -n "$resolved_ip" ]; then + json_add_string "dns_status" "ok" + json_add_string "dns_ip" "$resolved_ip" + else + json_add_string "dns_status" "failed" + fi + + # Cert check + local days_left + days_left=$(check_cert_expiry "$domain") + if [ -n "$days_left" ]; then + if [ "$days_left" -lt 0 ]; then + json_add_string "cert_status" "expired" + elif [ "$days_left" -lt 7 ]; then + json_add_string "cert_status" "critical" + elif [ "$days_left" -lt 30 ]; then + json_add_string "cert_status" "warning" + else + json_add_string "cert_status" "ok" + fi + json_add_int "cert_days" "$days_left" + else + json_add_string "cert_status" "missing" + fi + + json_close_object + done + fi + + json_close_array + json_close_object + + json_dump +} + # Get certificate status for service method_get_certificate_status() { local service_id @@ -908,13 +1207,27 @@ method_get_certificate_status() { return fi - # Get certificate info from HAProxy - local cert_info - cert_info=$(ubus call luci.haproxy list_certificates 2>/dev/null) + # Get certificate expiry info + local days_left + days_left=$(check_cert_expiry "$haproxy_domain") json_add_boolean "success" 1 json_add_string "domain" "$haproxy_domain" - json_add_string "status" "unknown" + + if [ -n "$days_left" ]; then + if [ "$days_left" -lt 0 ]; then + json_add_string "status" "expired" + elif [ "$days_left" -lt 7 ]; then + json_add_string "status" "critical" + elif [ "$days_left" -lt 30 ]; then + json_add_string "status" "warning" + else + json_add_string "status" "valid" + fi + json_add_int "days_left" "$days_left" + else + json_add_string "status" "missing" + fi json_dump } @@ -978,6 +1291,8 @@ case "$1" in "get_qr_data": { "service_id": "string", "url_type": "string" }, "list_categories": {}, "get_certificate_status": { "service_id": "string" }, + "check_service_health": { "service_id": "string", "domain": "string" }, + "check_all_health": {}, "get_landing_config": {}, "save_landing_config": { "auto_regen": "boolean" } } @@ -996,6 +1311,8 @@ EOF get_qr_data) method_get_qr_data ;; list_categories) method_list_categories ;; get_certificate_status) method_get_certificate_status ;; + check_service_health) method_check_service_health ;; + check_all_health) method_check_all_health ;; get_landing_config) method_get_landing_config ;; save_landing_config) method_save_landing_config ;; *) diff --git a/package/secubox/luci-app-service-registry/root/usr/share/rpcd/acl.d/luci-app-service-registry.json b/package/secubox/luci-app-service-registry/root/usr/share/rpcd/acl.d/luci-app-service-registry.json index a358fe13..f91234d3 100644 --- a/package/secubox/luci-app-service-registry/root/usr/share/rpcd/acl.d/luci-app-service-registry.json +++ b/package/secubox/luci-app-service-registry/root/usr/share/rpcd/acl.d/luci-app-service-registry.json @@ -9,6 +9,8 @@ "list_categories", "get_qr_data", "get_certificate_status", + "check_service_health", + "check_all_health", "get_landing_config" ], "luci.haproxy": [