Skip to main content

rws_proxy/config_check/
mod.rs

1//! `rws --check`: validate `rws.config.toml` without starting the server —
2//! the counterpart of `nginx -t` / `caddy validate`.
3//!
4//! Reports unknown keys (in settings and in proxy sections), values of the
5//! wrong type, TLS files that can't be loaded, proxy routes pointing at
6//! undeclared upstreams, auth secrets that aren't set, and similar mistakes
7//! that would otherwise surface as a warning in the startup log or as a 503
8//! at request time. Nothing is bound and the environment is not modified.
9//!
10//! ```bash
11//! rws --check                      # the file rws would load (RWS_CONFIG_FILE or ./rws.config.toml)
12//! rws --check=/etc/rws/site.toml   # a specific file
13//! ```
14
15#[cfg(test)]
16mod tests;
17
18use std::io::Cursor;
19use std::path::Path;
20
21use crate::entry_point::config_file::collect_settings;
22use crate::entry_point::Config;
23
24/// Errors and warnings found in a configuration file.
25#[derive(Debug, Default, Clone, PartialEq)]
26pub struct ConfigReport {
27    /// Mistakes: the configuration does not do what it says.
28    pub errors: Vec<String>,
29    /// Likely mistakes that don't stop the server working.
30    pub warnings: Vec<String>,
31}
32
33impl ConfigReport {
34    pub fn error(&mut self, message: impl Into<String>) {
35        self.errors.push(message.into());
36    }
37
38    pub fn warn(&mut self, message: impl Into<String>) {
39        self.warnings.push(message.into());
40    }
41
42    /// `true` when there are no errors (warnings are allowed).
43    pub fn is_ok(&self) -> bool {
44        self.errors.is_empty()
45    }
46
47    /// One JSON object: `{"path":…,"ok":…,"errors":[…],"warnings":[…]}`.
48    /// Strings are escaped; exit codes stay with the caller.
49    pub fn render_json(&self, path: &str) -> String {
50        fn escaped(s: &str) -> String {
51            let mut out = String::with_capacity(s.len() + 2);
52            for c in s.chars() {
53                match c {
54                    '"' => out.push_str("\\\""),
55                    '\\' => out.push_str("\\\\"),
56                    '\n' => out.push_str("\\n"),
57                    '\r' => out.push_str("\\r"),
58                    '\t' => out.push_str("\\t"),
59                    c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
60                    c => out.push(c),
61                }
62            }
63            out
64        }
65        let list = |items: &[String]| {
66            let inner: Vec<String> = items.iter().map(|i| format!("\"{}\"", escaped(i))).collect();
67            format!("[{}]", inner.join(","))
68        };
69        format!(
70            "{{\"path\":\"{}\",\"ok\":{},\"errors\":{},\"warnings\":{}}}",
71            escaped(path),
72            self.is_ok(),
73            list(&self.errors),
74            list(&self.warnings),
75        )
76    }
77
78    /// Human-readable report, one finding per line, ending in a verdict.
79    pub fn render(&self, path: &str) -> String {
80        let mut out = String::new();
81        for e in &self.errors {
82            out.push_str(&format!("error: {e}\n"));
83        }
84        for w in &self.warnings {
85            out.push_str(&format!("warning: {w}\n"));
86        }
87        if self.is_ok() {
88            out.push_str(&format!("{path}: configuration OK"));
89            if !self.warnings.is_empty() {
90                out.push_str(&format!(" ({} warning{})", self.warnings.len(), plural(self.warnings.len())));
91            }
92        } else {
93            out.push_str(&format!(
94                "{path}: {} error{}, {} warning{}",
95                self.errors.len(), plural(self.errors.len()),
96                self.warnings.len(), plural(self.warnings.len()),
97            ));
98        }
99        out.push('\n');
100        out
101    }
102}
103
104fn plural(n: usize) -> &'static str {
105    if n == 1 { "" } else { "s" }
106}
107
108/// The path `--check` targets in `args` (the process arguments), or `None`
109/// when `--check` is absent. `--check=<path>` and `--check <path>` name a
110/// file; a bare `--check` checks the file the server would load.
111pub fn check_target_from_args(args: &[String]) -> Option<String> {
112    let mut iter = args.iter().skip(1).peekable();
113    while let Some(arg) = iter.next() {
114        if let Some(path) = arg.strip_prefix("--check=") {
115            return Some(path.to_string());
116        }
117        if arg == "--check" {
118            return Some(match iter.peek() {
119                Some(next) if !next.starts_with('-') => next.to_string(),
120                _ => default_config_path(),
121            });
122        }
123    }
124    None
125}
126
127/// Whether the arguments ask for machine-readable output (`--format=json`).
128pub fn wants_json_output(args: &[String]) -> bool {
129    args.iter().any(|a| a == "--format=json")
130}
131
132/// `RWS_CONFIG_FILE` if set, else `rws.config.toml` in the working directory.
133pub fn default_config_path() -> String {
134    std::env::var("RWS_CONFIG_FILE")
135        .ok()
136        .filter(|p| !p.trim().is_empty())
137        .unwrap_or_else(|| "rws.config.toml".to_string())
138}
139
140/// Check the configuration file at `path`.
141pub fn check_file(path: &str) -> ConfigReport {
142    match std::fs::read_to_string(path) {
143        Ok(content) => check_str(&content),
144        Err(e) => {
145            let mut report = ConfigReport::default();
146            report.error(format!("cannot read {path}: {e}"));
147            report
148        }
149    }
150}
151
152thread_local! {
153    static OFFLINE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
154}
155
156/// `true` inside [`check_str_offline`]: checks that would read this
157/// machine's files or environment are skipped.
158#[doc(hidden)]
159pub fn offline() -> bool {
160    OFFLINE.with(|o| o.get())
161}
162
163/// [`check_str`] without looking at this machine: no file is opened or
164/// tested for existence (certificates, keys, CA bundles, htpasswd files,
165/// static roots, programs) and no environment variable is read (auth
166/// secrets, DNS API credentials). For configuration text from someone else,
167/// e.g. the docs MCP server's `check_config` tool, where those answers would
168/// describe the server, not the caller's deployment.
169pub fn check_str_offline(content: &str) -> ConfigReport {
170    struct Reset;
171    impl Drop for Reset {
172        fn drop(&mut self) {
173            OFFLINE.with(|o| o.set(false));
174        }
175    }
176    OFFLINE.with(|o| o.set(true));
177    let _reset = Reset;
178    check_str(content)
179}
180
181/// Check configuration text. Relative file paths in it (certificates,
182/// htpasswd files, static roots) are resolved against the working directory,
183/// as the server resolves them.
184pub fn check_str(content: &str) -> ConfigReport {
185    let mut report = ConfigReport::default();
186    if content.contains('\0') {
187        report.error("the file contains a NUL byte");
188        return report;
189    }
190
191    match collect_settings(Cursor::new(content.as_bytes()), String::new()) {
192        Ok(resolved) => {
193            for key in &resolved.unknown {
194                report.error(format!("unknown setting '{key}' — ignored"));
195            }
196            check_settings(&resolved.settings, &mut report);
197        }
198        Err(e) => report.error(format!("unable to parse: {e}")),
199    }
200
201    #[cfg(not(target_arch = "wasm32"))]
202    if content.contains("[[route]]") || content.contains("[[upstream]]")
203        || content.contains("[[tcp_proxy]]") || content.contains("[[udp_proxy]]") || content.contains("[[ws_proxy]]")
204    {
205        crate::proxy_config::validate::check_unknown_keys(content, &mut report);
206        crate::proxy_config::ProxyConfig::from_str(content).validate(&mut report);
207    } else if content.lines().any(|l| l.trim().starts_with("[middleware")) {
208        report.warn("[middleware] only applies in proxy mode (a file with [[route]] or [[upstream]] sections)");
209    }
210
211    report
212}
213
214/// Type and consistency checks for the `RWS_CONFIG_*` settings in the file.
215fn check_settings(settings: &[(String, String)], report: &mut ConfigReport) {
216    let get = |name: &str| settings.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.trim());
217
218    for (name, value) in settings {
219        let value = value.trim();
220        let key = setting_key(name);
221        match name.as_str() {
222            Config::RWS_CONFIG_PORT | Config::RWS_CONFIG_HTTP_REDIRECT_PORT | Config::RWS_CONFIG_ACME_CHALLENGE_PORT => {
223                if !(name == Config::RWS_CONFIG_HTTP_REDIRECT_PORT && value.is_empty())
224                    && !value.parse::<u16>().is_ok_and(|p| p > 0)
225                {
226                    report.error(format!("{key} = {value:?}: not a port number (1-65535)"));
227                }
228            }
229            Config::RWS_CONFIG_IP => {
230                if value.parse::<std::net::IpAddr>().is_err() {
231                    report.error(format!("{key} = {value:?}: not an IP address (use 0.0.0.0 or :: for all interfaces)"));
232                }
233            }
234            Config::RWS_CONFIG_THREAD_COUNT => {
235                if !value.parse::<usize>().is_ok_and(|n| n > 0) {
236                    report.error(format!("{key} = {value:?}: not a positive number"));
237                }
238            }
239            Config::RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES => {
240                if !value.parse::<u64>().is_ok_and(|n| n > 0) {
241                    report.error(format!("{key} = {value:?}: not a positive number"));
242                }
243            }
244            "RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES" | "RWS_CONFIG_OPEN_FILE_CACHE_SECS" | "RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS" | "RWS_CONFIG_RATE_LIMIT_WINDOW_SECS"
245            | "RWS_CONFIG_ACME_RENEW_BEFORE_DAYS" | "RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS"
246            | "RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD" | "RWS_CONFIG_CIRCUIT_BREAKER_MAX_HALF_OPEN_PROBES"
247            | "RWS_CONFIG_CIRCUIT_BREAKER_RECOVERY_SECS" | Config::RWS_CONFIG_CORS_MAX_AGE => {
248                if value.parse::<u64>().is_err() {
249                    report.error(format!("{key} = {value:?}: not a whole number"));
250                }
251            }
252            Config::RWS_CONFIG_CORS_ALLOW_ALL | Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS | Config::RWS_CONFIG_ACME_STAGING
253            | "RWS_CONFIG_CLIENT_HINTS" | "RWS_CONFIG_OPEN_FILE_CACHE_STRICT"
254            | "RWS_CONFIG_SECURITY_HEADERS" | "RWS_CONFIG_NANOS_HEADERS" | "RWS_CONFIG_SERVE_DOTFILES" | "RWS_CONFIG_WATCH"
255            | "RWS_CONFIG_PROXY_PROTOCOL" | "RWS_CONFIG_GATEWAY_API_ENABLED" => {
256                if !matches!(value.to_ascii_lowercase().as_str(), "true" | "false") {
257                    report.error(format!("{key} = {value:?}: expected true or false"));
258                }
259            }
260            "RWS_CONFIG_GATEWAY_API_MODE" => {
261                if !matches!(value.to_ascii_lowercase().as_str(), "shared" | "provisioned") {
262                    report.error(format!("{key} = {value:?}: expected shared or provisioned"));
263                }
264            }
265            "RWS_CONFIG_ACCESS_LOG_MAX_SIZE" | "RWS_CONFIG_ACCESS_LOG_KEEP" => {
266                if crate::disk_guard::parse_size(value).is_none() {
267                    report.error(format!("{key} = {value:?}: expected a number of bytes or files (e.g. 104857600 or 100M)"));
268                }
269            }
270            "RWS_CONFIG_RUNTIME" => {
271                if !matches!(value.to_ascii_lowercase().as_str(), "shared" | "per-core") {
272                    report.error(format!("{key} = {value:?}: expected \"shared\" or \"per-core\""));
273                }
274            }
275            "RWS_CONFIG_LOG_FORMAT" => {
276                if !matches!(value.to_ascii_lowercase().as_str(), "combined" | "json" | "off" | "none") {
277                    report.error(format!("{key} = {value:?}: expected \"combined\", \"json\" or \"off\""));
278                }
279            }
280            "RWS_CONFIG_TLS_CLIENT_CA_FILE" | Config::RWS_CONFIG_ACME_CA_FILE => {
281                if !offline() && !value.is_empty() && !Path::new(value).is_file() {
282                    report.error(format!("{key} = {value:?}: file not found"));
283                }
284            }
285            #[cfg(feature = "http2")]
286            "RWS_CONFIG_TLS_CLIENT_CRL_FILE" => {
287                if !offline() && !value.is_empty() {
288                    if let Err(e) = crate::tls::load_crls(value) {
289                        report.error(format!("{key}: {e}"));
290                    }
291                }
292            }
293            "RWS_CONFIG_TRUSTED_PROXIES" => {
294                if let Err(e) = crate::ip_filter::TrustedProxies::parse(value) {
295                    report.error(format!("{key}: {e}"));
296                } else if value.trim() == "*" {
297                    report.warn(format!("{key} = \"*\": every client can set X-Forwarded-Proto/Host and Forwarded (claim https, another host); list your load balancers instead"));
298                }
299            }
300            "RWS_CONFIG_TLS" => {
301                if !value.is_empty() && !value.eq_ignore_ascii_case("internal") {
302                    report.error(format!("{key} = {value:?}: expected \"internal\" (or leave it out)"));
303                }
304            }
305            "RWS_CONFIG_TEMPLATE_DIR" => {
306                if !offline() && !value.is_empty() && !Path::new(value).is_dir() {
307                    report.warn(format!("{key} = {value:?}: directory not found"));
308                }
309            }
310            _ => {}
311        }
312    }
313
314    #[cfg(not(target_arch = "wasm32"))]
315    crate::admin::validate(&|name| get(name).map(str::to_string), report);
316
317    // TLS: both files or neither; loadable unless ACME will create them.
318    let acme_domains = get(Config::RWS_CONFIG_ACME_DOMAINS).unwrap_or("");
319    let acme = !acme_domains.is_empty();
320    let cert = get(Config::RWS_CONFIG_TLS_CERT_FILE).unwrap_or("");
321    let key = get(Config::RWS_CONFIG_TLS_KEY_FILE).unwrap_or("");
322    match (cert.is_empty(), key.is_empty()) {
323        (false, true) => report.error("tls_cert_file is set but tls_key_file is not"),
324        (true, false) => report.error("tls_key_file is set but tls_cert_file is not"),
325        (false, false) if !acme => check_tls_pair("tls_cert_file / tls_key_file", cert, key, report),
326        _ => {}
327    }
328    // spa_fallback names a file relative to the working directory; if it is
329    // missing, every client-side route except / 404s.
330    if let Some(fallback) = get(Config::RWS_CONFIG_SPA_FALLBACK).filter(|v| !v.is_empty()) {
331        if !offline() && !std::path::Path::new(fallback).is_file() {
332            report.warn(format!(
333                "spa_fallback = {fallback:?}: the file does not exist in the working directory, so every client-side route would 404"
334            ));
335        }
336    }
337
338    // [cors] allow_origins must match what the browser sends in Origin:
339    // scheme://host[:port], no trailing slash, no path.
340    if let Some(origins) = get(Config::RWS_CONFIG_CORS_ALLOW_ORIGINS) {
341        for origin in origins.split(',').map(str::trim).filter(|o| !o.is_empty()) {
342            if origin != "*" && !origin.starts_with("http://") && !origin.starts_with("https://") {
343                report.warn(format!(
344                    "[cors] allow_origins: '{origin}' does not look like scheme://host[:port]; a browser's Origin header never matches it"
345                ));
346            } else if origin != "*" && (origin.ends_with('/') || origin.rsplit_once("://").is_some_and(|(_, rest)| rest.contains('/'))) {
347                report.warn(format!(
348                    "[cors] allow_origins: '{origin}' has a path or trailing slash; Origin headers carry none, so it never matches"
349                ));
350            }
351        }
352    }
353
354    let mut seen_domains: Vec<&str> = Vec::new();
355    for i in 0.. {
356        let domain = get(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_DOMAIN"));
357        let cert = get(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_CERT_FILE"));
358        let key = get(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_KEY_FILE"));
359        if domain.is_none() && cert.is_none() && key.is_none() {
360            break;
361        }
362        let label = format!("[[virtual_host]] {}", domain.unwrap_or("(no domain)"));
363        match (domain, cert, key) {
364            (Some(d), Some(c), Some(k)) if !d.is_empty() => {
365                // Two entries with the same domain: only one certificate can
366                // win, so the loser is a silent misconfiguration.
367                if seen_domains.contains(&d) {
368                    report.error(format!(
369                        "{label}: a second [[virtual_host]] with the same domain; only one certificate can win"
370                    ));
371                } else {
372                    seen_domains.push(d);
373                }
374                check_tls_pair(&label, c, k, report)
375            }
376            _ => report.error(format!("{label}: needs domain, cert_file and key_file")),
377        }
378    }
379
380    if acme {
381        #[cfg(not(feature = "acme"))]
382        report.error("[acme] is configured, but this rws binary was built without the `acme` feature — no certificate would be requested");
383        if get(Config::RWS_CONFIG_ACME_EMAIL).unwrap_or("").is_empty() {
384            report.warn("[acme] has no email: the CA can't warn you about expiring or revoked certificates");
385        }
386        let challenge = get(Config::RWS_CONFIG_ACME_CHALLENGE).map(str::trim).filter(|v| !v.is_empty()).unwrap_or("http-01").to_ascii_lowercase();
387        if !matches!(challenge.as_str(), "http-01" | "tls-alpn-01" | "dns-01" | "dns-persist-01") {
388            report.error(format!("[acme] challenge = {challenge:?}: expected http-01, tls-alpn-01, dns-01 or dns-persist-01"));
389        }
390        for domain in acme_domains.split(',').map(str::trim) {
391            // A wildcard is one leading "*." label (RFC 8555 §7.1.3).
392            let (wildcard, name) = match domain.strip_prefix("*.") {
393                Some(rest) => (true, rest),
394                None => (false, domain),
395            };
396            if name.is_empty() || name.contains('*') || name.contains('/') || name.contains(':') || !name.contains('.') {
397                report.error(format!("[acme] domains: '{domain}' is not a domain name"));
398            } else if wildcard && !challenge.starts_with("dns-") {
399                report.error(format!("[acme] domains: the wildcard '{domain}' needs challenge = \"dns-01\" or \"dns-persist-01\""));
400            }
401        }
402        let port = get(Config::RWS_CONFIG_PORT).unwrap_or("7878");
403        let challenge_port = get(Config::RWS_CONFIG_ACME_CHALLENGE_PORT).unwrap_or("80");
404        let redirect_port = get(Config::RWS_CONFIG_HTTP_REDIRECT_PORT).unwrap_or("");
405        if challenge == "http-01" && redirect_port.is_empty() {
406            report.warn(format!(
407                "[acme] without http_redirect_port: nothing answers the HTTP-01 challenge on port {challenge_port}; set http_redirect_port = {challenge_port}"
408            ));
409        }
410        if challenge == "dns-01" {
411            check_acme_dns(&get, report);
412        }
413        if challenge == "dns-persist-01" && get(Config::RWS_CONFIG_ACME_DNS_PROVIDER).is_some_and(|p| !p.trim().is_empty()) {
414            report.warn("[acme] dns_provider is ignored with dns-persist-01: you publish its record once yourself (rws prints it)");
415        }
416        let on_demand = get(Config::RWS_CONFIG_ACME_ON_DEMAND).unwrap_or("").trim().to_ascii_lowercase();
417        if !matches!(on_demand.as_str(), "" | "true" | "false") {
418            report.error(format!("[acme] on_demand = {on_demand:?}: expected true or false"));
419        } else if on_demand == "true" {
420            match get(Config::RWS_CONFIG_ACME_ON_DEMAND_ASK).map(str::trim).filter(|v| !v.is_empty()) {
421                None => report.error("[acme] on_demand needs on_demand_ask: without it anyone could make rws order certificates for any name pointed at it"),
422                Some(ask) if !(ask.starts_with("http://") || ask.starts_with("https://")) => {
423                    report.error(format!("[acme] on_demand_ask = {ask:?}: expected an http:// or https:// URL"))
424                }
425                Some(_) => {}
426            }
427            if challenge.starts_with("dns-") {
428                report.error(format!("[acme] on_demand works with http-01 and tls-alpn-01, not {challenge} (it would need every customer's DNS zone)"));
429            }
430        }
431        if port != "443" {
432            report.warn(format!("[acme] with port = {port}: browsers expect HTTPS on 443 (fine if a port mapping forwards 443 here)"));
433        }
434    }
435}
436
437/// `dns_api_url`, when set: an http(s) URL.
438fn check_api_url(url: Option<&str>, report: &mut ConfigReport) {
439    if let Some(url) = url.filter(|u| !(u.starts_with("https://") || u.starts_with("http://"))) {
440        report.error(format!("[acme] dns_api_url = {url:?}: expected an http:// or https:// URL"));
441    }
442}
443
444/// `[acme] challenge = "dns-01"`: a provider and what it needs.
445fn check_acme_dns<'a>(get: &dyn Fn(&str) -> Option<&'a str>, report: &mut ConfigReport) {
446    let set = |name: &str| get(name).map(str::trim).filter(|v| !v.is_empty());
447    match set(Config::RWS_CONFIG_ACME_DNS_PROVIDER).map(str::to_ascii_lowercase).as_deref() {
448        Some("rfc2136") => {
449            match set(Config::RWS_CONFIG_ACME_DNS_SERVER) {
450                None => report.error("[acme] dns_provider = \"rfc2136\" needs dns_server (the primary DNS server, host[:port])"),
451                Some(server) if server.contains('/') || server.is_empty() => report.error(format!("[acme] dns_server = {server:?}: expected host[:port]")),
452                Some(_) => {}
453            }
454            match (set(Config::RWS_CONFIG_ACME_DNS_TSIG_KEY), set(Config::RWS_CONFIG_ACME_DNS_TSIG_SECRET)) {
455                (Some(_), None) | (None, Some(_)) => report.error("[acme] dns_tsig_key and dns_tsig_secret go together"),
456                (None, None) => report.warn("[acme] rfc2136 without a TSIG key: the server must accept unsigned updates from this host"),
457                (Some(_), Some(secret)) => {
458                    #[cfg(feature = "acme")]
459                    if crate::acme::base64_secret(secret).is_none() {
460                        report.error("[acme] dns_tsig_secret is not base64");
461                    }
462                    #[cfg(not(feature = "acme"))]
463                    let _ = secret;
464                }
465            }
466            if let Some(algorithm) = set(Config::RWS_CONFIG_ACME_DNS_TSIG_ALGORITHM) {
467                if !matches!(algorithm.to_ascii_lowercase().as_str(), "hmac-sha256" | "hmac-sha512") {
468                    report.error(format!("[acme] dns_tsig_algorithm = {algorithm:?}: expected hmac-sha256 or hmac-sha512"));
469                }
470            }
471        }
472        Some("exec") => match set(Config::RWS_CONFIG_ACME_DNS_EXEC) {
473            None => report.error("[acme] dns_provider = \"exec\" needs dns_exec (the program)"),
474            Some(program) if !offline() && !Path::new(program).is_file() => report.error(format!("[acme] dns_exec = {program:?}: file not found")),
475            Some(_) => {}
476        },
477        Some("cloudflare") => {
478            // Usually kept out of the file, in the environment.
479            let in_env = offline() || std::env::var(Config::RWS_CONFIG_ACME_DNS_API_TOKEN).is_ok_and(|v| !v.trim().is_empty());
480            if set(Config::RWS_CONFIG_ACME_DNS_API_TOKEN).is_none() && !in_env {
481                report.error("[acme] dns_provider = \"cloudflare\" needs dns_api_token (an API token with DNS edit permission; or RWS_CONFIG_ACME_DNS_API_TOKEN)");
482            }
483            check_api_url(set(Config::RWS_CONFIG_ACME_DNS_API_URL), report);
484        }
485        Some("route53") => {
486            let aws = |name: &str| offline() || std::env::var(name).is_ok_and(|v| !v.trim().is_empty());
487            if !(aws("AWS_ACCESS_KEY_ID") && aws("AWS_SECRET_ACCESS_KEY")) {
488                report.warn("[acme] dns_provider = \"route53\" reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the environment, and they aren't set here");
489            }
490            check_api_url(set(Config::RWS_CONFIG_ACME_DNS_API_URL), report);
491        }
492        Some(other) => report.error(format!("[acme] dns_provider = {other:?}: expected rfc2136, cloudflare, route53 or exec")),
493        None => report.error("[acme] challenge = \"dns-01\" needs dns_provider (rfc2136, cloudflare, route53 or exec)"),
494    }
495    if let Some(secs) = set(Config::RWS_CONFIG_ACME_DNS_PROPAGATION_SECS) {
496        if secs.parse::<u64>().is_err() {
497            report.error(format!("[acme] dns_propagation_secs = {secs:?}: not a whole number"));
498        }
499    }
500}
501
502/// `RWS_CONFIG_CORS_ALLOW_ALL` → `cors_allow_all`, for messages.
503fn setting_key(env_name: &str) -> String {
504    env_name.trim_start_matches("RWS_CONFIG_").to_ascii_lowercase()
505}
506
507fn check_tls_pair(label: &str, cert: &str, key: &str, report: &mut ConfigReport) {
508    if offline() {
509        return;
510    }
511    let mut missing = false;
512    let token_key = key.trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("pkcs11:"));
513    for (what, path) in [("certificate", cert), ("key", key)] {
514        if what == "key" && token_key {
515            continue;
516        }
517        if !Path::new(path).is_file() {
518            report.error(format!("{label}: {what} file '{path}' not found"));
519            missing = true;
520        }
521    }
522    if missing {
523        return;
524    }
525    #[cfg(all(feature = "http2", not(target_arch = "wasm32")))]
526    if let Err(e) = crate::tls::create_tls_acceptor(cert, key) {
527        report.error(format!("{label}: {e}"));
528    }
529    #[cfg(not(feature = "http2"))]
530    report.warn(format!("{label}: this build has no TLS support (`http1` only), so the certificate is not used"));
531}