Skip to main content

didbot_tls/
directory.rs

1//! Which ACME directory a run talks to, made hard to get wrong by accident.
2//!
3//! Production Let's Encrypt has hard weekly rate limits per registered
4//! domain that are easy to burn through in a day of development iteration,
5//! and once burned they do not clear early. [`Environment::Staging`] is the
6//! default for exactly that reason, and [`Environment::banner`] exists so
7//! which one a running process is using is never something you have to go
8//! read source code to find out.
9
10use std::fmt;
11
12/// Which Let's Encrypt directory a certificate is requested against.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum Environment {
15    /// `https://acme-staging-v02.api.letsencrypt.org/directory`. Untrusted
16    /// certificates, generous rate limits. The default.
17    #[default]
18    Staging,
19    /// `https://acme-v02.api.letsencrypt.org/directory`. Trusted
20    /// certificates, strict rate limits.
21    Production,
22}
23
24impl Environment {
25    /// The ACME directory URL for this environment.
26    pub fn directory_url(&self) -> &'static str {
27        match self {
28            Self::Staging => "https://acme-staging-v02.api.letsencrypt.org/directory",
29            Self::Production => "https://acme-v02.api.letsencrypt.org/directory",
30        }
31    }
32
33    /// The issuer domain to name in this zone's `CAA` record.
34    ///
35    /// Both environments are Let's Encrypt, so both answer the same thing:
36    /// RFC 8659's `issue` property names the authority, not the directory,
37    /// and staging and production are two directories of one authority. It
38    /// is derived from this enum rather than configured so that the record
39    /// authorising an issuer and the directory the order is placed against
40    /// cannot be made to disagree — a `CAA` record naming an authority this
41    /// deployment does not use is an outage at the next renewal.
42    pub fn issuer_domain(&self) -> &'static str {
43        match self {
44            Self::Staging | Self::Production => "letsencrypt.org",
45        }
46    }
47
48    /// A one-line, hard-to-miss statement of which environment this is —
49    /// meant for a startup log line, not for parsing.
50    pub fn banner(&self) -> String {
51        match self {
52            Self::Staging => {
53                "ACME environment: STAGING (untrusted certificates, generous rate limits)"
54                    .to_owned()
55            }
56            Self::Production => {
57                "ACME environment: PRODUCTION (trusted certificates, strict rate limits -- \
58                 do not iterate against this)"
59                    .to_owned()
60            }
61        }
62    }
63
64    /// Parses `"staging"` or `"production"`, case-insensitively. Anything
65    /// else is refused rather than silently defaulting, because a typo that
66    /// falls back to staging in what somebody believed was a production
67    /// deploy is worse than a startup failure.
68    pub fn parse(value: &str) -> Result<Self, String> {
69        match value.to_ascii_lowercase().as_str() {
70            "staging" => Ok(Self::Staging),
71            "production" => Ok(Self::Production),
72            other => Err(format!(
73                "unrecognised ACME environment {other:?}: expected \"staging\" or \"production\""
74            )),
75        }
76    }
77}
78
79impl fmt::Display for Environment {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Staging => f.write_str("staging"),
83            Self::Production => f.write_str("production"),
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn defaults_to_staging() {
94        assert_eq!(Environment::default(), Environment::Staging);
95    }
96
97    #[test]
98    fn parses_case_insensitively() {
99        assert_eq!(
100            Environment::parse("Production").unwrap(),
101            Environment::Production
102        );
103        assert_eq!(Environment::parse("STAGING").unwrap(), Environment::Staging);
104    }
105
106    #[test]
107    fn refuses_an_unknown_value_rather_than_defaulting() {
108        assert!(Environment::parse("prod").is_err());
109    }
110
111    #[test]
112    fn both_directories_are_one_issuer() {
113        assert_eq!(
114            Environment::Staging.issuer_domain(),
115            Environment::Production.issuer_domain()
116        );
117    }
118
119    #[test]
120    fn directory_urls_are_distinct() {
121        assert_ne!(
122            Environment::Staging.directory_url(),
123            Environment::Production.directory_url()
124        );
125    }
126
127    #[test]
128    fn banners_name_their_own_environment() {
129        assert!(Environment::Staging.banner().contains("STAGING"));
130        assert!(Environment::Production.banner().contains("PRODUCTION"));
131    }
132}