Skip to main content

fips_core/node/lifecycle/
runtime.rs

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