1use crate::config::{BackendAuth, BackendConfig, BackendTransport};
9use axum::http::{HeaderName, HeaderValue};
10use std::collections::HashMap;
11
12pub const DEFAULT_BACKEND_NAME: &str = "default";
15
16pub(crate) fn validate_http_endpoint(url: &str, field: &str) -> Result<(), String> {
18 let authority = url
19 .split_once("://")
20 .map(|(_, rest)| rest.split(['/', '?', '#']).next().unwrap_or_default());
21 if url.trim() != url || authority.is_none_or(str::is_empty) {
22 return Err(format!(
23 "{field} must be a non-empty http:// or https:// URL with a host"
24 ));
25 }
26 let parsed = reqwest::Url::parse(url).map_err(|_| format!("{field} is not a valid URL"))?;
27 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none_or(str::is_empty) {
28 return Err(format!("{field} must be an http:// or https:// URL with a host"));
29 }
30 if !parsed.username().is_empty() || parsed.password().is_some() {
31 return Err(format!(
32 "{field} must not contain credentials; configure authentication separately"
33 ));
34 }
35 Ok(())
36}
37
38pub fn is_private_ip(ip: std::net::IpAddr) -> bool {
42 match ip {
43 std::net::IpAddr::V4(v4) => {
44 let [first, second, ..] = v4.octets();
45 v4.is_loopback()
46 || v4.is_private()
47 || v4.is_link_local()
48 || (first == 100 && (second & 0xc0) == 64)
49 }
50 std::net::IpAddr::V6(v6) => {
51 let [first, ..] = v6.segments();
52 v6.is_loopback()
53 || (first & 0xfe00) == 0xfc00
54 || (first & 0xffc0) == 0xfe80
55 || v6
56 .to_ipv4_mapped()
57 .is_some_and(|v4| is_private_ip(std::net::IpAddr::V4(v4)))
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, Default)]
69pub struct PrivateOnlyResolver;
70
71impl reqwest::dns::Resolve for PrivateOnlyResolver {
72 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
73 let host = name.as_str().to_owned();
74 Box::pin(async move {
75 let addresses: Vec<std::net::SocketAddr> =
76 tokio::net::lookup_host((host.as_str(), 0)).await?.collect();
77 if addresses.is_empty() {
78 return Err(format!("backend host {host} has no addresses").into());
79 }
80 if let Some(public) = addresses.iter().find(|address| !is_private_ip(address.ip())) {
81 tracing::warn!(%host, address = %public.ip(), "refused a backend host that resolves to a public address");
82 return Err(format!(
83 "backend host {host} resolves to non-private address {}",
84 public.ip()
85 )
86 .into());
87 }
88 Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs)
89 })
90 }
91}
92
93pub(crate) fn validate_private_endpoint(url: &str, field: &str) -> Result<(), String> {
96 let parsed = reqwest::Url::parse(url).map_err(|_| format!("{field} is not a valid URL"))?;
97 let host = parsed.host_str().unwrap_or_default();
98 let Ok(ip) = host
99 .trim_start_matches('[')
100 .trim_end_matches(']')
101 .parse::<std::net::IpAddr>()
102 else {
103 return Ok(());
104 };
105 if is_private_ip(ip) {
106 Ok(())
107 } else {
108 Err(format!(
109 "{field} uses the public address {ip}, but require_private_backends only allows private backend addresses"
110 ))
111 }
112}
113
114pub(crate) fn validate_configs(configs: &[BackendConfig]) -> Vec<String> {
116 let mut errors = Vec::new();
117 let mut names = std::collections::HashSet::new();
118 let mut tools = std::collections::HashSet::new();
119 let mut default_seen = false;
120 for backend in configs {
121 if backend.name.trim().is_empty() || backend.name.chars().any(|c| c.is_whitespace() || c.is_control())
122 {
123 errors.push("backend name must not be empty or contain whitespace/control characters".into());
124 }
125 if backend.name == DEFAULT_BACKEND_NAME {
126 errors.push("backend name \"default\" is reserved for tool_upstream_url".into());
127 }
128 if !names.insert(&backend.name) {
129 errors.push(format!("duplicate backend name {:?}", backend.name));
130 }
131 if let Err(error) = validate_http_endpoint(&backend.url, &format!("backend {:?} url", backend.name)) {
132 errors.push(error);
133 }
134 if backend.tools.is_empty() {
135 if default_seen {
136 errors.push("multiple backends with empty tools list (only one default allowed)".into());
137 }
138 default_seen = true;
139 }
140 for tool in &backend.tools {
141 if tool.is_empty()
142 || !tool.is_ascii()
143 || tool
144 .bytes()
145 .any(|b| b.is_ascii_uppercase() || b.is_ascii_whitespace() || b.is_ascii_control())
146 {
147 errors.push(format!("backend {:?}: tool {tool:?} must be non-empty lowercase ASCII without whitespace or controls", backend.name));
148 }
149 if !tools.insert(tool) {
150 errors.push(format!(
151 "tool {tool:?} mapped to multiple backends or repeated in backend {:?}",
152 backend.name
153 ));
154 }
155 }
156 match &backend.auth {
157 BackendAuth::StaticEnv(value) | BackendAuth::StaticFile(value) if value.trim().is_empty() => {
158 errors.push(format!(
159 "backend {:?}: authentication source must not be empty",
160 backend.name
161 ));
162 }
163 _ => {}
164 }
165 }
166 errors
167}
168
169#[derive(Debug, Clone)]
171pub struct ResolvedBackend {
172 pub name: String,
174 pub url: String,
176 pub auth_header: Option<(HeaderName, HeaderValue)>,
178 pub auth_mode: BackendAuthMode,
180 pub transport: BackendTransport,
182 pub tools: Vec<String>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum BackendAuthMode {
189 None,
191 Static,
193 Exchange,
195 ForwardToken,
197}
198
199pub struct BackendRouter {
201 tool_to_backend: HashMap<String, usize>,
202 backends: Vec<ResolvedBackend>,
203 default_idx: Option<usize>,
204}
205
206impl BackendRouter {
207 pub fn new(
212 configs: &[BackendConfig],
213 fallback_url: Option<&str>,
214 fallback_auth: Option<HeaderValue>,
215 ) -> Result<Self, String> {
216 let errors = validate_configs(configs);
217 if !errors.is_empty() {
218 return Err(errors.join("; "));
219 }
220 if let Some(url) = fallback_url {
221 validate_http_endpoint(url, "tool_upstream_url")?;
222 }
223 let mut backends = Vec::new();
224 let mut tool_to_backend = HashMap::new();
225 let mut default_idx = None;
226
227 for (idx, cfg) in configs.iter().enumerate() {
228 let what = format!("backend {:?} bearer token", cfg.name);
229 let secret = match &cfg.auth {
230 BackendAuth::None | BackendAuth::Exchange | BackendAuth::ForwardToken => None,
231 BackendAuth::StaticEnv(var) => {
232 crate::pipeline::read_secret(Some(var), None, &what).map_err(|e| e.to_string())?
233 }
234 BackendAuth::StaticFile(path) => {
235 crate::pipeline::read_secret(None, Some(path), &what).map_err(|e| e.to_string())?
236 }
237 };
238 let auth_header = match secret {
239 Some(token) => {
240 let mut value = HeaderValue::from_str(&format!("Bearer {token}"))
241 .map_err(|_| format!("{what} contains bytes that cannot appear in an HTTP header"))?;
242 value.set_sensitive(true);
243 Some((HeaderName::from_static("authorization"), value))
244 }
245 None => None,
246 };
247 let auth_mode = match &cfg.auth {
248 BackendAuth::None => BackendAuthMode::None,
249 BackendAuth::StaticEnv(_) | BackendAuth::StaticFile(_) => BackendAuthMode::Static,
250 BackendAuth::Exchange => BackendAuthMode::Exchange,
251 BackendAuth::ForwardToken => BackendAuthMode::ForwardToken,
252 };
253 backends.push(ResolvedBackend {
254 name: cfg.name.clone(),
255 url: cfg.url.clone(),
256 auth_header,
257 auth_mode,
258 transport: cfg.transport,
259 tools: cfg.tools.clone(),
260 });
261 if cfg.tools.is_empty() {
262 if default_idx.is_some() {
263 return Err(format!(
264 "backend {:?}: multiple backends with empty tools list (only one default allowed)",
265 cfg.name
266 ));
267 }
268 default_idx = Some(idx);
269 }
270 for tool in &cfg.tools {
271 if tool_to_backend.contains_key(tool) {
272 return Err(format!(
273 "tool {tool:?} mapped to multiple backends (backend {:?} conflicts)",
274 cfg.name
275 ));
276 }
277 tool_to_backend.insert(tool.clone(), idx);
278 }
279 }
280
281 if backends.is_empty() {
284 if let Some(url) = fallback_url {
285 let auth_mode = if fallback_auth.is_some() {
286 BackendAuthMode::Static
287 } else {
288 BackendAuthMode::None
289 };
290 backends.push(ResolvedBackend {
291 name: DEFAULT_BACKEND_NAME.into(),
292 url: url.to_owned(),
293 auth_header: fallback_auth.map(|value| (HeaderName::from_static("authorization"), value)),
294 auth_mode,
295 transport: BackendTransport::JsonRpc,
296 tools: Vec::new(),
297 });
298 default_idx = Some(0);
299 }
300 }
301
302 Ok(Self {
303 tool_to_backend,
304 backends,
305 default_idx,
306 })
307 }
308
309 pub fn resolve(&self, tool: &str) -> Option<&ResolvedBackend> {
312 let idx = self.tool_to_backend.get(tool).copied().or(self.default_idx)?;
313 self.backends.get(idx)
314 }
315
316 pub fn len(&self) -> usize {
318 self.backends.len()
319 }
320
321 pub fn is_empty(&self) -> bool {
323 self.backends.is_empty()
324 }
325
326 pub fn iter(&self) -> impl Iterator<Item = &ResolvedBackend> {
328 self.backends.iter()
329 }
330
331 pub fn get(&self, name: &str) -> Option<&ResolvedBackend> {
333 self.backends.iter().find(|backend| backend.name == name)
334 }
335
336 #[must_use]
340 pub fn with_fallback_transport(mut self, transport: BackendTransport) -> Self {
341 for backend in &mut self.backends {
342 if backend.name == DEFAULT_BACKEND_NAME {
343 backend.transport = transport;
344 }
345 }
346 self
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
353
354 use super::*;
355
356 fn backend(name: &str, url: &str, tools: &[&str]) -> BackendConfig {
357 BackendConfig {
358 name: name.into(),
359 url: url.into(),
360 auth: BackendAuth::None,
361 tools: tools.iter().map(|s| (*s).to_owned()).collect(),
362 transport: BackendTransport::default(),
363 }
364 }
365
366 #[test]
367 fn private_address_classification() {
368 for private in [
369 "127.0.0.1",
370 "10.1.2.3",
371 "172.16.0.1",
372 "192.168.1.1",
373 "169.254.1.1",
374 "100.64.0.1",
375 "::1",
376 "fd00::1",
377 "fe80::1",
378 "::ffff:10.0.0.1",
379 ] {
380 assert!(is_private_ip(private.parse().unwrap()), "{private} is private");
381 }
382 for public in [
383 "8.8.8.8",
384 "100.128.0.1",
385 "172.32.0.1",
386 "2001:4860::8888",
387 "::ffff:8.8.8.8",
388 "0.0.0.0",
389 ] {
390 assert!(!is_private_ip(public.parse().unwrap()), "{public} is public");
391 }
392 }
393
394 #[test]
395 fn private_endpoint_literals() {
396 assert!(validate_private_endpoint("http://10.0.0.5:8080/mcp", "f").is_ok());
397 assert!(validate_private_endpoint("http://[fd00::5]:8080/mcp", "f").is_ok());
398 assert!(validate_private_endpoint("http://backend.internal:8080/mcp", "f").is_ok());
399 assert!(validate_private_endpoint("http://8.8.8.8/mcp", "f").is_err());
400 }
401
402 #[tokio::test]
403 async fn private_only_resolver_allows_localhost_only() {
404 use reqwest::dns::Resolve as _;
405 let resolved: Vec<std::net::SocketAddr> = PrivateOnlyResolver
406 .resolve("localhost".parse().unwrap())
407 .await
408 .unwrap()
409 .collect();
410 assert!(resolved.iter().all(|address| address.ip().is_loopback()));
411 }
412
413 #[test]
414 fn explicit_tool_routing() {
415 let router = BackendRouter::new(
416 &[
417 backend("db", "http://db:8080", &["db_write", "db_read"]),
418 backend("search", "http://search:8080", &["search"]),
419 ],
420 None,
421 None,
422 )
423 .unwrap();
424 assert_eq!(router.resolve("db_write").unwrap().name, "db");
425 assert_eq!(router.resolve("search").unwrap().name, "search");
426 assert!(router.resolve("unknown").is_none());
427 }
428
429 #[test]
430 fn router_rejects_invalid_names_urls_and_duplicate_audiences() {
431 for config in [
432 backend("", "http://db", &["read"]),
433 backend("default", "http://db", &["read"]),
434 backend("db", "https:///mcp", &["read"]),
435 backend("db", "file:///mcp", &["read"]),
436 backend("db", "http://db:invalid", &["read"]),
437 ] {
438 assert!(BackendRouter::new(&[config], None, None).is_err());
439 }
440 assert!(BackendRouter::new(
441 &[
442 backend("db", "http://db", &["read"]),
443 backend("db", "http://other", &["write"]),
444 ],
445 None,
446 None
447 )
448 .err()
449 .unwrap()
450 .contains("duplicate backend name"));
451 assert!(BackendRouter::new(&[], Some("http:///mcp"), None).is_err());
452 }
453
454 #[test]
455 fn default_backend_catches_unmapped_tools() {
456 let router = BackendRouter::new(
457 &[
458 backend("db", "http://db:8080", &["db_write"]),
459 backend("fallback", "http://fallback:8080", &[]),
460 ],
461 None,
462 None,
463 )
464 .unwrap();
465 assert_eq!(router.resolve("db_write").unwrap().name, "db");
466 assert_eq!(router.resolve("anything").unwrap().name, "fallback");
467 }
468
469 #[test]
470 fn implicit_default_from_fallback_url() {
471 let router = BackendRouter::new(&[], Some("http://tool:8080"), None).unwrap();
472 assert_eq!(router.resolve("any_tool").unwrap().name, "default");
473 assert_eq!(router.resolve("any_tool").unwrap().url, "http://tool:8080");
474 }
475
476 #[test]
477 fn duplicate_tool_mapping_refused() {
478 let result = BackendRouter::new(
479 &[
480 backend("a", "http://a:8080", &["shared_tool"]),
481 backend("b", "http://b:8080", &["shared_tool"]),
482 ],
483 None,
484 None,
485 );
486 assert!(result.is_err());
487 }
488
489 #[test]
490 fn multiple_default_backends_refused() {
491 let result = BackendRouter::new(
492 &[
493 backend("a", "http://a:8080", &[]),
494 backend("b", "http://b:8080", &[]),
495 ],
496 None,
497 None,
498 );
499 assert!(result.is_err());
500 }
501
502 #[test]
503 fn no_backends_and_no_fallback() {
504 let router = BackendRouter::new(&[], None, None).unwrap();
505 assert!(router.is_empty());
506 assert!(router.resolve("anything").is_none());
507 }
508
509 #[test]
510 fn credential_isolation_between_backends() {
511 std::env::set_var("TEST_BACKEND_A_KEY", "secret-a");
512 let router = BackendRouter::new(
513 &[
514 BackendConfig {
515 name: "a".into(),
516 url: "http://a:8080".into(),
517 auth: BackendAuth::StaticEnv("TEST_BACKEND_A_KEY".into()),
518 tools: vec!["tool_a".into()],
519 transport: Default::default(),
520 },
521 backend("b", "http://b:8080", &["tool_b"]),
522 ],
523 None,
524 None,
525 )
526 .unwrap();
527 let a = router.resolve("tool_a").unwrap();
528 assert!(a.auth_header.is_some());
529 let b = router.resolve("tool_b").unwrap();
530 assert!(b.auth_header.is_none());
531 std::env::remove_var("TEST_BACKEND_A_KEY");
532 }
533
534 fn static_env_backend(name: &str, var: &str) -> BackendConfig {
535 BackendConfig {
536 name: name.into(),
537 url: format!("http://{name}:8080"),
538 auth: BackendAuth::StaticEnv(var.into()),
539 tools: vec![format!("{name}_tool")],
540 transport: Default::default(),
541 }
542 }
543
544 #[test]
545 fn implicit_default_carries_the_tool_upstream_bearer() {
546 let bearer = HeaderValue::from_static("Bearer tool-secret");
547 let router = BackendRouter::new(&[], Some("http://tool:8080/mcp"), Some(bearer)).unwrap();
548 let default = router.resolve("anything").unwrap();
549 assert_eq!(default.name, DEFAULT_BACKEND_NAME);
550 assert_eq!(default.auth_mode, BackendAuthMode::Static);
551 let (name, value) = default
552 .auth_header
553 .as_ref()
554 .expect("tool bearer must ride on the default");
555 assert_eq!(name.as_str(), "authorization");
556 assert_eq!(value.to_str().unwrap(), "Bearer tool-secret");
557 }
558
559 #[test]
560 fn explicit_backends_never_inherit_the_tool_upstream_bearer() {
561 let bearer = HeaderValue::from_static("Bearer tool-secret");
562 let router = BackendRouter::new(
563 &[backend("plain", "http://plain:8080", &["plain_tool"])],
564 Some("http://tool:8080/mcp"),
565 Some(bearer),
566 )
567 .unwrap();
568 assert!(router.resolve("plain_tool").unwrap().auth_header.is_none());
569 assert!(
570 router.resolve("unmapped").is_none(),
571 "with explicit backends, unmapped tools fall back to tool_upstream_url outside the router"
572 );
573 }
574
575 #[test]
576 fn static_credentials_are_sensitive_and_trimmed() {
577 std::env::set_var("AV_TEST_BACKEND_TRIM_KEY", " padded-secret \n");
578 let router = BackendRouter::new(
579 &[static_env_backend("trim", "AV_TEST_BACKEND_TRIM_KEY")],
580 None,
581 None,
582 )
583 .unwrap();
584 std::env::remove_var("AV_TEST_BACKEND_TRIM_KEY");
585 let (_, value) = router.resolve("trim_tool").unwrap().auth_header.clone().unwrap();
586 assert_eq!(value.to_str().unwrap(), "Bearer padded-secret");
587 assert!(
588 value.is_sensitive(),
589 "credential headers must be excluded from logs and HPACK indexing"
590 );
591 }
592
593 #[test]
594 fn empty_or_missing_static_secrets_refuse_to_boot() {
595 std::env::set_var("AV_TEST_BACKEND_EMPTY_KEY", " ");
596 let empty = BackendRouter::new(
597 &[static_env_backend("empty", "AV_TEST_BACKEND_EMPTY_KEY")],
598 None,
599 None,
600 );
601 std::env::remove_var("AV_TEST_BACKEND_EMPTY_KEY");
602 let error = empty.err().expect("whitespace-only secret must be refused");
603 assert!(error.contains("empty"), "got: {error}");
604
605 let missing = BackendRouter::new(
606 &[static_env_backend("missing", "AV_TEST_BACKEND_NEVER_SET_KEY")],
607 None,
608 None,
609 );
610 let error = missing.err().expect("unset env var must be refused");
611 assert!(error.contains("AV_TEST_BACKEND_NEVER_SET_KEY"), "got: {error}");
612 }
613
614 #[cfg(unix)]
615 #[test]
616 fn static_file_secret_is_trimmed_and_must_be_owner_only() {
617 use std::os::unix::fs::PermissionsExt as _;
618 let dir = tempfile::tempdir().unwrap();
619 let path = dir.path().join("backend.token");
620 std::fs::write(&path, "file-secret\n").unwrap();
621 let config = |p: &std::path::Path| BackendConfig {
622 name: "filed".into(),
623 url: "http://filed:8080".into(),
624 auth: BackendAuth::StaticFile(p.to_string_lossy().into_owned()),
625 tools: vec!["filed_tool".into()],
626 transport: Default::default(),
627 };
628
629 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
630 assert!(
631 BackendRouter::new(&[config(&path)], None, None).is_err(),
632 "a world-readable secret file must be refused"
633 );
634
635 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
636 let router = BackendRouter::new(&[config(&path)], None, None).unwrap();
637 let (_, value) = router.resolve("filed_tool").unwrap().auth_header.clone().unwrap();
638 assert_eq!(value.to_str().unwrap(), "Bearer file-secret");
639 }
640}