Skip to main content

fips_core/node/lifecycle/
runtime.rs

1use super::*;
2
3const SHUTDOWN_DISCONNECT_BUDGET: Duration = Duration::from_millis(100);
4const SHUTDOWN_FORWARDING_DRAIN_BUDGET: Duration = Duration::from_millis(100);
5
6async fn disconnect_notifications_within_shutdown_budget<F>(notifications: F) -> bool
7where
8    F: std::future::Future<Output = ()>,
9{
10    tokio::time::timeout(SHUTDOWN_DISCONNECT_BUDGET, notifications)
11        .await
12        .is_ok()
13}
14
15impl Node {
16    // === State Transitions ===
17
18    /// Start the node.
19    ///
20    /// Initializes the TUN interface (if configured), spawns I/O threads,
21    /// and transitions to the Running state.
22    pub async fn start(&mut self) -> Result<(), NodeError> {
23        if !self.state.can_start() {
24            return Err(NodeError::AlreadyStarted);
25        }
26        self.state = NodeState::Starting;
27
28        // Create packet channel for transport -> Node communication
29        let packet_buffer_size = self.config.node.buffers.packet_channel;
30        let (mut packet_tx, packet_rx) = packet_channel(packet_buffer_size);
31        self.dataplane_fast_ingress_rx = Some(
32            self.dataplane
33                .attach_established_fast_ingress(&mut packet_tx),
34        );
35        self.packet_tx = Some(packet_tx.clone());
36        self.packet_rx = Some(packet_rx);
37
38        // The dedicated loopback socket is both the sticky rendezvous bind and
39        // its ordinary authenticated FIPS carrier. Start it before potentially
40        // slow public discovery, but never let it alter other transports.
41        self.start_local_rendezvous(&packet_tx).await;
42
43        // Initialize configured transports (before TUN and public discovery).
44        let transport_handles = self.create_transports(&packet_tx).await;
45
46        for mut handle in transport_handles {
47            let transport_id = handle.transport_id();
48            let transport_type = handle.transport_type().name;
49            let name = handle.name().map(|s| s.to_string());
50
51            match handle.start().await {
52                Ok(()) => {
53                    self.transports.insert(transport_id, handle);
54                }
55                Err(e) => {
56                    if let Some(ref n) = name {
57                        warn!(transport_type, name = %n, error = %e, "Transport failed to start");
58                    } else {
59                        warn!(transport_type, error = %e, "Transport failed to start");
60                    }
61                }
62            }
63        }
64
65        if !self.transports.is_empty() {
66            info!(count = self.transports.len(), "Transports initialized");
67        }
68
69        if self.config.node.discovery.nostr.enabled {
70            match NostrDiscovery::start(&self.identity, self.config.node.discovery.nostr.clone())
71                .await
72            {
73                Ok(runtime) => {
74                    if let Err(err) = self.refresh_overlay_advert(&runtime).await {
75                        warn!(error = %err, "Failed to publish initial Nostr overlay advert");
76                    }
77                    self.nostr_discovery = Some(runtime);
78                    self.nostr_discovery_started_at_ms = Some(Self::now_ms());
79                    info!("Nostr overlay discovery enabled");
80                }
81                Err(err) => {
82                    warn!(error = %err, "Failed to start Nostr overlay discovery");
83                }
84            }
85        }
86
87        // mDNS / DNS-SD LAN discovery. Independent of Nostr — runs even
88        // when Nostr is disabled, since it gives us sub-second pairing
89        // on the same link without any relay or NAT-traversal roundtrip.
90        if self.config.node.discovery.lan.enabled {
91            let advertised_udp_port = self
92                .transports
93                .iter()
94                .filter(|(id, h)| h.is_operational() && !self.is_local_rendezvous_transport(id))
95                .map(|(_, handle)| handle)
96                .filter(|h| h.transport_type().name == "udp")
97                .find_map(|h| h.local_addr().map(|addr| addr.port()))
98                .unwrap_or(0);
99            let scope = self.lan_discovery_scope();
100            match crate::discovery::lan::LanDiscovery::start(
101                &self.identity,
102                scope,
103                advertised_udp_port,
104                self.config.node.discovery.lan.clone(),
105            )
106            .await
107            {
108                Ok(runtime) => {
109                    self.lan_discovery = Some(runtime);
110                    info!("LAN mDNS discovery enabled");
111                }
112                Err(err) => {
113                    debug!(error = %err, "LAN mDNS discovery not started");
114                }
115            }
116        }
117
118        // Connect to static peers before TUN is active
119        // This allows handshake messages to be sent before we start accepting packets
120        self.initiate_peer_connections().await;
121
122        // Initialize TUN interface last, after transports and peers are ready
123        if self.config.tun.enabled {
124            let address = *self.identity.address();
125            let mut tun_config = self.config.tun.clone();
126            if tun_config.mtu.is_none() {
127                tun_config.mtu = Some(self.runtime_tun_mtu());
128            }
129            match TunDevice::create(&tun_config, address).await {
130                Ok(device) => {
131                    let mtu = device.mtu();
132                    let name = device.name().to_string();
133                    let our_addr = *device.address();
134
135                    info!("TUN device active:");
136                    info!("     name: {}", name);
137                    info!("  address: {}", device.address());
138                    info!("      mtu: {}", mtu);
139
140                    // Calculate max MSS for TCP clamping
141                    let effective_mtu = self.effective_ipv6_mtu();
142                    let max_mss = effective_mtu.saturating_sub(40).saturating_sub(20); // IPv6 + TCP headers
143
144                    info!("effective MTU: {} bytes", effective_mtu);
145                    debug!("   max TCP MSS: {} bytes", max_mss);
146
147                    // On macOS, create a shutdown pipe. Writing to it unblocks the
148                    // reader thread's select() loop without closing the TUN fd
149                    // (which would cause a double-close when TunDevice drops).
150                    #[cfg(target_os = "macos")]
151                    let (shutdown_read_fd, shutdown_write_fd) = {
152                        let mut fds = [0i32; 2];
153                        if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
154                            return Err(NodeError::Tun(crate::upper::tun::TunError::Configure(
155                                "failed to create shutdown pipe".into(),
156                            )));
157                        }
158                        (fds[0], fds[1])
159                    };
160
161                    // Create writer (dups the fd for independent write access).
162                    // Pass path_mtu_lookup so inbound SYN-ACK clamp can read
163                    // per-destination path MTU learned via discovery.
164                    let (writer, tun_tx) =
165                        device.create_writer(max_mss, self.path_mtu_lookup.clone())?;
166
167                    // Spawn writer thread
168                    let writer_handle = thread::spawn(move || {
169                        writer.run();
170                    });
171
172                    // Clone tun_tx for the reader
173                    let reader_tun_tx = tun_tx.clone();
174
175                    // Create outbound channel for TUN reader → Node
176                    let tun_channel_size = self.config.node.buffers.tun_channel;
177                    let (outbound_tx, outbound_rx) =
178                        crate::upper::tun::tun_outbound_channel(tun_channel_size);
179
180                    // Spawn reader thread
181                    let transport_mtu = self.transport_mtu();
182                    let path_mtu_lookup = self.path_mtu_lookup.clone();
183                    let reader_runtime = TunReaderRuntime {
184                        device,
185                        mtu,
186                        our_addr,
187                        tun_tx: reader_tun_tx,
188                        outbound_tx,
189                        transport_mtu,
190                        path_mtu_lookup,
191                    };
192                    #[cfg(target_os = "macos")]
193                    let reader_handle = thread::spawn(move || {
194                        run_tun_reader(reader_runtime, shutdown_read_fd);
195                    });
196                    #[cfg(not(target_os = "macos"))]
197                    let reader_handle = thread::spawn(move || {
198                        run_tun_reader(reader_runtime);
199                    });
200
201                    self.tun_state = TunState::Active;
202                    self.tun_name = Some(name);
203                    self.tun_tx = Some(tun_tx);
204                    self.tun_outbound_rx = Some(outbound_rx);
205                    self.tun_reader_handle = Some(reader_handle);
206                    self.tun_writer_handle = Some(writer_handle);
207                    #[cfg(target_os = "macos")]
208                    {
209                        self.tun_shutdown_fd = Some(shutdown_write_fd);
210                    }
211                }
212                Err(e) => {
213                    self.tun_state = TunState::Failed;
214                    warn!(error = %e, "Failed to initialize TUN, continuing without it");
215                }
216            }
217        }
218
219        // Initialize DNS responder (independent of TUN).
220        //
221        // Default bind_addr is "::1" (IPv6 loopback). The shipped
222        // fips-dns-setup configures systemd-resolved via a global
223        // /etc/systemd/resolved.conf.d/fips.conf drop-in pointing at
224        // [::1]:5354, which sidesteps a Linux IPV6_PKTINFO behaviour
225        // where self-destined traffic to fips0's address is attributed
226        // to fips0 in PKTINFO and gets silently dropped by the
227        // mesh-interface filter in src/upper/dns.rs.
228        //
229        // For mesh-reachable resolution (rare), set bind_addr: "::"
230        // in fips.yaml. The mesh-interface filter remains active to
231        // prevent hosts-file alias enumeration in that mode.
232        // `IPV6_V6ONLY=0` is set explicitly so IPv4 clients on
233        // 127.0.0.1 still reach us regardless of kernel sysctl
234        // defaults — but only when bind is on a wildcard / IPv6 path.
235        if self.config.dns.enabled {
236            let addr_str = self.config.dns.bind_addr();
237            match addr_str.parse::<std::net::IpAddr>() {
238                Ok(ip) => {
239                    let bind = std::net::SocketAddr::new(ip, self.config.dns.port());
240                    match Self::bind_dns_socket(bind) {
241                        Ok(socket) => {
242                            let dns_channel_size = self.config.node.buffers.dns_channel;
243                            let (identity_tx, identity_rx) =
244                                tokio::sync::mpsc::channel(dns_channel_size);
245                            let dns_ttl = self.config.dns.ttl();
246                            let base_hosts = crate::upper::hosts::HostMap::from_peer_configs(
247                                self.config.peers(),
248                            );
249                            let reloader = if self.config.node.system_files_enabled {
250                                let hosts_path = std::path::PathBuf::from(
251                                    crate::upper::hosts::DEFAULT_HOSTS_PATH,
252                                );
253                                crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path)
254                            } else {
255                                crate::upper::hosts::HostMapReloader::memory_only(base_hosts)
256                            };
257                            // Resolve the TUN ifindex so the responder can
258                            // drop queries arriving on the mesh interface
259                            // (fips0). Without this, the `::` bind exposes
260                            // /etc/fips/hosts alias probing to any mesh peer.
261                            // When TUN isn't enabled or the name can't be
262                            // resolved, `None` disables the filter (there
263                            // is no mesh surface to defend anyway).
264                            let mesh_ifindex = Self::lookup_mesh_ifindex(self.config.tun.name());
265                            info!(
266                                bind = %bind,
267                                hosts = reloader.hosts().len(),
268                                mesh_ifindex = ?mesh_ifindex,
269                                "DNS responder started for .fips domain (auto-reload enabled)"
270                            );
271                            let handle = tokio::spawn(crate::upper::dns::run_dns_responder(
272                                socket,
273                                identity_tx,
274                                dns_ttl,
275                                reloader,
276                                mesh_ifindex,
277                            ));
278                            self.dns_identity_rx = Some(identity_rx);
279                            self.dns_task = Some(handle);
280                        }
281                        Err(e) => {
282                            warn!(bind = %bind, error = %e, "Failed to start DNS responder");
283                        }
284                    }
285                }
286                Err(e) => {
287                    warn!(addr = %addr_str, error = %e, "Invalid dns.bind_addr; DNS responder not started");
288                }
289            }
290        }
291
292        self.state = NodeState::Running;
293        info!("Node started:");
294        info!("       state: {}", self.state);
295        info!("  transports: {}", self.transports.len());
296        info!(" connections: {}", self.peers.connection_len());
297        Ok(())
298    }
299
300    /// Bind a UDP socket for the DNS responder.
301    ///
302    /// For IPv6 binds (including `::`), sets `IPV6_V6ONLY=0` so the socket
303    /// also accepts IPv4-mapped addresses. This guarantees dual-stack
304    /// delivery regardless of `net.ipv6.bindv6only` sysctl on the host —
305    /// v4 clients on 127.0.0.1 and v6 clients on the fips0 address both
306    /// land on the same socket.
307    ///
308    /// Also enables `IPV6_RECVPKTINFO` on IPv6 sockets so the responder
309    /// can learn the arrival interface per packet. The responder uses that
310    /// to drop queries arriving on the mesh TUN, closing the hosts-file
311    /// probing side-channel created by the `::` bind.
312    pub(super) fn bind_dns_socket(
313        addr: std::net::SocketAddr,
314    ) -> Result<tokio::net::UdpSocket, std::io::Error> {
315        use socket2::{Domain, Protocol, Socket, Type};
316        let domain = if addr.is_ipv4() {
317            Domain::IPV4
318        } else {
319            Domain::IPV6
320        };
321        let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
322        if addr.is_ipv6() {
323            sock.set_only_v6(false)?;
324            #[cfg(unix)]
325            Self::set_recv_pktinfo_v6(&sock)?;
326        }
327        sock.set_nonblocking(true)?;
328        sock.bind(&addr.into())?;
329        tokio::net::UdpSocket::from_std(sock.into())
330    }
331
332    /// Enable `IPV6_RECVPKTINFO` on an IPv6 UDP socket.
333    ///
334    /// After this setsockopt, each `recvmsg()` call on the socket receives
335    /// an `IPV6_PKTINFO` control message containing the arrival interface
336    /// index, which the DNS responder uses for its mesh-interface filter.
337    #[cfg(unix)]
338    pub(super) fn set_recv_pktinfo_v6(sock: &socket2::Socket) -> Result<(), std::io::Error> {
339        use std::os::fd::AsRawFd;
340        let enable: libc::c_int = 1;
341        let ret = unsafe {
342            libc::setsockopt(
343                sock.as_raw_fd(),
344                libc::IPPROTO_IPV6,
345                libc::IPV6_RECVPKTINFO,
346                &enable as *const _ as *const libc::c_void,
347                std::mem::size_of::<libc::c_int>() as libc::socklen_t,
348            )
349        };
350        if ret < 0 {
351            return Err(std::io::Error::last_os_error());
352        }
353        Ok(())
354    }
355
356    /// Resolve the mesh TUN interface index by name.
357    ///
358    /// Returns `None` if the interface does not exist (e.g. TUN disabled
359    /// or not yet created). A `None` result disables the DNS responder's
360    /// mesh-interface filter — safe, because if there is no fips0 there
361    /// is no mesh exposure to defend against.
362    pub(super) fn lookup_mesh_ifindex(name: &str) -> Option<u32> {
363        #[cfg(unix)]
364        {
365            let c_name = std::ffi::CString::new(name).ok()?;
366            let idx = unsafe { libc::if_nametoindex(c_name.as_ptr()) };
367            if idx == 0 { None } else { Some(idx) }
368        }
369        #[cfg(not(unix))]
370        {
371            let _ = name;
372            None
373        }
374    }
375
376    /// Stop the node.
377    ///
378    /// Shuts down TUN interface, stops I/O threads, and transitions to
379    /// the Stopped state.
380    pub async fn stop(&mut self) -> Result<(), NodeError> {
381        if !self.state.can_stop() {
382            return Err(NodeError::NotStarted);
383        }
384        self.state = NodeState::Stopping;
385        info!(state = %self.state, "Node stopping");
386
387        if tokio::time::timeout(
388            SHUTDOWN_FORWARDING_DRAIN_BUDGET,
389            self.drain_deferred_session_forwards(),
390        )
391        .await
392        .is_err()
393        {
394            let aborted = self
395                .abort_deferred_session_forwards("node stopped before forwarding receipt")
396                .await;
397            warn!(aborted, "Forwarding drain budget expired during shutdown");
398        }
399
400        // Stop DNS responder
401        if let Some(handle) = self.dns_task.take() {
402            handle.abort();
403            debug!("DNS responder stopped");
404        }
405
406        // Send disconnect notifications to all active peers before closing transports
407        if !disconnect_notifications_within_shutdown_budget(
408            self.send_disconnect_to_all_peers(DisconnectReason::Shutdown),
409        )
410        .await
411        {
412            warn!(
413                budget_ms = SHUTDOWN_DISCONNECT_BUDGET.as_millis(),
414                "Disconnect notification budget expired; continuing shutdown"
415            );
416        }
417
418        // Stop Nostr overlay discovery background work and withdraw any advert.
419        if let Some(bootstrap) = self.nostr_discovery.take()
420            && let Err(e) = bootstrap.shutdown().await
421        {
422            warn!(error = %e, "Failed to shutdown Nostr overlay discovery");
423        }
424
425        // Tear down LAN mDNS responder + browser. Best-effort: the
426        // OS will eventually time the advert out via its TTL even if
427        // we don't get a clean unregister out before the daemon exits.
428        if let Some(lan) = self.lan_discovery.take() {
429            lan.shutdown().await;
430        }
431
432        // Shutdown configured transports first. The fixed local socket is the
433        // OS rendezvous lock and remains available until all other network
434        // work and disconnect notices have drained.
435        let local_transport_id = self.local_rendezvous_transport_id();
436        let mut transport_ids: Vec<_> = self.transports.keys().cloned().collect();
437        transport_ids.sort_by_key(|id| (Some(*id) == local_transport_id, id.as_u32()));
438        for transport_id in transport_ids {
439            if let Some(mut handle) = self.transports.remove(&transport_id) {
440                let transport_type = handle.transport_type().name;
441                match handle.stop().await {
442                    Ok(()) => {
443                        info!(transport_id = %transport_id, transport_type, "Transport stopped");
444                    }
445                    Err(e) => {
446                        warn!(
447                            transport_id = %transport_id,
448                            transport_type,
449                            error = %e,
450                            "Transport stop failed"
451                        );
452                    }
453                }
454            }
455        }
456
457        // Drop packet channels
458        self.packet_tx.take();
459        self.packet_rx.take();
460
461        // Shutdown TUN interface
462        if let Some(name) = self.tun_name.take() {
463            info!(name = %name, "Shutting down TUN interface");
464
465            // Drop the tun_tx to signal the writer to stop
466            self.tun_tx.take();
467
468            // Delete the interface (on Linux, causes reader to get EFAULT)
469            if let Err(e) = shutdown_tun_interface(&name).await {
470                warn!(name = %name, error = %e, "Failed to shutdown TUN interface");
471            }
472
473            // On macOS, signal the reader thread to exit by writing to the
474            // shutdown pipe. The reader's select() will wake up and break.
475            #[cfg(target_os = "macos")]
476            if let Some(fd) = self.tun_shutdown_fd.take() {
477                unsafe {
478                    libc::write(fd, b"x".as_ptr() as *const libc::c_void, 1);
479                    libc::close(fd);
480                }
481            }
482
483            // Wait for threads to finish
484            if let Some(handle) = self.tun_reader_handle.take() {
485                let _ = handle.join();
486            }
487            if let Some(handle) = self.tun_writer_handle.take() {
488                let _ = handle.join();
489            }
490
491            self.tun_state = TunState::Disabled;
492        }
493
494        self.state = NodeState::Stopped;
495        info!(state = %self.state, "Node stopped");
496        Ok(())
497    }
498
499    /// Send disconnect notifications to all active peers.
500    ///
501    /// Best-effort: send failures are logged and ignored since the transport
502    /// may already be degraded. This runs before transports are shut down.
503    pub(super) async fn send_disconnect_to_all_peers(&mut self, reason: DisconnectReason) {
504        // Collect node_addrs to avoid borrow conflict with send helper
505        let peer_addrs: Vec<NodeAddr> = self
506            .peers
507            .iter()
508            .filter(|(_, peer)| peer.can_send() && peer.has_session())
509            .map(|(addr, _)| *addr)
510            .collect();
511
512        if peer_addrs.is_empty() {
513            debug!(
514                total_peers = self.peers.len(),
515                "No sendable peers for disconnect notification"
516            );
517            return;
518        }
519
520        let mut sent = 0usize;
521        for node_addr in &peer_addrs {
522            if self.send_disconnect_to_peer(node_addr, reason).await {
523                sent += 1;
524            }
525        }
526
527        info!(sent, total = peer_addrs.len(), reason = %reason, "Sent disconnect notifications");
528    }
529
530    /// Send a Disconnect notification to one peer, swallowing transport failures.
531    pub(super) async fn send_disconnect_to_peer(
532        &mut self,
533        node_addr: &NodeAddr,
534        reason: DisconnectReason,
535    ) -> bool {
536        let plaintext = Disconnect::new(reason).encode();
537        match self
538            .send_dataplane_fmp_link_plaintext(node_addr, &plaintext, false)
539            .await
540        {
541            Ok(()) => true,
542            Err(e) => {
543                debug!(
544                    peer = %self.peer_display_name(node_addr),
545                    error = %e,
546                    "Failed to send disconnect (transport may be down)"
547                );
548                false
549            }
550        }
551    }
552}
553
554#[cfg(test)]
555mod deadline_tests {
556    use super::*;
557
558    #[tokio::test(start_paused = true)]
559    async fn disconnect_notifications_share_one_shutdown_budget() {
560        let started = tokio::time::Instant::now();
561        let completed =
562            disconnect_notifications_within_shutdown_budget(std::future::pending::<()>()).await;
563
564        assert!(!completed);
565        assert_eq!(started.elapsed(), SHUTDOWN_DISCONNECT_BUDGET);
566    }
567}