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}