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. `tls.self_signed = true` gets you an encrypted port immediately \
157             without obtaining a certificate first.",
158        ));
159        // HSTS over plaintext is not merely useless, it is a trap: nothing sets it (browsers
160        // ignore the header on an http:// response), so the config reads as protected while
161        // every request is still in the clear.
162        if cfg.headers.hsts {
163            f.push(Finding::warn(
164                "headers.hsts = true but tls.enabled = false: browsers ignore \
165                 Strict-Transport-Security on a plain-HTTP response, so this setting is doing \
166                 nothing. Terminate TLS here, or make sure the proxy in front of you sets HSTS.",
167            ));
168        }
169        return;
170    }
171
172    if cfg.tls.self_signed {
173        f.push(Finding::warn(
174            "tls.self_signed = true: the certificate proves no identity, so browsers show an \
175             interstitial and strict clients refuse the connection. Correct for localhost, a \
176             private network or staging; for a public domain switch to [tls.acme].",
177        ));
178        if cfg.tls.self_signed_days > 825 {
179            f.push(Finding::warn(
180                "tls.self_signed_days is over 825: some clients reject certificates with a \
181                 lifetime that long outright. Keep it short and re-issue instead.",
182            ));
183        }
184    }
185
186    if !cfg.tls.self_signed
187        && !cfg.tls.acme.enabled
188        && (cfg.tls.cert_path.is_empty() || cfg.tls.key_path.is_empty())
189    {
190        f.push(Finding::error(
191            "tls.enabled = true but there is no certificate to serve: set cert_path/key_path, \
192             or enable tls.acme (public domain) or tls.self_signed (local/internal). EdgeGuard \
193             will refuse to start as configured.",
194        ));
195    }
196
197    if cfg.tls.self_signed && (cfg.tls.cert_path.is_empty() || cfg.tls.key_path.is_empty()) {
198        f.push(Finding::error(
199            "tls.self_signed = true needs tls.cert_path and tls.key_path — they are where the \
200             generated certificate is written, not only where an existing one is read from.",
201        ));
202    }
203
204    match cfg.tls.redirect_port {
205        // The whole point of the redirect listener is to catch the browser's first, bare-hostname
206        // request. Silence here is the single most common way TLS is enabled and still bypassed.
207        0 => f.push(Finding::info(
208            "tls.redirect_port = 0: nothing is listening on plain HTTP, so a visitor who types \
209             the bare hostname gets a connection error rather than being sent to HTTPS. Set \
210             tls.redirect_port = 80 to upgrade them instead.",
211        )),
212        p if p == cfg.server.port => f.push(Finding::error(format!(
213            "tls.redirect_port = {p} is the same as server.port: the redirect listener and the \
214             TLS listener cannot share a port, and EdgeGuard will fail to bind."
215        ))),
216        p if p == cfg.server.admin_port => f.push(Finding::error(format!(
217            "tls.redirect_port = {p} is the same as server.admin_port: the two listeners cannot \
218             share a port."
219        ))),
220        p => {
221            if !(300..400).contains(&cfg.tls.redirect_status) {
222                f.push(Finding::error(format!(
223                    "tls.redirect_status = {} is not a 3xx redirect status.",
224                    cfg.tls.redirect_status
225                )));
226            }
227            if cfg.tls.redirect_hosts.is_empty() {
228                f.push(Finding::info(
229                    "tls.redirect_hosts is empty: the redirect listener reflects whatever Host \
230                     header it is sent. That is the usual behaviour, but listing the hostnames \
231                     you actually serve stops a forged Host from producing a redirect that \
232                     appears to come from your domain.",
233                ));
234            }
235            if p == 80 && cfg.tls.acme.enabled {
236                f.push(Finding::info(
237                    "tls.redirect_port = 80 with ACME enabled: EdgeGuard orders the certificate \
238                     first and starts the redirect listener afterwards, so the two do not \
239                     contend for :80. The redirect listener answers 404 (not a redirect) on \
240                     /.well-known/acme-challenge/ so a future renewal is not broken by it.",
241                ));
242            }
243        }
244    }
245}
246
247fn lint_cors(cfg: &Config, f: &mut Vec<Finding>) {
248    let c = &cfg.cors;
249    if !c.enabled {
250        return;
251    }
252    let wildcard = c.allow_origins.iter().any(|o| o.trim() == "*");
253    if wildcard && c.allow_credentials {
254        // Also rejected by build, but report it cleanly here so `doctor` names the exact fix.
255        f.push(Finding::error(
256            "cors.allow_credentials = true cannot be combined with a \"*\" origin; list explicit \
257             origins instead.",
258        ));
259    } else if wildcard {
260        f.push(Finding::warn(
261            "cors.allow_origins = [\"*\"]: any website may make cross-origin requests and read \
262             responses. Prefer an explicit origin list.",
263        ));
264    }
265}
266
267fn lint_forwarded(cfg: &Config, f: &mut Vec<Finding>) {
268    if cfg.server.trust_forwarded_for {
269        f.push(Finding::info(
270            "server.trust_forwarded_for = true: only correct when EdgeGuard is behind a trusted \
271             proxy/LB that sets X-Forwarded-For. If it's directly reachable, clients can spoof \
272             their IP and defeat per-IP rate limiting.",
273        ));
274    }
275}
276
277fn lint_secrets(cfg: &Config, f: &mut Vec<Finding>) {
278    // A secret field is populated by `Config::load` either from the file or from the environment
279    // (the env/`*_FILE` override wins). We only want to nudge when it came from the *file* — so
280    // check whether the env (or `*_FILE`) source is set; if it is, the value is env-backed and the
281    // recommended path is already in use. Without this, a correct deployment using the env vars
282    // gets wrongly scolded for "committing" a secret.
283    if !cfg.auth.jwt.secret.is_empty() && !env_sourced("EDGEGUARD_JWT_SECRET") {
284        f.push(Finding::info(
285            "auth.jwt.secret is set in the config file; prefer the EDGEGUARD_JWT_SECRET env var (or \
286             EDGEGUARD_JWT_SECRET_FILE) so the secret isn't committed.",
287        ));
288    }
289    if !cfg.auth.api_keys.is_empty() && !env_sourced("EDGEGUARD_API_KEYS") {
290        f.push(Finding::info(
291            "auth.api_keys are listed in the config file; prefer the EDGEGUARD_API_KEYS env var (or \
292             EDGEGUARD_API_KEYS_FILE).",
293        ));
294    }
295    if !cfg.control_plane.edge_token.is_empty() && !env_sourced("EDGEGUARD_CP_EDGE_TOKEN") {
296        f.push(Finding::info(
297            "control_plane.edge_token is set in the config file; prefer EDGEGUARD_CP_EDGE_TOKEN (or \
298             EDGEGUARD_CP_EDGE_TOKEN_FILE).",
299        ));
300    }
301}
302
303/// Whether a secret env var (or its `*_FILE` companion) is set non-empty — i.e. `Config::load`
304/// would have sourced the value from the environment rather than the config file.
305fn env_sourced(name: &str) -> bool {
306    let nonempty = |k: String| std::env::var(k).is_ok_and(|v| !v.is_empty());
307    nonempty(name.to_string()) || nonempty(format!("{name}_FILE"))
308}
309
310fn lint_control_plane(cfg: &Config, f: &mut Vec<Finding>) {
311    if cfg.control_plane.enforce_quota && !cfg.control_plane.enabled {
312        f.push(Finding::error(
313            "control_plane.enforce_quota = true requires control_plane.enabled = true (with \
314             url/tenant_id/edge_token); otherwise the quota gate can never be evaluated.",
315        ));
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::collections::BTreeMap;
323
324    fn has_error(f: &[Finding]) -> bool {
325        f.iter().any(|x| x.level == Level::Error)
326    }
327
328    #[test]
329    fn default_config_has_no_errors() {
330        // The shipped default (auth = none) warns but doesn't error.
331        let f = lint(&Config::default());
332        assert!(!has_error(&f), "{f:?}");
333        assert!(f.iter().any(|x| x.message.contains("auth.mode = \"none\"")));
334    }
335
336    #[test]
337    fn placeholder_basic_credential_is_an_error() {
338        let mut cfg = Config::default();
339        cfg.auth.mode = "basic".into();
340        let mut users = BTreeMap::new();
341        users.insert(
342            "admin".to_string(),
343            "$argon2id$REPLACE_ME$run-edgeguard---hash".to_string(),
344        );
345        cfg.auth.users = users;
346        let f = lint(&cfg);
347        assert!(has_error(&f), "{f:?}");
348    }
349
350    #[test]
351    fn plaintext_basic_password_warns_not_errors() {
352        let mut cfg = Config::default();
353        cfg.auth.mode = "basic".into();
354        let mut users = BTreeMap::new();
355        users.insert("admin".to_string(), "hunter2".to_string());
356        cfg.auth.users = users;
357        let f = lint(&cfg);
358        assert!(!has_error(&f), "{f:?}");
359        assert!(f.iter().any(|x| x.level == Level::Warn));
360    }
361
362    #[test]
363    fn credentialed_wildcard_cors_is_an_error() {
364        let mut cfg = Config::default();
365        cfg.cors.enabled = true;
366        cfg.cors.allow_origins = vec!["*".into()];
367        cfg.cors.allow_credentials = true;
368        assert!(has_error(&lint(&cfg)));
369    }
370
371    #[test]
372    fn tls_enabled_without_any_certificate_source_is_an_error() {
373        let mut cfg = Config::default();
374        cfg.tls.enabled = true;
375        assert!(
376            has_error(&lint(&cfg)),
377            "no cert, no ACME, no self-signed must be an error"
378        );
379
380        // Any one of the three ways to get a certificate clears it.
381        cfg.tls.self_signed = true;
382        cfg.tls.cert_path = "/tmp/c.pem".into();
383        cfg.tls.key_path = "/tmp/k.pem".into();
384        assert!(!has_error(&lint(&cfg)));
385    }
386
387    #[test]
388    fn self_signed_without_paths_is_an_error_and_with_them_only_warns() {
389        let mut cfg = Config::default();
390        cfg.tls.enabled = true;
391        cfg.tls.self_signed = true;
392        // The paths are the OUTPUT location, so omitting them is not a "we'll pick one" case.
393        assert!(has_error(&lint(&cfg)));
394
395        cfg.tls.cert_path = "/tmp/c.pem".into();
396        cfg.tls.key_path = "/tmp/k.pem".into();
397        let f = lint(&cfg);
398        assert!(!has_error(&f));
399        assert!(
400            f.iter().any(|x| x.message.contains("proves no identity")),
401            "self-signed must warn that it is not publicly trusted"
402        );
403    }
404
405    #[test]
406    fn redirect_port_colliding_with_another_listener_is_an_error() {
407        let mut cfg = Config::default();
408        cfg.tls.enabled = true;
409        cfg.tls.self_signed = true;
410        cfg.tls.cert_path = "/tmp/c.pem".into();
411        cfg.tls.key_path = "/tmp/k.pem".into();
412
413        cfg.tls.redirect_port = cfg.server.port;
414        assert!(
415            has_error(&lint(&cfg)),
416            "redirect port == server port must be an error"
417        );
418
419        cfg.server.admin_port = 9090;
420        cfg.tls.redirect_port = 9090;
421        assert!(
422            has_error(&lint(&cfg)),
423            "redirect port == admin port must be an error"
424        );
425
426        cfg.tls.redirect_port = 80;
427        assert!(!has_error(&lint(&cfg)));
428    }
429
430    #[test]
431    fn non_3xx_redirect_status_is_an_error() {
432        let mut cfg = Config::default();
433        cfg.tls.enabled = true;
434        cfg.tls.self_signed = true;
435        cfg.tls.cert_path = "/tmp/c.pem".into();
436        cfg.tls.key_path = "/tmp/k.pem".into();
437        cfg.tls.redirect_port = 80;
438        cfg.tls.redirect_status = 200;
439        assert!(has_error(&lint(&cfg)));
440    }
441
442    #[test]
443    fn hsts_without_tls_warns_that_it_does_nothing() {
444        let cfg = Config::default();
445        // The shipped default is hsts = true, tls.enabled = false — a combination that reads as
446        // protected and is not, which is exactly what doctor exists to say out loud.
447        assert!(cfg.headers.hsts && !cfg.tls.enabled);
448        let f = lint(&cfg);
449        assert!(f
450            .iter()
451            .any(|x| x.message.contains("Strict-Transport-Security")));
452        assert!(!has_error(&f), "it is a warning, not an error");
453    }
454
455    #[test]
456    fn jwt_without_any_key_is_an_error() {
457        let mut cfg = Config::default();
458        cfg.auth.mode = "jwt".into();
459        cfg.auth.jwt.secret = String::new();
460        assert!(has_error(&lint(&cfg)));
461    }
462
463    #[test]
464    fn secret_in_config_warns_only_when_not_env_sourced() {
465        let mut cfg = Config::default();
466        cfg.auth.jwt.secret = "shhh".into();
467        let mentions_secret =
468            |f: &[Finding]| f.iter().any(|x| x.message.contains("auth.jwt.secret"));
469
470        // No env source set → the value came from the file, so nudge.
471        std::env::remove_var("EDGEGUARD_JWT_SECRET");
472        std::env::remove_var("EDGEGUARD_JWT_SECRET_FILE");
473        assert!(mentions_secret(&lint(&cfg)));
474
475        // Env-backed (the recommended path) → must NOT be scolded for "committing" a secret.
476        std::env::set_var("EDGEGUARD_JWT_SECRET", "shhh");
477        assert!(!mentions_secret(&lint(&cfg)));
478        std::env::remove_var("EDGEGUARD_JWT_SECRET");
479    }
480}