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