diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index aebef077..725039b2 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -4382,3 +4382,25 @@ git checkout HEAD -- index.html - `docs/sbom-pipeline.md` - Architecture, usage, CRA mapping - `SECURITY.md` - CRA Art. 13 ยง6 compliant disclosure policy - VEX policy reference + +73. **Routes Status Dashboard & User Services (2026-03-04)** + - **New Package: `luci-app-routes-status`** + - HAProxy vhosts status dashboard + - mitmproxy route configuration status (OUT/IN routes) + - SSL certificate status indicators + - WAF bypass detection (vhosts not using mitmproxy_inspector) + - Route sync and add missing route actions + - RPCD backend with optimized batch processing for 200+ vhosts + - **Shell Scripting Fixes:** + - `luci.network-modes`: Removed 60 lines of orphan dead code after `esac` + - `luci.netdata-dashboard`: Fixed bash process substitution to POSIX awk + - **Service User Extensions:** + - `secubox-core-users`: Added gitea and jellyfin service support + - `luci-app-secubox-users`: Added Jellyfin checkbox in frontend + - **LXC Networking Fix:** + - Discovered: LXC containers can't reach host's `127.0.0.1` + - Fixed mitmproxy routes in nextcloudctl, metablogizerctl, mitmproxyctl + - Use host LAN IP (192.168.255.1) instead of localhost for route targets + - **Mailserver Recovery:** + - Fixed webmail.gk2.secubox.in login error by starting mailserver container + - Verified IMAP/SMTP connectivity (ports 143, 993, 25, 587, 465) diff --git a/package/secubox/luci-app-routes-status/Makefile b/package/secubox/luci-app-routes-status/Makefile new file mode 100644 index 00000000..50845632 --- /dev/null +++ b/package/secubox/luci-app-routes-status/Makefile @@ -0,0 +1,31 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-routes-status +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=SecuBox Team +PKG_LICENSE:=GPL-3.0-or-later + +LUCI_TITLE:=LuCI Routes Status Dashboard +LUCI_DESCRIPTION:=Dashboard showing HAProxy vhosts and mitmproxy route status +LUCI_DEPENDS:=+luci-base +secubox-app-haproxy +secubox-app-mitmproxy +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/$(PKG_NAME)/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.routes-status $(1)/usr/libexec/rpcd/ + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-routes-status.json $(1)/usr/share/rpcd/acl.d/ + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-routes-status.json $(1)/usr/share/luci/menu.d/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/routes-status + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/routes-status/*.js $(1)/www/luci-static/resources/view/routes-status/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-routes-status/htdocs/luci-static/resources/view/routes-status/overview.js b/package/secubox/luci-app-routes-status/htdocs/luci-static/resources/view/routes-status/overview.js new file mode 100644 index 00000000..0ede95c9 --- /dev/null +++ b/package/secubox/luci-app-routes-status/htdocs/luci-static/resources/view/routes-status/overview.js @@ -0,0 +1,271 @@ +'use strict'; +'require view'; +'require dom'; +'require ui'; +'require rpc'; + +var callStatus = rpc.declare({ + object: 'luci.routes-status', + method: 'status', + expect: { } +}); + +var callSyncRoutes = rpc.declare({ + object: 'luci.routes-status', + method: 'sync_routes', + expect: { } +}); + +var callAddRoute = rpc.declare({ + object: 'luci.routes-status', + method: 'add_route', + params: ['domain', 'port'], + expect: { } +}); + +return view.extend({ + load: function() { + return callStatus(); + }, + + renderStatusBadge: function(running, label) { + var color = running ? '#4CAF50' : '#f44336'; + return E('span', { + 'style': 'display:inline-block;padding:4px 12px;margin:4px;border-radius:4px;color:#fff;background:' + color + ';font-size:0.9em;font-weight:500;' + }, label + ': ' + (running ? 'Running' : 'Stopped')); + }, + + renderRouteBadge: function(hasRoute, type) { + if (hasRoute) { + return E('span', { + 'style': 'display:inline-block;padding:2px 8px;margin:2px;border-radius:3px;color:#fff;background:#4CAF50;font-size:0.8em;' + }, type); + } else { + return E('span', { + 'style': 'display:inline-block;padding:2px 8px;margin:2px;border-radius:3px;color:#fff;background:#ff9800;font-size:0.8em;' + }, type + ' (missing)'); + } + }, + + renderSslBadge: function(status) { + var color, text; + if (status === 'missing') { + color = '#9e9e9e'; + text = 'No SSL'; + } else if (status === 'expired') { + color = '#f44336'; + text = 'Expired'; + } else if (status && status.indexOf('expiring:') === 0) { + var days = status.split(':')[1]; + color = '#ff9800'; + text = 'Expires in ' + days + 'd'; + } else if (status && status.indexOf('valid:') === 0) { + var days = status.split(':')[1]; + color = '#4CAF50'; + text = 'Valid (' + days + 'd)'; + } else { + color = '#4CAF50'; + text = 'Valid'; + } + + return E('span', { + 'style': 'display:inline-block;padding:2px 8px;margin:2px;border-radius:3px;color:#fff;background:' + color + ';font-size:0.8em;' + }, text); + }, + + renderWafBadge: function(bypass) { + if (bypass) { + return E('span', { + 'style': 'display:inline-block;padding:2px 8px;margin:2px;border-radius:3px;color:#fff;background:#f44336;font-size:0.8em;' + }, 'WAF Bypass'); + } else { + return E('span', { + 'style': 'display:inline-block;padding:2px 8px;margin:2px;border-radius:3px;color:#fff;background:#2196F3;font-size:0.8em;' + }, 'WAF Protected'); + } + }, + + handleSync: function() { + var self = this; + + ui.showModal(_('Syncing Routes...'), [ + E('p', { 'class': 'spinning' }, _('Please wait...')) + ]); + + callSyncRoutes().then(function(res) { + ui.hideModal(); + if (res && res.success) { + ui.addNotification(null, E('p', {}, _('Routes synchronized successfully')), 'success'); + location.reload(); + } else { + ui.addNotification(null, E('p', {}, _('Error: ') + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + handleAddRoute: function(domain, port) { + var self = this; + + if (!port) { + // Ask for port + ui.showModal(_('Add Route'), [ + E('div', { 'class': 'cbi-section' }, [ + E('p', {}, _('Add mitmproxy route for: %s').format(domain)), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Backend Port')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { 'type': 'number', 'id': 'route-port', 'value': '443', 'style': 'width:100px;' }) + ]) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), + E('button', { + 'class': 'btn cbi-button-positive', + 'click': function() { + var port = parseInt(document.getElementById('route-port').value, 10); + if (port > 0) { + ui.hideModal(); + self.doAddRoute(domain, port); + } + }, + 'style': 'margin-left:10px;' + }, _('Add Route')) + ]) + ]); + } else { + this.doAddRoute(domain, parseInt(port, 10)); + } + }, + + doAddRoute: function(domain, port) { + ui.showModal(_('Adding Route...'), [ + E('p', { 'class': 'spinning' }, _('Please wait...')) + ]); + + callAddRoute(domain, port).then(function(res) { + ui.hideModal(); + if (res && res.success) { + ui.addNotification(null, E('p', {}, _('Route added successfully')), 'success'); + location.reload(); + } else { + ui.addNotification(null, E('p', {}, _('Error: ') + (res.error || 'Unknown error')), 'error'); + } + }); + }, + + renderVhostRow: function(vhost) { + var self = this; + var missingRoutes = !vhost.has_route_out || !vhost.has_route_in; + + return E('tr', { 'class': vhost.active ? '' : 'inactive' }, [ + E('td', {}, [ + E('a', { + 'href': 'https://' + vhost.domain, + 'target': '_blank', + 'style': 'color:#1976D2;text-decoration:none;' + }, vhost.domain) + ]), + E('td', {}, vhost.backend || '-'), + E('td', { 'style': 'text-align:center;' }, vhost.backend_port || '-'), + E('td', {}, [ + this.renderRouteBadge(vhost.has_route_out, 'OUT'), + this.renderRouteBadge(vhost.has_route_in, 'IN') + ]), + E('td', {}, this.renderSslBadge(vhost.ssl_status)), + E('td', {}, this.renderWafBadge(vhost.waf_bypass)), + E('td', {}, [ + vhost.active ? + E('span', { 'style': 'color:#4CAF50;font-weight:bold;' }, 'Active') : + E('span', { 'style': 'color:#9e9e9e;' }, 'Inactive'), + missingRoutes ? E('button', { + 'class': 'btn cbi-button', + 'click': function() { self.handleAddRoute(vhost.domain, vhost.backend_port); }, + 'style': 'margin-left:10px;font-size:0.8em;padding:2px 8px;' + }, _('Add Route')) : null + ]) + ]); + }, + + render: function(data) { + var self = this; + var vhosts = data.vhosts || []; + + // Sort by domain + vhosts.sort(function(a, b) { + return a.domain.localeCompare(b.domain); + }); + + // Count stats + var totalVhosts = vhosts.length; + var activeVhosts = vhosts.filter(function(v) { return v.active; }).length; + var missingRoutes = vhosts.filter(function(v) { return !v.has_route_out || !v.has_route_in; }).length; + var wafBypassed = vhosts.filter(function(v) { return v.waf_bypass; }).length; + + var content = []; + + // Header + content.push(E('h2', {}, _('Routes Status Dashboard'))); + + // Service Status + content.push(E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Service Status')), + E('div', { 'style': 'margin:10px 0;' }, [ + this.renderStatusBadge(data.haproxy_running, 'HAProxy'), + this.renderStatusBadge(data.mitmproxy_running, 'mitmproxy') + ]), + E('p', { 'style': 'color:#666;' }, _('Host IP: %s').format(data.host_ip || '192.168.255.1')) + ])); + + // Statistics + content.push(E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Statistics')), + E('div', { 'style': 'display:flex;gap:30px;margin:15px 0;' }, [ + E('div', { 'style': 'text-align:center;' }, [ + E('div', { 'style': 'font-size:2em;font-weight:bold;color:#1976D2;' }, String(totalVhosts)), + E('div', { 'style': 'color:#666;' }, _('Total Vhosts')) + ]), + E('div', { 'style': 'text-align:center;' }, [ + E('div', { 'style': 'font-size:2em;font-weight:bold;color:#4CAF50;' }, String(activeVhosts)), + E('div', { 'style': 'color:#666;' }, _('Active')) + ]), + E('div', { 'style': 'text-align:center;' }, [ + E('div', { 'style': 'font-size:2em;font-weight:bold;color:' + (missingRoutes > 0 ? '#ff9800' : '#4CAF50') + ';' }, String(missingRoutes)), + E('div', { 'style': 'color:#666;' }, _('Missing Routes')) + ]), + E('div', { 'style': 'text-align:center;' }, [ + E('div', { 'style': 'font-size:2em;font-weight:bold;color:' + (wafBypassed > 0 ? '#f44336' : '#4CAF50') + ';' }, String(wafBypassed)), + E('div', { 'style': 'color:#666;' }, _('WAF Bypassed')) + ]) + ]) + ])); + + // Vhosts Table + var vhostRows = vhosts.map(function(v) { return self.renderVhostRow(v); }); + + content.push(E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Virtual Hosts')), + E('div', { 'class': 'cbi-page-actions', 'style': 'margin-bottom:15px;' }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { self.handleSync(); } + }, _('Sync Routes from HAProxy')) + ]), + vhosts.length > 0 ? + E('table', { 'class': 'table cbi-section-table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Domain')), + E('th', { 'class': 'th' }, _('Backend')), + E('th', { 'class': 'th', 'style': 'text-align:center;' }, _('Port')), + E('th', { 'class': 'th' }, _('Routes')), + E('th', { 'class': 'th' }, _('SSL')), + E('th', { 'class': 'th' }, _('WAF')), + E('th', { 'class': 'th' }, _('Status')) + ]) + ].concat(vhostRows)) : + E('p', { 'style': 'color:#666;' }, _('No virtual hosts configured.')) + ])); + + return E('div', { 'class': 'cbi-map' }, content); + } +}); diff --git a/package/secubox/luci-app-routes-status/root/usr/libexec/rpcd/luci.routes-status b/package/secubox/luci-app-routes-status/root/usr/libexec/rpcd/luci.routes-status new file mode 100755 index 00000000..45b25d43 --- /dev/null +++ b/package/secubox/luci-app-routes-status/root/usr/libexec/rpcd/luci.routes-status @@ -0,0 +1,205 @@ +#!/bin/sh +# RPCD handler for Routes Status dashboard +# Shows HAProxy vhosts and mitmproxy route configuration status + +. /usr/share/libubox/jshn.sh + +MITMPROXY_ROUTES="/srv/mitmproxy/haproxy-routes.json" +MITMPROXY_IN_ROUTES="/srv/mitmproxy-in/haproxy-routes.json" +HAPROXY_CERTS="/srv/haproxy/certs" + +# Get host LAN IP for route configuration +get_host_ip() { + uci -q get network.lan.ipaddr || echo "192.168.255.1" +} + +# Main status method - optimized to fetch data once +method_status() { + local host_ip=$(get_host_ip) + local haproxy_running=$(pgrep haproxy >/dev/null 2>&1 && echo "true" || echo "false") + local mitmproxy_running=$(pgrep -f mitmproxy >/dev/null 2>&1 && echo "true" || echo "false") + + # Fetch vhost list once to temp file + local vhost_tmp="/tmp/vhosts_$$" + if command -v haproxyctl >/dev/null 2>&1; then + haproxyctl vhost list 2>/dev/null | tail -n +3 > "$vhost_tmp" + else + touch "$vhost_tmp" + fi + + json_init + json_add_boolean haproxy_running "$haproxy_running" + json_add_boolean mitmproxy_running "$mitmproxy_running" + json_add_string host_ip "$host_ip" + + json_add_array vhosts + + # Process vhost data line by line (using file redirection to avoid subshell) + while IFS= read -r line; do + [ -z "$line" ] && continue + + # Parse line: " domain.com -> backend_name [enabled] SSL ..." + local domain=$(echo "$line" | awk '{print $1}') + local backend=$(echo "$line" | awk '{print $3}') + local enabled=$(echo "$line" | grep -qF '[enabled]' && echo "true" || echo "false") + + [ -z "$domain" ] && continue + + # Check mitmproxy routes (grep for domain in JSON) + local has_route_out="false" + local has_route_in="false" + if [ -f "$MITMPROXY_ROUTES" ] && grep -qF "\"$domain\"" "$MITMPROXY_ROUTES" 2>/dev/null; then + has_route_out="true" + fi + if [ -f "$MITMPROXY_IN_ROUTES" ] && grep -qF "\"$domain\"" "$MITMPROXY_IN_ROUTES" 2>/dev/null; then + has_route_in="true" + fi + + # Check SSL cert + local ssl_status="missing" + [ -f "$HAPROXY_CERTS/${domain}.pem" ] && ssl_status="valid" + + # WAF bypass check + local waf_bypass="false" + [ "$backend" != "mitmproxy_inspector" ] && waf_bypass="true" + + json_add_object "" + json_add_string domain "$domain" + json_add_string backend "$backend" + json_add_string backend_port "" + json_add_boolean active "$enabled" + json_add_string ssl_status "$ssl_status" + json_add_boolean has_route_out "$has_route_out" + json_add_boolean has_route_in "$has_route_in" + json_add_string route_target_out "" + json_add_string route_target_in "" + json_add_boolean waf_bypass "$waf_bypass" + json_close_object + done < "$vhost_tmp" + + json_close_array + json_dump + + # Cleanup + rm -f "$vhost_tmp" +} + +# Sync routes from HAProxy backends to mitmproxy +method_sync_routes() { + local result + if [ -x /usr/sbin/mitmproxyctl ]; then + result=$(/usr/sbin/mitmproxyctl sync-routes 2>&1) + json_init + json_add_boolean success true + json_add_string output "$result" + json_dump + else + json_init + json_add_boolean success false + json_add_string error "mitmproxyctl not found" + json_dump + fi +} + +# Add a missing route for a domain +method_add_route() { + local domain port + + # Read JSON input + read -r input + json_load "$input" + json_get_var domain domain + json_get_var port port + + if [ -z "$domain" ] || [ -z "$port" ]; then + json_init + json_add_boolean success false + json_add_string error "Missing domain or port parameter" + json_dump + return + fi + + local host_ip=$(get_host_ip) + + # Add route to both mitmproxy route files + local success="true" + local errors="" + + for routes_file in "$MITMPROXY_ROUTES" "$MITMPROXY_IN_ROUTES"; do + if [ -f "$routes_file" ]; then + # Create temp file with new route + local tmpfile=$(mktemp) + if command -v jq >/dev/null 2>&1; then + jq --arg d "$domain" --arg h "$host_ip" --argjson p "$port" \ + '. + {($d): [$h, $p]}' "$routes_file" > "$tmpfile" 2>/dev/null + else + # Fallback: manual JSON manipulation + # Remove trailing } and add new entry + sed 's/}$//' "$routes_file" > "$tmpfile" + # Check if file has content (not empty object) + if grep -q '": \[' "$routes_file"; then + printf ',\n "%s": ["%s", %s]\n}\n' "$domain" "$host_ip" "$port" >> "$tmpfile" + else + printf ' "%s": ["%s", %s]\n}\n' "$domain" "$host_ip" "$port" >> "$tmpfile" + fi + fi + + if [ -s "$tmpfile" ]; then + mv "$tmpfile" "$routes_file" + else + rm -f "$tmpfile" + success="false" + errors="$errors Failed to update $routes_file." + fi + fi + done + + # Restart mitmproxy to apply changes + if [ "$success" = "true" ]; then + /etc/init.d/mitmproxy restart >/dev/null 2>&1 + fi + + json_init + json_add_boolean success "$success" + [ -n "$errors" ] && json_add_string error "$errors" + json_dump +} + +# List available methods +list_methods() { + json_init + json_add_object status + json_close_object + json_add_object sync_routes + json_close_object + json_add_object add_route + json_add_string domain "string" + json_add_int port 0 + json_close_object + json_dump +} + +case "$1" in + list) + list_methods + ;; + call) + case "$2" in + status) + method_status + ;; + sync_routes) + method_sync_routes + ;; + add_route) + method_add_route + ;; + *) + echo '{"error":"Unknown method"}' + ;; + esac + ;; + *) + echo '{"error":"Unknown action"}' + ;; +esac diff --git a/package/secubox/luci-app-routes-status/root/usr/share/luci/menu.d/luci-app-routes-status.json b/package/secubox/luci-app-routes-status/root/usr/share/luci/menu.d/luci-app-routes-status.json new file mode 100644 index 00000000..dc960942 --- /dev/null +++ b/package/secubox/luci-app-routes-status/root/usr/share/luci/menu.d/luci-app-routes-status.json @@ -0,0 +1,14 @@ +{ + "admin/status/routes": { + "title": "Routes Status", + "order": 15, + "action": { + "type": "view", + "path": "routes-status/overview" + }, + "depends": { + "acl": ["luci-app-routes-status"], + "uci": {} + } + } +} diff --git a/package/secubox/luci-app-routes-status/root/usr/share/rpcd/acl.d/luci-app-routes-status.json b/package/secubox/luci-app-routes-status/root/usr/share/rpcd/acl.d/luci-app-routes-status.json new file mode 100644 index 00000000..cb366c22 --- /dev/null +++ b/package/secubox/luci-app-routes-status/root/usr/share/rpcd/acl.d/luci-app-routes-status.json @@ -0,0 +1,15 @@ +{ + "luci-app-routes-status": { + "description": "Grant access to Routes Status dashboard", + "read": { + "ubus": { + "luci.routes-status": ["status"] + } + }, + "write": { + "ubus": { + "luci.routes-status": ["sync_routes", "add_route"] + } + } + } +}