Skip to main content

didbot_serve/tree/
names.rs

1//! Splitting a requested name into the label it adds and the zone it sits
2//! under.
3
4use axum::http::StatusCode;
5use didbot_pds::Registry;
6
7use crate::error::ApiError;
8
9/// Labels no account may take directly under a zone, absent
10/// configuration: the hostnames a deployment answers at itself, the DNS
11/// conventions beside them, and the names a browser or resolver asks for
12/// without being told to. `[tree].reserved_labels` replaces the list.
13pub const RESERVED_LABELS: &[&str] = &[
14    "www",
15    "xrpc",
16    "dashboard",
17    "api",
18    "admin",
19    "mail",
20    "mx",
21    "smtp",
22    "ns",
23    "ns1",
24    "ns2",
25    "localhost",
26    "wpad",
27    "isatap",
28    "autoconfig",
29    "autodiscover",
30];
31
32/// The longest label DNS carries, in bytes (RFC 1035 ยง2.3.4).
33const MAX_LABEL_LEN: usize = 63;
34
35/// The longest hostname DNS carries, in bytes.
36const MAX_NAME_LEN: usize = 253;
37
38/// Splits `name` into the label and the served zone it sits under.
39///
40/// The zone is the longest one the name ends with, so a name under a
41/// subzone lands in the subzone. What is left is exactly one label: a
42/// zone's certificate covers one label under it, so a deeper name would
43/// never answer over TLS. A port on the name is ignored for the match: the
44/// zone decides it. The label may not be one of `reserved`, and the name
45/// may not be a zone's own apex.
46pub(super) fn split_name(
47    registry: &dyn Registry,
48    reserved: &[String],
49    name: &str,
50) -> Result<(String, String), ApiError> {
51    let host = name.split(':').next().unwrap_or(name).to_ascii_lowercase();
52    let mut domains = registry.available_user_domains();
53    domains.sort_by_key(|domain| std::cmp::Reverse(domain.len()));
54    let invalid = |why: String| ApiError::new(StatusCode::BAD_REQUEST, "InvalidName", why);
55    if domains
56        .iter()
57        .any(|domain| domain.trim_start_matches('.') == host)
58    {
59        return Err(invalid(format!(
60            "{name:?} is one of this server's zones, not a name an account may take"
61        )));
62    }
63    let domain = domains
64        .iter()
65        .find(|domain| host.ends_with(domain.as_str()))
66        .ok_or_else(|| {
67            ApiError::new(
68                StatusCode::BAD_REQUEST,
69                "NameOutsideZone",
70                format!(
71                    "{name:?} is not under a zone this server serves ({})",
72                    domains.join(", ")
73                ),
74            )
75        })?;
76    let zone = domain.trim_start_matches('.');
77    let label = &host[..host.len() - domain.len()];
78    if let Some((_, parent)) = label.split_once('.') {
79        return Err(invalid(format!(
80            "{name:?} is more than one label under {zone}, and a zone's certificate covers \
81             one; name it one label under a zone, or add {parent}.{zone} as a zone"
82        )));
83    }
84    if label.is_empty() || didbot_identity::validate_label(label).is_err() {
85        return Err(invalid(format!("{name:?} must be one label under {zone}")));
86    }
87    if host.len() > MAX_NAME_LEN || label.len() > MAX_LABEL_LEN {
88        return Err(invalid(format!(
89            "{name:?} is longer than DNS carries: a label is at most {MAX_LABEL_LEN} bytes and \
90             a name {MAX_NAME_LEN}"
91        )));
92    }
93    if reserved.iter().any(|reserved| reserved == label) {
94        return Err(invalid(format!(
95            "{name:?} takes {label:?}, a label this deployment keeps for itself"
96        )));
97    }
98    Ok((label.to_owned(), zone.to_owned()))
99}