dstar_gateway/auth/
client.rs

1//! `AuthClient` — `DPlus` TCP auth.
2//!
3//! Performs the mandatory TCP authentication step against
4//! `auth.dstargateway.org:20001` and returns the [`HostList`] cached
5//! by the auth server. The resulting host list is then handed to
6//! [`dstar_gateway_core::session::client::Session`] via its
7//! `authenticate` method to promote the sans-io core's typestate to
8//! [`dstar_gateway_core::session::client::Authenticated`].
9//!
10//! The on-wire packet format matches
11//! `ircDDBGateway/Common/DPlusAuthenticator.cpp:111-143`.
12
13use std::net::SocketAddr;
14use std::time::Duration;
15
16use dstar_gateway_core::codec::dplus::{HostList, parse_auth_response};
17use dstar_gateway_core::error::IoOperation;
18use dstar_gateway_core::types::Callsign;
19use dstar_gateway_core::validator::NullSink;
20use tokio::io::{AsyncReadExt, AsyncWriteExt};
21use tokio::net::TcpStream;
22use tokio::time::timeout;
23
24/// Default auth server hostname+port, matching `ircDDBGateway`.
25pub const DEFAULT_AUTH_ENDPOINT: &str = "auth.dstargateway.org:20001";
26
27/// Default connect timeout for the TCP auth connection.
28const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
29
30/// Default per-read timeout while draining the TCP auth response.
31const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(5);
32
33/// Length of the 56-byte `DPlus` auth request packet.
34const AUTH_PACKET_LEN: usize = 56;
35
36/// `DPlus` TCP authentication client.
37///
38/// Performs the mandatory TCP auth step against `auth.dstargateway.org`
39/// and returns the [`HostList`] cached by the auth server. Caller
40/// then hands the host list to the sans-io session to transition the
41/// typestate to [`dstar_gateway_core::session::client::Authenticated`].
42#[derive(Debug, Default, Clone)]
43pub struct AuthClient {
44    /// Optional override of the auth endpoint. `None` falls back to
45    /// [`DEFAULT_AUTH_ENDPOINT`], which is resolved by tokio at call
46    /// time.
47    endpoint: Option<SocketAddr>,
48    /// Timeout for the initial TCP connect.
49    connect_timeout: Duration,
50    /// Per-read timeout for draining the auth response. The auth
51    /// server closes the socket when done, so this only fires when
52    /// the server has hung mid-stream.
53    read_timeout: Duration,
54}
55
56impl AuthClient {
57    /// Create a new auth client with defaults.
58    ///
59    /// The endpoint is `None` (resolve `auth.dstargateway.org:20001`
60    /// via DNS at call time), the connect timeout is 10 s, and the
61    /// per-read timeout is 5 s.
62    #[must_use]
63    pub const fn new() -> Self {
64        Self {
65            endpoint: None,
66            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
67            read_timeout: DEFAULT_READ_TIMEOUT,
68        }
69    }
70
71    /// Override the TCP auth endpoint.
72    ///
73    /// Used by integration tests to point the client at a local fake
74    /// auth server on an ephemeral port.
75    #[must_use]
76    pub const fn with_endpoint(mut self, endpoint: SocketAddr) -> Self {
77        self.endpoint = Some(endpoint);
78        self
79    }
80
81    /// Override the TCP connect timeout.
82    #[must_use]
83    pub const fn with_connect_timeout(mut self, dur: Duration) -> Self {
84        self.connect_timeout = dur;
85        self
86    }
87
88    /// Override the per-read timeout while draining the response.
89    #[must_use]
90    pub const fn with_read_timeout(mut self, dur: Duration) -> Self {
91        self.read_timeout = dur;
92        self
93    }
94
95    /// Current endpoint override, if any.
96    #[must_use]
97    pub const fn endpoint(&self) -> Option<SocketAddr> {
98        self.endpoint
99    }
100
101    /// Perform the TCP auth against the configured endpoint.
102    ///
103    /// Builds the 56-byte auth packet per
104    /// `ircDDBGateway/Common/DPlusAuthenticator.cpp:111-143`, sends it
105    /// over a fresh TCP connection, and accumulates the framed
106    /// response until the server closes the socket. The accumulated
107    /// bytes are then parsed via
108    /// [`dstar_gateway_core::codec::dplus::parse_auth_response`] with
109    /// a [`NullSink`] for diagnostics.
110    ///
111    /// # Errors
112    ///
113    /// - [`AuthError::Timeout`] if any phase (connect, write, read)
114    ///   exceeds the configured timeout
115    /// - [`AuthError::Io`] if the underlying socket call fails
116    /// - [`AuthError::Parse`] if the response body is malformed
117    ///
118    /// # Cancellation safety
119    ///
120    /// This method is **not** cancel-safe. Cancelling the future may
121    /// leave a half-written request on the wire or an auth TCP session
122    /// dangling from the upstream host list server's perspective. The
123    /// method owns a transient [`tokio::net::TcpStream`] internally
124    /// and relies on drop-on-cancel to close it, but the upstream
125    /// server may briefly see the partial packet. Callers should
126    /// either await the future to completion or apply an outer
127    /// [`tokio::time::timeout`] that matches the configured
128    /// `connect_timeout`.
129    ///
130    /// # Example
131    ///
132    /// ```no_run
133    /// use dstar_gateway::auth::AuthClient;
134    /// use dstar_gateway_core::types::Callsign;
135    ///
136    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
137    /// let client = AuthClient::new();
138    /// let hosts = client.authenticate(Callsign::try_from_str("W1AW")?).await?;
139    /// println!("{} known REF hosts", hosts.len());
140    /// # Ok(()) }
141    /// ```
142    ///
143    /// # See also
144    ///
145    /// `ircDDBGateway/Common/DPlusAuthenticator.cpp:111-143` — the
146    /// reference 56-byte packet layout this client mirrors.
147    pub async fn authenticate(&self, callsign: Callsign) -> Result<HostList, AuthError> {
148        let endpoint_display = self.endpoint.map_or_else(
149            || DEFAULT_AUTH_ENDPOINT.to_string(),
150            |addr| addr.to_string(),
151        );
152        tracing::info!(
153            target: "dstar_gateway::auth",
154            %callsign,
155            endpoint = %endpoint_display,
156            connect_timeout_ms = u64::try_from(self.connect_timeout.as_millis()).unwrap_or(u64::MAX),
157            "DPlus TCP auth starting"
158        );
159
160        let mut stream = match self.connect().await {
161            Ok(s) => {
162                tracing::debug!(
163                    target: "dstar_gateway::auth",
164                    "DPlus TCP auth connected"
165                );
166                s
167            }
168            Err(e) => {
169                tracing::warn!(
170                    target: "dstar_gateway::auth",
171                    error = %e,
172                    %callsign,
173                    endpoint = %endpoint_display,
174                    "DPlus TCP auth connect failed"
175                );
176                return Err(e);
177            }
178        };
179        let packet = build_auth_packet(callsign);
180
181        if let Err(e) = timeout(self.connect_timeout, stream.write_all(&packet))
182            .await
183            .map_err(|_| AuthError::Timeout {
184                elapsed: self.connect_timeout,
185                phase: AuthPhase::Write,
186            })
187            .and_then(|res| {
188                res.map_err(|source| AuthError::Io {
189                    source,
190                    operation: IoOperation::TcpAuthWrite,
191                })
192            })
193        {
194            tracing::warn!(
195                target: "dstar_gateway::auth",
196                error = %e,
197                %callsign,
198                "DPlus TCP auth write failed"
199            );
200            return Err(e);
201        }
202
203        let response = match self.read_response(&mut stream).await {
204            Ok(r) => {
205                tracing::debug!(
206                    target: "dstar_gateway::auth",
207                    response_len = r.len(),
208                    "DPlus TCP auth response received"
209                );
210                r
211            }
212            Err(e) => {
213                tracing::warn!(
214                    target: "dstar_gateway::auth",
215                    error = %e,
216                    %callsign,
217                    "DPlus TCP auth read failed"
218                );
219                return Err(e);
220            }
221        };
222
223        let mut sink = NullSink;
224        match parse_auth_response(&response, &mut sink) {
225            Ok(hosts) => {
226                tracing::info!(
227                    target: "dstar_gateway::auth",
228                    %callsign,
229                    host_count = hosts.len(),
230                    "DPlus TCP auth succeeded"
231                );
232                Ok(hosts)
233            }
234            Err(e) => {
235                tracing::warn!(
236                    target: "dstar_gateway::auth",
237                    error = %e,
238                    %callsign,
239                    response_len = response.len(),
240                    "DPlus TCP auth response parse failed"
241                );
242                Err(e.into())
243            }
244        }
245    }
246
247    /// Connect to the auth server, respecting [`Self::connect_timeout`].
248    ///
249    /// `auth.dstargateway.org` round-robins across multiple A records
250    /// and at least one is typically filtered (connect times out) or
251    /// RST-refused (no listener). Passing the hostname to a plain
252    /// `TcpStream::connect` and wrapping it in an overall timeout
253    /// means if the OS resolver hands back a dead address first,
254    /// the macOS TCP stack's per-address retry budget (~75 s of SYN
255    /// retransmit before giving up and moving to the next address)
256    /// blows through our 10 s budget before any live address is
257    /// reached — the auth flow then times out deterministically on
258    /// every run that happens to draw a dead address first.
259    ///
260    /// The fix is to resolve the hostname ourselves with
261    /// [`tokio::net::lookup_host`], then race each resolved address
262    /// sequentially with a short per-address timeout
263    /// ([`Self::per_address_timeout`]). The first address that
264    /// completes the TCP handshake wins; dead addresses are abandoned
265    /// quickly so the next one gets a real chance. This is a
266    /// minimal happy-eyeballs-style fallback without the
267    /// IPv4/IPv6 staggering of the full RFC 8305 algorithm.
268    async fn connect(&self) -> Result<TcpStream, AuthError> {
269        let hostport = self.endpoint.map_or_else(
270            || DEFAULT_AUTH_ENDPOINT.to_string(),
271            |addr| addr.to_string(),
272        );
273
274        // Resolve all A/AAAA records, then try each in order.
275        let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&hostport)
276            .await
277            .map_err(|source| AuthError::Io {
278                source,
279                operation: IoOperation::TcpAuthConnect,
280            })?
281            .collect();
282
283        if addrs.is_empty() {
284            return Err(AuthError::Io {
285                source: std::io::Error::new(
286                    std::io::ErrorKind::AddrNotAvailable,
287                    format!("no addresses resolved for {hostport}"),
288                ),
289                operation: IoOperation::TcpAuthConnect,
290            });
291        }
292
293        let per_addr_timeout = Duration::from_secs(3);
294        let mut last_err: Option<std::io::Error> = None;
295
296        for (idx, addr) in addrs.iter().enumerate() {
297            tracing::debug!(
298                target: "dstar_gateway::auth",
299                index = idx,
300                addr = %addr,
301                timeout_ms = u64::try_from(per_addr_timeout.as_millis()).unwrap_or(u64::MAX),
302                "DPlus TCP auth trying address"
303            );
304            match timeout(per_addr_timeout, TcpStream::connect(addr)).await {
305                Ok(Ok(stream)) => {
306                    tracing::debug!(
307                        target: "dstar_gateway::auth",
308                        addr = %addr,
309                        "DPlus TCP auth connected to address"
310                    );
311                    return Ok(stream);
312                }
313                Ok(Err(e)) => {
314                    tracing::debug!(
315                        target: "dstar_gateway::auth",
316                        addr = %addr,
317                        error = %e,
318                        "DPlus TCP auth address refused"
319                    );
320                    last_err = Some(e);
321                }
322                Err(_) => {
323                    tracing::debug!(
324                        target: "dstar_gateway::auth",
325                        addr = %addr,
326                        timeout_ms = u64::try_from(per_addr_timeout.as_millis()).unwrap_or(u64::MAX),
327                        "DPlus TCP auth address timed out, trying next"
328                    );
329                    last_err = Some(std::io::Error::new(
330                        std::io::ErrorKind::TimedOut,
331                        format!("{addr} did not respond within {per_addr_timeout:?}"),
332                    ));
333                }
334            }
335        }
336
337        // Every resolved address failed — fall through to the outer
338        // Timeout variant if appropriate, otherwise an Io error with
339        // the last underlying cause.
340        Err(last_err.map_or(
341            AuthError::Timeout {
342                elapsed: self.connect_timeout,
343                phase: AuthPhase::Connect,
344            },
345            |source| AuthError::Io {
346                source,
347                operation: IoOperation::TcpAuthConnect,
348            },
349        ))
350    }
351
352    /// Drain the TCP response body until EOF.
353    ///
354    /// Each `read` call is wrapped in [`Self::read_timeout`]. A
355    /// timeout is treated as a fatal read error (no data arrived
356    /// within the configured window), matching the behavior of the
357    /// legacy `DPlusClient::authenticate` loop.
358    async fn read_response(&self, stream: &mut TcpStream) -> Result<Vec<u8>, AuthError> {
359        let mut response = Vec::new();
360        let mut buf = [0u8; 4096];
361        loop {
362            let read_fut = stream.read(&mut buf);
363            let n_result =
364                timeout(self.read_timeout, read_fut)
365                    .await
366                    .map_err(|_| AuthError::Timeout {
367                        elapsed: self.read_timeout,
368                        phase: AuthPhase::Read,
369                    })?;
370            let n = n_result.map_err(|source| AuthError::Io {
371                source,
372                operation: IoOperation::TcpAuthRead,
373            })?;
374            if n == 0 {
375                break;
376            }
377            let slice = buf.get(..n).unwrap_or(&[]);
378            response.extend_from_slice(slice);
379        }
380        Ok(response)
381    }
382}
383
384/// Build the 56-byte `DPlus` auth request packet for the given callsign.
385///
386/// Layout per `ircDDBGateway/Common/DPlusAuthenticator.cpp:111-143`:
387/// - `[0..4]` = magic `0x38 0xC0 0x01 0x00`
388/// - `[4..12]` = callsign (8 bytes, space-padded)
389/// - `[12..20]` = `"DV019999"`
390/// - `[28..33]` = `"W7IB2"`
391/// - `[40..47]` = `"DHS0257"`
392/// - all other bytes stay `0x20` (space)
393fn build_auth_packet(callsign: Callsign) -> [u8; AUTH_PACKET_LEN] {
394    let mut pkt = [b' '; AUTH_PACKET_LEN];
395
396    // Magic bytes.
397    if let Some(slot) = pkt.get_mut(0) {
398        *slot = 0x38;
399    }
400    if let Some(slot) = pkt.get_mut(1) {
401        *slot = 0xC0;
402    }
403    if let Some(slot) = pkt.get_mut(2) {
404        *slot = 0x01;
405    }
406    if let Some(slot) = pkt.get_mut(3) {
407        *slot = 0x00;
408    }
409
410    // Callsign (8 bytes, space-padded by `Callsign` invariant).
411    if let Some(dst) = pkt.get_mut(4..12) {
412        dst.copy_from_slice(callsign.as_bytes());
413    }
414
415    // `"DV019999"` client version tag.
416    if let Some(dst) = pkt.get_mut(12..20) {
417        dst.copy_from_slice(b"DV019999");
418    }
419
420    // `"W7IB2"` reference author tag.
421    if let Some(dst) = pkt.get_mut(28..33) {
422        dst.copy_from_slice(b"W7IB2");
423    }
424
425    // `"DHS0257"` reference client id.
426    if let Some(dst) = pkt.get_mut(40..47) {
427        dst.copy_from_slice(b"DHS0257");
428    }
429
430    pkt
431}
432
433/// `DPlus` TCP auth errors.
434#[derive(Debug, thiserror::Error)]
435#[non_exhaustive]
436pub enum AuthError {
437    /// I/O failure during connect, write, or read. The `operation`
438    /// field identifies which phase tripped.
439    #[error("DPlus auth I/O error during {operation:?}: {source}")]
440    Io {
441        /// Underlying `std::io::Error`.
442        source: std::io::Error,
443        /// Which phase of the auth flow failed.
444        operation: IoOperation,
445    },
446
447    /// Phase timed out — the configured [`AuthClient::with_connect_timeout`]
448    /// or [`AuthClient::with_read_timeout`] elapsed before the
449    /// operation completed.
450    #[error("DPlus auth timed out after {elapsed:?} during {phase:?}")]
451    Timeout {
452        /// Duration that elapsed before the timeout fired.
453        elapsed: Duration,
454        /// Which phase of the auth flow timed out.
455        phase: AuthPhase,
456    },
457
458    /// Response body failed to parse as a valid `DPlus` host list.
459    #[error(transparent)]
460    Parse(#[from] dstar_gateway_core::codec::dplus::DPlusError),
461}
462
463/// Phase discriminator for [`AuthError::Timeout`].
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465#[non_exhaustive]
466pub enum AuthPhase {
467    /// TCP connect to the auth server.
468    Connect,
469    /// Writing the 56-byte auth request packet.
470    Write,
471    /// Reading the framed host list response.
472    Read,
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn auth_client_default_endpoint_is_none() {
481        let client = AuthClient::new();
482        assert!(client.endpoint().is_none());
483    }
484
485    #[test]
486    fn auth_client_with_endpoint_sets_endpoint() {
487        use std::net::{IpAddr, Ipv4Addr};
488
489        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 54321);
490        let client = AuthClient::new().with_endpoint(addr);
491        assert_eq!(client.endpoint(), Some(addr));
492    }
493
494    #[test]
495    fn auth_phase_variants_distinct() {
496        assert_ne!(AuthPhase::Connect, AuthPhase::Write);
497        assert_ne!(AuthPhase::Write, AuthPhase::Read);
498        assert_ne!(AuthPhase::Connect, AuthPhase::Read);
499    }
500
501    #[test]
502    fn auth_error_timeout_display_contains_phase_and_elapsed() {
503        let err = AuthError::Timeout {
504            elapsed: Duration::from_secs(7),
505            phase: AuthPhase::Connect,
506        };
507        let rendered = err.to_string();
508        assert!(rendered.contains("Connect"), "display: {rendered}");
509        assert!(rendered.contains("7s"), "display: {rendered}");
510    }
511
512    #[test]
513    fn build_auth_packet_layout_matches_ircddbgateway() {
514        let cs = Callsign::from_wire_bytes(*b"W1AW    ");
515        let pkt = build_auth_packet(cs);
516
517        // Magic.
518        assert_eq!(pkt.first().copied(), Some(0x38));
519        assert_eq!(pkt.get(1).copied(), Some(0xC0));
520        assert_eq!(pkt.get(2).copied(), Some(0x01));
521        assert_eq!(pkt.get(3).copied(), Some(0x00));
522
523        // Callsign field.
524        assert_eq!(pkt.get(4..12), Some(cs.as_bytes().as_slice()));
525
526        // Version tag.
527        assert_eq!(pkt.get(12..20), Some(b"DV019999".as_slice()));
528
529        // Author tag.
530        assert_eq!(pkt.get(28..33), Some(b"W7IB2".as_slice()));
531
532        // Client id.
533        assert_eq!(pkt.get(40..47), Some(b"DHS0257".as_slice()));
534
535        // Gap 20..28 is all spaces.
536        assert!(
537            pkt.get(20..28)
538                .is_some_and(|s| s.iter().all(|&b| b == b' '))
539        );
540        // Gap 33..40 is all spaces.
541        assert!(
542            pkt.get(33..40)
543                .is_some_and(|s| s.iter().all(|&b| b == b' '))
544        );
545        // Tail 47..56 is all spaces.
546        assert!(
547            pkt.get(47..56)
548                .is_some_and(|s| s.iter().all(|&b| b == b' '))
549        );
550    }
551}