use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, warn};
use crate::error::Result;
use crate::storage::Storage;
pub const ACME_CHALLENGE_PATH_PREFIX: &str = "/.well-known/acme-challenge/";
pub const ZEROSSL_VALIDATION_PATH_PREFIX: &str = "/.well-known/pki-validation/";
const CHALLENGE_TOKENS_PREFIX: &str = "challenge_tokens";
pub struct HttpChallengeHandler {
challenges: Arc<RwLock<HashMap<String, String>>>,
storage: Option<Arc<dyn Storage>>,
storage_key_issuer_prefix: String,
}
impl HttpChallengeHandler {
pub fn new(
challenges: Arc<RwLock<HashMap<String, String>>>,
storage: Option<Arc<dyn Storage>>,
) -> Self {
Self {
challenges,
storage,
storage_key_issuer_prefix: String::new(),
}
}
pub fn with_prefix(
challenges: Arc<RwLock<HashMap<String, String>>>,
storage: Option<Arc<dyn Storage>>,
prefix: String,
) -> Self {
Self {
challenges,
storage,
storage_key_issuer_prefix: prefix,
}
}
pub async fn handle_http_request(
&self,
method: &str,
host: &str,
path: &str,
) -> Option<(u16, String)> {
if !method.eq_ignore_ascii_case("GET") {
return None;
}
let token = extract_challenge_token(path)?;
let host_only = strip_port(host);
let key_auth = self.lookup_token(token).await;
match key_auth {
Some(ka) => {
if host_only.is_empty() {
debug!(
token = token,
"rejecting challenge request with empty Host header"
);
return None;
}
debug!(
token = token,
host = host_only,
"served HTTP-01 challenge via handle_http_request"
);
Some((200, ka))
}
None => {
debug!(
token = token,
host = host_only,
"HTTP-01 challenge token not found"
);
Some((404, String::new()))
}
}
}
pub async fn handle_request(&self, path: &str) -> Option<String> {
let token = extract_challenge_token(path)?;
self.lookup_token(token).await
}
pub async fn handle_request_blind(
&self,
path: &str,
account_thumbprint: Option<&str>,
) -> Option<String> {
let token = extract_challenge_token(path)?;
if let Some(key_auth) = self.lookup_token(token).await {
return Some(key_auth);
}
if let Some(thumbprint) = account_thumbprint {
let key_auth = format!("{token}.{thumbprint}");
debug!(
token = token,
"serving HTTP-01 challenge via blind solving fallback"
);
return Some(key_auth);
}
None
}
async fn lookup_token(&self, token: &str) -> Option<String> {
{
let map = self.challenges.read().await;
if let Some(key_auth) = map.get(token) {
debug!(token = token, "served HTTP-01 challenge from local map");
return Some(key_auth.clone());
}
}
if let Some(ref storage) = self.storage {
match self.load_from_storage(storage, token).await {
Ok(Some(key_auth)) => {
debug!(
token = token,
"served HTTP-01 challenge from distributed storage"
);
return Some(key_auth);
}
Ok(None) => {
warn!(
token = token,
"HTTP-01 challenge token not found in storage"
);
}
Err(e) => {
warn!(
token = token,
error = %e,
"failed to load HTTP-01 challenge from storage"
);
}
}
}
None
}
async fn load_from_storage(
&self,
storage: &Arc<dyn Storage>,
token: &str,
) -> Result<Option<String>> {
let base = if self.storage_key_issuer_prefix.is_empty() {
CHALLENGE_TOKENS_PREFIX.to_string()
} else {
format!(
"{}/{}",
self.storage_key_issuer_prefix, CHALLENGE_TOKENS_PREFIX
)
};
let safe_token = crate::storage::safe_key(token);
let token_filename = format!("{safe_token}.json");
let domains = match storage.list(&base, false).await {
Ok(d) => d,
Err(_) => return Ok(None),
};
for domain_key in &domains {
let candidate = format!("{domain_key}/{token_filename}");
match storage.load(&candidate).await {
Ok(data) => {
let key_auth = String::from_utf8(data).map_err(|e| {
crate::error::Error::Other(format!(
"challenge data is not valid UTF-8: {e}"
))
})?;
return Ok(Some(key_auth));
}
Err(_) => continue,
}
}
Ok(None)
}
}
impl std::fmt::Debug for HttpChallengeHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpChallengeHandler")
.field("has_storage", &self.storage.is_some())
.field("storage_key_issuer_prefix", &self.storage_key_issuer_prefix)
.finish()
}
}
pub fn is_challenge_request(path: &str) -> bool {
path.starts_with(ACME_CHALLENGE_PATH_PREFIX)
}
pub fn extract_challenge_token(path: &str) -> Option<&str> {
let token = path.strip_prefix(ACME_CHALLENGE_PATH_PREFIX)?;
if token.is_empty() {
return None;
}
let token = token.split('?').next().unwrap_or(token);
let token = token.trim_end_matches('/');
if token.is_empty() {
return None;
}
Some(token)
}
pub fn is_valid_challenge_token(token: &str) -> bool {
if token.is_empty() {
return false;
}
token
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn strip_port(host: &str) -> &str {
if host.starts_with('[')
&& let Some(end) = host.find(']')
{
return &host[1..end];
}
if host.matches(':').count() > 1 {
return host;
}
if let Some(colon_pos) = host.rfind(':') {
let after = &host[colon_pos + 1..];
if !after.is_empty() && after.chars().all(|c| c.is_ascii_digit()) {
return &host[..colon_pos];
}
}
host
}
pub fn https_redirect_url(host: &str, path: &str) -> String {
format!("https://{host}{path}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_challenge_request_valid() {
assert!(is_challenge_request(
"/.well-known/acme-challenge/some-token"
));
}
#[test]
fn is_challenge_request_prefix_only() {
assert!(is_challenge_request("/.well-known/acme-challenge/"));
}
#[test]
fn is_challenge_request_wrong_path() {
assert!(!is_challenge_request("/other/path"));
assert!(!is_challenge_request("/.well-known/other"));
assert!(!is_challenge_request("/"));
assert!(!is_challenge_request(""));
}
#[test]
fn extract_token_normal() {
assert_eq!(
extract_challenge_token("/.well-known/acme-challenge/abc123"),
Some("abc123")
);
}
#[test]
fn extract_token_with_query() {
assert_eq!(
extract_challenge_token("/.well-known/acme-challenge/abc123?foo=bar"),
Some("abc123")
);
}
#[test]
fn extract_token_with_trailing_slash() {
assert_eq!(
extract_challenge_token("/.well-known/acme-challenge/abc123/"),
Some("abc123")
);
}
#[test]
fn extract_token_empty() {
assert_eq!(
extract_challenge_token("/.well-known/acme-challenge/"),
None
);
}
#[test]
fn extract_token_wrong_path() {
assert_eq!(extract_challenge_token("/other"), None);
}
#[test]
fn redirect_url_simple() {
assert_eq!(
https_redirect_url("example.com", "/page"),
"https://example.com/page"
);
}
#[test]
fn redirect_url_with_port() {
assert_eq!(
https_redirect_url("example.com:8080", "/"),
"https://example.com:8080/"
);
}
#[test]
fn redirect_url_root() {
assert_eq!(
https_redirect_url("example.com", "/"),
"https://example.com/"
);
}
#[tokio::test]
async fn handler_returns_none_for_non_challenge_path() {
let challenges = Arc::new(RwLock::new(HashMap::new()));
let handler = HttpChallengeHandler::new(challenges, None);
assert!(handler.handle_request("/index.html").await.is_none());
}
#[tokio::test]
async fn handler_returns_key_auth_from_local_map() {
let challenges = Arc::new(RwLock::new(HashMap::new()));
{
let mut map = challenges.write().await;
map.insert("mytoken".to_string(), "mytoken.thumbprint".to_string());
}
let handler = HttpChallengeHandler::new(challenges, None);
let result = handler
.handle_request("/.well-known/acme-challenge/mytoken")
.await;
assert_eq!(result, Some("mytoken.thumbprint".to_string()));
}
#[tokio::test]
async fn handler_returns_none_for_unknown_token() {
let challenges = Arc::new(RwLock::new(HashMap::new()));
let handler = HttpChallengeHandler::new(challenges, None);
let result = handler
.handle_request("/.well-known/acme-challenge/unknown")
.await;
assert!(result.is_none());
}
}