From eec83efa13c76337820d6209c318fac77b8a25e8 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 31 Jan 2026 09:19:36 +0100 Subject: [PATCH] feat(p2p): Add MirrorBox NetMesh Catalog with DNS federation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement distributed service catalog that discovers HAProxy vhosts and provides multi-endpoint access URLs (haproxy/mesh/local). Add dynamic DNS federation that auto-populates dnsmasq with mesh peer hostnames (hostname.mesh.local). New features: - /factory/catalog API endpoint with service registry - Catalog tab (πŸ“š) in Factory UI with endpoint filtering - QR codes with URL type switching (haproxy/mesh/local) - Linked mesh peers navigation panel - DNS federation via /tmp/hosts/secubox-mesh - CLI commands: dns-enable/disable/update, catalog sync/list/generate Bumps secubox-p2p to v0.6.0. Co-Authored-By: Claude Opus 4.5 --- .../root/www/secubox-feed/Packages | 143 +++--- .../root/www/secubox-feed/Packages.gz | Bin 7914 -> 7943 bytes .../secubox-feed/secubox-p2p_0.5.0-r1_all.ipk | Bin 0 -> 30574 bytes package/secubox/secubox-p2p/Makefile | 9 +- .../root/usr/lib/secubox/factory.sh | 198 ++++++++ .../secubox-p2p/root/usr/sbin/secubox-p2p | 234 ++++++++- .../secubox-p2p/root/www/api/factory/catalog | 351 ++++++++++++++ .../secubox-p2p/root/www/api/factory/run | 55 +++ .../secubox-p2p/root/www/api/factory/tools | 66 ++- .../secubox-p2p/root/www/factory/index.html | 454 +++++++++++++++++- 10 files changed, 1412 insertions(+), 98 deletions(-) create mode 100644 package/secubox/secubox-app-bonus/root/www/secubox-feed/secubox-p2p_0.5.0-r1_all.ipk create mode 100644 package/secubox/secubox-p2p/root/www/api/factory/catalog diff --git a/package/secubox/secubox-app-bonus/root/www/secubox-feed/Packages b/package/secubox/secubox-app-bonus/root/www/secubox-feed/Packages index 286c9185..8b92e065 100644 --- a/package/secubox/secubox-app-bonus/root/www/secubox-feed/Packages +++ b/package/secubox/secubox-app-bonus/root/www/secubox-feed/Packages @@ -20,7 +20,7 @@ Architecture: all Installed-Size: 378880 Description: Advanced bandwidth management with QoS rules, client quotas, and SQM integration Filename: luci-app-bandwidth-manager_0.5.0-r2_all.ipk -Size: 66966 +Size: 66972 Package: luci-app-cdn-cache Version: 0.5.0-r3 @@ -32,7 +32,7 @@ Architecture: all Installed-Size: 122880 Description: Dashboard for managing local CDN caching proxy on OpenWrt Filename: luci-app-cdn-cache_0.5.0-r3_all.ipk -Size: 23189 +Size: 23190 Package: luci-app-client-guardian Version: 0.4.0-r7 @@ -44,7 +44,7 @@ Architecture: all Installed-Size: 307200 Description: Network Access Control with client monitoring, zone management, captive portal, parental controls, and SMS/email alerts Filename: luci-app-client-guardian_0.4.0-r7_all.ipk -Size: 57042 +Size: 57045 Package: luci-app-crowdsec-dashboard Version: 0.7.0-r29 @@ -56,7 +56,7 @@ Architecture: all Installed-Size: 296960 Description: Real-time security monitoring dashboard for CrowdSec on OpenWrt Filename: luci-app-crowdsec-dashboard_0.7.0-r29_all.ipk -Size: 55585 +Size: 55583 Package: luci-app-cyberfeed Version: 0.1.1-r1 @@ -68,7 +68,7 @@ Architecture: all Installed-Size: 71680 Description: Cyberpunk-themed RSS feed aggregator dashboard with social media support Filename: luci-app-cyberfeed_0.1.1-r1_all.ipk -Size: 12838 +Size: 12841 Package: luci-app-exposure Version: 1.0.0-r3 @@ -92,7 +92,7 @@ Architecture: all Installed-Size: 92160 Description: Modern dashboard for Gitea Platform management on OpenWrt Filename: luci-app-gitea_1.0.0-r2_all.ipk -Size: 15585 +Size: 15589 Package: luci-app-glances Version: 1.0.0-r2 @@ -104,7 +104,7 @@ Architecture: all Installed-Size: 40960 Description: Modern dashboard for Glances system monitoring with SecuBox theme Filename: luci-app-glances_1.0.0-r2_all.ipk -Size: 6963 +Size: 6968 Package: luci-app-haproxy Version: 1.0.0-r8 @@ -116,7 +116,7 @@ Architecture: all Installed-Size: 204800 Description: Web interface for managing HAProxy load balancer with vhosts, SSL certificates, and backend routing Filename: luci-app-haproxy_1.0.0-r8_all.ipk -Size: 34168 +Size: 34166 Package: luci-app-hexojs Version: 1.0.0-r3 @@ -128,7 +128,7 @@ Architecture: all Installed-Size: 215040 Description: Modern dashboard for Hexo static site generator on OpenWrt Filename: luci-app-hexojs_1.0.0-r3_all.ipk -Size: 32974 +Size: 32975 Package: luci-app-ksm-manager Version: 0.4.0-r2 @@ -140,7 +140,7 @@ Architecture: all Installed-Size: 112640 Description: Centralized cryptographic key management with hardware security module (HSM) support for Nitrokey and YubiKey devices. Provides secure key storage, certificate management, SSH key handling, and secret storage with audit logging. Filename: luci-app-ksm-manager_0.4.0-r2_all.ipk -Size: 18720 +Size: 18721 Package: luci-app-localai Version: 0.1.0-r15 @@ -164,7 +164,7 @@ Architecture: all Installed-Size: 40960 Description: LuCI support for Lyrion Music Server Filename: luci-app-lyrion_1.0.0-r1_all.ipk -Size: 6725 +Size: 6730 Package: luci-app-magicmirror2 Version: 0.4.0-r6 @@ -176,7 +176,7 @@ Architecture: all Installed-Size: 71680 Description: Modern dashboard for MagicMirror2 smart display platform with module manager and SecuBox theme Filename: luci-app-magicmirror2_0.4.0-r6_all.ipk -Size: 12274 +Size: 12277 Package: luci-app-mailinabox Version: 1.0.0-r1 @@ -188,7 +188,7 @@ Architecture: all Installed-Size: 30720 Description: LuCI support for Mail-in-a-Box Filename: luci-app-mailinabox_1.0.0-r1_all.ipk -Size: 5481 +Size: 5482 Package: luci-app-media-flow Version: 0.6.4-r1 @@ -200,7 +200,7 @@ Architecture: all Installed-Size: 102400 Description: Real-time detection and monitoring of streaming services (Netflix, YouTube, Spotify, etc.) with quality estimation, history tracking, and alerts. Supports nDPId local DPI and netifyd. Filename: luci-app-media-flow_0.6.4-r1_all.ipk -Size: 19120 +Size: 19117 Package: luci-app-metablogizer Version: 1.0.0-r3 @@ -212,7 +212,7 @@ Architecture: all Installed-Size: 112640 Description: LuCI support for MetaBlogizer Static Site Publisher Filename: luci-app-metablogizer_1.0.0-r3_all.ipk -Size: 23503 +Size: 23501 Package: luci-app-metabolizer Version: 1.0.0-r2 @@ -236,7 +236,7 @@ Architecture: all Installed-Size: 102400 Description: Modern dashboard for mitmproxy HTTPS traffic inspection with SecuBox theme Filename: luci-app-mitmproxy_0.4.0-r6_all.ipk -Size: 18933 +Size: 18932 Package: luci-app-mmpm Version: 0.2.0-r3 @@ -248,7 +248,7 @@ Architecture: all Installed-Size: 51200 Description: Web interface for MMPM - MagicMirror Package Manager Filename: luci-app-mmpm_0.2.0-r3_all.ipk -Size: 7899 +Size: 7901 Package: luci-app-mqtt-bridge Version: 0.4.0-r4 @@ -260,7 +260,7 @@ Architecture: all Installed-Size: 122880 Description: USB-to-MQTT IoT hub with SecuBox theme Filename: luci-app-mqtt-bridge_0.4.0-r4_all.ipk -Size: 22777 +Size: 22779 Package: luci-app-ndpid Version: 1.1.2-r2 @@ -272,7 +272,7 @@ Architecture: all Installed-Size: 122880 Description: Modern dashboard for nDPId deep packet inspection on OpenWrt Filename: luci-app-ndpid_1.1.2-r2_all.ipk -Size: 22455 +Size: 22457 Package: luci-app-netdata-dashboard Version: 0.5.0-r2 @@ -284,7 +284,7 @@ Architecture: all Installed-Size: 133120 Description: Real-time system monitoring dashboard with Netdata integration for OpenWrt Filename: luci-app-netdata-dashboard_0.5.0-r2_all.ipk -Size: 22396 +Size: 22400 Package: luci-app-network-modes Version: 0.5.0-r3 @@ -296,7 +296,7 @@ Architecture: all Installed-Size: 307200 Description: Configure OpenWrt for different network modes: Sniffer, Access Point, Relay, Router Filename: luci-app-network-modes_0.5.0-r3_all.ipk -Size: 55608 +Size: 55613 Package: luci-app-network-tweaks Version: 1.0.0-r7 @@ -308,7 +308,7 @@ Architecture: all Installed-Size: 81920 Description: Unified network services dashboard with DNS/hosts sync, CDN cache control, and WPAD auto-proxy configuration Filename: luci-app-network-tweaks_1.0.0-r7_all.ipk -Size: 15455 +Size: 15466 Package: luci-app-nextcloud Version: 1.0.0-r1 @@ -320,7 +320,7 @@ Architecture: all Installed-Size: 30720 Description: LuCI support for Nextcloud Filename: luci-app-nextcloud_1.0.0-r1_all.ipk -Size: 6485 +Size: 6487 Package: luci-app-ollama Version: 0.1.0-r1 @@ -332,7 +332,7 @@ Architecture: all Installed-Size: 71680 Description: Modern dashboard for Ollama LLM management on OpenWrt Filename: luci-app-ollama_0.1.0-r1_all.ipk -Size: 11991 +Size: 11998 Package: luci-app-picobrew Version: 1.0.0-r1 @@ -344,7 +344,7 @@ Architecture: all Installed-Size: 51200 Description: Modern dashboard for PicoBrew Server management on OpenWrt Filename: luci-app-picobrew_1.0.0-r1_all.ipk -Size: 9972 +Size: 9979 Package: luci-app-secubox Version: 0.7.1-r4 @@ -356,7 +356,7 @@ Architecture: all Installed-Size: 266240 Description: Central control hub for all SecuBox modules. Provides unified dashboard, module status, system health monitoring, and quick actions. Filename: luci-app-secubox_0.7.1-r4_all.ipk -Size: 49900 +Size: 49901 Package: luci-app-secubox-admin Version: 1.0.0-r19 @@ -367,7 +367,7 @@ Architecture: all Installed-Size: 337920 Description: Unified admin control center for SecuBox appstore plugins with system monitoring Filename: luci-app-secubox-admin_1.0.0-r19_all.ipk -Size: 57094 +Size: 57098 Package: luci-app-secubox-crowdsec Version: 1.0.0-r3 @@ -379,7 +379,7 @@ Architecture: all Installed-Size: 81920 Description: LuCI SecuBox CrowdSec Dashboard Filename: luci-app-secubox-crowdsec_1.0.0-r3_all.ipk -Size: 13914 +Size: 13925 Package: luci-app-secubox-netdiag Version: 1.0.0-r1 @@ -391,7 +391,7 @@ Architecture: all Installed-Size: 61440 Description: Real-time DSA switch port statistics, error monitoring, and network health diagnostics Filename: luci-app-secubox-netdiag_1.0.0-r1_all.ipk -Size: 12000 +Size: 12002 Package: luci-app-secubox-netifyd Version: 1.2.1-r1 @@ -415,7 +415,7 @@ Architecture: all Installed-Size: 215040 Description: LuCI SecuBox P2P Hub Filename: luci-app-secubox-p2p_0.1.0-r1_all.ipk -Size: 39254 +Size: 39257 Package: luci-app-secubox-portal Version: 0.7.0-r2 @@ -427,7 +427,7 @@ Architecture: all Installed-Size: 122880 Description: Unified entry point for all SecuBox applications with tabbed navigation Filename: luci-app-secubox-portal_0.7.0-r2_all.ipk -Size: 24553 +Size: 24557 Package: luci-app-secubox-security-threats Version: 1.0.0-r4 @@ -439,7 +439,7 @@ Architecture: all Installed-Size: 71680 Description: Unified dashboard integrating netifyd DPI threats with CrowdSec intelligence for real-time threat monitoring and automated blocking Filename: luci-app-secubox-security-threats_1.0.0-r4_all.ipk -Size: 13899 +Size: 13906 Package: luci-app-service-registry Version: 1.0.0-r1 @@ -451,7 +451,7 @@ Architecture: all Installed-Size: 194560 Description: Unified service aggregation with HAProxy vhosts, Tor hidden services, and QR-coded landing page Filename: luci-app-service-registry_1.0.0-r1_all.ipk -Size: 39826 +Size: 39827 Package: luci-app-streamlit Version: 1.0.0-r9 @@ -463,7 +463,7 @@ Architecture: all Installed-Size: 122880 Description: Modern dashboard for Streamlit Platform management on OpenWrt Filename: luci-app-streamlit_1.0.0-r9_all.ipk -Size: 20470 +Size: 20471 Package: luci-app-system-hub Version: 0.5.1-r4 @@ -475,7 +475,7 @@ Architecture: all Installed-Size: 358400 Description: Central system control with monitoring, services, logs, and backup Filename: luci-app-system-hub_0.5.1-r4_all.ipk -Size: 66345 +Size: 66351 Package: luci-app-tor-shield Version: 1.0.0-r10 @@ -487,7 +487,7 @@ Architecture: all Installed-Size: 133120 Description: Modern dashboard for Tor anonymization on OpenWrt Filename: luci-app-tor-shield_1.0.0-r10_all.ipk -Size: 24532 +Size: 24537 Package: luci-app-traffic-shaper Version: 0.4.0-r2 @@ -499,7 +499,7 @@ Architecture: all Installed-Size: 92160 Description: Advanced traffic shaping with TC/CAKE for precise bandwidth control Filename: luci-app-traffic-shaper_0.4.0-r2_all.ipk -Size: 15635 +Size: 15637 Package: luci-app-vhost-manager Version: 0.5.0-r5 @@ -511,7 +511,7 @@ Architecture: all Installed-Size: 153600 Description: Nginx reverse proxy manager with Let's Encrypt SSL certificates, authentication, and WebSocket support Filename: luci-app-vhost-manager_0.5.0-r5_all.ipk -Size: 26199 +Size: 26204 Package: luci-app-wireguard-dashboard Version: 0.7.0-r5 @@ -523,7 +523,7 @@ Architecture: all Installed-Size: 235520 Description: Modern dashboard for WireGuard VPN monitoring on OpenWrt Filename: luci-app-wireguard-dashboard_0.7.0-r5_all.ipk -Size: 45368 +Size: 45372 Package: luci-app-zigbee2mqtt Version: 1.0.0-r2 @@ -535,7 +535,7 @@ Architecture: all Installed-Size: 40960 Description: Graphical interface for managing the Zigbee2MQTT docker application. Filename: luci-app-zigbee2mqtt_1.0.0-r2_all.ipk -Size: 7085 +Size: 7095 Package: luci-theme-secubox Version: 0.4.7-r1 @@ -547,7 +547,7 @@ Architecture: all Installed-Size: 460800 Description: Global CyberMood design system (CSS/JS/i18n) shared by all SecuBox dashboards. Filename: luci-theme-secubox_0.4.7-r1_all.ipk -Size: 111793 +Size: 111798 Package: secubox-app Version: 1.0.0-r2 @@ -558,7 +558,7 @@ Installed-Size: 92160 Description: Command line helper for SecuBox App Store manifests. Installs /usr/sbin/secubox-app and ships the default manifests under /usr/share/secubox/plugins/. Filename: secubox-app_1.0.0-r2_all.ipk -Size: 11185 +Size: 11181 Package: secubox-app-adguardhome Version: 1.0.0-r2 @@ -572,7 +572,7 @@ Description: Installer, configuration, and service manager for running AdGuard inside Docker on SecuBox-powered OpenWrt systems. Network-wide ad blocker with DNS-over-HTTPS/TLS support and detailed analytics. Filename: secubox-app-adguardhome_1.0.0-r2_all.ipk -Size: 2876 +Size: 2883 Package: secubox-app-auth-logger Version: 1.2.2-r1 @@ -590,7 +590,7 @@ Description: Logs authentication failures from LuCI/rpcd and Dropbear SSH - JavaScript hook to intercept login failures - CrowdSec parser and bruteforce scenario Filename: secubox-app-auth-logger_1.2.2-r1_all.ipk -Size: 9378 +Size: 9379 Package: secubox-app-crowdsec-custom Version: 1.1.0-r1 @@ -613,7 +613,7 @@ Description: Custom CrowdSec configurations for SecuBox web interface protectio - Webapp generic auth bruteforce protection - Whitelist for trusted networks Filename: secubox-app-crowdsec-custom_1.1.0-r1_all.ipk -Size: 5759 +Size: 5767 Package: secubox-app-cs-firewall-bouncer Version: 0.0.31-r4 @@ -640,7 +640,7 @@ Description: SecuBox CrowdSec Firewall Bouncer for OpenWrt. - Automatic restart on firewall reload - procd service management Filename: secubox-app-cs-firewall-bouncer_0.0.31-r4_aarch64_cortex-a72.ipk -Size: 5049321 +Size: 5049326 Package: secubox-app-cyberfeed Version: 0.2.1-r1 @@ -654,7 +654,7 @@ Description: Cyberpunk-themed RSS feed aggregator for OpenWrt/SecuBox. Features emoji injection, neon styling, and RSS-Bridge support for social media feeds (Facebook, Twitter, Mastodon). Filename: secubox-app-cyberfeed_0.2.1-r1_all.ipk -Size: 12451 +Size: 12453 Package: secubox-app-domoticz Version: 1.0.0-r2 @@ -667,7 +667,7 @@ Installed-Size: 10240 Description: Installer, configuration, and service manager for running Domoticz inside Docker on SecuBox-powered OpenWrt systems. Filename: secubox-app-domoticz_1.0.0-r2_all.ipk -Size: 2544 +Size: 2553 Package: secubox-app-exposure Version: 1.0.0-r1 @@ -682,7 +682,7 @@ Description: Unified service exposure manager for SecuBox. - Dynamic Tor hidden service management - HAProxy SSL reverse proxy configuration Filename: secubox-app-exposure_1.0.0-r1_all.ipk -Size: 6825 +Size: 6838 Package: secubox-app-gitea Version: 1.0.0-r5 @@ -772,7 +772,7 @@ Description: Hexo CMS - Self-hosted static blog generator for OpenWrt Runs in LXC container with Alpine Linux. Configure in /etc/config/hexojs. Filename: secubox-app-hexojs_1.0.0-r8_all.ipk -Size: 94934 +Size: 94935 Package: secubox-app-localai Version: 2.25.0-r1 @@ -794,7 +794,7 @@ Description: LocalAI native binary package for OpenWrt. API: http://:8081/v1 Filename: secubox-app-localai_2.25.0-r1_all.ipk -Size: 5712 +Size: 5721 Package: secubox-app-localai-wb Version: 2.25.0-r1 @@ -818,7 +818,7 @@ Description: LocalAI native binary package for OpenWrt. API: http://:8080/v1 Filename: secubox-app-localai-wb_2.25.0-r1_all.ipk -Size: 7950 +Size: 7956 Package: secubox-app-lyrion Version: 2.0.2-r1 @@ -838,7 +838,7 @@ Description: Lyrion Media Server (formerly Logitech Media Server / Squeezebox S Auto-detects available runtime, preferring LXC for lower resource usage. Configure runtime in /etc/config/lyrion. Filename: secubox-app-lyrion_2.0.2-r1_all.ipk -Size: 7285 +Size: 7289 Package: secubox-app-magicmirror2 Version: 0.4.0-r8 @@ -860,7 +860,7 @@ Description: MagicMirrorΒ² - Open source modular smart mirror platform for Secu Runs in LXC container for isolation and security. Configure in /etc/config/magicmirror2. Filename: secubox-app-magicmirror2_0.4.0-r8_all.ipk -Size: 9251 +Size: 9249 Package: secubox-app-mailinabox Version: 2.0.0-r1 @@ -885,7 +885,7 @@ Description: Complete email server solution using docker-mailserver for SecuBox Commands: mailinaboxctl --help Filename: secubox-app-mailinabox_2.0.0-r1_all.ipk -Size: 7566 +Size: 7575 Package: secubox-app-metabolizer Version: 1.0.0-r3 @@ -906,7 +906,7 @@ Description: Metabolizer Blog Pipeline - Integrated CMS with Git-based workflow Pipeline: Edit in Streamlit -> Push to Gitea -> Build with Hexo -> Publish Filename: secubox-app-metabolizer_1.0.0-r3_all.ipk -Size: 13979 +Size: 13984 Package: secubox-app-mitmproxy Version: 0.4.0-r16 @@ -927,7 +927,7 @@ Description: mitmproxy - Interactive HTTPS proxy for SecuBox-powered OpenWrt sy Runs in LXC container for isolation and security. Configure in /etc/config/mitmproxy. Filename: secubox-app-mitmproxy_0.4.0-r16_all.ipk -Size: 10215 +Size: 10213 Package: secubox-app-mmpm Version: 0.2.0-r5 @@ -948,7 +948,7 @@ Description: MMPM (MagicMirror Package Manager) for SecuBox. Runs inside the MagicMirror2 LXC container. Filename: secubox-app-mmpm_0.2.0-r5_all.ipk -Size: 3974 +Size: 3977 Package: secubox-app-nextcloud Version: 1.0.0-r2 @@ -962,7 +962,7 @@ Description: Installer, configuration, and service manager for running Nextclou inside Docker on SecuBox-powered OpenWrt systems. Self-hosted file sync and share with calendar, contacts, and collaboration. Filename: secubox-app-nextcloud_1.0.0-r2_all.ipk -Size: 2955 +Size: 2959 Package: secubox-app-ollama Version: 0.1.0-r1 @@ -984,7 +984,7 @@ Description: Ollama - Simple local LLM runtime for SecuBox-powered OpenWrt syst Runs in Docker/Podman container. Configure in /etc/config/ollama. Filename: secubox-app-ollama_0.1.0-r1_all.ipk -Size: 5733 +Size: 5739 Package: secubox-app-picobrew Version: 1.0.0-r7 @@ -1033,7 +1033,7 @@ Description: Streamlit App Platform - Self-hosted Python data app platform Configure in /etc/config/streamlit. Filename: secubox-app-streamlit_1.0.0-r5_all.ipk -Size: 11717 +Size: 11721 Package: secubox-app-tor Version: 1.0.0-r1 @@ -1056,7 +1056,7 @@ Description: SecuBox Tor Shield - One-click Tor anonymization for OpenWrt Configure in /etc/config/tor-shield. Filename: secubox-app-tor_1.0.0-r1_all.ipk -Size: 7379 +Size: 7377 Package: secubox-app-webapp Version: 1.5.0-r7 @@ -1074,7 +1074,7 @@ Description: SecuBox Control Center Dashboard - A web-based dashboard for monit - Service management - Network interface control Filename: secubox-app-webapp_1.5.0-r7_all.ipk -Size: 39169 +Size: 39171 Package: secubox-app-zigbee2mqtt Version: 1.0.0-r3 @@ -1087,7 +1087,7 @@ Installed-Size: 20480 Description: Installer, configuration, and service manager for running Zigbee2MQTT inside Docker on SecuBox-powered OpenWrt systems. Filename: secubox-app-zigbee2mqtt_1.0.0-r3_all.ipk -Size: 3542 +Size: 3545 Package: secubox-core Version: 0.10.0-r9 @@ -1107,21 +1107,22 @@ Description: SecuBox Core Framework provides the foundational infrastructure fo - Unified CLI interface - ubus RPC backend Filename: secubox-core_0.10.0-r9_all.ipk -Size: 80068 +Size: 80071 Package: secubox-p2p -Version: 0.4.0-r1 +Version: 0.5.0-r1 Depends: jsonfilter, curl, avahi-daemon, avahi-utils, uhttpd License: MIT Section: secubox Maintainer: SecuBox Team Architecture: all -Installed-Size: 133120 +Installed-Size: 143360 Description: SecuBox P2P Hub backend providing peer discovery, mesh networking DNS federation, and distributed service management. Includes mDNS service announcement, REST API on port 7331 for mesh visibility - and SecuBox Factory unified dashboard with Ed25519 signed Merkle - snapshots for cryptographic configuration validation. -Filename: secubox-p2p_0.4.0-r1_all.ipk -Size: 27891 + SecuBox Factory unified dashboard with Ed25519 signed Merkle + snapshots for cryptographic configuration validation, and distributed + mesh services panel for aggregated service discovery across all nodes. +Filename: secubox-p2p_0.5.0-r1_all.ipk +Size: 30574 diff --git a/package/secubox/secubox-app-bonus/root/www/secubox-feed/Packages.gz b/package/secubox/secubox-app-bonus/root/www/secubox-feed/Packages.gz index cd87c993c46de8b493bc93edc810d49a0a05690e..21c75f54268381974b1d7322c75ee80bbccb941f 100644 GIT binary patch delta 7003 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 delta 6994 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/dev/null || hostname) + local node_name=$(uci -q get system.@system[0].hostname || hostname) + local updated=$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S') + + # Fetch from local catalog API + local catalog=$(curl -s "http://127.0.0.1:7331/api/factory/catalog" 2>/dev/null) + + if [ -n "$catalog" ] && echo "$catalog" | grep -q "node_id"; then + echo "$catalog" > "$LOCAL_CATALOG" + echo "$LOCAL_CATALOG" + else + echo '{"error":"catalog_generation_failed"}' > "$LOCAL_CATALOG" + echo "$LOCAL_CATALOG" + fi +} + +# Pull catalog from a peer +catalog_pull_peer() { + local peer_addr="$1" + local peer_name="${2:-$peer_addr}" + + [ -z "$peer_addr" ] && return 1 + + catalog_init + + local peer_catalog=$(curl -s --connect-timeout 3 --max-time 10 "http://$peer_addr:7331/api/factory/catalog" 2>/dev/null) + + if [ -n "$peer_catalog" ] && echo "$peer_catalog" | grep -q "node_id"; then + local peer_node_name=$(echo "$peer_catalog" | jsonfilter -e '@.node_name' 2>/dev/null || echo "$peer_name") + echo "$peer_catalog" > "$CATALOG_DIR/peers/${peer_node_name}.json" + echo "pulled:$peer_node_name" + else + echo "failed:$peer_addr" + fi +} + +# Sync catalogs with all known peers +catalog_sync() { + catalog_init + + local peers_file="/tmp/secubox-p2p-peers.json" + [ -f "$peers_file" ] || { echo '{"synced":0,"error":"no_peers"}'; return 1; } + + # Generate local catalog first + catalog_generate_local >/dev/null 2>&1 + + # Pull from all online peers + local synced=0 + local failed=0 + local peer_count=$(jsonfilter -i "$peers_file" -e '@.peers[*]' 2>/dev/null | wc -l) + local p=0 + + while [ $p -lt $peer_count ]; do + local peer_addr=$(jsonfilter -i "$peers_file" -e "@.peers[$p].address" 2>/dev/null) + local peer_name=$(jsonfilter -i "$peers_file" -e "@.peers[$p].name" 2>/dev/null) + local is_local=$(jsonfilter -i "$peers_file" -e "@.peers[$p].is_local" 2>/dev/null) + + if [ "$is_local" != "true" ] && [ -n "$peer_addr" ]; then + local result=$(catalog_pull_peer "$peer_addr" "$peer_name") + if echo "$result" | grep -q "^pulled:"; then + synced=$((synced + 1)) + else + failed=$((failed + 1)) + fi + fi + p=$((p + 1)) + done + + # Merge all catalogs + catalog_merge + + # Push to Gitea if enabled + if [ "$(uci -q get secubox-p2p.gitea.enabled)" = "1" ]; then + catalog_push_gitea + fi + + echo "{\"synced\":$synced,\"failed\":$failed}" +} + +# Merge all catalogs into unified view (CRDT union) +catalog_merge() { + catalog_init + + local merged_services='{"nodes":[' + local first_node=1 + + # Add local catalog + if [ -f "$LOCAL_CATALOG" ]; then + [ $first_node -eq 0 ] && merged_services="$merged_services," + first_node=0 + cat "$LOCAL_CATALOG" | tr '\n' ' ' | tr '\t' ' ' >> /tmp/catalog_merge_local.tmp + local local_entry=$(cat /tmp/catalog_merge_local.tmp) + merged_services="$merged_services$local_entry" + rm -f /tmp/catalog_merge_local.tmp + fi + + # Add peer catalogs + for peer_catalog in "$CATALOG_DIR/peers"/*.json; do + [ -f "$peer_catalog" ] || continue + [ $first_node -eq 0 ] && merged_services="$merged_services," + first_node=0 + cat "$peer_catalog" | tr '\n' ' ' | tr '\t' ' ' >> /tmp/catalog_merge_peer.tmp + local peer_entry=$(cat /tmp/catalog_merge_peer.tmp) + merged_services="$merged_services$peer_entry" + rm -f /tmp/catalog_merge_peer.tmp + done + + merged_services="$merged_services],\"updated\":\"$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S')\"}" + + echo "$merged_services" > "$MERGED_CATALOG" +} + +# Push catalog to Gitea repository +catalog_push_gitea() { + local gitea_enabled=$(uci -q get secubox-p2p.gitea.enabled) + [ "$gitea_enabled" = "1" ] || return 0 + + local node_name=$(uci -q get system.@system[0].hostname || hostname) + + # This would push to secubox-catalog repo in Gitea + # For now, just log the action + logger -t factory "Catalog sync: would push to Gitea nodes/${node_name}.json" + + # Future: use ubus call to trigger actual git push + # ubus call luci.secubox-p2p push_catalog_gitea "{\"file\":\"nodes/${node_name}.json\"}" 2>/dev/null +} + +# Get merged catalog JSON +catalog_get_merged() { + if [ -f "$MERGED_CATALOG" ]; then + cat "$MERGED_CATALOG" + elif [ -f "$LOCAL_CATALOG" ]; then + # Return local only if no merge yet + echo "{\"nodes\":[$(cat "$LOCAL_CATALOG" | tr '\n' ' ')],\"updated\":\"$(date -Iseconds)\"}" + else + echo '{"nodes":[],"updated":"","error":"no_catalog"}' + fi +} + +# List available catalogs +catalog_list() { + catalog_init + echo "Local catalog: $LOCAL_CATALOG" + [ -f "$LOCAL_CATALOG" ] && echo " - $(jsonfilter -i "$LOCAL_CATALOG" -e '@.node_name' 2>/dev/null || echo 'unknown')" + + echo "Peer catalogs:" + for peer_catalog in "$CATALOG_DIR/peers"/*.json; do + [ -f "$peer_catalog" ] || continue + local name=$(basename "$peer_catalog" .json) + echo " - $name" + done +} + # Main entry point for CLI usage case "${1:-}" in init) @@ -331,6 +502,33 @@ case "${1:-}" in merkle) merkle_config ;; + # Catalog commands + catalog) + case "${2:-}" in + sync) + catalog_sync + ;; + list) + catalog_list + ;; + generate) + catalog_generate_local + ;; + merge) + catalog_merge + echo "Merged catalog: $MERGED_CATALOG" + ;; + get) + catalog_get_merged + ;; + pull) + catalog_pull_peer "$3" "$4" + ;; + *) + echo "Usage: factory.sh catalog {sync|list|generate|merge|get|pull }" + ;; + esac + ;; *) # Sourced as library - do nothing : diff --git a/package/secubox/secubox-p2p/root/usr/sbin/secubox-p2p b/package/secubox/secubox-p2p/root/usr/sbin/secubox-p2p index 26b4329e..c48ddad9 100644 --- a/package/secubox/secubox-p2p/root/usr/sbin/secubox-p2p +++ b/package/secubox/secubox-p2p/root/usr/sbin/secubox-p2p @@ -3,7 +3,7 @@ # Handles peer discovery, mesh networking, and service federation # Supports WAN IP, LAN IP, and WireGuard tunnel redundancy -VERSION="0.4.0" +VERSION="0.6.0" CONFIG_FILE="/etc/config/secubox-p2p" PEERS_FILE="/tmp/secubox-p2p-peers.json" SERVICES_FILE="/tmp/secubox-p2p-services.json" @@ -439,6 +439,210 @@ stop_mdns() { fi } +# =========================================== +# DNS Federation - Dynamic mesh DNS entries +# =========================================== + +DNS_HOSTS_FILE="/tmp/hosts/secubox-mesh" +DNS_DOMAIN_BASE="mesh.local" + +# Initialize DNS federation +dns_init() { + local dns_enabled=$(get_config dns enabled 0) + [ "$dns_enabled" = "1" ] || return 0 + + DNS_DOMAIN_BASE=$(get_config dns base_domain "mesh.local") + + # Ensure hosts directory exists + mkdir -p /tmp/hosts + + # Create or update mesh hosts file + touch "$DNS_HOSTS_FILE" + + # Ensure dnsmasq reads from /tmp/hosts + # Check if addn-hosts is configured + if ! uci -q get dhcp.@dnsmasq[0].addnhosts | grep -q "/tmp/hosts"; then + uci add_list dhcp.@dnsmasq[0].addnhosts="/tmp/hosts" + uci commit dhcp + logger -t secubox-p2p "Added /tmp/hosts to dnsmasq additional hosts" + fi +} + +# Update DNS entries for all mesh peers +dns_update_peers() { + local dns_enabled=$(get_config dns enabled 0) + [ "$dns_enabled" = "1" ] || return 0 + + dns_init + + local base_domain=$(get_config dns base_domain "mesh.local") + local hosts_content="" + local count=0 + + # Get all peers (including local) + local peers_json=$(cat "$PEERS_FILE" 2>/dev/null || echo '{"peers":[]}') + + # Parse peers and build hosts entries + # Format: IP hostname hostname.mesh.local + if command -v jq >/dev/null 2>&1; then + hosts_content=$(echo "$peers_json" | jq -r '.peers[] | select(.status == "online" or .is_local == true) | + # Build hostname entries + ( + (.address // empty) as $lan | + (.wan_address // empty) as $wan | + (.wg_addresses // empty) as $wg | + (.name // "peer") | gsub(" \\(local\\)"; "") | gsub("[^a-zA-Z0-9-]"; "-") | ascii_downcase as $hostname | + + # LAN entry (primary for local network) + (if $lan != "" and $lan != null then + "\($lan)\t\($hostname)\t\($hostname).'$base_domain'" + else empty end), + + # WireGuard entry (for mesh access) + (if $wg != "" and $wg != null then + ($wg | split(",")[0]) as $wg_ip | + if $wg_ip != "" then + "\($wg_ip)\t\($hostname)-wg\t\($hostname)-wg.'$base_domain'" + else empty end + else empty end) + ) + ' 2>/dev/null) + else + # Fallback without jq - use jsonfilter + local peer_count=$(echo "$peers_json" | jsonfilter -e '@.peers[*]' 2>/dev/null | wc -l) + local i=0 + + while [ $i -lt $peer_count ]; do + local addr=$(echo "$peers_json" | jsonfilter -e "@.peers[$i].address" 2>/dev/null) + local name=$(echo "$peers_json" | jsonfilter -e "@.peers[$i].name" 2>/dev/null) + local status=$(echo "$peers_json" | jsonfilter -e "@.peers[$i].status" 2>/dev/null) + local is_local=$(echo "$peers_json" | jsonfilter -e "@.peers[$i].is_local" 2>/dev/null) + local wg_addr=$(echo "$peers_json" | jsonfilter -e "@.peers[$i].wg_addresses" 2>/dev/null | cut -d',' -f1) + + # Only add online peers or local node + if [ "$status" = "online" ] || [ "$is_local" = "true" ]; then + # Clean hostname (remove special chars) + local hostname=$(echo "$name" | sed -e 's/ (local)//' -e 's/[^a-zA-Z0-9-]/-/g' | tr 'A-Z' 'a-z') + + # Add LAN entry + if [ -n "$addr" ]; then + hosts_content="$hosts_content$addr $hostname $hostname.$base_domain +" + count=$((count + 1)) + fi + + # Add WireGuard entry + if [ -n "$wg_addr" ]; then + hosts_content="$hosts_content$wg_addr ${hostname}-wg ${hostname}-wg.$base_domain +" + count=$((count + 1)) + fi + fi + i=$((i + 1)) + done + fi + + # Write hosts file + echo "# SecuBox Mesh DNS Federation - Auto-generated" > "$DNS_HOSTS_FILE" + echo "# Domain: $base_domain" >> "$DNS_HOSTS_FILE" + echo "# Updated: $(date -Iseconds 2>/dev/null || date)" >> "$DNS_HOSTS_FILE" + echo "" >> "$DNS_HOSTS_FILE" + echo "$hosts_content" >> "$DNS_HOSTS_FILE" + + # Count actual entries (non-comment, non-empty lines) + count=$(grep -c "^[0-9]" "$DNS_HOSTS_FILE" 2>/dev/null || echo "0") + + # Signal dnsmasq to reload hosts + killall -HUP dnsmasq 2>/dev/null || /etc/init.d/dnsmasq reload 2>/dev/null + + logger -t secubox-p2p "DNS federation updated: $count mesh host entries in $base_domain" + echo "{\"success\":true,\"entries\":$count,\"domain\":\"$base_domain\"}" +} + +# Add a single peer to DNS +dns_add_peer() { + local hostname="$1" + local ip="$2" + + local dns_enabled=$(get_config dns enabled 0) + [ "$dns_enabled" = "1" ] || return 0 + + local base_domain=$(get_config dns base_domain "mesh.local") + + # Clean hostname + hostname=$(echo "$hostname" | sed -e 's/[^a-zA-Z0-9-]/-/g' | tr 'A-Z' 'a-z') + + # Append to hosts file (avoid duplicates) + if ! grep -q " $hostname " "$DNS_HOSTS_FILE" 2>/dev/null; then + echo "$ip $hostname $hostname.$base_domain" >> "$DNS_HOSTS_FILE" + killall -HUP dnsmasq 2>/dev/null + logger -t secubox-p2p "DNS: Added $hostname.$base_domain -> $ip" + fi +} + +# Remove a peer from DNS +dns_remove_peer() { + local hostname="$1" + + local dns_enabled=$(get_config dns enabled 0) + [ "$dns_enabled" = "1" ] || return 0 + + # Clean hostname + hostname=$(echo "$hostname" | sed -e 's/[^a-zA-Z0-9-]/-/g' | tr 'A-Z' 'a-z') + + # Remove from hosts file + sed -i "/ $hostname /d" "$DNS_HOSTS_FILE" 2>/dev/null + killall -HUP dnsmasq 2>/dev/null + logger -t secubox-p2p "DNS: Removed $hostname" +} + +# Get DNS federation status +dns_status() { + local dns_enabled=$(get_config dns enabled 0) + local base_domain=$(get_config dns base_domain "mesh.local") + local entry_count=0 + + if [ -f "$DNS_HOSTS_FILE" ]; then + entry_count=$(grep -c "^[0-9]" "$DNS_HOSTS_FILE" 2>/dev/null || echo "0") + fi + + cat << EOF +{ + "enabled": $dns_enabled, + "domain": "$base_domain", + "hosts_file": "$DNS_HOSTS_FILE", + "entries": $entry_count, + "dnsmasq_running": $(pgrep dnsmasq >/dev/null && echo "true" || echo "false") +} +EOF +} + +# Enable DNS federation +dns_enable() { + set_config dns enabled 1 + + local base_domain="${1:-mesh.local}" + set_config dns base_domain "$base_domain" + + dns_init + dns_update_peers + + logger -t secubox-p2p "DNS federation enabled with domain: $base_domain" + echo "{\"success\":true,\"domain\":\"$base_domain\"}" +} + +# Disable DNS federation +dns_disable() { + set_config dns enabled 0 + + # Clear hosts file + echo "# SecuBox Mesh DNS Federation - Disabled" > "$DNS_HOSTS_FILE" + killall -HUP dnsmasq 2>/dev/null + + logger -t secubox-p2p "DNS federation disabled" + echo "{\"success\":true}" +} + # Get node status JSON (for REST API) # Now includes WAN IP and WireGuard tunnel addresses get_node_status() { @@ -526,6 +730,9 @@ daemon_loop() { # Register self in peer list register_self + # Initialize DNS federation + dns_init + # Setup signal handlers trap 'stop_mdns; exit 0' INT TERM @@ -603,6 +810,9 @@ daemon_loop() { echo "$peers" > "$PEERS_FILE" fi + # Update DNS federation with current peer list + dns_update_peers >/dev/null 2>&1 + # Sleep interval local interval=$(get_config main sync_interval 60) sleep "$interval" @@ -660,8 +870,28 @@ case "$1" in init register_self ;; + # DNS Federation commands + dns-status) + dns_status + ;; + dns-enable) + dns_enable "$2" + ;; + dns-disable) + dns_disable + ;; + dns-update) + init + dns_update_peers + ;; + dns-add) + dns_add_peer "$2" "$3" + ;; + dns-remove) + dns_remove_peer "$2" + ;; *) - echo "Usage: $0 {daemon|discover|peers|add-peer|remove-peer|settings|set-settings|services|shared-services|sync|broadcast|version|status|publish-mdns|stop-mdns|register-self}" + echo "Usage: $0 {daemon|discover|peers|add-peer|remove-peer|settings|set-settings|services|shared-services|sync|broadcast|version|status|publish-mdns|stop-mdns|register-self|dns-status|dns-enable|dns-disable|dns-update|dns-add|dns-remove}" exit 1 ;; esac diff --git a/package/secubox/secubox-p2p/root/www/api/factory/catalog b/package/secubox/secubox-p2p/root/www/api/factory/catalog new file mode 100644 index 00000000..7bbc9925 --- /dev/null +++ b/package/secubox/secubox-p2p/root/www/api/factory/catalog @@ -0,0 +1,351 @@ +#!/bin/sh +# Factory Catalog - Distributed Service Registry +# CGI endpoint for MirrorBox NetMesh Catalog +# Returns services with multiple access endpoints (HAProxy vhosts, mesh, local) + +echo "Content-Type: application/json" +echo "Access-Control-Allow-Origin: *" +echo "Access-Control-Allow-Methods: GET, OPTIONS" +echo "" + +# Handle CORS preflight +if [ "$REQUEST_METHOD" = "OPTIONS" ]; then + exit 0 +fi + +# Source OpenWrt functions for UCI iteration +. /lib/functions.sh + +# Load factory library +. /usr/lib/secubox/factory.sh 2>/dev/null + +# Get local node info +P2P_STATE_DIR="/var/run/secubox-p2p" +LOCAL_NODE_ID=$(cat "$P2P_STATE_DIR/node.id" 2>/dev/null || hostname) +LOCAL_NODE_NAME=$(uci -q get system.@system[0].hostname || hostname) +LOCAL_IP=$(uci -q get network.lan.ipaddr || echo "192.168.255.1") +UPDATED=$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S') + +# DNS Federation settings +DNS_ENABLED=$(uci -q get secubox-p2p.dns.enabled || echo "0") +DNS_DOMAIN=$(uci -q get secubox-p2p.dns.base_domain || echo "mesh.local") + +# Generate DNS hostname from node name +get_dns_hostname() { + local name="$1" + # Remove "(local)" suffix, lowercase, replace special chars with dashes + echo "$name" | sed -e 's/ (local)//' -e 's/[^a-zA-Z0-9-]/-/g' | tr 'A-Z' 'a-z' +} + +# Get WAN IP (for external access) +get_wan_ip() { + # Try to get WAN IP from interface + local wan_ip=$(ip -4 addr show wan 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1) + [ -z "$wan_ip" ] && wan_ip=$(ip -4 addr show pppoe-wan 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1) + [ -z "$wan_ip" ] && wan_ip=$(ip -4 addr show eth1 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1) + # Fallback: try to detect via external service (cached) + if [ -z "$wan_ip" ] && [ -f "/tmp/secubox-wan-ip" ]; then + wan_ip=$(cat /tmp/secubox-wan-ip) + fi + echo "$wan_ip" +} + +# Get WireGuard mesh IP +get_mesh_ip() { + local mesh_ip=$(ip -4 addr show wg0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1) + echo "$mesh_ip" +} + +# Build HAProxy vhost map: domain -> backend +# Returns: domain|backend|ssl|enabled for each vhost +get_haproxy_vhosts() { + config_load haproxy 2>/dev/null || return + + # Callback function for each vhost + _emit_vhost() { + local section="$1" + local enabled domain backend ssl ssl_redirect + + config_get enabled "$section" enabled "0" + [ "$enabled" = "1" ] || return + + config_get domain "$section" domain + config_get backend "$section" backend + config_get ssl "$section" ssl "0" + config_get ssl_redirect "$section" ssl_redirect "0" + + [ -n "$domain" ] || return + [ -n "$backend" ] || return + + # Determine scheme + local scheme="http" + [ "$ssl" = "1" ] && scheme="https" + + echo "${domain}|${backend}|${scheme}|${ssl_redirect}" + } + + config_foreach _emit_vhost vhost +} + +# Map backend name to service name (normalize) +# E.g., backend_gitea -> gitea, srv_domoticz -> domoticz +normalize_backend_name() { + local backend="$1" + # Strip common prefixes + echo "$backend" | sed -e 's/^backend_//' -e 's/^srv_//' -e 's/^be_//' -e 's/_/-/g' +} + +# Get service status and port from init scripts +get_service_info() { + local svc_name="$1" + local status="unknown" + local port="" + local enabled=false + + # Check init script + local init_script="/etc/init.d/$svc_name" + if [ -x "$init_script" ]; then + enabled=$("$init_script" enabled 2>/dev/null && echo true || echo false) + # Check if running + if pgrep "$svc_name" >/dev/null 2>&1; then + status="running" + else + status="stopped" + fi + fi + + # Try to find port from UCI or known mappings + case "$svc_name" in + gitea) port="3000" ;; + domoticz) port="8080" ;; + crowdsec) port="8080" ;; + haproxy) port="8404" ;; + adguardhome) port="3000" ;; + netdata) port="19999" ;; + glances) port="61208" ;; + streamlit) port="8501" ;; + zigbee2mqtt) port="8080" ;; + nextcloud) port="8080" ;; + ollama) port="11434" ;; + localai) port="8080" ;; + wireguard-dashboard|wg-dashboard) port="10086" ;; + mitmproxy) port="8081" ;; + *) + # Try to get from UCI config + port=$(uci -q get "$svc_name.main.port" 2>/dev/null) + [ -z "$port" ] && port=$(uci -q get "$svc_name.@server[0].port" 2>/dev/null) + ;; + esac + + echo "$status|$port|$enabled" +} + +# Build catalog entries from HAProxy vhosts and local services +build_catalog() { + local wan_ip=$(get_wan_ip) + local mesh_ip=$(get_mesh_ip) + local first_service=1 + + # Start services array + echo '"services": [' + + # Track processed backends to avoid duplicates + local processed_backends="" + + # First, enumerate HAProxy vhosts (primary access method) + local vhosts=$(get_haproxy_vhosts) + for vhost_line in $vhosts; do + local domain=$(echo "$vhost_line" | cut -d'|' -f1) + local backend=$(echo "$vhost_line" | cut -d'|' -f2) + local scheme=$(echo "$vhost_line" | cut -d'|' -f3) + local ssl_redirect=$(echo "$vhost_line" | cut -d'|' -f4) + + local svc_name=$(normalize_backend_name "$backend") + + # Skip if already processed + echo "$processed_backends" | grep -qw "$svc_name" && continue + processed_backends="$processed_backends $svc_name" + + # Get service status + local svc_info=$(get_service_info "$svc_name") + local status=$(echo "$svc_info" | cut -d'|' -f1) + local port=$(echo "$svc_info" | cut -d'|' -f2) + local enabled=$(echo "$svc_info" | cut -d'|' -f3) + + [ $first_service -eq 0 ] && echo "," + first_service=0 + + # Build endpoints array + local endpoints='[' + local first_ep=1 + + # Primary: HAProxy vhost (published domain) + [ $first_ep -eq 0 ] && endpoints="$endpoints," + first_ep=0 + endpoints="$endpoints{\"type\":\"haproxy\",\"url\":\"${scheme}://${domain}\",\"ssl\":$([ \"$scheme\" = \"https\" ] && echo true || echo false),\"primary\":true}" + + # Collect all vhosts for this backend + for other_vhost in $vhosts; do + local other_domain=$(echo "$other_vhost" | cut -d'|' -f1) + local other_backend=$(echo "$other_vhost" | cut -d'|' -f2) + local other_scheme=$(echo "$other_vhost" | cut -d'|' -f3) + + [ "$other_backend" = "$backend" ] || continue + [ "$other_domain" = "$domain" ] && continue # Skip primary + + endpoints="$endpoints,{\"type\":\"haproxy\",\"url\":\"${other_scheme}://${other_domain}\",\"ssl\":$([ \"$other_scheme\" = \"https\" ] && echo true || echo false),\"primary\":false}" + done + + # Mesh endpoint (WireGuard) + if [ -n "$mesh_ip" ] && [ -n "$port" ]; then + endpoints="$endpoints,{\"type\":\"mesh\",\"url\":\"http://${mesh_ip}:${port}\",\"ssl\":false,\"primary\":false}" + fi + + # Local endpoint (LAN) + if [ -n "$port" ]; then + endpoints="$endpoints,{\"type\":\"local\",\"url\":\"http://${LOCAL_IP}:${port}\",\"ssl\":false,\"primary\":false}" + fi + + endpoints="$endpoints]" + + # Output service entry + cat << SVCEOF +{ + "name": "$svc_name", + "backend": "$backend", + "status": "$status", + "enabled": $enabled, + "port": "${port:-}", + "endpoints": $endpoints, + "health": { + "last_check": "$UPDATED", + "status": "$([ \"$status\" = \"running\" ] && echo healthy || echo unhealthy)", + "latency_ms": 0 + } +} +SVCEOF + done + + # Now add services without HAProxy vhosts (mesh/local only) + local services_json=$(/usr/sbin/secubox-p2p services 2>/dev/null) + if [ -n "$services_json" ]; then + local svc_count=$(echo "$services_json" | jsonfilter -e '@.services[*]' 2>/dev/null | wc -l) + local i=0 + + while [ $i -lt $svc_count ]; do + local svc_name=$(echo "$services_json" | jsonfilter -e "@.services[$i].name" 2>/dev/null) + local svc_status=$(echo "$services_json" | jsonfilter -e "@.services[$i].status" 2>/dev/null) + local svc_enabled=$(echo "$services_json" | jsonfilter -e "@.services[$i].enabled" 2>/dev/null) + local svc_port=$(echo "$services_json" | jsonfilter -e "@.services[$i].port" 2>/dev/null) + + # Skip if already processed via HAProxy + if ! echo "$processed_backends" | grep -qw "$svc_name"; then + [ $first_service -eq 0 ] && echo "," + first_service=0 + + # Build endpoints (mesh + local only, no HAProxy) + local endpoints='[' + local first_ep=1 + + # Mesh endpoint + if [ -n "$mesh_ip" ] && [ -n "$svc_port" ] && [ "$svc_port" != "0" ]; then + [ $first_ep -eq 0 ] && endpoints="$endpoints," + first_ep=0 + endpoints="$endpoints{\"type\":\"mesh\",\"url\":\"http://${mesh_ip}:${svc_port}\",\"ssl\":false,\"primary\":true}" + fi + + # Local endpoint + if [ -n "$svc_port" ] && [ "$svc_port" != "0" ]; then + [ $first_ep -eq 0 ] && endpoints="$endpoints," + first_ep=0 + endpoints="$endpoints{\"type\":\"local\",\"url\":\"http://${LOCAL_IP}:${svc_port}\",\"ssl\":false,\"primary\":$([ $first_ep -eq 1 ] && echo true || echo false)}" + fi + + endpoints="$endpoints]" + + cat << SVCEOF +{ + "name": "$svc_name", + "backend": "", + "status": "$svc_status", + "enabled": ${svc_enabled:-false}, + "port": "${svc_port:-}", + "endpoints": $endpoints, + "health": { + "last_check": "$UPDATED", + "status": "$([ \"$svc_status\" = \"running\" ] && echo healthy || echo unhealthy)", + "latency_ms": 0 + } +} +SVCEOF + fi + + i=$((i + 1)) + done + fi + + echo ']' +} + +# Get DNS hostname for this node +LOCAL_DNS_HOST=$(get_dns_hostname "$LOCAL_NODE_NAME") + +# Get linked peers for navigation +get_peer_links() { + local peers_file="/tmp/secubox-p2p-peers.json" + [ -f "$peers_file" ] || { echo '[]'; return; } + + local result='[' + local first=1 + local peer_count=$(jsonfilter -i "$peers_file" -e '@.peers[*]' 2>/dev/null | wc -l) + local i=0 + + while [ $i -lt $peer_count ]; do + local is_local=$(jsonfilter -i "$peers_file" -e "@.peers[$i].is_local" 2>/dev/null) + [ "$is_local" = "true" ] && { i=$((i + 1)); continue; } + + local peer_addr=$(jsonfilter -i "$peers_file" -e "@.peers[$i].address" 2>/dev/null) + local peer_name=$(jsonfilter -i "$peers_file" -e "@.peers[$i].name" 2>/dev/null) + local peer_status=$(jsonfilter -i "$peers_file" -e "@.peers[$i].status" 2>/dev/null) + local peer_wg=$(jsonfilter -i "$peers_file" -e "@.peers[$i].wg_addresses" 2>/dev/null | cut -d',' -f1) + + [ -n "$peer_addr" ] || { i=$((i + 1)); continue; } + + local peer_dns=$(get_dns_hostname "$peer_name") + + [ $first -eq 0 ] && result="$result," + first=0 + + result="$result{\"name\":\"$peer_name\",\"address\":\"$peer_addr\",\"status\":\"$peer_status\"" + result="$result,\"dns_hostname\":\"$peer_dns\"" + result="$result,\"dns_fqdn\":\"$peer_dns.$DNS_DOMAIN\"" + result="$result,\"wg_address\":\"$peer_wg\"" + result="$result,\"factory_url\":\"http://$peer_addr:7331/factory/\"" + result="$result,\"catalog_url\":\"http://$peer_addr:7331/api/factory/catalog\"}" + + i=$((i + 1)) + done + + result="$result]" + echo "$result" +} + +# Main output +cat << EOF +{ + "node_id": "$LOCAL_NODE_ID", + "node_name": "$LOCAL_NODE_NAME", + "updated": "$UPDATED", + "local_ip": "$LOCAL_IP", + "wan_ip": "$(get_wan_ip)", + "mesh_ip": "$(get_mesh_ip)", + "dns": { + "enabled": $DNS_ENABLED, + "domain": "$DNS_DOMAIN", + "hostname": "$LOCAL_DNS_HOST", + "fqdn": "$LOCAL_DNS_HOST.$DNS_DOMAIN" + }, + "linked_peers": $(get_peer_links), + $(build_catalog) +} +EOF diff --git a/package/secubox/secubox-p2p/root/www/api/factory/run b/package/secubox/secubox-p2p/root/www/api/factory/run index 3a9dcf7f..2033683d 100644 --- a/package/secubox/secubox-p2p/root/www/api/factory/run +++ b/package/secubox/secubox-p2p/root/www/api/factory/run @@ -137,6 +137,61 @@ case "$tool_id" in output="Merkle root: $merkle" ;; + catalog-sync) + result=$(catalog_sync 2>&1) + output="Catalog sync result: $result" + ;; + + catalog-list) + result=$(catalog_list 2>&1) + output="$result" + ;; + + catalog-generate) + result=$(catalog_generate_local 2>&1) + output="Catalog generated: $result" + ;; + + dns-status) + if [ -x /usr/sbin/secubox-p2p ]; then + result=$(/usr/sbin/secubox-p2p dns-status 2>&1) + output="$result" + else + output='{"error":"p2p_unavailable"}' + success=0 + fi + ;; + + dns-enable) + if [ -x /usr/sbin/secubox-p2p ]; then + result=$(/usr/sbin/secubox-p2p dns-enable "mesh.local" 2>&1) + output="DNS Federation enabled: $result" + else + output="P2P daemon not available" + success=0 + fi + ;; + + dns-disable) + if [ -x /usr/sbin/secubox-p2p ]; then + result=$(/usr/sbin/secubox-p2p dns-disable 2>&1) + output="DNS Federation disabled: $result" + else + output="P2P daemon not available" + success=0 + fi + ;; + + dns-update) + if [ -x /usr/sbin/secubox-p2p ]; then + result=$(/usr/sbin/secubox-p2p dns-update 2>&1) + output="DNS entries updated: $result" + else + output="P2P daemon not available" + success=0 + fi + ;; + *) output="Unknown tool: $tool_id" success=0 diff --git a/package/secubox/secubox-p2p/root/www/api/factory/tools b/package/secubox/secubox-p2p/root/www/api/factory/tools index 7a25aa88..43f4b6d2 100644 --- a/package/secubox/secubox-p2p/root/www/api/factory/tools +++ b/package/secubox/secubox-p2p/root/www/api/factory/tools @@ -112,15 +112,73 @@ cat << 'EOF' "category": "security", "icon": "hash", "dangerous": false + }, + { + "id": "catalog-sync", + "name": "Sync Catalog", + "description": "Sync service catalog with mesh peers and merge registries", + "category": "catalog", + "icon": "book", + "dangerous": false + }, + { + "id": "catalog-list", + "name": "List Catalogs", + "description": "Show local and peer catalog files", + "category": "catalog", + "icon": "list", + "dangerous": false + }, + { + "id": "catalog-generate", + "name": "Generate Catalog", + "description": "Regenerate local service catalog from HAProxy vhosts", + "category": "catalog", + "icon": "refresh", + "dangerous": false + }, + { + "id": "dns-status", + "name": "DNS Federation Status", + "description": "Show mesh DNS federation status and entries", + "category": "dns", + "icon": "globe", + "dangerous": false + }, + { + "id": "dns-enable", + "name": "Enable DNS Federation", + "description": "Enable automatic DNS entries for mesh peers (.mesh.local)", + "category": "dns", + "icon": "toggle-on", + "dangerous": false + }, + { + "id": "dns-disable", + "name": "Disable DNS Federation", + "description": "Disable mesh DNS federation", + "category": "dns", + "icon": "toggle-off", + "dangerous": false + }, + { + "id": "dns-update", + "name": "Update DNS Entries", + "description": "Refresh DNS entries from current peer list", + "category": "dns", + "icon": "refresh", + "dangerous": false } ], "categories": [ {"id": "security", "name": "Security", "order": 1}, {"id": "mesh", "name": "Mesh Network", "order": 2}, - {"id": "monitoring", "name": "Monitoring", "order": 3}, - {"id": "maintenance", "name": "Maintenance", "order": 4}, - {"id": "backup", "name": "Backup", "order": 5}, - {"id": "queue", "name": "Queue", "order": 6} + {"id": "dns", "name": "DNS Federation", "order": 3}, + {"id": "catalog", "name": "Catalog", "order": 4}, + {"id": "monitoring", "name": "Monitoring", "order": 5}, + {"id": "maintenance", "name": "Maintenance", "order": 6}, + {"id": "backup", "name": "Backup", "order": 7}, + {"id": "queue", "name": "Queue", "order": 8} ] } EOF diff --git a/package/secubox/secubox-p2p/root/www/factory/index.html b/package/secubox/secubox-p2p/root/www/factory/index.html index 37bfd0ae..a5993345 100644 --- a/package/secubox/secubox-p2p/root/www/factory/index.html +++ b/package/secubox/secubox-p2p/root/www/factory/index.html @@ -98,12 +98,12 @@ /* Fingerprint display */ .fingerprint { font-family: ui-monospace, monospace; font-size: 0.8rem; background: var(--bg); padding: 0.35rem 0.5rem; border-radius: 0.25rem; letter-spacing: 0.05em; } - /* Tab navigation */ - .tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; } - .tab { padding: 0.5rem 1rem; background: transparent; border: none; color: var(--muted); cursor: pointer; font-weight: 500; position: relative; } - .tab.active { color: var(--accent); } - .tab.active::after { content: ''; position: absolute; bottom: -0.5rem; left: 0; right: 0; height: 2px; background: var(--accent); } - .tab:hover { color: var(--text); } + /* Emoji nav */ + .emoji-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; } + .emoji-btn { font-size: 1.5rem; padding: 0.5rem; background: var(--card); border: 2px solid transparent; border-radius: 0.5rem; cursor: pointer; transition: all 0.2s; filter: grayscale(0.5); opacity: 0.7; } + .emoji-btn:hover { filter: grayscale(0); opacity: 1; transform: scale(1.1); } + .emoji-btn.active { filter: grayscale(0); opacity: 1; border-color: var(--accent); background: rgba(99, 102, 241, 0.2); } + .emoji-btn[title]:hover::after { content: attr(title); position: absolute; bottom: -1.5rem; left: 50%; transform: translateX(-50%); font-size: 0.65rem; background: var(--card); padding: 0.2rem 0.4rem; border-radius: 0.2rem; white-space: nowrap; } .tab-content { display: none; } .tab-content.active { display: block; } @@ -137,6 +137,78 @@ .filter-input { flex: 1; min-width: 200px; padding: 0.5rem 0.75rem; background: var(--card); border: 1px solid var(--border); border-radius: 0.25rem; color: var(--text); font-size: 0.85rem; } .filter-input:focus { outline: none; border-color: var(--accent); } .filter-select { padding: 0.5rem 0.75rem; background: var(--card); border: 1px solid var(--border); border-radius: 0.25rem; color: var(--text); font-size: 0.85rem; } + + /* Health emoji status */ + .health-emoji { cursor: pointer; font-size: 1rem; transition: transform 0.2s; } + .health-emoji:hover { transform: scale(1.3); } + .health-emoji.clickable { cursor: pointer; } + + /* Service URL display */ + .service-url { font-size: 0.65rem; color: var(--accent); font-family: ui-monospace, monospace; word-break: break-all; } + .service-url a { color: var(--accent); text-decoration: none; } + .service-url a:hover { text-decoration: underline; } + + /* QR code modal */ + .qr-modal { text-align: center; } + .qr-modal canvas { margin: 1rem auto; display: block; background: white; padding: 1rem; border-radius: 0.5rem; } + .qr-url { font-family: ui-monospace, monospace; font-size: 0.8rem; color: var(--accent); margin-top: 0.5rem; word-break: break-all; } + + /* Stat emoji clickable */ + .stat-emoji { font-size: 1.5rem; cursor: pointer; transition: transform 0.2s; } + .stat-emoji:hover { transform: scale(1.2); } + + /* Catalog styles */ + .catalog-service { background: var(--card); border-radius: 0.5rem; overflow: hidden; margin-bottom: 0.75rem; } + .catalog-service-header { padding: 0.75rem 1rem; background: rgba(0,0,0,0.2); display: flex; justify-content: space-between; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border); } + .catalog-service-header:hover { background: rgba(0,0,0,0.3); } + .catalog-service-name { font-size: 1rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; } + .catalog-service-meta { display: flex; gap: 0.5rem; align-items: center; } + .catalog-endpoints { padding: 0; max-height: 0; overflow: hidden; transition: max-height 0.3s ease, padding 0.3s ease; } + .catalog-endpoints.open { padding: 0.75rem 1rem; max-height: 500px; } + .endpoint-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; background: rgba(0,0,0,0.15); border-radius: 0.25rem; border-left: 3px solid var(--border); } + .endpoint-item.primary { border-left-color: var(--accent); } + .endpoint-item.haproxy { border-left-color: var(--success); } + .endpoint-item.mesh { border-left-color: #818cf8; } + .endpoint-item.local { border-left-color: var(--muted); } + .endpoint-type { font-size: 0.65rem; text-transform: uppercase; padding: 0.15rem 0.4rem; border-radius: 0.15rem; background: var(--border); margin-right: 0.5rem; } + .endpoint-type.haproxy { background: var(--success); color: #000; } + .endpoint-type.mesh { background: #818cf8; } + .endpoint-type.local { background: var(--muted); } + .endpoint-url { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; color: var(--accent); word-break: break-all; } + .endpoint-url a { color: inherit; text-decoration: none; } + .endpoint-url a:hover { text-decoration: underline; } + .endpoint-actions { display: flex; gap: 0.35rem; } + .endpoint-badge { font-size: 0.6rem; padding: 0.1rem 0.3rem; border-radius: 0.15rem; background: var(--accent); } + .endpoint-badge.ssl { background: var(--success); color: #000; } + + /* Catalog node grouping */ + .catalog-node-group { margin-bottom: 1.5rem; } + .catalog-node-header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 2px solid var(--accent); margin-bottom: 0.75rem; } + .catalog-node-name { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; } + .catalog-node-stats { font-size: 0.75rem; color: var(--muted); } + + /* URL tabs in QR modal */ + .url-tabs { display: flex; gap: 0.25rem; margin-bottom: 0.75rem; flex-wrap: wrap; justify-content: center; } + .url-tab { padding: 0.35rem 0.6rem; border-radius: 0.25rem; font-size: 0.7rem; cursor: pointer; background: var(--border); border: none; color: var(--text); } + .url-tab.active { background: var(--accent); } + .url-tab.haproxy { border-bottom: 2px solid var(--success); } + .url-tab.mesh { border-bottom: 2px solid #818cf8; } + .url-tab.local { border-bottom: 2px solid var(--muted); } + + /* Linked peers / mesh navigation */ + .peer-links { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; padding: 0.75rem; background: var(--card); border-radius: 0.5rem; border-left: 3px solid #818cf8; } + .peer-links-title { font-size: 0.75rem; color: var(--muted); width: 100%; margin-bottom: 0.25rem; } + .peer-link { display: flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.6rem; background: rgba(0,0,0,0.2); border-radius: 0.25rem; font-size: 0.75rem; cursor: pointer; transition: all 0.2s; text-decoration: none; color: var(--text); } + .peer-link:hover { background: var(--accent); transform: translateY(-1px); } + .peer-link.online { border-left: 2px solid var(--success); } + .peer-link.offline { border-left: 2px solid var(--danger); opacity: 0.6; } + .peer-link-name { font-weight: 500; } + .peer-link-dns { font-family: ui-monospace, monospace; font-size: 0.65rem; color: var(--accent); } + + /* DNS info badge */ + .dns-info { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.5rem; background: rgba(129, 140, 248, 0.2); border-radius: 0.25rem; font-size: 0.7rem; font-family: ui-monospace, monospace; color: #818cf8; } + .dns-enabled { color: var(--success); } + .dns-disabled { color: var(--muted); } @@ -145,19 +217,20 @@ SecuBox Factory +
+ + + +
- nodes ... ... - +
-
- - -
@@ -170,6 +243,12 @@
Loading services...
+ +
+
+
Loading catalog...
+
+
@@ -187,14 +266,27 @@ + +

Service Access

+ +
+
+ + +
+
+