1pub 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
20pub(crate) struct ProxyState {
27 pub(crate) event_tx: broadcast::Sender<ProxyEvent>,
29 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 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
58pub 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 pub fn subscribe(&self) -> broadcast::Receiver<ProxyEvent> {
77 self.state().event_tx.subscribe()
78 }
79
80 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 pub async fn add(&self, config: ProxyConfig) -> Result<ProxyInfo, NodeError> {
99 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 self.state()
130 .proxies
131 .lock()
132 .expect("proxy state poisoned")
133 .insert(info.id.clone(), info.clone());
134
135 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 pub async fn remove(&self, id: &str) -> Result<(), NodeError> {
151 self.node.network.proxy_remove(id).await?;
152
153 self.state()
155 .proxies
156 .lock()
157 .expect("proxy state poisoned")
158 .remove(id);
159
160 let _ = self
162 .state()
163 .event_tx
164 .send(ProxyEvent::Stopped { id: id.to_string() });
165
166 Ok(())
167 }
168}
169
170fn 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 assert!(validate_config(&base_config(vec![url_route("api")])).is_err());
271 let mut both = url_route("/x");
273 both.dir = Some("/srv".into());
274 assert!(validate_config(&base_config(vec![both])).is_err());
275 let mut neither = url_route("/x");
277 neither.target_url = None;
278 assert!(validate_config(&base_config(vec![neither])).is_err());
279 let mut fb = url_route("/x");
281 fb.fallback = Some("/index.html".into());
282 assert!(validate_config(&base_config(vec![fb])).is_err());
283 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 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}