haematite_cli/mcp/http/
config.rs1use std::collections::BTreeSet;
2use std::net::{IpAddr, SocketAddr};
3use std::num::{NonZeroU64, NonZeroUsize};
4use std::time::Duration;
5
6use serde::Deserialize;
7use ureq::http::{HeaderValue, Uri};
8
9use super::error::Error;
10
11#[derive(Debug, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Config {
19 pub listen: SocketAddr,
20 pub allowed_origins: BTreeSet<String>,
21 #[serde(default = "mcp_path")]
24 pub path: String,
25 #[serde(default)]
28 pub resource_metadata_url: Option<String>,
29 #[serde(default = "connections")]
30 pub max_connections: NonZeroUsize,
31 #[serde(default = "header_timeout")]
32 pub header_timeout_ms: NonZeroU64,
33 #[serde(default = "body_timeout")]
34 pub body_timeout_ms: NonZeroU64,
35}
36
37fn mcp_path() -> String {
38 "/mcp".to_owned()
39}
40
41const fn connections() -> NonZeroUsize {
42 NonZeroUsize::MIN.saturating_add(63)
43}
44
45const fn header_timeout() -> NonZeroU64 {
46 NonZeroU64::MIN.saturating_add(1_999)
47}
48
49const fn body_timeout() -> NonZeroU64 {
50 NonZeroU64::MIN.saturating_add(4_999)
51}
52
53impl Config {
54 pub(super) fn validate(&self) -> Result<(), Error> {
55 if !self.listen.ip().is_loopback() {
56 return Err(Error::config(
57 "listen must be a loopback address behind a TLS proxy",
58 ));
59 }
60 if !self.path.starts_with('/')
61 || self.path.len() > 256
62 || self.path.ends_with('/')
63 || self.path.contains("//")
64 || !self.path.bytes().all(|byte| {
65 byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.')
66 })
67 {
68 return Err(Error::config(
69 "path must be an absolute request path of plain segments",
70 ));
71 }
72 for origin in &self.allowed_origins {
73 canonical_origin(origin)?;
74 }
75 if let Some(url) = &self.resource_metadata_url {
76 trusted(url)?;
77 if url.len() > 2_048 || HeaderValue::from_str(url).is_err() {
78 return Err(Error::config(
79 "resource_metadata_url must fit one safe challenge header",
80 ));
81 }
82 }
83 for millis in [self.header_timeout_ms.get(), self.body_timeout_ms.get()] {
84 if std::time::Instant::now()
85 .checked_add(Duration::from_millis(millis))
86 .is_none()
87 {
88 return Err(Error::config("HTTP deadline is not representable"));
89 }
90 }
91 Ok(())
92 }
93}
94
95fn canonical_origin(value: &str) -> Result<(), Error> {
96 let uri = trusted(value)?;
97 let scheme = uri
98 .scheme_str()
99 .ok_or_else(|| Error::config("origin scheme is missing"))?;
100 let authority = uri
101 .authority()
102 .ok_or_else(|| Error::config("origin authority is missing"))?;
103 let host = authority.host();
104 let normalized = match host.trim_matches(['[', ']']).parse::<IpAddr>() {
105 Ok(IpAddr::V4(address)) => address.to_string(),
106 Ok(IpAddr::V6(address)) => format!("[{address}]"),
107 Err(_) => host.to_ascii_lowercase(),
108 };
109 let port = authority.port_u16();
110 let suffix = match port {
111 Some(80) if scheme == "http" => String::new(),
112 Some(443) if scheme == "https" => String::new(),
113 Some(port) => format!(":{port}"),
114 None => String::new(),
115 };
116 if uri.path() != "/"
117 || uri.query().is_some()
118 || value != format!("{scheme}://{normalized}{suffix}")
119 {
120 return Err(Error::config(
121 "allowed_origins must contain canonical origins without paths",
122 ));
123 }
124 Ok(())
125}
126
127fn trusted(value: &str) -> Result<Uri, Error> {
128 if value.contains('#') || !value.is_ascii() || value.chars().any(|ch| matches!(ch, '"' | '\\'))
129 {
130 return Err(Error::config(
131 "configured URLs must be absolute ASCII URLs without fragments",
132 ));
133 }
134 let uri: Uri = value
135 .parse()
136 .map_err(|_| Error::config("configured URL is not valid"))?;
137 let host = uri
138 .host()
139 .ok_or_else(|| Error::config("configured URL host is missing"))?;
140 let authority = uri
141 .authority()
142 .ok_or_else(|| Error::config("configured URL authority is missing"))?;
143 let loopback = host
144 .trim_matches(['[', ']'])
145 .parse::<IpAddr>()
146 .is_ok_and(|address| address.is_loopback());
147 if authority.as_str().contains('@')
148 || !(uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && loopback))
149 {
150 return Err(Error::config(
151 "configured URL requires HTTPS or a literal loopback HTTP address without credentials",
152 ));
153 }
154 Ok(uri)
155}