mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-28 02:55:52 +00:00
feat(server): advertise local RuView installations
This commit is contained in:
93
docs/adr/ADR-344-adaptive-local-installation-discovery.md
Normal file
93
docs/adr/ADR-344-adaptive-local-installation-discovery.md
Normal file
@@ -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=<opaque id>` | 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
|
||||
@@ -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 |
|
||||
|
||||
1
v2/Cargo.lock
generated
1
v2/Cargo.lock
generated
@@ -13442,6 +13442,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"mdns-sd",
|
||||
"midstreamer-attractor",
|
||||
"midstreamer-temporal-compare",
|
||||
"opentelemetry-appender-tracing",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
125
v2/crates/wifi-densepose-sensing-server/src/discovery.rs
Normal file
125
v2/crates/wifi-densepose-sensing-server/src/discovery.rs
Normal file
@@ -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<ServiceInfo, mdns_sd::Error> {
|
||||
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::<String>(),
|
||||
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<DiscoveryAdvertiser, mdns_sd::Error> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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 {
|
||||
|
||||
Reference in New Issue
Block a user