Skip to main content

edgeguard/
doctor.rs

1//! Config linting for `edgeguard doctor`.
2//!
3//! `Config::load` + `build_runtime` already prove the config *parses* and *compiles* (bad
4//! rate/size/regex/auth values fail there). The linter adds the advisory layer on top: the
5//! foot-guns a drop-in-front-of-your-app operator actually hits — the shipped placeholder
6//! credential still in place, auth turned off on a public port, secrets committed to the file,
7//! an over-permissive CORS policy. It is intentionally pure (`&Config` in, findings out) so the
8//! CLI can format it and tests can assert on it.
9
10use argon2::PasswordHash;
11
12use crate::config::Config;
13
14/// Severity of a [`Finding`]. `Error` means "this will not work / is unsafe as written" and
15/// makes `edgeguard doctor` exit non-zero; `Warn`/`Info` are advisory.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Level {
18    Error,
19    Warn,
20    Info,
21}
22
23impl Level {
24    /// A short glyph for the CLI report.
25    pub fn glyph(self) -> &'static str {
26        match self {
27            Level::Error => "✗",
28            Level::Warn => "⚠",
29            Level::Info => "ℹ",
30        }
31    }
32    pub fn label(self) -> &'static str {
33        match self {
34            Level::Error => "error",
35            Level::Warn => "warn",
36            Level::Info => "info",
37        }
38    }
39}
40
41/// One linter result: a severity and a human-readable message (with remediation where useful).
42#[derive(Debug, Clone)]
43pub struct Finding {
44    pub level: Level,
45    pub message: String,
46}
47
48impl Finding {
49    fn error(msg: impl Into<String>) -> Finding {
50        Finding {
51            level: Level::Error,
52            message: msg.into(),
53        }
54    }
55    fn warn(msg: impl Into<String>) -> Finding {
56        Finding {
57            level: Level::Warn,
58            message: msg.into(),
59        }
60    }
61    fn info(msg: impl Into<String>) -> Finding {
62        Finding {
63            level: Level::Info,
64            message: msg.into(),
65        }
66    }
67}
68
69/// Lint a resolved [`Config`] for common deployment foot-guns. Findings are ordered roughly by
70/// the pipeline (auth → rate limit → TLS → CORS → secrets → managed mode).
71pub fn lint(cfg: &Config) -> Vec<Finding> {
72    let mut f = Vec::new();
73    lint_auth(cfg, &mut f);
74    lint_ratelimit(cfg, &mut f);
75    lint_tls(cfg, &mut f);
76    lint_cors(cfg, &mut f);
77    lint_forwarded(cfg, &mut f);
78    lint_secrets(cfg, &mut f);
79    lint_control_plane(cfg, &mut f);
80    f
81}
82
83fn lint_auth(cfg: &Config, f: &mut Vec<Finding>) {
84    match cfg.auth.mode.as_str() {
85        "none" => f.push(Finding::warn(
86            "auth.mode = \"none\": every request is forwarded unauthenticated. Set a gate \
87             (basic/apikey/jwt) before exposing this.",
88        )),
89        "basic" => {
90            if cfg.auth.users.is_empty() {
91                f.push(Finding::error(
92                    "auth.mode = \"basic\" but auth.users is empty: no one can authenticate.",
93                ));
94            }
95            for (user, value) in &cfg.auth.users {
96                if value.starts_with("$argon2") {
97                    // A real PHC string parses; the shipped placeholder ($argon2id$REPLACE_ME$…)
98                    // does not, and would reject every login.
99                    if PasswordHash::new(value).is_err() {
100                        f.push(Finding::error(format!(
101                            "auth.users[\"{user}\"] is not a valid argon2 hash (the shipped \
102                             placeholder?): no one can authenticate. Run `edgeguard --hash` and \
103                             paste the result."
104                        )));
105                    }
106                } else {
107                    f.push(Finding::warn(format!(
108                        "auth.users[\"{user}\"] is a plaintext password (dev convenience). Replace \
109                         it with an argon2 hash (`edgeguard --hash`) before exposing anything."
110                    )));
111                }
112            }
113        }
114        "apikey" => {
115            if cfg.auth.api_keys.is_empty() {
116                f.push(Finding::error(
117                    "auth.mode = \"apikey\" but no api_keys are set (config or EDGEGUARD_API_KEYS): \
118                     no request can authenticate.",
119                ));
120            }
121        }
122        "jwt" => {
123            let j = &cfg.auth.jwt;
124            if j.secret.is_empty() && j.public_key_pem.is_empty() && j.jwks_url.is_empty() {
125                f.push(Finding::error(
126                    "auth.mode = \"jwt\" but none of auth.jwt.secret / public_key_pem / jwks_url \
127                     is set: tokens cannot be verified.",
128                ));
129            }
130        }
131        _ => {} // unknown modes are already rejected by build_runtime
132    }
133}
134
135fn lint_ratelimit(cfg: &Config, f: &mut Vec<Finding>) {
136    let rl = &cfg.ratelimit;
137    if !rl.enabled {
138        f.push(Finding::warn(
139            "ratelimit.enabled = false: no rate limiting. A public front door usually wants a \
140             per-IP cap to blunt abuse/brute-force.",
141        ));
142        return;
143    }
144    if rl.store == "redis" && rl.redis_url.trim().is_empty() {
145        f.push(Finding::error(
146            "ratelimit.store = \"redis\" but redis_url is empty (set it or EDGEGUARD_REDIS_URL).",
147        ));
148    }
149}
150
151fn lint_tls(cfg: &Config, f: &mut Vec<Finding>) {
152    if !cfg.tls.enabled {
153        f.push(Finding::info(
154            "tls.enabled = false: EdgeGuard serves plain HTTP. Fine when your platform terminates \
155             TLS in front of it; on a VPS/front-proxy, enable [tls] (or [tls.acme]) so traffic \
156             isn't unencrypted.",
157        ));
158    }
159}
160
161fn lint_cors(cfg: &Config, f: &mut Vec<Finding>) {
162    let c = &cfg.cors;
163    if !c.enabled {
164        return;
165    }
166    let wildcard = c.allow_origins.iter().any(|o| o.trim() == "*");
167    if wildcard && c.allow_credentials {
168        // Also rejected by build, but report it cleanly here so `doctor` names the exact fix.
169        f.push(Finding::error(
170            "cors.allow_credentials = true cannot be combined with a \"*\" origin; list explicit \
171             origins instead.",
172        ));
173    } else if wildcard {
174        f.push(Finding::warn(
175            "cors.allow_origins = [\"*\"]: any website may make cross-origin requests and read \
176             responses. Prefer an explicit origin list.",
177        ));
178    }
179}
180
181fn lint_forwarded(cfg: &Config, f: &mut Vec<Finding>) {
182    if cfg.server.trust_forwarded_for {
183        f.push(Finding::info(
184            "server.trust_forwarded_for = true: only correct when EdgeGuard is behind a trusted \
185             proxy/LB that sets X-Forwarded-For. If it's directly reachable, clients can spoof \
186             their IP and defeat per-IP rate limiting.",
187        ));
188    }
189}
190
191fn lint_secrets(cfg: &Config, f: &mut Vec<Finding>) {
192    // A secret field is populated by `Config::load` either from the file or from the environment
193    // (the env/`*_FILE` override wins). We only want to nudge when it came from the *file* — so
194    // check whether the env (or `*_FILE`) source is set; if it is, the value is env-backed and the
195    // recommended path is already in use. Without this, a correct deployment using the env vars
196    // gets wrongly scolded for "committing" a secret.
197    if !cfg.auth.jwt.secret.is_empty() && !env_sourced("EDGEGUARD_JWT_SECRET") {
198        f.push(Finding::info(
199            "auth.jwt.secret is set in the config file; prefer the EDGEGUARD_JWT_SECRET env var (or \
200             EDGEGUARD_JWT_SECRET_FILE) so the secret isn't committed.",
201        ));
202    }
203    if !cfg.auth.api_keys.is_empty() && !env_sourced("EDGEGUARD_API_KEYS") {
204        f.push(Finding::info(
205            "auth.api_keys are listed in the config file; prefer the EDGEGUARD_API_KEYS env var (or \
206             EDGEGUARD_API_KEYS_FILE).",
207        ));
208    }
209    if !cfg.control_plane.edge_token.is_empty() && !env_sourced("EDGEGUARD_CP_EDGE_TOKEN") {
210        f.push(Finding::info(
211            "control_plane.edge_token is set in the config file; prefer EDGEGUARD_CP_EDGE_TOKEN (or \
212             EDGEGUARD_CP_EDGE_TOKEN_FILE).",
213        ));
214    }
215}
216
217/// Whether a secret env var (or its `*_FILE` companion) is set non-empty — i.e. `Config::load`
218/// would have sourced the value from the environment rather than the config file.
219fn env_sourced(name: &str) -> bool {
220    let nonempty = |k: String| std::env::var(k).is_ok_and(|v| !v.is_empty());
221    nonempty(name.to_string()) || nonempty(format!("{name}_FILE"))
222}
223
224fn lint_control_plane(cfg: &Config, f: &mut Vec<Finding>) {
225    if cfg.control_plane.enforce_quota && !cfg.control_plane.enabled {
226        f.push(Finding::error(
227            "control_plane.enforce_quota = true requires control_plane.enabled = true (with \
228             url/tenant_id/edge_token); otherwise the quota gate can never be evaluated.",
229        ));
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::collections::BTreeMap;
237
238    fn has_error(f: &[Finding]) -> bool {
239        f.iter().any(|x| x.level == Level::Error)
240    }
241
242    #[test]
243    fn default_config_has_no_errors() {
244        // The shipped default (auth = none) warns but doesn't error.
245        let f = lint(&Config::default());
246        assert!(!has_error(&f), "{f:?}");
247        assert!(f.iter().any(|x| x.message.contains("auth.mode = \"none\"")));
248    }
249
250    #[test]
251    fn placeholder_basic_credential_is_an_error() {
252        let mut cfg = Config::default();
253        cfg.auth.mode = "basic".into();
254        let mut users = BTreeMap::new();
255        users.insert(
256            "admin".to_string(),
257            "$argon2id$REPLACE_ME$run-edgeguard---hash".to_string(),
258        );
259        cfg.auth.users = users;
260        let f = lint(&cfg);
261        assert!(has_error(&f), "{f:?}");
262    }
263
264    #[test]
265    fn plaintext_basic_password_warns_not_errors() {
266        let mut cfg = Config::default();
267        cfg.auth.mode = "basic".into();
268        let mut users = BTreeMap::new();
269        users.insert("admin".to_string(), "hunter2".to_string());
270        cfg.auth.users = users;
271        let f = lint(&cfg);
272        assert!(!has_error(&f), "{f:?}");
273        assert!(f.iter().any(|x| x.level == Level::Warn));
274    }
275
276    #[test]
277    fn credentialed_wildcard_cors_is_an_error() {
278        let mut cfg = Config::default();
279        cfg.cors.enabled = true;
280        cfg.cors.allow_origins = vec!["*".into()];
281        cfg.cors.allow_credentials = true;
282        assert!(has_error(&lint(&cfg)));
283    }
284
285    #[test]
286    fn jwt_without_any_key_is_an_error() {
287        let mut cfg = Config::default();
288        cfg.auth.mode = "jwt".into();
289        cfg.auth.jwt.secret = String::new();
290        assert!(has_error(&lint(&cfg)));
291    }
292
293    #[test]
294    fn secret_in_config_warns_only_when_not_env_sourced() {
295        let mut cfg = Config::default();
296        cfg.auth.jwt.secret = "shhh".into();
297        let mentions_secret =
298            |f: &[Finding]| f.iter().any(|x| x.message.contains("auth.jwt.secret"));
299
300        // No env source set → the value came from the file, so nudge.
301        std::env::remove_var("EDGEGUARD_JWT_SECRET");
302        std::env::remove_var("EDGEGUARD_JWT_SECRET_FILE");
303        assert!(mentions_secret(&lint(&cfg)));
304
305        // Env-backed (the recommended path) → must NOT be scolded for "committing" a secret.
306        std::env::set_var("EDGEGUARD_JWT_SECRET", "shhh");
307        assert!(!mentions_secret(&lint(&cfg)));
308        std::env::remove_var("EDGEGUARD_JWT_SECRET");
309    }
310}