Skip to main content

syncweb_core/net/relay/
manager.rs

1//! Relay manager: persistent registration with Syncthing relays, session
2//! establishment, and the CLI `test-relay` check.
3//!
4//! Two distinct connections exist per peer relationship:
5//!
6//! * **Control connections** are TLS with ALPN `bep-relay` and carry our
7//!   X.509 client certificate, which is our device identity. We register
8//!   with every configured relay and then simply read, answering pings and
9//!   reacting to incoming [`Message::SessionInvitation`]s.
10//! * **Sessions** are plain TCP `[u32 length][data]` tunnels that a relay
11//!   brokers between two devices that joined with the same session key.
12
13use std::collections::HashSet;
14use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use iroh::PublicKey;
21use iroh_base::CustomAddr;
22use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
23use tokio::net::{TcpStream, lookup_host};
24use tokio::sync::mpsc;
25use tokio::time::{sleep, timeout};
26use tokio_rustls::client::TlsStream;
27use tokio_util::sync::CancellationToken;
28use tracing::warn;
29
30use crate::error::{Result, SyncwebError};
31
32use super::RelayConfig;
33use super::certs::{self, RelayCerts};
34use super::pool;
35use super::protocol::{
36    MAX_BYTE_FIELD, Message, PROTOCOL_NAME, read_data_frame, read_message, write_data_frame, write_message,
37};
38use super::transport::{Backend, IncomingPacket, Session, SyncthingRelayTransport, own_addr, peer_addr};
39
40/// Default relay listen port, also used for brokered sessions.
41const DEFAULT_RELAY_PORT: u16 = 22_067;
42/// Session queue depth before datagrams are dropped on overflow.
43const SESSION_QUEUE: usize = 1024;
44/// How often the relay list is refreshed.
45const RELAY_REFRESH: Duration = Duration::from_mins(30);
46/// How often to retry fetching the pool when no relays are configured.
47const POOL_RETRY: Duration = Duration::from_secs(30);
48/// How long a registration connection may go without any traffic.
49const CONTROL_IDLE: Duration = Duration::from_mins(1);
50/// Reconnect backoff when a registration connection drops.
51const JOIN_BACKOFF: Duration = Duration::from_secs(2);
52/// How many relays a dial attempts before giving up.
53const MAX_DIAL_ATTEMPTS: usize = 4;
54/// All-zero node id used when a peer's node id is not yet known.
55const UNKNOWN_NODE: [u8; 32] = [0_u8; 32];
56
57/// Options for starting a [`RelayManager`].
58#[derive(Clone, Debug)]
59#[non_exhaustive]
60pub struct RelayManagerOptions {
61    /// Relay configuration.
62    pub config: RelayConfig,
63    /// Directory that persists the relay identity certificate.
64    pub data_dir: PathBuf,
65}
66
67impl RelayManagerOptions {
68    /// Create options from a config and data directory.
69    #[must_use]
70    pub const fn new(config: RelayConfig, data_dir: PathBuf) -> Self {
71        Self { config, data_dir }
72    }
73}
74
75/// A connected relay client: the persistent identity, registration loops and
76/// live sessions, plus the transport that exposes it to iroh.
77#[derive(Clone)]
78pub struct RelayManager {
79    backend: Arc<Backend>,
80    config: RelayConfig,
81    cancel: CancellationToken,
82}
83
84impl std::fmt::Debug for RelayManager {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("RelayManager").finish_non_exhaustive()
87    }
88}
89
90impl RelayManager {
91    /// Start registration with the configured relays and build the transport
92    /// for iroh. The transport advertises a custom address carrying `own_node`.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the relay identity cannot be loaded or generated,
97    /// or if the TLS configuration cannot be built.
98    pub fn start(options: RelayManagerOptions, own_node: PublicKey) -> Result<Self> {
99        let certs = Arc::new(RelayCerts::load_or_create(&options.data_dir)?);
100        let connector = certs::tls_connector(&certs)?;
101        let backend = Arc::new(Backend::new(certs, connector, options.config.timeout));
102        // Publish our relay address immediately: sharing a ticket must not race
103        // the supervisor's first relay-list resolution.
104        backend.update_local_addr(own_addr(&own_node, backend.certs.relay_id()));
105        let cancel = CancellationToken::new();
106        let _spawned = tokio::spawn(supervisor(
107            backend.clone(),
108            options.config.clone(),
109            own_node,
110            cancel.clone(),
111        ));
112        Ok(Self {
113            backend,
114            config: options.config,
115            cancel,
116        })
117    }
118
119    /// The transport to install on the iroh endpoint.
120    #[must_use]
121    pub fn transport(&self) -> SyncthingRelayTransport {
122        SyncthingRelayTransport::new(self.backend.clone())
123    }
124
125    /// The custom address this node advertises.
126    #[must_use]
127    pub fn local_addr(&self) -> CustomAddr {
128        self.backend.local_addr()
129    }
130
131    /// The relay device id of this node (SHA-256 of its certificate).
132    #[must_use]
133    pub fn relay_id(&self) -> [u8; 32] {
134        *self.backend.certs.relay_id()
135    }
136
137    /// Record the iroh node id owning a peer relay id so inbound datagrams
138    /// from that peer can be attributed.
139    pub fn remember_node(&self, relay_id: &[u8; 32], node: PublicKey) {
140        self.backend.remember_node(*relay_id, *node.as_bytes());
141    }
142
143    /// Test whether `url` accepts our registration. A fresh TLS join request
144    /// is made; receiving the "joined" or "already connected" responses both
145    /// prove the relay knows our identity.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the relay is unreachable, misbehaving, or rejects
150    /// our identity.
151    pub async fn test_relay(&self, url: &str) -> Result<TestRelayReport> {
152        let started = Instant::now();
153        let (host, port, token) = parse_relay_url(url)?;
154        let mut stream = control_connect(&self.backend, url, &host, port).await?;
155        limited(
156            self.config.timeout,
157            write_message(
158                &mut stream,
159                &Message::JoinRelayRequest {
160                    token: token.unwrap_or_default(),
161                },
162            ),
163        )
164        .await?;
165        let reply = limited(self.config.timeout, read_message(&mut stream)).await?;
166        match &reply {
167            Message::Response {
168                code: Message::RESPONSE_SUCCESS | Message::RESPONSE_ALREADY_CONNECTED,
169                ..
170            } => {}
171            Message::Response { code, message } => {
172                return Err(SyncwebError::RelayUnreachable {
173                    reasons: format!("{url} rejected our registration (code {code}): {message}"),
174                });
175            }
176            Message::RelayFull => {
177                return Err(SyncwebError::RelayUnreachable {
178                    reasons: format!("{url} is full"),
179                });
180            }
181            Message::Ping
182            | Message::Pong
183            | Message::JoinRelayRequest { .. }
184            | Message::JoinSessionRequest { .. }
185            | Message::ConnectRequest { .. }
186            | Message::SessionInvitation { .. } => {
187                return Err(unexpected_reply(&reply, "unexpected reply to join request"));
188            }
189        }
190        Ok(TestRelayReport {
191            url: url.to_owned(),
192            own_relay_id: *self.backend.certs.relay_id(),
193            elapsed: started.elapsed(),
194        })
195    }
196
197    /// Send a `ConnectRequest` to a registered relay for `peer`.
198    ///
199    /// Exposed for tests: it exercises the same dial path the transport's send
200    /// side uses. The session is established asynchronously by the
201    /// registration reader when the relay answers with a `SessionInvitation`.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if no relay registration is available.
206    #[doc(hidden)]
207    pub fn dial_peer_for_test(&self, peer: [u8; 32], node: PublicKey) -> Result<()> {
208        dial_peer(&self.backend, peer, *node.as_bytes())
209    }
210
211    /// Stop all registration loops and sessions.
212    pub async fn shutdown(&self) {
213        self.cancel.cancel();
214        self.backend.cancel.cancel();
215        sleep(Duration::from_millis(50)).await;
216    }
217}
218
219/// Result of a successful `test-relay` handshake.
220#[derive(Clone, Debug, Eq, PartialEq)]
221#[non_exhaustive]
222pub struct TestRelayReport {
223    /// The relay url that accepted our identity.
224    pub url: String,
225    /// Our relay device id.
226    pub own_relay_id: [u8; 32],
227    /// Round-trip time of the handshake.
228    pub elapsed: Duration,
229}
230
231impl std::fmt::Display for TestRelayReport {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        write!(
234            f,
235            "relay {} reachable in {:?} (device {})",
236            self.url,
237            self.elapsed,
238            device_id(self.own_relay_id)
239        )
240    }
241}
242
243/// The error raised when a control reply does not match the expected state.
244fn unexpected_reply(reply: &Message, context: &str) -> SyncwebError {
245    SyncwebError::RelayDecode(format!("{context}: {reply:?}"))
246}
247
248/// Start a background dial to a peer from the transport's send path. Always
249/// returns immediately; if a dial is already running for this peer it is a
250/// no-op.
251pub(crate) fn start_peer_dial(backend: Arc<Backend>, peer: [u8; 32], node: [u8; 32]) {
252    if !backend.dials.lock().insert(peer) {
253        return;
254    }
255    tokio::spawn(async move {
256        let result = dial_peer(&backend, peer, node);
257        if result.is_ok() {
258            // Hold the dial slot until the session comes up so the transport's
259            // repeated sends do not spam the relay with duplicate
260            // `ConnectRequest`s (each of which brokers a fresh session key).
261            for _ in 0..150 {
262                if backend.sessions.read().contains_key(&peer) {
263                    break;
264                }
265                sleep(Duration::from_millis(100)).await;
266            }
267        }
268        backend.dials.lock().remove(&peer);
269        if let Err(error) = result {
270            warn!(%error, "relay dial to peer failed");
271        }
272    });
273}
274
275/// Resolve a registered relay and send a `ConnectRequest` for `peer` over its
276/// existing registration connection.
277///
278/// The relay attributes the request to the device that owns that connection
279/// and delivers a `SessionInvitation` back on it, which the registration
280/// reader turns into a session. Opening a fresh connection instead would make
281/// the relay treat us as a duplicate registration and drop it.
282fn dial_peer(backend: &Arc<Backend>, peer: [u8; 32], node: [u8; 32]) -> Result<()> {
283    if peer == [0_u8; 32] {
284        return Err(SyncwebError::RelayDecode("peer device id is empty".to_owned()));
285    }
286    // Record the peer's node id so the invitation delivered on our
287    // registration connection can be attributed to it.
288    backend.remember_node(peer, node);
289    let mut failures = Vec::new();
290    for _ in 0..MAX_DIAL_ATTEMPTS {
291        let Some(url) = backend.next_relay() else {
292            failures.push("no relays configured".to_owned());
293            break;
294        };
295        let sender = backend.control.read().get(&url).cloned();
296        match sender {
297            Some(control) => match control.send(Message::ConnectRequest { id: peer.to_vec() }) {
298                Ok(()) => return Ok(()),
299                Err(_) => failures.push(format!("{url}: registration connection closed")),
300            },
301            None => failures.push(format!("{url}: not registered")),
302        }
303    }
304    Err(SyncwebError::RelayUnreachable {
305        reasons: failures.join("; "),
306    })
307}
308
309/// A pending session negotiated with the relay.
310struct SessionSpec {
311    key: Vec<u8>,
312    address: Vec<u8>,
313    port: u16,
314}
315
316/// The join connection handler: keep one registration alive for a relay and
317/// act on incoming invitations and keepalives.
318async fn join_loop(backend: Arc<Backend>, url: String, cancel: CancellationToken) {
319    loop {
320        if cancel.is_cancelled() {
321            return;
322        }
323        let (host, port, token) = match parse_relay_url(&url) {
324            Ok(parts) => parts,
325            Err(error) => {
326                warn!(%url, %error, "invalid relay url");
327                return;
328            }
329        };
330        if let Err(error) = serve_registration(&backend, &url, &host, port, token).await {
331            warn!(%url, %error, "relay registration dropped");
332        }
333        sleep_with_cancel(cancel.clone(), JOIN_BACKOFF).await;
334    }
335}
336
337/// Perform the join handshake and then read registrations until the
338/// connection breaks.
339async fn serve_registration(
340    backend: &Arc<Backend>,
341    url: &str,
342    host: &str,
343    port: u16,
344    token: Option<String>,
345) -> Result<()> {
346    let mut stream = control_connect(backend, url, host, port).await?;
347    limited(
348        backend.timeout,
349        write_message(
350            &mut stream,
351            &Message::JoinRelayRequest {
352                token: token.unwrap_or_default(),
353            },
354        ),
355    )
356    .await?;
357    let reply = limited(backend.timeout, read_message(&mut stream)).await?;
358    match &reply {
359        Message::Response {
360            code: Message::RESPONSE_SUCCESS | Message::RESPONSE_ALREADY_CONNECTED,
361            ..
362        } => {}
363        Message::Response { code, message } => {
364            return Err(SyncwebError::RelayUnreachable {
365                reasons: format!("{url} rejected registration (code {code}): {message}"),
366            });
367        }
368        Message::RelayFull => {
369            return Err(SyncwebError::RelayUnreachable {
370                reasons: format!("{url} is full"),
371            });
372        }
373        Message::Ping
374        | Message::Pong
375        | Message::JoinRelayRequest { .. }
376        | Message::JoinSessionRequest { .. }
377        | Message::ConnectRequest { .. }
378        | Message::SessionInvitation { .. } => {
379            return Err(unexpected_reply(&reply, "unexpected reply to join"));
380        }
381    }
382
383    // Split the connection so outbound control messages (keepalive pongs and
384    // `ConnectRequest`s from the transport's send path) can be written while
385    // this task owns the read side. The writer channel is published under the
386    // relay url so `dial_peer` can address this registration.
387    let (mut reader, mut writer) = tokio::io::split(stream);
388    let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
389    backend.control.write().insert(url.to_owned(), tx.clone());
390    let control_tx = tx.clone();
391    let writer_tx = tx;
392    let writer_backend = backend.clone();
393    let writer_url = url.to_owned();
394    let writer_cancel = backend.cancel.clone();
395    tokio::spawn(async move {
396        loop {
397            tokio::select! {
398                () = writer_cancel.cancelled() => break,
399                incoming = rx.recv() => {
400                    let Some(message) = incoming else { break };
401                    if write_message(&mut writer, &message).await.is_err() {
402                        break;
403                    }
404                }
405            }
406        }
407        let mut control = writer_backend.control.write();
408        if control
409            .get(&writer_url)
410            .is_some_and(|existing| existing.same_channel(&writer_tx))
411        {
412            control.remove(&writer_url);
413        }
414    });
415    loop {
416        let message = match limited(backend.timeout.max(CONTROL_IDLE), read_message(&mut reader)).await {
417            Err(error) => return Err(error),
418            Ok(message) => message,
419        };
420        match &message {
421            Message::Ping => {
422                let _ = control_tx.send(Message::Pong);
423            }
424            Message::Pong | Message::RelayFull => {}
425            Message::SessionInvitation {
426                from: inviter,
427                key,
428                address,
429                port: invite_port,
430                ..
431            } => {
432                let from = try_into_32(inviter)?;
433                let node = backend.node_ids.read().get(&from).copied().unwrap_or(UNKNOWN_NODE);
434                let spec = SessionSpec {
435                    key: key.clone(),
436                    address: address.clone(),
437                    port: *invite_port,
438                };
439                tokio::spawn(inbound_session(backend.clone(), url.to_owned(), from, node, spec));
440            }
441            Message::JoinRelayRequest { .. }
442            | Message::JoinSessionRequest { .. }
443            | Message::Response { .. }
444            | Message::ConnectRequest { .. } => {
445                return Err(unexpected_reply(
446                    &message,
447                    "unexpected message on registration connection",
448                ));
449            }
450        }
451    }
452}
453
454/// Establish an inbound session from an invitation on our registration
455/// connection, in the background.
456async fn inbound_session(backend: Arc<Backend>, url: String, peer: [u8; 32], node: [u8; 32], spec: SessionSpec) {
457    if let Err(error) = establish_session(&backend, &url, peer, node, spec).await {
458        warn!(%url, %error, "inbound session failed");
459    }
460}
461
462/// Broker a session: connect to the relay's session listener and join.
463async fn establish_session(
464    backend: &Arc<Backend>,
465    url: &str,
466    peer: [u8; 32],
467    node: [u8; 32],
468    spec: SessionSpec,
469) -> Result<()> {
470    let addrs = session_addrs(url, spec.address.as_slice(), spec.port).await?;
471    let mut last_error = None;
472    for addr in addrs {
473        match join_session(backend, url, peer, node, addr, &spec.key).await {
474            Ok(()) => return Ok(()),
475            Err(error) => last_error = Some(error),
476        }
477    }
478    Err(last_error.unwrap_or_else(|| SyncwebError::RelayUnreachable {
479        reasons: format!("{url}: no session address was usable"),
480    }))
481}
482
483/// Join a single session listener.
484async fn join_session(
485    backend: &Arc<Backend>,
486    url: &str,
487    peer: [u8; 32],
488    node: [u8; 32],
489    addr: SocketAddr,
490    key: &[u8],
491) -> Result<()> {
492    if key.len() > MAX_BYTE_FIELD {
493        return Err(SyncwebError::RelayDecode(
494            "session key exceeds the protocol limit".to_owned(),
495        ));
496    }
497    let mut stream = limited_io(url, backend.timeout, TcpStream::connect(addr)).await?;
498    limited(
499        backend.timeout,
500        write_message(&mut stream, &Message::JoinSessionRequest { key: key.to_vec() }),
501    )
502    .await?;
503    let reply = limited(backend.timeout, read_message(&mut stream)).await?;
504    match &reply {
505        Message::Response {
506            code: Message::RESPONSE_SUCCESS,
507            ..
508        } => {}
509        Message::Response { code, message } => {
510            return Err(SyncwebError::RelayUnreachable {
511                reasons: format!("{url}: session join rejected (code {code}): {message}"),
512            });
513        }
514        Message::Ping
515        | Message::Pong
516        | Message::JoinRelayRequest { .. }
517        | Message::JoinSessionRequest { .. }
518        | Message::ConnectRequest { .. }
519        | Message::SessionInvitation { .. }
520        | Message::RelayFull => {
521            return Err(unexpected_reply(&reply, "unexpected reply to session join"));
522        }
523    }
524    register_session(backend, peer, node, stream);
525    Ok(())
526}
527
528/// Wrap a stream as a session, register it and start its reader/writer tasks.
529fn register_session(backend: &Arc<Backend>, peer: [u8; 32], node: [u8; 32], stream: TcpStream) {
530    let (read_half, write_half) = stream.into_split();
531    let (tx, rx) = mpsc::channel::<bytes::Bytes>(SESSION_QUEUE);
532    let cancel = backend.cancel.clone();
533    let session = Arc::new(Session {
534        peer,
535        peer_node: node,
536        queue: tx,
537        cancel: cancel.clone(),
538    });
539    {
540        let mut sessions = backend.sessions.write();
541        if let Some(previous) = sessions.insert(peer, session.clone()) {
542            previous.cancel.cancel();
543        }
544    }
545    tokio::spawn(run_session_reader(backend.clone(), session.clone(), read_half));
546    tokio::spawn(run_session_writer(backend.clone(), session, write_half, rx, cancel));
547}
548
549/// The reader task of a session: deliver frames to iroh.
550async fn run_session_reader(backend: Arc<Backend>, session: Arc<Session>, mut read_half: OwnedReadHalf) {
551    let remote = peer_addr(session.peer, session.peer_node);
552    let local = backend.local_addr();
553    while let Ok(frame) = read_data_frame(&mut read_half).await {
554        let Some(tx) = backend.ingress.lock().clone() else {
555            continue;
556        };
557        if tx
558            .send(IncomingPacket {
559                remote: remote.clone(),
560                local: local.clone(),
561                data: bytes::Bytes::from(frame),
562            })
563            .is_err()
564        {
565            break;
566        }
567    }
568    remove_session(&backend, session.peer, &session);
569}
570
571/// The writer task of a session: drain the queue into framed datagrams.
572async fn run_session_writer(
573    backend: Arc<Backend>,
574    session: Arc<Session>,
575    mut write_half: OwnedWriteHalf,
576    mut rx: mpsc::Receiver<bytes::Bytes>,
577    cancel: CancellationToken,
578) {
579    loop {
580        let data = tokio::select! {
581            () = cancel.cancelled() => break,
582            data = rx.recv() => match data {
583                Some(packet) => packet,
584                None => break,
585            },
586        };
587        if write_data_frame(&mut write_half, &data).await.is_err() {
588            break;
589        }
590    }
591    remove_session(&backend, session.peer, &session);
592}
593
594/// Remove `session` from the map only if it is still the registered one.
595fn remove_session(backend: &Backend, peer: [u8; 32], session: &Arc<Session>) {
596    let mut sessions = backend.sessions.write();
597    if sessions
598        .get(&peer)
599        .is_some_and(|existing| Arc::ptr_eq(existing, session))
600    {
601        sessions.remove(&peer);
602    }
603}
604
605/// The supervisor task resolves the relay list, publishes our address and
606/// keeps a registration loop running for every relay.
607async fn supervisor(backend: Arc<Backend>, config: RelayConfig, own_node: PublicKey, cancel: CancellationToken) {
608    let mut running: HashSet<String> = HashSet::new();
609    let mut pool_seen = false;
610    loop {
611        if cancel.is_cancelled() {
612            return;
613        }
614        let urls = match relay_list(&config, pool_seen).await {
615            Ok(urls) => urls,
616            Err(error) => {
617                warn!(%error, "relay list unavailable");
618                sleep_with_cancel(cancel.clone(), POOL_RETRY).await;
619                continue;
620            }
621        };
622        pool_seen = true;
623        let addr = own_addr(&own_node, backend.certs.relay_id());
624        let stored = urls.clone();
625        *backend.relays.write() = stored;
626        backend.update_local_addr(addr);
627        for url in urls {
628            if running.insert(url.clone()) {
629                tokio::spawn(join_loop(backend.clone(), url, cancel.clone()));
630            }
631        }
632        sleep_with_cancel(cancel.clone(), RELAY_REFRESH).await;
633    }
634}
635
636/// Resolve the list of relay urls: the configured ones, or the live pool
637/// when auto-fallback is enabled.
638async fn relay_list(config: &RelayConfig, pool_seen: bool) -> Result<Vec<String>> {
639    if !config.relay_urls.is_empty() {
640        return Ok(config.relay_urls.clone());
641    }
642    if !config.auto_fallback {
643        return Ok(Vec::new());
644    }
645    match pool::fetch_relay_pool(config.timeout.max(Duration::from_secs(5))).await {
646        Ok(urls) if urls.is_empty() => Err(SyncwebError::RelayUnreachable {
647            reasons: "relay pool returned no relays".to_owned(),
648        }),
649        Ok(urls) => Ok(urls),
650        Err(error) => {
651            if !pool_seen {
652                warn!(%error, "initial relay pool fetch failed");
653            }
654            Err(error)
655        }
656    }
657}
658
659/// Sleep for `duration` unless cancelled.
660async fn sleep_with_cancel(cancel: CancellationToken, duration: Duration) {
661    tokio::select! {
662        () = cancel.cancelled() => {}
663        () = sleep(duration) => {}
664    }
665}
666
667/// Open a TLS control connection to a relay and validate the ALPN handshake.
668async fn control_connect(backend: &Backend, url: &str, host: &str, port: u16) -> Result<TlsStream<TcpStream>> {
669    let socket = resolve(host, port)
670        .await
671        .ok_or_else(|| SyncwebError::RelayUnreachable {
672            reasons: format!("{url}: host {host} did not resolve"),
673        })?;
674    let name = certs::server_name_for(host)?;
675    let tcp = limited_io(url, backend.timeout, TcpStream::connect(socket)).await?;
676    let tls = limited_io(url, backend.timeout, backend.connector.connect(name, tcp)).await?;
677    if let Some(named) = tls.get_ref().1.alpn_protocol()
678        && named != PROTOCOL_NAME
679    {
680        return Err(SyncwebError::RelayUnreachable {
681            reasons: format!(
682                "{url}: relay negotiated unexpected ALPN protocol {:?}",
683                String::from_utf8_lossy(named)
684            ),
685        });
686    }
687    Ok(tls)
688}
689
690/// Resolve `host:port`; returns the first address.
691async fn resolve(host: &str, port: u16) -> Option<SocketAddr> {
692    lookup_host((host, port)).await.ok()?.next()
693}
694
695/// The session listener addresses to try for an invitation, falling back to
696/// the relay's own host when the invitation address is empty or unspecified.
697async fn session_addrs(url: &str, address: &[u8], invite_port: u16) -> Result<Vec<SocketAddr>> {
698    let port = if invite_port == 0 {
699        DEFAULT_RELAY_PORT
700    } else {
701        invite_port
702    };
703    if address.is_empty() || address.iter().all(|byte| *byte == 0) {
704        let (host, _token, _port) = parse_relay_url(url)?;
705        let addresses = lookup_host((host.as_str(), port)).await?.collect::<Vec<SocketAddr>>();
706        if addresses.is_empty() {
707            return Err(SyncwebError::RelayUnreachable {
708                reasons: format!("{url}: session host {host} did not resolve"),
709            });
710        }
711        Ok(addresses)
712    } else {
713        Ok(vec![SocketAddr::new(ip_from_bytes(address)?, port)])
714    }
715}
716
717/// Convert invitation address bytes into an [`IpAddr`].
718fn ip_from_bytes(bytes: &[u8]) -> Result<IpAddr> {
719    match bytes.len() {
720        4 => Ok(IpAddr::from(try_into_array::<4>(bytes)?)),
721        16 => Ok(IpAddr::from(try_into_array::<16>(bytes)?)),
722        length => Err(SyncwebError::RelayDecode(format!(
723            "invitation address has invalid length {length}"
724        ))),
725    }
726}
727
728/// Convert a byte slice into a fixed-size array.
729fn try_into_array<const N: usize>(bytes: &[u8]) -> Result<[u8; N]> {
730    bytes.try_into().map_err(|error| {
731        SyncwebError::RelayDecode(format!("byte field has unexpected length {}: {error}", bytes.len()))
732    })
733}
734
735/// Convert an invitation/connect byte field into a 32-byte device id.
736fn try_into_32(bytes: &[u8]) -> Result<[u8; 32]> {
737    try_into_array(bytes)
738}
739
740/// Parse a relay url into its host, port, and join token.
741fn parse_relay_url(url: &str) -> Result<(String, u16, Option<String>)> {
742    let parsed = url::Url::parse(url).map_err(|error| SyncwebError::RelayBadAddress(format!("{url}: {error}")))?;
743    match parsed.scheme() {
744        "relay" | "tcp" => {}
745        _ => return Err(SyncwebError::RelayBadScheme),
746    }
747    let host = parsed
748        .host_str()
749        .filter(|host| !host.is_empty())
750        .ok_or_else(|| SyncwebError::RelayBadAddress(url.to_owned()))?;
751    let port = parsed.port().unwrap_or(DEFAULT_RELAY_PORT);
752    let token = parsed
753        .query_pairs()
754        .find(|(key, _)| key == "token")
755        .map(|(_, value)| value.into_owned());
756    Ok((host.to_owned(), port, token))
757}
758
759/// Run a future with a timeout, mapping the ticking timeout into
760/// [`SyncwebError::RelayUnreachable`].
761async fn limited<T>(duration: Duration, future: impl Future<Output = Result<T>>) -> Result<T> {
762    timeout(duration, future).await.map_or_else(
763        |_| {
764            Err(SyncwebError::RelayUnreachable {
765                reasons: "relay operation timed out".to_owned(),
766            })
767        },
768        std::convert::identity,
769    )
770}
771
772/// Run an IO future with a timeout, mapping errors and timeouts into
773/// [`SyncwebError::RelayUnreachable`].
774async fn limited_io<T>(
775    context: &str,
776    duration: Duration,
777    future: impl Future<Output = std::io::Result<T>>,
778) -> Result<T> {
779    match timeout(duration, future).await {
780        Err(_) => Err(SyncwebError::RelayUnreachable {
781            reasons: format!("{context}: operation timed out"),
782        }),
783        Ok(Err(error)) => Err(SyncwebError::RelayUnreachable {
784            reasons: format!("{context}: {error}"),
785        }),
786        Ok(Ok(value)) => Ok(value),
787    }
788}
789
790/// Format a device id the way Syncthing relays display them.
791#[must_use]
792fn device_id(bytes: [u8; 32]) -> String {
793    use std::fmt::Write as _;
794    let mut out = String::with_capacity(64);
795    for (index, byte) in bytes.iter().enumerate() {
796        if index == 8 || index == 16 || index == 24 {
797            out.push('-');
798        }
799        let _ = write!(out, "{byte:02X}");
800    }
801    out
802}