1use async_trait::async_trait;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ValidationMethod {
27 #[default]
28 Http,
29 Txt,
30}
31
32impl ValidationMethod {
33 #[must_use]
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::Http => "http",
38 Self::Txt => "txt",
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct HostnameClaim {
47 pub hostname: String,
48 pub method: ValidationMethod,
49}
50
51impl HostnameClaim {
52 #[must_use]
55 pub fn new(hostname: impl Into<String>) -> Self {
56 Self {
57 hostname: hostname.into(),
58 method: ValidationMethod::Http,
59 }
60 }
61
62 #[must_use]
64 pub fn with_method(mut self, method: ValidationMethod) -> Self {
65 self.method = method;
66 self
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum DnsRecordType {
73 Txt,
74 Cname,
75}
76
77impl DnsRecordType {
78 #[must_use]
80 pub fn as_str(self) -> &'static str {
81 match self {
82 Self::Txt => "TXT",
83 Self::Cname => "CNAME",
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Validation {
92 pub record_type: DnsRecordType,
93 pub name: String,
94 pub value: String,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ProviderStatus {
100 Pending,
102 Active,
104 Failed { reason: String },
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum CertificateStatus {
113 Pending,
115 Active,
117 Failed { reason: String },
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct CustomHostname {
126 pub id: String,
127 pub hostname: String,
128 pub status: ProviderStatus,
129 pub certificate: CertificateStatus,
130 pub validation: Vec<Validation>,
131}
132
133impl CustomHostname {
134 #[must_use]
137 pub fn is_live(&self) -> bool {
138 matches!(self.status, ProviderStatus::Active)
139 && matches!(self.certificate, CertificateStatus::Active)
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum HostnameRefusal {
146 Empty,
148 TooLong,
150 Malformed,
152 IpLiteral,
154 Wildcard,
156 Apex,
159 OwnZone,
162}
163
164impl std::fmt::Display for HostnameRefusal {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 match self {
167 Self::Empty => f.write_str("the hostname is empty"),
168 Self::TooLong => f.write_str("the hostname is longer than 253 characters"),
169 Self::Malformed => f.write_str("the hostname is not a valid DNS name"),
170 Self::IpLiteral => {
171 f.write_str("an IP address is not a hostname a customer can point here")
172 }
173 Self::Wildcard => f.write_str("wildcard hostnames are not supported"),
174 Self::Apex => f.write_str("an apex hostname cannot serve a venture; claim a subdomain"),
175 Self::OwnZone => f.write_str("that hostname is inside this deployment's own zone"),
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum CustomHostnameError {
185 NotConfigured,
187 Refused(HostnameRefusal),
190 NotFound,
192 AlreadyExists,
194 Unauthorized,
196 RateLimited,
198 Rejected(String),
200 Provider(String),
202 Transport(String),
205}
206
207impl std::fmt::Display for CustomHostnameError {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 let scrub = crate::logging::scrub_text;
210 match self {
211 Self::NotConfigured => f.write_str("no custom-hostnames provider is configured"),
212 Self::Refused(refusal) => write!(f, "hostname refused: {refusal}"),
213 Self::NotFound => f.write_str("no such custom hostname at the provider"),
214 Self::AlreadyExists => f.write_str("the hostname is already claimed at the provider"),
215 Self::Unauthorized => f.write_str(
216 "custom hostnames provider rejected the request as unauthorized \
217 (check the API token)",
218 ),
219 Self::RateLimited => f.write_str("custom hostnames provider rate limited the request"),
220 Self::Rejected(message) => write!(
221 f,
222 "custom hostnames provider rejected the request: {}",
223 scrub(message)
224 ),
225 Self::Provider(message) => {
226 write!(f, "custom hostnames provider error: {}", scrub(message))
227 }
228 Self::Transport(message) => {
229 write!(f, "custom hostnames transport error: {}", scrub(message))
230 }
231 }
232 }
233}
234
235impl std::error::Error for CustomHostnameError {}
236
237#[async_trait]
240pub trait CustomHostnames: Send + Sync {
241 async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError>;
249
250 async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError>;
256
257 async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError>;
264
265 async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError>;
273}
274
275pub fn check_hostname(hostname: &str, own_zone: &str) -> Result<String, HostnameRefusal> {
291 let trimmed = hostname.trim();
292 let name = trimmed
293 .strip_suffix('.')
294 .unwrap_or(trimmed)
295 .to_ascii_lowercase();
296 if name.is_empty() {
297 return Err(HostnameRefusal::Empty);
298 }
299 if name.len() > 253 {
300 return Err(HostnameRefusal::TooLong);
301 }
302 if name.contains('*') {
303 return Err(HostnameRefusal::Wildcard);
304 }
305 let address = name
307 .strip_prefix('[')
308 .and_then(|inner| inner.strip_suffix(']'))
309 .unwrap_or(&name);
310 if address.parse::<std::net::IpAddr>().is_ok() {
311 return Err(HostnameRefusal::IpLiteral);
312 }
313
314 let labels: Vec<&str> = name.split('.').collect();
315 if labels.len() < 2 {
316 return Err(HostnameRefusal::Malformed);
317 }
318 for label in &labels {
319 if label.is_empty()
320 || label.len() > 63
321 || !label
322 .chars()
323 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
324 || label.starts_with('-')
325 || label.ends_with('-')
326 {
327 return Err(HostnameRefusal::Malformed);
328 }
329 }
330
331 let zone = own_zone
332 .trim()
333 .strip_suffix('.')
334 .unwrap_or(own_zone.trim())
335 .to_ascii_lowercase();
336 if !zone.is_empty() && (name == zone || name.ends_with(&format!(".{zone}"))) {
337 return Err(HostnameRefusal::OwnZone);
338 }
339
340 if labels.len() < 3 {
341 return Err(HostnameRefusal::Apex);
342 }
343 Ok(name)
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn a_name_is_normalised_and_accepted() {
352 assert_eq!(
353 check_hostname("Share.Acme.COM.", "cratefield.app").as_deref(),
354 Ok("share.acme.com")
355 );
356 assert_eq!(
357 check_hostname(" share.acme.com ", "cratefield.app").as_deref(),
358 Ok("share.acme.com")
359 );
360 }
361
362 #[test]
363 fn an_apex_is_refused() {
364 assert_eq!(
365 check_hostname("acme.com", "cratefield.app"),
366 Err(HostnameRefusal::Apex)
367 );
368 }
369
370 #[test]
371 fn addresses_are_refused() {
372 for raw in ["192.0.2.1", "2001:db8::1", "[::1]"] {
373 assert_eq!(
374 check_hostname(raw, "cratefield.app"),
375 Err(HostnameRefusal::IpLiteral),
376 "{raw}"
377 );
378 }
379 }
380
381 #[test]
382 fn a_wildcard_is_refused() {
383 assert_eq!(
384 check_hostname("*.acme.com", "cratefield.app"),
385 Err(HostnameRefusal::Wildcard)
386 );
387 assert_eq!(
388 check_hostname("share.*.acme.com", "cratefield.app"),
389 Err(HostnameRefusal::Wildcard)
390 );
391 }
392
393 #[test]
394 fn the_deployments_own_zone_is_refused() {
395 assert_eq!(
396 check_hostname("acme.com", "acme.com"),
397 Err(HostnameRefusal::OwnZone),
398 "the zone itself"
399 );
400 assert_eq!(
401 check_hostname("share.acme.com", "acme.com"),
402 Err(HostnameRefusal::OwnZone),
403 "a subdomain of the zone"
404 );
405 assert_eq!(
406 check_hostname("share.acme.com", "Acme.COM."),
407 Err(HostnameRefusal::OwnZone),
408 "the zone is normalised the same way"
409 );
410 }
411
412 #[test]
413 fn a_name_over_253_characters_is_refused() {
414 let long = "a".repeat(254);
415 assert_eq!(
416 check_hostname(&long, "cratefield.app"),
417 Err(HostnameRefusal::TooLong)
418 );
419 }
420
421 #[test]
422 fn malformed_names_are_refused() {
423 for raw in [
424 "-leading.acme.com",
425 "share..acme.com",
426 "share_acme.acme.com",
427 "",
428 ] {
429 assert!(
430 matches!(
431 check_hostname(raw, "cratefield.app"),
432 Err(HostnameRefusal::Malformed | HostnameRefusal::Empty)
433 ),
434 "{raw:?}"
435 );
436 }
437 assert_eq!(
438 check_hostname("share.acme.com.", "cratefield.app").as_deref(),
439 Ok("share.acme.com"),
440 "a trailing root dot is the same name, not an empty label"
441 );
442 }
443
444 #[test]
445 fn display_never_leaks_a_bearer_token() {
446 let error = CustomHostnameError::Provider(
449 "the provider said Bearer abcdefgh1234 is not a valid token".to_owned(),
450 );
451 let text = error.to_string();
452 assert!(!text.contains("abcdefgh1234"), "{text}");
453 assert!(text.contains("Bearer [redacted]"), "{text}");
454 assert!(format!("{error:?}").contains("abcdefgh1234"));
456 }
457}