Skip to main content

cratefield_core/ports/
custom_hostnames.rs

1//! The `CustomHostnames` port (issue #590): serving a venture on a hostname
2//! the customer owns — `share.acme.com` `CNAMEd` at the venture. The venture
3//! holds `Arc<dyn CustomHostnames>` and never learns that the provider
4//! behind it is Cloudflare for `SaaS`; the claim, its certificate and the DNS
5//! record the customer has to publish all travel as values from this
6//! module.
7//!
8//! A hostname the provider will not serve is a **recorded failure**, not a
9//! pending state: [`HostnameRefusal`] is checked before any request leaves
10//! the process, exactly as the dashboard's domains screen records its own
11//! refusals, and a provider that answers [`ProviderStatus::Failed`] says so
12//! rather than leaving a claim that merely looks unfinished.
13//!
14//! Out of scope, named so the boundary reads as a decision: **apex
15//! hostnames**. [`check_hostname`] refuses anything under three labels
16//! because the flow hands the customer a `CNAME`, which an apex cannot
17//! carry; Cloudflare's apex paths (Custom Nameservers, pre-validation) are
18//! a product decision this port does not guess at.
19
20use async_trait::async_trait;
21
22/// How the provider proves the customer controls the hostname: over HTTP
23/// (the provider fetches a token) or a `TXT` record the customer
24/// publishes. Cloudflare for `SaaS` defaults to HTTP.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ValidationMethod {
27    #[default]
28    Http,
29    Txt,
30}
31
32impl ValidationMethod {
33    /// The stable token recorded and sent to the provider.
34    #[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/// A hostname a customer wants served, and how they will prove they own
44/// it.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct HostnameClaim {
47    pub hostname: String,
48    pub method: ValidationMethod,
49}
50
51impl HostnameClaim {
52    /// A claim for `hostname`, validated over HTTP — the provider's
53    /// default.
54    #[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    /// The same claim, proved another way.
63    #[must_use]
64    pub fn with_method(mut self, method: ValidationMethod) -> Self {
65        self.method = method;
66        self
67    }
68}
69
70/// The DNS record type a [`Validation`] names.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum DnsRecordType {
73    Txt,
74    Cname,
75}
76
77impl DnsRecordType {
78    /// The token as it is written into DNS.
79    #[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/// One DNS record the customer has to publish for validation — a `TXT`
89/// pre-validation record (DCV), or a `CNAME` pointing the name here.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Validation {
92    pub record_type: DnsRecordType,
93    pub name: String,
94    pub value: String,
95}
96
97/// The hostname's own state at the provider.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ProviderStatus {
100    /// Registered; the customer's records have not been seen yet.
101    Pending,
102    /// Serving.
103    Active,
104    /// A step failed. `reason` is the provider's own wording, handed over
105    /// by the adapter already scrubbed with [`crate::logging::scrub_text`].
106    Failed { reason: String },
107}
108
109/// The edge certificate's state — separate from [`ProviderStatus`], since
110/// a hostname can be validated while its certificate is still issuing.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum CertificateStatus {
113    /// Issuing.
114    Pending,
115    /// Issued and being served.
116    Active,
117    /// Issuance failed. `reason` is the provider's wording, handed over by
118    /// the adapter already scrubbed with [`crate::logging::scrub_text`].
119    Failed { reason: String },
120}
121
122/// A hostname as the provider sees it: its id, its state, its certificate
123/// and the DNS records the customer must publish.
124#[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    /// Whether the name is actually serving: validated **and** holding an
135    /// active certificate. Either half alone is not enough.
136    #[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/// Why a hostname was refused before it ever reached the provider.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum HostnameRefusal {
146    /// No name was given.
147    Empty,
148    /// Longer than the DNS wire limit of 253 characters.
149    TooLong,
150    /// Not a DNS name: a bad label, an empty label, a stray character.
151    Malformed,
152    /// An address literal, not a hostname a customer can point here.
153    IpLiteral,
154    /// `*.example.com`: the provider certifies one name at a time.
155    Wildcard,
156    /// An apex (`example.com`): the flow hands the customer a `CNAME`,
157    /// which an apex cannot carry.
158    Apex,
159    /// Inside the deployment's own zone — the venture already answers
160    /// here.
161    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/// Custom-hostname failures. Every variant that carries the provider's own
181/// text is scrubbed in `Display` (issue #235), the same way
182/// [`MailError`](crate::MailError)'s is; `Debug` stays raw for tests.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum CustomHostnameError {
185    /// No zone or token is configured — answered before any request.
186    NotConfigured,
187    /// The hostname failed [`check_hostname`]; it was never sent to the
188    /// provider.
189    Refused(HostnameRefusal),
190    /// The provider has no such hostname.
191    NotFound,
192    /// The hostname is already claimed at the provider.
193    AlreadyExists,
194    /// The token was rejected or lacks permission.
195    Unauthorized,
196    /// Too many requests; retry later.
197    RateLimited,
198    /// The provider refused the request (a 4xx with a message).
199    Rejected(String),
200    /// The provider failed on its side (a 5xx, an unparseable envelope).
201    Provider(String),
202    /// The request never got an answer: a socket, a DNS failure, a
203    /// timeout.
204    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/// Claims, reads and releases custom hostnames at whichever provider the
238/// venture wired.
239#[async_trait]
240pub trait CustomHostnames: Send + Sync {
241    /// Claims `claim.hostname` with the provider and starts DV validation.
242    ///
243    /// # Errors
244    /// [`CustomHostnameError::NotConfigured`] when no provider is wired;
245    /// [`CustomHostnameError::Refused`] when the hostname fails
246    /// [`check_hostname`]; [`CustomHostnameError::AlreadyExists`] when the
247    /// name is already claimed; and the provider's own failures.
248    async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError>;
249
250    /// Reads the claim; `Ok(None)` when the provider has no such hostname.
251    ///
252    /// # Errors
253    /// [`CustomHostnameError::NotConfigured`], or the provider's own
254    /// failures.
255    async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError>;
256
257    /// Releases the claim; deleting a hostname that is not there is `Ok`
258    /// (idempotent).
259    ///
260    /// # Errors
261    /// [`CustomHostnameError::NotConfigured`], or the provider's own
262    /// failures.
263    async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError>;
264
265    /// Asks the provider to re-run validation now and returns the fresh
266    /// state.
267    ///
268    /// # Errors
269    /// [`CustomHostnameError::NotFound`] when the provider has no such
270    /// hostname, [`CustomHostnameError::NotConfigured`], or the provider's
271    /// own failures.
272    async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError>;
273}
274
275/// Validation helper, called by adapters **before** any provider call:
276/// normalises `hostname` (trimmed, lowercased, one trailing root dot
277/// stripped) or says why it cannot serve.
278///
279/// The rules, in the order they bite: empty; longer than 253 characters; a
280/// wildcard; an address literal; labels that are not `1..=63` characters
281/// of `[a-z0-9-]` and neither empty nor hyphen-edged; a name inside
282/// `own_zone`; and finally fewer than three labels.
283///
284/// The apex check counts labels and holds no public-suffix list, so
285/// `acme.co.uk` — a registrable name with three labels — is not caught
286/// here. The provider and the product decision own those.
287///
288/// # Errors
289/// Every [`HostnameRefusal`], in the order above.
290pub 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    // `[::1]` is the bracketed form a URL carries; the address is the same.
306    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        // A provider body echoing the request's credential must not reach
447        // a log line or a problem detail through `Display`.
448        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        // `Debug` keeps the raw text for tests.
455        assert!(format!("{error:?}").contains("abcdefgh1234"));
456    }
457}