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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-28 07:01:27 +01:00
parent b762bffa44
commit ab8e0c44bc
4 changed files with 650 additions and 17 deletions

View File

@ -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 || {}
};
});
}
});

View File

@ -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 = '<div class="sr-check-error">Please enter a domain</div>';
return;
}
// Clean domain (remove protocol if present)
domain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
resultsDiv.innerHTML = '<div class="sr-check-loading">🔄 Checking ' + domain + '...</div>';
api.checkServiceHealth('', domain).then(function(result) {
if (!result.success) {
resultsDiv.innerHTML = '<div class="sr-check-error">❌ Check failed: ' + (result.error || 'Unknown error') + '</div>';
return;
}
var html = '<div class="sr-check-grid">';
// DNS Status
var dnsStatus = result.dns || {};
var dnsIcon = healthIcons.dns[dnsStatus.status] || '❓';
var dnsClass = dnsStatus.status === 'ok' ? 'sr-check-ok' : 'sr-check-fail';
html += '<div class="sr-check-item ' + dnsClass + '">';
html += '<span class="sr-check-icon">' + dnsIcon + '</span>';
html += '<span class="sr-check-label">DNS Resolution</span>';
if (dnsStatus.status === 'ok') {
html += '<span class="sr-check-value">✅ Resolves to ' + dnsStatus.resolved_ip + '</span>';
} else {
html += '<span class="sr-check-value">❌ DNS not configured or not resolving</span>';
}
html += '</div>';
// 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 += '<div class="sr-check-item ' + fwClass + '">';
html += '<span class="sr-check-icon">' + fwIcon + '</span>';
html += '<span class="sr-check-label">Firewall Ports</span>';
var ports = [];
if (fwStatus.http_open) ports.push('80');
if (fwStatus.https_open) ports.push('443');
html += '<span class="sr-check-value">' + (ports.length ? 'Open: ' + ports.join(', ') : '❌ Ports 80/443 not open') + '</span>';
html += '</div>';
// 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 += '<div class="sr-check-item ' + certClass + '">';
html += '<span class="sr-check-icon">' + certIcon + '</span>';
html += '<span class="sr-check-label">SSL Certificate</span>';
if (certStatus.status === 'ok' || certStatus.status === 'warning') {
html += '<span class="sr-check-value">' + certStatus.days_left + ' days remaining</span>';
} else if (certStatus.status === 'expired') {
html += '<span class="sr-check-value">❌ Certificate expired</span>';
} else if (certStatus.status === 'missing') {
html += '<span class="sr-check-value">⚪ No certificate (request via HAProxy)</span>';
} else {
html += '<span class="sr-check-value">⚪ Not applicable</span>';
}
html += '</div>';
// HAProxy Status
var haStatus = result.haproxy || {};
var haIcon = haStatus.status === 'running' ? '🟢' : '🔴';
var haClass = haStatus.status === 'running' ? 'sr-check-ok' : 'sr-check-fail';
html += '<div class="sr-check-item ' + haClass + '">';
html += '<span class="sr-check-icon">' + haIcon + '</span>';
html += '<span class="sr-check-label">HAProxy</span>';
html += '<span class="sr-check-value">' + (haStatus.status === 'running' ? '✅ Running' : '❌ Not running') + '</span>';
html += '</div>';
html += '</div>';
// Summary and recommendation
var allOk = dnsStatus.status === 'ok' && fwStatus.status === 'ok' && haStatus.status === 'running';
var needsCert = certStatus.status === 'missing';
html += '<div class="sr-check-summary">';
if (allOk && !needsCert) {
html += '<div class="sr-check-ready">✅ ' + domain + ' is ready and serving!</div>';
} else if (allOk && needsCert) {
html += '<div class="sr-check-almost">⚠️ ' + domain + ' is ready - just need SSL certificate</div>';
html += '<a href="/cgi-bin/luci/admin/services/haproxy/certificates" class="sr-check-action">📜 Request Certificate</a>';
} else {
html += '<div class="sr-check-notready">❌ ' + domain + ' needs configuration</div>';
if (dnsStatus.status !== 'ok') {
html += '<div class="sr-check-tip">💡 Point DNS A record to your public IP</div>';
}
if (fwStatus.status !== 'ok') {
html += '<div class="sr-check-tip">💡 Open ports 80 and 443 in firewall</div>';
}
if (haStatus.status !== 'running') {
html += '<div class="sr-check-tip">💡 Start HAProxy container</div>';
}
}
html += '</div>';
resultsDiv.innerHTML = html;
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="sr-check-error">❌ Error: ' + err.message + '</div>';
});
},
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%; }
}
`;
}

View File

@ -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 ;;
*)

View File

@ -9,6 +9,8 @@
"list_categories",
"get_qr_data",
"get_certificate_status",
"check_service_health",
"check_all_health",
"get_landing_config"
],
"luci.haproxy": [