Skip to main content

ados_protocol/
plugin_mdns.rs

1//! The `mdns.advertise` and `mdns.browse` plugin methods: DNS-SD on the LAN,
2//! done by the host on a plugin's behalf.
3//!
4//! A sandboxed plugin cannot run an mDNS responder itself. Every plugin unit
5//! carries `SocketBindDeny=any`, and systemd's bind filter matches every port
6//! under `any`, the ephemeral port 0 included, so an in-process daemon fails at
7//! its first `bind` (the signal socket) before it could reach the multicast
8//! port. The host is not sandboxed and publishes the record with the same
9//! library and the same SRV-target rule core's own `_ados._tcp` advert uses:
10//! the system hostname as [`crate::reach::mdns_hostname`] resolves it, the one
11//! name avahi answers for.
12//!
13//! An advert is owned by the plugin connection that made it and is withdrawn
14//! when that connection ends, so a record never outlives the process serving
15//! the port it names. Its port must be one of the plugin's declared
16//! `listen_ports`: a plugin can only advertise what the sandbox lets it serve.
17//! The service types core publishes itself ([`RESERVED_SERVICE_TYPES`]) are
18//! refused, so a plugin cannot impersonate a node's pairing record or a ground
19//! station's receiver. Browsing any well-formed type is allowed.
20//!
21//! The types are the wire shape: the host decodes the request and serializes
22//! the reply with them, and the SDK does the reverse, so the two cannot drift.
23
24use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27
28/// The advertise method's wire name.
29pub const ADVERTISE_METHOD: &str = "mdns.advertise";
30/// The capability `mdns.advertise` is gated on: advertising a port is offering
31/// it to the network, which is what `network.listen` grants.
32pub const ADVERTISE_CAPABILITY: &str = "network.listen";
33/// The browse method's wire name.
34pub const BROWSE_METHOD: &str = "mdns.browse";
35/// The capability `mdns.browse` is gated on: a browse finds peers to connect
36/// out to.
37pub const BROWSE_CAPABILITY: &str = "network.outbound";
38
39/// The service types core publishes. A plugin may browse them but never
40/// advertise one: `_ados._tcp` is every node's pairing record (the control
41/// front publishes it on every profile and interface) and `_ados-receiver._tcp`
42/// is the ground-station mesh receiver.
43pub const RESERVED_SERVICE_TYPES: &[&str] = &["_ados._tcp", "_ados-receiver._tcp"];
44
45/// The browse window when the request names none.
46pub const BROWSE_DEFAULT_TIMEOUT_MS: u32 = 3_000;
47/// The longest browse window a request may ask for.
48pub const BROWSE_MAX_TIMEOUT_MS: u32 = 5_000;
49/// The shortest browse window: one mDNS query round trip.
50pub const BROWSE_MIN_TIMEOUT_MS: u32 = 100;
51/// The most services one browse returns.
52pub const BROWSE_MAX_RESULTS: usize = 64;
53
54/// The most TXT entries one advert may carry.
55pub const TXT_MAX_ENTRIES: usize = 16;
56/// The longest TXT key.
57pub const TXT_MAX_KEY_LEN: usize = 32;
58/// The longest TXT value, in bytes.
59pub const TXT_MAX_VALUE_LEN: usize = 200;
60
61/// An `mdns.advertise` request.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct AdvertiseRequest {
64    /// The DNS-SD service type, `_<name>._tcp` (a trailing `.local.` is
65    /// accepted). TCP only: the ports a plugin may serve are TCP ports.
66    pub service_type: String,
67    /// The port the service answers on; one of the plugin's declared
68    /// `listen_ports` on this node's profile.
69    pub port: u16,
70    /// The TXT record, at most [`TXT_MAX_ENTRIES`] entries.
71    #[serde(default)]
72    pub txt: BTreeMap<String, String>,
73}
74
75/// The record the host published.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Advertised {
78    /// The full service instance name
79    /// (`<instance>.<service type>.local.`).
80    pub fullname: String,
81    /// The SRV target: the system hostname avahi answers for.
82    pub hostname: String,
83    pub port: u16,
84}
85
86/// An `mdns.browse` request.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct BrowseRequest {
89    /// The DNS-SD service type to browse, `_<name>._tcp` or `_<name>._udp`.
90    pub service_type: String,
91    /// How long to collect answers, clamped to
92    /// [`BROWSE_MIN_TIMEOUT_MS`]..=[`BROWSE_MAX_TIMEOUT_MS`];
93    /// [`BROWSE_DEFAULT_TIMEOUT_MS`] when absent.
94    #[serde(default)]
95    pub timeout_ms: Option<u32>,
96}
97
98/// What one browse found.
99#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
100pub struct BrowseReply {
101    /// Every instance resolved inside the window, in the order first seen, at
102    /// most [`BROWSE_MAX_RESULTS`].
103    pub services: Vec<DiscoveredService>,
104}
105
106/// One resolved service instance.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct DiscoveredService {
109    /// The full service instance name.
110    pub fullname: String,
111    /// The SRV target, without the trailing dot.
112    pub hostname: String,
113    pub port: u16,
114    /// The addresses the instance answered with, IPv4 first, each group sorted.
115    pub addresses: Vec<String>,
116    pub txt: BTreeMap<String, String>,
117}
118
119impl BrowseRequest {
120    /// The browse window this request asks for, clamped to the allowed range.
121    pub fn timeout_ms(&self) -> u32 {
122        self.timeout_ms
123            .unwrap_or(BROWSE_DEFAULT_TIMEOUT_MS)
124            .clamp(BROWSE_MIN_TIMEOUT_MS, BROWSE_MAX_TIMEOUT_MS)
125    }
126}
127
128/// The canonical `_<name>._<tcp|udp>` form of a service type (a trailing
129/// `.local.` or `.local` is stripped), or why it is not one.
130///
131/// The name follows RFC 6335: 1-15 characters of lowercase letters, digits and
132/// hyphens, at least one letter, no leading, trailing or doubled hyphen.
133/// Uppercase is folded, since DNS names compare case-insensitively.
134pub fn normalize_service_type(raw: &str) -> Result<String, String> {
135    let lowered = raw.trim().to_ascii_lowercase();
136    let bare = lowered
137        .strip_suffix(".local.")
138        .or_else(|| lowered.strip_suffix(".local"))
139        .unwrap_or(&lowered);
140    let (name, proto) = bare
141        .rsplit_once('.')
142        .ok_or_else(|| format!("service type {raw:?} is not _<name>._tcp or _<name>._udp"))?;
143    if proto != "_tcp" && proto != "_udp" {
144        return Err(format!("service type {raw:?} must end in ._tcp or ._udp"));
145    }
146    let label = name
147        .strip_prefix('_')
148        .ok_or_else(|| format!("service type {raw:?} must start with an underscore"))?;
149    let valid = (1..=15).contains(&label.len())
150        && label
151            .bytes()
152            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
153        && label.bytes().any(|b| b.is_ascii_lowercase())
154        && !label.starts_with('-')
155        && !label.ends_with('-')
156        && !label.contains("--");
157    if !valid {
158        return Err(format!(
159            "service type {raw:?}: the name must be 1-15 letters, digits and single inner hyphens"
160        ));
161    }
162    Ok(format!("_{label}.{proto}"))
163}
164
165/// The service type a plugin may advertise, canonical: TCP, well formed, and
166/// not one core publishes.
167pub fn advertisable_service_type(raw: &str) -> Result<String, String> {
168    let ty = normalize_service_type(raw)?;
169    if !ty.ends_with("._tcp") {
170        return Err(format!(
171            "service type {ty} is not TCP; a plugin serves only the TCP ports it declares"
172        ));
173    }
174    if RESERVED_SERVICE_TYPES.contains(&ty.as_str()) {
175        return Err(format!(
176            "service type {ty} is published by the agent itself"
177        ));
178    }
179    Ok(ty)
180}
181
182/// Check a TXT record against the size and key rules: at most
183/// [`TXT_MAX_ENTRIES`] entries, keys of 1..=[`TXT_MAX_KEY_LEN`] printable ASCII
184/// characters without `=`, values of at most [`TXT_MAX_VALUE_LEN`] bytes.
185pub fn validate_txt(txt: &BTreeMap<String, String>) -> Result<(), String> {
186    if txt.len() > TXT_MAX_ENTRIES {
187        return Err(format!(
188            "txt carries {} entries; at most {TXT_MAX_ENTRIES}",
189            txt.len()
190        ));
191    }
192    for (key, value) in txt {
193        let key_ok = (1..=TXT_MAX_KEY_LEN).contains(&key.len())
194            && key.bytes().all(|b| (0x21..=0x7e).contains(&b) && b != b'=');
195        if !key_ok {
196            return Err(format!(
197                "txt key {key:?} must be 1-{TXT_MAX_KEY_LEN} printable ASCII characters without '='"
198            ));
199        }
200        if value.len() > TXT_MAX_VALUE_LEN {
201            return Err(format!(
202                "txt value for {key:?} is {} bytes; at most {TXT_MAX_VALUE_LEN}",
203                value.len()
204            ));
205        }
206    }
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn service_types_normalize_to_the_bare_form() {
216        for raw in [
217            "_ados-compute._tcp",
218            "_ados-compute._tcp.local",
219            "_ados-compute._tcp.local.",
220            " _ADOS-Compute._TCP.local. ",
221        ] {
222            assert_eq!(
223                normalize_service_type(raw).as_deref(),
224                Ok("_ados-compute._tcp"),
225                "{raw}"
226            );
227        }
228        assert_eq!(
229            normalize_service_type("_rtsp._udp").as_deref(),
230            Ok("_rtsp._udp")
231        );
232    }
233
234    #[test]
235    fn malformed_service_types_are_refused() {
236        for raw in [
237            "",
238            "ados._tcp",
239            "_ados._sctp",
240            "_._tcp",
241            "_-ados._tcp",
242            "_ados-._tcp",
243            "_ad--os._tcp",
244            "_1234._tcp",
245            "_sixteen-chars-xx._tcp",
246            "_ados_x._tcp",
247            "_ados.x._tcp",
248        ] {
249            assert!(normalize_service_type(raw).is_err(), "{raw:?}");
250        }
251    }
252
253    #[test]
254    fn a_plugin_may_not_advertise_a_core_type_or_a_udp_one() {
255        assert!(advertisable_service_type("_ados._tcp.local.").is_err());
256        assert!(advertisable_service_type("_ADOS-receiver._tcp").is_err());
257        assert!(advertisable_service_type("_ados-compute._udp").is_err());
258        assert_eq!(
259            advertisable_service_type("_ados-compute._tcp").as_deref(),
260            Ok("_ados-compute._tcp")
261        );
262    }
263
264    #[test]
265    fn txt_records_are_bounded() {
266        let one = |k: &str, v: &str| BTreeMap::from([(k.to_string(), v.to_string())]);
267        assert!(validate_txt(&one("deviceId", "compute-8270beb76258")).is_ok());
268        assert!(validate_txt(&one("", "x")).is_err());
269        assert!(validate_txt(&one("a=b", "x")).is_err());
270        assert!(validate_txt(&one("sp ace", "x")).is_err());
271        assert!(validate_txt(&one(&"k".repeat(TXT_MAX_KEY_LEN + 1), "x")).is_err());
272        assert!(validate_txt(&one("k", &"v".repeat(TXT_MAX_VALUE_LEN + 1))).is_err());
273        let many: BTreeMap<String, String> = (0..=TXT_MAX_ENTRIES)
274            .map(|i| (format!("k{i}"), String::new()))
275            .collect();
276        assert!(validate_txt(&many).is_err());
277    }
278
279    #[test]
280    fn a_browse_window_is_clamped() {
281        let req = |t: Option<u32>| BrowseRequest {
282            service_type: "_x._tcp".into(),
283            timeout_ms: t,
284        };
285        assert_eq!(req(None).timeout_ms(), BROWSE_DEFAULT_TIMEOUT_MS);
286        assert_eq!(req(Some(0)).timeout_ms(), BROWSE_MIN_TIMEOUT_MS);
287        assert_eq!(req(Some(60_000)).timeout_ms(), BROWSE_MAX_TIMEOUT_MS);
288        assert_eq!(req(Some(1_500)).timeout_ms(), 1_500);
289    }
290}