use std::{collections::BTreeSet, net::SocketAddr, sync::Arc, time::Duration};
use axum::{
body::Body,
extract::{ConnectInfo, Query, State},
http::{HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
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_subjects(client: &reqwest::Client) -> Result<BTreeSet<String>> {
let response = 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(active_subjects(&config))
}
fn active_subjects(config: &Value) -> BTreeSet<String> {
fn walk(value: &Value, out: &mut BTreeSet<String>) {
match value {
Value::Object(map) => {
for (key, val) in map {
if matches!(key.as_str(), "host" | "automate" | "subjects")
&& let Some(arr) = val.as_array()
{
out.extend(
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim_end_matches('.').to_ascii_lowercase()),
);
}
walk(val, out);
}
}
Value::Array(arr) => arr.iter().for_each(|v| walk(v, out)),
_ => {}
}
}
let mut out = BTreeSet::new();
walk(config, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_names_to_certify_come_from_caddys_live_configuration() {
let config = serde_json::json!({
"apps": {
"http": { "servers": { "srv0": { "routes": [
{ "match": [{ "host": ["a.example.com", "B.example.com."] }],
"handle": [{ "handler": "subroute", "routes": [
{ "match": [{ "host": ["nested.example.com"] }] }
]}] }
]}}},
"tls": {
"certificates": { "automate": ["auto.example.com"] },
"automation": { "policies": [{ "subjects": ["policy.example.com"] }] }
}
}
});
let subjects = active_subjects(&config);
for host in [
"a.example.com",
"b.example.com",
"nested.example.com",
"auto.example.com",
"policy.example.com",
] {
assert!(subjects.contains(host), "missing {host}");
}
}
#[test]
fn a_configuration_naming_no_site_names_nothing() {
assert!(active_subjects(&serde_json::json!({"apps": {}})).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);
}
}
}