294 lines
11 KiB
Rust
294 lines
11 KiB
Rust
//! `mdns` — pure builder for the cog's mDNS advertisement record.
|
|
//!
|
|
//! ADR-116 §2.2: the cog must advertise itself as `_ruview-ha._tcp`
|
|
//! so HA's discovery integration finds the Seed without manual
|
|
//! `broker host` config. This module produces the typed wire-format
|
|
//! shape — no socket I/O, no responder. The actual mDNS responder
|
|
//! (mdns-sd / zeroconf / pnet) lands next iter and consumes this
|
|
//! struct as its single input.
|
|
//!
|
|
//! Keeping the record-builder pure means:
|
|
//!
|
|
//! * the responder library can be swapped without touching the
|
|
//! content of the advertisement;
|
|
//! * the build-time `--print-manifest` path can include the
|
|
//! advertisement shape so Seed integration tests can assert on
|
|
//! it without booting tokio;
|
|
//! * the TXT keys are locked by named unit tests — drift between
|
|
//! the cog and the HA-side YAML auto-discovery (`hass-wifi-...`)
|
|
//! fires a test instead of silently breaking a deployment.
|
|
//!
|
|
//! ## TXT record convention (RFC 6763)
|
|
//!
|
|
//! HA's mDNS discovery integration reads TXT records when binding a
|
|
//! manifest to a `homeassistant.<integration>` zeroconf hook. We
|
|
//! publish the minimum set that lets HA distinguish a Seed cog from
|
|
//! a bare sensing-server and pick the right config flow:
|
|
//!
|
|
//! | Key | Value | Purpose |
|
|
//! |---|---|---|
|
|
//! | `cog_id` | `"ha-matter"` | Disambiguates from other RuView cogs |
|
|
//! | `cog_version` | `CARGO_PKG_VERSION` | HA Repairs surfaces upgrade nudges |
|
|
//! | `node_id` | identity node id | HA device registry key |
|
|
//! | `mqtt_port` | u16 string | Tells HA where to reach the cog's MQTT broker (embedded or external) |
|
|
//! | `privacy` | `"1"` / `"0"` | If `1`, HA's config flow gates biometric entities by default |
|
|
//! | `proto` | `"ruview-ha/1"` | Protocol version — bumps on breaking auto-discovery changes |
|
|
//!
|
|
//! No biometric data, no node coordinates, no SSID — TXT records
|
|
//! 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
|
|
/// at build time. Visible in HA's UI when the integration card is
|
|
/// added — "Cognitum Seed (kitchen)" beats a raw UUID.
|
|
const INSTANCE_TEMPLATE: &str = "Cognitum Seed — {node_id}";
|
|
|
|
/// Wire-format twin of the mDNS service record this cog publishes.
|
|
/// Owned so the responder can move the whole thing into its task.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MdnsService {
|
|
/// RFC 6763 service type. Locked to `_ruview-ha._tcp` by a named
|
|
/// test — drift breaks HA's YAML auto-discovery binding.
|
|
pub service_type: String,
|
|
/// Human-readable instance name shown in HA's discovery UI.
|
|
pub instance_name: String,
|
|
/// Port the cog's control plane listens on (NOT the MQTT broker
|
|
/// port — HA needs both, but the service record advertises the
|
|
/// control plane; the MQTT port rides as a TXT record).
|
|
pub control_port: u16,
|
|
/// TXT records sorted by key for deterministic ordering. RFC
|
|
/// 6763 §6.4 makes ordering implementation-defined, but locking
|
|
/// it keeps the cog's wire shape byte-stable across rebuilds.
|
|
pub txt_records: Vec<(String, String)>,
|
|
}
|
|
|
|
impl MdnsService {
|
|
/// Look up a TXT key without iterating the caller. `None` if the
|
|
/// key isn't published — the responder treats absence as
|
|
/// "feature off" rather than "unknown".
|
|
pub fn txt(&self, key: &str) -> Option<&str> {
|
|
self.txt_records
|
|
.iter()
|
|
.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
|
|
/// identity + ports. Pure: no I/O, no env reads.
|
|
pub fn build_mdns_service(
|
|
identity: &crate::runtime::CogIdentity,
|
|
control_port: u16,
|
|
mqtt_port: u16,
|
|
privacy_mode: bool,
|
|
) -> MdnsService {
|
|
let mut txt_records = vec![
|
|
("cog_id".to_string(), COG_ID.to_string()),
|
|
("cog_version".to_string(), identity.sw_version.clone()),
|
|
("node_id".to_string(), identity.node_id.clone()),
|
|
("mqtt_port".to_string(), mqtt_port.to_string()),
|
|
(
|
|
"privacy".to_string(),
|
|
if privacy_mode { "1" } else { "0" }.to_string(),
|
|
),
|
|
("proto".to_string(), "ruview-ha/1".to_string()),
|
|
];
|
|
// Deterministic ordering — see field docstring.
|
|
txt_records.sort();
|
|
|
|
MdnsService {
|
|
service_type: crate::MDNS_SERVICE_TYPE.to_string(),
|
|
instance_name: INSTANCE_TEMPLATE.replace("{node_id}", &identity.node_id),
|
|
control_port,
|
|
txt_records,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::runtime::CogIdentity;
|
|
|
|
fn id() -> CogIdentity {
|
|
CogIdentity {
|
|
node_id: "kitchen-seed".into(),
|
|
friendly_name: "Kitchen Seed".into(),
|
|
sw_version: "0.3.0".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn service_type_locked_to_ruview_ha_tcp() {
|
|
// Drift here breaks HA's YAML auto-discovery binding. Lock
|
|
// it so a future rename surfaces a named test instead of a
|
|
// silent broken deployment.
|
|
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
|
assert_eq!(svc.service_type, "_ruview-ha._tcp");
|
|
assert_eq!(svc.service_type, crate::MDNS_SERVICE_TYPE);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_name_carries_node_id() {
|
|
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
|
assert!(svc.instance_name.contains("kitchen-seed"));
|
|
}
|
|
|
|
#[test]
|
|
fn control_port_field_holds_control_not_mqtt_port() {
|
|
// Easy to swap by accident. Lock the binding so a refactor
|
|
// doesn't silently advertise the MQTT broker as the control
|
|
// plane.
|
|
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
|
assert_eq!(svc.control_port, 9180);
|
|
assert_eq!(svc.txt("mqtt_port"), Some("1883"));
|
|
}
|
|
|
|
#[test]
|
|
fn privacy_flag_is_one_or_zero() {
|
|
let on = build_mdns_service(&id(), 9180, 1883, true);
|
|
let off = build_mdns_service(&id(), 9180, 1883, false);
|
|
assert_eq!(on.txt("privacy"), Some("1"));
|
|
assert_eq!(off.txt("privacy"), Some("0"));
|
|
}
|
|
|
|
#[test]
|
|
fn proto_version_bumps_surface_in_txt() {
|
|
// Locked so a future breaking-change in the cog ↔ HA YAML
|
|
// contract surfaces here. Bumping it is a deliberate act.
|
|
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
|
assert_eq!(svc.txt("proto"), Some("ruview-ha/1"));
|
|
}
|
|
|
|
#[test]
|
|
fn cog_id_in_txt_matches_crate_constant() {
|
|
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
|
assert_eq!(svc.txt("cog_id"), Some(crate::COG_ID));
|
|
}
|
|
|
|
#[test]
|
|
fn txt_records_are_sorted_for_byte_stable_advertisement() {
|
|
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
|
let keys: Vec<&str> = svc.txt_records.iter().map(|(k, _)| k.as_str()).collect();
|
|
let mut sorted = keys.clone();
|
|
sorted.sort();
|
|
assert_eq!(keys, sorted);
|
|
}
|
|
|
|
#[test]
|
|
fn txt_carries_no_biometric_or_pii_keys() {
|
|
// TXT records broadcast in cleartext; passive scanners
|
|
// harvest them. Lock the publishable surface so a future
|
|
// "let's add hr_bpm to TXT for convenience" patch fires a
|
|
// named test instead of leaking biometrics.
|
|
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
|
let forbidden = [
|
|
"hr_bpm",
|
|
"br_bpm",
|
|
"pose_x",
|
|
"pose_y",
|
|
"keypoint",
|
|
"ssid",
|
|
"lat",
|
|
"lon",
|
|
"mac",
|
|
"rssi",
|
|
];
|
|
for key in forbidden {
|
|
assert!(
|
|
svc.txt(key).is_none(),
|
|
"TXT key `{key}` leaks PII / biometric data — must not be advertised"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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.
|
|
// Adding a key is fine; removing or renaming one breaks
|
|
// every deployed Seed. This test catches both directions.
|
|
let svc = build_mdns_service(&id(), 9180, 1883, true);
|
|
let required = ["cog_id", "cog_version", "node_id", "mqtt_port", "privacy", "proto"];
|
|
for key in required {
|
|
assert!(svc.txt(key).is_some(), "TXT key `{key}` missing");
|
|
}
|
|
}
|
|
}
|