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};
15
16use super::bridge::{Bridge, DIAL_TIMEOUT};
17use super::protocol::{
18    PingResultEventData, ProxyAddCommandData, ProxyInfoEventData, WhoisResultEventData,
19};
20use super::sidecar::{GoSidecar, ReplyGuard, SidecarConfig, SidecarInternalEvent};
21use crate::network::{
22    DialOpts, HealthInfo, IncomingConnection, ListenOpts, NetworkError, NetworkPeer,
23    NetworkPeerEvent, NetworkTcpListener, NodeIdentity, PeerAddr, PingResult, ProxyAddParams,
24    ProxyAddResult, ProxyListEntry, ProxyRuntimeError,
25};
26
27/// Configuration for creating a TailscaleProvider.
28#[derive(Clone)]
29pub struct TailscaleConfig {
30    /// Path to the Go sidecar binary.
31    pub binary_path: PathBuf,
32    /// Application identifier (RFC 017 §5.1). Stored as a plain `String`
33    /// because validation happens in `NodeBuilder::app_id`; by the time the
34    /// config is constructed the value is already a valid `AppId`.
35    pub app_id: String,
36    /// Stable per-device ULID (RFC 017 §5.4).
37    pub device_id: String,
38    /// Original (unsanitised) device name — retained for display and for
39    /// building the `NodeIdentity` returned from `local_identity()`.
40    pub device_name: String,
41    /// Final Tailscale hostname, already composed by the caller as
42    /// `truffle-{app_id}-{slug(device_name)}`. The provider does NOT rebuild
43    /// this — it trusts the builder has applied the RFC 017 derivation once.
44    pub hostname: String,
45    /// State directory for tsnet persistent state.
46    pub state_dir: String,
47    /// Optional Tailscale auth key for headless authentication.
48    pub auth_key: Option<String>,
49    /// Whether the node is ephemeral (removed when offline).
50    pub ephemeral: Option<bool>,
51    /// ACL tags to advertise (e.g., ["tag:truffle"]).
52    pub tags: Option<Vec<String>>,
53    /// Idle timeout for bridged connections in seconds (RFC 021 §6.5).
54    /// `None` → the sidecar's 600s default.
55    pub idle_timeout_secs: Option<u64>,
56}
57
58/// Manual `Debug`: `auth_key` is a tailnet credential and must never reach
59/// logs, so it is redacted while preserving presence (`Some`/`None`).
60impl std::fmt::Debug for TailscaleConfig {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("TailscaleConfig")
63            .field("binary_path", &self.binary_path)
64            .field("app_id", &self.app_id)
65            .field("device_id", &self.device_id)
66            .field("device_name", &self.device_name)
67            .field("hostname", &self.hostname)
68            .field("state_dir", &self.state_dir)
69            .field("auth_key", &self.auth_key.as_ref().map(|_| "[REDACTED]"))
70            .field("ephemeral", &self.ephemeral)
71            .field("tags", &self.tags)
72            .field("idle_timeout_secs", &self.idle_timeout_secs)
73            .finish()
74    }
75}
76
77/// State of the provider.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79enum ProviderState {
80    Stopped,
81    Starting,
82    Running,
83    Stopping,
84}
85
86/// Tailscale network provider implementing [`NetworkProvider`](crate::network::NetworkProvider).
87///
88/// Wraps the Go sidecar (tsnet) and local TCP bridge to provide:
89/// - Peer discovery via WatchIPNBus events
90/// - Raw TCP dial/listen over encrypted Tailscale tunnels
91/// - Network-level ping and health monitoring
92///
93/// All bridge internals (pending_dials, session tokens, binary headers) are
94/// completely hidden. Callers interact only with plain `TcpStream`s and
95/// high-level types.
96pub struct TailscaleProvider {
97    config: TailscaleConfig,
98    state: Arc<RwLock<ProviderState>>,
99
100    /// Local node identity (populated after start).
101    ///
102    /// Uses `std::sync::RwLock` (not tokio) so the sync trait methods
103    /// `local_identity()` and `local_addr()` can read without `.await`.
104    identity: Arc<std::sync::RwLock<NodeIdentity>>,
105    /// Local node address (populated after start).
106    ///
107    /// Uses `std::sync::RwLock` (not tokio) so the sync trait method
108    /// `local_addr()` can read without `.await`.
109    local_addr: Arc<std::sync::RwLock<PeerAddr>>,
110
111    /// Cached peer list.
112    peers: Arc<RwLock<HashMap<String, NetworkPeer>>>,
113
114    /// Broadcast channel for peer events.
115    peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
116
117    /// Health info cache.
118    health: Arc<RwLock<HealthInfo>>,
119
120    /// Handle to the Go sidecar (set during start).
121    sidecar: Arc<Mutex<Option<GoSidecar>>>,
122
123    /// Handle to the bridge (set during start).
124    bridge: Arc<Mutex<Option<Arc<Bridge>>>>,
125
126    /// Bridge shutdown sender.
127    bridge_shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
128
129    /// Session token (32 bytes, generated on start).
130    session_token: Arc<RwLock<[u8; 32]>>,
131
132    /// Local Tailscale stable ID, captured from the `tsnet:status` event
133    /// (netmap `self` entry). Used for self-filtering in the peer event
134    /// chain — we must filter by this, NOT by hostname, because hostname
135    /// collisions from crashed/restarted dev runs can cause the local
136    /// node to appear as its own peer under a different Tailscale ID.
137    local_tailscale_id: Arc<std::sync::RwLock<Option<String>>>,
138
139    /// Runtime proxy-engine errors, forwarded from sidecar `proxy:error`
140    /// events (RFC 023 G5). Node subscribes via `proxy_runtime_errors()`.
141    proxy_error_tx: broadcast::Sender<ProxyRuntimeError>,
142
143    /// Sidecar control-protocol version from `tsnet:status` (0 = v1 /
144    /// unknown). Gates RFC 023 v2 proxy features so they fail loudly on
145    /// old sidecars instead of being silently ignored on the wire.
146    sidecar_protocol_version: Arc<std::sync::atomic::AtomicU32>,
147}
148
149impl TailscaleProvider {
150    /// Create a new TailscaleProvider with the given configuration.
151    ///
152    /// Does not start the provider — call [`start()`](crate::network::NetworkProvider::start) to begin.
153    pub fn new(config: TailscaleConfig) -> Self {
154        let (peer_event_tx, _) = broadcast::channel(256);
155        let (proxy_error_tx, _) = broadcast::channel(64);
156
157        // Seed the identity with the RFC 017 fields we already know from
158        // the config. `tailscale_id`, `dns_name`, and `ip` are filled in
159        // later when the sidecar reports `tsnet:status`.
160        let initial_identity = NodeIdentity {
161            app_id: config.app_id.clone(),
162            device_id: config.device_id.clone(),
163            device_name: config.device_name.clone(),
164            tailscale_hostname: config.hostname.clone(),
165            tailscale_id: String::new(),
166            dns_name: None,
167            ip: None,
168        };
169
170        Self {
171            config,
172            state: Arc::new(RwLock::new(ProviderState::Stopped)),
173            identity: Arc::new(std::sync::RwLock::new(initial_identity)),
174            local_addr: Arc::new(std::sync::RwLock::new(PeerAddr::default())),
175            peers: Arc::new(RwLock::new(HashMap::new())),
176            peer_event_tx,
177            health: Arc::new(RwLock::new(HealthInfo {
178                state: "stopped".to_string(),
179                healthy: false,
180                ..Default::default()
181            })),
182            sidecar: Arc::new(Mutex::new(None)),
183            bridge: Arc::new(Mutex::new(None)),
184            bridge_shutdown_tx: Arc::new(Mutex::new(None)),
185            session_token: Arc::new(RwLock::new([0u8; 32])),
186            local_tailscale_id: Arc::new(std::sync::RwLock::new(None)),
187            proxy_error_tx,
188            sidecar_protocol_version: Arc::new(std::sync::atomic::AtomicU32::new(0)),
189        }
190    }
191
192    /// Generate a random 32-byte session token.
193    fn generate_session_token() -> Result<[u8; 32], NetworkError> {
194        let mut token = [0u8; 32];
195        getrandom::getrandom(&mut token).map_err(|e| {
196            NetworkError::Internal(format!("failed to generate session token: {e}"))
197        })?;
198        Ok(token)
199    }
200
201    /// Convert a SidecarPeer to a NetworkPeer.
202    fn sidecar_peer_to_network_peer(peer: &super::protocol::SidecarPeer) -> NetworkPeer {
203        let ip = peer
204            .tailscale_ips
205            .first()
206            .and_then(|s| s.parse::<IpAddr>().ok())
207            .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
208
209        NetworkPeer {
210            id: peer.id.clone(),
211            hostname: peer.hostname.clone(),
212            ip,
213            online: peer.online,
214            cur_addr: if peer.cur_addr.is_empty() {
215                None
216            } else {
217                Some(peer.cur_addr.clone())
218            },
219            relay: if peer.relay.is_empty() {
220                None
221            } else {
222                Some(peer.relay.clone())
223            },
224            os: if peer.os.is_empty() {
225                None
226            } else {
227                Some(peer.os.clone())
228            },
229            last_seen: peer.last_seen.clone(),
230            key_expiry: peer.key_expiry.clone(),
231            dns_name: Some(peer.dns_name.clone()),
232        }
233    }
234
235    /// Spawn the background event processing loop that maps sidecar events
236    /// to peer events and updates cached state.
237    #[allow(clippy::too_many_arguments)]
238    fn spawn_event_processor(
239        mut sidecar_rx: broadcast::Receiver<SidecarInternalEvent>,
240        peers: Arc<RwLock<HashMap<String, NetworkPeer>>>,
241        peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
242        health: Arc<RwLock<HealthInfo>>,
243        identity: Arc<std::sync::RwLock<NodeIdentity>>,
244        local_addr: Arc<std::sync::RwLock<PeerAddr>>,
245        local_tailscale_id: Arc<std::sync::RwLock<Option<String>>>,
246        state: Arc<RwLock<ProviderState>>,
247        started_tx: Option<oneshot::Sender<Result<(), NetworkError>>>,
248        app_id: String,
249        proxy_error_tx: broadcast::Sender<ProxyRuntimeError>,
250        sidecar_protocol_version: Arc<std::sync::atomic::AtomicU32>,
251    ) {
252        tokio::spawn(async move {
253            let mut started_tx = started_tx;
254
255            loop {
256                match sidecar_rx.recv().await {
257                    Ok(event) => {
258                        match event {
259                            SidecarInternalEvent::Started {
260                                hostname,
261                                dns_name,
262                                tailscale_ip,
263                                node_id,
264                                protocol_version,
265                            } => {
266                                // 0 = v1/unknown; gates RFC 023 v2 proxy features.
267                                sidecar_protocol_version.store(
268                                    protocol_version.unwrap_or(0),
269                                    std::sync::atomic::Ordering::Relaxed,
270                                );
271                                let ip: Option<IpAddr> = tailscale_ip.parse().ok();
272
273                                {
274                                    let mut id = identity.write().unwrap();
275                                    // `tailscale_hostname` is already populated
276                                    // from the config at construction time. We
277                                    // overwrite it with whatever the sidecar
278                                    // actually registered (Tailscale may append
279                                    // `-2`, `-3`, … for hostname collisions).
280                                    id.tailscale_hostname = hostname.clone();
281                                    id.dns_name = Some(dns_name.clone());
282                                    id.ip = ip;
283                                    if !node_id.is_empty() {
284                                        id.tailscale_id = node_id.clone();
285                                    }
286                                }
287
288                                // Capture the local Tailscale stable ID for
289                                // self-filtering in the peer event chain.
290                                if !node_id.is_empty() {
291                                    *local_tailscale_id.write().unwrap() = Some(node_id);
292                                }
293
294                                {
295                                    let mut addr = local_addr.write().unwrap();
296                                    addr.hostname = hostname;
297                                    addr.dns_name = Some(dns_name);
298                                    addr.ip = ip;
299                                }
300
301                                {
302                                    let mut h = health.write().await;
303                                    h.state = "running".to_string();
304                                    h.healthy = true;
305                                }
306
307                                *state.write().await = ProviderState::Running;
308
309                                // Signal start() that we're ready
310                                if let Some(tx) = started_tx.take() {
311                                    let _ = tx.send(Ok(()));
312                                }
313                            }
314                            SidecarInternalEvent::AuthRequired { auth_url } => {
315                                tracing::info!("tailscale auth required: {auth_url}");
316                                // Emit auth URL via peer events so callers can display it.
317                                // Do NOT consume started_tx — keep waiting for Running state.
318                                let _ = peer_event_tx
319                                    .send(NetworkPeerEvent::AuthRequired { url: auth_url });
320                            }
321                            SidecarInternalEvent::Stopped => {
322                                *state.write().await = ProviderState::Stopped;
323                                let mut h = health.write().await;
324                                h.state = "stopped".to_string();
325                                h.healthy = false;
326                                tracing::info!("tailscale provider stopped");
327                                return;
328                            }
329                            SidecarInternalEvent::StateChange { state: new_state } => {
330                                let mut h = health.write().await;
331                                h.state = new_state;
332                            }
333                            SidecarInternalEvent::KeyExpiring { expires_at } => {
334                                let mut h = health.write().await;
335                                h.key_expiry = Some(expires_at);
336                            }
337                            SidecarInternalEvent::HealthWarning { warnings } => {
338                                let mut h = health.write().await;
339                                h.warnings = warnings;
340                                h.healthy = h.warnings.is_empty();
341                            }
342                            SidecarInternalEvent::PeersReceived(sidecar_peers) => {
343                                let mut peer_map = peers.write().await;
344                                // Self-filter by Tailscale stable ID, not by
345                                // hostname — hostname collisions from crashed/
346                                // restarted dev runs can cause the local node
347                                // to appear as its own peer under a different
348                                // Tailscale ID.
349                                let self_id = local_tailscale_id.read().unwrap().clone();
350                                // Filter to peers that belong to our app AND
351                                // are not ourselves.
352                                let new_peers: HashMap<String, NetworkPeer> = sidecar_peers
353                                    .iter()
354                                    .filter(|p| {
355                                        if let Some(ref me) = self_id {
356                                            if p.id == *me {
357                                                return false;
358                                            }
359                                        }
360                                        is_app_peer(&p.hostname, &app_id)
361                                    })
362                                    .map(|p| {
363                                        let np = Self::sidecar_peer_to_network_peer(p);
364                                        (np.id.clone(), np)
365                                    })
366                                    .collect();
367
368                                // Detect joins, leaves, and updates
369                                for (id, new_peer) in &new_peers {
370                                    if let Some(_existing) = peer_map.get(id) {
371                                        let _ = peer_event_tx
372                                            .send(NetworkPeerEvent::Updated(new_peer.clone()));
373                                    } else {
374                                        let _ = peer_event_tx
375                                            .send(NetworkPeerEvent::Joined(new_peer.clone()));
376                                    }
377                                }
378                                for id in peer_map.keys() {
379                                    if !new_peers.contains_key(id) {
380                                        let _ =
381                                            peer_event_tx.send(NetworkPeerEvent::Left(id.clone()));
382                                    }
383                                }
384
385                                *peer_map = new_peers;
386                            }
387                            SidecarInternalEvent::PeerChanged(change) => {
388                                let mut peer_map = peers.write().await;
389                                // Self-filter by Tailscale stable ID, not
390                                // by hostname — see comment in PeersReceived.
391                                let self_id = local_tailscale_id.read().unwrap().clone();
392                                match change.change_type.as_str() {
393                                    "joined" => {
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::Joined(np));
405                                            }
406                                        }
407                                    }
408                                    "left" => {
409                                        if peer_map.remove(&change.peer_id).is_some() {
410                                            let _ = peer_event_tx
411                                                .send(NetworkPeerEvent::Left(change.peer_id));
412                                        }
413                                    }
414                                    "updated" => {
415                                        if let Some(p) = change.peer {
416                                            if let Some(ref me) = self_id {
417                                                if p.id == *me {
418                                                    continue;
419                                                }
420                                            }
421                                            if is_app_peer(&p.hostname, &app_id) {
422                                                let np = Self::sidecar_peer_to_network_peer(&p);
423                                                peer_map.insert(np.id.clone(), np.clone());
424                                                let _ = peer_event_tx
425                                                    .send(NetworkPeerEvent::Updated(np));
426                                            }
427                                        }
428                                    }
429                                    other => {
430                                        tracing::warn!("unknown peer change type: {other}");
431                                    }
432                                }
433                            }
434                            SidecarInternalEvent::Error { code, message } => {
435                                tracing::error!("sidecar error [{code}]: {message}");
436                                // If start() is still waiting and this is a fatal error
437                                if let Some(tx) = started_tx.take() {
438                                    let _ = tx.send(Err(NetworkError::SidecarError(format!(
439                                        "[{code}] {message}"
440                                    ))));
441                                }
442                            }
443                            SidecarInternalEvent::ProcessExited { exit_code } => {
444                                tracing::error!("sidecar process exited: {exit_code:?}");
445                                *state.write().await = ProviderState::Stopped;
446                                let mut h = health.write().await;
447                                h.state = "crashed".to_string();
448                                h.healthy = false;
449                                if let Some(tx) = started_tx.take() {
450                                    let _ = tx.send(Err(NetworkError::SidecarError(format!(
451                                        "process exited with code {exit_code:?}"
452                                    ))));
453                                }
454                                return;
455                            }
456                            SidecarInternalEvent::ProxyError { id, code, message } => {
457                                // Runtime engine errors (RFC 023 G5). Add-time
458                                // failures are also seen (and returned) by the
459                                // proxy_add wait loop; the Node-side forwarder
460                                // drops events for ids it never saw start.
461                                tracing::warn!("proxy runtime error [{code}] for {id}: {message}");
462                                let _ =
463                                    proxy_error_tx.send(ProxyRuntimeError { id, code, message });
464                            }
465                            // Dial/Listen/Ping results are handled by the caller,
466                            // not the background event processor
467                            _ => {}
468                        }
469                    }
470                    Err(broadcast::error::RecvError::Lagged(n)) => {
471                        tracing::warn!("event processor lagged by {n} events");
472                    }
473                    Err(broadcast::error::RecvError::Closed) => {
474                        tracing::info!("sidecar event channel closed, stopping event processor");
475                        return;
476                    }
477                }
478            }
479        });
480    }
481}
482
483/// Check if a hostname belongs to a truffle node in the given app.
484///
485/// RFC 017 §4: every truffle-managed Tailscale hostname has the shape
486/// `truffle-{app_id}-{slug(device_name)}`. The prefix `truffle-{app_id}-`
487/// is used to admit peers from our own application and reject peers from
488/// other apps on the same tailnet. A hostname that matches the prefix but
489/// has no trailing slug is rejected — we require at least one character
490/// after the separator so that `truffle-playground-` (empty slug edge)
491/// cannot masquerade as a real peer.
492pub(crate) fn is_app_peer(hostname: &str, app_id: &str) -> bool {
493    let prefix = format!("truffle-{app_id}-");
494    hostname.len() > prefix.len() && hostname.starts_with(&prefix)
495}
496
497impl super::super::NetworkProvider for TailscaleProvider {
498    async fn start(&mut self) -> Result<(), NetworkError> {
499        {
500            let current_state = *self.state.read().await;
501            if current_state != ProviderState::Stopped {
502                return Err(NetworkError::AlreadyRunning);
503            }
504        }
505        *self.state.write().await = ProviderState::Starting;
506
507        // Generate session token
508        let token = Self::generate_session_token()?;
509        let token_hex = hex::encode(token);
510        *self.session_token.write().await = token;
511
512        // Start the bridge
513        let bridge = Bridge::bind(token).await?;
514        let bridge_port = bridge.local_port()?;
515        let bridge = Arc::new(bridge);
516
517        // Create bridge shutdown channel
518        let (bridge_shutdown_tx, bridge_shutdown_rx) = tokio::sync::watch::channel(false);
519
520        // Run bridge accept loop
521        {
522            let bridge_clone = bridge.clone();
523            tokio::spawn(async move {
524                bridge_clone.run(bridge_shutdown_rx).await;
525            });
526        }
527
528        *self.bridge.lock().await = Some(bridge.clone());
529        *self.bridge_shutdown_tx.lock().await = Some(bridge_shutdown_tx);
530
531        // Build sidecar config
532        let sidecar_config = SidecarConfig {
533            binary_path: self.config.binary_path.clone(),
534            hostname: self.config.hostname.clone(),
535            state_dir: self.config.state_dir.clone(),
536            auth_key: self.config.auth_key.clone(),
537            bridge_port,
538            session_token_hex: token_hex,
539            ephemeral: self.config.ephemeral,
540            tags: self.config.tags.clone(),
541            idle_timeout_secs: self.config.idle_timeout_secs,
542        };
543
544        // Spawn the sidecar
545        let (sidecar, sidecar_rx) = GoSidecar::spawn(sidecar_config.clone()).await?;
546
547        // Create a channel for the event processor to signal when we're running
548        let (started_tx, started_rx) = oneshot::channel();
549
550        // Start event processor
551        Self::spawn_event_processor(
552            sidecar_rx,
553            self.peers.clone(),
554            self.peer_event_tx.clone(),
555            self.health.clone(),
556            self.identity.clone(),
557            self.local_addr.clone(),
558            self.local_tailscale_id.clone(),
559            self.state.clone(),
560            Some(started_tx),
561            self.config.app_id.clone(),
562            self.proxy_error_tx.clone(),
563            self.sidecar_protocol_version.clone(),
564        );
565
566        // Send start command to sidecar
567        sidecar.send_start(&sidecar_config).await?;
568
569        *self.sidecar.lock().await = Some(sidecar);
570
571        // Wait for the sidecar to reach "running" state.
572        // Use a generous timeout (5 min) because browser auth may take a while.
573        // Auth URLs are emitted via peer_events() so the caller can display them.
574        let auth_timeout = Duration::from_secs(300);
575        let result = tokio::time::timeout(auth_timeout, started_rx)
576            .await
577            .map_err(|_| {
578                NetworkError::StartFailed(
579                    "timed out waiting for authentication (5 min). \
580                 Subscribe to peer_events() to display auth URLs."
581                        .into(),
582                )
583            })?
584            .map_err(|_| NetworkError::StartFailed("start signal channel dropped".into()))?;
585
586        match result {
587            Ok(()) => {
588                // Fetch initial peer list
589                if let Some(ref sidecar) = *self.sidecar.lock().await {
590                    let _ = sidecar.send_get_peers().await;
591                    // Also start WatchIPNBus for real-time peer events
592                    let _ = sidecar.send_watch_peers().await;
593                }
594                tracing::info!("tailscale provider started successfully");
595                Ok(())
596            }
597            Err(e) => {
598                *self.state.write().await = ProviderState::Stopped;
599                Err(e)
600            }
601        }
602    }
603
604    async fn stop(&self) -> Result<(), NetworkError> {
605        *self.state.write().await = ProviderState::Stopping;
606
607        // Shut down sidecar
608        if let Some(sidecar) = self.sidecar.lock().await.take() {
609            sidecar.shutdown().await;
610        }
611
612        // Shut down bridge
613        if let Some(tx) = self.bridge_shutdown_tx.lock().await.take() {
614            let _ = tx.send(true);
615        }
616        *self.bridge.lock().await = None;
617
618        // Clear state
619        self.peers.write().await.clear();
620        *self.state.write().await = ProviderState::Stopped;
621        let mut h = self.health.write().await;
622        h.state = "stopped".to_string();
623        h.healthy = false;
624
625        tracing::info!("tailscale provider stopped");
626        Ok(())
627    }
628
629    fn local_identity(&self) -> NodeIdentity {
630        self.identity.read().unwrap().clone()
631    }
632
633    fn local_addr(&self) -> PeerAddr {
634        self.local_addr.read().unwrap().clone()
635    }
636
637    fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
638        self.peer_event_tx.subscribe()
639    }
640
641    async fn peers(&self) -> Vec<NetworkPeer> {
642        self.peers.read().await.values().cloned().collect()
643    }
644
645    async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
646        self.dial_tcp_opts(addr, port, DialOpts::default()).await
647    }
648
649    async fn dial_tcp_opts(
650        &self,
651        addr: &str,
652        port: u16,
653        opts: DialOpts,
654    ) -> Result<TcpStream, NetworkError> {
655        if *self.state.read().await != ProviderState::Running {
656            return Err(NetworkError::NotRunning);
657        }
658
659        let bridge = self
660            .bridge
661            .lock()
662            .await
663            .clone()
664            .ok_or(NetworkError::NotRunning)?;
665
666        // Generate a unique request ID
667        let request_id = uuid::Uuid::new_v4().to_string();
668
669        // Register the pending dial before sending the command
670        let dial_rx = bridge.register_dial(request_id.clone()).await;
671
672        // Scope the sidecar lock: register the reply slot + send, then release
673        let reply = {
674            let sidecar_guard = self.sidecar.lock().await;
675            let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
676
677            let reply = sidecar.register_reply(&request_id);
678
679            sidecar
680                .send_dial(request_id.clone(), addr.to_string(), port, opts.tls)
681                .await?;
682
683            reply
684        };
685
686        // Wait for either:
687        // 1. Bridge delivers the TcpStream (success path)
688        // 2. Sidecar reports the dial result via the broker (error path)
689        // 3. Timeout
690        Self::await_dial_result(&bridge, &request_id, dial_rx, reply, DIAL_TIMEOUT).await
691    }
692
693    async fn listen_tcp(&self, port: u16) -> Result<NetworkTcpListener, NetworkError> {
694        self.listen_tcp_opts(port, ListenOpts::default()).await
695    }
696
697    async fn listen_tcp_opts(
698        &self,
699        port: u16,
700        opts: ListenOpts,
701    ) -> Result<NetworkTcpListener, NetworkError> {
702        if *self.state.read().await != ProviderState::Running {
703            return Err(NetworkError::NotRunning);
704        }
705
706        let bridge = self
707            .bridge
708            .lock()
709            .await
710            .clone()
711            .ok_or(NetworkError::NotRunning)?;
712
713        // Create channel for incoming connections
714        let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
715
716        // `Some(true)` → tsnet ListenTLS with MagicDNS certs (RFC 023
717        // §7.1). Plain listeners send None so the field is omitted on
718        // the wire — sidecars predating the flag parse the command
719        // unchanged.
720        let tls = if opts.tls { Some(true) } else { None };
721
722        // v4 sidecars echo a correlation id on the Listening event, so the
723        // reply routes through the broker: immune to broadcast lag, and to
724        // the port-0 ambiguity where two concurrent ephemeral listens could
725        // steal each other's confirmations. Older sidecars fall back to
726        // port-matched value correlation.
727        let actual_port = if self.sidecar_version() >= Self::SIDECAR_V4_REPLY_ROUTING {
728            let request_id = uuid::Uuid::new_v4().to_string();
729            let mut reply = {
730                let sidecar_guard = self.sidecar.lock().await;
731                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
732                let reply = sidecar.register_reply(&request_id);
733                sidecar
734                    .send_listen(port, tls, Some(request_id.clone()))
735                    .await?;
736                reply
737            };
738            match tokio::time::timeout(Duration::from_secs(10), reply.recv())
739                .await
740                .map_err(|_| NetworkError::ListenFailed("listen confirmation timed out".into()))??
741            {
742                // When port is 0, the sidecar assigns an ephemeral port and
743                // reports the actual port here.
744                SidecarInternalEvent::Listening { port: p } => p,
745                SidecarInternalEvent::Error { code, message } => {
746                    return Err(NetworkError::ListenFailed(format!("[{code}] {message}")));
747                }
748                other => return Err(Self::unexpected_reply("listen", other)),
749            }
750        } else {
751            // Legacy pre-v4 path: value correlation over broadcast.
752            let mut event_rx = {
753                let sidecar_guard = self.sidecar.lock().await;
754                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
755
756                let event_rx = sidecar.subscribe();
757                sidecar.send_listen(port, tls, None).await?;
758                event_rx
759            };
760
761            tokio::time::timeout(Duration::from_secs(10), async {
762                loop {
763                    match event_rx.recv().await {
764                        Ok(SidecarInternalEvent::Listening { port: p })
765                            if port == 0 || p == port =>
766                        {
767                            return Ok(p);
768                        }
769                        Ok(SidecarInternalEvent::Error { code, message }) => {
770                            return Err(NetworkError::ListenFailed(format!("[{code}] {message}")));
771                        }
772                        Err(broadcast::error::RecvError::Closed) => {
773                            return Err(NetworkError::SidecarError("event channel closed".into()));
774                        }
775                        Err(broadcast::error::RecvError::Lagged(_)) => {
776                            return Err(NetworkError::SidecarError(
777                                "event channel lagged: listen confirmation may have been lost"
778                                    .into(),
779                            ));
780                        }
781                        _ => continue,
782                    }
783                }
784            })
785            .await
786            .map_err(|_| NetworkError::ListenFailed("listen confirmation timed out".into()))??
787        };
788
789        // Register the channel with the bridge using the actual port
790        bridge.register_listener(actual_port, tx).await;
791
792        Ok(NetworkTcpListener {
793            port: actual_port,
794            incoming: rx,
795        })
796    }
797
798    async fn unlisten_tcp(&self, port: u16) -> Result<(), NetworkError> {
799        if *self.state.read().await != ProviderState::Running {
800            return Err(NetworkError::NotRunning);
801        }
802
803        let bridge = self
804            .bridge
805            .lock()
806            .await
807            .clone()
808            .ok_or(NetworkError::NotRunning)?;
809
810        // Remove bridge listener
811        bridge.remove_listener(port).await;
812
813        // Tell sidecar to stop listening
814        {
815            let sidecar_guard = self.sidecar.lock().await;
816            let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
817            sidecar.send_unlisten(port).await?;
818        }
819
820        Ok(())
821    }
822
823    async fn ping(&self, addr: &str) -> Result<PingResult, NetworkError> {
824        if *self.state.read().await != ProviderState::Running {
825            return Err(NetworkError::NotRunning);
826        }
827
828        let target = addr.to_string();
829
830        // v2+ sidecars echo a correlation id on the ping result (P12), so
831        // the reply routes through the broker — immune to broadcast lag.
832        // Older sidecars fall back to target-matched value correlation.
833        if self.sidecar_version() >= Self::SIDECAR_V2_PING_ECHO {
834            let request_id = uuid::Uuid::new_v4().to_string();
835            let mut reply = {
836                let sidecar_guard = self.sidecar.lock().await;
837                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
838                let reply = sidecar.register_reply(&request_id);
839                sidecar
840                    .send_ping(target, None, Some(request_id.clone()))
841                    .await?;
842                reply
843            };
844            return match tokio::time::timeout(Duration::from_secs(15), reply.recv())
845                .await
846                .map_err(|_| NetworkError::PingFailed("ping timed out".into()))??
847            {
848                SidecarInternalEvent::PingResult(data) => Self::map_ping_result(data),
849                SidecarInternalEvent::Error { code, message } => {
850                    Err(NetworkError::PingFailed(format!("[{code}] {message}")))
851                }
852                other => Err(Self::unexpected_reply("ping", other)),
853            };
854        }
855
856        // Legacy pre-v2 path: value correlation over broadcast.
857        let mut event_rx = {
858            let sidecar_guard = self.sidecar.lock().await;
859            let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
860
861            let event_rx = sidecar.subscribe();
862            sidecar.send_ping(target.clone(), None, None).await?;
863            event_rx
864        };
865
866        tokio::time::timeout(Duration::from_secs(15), async {
867            loop {
868                match event_rx.recv().await {
869                    Ok(SidecarInternalEvent::PingResult(data)) if data.target == target => {
870                        return Self::map_ping_result(data);
871                    }
872                    Err(broadcast::error::RecvError::Closed) => {
873                        return Err(NetworkError::SidecarError("event channel closed".into()));
874                    }
875                    Err(broadcast::error::RecvError::Lagged(_)) => {
876                        return Err(NetworkError::SidecarError(
877                            "event channel lagged: ping result may have been lost".into(),
878                        ));
879                    }
880                    _ => continue,
881                }
882            }
883        })
884        .await
885        .map_err(|_| NetworkError::PingFailed("ping timed out".into()))?
886    }
887
888    async fn whois(
889        &self,
890        addr: &str,
891    ) -> Result<Option<super::super::TailscalePeerIdentity>, NetworkError> {
892        if *self.state.read().await != ProviderState::Running {
893            return Err(NetworkError::NotRunning);
894        }
895
896        // tsnet:whois shipped with protocol v3 — an older sidecar would
897        // silently swallow the command and this call would only time out, so
898        // fail fast with an actionable error instead.
899        let version = self
900            .sidecar_protocol_version
901            .load(std::sync::atomic::Ordering::Relaxed);
902        if version < 3 {
903            return Err(NetworkError::Unsupported(format!(
904                "sidecar protocol v{version} predates tsnet:whois; upgrade the \
905                 sidecar binary (needs v3)"
906            )));
907        }
908
909        let target = addr.to_string();
910        let request_id = uuid::Uuid::new_v4().to_string();
911
912        // Broker-routed reply: every whois-capable sidecar echoes the id,
913        // so this path needs no gate beyond the v3 check above. Register
914        // BEFORE sending, under the same lock scope, so the answer can
915        // never race the registration.
916        let mut reply = {
917            let sidecar_guard = self.sidecar.lock().await;
918            let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
919            let reply = sidecar.register_reply(&request_id);
920            sidecar.send_whois(target, Some(request_id.clone())).await?;
921            reply
922        };
923
924        match tokio::time::timeout(Duration::from_secs(10), reply.recv())
925            .await
926            .map_err(|_| NetworkError::SidecarError("whois timed out".into()))??
927        {
928            SidecarInternalEvent::WhoisResult(data) => Self::map_whois_result(data),
929            SidecarInternalEvent::Error { code, message } => {
930                Err(NetworkError::SidecarError(format!("[{code}] {message}")))
931            }
932            other => Err(Self::unexpected_reply("whois", other)),
933        }
934    }
935
936    async fn bind_udp(&self, port: u16) -> Result<super::super::NetworkUdpSocket, NetworkError> {
937        if *self.state.read().await != ProviderState::Running {
938            return Err(NetworkError::NotRunning);
939        }
940
941        // Wait for the sidecar to report the local relay port. v4 sidecars
942        // echo a correlation id, routing the reply through the broker;
943        // older ones fall back to port-matched value correlation.
944        let local_port = if self.sidecar_version() >= Self::SIDECAR_V4_REPLY_ROUTING {
945            let request_id = uuid::Uuid::new_v4().to_string();
946            let mut reply = {
947                let sidecar_guard = self.sidecar.lock().await;
948                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
949                let reply = sidecar.register_reply(&request_id);
950                sidecar
951                    .send_listen_packet(port, Some(request_id.clone()))
952                    .await?;
953                reply
954            };
955            match tokio::time::timeout(Duration::from_secs(10), reply.recv())
956                .await
957                .map_err(|_| {
958                    NetworkError::ListenFailed("UDP listenPacket confirmation timed out".into())
959                })?? {
960                SidecarInternalEvent::ListeningPacket { local_port, .. } => local_port,
961                SidecarInternalEvent::Error { code, message } => {
962                    return Err(NetworkError::ListenFailed(format!(
963                        "UDP bind failed [{code}] {message}"
964                    )));
965                }
966                other => return Err(Self::unexpected_reply("listenPacket", other)),
967            }
968        } else {
969            // Legacy pre-v4 path: value correlation over broadcast.
970            let mut event_rx = {
971                let sidecar_guard = self.sidecar.lock().await;
972                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
973
974                let event_rx = sidecar.subscribe();
975                sidecar.send_listen_packet(port, None).await?;
976                event_rx
977            };
978
979            tokio::time::timeout(Duration::from_secs(10), async {
980                loop {
981                    match event_rx.recv().await {
982                        Ok(SidecarInternalEvent::ListeningPacket {
983                            port: p,
984                            local_port,
985                        }) if p == port => {
986                            return Ok(local_port);
987                        }
988                        Ok(SidecarInternalEvent::Error { code, message }) => {
989                            return Err(NetworkError::ListenFailed(format!(
990                                "UDP bind failed [{code}] {message}"
991                            )));
992                        }
993                        Err(broadcast::error::RecvError::Closed) => {
994                            return Err(NetworkError::SidecarError("event channel closed".into()));
995                        }
996                        Err(broadcast::error::RecvError::Lagged(_)) => {
997                            return Err(NetworkError::SidecarError(
998                                "event channel lagged: UDP bind confirmation may have been lost"
999                                    .into(),
1000                            ));
1001                        }
1002                        _ => continue,
1003                    }
1004                }
1005            })
1006            .await
1007            .map_err(|_| {
1008                NetworkError::ListenFailed("UDP listenPacket confirmation timed out".into())
1009            })??
1010        };
1011
1012        // Bind a local UDP socket and connect it to the relay
1013        let local_socket = tokio::net::UdpSocket::bind("127.0.0.1:0")
1014            .await
1015            .map_err(|e| NetworkError::Internal(format!("failed to bind local UDP socket: {e}")))?;
1016
1017        local_socket
1018            .connect(format!("127.0.0.1:{local_port}"))
1019            .await
1020            .map_err(|e| {
1021                NetworkError::Internal(format!("failed to connect local UDP socket to relay: {e}"))
1022            })?;
1023
1024        let rust_local_addr = local_socket
1025            .local_addr()
1026            .map_err(|e| NetworkError::Internal(format!("failed to get local UDP addr: {e}")))?;
1027
1028        // Send a registration packet so the relay learns our address.
1029        // Without this, the relay drops inbound packets because it doesn't
1030        // know where to forward them (it learns the Rust peer address from
1031        // the first outbound packet).
1032        local_socket
1033            .send(b"TRUFFLE_UDP_REGISTER")
1034            .await
1035            .map_err(|e| NetworkError::Internal(format!("failed to send UDP registration: {e}")))?;
1036
1037        tracing::info!(
1038            tsnet_port = port,
1039            relay_port = local_port,
1040            rust_local_addr = %rust_local_addr,
1041            "UDP socket bound via tsnet relay (registered)"
1042        );
1043
1044        Ok(super::super::NetworkUdpSocket::new(local_socket, port))
1045    }
1046
1047    async fn health(&self) -> HealthInfo {
1048        self.health.read().await.clone()
1049    }
1050
1051    fn proxy_runtime_errors(&self) -> Option<broadcast::Receiver<ProxyRuntimeError>> {
1052        Some(self.proxy_error_tx.subscribe())
1053    }
1054
1055    // ── Reverse proxy ─────────────────────────────────────────────────
1056
1057    async fn proxy_add(&self, config: ProxyAddParams) -> Result<ProxyAddResult, NetworkError> {
1058        if *self.state.read().await != ProviderState::Running {
1059            return Err(NetworkError::NotRunning);
1060        }
1061
1062        // RFC 023 §8.1: v2-only features must fail loudly on old sidecars.
1063        // The wire silently drops unknown JSON fields — for `allow` that
1064        // would be an access gate the user believes exists and doesn't.
1065        let uses_v2 = !config.tls
1066            || config.allow_non_loopback
1067            || !config.allow.is_empty()
1068            || !config.routes.is_empty();
1069        let version = self
1070            .sidecar_protocol_version
1071            .load(std::sync::atomic::Ordering::Relaxed);
1072        if uses_v2 && version < 2 {
1073            return Err(NetworkError::ProxyError(format!(
1074                "sidecar protocol v{version} predates RFC 023 — routes, allow lists, \
1075                 tls: false, and non-loopback targets need a v2 sidecar; upgrade the \
1076                 sidecar binary"
1077            )));
1078        }
1079
1080        let command = ProxyAddCommandData {
1081            id: config.id.clone(),
1082            name: config.name.clone(),
1083            listen_port: config.listen_port,
1084            target_host: config.target_host.clone(),
1085            target_port: config.target_port,
1086            target_scheme: config.target_scheme.clone(),
1087            tls: config.tls,
1088            allow_non_loopback: config.allow_non_loopback,
1089            allow: config.allow.clone(),
1090            routes: config.routes.clone(),
1091            request_id: None,
1092        };
1093
1094        // v4 sidecars echo a correlation id on proxy:added / proxy:error,
1095        // routing the reply through the broker; older ones fall back to
1096        // id-matched value correlation.
1097        if self.sidecar_version() >= Self::SIDECAR_V4_REPLY_ROUTING {
1098            let request_id = uuid::Uuid::new_v4().to_string();
1099            let mut reply = {
1100                let sidecar_guard = self.sidecar.lock().await;
1101                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
1102                let reply = sidecar.register_reply(&request_id);
1103                sidecar
1104                    .send_proxy_add(ProxyAddCommandData {
1105                        request_id: Some(request_id.clone()),
1106                        ..command
1107                    })
1108                    .await?;
1109                reply
1110            };
1111            return match tokio::time::timeout(Duration::from_secs(10), reply.recv())
1112                .await
1113                .map_err(|_| NetworkError::ProxyError("proxy add timed out".into()))??
1114            {
1115                SidecarInternalEvent::ProxyAdded {
1116                    id,
1117                    listen_port,
1118                    url,
1119                } => Ok(ProxyAddResult {
1120                    id,
1121                    listen_port,
1122                    url,
1123                }),
1124                SidecarInternalEvent::ProxyError { code, message, .. }
1125                | SidecarInternalEvent::Error { code, message } => {
1126                    Err(NetworkError::ProxyError(format!("[{code}] {message}")))
1127                }
1128                other => Err(Self::unexpected_reply("proxy add", other)),
1129            };
1130        }
1131
1132        // Legacy pre-v4 path: value correlation over broadcast.
1133        let mut event_rx = {
1134            let sidecar_guard = self.sidecar.lock().await;
1135            let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
1136            let event_rx = sidecar.subscribe();
1137            sidecar.send_proxy_add(command).await?;
1138            event_rx
1139        };
1140
1141        // Wait for confirmation or error
1142        tokio::time::timeout(Duration::from_secs(10), async {
1143            loop {
1144                match event_rx.recv().await {
1145                    Ok(SidecarInternalEvent::ProxyAdded {
1146                        id,
1147                        listen_port,
1148                        url,
1149                    }) if id == config.id => {
1150                        return Ok(ProxyAddResult {
1151                            id,
1152                            listen_port,
1153                            url,
1154                        });
1155                    }
1156                    Ok(SidecarInternalEvent::ProxyError { id, code, message })
1157                        if id == config.id =>
1158                    {
1159                        return Err(NetworkError::ProxyError(format!("[{code}] {message}")));
1160                    }
1161                    Ok(SidecarInternalEvent::Error { code, message }) => {
1162                        return Err(NetworkError::ProxyError(format!("[{code}] {message}")));
1163                    }
1164                    Err(broadcast::error::RecvError::Closed) => {
1165                        return Err(NetworkError::SidecarError("event channel closed".into()));
1166                    }
1167                    Err(broadcast::error::RecvError::Lagged(_)) => {
1168                        return Err(NetworkError::SidecarError(
1169                            "event channel lagged: proxy confirmation may have been lost".into(),
1170                        ));
1171                    }
1172                    Ok(_) => continue,
1173                }
1174            }
1175        })
1176        .await
1177        .map_err(|_| NetworkError::ProxyError("proxy add timed out".into()))?
1178    }
1179
1180    async fn proxy_remove(&self, id: &str) -> Result<(), NetworkError> {
1181        if *self.state.read().await != ProviderState::Running {
1182            return Err(NetworkError::NotRunning);
1183        }
1184
1185        let target_id = id.to_string();
1186
1187        // v4 sidecars echo a correlation id, routing the reply through the
1188        // broker; older ones fall back to id-matched value correlation.
1189        if self.sidecar_version() >= Self::SIDECAR_V4_REPLY_ROUTING {
1190            let request_id = uuid::Uuid::new_v4().to_string();
1191            let mut reply = {
1192                let sidecar_guard = self.sidecar.lock().await;
1193                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
1194                let reply = sidecar.register_reply(&request_id);
1195                sidecar
1196                    .send_proxy_remove(id, Some(request_id.clone()))
1197                    .await?;
1198                reply
1199            };
1200            return match tokio::time::timeout(Duration::from_secs(10), reply.recv())
1201                .await
1202                .map_err(|_| NetworkError::ProxyError("proxy remove timed out".into()))??
1203            {
1204                SidecarInternalEvent::ProxyRemoved { .. } => Ok(()),
1205                SidecarInternalEvent::ProxyError { code, message, .. }
1206                | SidecarInternalEvent::Error { code, message } => {
1207                    Err(NetworkError::ProxyError(format!("[{code}] {message}")))
1208                }
1209                other => Err(Self::unexpected_reply("proxy remove", other)),
1210            };
1211        }
1212
1213        // Legacy pre-v4 path: value correlation over broadcast.
1214        let mut event_rx = {
1215            let sidecar_guard = self.sidecar.lock().await;
1216            let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
1217            let event_rx = sidecar.subscribe();
1218            sidecar.send_proxy_remove(id, None).await?;
1219            event_rx
1220        };
1221
1222        // Wait for confirmation or error
1223        tokio::time::timeout(Duration::from_secs(10), async {
1224            loop {
1225                match event_rx.recv().await {
1226                    Ok(SidecarInternalEvent::ProxyRemoved { id }) if id == target_id => {
1227                        return Ok(());
1228                    }
1229                    Ok(SidecarInternalEvent::ProxyError { id, code, message })
1230                        if id == target_id =>
1231                    {
1232                        return Err(NetworkError::ProxyError(format!("[{code}] {message}")));
1233                    }
1234                    Ok(SidecarInternalEvent::Error { code, message }) => {
1235                        return Err(NetworkError::ProxyError(format!("[{code}] {message}")));
1236                    }
1237                    Err(broadcast::error::RecvError::Closed) => {
1238                        return Err(NetworkError::SidecarError("event channel closed".into()));
1239                    }
1240                    Err(broadcast::error::RecvError::Lagged(_)) => {
1241                        return Err(NetworkError::SidecarError(
1242                            "event channel lagged: proxy confirmation may have been lost".into(),
1243                        ));
1244                    }
1245                    Ok(_) => continue,
1246                }
1247            }
1248        })
1249        .await
1250        .map_err(|_| NetworkError::ProxyError("proxy remove timed out".into()))?
1251    }
1252
1253    async fn proxy_list(&self) -> Result<Vec<ProxyListEntry>, NetworkError> {
1254        if *self.state.read().await != ProviderState::Running {
1255            return Err(NetworkError::NotRunning);
1256        }
1257
1258        // v4 sidecars echo a correlation id on the list result, routing the
1259        // reply through the broker; older ones fall back to the historical
1260        // assumption that one list call is in flight at a time.
1261        if self.sidecar_version() >= Self::SIDECAR_V4_REPLY_ROUTING {
1262            let request_id = uuid::Uuid::new_v4().to_string();
1263            let mut reply = {
1264                let sidecar_guard = self.sidecar.lock().await;
1265                let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
1266                let reply = sidecar.register_reply(&request_id);
1267                sidecar.send_proxy_list(Some(request_id.clone())).await?;
1268                reply
1269            };
1270            return match tokio::time::timeout(Duration::from_secs(10), reply.recv())
1271                .await
1272                .map_err(|_| NetworkError::ProxyError("proxy list timed out".into()))??
1273            {
1274                SidecarInternalEvent::ProxyList { proxies } => Ok(Self::proxy_entries(proxies)),
1275                SidecarInternalEvent::ProxyError { code, message, .. }
1276                | SidecarInternalEvent::Error { code, message } => {
1277                    Err(NetworkError::ProxyError(format!("[{code}] {message}")))
1278                }
1279                other => Err(Self::unexpected_reply("proxy list", other)),
1280            };
1281        }
1282
1283        // Legacy pre-v4 path: value correlation over broadcast.
1284        let mut event_rx = {
1285            let sidecar_guard = self.sidecar.lock().await;
1286            let sidecar = sidecar_guard.as_ref().ok_or(NetworkError::NotRunning)?;
1287            let event_rx = sidecar.subscribe();
1288            sidecar.send_proxy_list(None).await?;
1289            event_rx
1290        };
1291
1292        // Wait for the list response
1293        tokio::time::timeout(Duration::from_secs(10), async {
1294            loop {
1295                match event_rx.recv().await {
1296                    Ok(SidecarInternalEvent::ProxyList { proxies }) => {
1297                        return Ok(Self::proxy_entries(proxies));
1298                    }
1299                    Ok(SidecarInternalEvent::Error { code, message }) => {
1300                        return Err(NetworkError::ProxyError(format!("[{code}] {message}")));
1301                    }
1302                    Err(broadcast::error::RecvError::Closed) => {
1303                        return Err(NetworkError::SidecarError("event channel closed".into()));
1304                    }
1305                    Err(broadcast::error::RecvError::Lagged(_)) => {
1306                        return Err(NetworkError::SidecarError(
1307                            "event channel lagged: proxy confirmation may have been lost".into(),
1308                        ));
1309                    }
1310                    Ok(_) => continue,
1311                }
1312            }
1313        })
1314        .await
1315        .map_err(|_| NetworkError::ProxyError("proxy list timed out".into()))?
1316    }
1317}
1318
1319impl TailscaleProvider {
1320    /// Get the local identity (convenience alias — same as the trait method).
1321    ///
1322    /// Retained for backwards compatibility with existing callers that used
1323    /// the old async version.
1324    pub async fn local_identity_async(&self) -> NodeIdentity {
1325        self.identity.read().unwrap().clone()
1326    }
1327
1328    /// Get the local address (convenience alias — same as the trait method).
1329    ///
1330    /// Retained for backwards compatibility with existing callers that used
1331    /// the old async version.
1332    pub async fn local_addr_async(&self) -> PeerAddr {
1333        self.local_addr.read().unwrap().clone()
1334    }
1335
1336    /// Wait for a registered dial to resolve: the bridge delivers the
1337    /// `TcpStream`, the sidecar reports a dial failure, or the timeout
1338    /// elapses.
1339    ///
1340    /// Cleanup is guaranteed on every exit path — including timeout — so a
1341    /// failed dial never leaks its `pending_dials` entry or the fail-watcher
1342    /// task (which would otherwise hold a broadcast receiver forever).
1343    ///
1344    /// `pub(super)` so the module tests can exercise the timeout path.
1345    /// Earliest sidecar protocol that echoes `requestId` on
1346    /// `tsnet:pingResult` (P12, shipped before v2 was minted — v2 is the
1347    /// earliest version we can address).
1348    const SIDECAR_V2_PING_ECHO: u32 = 2;
1349
1350    /// Earliest sidecar protocol that echoes `requestId` on every RPC
1351    /// event: listening, listeningPacket, proxy:added/removed/list,
1352    /// proxy:error, and correlated tsnet:error.
1353    const SIDECAR_V4_REPLY_ROUTING: u32 = 4;
1354
1355    fn sidecar_version(&self) -> u32 {
1356        self.sidecar_protocol_version
1357            .load(std::sync::atomic::Ordering::Relaxed)
1358    }
1359
1360    /// Broker replies are typed by the sidecar, not by us — a reply of an
1361    /// unexpected variant is a protocol bug worth naming, not a hang.
1362    fn unexpected_reply(what: &str, event: SidecarInternalEvent) -> NetworkError {
1363        NetworkError::SidecarError(format!("unexpected {what} reply: {event:?}"))
1364    }
1365
1366    /// Map a `tsnet:pingResult` payload to the public result. Shared by the
1367    /// broker path and the pre-v2 legacy wait loop.
1368    fn map_ping_result(data: PingResultEventData) -> Result<PingResult, NetworkError> {
1369        if !data.error.is_empty() {
1370            return Err(NetworkError::PingFailed(data.error));
1371        }
1372        let connection = if data.direct {
1373            "direct".to_string()
1374        } else if !data.relay.is_empty() {
1375            format!("relay:{}", data.relay)
1376        } else {
1377            "unknown".to_string()
1378        };
1379        Ok(PingResult {
1380            latency: Duration::from_secs_f64(data.latency_ms / 1000.0),
1381            connection,
1382            peer_addr: if data.peer_addr.is_empty() {
1383                None
1384            } else {
1385                Some(data.peer_addr)
1386            },
1387        })
1388    }
1389
1390    /// Map a `tsnet:whoisResult` payload to the public identity.
1391    ///
1392    /// Belt-and-braces for the "absent, not fabricated" contract: the wire
1393    /// omits empty fields, but don't let that depend on the serializer —
1394    /// drop present-but-empty fields, and fold an identity with no
1395    /// information at all into `None`.
1396    fn map_whois_result(
1397        data: WhoisResultEventData,
1398    ) -> Result<Option<super::super::TailscalePeerIdentity>, NetworkError> {
1399        if !data.error.is_empty() {
1400            return Err(NetworkError::SidecarError(data.error));
1401        }
1402        Ok(data
1403            .identity
1404            .map(super::super::TailscalePeerIdentity::normalized)
1405            .filter(|identity| !identity.is_empty()))
1406    }
1407
1408    /// Map `proxy:list` payload entries to the public list entries.
1409    fn proxy_entries(proxies: Vec<ProxyInfoEventData>) -> Vec<ProxyListEntry> {
1410        proxies
1411            .into_iter()
1412            .map(|p| ProxyListEntry {
1413                id: p.id,
1414                name: p.name,
1415                listen_port: p.listen_port,
1416                target_host: p.target_host,
1417                target_port: p.target_port,
1418                target_scheme: p.target_scheme,
1419                url: p.url,
1420            })
1421            .collect()
1422    }
1423
1424    /// Wait for a dial to complete: the bridge delivers the `TcpStream` on
1425    /// success, while the broker-routed `bridge:dialResult` reply reports
1426    /// failures (`dialResult` has carried the request id since the bridge
1427    /// protocol's first version, so this path needs no version gate). A
1428    /// success reply only confirms the socket is coming — keep waiting for
1429    /// the bridge to deliver it.
1430    pub(super) async fn await_dial_result(
1431        bridge: &Bridge,
1432        request_id: &str,
1433        mut dial_rx: oneshot::Receiver<TcpStream>,
1434        mut reply: ReplyGuard,
1435        timeout: Duration,
1436    ) -> Result<TcpStream, NetworkError> {
1437        let result = tokio::time::timeout(timeout, async {
1438            let mut reply_pending = true;
1439            loop {
1440                tokio::select! {
1441                    stream_result = &mut dial_rx => {
1442                        return stream_result
1443                            .map_err(|_| NetworkError::DialFailed("dial cancelled".into()));
1444                    }
1445                    reply_result = reply.recv(), if reply_pending => {
1446                        match reply_result {
1447                            Ok(SidecarInternalEvent::DialFailed { error, .. }) => {
1448                                return Err(NetworkError::DialFailed(error));
1449                            }
1450                            Ok(SidecarInternalEvent::Error { code, message }) => {
1451                                return Err(NetworkError::DialFailed(format!(
1452                                    "[{code}] {message}"
1453                                )));
1454                            }
1455                            // DialSucceeded — or a dropped slot during
1456                            // shutdown: no failure to report; the socket,
1457                            // or the timeout, decides from here.
1458                            _ => reply_pending = false,
1459                        }
1460                    }
1461                }
1462            }
1463        })
1464        .await
1465        .unwrap_or(Err(NetworkError::DialTimeout(timeout)));
1466
1467        // Clean up the pending dial on any error — including timeout.
1468        if result.is_err() {
1469            bridge.remove_dial(request_id).await;
1470        }
1471
1472        result
1473    }
1474}
1475
1476#[cfg(test)]
1477mod config_debug_tests {
1478    use super::*;
1479
1480    #[test]
1481    fn tailscale_config_debug_redacts_auth_key() {
1482        let config = TailscaleConfig {
1483            binary_path: PathBuf::from("/opt/sidecar"),
1484            app_id: "demo".to_string(),
1485            device_id: "01JZZZZZZZZZZZZZZZZZZZZZZZ".to_string(),
1486            device_name: "dev".to_string(),
1487            hostname: "truffle-demo-dev".to_string(),
1488            state_dir: "/tmp/state".to_string(),
1489            auth_key: Some("dummy-auth-SECRET123".to_string()),
1490            ephemeral: None,
1491            tags: None,
1492            idle_timeout_secs: None,
1493        };
1494        let dbg = format!("{config:?}");
1495        assert!(!dbg.contains("SECRET123"));
1496        assert!(dbg.contains("[REDACTED]"));
1497    }
1498}