use std::{net::SocketAddr, sync::Arc, time::Duration};
use axum::{
body::Body,
extract::{ConnectInfo, Query, State},
http::{HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use bestool_alertd::ownership::CaddySites;
use miette::{IntoDiagnostic as _, Result, WrapErr as _};
use serde::Deserialize;
use serde_json::Value;
use tracing::{debug, warn};
use super::peer;
use crate::alertd::http_server::ServerState;
#[derive(Debug, Deserialize)]
pub struct CertificateQuery {
#[serde(default)]
pub server_name: String,
}
#[derive(Debug, Clone, Copy)]
pub struct Endpoints {
pub remote: SocketAddr,
pub local: SocketAddr,
}
impl
axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, tokio::net::TcpListener>>
for Endpoints
{
fn connect_info(stream: axum::serve::IncomingStream<'_, tokio::net::TcpListener>) -> Self {
let remote = *stream.remote_addr();
let local = stream.io().local_addr().unwrap_or(remote);
Self { remote, local }
}
}
pub async fn handle_certificate(
State(state): State<Arc<ServerState>>,
ConnectInfo(ends): ConnectInfo<Endpoints>,
Query(query): Query<CertificateQuery>,
) -> Response {
let Some(certificates) = state.certificates.as_ref() else {
return StatusCode::NO_CONTENT.into_response();
};
if let Err(err) =
peer::caller_permitted(certificates.permitted(), ends.remote, ends.local).await
{
warn!(peer = %ends.remote, %err, "refused a certificate request");
return (StatusCode::FORBIDDEN, format!("{err}")).into_response();
}
if query.server_name.is_empty() {
debug!("a certificate request carried no server_name");
return StatusCode::NO_CONTENT.into_response();
}
match certificates.serve(&query.server_name).await {
Some((chain, key)) => {
debug!(name = %query.server_name, "serving a canopy-issued chain");
let mut body = chain;
if !body.ends_with('\n') {
body.push('\n');
}
body.push_str(&key);
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/x-pem-file"),
);
response
}
None => {
certificates.note_wanted(&query.server_name).await;
StatusCode::NO_CONTENT.into_response()
}
}
}
const CONFIG_URL: &str = "http://localhost:2019/config/";
const CONFIG_TIMEOUT: Duration = Duration::from_secs(3);
pub async fn caddy_sites() -> Result<CaddySites> {
let response = bestool_alertd::local_http::client()
.get(CONFIG_URL)
.timeout(CONFIG_TIMEOUT)
.send()
.await
.into_diagnostic()
.wrap_err("reading the caddy admin API at localhost:2019")?;
if !response.status().is_success() {
return Err(miette::miette!(
"the caddy admin API answered {}",
response.status()
));
}
let config: Value = response
.json()
.await
.into_diagnostic()
.wrap_err("parsing caddy's configuration")?;
Ok(CaddySites::from_config(&config))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_sites_that_name_the_daemons_endpoint_are_certified() {
let config = serde_json::json!({
"apps": {
"http": { "servers": { "srv0": { "routes": [
{ "match": [{ "host": ["hooked.example.com", "B.example.com."] }] },
{ "match": [{ "host": ["plain.example.com"] }] }
]}}},
"tls": { "automation": { "policies": [{
"subjects": ["hooked.example.com", "b.example.com"],
"get_certificate": [{ "via": "http", "url": "http://127.0.0.1:8271/certificate" }]
}]}}
}
});
let sites = CaddySites::from_config(&config);
assert_eq!(sites.addresses().len(), 3);
let certified = sites.hooked_addresses();
assert!(certified.contains("hooked.example.com"));
assert!(certified.contains("b.example.com"));
assert!(!certified.contains("plain.example.com"));
}
#[test]
fn a_configuration_naming_no_site_names_nothing() {
let sites = CaddySites::from_config(&serde_json::json!({"apps": {}}));
assert!(sites.addresses().is_empty());
assert!(sites.hooked_addresses().is_empty());
}
#[cfg(target_os = "linux")]
mod over_a_connection {
use std::sync::Arc;
use axum::{Router, routing::get};
use super::super::{Endpoints, handle_certificate};
use crate::alertd::certificates::{CertificateState, peer};
async fn serve(state: Option<Arc<CertificateState>>) -> String {
let mut server = crate::alertd::http_server::test_utils::create_test_state().await;
Arc::get_mut(&mut server).unwrap().certificates = state;
let app = Router::new()
.route("/certificate", get(handle_certificate))
.with_state(server);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let _ = axum::serve(
listener,
app.into_make_service_with_connect_info::<Endpoints>(),
)
.await;
});
base
}
fn own_uid() -> u32 {
std::fs::read_to_string("/proc/self/status")
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("Uid:"))
.and_then(|rest| rest.split_whitespace().next()?.parse().ok())
.unwrap()
}
async fn state_holding(name: Option<&str>) -> Arc<CertificateState> {
let state = Arc::new(CertificateState::new(
std::env::temp_dir().join("a3-delivery-test"),
peer::Permitted::new(Some(own_uid())),
));
if let Some(name) = name {
state.held.write().await.insert(
name.to_owned(),
crate::alertd::certificates::Held {
chain: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----"
.into(),
key_pem: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----\n"
.into(),
not_after: None,
usable: true,
revoked: false,
},
);
}
state
}
#[tokio::test]
async fn a_held_chain_is_served_from_memory_with_its_key() {
let base = serve(Some(state_holding(Some("app.example.com")).await)).await;
let response = reqwest::get(format!("{base}/certificate?server_name=app.example.com"))
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.text().await.unwrap();
assert!(body.contains("BEGIN CERTIFICATE"), "{body}");
assert!(body.contains("BEGIN PRIVATE KEY"), "{body}");
}
#[tokio::test]
async fn nothing_held_declines_with_no_content() {
let base = serve(Some(state_holding(None).await)).await;
let response = reqwest::get(format!("{base}/certificate?server_name=app.example.com"))
.await
.unwrap();
assert_eq!(response.status(), 204);
}
#[tokio::test]
async fn a_request_with_no_server_name_declines() {
let base = serve(Some(state_holding(Some("app.example.com")).await)).await;
let response = reqwest::get(format!("{base}/certificate?server_name="))
.await
.unwrap();
assert_eq!(response.status(), 204);
let response = reqwest::get(format!("{base}/certificate")).await.unwrap();
assert_eq!(response.status(), 204);
}
#[tokio::test]
async fn a_caller_that_is_not_permitted_is_refused() {
if own_uid() == 0 {
return;
}
let state = Arc::new(CertificateState::new(
std::env::temp_dir().join("a3-delivery-test"),
peer::Permitted::new(Some(own_uid().wrapping_add(1))),
));
let base = serve(Some(state)).await;
let response = reqwest::get(format!("{base}/certificate?server_name=app.example.com"))
.await
.unwrap();
assert_eq!(response.status(), 403);
}
}
}