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}