feat(p2p): Add MirrorBox NetMesh Catalog with DNS federation

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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-31 09:19:36 +01:00
parent cf115b346a
commit eec83efa13
10 changed files with 1412 additions and 98 deletions

View File

@ -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://<router-ip>: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://<router-ip>: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

View File

@ -1,7 +1,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-p2p
PKG_VERSION:=0.5.0
PKG_VERSION:=0.6.0
PKG_RELEASE:=1
PKG_MAINTAINER:=SecuBox Team
@ -22,8 +22,10 @@ define Package/secubox-p2p/description
DNS federation, and distributed service management. Includes mDNS
service announcement, REST API on port 7331 for mesh visibility,
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.
snapshots for cryptographic configuration validation, distributed
mesh services panel for aggregated service discovery across all nodes,
and MirrorBox NetMesh Catalog for cross-chain distributed service
registry with HAProxy vhost discovery and multi-endpoint access URLs.
endef
define Package/secubox-p2p/conffiles
@ -66,6 +68,7 @@ define Package/secubox-p2p/install
$(INSTALL_BIN) ./root/www/api/factory/snapshot $(1)/www/api/factory/
$(INSTALL_BIN) ./root/www/api/factory/pubkey $(1)/www/api/factory/
$(INSTALL_BIN) ./root/www/api/factory/mesh-services $(1)/www/api/factory/
$(INSTALL_BIN) ./root/www/api/factory/catalog $(1)/www/api/factory/
# Factory Web UI
$(INSTALL_DIR) $(1)/www/factory

View File

