Skip to repository content336 lines · 11.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:35:47.581Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
upstream.rs
1//! Upstream HTTP proxy configuration: URL parsing, auth, and `NO_PROXY` rules.
2//!
3//! When the parent process has `HTTPS_PROXY=http://corp.proxy:3128` (etc.)
4//! in its environment, we propagate that to our in-process proxy as an
5//! [`UpstreamProxy`]. Outbound connections from our proxy then chain through
6//! the corporate one — except for hosts that match `NO_PROXY`, which connect
7//! direct.
8//!
9//! Only HTTP upstream proxies are supported (URL scheme must be `http://`).
10//! HTTPS upstream proxies are rare in practice and would require a TLS
11//! handshake on our side; out of scope for v1.
12//!
13//! `NO_PROXY` matching is delegated to the [`proxyvars`] crate, which
14//! implements the curl/Go convention (CIDR, hostnames with optional port,
15//! leading-dot subdomain rules, `*` wildcard, implicit loopback bypass).
16
17use anyhow::{Context, Result, anyhow, bail};
18use proxyvars::NoProxy;
19use std::fmt;
20use std::sync::Arc;
21use url::Url;
22
23/// Upstream HTTP proxy used for chaining outbound connections.
24///
25/// `Clone` shares the parsed `NoProxy` matcher via `Arc` rather than
26/// re-parsing the rule list — cloning is essentially free, and the matcher
27/// itself isn't cheap to construct (CIDR / IpNet / wildcard parsing).
28#[derive(Clone)]
29pub struct UpstreamProxy {
30 /// Hostname of the upstream proxy. Could be a hostname or IP literal —
31 /// the upstream proxy is in the user's trusted environment, so IP
32 /// literals are fine here even though we forbid them in the
33 /// agent-facing allowlist.
34 pub host: String,
35 /// Port of the upstream proxy.
36 pub port: u16,
37 /// Optional `user:pass` for `Proxy-Authorization: Basic ...`. Forwarded
38 /// verbatim when chaining.
39 pub auth: Option<UpstreamAuth>,
40 /// Hosts that bypass the upstream and connect direct, parsed from
41 /// `NO_PROXY` / `no_proxy`. `proxyvars::NoProxy` doesn't impl `Clone`,
42 /// so we share via `Arc`.
43 no_proxy: Arc<NoProxy>,
44}
45
46impl fmt::Debug for UpstreamProxy {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 // Include host/port but not creds; the `NoProxy` matcher is opaque
49 // and its `Debug` would just enumerate internal rule variants —
50 // not useful for our log/error output.
51 f.debug_struct("UpstreamProxy")
52 .field("host", &self.host)
53 .field("port", &self.port)
54 .field("auth", &self.auth.as_ref().map(|_| "<redacted>"))
55 .finish_non_exhaustive()
56 }
57}
58
59/// Basic credentials for the upstream proxy.
60#[derive(Clone)]
61pub struct UpstreamAuth {
62 pub user: String,
63 pub password: String,
64}
65
66impl fmt::Debug for UpstreamAuth {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 // Don't print credentials, even in Debug output.
69 f.debug_struct("UpstreamAuth")
70 .field("user", &"<redacted>")
71 .field("password", &"<redacted>")
72 .finish()
73 }
74}
75
76impl UpstreamProxy {
77 /// Reads the parent process's environment for `HTTPS_PROXY` / `HTTP_PROXY`
78 /// / `ALL_PROXY` (and their lowercase forms) plus `NO_PROXY` / `no_proxy`.
79 /// Returns `Ok(None)` if no upstream proxy is configured.
80 ///
81 /// Thin convenience over [`UpstreamProxy::parse`] — kept tiny so the
82 /// pure function stays the testable surface.
83 pub fn from_env() -> Result<Option<Self>> {
84 let url = first_nonempty_env(&[
85 "HTTPS_PROXY",
86 "https_proxy",
87 "ALL_PROXY",
88 "all_proxy",
89 "HTTP_PROXY",
90 "http_proxy",
91 ]);
92 let no_proxy = first_nonempty_env(&["NO_PROXY", "no_proxy"]);
93 Self::parse(url.as_deref(), no_proxy.as_deref())
94 }
95
96 /// Pure constructor: parse an `(HTTPS_PROXY, NO_PROXY)` pair into an
97 /// `UpstreamProxy`. Returns `Ok(None)` if `https_proxy` is `None` (no
98 /// upstream configured); `NO_PROXY` is honored either way for callers
99 /// that want to inspect the matcher independently.
100 ///
101 /// Accepts `http://[user[:pass]@]host[:port][/path]`. Path is ignored;
102 /// the port defaults to 80 when omitted. A bare `host:port` (no scheme)
103 /// is also accepted as a compatibility concession to common proxy-config
104 /// conventions.
105 pub fn parse(https_proxy: Option<&str>, no_proxy: Option<&str>) -> Result<Option<Self>> {
106 let no_proxy = Arc::new(NoProxy::from(no_proxy.unwrap_or("")));
107 let Some(url) = https_proxy else {
108 return Ok(None);
109 };
110 Self::parse_url(url, no_proxy).map(Some)
111 }
112
113 fn parse_url(url: &str, no_proxy: Arc<NoProxy>) -> Result<Self> {
114 let url = url.trim();
115 // `Url::parse` requires a scheme. Add `http://` if missing — the
116 // bare-`host:port` form is common in proxy configs and we want to
117 // keep accepting it.
118 let normalized = if url.contains("://") {
119 url.to_string()
120 } else {
121 format!("http://{url}")
122 };
123
124 let parsed = Url::parse(&normalized)
125 .with_context(|| format!("parsing upstream proxy url '{url}'"))?;
126
127 if parsed.scheme() != "http" {
128 bail!(
129 "unsupported upstream proxy scheme '{}://' in '{url}': only http:// is supported",
130 parsed.scheme()
131 );
132 }
133
134 let host = parsed
135 .host_str()
136 .ok_or_else(|| anyhow!("upstream proxy url '{url}' has no host"))?;
137 // Brackets around IPv6 literals are URL syntax, not part of the
138 // address; strip them so the host can be handed to
139 // `ToSocketAddrs` (which rejects bracketed forms).
140 let host = host
141 .strip_prefix('[')
142 .and_then(|stripped| stripped.strip_suffix(']'))
143 .unwrap_or(host)
144 .to_string();
145 // `Url::port` elides scheme-default ports, so a plain `.port()`
146 // can't distinguish `http://proxy:80` from `http://proxy`; treat
147 // both as port 80.
148 let port = parsed
149 .port_or_known_default()
150 .ok_or_else(|| anyhow!("upstream proxy url '{url}' must include a port"))?;
151
152 let auth = if parsed.username().is_empty() {
153 None
154 } else {
155 Some(UpstreamAuth {
156 // `Url` returns percent-encoded credentials; decode for the
157 // wire `Basic` token, which expects the raw `user:pass`.
158 user: percent_decode(parsed.username()),
159 password: percent_decode(parsed.password().unwrap_or("")),
160 })
161 };
162
163 Ok(Self {
164 host,
165 port,
166 auth,
167 no_proxy,
168 })
169 }
170
171 /// Whether the given destination should bypass this upstream proxy
172 /// (i.e., connect direct because it matches `NO_PROXY`).
173 ///
174 /// Builds a URL from `host:port` and delegates to [`NoProxy::matches`].
175 /// Scheme is fixed to `http://` — `proxyvars` only uses the scheme to
176 /// supply a default port, and we always pass an explicit port, so it
177 /// doesn't actually affect the decision.
178 pub fn bypasses(&self, host: &str, port: u16) -> bool {
179 // Callers pass normalized (bracket-free) hosts; IPv6 literals need
180 // their brackets back to form a valid URL.
181 let url = if host.contains(':') && !host.starts_with('[') {
182 format!("http://[{host}]:{port}")
183 } else {
184 format!("http://{host}:{port}")
185 };
186 self.no_proxy.matches(&url)
187 }
188}
189
190fn first_nonempty_env(names: &[&str]) -> Option<String> {
191 for name in names {
192 if let Ok(value) = std::env::var(name)
193 && !value.trim().is_empty()
194 {
195 return Some(value);
196 }
197 }
198 None
199}
200
201fn percent_decode(input: &str) -> String {
202 percent_encoding::percent_decode_str(input)
203 .decode_utf8_lossy()
204 .into_owned()
205}
206
207impl fmt::Display for UpstreamProxy {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 // Don't print credentials. IPv6 literal hosts (stored bracket-free)
210 // get their URL brackets back.
211 if self.host.contains(':') {
212 write!(f, "http://[{}]:{}", self.host, self.port)
213 } else {
214 write!(f, "http://{}:{}", self.host, self.port)
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn parse(url: &str) -> UpstreamProxy {
224 UpstreamProxy::parse(Some(url), None).unwrap().unwrap()
225 }
226
227 #[test]
228 fn parses_plain_host_port() {
229 let p = parse("http://corp.proxy:3128");
230 assert_eq!(p.host, "corp.proxy");
231 assert_eq!(p.port, 3128);
232 assert!(p.auth.is_none());
233 }
234
235 #[test]
236 fn parses_bare_host_port() {
237 let p = parse("corp.proxy:3128");
238 assert_eq!(p.host, "corp.proxy");
239 assert_eq!(p.port, 3128);
240 }
241
242 #[test]
243 fn parses_with_basic_auth() {
244 let p = parse("http://alice:s3cret@corp.proxy:3128");
245 let auth = p.auth.unwrap();
246 assert_eq!(auth.user, "alice");
247 assert_eq!(auth.password, "s3cret");
248 }
249
250 #[test]
251 fn parses_percent_encoded_auth() {
252 let p = parse("http://alice%40example:p%40ss@corp.proxy:3128");
253 let auth = p.auth.unwrap();
254 assert_eq!(auth.user, "alice@example");
255 assert_eq!(auth.password, "p@ss");
256 }
257
258 #[test]
259 fn parses_bracketed_ipv6() {
260 // The brackets are URL syntax; the stored host must be the bare
261 // address so `ToSocketAddrs` can use it.
262 let p = parse("http://[::1]:3128");
263 assert_eq!(p.host, "::1");
264 assert_eq!(p.port, 3128);
265 assert_eq!(format!("{p}"), "http://[::1]:3128");
266 }
267
268 #[test]
269 fn ignores_trailing_path() {
270 let p = parse("http://corp.proxy:3128/some/path");
271 assert_eq!(p.host, "corp.proxy");
272 assert_eq!(p.port, 3128);
273 }
274
275 #[test]
276 fn rejects_non_http_scheme() {
277 let err = UpstreamProxy::parse(Some("https://corp.proxy:3128"), None).unwrap_err();
278 assert!(format!("{err}").contains("unsupported upstream proxy scheme"));
279 }
280
281 #[test]
282 fn defaults_to_port_80() {
283 // `Url::port` elides scheme-default ports, so both of these must
284 // come out as 80 rather than "missing port" errors.
285 assert_eq!(parse("http://corp.proxy").port, 80);
286 assert_eq!(parse("http://corp.proxy:80").port, 80);
287 }
288
289 #[test]
290 fn returns_none_when_https_proxy_is_none() {
291 assert!(UpstreamProxy::parse(None, None).unwrap().is_none());
292 assert!(
293 UpstreamProxy::parse(None, Some("example.com"))
294 .unwrap()
295 .is_none()
296 );
297 }
298
299 #[test]
300 fn display_hides_credentials() {
301 let p = parse("http://alice:s3cret@corp.proxy:3128");
302 let s = format!("{p}");
303 assert!(!s.contains("alice"));
304 assert!(!s.contains("s3cret"));
305 assert!(s.contains("corp.proxy"));
306 }
307
308 #[test]
309 fn no_proxy_bypasses_listed_host() {
310 let p = UpstreamProxy::parse(Some("http://corp.proxy:3128"), Some("internal.example"))
311 .unwrap()
312 .unwrap();
313 assert!(p.bypasses("internal.example", 443));
314 assert!(p.bypasses("api.internal.example", 443));
315 assert!(!p.bypasses("github.com", 443));
316 }
317
318 #[test]
319 fn no_proxy_bypasses_loopback_implicitly() {
320 let p = UpstreamProxy::parse(Some("http://corp.proxy:3128"), None)
321 .unwrap()
322 .unwrap();
323 assert!(p.bypasses("127.0.0.1", 80));
324 assert!(p.bypasses("[::1]", 80));
325 }
326
327 #[test]
328 fn no_proxy_with_cidr() {
329 let p = UpstreamProxy::parse(Some("http://corp.proxy:3128"), Some("10.0.0.0/8"))
330 .unwrap()
331 .unwrap();
332 assert!(p.bypasses("10.5.4.3", 443));
333 assert!(!p.bypasses("11.0.0.1", 443));
334 }
335}
336