From 27f5540663d5eb21753f9d9c0ec6c3f348b710eb Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 27 Aug 2026 15:51:42 -0400 Subject: [PATCH] feat(server): advertise local RuView installations --- ...4-adaptive-local-installation-discovery.md | 93 +++++++++++++ docs/adr/README.md | 1 + v2/Cargo.lock | 1 + .../wifi-densepose-sensing-server/Cargo.toml | 1 + .../src/discovery.rs | 125 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/lib.rs | 1 + .../wifi-densepose-sensing-server/src/main.rs | 51 ++++++- 7 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 docs/adr/ADR-344-adaptive-local-installation-discovery.md create mode 100644 v2/crates/wifi-densepose-sensing-server/src/discovery.rs diff --git a/docs/adr/ADR-344-adaptive-local-installation-discovery.md b/docs/adr/ADR-344-adaptive-local-installation-discovery.md new file mode 100644 index 00000000..ac37ecce --- /dev/null +++ b/docs/adr/ADR-344-adaptive-local-installation-discovery.md @@ -0,0 +1,93 @@ +# ADR-344: Adaptive local installation discovery + +## Status + +Accepted — local advertisement and mobile discovery implemented; physical +peer-link and hosted-relay qualification pending. + +## Context + +RuView installations previously depended on a manually stored IP address. +DHCP changes, access-point changes, and client isolation could therefore leave +a healthy sensing installation unreachable from the mobile app. Repeated +authentication-policy log entries did not prove that the selected endpoint was +reachable. + +Discovery must make commissioning recoverable without turning a service name +into authentication, leaking sensor data, scanning arbitrary subnets, or +silently moving private spatial data to a hosted service. + +## Decision + +The sensing server advertises one bounded `_ruview._tcp.local.` service when it +is bound to a routable interface. Loopback-only instances do not advertise, +and operators can disable advertisement with `--no-mdns`. + +The TXT contract is deliberately small: + +| Key | Required | Meaning | +|---|---:|---| +| `schema=ruview.installation.v1` | yes | Fail-closed protocol discriminator | +| `tls=0|1` | yes | HTTP or HTTPS origin construction | +| `installation=` | no | Bounded routing hint, never authentication | + +The advertisement contains no node inventory, room identifier, SSID, +credentials, CSI, vital estimates, pose, identity, or learning data. The +hostname is bounded and sanitized before registration. Advertisement failure +is recoverable and does not stop sensing. + +RuView Mobile resolves only the matching service and schema, validates the +resulting origin under its private-LAN/HTTPS policy, and requires an application +health probe before selection. Its adaptive broker retains the configured +origin preference and requires two failed configured probes plus two healthy +fallback probes before switching. Credentials remain scoped to their saved +origin. + +The recovery ladder is: + +1. configured private-LAN or HTTPS origin; +2. verified Bonjour local origin; +3. physically qualified Apple peer-to-peer local path; +4. explicit, authenticated HTTPS relay for bounded derived frames only; +5. optional administrator-managed WireGuard/Tailscale access. + +Only levels 1 and 2 are implemented and software-validated by this decision. +Peer-to-peer browsing is enabled on Apple platforms, but it is not a qualified +peer-link data plane. This repository does not provide a hosted relay and must +fail closed when no local endpoint is healthy. + +## Security and privacy consequences + +- Service discovery is routing evidence, not installation authentication. +- Public HTTP origins, credentials in URLs, malformed records, and records with + the wrong schema are rejected before use. +- Raw CSI, RSSI streams, camera/LiDAR frames, room geometry, pose labels, + identity data, and training examples remain local. +- Future relay work requires a separate consent, authentication, revocation, + minimization, and physical evidence review. +- ESP32-S3/C6 nodes remain provisioned to the sensing installation. This ADR + does not claim direct phone-to-node discovery or invent a firmware protocol. + +## Validation + +Software acceptance requires: + +- unit tests for bounded advertisement construction and hostname sanitation; +- mobile parser rejection, route scoring, anti-flapping, and Settings UI tests; +- Rust, TypeScript, lint, security, metaharness, Expo, and native compile gates; +- a real Bonjour resolve of the TXT contract followed by a successful + `/api/v1/status` probe. + +Physical qualification additionally requires installation discovery on an +iPhone, live frame and node-inventory receipt, DHCP-change recovery without +flapping, and a five-to-ten-minute zero-fusion-error burn-in. Simulator and +host-only results remain `MEASURED_SOFTWARE`; peer-link, relay, and physical +reconnection claims remain `NOT_MEASURED` until those captures exist. + +## Implementation references + +- `v2/crates/wifi-densepose-sensing-server/src/discovery.rs` +- `v2/crates/wifi-densepose-sensing-server/src/main.rs` +- Mobile companion decision: `cognitum-one/ruview-mobile`, + `docs/adr/ADR-026-adaptive-local-installation-discovery-and-transport-recovery.md` +- Related decisions: ADR-034, ADR-054, ADR-296 diff --git a/docs/adr/README.md b/docs/adr/README.md index 5071ace4..6ea87665 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -105,6 +105,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-035](ADR-035-live-sensing-ui-accuracy.md) | Live Sensing UI Accuracy and Data Transparency | Accepted | | [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed | | [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted | +| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) | | [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) | | [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed | | [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed | diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 79772988..73d34fc5 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -13442,6 +13442,7 @@ dependencies = [ "futures-util", "hmac", "jsonwebtoken", + "mdns-sd", "midstreamer-attractor", "midstreamer-temporal-compare", "opentelemetry-appender-tracing", diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 5099c4be..3ab1a3c3 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -26,6 +26,7 @@ tower-http = { version = "0.6", features = ["fs", "cors", "set-header"] } tokio = { workspace = true, features = ["full", "process"] } futures-util = "0.3" ruvector-mincut = { workspace = true } +mdns-sd = "0.11" # Serialization serde = { workspace = true } diff --git a/v2/crates/wifi-densepose-sensing-server/src/discovery.rs b/v2/crates/wifi-densepose-sensing-server/src/discovery.rs new file mode 100644 index 00000000..10eda764 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/discovery.rs @@ -0,0 +1,125 @@ +//! Local DNS-SD advertisement for RuView installation discovery. +//! +//! The TXT record deliberately contains no SSID, node identifiers, room data, +//! sensor values, credentials, or personal data. Mobile clients treat the +//! installation id as a routing hint only and still health-check the service. + +use std::collections::HashMap; + +use mdns_sd::{ServiceDaemon, ServiceInfo}; + +pub const SERVICE_TYPE: &str = "_ruview._tcp.local."; +pub const SCHEMA: &str = "ruview.installation.v1"; + +pub struct DiscoveryAdvertiser { + daemon: ServiceDaemon, + fullname: String, +} + +impl Drop for DiscoveryAdvertiser { + fn drop(&mut self) { + let _ = self.daemon.unregister(&self.fullname); + let _ = self.daemon.shutdown(); + } +} + +/// Reduce an operator/host label to a deterministic RFC 6762-safe hostname. +pub fn discovery_hostname(raw: &str) -> String { + let mut label = String::with_capacity(48); + for character in raw.chars().flat_map(char::to_lowercase) { + if character.is_ascii_alphanumeric() { + label.push(character); + } else if (character == '-' || character == '_' || character == ' ') + && !label.ends_with('-') + { + label.push('-'); + } + if label.len() >= 40 { + break; + } + } + let label = label.trim_matches('-'); + let safe = if label.is_empty() { + "installation" + } else { + label + }; + format!("ruview-{safe}.local.") +} + +pub fn build_service( + instance_name: &str, + installation_id: &str, + hostname: &str, + http_port: u16, + tls: bool, +) -> Result { + let mut properties = HashMap::with_capacity(3); + properties.insert("schema".to_string(), SCHEMA.to_string()); + properties.insert("tls".to_string(), if tls { "1" } else { "0" }.to_string()); + properties.insert( + "installation".to_string(), + installation_id.chars().take(128).collect(), + ); + ServiceInfo::new( + SERVICE_TYPE, + &instance_name.chars().take(96).collect::(), + hostname, + (), + http_port, + Some(properties), + ) + .map(ServiceInfo::enable_addr_auto) +} + +/// Register a live local advertisement. Failure is recoverable: the sensing +/// server remains available through its explicitly configured origin. +pub fn start_advertiser( + instance_name: &str, + installation_id: &str, + hostname: &str, + http_port: u16, +) -> Result { + let daemon = ServiceDaemon::new()?; + let service = build_service(instance_name, installation_id, hostname, http_port, false)?; + let fullname = service.get_fullname().to_owned(); + daemon.register(service)?; + Ok(DiscoveryAdvertiser { daemon, fullname }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertisement_matches_mobile_contract_without_sensor_metadata() { + let service = build_service( + "RuView Living Room", + "home-a", + "ruview-home-a.local.", + 3000, + false, + ) + .unwrap(); + assert_eq!(service.get_type(), SERVICE_TYPE); + assert_eq!(service.get_port(), 3000); + assert!(service.is_addr_auto()); + assert_eq!(service.get_property_val_str("schema"), Some(SCHEMA)); + assert_eq!(service.get_property_val_str("tls"), Some("0")); + assert_eq!(service.get_property_val_str("installation"), Some("home-a")); + for forbidden in ["ssid", "node", "room", "csi", "pose", "token"] { + assert!(service.get_property(forbidden).is_none()); + } + } + + #[test] + fn hostname_is_bounded_and_safe() { + assert_eq!( + discovery_hostname("Cohen's Mac Mini"), + "ruview-cohens-mac-mini.local." + ); + let value = discovery_hostname("🚫 ///"); + assert_eq!(value, "ruview-installation.local."); + assert!(discovery_hostname(&"A".repeat(200)).len() <= 54); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 36958830..93d00f11 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -13,6 +13,7 @@ pub mod browser_session; pub mod ws_ticket; pub mod cli; pub mod dataset; +pub mod discovery; pub mod edge_registry; pub mod error_response; pub mod host_validation; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 0b35a5c6..fb1c9e09 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -133,6 +133,23 @@ struct Args { #[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")] bind_addr: String, + /// Disable local `_ruview._tcp` discovery. Discovery is automatically + /// skipped for loopback-only binds and never carries sensor data. + #[arg(long, env = "RUVIEW_NO_MDNS")] + no_mdns: bool, + + /// Stable, non-secret installation routing hint published over mDNS. + #[arg(long, env = "RUVIEW_INSTALLATION_ID")] + installation_id: Option, + + /// Human-readable local service name shown by commissioning clients. + #[arg( + long, + default_value = "RuView Installation", + env = "RUVIEW_INSTALLATION_NAME" + )] + installation_name: String, + /// Additional hostname (with or without `:PORT`) to permit in the `Host` /// header — defends loopback-bound deployments against DNS rebinding. /// Loopback names (`localhost`, `127.0.0.1`, `[::1]`) are always permitted @@ -8762,9 +8779,15 @@ async fn main() { ); wifi_densepose_sensing_server::host_validation::HostAllowlist::disabled() } else { + let discovery_label = args.installation_id.as_deref().unwrap_or("installation"); + let discovery_host = + wifi_densepose_sensing_server::discovery::discovery_hostname(discovery_label); let allowlist = wifi_densepose_sensing_server::host_validation::HostAllowlist::from_cli_and_env( - args.allowed_hosts.iter().cloned(), + args.allowed_hosts + .iter() + .cloned() + .chain(std::iter::once(discovery_host)), ); info!( "Host-header validation ON ({} entries; loopback names always included)", @@ -8978,6 +9001,32 @@ async fn main() { args.http_port ); + let discovery_label = args.installation_id.as_deref().unwrap_or("installation"); + let discovery_hostname = + wifi_densepose_sensing_server::discovery::discovery_hostname(discovery_label); + let _discovery_advertiser = if args.no_mdns || bind_ip.is_loopback() { + if bind_ip.is_loopback() && !args.no_mdns { + info!("RuView discovery skipped for loopback-only HTTP bind"); + } + None + } else { + match wifi_densepose_sensing_server::discovery::start_advertiser( + &args.installation_name, + discovery_label, + &discovery_hostname, + args.http_port, + ) { + Ok(advertiser) => { + info!(hostname = %discovery_hostname, port = args.http_port, "RuView local discovery advertised"); + Some(advertiser) + } + Err(error) => { + warn!(%error, "RuView local discovery unavailable; manual origin remains usable"); + None + } + } + }; + // Run the HTTP server with graceful shutdown support let shutdown_state = state.clone(); let server = axum::serve(http_listener, http_app).with_graceful_shutdown(async {