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