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}