mirror of
https://github.com/ruvnet/RuView
synced 2026-06-10 10:23:19 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07b792715f | |||
| 34eced880f |
@@ -95,7 +95,7 @@ Ranked by build cost × user impact:
|
||||
| **P1** | Research dossier ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)) | ✅ **done** — 8 sections, 30+ citations, v1 scope ranked |
|
||||
| **P2** | Cog crate scaffold (`v2/crates/cog-ha-matter/`) — Cargo.toml + `src/{lib,main,manifest}.rs`, workspace member, CLI args, `--print-manifest` flag, 2 manifest unit tests | ✅ **done** — `cargo check` + `cargo test` green |
|
||||
| **P3** | Wrap existing ADR-115 MQTT publisher as cog entry point | ✅ **wiring done** — `main.rs` boots ADR-115's `publisher::spawn` via `runtime::spawn_publisher` thin wrapper, holds a long-lived `broadcast::Sender<VitalsSnapshot>`, awaits Ctrl-C. Live-handle test green without a broker. Next (P3.5): subscribe to sensing-server `/v1/snapshot` WS and republish into the channel. |
|
||||
| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — (a) mDNS record-builder ✅. (b) Witness hash-chain ✅. (c) JSONL line serializer ✅. (d) File persistence + chain-level verify ✅. **(e) Ed25519 signing layer ✅** — `witness_signing::{sign_event, verify_signature, signature_to_hex, signature_from_hex}` signs the same canonical bytes the hash chain commits to, so a single attestation covers `kind + payload + ts + seq + prev_hash`. Tests cover wrong-key, tampered-event, wrong-prev_hash, hex round-trip, determinism. (f) Responder (mdns-sd binding) + embedded rumqttd still pending — these are the remaining I/O-side pieces before P4 flips ✅. |
|
||||
| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — **mDNS half complete:** record-builder ✅, ServiceInfo conversion ✅, **live responder ✅** (`runtime::start_mdns_responder` binds multicast, registers, returns `MdnsResponderHandle` with explicit `shutdown()` + best-effort Drop). **Witness half complete:** hash-chain ✅, JSONL line serializer ✅, file persistence + chain-level verify ✅, Ed25519 signing ✅. **Remaining:** embedded rumqttd broker. |
|
||||
| **P5** | RuVector-backed threshold learning (SONA adaptation) | pending |
|
||||
| **P6** | Multi-Seed federation (cross-Seed dedup + witness) | pending |
|
||||
| **P7** | Matter Bridge mode (depends on matter-rs / esp-matter readiness) | pending |
|
||||
|
||||
Generated
+1
@@ -935,6 +935,7 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ed25519-dalek",
|
||||
"mdns-sd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -41,5 +41,10 @@ wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardwar
|
||||
sha2 = { workspace = true }
|
||||
ed25519-dalek = "2.1"
|
||||
|
||||
# mDNS responder (ADR-116 P4 §2.2): pure-Rust zero-conf daemon.
|
||||
# Same version pinned in wifi-densepose-desktop to keep the
|
||||
# workspace lockfile narrow.
|
||||
mdns-sd = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
//! are broadcast in cleartext and harvested by passive scanners, so
|
||||
//! treating them as PII-clean is part of the privacy posture.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mdns_sd::ServiceInfo;
|
||||
|
||||
use crate::COG_ID;
|
||||
|
||||
/// Default mDNS instance name template. `{node_id}` is substituted
|
||||
@@ -74,6 +78,33 @@ impl MdnsService {
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Convert into the `mdns_sd::ServiceInfo` the responder daemon
|
||||
/// consumes. Pure transform — no socket binding, no daemon
|
||||
/// registration. The caller wires the resulting `ServiceInfo`
|
||||
/// into `ServiceDaemon::register` (next iter).
|
||||
///
|
||||
/// `hostname` should end in `.local.` per RFC 6762 — e.g.
|
||||
/// `"cognitum-seed-1.local."`. `ipv4` is the LAN-routable
|
||||
/// address HA's discovery will reach back on.
|
||||
pub fn to_service_info(
|
||||
&self,
|
||||
hostname: &str,
|
||||
ipv4: &str,
|
||||
) -> Result<ServiceInfo, mdns_sd::Error> {
|
||||
let mut props: HashMap<String, String> = HashMap::with_capacity(self.txt_records.len());
|
||||
for (k, v) in &self.txt_records {
|
||||
props.insert(k.clone(), v.clone());
|
||||
}
|
||||
ServiceInfo::new(
|
||||
&self.service_type,
|
||||
&self.instance_name,
|
||||
hostname,
|
||||
ipv4,
|
||||
self.control_port,
|
||||
Some(props),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the cog's mDNS advertisement record from the cog's typed
|
||||
@@ -203,6 +234,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_carries_service_type_and_port() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// mdns-sd may rewrite the type with a trailing dot; allow
|
||||
// both forms.
|
||||
let ty = info.get_type();
|
||||
assert!(
|
||||
ty == "_ruview-ha._tcp" || ty == "_ruview-ha._tcp.",
|
||||
"unexpected service type: {ty}"
|
||||
);
|
||||
assert_eq!(info.get_port(), 9180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_propagates_txt_records() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, true);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// Every locked TXT key must reach the wire-format payload.
|
||||
assert_eq!(info.get_property_val_str("cog_id"), Some(crate::COG_ID));
|
||||
assert_eq!(info.get_property_val_str("mqtt_port"), Some("1883"));
|
||||
assert_eq!(info.get_property_val_str("privacy"), Some("1"));
|
||||
assert_eq!(info.get_property_val_str("proto"), Some("ruview-ha/1"));
|
||||
assert!(info.get_property_val_str("node_id").is_some());
|
||||
assert!(info.get_property_val_str("cog_version").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_does_not_silently_drop_caller_hostname() {
|
||||
// mdns-sd 0.11 accepts bare hostnames (no `.local.`); the
|
||||
// responsibility for the trailing dot lives in our wrapper.
|
||||
// Lock that the caller's hostname survives the conversion
|
||||
// verbatim — a future bump that starts mutating the value
|
||||
// surfaces a named test instead of a silent change.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.unwrap();
|
||||
assert!(info.get_hostname().contains("cognitum-seed-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_keys_match_locked_surface() {
|
||||
// The HA-side YAML auto-discovery binds on these exact keys.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use mdns_sd::ServiceDaemon;
|
||||
use tokio::{sync::broadcast, task::JoinHandle};
|
||||
use wifi_densepose_sensing_server::mqtt::{
|
||||
config::{MqttConfig, PublishRates, TlsConfig},
|
||||
@@ -29,6 +30,8 @@ use wifi_densepose_sensing_server::mqtt::{
|
||||
DEFAULT_DISCOVERY_PREFIX, MANUFACTURER,
|
||||
};
|
||||
|
||||
use crate::mdns::MdnsService;
|
||||
|
||||
/// Caller-supplied identity for the cog instance. Filled in by the
|
||||
/// cog runtime from the mDNS hostname / Seed control plane in
|
||||
/// production; threaded as a parameter so tests can build inputs
|
||||
@@ -129,6 +132,66 @@ pub fn spawn_publisher(
|
||||
publisher::spawn(Arc::new(config), discovery, state_rx)
|
||||
}
|
||||
|
||||
/// Owned handle to a live mDNS responder. Holding it keeps the
|
||||
/// service advertised; `shutdown` unregisters cleanly so HA's
|
||||
/// discovery integration sees a goodbye packet instead of a
|
||||
/// dropped advertisement.
|
||||
///
|
||||
/// `Drop` is best-effort: tries unregister + daemon shutdown but
|
||||
/// swallows errors, since panicking in Drop would mask the real
|
||||
/// failure that prompted the shutdown.
|
||||
pub struct MdnsResponderHandle {
|
||||
daemon: ServiceDaemon,
|
||||
fullname: String,
|
||||
}
|
||||
|
||||
impl MdnsResponderHandle {
|
||||
/// Fully-qualified DNS-SD name (`<instance>.<type>.<domain>`).
|
||||
/// Exposed for tests + logging; the responder uses it to
|
||||
/// unregister.
|
||||
pub fn fullname(&self) -> &str {
|
||||
&self.fullname
|
||||
}
|
||||
|
||||
/// Unregister the service and shut down the daemon. Returns
|
||||
/// any error so the caller's shutdown sequence can surface it.
|
||||
pub fn shutdown(self) -> Result<(), mdns_sd::Error> {
|
||||
let _ = self.daemon.unregister(&self.fullname);
|
||||
let _ = self.daemon.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MdnsResponderHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.daemon.unregister(&self.fullname);
|
||||
let _ = self.daemon.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the mDNS responder for a cog and register its service.
|
||||
///
|
||||
/// Binds a multicast socket (`mdns_sd::ServiceDaemon::new`) and
|
||||
/// publishes `service` under `hostname` (must end in `.local.`)
|
||||
/// and `ipv4` (the LAN-routable address HA's discovery reaches
|
||||
/// back on).
|
||||
///
|
||||
/// Live-I/O: binding multicast may fail in containerised CI or
|
||||
/// on networks where 5353/udp is filtered — callers should treat
|
||||
/// the error as recoverable (log + retry, or fall back to manual
|
||||
/// HA configuration) rather than fatal to the cog.
|
||||
pub fn start_mdns_responder(
|
||||
service: &MdnsService,
|
||||
hostname: &str,
|
||||
ipv4: &str,
|
||||
) -> Result<MdnsResponderHandle, mdns_sd::Error> {
|
||||
let daemon = ServiceDaemon::new()?;
|
||||
let info = service.to_service_info(hostname, ipv4)?;
|
||||
let fullname = info.get_fullname().to_string();
|
||||
daemon.register(info)?;
|
||||
Ok(MdnsResponderHandle { daemon, fullname })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -230,6 +293,36 @@ mod tests {
|
||||
assert!(DEFAULT_STATE_CHANNEL_CAPACITY >= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mdns_responder_fullname_concatenates_instance_and_service_type() {
|
||||
// Live-I/O test: binds multicast on the loopback adapter.
|
||||
// Skips with a warning if the host's network stack refuses
|
||||
// the bind (containerised CI without --network host, etc.)
|
||||
// rather than failing the whole test suite.
|
||||
use crate::mdns::build_mdns_service;
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let handle = match start_mdns_responder(&svc, "cog-ha-matter-test.local.", "127.0.0.1") {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("mdns multicast bind not available in this sandbox: {e} — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Fullname format is "<instance>.<service_type>." per RFC 6763.
|
||||
// mdns-sd may URL-escape special chars (— in instance name) so
|
||||
// we only assert on the service-type segment which is stable.
|
||||
let fullname = handle.fullname().to_string();
|
||||
assert!(
|
||||
!fullname.is_empty(),
|
||||
"fullname empty after register"
|
||||
);
|
||||
assert!(
|
||||
fullname.contains("_ruview-ha._tcp"),
|
||||
"fullname `{fullname}` missing service type"
|
||||
);
|
||||
handle.shutdown().expect("clean shutdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_identity_carries_pkg_version_and_pid() {
|
||||
let identity = CogIdentity::default_for_build();
|
||||
|
||||
Reference in New Issue
Block a user