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