Skip to main content

truffle_core/network/
mod.rs

1//! Layer 3: Network — Peer discovery, addressing, encrypted tunnels.
2//!
3//! This module defines the [`NetworkProvider`] trait, the public API for Layer 3.
4//! The trait is generic — no Tailscale-specific types leak through.
5//!
6//! The [`tailscale`] submodule contains the [`TailscaleProvider`](tailscale::TailscaleProvider) implementation
7//! that wraps the Go sidecar (tsnet) and bridge.
8
9pub mod tailscale;
10
11use std::net::{IpAddr, Ipv4Addr, SocketAddr};
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15use tokio::net::TcpStream;
16use tokio::sync::broadcast;
17
18// ---------------------------------------------------------------------------
19// NetworkProvider trait — the public API of Layer 3
20// ---------------------------------------------------------------------------
21
22/// Provides network-level peer discovery and raw connectivity.
23///
24/// The primary implementation is [`TailscaleProvider`](tailscale::TailscaleProvider)
25/// which uses tsnet via a Go sidecar. The trait is designed to be swappable —
26/// future providers could use mDNS (LAN), STUN/TURN (internet), or Bluetooth.
27///
28/// # Layer rules
29///
30/// - Layer 3 does NOT know about WebSocket, QUIC, or any Layer 4 protocol
31/// - Layer 3 does NOT know about envelopes, namespaces, or messages
32/// - Layer 3 provides raw `TcpStream` — not framed connections
33/// - `peer_events()` is the ONLY source of peer events — no polling, no announce
34///
35/// All async methods return `Send` futures so that `Node<N>` can be used
36/// inside `tokio::spawn` tasks (required by the file transfer subsystem
37/// and any other code that needs to spawn tasks with node access).
38pub trait NetworkProvider: Send + Sync {
39    /// Start the network provider.
40    ///
41    /// This spawns child processes, binds ports, and performs authentication.
42    /// If authentication is required (e.g., Tailscale browser auth), the provider
43    /// emits `NetworkPeerEvent::AuthRequired { url }` via `peer_events()` and
44    /// **keeps waiting** until auth completes or the timeout is reached.
45    ///
46    /// Callers should subscribe to `peer_events()` BEFORE calling `start()` to
47    /// receive auth URLs and display them to the user.
48    ///
49    /// Returns `Ok(())` when the provider is fully online, or `Err` if auth
50    /// times out or a fatal error occurs.
51    fn start(&mut self) -> impl std::future::Future<Output = Result<(), NetworkError>> + Send;
52
53    /// Stop the network provider and clean up all resources.
54    fn stop(&self) -> impl std::future::Future<Output = Result<(), NetworkError>> + Send;
55
56    /// Local node's identity (stable ID, hostname, display name).
57    ///
58    /// Returns a clone of the current cached identity. The identity is
59    /// populated after [`start()`](Self::start) completes and may be
60    /// updated when the sidecar reports `tsnet:status`.
61    fn local_identity(&self) -> NodeIdentity;
62
63    /// Local node's network address.
64    ///
65    /// Returns a clone of the current cached address. The address is
66    /// populated after [`start()`](Self::start) completes and may be
67    /// updated when the sidecar reports `tsnet:status`.
68    fn local_addr(&self) -> PeerAddr;
69
70    // ── Discovery (event-driven, NOT polling) ──
71
72    /// Subscribe to peer events. Fires immediately when peers join/leave/update.
73    ///
74    /// Uses `WatchIPNBus` for real-time notifications instead of polling.
75    fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent>;
76
77    /// Snapshot of all currently known peers.
78    fn peers(&self) -> impl std::future::Future<Output = Vec<NetworkPeer>> + Send;
79
80    // ── Connectivity primitives for Layer 4 ──
81
82    /// Dial a TCP connection to a peer via the encrypted Tailscale tunnel.
83    ///
84    /// Returns a plain `TcpStream` — all bridge internals (pending_dials,
85    /// session token, binary headers) are hidden inside the provider.
86    fn dial_tcp(
87        &self,
88        addr: &str,
89        port: u16,
90    ) -> impl std::future::Future<Output = Result<TcpStream, NetworkError>> + Send;
91
92    /// Dial a TCP connection with explicit options (e.g. a TLS override).
93    ///
94    /// The default implementation ignores the options and delegates to
95    /// [`dial_tcp`](Self::dial_tcp), so providers that don't support the
96    /// options keep working unchanged.
97    fn dial_tcp_opts(
98        &self,
99        addr: &str,
100        port: u16,
101        opts: DialOpts,
102    ) -> impl std::future::Future<Output = Result<TcpStream, NetworkError>> + Send {
103        let _ = opts;
104        self.dial_tcp(addr, port)
105    }
106
107    /// Listen for incoming TCP connections on a port via the Tailscale tunnel.
108    ///
109    /// The returned receiver yields `TcpStream`s for each accepted connection.
110    fn listen_tcp(
111        &self,
112        port: u16,
113    ) -> impl std::future::Future<Output = Result<NetworkTcpListener, NetworkError>> + Send;
114
115    /// As [`listen_tcp`](Self::listen_tcp), with options (RFC 023 §7.1).
116    ///
117    /// Providers without TLS listener support reject `tls: true` rather than
118    /// silently serving plaintext.
119    fn listen_tcp_opts(
120        &self,
121        port: u16,
122        opts: ListenOpts,
123    ) -> impl std::future::Future<Output = Result<NetworkTcpListener, NetworkError>> + Send {
124        async move {
125            if opts.tls {
126                return Err(NetworkError::Unsupported(
127                    "TLS listeners are not supported by this provider".into(),
128                ));
129            }
130            self.listen_tcp(port).await
131        }
132    }
133
134    /// Stop listening on a previously opened port.
135    fn unlisten_tcp(
136        &self,
137        port: u16,
138    ) -> impl std::future::Future<Output = Result<(), NetworkError>> + Send;
139
140    /// Bind a UDP socket on a port via the network tunnel.
141    ///
142    /// Returns a [`NetworkUdpSocket`] that transparently relays datagrams
143    /// through the network provider. The socket supports `send_to` / `recv_from`
144    /// with full remote address information.
145    ///
146    /// Not all providers support UDP. Returns [`NetworkError::Internal`] if
147    /// the provider has not implemented UDP transport yet.
148    fn bind_udp(
149        &self,
150        port: u16,
151    ) -> impl std::future::Future<Output = Result<NetworkUdpSocket, NetworkError>> + Send;
152
153    // ── Diagnostics ──
154
155    /// Ping a peer via the network layer (Tailscale TSMP).
156    fn ping(
157        &self,
158        addr: &str,
159    ) -> impl std::future::Future<Output = Result<PingResult, NetworkError>> + Send;
160
161    /// Node health info (key expiry, connection quality, warnings).
162    fn health(&self) -> impl std::future::Future<Output = HealthInfo> + Send;
163
164    /// Tailnet identity of the node that owns `addr` (Tailscale WhoIs).
165    ///
166    /// Unlike [`peers`](Self::peers), this reaches ANY tailnet device — other
167    /// apps' nodes, plain machines, tagged nodes — not just app-filtered mesh
168    /// peers. `Ok(None)` means the lookup succeeded but the tailnet has no
169    /// identity for the address (anonymous — absent, not fabricated).
170    ///
171    /// The default implementation reports the capability as unsupported, so
172    /// providers without WhoIs (e.g. mocks) keep compiling unchanged.
173    fn whois(
174        &self,
175        _addr: &str,
176    ) -> impl std::future::Future<Output = Result<Option<TailscalePeerIdentity>, NetworkError>> + Send
177    {
178        std::future::ready(Err(NetworkError::Unsupported(
179            "whois not supported by this provider".into(),
180        )))
181    }
182
183    // ── Reverse proxy (optional, requires sidecar) ──
184
185    /// Start a reverse proxy. Only supported by providers with sidecar integration.
186    fn proxy_add(
187        &self,
188        _config: ProxyAddParams,
189    ) -> impl std::future::Future<Output = Result<ProxyAddResult, NetworkError>> + Send {
190        std::future::ready(Err(NetworkError::Unsupported(
191            "proxy_add not supported by this provider".into(),
192        )))
193    }
194
195    /// Stop a reverse proxy.
196    fn proxy_remove(
197        &self,
198        _id: &str,
199    ) -> impl std::future::Future<Output = Result<(), NetworkError>> + Send {
200        std::future::ready(Err(NetworkError::Unsupported(
201            "proxy_remove not supported by this provider".into(),
202        )))
203    }
204
205    /// List active reverse proxies.
206    fn proxy_list(
207        &self,
208    ) -> impl std::future::Future<Output = Result<Vec<ProxyListEntry>, NetworkError>> + Send {
209        std::future::ready(Err(NetworkError::Unsupported(
210            "proxy_list not supported by this provider".into(),
211        )))
212    }
213
214    /// Subscribe to runtime proxy-engine errors (RFC 023 G5). `None` for
215    /// providers without a proxy engine — the caller then skips spawning a
216    /// forwarding task.
217    fn proxy_runtime_errors(&self) -> Option<broadcast::Receiver<ProxyRuntimeError>> {
218        None
219    }
220}
221
222// ---------------------------------------------------------------------------
223// Public types
224// ---------------------------------------------------------------------------
225
226/// Options for [`NetworkProvider::dial_tcp_opts`].
227#[derive(Debug, Clone, Copy, Default)]
228pub struct DialOpts {
229    /// Override TLS wrapping of the dial. `None` = no wrap on current
230    /// sidecars (RFC 023 D4 removed the legacy wrap-iff-port-443 rule);
231    /// `Some(true)` / `Some(false)` force it on / off (RFC 021 §6.4).
232    pub tls: Option<bool>,
233}
234
235/// Options for [`NetworkProvider::listen_tcp_opts`].
236#[derive(Debug, Clone, Copy, Default)]
237pub struct ListenOpts {
238    /// Terminate TLS at the provider using its platform certificates
239    /// (Tailscale: tsnet `ListenTLS` with automatic MagicDNS / Let's
240    /// Encrypt certs, RFC 023 §7.1 — requires MagicDNS + HTTPS enabled on
241    /// the tailnet). `false` = plain TCP listener.
242    pub tls: bool,
243}
244
245/// A peer as seen by the network layer (Layer 3).
246///
247/// Contains only information available from the network provider itself
248/// (e.g., Tailscale status). No transport or session state.
249#[derive(Debug, Clone)]
250pub struct NetworkPeer {
251    /// Stable node ID from the network provider.
252    pub id: String,
253    /// Hostname on the network (e.g., "truffle-cli-abc123").
254    pub hostname: String,
255    /// Network IP address (e.g., 100.x.x.x for Tailscale).
256    pub ip: IpAddr,
257    /// Whether the peer is currently online.
258    pub online: bool,
259    /// Direct endpoint address, if connected directly.
260    pub cur_addr: Option<String>,
261    /// DERP relay name if connection is relayed.
262    pub relay: Option<String>,
263    /// Operating system of the peer.
264    pub os: Option<String>,
265    /// Last time the peer was seen online (RFC 3339 string).
266    pub last_seen: Option<String>,
267    /// Key expiry timestamp (RFC 3339 string).
268    pub key_expiry: Option<String>,
269    /// DNS name on the tailnet (e.g., "truffle-cli-abc123.tailnet.ts.net").
270    pub dns_name: Option<String>,
271}
272
273/// Events emitted when network peers change state.
274#[derive(Debug, Clone)]
275pub enum NetworkPeerEvent {
276    /// A new peer appeared on the network.
277    Joined(NetworkPeer),
278    /// A peer left the network (by stable node ID).
279    Left(String),
280    /// A peer's metadata changed (IP, relay, online status, etc.).
281    Updated(NetworkPeer),
282    /// Authentication is required. The URL should be shown to the user.
283    /// Emitted during `start()` — the provider keeps waiting for auth to complete.
284    /// May be emitted multiple times if the URL expires and is refreshed.
285    AuthRequired {
286        /// URL the user should open in a browser.
287        url: String,
288    },
289}
290
291/// Network address of a peer.
292#[derive(Debug, Clone, Default)]
293pub struct PeerAddr {
294    /// IP address (100.x.x.x for Tailscale).
295    pub ip: Option<IpAddr>,
296    /// Hostname on the network.
297    pub hostname: String,
298    /// DNS name on the tailnet.
299    pub dns_name: Option<String>,
300}
301
302/// Identity of the local node on the network.
303///
304/// Carries the RFC 017 identity triple: `app_id` (namespace), `device_id`
305/// (stable per-device ULID), and `device_name` (human-readable). The
306/// Tailscale hostname and stable ID are kept alongside as escape hatches
307/// and for internal filtering.
308#[derive(Debug, Clone, Default)]
309pub struct NodeIdentity {
310    /// Application namespace identifier (RFC 017 §5.1).
311    pub app_id: String,
312    /// Stable per-device ULID (RFC 017 §5.4).
313    pub device_id: String,
314    /// Human-readable device name, original (unsanitised) string.
315    pub device_name: String,
316    /// Tailscale hostname — `truffle-{app_id}-{slug(device_name)}`.
317    pub tailscale_hostname: String,
318    /// Tailscale stable node ID (populated after the sidecar reaches Running).
319    pub tailscale_id: String,
320    /// DNS name on the tailnet.
321    pub dns_name: Option<String>,
322    /// Tailscale IP address.
323    pub ip: Option<IpAddr>,
324}
325
326/// Result of a network-level ping.
327#[derive(Debug, Clone)]
328pub struct PingResult {
329    /// Round-trip latency.
330    pub latency: Duration,
331    /// Connection type description (e.g., "direct" or "relay:sfo").
332    pub connection: String,
333    /// Direct peer endpoint address, if available.
334    pub peer_addr: Option<String>,
335}
336
337/// Health information from the network provider.
338#[derive(Debug, Clone, Default)]
339pub struct HealthInfo {
340    /// Current backend state (e.g., "Running", "NeedsLogin").
341    pub state: String,
342    /// Key expiry timestamp (RFC 3339), if applicable.
343    pub key_expiry: Option<String>,
344    /// Active health warnings.
345    pub warnings: Vec<String>,
346    /// Whether the network is fully operational.
347    pub healthy: bool,
348}
349
350/// A listener for incoming TCP connections via the network provider.
351///
352/// Wraps a channel that receives `TcpStream`s from the bridge. The bridge
353/// internals (binary headers, session tokens) are completely hidden.
354pub struct NetworkTcpListener {
355    /// Port this listener is bound to.
356    pub port: u16,
357    /// Receiver for incoming connections.
358    pub incoming: tokio::sync::mpsc::Receiver<IncomingConnection>,
359}
360
361/// An incoming TCP connection with metadata.
362#[derive(Debug)]
363pub struct IncomingConnection {
364    /// The raw TCP stream (bridge headers already consumed).
365    pub stream: TcpStream,
366    /// Remote address of the connecting peer.
367    pub remote_addr: String,
368    /// Remote DNS name or peer identity JSON.
369    pub remote_identity: String,
370    /// Port the connection arrived on.
371    pub port: u16,
372}
373
374/// A remote peer's Tailscale-authenticated identity, parsed from the WhoIs
375/// JSON the sidecar attaches to inbound bridge connections (the
376/// [`remote_identity`](IncomingConnection::remote_identity) field of
377/// [`IncomingConnection`]).
378///
379/// Produced by Layer 3 — the Go sidecar's `resolvePeerIdentity` writes this
380/// JSON into the bridge header — and consumed by Layer 4+ transports and the
381/// bindings. Every field is optional: the sidecar omits empty ones, WhoIs may
382/// return no Node, and legacy sidecars send a bare DNS name that does not
383/// parse as this struct at all.
384#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct TailscalePeerIdentity {
387    /// Tailnet DNS name (e.g., "kitchen.tailnet.ts.net"), trailing dot stripped.
388    pub dns_name: Option<String>,
389    /// Tailscale login (owner) name, e.g., "alice@example.com".
390    pub login_name: Option<String>,
391    /// Human-readable display name from the identity provider.
392    pub display_name: Option<String>,
393    /// URL of the peer owner's profile picture.
394    pub profile_pic_url: Option<String>,
395    /// Stable Tailscale node ID (WhoIs `Node.StableID`).
396    pub node_id: Option<String>,
397}
398
399impl TailscalePeerIdentity {
400    /// Map present-but-empty fields to `None`. The wire contract says empty
401    /// fields are omitted, but the "absent, not fabricated" guarantee must
402    /// not depend on the peer's serializer honoring that.
403    pub(crate) fn normalized(mut self) -> Self {
404        fn drop_empty(field: &mut Option<String>) {
405            if field.as_deref().is_some_and(str::is_empty) {
406                *field = None;
407            }
408        }
409        drop_empty(&mut self.dns_name);
410        drop_empty(&mut self.login_name);
411        drop_empty(&mut self.display_name);
412        drop_empty(&mut self.profile_pic_url);
413        drop_empty(&mut self.node_id);
414        self
415    }
416
417    /// True when no field carries any information.
418    pub(crate) fn is_empty(&self) -> bool {
419        self.dns_name.is_none()
420            && self.login_name.is_none()
421            && self.display_name.is_none()
422            && self.profile_pic_url.is_none()
423            && self.node_id.is_none()
424    }
425}
426
427// ---------------------------------------------------------------------------
428// NetworkUdpSocket — address-framed UDP relay wrapper
429// ---------------------------------------------------------------------------
430
431/// A UDP socket that relays datagrams through the network provider.
432///
433/// Under the hood, the Rust side talks to a local relay socket. Each outbound
434/// datagram is prefixed with a 6-byte address header (`[4-byte IPv4][2-byte port BE]`)
435/// so the relay (Go sidecar) knows where to forward the packet on the tsnet
436/// network. Inbound datagrams arrive with the same header prepended by the relay.
437///
438/// This struct hides the framing — callers use `send_to` / `recv_from` with
439/// normal `SocketAddr` values.
440pub struct NetworkUdpSocket {
441    /// The underlying tokio UDP socket connected to the local relay.
442    inner: tokio::net::UdpSocket,
443    /// The tsnet-bound port (the logical port on the Tailscale network).
444    tsnet_port: u16,
445}
446
447/// Address header size: 4 bytes IPv4 + 2 bytes port (big-endian).
448const UDP_ADDR_HEADER_SIZE: usize = 6;
449
450impl NetworkUdpSocket {
451    /// Create a new `NetworkUdpSocket` from a tokio UdpSocket and the tsnet port.
452    pub(crate) fn new(inner: tokio::net::UdpSocket, tsnet_port: u16) -> Self {
453        Self { inner, tsnet_port }
454    }
455
456    /// Send a datagram to the specified address via the relay.
457    ///
458    /// The relay will forward the datagram to the target on the tsnet network.
459    pub async fn send_to(&self, data: &[u8], addr: SocketAddr) -> Result<usize, NetworkError> {
460        let ip = match addr.ip() {
461            IpAddr::V4(v4) => v4,
462            IpAddr::V6(_) => {
463                return Err(NetworkError::Internal(
464                    "NetworkUdpSocket: IPv6 not supported in relay framing".into(),
465                ));
466            }
467        };
468        let port = addr.port();
469
470        // Build framed packet: [4-byte IPv4][2-byte port BE][payload]
471        let mut framed = Vec::with_capacity(UDP_ADDR_HEADER_SIZE + data.len());
472        framed.extend_from_slice(&ip.octets());
473        framed.extend_from_slice(&port.to_be_bytes());
474        framed.extend_from_slice(data);
475
476        tracing::debug!(
477            target_addr = %addr,
478            payload_len = data.len(),
479            framed_len = framed.len(),
480            relay_addr = ?self.inner.peer_addr().ok(),
481            "NetworkUdpSocket: sending framed datagram to relay"
482        );
483
484        let n = self.inner.send(&framed).await.map_err(NetworkError::Io)?;
485
486        tracing::debug!(
487            bytes_sent = n,
488            "NetworkUdpSocket: framed datagram sent to relay"
489        );
490
491        // Return the number of payload bytes sent (subtract header)
492        Ok(n.saturating_sub(UDP_ADDR_HEADER_SIZE))
493    }
494
495    /// Receive a datagram from the relay, returning the payload and sender address.
496    ///
497    /// The relay prepends a 6-byte address header to each inbound datagram.
498    pub async fn recv_from(&self, buf: &mut [u8]) -> Result<(usize, SocketAddr), NetworkError> {
499        tracing::debug!("NetworkUdpSocket: waiting for inbound datagram from relay...");
500
501        // Read into a temporary buffer that includes space for the header
502        let mut tmp = vec![0u8; UDP_ADDR_HEADER_SIZE + buf.len()];
503        let n = self.inner.recv(&mut tmp).await.map_err(NetworkError::Io)?;
504
505        if n < UDP_ADDR_HEADER_SIZE {
506            return Err(NetworkError::Internal(
507                "NetworkUdpSocket: received packet too short for address header".into(),
508            ));
509        }
510
511        // Parse address header
512        let ip = Ipv4Addr::new(tmp[0], tmp[1], tmp[2], tmp[3]);
513        let port = u16::from_be_bytes([tmp[4], tmp[5]]);
514        let addr = SocketAddr::new(IpAddr::V4(ip), port);
515
516        // Copy payload to caller's buffer
517        let payload_len = n - UDP_ADDR_HEADER_SIZE;
518        buf[..payload_len].copy_from_slice(&tmp[UDP_ADDR_HEADER_SIZE..n]);
519
520        tracing::debug!(
521            raw_bytes = n,
522            payload_len = payload_len,
523            sender_addr = %addr,
524            "NetworkUdpSocket: received inbound datagram from relay"
525        );
526
527        Ok((payload_len, addr))
528    }
529
530    /// Return the local address of the underlying relay socket.
531    pub fn local_addr(&self) -> Result<SocketAddr, NetworkError> {
532        self.inner.local_addr().map_err(NetworkError::Io)
533    }
534
535    /// Return the tsnet-bound port (the logical port on the Tailscale network).
536    pub fn tsnet_port(&self) -> u16 {
537        self.tsnet_port
538    }
539
540    /// Get a reference to the inner tokio UdpSocket.
541    ///
542    /// This is the socket connected to the local relay. Direct reads/writes
543    /// bypass the address framing — prefer `send_to` / `recv_from` instead.
544    pub fn inner(&self) -> &tokio::net::UdpSocket {
545        &self.inner
546    }
547}
548
549impl std::fmt::Debug for NetworkUdpSocket {
550    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551        f.debug_struct("NetworkUdpSocket")
552            .field("tsnet_port", &self.tsnet_port)
553            .field("local_addr", &self.inner.local_addr().ok())
554            .finish()
555    }
556}
557
558// ---------------------------------------------------------------------------
559// Reverse proxy types (used by NetworkProvider trait methods)
560// ---------------------------------------------------------------------------
561
562/// Parameters for starting a reverse proxy via the network provider.
563#[derive(Debug, Clone)]
564pub struct ProxyAddParams {
565    /// Unique identifier for this proxy.
566    pub id: String,
567    /// Human-readable name.
568    pub name: String,
569    /// Port on which the proxy listens on the tailnet.
570    pub listen_port: u16,
571    /// Target host to forward to (e.g., "localhost").
572    pub target_host: String,
573    /// Target port to forward to.
574    pub target_port: u16,
575    /// Target scheme ("http" or "https").
576    pub target_scheme: String,
577    /// Terminate TLS on the tailnet listener (RFC 023; `true` = the v1
578    /// always-TLS behavior, `false` = plain HTTP listener).
579    pub tls: bool,
580    /// Permit non-loopback targets (RFC 023 §9.3; default deny).
581    pub allow_non_loopback: bool,
582    /// loginName allow globs; empty = whole tailnet (RFC 023 §9.7).
583    pub allow: Vec<String>,
584    /// Path-prefix routes; empty = the single-target v1 shape.
585    pub routes: Vec<ProxyRoute>,
586}
587
588/// One path-prefix route of a v2 proxy (RFC 023 §7). Wire-shaped: exactly
589/// one of `target_url` / `dir` must be set. Longest prefix wins (D11).
590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
591#[serde(rename_all = "camelCase")]
592pub struct ProxyRoute {
593    /// Path prefix to match (must start with `/`).
594    pub prefix: String,
595    /// Proxy target URL (e.g. `http://localhost:8000`).
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub target_url: Option<String>,
598    /// Static directory to serve (absolute path on the serving machine).
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub dir: Option<String>,
601    /// SPA fallback rewritten on static misses (e.g. `/index.html`);
602    /// only meaningful with `dir`.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub fallback: Option<String>,
605    /// Strip the matched prefix before proxying (default false — D11;
606    /// only meaningful with `target_url`).
607    #[serde(default)]
608    pub strip_prefix: bool,
609    /// Per-route loginName globs; overrides the config-level `allow`.
610    #[serde(default, skip_serializing_if = "Vec::is_empty")]
611    pub allow: Vec<String>,
612}
613
614/// A runtime error from the provider's proxy engine after a successful add
615/// (RFC 023 G5 fix — e.g. sidecar `SERVE_ERROR` / `CONNECTION_REFUSED`).
616#[derive(Debug, Clone)]
617pub struct ProxyRuntimeError {
618    /// Proxy id the error belongs to.
619    pub id: String,
620    /// Machine-readable error code.
621    pub code: String,
622    /// Human-readable detail.
623    pub message: String,
624}
625
626/// Result of successfully starting a reverse proxy.
627#[derive(Debug, Clone)]
628pub struct ProxyAddResult {
629    /// Proxy ID (echoed back from sidecar).
630    pub id: String,
631    /// Actual listen port (may differ from requested if 0 was passed).
632    pub listen_port: u16,
633    /// Fully qualified URL (e.g., "<https://hostname.ts.net:3001>").
634    pub url: String,
635}
636
637/// Entry in the proxy list from the network provider.
638#[derive(Debug, Clone)]
639pub struct ProxyListEntry {
640    pub id: String,
641    pub name: String,
642    pub listen_port: u16,
643    pub target_host: String,
644    pub target_port: u16,
645    pub target_scheme: String,
646    pub url: String,
647}
648
649// ---------------------------------------------------------------------------
650// Errors
651// ---------------------------------------------------------------------------
652
653/// Errors from Layer 3 network operations.
654#[derive(Debug, thiserror::Error)]
655pub enum NetworkError {
656    /// The network provider is not running.
657    #[error("network provider not running")]
658    NotRunning,
659
660    /// The network provider is already running.
661    #[error("network provider already running")]
662    AlreadyRunning,
663
664    /// Failed to start the network provider.
665    #[error("start failed: {0}")]
666    StartFailed(String),
667
668    /// Failed to stop the network provider.
669    #[error("stop failed: {0}")]
670    StopFailed(String),
671
672    /// Authentication is required.
673    #[error("authentication required: {url}")]
674    AuthRequired { url: String },
675
676    /// A dial operation failed.
677    #[error("dial failed: {0}")]
678    DialFailed(String),
679
680    /// A dial operation timed out.
681    #[error("dial timed out after {0:?}")]
682    DialTimeout(Duration),
683
684    /// A listen operation failed.
685    #[error("listen failed: {0}")]
686    ListenFailed(String),
687
688    /// A ping operation failed.
689    #[error("ping failed: {0}")]
690    PingFailed(String),
691
692    /// The sidecar process crashed or is unavailable.
693    #[error("sidecar error: {0}")]
694    SidecarError(String),
695
696    /// Bridge communication error.
697    #[error("bridge error: {0}")]
698    BridgeError(String),
699
700    /// I/O error.
701    #[error("io error: {0}")]
702    Io(#[from] std::io::Error),
703
704    /// Serialization error.
705    #[error("serialization error: {0}")]
706    Serialize(#[from] serde_json::Error),
707
708    /// Generic internal error.
709    #[error("internal error: {0}")]
710    Internal(String),
711
712    /// The operation is not supported by this provider.
713    #[error("unsupported: {0}")]
714    Unsupported(String),
715
716    /// A reverse-proxy operation failed.
717    #[error("proxy error: {0}")]
718    ProxyError(String),
719}