Skip to main content

navcore_signalk_client/
discovery.rs

1//! Finding the server on the local network.
2//!
3//! A Signal K server announces itself over mDNS, and the announcement
4//! carries more than an address: its TXT record names the vessel and gives
5//! the identifier that own-ship deltas will arrive under. A client
6//! therefore knows which boat it is looking at before it has connected to
7//! anything, and nothing about the server has to be typed in.
8
9use std::net::IpAddr;
10use std::time::Duration;
11
12use mdns_sd::{ServiceDaemon, ServiceEvent};
13
14use crate::ClientError;
15
16/// The service a Signal K server announces its data stream under.
17const WEBSOCKET_SERVICE: &str = "_signalk-ws._tcp.local.";
18
19/// And the same stream on a server with TLS switched on.
20///
21/// A different service name, not a flag in the TXT record: the server
22/// renames the whole announcement (`interfaces/ws.js`: `ssl ? '_signalk-wss'
23/// : '_signalk-ws'`). A client that browses only for the plain name finds
24/// nothing at all once TLS is switched on, not just an unset flag.
25const WEBSOCKET_SERVICE_TLS: &str = "_signalk-wss._tcp.local.";
26
27/// A server that answered.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Server {
30    /// What it calls itself on the network.
31    pub host: String,
32    /// Address to connect to. Preferred over the host name: a boat network
33    /// with no working name resolution is the normal case, not the odd one.
34    ///
35    /// Kept as an address rather than a string because the two families do
36    /// not go into a URL the same way, and a string invites forgetting that.
37    pub address: IpAddr,
38    /// The stream's port.
39    pub port: u16,
40    /// The vessel's own identifier, straight out of the TXT record. Deltas
41    /// about own ship arrive under this, in full, rather than under
42    /// "vessels.self" -- confirmed against a real server.
43    pub self_id: Option<String>,
44    /// The vessel's name, if the server states one. For telling two boats
45    /// apart in a marina, where several servers answer.
46    pub vessel_name: Option<String>,
47    /// Whether this server speaks TLS, taken from which service it
48    /// announced itself under rather than guessed or attempted.
49    pub secure: bool,
50}
51
52impl Server {
53    /// Host and port as a URL states them.
54    ///
55    /// An IPv6 address has to be bracketed here: without the brackets its
56    /// own colons are indistinguishable from the one before the port, and
57    /// the result is not a URL that anything will parse.
58    fn authority(&self) -> String {
59        match self.address {
60            IpAddr::V4(address) => format!("{address}:{}", self.port),
61            IpAddr::V6(address) => format!("[{address}]:{}", self.port),
62        }
63    }
64
65    /// Where the data stream is.
66    #[must_use]
67    pub fn stream_url(&self) -> String {
68        let scheme = if self.secure { "wss" } else { "ws" };
69        format!("{scheme}://{}/signalk/v1/stream", self.authority())
70    }
71
72    /// Where the REST interface is. Same host and port; Signal K puts both
73    /// on one server.
74    #[must_use]
75    pub fn http_base(&self) -> String {
76        let scheme = if self.secure { "https" } else { "http" };
77        format!("{scheme}://{}", self.authority())
78    }
79}
80
81/// How usable an announced address is, best first.
82///
83/// A server announces every address it has, and they are not equally good
84/// to dial. IPv4 first because it is what a boat network reliably routes.
85/// A link-local IPv6 address comes last: it is only usable together with
86/// the scope of the interface that received the announcement, which the
87/// announcement itself does not carry -- so it works by luck on a machine
88/// with one interface and fails on the cockpit computer, which will have
89/// wifi and ethernet both.
90fn address_rank(address: &IpAddr) -> u8 {
91    match address {
92        IpAddr::V4(address) if address.is_loopback() => 3,
93        IpAddr::V4(_) => 0,
94        IpAddr::V6(address) if address.is_loopback() => 3,
95        IpAddr::V6(address) if (address.segments()[0] & 0xffc0) == 0xfe80 => 2,
96        IpAddr::V6(_) => 1,
97    }
98}
99
100/// Listens for servers for as long as it is given.
101///
102/// Always waits the whole time rather than returning on the first answer:
103/// in a marina there may be several, and a client that grabbed whichever
104/// replied first would attach to the neighbour's boat.
105///
106/// # Errors
107///
108/// If the mDNS responder cannot be started or the browse cannot begin.
109pub fn discover(listen_for: Duration) -> Result<Vec<Server>, ClientError> {
110    let daemon = ServiceDaemon::new().map_err(|error| ClientError::Discovery(error.to_string()))?;
111    // Both names are browsed because a server announces itself under one or
112    // the other, never both, and which one it picks is exactly the fact
113    // worth learning. Each browse has its own channel, so the loop below
114    // drains them in turn rather than blocking on either.
115    let receiver = daemon
116        .browse(WEBSOCKET_SERVICE)
117        .map_err(|error| ClientError::Discovery(error.to_string()))?;
118    let secure_receiver = daemon
119        .browse(WEBSOCKET_SERVICE_TLS)
120        .map_err(|error| ClientError::Discovery(error.to_string()))?;
121
122    let mut found: Vec<Server> = Vec::new();
123    let deadline = std::time::Instant::now() + listen_for;
124
125    while std::time::Instant::now() < deadline {
126        let mut heard = false;
127        for receiver in [&receiver, &secure_receiver] {
128            while let Ok(event) = receiver.try_recv() {
129                heard = true;
130                let ServiceEvent::ServiceResolved(info) = event else {
131                    continue;
132                };
133                if let Some(server) = read_server(&info) {
134                    // The same server answers on every interface it has. One
135                    // entry per machine, not one per network card.
136                    if !found.iter().any(|seen| seen.self_id == server.self_id) {
137                        found.push(server);
138                    }
139                }
140            }
141        }
142        if !heard {
143            std::thread::sleep(Duration::from_millis(50));
144        }
145    }
146
147    let _ = daemon.shutdown();
148    Ok(found)
149}
150
151/// Reads one resolved announcement into a server, if it carries an address.
152///
153/// Whether the server speaks TLS is read from the service it answered
154/// under, not attempted and not inferred from the port. An announcement is
155/// the server stating what it is; anything else here would be this client
156/// guessing on its behalf.
157fn read_server(info: &mdns_sd::ResolvedService) -> Option<Server> {
158    // Every address the server has, ranked -- and ties broken by the
159    // address itself, because the announcement arrives as a set and
160    // "whichever came out first" would differ between two runs on the
161    // same network.
162    let address = info
163        .addresses
164        .iter()
165        .map(mdns_sd::ScopedIp::to_ip_addr)
166        .min_by(|left, right| {
167            address_rank(left)
168                .cmp(&address_rank(right))
169                .then_with(|| left.cmp(right))
170        })?;
171
172    Some(Server {
173        host: info.host.trim_end_matches('.').to_owned(),
174        address,
175        port: info.port,
176        self_id: text_value(info, "self"),
177        vessel_name: text_value(info, "vname"),
178        secure: info.ty_domain == WEBSOCKET_SERVICE_TLS,
179    })
180}
181
182/// One entry of the TXT record.
183fn text_value(info: &mdns_sd::ResolvedService, key: &str) -> Option<String> {
184    info.get_property_val_str(key)
185        .map(str::to_owned)
186        .filter(|value| !value.is_empty())
187}
188
189/// How a client should look for the server -- read from a settings store
190/// by the caller, plain string in, plain enum out.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum Discovery {
193    /// Listen for the server announcing itself over mDNS ([`discover`]).
194    /// The normal case: a boat network hands out addresses that change,
195    /// and a mariner should not have to know one.
196    Mdns,
197    /// Use an address stated by hand ([`resolve_stated_server`]). For a
198    /// server that does not announce itself -- one behind a router, or
199    /// with mDNS switched off.
200    Manual,
201}
202
203impl Discovery {
204    /// The string a settings store names, in the order a dialog would
205    /// list them.
206    const NAMES: [&'static str; 2] = ["mdns", "manual"];
207
208    /// The variant a stored wire string names -- `"mdns"` for anything it
209    /// does not recognise, listening being the fail-safe default: it asks
210    /// nothing of a mariner who never typed an address at all.
211    #[must_use]
212    pub fn from_key(value: &str) -> Self {
213        match value {
214            "manual" => Self::Manual,
215            _ => Self::Mdns,
216        }
217    }
218
219    /// The wire string a settings store should keep, the inverse of
220    /// [`Discovery::from_key`].
221    #[must_use]
222    pub fn as_str(self) -> &'static str {
223        match self {
224            Self::Mdns => Self::NAMES[0],
225            Self::Manual => Self::NAMES[1],
226        }
227    }
228
229    /// Position in a dialog's list.
230    #[must_use]
231    pub fn position(self) -> u32 {
232        match self {
233            Self::Mdns => 0,
234            Self::Manual => 1,
235        }
236    }
237
238    /// Back from a position in that list.
239    #[must_use]
240    pub fn from_position(position: u32) -> Self {
241        if position == 1 { Self::Manual } else { Self::Mdns }
242    }
243}
244
245/// The server the mariner stated by hand, resolved to something dialable.
246///
247/// `None` when the address is empty or names nothing this machine can
248/// reach. A caller should report that the same way a silent mDNS browse
249/// is reported ("no server answered"): from the chart's point of view it
250/// is the same thing, nowhere to get a position from.
251///
252/// `assume_tls` is what a bare address (no `http(s)://`/`ws(s)://`
253/// scheme) is taken to mean -- the mariner being explicit with a scheme
254/// always wins over this. A caller with the boat's own certificate
255/// authority installed should pass `true`: a mariner who has been through
256/// installing one is running a secured server, and silently dialling
257/// plain text would be the one guess with a cost.
258#[must_use]
259pub fn resolve_stated_server(stated: &str, assume_tls: bool) -> Option<Server> {
260    let stated = stated.trim();
261    if stated.is_empty() {
262        return None;
263    }
264
265    // The scheme, when there is one, is the mariner being explicit and
266    // wins over any assumption made here.
267    let (secure, rest) = match stated.split_once("://") {
268        Some(("https" | "wss", rest)) => (true, rest),
269        Some(("http" | "ws", rest)) => (false, rest),
270        Some((_, rest)) => (assume_tls, rest),
271        None => (assume_tls, stated),
272    };
273
274    let authority = rest.split(['/', '?']).next().unwrap_or(rest);
275    let default_port = if secure { 3443 } else { 3000 };
276
277    // Bracketed IPv6 is parsed on its own terms first: a URL keeps the
278    // brackets right up to the resolver, which wants them gone, and a
279    // colon inside them is part of the address, not a host/port split --
280    // the same split that produced an unparseable address the first time
281    // this met a real server.
282    let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
283        let (host, after) = rest.split_once(']').unwrap_or((rest, ""));
284        let port = after.strip_prefix(':').and_then(|text| text.parse().ok()).unwrap_or(default_port);
285        (host, port)
286    } else {
287        match authority.rsplit_once(':') {
288            // More than one colon with no brackets is a bare IPv6
289            // literal with no port, not a host:port pair -- splitting at
290            // the last colon would otherwise cut it at the wrong one.
291            Some((host, port)) if !host.is_empty() && !host.contains(':') => {
292                (host, port.parse().ok()?)
293            }
294            // No port stated: the server's own defaults.
295            _ => (authority, default_port),
296        }
297    };
298
299    use std::net::ToSocketAddrs;
300    let address = (host, port).to_socket_addrs().ok()?.next()?.ip();
301
302    Some(Server {
303        host: host.to_owned(),
304        address,
305        port,
306        // Neither is known until the server greets us; discovery learns
307        // them from the announcement, and there is no announcement here.
308        self_id: None,
309        vessel_name: None,
310        secure,
311    })
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn server_at(address: &str) -> Server {
319        // Documentation addresses from RFC 5737 and RFC 3849, and a made-up
320        // boat. Nothing from whatever network this happens to be written on:
321        // test data that names a real machine outlives the machine.
322        Server {
323            host: "boat-server.local".to_owned(),
324            address: address.parse().expect("an address"),
325            port: 3000,
326            self_id: Some("urn:mrn:signalk:uuid:00000000-0000-4000-8000-000000000000".to_owned()),
327            vessel_name: Some("Example Vessel".to_owned()),
328            secure: false,
329        }
330    }
331
332    /// The same server, but announced under the TLS service name.
333    fn secure_server_at(address: &str) -> Server {
334        Server {
335            secure: true,
336            ..server_at(address)
337        }
338    }
339
340    #[test]
341    fn a_tls_server_gets_tls_urls() {
342        // Both halves have to move together: a client that upgraded the
343        // stream but went on asking for its token over plain http would
344        // send the one thing worth protecting in clear text.
345        let server = secure_server_at("198.51.100.7");
346        assert_eq!(
347            server.stream_url(),
348            "wss://198.51.100.7:3000/signalk/v1/stream"
349        );
350        assert_eq!(server.http_base(), "https://198.51.100.7:3000");
351    }
352
353    #[test]
354    fn a_plain_server_is_left_plain() {
355        // Never optimistically upgraded: a server that did not announce TLS
356        // does not speak it, and trying would only fail more slowly.
357        let server = server_at("198.51.100.7");
358        assert_eq!(
359            server.stream_url(),
360            "ws://198.51.100.7:3000/signalk/v1/stream"
361        );
362        assert_eq!(server.http_base(), "http://198.51.100.7:3000");
363    }
364
365    #[test]
366    fn an_ipv6_tls_server_keeps_its_brackets() {
367        let server = secure_server_at("2001:db8::1");
368        assert_eq!(
369            server.stream_url(),
370            "wss://[2001:db8::1]:3000/signalk/v1/stream"
371        );
372        assert_eq!(server.http_base(), "https://[2001:db8::1]:3000");
373    }
374
375    #[test]
376    fn the_urls_are_built_from_the_address_and_not_the_name() {
377        // Boat networks routinely have no working name resolution. The
378        // announcement carries the address for exactly this reason.
379        let server = server_at("198.51.100.7");
380        assert_eq!(
381            server.stream_url(),
382            "ws://198.51.100.7:3000/signalk/v1/stream"
383        );
384        assert_eq!(server.http_base(), "http://198.51.100.7:3000");
385    }
386
387    #[test]
388    fn an_ipv6_address_is_bracketed() {
389        // Unbracketed, the address's own colons run into the one before the
390        // port and nothing can parse the result.
391        let server = server_at("2001:db8::1");
392        assert_eq!(
393            server.stream_url(),
394            "ws://[2001:db8::1]:3000/signalk/v1/stream"
395        );
396        assert_eq!(server.http_base(), "http://[2001:db8::1]:3000");
397    }
398
399    #[test]
400    fn a_routable_address_is_preferred_to_a_link_local_one() {
401        // The order the announcement arrives in must not decide this: a
402        // link-local address is unusable without the scope of the interface
403        // that heard it, which is not in the announcement.
404        let mut addresses: Vec<IpAddr> = vec![
405            "fe80::1".parse().expect("an address"),
406            "2001:db8::1".parse().expect("an address"),
407            "198.51.100.7".parse().expect("an address"),
408        ];
409        addresses.sort_by_key(address_rank);
410        assert_eq!(addresses[0].to_string(), "198.51.100.7");
411        assert_eq!(addresses[2].to_string(), "fe80::1");
412    }
413
414    #[test]
415    fn discovery_survives_a_round_trip_through_the_dialog() {
416        for mode in [Discovery::Mdns, Discovery::Manual] {
417            assert_eq!(Discovery::from_position(mode.position()), mode);
418        }
419    }
420
421    #[test]
422    fn a_stated_address_with_no_scheme_takes_the_assumption_it_is_given() {
423        let plain = resolve_stated_server("198.51.100.7:3000", false).expect("a server");
424        assert!(!plain.secure);
425        let tls = resolve_stated_server("198.51.100.7:3000", true).expect("a server");
426        assert!(tls.secure);
427    }
428
429    #[test]
430    fn an_explicit_scheme_overrides_the_assumption() {
431        let server = resolve_stated_server("wss://198.51.100.7:3000", false).expect("a server");
432        assert!(server.secure);
433        let server = resolve_stated_server("ws://198.51.100.7:3000", true).expect("a server");
434        assert!(!server.secure);
435    }
436
437    #[test]
438    fn a_stated_address_with_no_port_gets_the_schemes_own_default() {
439        let plain = resolve_stated_server("198.51.100.7", false).expect("a server");
440        assert_eq!(plain.port, 3000);
441        let tls = resolve_stated_server("198.51.100.7", true).expect("a server");
442        assert_eq!(tls.port, 3443);
443    }
444
445    #[test]
446    fn a_bracketed_ipv6_address_keeps_its_brackets_off_the_host() {
447        let server = resolve_stated_server("[2001:db8::1]:3000", false).expect("a server");
448        assert_eq!(server.host, "2001:db8::1");
449        assert_eq!(server.port, 3000);
450    }
451
452    #[test]
453    fn an_empty_or_blank_address_resolves_to_nothing() {
454        assert!(resolve_stated_server("", false).is_none());
455        assert!(resolve_stated_server("   ", false).is_none());
456    }
457}