@ -295,6 +295,177 @@ factory_audit_log() {
fi
}
# ===========================================
# Distributed Catalog Functions
# ===========================================
CATALOG_DIR="$FACTORY_DIR/catalog"
LOCAL_CATALOG="$CATALOG_DIR/local.json"
MERGED_CATALOG="$CATALOG_DIR/merged.json"
# Initialize catalog directory
catalog_init() {
factory_init
mkdir -p "$CATALOG_DIR"
mkdir -p "$CATALOG_DIR/peers"
}
# Generate local catalog (calls the CGI endpoint logic)
catalog_generate_local() {
catalog_init
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 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 <peer_addr>}"
;;
esac
;;
*)
# Sourced as library - do nothing
:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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); }
</style>
</head>
<body>
@ -145,19 +217,20 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M9 8h6M9 12h6M9 16h6M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16"/></svg>
SecuBox Factory
</div>
<div class="emoji-nav">
<button class="emoji-btn active" onclick="switchTab('dashboard')" title="Dashboard">🏭</button>
<button class="emoji-btn" onclick="switchTab('services')" title="Mesh Services">🔗</button>
<button class="emoji-btn" onclick="switchTab('catalog')" title="Distributed Catalog">📚</button>
</div>
<div class="header-right">
<span class="badge badge-muted" id="node-count">- nodes</span>
<span class="badge" id="snapshot-status">...</span>
<span class="fingerprint" id="fingerprint" title="Node Fingerprint">...</span>
<button onclick="toggleTools()" title="Tools">Tools</button>
<button onclick="toggleTools()" title="Tools">🧰</button>
</div>
</header>
<main style="padding: 1rem;">
<div class="tabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('services')">Mesh Services</button>
</div>
<div id="dashboard" class="tab-content active" style="display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
<div class="card accent">
@ -170,6 +243,12 @@
<div class="stat"><div class="spinner"></div><div class="stat-label">Loading services...</div></div>
</div>
</div>
<div id="catalog" class="tab-content">
<div class="card accent">
<div class="stat"><div class="spinner"></div><div class="stat-label">Loading catalog...</div></div>
</div>
</div>
</main>
<div class="overlay" id="overlay" onclick="toggleTools()"></div>
@ -187,14 +266,27 @@
</div>
</dialog>
<dialog id="qr-modal" class="qr-modal">
<h3><span id="qr-title">Service Access</span><button onclick="closeQR()" style="background:transparent;font-size:1.25rem;padding:0.25rem;">&times;</button></h3>
<canvas id="qr-canvas" width="200" height="200"></canvas>
<div class="qr-url" id="qr-url"></div>
<div class="actions">
<button onclick="copyUrl()">📋 Copy URL</button>
<button onclick="closeQR()">Close</button>
</div>
</dialog>
<script>
// State
let data = { local: {}, peers: [], snapshot: {}, services: {}, system: {} };
let meshServices = { nodes: [], summary: {} };
let catalogData = { services: [], node_name: '', updated: '' };
let tools = [];
let refreshInterval = null;
let activeTab = 'dashboard';
let serviceFilter = { search: '', status: 'all', node: 'all' };
let catalogFilter = { search: '', type: 'all', status: 'all' };
let expandedServices = new Set();
// API helpers - use same origin (HAProxy routes /factory/* to API)
const apiBase = '/factory/';
@ -234,19 +326,47 @@
}
}
// Refresh catalog data
async function refreshCatalog() {
try {
catalogData = await api.get('catalog');
renderCatalog();
} catch (e) {
console.error('Catalog refresh failed:', e);
}
}
// Switch tabs
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.emoji-btn').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab[onclick*="${tab}"]`).classList.add('active');
document.querySelector(`.emoji-btn[onclick*="${tab}"]`).classList.add('active');
document.getElementById(tab).classList.add('active');
if (tab === 'services') {
refreshMeshServices();
} else if (tab === 'catalog') {
refreshCatalog();
}
}
// Filter catalog
function filterCatalog(type, value) {
catalogFilter[type] = value;
renderCatalog();
}
// Toggle service expansion
function toggleServiceExpand(name) {
if (expandedServices.has(name)) {
expandedServices.delete(name);
} else {
expandedServices.add(name);
}
renderCatalog();
}
// Filter services
function filterServices(type, value) {
serviceFilter[type] = value;
@ -472,19 +592,20 @@
html += `<div style="padding: 1rem; text-align: center; color: var(--muted);">No matching services</div>`;
} else {
services.forEach(svc => {
const statusClass = svc.status === 'running' ? 'running' : (svc.enabled ? 'stopped' : 'disabled');
const hasPort = svc.port && svc.port !== '' && svc.port !== '0';
const accessUrl = hasPort ? (node.is_local ? `http://${location.hostname}:${svc.port}` : `http://${nodeAddr}:${svc.port}`) : null;
const healthEmoji = svc.status === 'running' ? '🟢' : (svc.enabled ? '🔴' : '⚫');
const healthTitle = svc.status === 'running' ? 'Running' : (svc.enabled ? 'Stopped' : 'Disabled');
html += `
<div class="service-item">
<div class="service-info">
<div class="service-name">${svc.name}</div>
${hasPort ? `<div class="service-port">Port: ${svc.port}</div>` : ''}
${accessUrl ? `<div class="service-url"><a href="${accessUrl}" target="_blank">${accessUrl}</a></div>` : (hasPort ? `<div class="service-port">:${svc.port}</div>` : '')}
</div>
<div class="service-status">
${accessUrl ? `<a href="${accessUrl}" target="_blank" class="service-link">Open</a>` : ''}
<span class="status-dot ${statusClass}" title="${svc.status}"></span>
${accessUrl ? `<span class="health-emoji clickable" onclick="showQR('${svc.name}', '${accessUrl}')" title="Show QR">📱</span>` : ''}
<span class="health-emoji" title="${healthTitle}" onclick="toggleService('${svc.name}', '${svc.status}')">${healthEmoji}</span>
</div>
</div>`;
});
@ -500,6 +621,262 @@
document.getElementById('services').innerHTML = html;
}
// Render catalog panel
function renderCatalog() {
const services = catalogData.services || [];
const nodeName = catalogData.node_name || 'Local';
const nodeId = catalogData.node_id || '';
const updated = catalogData.updated || '';
const dns = catalogData.dns || { enabled: false, domain: 'mesh.local', hostname: '', fqdn: '' };
const linkedPeers = catalogData.linked_peers || [];
// Count stats
let totalServices = services.length;
let runningServices = services.filter(s => s.status === 'running').length;
let haproxyCount = 0;
let meshCount = 0;
let localCount = 0;
services.forEach(s => {
(s.endpoints || []).forEach(ep => {
if (ep.type === 'haproxy') haproxyCount++;
else if (ep.type === 'mesh') meshCount++;
else if (ep.type === 'local') localCount++;
});
});
// Filter services
let filteredServices = services;
if (catalogFilter.search) {
const search = catalogFilter.search.toLowerCase();
filteredServices = filteredServices.filter(s => s.name.toLowerCase().includes(search));
}
if (catalogFilter.status !== 'all') {
filteredServices = filteredServices.filter(s => s.status === catalogFilter.status);
}
if (catalogFilter.type !== 'all') {
filteredServices = filteredServices.filter(s =>
(s.endpoints || []).some(ep => ep.type === catalogFilter.type)
);
}
// Build linked peers section
let peersHtml = '';
if (linkedPeers.length > 0) {
peersHtml = `
<div class="peer-links">
<div class="peer-links-title">🔗 Linked Mesh Nodes</div>
${linkedPeers.map(peer => `
<a href="${peer.factory_url}" target="_blank" class="peer-link ${peer.status}" title="Open ${peer.name} Factory Dashboard">
<span>${peer.status === 'online' ? '🟢' : '🔴'}</span>
<span class="peer-link-name">${peer.name}</span>
${dns.enabled && peer.dns_fqdn ? `<span class="peer-link-dns">${peer.dns_fqdn}</span>` : ''}
</a>
`).join('')}
</div>`;
}
let html = `
<div class="services-summary">
<div class="summary-item">
<span class="summary-count">${runningServices}/${totalServices}</span>
<span class="summary-label">Services</span>
</div>
<div class="summary-item">
<span class="summary-count" style="color: var(--success);">${haproxyCount}</span>
<span class="summary-label">HAProxy URLs</span>
</div>
<div class="summary-item">
<span class="summary-count" style="color: #818cf8;">${meshCount}</span>
<span class="summary-label">Mesh URLs</span>
</div>
<div class="summary-item">
<span class="summary-count" style="color: var(--muted);">${localCount}</span>
<span class="summary-label">Local URLs</span>
</div>
<div class="summary-item">
<span class="dns-info ${dns.enabled ? 'dns-enabled' : 'dns-disabled'}">
${dns.enabled ? '🌐' : '⚫'} ${dns.enabled ? dns.fqdn : 'DNS off'}
</span>
</div>
<button class="sm" onclick="refreshCatalog()">Refresh</button>
<button class="sm primary" onclick="syncCatalog()">Sync Peers</button>
</div>
${peersHtml}
<div class="filter-bar">
<input type="text" class="filter-input" placeholder="Search services..."
value="${catalogFilter.search}"
oninput="filterCatalog('search', this.value)">
<select class="filter-select" onchange="filterCatalog('status', this.value)">
<option value="all" ${catalogFilter.status === 'all' ? 'selected' : ''}>All Status</option>
<option value="running" ${catalogFilter.status === 'running' ? 'selected' : ''}>Running</option>
<option value="stopped" ${catalogFilter.status === 'stopped' ? 'selected' : ''}>Stopped</option>
</select>
<select class="filter-select" onchange="filterCatalog('type', this.value)">
<option value="all" ${catalogFilter.type === 'all' ? 'selected' : ''}>All Endpoints</option>
<option value="haproxy" ${catalogFilter.type === 'haproxy' ? 'selected' : ''}>HAProxy (WAN)</option>
<option value="mesh" ${catalogFilter.type === 'mesh' ? 'selected' : ''}>Mesh (WG)</option>
<option value="local" ${catalogFilter.type === 'local' ? 'selected' : ''}>Local (LAN)</option>
</select>
</div>
<div class="catalog-node-group">
<div class="catalog-node-header">
<div class="catalog-node-name">
<span>📍</span> ${nodeName}
</div>
<div class="catalog-node-stats">
${updated ? `Updated: ${new Date(updated).toLocaleString()}` : ''}
</div>
</div>`;
if (filteredServices.length === 0) {
html += `<div class="card"><div style="text-align: center; padding: 2rem; color: var(--muted);">No services found matching filters</div></div>`;
} else {
filteredServices.forEach(svc => {
const isExpanded = expandedServices.has(svc.name);
const healthEmoji = svc.status === 'running' ? '🟢' : (svc.enabled ? '🔴' : '⚫');
const endpoints = svc.endpoints || [];
const primaryEndpoint = endpoints.find(ep => ep.primary) || endpoints[0];
const endpointCount = endpoints.length;
html += `
<div class="catalog-service">
<div class="catalog-service-header" onclick="toggleServiceExpand('${svc.name}')">
<div class="catalog-service-name">
<span>${healthEmoji}</span>
<span>${svc.name}</span>
${svc.port ? `<span class="badge badge-muted">:${svc.port}</span>` : ''}
</div>
<div class="catalog-service-meta">
<span class="badge badge-muted">${endpointCount} URL${endpointCount !== 1 ? 's' : ''}</span>
${primaryEndpoint ? `<span class="badge ${primaryEndpoint.ssl ? 'badge-ok' : 'badge-muted'}">${primaryEndpoint.type}</span>` : ''}
<span style="font-size: 0.8rem;">${isExpanded ? '▼' : '▶'}</span>
</div>
</div>
<div class="catalog-endpoints ${isExpanded ? 'open' : ''}">`;
// Sort endpoints: haproxy first, then mesh, then local
const sortOrder = { haproxy: 0, mesh: 1, local: 2 };
const sortedEndpoints = [...endpoints].sort((a, b) =>
(sortOrder[a.type] || 99) - (sortOrder[b.type] || 99)
);
sortedEndpoints.forEach(ep => {
const isPrimary = ep.primary === true;
html += `
<div class="endpoint-item ${ep.type} ${isPrimary ? 'primary' : ''}">
<span class="endpoint-type ${ep.type}">${ep.type}</span>
<div class="endpoint-url">
<a href="${ep.url}" target="_blank">${ep.url}</a>
</div>
<div class="endpoint-actions">
${ep.ssl ? '<span class="endpoint-badge ssl">SSL</span>' : ''}
${isPrimary ? '<span class="endpoint-badge">Primary</span>' : ''}
<button class="sm" onclick="event.stopPropagation(); showQRMulti('${svc.name}', ${JSON.stringify(endpoints).replace(/"/g, '&quot;')}, '${ep.url}')" title="QR Code">📱</button>
<button class="sm" onclick="event.stopPropagation(); copyToClipboard('${ep.url}')" title="Copy URL">📋</button>
</div>
</div>`;
});
html += `
</div>
</div>`;
});
}
html += `</div>`;
document.getElementById('catalog').innerHTML = html;
}
// Sync catalog with peers
async function syncCatalog() {
const modal = document.getElementById('modal');
document.getElementById('modal-title').textContent = 'Catalog Sync';
document.getElementById('modal-output').innerHTML = '<span class="spinner"></span> Syncing catalogs with peers...';
modal.showModal();
try {
const result = await api.post('run', { tool: 'catalog-sync' });
if (result.success) {
document.getElementById('modal-output').textContent = result.output || 'Sync completed';
refreshCatalog();
} else {
document.getElementById('modal-output').textContent = 'Error: ' + (result.error || 'Sync failed');
}
} catch (e) {
document.getElementById('modal-output').textContent = 'Error: ' + e.message;
}
}
// Show QR with multiple URL options
function showQRMulti(name, endpoints, selectedUrl) {
currentQRUrl = selectedUrl;
document.getElementById('qr-title').textContent = name;
// Build URL tabs
let tabsHtml = '<div class="url-tabs">';
endpoints.forEach(ep => {
const isActive = ep.url === selectedUrl;
tabsHtml += `<button class="url-tab ${ep.type} ${isActive ? 'active' : ''}" onclick="switchQRUrl('${name}', '${ep.url}', '${ep.type}')">${ep.type}${ep.ssl ? ' 🔒' : ''}</button>`;
});
tabsHtml += '</div>';
// Insert tabs before URL display
const qrUrlEl = document.getElementById('qr-url');
qrUrlEl.innerHTML = tabsHtml + `<div style="margin-top: 0.5rem;">${selectedUrl}</div>`;
// Generate QR
const canvas = document.getElementById('qr-canvas');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, 200, 200);
ctx.drawImage(img, 0, 0, 200, 200);
};
img.src = `https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=${encodeURIComponent(selectedUrl)}&choe=UTF-8`;
document.getElementById('qr-modal').showModal();
}
// Switch QR URL tab
function switchQRUrl(name, url, type) {
currentQRUrl = url;
// Update active tab
document.querySelectorAll('.url-tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.url-tab.${type}`).classList.add('active');
// Update URL display
const qrUrlEl = document.getElementById('qr-url');
const tabsHtml = qrUrlEl.querySelector('.url-tabs').outerHTML;
qrUrlEl.innerHTML = tabsHtml + `<div style="margin-top: 0.5rem;">${url}</div>`;
// Regenerate QR
const canvas = document.getElementById('qr-canvas');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, 200, 200);
ctx.drawImage(img, 0, 0, 200, 200);
};
img.src = `https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=${encodeURIComponent(url)}&choe=UTF-8`;
}
// Copy to clipboard helper
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
// Brief visual feedback could be added here
});
}
// Render tools panel
function renderTools(categories) {
const grouped = {};
@ -559,10 +936,51 @@
document.getElementById('modal').close();
}
// QR Code functions
let currentQRUrl = '';
function showQR(name, url) {
currentQRUrl = url;
document.getElementById('qr-title').textContent = name;
document.getElementById('qr-url').textContent = url;
// Simple QR code using Google Charts API (zero deps)
const canvas = document.getElementById('qr-canvas');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, 200, 200);
ctx.drawImage(img, 0, 0, 200, 200);
};
img.src = `https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=${encodeURIComponent(url)}&choe=UTF-8`;
document.getElementById('qr-modal').showModal();
}
function closeQR() {
document.getElementById('qr-modal').close();
}
function copyUrl() {
navigator.clipboard.writeText(currentQRUrl).then(() => {
const btn = event.target;
btn.textContent = '✓ Copied!';
setTimeout(() => btn.textContent = '📋 Copy URL', 2000);
});
}
function toggleService(name, status) {
// Could be extended to start/stop services
console.log(`Toggle service ${name} (currently ${status})`);
}
// Handle ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (document.getElementById('modal').open) closeModal();
if (document.getElementById('qr-modal').open) closeQR();
else if (document.getElementById('modal').open) closeModal();
else if (document.getElementById('tools').classList.contains('open')) toggleTools();
}
});