sdr_rtltcp_discovery/lib.rs
1#![allow(
2 // Service / event names duplicate across docs and code; no gain
3 // from backtick-wrapping every mention.
4 clippy::doc_markdown,
5 // `AdvertiseOptions` is moved-in deliberately; Browser & Advertiser
6 // both have owned state paths that don't consume the input fully.
7 clippy::needless_pass_by_value,
8 // Folding `ServiceEvent::SearchStarted | SearchStopped | ServiceFound => None`
9 // with the `_ => None` catch-all loses the explicit variant list we
10 // want future-readers to see.
11 clippy::match_same_arms
12)]
13//! mDNS/DNS-SD discovery for `rtl_tcp`-compatible servers.
14//!
15//! Provides:
16//! - [`Advertiser`] — for an `rtl_tcp` server to announce itself on
17//! the local network (e.g. the `sdr-server-rtltcp` crate uses this)
18//! - [`Browser`] — for an `rtl_tcp` client to find servers without
19//! the user manually typing `host:port`
20//!
21//! Service type: `_rtl_tcp._tcp.local.` This is not an IANA-registered
22//! type — the SDR ecosystem uses it by convention (ShinySDR,
23//! `rtl_tcp_client`, etc.). Picking the same string means interop with
24//! those tools where they implement discovery.
25//!
26//! ## Pure-Rust stack
27//!
28//! Uses `mdns-sd` — no Avahi / Bonjour system dependency, no async
29//! runtime. The daemon runs on its own thread internally; the
30//! [`Browser`] spawns a second thread that translates `mdns-sd`'s
31//! event channel into the domain events in this crate.
32
33mod advertiser;
34mod browser;
35mod error;
36mod txt;
37
38pub use advertiser::{AdvertiseOptions, Advertiser, local_hostname};
39pub use browser::{Browser, DiscoveredServer, DiscoveryEvent};
40pub use error::DiscoveryError;
41pub use txt::TxtRecord;
42
43/// Fully-qualified mDNS service type used by every rtl_tcp
44/// advertisement. This string is load-bearing for interop — any other
45/// tool that wants to browse us (or that we want to browse) must use
46/// the same literal.
47pub const SERVICE_TYPE: &str = "_rtl_tcp._tcp.local.";
48
49#[cfg(test)]
50#[allow(clippy::unwrap_used)]
51mod tests {
52 use super::*;
53
54 #[test]
55 fn local_hostname_returns_bare_non_empty_name_without_local_suffix() {
56 // Contract: non-empty, no trailing `.local.` / `.local`.
57 // `libc::gethostname` on CI runners and dev machines returns a
58 // real name; if the syscall ever failed it'd fall back to
59 // "localhost" which still satisfies the contract.
60 let host = local_hostname();
61 assert!(!host.is_empty(), "local_hostname() returned empty string");
62 // clippy::case_sensitive_file_extension_comparisons wants
63 // `.rsplit('.').next()` — but this is a hostname-suffix check
64 // that is genuinely case-sensitive per DNS labels (though
65 // mDNS normalizes case in practice, our local_hostname()
66 // contract is byte-exact). Allow the lint locally.
67 #[allow(clippy::case_sensitive_file_extension_comparisons)]
68 let ends_bad = host.ends_with(".local.") || host.ends_with(".local");
69 assert!(
70 !ends_bad,
71 "local_hostname() must return bare name, not mDNS-qualified: {host:?}"
72 );
73 // mDNS DNS-SD instance-name components aren't allowed to
74 // contain NUL bytes. gethostname should never produce one, but
75 // our UTF-8 trim path should have stripped any interior NUL
76 // regardless.
77 assert!(!host.contains('\0'));
78 }
79
80 #[test]
81 fn service_type_matches_dns_sd_shape() {
82 // `_service._transport.domain.` — trailing dot means
83 // fully-qualified in DNS. This exact string is used for both
84 // registration and browse queries; regressing it silently
85 // breaks interop.
86 assert_eq!(SERVICE_TYPE, "_rtl_tcp._tcp.local.");
87 assert!(SERVICE_TYPE.starts_with("_rtl_tcp."));
88 assert!(SERVICE_TYPE.contains("._tcp."));
89 assert!(SERVICE_TYPE.ends_with("local."));
90 }
91
92 /// Live integration test: start an Advertiser on localhost, start
93 /// a Browser, and verify we see our own advertisement come back.
94 ///
95 /// `#[ignore]` because it requires a functioning mDNS multicast
96 /// layer (UDP 5353 on 224.0.0.251) — works fine on dev machines
97 /// but unreliable in sandboxed CI environments.
98 ///
99 /// Run manually with `cargo test --ignored mdns_roundtrip`.
100 #[test]
101 #[ignore = "needs multicast network; run with --ignored locally"]
102 fn mdns_roundtrip() {
103 use std::sync::{Arc, Mutex};
104 use std::time::{Duration, Instant};
105
106 /// Arbitrary high port for the fake advertisement — outside
107 /// the upstream rtl_tcp default (1234) so a stray real server
108 /// doesn't alias this test.
109 const MDNS_ROUNDTRIP_PORT: u16 = 31_234;
110 /// How long to wait for mDNS propagation across the loopback
111 /// multicast path. Typical resolution is 1-2 s; 5 s is slack
112 /// for slower loopbacks / loaded machines.
113 const MDNS_PROPAGATION_TIMEOUT: Duration = Duration::from_secs(5);
114 /// Poll cadence while waiting. Short enough that the test
115 /// doesn't sleep meaningfully past the actual resolution.
116 const MDNS_POLL_INTERVAL: Duration = Duration::from_millis(100);
117 /// Expected gain count in the TXT payload — R820T standard
118 /// step count.
119 const R820T_GAIN_COUNT: u32 = 29;
120
121 let observed: Arc<Mutex<Vec<DiscoveredServer>>> = Arc::new(Mutex::new(Vec::new()));
122 let obs_clone = observed.clone();
123 let browser = Browser::start(move |event| {
124 if let DiscoveryEvent::ServerAnnounced(s) = event
125 && s.instance_name.contains("sdr-rtltcp-integration-test")
126 {
127 obs_clone.lock().unwrap().push(s);
128 }
129 })
130 .expect("start browser");
131
132 // Advertise a fake server.
133 let _advertiser = Advertiser::announce(AdvertiseOptions {
134 port: MDNS_ROUNDTRIP_PORT,
135 instance_name: "sdr-rtltcp-integration-test".into(),
136 hostname: String::new(),
137 txt: TxtRecord {
138 tuner: "R820T".into(),
139 version: env!("CARGO_PKG_VERSION").into(),
140 gains: R820T_GAIN_COUNT,
141 nickname: "integration-test-nick".into(),
142 txbuf: None,
143 codecs: None,
144 auth_required: None,
145 },
146 })
147 .expect("announce");
148
149 let deadline = Instant::now() + MDNS_PROPAGATION_TIMEOUT;
150 while Instant::now() < deadline {
151 if !observed.lock().unwrap().is_empty() {
152 break;
153 }
154 std::thread::sleep(MDNS_POLL_INTERVAL);
155 }
156
157 browser.stop();
158
159 let seen = observed.lock().unwrap();
160 assert!(
161 !seen.is_empty(),
162 "browser never observed the advertised service"
163 );
164 let server = &seen[0];
165 assert_eq!(server.port, MDNS_ROUNDTRIP_PORT);
166 assert_eq!(server.txt.tuner, "R820T");
167 assert_eq!(server.txt.nickname, "integration-test-nick");
168 assert_eq!(server.txt.gains, R820T_GAIN_COUNT);
169 }
170}