use std::{net::IpAddr, sync::Arc};
use bytes::Bytes;
use eyre::{eyre, Report};
use http_body_util::Empty;
use hyper::{
body::Incoming,
client::conn::http1,
header::{HOST, LOCATION, PROXY_AUTHORIZATION, USER_AGENT},
Request, Response, Uri,
};
use hyper_util::{
client::proxy::matcher::{Intercept, Matcher},
rt::TokioIo,
};
use tokio::{
io::{AsyncRead, AsyncWrite},
net::TcpStream,
};
use tokio_rustls::{
rustls::{pki_types::ServerName, ClientConfig},
TlsConnector,
};
const DOWNLOAD_USER_AGENT: &str = concat!("zakura/", env!("CARGO_PKG_VERSION"));
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
pub(super) fn parse_url(url: &str) -> Result<Uri, Report> {
let uri: Uri = url
.parse()
.map_err(|err| eyre!("'{url}' is not a valid absolute URL: {err}"))?;
match (uri.scheme(), uri.authority()) {
(Some(_), Some(authority)) if !authority.as_str().contains('@') => Ok(uri),
(Some(_), Some(_)) => Err(eyre!("URL '{url}' must not contain credentials")),
_ => Err(eyre!("'{url}' is not a valid absolute URL")),
}
}
fn resolve_redirect(url: &Uri, location: &str) -> Result<Uri, Report> {
if location.starts_with('/') && !location.starts_with("//") {
let mut parts = url.clone().into_parts();
parts.path_and_query = Some(
location
.parse()
.map_err(|err| eyre!("invalid redirect location '{location}': {err}"))?,
);
Uri::from_parts(parts).map_err(|err| eyre!("invalid redirect location '{location}': {err}"))
} else {
parse_url(location)
}
}
pub(super) async fn get_following_redirects(
url: Uri,
tls: Arc<ClientConfig>,
proxies: &Matcher,
max_redirects: usize,
is_allowed: impl Fn(&Uri) -> bool,
) -> Result<Response<Incoming>, Report> {
let tls = TlsConnector::from(tls);
let mut url = url;
let mut redirects = 0;
loop {
let response = get(&url, &tls, proxies).await?;
if !response.status().is_redirection() {
return Ok(response);
}
let location = response
.headers()
.get(LOCATION)
.and_then(|location| location.to_str().ok())
.ok_or_else(|| {
eyre!(
"redirect from {url} with status {} has no valid Location header",
response.status()
)
})?;
let next = resolve_redirect(&url, location)
.map_err(|err| eyre!("bad redirect from {url}: {err:#}"))?;
redirects += 1;
if redirects > max_redirects {
return Err(eyre!("more than {max_redirects} redirects"));
}
if !is_allowed(&next) {
return Err(eyre!("redirected to non-https URL {next}"));
}
url = next;
}
}
async fn get(
url: &Uri,
tls: &TlsConnector,
proxies: &Matcher,
) -> Result<Response<Incoming>, Report> {
let host = url.host().ok_or_else(|| eyre!("URL {url} has no host"))?;
let port = match (url.port_u16(), url.scheme_str()) {
(Some(port), _) => port,
(None, Some("https")) => 443,
(None, Some("http")) => 80,
_ => return Err(eyre!("URL {url} has no port")),
};
let authority = url
.authority()
.ok_or_else(|| eyre!("URL {url} has no host"))?
.as_str();
let path = url
.path_and_query()
.map_or("/", |path_and_query| path_and_query.as_str());
let stream = match proxies.intercept(url) {
Some(proxy) => connect_through_proxy(&proxy, authority, tls).await?,
None => Box::new(connect(host, port).await?) as Box<dyn Stream>,
};
let stream = match url.scheme_str() {
Some("https") => Box::new(
tls.connect(server_name(host)?, stream)
.await
.map_err(|err| eyre!("TLS handshake with {host} failed: {err}"))?,
) as Box<dyn Stream>,
_ => stream,
};
let mut sender = handshake(stream, false).await?;
let request = Request::get(path)
.header(HOST, authority)
.header(USER_AGENT, DOWNLOAD_USER_AGENT)
.body(Empty::<Bytes>::new())
.map_err(|err| eyre!("invalid request for {url}: {err}"))?;
sender
.send_request(request)
.await
.map_err(|err| eyre!("request to {url} failed: {err}"))
}
async fn connect_through_proxy(
proxy: &Intercept,
authority: &str,
tls: &TlsConnector,
) -> Result<Box<dyn Stream>, Report> {
let proxy_uri = proxy.uri();
let proxy_host = proxy_uri
.host()
.ok_or_else(|| eyre!("proxy URL {proxy_uri} has no host"))?;
let stream: Box<dyn Stream> = match proxy_uri.scheme_str() {
Some("http") => Box::new(connect(proxy_host, proxy_uri.port_u16().unwrap_or(80)).await?),
Some("https") => {
let stream = connect(proxy_host, proxy_uri.port_u16().unwrap_or(443)).await?;
Box::new(
tls.connect(server_name(proxy_host)?, stream)
.await
.map_err(|err| eyre!("TLS handshake with proxy {proxy_host} failed: {err}"))?,
)
}
_ => {
return Err(eyre!(
"unsupported proxy URL {proxy_uri}: use an http or https proxy"
))
}
};
let mut sender = handshake(stream, true).await?;
let mut request = Request::connect(authority)
.header(HOST, authority)
.header(USER_AGENT, DOWNLOAD_USER_AGENT)
.body(Empty::<Bytes>::new())
.map_err(|err| eyre!("invalid proxy request for {authority}: {err}"))?;
if let Some(auth) = proxy.basic_auth() {
request
.headers_mut()
.insert(PROXY_AUTHORIZATION, auth.clone());
}
let response = sender
.send_request(request)
.await
.map_err(|err| eyre!("proxy request to {proxy_host} failed: {err}"))?;
if !response.status().is_success() {
return Err(eyre!(
"proxy {proxy_host} refused to connect to {authority} with HTTP status {}",
response.status()
));
}
let tunnel = hyper::upgrade::on(response)
.await
.map_err(|err| eyre!("proxy {proxy_host} tunnel to {authority} failed: {err}"))?;
Ok(Box::new(TokioIo::new(tunnel)))
}
async fn connect(host: &str, port: u16) -> Result<TcpStream, Report> {
let host = host.trim_start_matches('[').trim_end_matches(']');
TcpStream::connect((host, port))
.await
.map_err(|err| eyre!("failed to connect to {host}:{port}: {err}"))
}
async fn handshake(
stream: Box<dyn Stream>,
with_upgrades: bool,
) -> Result<http1::SendRequest<Empty<Bytes>>, Report> {
let (sender, connection) = http1::handshake(TokioIo::new(stream))
.await
.map_err(|err| eyre!("HTTP handshake failed: {err}"))?;
if with_upgrades {
tokio::spawn(connection.with_upgrades());
} else {
tokio::spawn(connection);
}
Ok(sender)
}
fn server_name(host: &str) -> Result<ServerName<'static>, Report> {
let host = host.trim_start_matches('[').trim_end_matches(']');
match host.parse::<IpAddr>() {
Ok(ip) => Ok(ServerName::IpAddress(ip.into())),
Err(_) => ServerName::try_from(host.to_owned())
.map_err(|err| eyre!("invalid TLS server name {host}: {err}")),
}
}
#[cfg(test)]
mod tests {
use super::{parse_url, resolve_redirect};
#[test]
fn parses_absolute_ascii_urls_without_credentials() {
let url = parse_url("https://example.com:8443/path?query").expect("the URL is valid");
assert_eq!(url.host(), Some("example.com"));
assert_eq!(url.port_u16(), Some(8443));
let url = parse_url("https://example.com/path#fragment").expect("the URL is valid");
assert_eq!(url.to_string(), "https://example.com/path");
for (url, error) in [
("/path", "not a valid absolute URL"),
("example.com/path", "not a valid absolute URL"),
(
"https://user:secret@example.com/path",
"must not contain credentials",
),
("https://exämple.com/path", "not a valid absolute URL"),
] {
let message = format!("{:#}", parse_url(url).expect_err(url));
assert!(message.contains(error), "{url}: {message}");
}
}
#[test]
fn resolves_absolute_redirects_and_same_server_paths() {
let url = parse_url("https://example.com/releases/zcashd").expect("the URL is valid");
let absolute = resolve_redirect(&url, "https://objects.example.net/file?sig=1")
.expect("absolute redirects are accepted");
assert_eq!(
absolute.to_string(),
"https://objects.example.net/file?sig=1"
);
let same_server =
resolve_redirect(&url, "/other/zcashd").expect("absolute paths are accepted");
assert_eq!(same_server.to_string(), "https://example.com/other/zcashd");
for location in ["zcashd-v2", "../zcashd", "//objects.example.net/file"] {
assert!(resolve_redirect(&url, location).is_err(), "{location}");
}
}
}