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