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
25pub fn parse_config(input: &str) -> Result<AppConfig, ConfigError> {
27 let doc: KdlDocument = input.parse()?;
28 let mut config = AppConfig::default();
29
30 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 for node in doc.nodes() {
42 match node.name().to_string().as_str() {
43 "snippet" => { }
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
58pub 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 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 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 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 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
129fn 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 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
284fn 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 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 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
487fn 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
543fn 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 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 }
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 }
646 "root" => {
647 }
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 let mut config = HashMap::new();
669 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 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 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 handler.is_none() {
744 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
776fn 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
818fn 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
858fn 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
889fn parse_proxy(node: &KdlNode) -> Result<ProxyConfig, ConfigError> {
894 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 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 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 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
1182fn 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 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
1585fn parse_matcher(node: &KdlNode) -> Result<RequestMatcher, ConfigError> {
1601 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 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 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
1666fn 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
1726fn 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
1761fn 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
1791fn 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
1820fn 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
1833fn 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
1842fn 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 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
1874fn parse_listen_addr(s: &str) -> Result<SocketAddr, ConfigError> {
1876 let s = s.trim();
1877 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 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 let acme = config.tls.as_ref().unwrap().acme.as_ref().unwrap();
1976 assert_eq!(acme.email, "admin@example.com");
1977
1978 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 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}