Skip to main content

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}