use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::Bytes;
use dynamic_config::Error;
use http_body_util::{BodyExt as _, Empty, Limited};
use hyper::body::Incoming;
use hyper::client::conn::http1::SendRequest;
use hyper::header::{ACCEPT, AUTHORIZATION, HOST};
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use rustls::pki_types::ServerName;
use tokio::net::TcpStream;
use tokio::task::JoinHandle;
use tokio_rustls::TlsConnector;
#[derive(Debug, Clone)]
pub(super) struct Endpoint {
pub(super) secure: bool,
pub(super) host: String,
pub(super) port: u16,
pub(super) prefix: String,
}
impl Endpoint {
pub(super) fn parse(url: &str, described: &str) -> Result<Self, Error> {
let refuse = |why: &str| Error::remote(format!("{described}: {why}"));
let Some((scheme, rest)) = url.split_once("://") else {
return Err(refuse("the URL needs an `http://` or `https://` scheme"));
};
let secure = match scheme.to_ascii_lowercase().as_str() {
"http" => false,
"https" => true,
_ => return Err(refuse("the URL's scheme has to be `http` or `https`")),
};
let (authority, prefix) = match rest.find('/') {
Some(at) => rest.split_at(at),
None => (rest, ""),
};
if authority.contains('@') {
return Err(refuse(
"the URL carries a `user:password@` authority, which this server does not \
accept as a credential; put the bearer token in `with_token` instead",
));
}
let (host, port) = split_authority(authority, secure).ok_or_else(|| {
refuse("the URL's host and port are not `host`, `host:port` or `[v6]:port`")
})?;
if host.is_empty() {
return Err(refuse("the URL names no host"));
}
Ok(Self {
secure,
host,
port,
prefix: prefix.trim_end_matches('/').to_owned(),
})
}
fn authority(&self) -> String {
let bracketed = if self.host.contains(':') {
format!("[{}]", self.host)
} else {
self.host.clone()
};
if (self.secure && self.port == 443) || (!self.secure && self.port == 80) {
bracketed
} else {
format!("{bracketed}:{}", self.port)
}
}
pub(super) fn path(&self, suffix: &str) -> String {
format!("{}{suffix}", self.prefix)
}
}
fn split_authority(authority: &str, secure: bool) -> Option<(String, u16)> {
let default = if secure { 443 } else { 80 };
if let Some(rest) = authority.strip_prefix('[') {
let (host, tail) = rest.split_once(']')?;
return match tail {
"" => Some((host.to_owned(), default)),
_ => Some((host.to_owned(), tail.strip_prefix(':')?.parse().ok()?)),
};
}
match authority.rsplit_once(':') {
Some((host, port)) => Some((host.to_owned(), port.parse().ok()?)),
None => Some((authority.to_owned(), default)),
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Budget {
until: Instant,
}
impl Budget {
pub(super) fn starting(timeout: Duration) -> Self {
Self {
until: Instant::now() + timeout,
}
}
pub(super) fn left(self) -> Duration {
self.until.saturating_duration_since(Instant::now())
}
}
pub(super) struct Connection {
sender: SendRequest<Empty<Bytes>>,
driver: JoinHandle<()>,
}
impl Drop for Connection {
fn drop(&mut self) {
self.driver.abort();
}
}
impl Connection {
pub(super) async fn open(
endpoint: &Endpoint,
tls: Option<&Arc<rustls::ClientConfig>>,
budget: Budget,
described: &str,
) -> Result<Self, Error> {
let stream = deadline(
budget.left(),
TcpStream::connect((endpoint.host.as_str(), endpoint.port)),
described,
"connecting",
)
.await?
.map_err(|error| Error::remote(format!("{described}: connecting: {error}")))?;
let _ = stream.set_nodelay(true);
let Some(config) = tls else {
return Self::handshake(stream, described).await;
};
let name = ServerName::try_from(endpoint.host.clone()).map_err(|_| {
Error::remote(format!(
"{described}: `{}` is neither a DNS name nor an IP address, so no certificate \
could be checked against it",
endpoint.host
))
})?;
let stream = deadline(
budget.left(),
TlsConnector::from(Arc::clone(config)).connect(name, stream),
described,
"the TLS handshake",
)
.await?
.map_err(|error| {
Error::remote(format!("{described}: the TLS handshake failed: {error}"))
})?;
Self::handshake(stream, described).await
}
async fn handshake<S>(stream: S, described: &str) -> Result<Self, Error>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let (sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream))
.await
.map_err(|error| Error::remote(format!("{described}: {error}")))?;
Ok(Self {
sender,
driver: tokio::spawn(async move {
let _ = connection.await;
}),
})
}
pub(super) async fn get(
&mut self,
endpoint: &Endpoint,
path: &str,
token: Option<&str>,
accept: &str,
budget: Budget,
described: &str,
) -> Result<Response<Incoming>, Error> {
let mut request = Request::builder()
.method("GET")
.uri(path)
.header(HOST, endpoint.authority())
.header(ACCEPT, accept);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
let request = request.body(Empty::<Bytes>::new()).map_err(|_| {
Error::remote(format!(
"{described}: the request could not be built; a bearer token has to be a legal \
HTTP header value"
))
})?;
deadline(
budget.left(),
self.sender.send_request(request),
described,
"the request",
)
.await?
.map_err(|error| Error::remote(format!("{described}: {error}")))
}
}
pub(super) async fn body(
response: Response<Incoming>,
limit: usize,
budget: Budget,
described: &str,
) -> Result<Vec<u8>, Error> {
let collected = deadline(
budget.left(),
Limited::new(response.into_body(), limit).collect(),
described,
"the response body",
)
.await?
.map_err(|_| {
Error::remote(format!(
"{described}: the response body could not be read, or was longer than \
{limit} bytes"
))
})?;
Ok(collected.to_bytes().to_vec())
}
pub(super) fn refused(status: StatusCode, described: &str) -> Error {
match status {
StatusCode::UNAUTHORIZED => Error::auth(format!(
"{described}: the server refused the credential (401); this client presented no \
bearer token, or one the server's roster does not have"
)),
StatusCode::FORBIDDEN => Error::auth(format!(
"{described}: the server refused the credential (403)"
)),
StatusCode::NOT_FOUND => Error::auth(format!(
"{described}: the server answered 404, which it uses for both `this caller may not \
read that` and `no such application and profile` — check the grant and the \
section, because waiting will not change either"
)),
_ => Error::remote(format!("{described}: the server answered {status}")),
}
}
async fn deadline<F>(
timeout: Duration,
future: F,
described: &str,
what: &str,
) -> Result<F::Output, Error>
where
F: std::future::Future,
{
tokio::time::timeout(timeout, future).await.map_err(|_| {
Error::remote(format!(
"{described}: {what} did not finish within {timeout:?}"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint(url: &str) -> Endpoint {
Endpoint::parse(url, "config-server").expect("a URL this crate accepts")
}
#[test]
fn a_url_is_taken_apart_into_scheme_host_port_and_prefix() {
let plain = endpoint("http://config.internal:8080");
assert!(!plain.secure);
assert_eq!(plain.host, "config.internal");
assert_eq!(plain.port, 8080);
assert_eq!(plain.prefix, "");
assert_eq!(plain.path("/billing/prod"), "/billing/prod");
let mounted = endpoint("https://config.internal/config/");
assert!(mounted.secure);
assert_eq!(mounted.port, 443, "https defaults to 443");
assert_eq!(mounted.prefix, "/config");
assert_eq!(mounted.path("/billing/prod"), "/config/billing/prod");
}
#[test]
fn an_ipv6_literal_keeps_its_colons_and_its_port() {
let six = endpoint("http://[::1]:8080/");
assert_eq!(six.host, "::1");
assert_eq!(six.port, 8080);
assert_eq!(six.authority(), "[::1]:8080");
assert_eq!(endpoint("http://[::1]").port, 80);
}
#[test]
fn the_host_header_drops_a_default_port_and_keeps_any_other() {
assert_eq!(
endpoint("https://config.internal").authority(),
"config.internal"
);
assert_eq!(
endpoint("http://config.internal").authority(),
"config.internal"
);
assert_eq!(
endpoint("https://config.internal:8443").authority(),
"config.internal:8443"
);
}
#[test]
fn a_password_in_the_url_is_refused_rather_than_dropped() {
let error = Endpoint::parse("https://user:hunter2@config.internal", "config-server")
.expect_err("a URL credential is not a credential here");
assert!(error.to_string().contains("with_token"), "{error}");
assert!(
!error.to_string().contains("hunter2"),
"the refusal quoted the password: {error}"
);
}
#[test]
fn a_url_without_a_usable_scheme_or_host_is_refused() {
for url in [
"config.internal:8080",
"ftp://config.internal",
"https://",
"http://config.internal:not-a-port",
] {
assert!(
Endpoint::parse(url, "config-server").is_err(),
"`{url}` should not parse"
);
}
}
#[test]
fn a_status_says_whether_waiting_could_help() {
use dynamic_config::ErrorKind;
for status in [
StatusCode::UNAUTHORIZED,
StatusCode::FORBIDDEN,
StatusCode::NOT_FOUND,
] {
assert_eq!(
refused(status, "config-server").kind(),
ErrorKind::Auth,
"{status} is a configuration problem, not a transient one"
);
}
for status in [
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::BAD_GATEWAY,
] {
assert_eq!(refused(status, "config-server").kind(), ErrorKind::Remote);
}
}
}