Skip to main content

truffle_core/network/tailscale/
provider.rs

1//! TailscaleProvider — the public NetworkProvider implementation.
2//!
3//! Orchestrates the Go sidecar (Layer 1) and bridge (Layer 2) to provide
4//! peer discovery, raw TCP connectivity, and diagnostics via the Tailscale
5//! network.
6
7use std::collections::HashMap;
8use std::net::IpAddr;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Duration;
12
13use tokio::net::TcpStream;
14use tokio::sync::{broadcast, mpsc, oneshot, Mutex, RwLock};
15use tokio::task::JoinHandle;
16
17use super::bridge::{Bridge, DIAL_TIMEOUT};
18use super::sidecar::{GoSidecar, SidecarConfig, SidecarInternalEvent};
19use crate::network::{
20    HealthInfo, IncomingConnection, NetworkError, NetworkPeer, NetworkPeerEvent,
21    NetworkTcpListener, NodeIdentity, PeerAddr, PingResult,
22};
23
24/// Configuration for creating a TailscaleProvider.
25#[derive(Debug, Clone)]
26pub struct TailscaleConfig {
27    /// Path to the Go sidecar binary.
28    pub binary_path: PathBuf,
29    /// Hostname for the tsnet node (e.g., "truffle-cli-{uuid}").
30    pub hostname: String,
31    /// State directory for tsnet persistent state.
32    pub state_dir: String,
33    /// Optional Tailscale auth key for headless authentication.
34    pub auth_key: Option<String>,
35    /// Whether the node is ephemeral (removed when offline).
36    pub ephemeral: Option<bool>,
37    /// ACL tags to advertise (e.g., ["tag:truffle"]).
38    pub tags: Option<Vec<String>>,
39}
40
41/// State of the provider.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum ProviderState {
44    Stopped,
45    Starting,
46    Running,
47    Stopping,
48}
49
50/// Tailscale network provider implementing [`NetworkProvider`].
51///
52/// Wraps the Go sidecar (tsnet) and local TCP bridge to provide:
53/// - Peer discovery via WatchIPNBus events
54/// - Raw TCP dial/listen over encrypted Tailscale tunnels
55/// - Network-level ping and health monitoring
56///
57/// All bridge internals (pending_dials, session tokens, binary headers) are
58/// completely hidden. Callers interact only with plain `TcpStream`s and
59/// high-level types.
60pub struct TailscaleProvider {
61    config: TailscaleConfig,
62    state: Arc<RwLock<ProviderState>>,
63
64    /// Local node identity (populated after start).
65    ///
66    /// Uses `std::sync::RwLock` (not tokio) so the sync trait methods
67    /// `local_identity()` and `local_addr()` can read without `.await`.
68    identity: Arc<std::sync::RwLock<NodeIdentity>>,
69    /// Local node address (populated after start).
70    ///
71    /// Uses `std::sync::RwLock` (not tokio) so the sync trait method
72    /// `local_addr()` can read without `.await`.
73    local_addr: Arc<std::sync::RwLock<PeerAddr>>,
74
75    /// Cached peer list.
76    peers: Arc<RwLock<HashMap<String, NetworkPeer>>>,
77
78    /// Broadcast channel for peer events.
79    peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
80
81    /// Health info cache.
82    health: Arc<RwLock<HealthInfo>>,
83
84    /// Handle to the Go sidecar (set during start).
85    sidecar: Arc<Mutex<Option<GoSidecar>>>,
86
87    /// Handle to the bridge (set during start).
88    bridge: Arc<Mutex<Option<Arc<Bridge>>>>,
89
90    /// Bridge shutdown sender.
91    bridge_shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
92
93    /// Session token (32 bytes, generated on start).
94    session_token: Arc<RwLock<[u8; 32]>>,
95}
96
97impl TailscaleProvider {
98    /// Create a new TailscaleProvider with the given configuration.
99    ///
100    /// Does not start the provider — call [`start()`](NetworkProvider::start) to begin.
101    pub fn new(config: TailscaleConfig) -> Self {
102        let (peer_event_tx, _) = broadcast::channel(256);
103
104        Self {
105            config,
106            state: Arc::new(RwLock::new(ProviderState::Stopped)),
107            identity: Arc::new(std::sync::RwLock::new(NodeIdentity::default())),
108            local_addr: Arc::new(std::sync::RwLock::new(PeerAddr::default())),
109            peers: Arc::new(RwLock::new(HashMap::new())),
110            peer_event_tx,
111            health: Arc::new(RwLock::new(HealthInfo {
112                state: "stopped".to_string(),
113                healthy: false,
114                ..Default::default()
115            })),
116            sidecar: Arc::new(Mutex::new(None)),
117            bridge: Arc::new(Mutex::new(None)),
118            bridge_shutdown_tx: Arc::new(Mutex::new(None)),
119            session_token: Arc::new(RwLock::new([0u8; 32])),
120        }
121    }
122
123    /// Generate a random 32-byte session token.
124    fn generate_session_token() -> Result<[u8; 32], NetworkError> {
125        let mut token = [0u8; 32];
126        getrandom::getrandom(&mut token)
127            .map_err(|e| NetworkError::Internal(format!("failed to generate session token: {e}")))?;
128        Ok(token)
129    }
130
131    /// Convert a SidecarPeer to a NetworkPeer.
132    fn sidecar_peer_to_network_peer(
133        peer: &super::protocol::SidecarPeer,
134    ) -> NetworkPeer {
135        let ip = peer
136            .tailscale_ips
137            .first()
138            .and_then(|s| s.parse::<IpAddr>().ok())
139            .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
140
141        NetworkPeer {
142            id: peer.id.clone(),
143            hostname: peer.hostname.clone(),
144            ip,
145            online: peer.online,
146            cur_addr: if peer.cur_addr.is_empty() {
147                None
148            } else {
149                Some(peer.cur_addr.clone())
150            },
151            relay: if peer.relay.is_empty() {
152                None
153            } else {
154                Some(peer.relay.clone())
155            },
156            os: if peer.os.is_empty() {
157                None
158            } else {
159                Some(peer.os.clone())
160            },
161            last_seen: peer.last_seen.clone(),
162            key_expiry: peer.key_expiry.clone(),
163            dns_name: Some(peer.dns_name.clone()),
164        }
165    }
166
167    /// Spawn the background event processing loop that maps sidecar events
168    /// to peer events and updates cached state.
169    fn spawn_event_processor(
170        mut sidecar_rx: broadcast::Receiver<SidecarInternalEvent>,
171        peers: Arc<RwLock<HashMap<String, NetworkPeer>>>,
172        peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
173        health: Arc<RwLock<HealthInfo>>,
174        identity: Arc<std::sync::RwLock<NodeIdentity>>,
175        local_addr: Arc<std::sync::RwLock<PeerAddr>>,
176        state: Arc<RwLock<ProviderState>>,
177        started_tx: Option<oneshot::Sender<Result<(), NetworkError>>>,
178    ) {
179        tokio::spawn(async move {
180            let mut started_tx = started_tx;
181
182            loop {
183                match sidecar_rx.recv().await {
184                    Ok(event) => {
185                        match event {
186                            SidecarInternalEvent::Started {
187                                hostname,
188                                dns_name,
189                                tailscale_ip,
190                                node_id,
191                            } => {
192                                let ip: Option<IpAddr> = tailscale_ip.parse().ok();
193
194                                {
195                                    let mut id = identity.write().unwrap();
196                                    id.hostname = hostname.clone();
197                                    id.dns_name = Some(dns_name.clone());
198                                    id.name = hostname.clone();
199                                    id.ip = ip;
200                                    if !node_id.is_empty() {
201                                        id.id = node_id;
202                                    }
203                                }
204
205                                {
206                                    let mut addr = local_addr.write().unwrap();
207                                    addr.hostname = hostname;
208                                    addr.dns_name = Some(dns_name);
209                                    addr.ip = ip;
210                                }
211
212                                {
213                                    let mut h = health.write().await;
214                                    h.state = "running".to_string();
215                                    h.healthy = true;
216                                }
217
218                                *state.write().await = ProviderState::Running;
219
220                                // Signal start() that we're ready
221                                if let Some(tx) = started_tx.take() {
222                                    let _ = tx.send(Ok(()));
223                                }
224                            }
225                            SidecarInternalEvent::AuthRequired { auth_url } => {
226                                tracing::info!("tailscale auth required: {auth_url}");
227                                // Emit auth URL via peer events so callers can display it.
228                                // Do NOT consume started_tx — keep waiting for Running state.
229                                let _ = peer_event_tx.send(NetworkPeerEvent::AuthRequired {
230                                    url: auth_url,
231                                });
232                            }
233                            SidecarInternalEvent::Stopped => {
234                                *state.write().await = ProviderState::Stopped;
235                                let mut h = health.write().await;
236                                h.state = "stopped".to_string();
237                                h.healthy = false;
238                                tracing::info!("tailscale provider stopped");
239                                return;
240                            }
241                            SidecarInternalEvent::StateChange { state: new_state } => {
242                                let mut h = health.write().await;
243                                h.state = new_state;
244                            }
245                            SidecarInternalEvent::KeyExpiring { expires_at } => {
246                                let mut h = health.write().await;
247                                h.key_expiry = Some(expires_at);
248                            }
249                            SidecarInternalEvent::HealthWarning { warnings } => {
250                                let mut h = health.write().await;
251                                h.warnings = warnings;
252                                h.healthy = h.warnings.is_empty();
253                            }
254                            SidecarInternalEvent::PeersReceived(sidecar_peers) => {
255                                let mut peer_map = peers.write().await;
256                                // Filter to truffle peers only
257                                let new_peers: HashMap<String, NetworkPeer> = sidecar_peers
258                                    .iter()
259                                    .filter(|p| is_truffle_peer(&p.hostname))
260                                    .map(|p| {
261                                        let np = Self::sidecar_peer_to_network_peer(p);
262                                        (np.id.clone(), np)
263                                    })
264                                    .collect();
265
266                                // Detect joins, leaves, and updates
267                                for (id, new_peer) in &new_peers {
268                                    if let Some(_existing) = peer_map.get(id) {
269                                        let _ = peer_event_tx
270                                            .send(NetworkPeerEvent::Updated(new_peer.clone()));
271                                    } else {
272                                        let _ = peer_event_tx
273                                            .send(NetworkPeerEvent::Joined(new_peer.clone()));
274                                    }
275                                }
276                                for id in peer_map.keys() {
277                                    if !new_peers.contains_key(id) {
278                                        let _ = peer_event_tx
279                                            .send(NetworkPeerEvent::Left(id.clone()));
280                                    }
281                                }
282
283                                *peer_map = new_peers;
284                            }
285                            SidecarInternalEvent::PeerChanged(change) => {
286                                let mut peer_map = peers.write().await;
287                                match change.change_type.as_str() {
288                                    "joined" => {
289                                        if let Some(p) = change.peer {
290                                            if is_truffle_peer(&p.hostname) {
291                                                let np = Self::sidecar_peer_to_network_peer(&p);
292                                                peer_map.insert(np.id.clone(), np.clone());
293                                                let _ = peer_event_tx
294                                                    .send(NetworkPeerEvent::Joined(np));
295                                            }
296                                        }
297                                    }
298                                    "left" => {
299                                        if peer_map.remove(&change.peer_id).is_some() {
300                                            let _ = peer_event_tx
301                                                .send(NetworkPeerEvent::Left(change.peer_id));
302                                        }
303                                    }
304                                    "updated" => {
305                                        if let Some(p) = change.peer {
306                                            if is_truffle_peer(&p.hostname) {
307                                                let np = Self::sidecar_peer_to_network_peer(&p);
308                                                peer_map.insert(np.id.clone(), np.clone());
309                                                let _ = peer_event_tx
310                                                    .send(NetworkPeerEvent::Updated(np));
311                                            }
312                                        }
313                                    }
314                                    other => {
315                                        tracing::warn!("unknown peer change type: {other}");
316                                    }
317                                }
318                            }
319                            SidecarInternalEvent::Error { code, message } => {
320                                tracing::error!("sidecar error [{code}]: {message}");
321                                // If start() is still waiting and this is a fatal error
322                                if let Some(tx) = started_tx.take() {
323                                    let _ = tx.send(Err(NetworkError::SidecarError(
324                                        format!("[{code}] {message}"),
325                                    )));
326                                }
327                            }
328                            SidecarInternalEvent::ProcessExited { exit_code } => {
329                                tracing::error!("sidecar process exited: {exit_code:?}");
330                                *state.write().await = ProviderState::Stopped;
331                                let mut h = health.write().await;
332                                h.state = "crashed".to_string();
333                                h.healthy = false;
334                                if let Some(tx) = started_tx.take() {
335                                    let _ = tx.send(Err(NetworkError::SidecarError(
336                                        format!("process exited with code {exit_code:?}"),
337                                    )));
338                                }
339                                return;
340                            }
341                            // Dial/Listen/Ping results are handled by the caller,
342                            // not the background event processor
343                            _ => {}
344                        }
345                    }
346                    Err(broadcast::error::RecvError::Lagged(n)) => {
347                        tracing::warn!("event processor lagged by {n} events");
348                    }
349                    Err(broadcast::error::RecvError::Closed) => {
350                        tracing::info!("sidecar event channel closed, stopping event processor");
351                        return;
352                    }
353                }
354            }
355        });
356    }
357}
358
359/// Check if a hostname belongs to a truffle node.
360/// Truffle nodes use hostnames like "truffle-cli-{uuid}" or "truffle-{name}".
361pub(crate) fn is_truffle_peer(hostname: &str) -> bool {
362    hostname.starts_with("truffle-")
363}
364
365impl super::super::NetworkProvider for TailscaleProvider {
366    async fn start(&mut self) -> Result<(), NetworkError> {
367        {
368            let current_state = *self.state.read().await;
369            if current_state != ProviderState::Stopped {
370                return Err(NetworkError::AlreadyRunning);
371            }
372        }
373        *self.state.write().await = ProviderState::Starting;
374
375        // Generate session token
376        let token = Self::generate_session_token()?;
377        let token_hex = hex::encode(token);
378        *self.session_token.write().await = token;
379
380        // Start the bridge
381        let bridge = Bridge::bind(token).await?;
382        let bridge_port = bridge.local_port()?;
383        let bridge = Arc::new(bridge);
384
385        // Create bridge shutdown channel
386        let (bridge_shutdown_tx, bridge_shutdown_rx) = tokio::sync::watch::channel(false);
387
388        // Run bridge accept loop
389        {
390            let bridge_clone = bridge.clone();
391            tokio::spawn(async move {
392                bridge_clone.run(bridge_shutdown_rx).await;
393            });
394        }
395
396        *self.bridge.lock().await = Some(bridge.clone());
397        *self.bridge_shutdown_tx.lock().await = Some(bridge_shutdown_tx);
398
399        // Build sidecar config
400        let sidecar_config = SidecarConfig {
401            binary_path: self.config.binary_path.clone(),
402            hostname: self.config.hostname.clone(),
403            state_dir: self.config.state_dir.clone(),
404            auth_key: self.config.auth_key.clone(),
405            bridge_port,
406            session_token_hex: token_hex,
407            ephemeral: self.config.ephemeral,
408            tags: self.config.tags.clone(),
409        };
410
411        // Spawn the sidecar
412        let (sidecar, sidecar_rx) = GoSidecar::spawn(sidecar_config.clone()).await?;
413
414        // Create a channel for the event processor to signal when we're running
415        let (started_tx, started_rx) = oneshot::channel();
416
417        // Start event processor
418        Self::spawn_event_processor(
419            sidecar_rx,
420            self.peers.clone(),
421            self.peer_event_tx.clone(),
422            self.health.clone(),
423            self.identity.clone(),
424            self.local_addr.clone(),
425            self.state.clone(),
426            Some(started_tx),
427        );
428
429        // Send start command to sidecar
430        sidecar.send_start(&sidecar_config).await?;
431
432        *self.sidecar.lock().await = Some(sidecar);
433
434        // Wait for the sidecar to reach "running" state.
435        // Use a generous timeout (5 min) because browser auth may take a while.
436        // Auth URLs are emitted via peer_events() so the caller can display them.
437        let auth_timeout = Duration::from_secs(300);
438        let result = tokio::time::timeout(auth_timeout, started_rx)
439            .await
440            .map_err(|_| NetworkError::StartFailed(
441                "timed out waiting for authentication (5 min). \
442                 Subscribe to peer_events() to display auth URLs.".into()
443            ))?
444            .map_err(|_| NetworkError::StartFailed("start signal channel dropped".into()))?;
445
446        match result {
447            Ok(()) => {
448                // Fetch initial peer list
449                if let Some(ref sidecar) = *self.sidecar.lock().await {
450                    let _ = sidecar.send_get_peers().await;
451                    // Also start WatchIPNBus for real-time peer events
452                    let _ = sidecar.send_watch_peers().await;
453                }
454                tracing::info!("tailscale provider started successfully");
455                Ok(())
456            }
457            Err(e) => {
458                *self.state.write().await = ProviderState::Stopped;
459                Err(e)
460            }
461        }
462    }
463
464    async fn stop(&mut self) -> Result<(), NetworkError> {
465        *self.state.write().await = ProviderState::Stopping;
466
467        // Shut down sidecar
468        if let Some(sidecar) = self.sidecar.lock().await.take() {
469            sidecar.shutdown().await;
470        }
471
472        // Shut down bridge
473        if let Some(tx) = self.bridge_shutdown_tx.lock().await.take() {
474            let _ = tx.send(true);
475        }
476        *self.bridge.lock().await = None;
477
478        // Clear state
479        self.peers.write().await.clear();
480        *self.state.write().await = ProviderState::Stopped;
481        let mut h = self.health.write().await;
482        h.state = "stopped".to_string();
483        h.healthy = false;
484
485        tracing::info!("tailscale provider stopped");
486        Ok(())
487    }
488
489    fn local_identity(&self) -> NodeIdentity {
490        self.identity.read().unwrap().clone()
491    }
492
493    fn local_addr(&self) -> PeerAddr {
494        self.local_addr.read().unwrap().clone()
495    }
496
497    fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
498        self.peer_event_tx.subscribe()
499    }
500
501    async fn peers(&self) -> Vec<NetworkPeer> {
502        self.peers.read().await.values().cloned().collect()
503    }
504
505    async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
506        if *self.state.read().await != ProviderState::Running {
507            return Err(NetworkError::NotRunning);
508        }
509
510        let bridge = self
511            .bridge
512            .lock()
513            .await
514            .clone()
515            .ok_or(NetworkError::NotRunning)?;
516
517        // Generate a unique request ID
518        let request_id = uuid::Uuid::new_v4().to_string();
519
520        // Register the pending dial before sending the command
521        let dial_rx = bridge.register_dial(request_id.clone()).await;
522
523        // Scope the sidecar lock: subscribe + send, then release
524        let mut event_rx = {
525            let sidecar_guard = self.sidecar.lock().await;
526            let sidecar = sidecar_guard
527                .as_ref()
528                .ok_or(NetworkError::NotRunning)?;
529
530            let event_rx = sidecar.subscribe();
531
532            sidecar
533                .send_dial(request_id.clone(), addr.to_string(), port)
534                .await?;
535
536            event_rx
537        };
538
539        // Wait for either:
540        // 1. Bridge delivers the TcpStream (success path)
541        // 2. Sidecar reports dial failure via event (error path)
542        // 3. Timeout
543        //
544        // The bridge delivers the TcpStream once the Go sidecar bridges the
545        // connection back. If the sidecar reports a failure, we get that via
546        // the event channel and abort early.
547        let result = tokio::time::timeout(DIAL_TIMEOUT, async {
548            // Spawn a task to watch for dial failure events.
549            // We keep the JoinHandle so we can abort it once the select resolves,
550            // preventing an orphaned task that would loop forever.
551            let fail_request_id = request_id.clone();
552            let (fail_tx, fail_rx) = oneshot::channel::<String>();
553            let fail_watcher: JoinHandle<()> = tokio::spawn(async move {
554                loop {
555                    match event_rx.recv().await {
556                        Ok(SidecarInternalEvent::DialFailed { request_id: rid, error })
557                            if rid == fail_request_id =>
558                        {
559                            let _ = fail_tx.send(error);
560                            return;
561                        }
562                        Err(broadcast::error::RecvError::Closed) => {
563                            let _ = fail_tx.send("event channel closed".to_string());
564                            return;
565                        }
566                        _ => continue,
567                    }
568                }
569            });
570
571            let result = tokio::select! {
572                stream_result = dial_rx => {
573                    stream_result.map_err(|_| NetworkError::DialFailed("dial cancelled".into()))
574                }
575                fail_result = fail_rx => {
576                    let error = fail_result.unwrap_or_else(|_| "dial watcher dropped".to_string());
577                    Err(NetworkError::DialFailed(error))
578                }
579            };
580
581            // Cancel the fail-watcher task so it doesn't leak
582            fail_watcher.abort();
583
584            result
585        })
586        .await
587        .map_err(|_| NetworkError::DialTimeout(DIAL_TIMEOUT))?;
588
589        // Clean up pending dial on any error
590        if result.is_err() {
591            bridge.remove_dial(&request_id).await;
592        }
593
594        result
595    }
596
597    async fn listen_tcp(
598        &self,
599        port: u16,
600    ) -> Result<NetworkTcpListener, NetworkError> {
601        if *self.state.read().await != ProviderState::Running {
602            return Err(NetworkError::NotRunning);
603        }
604
605        let bridge = self
606            .bridge
607            .lock()
608            .await
609            .clone()
610            .ok_or(NetworkError::NotRunning)?;
611
612        // Create channel for incoming connections
613        let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
614
615        // Scope the sidecar lock: subscribe + send listen, then release
616        let mut event_rx = {
617            let sidecar_guard = self.sidecar.lock().await;
618            let sidecar = sidecar_guard
619                .as_ref()
620                .ok_or(NetworkError::NotRunning)?;
621
622            let event_rx = sidecar.subscribe();
623            sidecar.send_listen(port, None).await?;
624            event_rx
625        };
626
627        // Wait for confirmation or error.
628        // When port is 0, the sidecar assigns an ephemeral port and reports
629        // the actual port in the Listening event.
630        let actual_port = tokio::time::timeout(Duration::from_secs(10), async {
631            loop {
632                match event_rx.recv().await {
633                    Ok(SidecarInternalEvent::Listening { port: p })
634                        if port == 0 || p == port =>
635                    {
636                        return Ok(p);
637                    }
638                    Ok(SidecarInternalEvent::Error { code, message }) => {
639                        return Err(NetworkError::ListenFailed(format!("[{code}] {message}")));
640                    }
641                    Err(broadcast::error::RecvError::Closed) => {
642                        return Err(NetworkError::SidecarError("event channel closed".into()));
643                    }
644                    _ => continue,
645                }
646            }
647        })
648        .await
649        .map_err(|_| NetworkError::ListenFailed("listen confirmation timed out".into()))??;
650
651        // Register the channel with the bridge using the actual port
652        bridge.register_listener(actual_port, tx).await;
653
654        Ok(NetworkTcpListener { port: actual_port, incoming: rx })
655    }
656
657    async fn unlisten_tcp(&self, port: u16) -> Result<(), NetworkError> {
658        if *self.state.read().await != ProviderState::Running {
659            return Err(NetworkError::NotRunning);
660        }
661
662        let bridge = self
663            .bridge
664            .lock()
665            .await
666            .clone()
667            .ok_or(NetworkError::NotRunning)?;
668
669        // Remove bridge listener
670        bridge.remove_listener(port).await;
671
672        // Tell sidecar to stop listening
673        {
674            let sidecar_guard = self.sidecar.lock().await;
675            let sidecar = sidecar_guard
676                .as_ref()
677                .ok_or(NetworkError::NotRunning)?;
678            sidecar.send_unlisten(port).await?;
679        }
680
681        Ok(())
682    }
683
684    async fn ping(&self, addr: &str) -> Result<PingResult, NetworkError> {
685        if *self.state.read().await != ProviderState::Running {
686            return Err(NetworkError::NotRunning);
687        }
688
689        let target = addr.to_string();
690
691        // Scope the sidecar lock: subscribe + send ping, then release
692        let mut event_rx = {
693            let sidecar_guard = self.sidecar.lock().await;
694            let sidecar = sidecar_guard
695                .as_ref()
696                .ok_or(NetworkError::NotRunning)?;
697
698            let event_rx = sidecar.subscribe();
699            sidecar.send_ping(target.clone(), None).await?;
700            event_rx
701        };
702
703        // Wait for result
704        let result = tokio::time::timeout(Duration::from_secs(15), async {
705            loop {
706                match event_rx.recv().await {
707                    Ok(SidecarInternalEvent::PingResult(data)) if data.target == target => {
708                        if !data.error.is_empty() {
709                            return Err(NetworkError::PingFailed(data.error));
710                        }
711                        let connection = if data.direct {
712                            "direct".to_string()
713                        } else if !data.relay.is_empty() {
714                            format!("relay:{}", data.relay)
715                        } else {
716                            "unknown".to_string()
717                        };
718                        return Ok(PingResult {
719                            latency: Duration::from_secs_f64(data.latency_ms / 1000.0),
720                            connection,
721                            peer_addr: if data.peer_addr.is_empty() {
722                                None
723                            } else {
724                                Some(data.peer_addr)
725                            },
726                        });
727                    }
728                    Err(broadcast::error::RecvError::Closed) => {
729                        return Err(NetworkError::SidecarError("event channel closed".into()));
730                    }
731                    _ => continue,
732                }
733            }
734        })
735        .await
736        .map_err(|_| NetworkError::PingFailed("ping timed out".into()))?;
737
738        result
739    }
740
741    async fn bind_udp(&self, port: u16) -> Result<super::super::NetworkUdpSocket, NetworkError> {
742        if *self.state.read().await != ProviderState::Running {
743            return Err(NetworkError::NotRunning);
744        }
745
746        // Scope the sidecar lock: subscribe + send listenPacket, then release
747        let mut event_rx = {
748            let sidecar_guard = self.sidecar.lock().await;
749            let sidecar = sidecar_guard
750                .as_ref()
751                .ok_or(NetworkError::NotRunning)?;
752
753            let event_rx = sidecar.subscribe();
754            sidecar.send_listen_packet(port).await?;
755            event_rx
756        };
757
758        // Wait for the sidecar to report the local relay port
759        let local_port = tokio::time::timeout(Duration::from_secs(10), async {
760            loop {
761                match event_rx.recv().await {
762                    Ok(SidecarInternalEvent::ListeningPacket {
763                        port: p,
764                        local_port,
765                    }) if p == port => {
766                        return Ok(local_port);
767                    }
768                    Ok(SidecarInternalEvent::Error { code, message }) => {
769                        return Err(NetworkError::ListenFailed(format!(
770                            "UDP bind failed [{code}] {message}"
771                        )));
772                    }
773                    Err(broadcast::error::RecvError::Closed) => {
774                        return Err(NetworkError::SidecarError(
775                            "event channel closed".into(),
776                        ));
777                    }
778                    _ => continue,
779                }
780            }
781        })
782        .await
783        .map_err(|_| {
784            NetworkError::ListenFailed("UDP listenPacket confirmation timed out".into())
785        })??;
786
787        // Bind a local UDP socket and connect it to the relay
788        let local_socket = tokio::net::UdpSocket::bind("127.0.0.1:0")
789            .await
790            .map_err(|e| {
791                NetworkError::Internal(format!("failed to bind local UDP socket: {e}"))
792            })?;
793
794        local_socket
795            .connect(format!("127.0.0.1:{local_port}"))
796            .await
797            .map_err(|e| {
798                NetworkError::Internal(format!(
799                    "failed to connect local UDP socket to relay: {e}"
800                ))
801            })?;
802
803        let rust_local_addr = local_socket.local_addr().map_err(|e| {
804            NetworkError::Internal(format!("failed to get local UDP addr: {e}"))
805        })?;
806
807        // Send a registration packet so the relay learns our address.
808        // Without this, the relay drops inbound packets because it doesn't
809        // know where to forward them (it learns the Rust peer address from
810        // the first outbound packet).
811        local_socket
812            .send(b"TRUFFLE_UDP_REGISTER")
813            .await
814            .map_err(|e| {
815                NetworkError::Internal(format!("failed to send UDP registration: {e}"))
816            })?;
817
818        tracing::info!(
819            tsnet_port = port,
820            relay_port = local_port,
821            rust_local_addr = %rust_local_addr,
822            "UDP socket bound via tsnet relay (registered)"
823        );
824
825        Ok(super::super::NetworkUdpSocket::new(local_socket, port))
826    }
827
828    async fn health(&self) -> HealthInfo {
829        self.health.read().await.clone()
830    }
831}
832
833impl TailscaleProvider {
834    /// Get the local identity (convenience alias — same as the trait method).
835    ///
836    /// Retained for backwards compatibility with existing callers that used
837    /// the old async version.
838    pub async fn local_identity_async(&self) -> NodeIdentity {
839        self.identity.read().unwrap().clone()
840    }
841
842    /// Get the local address (convenience alias — same as the trait method).
843    ///
844    /// Retained for backwards compatibility with existing callers that used
845    /// the old async version.
846    pub async fn local_addr_async(&self) -> PeerAddr {
847        self.local_addr.read().unwrap().clone()
848    }
849}