Skip to main content

fips_core/config/
transport.rs

1//! Transport configuration types.
2//!
3//! Generic transport instance handling (single vs. named) and
4//! transport-specific configuration structs.
5
6use std::collections::HashMap;
7use std::net::{IpAddr, SocketAddr};
8
9use serde::{Deserialize, Serialize};
10
11/// Parse an `external_addr` config string against a known bind port,
12/// producing the absolute `SocketAddr` to advertise on Nostr.
13///
14/// Accepts either a bare IP (`"198.51.100.1"` or `"[::1]"`) — in which
15/// case the bind port is appended — or a full `host:port` form
16/// (`"198.51.100.1:443"` or `"[::1]:443"`). Returns `None` on any parse
17/// error. IPv6 must use bracket notation when supplying a port.
18fn parse_external_advert_addr(raw: &str, bind_port: u16) -> Option<SocketAddr> {
19    if let Ok(sa) = raw.parse::<SocketAddr>() {
20        return Some(sa);
21    }
22    let ip: IpAddr = raw.parse().ok()?;
23    Some(SocketAddr::new(ip, bind_port))
24}
25
26/// Extract the port from a `bind_addr` string. Returns `None` if the
27/// string can't be parsed (e.g. a bare hostname without port).
28fn parse_bind_port(raw: &str) -> Option<u16> {
29    raw.parse::<SocketAddr>().ok().map(|sa| sa.port())
30}
31
32/// Default UDP bind address.
33const DEFAULT_UDP_BIND_ADDR: &str = "0.0.0.0:2121";
34
35/// Default UDP MTU (IPv6 minimum).
36const DEFAULT_UDP_MTU: u16 = 1280;
37
38/// Default UDP receive buffer size (16 MiB).
39///
40/// At sustained multi-Gbps single-stream the kernel UDP queue
41/// drained ~113 kpps × ~1.5 KiB ≈ 170 MiB/s, so a few-hundred-ms
42/// userspace stall would fill a 2 MiB buffer in <20 ms — small
43/// enough that ordinary jitter (GC, allocator-coalesce, scheduler
44/// context-switch on a busy host) trips RcvbufErrors and tanks TCP
45/// throughput via cwnd-halving. 16 MiB gives ~100 ms of headroom.
46///
47/// On platforms whose `net.core.rmem_max` is smaller than this, the
48/// UDP socket layer falls back to SO_RCVBUFFORCE (CAP_NET_ADMIN
49/// required) before honouring the kernel ceiling. See
50/// `transport/udp/socket.rs::UdpRawSocket::open`.
51const DEFAULT_UDP_RECV_BUF: usize = 16 * 1024 * 1024;
52
53/// Default UDP send buffer size (8 MiB). Mirrors the receive-side
54/// reasoning at half the size — outbound burst absorption matters
55/// less because we control the producer rate via the rx_loop's
56/// per-drain sendmmsg flush.
57const DEFAULT_UDP_SEND_BUF: usize = 8 * 1024 * 1024;
58
59/// UDP transport instance configuration.
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct UdpConfig {
63    /// Bind address (`bind_addr`). Defaults to "0.0.0.0:2121".
64    ///
65    /// When `outbound_only = true`, this field is ignored and the transport
66    /// binds to `0.0.0.0:0` (kernel-assigned ephemeral port) regardless.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub bind_addr: Option<String>,
69
70    /// UDP MTU (`mtu`). Defaults to 1280 (IPv6 minimum).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub mtu: Option<u16>,
73
74    /// UDP receive buffer size in bytes (`recv_buf_size`). Defaults to 16 MiB.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub recv_buf_size: Option<usize>,
77
78    /// UDP send buffer size in bytes (`send_buf_size`). Defaults to 8 MiB.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub send_buf_size: Option<usize>,
81
82    /// Whether this transport should be advertised on Nostr overlay discovery.
83    /// Default: false. Implicitly forced false when `outbound_only = true`.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub advertise_on_nostr: Option<bool>,
86
87    /// Whether UDP should be advertised as directly reachable (`host:port`) on
88    /// Nostr. When false and advertised, UDP is emitted as `addr: "nat"` to
89    /// trigger rendezvous traversal.
90    ///
91    /// Default: false.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub public: Option<bool>,
94    /// Optional explicit public address to advertise when `public: true`
95    /// is set. Takes precedence over both the bound address and any
96    /// STUN-derived autodiscovery. Accepts either a bare IP
97    /// (`"198.51.100.1"` — the configured `bind_addr` port is appended)
98    /// or a full `host:port` (`"198.51.100.1:443"`). Useful when the
99    /// public IP isn't on a local interface (e.g. AWS EIP / cloud 1:1
100    /// NAT) and the operator wants to skip STUN autodiscovery for a
101    /// deterministic value.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub external_addr: Option<String>,
104    /// Outbound-only mode. When true, the transport binds to a kernel-
105    /// assigned ephemeral port (`0.0.0.0:0`) instead of the configured
106    /// `bind_addr`, refuses inbound handshake msg1, and is never
107    /// advertised on Nostr regardless of `advertise_on_nostr`. Use this
108    /// to participate in the mesh as a pure client — initiate outbound
109    /// links without exposing an inbound listener on a known port.
110    /// Default: false.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub outbound_only: Option<bool>,
113
114    /// Accept inbound handshake msg1 from new peers. Default: true.
115    /// Setting this to false combined with `auto_connect: true` on
116    /// peer-side configurations gives a "client" posture: this node
117    /// initiates outbound links but refuses inbound handshakes from
118    /// unfamiliar addresses. The Node-level gate at
119    /// `src/node/handlers/handshake.rs` carves out msg1 from peers
120    /// already established on this transport (so rekey continues to
121    /// work) — see ISSUE-2026-0004.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub accept_connections: Option<bool>,
124}
125
126impl UdpConfig {
127    /// Get the bind address, using default if not configured.
128    ///
129    /// When `outbound_only = true`, returns `0.0.0.0:0` so the kernel picks
130    /// an ephemeral source port and no listener is exposed on a known port.
131    pub fn bind_addr(&self) -> &str {
132        if self.outbound_only() {
133            "0.0.0.0:0"
134        } else {
135            self.bind_addr.as_deref().unwrap_or(DEFAULT_UDP_BIND_ADDR)
136        }
137    }
138
139    /// Get the UDP MTU, using default if not configured.
140    pub fn mtu(&self) -> u16 {
141        self.mtu.unwrap_or(DEFAULT_UDP_MTU)
142    }
143
144    /// Get the receive buffer size, using default if not configured.
145    pub fn recv_buf_size(&self) -> usize {
146        self.recv_buf_size.unwrap_or(DEFAULT_UDP_RECV_BUF)
147    }
148
149    /// Get the send buffer size, using default if not configured.
150    pub fn send_buf_size(&self) -> usize {
151        self.send_buf_size.unwrap_or(DEFAULT_UDP_SEND_BUF)
152    }
153
154    /// Whether this UDP transport should be advertised on Nostr discovery.
155    /// Always false when `outbound_only = true`.
156    pub fn advertise_on_nostr(&self) -> bool {
157        if self.outbound_only() {
158            false
159        } else {
160            self.advertise_on_nostr.unwrap_or(false)
161        }
162    }
163
164    /// Whether this UDP transport should be advertised as directly reachable.
165    pub fn is_public(&self) -> bool {
166        self.public.unwrap_or(false)
167    }
168
169    /// Parse `external_addr` against the configured `bind_addr` port,
170    /// returning the absolute `SocketAddr` to advertise on Nostr.
171    /// Returns `None` if `external_addr` is unset or malformed, or if
172    /// the port cannot be inferred.
173    pub fn external_advert_addr(&self) -> Option<SocketAddr> {
174        let raw = self.external_addr.as_deref()?;
175        let bind_port = parse_bind_port(self.bind_addr())?;
176        parse_external_advert_addr(raw, bind_port)
177    }
178
179    /// Whether this transport runs in outbound-only mode. Default: false.
180    pub fn outbound_only(&self) -> bool {
181        self.outbound_only.unwrap_or(false)
182    }
183
184    /// Whether this transport accepts inbound handshakes. Default: true.
185    pub fn accept_connections(&self) -> bool {
186        self.accept_connections.unwrap_or(true)
187    }
188}
189
190/// Default simulated transport MTU (IPv6 minimum).
191#[cfg(feature = "sim-transport")]
192const DEFAULT_SIM_MTU: u16 = 1280;
193
194/// Default simulated network registry name.
195#[cfg(feature = "sim-transport")]
196const DEFAULT_SIM_NETWORK: &str = "default";
197
198/// In-memory simulated transport instance configuration.
199///
200/// This transport is intended for production-backed simulations. It uses the
201/// normal node/session/routing stack, but delivers transport packets through a
202/// registered in-process network that can model latency, throughput, loss, and
203/// churn without binding real sockets.
204#[cfg(feature = "sim-transport")]
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct SimTransportConfig {
208    /// Registry name of the in-process simulated network.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub network: Option<String>,
211
212    /// Address of this simulated endpoint within the network.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub addr: Option<String>,
215
216    /// Transport MTU. Defaults to 1280.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub mtu: Option<u16>,
219
220    /// Whether discovery should auto-connect to discovered peers.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub auto_connect: Option<bool>,
223
224    /// Accept inbound handshake msg1 from new peers. Default: true.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub accept_connections: Option<bool>,
227}
228
229#[cfg(feature = "sim-transport")]
230impl SimTransportConfig {
231    /// Registry name of the in-process simulated network.
232    pub fn network(&self) -> &str {
233        self.network.as_deref().unwrap_or(DEFAULT_SIM_NETWORK)
234    }
235
236    /// Get the simulated MTU.
237    pub fn mtu(&self) -> u16 {
238        self.mtu.unwrap_or(DEFAULT_SIM_MTU)
239    }
240
241    /// Whether this transport auto-connects to discovered peers.
242    pub fn auto_connect(&self) -> bool {
243        self.auto_connect.unwrap_or(false)
244    }
245
246    /// Whether this transport accepts inbound handshakes.
247    pub fn accept_connections(&self) -> bool {
248        self.accept_connections.unwrap_or(true)
249    }
250}
251
252/// Transport instances - either a single config or named instances.
253///
254/// Allows both simple single-instance config:
255/// ```yaml
256/// transports:
257///   udp:
258///     bind_addr: "0.0.0.0:2121"
259/// ```
260///
261/// And multiple named instances:
262/// ```yaml
263/// transports:
264///   udp:
265///     main:
266///       bind_addr: "0.0.0.0:2121"
267///     backup:
268///       bind_addr: "192.168.1.100:2122"
269/// ```
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(untagged)]
272pub enum TransportInstances<T> {
273    /// Single unnamed instance (config fields directly under transport type).
274    Single(T),
275    /// Multiple named instances.
276    Named(HashMap<String, T>),
277}
278
279impl<T> TransportInstances<T> {
280    /// Get the number of instances.
281    pub fn len(&self) -> usize {
282        match self {
283            TransportInstances::Single(_) => 1,
284            TransportInstances::Named(map) => map.len(),
285        }
286    }
287
288    /// Check if there are no instances.
289    pub fn is_empty(&self) -> bool {
290        match self {
291            TransportInstances::Single(_) => false,
292            TransportInstances::Named(map) => map.is_empty(),
293        }
294    }
295
296    /// Iterate over all instances as (name, config) pairs.
297    ///
298    /// Single instances have `None` as the name.
299    /// Named instances have `Some(name)`.
300    pub fn iter(&self) -> impl Iterator<Item = (Option<&str>, &T)> {
301        match self {
302            TransportInstances::Single(config) => vec![(None, config)].into_iter(),
303            TransportInstances::Named(map) => map
304                .iter()
305                .map(|(k, v)| (Some(k.as_str()), v))
306                .collect::<Vec<_>>()
307                .into_iter(),
308        }
309    }
310}
311
312impl<T> Default for TransportInstances<T> {
313    fn default() -> Self {
314        TransportInstances::Named(HashMap::new())
315    }
316}
317
318/// Default Ethernet EtherType (FIPS default).
319const DEFAULT_ETHERNET_ETHERTYPE: u16 = 0x2121;
320
321/// Default Ethernet receive buffer size (2 MB).
322const DEFAULT_ETHERNET_RECV_BUF: usize = 2 * 1024 * 1024;
323
324/// Default Ethernet send buffer size (2 MB).
325const DEFAULT_ETHERNET_SEND_BUF: usize = 2 * 1024 * 1024;
326
327/// Default beacon announcement interval in seconds.
328const DEFAULT_BEACON_INTERVAL_SECS: u64 = 30;
329
330/// Minimum beacon announcement interval in seconds.
331const MIN_BEACON_INTERVAL_SECS: u64 = 10;
332
333/// Ethernet transport instance configuration.
334///
335/// EthernetConfig is always compiled (for config parsing on any platform),
336/// but the transport runtime currently requires Linux or macOS raw sockets.
337#[derive(Debug, Clone, Default, Serialize, Deserialize)]
338#[serde(deny_unknown_fields)]
339pub struct EthernetConfig {
340    /// Network interface name (e.g., "eth0", "enp3s0"). Required.
341    pub interface: String,
342
343    /// Custom EtherType (default: 0x2121).
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub ethertype: Option<u16>,
346
347    /// MTU override. Defaults to the interface's MTU minus 1 (for frame type prefix).
348    /// Cannot exceed the interface's actual MTU.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub mtu: Option<u16>,
351
352    /// Receive buffer size in bytes. Default: 2 MB.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub recv_buf_size: Option<usize>,
355
356    /// Send buffer size in bytes. Default: 2 MB.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub send_buf_size: Option<usize>,
359
360    /// Listen for discovery beacons from other nodes. Default: true.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub discovery: Option<bool>,
363
364    /// Broadcast announcement beacons on the LAN. Default: false.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub announce: Option<bool>,
367
368    /// Auto-connect to discovered peers. Default: false.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub auto_connect: Option<bool>,
371
372    /// Accept incoming connection attempts. Default: false.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub accept_connections: Option<bool>,
375
376    /// Optional discovery scope carried in Ethernet beacons.
377    ///
378    /// When set, this transport ignores Ethernet beacons from other scopes.
379    /// This is a discovery/noise filter, not an access-control mechanism. If
380    /// unset, the node-level LAN discovery scope is used when available.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub discovery_scope: Option<String>,
383
384    /// Announcement beacon interval in seconds. Default: 30.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub beacon_interval_secs: Option<u64>,
387}
388
389impl EthernetConfig {
390    /// Get the EtherType, using default if not configured.
391    pub fn ethertype(&self) -> u16 {
392        self.ethertype.unwrap_or(DEFAULT_ETHERNET_ETHERTYPE)
393    }
394
395    /// Get the receive buffer size, using default if not configured.
396    pub fn recv_buf_size(&self) -> usize {
397        self.recv_buf_size.unwrap_or(DEFAULT_ETHERNET_RECV_BUF)
398    }
399
400    /// Get the send buffer size, using default if not configured.
401    pub fn send_buf_size(&self) -> usize {
402        self.send_buf_size.unwrap_or(DEFAULT_ETHERNET_SEND_BUF)
403    }
404
405    /// Whether to listen for discovery beacons. Default: true.
406    pub fn discovery(&self) -> bool {
407        self.discovery.unwrap_or(true)
408    }
409
410    /// Whether to broadcast announcement beacons. Default: false.
411    pub fn announce(&self) -> bool {
412        self.announce.unwrap_or(false)
413    }
414
415    /// Whether to auto-connect to discovered peers. Default: false.
416    pub fn auto_connect(&self) -> bool {
417        self.auto_connect.unwrap_or(false)
418    }
419
420    /// Whether to accept incoming connections. Default: false.
421    pub fn accept_connections(&self) -> bool {
422        self.accept_connections.unwrap_or(false)
423    }
424
425    /// Optional discovery scope for Ethernet beacons.
426    pub fn discovery_scope(&self) -> Option<&str> {
427        self.discovery_scope.as_deref().filter(|s| !s.is_empty())
428    }
429
430    /// Get the beacon interval, clamped to minimum. Default: 30s.
431    pub fn beacon_interval_secs(&self) -> u64 {
432        self.beacon_interval_secs
433            .unwrap_or(DEFAULT_BEACON_INTERVAL_SECS)
434            .max(MIN_BEACON_INTERVAL_SECS)
435    }
436}
437
438// ============================================================================
439// TCP Transport Configuration
440// ============================================================================
441
442/// Default TCP MTU (conservative, matches typical Ethernet MSS minus overhead).
443const DEFAULT_TCP_MTU: u16 = 1400;
444
445/// Default TCP connect timeout in milliseconds.
446const DEFAULT_TCP_CONNECT_TIMEOUT_MS: u64 = 5000;
447
448/// Default timeout for an accepted inbound TCP connection to deliver its
449/// first complete FMP frame.
450const DEFAULT_TCP_FIRST_FRAME_TIMEOUT_MS: u64 = 3000;
451
452/// Default TCP keepalive interval in seconds.
453const DEFAULT_TCP_KEEPALIVE_SECS: u64 = 30;
454
455/// Default TCP receive buffer size (2 MB).
456const DEFAULT_TCP_RECV_BUF: usize = 2 * 1024 * 1024;
457
458/// Default TCP send buffer size (2 MB).
459const DEFAULT_TCP_SEND_BUF: usize = 2 * 1024 * 1024;
460
461/// Default maximum inbound TCP connections.
462const DEFAULT_TCP_MAX_INBOUND: usize = 256;
463
464/// TCP transport instance configuration.
465#[derive(Debug, Clone, Default, Serialize, Deserialize)]
466#[serde(deny_unknown_fields)]
467pub struct TcpConfig {
468    /// Listen address (e.g., "0.0.0.0:443"). If not set, outbound-only.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub bind_addr: Option<String>,
471
472    /// Default MTU for TCP connections. Defaults to 1400.
473    /// Per-connection MTU is derived from TCP_MAXSEG when available.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub mtu: Option<u16>,
476
477    /// Outbound connect timeout in milliseconds. Defaults to 5000.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub connect_timeout_ms: Option<u64>,
480
481    /// Inbound first-frame timeout in milliseconds. Accepted connections
482    /// must deliver one complete FMP frame within this window or they are
483    /// closed. Set to 0 to disable. Defaults to 3000.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub first_frame_timeout_ms: Option<u64>,
486
487    /// Enable TCP_NODELAY (disable Nagle). Defaults to true.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub nodelay: Option<bool>,
490
491    /// TCP keepalive interval in seconds. 0 = disabled. Defaults to 30.
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub keepalive_secs: Option<u64>,
494
495    /// TCP receive buffer size in bytes. Defaults to 2 MB.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub recv_buf_size: Option<usize>,
498
499    /// TCP send buffer size in bytes. Defaults to 2 MB.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub send_buf_size: Option<usize>,
502
503    /// Maximum simultaneous inbound connections. Defaults to 256.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub max_inbound_connections: Option<usize>,
506
507    /// Whether this transport should be advertised on Nostr overlay discovery.
508    /// Default: false.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub advertise_on_nostr: Option<bool>,
511
512    /// Optional explicit public address to advertise. Required when
513    /// `bind_addr` is wildcard (e.g. `"0.0.0.0:443"`) and
514    /// `advertise_on_nostr: true`, since TCP has no STUN equivalent
515    /// for autodiscovery. Accepts either a bare IP (`"198.51.100.1"`
516    /// — the configured `bind_addr` port is appended) or a full
517    /// `host:port`. Common pattern on AWS EIP / cloud 1:1 NAT setups
518    /// where the public IP isn't bindable on the host.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub external_addr: Option<String>,
521}
522
523impl TcpConfig {
524    /// Get the default MTU.
525    pub fn mtu(&self) -> u16 {
526        self.mtu.unwrap_or(DEFAULT_TCP_MTU)
527    }
528
529    /// Get the connect timeout in milliseconds.
530    pub fn connect_timeout_ms(&self) -> u64 {
531        self.connect_timeout_ms
532            .unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS)
533    }
534
535    /// Get the inbound first-frame timeout in milliseconds. 0 disables it.
536    pub fn first_frame_timeout_ms(&self) -> u64 {
537        self.first_frame_timeout_ms
538            .unwrap_or(DEFAULT_TCP_FIRST_FRAME_TIMEOUT_MS)
539    }
540
541    /// Whether TCP_NODELAY is enabled. Default: true.
542    pub fn nodelay(&self) -> bool {
543        self.nodelay.unwrap_or(true)
544    }
545
546    /// Get the keepalive interval in seconds. 0 = disabled. Default: 30.
547    pub fn keepalive_secs(&self) -> u64 {
548        self.keepalive_secs.unwrap_or(DEFAULT_TCP_KEEPALIVE_SECS)
549    }
550
551    /// Get the receive buffer size. Default: 2 MB.
552    pub fn recv_buf_size(&self) -> usize {
553        self.recv_buf_size.unwrap_or(DEFAULT_TCP_RECV_BUF)
554    }
555
556    /// Get the send buffer size. Default: 2 MB.
557    pub fn send_buf_size(&self) -> usize {
558        self.send_buf_size.unwrap_or(DEFAULT_TCP_SEND_BUF)
559    }
560
561    /// Get the maximum number of inbound connections. Default: 256.
562    pub fn max_inbound_connections(&self) -> usize {
563        self.max_inbound_connections
564            .unwrap_or(DEFAULT_TCP_MAX_INBOUND)
565    }
566
567    /// Whether this TCP transport should be advertised on Nostr discovery.
568    pub fn advertise_on_nostr(&self) -> bool {
569        self.advertise_on_nostr.unwrap_or(false)
570    }
571
572    /// Parse `external_addr` against the configured `bind_addr` port,
573    /// returning the absolute `SocketAddr` to advertise on Nostr.
574    /// Returns `None` if `external_addr` is unset or malformed, or if
575    /// `bind_addr` is unset / unparseable so no port can be inferred.
576    pub fn external_advert_addr(&self) -> Option<SocketAddr> {
577        let raw = self.external_addr.as_deref()?;
578        let bind_port = parse_bind_port(self.bind_addr.as_deref()?)?;
579        parse_external_advert_addr(raw, bind_port)
580    }
581}
582
583// ============================================================================
584// Tor Transport Configuration
585// ============================================================================
586
587/// Default Tor SOCKS5 proxy address.
588const DEFAULT_TOR_SOCKS5_ADDR: &str = "127.0.0.1:9050";
589
590/// Default Tor control port address.
591const DEFAULT_TOR_CONTROL_ADDR: &str = "/run/tor/control";
592
593/// Default Tor control cookie file path (Debian standard location).
594const DEFAULT_TOR_COOKIE_PATH: &str = "/var/run/tor/control.authcookie";
595
596/// Default Tor connect timeout in milliseconds (120s — Tor circuit
597/// establishment can take 30-60s on first connect, plus SOCKS5 handshake).
598const DEFAULT_TOR_CONNECT_TIMEOUT_MS: u64 = 120_000;
599
600/// Default Tor MTU (same as TCP).
601const DEFAULT_TOR_MTU: u16 = 1400;
602
603/// Default max inbound connections via onion service.
604const DEFAULT_TOR_MAX_INBOUND: usize = 64;
605
606/// Default HiddenServiceDir hostname file path.
607const DEFAULT_HOSTNAME_FILE: &str = "/var/lib/tor/fips_onion_service/hostname";
608
609/// Default directory mode bind address.
610const DEFAULT_DIRECTORY_BIND_ADDR: &str = "127.0.0.1:8443";
611
612/// Default advertised onion port for Nostr overlay discovery. Matches the
613/// Tor convention of `HiddenServicePort 443 127.0.0.1:<bind_port>` in torrc.
614const DEFAULT_TOR_ADVERTISED_PORT: u16 = 443;
615
616/// Tor transport instance configuration.
617///
618/// Supports three modes:
619/// - `socks5`: Outbound-only connections through a Tor SOCKS5 proxy.
620/// - `control_port`: Full bidirectional support — outbound via SOCKS5
621///   plus inbound via Tor onion service managed through the control port.
622/// - `directory`: Full bidirectional support — outbound via SOCKS5,
623///   inbound via a Tor-managed `HiddenServiceDir` onion service. No
624///   control port needed. Enables Tor `Sandbox 1` mode.
625#[derive(Debug, Clone, Default, Serialize, Deserialize)]
626#[serde(deny_unknown_fields)]
627pub struct TorConfig {
628    /// Tor access mode: "socks5", "control_port", or "directory".
629    /// Default: "socks5".
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub mode: Option<String>,
632
633    /// SOCKS5 proxy address (host:port). Defaults to "127.0.0.1:9050".
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub socks5_addr: Option<String>,
636
637    /// Outbound connect timeout in milliseconds. Defaults to 120000 (120s).
638    /// Tor circuit establishment can take 30-60s, so this must be generous.
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub connect_timeout_ms: Option<u64>,
641
642    /// Default MTU for Tor connections. Defaults to 1400.
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub mtu: Option<u16>,
645
646    /// Control port address: a Unix socket path (`/run/tor/control`) or
647    /// TCP address (`host:port`). Unix sockets are preferred for security.
648    /// Defaults to "/run/tor/control".
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub control_addr: Option<String>,
651
652    /// Control port authentication method:
653    /// `"cookie"` (read from default path),
654    /// `"cookie:/path/to/cookie"` (read from specified path), or
655    /// `"password:secret"` (password auth). Default: `"cookie"`.
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub control_auth: Option<String>,
658
659    /// Path to the Tor control cookie file. Used when control_auth is "cookie".
660    /// Defaults to "/var/run/tor/control.authcookie".
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub cookie_path: Option<String>,
663
664    /// Maximum number of inbound connections via onion service. Default: 64.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub max_inbound_connections: Option<usize>,
667
668    /// Directory-mode onion service configuration. Only valid in
669    /// "directory" mode. Tor manages the onion service via HiddenServiceDir
670    /// in torrc; fips reads the .onion hostname from a file.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub directory_service: Option<DirectoryServiceConfig>,
673
674    /// Whether this transport should be advertised on Nostr overlay discovery.
675    /// Default: false.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub advertise_on_nostr: Option<bool>,
678
679    /// Public-facing onion port published in Nostr overlay adverts. Must
680    /// match the virtual port in torrc's `HiddenServicePort <port>
681    /// 127.0.0.1:<bind_port>` directive — that is the port other peers
682    /// will use to reach this onion. Default: 443.
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub advertised_port: Option<u16>,
685}
686
687/// Directory-mode onion service configuration.
688///
689/// In `directory` mode, Tor manages the onion service via `HiddenServiceDir`
690/// in torrc. FIPS reads the `.onion` address from the hostname file and
691/// binds a local TCP listener for Tor to forward inbound connections to.
692/// This mode requires no control port and enables Tor's `Sandbox 1`.
693#[derive(Debug, Clone, Default, Serialize, Deserialize)]
694#[serde(deny_unknown_fields)]
695pub struct DirectoryServiceConfig {
696    /// Path to the Tor-managed hostname file containing the .onion address.
697    /// Defaults to "/var/lib/tor/fips_onion_service/hostname".
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub hostname_file: Option<String>,
700
701    /// Local bind address for the listener that Tor forwards inbound
702    /// connections to. Must match the target in torrc's `HiddenServicePort`.
703    /// Defaults to "127.0.0.1:8443".
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub bind_addr: Option<String>,
706}
707
708impl DirectoryServiceConfig {
709    /// Path to the hostname file. Default: "/var/lib/tor/fips_onion_service/hostname".
710    pub fn hostname_file(&self) -> &str {
711        self.hostname_file
712            .as_deref()
713            .unwrap_or(DEFAULT_HOSTNAME_FILE)
714    }
715
716    /// Local bind address for the listener. Default: "127.0.0.1:8443".
717    pub fn bind_addr(&self) -> &str {
718        self.bind_addr
719            .as_deref()
720            .unwrap_or(DEFAULT_DIRECTORY_BIND_ADDR)
721    }
722}
723
724impl TorConfig {
725    /// Get the access mode. Default: "socks5".
726    pub fn mode(&self) -> &str {
727        self.mode.as_deref().unwrap_or("socks5")
728    }
729
730    /// Get the SOCKS5 proxy address. Default: "127.0.0.1:9050".
731    pub fn socks5_addr(&self) -> &str {
732        self.socks5_addr
733            .as_deref()
734            .unwrap_or(DEFAULT_TOR_SOCKS5_ADDR)
735    }
736
737    /// Get the control port address. Default: "/run/tor/control".
738    pub fn control_addr(&self) -> &str {
739        self.control_addr
740            .as_deref()
741            .unwrap_or(DEFAULT_TOR_CONTROL_ADDR)
742    }
743
744    /// Get the control auth string. Default: "cookie".
745    pub fn control_auth(&self) -> &str {
746        self.control_auth.as_deref().unwrap_or("cookie")
747    }
748
749    /// Get the cookie file path. Default: "/var/run/tor/control.authcookie".
750    pub fn cookie_path(&self) -> &str {
751        self.cookie_path
752            .as_deref()
753            .unwrap_or(DEFAULT_TOR_COOKIE_PATH)
754    }
755
756    /// Get the connect timeout in milliseconds. Default: 120000.
757    pub fn connect_timeout_ms(&self) -> u64 {
758        self.connect_timeout_ms
759            .unwrap_or(DEFAULT_TOR_CONNECT_TIMEOUT_MS)
760    }
761
762    /// Get the default MTU. Default: 1400.
763    pub fn mtu(&self) -> u16 {
764        self.mtu.unwrap_or(DEFAULT_TOR_MTU)
765    }
766
767    /// Get the max inbound connections. Default: 64.
768    pub fn max_inbound_connections(&self) -> usize {
769        self.max_inbound_connections
770            .unwrap_or(DEFAULT_TOR_MAX_INBOUND)
771    }
772
773    /// Whether this Tor transport should be advertised on Nostr discovery.
774    pub fn advertise_on_nostr(&self) -> bool {
775        self.advertise_on_nostr.unwrap_or(false)
776    }
777
778    /// Public-facing onion port published in Nostr overlay adverts.
779    /// Default: 443.
780    pub fn advertised_port(&self) -> u16 {
781        self.advertised_port.unwrap_or(DEFAULT_TOR_ADVERTISED_PORT)
782    }
783}
784
785// ============================================================================
786// WebRTC Transport Configuration
787// ============================================================================
788
789/// Default WebRTC data-channel MTU.
790const DEFAULT_WEBRTC_MTU: u16 = 1200;
791
792/// Default WebRTC connection timeout in milliseconds.
793const DEFAULT_WEBRTC_CONNECT_TIMEOUT_MS: u64 = 30_000;
794
795/// Default non-trickle ICE gathering timeout in milliseconds.
796const DEFAULT_WEBRTC_ICE_GATHER_TIMEOUT_MS: u64 = 10_000;
797
798/// Default maximum simultaneous WebRTC peer connections.
799const DEFAULT_WEBRTC_MAX_CONNECTIONS: usize = 32;
800
801/// Default WebRTC data channel label.
802const DEFAULT_WEBRTC_DATA_CHANNEL_LABEL: &str = "fips";
803
804/// WebRTC transport instance configuration.
805///
806/// WebRTC uses Nostr gift-wrapped signaling from `node.discovery.nostr` and
807/// carries ordinary FIPS datagrams over an SCTP data channel.
808#[derive(Debug, Clone, Default, Serialize, Deserialize)]
809#[serde(deny_unknown_fields)]
810pub struct WebRtcConfig {
811    /// Whether this transport should be advertised on Nostr overlay discovery.
812    /// Default: false.
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub advertise_on_nostr: Option<bool>,
815
816    /// Whether to automatically connect to discovered WebRTC peers.
817    /// Default: false.
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub auto_connect: Option<bool>,
820
821    /// Accept inbound WebRTC offers. Default: true.
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub accept_connections: Option<bool>,
824
825    /// Data-channel MTU. Defaults to 1200.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub mtu: Option<u16>,
828
829    /// Maximum simultaneous WebRTC peer connections. Defaults to 32.
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub max_connections: Option<usize>,
832
833    /// Outbound connect timeout in milliseconds. Defaults to 30000.
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub connect_timeout_ms: Option<u64>,
836
837    /// Non-trickle ICE gathering timeout in milliseconds. Defaults to 10000.
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub ice_gather_timeout_ms: Option<u64>,
840
841    /// Data channel label. Defaults to "fips".
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub data_channel_label: Option<String>,
844
845    /// Ordered data channel delivery. Default: true.
846    #[serde(default, skip_serializing_if = "Option::is_none")]
847    pub ordered: Option<bool>,
848
849    /// Maximum retransmits for partial reliability. Default: unset, which uses
850    /// WebRTC's reliable data-channel mode. Set to 0 for datagram-like delivery.
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub max_retransmits: Option<u16>,
853
854    /// Override signaling relays for this transport. When unset,
855    /// `node.discovery.nostr.dm_relays` is used.
856    #[serde(default, skip_serializing_if = "Option::is_none")]
857    pub signal_relays: Option<Vec<String>>,
858
859    /// Override STUN servers for this transport. When unset,
860    /// `node.discovery.nostr.stun_servers` is used.
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub stun_servers: Option<Vec<String>>,
863}
864
865impl WebRtcConfig {
866    /// Whether this WebRTC transport should be advertised on Nostr discovery.
867    pub fn advertise_on_nostr(&self) -> bool {
868        self.advertise_on_nostr.unwrap_or(false)
869    }
870
871    /// Whether this transport auto-connects to discovered peers.
872    pub fn auto_connect(&self) -> bool {
873        self.auto_connect.unwrap_or(false)
874    }
875
876    /// Whether this transport accepts inbound offers.
877    pub fn accept_connections(&self) -> bool {
878        self.accept_connections.unwrap_or(true)
879    }
880
881    /// Get the data-channel MTU.
882    pub fn mtu(&self) -> u16 {
883        self.mtu.unwrap_or(DEFAULT_WEBRTC_MTU)
884    }
885
886    /// Get the maximum number of peer connections.
887    pub fn max_connections(&self) -> usize {
888        self.max_connections
889            .unwrap_or(DEFAULT_WEBRTC_MAX_CONNECTIONS)
890    }
891
892    /// Get the connect timeout in milliseconds.
893    pub fn connect_timeout_ms(&self) -> u64 {
894        self.connect_timeout_ms
895            .unwrap_or(DEFAULT_WEBRTC_CONNECT_TIMEOUT_MS)
896    }
897
898    /// Get the ICE gathering timeout in milliseconds.
899    pub fn ice_gather_timeout_ms(&self) -> u64 {
900        self.ice_gather_timeout_ms
901            .unwrap_or(DEFAULT_WEBRTC_ICE_GATHER_TIMEOUT_MS)
902    }
903
904    /// Get the data channel label.
905    pub fn data_channel_label(&self) -> &str {
906        self.data_channel_label
907            .as_deref()
908            .unwrap_or(DEFAULT_WEBRTC_DATA_CHANNEL_LABEL)
909    }
910
911    /// Whether the data channel is ordered.
912    pub fn ordered(&self) -> bool {
913        self.ordered.unwrap_or(true)
914    }
915
916    /// Get the configured max retransmits. None uses WebRTC's reliable mode.
917    pub fn max_retransmits(&self) -> Option<u16> {
918        self.max_retransmits
919    }
920
921    /// Resolve signaling relays, falling back to node discovery relays.
922    pub fn signal_relays<'a>(&'a self, fallback: &'a [String]) -> Vec<String> {
923        self.signal_relays
924            .as_ref()
925            .filter(|relays| !relays.is_empty())
926            .cloned()
927            .unwrap_or_else(|| fallback.to_vec())
928    }
929
930    /// Resolve STUN servers, falling back to node discovery STUN servers.
931    pub fn stun_servers<'a>(&'a self, fallback: &'a [String]) -> Vec<String> {
932        self.stun_servers
933            .as_ref()
934            .filter(|servers| !servers.is_empty())
935            .cloned()
936            .unwrap_or_else(|| fallback.to_vec())
937    }
938}
939
940// ============================================================================
941// BLE Transport Configuration
942// ============================================================================
943
944/// Default BLE L2CAP PSM (dynamic range).
945const DEFAULT_BLE_PSM: u16 = 0x0085;
946
947/// Default BLE MTU for L2CAP CoC connections.
948const DEFAULT_BLE_MTU: u16 = 2048;
949
950/// Default maximum concurrent BLE connections.
951const DEFAULT_BLE_MAX_CONNECTIONS: usize = 7;
952
953/// Default BLE connect timeout in milliseconds.
954const DEFAULT_BLE_CONNECT_TIMEOUT_MS: u64 = 10_000;
955
956/// Default BLE probe cooldown in seconds. After probing an address
957/// (success or failure), wait this long before probing it again.
958const DEFAULT_BLE_PROBE_COOLDOWN_SECS: u64 = 30;
959
960/// BLE transport instance configuration.
961///
962/// BleConfig is always compiled (for config parsing on any platform),
963/// but the transport runtime requires Linux and the `ble` feature.
964#[derive(Debug, Clone, Default, Serialize, Deserialize)]
965#[serde(deny_unknown_fields)]
966pub struct BleConfig {
967    /// HCI adapter name (e.g., "hci0"). Required.
968    #[serde(default, skip_serializing_if = "Option::is_none")]
969    pub adapter: Option<String>,
970
971    /// L2CAP PSM for FIPS connections. Default: 0x0085 (133).
972    #[serde(default, skip_serializing_if = "Option::is_none")]
973    pub psm: Option<u16>,
974
975    /// Default MTU for BLE connections. Default: 2048.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub mtu: Option<u16>,
978
979    /// Maximum concurrent BLE connections. Default: 7.
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub max_connections: Option<usize>,
982
983    /// Outbound connect timeout in milliseconds. Default: 10000.
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub connect_timeout_ms: Option<u64>,
986
987    /// Broadcast BLE advertisements. Default: true.
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub advertise: Option<bool>,
990
991    /// Listen for BLE advertisements. Default: true.
992    #[serde(default, skip_serializing_if = "Option::is_none")]
993    pub scan: Option<bool>,
994
995    /// Auto-connect to discovered BLE peers. Default: false.
996    #[serde(default, skip_serializing_if = "Option::is_none")]
997    pub auto_connect: Option<bool>,
998
999    /// Accept incoming BLE connections. Default: true.
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub accept_connections: Option<bool>,
1002
1003    /// Probe cooldown in seconds. After probing a BLE address, wait
1004    /// this long before probing the same address again. Default: 30.
1005    #[serde(default, skip_serializing_if = "Option::is_none")]
1006    pub probe_cooldown_secs: Option<u64>,
1007}
1008
1009impl BleConfig {
1010    /// Get the adapter name. Default: "hci0".
1011    pub fn adapter(&self) -> &str {
1012        self.adapter.as_deref().unwrap_or("hci0")
1013    }
1014
1015    /// Get the L2CAP PSM. Default: 0x0085.
1016    pub fn psm(&self) -> u16 {
1017        self.psm.unwrap_or(DEFAULT_BLE_PSM)
1018    }
1019
1020    /// Get the default MTU. Default: 2048.
1021    pub fn mtu(&self) -> u16 {
1022        self.mtu.unwrap_or(DEFAULT_BLE_MTU)
1023    }
1024
1025    /// Get the maximum concurrent connections. Default: 7.
1026    pub fn max_connections(&self) -> usize {
1027        self.max_connections.unwrap_or(DEFAULT_BLE_MAX_CONNECTIONS)
1028    }
1029
1030    /// Get the connect timeout in milliseconds. Default: 10000.
1031    pub fn connect_timeout_ms(&self) -> u64 {
1032        self.connect_timeout_ms
1033            .unwrap_or(DEFAULT_BLE_CONNECT_TIMEOUT_MS)
1034    }
1035
1036    /// Whether to broadcast advertisements. Default: true.
1037    pub fn advertise(&self) -> bool {
1038        self.advertise.unwrap_or(true)
1039    }
1040
1041    /// Whether to scan for advertisements. Default: true.
1042    pub fn scan(&self) -> bool {
1043        self.scan.unwrap_or(true)
1044    }
1045
1046    /// Whether to auto-connect to discovered peers. Default: false.
1047    pub fn auto_connect(&self) -> bool {
1048        self.auto_connect.unwrap_or(false)
1049    }
1050
1051    /// Whether to accept incoming connections. Default: true.
1052    pub fn accept_connections(&self) -> bool {
1053        self.accept_connections.unwrap_or(true)
1054    }
1055
1056    /// Get the probe cooldown in seconds. Default: 30.
1057    pub fn probe_cooldown_secs(&self) -> u64 {
1058        self.probe_cooldown_secs
1059            .unwrap_or(DEFAULT_BLE_PROBE_COOLDOWN_SECS)
1060    }
1061}
1062
1063// ============================================================================
1064// TransportsConfig
1065// ============================================================================
1066
1067/// Transports configuration section.
1068///
1069/// Each transport type can have either a single instance (config directly
1070/// under the type name) or multiple named instances.
1071#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1072pub struct TransportsConfig {
1073    /// UDP transport instances.
1074    #[serde(default, skip_serializing_if = "is_transport_empty")]
1075    pub udp: TransportInstances<UdpConfig>,
1076
1077    /// In-memory simulated transport instances.
1078    #[cfg(feature = "sim-transport")]
1079    #[serde(default, skip_serializing_if = "is_transport_empty")]
1080    pub sim: TransportInstances<SimTransportConfig>,
1081
1082    /// Ethernet transport instances.
1083    #[serde(default, skip_serializing_if = "is_transport_empty")]
1084    pub ethernet: TransportInstances<EthernetConfig>,
1085
1086    /// TCP transport instances.
1087    #[serde(default, skip_serializing_if = "is_transport_empty")]
1088    pub tcp: TransportInstances<TcpConfig>,
1089
1090    /// Tor transport instances.
1091    #[serde(default, skip_serializing_if = "is_transport_empty")]
1092    pub tor: TransportInstances<TorConfig>,
1093
1094    /// WebRTC transport instances.
1095    #[serde(default, skip_serializing_if = "is_transport_empty")]
1096    pub webrtc: TransportInstances<WebRtcConfig>,
1097
1098    /// BLE transport instances.
1099    #[serde(default, skip_serializing_if = "is_transport_empty")]
1100    pub ble: TransportInstances<BleConfig>,
1101}
1102
1103/// Helper for skip_serializing_if on TransportInstances.
1104fn is_transport_empty<T>(instances: &TransportInstances<T>) -> bool {
1105    instances.is_empty()
1106}
1107
1108impl TransportsConfig {
1109    /// Check if any transports are configured.
1110    pub fn is_empty(&self) -> bool {
1111        self.udp.is_empty()
1112            && {
1113                #[cfg(feature = "sim-transport")]
1114                {
1115                    self.sim.is_empty()
1116                }
1117                #[cfg(not(feature = "sim-transport"))]
1118                {
1119                    true
1120                }
1121            }
1122            && self.ethernet.is_empty()
1123            && self.tcp.is_empty()
1124            && self.tor.is_empty()
1125            && self.webrtc.is_empty()
1126            && self.ble.is_empty()
1127    }
1128
1129    /// Merge another TransportsConfig into this one.
1130    ///
1131    /// Non-empty transport sections from `other` replace those in `self`.
1132    pub fn merge(&mut self, other: TransportsConfig) {
1133        if !other.udp.is_empty() {
1134            self.udp = other.udp;
1135        }
1136        #[cfg(feature = "sim-transport")]
1137        if !other.sim.is_empty() {
1138            self.sim = other.sim;
1139        }
1140        if !other.ethernet.is_empty() {
1141            self.ethernet = other.ethernet;
1142        }
1143        if !other.tcp.is_empty() {
1144            self.tcp = other.tcp;
1145        }
1146        if !other.tor.is_empty() {
1147            self.tor = other.tor;
1148        }
1149        if !other.webrtc.is_empty() {
1150            self.webrtc = other.webrtc;
1151        }
1152        if !other.ble.is_empty() {
1153            self.ble = other.ble;
1154        }
1155    }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161
1162    #[test]
1163    fn parse_external_addr_accepts_bare_ipv4_with_appended_bind_port() {
1164        let sa = parse_external_advert_addr("198.51.100.1", 2121).unwrap();
1165        assert_eq!(sa.to_string(), "198.51.100.1:2121");
1166    }
1167
1168    #[test]
1169    fn parse_external_addr_accepts_full_ipv4_socket_addr() {
1170        let sa = parse_external_advert_addr("198.51.100.1:443", 2121).unwrap();
1171        assert_eq!(sa.to_string(), "198.51.100.1:443");
1172        // Explicit port wins over the bind port we passed in.
1173    }
1174
1175    #[test]
1176    fn parse_external_addr_accepts_bare_ipv6_with_appended_bind_port() {
1177        let sa = parse_external_advert_addr("2001:db8::1", 443).unwrap();
1178        assert_eq!(sa.to_string(), "[2001:db8::1]:443");
1179    }
1180
1181    #[test]
1182    fn parse_external_addr_accepts_bracketed_ipv6_with_explicit_port() {
1183        let sa = parse_external_advert_addr("[2001:db8::1]:8443", 443).unwrap();
1184        assert_eq!(sa.to_string(), "[2001:db8::1]:8443");
1185    }
1186
1187    #[test]
1188    fn parse_external_addr_rejects_garbage() {
1189        assert!(parse_external_advert_addr("not-an-ip", 443).is_none());
1190        assert!(parse_external_advert_addr("", 443).is_none());
1191    }
1192
1193    #[test]
1194    fn udp_external_advert_addr_combines_with_bind_port_default() {
1195        let cfg = UdpConfig {
1196            external_addr: Some("198.51.100.1".to_string()),
1197            ..UdpConfig::default()
1198        };
1199        // bind_addr unset, so default DEFAULT_UDP_BIND_ADDR (0.0.0.0:2121) applies.
1200        let sa = cfg.external_advert_addr().unwrap();
1201        assert_eq!(sa.to_string(), "198.51.100.1:2121");
1202    }
1203
1204    #[test]
1205    fn udp_external_advert_addr_with_explicit_full_socket_addr_overrides_bind_port() {
1206        let cfg = UdpConfig {
1207            bind_addr: Some("0.0.0.0:2121".to_string()),
1208            external_addr: Some("198.51.100.1:9999".to_string()),
1209            ..UdpConfig::default()
1210        };
1211        let sa = cfg.external_advert_addr().unwrap();
1212        assert_eq!(sa.to_string(), "198.51.100.1:9999");
1213    }
1214
1215    #[test]
1216    fn udp_external_advert_addr_returns_none_when_unset() {
1217        let cfg = UdpConfig::default();
1218        assert!(cfg.external_advert_addr().is_none());
1219    }
1220
1221    #[test]
1222    fn tcp_external_advert_addr_requires_bind_port() {
1223        let cfg = TcpConfig {
1224            external_addr: Some("198.51.100.1".to_string()),
1225            ..TcpConfig::default()
1226        };
1227        // bind_addr unset → no port to combine with → None.
1228        assert!(cfg.external_advert_addr().is_none());
1229
1230        let cfg = TcpConfig {
1231            bind_addr: Some("0.0.0.0:443".to_string()),
1232            external_addr: Some("198.51.100.1".to_string()),
1233            ..TcpConfig::default()
1234        };
1235        let sa = cfg.external_advert_addr().unwrap();
1236        assert_eq!(sa.to_string(), "198.51.100.1:443");
1237    }
1238
1239    #[test]
1240    fn tcp_external_advert_addr_with_full_socket_addr_independent_of_bind() {
1241        let cfg = TcpConfig {
1242            bind_addr: Some("0.0.0.0:443".to_string()),
1243            external_addr: Some("198.51.100.1:8443".to_string()),
1244            ..TcpConfig::default()
1245        };
1246        let sa = cfg.external_advert_addr().unwrap();
1247        assert_eq!(sa.to_string(), "198.51.100.1:8443");
1248    }
1249
1250    #[test]
1251    fn parse_bind_port_extracts_from_socket_addr_strings() {
1252        assert_eq!(parse_bind_port("0.0.0.0:2121"), Some(2121));
1253        assert_eq!(parse_bind_port("[::]:443"), Some(443));
1254        assert_eq!(parse_bind_port("not-a-socket-addr"), None);
1255    }
1256}