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