use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ValidationMethod {
#[default]
Http,
Txt,
}
impl ValidationMethod {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Txt => "txt",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostnameClaim {
pub hostname: String,
pub method: ValidationMethod,
}
impl HostnameClaim {
#[must_use]
pub fn new(hostname: impl Into<String>) -> Self {
Self {
hostname: hostname.into(),
method: ValidationMethod::Http,
}
}
#[must_use]
pub fn with_method(mut self, method: ValidationMethod) -> Self {
self.method = method;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DnsRecordType {
Txt,
Cname,
}
impl DnsRecordType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Txt => "TXT",
Self::Cname => "CNAME",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Validation {
pub record_type: DnsRecordType,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderStatus {
Pending,
Active,
Failed { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CertificateStatus {
Pending,
Active,
Failed { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomHostname {
pub id: String,
pub hostname: String,
pub status: ProviderStatus,
pub certificate: CertificateStatus,
pub validation: Vec<Validation>,
}
impl CustomHostname {
#[must_use]
pub fn is_live(&self) -> bool {
matches!(self.status, ProviderStatus::Active)
&& matches!(self.certificate, CertificateStatus::Active)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostnameRefusal {
Empty,
TooLong,
Malformed,
IpLiteral,
Wildcard,
Apex,
OwnZone,
}
impl std::fmt::Display for HostnameRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => f.write_str("the hostname is empty"),
Self::TooLong => f.write_str("the hostname is longer than 253 characters"),
Self::Malformed => f.write_str("the hostname is not a valid DNS name"),
Self::IpLiteral => {
f.write_str("an IP address is not a hostname a customer can point here")
}
Self::Wildcard => f.write_str("wildcard hostnames are not supported"),
Self::Apex => f.write_str("an apex hostname cannot serve a venture; claim a subdomain"),
Self::OwnZone => f.write_str("that hostname is inside this deployment's own zone"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomHostnameError {
NotConfigured,
Refused(HostnameRefusal),
NotFound,
AlreadyExists,
Unauthorized,
RateLimited,
Rejected(String),
Provider(String),
Transport(String),
}
impl std::fmt::Display for CustomHostnameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let scrub = crate::logging::scrub_text;
match self {
Self::NotConfigured => f.write_str("no custom-hostnames provider is configured"),
Self::Refused(refusal) => write!(f, "hostname refused: {refusal}"),
Self::NotFound => f.write_str("no such custom hostname at the provider"),
Self::AlreadyExists => f.write_str("the hostname is already claimed at the provider"),
Self::Unauthorized => f.write_str(
"custom hostnames provider rejected the request as unauthorized \
(check the API token)",
),
Self::RateLimited => f.write_str("custom hostnames provider rate limited the request"),
Self::Rejected(message) => write!(
f,
"custom hostnames provider rejected the request: {}",
scrub(message)
),
Self::Provider(message) => {
write!(f, "custom hostnames provider error: {}", scrub(message))
}
Self::Transport(message) => {
write!(f, "custom hostnames transport error: {}", scrub(message))
}
}
}
}
impl std::error::Error for CustomHostnameError {}
#[async_trait]
pub trait CustomHostnames: Send + Sync {
async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError>;
async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError>;
async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError>;
async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError>;
}
pub fn check_hostname(hostname: &str, own_zone: &str) -> Result<String, HostnameRefusal> {
let trimmed = hostname.trim();
let name = trimmed
.strip_suffix('.')
.unwrap_or(trimmed)
.to_ascii_lowercase();
if name.is_empty() {
return Err(HostnameRefusal::Empty);
}
if name.len() > 253 {
return Err(HostnameRefusal::TooLong);
}
if name.contains('*') {
return Err(HostnameRefusal::Wildcard);
}
let address = name
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.unwrap_or(&name);
if address.parse::<std::net::IpAddr>().is_ok() {
return Err(HostnameRefusal::IpLiteral);
}
let labels: Vec<&str> = name.split('.').collect();
if labels.len() < 2 {
return Err(HostnameRefusal::Malformed);
}
for label in &labels {
if label.is_empty()
|| label.len() > 63
|| !label
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|| label.starts_with('-')
|| label.ends_with('-')
{
return Err(HostnameRefusal::Malformed);
}
}
let zone = own_zone
.trim()
.strip_suffix('.')
.unwrap_or(own_zone.trim())
.to_ascii_lowercase();
if !zone.is_empty() && (name == zone || name.ends_with(&format!(".{zone}"))) {
return Err(HostnameRefusal::OwnZone);
}
if labels.len() < 3 {
return Err(HostnameRefusal::Apex);
}
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_is_normalised_and_accepted() {
assert_eq!(
check_hostname("Share.Acme.COM.", "cratefield.app").as_deref(),
Ok("share.acme.com")
);
assert_eq!(
check_hostname(" share.acme.com ", "cratefield.app").as_deref(),
Ok("share.acme.com")
);
}
#[test]
fn an_apex_is_refused() {
assert_eq!(
check_hostname("acme.com", "cratefield.app"),
Err(HostnameRefusal::Apex)
);
}
#[test]
fn addresses_are_refused() {
for raw in ["192.0.2.1", "2001:db8::1", "[::1]"] {
assert_eq!(
check_hostname(raw, "cratefield.app"),
Err(HostnameRefusal::IpLiteral),
"{raw}"
);
}
}
#[test]
fn a_wildcard_is_refused() {
assert_eq!(
check_hostname("*.acme.com", "cratefield.app"),
Err(HostnameRefusal::Wildcard)
);
assert_eq!(
check_hostname("share.*.acme.com", "cratefield.app"),
Err(HostnameRefusal::Wildcard)
);
}
#[test]
fn the_deployments_own_zone_is_refused() {
assert_eq!(
check_hostname("acme.com", "acme.com"),
Err(HostnameRefusal::OwnZone),
"the zone itself"
);
assert_eq!(
check_hostname("share.acme.com", "acme.com"),
Err(HostnameRefusal::OwnZone),
"a subdomain of the zone"
);
assert_eq!(
check_hostname("share.acme.com", "Acme.COM."),
Err(HostnameRefusal::OwnZone),
"the zone is normalised the same way"
);
}
#[test]
fn a_name_over_253_characters_is_refused() {
let long = "a".repeat(254);
assert_eq!(
check_hostname(&long, "cratefield.app"),
Err(HostnameRefusal::TooLong)
);
}
#[test]
fn malformed_names_are_refused() {
for raw in [
"-leading.acme.com",
"share..acme.com",
"share_acme.acme.com",
"",
] {
assert!(
matches!(
check_hostname(raw, "cratefield.app"),
Err(HostnameRefusal::Malformed | HostnameRefusal::Empty)
),
"{raw:?}"
);
}
assert_eq!(
check_hostname("share.acme.com.", "cratefield.app").as_deref(),
Ok("share.acme.com"),
"a trailing root dot is the same name, not an empty label"
);
}
#[test]
fn display_never_leaks_a_bearer_token() {
let error = CustomHostnameError::Provider(
"the provider said Bearer abcdefgh1234 is not a valid token".to_owned(),
);
let text = error.to_string();
assert!(!text.contains("abcdefgh1234"), "{text}");
assert!(text.contains("Bearer [redacted]"), "{text}");
assert!(format!("{error:?}").contains("abcdefgh1234"));
}
}