1use argon2::PasswordHash;
11
12use crate::config::Config;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Level {
18 Error,
19 Warn,
20 Info,
21}
22
23impl Level {
24 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#[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
69pub 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 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 _ => {} }
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 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 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 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 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
303fn 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 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 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 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 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 std::env::remove_var("EDGEGUARD_JWT_SECRET");
472 std::env::remove_var("EDGEGUARD_JWT_SECRET_FILE");
473 assert!(mentions_secret(&lint(&cfg)));
474
475 std::env::set_var("EDGEGUARD_JWT_SECRET", "shhh");
477 assert!(!mentions_secret(&lint(&cfg)));
478 std::env::remove_var("EDGEGUARD_JWT_SECRET");
479 }
480}