feat(server): advertise local RuView installations

This commit is contained in:
ruv
2026-08-27 15:51:42 -04:00
parent b742eae7d6
commit 27f5540663
7 changed files with 272 additions and 1 deletions

View 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

View File

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

@@ -13442,6 +13442,7 @@ dependencies = [
"futures-util",
"hmac",
"jsonwebtoken",
"mdns-sd",
"midstreamer-attractor",
"midstreamer-temporal-compare",
"opentelemetry-appender-tracing",

View File

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

View 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);
}
}

View File

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

View File

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