Skip to main content

truffle_core/proxy/
mod.rs

1//! Reverse proxy subsystem.
2//!
3//! Exposes local HTTP/HTTPS ports to the Tailscale mesh network.
4//! The Go sidecar handles the actual HTTP reverse proxying (TLS listener,
5//! `httputil.ReverseProxy`, WebSocket hijack). This module provides the
6//! Rust API surface and mesh discovery.
7
8pub mod discovery;
9pub mod types;
10
11use std::collections::HashMap;
12use std::sync::Mutex;
13use tokio::sync::broadcast;
14
15pub use types::*;
16
17use crate::network::{NetworkProvider, ProxyAddParams};
18use crate::node::{Node, NodeError};
19
20/// Internal state for the proxy subsystem, stored in [`Node`].
21///
22/// Runtime sidecar errors (`SERVE_ERROR`, `CONNECTION_REFUSED`, …) reach
23/// `event_tx` through a node-scoped forwarding task that subscribes to the
24/// provider's [`proxy_runtime_errors`](crate::network::NetworkProvider::proxy_runtime_errors)
25/// stream (RFC 023 G5 fix) — see `Node::spawn_proxy_error_forwarder`.
26pub(crate) struct ProxyState {
27    /// Broadcast channel for proxy lifecycle events.
28    pub(crate) event_tx: broadcast::Sender<ProxyEvent>,
29    /// Currently active proxies (local to this node).
30    pub(crate) proxies: Mutex<HashMap<String, ProxyInfo>>,
31}
32
33impl ProxyState {
34    pub(crate) fn new() -> Self {
35        let (event_tx, _) = broadcast::channel(64);
36        Self {
37            event_tx,
38            proxies: Mutex::new(HashMap::new()),
39        }
40    }
41
42    /// Record a runtime engine error: mark the proxy `Error` and emit
43    /// [`ProxyEvent::Error`]. Errors for ids that never started are dropped
44    /// — add-time failures are returned by [`Proxy::add`] itself, and
45    /// re-emitting them here would announce a proxy that never existed.
46    pub(crate) fn record_runtime_error(&self, id: String, code: String, message: String) {
47        {
48            let mut proxies = self.proxies.lock().expect("proxy state poisoned");
49            match proxies.get_mut(&id) {
50                Some(info) => info.status = ProxyStatus::Error(format!("[{code}] {message}")),
51                None => return,
52            }
53        }
54        let _ = self.event_tx.send(ProxyEvent::Error { id, code, message });
55    }
56}
57
58/// Handle for interacting with the reverse proxy subsystem.
59///
60/// Obtained via [`Node::proxy()`]. Follows the same pattern as
61/// [`FileTransfer`](crate::file_transfer::FileTransfer).
62pub struct Proxy<'a, N: NetworkProvider + 'static> {
63    node: &'a Node<N>,
64}
65
66impl<'a, N: NetworkProvider + 'static> Proxy<'a, N> {
67    pub(crate) fn new(node: &'a Node<N>) -> Self {
68        Self { node }
69    }
70
71    fn state(&self) -> &ProxyState {
72        &self.node.proxy_state
73    }
74
75    /// Subscribe to proxy lifecycle events.
76    pub fn subscribe(&self) -> broadcast::Receiver<ProxyEvent> {
77        self.state().event_tx.subscribe()
78    }
79
80    /// Get a snapshot of all locally active proxies.
81    pub fn list(&self) -> Vec<ProxyInfo> {
82        self.state()
83            .proxies
84            .lock()
85            .expect("proxy state poisoned")
86            .values()
87            .cloned()
88            .collect()
89    }
90
91    /// Add a reverse proxy that forwards traffic from a port on the mesh to
92    /// a local target — or, with `routes`, to several targets and static
93    /// directories by path prefix (RFC 023 §7).
94    ///
95    /// Sends the `proxy:add` command to the sidecar and waits for confirmation.
96    /// On success, stores the proxy in local state and emits a
97    /// [`ProxyEvent::Started`] event.
98    pub async fn add(&self, config: ProxyConfig) -> Result<ProxyInfo, NodeError> {
99        // RFC 023 G4: proxy listeners honor the same reserved-port rule as
100        // raw listeners instead of failing at the sidecar OS bind.
101        crate::node::ensure_port_unreserved(config.listen_port, self.node.ws_port)?;
102        validate_config(&config).map_err(NodeError::ConnectionFailed)?;
103
104        let params = ProxyAddParams {
105            id: config.id.clone(),
106            name: config.name.clone(),
107            listen_port: config.listen_port,
108            target_host: config.target.host.clone(),
109            target_port: config.target.port,
110            target_scheme: config.target.scheme.clone(),
111            tls: config.tls,
112            allow_non_loopback: config.allow_non_loopback,
113            allow: config.allow.clone(),
114            routes: config.routes.clone(),
115        };
116
117        let result = self.node.network.proxy_add(params).await?;
118
119        let info = ProxyInfo {
120            id: result.id.clone(),
121            name: config.name,
122            listen_port: result.listen_port,
123            target: config.target,
124            url: result.url.clone(),
125            status: ProxyStatus::Running,
126        };
127
128        // Store in local state
129        self.state()
130            .proxies
131            .lock()
132            .expect("proxy state poisoned")
133            .insert(info.id.clone(), info.clone());
134
135        // Emit event
136        let _ = self.state().event_tx.send(ProxyEvent::Started {
137            id: result.id,
138            url: result.url,
139            listen_port: result.listen_port,
140        });
141
142        Ok(info)
143    }
144
145    /// Remove a reverse proxy by ID.
146    ///
147    /// Sends the `proxy:remove` command to the sidecar and waits for
148    /// confirmation. On success, removes the proxy from local state and
149    /// emits a [`ProxyEvent::Stopped`] event.
150    pub async fn remove(&self, id: &str) -> Result<(), NodeError> {
151        self.node.network.proxy_remove(id).await?;
152
153        // Remove from local state
154        self.state()
155            .proxies
156            .lock()
157            .expect("proxy state poisoned")
158            .remove(id);
159
160        // Emit event
161        let _ = self
162            .state()
163            .event_tx
164            .send(ProxyEvent::Stopped { id: id.to_string() });
165
166        Ok(())
167    }
168}
169
170/// Config-shape validation shared by every binding (RFC 023 §7): route
171/// prefixes are absolute, each route names exactly one backend, and option
172/// combinations that would silently do nothing are rejected here rather
173/// than half-applied by the engine.
174fn validate_config(config: &ProxyConfig) -> Result<(), String> {
175    for route in &config.routes {
176        if !route.prefix.starts_with('/') {
177            return Err(format!(
178                "route prefix {:?} must start with '/'",
179                route.prefix
180            ));
181        }
182        match (&route.target_url, &route.dir) {
183            (Some(_), Some(_)) => {
184                return Err(format!(
185                    "route {:?} sets both targetUrl and dir — pick one",
186                    route.prefix
187                ));
188            }
189            (None, None) => {
190                return Err(format!(
191                    "route {:?} needs a targetUrl or a dir",
192                    route.prefix
193                ));
194            }
195            _ => {}
196        }
197        if route.fallback.is_some() && route.dir.is_none() {
198            return Err(format!(
199                "route {:?}: fallback only applies to dir routes",
200                route.prefix
201            ));
202        }
203        if route.strip_prefix && route.target_url.is_none() {
204            return Err(format!(
205                "route {:?}: stripPrefix only applies to targetUrl routes",
206                route.prefix
207            ));
208        }
209    }
210    if config.allow.iter().any(|glob| glob.trim().is_empty()) {
211        return Err("allow globs must be non-empty strings".into());
212    }
213    if config
214        .routes
215        .iter()
216        .flat_map(|r| r.allow.iter())
217        .any(|glob| glob.trim().is_empty())
218    {
219        return Err("route allow globs must be non-empty strings".into());
220    }
221    Ok(())
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn base_config(routes: Vec<ProxyRoute>) -> ProxyConfig {
229        ProxyConfig {
230            id: "p".into(),
231            name: "p".into(),
232            listen_port: 8443,
233            target: ProxyTarget::default(),
234            announce: true,
235            tls: true,
236            allow_non_loopback: false,
237            allow: vec![],
238            routes,
239        }
240    }
241
242    fn url_route(prefix: &str) -> ProxyRoute {
243        ProxyRoute {
244            prefix: prefix.into(),
245            target_url: Some("http://localhost:3000".into()),
246            dir: None,
247            fallback: None,
248            strip_prefix: false,
249            allow: vec![],
250        }
251    }
252
253    #[test]
254    fn validate_accepts_v1_shape_and_mixed_routes() {
255        assert!(validate_config(&base_config(vec![])).is_ok());
256        let dir_route = ProxyRoute {
257            prefix: "/".into(),
258            target_url: None,
259            dir: Some("/srv/public".into()),
260            fallback: Some("/index.html".into()),
261            strip_prefix: false,
262            allow: vec![],
263        };
264        assert!(validate_config(&base_config(vec![url_route("/api"), dir_route])).is_ok());
265    }
266
267    #[test]
268    fn validate_rejects_bad_routes() {
269        // Relative prefix.
270        assert!(validate_config(&base_config(vec![url_route("api")])).is_err());
271        // Both backends.
272        let mut both = url_route("/x");
273        both.dir = Some("/srv".into());
274        assert!(validate_config(&base_config(vec![both])).is_err());
275        // Neither backend.
276        let mut neither = url_route("/x");
277        neither.target_url = None;
278        assert!(validate_config(&base_config(vec![neither])).is_err());
279        // fallback without dir.
280        let mut fb = url_route("/x");
281        fb.fallback = Some("/index.html".into());
282        assert!(validate_config(&base_config(vec![fb])).is_err());
283        // stripPrefix on a dir route.
284        let strip_dir = ProxyRoute {
285            prefix: "/x".into(),
286            target_url: None,
287            dir: Some("/srv".into()),
288            fallback: None,
289            strip_prefix: true,
290            allow: vec![],
291        };
292        assert!(validate_config(&base_config(vec![strip_dir])).is_err());
293    }
294
295    #[test]
296    fn validate_rejects_empty_allow_globs() {
297        let mut config = base_config(vec![]);
298        config.allow = vec!["*@corp.com".into(), "  ".into()];
299        assert!(validate_config(&config).is_err());
300
301        let mut route = url_route("/api");
302        route.allow = vec![String::new()];
303        assert!(validate_config(&base_config(vec![route])).is_err());
304    }
305
306    #[test]
307    fn runtime_errors_only_mark_known_proxies() {
308        let state = ProxyState::new();
309        let mut rx = state.event_tx.subscribe();
310
311        // Unknown id: dropped (add-time failures are returned by add()).
312        state.record_runtime_error("ghost".into(), "SERVE_ERROR".into(), "boom".into());
313        assert!(rx.try_recv().is_err());
314
315        state.proxies.lock().unwrap().insert(
316            "web".into(),
317            ProxyInfo {
318                id: "web".into(),
319                name: "web".into(),
320                listen_port: 8443,
321                target: ProxyTarget::default(),
322                url: "https://x.ts.net:8443".into(),
323                status: ProxyStatus::Running,
324            },
325        );
326        state.record_runtime_error("web".into(), "CONNECTION_REFUSED".into(), "down".into());
327        match rx.try_recv() {
328            Ok(ProxyEvent::Error { id, code, .. }) => {
329                assert_eq!(id, "web");
330                assert_eq!(code, "CONNECTION_REFUSED");
331            }
332            other => panic!("expected Error event, got {other:?}"),
333        }
334        let proxies = state.proxies.lock().unwrap();
335        assert!(matches!(
336            proxies.get("web").unwrap().status,
337            ProxyStatus::Error(_)
338        ));
339    }
340}