aurum_core/remote/
client.rs1use super::status::redact_secret;
4use crate::error::{ProviderError, Result, UserError};
5use crate::postprocess::truncate_chars;
6use reqwest::{Client, Method, RequestBuilder, StatusCode};
7use std::time::Duration;
8use url::Url;
9
10pub const DEFAULT_OPENROUTER_ORIGIN: &str = "https://openrouter.ai";
12
13#[derive(Debug, Clone)]
15pub struct RemoteEndpoint {
16 pub base_url: String,
17 pub is_official: bool,
19 pub credentials_allowed: bool,
21}
22
23#[derive(Debug, Clone)]
25pub struct RemotePolicy {
26 pub connect_timeout: Duration,
28 pub total_timeout: Duration,
30 pub use_system_proxy: bool,
32 pub allow_custom_credentialed_endpoint: bool,
35 pub allow_loopback_http: bool,
37}
38
39impl Default for RemotePolicy {
40 fn default() -> Self {
41 Self {
42 connect_timeout: Duration::from_secs(30),
43 total_timeout: Duration::from_secs(600),
44 use_system_proxy: false,
45 allow_custom_credentialed_endpoint: false,
46 allow_loopback_http: false,
47 }
48 }
49}
50
51pub fn validate_endpoint(raw: &str, policy: &RemotePolicy) -> Result<RemoteEndpoint> {
53 let trimmed = raw.trim().trim_end_matches('/');
54 if trimmed.is_empty() {
55 return Err(UserError::InvalidConfig {
56 reason: "remote base URL is empty".into(),
57 }
58 .into());
59 }
60 let url = Url::parse(trimmed).map_err(|e| UserError::InvalidConfig {
61 reason: format!("invalid remote base URL: {e}"),
62 })?;
63
64 if url.username() != "" || url.password().is_some() {
65 return Err(UserError::InvalidConfig {
66 reason: "remote base URL must not embed userinfo/credentials".into(),
67 }
68 .into());
69 }
70
71 let scheme = url.scheme();
72 let host = url.host_str().unwrap_or("").to_ascii_lowercase();
73 let is_loopback = matches!(host.as_str(), "127.0.0.1" | "localhost" | "::1");
74 let is_official = host == "openrouter.ai" && scheme == "https";
75
76 match scheme {
77 "https" => {}
78 "http" if policy.allow_loopback_http && is_loopback => {}
79 "http" => {
80 return Err(UserError::InvalidConfig {
81 reason: format!(
82 "HTTP remote endpoints are only allowed for loopback test mode (got {trimmed})"
83 ),
84 }
85 .into());
86 }
87 other => {
88 return Err(UserError::InvalidConfig {
89 reason: format!("unsupported URL scheme '{other}' (use https)"),
90 }
91 .into());
92 }
93 }
94
95 let credentials_allowed = is_official
96 || (is_loopback && policy.allow_loopback_http)
97 || (policy.allow_custom_credentialed_endpoint && scheme == "https");
98 if !credentials_allowed {
99 return Err(UserError::InvalidConfig {
100 reason: format!(
101 "credentialed remote endpoint '{trimmed}' is not the official OpenRouter origin.\n \
102 Hint: set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
103 or use {DEFAULT_OPENROUTER_ORIGIN}."
104 ),
105 }
106 .into());
107 }
108
109 Ok(RemoteEndpoint {
110 base_url: trimmed.to_string(),
111 is_official,
112 credentials_allowed,
113 })
114}
115
116#[derive(Clone)]
118pub struct HardenedHttpClient {
119 http: Client,
120 endpoint: RemoteEndpoint,
121 policy: RemotePolicy,
122}
123
124impl std::fmt::Debug for HardenedHttpClient {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("HardenedHttpClient")
127 .field("base_url", &self.endpoint.base_url)
128 .field("is_official", &self.endpoint.is_official)
129 .finish()
130 }
131}
132
133impl HardenedHttpClient {
134 pub fn build(base_url: Option<&str>, policy: RemotePolicy) -> Result<Self> {
135 let raw = base_url
136 .map(|s| s.trim())
137 .filter(|s| !s.is_empty())
138 .unwrap_or("https://openrouter.ai/api/v1");
139 let endpoint = validate_endpoint(raw, &policy)?;
140
141 let mut builder = Client::builder()
142 .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
143 .connect_timeout(policy.connect_timeout)
144 .timeout(policy.total_timeout)
145 .redirect(reqwest::redirect::Policy::none());
147
148 if !policy.use_system_proxy {
149 builder = builder.no_proxy();
150 }
151
152 let http = builder.build().map_err(|e| ProviderError::Network {
153 provider: "remote".into(),
154 reason: e.to_string(),
155 })?;
156
157 Ok(Self {
158 http,
159 endpoint,
160 policy,
161 })
162 }
163
164 pub fn endpoint(&self) -> &RemoteEndpoint {
165 &self.endpoint
166 }
167
168 pub fn base_url(&self) -> &str {
169 &self.endpoint.base_url
170 }
171
172 pub fn policy(&self) -> &RemotePolicy {
173 &self.policy
174 }
175
176 pub fn request(&self, method: Method, path: &str, api_key: &str) -> Result<RequestBuilder> {
178 if !self.endpoint.credentials_allowed {
179 return Err(UserError::InvalidConfig {
180 reason: "credentials are not allowed for this endpoint under current policy".into(),
181 }
182 .into());
183 }
184 let path = path.trim_start_matches('/');
185 let url = format!("{}/{}", self.endpoint.base_url, path);
186 if let Ok(u) = Url::parse(&url) {
188 let base = Url::parse(&self.endpoint.base_url).ok();
189 if let Some(b) = base {
190 if u.origin() != b.origin() {
191 return Err(UserError::InvalidConfig {
192 reason: "request URL origin diverged from validated endpoint".into(),
193 }
194 .into());
195 }
196 }
197 }
198 Ok(self
199 .http
200 .request(method, url)
201 .header("Authorization", format!("Bearer {api_key}"))
202 .header("HTTP-Referer", "https://github.com/joe-broadhead/aurum")
203 .header("X-Title", "Aurum")
204 .header(
205 "X-Request-Id",
206 format!(
207 "aurum-{}",
208 std::time::SystemTime::now()
209 .duration_since(std::time::UNIX_EPOCH)
210 .map(|d| d.as_millis())
211 .unwrap_or(0)
212 ),
213 ))
214 }
215
216 pub fn get_raw(&self) -> &Client {
217 &self.http
218 }
219}
220
221pub fn map_http_status(provider: &str, status: StatusCode, body: &str) -> Result<()> {
223 let safe = redact_secret(&truncate_chars(body, 300));
224 match status.as_u16() {
225 200..=299 => Ok(()),
226 401 | 403 => Err(ProviderError::Auth {
227 provider: provider.into(),
228 reason: safe,
229 }
230 .into()),
231 429 => Err(ProviderError::RateLimited {
232 provider: provider.into(),
233 }
234 .into()),
235 402 => Err(ProviderError::QuotaExceeded {
236 provider: provider.into(),
237 reason: safe,
238 }
239 .into()),
240 _ => Err(ProviderError::Remote {
241 provider: provider.into(),
242 reason: format!("HTTP {status}: {safe}"),
243 }
244 .into()),
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn official_endpoint_ok() {
254 let ep =
255 validate_endpoint("https://openrouter.ai/api/v1", &RemotePolicy::default()).unwrap();
256 assert!(ep.is_official);
257 assert!(ep.credentials_allowed);
258 }
259
260 #[test]
261 fn foreign_host_rejected_by_default() {
262 let err =
263 validate_endpoint("https://evil.example/api", &RemotePolicy::default()).unwrap_err();
264 assert_eq!(err.exit_code(), 2);
265 assert!(
266 err.to_string().contains("allow_custom_endpoint")
267 || err.to_string().contains("official")
268 );
269 }
270
271 #[test]
272 fn custom_allowed_with_opt_in() {
273 let policy = RemotePolicy {
274 allow_custom_credentialed_endpoint: true,
275 ..Default::default()
276 };
277 let ep = validate_endpoint("https://compatible.example/v1", &policy).unwrap();
278 assert!(!ep.is_official);
279 assert!(ep.credentials_allowed);
280 }
281
282 #[test]
283 fn rejects_userinfo() {
284 let err = validate_endpoint(
285 "https://user:pass@openrouter.ai/api/v1",
286 &RemotePolicy::default(),
287 )
288 .unwrap_err();
289 assert!(err.to_string().contains("userinfo") || err.to_string().contains("credential"));
290 }
291
292 #[test]
293 fn loopback_http_for_tests() {
294 let policy = RemotePolicy {
295 allow_loopback_http: true,
296 ..Default::default()
297 };
298 let ep = validate_endpoint("http://127.0.0.1:9", &policy).unwrap();
299 assert!(ep.credentials_allowed);
300 }
301
302 #[test]
303 fn http_non_loopback_rejected() {
304 assert!(validate_endpoint("http://evil.example", &RemotePolicy::default()).is_err());
305 }
306}