///|
/// A proxy server: where to dial, and what to tell it.
priv struct ProxyServer {
/// `http://host:port` or `https://host:port`, the port always written.
origin : String
/// `Basic` credentials from the proxy URL's userinfo, for the
/// `Proxy-Authorization` header. They go to the proxy alone.
authorization : String?
}
///|
/// How a request reaches its origin.
priv enum Route {
Direct
/// An `http://` request handed to the proxy, its target in absolute form
/// (RFC 9112 §3.2.2), on a connection to the proxy.
Forward(ProxyServer)
/// An `https://` request sent through a tunnel the proxy opens with
/// `CONNECT` (RFC 9110 §9.3.6), TLS running end to end inside it.
Tunnel(ProxyServer)
}
///|
/// The proxies a transport uses, fixed when it is made: one for `http://`
/// requests and one for `https://`, each a server or why the setting
/// cannot be used, and the hosts that skip them. A request whose proxy
/// setting is unusable fails rather than going direct.
priv struct Proxies {
http : Result[ProxyServer, String]?
https : Result[ProxyServer, String]?
bypass : Array[NoProxyRule]
}
///|
/// The proxies for an explicit `proxy` URL, which carries every request
/// (httpx's `proxy=`), or else, when `trust_env`, those the environment
/// names through `lookup`, as curl and httpx read it:
/// - `http_proxy` for `http://` requests, `https_proxy` for `https://`,
/// `all_proxy` for either when its own is unset;
/// - `no_proxy` for the hosts that go direct (`parse_no_proxy`);
/// - each name in lower case first, then upper case; an empty value is
/// unset. Upper-case `HTTP_PROXY` is ignored when `REQUEST_METHOD` is
/// set, since a CGI program gets it from a request's `Proxy` header
/// (httpoxy, CVE-2016-5385), as Go and Python ignore it.
fn configure_proxies(
proxy : String?,
trust_env~ : Bool,
lookup : (String) -> String?,
) -> Proxies {
if proxy is Some(text) {
let server = Some(parse_proxy(text, source="proxy"))
return { http: server, https: server, bypass: [], }
}
guard trust_env else { return { http: None, https: None, bypass: [], } }
let cgi = lookup("REQUEST_METHOD") is Some(value) && !value.is_empty()
let all = env_value(lookup, "all_proxy", upper=true)
let setting = (own : (String, String)?) => {
guard (if own is Some(_) { own } else { all }) is Some((name, value)) else {
None
}
Some(parse_proxy(value, source=name))
}
{
http: setting(env_value(lookup, "http_proxy", upper=!cgi)),
https: setting(env_value(lookup, "https_proxy", upper=true)),
bypass: match env_value(lookup, "no_proxy", upper=true) {
Some((_, value)) => parse_no_proxy(value)
None => []
},
}
}
///|
/// The first non-empty of `name` and, when `upper`, its upper-case form,
/// with the spelling found.
fn env_value(
lookup : (String) -> String?,
name : String,
upper~ : Bool,
) -> (String, String)? {
let names = if upper { [name, name.to_upper()] } else { [name] }
for candidate in names {
if lookup(candidate) is Some(value) && !value.trim().is_empty() {
return Some((candidate, value.trim().to_owned()))
}
}
None
}
///|
/// The proxy at `text`: `http://` or `https://`, a scheme taken as `http://`
/// when missing (curl, httpx's environment), the port as the scheme's
/// default when missing, and `user:password@` percent-decoded into `Basic`
/// credentials. A path is ignored. `source` names the setting in the error.
fn parse_proxy(text : String, source~ : String) -> Result[ProxyServer, String] {
let text = text.trim().to_owned()
let text = if text.contains("://") { text } else { "http://" + text }
let url = @http.Url::parse(text) catch {
@http.UrlError::Invalid(reason) =>
return Err("invalid \{source} URL: \{reason}")
}
let scheme = url.scheme.unwrap_or("")
guard scheme is ("http" | "https") else {
return Err(
"unsupported \{source} scheme \{scheme}: only http and https proxies are supported",
)
}
guard url.host is Some(host) && !host.is_empty() else {
return Err("invalid \{source} URL: no host")
}
let authorization = match url.userinfo {
None => None
Some(userinfo) =>
match basic_credentials(userinfo) {
Some(credentials) => Some(credentials)
None => return Err("invalid \{source} URL: malformed credentials")
}
}
let host = if host.contains(":") { "[" + host + "]" } else { host }
let port = url.port_or_default().unwrap_or(80)
Ok({ origin: "\{scheme}://\{host}:\{port}", authorization, })
}
///|
/// `Basic` credentials (RFC 7617) for a URL's userinfo, its user and
/// password percent-decoded; a userinfo without `:` is a user with an empty
/// password, as httpx takes it.
fn basic_credentials(userinfo : String) -> String? {
let (user, password) = match userinfo.find(":") {
Some(colon) => (userinfo[:colon], userinfo[colon + 1:])
None => (userinfo[:], "")
}
let user = @percent.decode(user) catch { _ => return None }
let password = @percent.decode(password) catch { _ => return None }
Some("Basic " + @base64.encode(@http.encode_utf8(user + ":" + password)))
}
///|
/// The route of a request to `origin`: direct when its scheme has no proxy
/// or `bypass` names its host, otherwise forwarded (`http://`) or tunnelled
/// (`https://`). A request is routed by its own URL, so a redirect to another
/// origin is routed afresh. An unusable proxy setting raises `Protocol`
/// before anything is sent.
fn Proxies::route(
self : Proxies,
origin : Origin,
) -> Route raise @http.HttpError {
guard (if origin.https { self.https } else { self.http }) is Some(setting) else {
return Direct
}
if bypasses_proxy(self.bypass, origin.host, origin.port) {
return Direct
}
match setting {
Ok(server) => if origin.https { Tunnel(server) } else { Forward(server) }
Err(message) => raise @http.HttpError::Protocol(message)
}
}
///|
/// The key that parks and counts connections for `origin` on this route: a
/// connection to the proxy or through its tunnel is never handed to a
/// request that goes direct, or through another proxy.
fn Route::key(self : Route, origin : String) -> String {
match self {
Direct => origin
Forward(server) | Tunnel(server) => origin + " via " + server.origin
}
}
///|
/// The request target: `path` on the origin's own connection or tunnel,
/// the absolute URL when the proxy forwards the request.
fn Route::target(self : Route, origin : String, path : String) -> String {
match self {
Forward(_) => origin + path
Direct | Tunnel(_) => path
}
}
///|
/// Opens a connection to `origin` on `route`: to the origin itself, to the
/// proxy, or to the proxy and then, through a `CONNECT` tunnel, to the
/// origin, whose TLS runs inside it. `Proxy-Authorization` goes on the
/// proxy's connection alone, so the origin never sees it. moonbitlang/async
/// sends the `CONNECT` and refuses a non-2xx answer with `ProxyError`, which
/// `failure` makes `HttpError::Proxy`. It also names the dialed proxy in
/// `Host`, for a forwarded request too; RFC 9112 §3.2.2 has a proxy take the
/// target from the absolute form and replace `Host`.
async fn dial(
origin : String,
route : Route,
user_agent : String?,
) -> @ahttp.Client {
let headers : @ahttp.Headers = Map([])
if user_agent is Some(value) {
headers["User-Agent"] = value
}
match route {
Direct => @ahttp.Client(origin, headers~)
Forward(server) =>
@ahttp.Client(server.origin, headers=proxy_headers(server, user_agent))
Tunnel(server) => {
let proxy = @ahttp.Client(
server.origin,
headers=proxy_headers(server, user_agent),
)
// The tunnel owns `proxy` once made; closing is idempotent.
errdefer proxy.close()
@ahttp.Client(origin, headers~, proxy~)
}
}
}
///|
fn proxy_headers(server : ProxyServer, user_agent : String?) -> @ahttp.Headers {
let headers : @ahttp.Headers = Map([])
if user_agent is Some(value) {
headers["User-Agent"] = value
}
if server.authorization is Some(credentials) {
headers["Proxy-Authorization"] = credentials
}
headers
}