Skip to main content

fips_core/node/
core_impl.rs

1use super::*;
2
3impl Node {
4    /// Create a new node from configuration.
5    pub fn new(config: Config) -> Result<Self, NodeError> {
6        config.validate()?;
7        let identity = config.create_identity()?;
8        let node_addr = *identity.node_addr();
9        let is_leaf_only = config.is_leaf_only();
10
11        let (decrypt_fallback_tx, decrypt_fallback_rx) =
12            decrypt_worker::decrypt_worker_fallback_channels();
13        let decrypt_fallback_rx = Some(decrypt_fallback_rx);
14
15        let mut startup_epoch = [0u8; 8];
16        rand::rng().fill_bytes(&mut startup_epoch);
17
18        let mut bloom_state = if is_leaf_only {
19            BloomState::leaf_only(node_addr)
20        } else {
21            BloomState::new(node_addr)
22        };
23        bloom_state.set_update_debounce_ms(config.node.bloom.update_debounce_ms);
24
25        let tun_state = if config.tun.enabled {
26            TunState::Configured
27        } else {
28            TunState::Disabled
29        };
30
31        // Initialize tree state with signed self-declaration
32        let mut tree_state = TreeState::new(node_addr);
33        tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis);
34        tree_state.set_hold_down(config.node.tree.hold_down_secs);
35        tree_state.set_flap_dampening(
36            config.node.tree.flap_threshold,
37            config.node.tree.flap_window_secs,
38            config.node.tree.flap_dampening_secs,
39        );
40        tree_state
41            .sign_declaration(&identity)
42            .expect("signing own declaration should never fail");
43
44        let coord_cache = CoordCache::new(
45            config.node.cache.coord_size,
46            config.node.cache.coord_ttl_secs * 1000,
47        );
48        let rl = &config.node.rate_limit;
49        let msg1_rate_limiter = HandshakeRateLimiter::with_params(
50            rate_limit::TokenBucket::with_params(rl.handshake_burst, rl.handshake_rate),
51            config.node.limits.max_pending_inbound,
52        );
53
54        let max_connections = config.node.limits.max_connections;
55        let max_peers = config.node.limits.max_peers;
56        let max_links = config.node.limits.max_links;
57        let coords_response_interval_ms = config.node.session.coords_response_interval_ms;
58        let backoff_base_secs = config.node.discovery.backoff_base_secs;
59        let backoff_max_secs = config.node.discovery.backoff_max_secs;
60        let forward_min_interval_secs = config.node.discovery.forward_min_interval_secs;
61
62        let (host_map, peer_acl) = Self::host_map_and_peer_acl(&config);
63        let configured_peer_send_weights = ConfiguredPeerSendWeights::from_config(&config);
64
65        Ok(Self {
66            identity,
67            startup_epoch,
68            started_at: std::time::Instant::now(),
69            config,
70            state: NodeState::Created,
71            is_leaf_only,
72            tree_state,
73            bloom_state,
74            coord_cache,
75            learned_routes: LearnedRouteTable::default(),
76            session_direct_degradation: SessionDirectDegradation::default(),
77            recent_requests: RecentDiscoveryRequests::default(),
78            transports: HashMap::new(),
79            transport_drops: TransportDropTracker::default(),
80            transport_socket_drops: TransportDropTracker::default(),
81            transport_namespace_drops: TransportDropTracker::default(),
82            links: LinkRegistry::default(),
83            packet_tx: None,
84            packet_rx: None,
85            peers: PeerLifecycleRegistry::default(),
86            sessions: SessionRegistry::default(),
87            identity_cache: IdentityCache::default(),
88            pending_session_traffic: PendingSessionTrafficQueues::default(),
89            pending_lookups: handlers::discovery::PendingDiscoveryLookups::default(),
90            max_connections,
91            max_peers,
92            max_links,
93            next_link_id: 1,
94            next_transport_id: 1,
95            stats: stats::NodeStats::new(),
96            stats_history: stats_history::StatsHistory::new(),
97            tun_state,
98            tun_name: None,
99            tun_tx: None,
100            tun_outbound_rx: None,
101            external_packet_tx: None,
102            endpoint_priority_command_rx: None,
103            endpoint_command_rx: None,
104            endpoint_events: EndpointEventRuntime::default(),
105            endpoint_bulk_feedback_rx: None,
106            #[cfg(unix)]
107            endpoint_bulk_send_runtime: None,
108            encrypt_workers: None,
109            decrypt_workers: None,
110            decrypt_fallback_tx,
111            decrypt_fallback_rx,
112            tun_reader_handle: None,
113            tun_writer_handle: None,
114            #[cfg(target_os = "macos")]
115            tun_shutdown_fd: None,
116            dns_identity_rx: None,
117            dns_task: None,
118            index_allocator: IndexAllocator::new(),
119            pending_outbound: PendingOutboundHandshakes::default(),
120            msg1_rate_limiter,
121            icmp_rate_limiter: IcmpRateLimiter::new(),
122            routing_error_rate_limiter: RoutingErrorRateLimiter::new(),
123            coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval(
124                std::time::Duration::from_millis(coords_response_interval_ms),
125            ),
126            discovery_backoff: DiscoveryBackoff::with_params(backoff_base_secs, backoff_max_secs),
127            discovery_forward_limiter: DiscoveryForwardRateLimiter::with_interval(
128                std::time::Duration::from_secs(forward_min_interval_secs),
129            ),
130            pending_connects: Vec::new(),
131            retry_pending: retry::PendingRouteRetries::default(),
132            nostr_discovery: None,
133            nostr_discovery_started_at_ms: None,
134            lan_discovery: None,
135            local_instance_registry: None,
136            local_instance_started_at_ms: None,
137            last_local_instance_publish_ms: None,
138            last_local_instance_scan_ms: None,
139            startup_open_discovery_sweep_done: false,
140            bootstrap_transports: BootstrapTransports::default(),
141            discovery_fallback_transit: DiscoveryFallbackTransit::default(),
142            last_parent_reeval: None,
143            last_congestion_log: None,
144            estimated_mesh_size: None,
145            last_mesh_size_log: None,
146            last_self_warn: None,
147            local_send_failures: LocalSendFailures::default(),
148            last_rx_loop_maintenance_timeout_at: None,
149            peer_aliases: HashMap::new(),
150            configured_peer_send_weights,
151            peer_acl,
152            host_map,
153            path_mtu_lookup: Arc::new(std::sync::RwLock::new(HashMap::new())),
154        })
155    }
156
157    /// Create a node with a specific identity.
158    ///
159    /// This constructor validates cross-field config invariants before
160    /// constructing the node, same as [`Node::new`].
161    pub fn with_identity(identity: Identity, config: Config) -> Result<Self, NodeError> {
162        config.validate()?;
163        let node_addr = *identity.node_addr();
164
165        let (decrypt_fallback_tx, decrypt_fallback_rx) =
166            decrypt_worker::decrypt_worker_fallback_channels();
167        let decrypt_fallback_rx = Some(decrypt_fallback_rx);
168
169        let mut startup_epoch = [0u8; 8];
170        rand::rng().fill_bytes(&mut startup_epoch);
171
172        let tun_state = if config.tun.enabled {
173            TunState::Configured
174        } else {
175            TunState::Disabled
176        };
177
178        // Initialize tree state with signed self-declaration
179        let mut tree_state = TreeState::new(node_addr);
180        tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis);
181        tree_state.set_hold_down(config.node.tree.hold_down_secs);
182        tree_state.set_flap_dampening(
183            config.node.tree.flap_threshold,
184            config.node.tree.flap_window_secs,
185            config.node.tree.flap_dampening_secs,
186        );
187        tree_state
188            .sign_declaration(&identity)
189            .expect("signing own declaration should never fail");
190
191        let mut bloom_state = BloomState::new(node_addr);
192        bloom_state.set_update_debounce_ms(config.node.bloom.update_debounce_ms);
193
194        let coord_cache = CoordCache::new(
195            config.node.cache.coord_size,
196            config.node.cache.coord_ttl_secs * 1000,
197        );
198        let rl = &config.node.rate_limit;
199        let msg1_rate_limiter = HandshakeRateLimiter::with_params(
200            rate_limit::TokenBucket::with_params(rl.handshake_burst, rl.handshake_rate),
201            config.node.limits.max_pending_inbound,
202        );
203
204        let max_connections = config.node.limits.max_connections;
205        let max_peers = config.node.limits.max_peers;
206        let max_links = config.node.limits.max_links;
207        let coords_response_interval_ms = config.node.session.coords_response_interval_ms;
208
209        let (host_map, peer_acl) = Self::host_map_and_peer_acl(&config);
210        let configured_peer_send_weights = ConfiguredPeerSendWeights::from_config(&config);
211
212        Ok(Self {
213            identity,
214            startup_epoch,
215            started_at: std::time::Instant::now(),
216            config,
217            state: NodeState::Created,
218            is_leaf_only: false,
219            tree_state,
220            bloom_state,
221            coord_cache,
222            learned_routes: LearnedRouteTable::default(),
223            session_direct_degradation: SessionDirectDegradation::default(),
224            recent_requests: RecentDiscoveryRequests::default(),
225            transports: HashMap::new(),
226            transport_drops: TransportDropTracker::default(),
227            transport_socket_drops: TransportDropTracker::default(),
228            transport_namespace_drops: TransportDropTracker::default(),
229            links: LinkRegistry::default(),
230            packet_tx: None,
231            packet_rx: None,
232            peers: PeerLifecycleRegistry::default(),
233            sessions: SessionRegistry::default(),
234            identity_cache: IdentityCache::default(),
235            pending_session_traffic: PendingSessionTrafficQueues::default(),
236            pending_lookups: handlers::discovery::PendingDiscoveryLookups::default(),
237            max_connections,
238            max_peers,
239            max_links,
240            next_link_id: 1,
241            next_transport_id: 1,
242            stats: stats::NodeStats::new(),
243            stats_history: stats_history::StatsHistory::new(),
244            tun_state,
245            tun_name: None,
246            tun_tx: None,
247            tun_outbound_rx: None,
248            external_packet_tx: None,
249            endpoint_priority_command_rx: None,
250            endpoint_command_rx: None,
251            endpoint_events: EndpointEventRuntime::default(),
252            endpoint_bulk_feedback_rx: None,
253            #[cfg(unix)]
254            endpoint_bulk_send_runtime: None,
255            encrypt_workers: None,
256            decrypt_workers: None,
257            decrypt_fallback_tx,
258            decrypt_fallback_rx,
259            tun_reader_handle: None,
260            tun_writer_handle: None,
261            #[cfg(target_os = "macos")]
262            tun_shutdown_fd: None,
263            dns_identity_rx: None,
264            dns_task: None,
265            index_allocator: IndexAllocator::new(),
266            pending_outbound: PendingOutboundHandshakes::default(),
267            msg1_rate_limiter,
268            icmp_rate_limiter: IcmpRateLimiter::new(),
269            routing_error_rate_limiter: RoutingErrorRateLimiter::new(),
270            coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval(
271                std::time::Duration::from_millis(coords_response_interval_ms),
272            ),
273            discovery_backoff: DiscoveryBackoff::new(),
274            discovery_forward_limiter: DiscoveryForwardRateLimiter::new(),
275            pending_connects: Vec::new(),
276            retry_pending: retry::PendingRouteRetries::default(),
277            nostr_discovery: None,
278            nostr_discovery_started_at_ms: None,
279            lan_discovery: None,
280            local_instance_registry: None,
281            local_instance_started_at_ms: None,
282            last_local_instance_publish_ms: None,
283            last_local_instance_scan_ms: None,
284            startup_open_discovery_sweep_done: false,
285            bootstrap_transports: BootstrapTransports::default(),
286            discovery_fallback_transit: DiscoveryFallbackTransit::default(),
287            last_parent_reeval: None,
288            last_congestion_log: None,
289            estimated_mesh_size: None,
290            last_mesh_size_log: None,
291            last_self_warn: None,
292            local_send_failures: LocalSendFailures::default(),
293            last_rx_loop_maintenance_timeout_at: None,
294            peer_aliases: HashMap::new(),
295            configured_peer_send_weights,
296            peer_acl,
297            host_map,
298            path_mtu_lookup: Arc::new(std::sync::RwLock::new(HashMap::new())),
299        })
300    }
301
302    /// Create a leaf-only node (simplified state).
303    pub fn leaf_only(config: Config) -> Result<Self, NodeError> {
304        let mut node = Self::new(config)?;
305        node.is_leaf_only = true;
306        node.bloom_state = BloomState::leaf_only(*node.identity.node_addr());
307        Ok(node)
308    }
309
310    pub(super) fn host_map_and_peer_acl(config: &Config) -> (Arc<HostMap>, acl::PeerAclReloader) {
311        let base_host_map = HostMap::from_peer_configs(config.peers());
312        if !config.node.system_files_enabled {
313            return (
314                Arc::new(base_host_map.clone()),
315                acl::PeerAclReloader::memory_only(base_host_map),
316            );
317        }
318
319        let mut host_map = base_host_map.clone();
320        let hosts_path = std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH);
321        let hosts_file = HostMap::load_hosts_file(std::path::Path::new(
322            crate::upper::hosts::DEFAULT_HOSTS_PATH,
323        ));
324        host_map.merge(hosts_file);
325        let peer_acl = acl::PeerAclReloader::with_alias_sources(
326            std::path::PathBuf::from(acl::DEFAULT_PEERS_ALLOW_PATH),
327            std::path::PathBuf::from(acl::DEFAULT_PEERS_DENY_PATH),
328            base_host_map,
329            hosts_path,
330        );
331        (Arc::new(host_map), peer_acl)
332    }
333
334    #[cfg(unix)]
335    pub(super) fn send_weight_for_peer(&self, peer_addr: &NodeAddr) -> u8 {
336        self.configured_peer_send_weights.weight_for(peer_addr)
337    }
338
339    #[cfg(unix)]
340    pub(in crate::node) fn resolve_peer_runtime_route_decision(
341        &mut self,
342        dest_addr: &NodeAddr,
343        now_ms: u64,
344    ) -> Result<PeerRuntimeRouteDecision, PeerRuntimeRouteDecisionError> {
345        let Some(next_hop_addr) = self.find_next_hop(dest_addr).map(|peer| *peer.node_addr())
346        else {
347            return Err(PeerRuntimeRouteDecisionError::NoRoute {
348                dest_addr: *dest_addr,
349            });
350        };
351
352        let peer_snapshot = self
353            .peers
354            .prepare_peer_runtime_route_snapshot(&next_hop_addr)
355            .map_err(|error| PeerRuntimeRouteDecisionError::FmpPreparation {
356                next_hop_addr,
357                error,
358            })?;
359        let scheduling_weight = self.send_weight_for_peer(&next_hop_addr);
360        let direct_path_blocks_direct_payload = next_hop_addr == *dest_addr
361            && self.session_direct_path_blocks_direct_payload(dest_addr, now_ms);
362
363        Ok(PeerRuntimeRouteDecision::new(
364            next_hop_addr,
365            peer_snapshot,
366            scheduling_weight,
367            direct_path_blocks_direct_payload,
368        ))
369    }
370
371    /// Create transport instances from configuration.
372    ///
373    /// Returns a vector of TransportHandles for all configured transports.
374    pub(super) async fn create_transports(&mut self, packet_tx: &PacketTx) -> Vec<TransportHandle> {
375        let mut transports = Vec::new();
376
377        // Collect UDP configs with optional names to avoid borrow conflicts
378        let udp_instances: Vec<_> = self
379            .config
380            .transports
381            .udp
382            .iter()
383            .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
384            .collect();
385
386        // Create UDP transport instances
387        for (name, udp_config) in udp_instances {
388            let transport_id = self.allocate_transport_id();
389            #[cfg(target_os = "macos")]
390            let udp = UdpTransport::new_with_connected_udp_listener(
391                transport_id,
392                name,
393                udp_config,
394                packet_tx.clone(),
395                crate::transport::udp::socket::macos_connected_udp_enabled(
396                    self.config.node.connected_udp.enabled,
397                ),
398            );
399            #[cfg(not(target_os = "macos"))]
400            let udp = UdpTransport::new(transport_id, name, udp_config, packet_tx.clone());
401            transports.push(TransportHandle::Udp(udp));
402        }
403
404        #[cfg(feature = "sim-transport")]
405        {
406            let sim_instances: Vec<_> = self
407                .config
408                .transports
409                .sim
410                .iter()
411                .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
412                .collect();
413
414            for (name, sim_config) in sim_instances {
415                let transport_id = self.allocate_transport_id();
416                let sim = crate::transport::sim::SimTransport::new(
417                    transport_id,
418                    name,
419                    sim_config,
420                    packet_tx.clone(),
421                );
422                transports.push(TransportHandle::Sim(sim));
423            }
424        }
425
426        // Create Ethernet transport instances where raw-socket support exists.
427        #[cfg(any(target_os = "linux", target_os = "macos"))]
428        {
429            let eth_instances: Vec<_> = self
430                .config
431                .transports
432                .ethernet
433                .iter()
434                .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
435                .collect();
436            let xonly = self.identity.pubkey();
437            for (name, eth_config) in eth_instances {
438                let mut eth_config = eth_config;
439                if eth_config.discovery_scope.is_none() {
440                    eth_config.discovery_scope = self.lan_discovery_scope();
441                }
442                let transport_id = self.allocate_transport_id();
443                let mut eth =
444                    EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
445                eth.set_local_pubkey(xonly);
446                transports.push(TransportHandle::Ethernet(eth));
447            }
448        }
449
450        // Create TCP transport instances
451        let tcp_instances: Vec<_> = self
452            .config
453            .transports
454            .tcp
455            .iter()
456            .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
457            .collect();
458
459        for (name, tcp_config) in tcp_instances {
460            let transport_id = self.allocate_transport_id();
461            let tcp = TcpTransport::new(transport_id, name, tcp_config, packet_tx.clone());
462            transports.push(TransportHandle::Tcp(tcp));
463        }
464
465        // Create Tor transport instances
466        let tor_instances: Vec<_> = self
467            .config
468            .transports
469            .tor
470            .iter()
471            .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
472            .collect();
473
474        for (name, tor_config) in tor_instances {
475            let transport_id = self.allocate_transport_id();
476            let tor = TorTransport::new(transport_id, name, tor_config, packet_tx.clone());
477            transports.push(TransportHandle::Tor(tor));
478        }
479
480        let webrtc_instances: Vec<_> = self
481            .config
482            .transports
483            .webrtc
484            .iter()
485            .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
486            .collect();
487
488        #[cfg(feature = "webrtc-transport")]
489        {
490            for (name, webrtc_config) in webrtc_instances {
491                let transport_id = self.allocate_transport_id();
492                match WebRtcTransport::new(
493                    transport_id,
494                    name,
495                    webrtc_config,
496                    packet_tx.clone(),
497                    &self.identity,
498                    &self.config.node.discovery.nostr,
499                ) {
500                    Ok(webrtc) => transports.push(TransportHandle::WebRtc(Box::new(webrtc))),
501                    Err(err) => {
502                        warn!(
503                            transport_id = %transport_id,
504                            error = %err,
505                            "failed to initialize WebRTC transport"
506                        );
507                    }
508                }
509            }
510        }
511        #[cfg(not(feature = "webrtc-transport"))]
512        if !webrtc_instances.is_empty() {
513            warn!("WebRTC transport configured but this build lacks WebRTC transport support");
514        }
515
516        // Create BLE transport instances
517        #[cfg(bluer_available)]
518        {
519            let ble_instances: Vec<_> = self
520                .config
521                .transports
522                .ble
523                .iter()
524                .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
525                .collect();
526
527            #[cfg(all(bluer_available, not(test)))]
528            for (name, ble_config) in ble_instances {
529                let transport_id = self.allocate_transport_id();
530                let adapter = ble_config.adapter().to_string();
531                let mtu = ble_config.mtu();
532                match crate::transport::ble::io::BluerIo::new(&adapter, mtu).await {
533                    Ok(io) => {
534                        let mut ble = crate::transport::ble::BleTransport::new(
535                            transport_id,
536                            name,
537                            ble_config,
538                            io,
539                            packet_tx.clone(),
540                        );
541                        ble.set_local_pubkey(self.identity.pubkey().serialize());
542                        transports.push(TransportHandle::Ble(ble));
543                    }
544                    Err(e) => {
545                        tracing::warn!(adapter = %adapter, error = %e, "failed to initialize BLE adapter");
546                    }
547                }
548            }
549
550            #[cfg(any(not(bluer_available), test))]
551            if !ble_instances.is_empty() {
552                #[cfg(not(test))]
553                tracing::warn!("BLE transport configured but this build lacks BlueZ support");
554            }
555        }
556
557        transports
558    }
559
560    /// Find an operational transport that matches the given transport type name.
561    ///
562    /// Adopted UDP bootstrap transports are point-to-point sockets handed off
563    /// from Nostr/STUN traversal. They must not be reused for ordinary
564    /// `udp host:port` dials discovered through static config, mDNS, or overlay
565    /// adverts: on macOS a `send_to` through the wrong adopted socket can fail
566    /// with `EINVAL`, and even on platforms that allow it the packet would use
567    /// the wrong 5-tuple/NAT mapping. Prefer configured transports and make the
568    /// choice deterministic by lowest transport id instead of HashMap order.
569    pub(super) fn find_transport_for_type(&self, transport_type: &str) -> Option<TransportId> {
570        self.transports
571            .iter()
572            .filter(|(id, handle)| {
573                handle.transport_type().name == transport_type
574                    && handle.is_operational()
575                    && !self.bootstrap_transports.contains(id)
576            })
577            .min_by_key(|(id, _)| id.as_u32())
578            .map(|(id, _)| *id)
579    }
580
581    /// Resolve an Ethernet peer address ("interface/mac") to a transport ID
582    /// and binary TransportAddr.
583    ///
584    /// Finds the Ethernet transport instance bound to the named interface
585    /// and parses the MAC portion into a 6-byte TransportAddr.
586    #[allow(unused_variables)]
587    pub(super) fn resolve_ethernet_addr(
588        &self,
589        addr_str: &str,
590    ) -> Result<(TransportId, TransportAddr), NodeError> {
591        #[cfg(any(target_os = "linux", target_os = "macos"))]
592        {
593            let (iface, mac_str) = addr_str.split_once('/').ok_or_else(|| {
594                NodeError::NoTransportForType(format!(
595                    "invalid Ethernet address format '{}': expected 'interface/mac'",
596                    addr_str
597                ))
598            })?;
599
600            // Find the Ethernet transport bound to this interface
601            let transport_id = self
602                .transports
603                .iter()
604                .find(|(_, handle)| {
605                    handle.transport_type().name == "ethernet"
606                        && handle.is_operational()
607                        && handle.interface_name() == Some(iface)
608                })
609                .map(|(id, _)| *id)
610                .ok_or_else(|| {
611                    NodeError::NoTransportForType(format!(
612                        "no operational Ethernet transport for interface '{}'",
613                        iface
614                    ))
615                })?;
616
617            let mac = crate::transport::ethernet::parse_mac_string(mac_str).map_err(|e| {
618                NodeError::NoTransportForType(format!("invalid MAC in '{}': {}", addr_str, e))
619            })?;
620
621            Ok((transport_id, TransportAddr::from_bytes(&mac)))
622        }
623        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
624        {
625            Err(NodeError::NoTransportForType(
626                "Ethernet transport is not supported on this platform".to_string(),
627            ))
628        }
629    }
630
631    /// Resolve a BLE address string (`"adapter/AA:BB:CC:DD:EE:FF"`) to a
632    /// (TransportId, TransportAddr) pair by finding the BLE transport
633    /// instance matching the adapter name.
634    #[cfg(bluer_available)]
635    pub(super) fn resolve_ble_addr(
636        &self,
637        addr_str: &str,
638    ) -> Result<(TransportId, TransportAddr), NodeError> {
639        let ta = TransportAddr::from_string(addr_str);
640        let adapter = crate::transport::ble::addr::adapter_from_addr(&ta).ok_or_else(|| {
641            NodeError::NoTransportForType(format!(
642                "invalid BLE address format '{}': expected 'adapter/mac'",
643                addr_str
644            ))
645        })?;
646
647        // Find the BLE transport for this adapter
648        let transport_id = self
649            .transports
650            .iter()
651            .find(|(_, handle)| handle.transport_type().name == "ble" && handle.is_operational())
652            .map(|(id, _)| *id)
653            .ok_or_else(|| {
654                NodeError::NoTransportForType(format!(
655                    "no operational BLE transport for adapter '{}'",
656                    adapter
657                ))
658            })?;
659
660        // Validate the address format
661        crate::transport::ble::addr::BleAddr::parse(addr_str).map_err(|e| {
662            NodeError::NoTransportForType(format!("invalid BLE address '{}': {}", addr_str, e))
663        })?;
664
665        Ok((transport_id, TransportAddr::from_string(addr_str)))
666    }
667}