Skip to main content

gatel_core/config/
parse.rs

1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::time::Duration;
4
5use kdl::{KdlDocument, KdlNode};
6
7use super::types::*;
8use crate::router::matcher::RequestMatcher;
9
10#[derive(Debug, thiserror::Error)]
11pub enum ConfigError {
12    #[error("KDL parse error: {0}")]
13    Kdl(#[from] kdl::KdlError),
14
15    #[error("missing required field: {0}")]
16    MissingField(String),
17
18    #[error("unknown directive: {0}")]
19    UnknownDirective(String),
20
21    #[error("invalid value for '{field}': {detail}")]
22    InvalidValue { field: String, detail: String },
23}
24
25/// Parse a KDL configuration string into an `AppConfig`.
26pub fn parse_config(input: &str) -> Result<AppConfig, ConfigError> {
27    let doc: KdlDocument = input.parse()?;
28    let mut config = AppConfig::default();
29
30    // First pass: collect all `snippet "name" { ... }` blocks.
31    let mut snippets: HashMap<String, KdlNode> = HashMap::new();
32    for node in doc.nodes() {
33        if node.name().to_string() == "snippet"
34            && let Some(name) = first_string_arg(node)
35        {
36            snippets.insert(name, node.clone());
37        }
38    }
39
40    // Second pass: parse the rest of the config, skipping snippet nodes.
41    for node in doc.nodes() {
42        match node.name().to_string().as_str() {
43            "snippet" => { /* already collected above */ }
44            "global" => config.global = parse_global(node)?,
45            "tls" => config.tls = Some(parse_tls(node)?),
46            "site" => {
47                let host = first_string_arg(node)
48                    .ok_or_else(|| ConfigError::MissingField("site host".into()))?;
49                config.sites.push(parse_site(&host, node, &snippets)?);
50            }
51            "stream" => config.stream = Some(parse_stream(node)?),
52            other => return Err(ConfigError::UnknownDirective(other.into())),
53        }
54    }
55    Ok(config)
56}
57
58/// Generate a minimal KDL config string from well-known environment variables.
59///
60/// Returns `None` if no relevant environment variables are set.
61///
62/// Recognised variables:
63/// - `GATEL_HTTP_ADDR`   — HTTP listen address (default `:80`)
64/// - `GATEL_HTTPS_ADDR`  — HTTPS listen address (default `:443`)
65/// - `GATEL_ADMIN_ADDR`  — admin API listen address
66/// - `GATEL_ACME_EMAIL`  — ACME email (enables auto-TLS)
67/// - `GATEL_ACME_CA`     — ACME CA (`letsencrypt`, `letsencrypt-staging`, `zerossl`; default
68///   `letsencrypt`)
69/// - `GATEL_HOST`        — virtual-host name for the generated site block
70/// - `GATEL_UPSTREAM`    — upstream proxy target (enables a `proxy` route)
71pub fn auto_config_from_env() -> Option<String> {
72    let http_addr = std::env::var("GATEL_HTTP_ADDR").ok();
73    let https_addr = std::env::var("GATEL_HTTPS_ADDR").ok();
74    let admin_addr = std::env::var("GATEL_ADMIN_ADDR").ok();
75    let acme_email = std::env::var("GATEL_ACME_EMAIL").ok();
76    let acme_ca = std::env::var("GATEL_ACME_CA").unwrap_or_else(|_| "letsencrypt".to_string());
77    let host = std::env::var("GATEL_HOST").ok();
78    let upstream = std::env::var("GATEL_UPSTREAM").ok();
79
80    // Require at least one meaningful env var to be present.
81    if http_addr.is_none()
82        && https_addr.is_none()
83        && admin_addr.is_none()
84        && acme_email.is_none()
85        && host.is_none()
86        && upstream.is_none()
87    {
88        return None;
89    }
90
91    let mut out = String::new();
92
93    // Global block.
94    out.push_str("global {\n");
95    if let Some(addr) = &http_addr {
96        out.push_str(&format!("    http \"{addr}\"\n"));
97    }
98    if let Some(addr) = &https_addr {
99        out.push_str(&format!("    https \"{addr}\"\n"));
100    }
101    if let Some(addr) = &admin_addr {
102        out.push_str(&format!("    admin \"{addr}\"\n"));
103    }
104    out.push_str("}\n");
105
106    // TLS / ACME block (only when an email is provided).
107    if let Some(email) = &acme_email {
108        out.push_str("tls {\n");
109        out.push_str("    acme {\n");
110        out.push_str(&format!("        email \"{email}\"\n"));
111        out.push_str(&format!("        ca \"{acme_ca}\"\n"));
112        out.push_str("    }\n");
113        out.push_str("}\n");
114    }
115
116    // Site / proxy block (only when an upstream is provided).
117    if let Some(upstream_addr) = &upstream {
118        let site_host = host.as_deref().unwrap_or("*");
119        out.push_str(&format!("site \"{site_host}\" {{\n"));
120        out.push_str("    route \"/*\" {\n");
121        out.push_str(&format!("        proxy \"{upstream_addr}\"\n"));
122        out.push_str("    }\n");
123        out.push_str("}\n");
124    }
125
126    Some(out)
127}
128
129// ---------------------------------------------------------------------------
130// Global
131// ---------------------------------------------------------------------------
132
133fn parse_global(node: &KdlNode) -> Result<GlobalConfig, ConfigError> {
134    let mut cfg = GlobalConfig::default();
135    let Some(children) = node.children() else {
136        return Ok(cfg);
137    };
138    for child in children.nodes() {
139        match child.name().to_string().as_str() {
140            "admin" => {
141                if let Some(addr) = first_string_arg(child) {
142                    cfg.admin_addr = Some(parse_listen_addr(&addr)?);
143                }
144            }
145            "log" => {
146                if let Some(level) = child.get("level") {
147                    cfg.log_level = level
148                        .as_string()
149                        .ok_or_else(|| ConfigError::InvalidValue {
150                            field: "log level".into(),
151                            detail: "expected string".into(),
152                        })?
153                        .to_string();
154                }
155                if let Some(format) = child.get("format") {
156                    cfg.log_format = format
157                        .as_string()
158                        .ok_or_else(|| ConfigError::InvalidValue {
159                            field: "log format".into(),
160                            detail: "expected string".into(),
161                        })?
162                        .to_string();
163                }
164            }
165            "grace-period" => {
166                if let Some(d) = first_string_arg(child) {
167                    cfg.grace_period = parse_duration(&d)?;
168                }
169            }
170            "http" => {
171                if let Some(addr) = first_string_arg(child) {
172                    cfg.http_addr = parse_listen_addr(&addr)?;
173                }
174            }
175            "https" => {
176                if let Some(addr) = first_string_arg(child) {
177                    cfg.https_addr = parse_listen_addr(&addr)?;
178                }
179            }
180            "http3" => {
181                // `http3 true` or `http3 false`
182                cfg.http3 = child
183                    .entries()
184                    .iter()
185                    .find(|e| e.name().is_none())
186                    .and_then(|e| e.value().as_bool())
187                    .unwrap_or(true);
188            }
189            "proxy-protocol" => {
190                cfg.proxy_protocol = child
191                    .entries()
192                    .iter()
193                    .find(|e| e.name().is_none())
194                    .and_then(|e| e.value().as_bool())
195                    .unwrap_or(true);
196            }
197            "access-log" => {
198                cfg.access_log = Some(parse_log_file_config(child)?);
199            }
200            "error-log" => {
201                cfg.error_log = Some(parse_log_file_config(child)?);
202            }
203            "tcp-nodelay" => {
204                cfg.tcp_nodelay = child
205                    .entries()
206                    .iter()
207                    .find(|e| e.name().is_none())
208                    .and_then(|e| e.value().as_bool())
209                    .unwrap_or(true);
210            }
211            "tcp-send-buffer" => {
212                cfg.tcp_send_buffer = child
213                    .entries()
214                    .iter()
215                    .find(|e| e.name().is_none())
216                    .and_then(|e| e.value().as_integer())
217                    .map(|v| v as usize);
218            }
219            "tcp-recv-buffer" => {
220                cfg.tcp_recv_buffer = child
221                    .entries()
222                    .iter()
223                    .find(|e| e.name().is_none())
224                    .and_then(|e| e.value().as_integer())
225                    .map(|v| v as usize);
226            }
227            "otlp-endpoint" => {
228                cfg.otlp_endpoint = first_string_arg(child);
229            }
230            "otlp-service-name" => {
231                cfg.otlp_service_name = first_string_arg(child);
232            }
233            "admin-auth-token" => {
234                cfg.admin_auth_token = first_string_arg(child);
235            }
236            other => return Err(ConfigError::UnknownDirective(other.into())),
237        }
238    }
239    Ok(cfg)
240}
241
242fn parse_log_file_config(node: &KdlNode) -> Result<LogFileConfig, ConfigError> {
243    let path =
244        first_string_arg(node).ok_or_else(|| ConfigError::MissingField("log file path".into()))?;
245    let mut format = None;
246    let mut rotate_size = None;
247    let mut rotate_keep = None;
248
249    if let Some(children) = node.children() {
250        for child in children.nodes() {
251            match child.name().to_string().as_str() {
252                "format" => {
253                    format = first_string_arg(child);
254                }
255                "rotate-size" => {
256                    rotate_size = child
257                        .entries()
258                        .iter()
259                        .find(|e| e.name().is_none())
260                        .and_then(|e| e.value().as_integer())
261                        .map(|v| v as u64);
262                }
263                "rotate-keep" => {
264                    rotate_keep = child
265                        .entries()
266                        .iter()
267                        .find(|e| e.name().is_none())
268                        .and_then(|e| e.value().as_integer())
269                        .map(|v| v as usize);
270                }
271                _ => {}
272            }
273        }
274    }
275
276    Ok(LogFileConfig {
277        path,
278        format,
279        rotate_size,
280        rotate_keep,
281    })
282}
283
284// ---------------------------------------------------------------------------
285// TLS (global)
286// ---------------------------------------------------------------------------
287
288fn parse_tls(node: &KdlNode) -> Result<TlsConfig, ConfigError> {
289    let mut tls = TlsConfig {
290        acme: None,
291        client_auth: None,
292        on_demand: None,
293        min_version: None,
294        max_version: None,
295        cipher_suites: Vec::new(),
296        ocsp_stapling: false,
297        ecdh_curves: Vec::new(),
298    };
299    let Some(children) = node.children() else {
300        return Ok(tls);
301    };
302    for child in children.nodes() {
303        match child.name().to_string().as_str() {
304            "acme" => tls.acme = Some(parse_acme(child)?),
305            "client-auth" => {
306                let mut ca_certs = Vec::new();
307                let required = child
308                    .get("required")
309                    .and_then(|v| v.as_bool())
310                    .unwrap_or(true);
311                if let Some(cc) = child.children() {
312                    for n in cc.nodes() {
313                        if n.name().to_string() == "ca-cert"
314                            && let Some(path) = first_string_arg(n)
315                        {
316                            ca_certs.push(path);
317                        }
318                    }
319                }
320                tls.client_auth = Some(ClientAuthConfig { ca_certs, required });
321            }
322            "on-demand" => {
323                let ask = child
324                    .get("ask")
325                    .and_then(|v| v.as_string())
326                    .map(|s| s.to_string());
327                let rate_limit = child
328                    .get("rate-limit")
329                    .and_then(|v| v.as_integer())
330                    .map(|v| v as u32);
331                tls.on_demand = Some(OnDemandTlsConfig { ask, rate_limit });
332            }
333            "min-version" => {
334                tls.min_version = first_string_arg(child);
335            }
336            "max-version" => {
337                tls.max_version = first_string_arg(child);
338            }
339            "cipher-suites" => {
340                // Accept space or comma separated suite names as positional args.
341                let args = string_args(child);
342                for arg in args {
343                    for part in arg.split([' ', ',']) {
344                        let part = part.trim();
345                        if !part.is_empty() {
346                            tls.cipher_suites.push(part.to_string());
347                        }
348                    }
349                }
350            }
351            "ocsp-stapling" => {
352                tls.ocsp_stapling = child
353                    .entries()
354                    .iter()
355                    .find(|e| e.name().is_none())
356                    .and_then(|e| e.value().as_bool())
357                    .unwrap_or(true);
358            }
359            "ecdh-curves" => {
360                // Accept space or comma separated curve names as positional args.
361                let args = string_args(child);
362                for arg in args {
363                    for part in arg.split([' ', ',']) {
364                        let part = part.trim();
365                        if !part.is_empty() {
366                            tls.ecdh_curves.push(part.to_string());
367                        }
368                    }
369                }
370            }
371            other => return Err(ConfigError::UnknownDirective(other.into())),
372        }
373    }
374    Ok(tls)
375}
376
377fn parse_acme(node: &KdlNode) -> Result<AcmeConfig, ConfigError> {
378    let mut email = String::new();
379    let mut ca = CertAuthority::default();
380    let mut challenge = ChallengeType::default();
381    let mut eab: Option<EabConfig> = None;
382    let mut dns_provider: Option<DnsProviderConfig> = None;
383
384    let Some(children) = node.children() else {
385        return Err(ConfigError::MissingField("acme email".into()));
386    };
387    for child in children.nodes() {
388        match child.name().to_string().as_str() {
389            "email" => {
390                email = first_string_arg(child)
391                    .ok_or_else(|| ConfigError::MissingField("acme email value".into()))?;
392            }
393            "ca" => {
394                let v = first_string_arg(child).unwrap_or_default();
395                ca = match v.as_str() {
396                    "letsencrypt" | "le" => CertAuthority::LetsEncrypt,
397                    "letsencrypt-staging" | "le-staging" => CertAuthority::LetsEncryptStaging,
398                    "zerossl" => CertAuthority::ZeroSsl,
399                    _ => {
400                        return Err(ConfigError::InvalidValue {
401                            field: "ca".into(),
402                            detail: format!("unknown CA: {v}"),
403                        });
404                    }
405                };
406            }
407            "challenge" => {
408                let v = first_string_arg(child).unwrap_or_default();
409                challenge = match v.as_str() {
410                    "http-01" => ChallengeType::Http01,
411                    "tls-alpn-01" => ChallengeType::TlsAlpn01,
412                    "dns-01" => ChallengeType::Dns01,
413                    _ => {
414                        return Err(ConfigError::InvalidValue {
415                            field: "challenge".into(),
416                            detail: format!("unknown challenge type: {v}"),
417                        });
418                    }
419                };
420            }
421            "eab" => {
422                eab = Some(parse_eab(child)?);
423            }
424            "dns-provider" => {
425                dns_provider = Some(parse_dns_provider(child)?);
426            }
427            other => return Err(ConfigError::UnknownDirective(other.into())),
428        }
429    }
430    if email.is_empty() {
431        return Err(ConfigError::MissingField("acme email".into()));
432    }
433    Ok(AcmeConfig {
434        email,
435        ca,
436        challenge,
437        eab,
438        dns_provider,
439    })
440}
441
442fn parse_eab(node: &KdlNode) -> Result<EabConfig, ConfigError> {
443    let mut kid = String::new();
444    let mut hmac_key = String::new();
445    if let Some(children) = node.children() {
446        for child in children.nodes() {
447            match child.name().to_string().as_str() {
448                "kid" => kid = first_string_arg(child).unwrap_or_default(),
449                "hmac-key" => hmac_key = first_string_arg(child).unwrap_or_default(),
450                _ => {}
451            }
452        }
453    }
454    Ok(EabConfig { kid, hmac_key })
455}
456
457fn parse_dns_provider(node: &KdlNode) -> Result<DnsProviderConfig, ConfigError> {
458    let provider = first_string_arg(node)
459        .ok_or_else(|| ConfigError::MissingField("dns-provider name".into()))?;
460    let mut api_token = None;
461    let mut api_key = None;
462    let mut api_secret = None;
463    let mut options = HashMap::new();
464    if let Some(children) = node.children() {
465        for child in children.nodes() {
466            match child.name().to_string().as_str() {
467                "api-token" => api_token = first_string_arg(child),
468                "api-key" => api_key = first_string_arg(child),
469                "api-secret" => api_secret = first_string_arg(child),
470                other => {
471                    if let Some(val) = first_string_arg(child) {
472                        options.insert(other.to_string(), val);
473                    }
474                }
475            }
476        }
477    }
478    Ok(DnsProviderConfig {
479        provider,
480        api_token,
481        api_key,
482        api_secret,
483        options,
484    })
485}
486
487// ---------------------------------------------------------------------------
488// Site
489// ---------------------------------------------------------------------------
490
491fn parse_site(
492    host: &str,
493    node: &KdlNode,
494    snippets: &HashMap<String, KdlNode>,
495) -> Result<SiteConfig, ConfigError> {
496    let mut site = SiteConfig {
497        host: host.to_string(),
498        tls: None,
499        routes: Vec::new(),
500    };
501    let Some(children) = node.children() else {
502        return Ok(site);
503    };
504    for child in children.nodes() {
505        match child.name().to_string().as_str() {
506            "tls" => site.tls = Some(parse_site_tls(child)?),
507            "route" => {
508                let path = first_string_arg(child)
509                    .ok_or_else(|| ConfigError::MissingField("route path".into()))?;
510                site.routes.push(parse_route(&path, child, snippets)?);
511            }
512            other => return Err(ConfigError::UnknownDirective(other.into())),
513        }
514    }
515    Ok(site)
516}
517
518fn parse_site_tls(node: &KdlNode) -> Result<SiteTlsConfig, ConfigError> {
519    let mut cert = String::new();
520    let mut key = String::new();
521    let Some(children) = node.children() else {
522        return Err(ConfigError::MissingField("tls cert/key".into()));
523    };
524    for child in children.nodes() {
525        match child.name().to_string().as_str() {
526            "cert" => {
527                cert = first_string_arg(child)
528                    .ok_or_else(|| ConfigError::MissingField("tls cert path".into()))?;
529            }
530            "key" => {
531                key = first_string_arg(child)
532                    .ok_or_else(|| ConfigError::MissingField("tls key path".into()))?;
533            }
534            other => return Err(ConfigError::UnknownDirective(other.into())),
535        }
536    }
537    if cert.is_empty() || key.is_empty() {
538        return Err(ConfigError::MissingField("tls cert and key".into()));
539    }
540    Ok(SiteTlsConfig { cert, key })
541}
542
543// ---------------------------------------------------------------------------
544// Route
545// ---------------------------------------------------------------------------
546
547fn parse_route(
548    path: &str,
549    node: &KdlNode,
550    snippets: &HashMap<String, KdlNode>,
551) -> Result<RouteConfig, ConfigError> {
552    let mut middlewares = Vec::new();
553    let mut matchers = Vec::new();
554    let mut handler = None;
555    let mut condition: Option<RouteCondition> = None;
556
557    let Some(children) = node.children() else {
558        return Err(ConfigError::MissingField("route handler".into()));
559    };
560
561    // Flatten `use "snippet-name"` directives by collecting all effective nodes.
562    // We build an owned list so we can iterate without borrowing issues.
563    let mut effective_nodes: Vec<KdlNode> = Vec::new();
564    for child in children.nodes() {
565        if child.name().to_string() == "use" {
566            if let Some(snippet_name) = first_string_arg(child) {
567                match snippets.get(&snippet_name) {
568                    Some(snippet_node) => {
569                        if let Some(snippet_children) = snippet_node.children() {
570                            for sn in snippet_children.nodes() {
571                                effective_nodes.push(sn.clone());
572                            }
573                        }
574                    }
575                    None => {
576                        return Err(ConfigError::InvalidValue {
577                            field: "use".into(),
578                            detail: format!("unknown snippet: {snippet_name}"),
579                        });
580                    }
581                }
582            }
583        } else {
584            effective_nodes.push(child.clone());
585        }
586    }
587
588    for child in &effective_nodes {
589        match child.name().to_string().as_str() {
590            "match" => {
591                matchers.push(parse_matcher(child)?);
592            }
593            "if" => {
594                condition = Some(parse_route_condition(child, false)?);
595            }
596            "if-not" => {
597                condition = Some(parse_route_condition(child, true)?);
598            }
599            "rate-limit" => middlewares.push(parse_rate_limit(child)?),
600            "encode" => middlewares.push(parse_encode(child)?),
601            "basic-auth" => middlewares.push(parse_basic_auth(child)?),
602            "cache" => middlewares.push(parse_cache(child)?),
603            "ip-filter" => middlewares.push(parse_ip_filter(child)?),
604            "rewrite" => middlewares.push(parse_rewrite(child)?),
605            "replace" => middlewares.push(parse_replace(child)?),
606            "forward-auth" => middlewares.push(parse_forward_auth(child)?),
607            "buffer-limit" => middlewares.push(parse_buffer_limit(child)?),
608            "cors" => middlewares.push(parse_cors(child)?),
609            "timeout" => middlewares.push(parse_timeout(child)?),
610            "request-id" => middlewares.push(parse_request_id(child)?),
611            "force-https" => middlewares.push(parse_force_https(child)?),
612            "trailing-slash" => middlewares.push(parse_trailing_slash(child)?),
613            "decompress" => {
614                let max_size = child
615                    .get("max-size")
616                    .and_then(|v| v.as_integer())
617                    .map(|v| v as usize);
618                middlewares.push(HoopConfig::Decompress { max_size });
619            }
620            "error-pages" => middlewares.push(parse_error_pages(child)?),
621            "stream-replace" => middlewares.push(parse_stream_replace(child)?),
622            "templates" => {
623                let root = first_string_arg(child).or_else(|| {
624                    child
625                        .get("root")
626                        .and_then(|v| v.as_string())
627                        .map(|s| s.to_string())
628                });
629                middlewares.push(HoopConfig::Templates { root });
630            }
631            "header-up" | "header-down" => {
632                // Collect header directives — handled below with the proxy
633                // For now, these are part of the proxy config, skip here.
634            }
635            "proxy" => handler = Some(HandlerConfig::Proxy(parse_proxy(child)?)),
636            "fastcgi" => handler = Some(HandlerConfig::FastCgi(parse_fastcgi(child)?)),
637            "forward-proxy" => {
638                handler = Some(HandlerConfig::ForwardProxy(parse_forward_proxy(child)?));
639            }
640            "cgi" => handler = Some(HandlerConfig::Cgi(parse_cgi(child)?)),
641            "scgi" => handler = Some(HandlerConfig::Scgi(parse_scgi(child)?)),
642            "file-server" => {
643                // file-server has no children usually; root is set separately
644                // We handle it as: find "root" sibling, then file-server node.
645            }
646            "root" => {
647                // root sets the directory for file-server
648            }
649            "redirect" => {
650                let to = first_string_arg(child)
651                    .ok_or_else(|| ConfigError::MissingField("redirect target".into()))?;
652                let permanent = child
653                    .get("permanent")
654                    .and_then(|v| v.as_bool())
655                    .unwrap_or(false);
656                handler = Some(HandlerConfig::Redirect { to, permanent });
657            }
658            "respond" => {
659                let body = first_string_arg(child).unwrap_or_default();
660                let status = child
661                    .get("status")
662                    .and_then(|v| v.as_integer())
663                    .unwrap_or(200) as u16;
664                handler = Some(HandlerConfig::Respond { status, body });
665            }
666            other => {
667                // Try to interpret as a module middleware directive.
668                let mut config = HashMap::new();
669                // Collect all named entries as key-value config.
670                for entry in child.entries() {
671                    if let Some(name) = entry.name() {
672                        if let Some(val) = entry.value().as_string() {
673                            config.insert(name.to_string(), val.to_string());
674                        } else if let Some(val) = entry.value().as_integer() {
675                            config.insert(name.to_string(), val.to_string());
676                        } else if let Some(val) = entry.value().as_bool() {
677                            config.insert(name.to_string(), val.to_string());
678                        }
679                    }
680                }
681                // Also collect children as nested config
682                if let Some(children) = child.children() {
683                    for c in children.nodes() {
684                        if let Some(val) = first_string_arg(c) {
685                            config.insert(c.name().to_string(), val);
686                        }
687                    }
688                }
689                middlewares.push(HoopConfig::Module {
690                    name: other.to_string(),
691                    config,
692                });
693            }
694        }
695    }
696
697    // Handle file-server: look for root + file-server pair
698    if handler.is_none() {
699        let fs_node = effective_nodes
700            .iter()
701            .find(|n| n.name().to_string() == "file-server");
702        if let Some(fs_node) = fs_node {
703            let root = effective_nodes
704                .iter()
705                .find(|n| n.name().to_string() == "root")
706                .and_then(first_string_arg)
707                .unwrap_or_else(|| ".".into());
708            let browse = fs_node
709                .get("browse")
710                .and_then(|v| v.as_bool())
711                .unwrap_or(false);
712            let trailing_slash = fs_node
713                .get("trailing-slash")
714                .and_then(|v| v.as_bool())
715                .unwrap_or(true);
716            let mut index: Vec<String> = Vec::new();
717            if let Some(fs_children) = fs_node.children() {
718                for n in fs_children.nodes() {
719                    if n.name().to_string() == "index" {
720                        for arg in string_args(n) {
721                            index.push(arg);
722                        }
723                    }
724                }
725            }
726            if index.is_empty() {
727                index.push("index.html".to_string());
728            }
729            handler = Some(HandlerConfig::FileServer(FileServerConfig {
730                root,
731                browse,
732                trailing_slash,
733                index,
734            }));
735        }
736    }
737
738    // If no built-in handler matched, check for any module handler among the
739    // nodes that were collected as module middleware but that might be the
740    // terminal handler (i.e. there is no built-in handler and the module
741    // loader would provide one).  We promote the last Module middleware entry
742    // that could serve as a handler into HandlerConfig::Module.
743    if handler.is_none() {
744        // Look for any unknown directive that was not a known middleware directive.
745        // Since unknown directives are already collected as Module middlewares,
746        // we scan effective_nodes for any node not matching known directives.
747        for n in &effective_nodes {
748            let name = n.name().to_string();
749            if !is_known_directive(&name) {
750                let mut config = HashMap::new();
751                for entry in n.entries() {
752                    if let Some(ename) = entry.name()
753                        && let Some(val) = entry.value().as_string()
754                    {
755                        config.insert(ename.to_string(), val.to_string());
756                    }
757                }
758                handler = Some(HandlerConfig::Module { name, config });
759                break;
760            }
761        }
762    }
763
764    let handler =
765        handler.ok_or_else(|| ConfigError::MissingField("route must have a handler".into()))?;
766
767    Ok(RouteConfig {
768        path: path.to_string(),
769        matchers,
770        middlewares,
771        handler,
772        condition,
773    })
774}
775
776/// Returns true for directive names that are handled natively by gatel.
777/// Unknown directives are treated as potential module middleware or handlers.
778fn is_known_directive(name: &str) -> bool {
779    matches!(
780        name,
781        "match"
782            | "rate-limit"
783            | "encode"
784            | "basic-auth"
785            | "cache"
786            | "templates"
787            | "header-up"
788            | "header-down"
789            | "proxy"
790            | "fastcgi"
791            | "forward-proxy"
792            | "cgi"
793            | "scgi"
794            | "file-server"
795            | "root"
796            | "redirect"
797            | "respond"
798            | "ip-filter"
799            | "rewrite"
800            | "replace"
801            | "forward-auth"
802            | "headers"
803            | "buffer-limit"
804            | "cors"
805            | "timeout"
806            | "request-id"
807            | "force-https"
808            | "trailing-slash"
809            | "decompress"
810            | "error-pages"
811            | "stream-replace"
812            | "use"
813            | "if"
814            | "if-not"
815    )
816}
817
818/// Parse an `if` or `if-not` condition node.
819///
820/// Supported attribute forms:
821/// - `if remote-ip="10.0.0.0/8"`
822/// - `if header="X-Internal" value="yes"`
823fn parse_route_condition(node: &KdlNode, negate: bool) -> Result<RouteCondition, ConfigError> {
824    if let Some(cidr) = node.get("remote-ip").and_then(|v| v.as_string()) {
825        let cidrs: Vec<String> = cidr.split(',').map(|s| s.trim().to_string()).collect();
826        return Ok(if negate {
827            RouteCondition::NotRemoteIp(cidrs)
828        } else {
829            RouteCondition::RemoteIp(cidrs)
830        });
831    }
832
833    if let Some(name) = node.get("header").and_then(|v| v.as_string()) {
834        let value = node
835            .get("value")
836            .and_then(|v| v.as_string())
837            .unwrap_or("")
838            .to_string();
839        return Ok(if negate {
840            RouteCondition::NotHeader {
841                name: name.to_string(),
842                value,
843            }
844        } else {
845            RouteCondition::Header {
846                name: name.to_string(),
847                value,
848            }
849        });
850    }
851
852    Err(ConfigError::InvalidValue {
853        field: if negate { "if-not" } else { "if" }.into(),
854        detail: "expected remote-ip or header attribute".into(),
855    })
856}
857
858/// Parse a `forward-proxy` handler node.
859///
860/// ```kdl
861/// forward-proxy {
862///     user "alice" hash="$2b$..."
863///     user "bob"   hash="plaintext"
864/// }
865/// ```
866fn parse_forward_proxy(node: &KdlNode) -> Result<ForwardProxyConfig, ConfigError> {
867    let mut auth_users = Vec::new();
868    if let Some(children) = node.children() {
869        for child in children.nodes() {
870            if child.name().to_string() == "user" {
871                let username = first_string_arg(child).ok_or_else(|| {
872                    ConfigError::MissingField("forward-proxy user username".into())
873                })?;
874                let password_hash = child
875                    .get("hash")
876                    .and_then(|v| v.as_string())
877                    .unwrap_or("")
878                    .to_string();
879                auth_users.push(BasicAuthUser {
880                    username,
881                    password_hash,
882                });
883            }
884        }
885    }
886    Ok(ForwardProxyConfig { auth_users })
887}
888
889// ---------------------------------------------------------------------------
890// Proxy
891// ---------------------------------------------------------------------------
892
893fn parse_proxy(node: &KdlNode) -> Result<ProxyConfig, ConfigError> {
894    // Simple form: `proxy "host:port"` — single upstream, no children
895    if let Some(addr) = first_string_arg(node)
896        && node.children().is_none()
897    {
898        return Ok(ProxyConfig {
899            upstreams: vec![UpstreamConfig { addr, weight: 1 }],
900            lb: LbPolicy::default(),
901            lb_header: None,
902            lb_cookie: None,
903            health_check: None,
904            passive_health: None,
905            headers_up: HashMap::new(),
906            headers_down: HashMap::new(),
907            retries: 0,
908            dynamic_upstreams: None,
909            error_pages: HashMap::new(),
910            headers_up_replace: Vec::new(),
911            tls_skip_verify: false,
912            upstream_http2: false,
913            max_connections: None,
914            keepalive_timeout: None,
915            sanitize_uri: true,
916            srv_upstream: None,
917        });
918    }
919
920    let mut upstreams = Vec::new();
921    let mut lb = LbPolicy::default();
922    let mut lb_header = None;
923    let mut lb_cookie = None;
924    let mut health_check = None;
925    let mut passive_health = None;
926    let mut headers_up = HashMap::new();
927    let mut headers_down = HashMap::new();
928    let mut retries = 0u32;
929    let mut dynamic_upstreams = None;
930    let mut error_pages: HashMap<u16, String> = HashMap::new();
931    let mut headers_up_replace: Vec<(String, String, String)> = Vec::new();
932    let mut tls_skip_verify = false;
933    let mut upstream_http2 = false;
934    let mut max_connections: Option<usize> = None;
935    let mut keepalive_timeout: Option<Duration> = None;
936    let mut sanitize_uri = true;
937    let mut srv_upstream: Option<SrvUpstreamConfig> = None;
938
939    let Some(children) = node.children() else {
940        return Err(ConfigError::MissingField("proxy upstream".into()));
941    };
942    for child in children.nodes() {
943        match child.name().to_string().as_str() {
944            "upstream" => {
945                let addr = first_string_arg(child)
946                    .ok_or_else(|| ConfigError::MissingField("upstream address".into()))?;
947                let weight = child
948                    .get("weight")
949                    .and_then(|v| v.as_integer())
950                    .unwrap_or(1) as u32;
951                upstreams.push(UpstreamConfig { addr, weight });
952            }
953            "lb" => {
954                let policy = first_string_arg(child).unwrap_or_default();
955                lb = match policy.as_str() {
956                    "round_robin" => LbPolicy::RoundRobin,
957                    "random" => LbPolicy::Random,
958                    "weighted_round_robin" => LbPolicy::WeightedRoundRobin,
959                    "ip_hash" => LbPolicy::IpHash,
960                    "least_conn" => LbPolicy::LeastConn,
961                    "uri_hash" => LbPolicy::UriHash,
962                    "header_hash" => LbPolicy::HeaderHash,
963                    "cookie_hash" => LbPolicy::CookieHash,
964                    "first" => LbPolicy::First,
965                    "two_random_choices" => LbPolicy::TwoRandomChoices,
966                    _ => {
967                        return Err(ConfigError::InvalidValue {
968                            field: "lb".into(),
969                            detail: format!("unknown policy: {policy}"),
970                        });
971                    }
972                };
973                // Read optional header/cookie name from the same node
974                if let Some(h) = child.get("header").and_then(|v| v.as_string()) {
975                    lb_header = Some(h.to_string());
976                }
977                if let Some(c) = child.get("cookie").and_then(|v| v.as_string()) {
978                    lb_cookie = Some(c.to_string());
979                }
980            }
981            "health-check" => {
982                let uri = child
983                    .get("uri")
984                    .and_then(|v| v.as_string())
985                    .unwrap_or("/health")
986                    .to_string();
987                let interval = child
988                    .get("interval")
989                    .and_then(|v| v.as_string())
990                    .map(parse_duration)
991                    .transpose()?
992                    .unwrap_or(Duration::from_secs(10));
993                let timeout = child
994                    .get("timeout")
995                    .and_then(|v| v.as_string())
996                    .map(parse_duration)
997                    .transpose()?
998                    .unwrap_or(Duration::from_secs(5));
999                let unhealthy_threshold = child
1000                    .get("unhealthy-threshold")
1001                    .and_then(|v| v.as_integer())
1002                    .unwrap_or(3) as u32;
1003                let healthy_threshold = child
1004                    .get("healthy-threshold")
1005                    .and_then(|v| v.as_integer())
1006                    .unwrap_or(2) as u32;
1007                health_check = Some(HealthCheckConfig {
1008                    uri,
1009                    interval,
1010                    timeout,
1011                    unhealthy_threshold,
1012                    healthy_threshold,
1013                });
1014            }
1015            "passive-health" => {
1016                let max_fails = child
1017                    .get("max-fails")
1018                    .and_then(|v| v.as_integer())
1019                    .unwrap_or(5) as u32;
1020                let fail_window = child
1021                    .get("fail-window")
1022                    .and_then(|v| v.as_string())
1023                    .map(parse_duration)
1024                    .transpose()?
1025                    .unwrap_or(Duration::from_secs(30));
1026                let cooldown = child
1027                    .get("cooldown")
1028                    .and_then(|v| v.as_string())
1029                    .map(parse_duration)
1030                    .transpose()?
1031                    .unwrap_or(Duration::from_secs(60));
1032                passive_health = Some(PassiveHealthConfig {
1033                    max_fails,
1034                    fail_window,
1035                    cooldown,
1036                });
1037            }
1038            "retries" => {
1039                retries = first_string_arg(child)
1040                    .or_else(|| {
1041                        child
1042                            .entries()
1043                            .iter()
1044                            .find(|e| e.name().is_none())
1045                            .and_then(|e| e.value().as_integer())
1046                            .map(|i| i.to_string())
1047                    })
1048                    .and_then(|s| s.parse::<u32>().ok())
1049                    .unwrap_or(0);
1050            }
1051            "header-up" => {
1052                let entries: Vec<String> = string_args(child);
1053                if entries.len() >= 2 {
1054                    headers_up.insert(entries[0].clone(), entries[1].clone());
1055                } else if entries.len() == 1 && entries[0].starts_with('-') {
1056                    headers_up.insert(entries[0].clone(), String::new());
1057                }
1058            }
1059            "header-down" => {
1060                let entries: Vec<String> = string_args(child);
1061                if entries.len() >= 2 {
1062                    headers_down.insert(entries[0].clone(), entries[1].clone());
1063                } else if entries.len() == 1 && entries[0].starts_with('-') {
1064                    headers_down.insert(entries[0].clone(), String::new());
1065                }
1066            }
1067            "dns-upstream" => {
1068                let dns_name = child
1069                    .get("name")
1070                    .and_then(|v| v.as_string())
1071                    .map(|s| s.to_string())
1072                    .or_else(|| first_string_arg(child))
1073                    .ok_or_else(|| ConfigError::MissingField("dns-upstream name".into()))?;
1074                let port = child.get("port").and_then(|v| v.as_integer()).unwrap_or(80) as u16;
1075                let refresh_interval = child
1076                    .get("refresh")
1077                    .and_then(|v| v.as_string())
1078                    .map(parse_duration)
1079                    .transpose()?
1080                    .unwrap_or(Duration::from_secs(30));
1081                dynamic_upstreams = Some(DnsUpstreamConfig {
1082                    dns_name,
1083                    port,
1084                    refresh_interval,
1085                });
1086            }
1087            "error-page" => {
1088                // `error-page 502 "Bad Gateway - upstream unreachable"`
1089                let entries = child.entries();
1090                let positional: Vec<_> = entries.iter().filter(|e| e.name().is_none()).collect();
1091                if positional.len() >= 2
1092                    && let (Some(code), Some(body)) = (
1093                        positional[0].value().as_integer(),
1094                        positional[1].value().as_string(),
1095                    )
1096                {
1097                    error_pages.insert(code as u16, body.to_string());
1098                }
1099            }
1100            "header-up-replace" => {
1101                // `header-up-replace "Host" "old.example.com" "new.example.com"`
1102                let args = string_args(child);
1103                if args.len() >= 3 {
1104                    headers_up_replace.push((args[0].clone(), args[1].clone(), args[2].clone()));
1105                }
1106            }
1107            "tls-skip-verify" => {
1108                tls_skip_verify = child.get(0).and_then(|v| v.as_bool()).unwrap_or(true);
1109            }
1110            "http2" => {
1111                upstream_http2 = child.get(0).and_then(|v| v.as_bool()).unwrap_or(true);
1112            }
1113            "max-connections" => {
1114                max_connections = child
1115                    .get(0)
1116                    .and_then(|v| v.as_integer())
1117                    .map(|v| v as usize);
1118            }
1119            "keepalive" => {
1120                keepalive_timeout = child
1121                    .get(0)
1122                    .and_then(|v| v.as_string())
1123                    .map(parse_duration)
1124                    .transpose()?;
1125            }
1126            "sanitize-uri" => {
1127                sanitize_uri = child
1128                    .entries()
1129                    .iter()
1130                    .find(|e| e.name().is_none())
1131                    .and_then(|e| e.value().as_bool())
1132                    .unwrap_or(true);
1133            }
1134            "srv-upstream" => {
1135                let service_name = child
1136                    .get("name")
1137                    .and_then(|v| v.as_string())
1138                    .map(|s| s.to_string())
1139                    .or_else(|| first_string_arg(child))
1140                    .ok_or_else(|| ConfigError::MissingField("srv-upstream name".into()))?;
1141                let refresh_interval = child
1142                    .get("refresh")
1143                    .and_then(|v| v.as_string())
1144                    .map(parse_duration)
1145                    .transpose()?
1146                    .unwrap_or(Duration::from_secs(30));
1147                srv_upstream = Some(SrvUpstreamConfig {
1148                    service_name,
1149                    refresh_interval,
1150                });
1151            }
1152            _ => {}
1153        }
1154    }
1155    if upstreams.is_empty() && dynamic_upstreams.is_none() && srv_upstream.is_none() {
1156        return Err(ConfigError::MissingField(
1157            "proxy must have at least one upstream, a dns-upstream, or srv-upstream".into(),
1158        ));
1159    }
1160    Ok(ProxyConfig {
1161        upstreams,
1162        lb,
1163        lb_header,
1164        lb_cookie,
1165        health_check,
1166        passive_health,
1167        headers_up,
1168        headers_down,
1169        retries,
1170        dynamic_upstreams,
1171        error_pages,
1172        headers_up_replace,
1173        tls_skip_verify,
1174        upstream_http2,
1175        max_connections,
1176        keepalive_timeout,
1177        sanitize_uri,
1178        srv_upstream,
1179    })
1180}
1181
1182// ---------------------------------------------------------------------------
1183// Middleware parsers
1184// ---------------------------------------------------------------------------
1185
1186fn parse_rate_limit(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1187    let window = node
1188        .get("window")
1189        .and_then(|v| v.as_string())
1190        .map(parse_duration)
1191        .transpose()?
1192        .unwrap_or(Duration::from_secs(60));
1193    let max = node.get("max").and_then(|v| v.as_integer()).unwrap_or(100) as u64;
1194    let burst = node
1195        .get("burst")
1196        .and_then(|v| v.as_integer())
1197        .map(|v| v as u64);
1198    Ok(HoopConfig::RateLimit { window, max, burst })
1199}
1200
1201fn parse_encode(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1202    let encodings = string_args(node);
1203    let level = node
1204        .get("level")
1205        .and_then(|v| v.as_integer())
1206        .map(|v| v as u32);
1207    if encodings.is_empty() {
1208        return Ok(HoopConfig::Encode {
1209            encodings: vec!["gzip".into()],
1210            level,
1211        });
1212    }
1213    Ok(HoopConfig::Encode { encodings, level })
1214}
1215
1216fn parse_basic_auth(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1217    let mut users = Vec::new();
1218    let mut brute_force_max: Option<u32> = None;
1219    let mut brute_force_window: Option<Duration> = None;
1220
1221    if let Some(children) = node.children() {
1222        for child in children.nodes() {
1223            match child.name().to_string().as_str() {
1224                "user" => {
1225                    let username = first_string_arg(child)
1226                        .ok_or_else(|| ConfigError::MissingField("basic-auth username".into()))?;
1227                    let password_hash = child
1228                        .get("hash")
1229                        .and_then(|v| v.as_string())
1230                        .unwrap_or("")
1231                        .to_string();
1232                    users.push(BasicAuthUser {
1233                        username,
1234                        password_hash,
1235                    });
1236                }
1237                "brute-force-max" => {
1238                    brute_force_max = child
1239                        .entries()
1240                        .iter()
1241                        .find(|e| e.name().is_none())
1242                        .and_then(|e| e.value().as_integer())
1243                        .map(|v| v as u32);
1244                }
1245                "brute-force-window" => {
1246                    brute_force_window = first_string_arg(child)
1247                        .map(|s| parse_duration(&s))
1248                        .transpose()?;
1249                }
1250                _ => {}
1251            }
1252        }
1253    }
1254    Ok(HoopConfig::BasicAuth {
1255        users,
1256        brute_force_max,
1257        brute_force_window,
1258    })
1259}
1260
1261fn parse_ip_filter(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1262    let mut allow = Vec::new();
1263    let mut deny = Vec::new();
1264    let forwarded_for = node
1265        .get("forwarded-for")
1266        .and_then(|v| v.as_bool())
1267        .unwrap_or(false);
1268    if let Some(children) = node.children() {
1269        for child in children.nodes() {
1270            match child.name().to_string().as_str() {
1271                "allow" => {
1272                    for s in string_args(child) {
1273                        allow.push(s);
1274                    }
1275                }
1276                "deny" => {
1277                    for s in string_args(child) {
1278                        deny.push(s);
1279                    }
1280                }
1281                _ => {}
1282            }
1283        }
1284    }
1285    Ok(HoopConfig::IpFilter {
1286        allow,
1287        deny,
1288        forwarded_for,
1289    })
1290}
1291
1292fn parse_rewrite(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1293    let mut strip_prefix = None;
1294    let mut uri = None;
1295    let mut regex_rules = Vec::new();
1296    let mut if_not_file = false;
1297    let mut if_not_dir = false;
1298    let mut root = None;
1299    let mut normalize_slashes = false;
1300
1301    if let Some(children) = node.children() {
1302        for child in children.nodes() {
1303            match child.name().to_string().as_str() {
1304                "strip-prefix" => {
1305                    strip_prefix = first_string_arg(child);
1306                }
1307                "uri" => {
1308                    uri = first_string_arg(child);
1309                }
1310                "regex" => {
1311                    let args = string_args(child);
1312                    if args.len() >= 2 {
1313                        regex_rules.push((args[0].clone(), args[1].clone()));
1314                    }
1315                }
1316                "if-not-file" => {
1317                    if_not_file = child
1318                        .entries()
1319                        .iter()
1320                        .find(|e| e.name().is_none())
1321                        .and_then(|e| e.value().as_bool())
1322                        .unwrap_or(true);
1323                }
1324                "if-not-dir" => {
1325                    if_not_dir = child
1326                        .entries()
1327                        .iter()
1328                        .find(|e| e.name().is_none())
1329                        .and_then(|e| e.value().as_bool())
1330                        .unwrap_or(true);
1331                }
1332                "root" => {
1333                    root = first_string_arg(child);
1334                }
1335                "normalize-slashes" => {
1336                    normalize_slashes = child
1337                        .entries()
1338                        .iter()
1339                        .find(|e| e.name().is_none())
1340                        .and_then(|e| e.value().as_bool())
1341                        .unwrap_or(true);
1342                }
1343                _ => {}
1344            }
1345        }
1346    }
1347
1348    Ok(HoopConfig::Rewrite {
1349        strip_prefix,
1350        uri,
1351        regex_rules,
1352        if_not_file,
1353        if_not_dir,
1354        root,
1355        normalize_slashes,
1356    })
1357}
1358
1359fn parse_replace(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1360    let mut rules = Vec::new();
1361    let mut once = false;
1362
1363    if let Some(children) = node.children() {
1364        for child in children.nodes() {
1365            match child.name().to_string().as_str() {
1366                "rule" => {
1367                    let args = string_args(child);
1368                    if args.len() >= 2 {
1369                        rules.push((args[0].clone(), args[1].clone()));
1370                    }
1371                }
1372                "once" => {
1373                    once = child
1374                        .entries()
1375                        .iter()
1376                        .find(|e| e.name().is_none())
1377                        .and_then(|e| e.value().as_bool())
1378                        .unwrap_or(true);
1379                }
1380                _ => {}
1381            }
1382        }
1383    }
1384
1385    Ok(HoopConfig::Replace { rules, once })
1386}
1387
1388fn parse_cache(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1389    let mut cfg = CacheConfig::default();
1390    if let Some(v) = node.get("max-entries").and_then(|v| v.as_integer()) {
1391        cfg.max_entries = v as usize;
1392    }
1393    if let Some(v) = node.get("max-entry-size").and_then(|v| v.as_integer()) {
1394        cfg.max_entry_size = v as usize;
1395    }
1396    if let Some(v) = node.get("max-age").and_then(|v| v.as_string()) {
1397        cfg.default_max_age = parse_duration(v)?;
1398    }
1399    Ok(HoopConfig::Cache(cfg))
1400}
1401
1402fn parse_forward_auth(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1403    let url = first_string_arg(node)
1404        .ok_or_else(|| ConfigError::MissingField("forward-auth url".into()))?;
1405
1406    let mut copy_headers = Vec::new();
1407    if let Some(children) = node.children() {
1408        for child in children.nodes() {
1409            if child.name().to_string() == "copy-headers" {
1410                for header in string_args(child) {
1411                    copy_headers.push(header);
1412                }
1413            }
1414        }
1415    }
1416
1417    Ok(HoopConfig::ForwardAuth { url, copy_headers })
1418}
1419
1420fn parse_buffer_limit(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1421    let max_request_body = node
1422        .get("max-request-body")
1423        .and_then(|v| v.as_integer())
1424        .map(|v| v as usize);
1425    let max_response_body = node
1426        .get("max-response-body")
1427        .and_then(|v| v.as_integer())
1428        .map(|v| v as usize);
1429    Ok(HoopConfig::BufferLimit {
1430        max_request_body,
1431        max_response_body,
1432    })
1433}
1434
1435fn parse_cors(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1436    let mut allow_origins = Vec::new();
1437    let mut allow_methods = Vec::new();
1438    let mut allow_headers = Vec::new();
1439    let mut allow_credentials = false;
1440    let mut expose_headers = Vec::new();
1441    let mut max_age = None;
1442
1443    if let Some(children) = node.children() {
1444        for child in children.nodes() {
1445            match child.name().to_string().as_str() {
1446                "allow-origin" => {
1447                    allow_origins.extend(string_args(child));
1448                }
1449                "allow-method" => {
1450                    allow_methods.extend(string_args(child));
1451                }
1452                "allow-header" => {
1453                    allow_headers.extend(string_args(child));
1454                }
1455                "allow-credentials" => {
1456                    allow_credentials = child
1457                        .entries()
1458                        .iter()
1459                        .find(|e| e.name().is_none())
1460                        .and_then(|e| e.value().as_bool())
1461                        .unwrap_or(true);
1462                }
1463                "expose-header" => {
1464                    expose_headers.extend(string_args(child));
1465                }
1466                "max-age" => {
1467                    max_age = child
1468                        .entries()
1469                        .iter()
1470                        .find(|e| e.name().is_none())
1471                        .and_then(|e| e.value().as_integer())
1472                        .map(|v| v as u64);
1473                }
1474                _ => {}
1475            }
1476        }
1477    }
1478
1479    // Default to permissive if nothing specified.
1480    if allow_origins.is_empty() {
1481        allow_origins.push("*".into());
1482    }
1483    if allow_methods.is_empty() {
1484        allow_methods.push("*".into());
1485    }
1486    if allow_headers.is_empty() {
1487        allow_headers.push("*".into());
1488    }
1489
1490    Ok(HoopConfig::Cors {
1491        allow_origins,
1492        allow_methods,
1493        allow_headers,
1494        allow_credentials,
1495        expose_headers,
1496        max_age,
1497    })
1498}
1499
1500fn parse_timeout(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1501    let duration = first_string_arg(node)
1502        .map(|s| parse_duration(&s))
1503        .transpose()?
1504        .unwrap_or(Duration::from_secs(30));
1505    Ok(HoopConfig::Timeout { duration })
1506}
1507
1508fn parse_request_id(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1509    let header_name = node
1510        .get("header")
1511        .and_then(|v| v.as_string())
1512        .map(|s| s.to_string());
1513    let overwrite = node
1514        .get("overwrite")
1515        .and_then(|v| v.as_bool())
1516        .unwrap_or(true);
1517    Ok(HoopConfig::RequestId {
1518        header_name,
1519        overwrite,
1520    })
1521}
1522
1523fn parse_force_https(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1524    let https_port = node
1525        .get("port")
1526        .and_then(|v| v.as_integer())
1527        .map(|v| v as u16)
1528        .or_else(|| first_string_arg(node).and_then(|s| s.parse::<u16>().ok()));
1529    Ok(HoopConfig::ForceHttps { https_port })
1530}
1531
1532fn parse_trailing_slash(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1533    let action = first_string_arg(node).unwrap_or_else(|| "add".to_string());
1534    if action != "add" && action != "remove" {
1535        return Err(ConfigError::InvalidValue {
1536            field: "trailing-slash".into(),
1537            detail: format!("action must be 'add' or 'remove', got '{action}'"),
1538        });
1539    }
1540    Ok(HoopConfig::TrailingSlash { action })
1541}
1542
1543fn parse_error_pages(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1544    let mut pages = std::collections::HashMap::new();
1545    if let Some(children) = node.children() {
1546        for child in children.nodes() {
1547            let code_str = child.name().to_string();
1548            if let Ok(code) = code_str.parse::<u16>()
1549                && let Some(body) = first_string_arg(child)
1550            {
1551                pages.insert(code, body);
1552            }
1553        }
1554    }
1555    Ok(HoopConfig::ErrorPages { pages })
1556}
1557
1558fn parse_stream_replace(node: &KdlNode) -> Result<HoopConfig, ConfigError> {
1559    let mut rules = Vec::new();
1560    let mut once = false;
1561    if let Some(children) = node.children() {
1562        for child in children.nodes() {
1563            match child.name().to_string().as_str() {
1564                "rule" => {
1565                    let args = string_args(child);
1566                    if args.len() >= 2 {
1567                        rules.push((args[0].clone(), args[1].clone()));
1568                    }
1569                }
1570                "once" => {
1571                    once = child
1572                        .entries()
1573                        .iter()
1574                        .find(|e| e.name().is_none())
1575                        .and_then(|e| e.value().as_bool())
1576                        .unwrap_or(true);
1577                }
1578                _ => {}
1579            }
1580        }
1581    }
1582    Ok(HoopConfig::StreamReplace { rules, once })
1583}
1584
1585// ---------------------------------------------------------------------------
1586// Matcher parser
1587// ---------------------------------------------------------------------------
1588
1589/// Parse a `match` directive into a `RequestMatcher`.
1590///
1591/// Supported forms:
1592/// - `match method="GET"`
1593/// - `match path="/api/*"`
1594/// - `match header="X-Custom" pattern="foo*"`
1595/// - `match query="key" value="val"`
1596/// - `match remote-ip="192.168.0.0/16"`
1597/// - `match protocol="https"`
1598/// - `match expression="{method} == GET && {path} ~ /api/*"`
1599/// - `match not { ... }` (with children containing inner matcher)
1600fn parse_matcher(node: &KdlNode) -> Result<RequestMatcher, ConfigError> {
1601    // Check for keyword-style matchers.
1602    if let Some(method) = node.get("method").and_then(|v| v.as_string()) {
1603        let methods: Vec<String> = method.split(',').map(|s| s.trim().to_string()).collect();
1604        return Ok(RequestMatcher::Method(methods));
1605    }
1606    if let Some(path) = node.get("path").and_then(|v| v.as_string()) {
1607        return Ok(RequestMatcher::Path(path.to_string()));
1608    }
1609    if let Some(header_name) = node.get("header").and_then(|v| v.as_string()) {
1610        let pattern = node
1611            .get("pattern")
1612            .and_then(|v| v.as_string())
1613            .unwrap_or("*")
1614            .to_string();
1615        return Ok(RequestMatcher::Header {
1616            name: header_name.to_string(),
1617            pattern,
1618        });
1619    }
1620    if let Some(key) = node.get("query").and_then(|v| v.as_string()) {
1621        let value = node
1622            .get("value")
1623            .and_then(|v| v.as_string())
1624            .map(|s| s.to_string());
1625        return Ok(RequestMatcher::Query {
1626            key: key.to_string(),
1627            value,
1628        });
1629    }
1630    if let Some(cidr) = node.get("remote-ip").and_then(|v| v.as_string()) {
1631        let cidrs: Vec<String> = cidr.split(',').map(|s| s.trim().to_string()).collect();
1632        return Ok(RequestMatcher::RemoteIp(cidrs));
1633    }
1634    if let Some(proto) = node.get("protocol").and_then(|v| v.as_string()) {
1635        return Ok(RequestMatcher::Protocol(proto.to_string()));
1636    }
1637    if let Some(expr) = node.get("expression").and_then(|v| v.as_string()) {
1638        return Ok(RequestMatcher::Expression(expr.to_string()));
1639    }
1640    if let Some(lang) = node.get("language").and_then(|v| v.as_string()) {
1641        let langs: Vec<String> = lang.split(',').map(|s| s.trim().to_string()).collect();
1642        return Ok(RequestMatcher::Language(langs));
1643    }
1644
1645    // Check for "not" or composite matchers via children.
1646    let kind = first_string_arg(node).unwrap_or_default();
1647    if kind == "not"
1648        && let Some(children) = node.children()
1649        && let Some(child) = children.nodes().first()
1650    {
1651        let inner = parse_matcher(child)?;
1652        return Ok(RequestMatcher::Not(Box::new(inner)));
1653    }
1654
1655    // Default to a path matcher using the first positional arg.
1656    if !kind.is_empty() {
1657        return Ok(RequestMatcher::Path(kind));
1658    }
1659
1660    Err(ConfigError::InvalidValue {
1661        field: "match".into(),
1662        detail: "unrecognized matcher format".into(),
1663    })
1664}
1665
1666// ---------------------------------------------------------------------------
1667// FastCGI parser
1668// ---------------------------------------------------------------------------
1669
1670/// Parse a `fastcgi` handler directive.
1671///
1672/// ```kdl
1673/// fastcgi "127.0.0.1:9000" {
1674///     script-root "/var/www/html"
1675///     split ".php"
1676///     index "index.php"
1677///     env "SERVER_SOFTWARE" "gatel"
1678/// }
1679/// ```
1680fn parse_fastcgi(node: &KdlNode) -> Result<FastCgiConfig, ConfigError> {
1681    let addr = first_string_arg(node)
1682        .ok_or_else(|| ConfigError::MissingField("fastcgi address".into()))?;
1683    let mut script_root = String::new();
1684    let mut index = Vec::new();
1685    let mut split_path = None;
1686    let mut env = HashMap::new();
1687
1688    if let Some(children) = node.children() {
1689        for child in children.nodes() {
1690            match child.name().to_string().as_str() {
1691                "script-root" | "root" => {
1692                    script_root = first_string_arg(child).unwrap_or_default();
1693                }
1694                "split" => {
1695                    split_path = first_string_arg(child);
1696                }
1697                "index" => {
1698                    for arg in string_args(child) {
1699                        index.push(arg);
1700                    }
1701                }
1702                "env" => {
1703                    let args = string_args(child);
1704                    if args.len() >= 2 {
1705                        env.insert(args[0].clone(), args[1].clone());
1706                    }
1707                }
1708                _ => {}
1709            }
1710        }
1711    }
1712
1713    if index.is_empty() {
1714        index.push("index.php".to_string());
1715    }
1716
1717    Ok(FastCgiConfig {
1718        addr,
1719        script_root,
1720        index,
1721        split_path,
1722        env,
1723    })
1724}
1725
1726// ---------------------------------------------------------------------------
1727// CGI parser
1728// ---------------------------------------------------------------------------
1729
1730/// Parse a `cgi` handler directive.
1731///
1732/// ```kdl
1733/// cgi "/var/www/cgi-bin" {
1734///     env "APP_ENV" "production"
1735/// }
1736/// ```
1737fn parse_cgi(node: &KdlNode) -> Result<CgiConfig, ConfigError> {
1738    let root = first_string_arg(node)
1739        .or_else(|| {
1740            node.get("root")
1741                .and_then(|v| v.as_string())
1742                .map(|s| s.to_string())
1743        })
1744        .ok_or_else(|| ConfigError::MissingField("cgi root directory".into()))?;
1745    let mut env = HashMap::new();
1746
1747    if let Some(children) = node.children() {
1748        for child in children.nodes() {
1749            if child.name().to_string().as_str() == "env" {
1750                let args = string_args(child);
1751                if args.len() >= 2 {
1752                    env.insert(args[0].clone(), args[1].clone());
1753                }
1754            }
1755        }
1756    }
1757
1758    Ok(CgiConfig { root, env })
1759}
1760
1761// ---------------------------------------------------------------------------
1762// SCGI parser
1763// ---------------------------------------------------------------------------
1764
1765/// Parse an `scgi` handler directive.
1766///
1767/// ```kdl
1768/// scgi "127.0.0.1:9000" {
1769///     env "APP_ENV" "production"
1770/// }
1771/// ```
1772fn parse_scgi(node: &KdlNode) -> Result<ScgiConfig, ConfigError> {
1773    let addr =
1774        first_string_arg(node).ok_or_else(|| ConfigError::MissingField("scgi address".into()))?;
1775    let mut env = HashMap::new();
1776
1777    if let Some(children) = node.children() {
1778        for child in children.nodes() {
1779            if child.name().to_string().as_str() == "env" {
1780                let args = string_args(child);
1781                if args.len() >= 2 {
1782                    env.insert(args[0].clone(), args[1].clone());
1783                }
1784            }
1785        }
1786    }
1787
1788    Ok(ScgiConfig { addr, env })
1789}
1790
1791// ---------------------------------------------------------------------------
1792// Stream
1793// ---------------------------------------------------------------------------
1794
1795fn parse_stream(node: &KdlNode) -> Result<StreamConfig, ConfigError> {
1796    let mut listeners = Vec::new();
1797    let Some(children) = node.children() else {
1798        return Ok(StreamConfig { listeners });
1799    };
1800    for child in children.nodes() {
1801        if child.name().to_string() == "listen" {
1802            let addr_str = first_string_arg(child)
1803                .ok_or_else(|| ConfigError::MissingField("stream listen address".into()))?;
1804            let listen = parse_listen_addr(&addr_str)?;
1805            let proxy = child
1806                .children()
1807                .and_then(|c| {
1808                    c.nodes()
1809                        .iter()
1810                        .find(|n| n.name().to_string() == "proxy")
1811                        .and_then(first_string_arg)
1812                })
1813                .ok_or_else(|| ConfigError::MissingField("stream proxy target".into()))?;
1814            listeners.push(StreamListenerConfig { listen, proxy });
1815        }
1816    }
1817    Ok(StreamConfig { listeners })
1818}
1819
1820// ---------------------------------------------------------------------------
1821// Helpers
1822// ---------------------------------------------------------------------------
1823
1824/// Get the first positional string argument of a KDL node.
1825fn first_string_arg(node: &KdlNode) -> Option<String> {
1826    node.entries()
1827        .iter()
1828        .find(|e| e.name().is_none())
1829        .and_then(|e| e.value().as_string())
1830        .map(|s| s.to_string())
1831}
1832
1833/// Get all positional string arguments.
1834fn string_args(node: &KdlNode) -> Vec<String> {
1835    node.entries()
1836        .iter()
1837        .filter(|e| e.name().is_none())
1838        .filter_map(|e| e.value().as_string().map(|s| s.to_string()))
1839        .collect()
1840}
1841
1842/// Parse a duration string like "30s", "1m", "10s".
1843fn parse_duration(s: &str) -> Result<Duration, ConfigError> {
1844    let s = s.trim();
1845    if let Some(secs) = s.strip_suffix('s') {
1846        let n: u64 = secs.parse().map_err(|_| ConfigError::InvalidValue {
1847            field: "duration".into(),
1848            detail: format!("invalid seconds: {s}"),
1849        })?;
1850        return Ok(Duration::from_secs(n));
1851    }
1852    if let Some(mins) = s.strip_suffix('m') {
1853        let n: u64 = mins.parse().map_err(|_| ConfigError::InvalidValue {
1854            field: "duration".into(),
1855            detail: format!("invalid minutes: {s}"),
1856        })?;
1857        return Ok(Duration::from_secs(n * 60));
1858    }
1859    if let Some(hours) = s.strip_suffix('h') {
1860        let n: u64 = hours.parse().map_err(|_| ConfigError::InvalidValue {
1861            field: "duration".into(),
1862            detail: format!("invalid hours: {s}"),
1863        })?;
1864        return Ok(Duration::from_secs(n * 3600));
1865    }
1866    // Try bare seconds
1867    let n: u64 = s.parse().map_err(|_| ConfigError::InvalidValue {
1868        field: "duration".into(),
1869        detail: format!("expected duration like '30s', '1m', got: {s}"),
1870    })?;
1871    Ok(Duration::from_secs(n))
1872}
1873
1874/// Parse a listen address like ":8080" or "0.0.0.0:8080".
1875fn parse_listen_addr(s: &str) -> Result<SocketAddr, ConfigError> {
1876    let s = s.trim();
1877    // Support ":port" shorthand
1878    let s = if s.starts_with(':') {
1879        format!("0.0.0.0{s}")
1880    } else {
1881        s.to_string()
1882    };
1883    s.parse().map_err(|_| ConfigError::InvalidValue {
1884        field: "address".into(),
1885        detail: format!("invalid socket address: {s}"),
1886    })
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891    use super::*;
1892
1893    #[test]
1894    fn test_parse_minimal_config() {
1895        let input = r#"
1896site "app.example.com" {
1897    route "/*" {
1898        proxy "localhost:3001"
1899    }
1900}
1901"#;
1902        let config = parse_config(input).unwrap();
1903        assert_eq!(config.sites.len(), 1);
1904        assert_eq!(config.sites[0].host, "app.example.com");
1905        assert_eq!(config.sites[0].routes.len(), 1);
1906        assert_eq!(config.sites[0].routes[0].path, "/*");
1907        match &config.sites[0].routes[0].handler {
1908            HandlerConfig::Proxy(p) => {
1909                assert_eq!(p.upstreams[0].addr, "localhost:3001");
1910            }
1911            _ => panic!("expected proxy handler"),
1912        }
1913    }
1914
1915    #[test]
1916    fn test_parse_full_config() {
1917        let input = r#"
1918global {
1919    admin ":2019"
1920    log level="info" format="json"
1921    grace-period "30s"
1922}
1923
1924tls {
1925    acme {
1926        email "admin@example.com"
1927        ca "letsencrypt"
1928        challenge "http-01"
1929    }
1930}
1931
1932site "app.example.com" {
1933    route "/api/*" {
1934        rate-limit window="1m" max=100
1935        proxy {
1936            upstream "localhost:3001" weight=3
1937            upstream "localhost:3002" weight=1
1938            lb "weighted_round_robin"
1939            health-check uri="/health" interval="10s"
1940            header-up "X-Real-IP" "{client_ip}"
1941            header-down "-Server"
1942        }
1943    }
1944    route "/*" {
1945        encode "gzip" "zstd"
1946        root "/var/www/html"
1947        file-server
1948    }
1949}
1950
1951site "api.example.com" {
1952    tls {
1953        cert "/path/to/cert.pem"
1954        key "/path/to/key.pem"
1955    }
1956    route "/*" {
1957        basic-auth {
1958            user "admin" hash="$2b$12$..."
1959        }
1960        proxy "localhost:8080"
1961    }
1962}
1963"#;
1964        let config = parse_config(input).unwrap();
1965        assert_eq!(config.sites.len(), 2);
1966
1967        // Global
1968        assert_eq!(
1969            config.global.admin_addr,
1970            Some("0.0.0.0:2019".parse().unwrap())
1971        );
1972        assert_eq!(config.global.grace_period, Duration::from_secs(30));
1973
1974        // ACME
1975        let acme = config.tls.as_ref().unwrap().acme.as_ref().unwrap();
1976        assert_eq!(acme.email, "admin@example.com");
1977
1978        // First site
1979        let site0 = &config.sites[0];
1980        assert_eq!(site0.routes.len(), 2);
1981        match &site0.routes[0].handler {
1982            HandlerConfig::Proxy(p) => {
1983                assert_eq!(p.upstreams.len(), 2);
1984                assert_eq!(p.lb, LbPolicy::WeightedRoundRobin);
1985            }
1986            _ => panic!("expected proxy"),
1987        }
1988
1989        // Second site with manual TLS
1990        let site1 = &config.sites[1];
1991        assert!(site1.tls.is_some());
1992    }
1993
1994    #[test]
1995    fn test_parse_duration() {
1996        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
1997        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
1998        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
1999    }
2000
2001    #[test]
2002    fn test_parse_listen_addr() {
2003        assert_eq!(
2004            parse_listen_addr(":8080").unwrap(),
2005            "0.0.0.0:8080".parse::<SocketAddr>().unwrap()
2006        );
2007        assert_eq!(
2008            parse_listen_addr("127.0.0.1:3000").unwrap(),
2009            "127.0.0.1:3000".parse::<SocketAddr>().unwrap()
2010        );
2011    }
2012
2013    #[test]
2014    fn test_parse_fastcgi_config() {
2015        let input = r#"
2016site "app.example.com" {
2017    route "*.php" {
2018        fastcgi "127.0.0.1:9000" {
2019            root "/var/www/html"
2020            index "index.php"
2021            split ".php"
2022            env "APP_ENV" "production"
2023        }
2024    }
2025}
2026"#;
2027        let config = parse_config(input).unwrap();
2028        let route = &config.sites[0].routes[0];
2029        assert_eq!(route.path, "*.php");
2030        match &route.handler {
2031            HandlerConfig::FastCgi(cfg) => {
2032                assert_eq!(cfg.addr, "127.0.0.1:9000");
2033                assert_eq!(cfg.script_root, "/var/www/html");
2034                assert_eq!(cfg.index, vec!["index.php"]);
2035                assert_eq!(cfg.split_path, Some(".php".to_string()));
2036                assert_eq!(cfg.env.get("APP_ENV").unwrap(), "production");
2037            }
2038            _ => panic!("expected FastCgi handler"),
2039        }
2040    }
2041
2042    #[test]
2043    fn test_parse_dns_upstream() {
2044        let input = r#"
2045site "app.example.com" {
2046    route "/api/*" {
2047        proxy {
2048            upstream "fallback:8080"
2049            dns-upstream name="app.svc.cluster.local" port=8080 refresh="30s"
2050        }
2051    }
2052}
2053"#;
2054        let config = parse_config(input).unwrap();
2055        match &config.sites[0].routes[0].handler {
2056            HandlerConfig::Proxy(p) => {
2057                assert_eq!(p.upstreams.len(), 1);
2058                let dns = p.dynamic_upstreams.as_ref().unwrap();
2059                assert_eq!(dns.dns_name, "app.svc.cluster.local");
2060                assert_eq!(dns.port, 8080);
2061                assert_eq!(dns.refresh_interval, Duration::from_secs(30));
2062            }
2063            _ => panic!("expected proxy handler"),
2064        }
2065    }
2066
2067    #[test]
2068    fn test_parse_matchers() {
2069        let input = r#"
2070site "app.example.com" {
2071    route "/api/*" {
2072        match method="GET,POST"
2073        match header="X-Custom" pattern="foo*"
2074        proxy "localhost:8080"
2075    }
2076}
2077"#;
2078        let config = parse_config(input).unwrap();
2079        let route = &config.sites[0].routes[0];
2080        assert_eq!(route.matchers.len(), 2);
2081        match &route.matchers[0] {
2082            RequestMatcher::Method(methods) => {
2083                assert!(methods.contains(&"GET".to_string()));
2084                assert!(methods.contains(&"POST".to_string()));
2085            }
2086            _ => panic!("expected Method matcher"),
2087        }
2088        match &route.matchers[1] {
2089            RequestMatcher::Header { name, pattern } => {
2090                assert_eq!(name, "X-Custom");
2091                assert_eq!(pattern, "foo*");
2092            }
2093            _ => panic!("expected Header matcher"),
2094        }
2095    }
2096}