Skip to main content

acme_proxy_server/
roles.rs

1//! Which of the server's three jobs this process does.
2//!
3//! One binary, several processes: `acme-proxy serve --role acme,admin,worker`,
4//! defaulting to all three. **All-in-one stays the default** — the split is a
5//! deployment mode, not a replacement, and a `serve` with no `--role` builds
6//! exactly what it always did.
7//!
8//! What it buys is privilege separation, which is the point for a CA. The
9//! `acme` role parses untrusted JWS and CSRs from the internet; the `admin`
10//! role holds operator sessions; the `worker` role reaches out to
11//! client-chosen hosts, talks to an upstream CA, sends mail, and is the only
12//! one holding the CA key. Each can run under its own uid and sandbox.
13//!
14//! **This is not [`sockets::Role`](super::sockets::Role)**, and the two must
15//! not be conflated. That enum names the three *listeners* a process may hold
16//! (`acme`, `admin`, `metrics`); this one names the three *jobs* a process may
17//! do. `worker` holds no socket at all, and `metrics` is a socket every role
18//! may serve rather than a job anybody does — so the sets differ in both
19//! directions and one type could not carry both meanings.
20
21use std::fmt;
22use std::str::FromStr;
23
24/// One of the three jobs a process may do.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub enum ProcessRole {
27    /// Serve ACME to certificate clients: the ACME listener and the root
28    /// router. Enqueues work; runs none.
29    Acme,
30    /// Serve the web admin: `/ui` and `/api`. Enqueues work; runs none.
31    Admin,
32    /// Drain the job queue. Owns the schema and the first-run material.
33    Worker,
34}
35
36impl ProcessRole {
37    /// Every role, in the order `--role` documents them.
38    pub const ALL: [Self; 3] = [Self::Acme, Self::Admin, Self::Worker];
39
40    /// The spelling `--role` takes and every log line carries.
41    #[must_use]
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Self::Acme => "acme",
45            Self::Admin => "admin",
46            Self::Worker => "worker",
47        }
48    }
49}
50
51impl fmt::Display for ProcessRole {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        formatter.write_str(self.as_str())
54    }
55}
56
57impl FromStr for ProcessRole {
58    type Err = String;
59
60    /// Refuses an unknown value **by name**, listing the three.
61    ///
62    /// The `AdminRole`/`--status` rule: passing an unrecognised role through
63    /// would start a process doing less than the operator asked for, which is
64    /// the failure mode a role flag exists to make visible.
65    fn from_str(value: &str) -> Result<Self, Self::Err> {
66        match value.trim() {
67            "acme" => Ok(Self::Acme),
68            "admin" => Ok(Self::Admin),
69            "worker" => Ok(Self::Worker),
70            other => Err(format!(
71                "unknown role `{other}` (expected one of: acme, admin, worker)"
72            )),
73        }
74    }
75}
76
77/// The roles one process runs, never empty.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct RoleSet {
80    acme: bool,
81    admin: bool,
82    worker: bool,
83}
84
85impl Default for RoleSet {
86    /// All three: what `serve` with no `--role` runs, and what every
87    /// deployment before the flag existed ran.
88    fn default() -> Self {
89        Self {
90            acme: true,
91            admin: true,
92            worker: true,
93        }
94    }
95}
96
97impl RoleSet {
98    /// Whether this process runs `role`.
99    #[must_use]
100    pub fn has(self, role: ProcessRole) -> bool {
101        match role {
102            ProcessRole::Acme => self.acme,
103            ProcessRole::Admin => self.admin,
104            ProcessRole::Worker => self.worker,
105        }
106    }
107
108    /// The roles held, in a stable order, for a log field.
109    #[must_use]
110    pub fn labels(self) -> Vec<&'static str> {
111        ProcessRole::ALL
112            .into_iter()
113            .filter(|role| self.has(*role))
114            .map(ProcessRole::as_str)
115            .collect()
116    }
117
118    /// Parses a `--role` value: a comma-separated list, or `None` for all
119    /// three.
120    ///
121    /// # Errors
122    ///
123    /// An unknown role name, or a list that names none — `--role ""` asks for
124    /// a process with nothing to do, which is a typo rather than a topology.
125    pub fn parse(value: Option<&str>) -> Result<Self, String> {
126        let Some(value) = value else {
127            return Ok(Self::default());
128        };
129
130        let mut set = Self {
131            acme: false,
132            admin: false,
133            worker: false,
134        };
135        for name in value.split(',').filter(|name| !name.trim().is_empty()) {
136            match name.parse::<ProcessRole>()? {
137                ProcessRole::Acme => set.acme = true,
138                ProcessRole::Admin => set.admin = true,
139                ProcessRole::Worker => set.worker = true,
140            }
141        }
142
143        if !(set.acme || set.admin || set.worker) {
144            return Err("--role names no role (expected one of: acme, admin, worker)".to_string());
145        }
146        Ok(set)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn no_flag_is_every_role() {
156        let set = RoleSet::parse(None).unwrap();
157        for role in ProcessRole::ALL {
158            assert!(set.has(role), "{role} must be on by default");
159        }
160        assert_eq!(set, RoleSet::default());
161    }
162
163    #[test]
164    fn a_list_selects_exactly_what_it_names() {
165        let set = RoleSet::parse(Some("acme,worker")).unwrap();
166        assert!(set.has(ProcessRole::Acme));
167        assert!(set.has(ProcessRole::Worker));
168        assert!(!set.has(ProcessRole::Admin));
169        assert_eq!(set.labels(), vec!["acme", "worker"]);
170    }
171
172    #[test]
173    fn whitespace_and_repeats_are_tolerated() {
174        let set = RoleSet::parse(Some(" admin , admin ,worker")).unwrap();
175        assert_eq!(set.labels(), vec!["admin", "worker"]);
176    }
177
178    /// The `--status` rule: an unknown value is refused naming itself and the
179    /// alternatives, never silently dropped — a process quietly doing less
180    /// than asked is the failure this flag exists to prevent.
181    #[test]
182    fn an_unknown_role_is_refused_by_name() {
183        let error = RoleSet::parse(Some("acme,wroker")).unwrap_err();
184        assert!(error.contains("wroker"), "{error}");
185        assert!(error.contains("acme, admin, worker"), "{error}");
186    }
187
188    #[test]
189    fn a_list_naming_no_role_is_refused() {
190        assert!(RoleSet::parse(Some("")).unwrap_err().contains("no role"));
191        assert!(RoleSet::parse(Some(" , ")).unwrap_err().contains("no role"));
192    }
193
194    #[test]
195    fn every_role_round_trips_through_its_spelling() {
196        for role in ProcessRole::ALL {
197            assert_eq!(role.as_str().parse::<ProcessRole>().unwrap(), role);
198            assert_eq!(role.to_string(), role.as_str());
199        }
200    }
201}