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