1use std::collections::hash_map::DefaultHasher;
37use std::hash::{Hash, Hasher};
38use std::str::FromStr as _;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, Mutex};
41
42use arc_swap::ArcSwap;
43
44use crate::config::{
45 gateway_to_yaml, Credential, Gateway, ListenerProtocol, PathMatchKind, PathRewrite, Route,
46 RouteAction, TlsMode, TranslationKind, ZeroRttPolicy,
47};
48
49#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ValidationIssue {
53 pub entity: String,
55 pub name: String,
57 pub field: String,
59 pub message: String,
60}
61
62impl std::fmt::Display for ValidationIssue {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 write!(
65 f,
66 "{} '{}.{}': {}",
67 self.entity, self.name, self.field, self.message
68 )
69 }
70}
71
72#[derive(Debug)]
75pub enum CompileError {
76 Validation(Vec<ValidationIssue>),
77 InvalidRegex {
78 route: String,
79 pattern: String,
80 message: String,
81 },
82 RouteConflict {
83 route: String,
84 pattern: String,
85 message: String,
86 },
87 Internal(String),
88}
89
90impl std::fmt::Display for CompileError {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match self {
93 CompileError::Validation(issues) => {
94 write!(f, "config validation failed ({} issues)", issues.len())?;
95 for i in issues {
96 write!(f, "\n - {i}")?;
97 }
98 Ok(())
99 }
100 CompileError::InvalidRegex {
101 route,
102 pattern,
103 message,
104 } => write!(f, "route '{route}': invalid regex '{pattern}': {message}"),
105 CompileError::RouteConflict {
106 route,
107 pattern,
108 message,
109 } => write!(
110 f,
111 "route '{route}': conflicting path template '{pattern}': {message}"
112 ),
113 CompileError::Internal(m) => write!(f, "internal compile error: {m}"),
114 }
115 }
116}
117
118impl std::error::Error for CompileError {}
119
120fn issue(entity: &str, name: &str, field: &str, message: impl Into<String>) -> ValidationIssue {
121 ValidationIssue {
122 entity: entity.to_string(),
123 name: name.to_string(),
124 field: field.to_string(),
125 message: message.into(),
126 }
127}
128
129fn upstream_protocol_name(p: crate::config::UpstreamProtocol) -> &'static str {
132 match p {
133 crate::config::UpstreamProtocol::Http1 => "http1",
134 crate::config::UpstreamProtocol::Http2 => "http2",
135 crate::config::UpstreamProtocol::H2c => "h2c",
136 crate::config::UpstreamProtocol::Https => "https",
137 crate::config::UpstreamProtocol::H3 => "h3",
138 }
139}
140
141fn check_file_readable(
163 entity: &str,
164 name: &str,
165 field: &str,
166 path: &str,
167 issues: &mut Vec<ValidationIssue>,
168) {
169 if !std::path::Path::new(path).is_file() {
170 issues.push(issue(
171 entity,
172 name,
173 field,
174 format!("{field} '{path}' is not a readable file"),
175 ));
176 }
177}
178
179fn check_trusted_ca_file(
180 entity: &str,
181 name: &str,
182 field: &str,
183 path: &str,
184 issues: &mut Vec<ValidationIssue>,
185) {
186 use rustls_pki_types::pem::PemObject;
187
188 if let Err(e) = std::fs::File::open(path) {
189 issues.push(issue(
190 entity,
191 name,
192 field,
193 format!("{field} '{path}' is not a readable file: {e}"),
194 ));
195 return;
196 }
197 let parsed = rustls_pki_types::CertificateDer::pem_file_iter(path).and_then(|iter| {
203 iter.collect::<Result<Vec<rustls_pki_types::CertificateDer<'static>>, _>>()
204 });
205 match parsed {
206 Ok(certs) if !certs.is_empty() => {}
207 Ok(_) => issues.push(issue(
208 entity,
209 name,
210 field,
211 format!(
212 "{field} '{path}' holds no usable CA certificates \
213 (the PEM bundle must list at least one CERTIFICATE)"
214 ),
215 )),
216 Err(e) => issues.push(issue(
217 entity,
218 name,
219 field,
220 format!(
221 "{field} '{path}' could not be parsed as a PEM \
222 certificate bundle: {e}"
223 ),
224 )),
225 }
226}
227
228fn validate_authz(
238 entity: &str,
239 name: &str,
240 authz: &crate::config::Authz,
241 consumers: &std::collections::BTreeSet<&str>,
242 consumer_groups: &std::collections::BTreeSet<&str>,
243 issues: &mut Vec<ValidationIssue>,
244) {
245 let field_prefix = "authorization".to_string();
246 let empty = authz.allowed_consumers.is_empty()
247 && authz.denied_consumers.is_empty()
248 && authz.allowed_groups.is_empty()
249 && authz.denied_groups.is_empty()
250 && authz.required_scopes.is_empty()
251 && authz.required_claims.is_empty()
252 && authz.ip_acl.is_none()
253 && authz.geoip.is_none();
254 if empty {
255 issues.push(issue(
256 entity,
257 name,
258 "authorization",
259 "carries no rules (no consumers, groups, scopes, claims, ip_acl, \
260 or geoip) and is always a mistake: omit the authorization block \
261 entirely",
262 ));
263 }
264 for (side, entries) in [
265 ("allowed_consumers", &authz.allowed_consumers),
266 ("denied_consumers", &authz.denied_consumers),
267 ] {
268 for entry in entries.iter() {
269 if !consumers.contains(entry.as_str()) {
270 issues.push(issue(
271 entity,
272 name,
273 &format!("{field_prefix}.{side}"),
274 format!("references unknown consumer '{entry}'"),
275 ));
276 }
277 }
278 }
279 for (side, entries) in [
280 ("allowed_groups", &authz.allowed_groups),
281 ("denied_groups", &authz.denied_groups),
282 ] {
283 for group in entries.iter() {
284 if !consumer_groups.contains(group.as_str()) {
285 issues.push(issue(
286 entity,
287 name,
288 &format!("{field_prefix}.{side}"),
289 format!("references group '{group}' that no consumer is a member of"),
290 ));
291 }
292 }
293 }
294 if let Some(acl) = &authz.ip_acl {
295 for (side, entries) in [("ip_acl.allow", &acl.allow), ("ip_acl.deny", &acl.deny)] {
296 for (i, entry) in entries.iter().enumerate() {
297 if crate::config::net::parse_ip_or_cidr(entry).is_none() {
298 issues.push(issue(
299 entity,
300 name,
301 &format!("{field_prefix}.{side}[{i}]"),
302 format!(
303 "'{entry}' is not an IP address or CIDR (e.g. 10.0.0.0/8 \
304 or 2001:db8::/32)"
305 ),
306 ));
307 } else if side == "ip_acl.allow"
308 && crate::config::net::parse_ip_or_cidr(entry)
309 .is_some_and(|(_, prefix)| prefix == 0)
310 {
311 issues.push(issue(
312 entity,
313 name,
314 &format!("{field_prefix}.{side}[{i}]"),
315 format!(
316 "'{entry}' allows every address and filters nothing; \
317 use 'default: allow' instead of an allow-all entry"
318 ),
319 ));
320 }
321 }
322 }
323 }
324}
325
326fn validate_cors(name: &str, cors: &crate::config::Cors, issues: &mut Vec<ValidationIssue>) {
333 if cors.allowed_origins.is_empty() {
334 issues.push(issue(
335 "route",
336 name,
337 "cors.allowed_origins",
338 "allowed_origins is empty: a cors block with no origins filters nothing \
339 and is always a mistake; omit the cors block to disable CORS",
340 ));
341 }
342 let wildcard = cors.allowed_origins.iter().any(|o| o == "*");
343 if wildcard && cors.allow_credentials {
344 issues.push(issue(
345 "route",
346 name,
347 "cors.allowed_origins",
348 "'*' cannot be combined with allow_credentials (the Fetch spec forbids \
349 wildcard credentialed responses); list explicit origins instead",
350 ));
351 }
352 for (i, origin) in cors.allowed_origins.iter().enumerate() {
353 if origin == "*" {
354 continue;
355 }
356 if crate::config::normalize_origin(origin).is_none() {
360 issues.push(issue(
361 "route",
362 name,
363 &format!("cors.allowed_origins[{i}]"),
364 format!(
365 "'{origin}' is not a well-formed origin (expected scheme://host[:port] \
366 with an http or https scheme and no path, e.g. https://api.example.com, \
367 or the single entry '*')"
368 ),
369 ));
370 }
371 }
372 if wildcard && cors.allowed_origins.len() > 1 {
373 issues.push(issue(
374 "route",
375 name,
376 "cors.allowed_origins",
377 "'*' cannot be combined with other origins ('*' already matches everything)",
378 ));
379 }
380 for (i, m) in cors.allowed_methods.iter().enumerate() {
381 if hyper::Method::from_bytes(m.as_bytes()).is_err() {
382 issues.push(issue(
383 "route",
384 name,
385 &format!("cors.allowed_methods[{i}]"),
386 format!("'{m}' is not a valid HTTP method token"),
387 ));
388 }
389 }
390 let header_lists = [
391 ("cors.allowed_headers", &cors.allowed_headers, true),
392 ("cors.expose_headers", &cors.expose_headers, false),
393 ];
394 for (field, list, allows_wildcard) in header_lists {
395 for (i, h) in list.iter().enumerate() {
396 if h == "*" {
397 if !allows_wildcard {
398 issues.push(issue(
399 "route",
400 name,
401 &format!("{field}[{i}]"),
402 "'*' is only valid in allowed_headers (expose_headers must name \
403 the headers to expose)",
404 ));
405 } else if cors.allow_credentials {
406 issues.push(issue(
407 "route",
408 name,
409 &format!("{field}[{i}]"),
410 "'*' cannot be combined with allow_credentials (the Fetch spec \
411 forbids wildcard allow-headers on credentialed preflights); \
412 list explicit header names instead",
413 ));
414 }
415 continue;
416 }
417 if hyper::header::HeaderName::from_bytes(h.as_bytes()).is_err() {
418 issues.push(issue(
419 "route",
420 name,
421 &format!("{field}[{i}]"),
422 format!("'{h}' is not a valid HTTP header name"),
423 ));
424 }
425 }
426 }
427}
428
429fn validate_compression(
433 name: &str,
434 compression: &crate::config::Compression,
435 issues: &mut Vec<ValidationIssue>,
436) {
437 if compression.algorithms.is_empty() {
438 issues.push(issue(
439 "route",
440 name,
441 "compression.algorithms",
442 "algorithms is empty: a compression block with no algorithms compresses \
443 nothing and is always a mistake; omit the compression block to disable it",
444 ));
445 }
446 let mut seen = std::collections::BTreeSet::new();
447 for (i, a) in compression.algorithms.iter().enumerate() {
448 if !seen.insert(a) {
449 issues.push(issue(
450 "route",
451 name,
452 &format!("compression.algorithms[{i}]"),
453 format!("algorithm '{a:?}' is listed more than once"),
454 ));
455 }
456 }
457 if let Some(level) = compression.level {
458 if level > 22 {
459 issues.push(issue(
460 "route",
461 name,
462 "compression.level",
463 format!(
464 "level {level} is out of range: 0-22 (clamped per algorithm: \
465 gzip 0-9, brotli 0-11, zstd 0-22)"
466 ),
467 ));
468 }
469 }
470 for (field, entries) in [
471 ("compression.content_types", &compression.content_types),
472 (
473 "compression.excluded_content_types",
474 &compression.excluded_content_types,
475 ),
476 ] {
477 for (i, entry) in entries.iter().enumerate() {
478 let t = entry.trim().to_lowercase();
479 if t.is_empty() || t.contains(char::is_whitespace) {
480 issues.push(issue(
481 "route",
482 name,
483 &format!("{field}[{i}]"),
484 format!(
485 "'{entry}' is not a content-type prefix (expected a non-empty \
486 token like 'text/' or 'application/json')"
487 ),
488 ));
489 }
490 }
491 }
492}
493
494fn validate_request_limits(
497 name: &str,
498 limits: &crate::config::RequestLimits,
499 issues: &mut Vec<ValidationIssue>,
500) {
501 if limits.max_body_bytes.is_none()
502 && limits.max_header_count.is_none()
503 && limits.max_header_bytes.is_none()
504 {
505 issues.push(issue(
506 "route",
507 name,
508 "limits",
509 "carries no limits (no max_body_bytes, max_header_count, or max_header_bytes) \
510 and is always a mistake: omit the limits block entirely",
511 ));
512 }
513 if let Some(0) = limits.max_body_bytes {
514 issues.push(issue(
515 "route",
516 name,
517 "limits.max_body_bytes",
518 "max_body_bytes must be > 0 (0 would reject every request with a body)",
519 ));
520 }
521 if let Some(0) = limits.max_header_count {
522 issues.push(issue(
523 "route",
524 name,
525 "limits.max_header_count",
526 "max_header_count must be > 0",
527 ));
528 }
529 if let Some(0) = limits.max_header_bytes {
530 issues.push(issue(
531 "route",
532 name,
533 "limits.max_header_bytes",
534 "max_header_bytes must be > 0",
535 ));
536 }
537}
538
539fn validate_deprecation(
552 name: &str,
553 dep: &crate::config::Deprecation,
554 issues: &mut Vec<ValidationIssue>,
555) {
556 let since = dep
557 .since
558 .as_deref()
559 .and_then(crate::config::versioning::parse_http_date);
560 let sunset = dep
561 .sunset
562 .as_deref()
563 .and_then(crate::config::versioning::parse_http_date);
564 if dep.since.is_none() && dep.sunset.is_none() {
568 issues.push(issue(
569 "route",
570 name,
571 "deprecation",
572 "carries no dates (no since or sunset) and emits nothing: omit the \
573 deprecation block entirely",
574 ));
575 }
576 for (field, value) in [
577 ("deprecation.since", &dep.since),
578 ("deprecation.sunset", &dep.sunset),
579 ] {
580 if let Some(v) = value {
581 if crate::config::versioning::parse_http_date(v).is_none() {
582 issues.push(issue(
583 "route",
584 name,
585 field,
586 format!(
587 "'{v}' is not a valid HTTP-date: use the IMF-fixdate form \
588 (e.g. 'Sun, 06 Nov 1994 08:49:37 GMT')"
589 ),
590 ));
591 }
592 }
593 }
594 if let Some(s) = since {
595 if s.unix_seconds() < 0 {
596 issues.push(issue(
597 "route",
598 name,
599 "deprecation.since",
600 "since must be 1970 or later (the RFC 9745 Deprecation header renders it \
601 as a Unix-time structured date)",
602 ));
603 }
604 }
605 if let Some(s) = sunset {
606 let now = std::time::SystemTime::now()
607 .duration_since(std::time::UNIX_EPOCH)
608 .map(|d| d.as_secs() as i64)
609 .unwrap_or(0);
610 if s.unix_seconds() < now {
611 issues.push(issue(
612 "route",
613 name,
614 "deprecation.sunset",
615 format!(
616 "sunset date '{}' is in the past: the route should be removed or the \
617 date extended, not advertise a removal that already happened",
618 dep.sunset.as_deref().unwrap_or_default()
619 ),
620 ));
621 }
622 if let Some(since_date) = since {
623 if s.unix_seconds() < since_date.unix_seconds() {
624 issues.push(issue(
625 "route",
626 name,
627 "deprecation.sunset",
628 "sunset is before since (the route would be removed before it is \
629 deprecated)",
630 ));
631 }
632 }
633 }
634 if let Some(uri) = &dep.uri {
635 if since.is_none() {
636 issues.push(issue(
637 "route",
638 name,
639 "deprecation.uri",
640 "uri requires since: the Link rel=\"deprecation\" it emits documents the \
641 Deprecation header, which only since produces",
642 ));
643 }
644 let parseable = match uri.parse::<hyper::Uri>() {
648 Ok(u) => matches!(u.scheme_str(), Some("http") | Some("https")) && u.host().is_some(),
649 Err(_) => false,
650 };
651 if !parseable || uri.bytes().any(|b| matches!(b, b'<' | b'>' | b'"')) {
652 issues.push(issue(
653 "route",
654 name,
655 "deprecation.uri",
656 format!(
657 "'{uri}' must be an absolute http(s) URL with no '<', '>', or '\"' \
658 (it is emitted inside a Link header)"
659 ),
660 ));
661 }
662 }
663}
664
665fn validate_maintenance(
671 name: &str,
672 maintenance: &crate::config::Maintenance,
673 issues: &mut Vec<ValidationIssue>,
674) {
675 if maintenance.retry_after_secs == Some(0) {
676 issues.push(issue(
677 "route",
678 name,
679 "maintenance.retry_after_secs",
680 "retry_after_secs must be > 0 (0 tells clients to retry immediately against a \
681 route that is down; omit the field for the 60s default)",
682 ));
683 }
684 if maintenance
685 .message
686 .as_deref()
687 .is_some_and(|m| m.trim().is_empty())
688 {
689 issues.push(issue(
690 "route",
691 name,
692 "maintenance.message",
693 "message is empty: omit the field for the default 'route under maintenance' text",
694 ));
695 }
696}
697
698fn validate_transforms(
707 name: &str,
708 transforms: &crate::config::transforms::Transforms,
709 issues: &mut Vec<ValidationIssue>,
710) {
711 if transforms.request.is_none() && transforms.response.is_none() {
712 issues.push(issue(
713 "route",
714 name,
715 "transforms",
716 "carries neither request nor response (nothing to transform) and is always a \
717 mistake: omit the transforms block entirely",
718 ));
719 }
720 if let Some(req) = &transforms.request {
721 if req.headers.is_none() && req.query.is_none() && req.body.is_none() {
722 issues.push(issue(
723 "route",
724 name,
725 "transforms.request",
726 "carries no headers, query, or body block and is always a mistake: omit it",
727 ));
728 }
729 if let Some(headers) = &req.headers {
730 validate_header_ops(name, "transforms.request.headers", headers, true, issues);
731 }
732 if let Some(query) = &req.query {
733 validate_query_ops(name, "transforms.request.query", query, issues);
734 }
735 if let Some(body) = &req.body {
736 validate_json_body(name, "transforms.request.body", &body.json, issues);
737 }
738 }
739 if let Some(resp) = &transforms.response {
740 if resp.headers.is_none() && resp.body.is_none() {
741 issues.push(issue(
742 "route",
743 name,
744 "transforms.response",
745 "carries no headers or body block and is always a mistake: omit it",
746 ));
747 }
748 if let Some(headers) = &resp.headers {
749 validate_header_ops(name, "transforms.response.headers", headers, false, issues);
750 }
751 if let Some(body) = &resp.body {
752 validate_json_body(name, "transforms.response.body", &body.json, issues);
753 }
754 }
755}
756
757fn validate_header_ops(
765 route: &str,
766 field: &str,
767 ops: &crate::config::transforms::HeaderOps,
768 request_side: bool,
769 issues: &mut Vec<ValidationIssue>,
770) {
771 use crate::config::transforms as t;
772 if ops.set.is_empty() && ops.add.is_empty() && ops.remove.is_empty() && ops.rename.is_empty() {
773 issues.push(issue(
774 "route",
775 route,
776 field,
777 "carries no set, add, remove, or rename entries and is always a mistake: omit it",
778 ));
779 }
780 let names: Vec<(&str, String)> = ops
781 .set
782 .keys()
783 .map(|k| ("set", k.clone()))
784 .chain(ops.add.keys().map(|k| ("add", k.clone())))
785 .chain(ops.remove.iter().map(|k| ("remove", k.clone())))
786 .chain(ops.rename.keys().map(|k| ("rename", k.clone())))
787 .chain(ops.rename.values().map(|k| ("rename (to)", k.clone())))
788 .collect();
789 for (op, header) in &names {
790 if hyper::header::HeaderName::from_bytes(header.as_bytes()).is_err() {
791 issues.push(issue(
792 "route",
793 route,
794 field,
795 format!("'{header}' ({op}) is not a valid HTTP header name"),
796 ));
797 continue;
798 }
799 let forbidden = if request_side {
800 t::is_forbidden_request_header(header)
801 } else {
802 t::is_forbidden_response_header(header)
803 };
804 if forbidden {
805 issues.push(issue(
806 "route",
807 route,
808 field,
809 format!(
810 "'{header}' ({op}) is a framing/hop-by-hop header the gateway's body \
811 pipeline owns: transforms may not touch it (request smuggling and \
812 body-corruption guard)"
813 ),
814 ));
815 }
816 }
817 for (op, value) in ops
818 .set
819 .values()
820 .map(|v| ("set", v))
821 .chain(ops.add.values().map(|v| ("add", v)))
822 {
823 if hyper::header::HeaderValue::from_str(value).is_err() {
824 issues.push(issue(
825 "route",
826 route,
827 field,
828 format!(
829 "the '{op}' value '{value}' is not representable in an HTTP header \
830 (control characters and non-visible bytes are rejected)"
831 ),
832 ));
833 }
834 }
835 for (from, to) in &ops.rename {
836 if from == to {
837 issues.push(issue(
838 "route",
839 route,
840 field,
841 format!("rename '{from}' -> '{to}' is a no-op: rename to a different name"),
842 ));
843 }
844 }
845}
846
847fn validate_query_ops(
852 route: &str,
853 field: &str,
854 ops: &crate::config::transforms::QueryOps,
855 issues: &mut Vec<ValidationIssue>,
856) {
857 if ops.set.is_empty() && ops.add.is_empty() && ops.remove.is_empty() && ops.rename.is_empty() {
858 issues.push(issue(
859 "route",
860 route,
861 field,
862 "carries no set, add, remove, or rename entries and is always a mistake: omit it",
863 ));
864 }
865 let check = |kind: &str, key: &str, value: Option<&str>, issues: &mut Vec<ValidationIssue>| {
866 let bad_name = key.is_empty()
867 || key.bytes().any(|b| {
868 !b.is_ascii() || b.is_ascii_control() || matches!(b, b'&' | b'=' | b'#' | b' ')
869 });
870 if bad_name {
871 issues.push(issue(
872 "route",
873 route,
874 field,
875 format!(
876 "the {kind} name '{key}' is not a plain query name (non-empty ASCII, no \
877 '&', '=', '#', or spaces)"
878 ),
879 ));
880 }
881 if let Some(v) = value {
882 if v.bytes()
883 .any(|b| !b.is_ascii() || b.is_ascii_control() || matches!(b, b'&' | b'#'))
884 {
885 issues.push(issue(
886 "route",
887 route,
888 field,
889 format!(
890 "the {kind} value '{v}' is not representable in a query string \
891 (ASCII without '&' or '#')"
892 ),
893 ));
894 }
895 }
896 };
897 for (k, v) in &ops.set {
898 check("set", k, Some(v), issues);
899 }
900 for (k, v) in &ops.add {
901 check("add", k, Some(v), issues);
902 }
903 for k in &ops.remove {
904 check("remove", k, None, issues);
905 }
906 for (from, to) in &ops.rename {
907 check("rename", from, None, issues);
908 check("rename (to)", to, None, issues);
909 if from == to {
910 issues.push(issue(
911 "route",
912 route,
913 field,
914 format!("rename '{from}' -> '{to}' is a no-op: rename to a different key"),
915 ));
916 }
917 }
918}
919
920fn validate_json_body(
924 route: &str,
925 field: &str,
926 json: &crate::config::transforms::JsonBodyTransform,
927 issues: &mut Vec<ValidationIssue>,
928) {
929 use crate::config::transforms as t;
930 if json.ops.is_empty() {
931 issues.push(issue(
932 "route",
933 route,
934 &format!("{field}.json.ops"),
935 "ops is empty: a body transform that transforms nothing is always a mistake \
936 (and still pays the content-type gate); omit the body block",
937 ));
938 }
939 if json.max_bytes == 0 {
940 issues.push(issue(
941 "route",
942 route,
943 &format!("{field}.json.max_bytes"),
944 "max_bytes must be > 0 (0 would fail every body against the transform cap)",
945 ));
946 }
947 for (i, op) in json.ops.iter().enumerate() {
948 let path = match op {
949 t::JsonOp::Set { path, .. } => path,
950 t::JsonOp::Remove { path } => path,
951 };
952 match t::JsonPointer::parse(path) {
953 None => issues.push(issue(
954 "route",
955 route,
956 &format!("{field}.json.ops[{i}].path"),
957 format!(
958 "'{path}' is not an RFC 6901 JSON pointer (start with '/', use ~0 and ~1 \
959 for '~' and '/', e.g. /items/0/id; '' addresses the whole document)"
960 ),
961 )),
962 Some(pointer) => {
963 if matches!(op, t::JsonOp::Remove { .. }) && pointer.is_root() {
964 issues.push(issue(
965 "route",
966 route,
967 &format!("{field}.json.ops[{i}].path"),
968 "remove at the root pointer ('') would delete the whole document — \
969 there is no 'no body' state to forward; use set at the root to \
970 replace it",
971 ));
972 }
973 }
974 }
975 }
976}
977
978fn validate_security_headers(
986 name: &str,
987 sh: &crate::config::transforms::SecurityHeaders,
988 issues: &mut Vec<ValidationIssue>,
989) {
990 if sh.hsts_max_age_secs.is_none()
991 && !sh.nosniff
992 && sh.content_security_policy.is_none()
993 && sh.frame_options.is_none()
994 {
995 issues.push(issue(
996 "route",
997 name,
998 "security_headers",
999 "carries no policy (no HSTS, nosniff, CSP, or frame_options) and is always a \
1000 mistake: omit the security_headers block to disable injection",
1001 ));
1002 }
1003 if sh.hsts_max_age_secs == Some(0) {
1004 issues.push(issue(
1005 "route",
1006 name,
1007 "security_headers.hsts_max_age_secs",
1008 "hsts_max_age_secs must be > 0 (max-age=0 is the RFC 6797 deletion signal — \
1009 delete the field to stop emitting HSTS on this route)",
1010 ));
1011 }
1012 if sh.hsts_max_age_secs.is_none() && (sh.hsts_include_subdomains || sh.hsts_preload) {
1013 issues.push(issue(
1014 "route",
1015 name,
1016 "security_headers.hsts_include_subdomains",
1017 "include_subdomains/preload require hsts_max_age_secs: the directives only \
1018 exist inside a Strict-Transport-Security header, which a max-age starts",
1019 ));
1020 }
1021 if sh.hsts_preload && !sh.hsts_include_subdomains {
1022 issues.push(issue(
1023 "route",
1024 name,
1025 "security_headers.hsts_preload",
1026 "preload requires include_subdomains (the HSTS preload list rejects entries \
1027 without it; emitting a header the list would refuse is an authoring mistake)",
1028 ));
1029 }
1030 if sh
1031 .content_security_policy
1032 .as_deref()
1033 .is_some_and(|p| p.trim().is_empty())
1034 {
1035 issues.push(issue(
1036 "route",
1037 name,
1038 "security_headers.content_security_policy",
1039 "content_security_policy is empty: omit the field to stop emitting CSP on \
1040 this route",
1041 ));
1042 }
1043 if let Some(csp) = sh.content_security_policy.as_deref() {
1044 if !csp.trim().is_empty() && hyper::header::HeaderValue::from_str(csp).is_err() {
1050 issues.push(issue(
1051 "route",
1052 name,
1053 "security_headers.content_security_policy",
1054 "content_security_policy is not representable in an HTTP header (control \
1055 characters are rejected — including the trailing newline a YAML block \
1056 scalar adds)",
1057 ));
1058 }
1059 }
1060}
1061
1062const NANO_SERVICE_MAX_MEMORY: usize = 67_108_864; const NANO_SERVICE_MAX_TIMEOUT_MS: u64 = 5_000;
1067
1068fn validate_nano_service(
1073 name: &str,
1074 nano: &crate::config::NanoServiceAction,
1075 issues: &mut Vec<ValidationIssue>,
1076) {
1077 if nano.module.trim().is_empty() {
1078 issues.push(issue(
1079 "route",
1080 name,
1081 "action.module",
1082 "nano_service module path must be non-empty",
1083 ));
1084 } else if !std::path::Path::new(&nano.module).exists() {
1085 issues.push(issue(
1086 "route",
1087 name,
1088 "action.module",
1089 format!(
1090 "nano_service module file '{}' does not exist (checked at config publish time)",
1091 nano.module
1092 ),
1093 ));
1094 }
1095 if nano.memory_limit == 0 {
1096 issues.push(issue(
1097 "route",
1098 name,
1099 "action.memory_limit",
1100 "nano_service memory_limit must be > 0 (omit for the default 1 MiB)",
1101 ));
1102 } else if nano.memory_limit > NANO_SERVICE_MAX_MEMORY {
1103 issues.push(issue(
1104 "route",
1105 name,
1106 "action.memory_limit",
1107 format!(
1108 "nano_service memory_limit ({}) exceeds the maximum {} (64 MiB)",
1109 nano.memory_limit, NANO_SERVICE_MAX_MEMORY
1110 ),
1111 ));
1112 }
1113 if nano.execution_timeout_ms == 0 {
1114 issues.push(issue(
1115 "route",
1116 name,
1117 "action.execution_timeout_ms",
1118 "nano_service execution_timeout_ms must be > 0 (omit for the default 100ms)",
1119 ));
1120 } else if nano.execution_timeout_ms > NANO_SERVICE_MAX_TIMEOUT_MS {
1121 issues.push(issue(
1122 "route",
1123 name,
1124 "action.execution_timeout_ms",
1125 format!(
1126 "nano_service execution_timeout_ms ({}) exceeds the maximum {}ms",
1127 nano.execution_timeout_ms, NANO_SERVICE_MAX_TIMEOUT_MS
1128 ),
1129 ));
1130 }
1131}
1132
1133fn validate_route_cache(
1139 name: &str,
1140 cache: &crate::config::cache::RouteCache,
1141 issues: &mut Vec<ValidationIssue>,
1142) {
1143 use crate::config::cache::{
1144 forbidden_vary_reason, MAX_CACHE_MAX_BODY_BYTES, MAX_CACHE_STALE_SECS, MAX_CACHE_TTL_SECS,
1145 MAX_CACHE_VARY_HEADERS, MAX_COALESCE_WAIT_MS,
1146 };
1147 if cache.ttl_secs == 0 {
1148 issues.push(issue(
1149 "route",
1150 name,
1151 "cache.ttl_secs",
1152 "ttl_secs must be >= 1 (a zero lifetime would expire every entry immediately; \
1153 omit the cache block to disable caching)",
1154 ));
1155 } else if cache.ttl_secs > MAX_CACHE_TTL_SECS {
1156 issues.push(issue(
1157 "route",
1158 name,
1159 "cache.ttl_secs",
1160 format!(
1161 "ttl_secs must be <= {MAX_CACHE_TTL_SECS} (24 h; longer freshness belongs to a \
1162 CDN in front of the gateway)"
1163 ),
1164 ));
1165 }
1166 if let Some(swr) = cache.stale_while_revalidate_secs {
1167 if swr > MAX_CACHE_STALE_SECS {
1168 issues.push(issue(
1169 "route",
1170 name,
1171 "cache.stale_while_revalidate_secs",
1172 format!(
1173 "stale_while_revalidate_secs must be <= {MAX_CACHE_STALE_SECS} (24 h, \
1174 symmetric with the ttl bound)"
1175 ),
1176 ));
1177 }
1178 }
1179 if cache.max_body_bytes == 0 {
1180 issues.push(issue(
1181 "route",
1182 name,
1183 "cache.max_body_bytes",
1184 "max_body_bytes must be >= 1 (0 would store nothing; omit the cache block to \
1185 disable caching)",
1186 ));
1187 } else if cache.max_body_bytes > MAX_CACHE_MAX_BODY_BYTES {
1188 issues.push(issue(
1189 "route",
1190 name,
1191 "cache.max_body_bytes",
1192 format!(
1193 "max_body_bytes must be <= {MAX_CACHE_MAX_BODY_BYTES} (16 MiB; larger bodies \
1194 stream through unstored)"
1195 ),
1196 ));
1197 }
1198 if cache.vary.len() > MAX_CACHE_VARY_HEADERS {
1199 issues.push(issue(
1200 "route",
1201 name,
1202 "cache.vary",
1203 format!(
1204 "at most {MAX_CACHE_VARY_HEADERS} vary headers per route (each multiplies the \
1205 entry key space)"
1206 ),
1207 ));
1208 }
1209 let mut seen: Vec<String> = Vec::new();
1210 for (i, raw) in cache.vary.iter().enumerate() {
1211 let trimmed = raw.trim();
1212 let lowered = trimmed.to_ascii_lowercase();
1213 let field = format!("cache.vary[{i}]");
1214 if lowered.is_empty()
1215 || lowered != trimmed
1216 || lowered.starts_with('-')
1217 || lowered.ends_with('-')
1218 || !lowered
1219 .bytes()
1220 .all(|b| b.is_ascii_lowercase() || b == b'-' || b.is_ascii_digit())
1221 {
1222 issues.push(issue(
1223 "route",
1224 name,
1225 &field,
1226 format!("'{raw}' is not a header name (use the trimmed lowercase form)"),
1227 ));
1228 continue;
1229 }
1230 if let Some(reason) = forbidden_vary_reason(&lowered) {
1231 issues.push(issue(
1232 "route",
1233 name,
1234 &field,
1235 format!("'{lowered}' cannot vary: {reason}"),
1236 ));
1237 continue;
1238 }
1239 if seen.contains(&lowered) {
1240 issues.push(issue(
1241 "route",
1242 name,
1243 &field,
1244 format!("duplicate vary header '{lowered}' (deduplicate the list)"),
1245 ));
1246 }
1247 seen.push(lowered);
1248 }
1249 if let Some(coalescing) = &cache.coalescing {
1254 if coalescing.wait_ms == 0 {
1255 issues.push(issue(
1256 "route",
1257 name,
1258 "cache.coalescing.wait_ms",
1259 "wait_ms must be >= 1 (0 would fail every follower open before the leader \
1260 answers; omit the coalescing block to disable coalescing)",
1261 ));
1262 } else if coalescing.wait_ms > MAX_COALESCE_WAIT_MS {
1263 issues.push(issue(
1264 "route",
1265 name,
1266 "cache.coalescing.wait_ms",
1267 format!(
1268 "wait_ms must be <= {MAX_COALESCE_WAIT_MS} (60 s; a follower parked longer \
1269 is a stuck request from the client's point of view)"
1270 ),
1271 ));
1272 }
1273 }
1274}
1275
1276fn validate_masking(
1284 name: &str,
1285 masking: &crate::config::transforms::Masking,
1286 consumer_groups: &std::collections::BTreeSet<&str>,
1287 issues: &mut Vec<ValidationIssue>,
1288) {
1289 use crate::config::transforms as t;
1290 if masking.fields.is_empty() && masking.groups.is_empty() {
1291 issues.push(issue(
1292 "route",
1293 name,
1294 "masking",
1295 "carries no fields and no groups (nothing to mask) and is always a mistake: \
1296 omit the masking block to disable masking on this route",
1297 ));
1298 }
1299 if masking.max_bytes == 0 {
1300 issues.push(issue(
1301 "route",
1302 name,
1303 "masking.max_bytes",
1304 "max_bytes must be > 0 (0 would fail every response against the masking cap)",
1305 ));
1306 }
1307 let check_pointer =
1308 |field: String, path: &str, issues: &mut Vec<ValidationIssue>| match t::JsonPointer::parse(
1309 path,
1310 ) {
1311 None => issues.push(issue(
1312 "route",
1313 name,
1314 &field,
1315 format!(
1316 "'{path}' is not an RFC 6901 JSON pointer (start with '/', use ~0 and ~1 for \
1317 '~' and '/', e.g. /items/0/id)"
1318 ),
1319 )),
1320 Some(pointer) => {
1321 if pointer.is_root() {
1322 issues.push(issue(
1323 "route",
1324 name,
1325 &field,
1326 "the root pointer ('') would replace the whole document with the sentinel — \
1327 a body the route cannot usefully serve; mask fields, not the document",
1328 ));
1329 }
1330 }
1331 };
1332 for (i, path) in masking.fields.iter().enumerate() {
1333 check_pointer(format!("masking.fields[{i}]"), path, issues);
1334 }
1335 for (group, paths) in &masking.groups {
1336 if group.trim().is_empty() {
1337 issues.push(issue(
1338 "route",
1339 name,
1340 "masking.groups",
1341 format!("group key '{group}' is empty: group names are non-empty strings"),
1342 ));
1343 }
1344 if !consumer_groups.contains(group.as_str()) {
1345 issues.push(issue(
1346 "route",
1347 name,
1348 "masking.groups",
1349 format!(
1350 "group '{group}' matches no configured consumer's groups membership — a \
1351 typo here silently never masks (fail-open); grant a consumer the group \
1352 or fix the name (store-managed consumers carry groups the config cannot \
1353 see, the known caveat of this check)"
1354 ),
1355 ));
1356 }
1357 if paths.is_empty() {
1358 issues.push(issue(
1359 "route",
1360 name,
1361 "masking.groups",
1362 format!(
1363 "group '{group}' carries no pointers and is always a mistake: omit \
1364 the entry (the route floor still applies to the group's consumers)"
1365 ),
1366 ));
1367 }
1368 for (i, path) in paths.iter().enumerate() {
1369 check_pointer(format!("masking.groups.{group}[{i}]"), path, issues);
1370 }
1371 }
1372}
1373
1374fn validate_analytics(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1385 let Some(a) = &gateway.analytics else { return };
1386 if a.path.trim().is_empty() {
1387 issues.push(issue(
1388 "gateway",
1389 "(root)",
1390 "analytics.path",
1391 "analytics.path is empty: the database path must name a real file \
1392 (an empty path would open a throwaway temp database, losing every \
1393 record)",
1394 ));
1395 }
1396 if let Some(flush) = a.flush_ms {
1397 if !(100..=60_000).contains(&flush) {
1398 issues.push(issue(
1399 "gateway",
1400 "(root)",
1401 "analytics.flush_ms",
1402 format!(
1403 "flush_ms {flush} is out of range: must be 100..=60000 \
1404 (below 100 is timer churn; above 60000 adds nothing the \
1405 rollup grace does not already cover)"
1406 ),
1407 ));
1408 }
1409 }
1410 if let Some(d) = &a.sampled_degradation {
1412 if !(0.0..=1.0).contains(&d.high_watermark) || d.high_watermark <= 0.0 {
1413 issues.push(issue(
1414 "gateway",
1415 "(root)",
1416 "analytics.sampled_degradation.high_watermark",
1417 "high_watermark must be in (0.0, 1.0]",
1418 ));
1419 }
1420 if !(0.0..=1.0).contains(&d.low_watermark) {
1421 issues.push(issue(
1422 "gateway",
1423 "(root)",
1424 "analytics.sampled_degradation.low_watermark",
1425 "low_watermark must be in [0.0, 1.0)",
1426 ));
1427 }
1428 if d.low_watermark >= d.high_watermark {
1429 issues.push(issue(
1430 "gateway",
1431 "(root)",
1432 "analytics.sampled_degradation.low_watermark",
1433 "low_watermark must be < high_watermark",
1434 ));
1435 }
1436 if !(0.0..=1.0).contains(&d.keep_rate) || d.keep_rate <= 0.0 {
1437 issues.push(issue(
1438 "gateway",
1439 "(root)",
1440 "analytics.sampled_degradation.keep_rate",
1441 "keep_rate must be in (0.0, 1.0]",
1442 ));
1443 }
1444 }
1445 if let Some(r) = &a.retention {
1446 let e = r.effective();
1447 let names = ["raw_ms", "m1_ms", "m5_ms", "h1_ms", "d1_ms"];
1448 for i in 0..5 {
1449 let v = e[i];
1450 let cap: i64 = match i {
1451 0 => 7 * 86_400_000, 1 => 30 * 86_400_000, 2 => 90 * 86_400_000, 3 => 365 * 86_400_000, _ => 3_650 * 86_400_000, };
1457 if v > cap {
1458 issues.push(issue(
1459 "gateway",
1460 "(root)",
1461 &format!("analytics.retention.{}", names[i]),
1462 format!(
1463 "{} {} ms exceeds the cap {} ms (bounded disk is the \
1464 point of the store)",
1465 names[i], v, cap
1466 ),
1467 ));
1468 }
1469 }
1470 for w in 0..4 {
1471 if e[w + 1] < e[w] {
1472 issues.push(issue(
1473 "gateway",
1474 "(root)",
1475 &format!("analytics.retention.{}", names[w + 1]),
1476 format!(
1477 "{} {} ms is shorter than {} {} ms: a coarser \
1478 rollup may not expire before the finer table it \
1479 cascades from",
1480 names[w + 1],
1481 e[w + 1],
1482 names[w],
1483 e[w]
1484 ),
1485 ));
1486 }
1487 }
1488 }
1489 let mut seen = std::collections::BTreeSet::new();
1490 for (i, dim) in a.dimensions.iter().enumerate() {
1491 let field = format!("analytics.dimensions[{i}]");
1492 let name_ok = !dim.name.is_empty()
1493 && dim.name.len() <= 32
1494 && dim
1495 .name
1496 .bytes()
1497 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
1498 if !name_ok {
1499 issues.push(issue(
1500 "gateway",
1501 "(root)",
1502 &format!("{field}.name"),
1503 format!(
1504 "dimension name '{}' is invalid: lowercase [a-z0-9_], \
1505 1..=32 bytes (it is the rollup dimension key)",
1506 dim.name
1507 ),
1508 ));
1509 }
1510 match dim.effective_source() {
1513 crate::config::DimensionSource::Header => {
1514 if let Some(header) = &dim.header {
1515 if let Err(e) = hyper::header::HeaderName::try_from(header.as_str()) {
1516 issues.push(issue(
1517 "gateway",
1518 "(root)",
1519 &format!("{field}.header"),
1520 format!("header name '{header}' is not representable: {e}"),
1521 ));
1522 }
1523 } else {
1524 issues.push(issue(
1525 "gateway",
1526 "(root)",
1527 &format!("{field}.header"),
1528 "source 'header' (or absent source) requires the \
1529 'header' field",
1530 ));
1531 }
1532 }
1533 crate::config::DimensionSource::Claim => {
1534 if let Some(claim) = &dim.claim {
1535 if claim.is_empty()
1536 || !claim.bytes().all(|b| b.is_ascii_graphic())
1537 || claim.len() > 128
1538 {
1539 issues.push(issue(
1540 "gateway",
1541 "(root)",
1542 &format!("{field}.claim"),
1543 format!(
1544 "claim name '{}' is invalid: non-empty \
1545 printable ASCII, at most 128 bytes",
1546 claim
1547 ),
1548 ));
1549 }
1550 } else {
1551 issues.push(issue(
1552 "gateway",
1553 "(root)",
1554 &format!("{field}.claim"),
1555 "source 'claim' requires the 'claim' field",
1556 ));
1557 }
1558 }
1559 crate::config::DimensionSource::BodyPath => {
1560 if let Some(path) = &dim.body_path {
1561 if crate::config::transforms::JsonPointer::parse(path).is_none() {
1562 issues.push(issue(
1563 "gateway",
1564 "(root)",
1565 &format!("{field}.body_path"),
1566 format!(
1567 "body_path '{}' is not a valid RFC 6901 JSON \
1568 pointer (must start with '/' or be empty for \
1569 the whole document)",
1570 path
1571 ),
1572 ));
1573 }
1574 } else {
1575 issues.push(issue(
1576 "gateway",
1577 "(root)",
1578 &format!("{field}.body_path"),
1579 "source 'body_path' requires the 'body_path' field",
1580 ));
1581 }
1582 }
1583 }
1584 if !seen.insert(dim.name.to_ascii_lowercase()) {
1585 issues.push(issue(
1586 "gateway",
1587 "(root)",
1588 &format!("{field}.name"),
1589 format!(
1590 "duplicate dimension name '{}' (each name is one rollup key)",
1591 dim.name
1592 ),
1593 ));
1594 }
1595 }
1596 if a.dimensions.len() > 16 {
1597 issues.push(issue(
1598 "gateway",
1599 "(root)",
1600 "analytics.dimensions",
1601 format!(
1602 "{} dimensions declared; at most 16 (each multiplies rollup \
1603 cardinality)",
1604 a.dimensions.len()
1605 ),
1606 ));
1607 }
1608 if let Some(exports) = &a.exports {
1609 if exports.directory.trim().is_empty() {
1610 issues.push(issue(
1611 "gateway",
1612 "(root)",
1613 "analytics.exports.directory",
1614 "analytics.exports.directory is empty: exports need a real \
1615 directory to write their files into",
1616 ));
1617 }
1618 let mut seen_formats = std::collections::BTreeSet::new();
1619 for (i, f) in exports.formats.iter().enumerate() {
1620 if !seen_formats.insert(f) {
1621 issues.push(issue(
1622 "gateway",
1623 "(root)",
1624 &format!("analytics.exports.formats[{i}]"),
1625 format!(
1626 "duplicate export format '{f:?}': each format writes \
1627 exactly one file per window",
1628 ),
1629 ));
1630 }
1631 }
1632 }
1633 if let Some(ls) = &a.live_sketches {
1635 if ls.enabled && !(100..=5000).contains(&ls.freshness_target_ms) {
1636 issues.push(issue(
1637 "gateway",
1638 "(root)",
1639 "analytics.live_sketches.freshness_target_ms",
1640 format!(
1641 "freshness_target_ms {} is out of range: must be 100..=5000 \
1642 (below 100 is timer churn; above 5000 is no longer 'live')",
1643 ls.freshness_target_ms
1644 ),
1645 ));
1646 }
1647 }
1648 if let Some(ins) = &a.insights {
1651 if (ins.forecast || ins.anomaly_baseline) && ins.baseline_windows == 0 {
1652 issues.push(issue(
1653 "gateway",
1654 "(root)",
1655 "analytics.insights.baseline_windows",
1656 "baseline_windows must be > 0 when forecast or anomaly_baseline \
1657 is enabled (the ring buffer must hold at least one window)",
1658 ));
1659 }
1660 }
1661}
1662
1663fn validate_geoip(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1670 fn check(a: Option<&crate::config::Authz>, where_: &str, issues: &mut Vec<ValidationIssue>) {
1671 let Some(rules) = a.and_then(|a| a.geoip.as_ref()) else {
1672 return;
1673 };
1674 for c in rules
1675 .allowed_countries
1676 .iter()
1677 .chain(&rules.denied_countries)
1678 {
1679 if c.len() != 2 || !c.chars().all(|ch| ch.is_ascii_alphabetic()) {
1680 issues.push(issue(
1681 "gateway",
1682 where_,
1683 "authorization.geoip.countries",
1684 format!("country code '{c}' is not two ASCII letters (ISO 3166-1 alpha-2)"),
1685 ));
1686 }
1687 }
1688 }
1689 let has_rules = |a: Option<&crate::config::Authz>| a.is_some_and(|a| a.geoip.is_some());
1690 let any = gateway
1691 .authorization
1692 .as_ref()
1693 .is_some_and(|a| has_rules(Some(a)))
1694 || gateway
1695 .listeners
1696 .iter()
1697 .any(|l| has_rules(l.authorization.as_ref()))
1698 || gateway
1699 .services
1700 .iter()
1701 .any(|s| has_rules(s.authorization.as_ref()))
1702 || gateway
1703 .routes
1704 .iter()
1705 .any(|r| has_rules(r.authorization.as_ref()))
1706 || gateway
1707 .consumers
1708 .iter()
1709 .any(|c| has_rules(c.authorization.as_ref()));
1710 match &gateway.geoip {
1711 None if any => issues.push(issue(
1712 "gateway",
1713 "(root)",
1714 "geoip",
1715 "authorization.geoip rules require a gateway.geoip database block \
1716 (without one the gate cannot be evaluated)",
1717 )),
1718 Some(g) if g.path.trim().is_empty() => issues.push(issue(
1719 "gateway",
1720 "(root)",
1721 "geoip.path",
1722 "geoip.path is empty: name a real .mmdb file or remove the block",
1723 )),
1724 _ => {}
1725 }
1726 check(gateway.authorization.as_ref(), "(root)", issues);
1727 for l in &gateway.listeners {
1728 check(
1729 l.authorization.as_ref(),
1730 &format!("listeners[{}]", l.name),
1731 issues,
1732 );
1733 }
1734 for s in &gateway.services {
1735 check(
1736 s.authorization.as_ref(),
1737 &format!("services[{}]", s.name),
1738 issues,
1739 );
1740 }
1741 for r in &gateway.routes {
1742 check(
1743 r.authorization.as_ref(),
1744 &format!("routes[{}]", r.name),
1745 issues,
1746 );
1747 }
1748 for c in &gateway.consumers {
1749 check(
1750 c.authorization.as_ref(),
1751 &format!("consumers[{}]", c.name),
1752 issues,
1753 );
1754 }
1755}
1756
1757fn validate_quotas(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1765 for c in &gateway.consumers {
1766 let Some(q) = &c.quotas else {
1767 continue;
1768 };
1769 let field = format!("consumers[{}].quotas", c.name);
1770 if q.daily_requests.is_none() && q.monthly_requests.is_none() {
1771 issues.push(issue(
1772 "consumer",
1773 &c.name,
1774 &field,
1775 "sets no budget: at least one of daily_requests or monthly_requests \
1776 must be present (omit the quotas block entirely for no budgets)",
1777 ));
1778 }
1779 for (name, value) in [
1780 ("daily_requests", q.daily_requests),
1781 ("monthly_requests", q.monthly_requests),
1782 ] {
1783 if value == Some(0) {
1784 issues.push(issue(
1785 "consumer",
1786 &c.name,
1787 &format!("{field}.{name}"),
1788 format!(
1789 "{name} must be > 0 (a zero budget denies the consumer's \
1790 first request; no budget is the omitted field)"
1791 ),
1792 ));
1793 }
1794 }
1795 }
1796}
1797
1798fn validate_plugins(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1808 let mut seen = std::collections::BTreeSet::new();
1809 for p in &gateway.plugins {
1810 if !seen.insert(p.name.as_str()) {
1811 issues.push(issue("plugin", &p.name, "name", "duplicate plugin name"));
1812 }
1813 let has_wasm = p.wasm.is_some();
1814 let has_native = p.native.is_some();
1815 let has_source = p.source.is_some();
1816 let has_wasm_or_source = has_wasm || has_source;
1820 if !has_wasm_or_source && !has_native {
1821 issues.push(issue(
1822 "plugin",
1823 &p.name,
1824 "wasm",
1825 "plugin must set exactly one of `wasm`, `source`, or `native` (all are absent)",
1826 ));
1827 }
1828 if has_wasm_or_source && has_native {
1829 issues.push(issue(
1830 "plugin",
1831 &p.name,
1832 "native",
1833 "plugin must set exactly one of `wasm`/`source` or `native` (both are set)",
1834 ));
1835 }
1836 if has_wasm && has_source {
1837 issues.push(issue(
1838 "plugin",
1839 &p.name,
1840 "source",
1841 "plugin must not set both `wasm` and `source` (use `source` with optional `cache_path`)",
1842 ));
1843 }
1844 if p.phases.is_empty() {
1845 issues.push(issue(
1846 "plugin",
1847 &p.name,
1848 "phases",
1849 "phases must be a non-empty subset of request_headers, \
1850 request_body, response_headers, response_body",
1851 ));
1852 }
1853 if let Some(src) = &p.source {
1857 let digest_ok =
1858 src.digest.len() == 64 && src.digest.bytes().all(|b| b.is_ascii_hexdigit());
1859 if !digest_ok {
1860 let detail = if src.digest.len() != 64 {
1861 format!("got {} chars, expected 64", src.digest.len())
1862 } else {
1863 "contains non-hex characters".to_string()
1864 };
1865 issues.push(issue(
1866 "plugin",
1867 &p.name,
1868 "source.digest",
1869 format!("source digest must be a hex-encoded SHA-256 string ({detail})"),
1870 ));
1871 }
1872 if src.url.starts_with("oci://") {
1873 issues.push(issue(
1874 "plugin",
1875 &p.name,
1876 "source.url",
1877 "oci:// plugin sources are not supported in this version; publish \
1878 the artifact over https:// or pre-fetch it with `dwara-cli plugin \
1879 install` and reference the local file with `wasm`",
1880 ));
1881 } else if !src.url.starts_with("https://") {
1882 issues.push(issue(
1883 "plugin",
1884 &p.name,
1885 "source.url",
1886 "source url must use the https:// scheme (oci:// is not supported \
1887 in this version)",
1888 ));
1889 }
1890 if let Some(pk) = &src.public_key {
1891 if pk.len() != 64 || !pk.bytes().all(|b| b.is_ascii_hexdigit()) {
1892 issues.push(issue(
1893 "plugin",
1894 &p.name,
1895 "source.public_key",
1896 "source.public_key must be a 32-byte Ed25519 key hex-encoded \
1897 (64 chars)",
1898 ));
1899 }
1900 }
1901 if let Some(sig) = &src.signature {
1902 if sig.len() != 128 || !sig.bytes().all(|b| b.is_ascii_hexdigit()) {
1903 issues.push(issue(
1904 "plugin",
1905 &p.name,
1906 "source.signature",
1907 "source.signature must be a 64-byte Ed25519 signature \
1908 hex-encoded (128 chars)",
1909 ));
1910 }
1911 }
1912 if src.signature.is_some() && src.public_key.is_none() {
1913 let registry_keys = gateway
1917 .plugin_registry
1918 .as_ref()
1919 .map(|r| !r.public_keys.is_empty())
1920 .unwrap_or(false);
1921 if !registry_keys {
1922 issues.push(issue(
1923 "plugin",
1924 &p.name,
1925 "source.public_key",
1926 "source.public_key is required when source.signature is set \
1927 (or pin keys via plugin_registry.public_keys)",
1928 ));
1929 }
1930 }
1931 if src.public_key.is_some() && src.signature.is_none() {
1932 issues.push(issue(
1936 "plugin",
1937 &p.name,
1938 "source.signature",
1939 "source.signature is required when source.public_key is set \
1940 (a key without a signature verifies nothing — sign the \
1941 artifact or remove the key)",
1942 ));
1943 }
1944 if let Some(cp) = &src.cache_path {
1945 if let Some(reason) = crate::config::PluginSourceConfig::cache_path_violation(cp) {
1952 issues.push(issue(
1953 "plugin",
1954 &p.name,
1955 "source.cache_path",
1956 format!(
1957 "source.cache_path '{cp}' is not usable ({reason}); it must \
1958 be a relative path under plugin_registry.cache_dir with no \
1959 parent segments"
1960 ),
1961 ));
1962 }
1963 }
1964 }
1965 }
1966 if let Some(reg) = &gateway.plugin_registry {
1968 for pk in ®.public_keys {
1969 if pk.len() != 64 {
1970 issues.push(issue(
1971 "plugin_registry",
1972 "gateway",
1973 "public_keys",
1974 "each public key must be a 32-byte Ed25519 key hex-encoded (64 chars)",
1975 ));
1976 }
1977 }
1978 }
1979}
1980
1981fn validate_filter_chain(
1985 fc: &crate::config::FilterChainConfig,
1986 entity: &str,
1987 name: &str,
1988 issues: &mut Vec<ValidationIssue>,
1989) {
1990 if let Some(order) = &fc.order {
1991 let default = crate::config::FilterPhase::DEFAULT_ORDER;
1992 if order.len() != default.len() {
1993 issues.push(issue(
1994 entity,
1995 name,
1996 "filter_chain.order",
1997 format!(
1998 "order must contain exactly {} phases, got {}",
1999 default.len(),
2000 order.len()
2001 ),
2002 ));
2003 } else {
2004 let mut seen = std::collections::BTreeSet::new();
2005 for phase in order {
2006 if !seen.insert(*phase) {
2007 issues.push(issue(
2008 entity,
2009 name,
2010 "filter_chain.order",
2011 format!("duplicate phase '{}' in filter_chain.order", phase.as_str()),
2012 ));
2013 }
2014 }
2015 for dp in &default {
2017 if !order.contains(dp) {
2018 issues.push(issue(
2019 entity,
2020 name,
2021 "filter_chain.order",
2022 format!("filter_chain.order is missing phase '{}'", dp.as_str()),
2023 ));
2024 }
2025 }
2026 }
2027 }
2028 let mut seen = std::collections::BTreeSet::new();
2030 for phase in &fc.dry_run {
2031 if !seen.insert(*phase) {
2032 issues.push(issue(
2033 entity,
2034 name,
2035 "filter_chain.dry_run",
2036 format!(
2037 "duplicate phase '{}' in filter_chain.dry_run",
2038 phase.as_str()
2039 ),
2040 ));
2041 }
2042 }
2043}
2044
2045fn validate_license(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2052 let Some(lic) = &gateway.license else {
2053 return;
2054 };
2055 if lic.file.trim().is_empty() {
2056 issues.push(issue(
2057 "gateway",
2058 "(root)",
2059 "license.file",
2060 "license.file must be a non-empty filesystem path",
2061 ));
2062 }
2063 if lic.grace_period_days > crate::config::limits::MAX_LICENSE_GRACE_PERIOD_DAYS {
2064 issues.push(issue(
2065 "gateway",
2066 "(root)",
2067 "license.grace_period_days",
2068 format!(
2069 "license.grace_period_days {} is out of range: must be 0..={}",
2070 lic.grace_period_days,
2071 crate::config::limits::MAX_LICENSE_GRACE_PERIOD_DAYS,
2072 ),
2073 ));
2074 }
2075}
2076
2077fn validate_redis_rate_limiter(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2087 let Some(rl) = &gateway.redis_rate_limiter else {
2088 return;
2089 };
2090 if rl.url.trim().is_empty() {
2091 issues.push(issue(
2092 "gateway",
2093 "(root)",
2094 "redis_rate_limiter.url",
2095 "redis_rate_limiter.url must be a non-empty Redis URL",
2096 ));
2097 }
2098 let timeout = rl.connection_timeout_ms;
2099 if !(crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS
2100 ..=crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS)
2101 .contains(&timeout)
2102 {
2103 issues.push(issue(
2104 "gateway",
2105 "(root)",
2106 "redis_rate_limiter.connection_timeout_ms",
2107 format!(
2108 "redis_rate_limiter.connection_timeout_ms {} is out of range: must be {}..={}",
2109 timeout,
2110 crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS,
2111 crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS,
2112 ),
2113 ));
2114 }
2115 let ttl = rl.key_ttl_s;
2116 if !(crate::config::limits::MIN_REDIS_KEY_TTL_S..=crate::config::limits::MAX_REDIS_KEY_TTL_S)
2117 .contains(&ttl)
2118 {
2119 issues.push(issue(
2120 "gateway",
2121 "(root)",
2122 "redis_rate_limiter.key_ttl_s",
2123 format!(
2124 "redis_rate_limiter.key_ttl_s {} is out of range: must be {}..={}",
2125 ttl,
2126 crate::config::limits::MIN_REDIS_KEY_TTL_S,
2127 crate::config::limits::MAX_REDIS_KEY_TTL_S,
2128 ),
2129 ));
2130 }
2131 if rl.key_prefix.is_empty() {
2132 issues.push(issue(
2133 "gateway",
2134 "(root)",
2135 "redis_rate_limiter.key_prefix",
2136 "redis_rate_limiter.key_prefix must be a non-empty string",
2137 ));
2138 }
2139 validate_redis_ha("redis_rate_limiter", &rl.ha, issues);
2140}
2141
2142fn validate_redis_ha(
2150 prefix: &str,
2151 ha: &crate::config::RedisHaConfig,
2152 issues: &mut Vec<ValidationIssue>,
2153) {
2154 use crate::config::RedisTopology;
2155 match ha.topology {
2156 RedisTopology::Single => {}
2157 RedisTopology::Sentinel => {
2158 if ha.master_name.is_none()
2159 || ha
2160 .master_name
2161 .as_deref()
2162 .map(|s| s.trim().is_empty())
2163 .unwrap_or(true)
2164 {
2165 issues.push(issue(
2166 "gateway",
2167 "(root)",
2168 &format!("{prefix}.ha.master_name"),
2169 format!("{prefix}.ha.master_name is required when topology = sentinel"),
2170 ));
2171 }
2172 if ha.nodes.is_empty() {
2173 issues.push(issue(
2174 "gateway",
2175 "(root)",
2176 &format!("{prefix}.ha.nodes"),
2177 format!("{prefix}.ha.nodes must list at least one Sentinel URL when topology = sentinel"),
2178 ));
2179 }
2180 }
2181 RedisTopology::Cluster => {
2182 if ha.nodes.is_empty() {
2183 issues.push(issue(
2184 "gateway",
2185 "(root)",
2186 &format!("{prefix}.ha.nodes"),
2187 format!("{prefix}.ha.nodes must list at least one cluster node URL when topology = cluster"),
2188 ));
2189 }
2190 }
2191 }
2192 let timeout = ha.request_timeout_ms;
2193 if timeout != 0 && !(50..=30_000).contains(&timeout) {
2194 issues.push(issue(
2195 "gateway",
2196 "(root)",
2197 &format!("{prefix}.ha.request_timeout_ms"),
2198 format!(
2199 "{prefix}.ha.request_timeout_ms {timeout} is out of range: must be 0 or 50..=30000"
2200 ),
2201 ));
2202 }
2203}
2204
2205fn validate_redis_quotas(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2213 let Some(rq) = &gateway.redis_quotas else {
2214 return;
2215 };
2216 if rq.url.trim().is_empty() {
2217 issues.push(issue(
2218 "gateway",
2219 "(root)",
2220 "redis_quotas.url",
2221 "redis_quotas.url must be a non-empty Redis URL",
2222 ));
2223 }
2224 let timeout = rq.connection_timeout_ms;
2225 if !(crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS
2226 ..=crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS)
2227 .contains(&timeout)
2228 {
2229 issues.push(issue(
2230 "gateway",
2231 "(root)",
2232 "redis_quotas.connection_timeout_ms",
2233 format!(
2234 "redis_quotas.connection_timeout_ms {} is out of range: must be {}..={}",
2235 timeout,
2236 crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS,
2237 crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS,
2238 ),
2239 ));
2240 }
2241 if rq.key_prefix.is_empty() {
2242 issues.push(issue(
2243 "gateway",
2244 "(root)",
2245 "redis_quotas.key_prefix",
2246 "redis_quotas.key_prefix must be a non-empty string",
2247 ));
2248 }
2249 validate_redis_ha("redis_quotas", &rq.ha, issues);
2250}
2251
2252fn validate_redis_cache(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2259 let Some(rc) = &gateway.redis_cache else {
2260 return;
2261 };
2262 if rc.url.trim().is_empty() {
2263 issues.push(issue(
2264 "gateway",
2265 "(root)",
2266 "redis_cache.url",
2267 "redis_cache.url must be a non-empty Redis URL",
2268 ));
2269 }
2270 let timeout = rc.connection_timeout_ms;
2271 if !(crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS
2272 ..=crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS)
2273 .contains(&timeout)
2274 {
2275 issues.push(issue(
2276 "gateway",
2277 "(root)",
2278 "redis_cache.connection_timeout_ms",
2279 format!(
2280 "redis_cache.connection_timeout_ms {} is out of range: must be {}..={}",
2281 timeout,
2282 crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS,
2283 crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS,
2284 ),
2285 ));
2286 }
2287 if rc.key_prefix.is_empty() {
2288 issues.push(issue(
2289 "gateway",
2290 "(root)",
2291 "redis_cache.key_prefix",
2292 "redis_cache.key_prefix must be a non-empty string",
2293 ));
2294 }
2295 validate_redis_ha("redis_cache", &rc.ha, issues);
2296}
2297
2298fn validate_config_convergence(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2310 let Some(cc) = &gateway.config_convergence else {
2311 return;
2312 };
2313 if cc.backend != "redis" {
2314 issues.push(issue(
2315 "gateway",
2316 "(root)",
2317 "config_convergence.backend",
2318 format!(
2319 "config_convergence.backend '{}' is not supported: v1 ships only 'redis' \
2320 (etcd/Consul are deferred behind the ConfigConvergenceBackend trait)",
2321 cc.backend,
2322 ),
2323 ));
2324 }
2325 if cc.backend == "redis" {
2326 match &cc.redis_url {
2327 None => issues.push(issue(
2328 "gateway",
2329 "(root)",
2330 "config_convergence.redis_url",
2331 "config_convergence.redis_url is required when backend = 'redis'",
2332 )),
2333 Some(url) if url.trim().is_empty() => issues.push(issue(
2334 "gateway",
2335 "(root)",
2336 "config_convergence.redis_url",
2337 "config_convergence.redis_url must be a non-empty Redis URL",
2338 )),
2339 _ => {}
2340 }
2341 }
2342 let poll = cc.poll_interval_ms;
2343 if !(crate::config::limits::MIN_CONFIG_CONVERGENCE_POLL_MS
2344 ..=crate::config::limits::MAX_CONFIG_CONVERGENCE_POLL_MS)
2345 .contains(&poll)
2346 {
2347 issues.push(issue(
2348 "gateway",
2349 "(root)",
2350 "config_convergence.poll_interval_ms",
2351 format!(
2352 "config_convergence.poll_interval_ms {} is out of range: must be {}..={}",
2353 poll,
2354 crate::config::limits::MIN_CONFIG_CONVERGENCE_POLL_MS,
2355 crate::config::limits::MAX_CONFIG_CONVERGENCE_POLL_MS,
2356 ),
2357 ));
2358 }
2359 let drift = cc.drift_check_interval_ms;
2360 if !(crate::config::limits::MIN_CONFIG_CONVERGENCE_DRIFT_CHECK_MS
2361 ..=crate::config::limits::MAX_CONFIG_CONVERGENCE_DRIFT_CHECK_MS)
2362 .contains(&drift)
2363 {
2364 issues.push(issue(
2365 "gateway",
2366 "(root)",
2367 "config_convergence.drift_check_interval_ms",
2368 format!(
2369 "config_convergence.drift_check_interval_ms {} is out of range: must be {}..={}",
2370 drift,
2371 crate::config::limits::MIN_CONFIG_CONVERGENCE_DRIFT_CHECK_MS,
2372 crate::config::limits::MAX_CONFIG_CONVERGENCE_DRIFT_CHECK_MS,
2373 ),
2374 ));
2375 }
2376 if cc.key_prefix.is_empty() {
2377 issues.push(issue(
2378 "gateway",
2379 "(root)",
2380 "config_convergence.key_prefix",
2381 "config_convergence.key_prefix must be a non-empty string",
2382 ));
2383 }
2384}
2385
2386fn validate_fleet(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2392 let Some(fleet) = &gateway.fleet else {
2393 return;
2394 };
2395 if !fleet.enabled {
2396 return;
2397 }
2398 let stale = fleet.stale_timeout_secs;
2399 if !(5..=3600).contains(&stale) {
2400 issues.push(issue(
2401 "gateway",
2402 "(root)",
2403 "fleet.stale_timeout_secs",
2404 format!("fleet.stale_timeout_secs {stale} is out of range: must be 5..=3600"),
2405 ));
2406 }
2407 if let Some(cv) = &fleet.controller_version {
2408 if cv.trim().is_empty() {
2409 issues.push(issue(
2410 "gateway",
2411 "(root)",
2412 "fleet.controller_version",
2413 "fleet.controller_version must be a non-empty semver string when present",
2414 ));
2415 }
2416 }
2417 if let Some(upgrade) = &fleet.upgrade {
2418 let mut seen_names = std::collections::HashSet::new();
2419 for (i, entry) in upgrade.order.iter().enumerate() {
2420 if entry.name.is_empty() {
2421 issues.push(issue(
2422 "gateway",
2423 "(root)",
2424 &format!("fleet.upgrade.order[{i}].name"),
2425 "upgrade order entry name must be a non-empty string",
2426 ));
2427 }
2428 if !seen_names.insert(entry.name.as_str()) {
2429 issues.push(issue(
2430 "gateway",
2431 "(root)",
2432 &format!("fleet.upgrade.order[{i}].name"),
2433 format!(
2434 "upgrade order entry name '{}' is duplicated (each wave must have a \
2435 unique name)",
2436 entry.name
2437 ),
2438 ));
2439 }
2440 if entry.labels.is_empty() {
2441 issues.push(issue(
2442 "gateway",
2443 "(root)",
2444 &format!("fleet.upgrade.order[{i}].labels"),
2445 "upgrade order entry labels must be a non-empty label selector",
2446 ));
2447 }
2448 }
2449 for (i, a) in upgrade.order.iter().enumerate() {
2452 for (j, b) in upgrade.order.iter().enumerate().skip(i + 1) {
2453 if labels_overlap(&a.labels, &b.labels) {
2454 issues.push(issue(
2455 "gateway",
2456 "(root)",
2457 &format!("fleet.upgrade.order[{i}].labels"),
2458 format!(
2459 "upgrade order entries '{}' (index {i}) and '{}' (index {j}) have \
2460 overlapping label selectors (an edge would match both, making the \
2461 order ambiguous)",
2462 a.name, b.name
2463 ),
2464 ));
2465 }
2466 }
2467 }
2468 }
2469}
2470
2471fn labels_overlap(
2475 a: &std::collections::HashMap<String, String>,
2476 b: &std::collections::HashMap<String, String>,
2477) -> bool {
2478 let (smaller, larger) = if a.len() <= b.len() { (a, b) } else { (b, a) };
2479 smaller.iter().all(|(k, v)| larger.get(k) == Some(v))
2480}
2481
2482fn validate_mesh(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2493 let Some(mesh) = &gateway.mesh else {
2494 return;
2495 };
2496
2497 #[cfg(not(feature = "ent"))]
2505 {
2506 issues.push(issue(
2507 "gateway",
2508 "(root)",
2509 "mesh",
2510 "the mesh block is configured but the `ent` cargo feature is \
2511 not compiled in: service mesh mode is an enterprise feature \
2512 (DW-107). Build with --features ent (combined with --features \
2513 mesh) for license-gated enforcement.",
2514 ));
2515 }
2516
2517 if !mesh.enabled {
2518 return;
2519 }
2520
2521 if let Some(sidecar) = &mesh.sidecar {
2523 if sidecar.inbound_port == 0 {
2524 issues.push(issue(
2525 "gateway",
2526 "(root)",
2527 "mesh.sidecar.inbound_port",
2528 "inbound_port must be > 0",
2529 ));
2530 }
2531 if sidecar.outbound_port == 0 {
2532 issues.push(issue(
2533 "gateway",
2534 "(root)",
2535 "mesh.sidecar.outbound_port",
2536 "outbound_port must be > 0",
2537 ));
2538 }
2539 if sidecar.inbound_port == sidecar.outbound_port && sidecar.inbound_port != 0 {
2540 issues.push(issue(
2541 "gateway",
2542 "(root)",
2543 "mesh.sidecar.inbound_port",
2544 format!(
2545 "inbound_port ({}) and outbound_port ({}) must be different \
2546 (the sidecar binds separate listeners for inbound and outbound \
2547 traffic)",
2548 sidecar.inbound_port, sidecar.outbound_port
2549 ),
2550 ));
2551 }
2552 if sidecar.redirect_mode != "iptables" && sidecar.redirect_mode != "tproxy" {
2553 issues.push(issue(
2554 "gateway",
2555 "(root)",
2556 "mesh.sidecar.redirect_mode",
2557 format!(
2558 "redirect_mode '{}' is not a supported redirect mode: must be \
2559 'iptables' or 'tproxy'",
2560 sidecar.redirect_mode
2561 ),
2562 ));
2563 }
2564 }
2565
2566 if let Some(spiffe) = &mesh.spiffe {
2568 if spiffe.trust_domain.trim().is_empty() {
2569 issues.push(issue(
2570 "gateway",
2571 "(root)",
2572 "mesh.spiffe.trust_domain",
2573 "trust_domain must be non-empty (it forms the SPIFFE ID prefix \
2574 spiffe://<trust-domain>/...)",
2575 ));
2576 }
2577 if spiffe.workload_api_socket.trim().is_empty() {
2578 issues.push(issue(
2579 "gateway",
2580 "(root)",
2581 "mesh.spiffe.workload_api_socket",
2582 "workload_api_socket must be non-empty (the SPIRE Workload API \
2583 Unix socket path)",
2584 ));
2585 }
2586 if spiffe.svid_refresh_interval_secs == 0 {
2587 issues.push(issue(
2588 "gateway",
2589 "(root)",
2590 "mesh.spiffe.svid_refresh_interval_secs",
2591 "svid_refresh_interval_secs must be > 0 (omit for the default 300s)",
2592 ));
2593 }
2594 }
2595}
2596
2597fn validate_oidc_providers(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2605 {
2608 let mut seen = std::collections::BTreeSet::new();
2609 for p in &gateway.oidc_providers {
2610 if !seen.insert(p.name.as_str()) {
2611 issues.push(issue("oidc_provider", &p.name, "name", "duplicate name"));
2612 }
2613 }
2614 }
2615 for p in &gateway.oidc_providers {
2616 match p.issuer.parse::<hyper::Uri>() {
2617 Ok(uri) => {
2618 if !matches!(uri.scheme_str(), Some("http") | Some("https")) || uri.host().is_none()
2619 {
2620 issues.push(issue(
2621 "oidc_provider",
2622 &p.name,
2623 "issuer",
2624 format!("'{}' must be an absolute http(s) URL", p.issuer),
2625 ));
2626 }
2627 }
2628 Err(_) => issues.push(issue(
2629 "oidc_provider",
2630 &p.name,
2631 "issuer",
2632 format!("'{}' is not a valid URL", p.issuer),
2633 )),
2634 }
2635 let ttl = p.introspection_cache_ttl_s;
2636 if !(crate::config::limits::MIN_OIDC_INTROSPECTION_CACHE_TTL_S
2637 ..=crate::config::limits::MAX_OIDC_INTROSPECTION_CACHE_TTL_S)
2638 .contains(&ttl)
2639 {
2640 issues.push(issue(
2641 "oidc_provider",
2642 &p.name,
2643 "introspection_cache_ttl_s",
2644 format!(
2645 "introspection_cache_ttl_s {} is out of range: must be {}..={}",
2646 ttl,
2647 crate::config::limits::MIN_OIDC_INTROSPECTION_CACHE_TTL_S,
2648 crate::config::limits::MAX_OIDC_INTROSPECTION_CACHE_TTL_S,
2649 ),
2650 ));
2651 }
2652 if let Some(consumer) = &p.consumer {
2653 if !gateway.consumers.iter().any(|c| &c.name == consumer) {
2654 issues.push(issue(
2655 "oidc_provider",
2656 &p.name,
2657 "consumer",
2658 format!("references unknown consumer '{consumer}'"),
2659 ));
2660 }
2661 }
2662 if let Some(ca) = &p.trusted_ca_file {
2666 if let Ok(uri) = p.issuer.parse::<hyper::Uri>() {
2667 if uri
2668 .scheme_str()
2669 .is_some_and(|s| s.eq_ignore_ascii_case("https"))
2670 {
2671 check_trusted_ca_file("oidc_provider", &p.name, "trusted_ca_file", ca, issues);
2672 } else {
2673 issues.push(issue(
2674 "oidc_provider",
2675 &p.name,
2676 "trusted_ca_file",
2677 "trusted_ca_file only applies to an https issuer (no TLS is \
2678 negotiated toward an http endpoint)",
2679 ));
2680 }
2681 }
2682 }
2683 if let Some(ep) = &p.introspection_endpoint {
2685 validate_delivery_url(
2686 &format!("oidc_provider[{}].introspection_endpoint", p.name),
2687 ep,
2688 issues,
2689 );
2690 }
2691 if let Some(ep) = &p.revocation_endpoint {
2692 validate_delivery_url(
2693 &format!("oidc_provider[{}].revocation_endpoint", p.name),
2694 ep,
2695 issues,
2696 );
2697 }
2698 }
2699}
2700
2701fn validate_webhooks(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2711 let mut seen_urls = std::collections::BTreeSet::new();
2712 for (i, hook) in gateway.webhooks.iter().enumerate() {
2713 let field = format!("webhooks[{i}]");
2714 validate_delivery_url(&format!("{field}.url"), &hook.url, issues);
2715 if !seen_urls.insert(hook.url.trim().to_string()) {
2716 issues.push(issue(
2717 "gateway",
2718 "(root)",
2719 &format!("{field}.url"),
2720 format!(
2721 "duplicate webhook url '{}' (an event would be delivered \
2722 twice; merge their events lists instead)",
2723 hook.url
2724 ),
2725 ));
2726 }
2727 if hook.events.is_empty() {
2728 issues.push(issue(
2729 "gateway",
2730 "(root)",
2731 &format!("{field}.events"),
2732 "events is empty: a webhook subscribing to nothing never fires \
2733 and is always a mistake; omit the entry",
2734 ));
2735 }
2736 for (j, kind) in hook.events.iter().enumerate() {
2737 if crate::events::EventKind::from_config(kind).is_none() {
2738 issues.push(issue(
2739 "gateway",
2740 "(root)",
2741 &format!("{field}.events[{j}]"),
2742 format!(
2743 "unknown event kind '{kind}' (emitted kinds: {})",
2744 crate::events::EventKind::ALL
2745 .iter()
2746 .map(|k| k.as_str())
2747 .collect::<Vec<_>>()
2748 .join(", ")
2749 ),
2750 ));
2751 }
2752 }
2753 validate_delivery_headers(&field, &hook.headers, issues);
2754 if hook.timeout_ms == 0 || hook.timeout_ms > crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS {
2755 issues.push(issue(
2756 "gateway",
2757 "(root)",
2758 &format!("{field}.timeout_ms"),
2759 format!(
2760 "timeout_ms must be in 1..={} (one total budget per delivery, \
2761 shared by every retry attempt)",
2762 crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS
2763 ),
2764 ));
2765 }
2766 if hook.max_attempts == 0 || hook.max_attempts > crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2767 {
2768 issues.push(issue(
2769 "gateway",
2770 "(root)",
2771 &format!("{field}.max_attempts"),
2772 format!(
2773 "max_attempts must be in 1..={} (total attempts per delivery)",
2774 crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2775 ),
2776 ));
2777 }
2778 if hook.backoff_base_ms == 0 {
2779 issues.push(issue(
2780 "gateway",
2781 "(root)",
2782 &format!("{field}.backoff_base_ms"),
2783 "backoff_base_ms must be > 0",
2784 ));
2785 }
2786 if hook.backoff_cap_ms < hook.backoff_base_ms {
2787 issues.push(issue(
2788 "gateway",
2789 "(root)",
2790 &format!("{field}.backoff_cap_ms"),
2791 "backoff_cap_ms must be >= backoff_base_ms",
2792 ));
2793 }
2794 }
2795}
2796
2797fn validate_delivery_url(field: &str, url: &str, issues: &mut Vec<ValidationIssue>) {
2801 match url.parse::<hyper::Uri>() {
2802 Ok(uri) => {
2803 if !matches!(uri.scheme_str(), Some("http") | Some("https")) || uri.host().is_none() {
2804 issues.push(issue(
2805 "gateway",
2806 "(root)",
2807 field,
2808 format!(
2809 "'{url}' must be an absolute http(s) URL with a host \
2810 (e.g. https://hooks.example.com/alerts)"
2811 ),
2812 ));
2813 }
2814 }
2815 Err(_) => issues.push(issue(
2816 "gateway",
2817 "(root)",
2818 field,
2819 format!("'{url}' is not a valid URL"),
2820 )),
2821 }
2822}
2823
2824fn validate_delivery_headers(
2831 field: &str,
2832 headers: &std::collections::BTreeMap<String, String>,
2833 issues: &mut Vec<ValidationIssue>,
2834) {
2835 for (name, value) in headers {
2836 if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2837 issues.push(issue(
2838 "gateway",
2839 "(root)",
2840 &format!("{field}.headers"),
2841 format!("header name '{name}' is not a valid HTTP header name"),
2842 ));
2843 continue;
2844 }
2845 let problem = match crate::config::credentials::resolve_configured_secret(value) {
2846 Ok(resolved) => {
2847 if hyper::header::HeaderValue::from_str(&resolved).is_err() {
2848 Some(
2849 "the header value (after secret-reference resolution) \
2850 contains characters that cannot appear in an HTTP \
2851 header value"
2852 .to_string(),
2853 )
2854 } else {
2855 None
2856 }
2857 }
2858 Err(message) => Some(message),
2859 };
2860 if let Some(message) = problem {
2861 issues.push(issue(
2862 "gateway",
2863 "(root)",
2864 &format!("{field}.headers"),
2865 format!("header '{name}': {message}"),
2866 ));
2867 }
2868 }
2869}
2870
2871fn validate_analytics_stream(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2878 let Some(cfg) = gateway.analytics_stream.as_ref() else {
2879 return;
2880 };
2881 match &cfg.sink {
2882 crate::config::AnalyticsStreamSink::Webhook(wh) => {
2883 validate_delivery_url("analytics_stream.sink.webhook.url", &wh.url, issues);
2884 validate_delivery_headers("analytics_stream.sink.webhook", &wh.headers, issues);
2885 if wh.timeout_ms == 0 || wh.timeout_ms > crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS {
2886 issues.push(issue(
2887 "gateway",
2888 "(root)",
2889 "analytics_stream.sink.webhook.timeout_ms",
2890 format!(
2891 "timeout_ms must be in 1..={} (one total budget per batch \
2892 delivery, shared by every retry attempt — the shared \
2893 webhook engine's bound)",
2894 crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS
2895 ),
2896 ));
2897 }
2898 if wh.max_attempts == 0 || wh.max_attempts > crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2899 {
2900 issues.push(issue(
2901 "gateway",
2902 "(root)",
2903 "analytics_stream.sink.webhook.max_attempts",
2904 format!(
2905 "max_attempts must be in 1..={} (total attempts per batch \
2906 delivery)",
2907 crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2908 ),
2909 ));
2910 }
2911 if wh.backoff_base_ms == 0 {
2912 issues.push(issue(
2913 "gateway",
2914 "(root)",
2915 "analytics_stream.sink.webhook.backoff_base_ms",
2916 "backoff_base_ms must be > 0",
2917 ));
2918 }
2919 if wh.backoff_cap_ms < wh.backoff_base_ms {
2920 issues.push(issue(
2921 "gateway",
2922 "(root)",
2923 "analytics_stream.sink.webhook.backoff_cap_ms",
2924 "backoff_cap_ms must be >= backoff_base_ms",
2925 ));
2926 }
2927 }
2928 crate::config::AnalyticsStreamSink::Kafka(k) => {
2929 validate_delivery_url(
2930 "analytics_stream.sink.kafka.rest_proxy_url",
2931 &k.rest_proxy_url,
2932 issues,
2933 );
2934 validate_delivery_headers("analytics_stream.sink.kafka", &k.headers, issues);
2935 if k.timeout_ms == 0 || k.timeout_ms > crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS {
2936 issues.push(issue(
2937 "gateway",
2938 "(root)",
2939 "analytics_stream.sink.kafka.timeout_ms",
2940 format!(
2941 "timeout_ms must be in 1..={} (one total budget per batch \
2942 delivery, shared by every retry attempt — the shared \
2943 webhook engine's bound)",
2944 crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS
2945 ),
2946 ));
2947 }
2948 if k.max_attempts == 0 || k.max_attempts > crate::config::limits::MAX_WEBHOOK_ATTEMPTS {
2949 issues.push(issue(
2950 "gateway",
2951 "(root)",
2952 "analytics_stream.sink.kafka.max_attempts",
2953 format!(
2954 "max_attempts must be in 1..={} (total attempts per batch \
2955 delivery)",
2956 crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2957 ),
2958 ));
2959 }
2960 if k.backoff_base_ms == 0 {
2961 issues.push(issue(
2962 "gateway",
2963 "(root)",
2964 "analytics_stream.sink.kafka.backoff_base_ms",
2965 "backoff_base_ms must be > 0",
2966 ));
2967 }
2968 if k.backoff_cap_ms < k.backoff_base_ms {
2969 issues.push(issue(
2970 "gateway",
2971 "(root)",
2972 "analytics_stream.sink.kafka.backoff_cap_ms",
2973 "backoff_cap_ms must be >= backoff_base_ms",
2974 ));
2975 }
2976 }
2977 }
2978 if let Some(buffer) = cfg.buffer {
2979 if !(crate::config::limits::MIN_STREAM_BUFFER..=crate::config::limits::MAX_STREAM_BUFFER)
2980 .contains(&buffer)
2981 {
2982 issues.push(issue(
2983 "gateway",
2984 "(root)",
2985 "analytics_stream.buffer",
2986 format!(
2987 "buffer must be in {}..={} (the queue's whole memory story; \
2988 a full queue drops and counts, it never blocks)",
2989 crate::config::limits::MIN_STREAM_BUFFER,
2990 crate::config::limits::MAX_STREAM_BUFFER,
2991 ),
2992 ));
2993 }
2994 }
2995 if let Some(flush) = cfg.flush_ms {
2996 if !(crate::config::limits::MIN_STREAM_FLUSH_MS
2997 ..=crate::config::limits::MAX_STREAM_FLUSH_MS)
2998 .contains(&flush)
2999 {
3000 issues.push(issue(
3001 "gateway",
3002 "(root)",
3003 "analytics_stream.flush_ms",
3004 format!(
3005 "flush_ms must be in {}..={} (maximum batch latency)",
3006 crate::config::limits::MIN_STREAM_FLUSH_MS,
3007 crate::config::limits::MAX_STREAM_FLUSH_MS,
3008 ),
3009 ));
3010 }
3011 }
3012 if let Some(batch) = cfg.batch_max {
3013 if batch == 0 || batch > crate::config::limits::MAX_STREAM_BATCH_RECORDS {
3014 issues.push(issue(
3015 "gateway",
3016 "(root)",
3017 "analytics_stream.batch_max",
3018 format!(
3019 "batch_max must be in 1..={} (records per flushed batch — \
3020 also the largest single delivery)",
3021 crate::config::limits::MAX_STREAM_BATCH_RECORDS,
3022 ),
3023 ));
3024 }
3025 }
3026}
3027
3028fn is_cookie_name(name: &str) -> bool {
3034 !name.is_empty()
3035 && name.bytes().all(|b| {
3036 b.is_ascii_alphanumeric()
3037 || matches!(
3038 b,
3039 b'!' | b'#'
3040 | b'$'
3041 | b'%'
3042 | b'&'
3043 | b'\''
3044 | b'*'
3045 | b'+'
3046 | b'-'
3047 | b'.'
3048 | b'^'
3049 | b'_'
3050 | b'`'
3051 | b'|'
3052 | b'~'
3053 )
3054 })
3055}
3056
3057fn validate_canary_rule(
3061 service: &str,
3062 field: &str,
3063 rule: &crate::config::CanaryRule,
3064 issues: &mut Vec<ValidationIssue>,
3065) {
3066 match rule.metric {
3067 crate::config::CanaryMetric::ErrorRate => {
3068 if rule.threshold < 0.0 || rule.threshold > 1.0 {
3069 issues.push(issue(
3070 "service",
3071 service,
3072 &format!("{field}.threshold"),
3073 "error_rate threshold must be in [0.0, 1.0] (a fraction)",
3074 ));
3075 }
3076 }
3077 crate::config::CanaryMetric::LatencyP99
3078 | crate::config::CanaryMetric::LatencyP95
3079 | crate::config::CanaryMetric::LatencyP50 => {
3080 if rule.threshold <= 0.0 {
3081 issues.push(issue(
3082 "service",
3083 service,
3084 &format!("{field}.threshold"),
3085 "latency threshold must be > 0 (milliseconds)",
3086 ));
3087 }
3088 }
3089 }
3090}
3091
3092fn validate_ai_canary_rule(
3096 alias: &str,
3097 field: &str,
3098 rule: &crate::config::CanaryRule,
3099 issues: &mut Vec<ValidationIssue>,
3100) {
3101 match rule.metric {
3102 crate::config::CanaryMetric::ErrorRate => {
3103 if rule.threshold < 0.0 || rule.threshold > 1.0 {
3104 issues.push(issue(
3105 "gateway",
3106 "(root)",
3107 &format!("ai.models[{alias}].{field}.threshold"),
3108 "error_rate threshold must be in [0.0, 1.0] (a fraction)",
3109 ));
3110 }
3111 }
3112 crate::config::CanaryMetric::LatencyP99
3113 | crate::config::CanaryMetric::LatencyP95
3114 | crate::config::CanaryMetric::LatencyP50 => {
3115 if rule.threshold <= 0.0 {
3116 issues.push(issue(
3117 "gateway",
3118 "(root)",
3119 &format!("ai.models[{alias}].{field}.threshold"),
3120 "latency threshold must be > 0 (milliseconds)",
3121 ));
3122 }
3123 }
3124 }
3125}
3126
3127fn validate_ai(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
3135 let Some(ai) = &gateway.ai else { return };
3136 if ai.providers.is_empty() {
3137 issues.push(issue(
3138 "gateway",
3139 "(root)",
3140 "ai.providers",
3141 "the ai block is present but declares no providers: it could never \
3142 serve a model (remove the block, or declare at least one provider)",
3143 ));
3144 }
3145 if ai.models.is_empty() {
3146 issues.push(issue(
3147 "gateway",
3148 "(root)",
3149 "ai.models",
3150 "the ai block is present but declares no models: no client model \
3151 could ever resolve (remove the block, or map at least one alias)",
3152 ));
3153 }
3154 let mut names = std::collections::BTreeSet::new();
3155 for p in &ai.providers {
3156 if p.name.trim().is_empty() {
3157 issues.push(issue(
3158 "gateway",
3159 "(root)",
3160 "ai.providers[].name",
3161 "provider name must be non-empty",
3162 ));
3163 continue;
3164 }
3165 if !names.insert(p.name.as_str()) {
3166 issues.push(issue(
3167 "gateway",
3168 &p.name,
3169 "ai.providers[].name",
3170 "duplicate ai provider name",
3171 ));
3172 }
3173 if !gateway.upstreams.iter().any(|u| u.name == p.upstream) {
3174 issues.push(issue(
3175 "gateway",
3176 &p.name,
3177 "ai.providers[].upstream",
3178 format!(
3179 "references unknown upstream '{}' (the provider's transport \
3180 is a standard upstreams entry)",
3181 p.upstream
3182 ),
3183 ));
3184 }
3185 if let Some(path) = &p.path {
3186 if path.trim().is_empty() {
3187 issues.push(issue(
3188 "gateway",
3189 &p.name,
3190 "ai.providers[].path",
3191 "provider path override must be non-empty (remove the \
3192 field to use the adapter's default path)",
3193 ));
3194 } else if !path.starts_with('/') {
3195 issues.push(issue(
3196 "gateway",
3197 &p.name,
3198 "ai.providers[].path",
3199 format!(
3200 "provider path override '{path}' must start with '/' \
3201 (it is a request path, not a full URL)"
3202 ),
3203 ));
3204 }
3205 }
3206 if let Some(auth) = &p.auth {
3207 if hyper::header::HeaderName::from_bytes(auth.header.as_bytes()).is_err() {
3208 issues.push(issue(
3209 "gateway",
3210 &p.name,
3211 "ai.providers[].auth.header",
3212 format!("'{}' is not a valid HTTP header name", auth.header),
3213 ));
3214 }
3215 let problem = match crate::config::credentials::resolve_configured_secret(&auth.value) {
3219 Ok(resolved) => {
3220 if hyper::header::HeaderValue::from_str(&resolved).is_err() {
3221 Some(
3222 "the auth value (after secret-reference resolution) \
3223 contains characters that cannot appear in an HTTP \
3224 header value"
3225 .to_string(),
3226 )
3227 } else {
3228 None
3229 }
3230 }
3231 Err(message) => Some(message),
3232 };
3233 if let Some(message) = problem {
3234 issues.push(issue(
3235 "gateway",
3236 &p.name,
3237 "ai.providers[].auth.value",
3238 format!("provider auth: {message}"),
3239 ));
3240 }
3241 }
3242 if let Some(pool) = &p.credential_pool {
3244 #[cfg(not(feature = "ent"))]
3246 {
3247 issues.push(issue(
3248 "gateway",
3249 &p.name,
3250 "ai.providers[].credential_pool",
3251 "credential pools require the enterprise edition (build with \
3252 --features ent)",
3253 ));
3254 }
3255 if p.auth.is_some() {
3257 issues.push(issue(
3258 "gateway",
3259 &p.name,
3260 "ai.providers[].credential_pool",
3261 "credential_pool and auth are mutually exclusive; use one or \
3262 the other",
3263 ));
3264 }
3265 if pool.credentials.len() < 2 {
3267 issues.push(issue(
3268 "gateway",
3269 &p.name,
3270 "ai.providers[].credential_pool.credentials",
3271 "credential pool requires at least 2 entries (a single-entry \
3272 pool is equivalent to auth)",
3273 ));
3274 }
3275 for (i, cred) in pool.credentials.iter().enumerate() {
3277 if hyper::header::HeaderName::from_bytes(cred.header.as_bytes()).is_err() {
3278 issues.push(issue(
3279 "gateway",
3280 &p.name,
3281 &format!("ai.providers[].credential_pool.credentials[{i}].header"),
3282 format!("'{}' is not a valid HTTP header name", cred.header),
3283 ));
3284 }
3285 let problem =
3286 match crate::config::credentials::resolve_configured_secret(&cred.value) {
3287 Ok(resolved) => {
3288 if hyper::header::HeaderValue::from_str(&resolved).is_err() {
3289 Some(
3290 "the credential value (after secret-reference \
3291 resolution) contains characters that cannot appear \
3292 in an HTTP header value"
3293 .to_string(),
3294 )
3295 } else {
3296 None
3297 }
3298 }
3299 Err(message) => Some(message),
3300 };
3301 if let Some(message) = problem {
3302 issues.push(issue(
3303 "gateway",
3304 &p.name,
3305 &format!("ai.providers[].credential_pool.credentials[{i}].value"),
3306 format!("credential pool entry {i}: {message}"),
3307 ));
3308 }
3309 }
3310 if pool.quarantine_secs == 0 {
3312 issues.push(issue(
3313 "gateway",
3314 &p.name,
3315 "ai.providers[].credential_pool.quarantine_secs",
3316 "quarantine_secs must be greater than 0",
3317 ));
3318 }
3319 if pool.quarantine_secs > 600 {
3320 issues.push(issue(
3321 "gateway",
3322 &p.name,
3323 "ai.providers[].credential_pool.quarantine_secs",
3324 "quarantine_secs must not exceed 600 (10 minutes)",
3325 ));
3326 }
3327 }
3328 }
3329 for (alias, m) in &ai.models {
3330 if alias.trim().is_empty() {
3331 issues.push(issue(
3332 "gateway",
3333 "(root)",
3334 "ai.models",
3335 "model alias must be non-empty",
3336 ));
3337 }
3338 if !names.contains(m.provider.as_str()) {
3339 issues.push(issue(
3340 "gateway",
3341 "(root)",
3342 &format!("ai.models[{alias}].provider"),
3343 format!("references unknown ai provider '{}'", m.provider),
3344 ));
3345 }
3346 if m.provider_model.trim().is_empty() {
3347 issues.push(issue(
3348 "gateway",
3349 "(root)",
3350 &format!("ai.models[{alias}].provider_model"),
3351 "provider_model must be non-empty",
3352 ));
3353 }
3354 if !m.failover.is_empty() && !m.canary.is_empty() {
3361 issues.push(issue(
3362 "gateway",
3363 "(root)",
3364 &format!("ai.models[{alias}]"),
3365 "an alias cannot declare both failover and canary: \
3366 failover is an availability chain for one logical model, \
3367 a canary split deliberately serves several versions — \
3368 combine them on separate aliases",
3369 ));
3370 }
3371 if m.routing_policy.is_some() && (!m.failover.is_empty() || !m.canary.is_empty()) {
3372 issues.push(issue(
3373 "gateway",
3374 "(root)",
3375 &format!("ai.models[{alias}]"),
3376 "an alias cannot declare a routing_policy together with \
3377 failover or canary: a policy alias composes over other \
3378 aliases' routing plans and has no provider/model pair of \
3379 its own — combine them on separate aliases",
3380 ));
3381 }
3382 if m.ab_test.is_some()
3386 && (!m.failover.is_empty() || !m.canary.is_empty() || m.routing_policy.is_some())
3387 {
3388 issues.push(issue(
3389 "gateway",
3390 "(root)",
3391 &format!("ai.models[{alias}]"),
3392 "an alias cannot declare an ab_test together with \
3393 failover, canary, or routing_policy: an experiment alias \
3394 composes over other aliases' routing plans and has no \
3395 provider/model pair of its own — combine them on separate \
3396 aliases",
3397 ));
3398 }
3399 if let Some(test_name) = &m.ab_test {
3402 let exists = ai
3403 .experiments
3404 .as_ref()
3405 .map(|e| e.ab_tests.contains_key(test_name))
3406 .unwrap_or(false);
3407 if !exists {
3408 issues.push(issue(
3409 "gateway",
3410 "(root)",
3411 &format!("ai.models[{alias}].ab_test"),
3412 format!(
3413 "references unknown A/B test '{}' (not in \
3414 ai.experiments.ab_tests — a typo, or the test was \
3415 removed, or ai.experiments is not configured)",
3416 test_name
3417 ),
3418 ));
3419 }
3420 }
3421 if let Some(policy_name) = &m.routing_policy {
3423 if !ai.routing_policies.contains_key(policy_name) {
3424 issues.push(issue(
3425 "gateway",
3426 "(root)",
3427 &format!("ai.models[{alias}].routing_policy"),
3428 format!(
3429 "references unknown routing policy '{}' (not in \
3430 ai.routing_policies — a typo, or the policy was \
3431 removed)",
3432 policy_name
3433 ),
3434 ));
3435 }
3436 }
3437 if m.failover.len() > 4 {
3445 issues.push(issue(
3446 "gateway",
3447 "(root)",
3448 &format!("ai.models[{alias}].failover"),
3449 format!(
3450 "failover accepts at most 4 alternates ({} given): the \
3451 chain is walked synchronously, so every extra candidate \
3452 adds a full provider round-trip to the request's \
3453 worst-case latency",
3454 m.failover.len()
3455 ),
3456 ));
3457 }
3458 let mut chain = vec![(m.provider.as_str(), m.provider_model.as_str())];
3459 for (i, f) in m.failover.iter().enumerate() {
3460 if !names.contains(f.provider.as_str()) {
3461 issues.push(issue(
3462 "gateway",
3463 "(root)",
3464 &format!("ai.models[{alias}].failover[{i}].provider"),
3465 format!("references unknown ai provider '{}'", f.provider),
3466 ));
3467 }
3468 if f.provider_model.trim().is_empty() {
3469 issues.push(issue(
3470 "gateway",
3471 "(root)",
3472 &format!("ai.models[{alias}].failover[{i}].provider_model"),
3473 "provider_model must be non-empty",
3474 ));
3475 }
3476 if chain.contains(&(f.provider.as_str(), f.provider_model.as_str())) {
3477 issues.push(issue(
3478 "gateway",
3479 "(root)",
3480 &format!("ai.models[{alias}].failover[{i}]"),
3481 format!(
3482 "duplicates the provider/model pair ('{}' / '{}') already \
3483 earlier in the chain — the upstream breaker owns \
3484 same-target retries",
3485 f.provider, f.provider_model
3486 ),
3487 ));
3488 } else {
3489 chain.push((f.provider.as_str(), f.provider_model.as_str()));
3490 }
3491 }
3492 if !m.canary.is_empty() && !(2..=8).contains(&m.canary.len()) {
3499 issues.push(issue(
3500 "gateway",
3501 "(root)",
3502 &format!("ai.models[{alias}].canary"),
3503 format!(
3504 "a canary split needs 2..=8 versions ({} given): one \
3505 version is the whole model (no split), and every version \
3506 is a metrics label — the bound keeps the pick cheap and \
3507 the series count sane",
3508 m.canary.len()
3509 ),
3510 ));
3511 }
3512 let mut versions = std::collections::BTreeSet::new();
3513 for (i, v) in m.canary.iter().enumerate() {
3514 if v.version.trim().is_empty() {
3515 issues.push(issue(
3516 "gateway",
3517 "(root)",
3518 &format!("ai.models[{alias}].canary[{i}].version"),
3519 "version name must be non-empty",
3520 ));
3521 }
3522 if !versions.insert(v.version.as_str()) {
3523 issues.push(issue(
3524 "gateway",
3525 "(root)",
3526 &format!("ai.models[{alias}].canary[{i}].version"),
3527 format!("duplicate version name '{}'", v.version),
3528 ));
3529 }
3530 if v.weight == 0 {
3531 issues.push(issue(
3532 "gateway",
3533 "(root)",
3534 &format!("ai.models[{alias}].canary[{i}].weight"),
3535 "weight must be >= 1 (park a version by removing the \
3536 entry, not by zeroing it)",
3537 ));
3538 }
3539 if !names.contains(v.provider.as_str()) {
3540 issues.push(issue(
3541 "gateway",
3542 "(root)",
3543 &format!("ai.models[{alias}].canary[{i}].provider"),
3544 format!("references unknown ai provider '{}'", v.provider),
3545 ));
3546 }
3547 if v.provider_model.trim().is_empty() {
3548 issues.push(issue(
3549 "gateway",
3550 "(root)",
3551 &format!("ai.models[{alias}].canary[{i}].provider_model"),
3552 "provider_model must be non-empty",
3553 ));
3554 }
3555 }
3556 if let Some(analysis) = &m.canary_analysis {
3561 if m.canary.len() != 2 {
3562 issues.push(issue(
3563 "gateway",
3564 "(root)",
3565 &format!("ai.models[{alias}].canary_analysis"),
3566 "canary_analysis requires exactly 2 canary versions (baseline + canary): \
3567 the controller compares two sides",
3568 ));
3569 }
3570 if analysis.window_seconds == 0 {
3571 issues.push(issue(
3572 "gateway",
3573 "(root)",
3574 &format!("ai.models[{alias}].canary_analysis.window_seconds"),
3575 "window_seconds must be >= 1",
3576 ));
3577 }
3578 if analysis.step == 0 {
3579 issues.push(issue(
3580 "gateway",
3581 "(root)",
3582 &format!("ai.models[{alias}].canary_analysis.step"),
3583 "step must be >= 1 (a 0-step never adjusts)",
3584 ));
3585 }
3586 if analysis.min_requests == 0 {
3587 issues.push(issue(
3588 "gateway",
3589 "(root)",
3590 &format!("ai.models[{alias}].canary_analysis.min_requests"),
3591 "min_requests must be >= 1",
3592 ));
3593 }
3594 if analysis.cooldown_seconds == 0 {
3595 issues.push(issue(
3596 "gateway",
3597 "(root)",
3598 &format!("ai.models[{alias}].canary_analysis.cooldown_seconds"),
3599 "cooldown_seconds must be >= 1",
3600 ));
3601 }
3602 validate_ai_canary_rule(alias, "canary_analysis.promote", &analysis.promote, issues);
3603 validate_ai_canary_rule(
3604 alias,
3605 "canary_analysis.rollback",
3606 &analysis.rollback,
3607 issues,
3608 );
3609 }
3610 }
3611 if let Some(gov) = &ai.governance {
3617 let known: std::collections::BTreeSet<&str> =
3618 ai.models.keys().map(|s| s.as_str()).collect();
3619 for (policy, allowlist) in &gov.team_allowlists {
3620 if !gateway.policies.iter().any(|p| &p.name == policy) {
3623 issues.push(issue(
3624 "gateway",
3625 policy,
3626 "ai.governance.team_allowlists",
3627 format!(
3628 "references unknown policy '{}' (the allowlist would \
3629 govern nobody — a typo, or the policy was removed)",
3630 policy
3631 ),
3632 ));
3633 }
3634 for (i, alias) in allowlist.iter().enumerate() {
3635 if !known.contains(alias.as_str()) {
3636 issues.push(issue(
3637 "gateway",
3638 policy,
3639 &format!("ai.governance.team_allowlists[{policy}][{i}]"),
3640 format!(
3641 "references unknown model alias '{}' (not in \
3642 ai.models — a typo, or the alias was renamed)",
3643 alias
3644 ),
3645 ));
3646 }
3647 }
3648 }
3649 }
3650 if let Some(logging) = &ai.logging {
3655 if !(0.0..=1.0).contains(&logging.sample_rate) {
3656 issues.push(issue(
3657 "gateway",
3658 "(root)",
3659 "ai.logging.sample_rate",
3660 format!(
3661 "sample_rate must be between 0.0 and 1.0 (got {})",
3662 logging.sample_rate
3663 ),
3664 ));
3665 }
3666 if logging.enabled && logging.retention_secs == 0 {
3667 issues.push(issue(
3668 "gateway",
3669 "(root)",
3670 "ai.logging.retention_secs",
3671 "retention_secs must be > 0 when logging is enabled \
3672 (0 would retain records forever — omit the field for \
3673 the 7-day default, or set a positive value)",
3674 ));
3675 }
3676 for (i, pat) in logging.redaction.patterns.iter().enumerate() {
3677 if regex::Regex::new(pat).is_err() {
3678 issues.push(issue(
3679 "gateway",
3680 "(root)",
3681 &format!("ai.logging.redaction.patterns[{i}]"),
3682 format!(
3683 "'{}' is not a valid regex pattern (the custom \
3684 redaction pattern must compile)",
3685 pat
3686 ),
3687 ));
3688 }
3689 }
3690 }
3691 if let Some(guardrails) = &ai.guardrails {
3696 let mut rule_names = std::collections::BTreeSet::new();
3697 for (i, rule) in guardrails.rules.iter().enumerate() {
3698 if rule.name.trim().is_empty() {
3699 issues.push(issue(
3700 "gateway",
3701 "(root)",
3702 &format!("ai.guardrails.rules[{i}].name"),
3703 "guardrail rule name must be non-empty",
3704 ));
3705 } else if !rule_names.insert(rule.name.as_str()) {
3706 issues.push(issue(
3707 "gateway",
3708 "(root)",
3709 &format!("ai.guardrails.rules[{i}].name"),
3710 format!("duplicate guardrail rule name '{}'", rule.name),
3711 ));
3712 }
3713 for (j, pat) in rule.patterns.iter().enumerate() {
3715 if regex::Regex::new(pat).is_err() {
3716 issues.push(issue(
3717 "gateway",
3718 "(root)",
3719 &format!("ai.guardrails.rules[{i}].patterns[{j}]"),
3720 format!(
3721 "'{}' is not a valid regex pattern (the guardrail \
3722 pattern must compile)",
3723 pat
3724 ),
3725 ));
3726 }
3727 }
3728 if rule.kind == crate::config::ai::AiGuardrailKind::Schema && rule.schema.is_none() {
3730 issues.push(issue(
3731 "gateway",
3732 &rule.name,
3733 &format!("ai.guardrails.rules[{i}].schema"),
3734 "a schema-kind guardrail rule must declare a JSON schema \
3735 (the `schema` field is required for kind: schema)",
3736 ));
3737 }
3738 if let Some(schema) = &rule.schema {
3742 if !schema.is_object() && !schema.is_boolean() {
3743 issues.push(issue(
3744 "gateway",
3745 &rule.name,
3746 &format!("ai.guardrails.rules[{i}].schema"),
3747 "a JSON schema must be an object or a boolean \
3748 (the root schema shape)",
3749 ));
3750 }
3751 }
3752 for (j, policy) in rule.policies.iter().enumerate() {
3754 if !gateway.policies.iter().any(|p| &p.name == policy) {
3755 issues.push(issue(
3756 "gateway",
3757 &rule.name,
3758 &format!("ai.guardrails.rules[{i}].policies[{j}]"),
3759 format!(
3760 "references unknown policy '{}' (the rule would \
3761 apply to nobody — a typo, or the policy was removed)",
3762 policy
3763 ),
3764 ));
3765 }
3766 }
3767 if rule.action == crate::config::ai::AiGuardrailAction::Redact
3771 && !matches!(
3772 rule.phase,
3773 crate::config::ai::AiGuardrailPhase::Prompt
3774 | crate::config::ai::AiGuardrailPhase::Both
3775 )
3776 {
3777 issues.push(issue(
3781 "gateway",
3782 &rule.name,
3783 &format!("ai.guardrails.rules[{i}].action"),
3784 "redact action is only effective at the prompt phase; \
3785 at the response phase it is treated as log (dry-run)",
3786 ));
3787 }
3788 }
3789 }
3790 if let Some(sem_cache) = &ai.semantic_cache {
3795 if sem_cache.enabled {
3796 if sem_cache.embedding_url.trim().is_empty() {
3797 issues.push(issue(
3798 "gateway",
3799 "(root)",
3800 "ai.semantic_cache.embedding_url",
3801 "the semantic cache embedding_url must be non-empty when \
3802 enabled (the OpenAI-compatible /v1/embeddings endpoint)",
3803 ));
3804 } else if !sem_cache.embedding_url.starts_with("http://")
3805 && !sem_cache.embedding_url.starts_with("https://")
3806 {
3807 issues.push(issue(
3808 "gateway",
3809 "(root)",
3810 "ai.semantic_cache.embedding_url",
3811 "the semantic cache embedding_url must be an http or https \
3812 URL",
3813 ));
3814 }
3815 if sem_cache.embedding_model.trim().is_empty() {
3816 issues.push(issue(
3817 "gateway",
3818 "(root)",
3819 "ai.semantic_cache.embedding_model",
3820 "the semantic cache embedding_model must be non-empty when \
3821 enabled (the model name passed to the embedding service)",
3822 ));
3823 }
3824 if sem_cache.embedding_dim == 0 {
3825 issues.push(issue(
3826 "gateway",
3827 "(root)",
3828 "ai.semantic_cache.embedding_dim",
3829 "the semantic cache embedding_dim must be > 0 (it must \
3830 match the embedding service's vector dimension)",
3831 ));
3832 }
3833 if !(0.0..=1.0).contains(&sem_cache.threshold) {
3834 issues.push(issue(
3835 "gateway",
3836 "(root)",
3837 "ai.semantic_cache.threshold",
3838 format!(
3839 "the semantic cache threshold must be in [0.0, 1.0] \
3840 (got {})",
3841 sem_cache.threshold
3842 ),
3843 ));
3844 }
3845 if sem_cache.ttl_secs == 0 {
3846 issues.push(issue(
3847 "gateway",
3848 "(root)",
3849 "ai.semantic_cache.ttl_secs",
3850 "the semantic cache ttl_secs must be > 0",
3851 ));
3852 }
3853 if sem_cache.max_entries == 0 {
3854 issues.push(issue(
3855 "gateway",
3856 "(root)",
3857 "ai.semantic_cache.max_entries",
3858 "the semantic cache max_entries must be > 0",
3859 ));
3860 }
3861 if sem_cache.embedding_timeout_ms == 0 {
3862 issues.push(issue(
3863 "gateway",
3864 "(root)",
3865 "ai.semantic_cache.embedding_timeout_ms",
3866 "the semantic cache embedding_timeout_ms must be > 0",
3867 ));
3868 }
3869 }
3870 }
3871 let policy_aliases: std::collections::BTreeSet<&str> = ai
3880 .models
3881 .iter()
3882 .filter(|(_, m)| m.routing_policy.is_some())
3883 .map(|(alias, _)| alias.as_str())
3884 .collect();
3885 for (policy_name, policy) in &ai.routing_policies {
3886 match policy {
3887 crate::config::ai::AiRoutingPolicy::FallbackChain(p) => {
3888 if !ai.models.contains_key(&p.cheap) {
3889 issues.push(issue(
3890 "gateway",
3891 policy_name,
3892 "ai.routing_policies[fallback_chain].cheap",
3893 format!(
3894 "references unknown model alias '{}' (not in \
3895 ai.models — a typo, or the alias was renamed)",
3896 p.cheap
3897 ),
3898 ));
3899 } else if policy_aliases.contains(p.cheap.as_str()) {
3900 issues.push(issue(
3901 "gateway",
3902 policy_name,
3903 "ai.routing_policies[fallback_chain].cheap",
3904 format!(
3905 "references alias '{}' which is itself a policy \
3906 alias (nested policies are not allowed — a policy \
3907 composes over plain chain/canary aliases only)",
3908 p.cheap
3909 ),
3910 ));
3911 }
3912 if !ai.models.contains_key(&p.escalate_to) {
3913 issues.push(issue(
3914 "gateway",
3915 policy_name,
3916 "ai.routing_policies[fallback_chain].escalate_to",
3917 format!(
3918 "references unknown model alias '{}' (not in \
3919 ai.models — a typo, or the alias was renamed)",
3920 p.escalate_to
3921 ),
3922 ));
3923 } else if policy_aliases.contains(p.escalate_to.as_str()) {
3924 issues.push(issue(
3925 "gateway",
3926 policy_name,
3927 "ai.routing_policies[fallback_chain].escalate_to",
3928 format!(
3929 "references alias '{}' which is itself a policy \
3930 alias (nested policies are not allowed — a policy \
3931 composes over plain chain/canary aliases only)",
3932 p.escalate_to
3933 ),
3934 ));
3935 }
3936 if p.classifier_url.trim().is_empty() {
3937 issues.push(issue(
3938 "gateway",
3939 policy_name,
3940 "ai.routing_policies[fallback_chain].classifier_url",
3941 "the classifier_url must be non-empty (the external \
3942 complexity-classifier service endpoint)",
3943 ));
3944 } else if !p.classifier_url.starts_with("http://")
3945 && !p.classifier_url.starts_with("https://")
3946 {
3947 issues.push(issue(
3948 "gateway",
3949 policy_name,
3950 "ai.routing_policies[fallback_chain].classifier_url",
3951 "the classifier_url must be an http or https URL",
3952 ));
3953 }
3954 if p.classifier_model.trim().is_empty() {
3955 issues.push(issue(
3956 "gateway",
3957 policy_name,
3958 "ai.routing_policies[fallback_chain].classifier_model",
3959 "the classifier_model must be non-empty (the model \
3960 name passed to the classifier service)",
3961 ));
3962 }
3963 if !(0.0..=1.0).contains(&p.threshold) {
3964 issues.push(issue(
3965 "gateway",
3966 policy_name,
3967 "ai.routing_policies[fallback_chain].threshold",
3968 format!("the threshold must be in [0.0, 1.0] (got {})", p.threshold),
3969 ));
3970 }
3971 if p.timeout_ms == 0 {
3972 issues.push(issue(
3973 "gateway",
3974 policy_name,
3975 "ai.routing_policies[fallback_chain].timeout_ms",
3976 "the timeout_ms must be > 0",
3977 ));
3978 }
3979 }
3980 crate::config::ai::AiRoutingPolicy::LatencyCost(p) => {
3981 if p.candidates.is_empty() {
3982 issues.push(issue(
3983 "gateway",
3984 policy_name,
3985 "ai.routing_policies[latency_cost].candidates",
3986 "a latency_cost policy must declare at least one \
3987 candidate (an empty list would route to nothing)",
3988 ));
3989 }
3990 for (i, c) in p.candidates.iter().enumerate() {
3991 if !ai.models.contains_key(&c.model) {
3992 issues.push(issue(
3993 "gateway",
3994 policy_name,
3995 &format!("ai.routing_policies[latency_cost].candidates[{i}].model"),
3996 format!(
3997 "references unknown model alias '{}' (not in \
3998 ai.models — a typo, or the alias was renamed)",
3999 c.model
4000 ),
4001 ));
4002 } else if policy_aliases.contains(c.model.as_str()) {
4003 issues.push(issue(
4004 "gateway",
4005 policy_name,
4006 &format!("ai.routing_policies[latency_cost].candidates[{i}].model"),
4007 format!(
4008 "references alias '{}' which is itself a policy \
4009 alias (nested policies are not allowed — a \
4010 policy composes over plain chain/canary aliases \
4011 only)",
4012 c.model
4013 ),
4014 ));
4015 }
4016 if !(1..=10).contains(&c.cost) {
4017 issues.push(issue(
4018 "gateway",
4019 policy_name,
4020 &format!("ai.routing_policies[latency_cost].candidates[{i}].cost"),
4021 format!("the cost score must be in 1..=10 (got {})", c.cost),
4022 ));
4023 }
4024 if !(1..=10).contains(&c.latency) {
4025 issues.push(issue(
4026 "gateway",
4027 policy_name,
4028 &format!("ai.routing_policies[latency_cost].candidates[{i}].latency"),
4029 format!("the latency score must be in 1..=10 (got {})", c.latency),
4030 ));
4031 }
4032 }
4033 }
4034 }
4035 }
4036
4037 let experiment_aliases: std::collections::BTreeSet<&str> = ai
4045 .models
4046 .iter()
4047 .filter(|(_, m)| m.ab_test.is_some())
4048 .map(|(alias, _)| alias.as_str())
4049 .collect();
4050 if let Some(experiments) = &ai.experiments {
4051 for (test_name, test) in &experiments.ab_tests {
4053 if test.variants.len() < 2 {
4054 issues.push(issue(
4055 "gateway",
4056 test_name,
4057 &format!("ai.experiments.ab_tests[{test_name}].variants"),
4058 "an A/B test must have at least 2 variants (got \
4059 fewer)",
4060 ));
4061 }
4062 for (i, variant) in test.variants.iter().enumerate() {
4063 if !ai.models.contains_key(&variant.model) {
4064 issues.push(issue(
4065 "gateway",
4066 test_name,
4067 &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].model"),
4068 format!(
4069 "references unknown model alias '{}' (not in \
4070 ai.models — a typo, or the alias was renamed)",
4071 variant.model
4072 ),
4073 ));
4074 } else if policy_aliases.contains(variant.model.as_str())
4075 || experiment_aliases.contains(variant.model.as_str())
4076 {
4077 issues.push(issue(
4078 "gateway",
4079 test_name,
4080 &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].model"),
4081 format!(
4082 "references alias '{}' which is itself a policy \
4083 or experiment alias (nested policies/experiments \
4084 are not allowed — an experiment composes over \
4085 plain chain/canary aliases only)",
4086 variant.model
4087 ),
4088 ));
4089 }
4090 if variant.weight == 0 {
4091 issues.push(issue(
4092 "gateway",
4093 test_name,
4094 &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].weight"),
4095 "a variant weight must be >= 1 (got 0)",
4096 ));
4097 }
4098 if let Some(prompt_ref) = &variant.prompt {
4100 if let Some((prompt_name, version_name)) = prompt_ref.split_once('/') {
4101 let prompt_exists = experiments
4102 .prompts
4103 .get(prompt_name)
4104 .is_some_and(|p| p.versions.contains_key(version_name));
4105 if !prompt_exists {
4106 issues.push(issue(
4107 "gateway",
4108 test_name,
4109 &format!(
4110 "ai.experiments.ab_tests[{test_name}].variants[{i}].prompt"
4111 ),
4112 format!(
4113 "references unknown prompt version '{}' \
4114 (not in ai.experiments.prompts — a typo, \
4115 or the prompt/version was removed)",
4116 prompt_ref
4117 ),
4118 ));
4119 }
4120 } else {
4121 issues.push(issue(
4122 "gateway",
4123 test_name,
4124 &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].prompt"),
4125 format!(
4126 "prompt reference '{}' must be in the form \
4127 'prompt_name/version_name'",
4128 prompt_ref
4129 ),
4130 ));
4131 }
4132 }
4133 }
4134 }
4135 for (prompt_name, prompt) in &experiments.prompts {
4137 if !prompt.versions.contains_key(&prompt.active) {
4138 issues.push(issue(
4139 "gateway",
4140 prompt_name,
4141 &format!("ai.experiments.prompts[{prompt_name}].active"),
4142 format!(
4143 "references unknown version '{}' (not in \
4144 ai.experiments.prompts[{prompt_name}].versions — \
4145 a typo, or the version was removed)",
4146 prompt.active
4147 ),
4148 ));
4149 }
4150 if prompt.versions.is_empty() {
4151 issues.push(issue(
4152 "gateway",
4153 prompt_name,
4154 &format!("ai.experiments.prompts[{prompt_name}].versions"),
4155 "a prompt must have at least one version",
4156 ));
4157 }
4158 }
4159 for (eval_name, eval) in &experiments.evals {
4164 if let Some(prompt_ref) = &eval.prompt {
4165 if let Some((prompt_name, version_name)) = prompt_ref.split_once('/') {
4166 let prompt_exists = experiments
4167 .prompts
4168 .get(prompt_name)
4169 .is_some_and(|p| p.versions.contains_key(version_name));
4170 if !prompt_exists {
4171 issues.push(issue(
4172 "gateway",
4173 eval_name,
4174 &format!("ai.experiments.evals[{eval_name}].prompt"),
4175 format!(
4176 "references unknown prompt version '{}' \
4177 (not in ai.experiments.prompts)",
4178 prompt_ref
4179 ),
4180 ));
4181 }
4182 } else {
4183 issues.push(issue(
4184 "gateway",
4185 eval_name,
4186 &format!("ai.experiments.evals[{eval_name}].prompt"),
4187 format!(
4188 "prompt reference '{}' must be in the form \
4189 'prompt_name/version_name'",
4190 prompt_ref
4191 ),
4192 ));
4193 }
4194 }
4195 if eval.golden_set.is_empty() {
4196 issues.push(issue(
4197 "gateway",
4198 eval_name,
4199 &format!("ai.experiments.evals[{eval_name}].golden_set"),
4200 "an eval must have at least one golden-set case",
4201 ));
4202 }
4203 for (i, case) in eval.golden_set.iter().enumerate() {
4204 if case.input.is_empty() {
4205 issues.push(issue(
4206 "gateway",
4207 eval_name,
4208 &format!("ai.experiments.evals[{eval_name}].golden_set[{i}].input"),
4209 "an eval case input must be non-empty",
4210 ));
4211 }
4212 if case.expected.is_empty() {
4213 issues.push(issue(
4214 "gateway",
4215 eval_name,
4216 &format!("ai.experiments.evals[{eval_name}].golden_set[{i}].expected"),
4217 "an eval case expected value must be non-empty",
4218 ));
4219 }
4220 if let Some(scorer) = &case.scorer {
4221 if scorer != "exact_match" && scorer != "contains" && scorer != "regex" {
4222 issues.push(issue(
4223 "gateway",
4224 eval_name,
4225 &format!(
4226 "ai.experiments.evals[{eval_name}].golden_set[{i}].scorer"
4227 ),
4228 format!(
4229 "unknown scorer '{}' (must be one of: exact_match, contains, regex)",
4230 scorer
4231 ),
4232 ));
4233 }
4234 if scorer == "regex" {
4235 if let Err(e) = regex::Regex::new(&case.expected) {
4236 issues.push(issue(
4237 "gateway",
4238 eval_name,
4239 &format!(
4240 "ai.experiments.evals[{eval_name}].golden_set[{i}].expected"
4241 ),
4242 format!("invalid regex pattern for regex scorer: {e}",),
4243 ));
4244 }
4245 }
4246 }
4247 }
4248 }
4249 }
4250
4251 if let Some(mcp) = &ai.mcp {
4258 let upstream_names: std::collections::BTreeSet<&str> =
4259 gateway.upstreams.iter().map(|u| u.name.as_str()).collect();
4260 for (name, tool) in &mcp.tools {
4261 if name.trim().is_empty() {
4262 issues.push(issue(
4263 "gateway",
4264 "(root)",
4265 "ai.mcp.tools",
4266 "tool name must be non-empty",
4267 ));
4268 }
4269 if tool.description.trim().is_empty() {
4270 issues.push(issue(
4271 "gateway",
4272 name,
4273 "ai.mcp.tools.description",
4274 "tool description must be non-empty",
4275 ));
4276 }
4277 if !upstream_names.contains(tool.upstream.as_str()) {
4278 issues.push(issue(
4279 "gateway",
4280 name,
4281 "ai.mcp.tools.upstream",
4282 format!(
4283 "references unknown upstream '{}' (not in \
4284 upstreams[] — a typo, or the upstream was renamed)",
4285 tool.upstream
4286 ),
4287 ));
4288 }
4289 if let Some(path) = &tool.path {
4290 if !path.starts_with('/') {
4291 issues.push(issue(
4292 "gateway",
4293 name,
4294 "ai.mcp.tools.path",
4295 "the path must start with '/' (it is appended to the upstream endpoint)",
4296 ));
4297 }
4298 }
4299 if let Some(method) = &tool.method {
4300 let m = method.to_ascii_uppercase();
4301 if !matches!(m.as_str(), "GET" | "POST" | "PUT" | "PATCH" | "DELETE") {
4302 issues.push(issue(
4303 "gateway",
4304 name,
4305 "ai.mcp.tools.method",
4306 format!(
4307 "unknown HTTP method '{}' (must be GET, POST, PUT, PATCH, or DELETE)",
4308 method
4309 ),
4310 ));
4311 }
4312 }
4313 if !tool.input_schema.is_object() {
4314 issues.push(issue(
4315 "gateway",
4316 name,
4317 "ai.mcp.tools.input_schema",
4318 "input_schema must be a JSON object (a JSON Schema for the tool's arguments)",
4319 ));
4320 }
4321 if let Some(timeout) = tool.timeout_ms {
4322 if timeout == 0 {
4323 issues.push(issue(
4324 "gateway",
4325 name,
4326 "ai.mcp.tools.timeout_ms",
4327 "timeout_ms must be > 0 (omit for the default 30s)",
4328 ));
4329 }
4330 }
4331 }
4332 if let Some(sessions) = &mcp.sessions {
4333 if let Some(ttl) = sessions.ttl_secs {
4334 if ttl == 0 {
4335 issues.push(issue(
4336 "gateway",
4337 "(root)",
4338 "ai.mcp.sessions.ttl_secs",
4339 "ttl_secs must be > 0 (omit for the default 3600s)",
4340 ));
4341 }
4342 }
4343 if let Some(max) = sessions.max_concurrent {
4344 if max == 0 {
4345 issues.push(issue(
4346 "gateway",
4347 "(root)",
4348 "ai.mcp.sessions.max_concurrent",
4349 "max_concurrent must be > 0 (omit for the default 1000)",
4350 ));
4351 }
4352 }
4353 }
4354 if let Some(path) = &mcp.path {
4355 if !path.starts_with('/') {
4356 issues.push(issue(
4357 "gateway",
4358 "(root)",
4359 "ai.mcp.path",
4360 "the path must start with '/' (it is a reserved HTTP path)",
4361 ));
4362 }
4363 }
4364 }
4365
4366 if let Some(a2a) = &ai.a2a {
4374 let upstream_names: std::collections::BTreeSet<&str> =
4378 gateway.upstreams.iter().map(|u| u.name.as_str()).collect();
4379 let mut agent_names = std::collections::BTreeSet::new();
4380 for agent in &a2a.agents {
4381 if agent.name.trim().is_empty() {
4382 issues.push(issue(
4383 "gateway",
4384 "(root)",
4385 "ai.a2a.agents[].name",
4386 "agent name must be non-empty",
4387 ));
4388 } else if !agent_names.insert(agent.name.as_str()) {
4389 issues.push(issue(
4390 "gateway",
4391 &agent.name,
4392 "ai.a2a.agents[].name",
4393 "duplicate a2a agent name",
4394 ));
4395 }
4396 if agent.url.trim().is_empty() {
4397 issues.push(issue(
4398 "gateway",
4399 &agent.name,
4400 "ai.a2a.agents[].url",
4401 "agent url must be non-empty",
4402 ));
4403 } else if !agent.url.starts_with("http://") && !agent.url.starts_with("https://") {
4404 issues.push(issue(
4405 "gateway",
4406 &agent.name,
4407 "ai.a2a.agents[].url",
4408 "the agent url must be an http or https URL",
4409 ));
4410 }
4411 if !upstream_names.contains(agent.upstream.as_str()) {
4412 issues.push(issue(
4413 "gateway",
4414 &agent.name,
4415 "ai.a2a.agents[].upstream",
4416 format!(
4417 "references unknown upstream '{}' (not in \
4418 upstreams[] -- a typo, or the upstream was renamed)",
4419 agent.upstream
4420 ),
4421 ));
4422 }
4423 if let Some(card) = &agent.card {
4424 if let Some(inline) = &card.inline {
4425 if !inline.is_object() {
4426 issues.push(issue(
4427 "gateway",
4428 &agent.name,
4429 "ai.a2a.agents[].card.inline",
4430 "the inline agent card must be a JSON object",
4431 ));
4432 }
4433 }
4434 if card.path.is_none() && card.inline.is_none() {
4435 issues.push(issue(
4436 "gateway",
4437 &agent.name,
4438 "ai.a2a.agents[].card",
4439 "agent card has neither inline nor path set (set one)",
4440 ));
4441 }
4442 }
4443 }
4444 if let Some(sessions) = &a2a.sessions {
4445 if let Some(ttl) = sessions.ttl_secs {
4446 if ttl == 0 {
4447 issues.push(issue(
4448 "gateway",
4449 "(root)",
4450 "ai.a2a.sessions.ttl_secs",
4451 "ttl_secs must be > 0 (omit for the default 3600s)",
4452 ));
4453 }
4454 }
4455 if let Some(max) = sessions.max_concurrent {
4456 if max == 0 {
4457 issues.push(issue(
4458 "gateway",
4459 "(root)",
4460 "ai.a2a.sessions.max_concurrent",
4461 "max_concurrent must be > 0 (omit for the default 1000)",
4462 ));
4463 }
4464 }
4465 }
4466 }
4467}
4468
4469pub fn validate(gateway: &Gateway) -> Vec<ValidationIssue> {
4470 let mut issues = Vec::new();
4471
4472 for (i, entry) in gateway.trusted_proxies.iter().enumerate() {
4475 if crate::config::net::parse_ip_or_cidr(entry).is_none() {
4476 issues.push(issue(
4477 "gateway",
4478 "(root)",
4479 &format!("trusted_proxies[{i}]"),
4480 format!("'{entry}' is not an IP address or CIDR (e.g. 10.0.0.0/8 or ::1/128)"),
4481 ));
4482 }
4483 }
4484
4485 if gateway.max_concurrent_requests == Some(0) {
4486 issues.push(issue(
4487 "gateway",
4488 "(root)",
4489 "max_concurrent_requests",
4490 "max_concurrent_requests must be > 0 (unlimited is expressed by \
4491 omitting the field, so an explicit 0 is rejected as ambiguous)",
4492 ));
4493 }
4494
4495 if let Some(waf) = &gateway.waf {
4500 if waf.enabled {
4501 if !(1..=4).contains(&waf.paranoia_level) {
4502 issues.push(issue(
4503 "gateway",
4504 "(root)",
4505 "waf.paranoia_level",
4506 format!(
4507 "paranoia_level {} is out of range: must be 1-4",
4508 waf.paranoia_level
4509 ),
4510 ));
4511 }
4512 if waf.anomaly_threshold == 0 {
4513 issues.push(issue(
4514 "gateway",
4515 "(root)",
4516 "waf.anomaly_threshold",
4517 "anomaly_threshold must be > 0 (use dry_run for audit-only mode)",
4518 ));
4519 }
4520 if waf.max_body_inspect_bytes > 1_048_576 {
4521 issues.push(issue(
4522 "gateway",
4523 "(root)",
4524 "waf.max_body_inspect_bytes",
4525 format!(
4526 "max_body_inspect_bytes {} is out of range: must be 0 \
4527 (no body inspection) or 1..=1048576 (1 MiB)",
4528 waf.max_body_inspect_bytes
4529 ),
4530 ));
4531 }
4532 for (i, rule) in waf.rules.iter().enumerate() {
4533 if !(1..=4).contains(&rule.severity) {
4534 issues.push(issue(
4535 "gateway",
4536 "(root)",
4537 &format!("waf.rules[{i}].severity"),
4538 format!(
4539 "rule {} severity {} is out of range: must be 1-4",
4540 rule.id, rule.severity
4541 ),
4542 ));
4543 }
4544 if !(1..=2).contains(&rule.phase) {
4545 issues.push(issue(
4546 "gateway",
4547 "(root)",
4548 &format!("waf.rules[{i}].phase"),
4549 format!(
4550 "rule {} phase {} is out of range: must be 1 (request headers) or 2 (request body)",
4551 rule.id, rule.phase
4552 ),
4553 ));
4554 }
4555 if regex::Regex::new(&rule.pattern).is_err() {
4556 issues.push(issue(
4557 "gateway",
4558 "(root)",
4559 &format!("waf.rules[{i}].pattern"),
4560 format!("rule {} has an invalid regex pattern", rule.id),
4561 ));
4562 }
4563 for (j, target) in rule.targets.iter().enumerate() {
4564 if !matches!(
4565 target.as_str(),
4566 "path" | "query" | "header" | "headers" | "body"
4567 ) {
4568 issues.push(issue(
4569 "gateway",
4570 "(root)",
4571 &format!("waf.rules[{i}].targets[{j}]"),
4572 format!(
4573 "rule {} has unknown target '{}': must be path, query, header, headers, or body",
4574 rule.id, target
4575 ),
4576 ));
4577 }
4578 }
4579 for (j, t) in rule.transformations.iter().enumerate() {
4580 if !matches!(
4581 t.as_str(),
4582 "lowercase"
4583 | "url_decode"
4584 | "html_entity_decode"
4585 | "compress_whitespace"
4586 | "remove_whitespace"
4587 | "url_decode_uni"
4588 ) {
4589 issues.push(issue(
4590 "gateway",
4591 "(root)",
4592 &format!("waf.rules[{i}].transformations[{j}]"),
4593 format!("rule {} has unknown transformation '{}'", rule.id, t),
4594 ));
4595 }
4596 }
4597 }
4598 }
4599 }
4600
4601 if gateway.load_shed_dry_run && gateway.max_concurrent_requests.is_none() {
4606 issues.push(issue(
4607 "gateway",
4608 "(root)",
4609 "load_shed_dry_run",
4610 "load_shed_dry_run requires max_concurrent_requests: an uncapped gateway \
4611 never sheds, so the flag would be a silent no-op (set a cap, or omit \
4612 the flag)",
4613 ));
4614 }
4615
4616 if let Some(aq) = &gateway.admission_queue {
4621 if aq.enabled && gateway.max_concurrent_requests.is_none() {
4622 issues.push(issue(
4623 "gateway",
4624 "(root)",
4625 "admission_queue.enabled",
4626 "admission_queue.enabled requires max_concurrent_requests: the queue \
4627 waits for a cap permit, so an uncapped gateway has nothing to queue \
4628 for (set a cap, or omit the block)",
4629 ));
4630 }
4631 if aq.max_queue_size == 0 {
4632 issues.push(issue(
4633 "gateway",
4634 "(root)",
4635 "admission_queue.max_queue_size",
4636 "admission_queue.max_queue_size must be > 0 (range 1..=10000)",
4637 ));
4638 }
4639 if aq.max_queue_size > 10000 {
4640 issues.push(issue(
4641 "gateway",
4642 "(root)",
4643 "admission_queue.max_queue_size",
4644 format!(
4645 "admission_queue.max_queue_size {} is out of range: must be \
4646 1..=10000",
4647 aq.max_queue_size
4648 ),
4649 ));
4650 }
4651 if aq.queue_timeout_ms == 0 {
4652 issues.push(issue(
4653 "gateway",
4654 "(root)",
4655 "admission_queue.queue_timeout_ms",
4656 "admission_queue.queue_timeout_ms must be > 0 (range 1..=10000)",
4657 ));
4658 }
4659 if aq.queue_timeout_ms > 10000 {
4660 issues.push(issue(
4661 "gateway",
4662 "(root)",
4663 "admission_queue.queue_timeout_ms",
4664 format!(
4665 "admission_queue.queue_timeout_ms {} is out of range: must be \
4666 1..=10000",
4667 aq.queue_timeout_ms
4668 ),
4669 ));
4670 }
4671 }
4672
4673 if let Some(hmac) = &gateway.hmac_auth {
4678 let skew = hmac.max_clock_skew_secs;
4679 if !(1..=3600).contains(&skew) {
4680 issues.push(issue(
4681 "gateway",
4682 "(root)",
4683 "hmac_auth.max_clock_skew_secs",
4684 format!(
4685 "max_clock_skew_secs {skew} is out of range: must be 1..=3600 seconds \
4686 (0 rejects every signer with any clock drift; a larger window pins \
4687 replay-nonce memory for its whole duration)"
4688 ),
4689 ));
4690 }
4691 }
4692
4693 validate_webhooks(gateway, &mut issues);
4695 validate_analytics_stream(gateway, &mut issues);
4697
4698 validate_analytics(gateway, &mut issues);
4700
4701 validate_geoip(gateway, &mut issues);
4703
4704 validate_quotas(gateway, &mut issues);
4706
4707 validate_license(gateway, &mut issues);
4711
4712 validate_redis_rate_limiter(gateway, &mut issues);
4717
4718 validate_redis_quotas(gateway, &mut issues);
4723
4724 validate_redis_cache(gateway, &mut issues);
4729
4730 validate_config_convergence(gateway, &mut issues);
4735 validate_fleet(gateway, &mut issues);
4736
4737 validate_mesh(gateway, &mut issues);
4742
4743 validate_oidc_providers(gateway, &mut issues);
4746
4747 validate_ai(gateway, &mut issues);
4750
4751 validate_fips(gateway, &mut issues);
4759
4760 validate_pq(gateway, &mut issues);
4767
4768 if gateway.routes.is_empty() && !gateway.allow_empty_routes {
4774 issues.push(issue(
4775 "gateway",
4776 "(root)",
4777 "routes",
4778 "routes is empty: publishing an empty route set drops all routing \
4779 (every request would 404), and a truncated or torn config write \
4780 is schema-valid here — set allow_empty_routes: true if this \
4781 route-less shape is deliberate",
4782 ));
4783 }
4784
4785 for p in &gateway.policies {
4789 if let Some(tb) = &p.token_budget {
4790 if tb.tokens_per_min.is_none() && tb.cost_per_day_micros.is_none() {
4791 issues.push(issue(
4792 "gateway",
4793 &p.name,
4794 "token_budget",
4795 "a token_budget must set tokens_per_min and/or cost_per_day_micros \
4796 (an empty budget bounds nothing)",
4797 ));
4798 }
4799 if tb.tokens_per_min == Some(0) {
4800 issues.push(issue(
4801 "gateway",
4802 &p.name,
4803 "token_budget.tokens_per_min",
4804 "tokens_per_min must be > 0 (omit the field for no token window)",
4805 ));
4806 }
4807 if tb.cost_per_day_micros == Some(0) {
4808 issues.push(issue(
4809 "gateway",
4810 &p.name,
4811 "token_budget.cost_per_day_micros",
4812 "cost_per_day_micros must be > 0 (omit the field for no cost window)",
4813 ));
4814 }
4815 }
4816 }
4817
4818 let mut check_dups = |kind: &str, field: &str, names: Vec<&str>| {
4820 let mut seen = std::collections::BTreeSet::new();
4821 for name in names {
4822 if !seen.insert(name) {
4823 issues.push(issue(kind, name, field, "duplicate name"));
4824 }
4825 }
4826 };
4827 check_dups(
4828 "listener",
4829 "name",
4830 gateway.listeners.iter().map(|l| l.name.as_str()).collect(),
4831 );
4832 check_dups(
4833 "route",
4834 "name",
4835 gateway.routes.iter().map(|r| r.name.as_str()).collect(),
4836 );
4837 check_dups(
4838 "service",
4839 "name",
4840 gateway.services.iter().map(|s| s.name.as_str()).collect(),
4841 );
4842 check_dups(
4843 "upstream",
4844 "name",
4845 gateway.upstreams.iter().map(|u| u.name.as_str()).collect(),
4846 );
4847 check_dups(
4848 "consumer",
4849 "name",
4850 gateway.consumers.iter().map(|c| c.name.as_str()).collect(),
4851 );
4852 check_dups(
4853 "policy",
4854 "name",
4855 gateway.policies.iter().map(|p| p.name.as_str()).collect(),
4856 );
4857 check_dups(
4858 "jwt_provider",
4859 "name",
4860 gateway
4861 .jwt_providers
4862 .iter()
4863 .map(|p| p.name.as_str())
4864 .collect(),
4865 );
4866
4867 validate_plugins(gateway, &mut issues);
4870
4871 if let Some(fc) = &gateway.filter_chain {
4873 validate_filter_chain(fc, "gateway", "filter_chain", &mut issues);
4874 }
4875
4876 for route in &gateway.routes {
4878 if let Some(fc) = &route.filter_chain {
4879 validate_filter_chain(fc, "route", &route.name, &mut issues);
4880 }
4881 }
4882
4883 for p in &gateway.jwt_providers {
4887 match p.jwks_url.parse::<hyper::Uri>() {
4888 Ok(uri) => {
4889 if !matches!(uri.scheme_str(), Some("http") | Some("https")) || uri.host().is_none()
4890 {
4891 issues.push(issue(
4892 "jwt_provider",
4893 &p.name,
4894 "jwks_url",
4895 format!("'{}' must be an absolute http(s) URL", p.jwks_url),
4896 ));
4897 }
4898 }
4899 Err(_) => issues.push(issue(
4900 "jwt_provider",
4901 &p.name,
4902 "jwks_url",
4903 format!("'{}' is not a valid URL", p.jwks_url),
4904 )),
4905 }
4906 if p.algorithms.is_empty() {
4907 issues.push(issue(
4908 "jwt_provider",
4909 &p.name,
4910 "algorithms",
4911 "at least one algorithm must be allowed",
4912 ));
4913 }
4914 for a in &p.algorithms {
4915 let upper = a.to_ascii_uppercase();
4916 if matches!(upper.as_str(), "HS256" | "HS384" | "HS512" | "NONE") {
4917 issues.push(issue(
4918 "jwt_provider",
4919 &p.name,
4920 "algorithms",
4921 format!(
4922 "algorithm '{a}' is not allowed: only asymmetric verification \
4923 (RS*/ES*/PS*/EdDSA) is supported"
4924 ),
4925 ));
4926 } else if jsonwebtoken::Algorithm::from_str(&upper).is_err() {
4927 issues.push(issue(
4928 "jwt_provider",
4929 &p.name,
4930 "algorithms",
4931 format!("unknown algorithm '{a}'"),
4932 ));
4933 }
4934 }
4935 if p.refresh_secs == 0 {
4936 issues.push(issue(
4937 "jwt_provider",
4938 &p.name,
4939 "refresh_secs",
4940 "refresh_secs must be > 0",
4941 ));
4942 }
4943 if let Some(grace) = p.retired_key_grace_secs {
4947 if grace > 604_800 {
4948 issues.push(issue(
4949 "jwt_provider",
4950 &p.name,
4951 "retired_key_grace_secs",
4952 format!(
4953 "retired_key_grace_secs {grace} exceeds the 604800 cap \
4954 (7 days): a longer window would keep a retired issuer \
4955 key verifying indefinitely"
4956 ),
4957 ));
4958 }
4959 }
4960 if let Some(consumer) = &p.consumer {
4961 if !gateway.consumers.iter().any(|c| &c.name == consumer) {
4962 issues.push(issue(
4963 "jwt_provider",
4964 &p.name,
4965 "consumer",
4966 format!("references unknown consumer '{consumer}'"),
4967 ));
4968 }
4969 }
4970 if let Some(ca) = &p.trusted_ca_file {
4977 if let Ok(uri) = p.jwks_url.parse::<hyper::Uri>() {
4980 if uri
4981 .scheme_str()
4982 .is_some_and(|s| s.eq_ignore_ascii_case("https"))
4983 {
4984 check_trusted_ca_file(
4985 "jwt_provider",
4986 &p.name,
4987 "trusted_ca_file",
4988 ca,
4989 &mut issues,
4990 );
4991 } else {
4992 issues.push(issue(
4993 "jwt_provider",
4994 &p.name,
4995 "trusted_ca_file",
4996 "trusted_ca_file only applies to an https jwks_url (no TLS is \
4997 negotiated toward an http endpoint)",
4998 ));
4999 }
5000 }
5001 }
5002 }
5003
5004 if let Some(admin) = &gateway.admin {
5009 if admin.bind.trim().is_empty() {
5010 issues.push(issue(
5011 "admin",
5012 "(root)",
5013 "bind",
5014 "admin bind is empty (default 127.0.0.1:2019)",
5015 ));
5016 } else if admin.bind.parse::<std::net::SocketAddr>().is_err() {
5017 issues.push(issue(
5018 "admin",
5019 "(root)",
5020 "bind",
5021 format!(
5022 "admin bind '{}' is not a valid address:port (e.g. 127.0.0.1:2019)",
5023 admin.bind
5024 ),
5025 ));
5026 }
5027 let t = &admin.tls;
5028 for (field, value) in [
5029 ("tls.cert_file", &t.cert_file),
5030 ("tls.key_file", &t.key_file),
5031 ("tls.client_ca_file", &t.client_ca_file),
5032 ] {
5033 if value.trim().is_empty() {
5034 issues.push(issue(
5035 "admin",
5036 "(root)",
5037 field,
5038 format!("{field} is required (mTLS-only admin: server cert, key, and client CA must all be set)"),
5039 ));
5040 }
5041 }
5042 }
5043
5044 let mut binds = std::collections::BTreeSet::new();
5046 for l in &gateway.listeners {
5047 if l.port == 0 {
5048 issues.push(issue(
5049 "listener",
5050 &l.name,
5051 "port",
5052 "port 0 is not a valid gateway listen port (only meaningful as an ephemeral test bind)",
5053 ));
5054 }
5055 if l.address.trim().is_empty() {
5056 issues.push(issue("listener", &l.name, "address", "address is empty"));
5057 }
5058 match l.protocol {
5059 ListenerProtocol::Https => {
5060 match &l.tls {
5061 None => issues.push(issue(
5062 "listener",
5063 &l.name,
5064 "tls",
5065 "protocol https requires a tls block",
5066 )),
5067 Some(t) => match t.mode {
5068 TlsMode::Terminate => {
5069 let has_pair = t.cert_file.is_some() || t.key_file.is_some();
5070 if t.certificates.is_empty() || has_pair {
5071 if t.cert_file.as_deref().unwrap_or("").trim().is_empty() {
5072 issues.push(issue(
5073 "listener",
5074 &l.name,
5075 "tls.cert_file",
5076 "tls mode terminate requires a non-empty cert_file",
5077 ));
5078 }
5079 if t.key_file.as_deref().unwrap_or("").trim().is_empty() {
5080 issues.push(issue(
5081 "listener",
5082 &l.name,
5083 "tls.key_file",
5084 "tls mode terminate requires a non-empty key_file",
5085 ));
5086 }
5087 }
5088 if !t.sni_routes.is_empty() {
5089 issues.push(issue(
5090 "listener",
5091 &l.name,
5092 "tls.sni_routes",
5093 "sni_routes only apply to tls mode passthrough",
5094 ));
5095 }
5096 if let Some(ca) = &t.client_ca_file {
5104 check_trusted_ca_file(
5105 "listener",
5106 &l.name,
5107 "tls.client_ca_file",
5108 ca,
5109 &mut issues,
5110 );
5111 }
5112 let mut seen_names = std::collections::BTreeSet::new();
5113 for (i, c) in t.certificates.iter().enumerate() {
5114 if c.server_names.is_empty() {
5115 issues.push(issue(
5116 "listener",
5117 &l.name,
5118 &format!("tls.certificates[{i}].server_names"),
5119 "certificate entry lists no server_names",
5120 ));
5121 }
5122 for n in &c.server_names {
5123 if n.trim().is_empty() {
5124 issues.push(issue(
5125 "listener",
5126 &l.name,
5127 &format!("tls.certificates[{i}].server_names"),
5128 "server name is empty",
5129 ));
5130 } else if !seen_names.insert(n.to_ascii_lowercase()) {
5131 issues.push(issue(
5132 "listener",
5133 &l.name,
5134 &format!("tls.certificates[{i}].server_names"),
5135 format!("duplicate server name '{n}'"),
5136 ));
5137 }
5138 }
5139 if c.cert_file.trim().is_empty() {
5140 issues.push(issue(
5141 "listener",
5142 &l.name,
5143 &format!("tls.certificates[{i}].cert_file"),
5144 "certificate entry requires a non-empty cert_file",
5145 ));
5146 }
5147 if c.key_file.trim().is_empty() {
5148 issues.push(issue(
5149 "listener",
5150 &l.name,
5151 &format!("tls.certificates[{i}].key_file"),
5152 "certificate entry requires a non-empty key_file",
5153 ));
5154 }
5155 }
5156 if let Some(acme) = &t.acme {
5159 if acme.domains.is_empty() {
5160 issues.push(issue(
5161 "listener",
5162 &l.name,
5163 "tls.acme.domains",
5164 "acme domains list must not be empty",
5165 ));
5166 }
5167 if !acme.staging && acme.contact.is_empty() {
5168 issues.push(issue(
5169 "listener",
5170 &l.name,
5171 "tls.acme.contact",
5172 "acme contact email is required for production issuance \
5173 (Let's Encrypt requires at least one contact email)",
5174 ));
5175 }
5176 }
5177 }
5178 TlsMode::Passthrough => {
5179 if t.cert_file.is_some() || t.key_file.is_some() {
5180 issues.push(issue(
5181 "listener",
5182 &l.name,
5183 "tls",
5184 "tls mode passthrough ignores cert_file/key_file; remove them or use mode terminate",
5185 ));
5186 }
5187 if l.proxy_protocol {
5192 issues.push(issue(
5193 "listener",
5194 &l.name,
5195 "proxy_protocol",
5196 "proxy_protocol cannot be combined with tls mode passthrough: \
5197 passthrough splices raw bytes and never runs the pipeline that \
5198 consumes the PROXY client address (use protocol http, or https \
5199 with mode terminate)",
5200 ));
5201 }
5202 if !t.certificates.is_empty() {
5203 issues.push(issue(
5204 "listener",
5205 &l.name,
5206 "tls.certificates",
5207 "tls mode passthrough does not terminate TLS; certificates do not apply",
5208 ));
5209 }
5210 if t.client_ca_file.is_some() {
5211 issues.push(issue(
5212 "listener",
5213 &l.name,
5214 "tls.client_ca_file",
5215 "tls mode passthrough does not terminate TLS; client certificates \
5216 cannot be verified (use mode terminate)",
5217 ));
5218 }
5219 if t.acme.is_some() {
5220 issues.push(issue(
5221 "listener",
5222 &l.name,
5223 "tls.acme",
5224 "tls mode passthrough does not terminate TLS; acme certificate \
5225 automation does not apply (use mode terminate)",
5226 ));
5227 }
5228 let mut seen_names = std::collections::BTreeSet::new();
5229 for (i, r) in t.sni_routes.iter().enumerate() {
5230 if r.server_names.is_empty() {
5231 issues.push(issue(
5232 "listener",
5233 &l.name,
5234 &format!("tls.sni_routes[{i}].server_names"),
5235 "sni route lists no server_names",
5236 ));
5237 }
5238 for n in &r.server_names {
5239 if n.trim().is_empty() {
5240 issues.push(issue(
5241 "listener",
5242 &l.name,
5243 &format!("tls.sni_routes[{i}].server_names"),
5244 "server name is empty",
5245 ));
5246 } else if !seen_names.insert(n.to_ascii_lowercase()) {
5247 issues.push(issue(
5248 "listener",
5249 &l.name,
5250 &format!("tls.sni_routes[{i}].server_names"),
5251 format!("duplicate server name '{n}'"),
5252 ));
5253 }
5254 }
5255 }
5256 }
5257 },
5258 } if let Some(t) = &l.tls {
5264 if t.zero_rtt != ZeroRttPolicy::Reject {
5265 issues.push(issue(
5266 "listener",
5267 &l.name,
5268 "tls.zero_rtt",
5269 "zero_rtt only applies to protocol h3 listeners (TLS 1.3 early data over QUIC); remove it or use protocol h3",
5270 ));
5271 }
5272 }
5273 if let Some(alt_svc) = &l.alt_svc {
5276 if alt_svc.trim().is_empty() {
5277 issues.push(issue(
5278 "listener",
5279 &l.name,
5280 "alt_svc",
5281 "alt_svc is set but empty; provide a valid alt-svc value or remove the field",
5282 ));
5283 }
5284 }
5285 }
5286 ListenerProtocol::Http => {
5287 if l.tls.is_some() {
5288 issues.push(issue(
5289 "listener",
5290 &l.name,
5291 "tls",
5292 "protocol http must not carry a tls block (use protocol https for TLS)",
5293 ));
5294 }
5295 if let Some(alt_svc) = &l.alt_svc {
5299 if alt_svc.trim().is_empty() {
5300 issues.push(issue(
5301 "listener",
5302 &l.name,
5303 "alt_svc",
5304 "alt_svc is set but empty; provide a valid alt-svc value or remove the field",
5305 ));
5306 }
5307 }
5308 }
5309 ListenerProtocol::H3 => {
5310 match &l.tls {
5313 None => issues.push(issue(
5314 "listener",
5315 &l.name,
5316 "tls",
5317 "protocol h3 requires a tls block with cert_file and key_file (QUIC uses TLS 1.3)",
5318 )),
5319 Some(t) => {
5320 if t.mode == TlsMode::Passthrough {
5321 issues.push(issue(
5322 "listener",
5323 &l.name,
5324 "tls.mode",
5325 "protocol h3 does not support tls mode passthrough (QUIC terminates TLS in the handshake)",
5326 ));
5327 } else {
5328 if t.cert_file.as_deref().unwrap_or("").trim().is_empty()
5331 && t.certificates.is_empty()
5332 {
5333 issues.push(issue(
5334 "listener",
5335 &l.name,
5336 "tls.cert_file",
5337 "protocol h3 requires a non-empty cert_file or certificates (QUIC handshake needs a server certificate)",
5338 ));
5339 }
5340 if t.key_file.as_deref().unwrap_or("").trim().is_empty()
5341 && t.certificates.is_empty()
5342 {
5343 issues.push(issue(
5344 "listener",
5345 &l.name,
5346 "tls.key_file",
5347 "protocol h3 requires a non-empty key_file or certificates (QUIC handshake needs a private key)",
5348 ));
5349 }
5350 if !t.sni_routes.is_empty() {
5351 issues.push(issue(
5352 "listener",
5353 &l.name,
5354 "tls.sni_routes",
5355 "sni_routes only apply to tls mode passthrough (H3 uses SNI for cert selection, not routing)",
5356 ));
5357 }
5358 }
5359 }
5362 }
5363 if l.alt_svc.is_some() {
5366 issues.push(issue(
5367 "listener",
5368 &l.name,
5369 "alt_svc",
5370 "alt_svc must not be set on an h3 listener (the h3 listener is the alt-svc target, not the advertiser)",
5371 ));
5372 }
5373 if l.proxy_protocol {
5376 issues.push(issue(
5377 "listener",
5378 &l.name,
5379 "proxy_protocol",
5380 "proxy_protocol is not supported on h3 listeners (QUIC has no pre-handshake byte stream)",
5381 ));
5382 }
5383 }
5384 ListenerProtocol::Tcp | ListenerProtocol::Udp => {
5385 let l4_cfg = match &l.l4 {
5389 None => {
5390 issues.push(issue(
5391 "listener",
5392 &l.name,
5393 "l4",
5394 format!(
5395 "protocol {} requires an l4 block (upstream + optional \
5396 sni_routing + idle_timeout_s)",
5397 if l.protocol == ListenerProtocol::Tcp {
5398 "tcp"
5399 } else {
5400 "udp"
5401 }
5402 ),
5403 ));
5404 continue;
5405 }
5406 Some(cfg) => cfg,
5407 };
5408 if l4_cfg.sni_routing && l.protocol == ListenerProtocol::Udp {
5411 issues.push(issue(
5412 "listener",
5413 &l.name,
5414 "l4.sni_routing",
5415 "sni_routing is not supported on udp listeners (UDP has no \
5416 byte-stream handshake to peek; use protocol tcp for SNI-routed \
5417 L4 proxying)",
5418 ));
5419 }
5420 if let Some(timeout) = l4_cfg.idle_timeout_s {
5422 if timeout > 3600 {
5423 issues.push(issue(
5424 "listener",
5425 &l.name,
5426 "l4.idle_timeout_s",
5427 "idle_timeout_s must be at most 3600 (1 hour)",
5428 ));
5429 }
5430 }
5431 if !l4_cfg.sni_routing && l4_cfg.upstream.is_none() {
5436 issues.push(issue(
5437 "listener",
5438 &l.name,
5439 "l4.upstream",
5440 "l4.upstream is required when sni_routing is false (every \
5441 connection goes to the configured upstream)",
5442 ));
5443 }
5444 if l4_cfg.sni_routing {
5451 match &l.tls {
5452 None => {
5453 issues.push(issue(
5454 "listener",
5455 &l.name,
5456 "tls",
5457 "l4.sni_routing requires a tls block with sni_routes \
5458 (the SNI routing table, same as tls passthrough)",
5459 ));
5460 }
5461 Some(t) => {
5462 if t.sni_routes.is_empty() {
5463 issues.push(issue(
5464 "listener",
5465 &l.name,
5466 "tls.sni_routes",
5467 "l4.sni_routing requires at least one tls.sni_routes \
5468 entry (the SNI routing table is empty)",
5469 ));
5470 }
5471 }
5472 }
5473 } else if l.tls.is_some() {
5474 if l.tls
5479 .as_ref()
5480 .map(|t| t.sni_routes.is_empty())
5481 .unwrap_or(true)
5482 {
5483 issues.push(issue(
5484 "listener",
5485 &l.name,
5486 "tls",
5487 "tls block on an l4 listener without sni_routing has no \
5488 effect (remove it or set l4.sni_routing: true)",
5489 ));
5490 }
5491 }
5492 if l.alt_svc.is_some() {
5495 issues.push(issue(
5496 "listener",
5497 &l.name,
5498 "alt_svc",
5499 "alt_svc must not be set on an l4 listener (L4 proxying has no \
5500 HTTP response to advertise on)",
5501 ));
5502 }
5503 if let Some(t) = &l.tls {
5504 if t.zero_rtt != ZeroRttPolicy::Reject {
5505 issues.push(issue(
5506 "listener",
5507 &l.name,
5508 "tls.zero_rtt",
5509 "zero_rtt only applies to protocol h3 listeners (TLS 1.3 \
5510 early data over QUIC); remove it",
5511 ));
5512 }
5513 }
5514 }
5515 }
5516 if !binds.insert((l.address.as_str(), l.port)) {
5517 issues.push(issue(
5518 "listener",
5519 &l.name,
5520 "port",
5521 format!(
5522 "address {}:{} already bound by another listener",
5523 l.address, l.port
5524 ),
5525 ));
5526 }
5527 }
5528
5529 let services: std::collections::BTreeSet<&str> =
5530 gateway.services.iter().map(|s| s.name.as_str()).collect();
5531 let upstreams: std::collections::BTreeSet<&str> =
5532 gateway.upstreams.iter().map(|u| u.name.as_str()).collect();
5533 let policies: std::collections::BTreeSet<&str> =
5534 gateway.policies.iter().map(|p| p.name.as_str()).collect();
5535 let consumers: std::collections::BTreeSet<&str> =
5536 gateway.consumers.iter().map(|c| c.name.as_str()).collect();
5537 let consumer_groups: std::collections::BTreeSet<&str> = gateway
5538 .consumers
5539 .iter()
5540 .flat_map(|c| c.groups.iter().map(String::as_str))
5541 .collect();
5542 let plugin_names: std::collections::BTreeSet<&str> =
5544 gateway.plugins.iter().map(|p| p.name.as_str()).collect();
5545
5546 for l in &gateway.listeners {
5547 for p in &l.policies {
5551 if !policies.contains(p.as_str()) {
5552 issues.push(issue(
5553 "listener",
5554 &l.name,
5555 "policies",
5556 format!("references unknown policy '{p}'"),
5557 ));
5558 }
5559 }
5560 if let Some(authz) = &l.authorization {
5563 validate_authz(
5564 "listener",
5565 &l.name,
5566 authz,
5567 &consumers,
5568 &consumer_groups,
5569 &mut issues,
5570 );
5571 }
5572 let Some(tls) = &l.tls else { continue };
5573 for (i, r) in tls.sni_routes.iter().enumerate() {
5574 if !upstreams.contains(r.upstream.as_str()) {
5575 issues.push(issue(
5576 "listener",
5577 &l.name,
5578 &format!("tls.sni_routes[{i}].upstream"),
5579 format!("references unknown upstream '{}'", r.upstream),
5580 ));
5581 }
5582 }
5583 }
5584
5585 for l in &gateway.listeners {
5588 if !matches!(l.protocol, ListenerProtocol::Tcp | ListenerProtocol::Udp) {
5589 continue;
5590 }
5591 let Some(l4_cfg) = &l.l4 else { continue };
5592 if let Some(name) = &l4_cfg.upstream {
5594 if !upstreams.contains(name.as_str()) {
5595 issues.push(issue(
5596 "listener",
5597 &l.name,
5598 "l4.upstream",
5599 format!("references unknown upstream '{name}'"),
5600 ));
5601 }
5602 }
5603 if l4_cfg.sni_routing {
5611 if let Some(tls) = &l.tls {
5612 for r in &tls.sni_routes {
5613 if let Some(upstream) = gateway.upstreams.iter().find(|u| u.name == r.upstream)
5614 {
5615 if !matches!(
5616 upstream.protocol,
5617 crate::config::UpstreamProtocol::Https
5618 | crate::config::UpstreamProtocol::Http2
5619 | crate::config::UpstreamProtocol::H3
5620 ) {
5621 issues.push(issue(
5622 "listener",
5623 &l.name,
5624 "tls.sni_routes.upstream",
5625 format!(
5626 "sni_routing upstream '{}' must use a TLS protocol \
5627 (https, http2, or h3); '{}' cannot receive a \
5628 TLS-routed L4 splice",
5629 r.upstream,
5630 upstream_protocol_name(upstream.protocol)
5631 ),
5632 ));
5633 }
5634 }
5635 }
5636 }
5637 }
5638 }
5639
5640 for p in &gateway.global_policies {
5643 if !policies.contains(p.as_str()) {
5644 issues.push(issue(
5645 "gateway",
5646 "(root)",
5647 "global_policies",
5648 format!("references unknown policy '{p}'"),
5649 ));
5650 }
5651 }
5652 if let Some(authz) = &gateway.authorization {
5654 validate_authz(
5655 "gateway",
5656 "(root)",
5657 authz,
5658 &consumers,
5659 &consumer_groups,
5660 &mut issues,
5661 );
5662 }
5663
5664 for s in &gateway.services {
5665 match (&s.upstream, &s.split) {
5670 (Some(_), Some(_)) => issues.push(issue(
5671 "service",
5672 &s.name,
5673 "upstream",
5674 "upstream and split are both set: a service dispatches through exactly one \
5675 (upstream = single target; split = weighted canary/blue-green)",
5676 )),
5677 (None, None) => issues.push(issue(
5678 "service",
5679 &s.name,
5680 "upstream",
5681 "neither upstream nor split is set: set upstream (single target) or split \
5682 (weighted targets)",
5683 )),
5684 _ => {}
5685 }
5686 if let Some(upstream) = &s.upstream {
5687 if !upstreams.contains(upstream.as_str()) {
5688 issues.push(issue(
5689 "service",
5690 &s.name,
5691 "upstream",
5692 format!("references unknown upstream '{upstream}'"),
5693 ));
5694 }
5695 }
5696 if let Some(split) = &s.split {
5701 if split.targets.len() < 2 || split.targets.len() > 8 {
5702 issues.push(issue(
5703 "service",
5704 &s.name,
5705 "split.targets",
5706 format!(
5707 "a split needs 2..=8 targets ({} given): one target is what \
5708 services[].upstream expresses",
5709 split.targets.len()
5710 ),
5711 ));
5712 }
5713 let mut seen = std::collections::BTreeSet::new();
5714 let mut total: u64 = 0;
5715 for (i, t) in split.targets.iter().enumerate() {
5716 if !upstreams.contains(t.upstream.as_str()) {
5717 issues.push(issue(
5718 "service",
5719 &s.name,
5720 &format!("split.targets[{i}].upstream"),
5721 format!("references unknown upstream '{}'", t.upstream),
5722 ));
5723 }
5724 if !seen.insert(t.upstream.clone()) {
5725 issues.push(issue(
5726 "service",
5727 &s.name,
5728 &format!("split.targets[{i}].upstream"),
5729 format!(
5730 "duplicate upstream '{}' in the split (weights merge by intent; \
5731 name it once)",
5732 t.upstream
5733 ),
5734 ));
5735 }
5736 total += u64::from(t.weight);
5737 }
5738 if total == 0 {
5739 issues.push(issue(
5740 "service",
5741 &s.name,
5742 "split.targets",
5743 "every weight is 0: a split must serve some traffic (a parked \
5744 blue-green side is weight 0, the other side is not)",
5745 ));
5746 }
5747 if total > 100_000 {
5748 issues.push(issue(
5749 "service",
5750 &s.name,
5751 "split.targets",
5752 format!("weights sum to {total}; keep the total at or under 100000"),
5753 ));
5754 }
5755 if let Some(analysis) = &split.canary_analysis {
5762 if split.targets.len() != 2 {
5763 issues.push(issue(
5764 "service",
5765 &s.name,
5766 "split.canary_analysis",
5767 "canary_analysis requires exactly 2 targets (baseline + canary): \
5768 the controller compares two sides",
5769 ));
5770 }
5771 if analysis.window_seconds == 0 {
5772 issues.push(issue(
5773 "service",
5774 &s.name,
5775 "split.canary_analysis.window_seconds",
5776 "window_seconds must be >= 1",
5777 ));
5778 }
5779 if analysis.step == 0 {
5780 issues.push(issue(
5781 "service",
5782 &s.name,
5783 "split.canary_analysis.step",
5784 "step must be >= 1 (a 0-step never adjusts)",
5785 ));
5786 }
5787 if analysis.min_requests == 0 {
5788 issues.push(issue(
5789 "service",
5790 &s.name,
5791 "split.canary_analysis.min_requests",
5792 "min_requests must be >= 1",
5793 ));
5794 }
5795 if analysis.cooldown_seconds == 0 {
5796 issues.push(issue(
5797 "service",
5798 &s.name,
5799 "split.canary_analysis.cooldown_seconds",
5800 "cooldown_seconds must be >= 1",
5801 ));
5802 }
5803 validate_canary_rule(
5804 &s.name,
5805 "split.canary_analysis.promote",
5806 &analysis.promote,
5807 &mut issues,
5808 );
5809 validate_canary_rule(
5810 &s.name,
5811 "split.canary_analysis.rollback",
5812 &analysis.rollback,
5813 &mut issues,
5814 );
5815 }
5816 }
5817 if let Some(sticky) = &s.sticky {
5820 if !is_cookie_name(&sticky.cookie) {
5821 issues.push(issue(
5822 "service",
5823 &s.name,
5824 "sticky.cookie",
5825 format!(
5826 "'{}' is not a valid cookie name (RFC 6265 token: no \
5827 separators, spaces, or non-token characters)",
5828 sticky.cookie
5829 ),
5830 ));
5831 }
5832 if sticky.ttl_s == 0 || sticky.ttl_s > 2_592_000 {
5833 issues.push(issue(
5834 "service",
5835 &s.name,
5836 "sticky.ttl_s",
5837 "ttl_s must be in 1..=2592000 (30 days)",
5838 ));
5839 }
5840 }
5841 for p in &s.policies {
5842 if !policies.contains(p.as_str()) {
5843 issues.push(issue(
5844 "service",
5845 &s.name,
5846 "policies",
5847 format!("references unknown policy '{p}'"),
5848 ));
5849 }
5850 }
5851 if let Some(authz) = &s.authorization {
5854 validate_authz(
5855 "service",
5856 &s.name,
5857 authz,
5858 &consumers,
5859 &consumer_groups,
5860 &mut issues,
5861 );
5862 }
5863 }
5864
5865 for r in &gateway.routes {
5866 if let Some(p) = r.priority {
5867 if p > 10 {
5868 issues.push(issue(
5869 "route",
5870 &r.name,
5871 "priority",
5872 format!("priority {p} is out of range: must be 0 (lowest) to 10 (highest)"),
5873 ));
5874 }
5875 }
5876 if !services.contains(r.service.as_str()) {
5877 issues.push(issue(
5878 "route",
5879 &r.name,
5880 "service",
5881 format!("references unknown service '{}'", r.service),
5882 ));
5883 }
5884 for p in &r.policies {
5885 if !policies.contains(p.as_str()) {
5886 issues.push(issue(
5887 "route",
5888 &r.name,
5889 "policies",
5890 format!("references unknown policy '{p}'"),
5891 ));
5892 }
5893 }
5894 let mut route_plugins = std::collections::BTreeSet::new();
5899 for p in &r.plugins {
5900 if !route_plugins.insert(p.as_str()) {
5901 issues.push(issue(
5902 "route",
5903 &r.name,
5904 "plugins",
5905 format!("references plugin '{p}' more than once"),
5906 ));
5907 }
5908 if !plugin_names.contains(p.as_str()) {
5909 issues.push(issue(
5910 "route",
5911 &r.name,
5912 "plugins",
5913 format!("references unknown plugin '{p}'"),
5914 ));
5915 }
5916 }
5917 if let Some(authz) = &r.authorization {
5920 validate_authz(
5921 "route",
5922 &r.name,
5923 authz,
5924 &consumers,
5925 &consumer_groups,
5926 &mut issues,
5927 );
5928 }
5929 if let Some(cors) = &r.cors {
5932 validate_cors(&r.name, cors, &mut issues);
5933 }
5934 if let Some(compression) = &r.compression {
5935 validate_compression(&r.name, compression, &mut issues);
5936 }
5937 if let Some(limits) = &r.limits {
5938 validate_request_limits(&r.name, limits, &mut issues);
5939 }
5940 if let Some(dep) = &r.deprecation {
5941 validate_deprecation(&r.name, dep, &mut issues);
5942 }
5943 if let Some(maintenance) = &r.maintenance {
5944 validate_maintenance(&r.name, maintenance, &mut issues);
5945 }
5946 if let Some(transforms) = &r.transforms {
5948 validate_transforms(&r.name, transforms, &mut issues);
5949 }
5950 if let Some(sh) = &r.security_headers {
5951 validate_security_headers(&r.name, sh, &mut issues);
5952 }
5953 if let Some(masking) = &r.masking {
5955 validate_masking(&r.name, masking, &consumer_groups, &mut issues);
5956 }
5957 if let Some(cache) = &r.cache {
5959 validate_route_cache(&r.name, cache, &mut issues);
5960 }
5961 {
5967 let mut seen_methods = std::collections::BTreeSet::new();
5968 for (i, m) in r.methods.iter().enumerate() {
5969 if m.trim().is_empty() {
5970 issues.push(issue(
5971 "route",
5972 &r.name,
5973 &format!("methods[{i}]"),
5974 "method is empty or only whitespace",
5975 ));
5976 } else if hyper::Method::from_bytes(m.as_bytes()).is_err() {
5977 issues.push(issue(
5978 "route",
5979 &r.name,
5980 &format!("methods[{i}]"),
5981 format!("'{m}' is not a valid HTTP method token"),
5982 ));
5983 } else if !seen_methods.insert(m.to_ascii_lowercase()) {
5984 issues.push(issue(
5985 "route",
5986 &r.name,
5987 &format!("methods[{i}]"),
5988 format!("duplicate method '{m}' (matching is case-insensitive)"),
5989 ));
5990 }
5991 }
5992 }
5993 if let Some(slo) = &r.slo {
5999 if !(0.0..=100.0).contains(&slo.availability) || slo.availability <= 0.0 {
6000 issues.push(issue(
6001 "route",
6002 &r.name,
6003 "slo.availability",
6004 format!(
6005 "availability {} is out of range: a percentage in (0, 100] \
6006 (100 = every request must be non-5xx)",
6007 slo.availability
6008 ),
6009 ));
6010 }
6011 if let Some(ms) = slo.latency_ms {
6012 if !(1.0..=600_000.0).contains(&ms) {
6013 issues.push(issue(
6014 "route",
6015 &r.name,
6016 "slo.latency_ms",
6017 format!(
6018 "latency_ms {ms} is out of range: 1..=600000 (the \
6019 whole-request budget; nothing sensible is faster \
6020 than 1 ms or slower than the 10-minute class of \
6021 timeouts)"
6022 ),
6023 ));
6024 }
6025 }
6026 if let Some(t) = slo.latency_target {
6027 if !(0.0..=100.0).contains(&t) || t <= 0.0 {
6028 issues.push(issue(
6029 "route",
6030 &r.name,
6031 "slo.latency_target",
6032 format!(
6033 "latency_target {t} is out of range: a percentage in \
6034 (0, 100]"
6035 ),
6036 ));
6037 }
6038 }
6039 if slo.latency_target.is_some() && slo.latency_ms.is_none() {
6040 issues.push(issue(
6041 "route",
6042 &r.name,
6043 "slo.latency_target",
6044 "latency_target without latency_ms: the objective has no \
6045 threshold to measure against",
6046 ));
6047 }
6048 }
6049 if let Some(accept) = &r.r#match.accept {
6053 if crate::config::versioning::normalize_media_type(accept).is_none() {
6054 issues.push(issue(
6055 "route",
6056 &r.name,
6057 "match.accept",
6058 format!(
6059 "'{accept}' is not a bare media type: use type/subtype like \
6060 'application/vnd.acme.v2+json' (parameters and wildcards are not \
6061 supported; wildcards never match a versioned route)"
6062 ),
6063 ));
6064 }
6065 }
6066 if let Some(ws) = &r.websocket {
6072 for (i, origin) in ws.origins.iter().enumerate() {
6073 if origin.is_empty() {
6074 issues.push(issue(
6075 "route",
6076 &r.name,
6077 &format!("websocket.origins[{i}]"),
6078 "origin is empty: an allowlist entry must name an origin \
6079 (e.g. https://app.example.com, or the literal null)",
6080 ));
6081 } else if origin.len() > 256 || !origin.bytes().all(|b| (0x20..0x7f).contains(&b)) {
6082 issues.push(issue(
6083 "route",
6084 &r.name,
6085 &format!("websocket.origins[{i}]"),
6086 "origin must be printable ASCII at most 256 bytes",
6087 ));
6088 }
6089 }
6090 if let Some(rate) = ws.max_frames_per_sec {
6091 if rate == 0 || rate > crate::config::limits::MAX_WEBSOCKET_FRAMES_PER_SEC {
6092 issues.push(issue(
6093 "route",
6094 &r.name,
6095 "websocket.max_frames_per_sec",
6096 format!(
6097 "max_frames_per_sec must be in 1..={} (sustained data \
6098 frames per second, one-second burst)",
6099 crate::config::limits::MAX_WEBSOCKET_FRAMES_PER_SEC
6100 ),
6101 ));
6102 }
6103 }
6104 if let Some(idle) = ws.idle_timeout_s {
6106 if idle == 0 || idle > 86400 {
6107 issues.push(issue(
6108 "route",
6109 &r.name,
6110 "websocket.idle_timeout_s",
6111 "idle_timeout_s must be in 1..=86400 (seconds, 1s to 24h)",
6112 ));
6113 }
6114 }
6115 if let Some(sz) = ws.max_frame_size_bytes {
6116 if sz == 0 || sz > 16_777_216 {
6117 issues.push(issue(
6118 "route",
6119 &r.name,
6120 "websocket.max_frame_size_bytes",
6121 "max_frame_size_bytes must be in 1..=16777216 (1 byte to 16 MiB)",
6122 ));
6123 }
6124 }
6125 }
6126 if let Some(waf) = &r.waf {
6133 if waf.enabled {
6134 for (i, f) in waf.filters.iter().enumerate() {
6135 if !matches!(f.as_str(), "sqli" | "xss" | "path_traversal") {
6136 issues.push(issue(
6137 "route",
6138 &r.name,
6139 &format!("waf.filters[{i}]"),
6140 format!(
6141 "unknown filter category '{f}': must be one of \
6142 sqli, xss, path_traversal (or omit the list for \
6143 all three)"
6144 ),
6145 ));
6146 }
6147 }
6148 }
6149 if waf.max_body_inspect_bytes > 1_048_576 {
6150 issues.push(issue(
6151 "route",
6152 &r.name,
6153 "waf.max_body_inspect_bytes",
6154 format!(
6155 "max_body_inspect_bytes {} is out of range: must be 0 \
6156 (no body inspection) or 1..=1048576 (1 MiB)",
6157 waf.max_body_inspect_bytes
6158 ),
6159 ));
6160 }
6161 for (i, pat) in waf.custom_patterns.iter().enumerate() {
6162 if regex::Regex::new(pat).is_err() {
6163 issues.push(issue(
6164 "route",
6165 &r.name,
6166 &format!("waf.custom_patterns[{i}]"),
6167 format!("invalid regex pattern: '{pat}'"),
6168 ));
6169 }
6170 }
6171 }
6172 if let Some(graphql) = &r.graphql {
6181 if graphql.enabled {
6182 if graphql.depth_limit == 0 {
6183 issues.push(issue(
6184 "route",
6185 &r.name,
6186 "graphql.depth_limit",
6187 "depth_limit must be > 0 when graphql is enabled",
6188 ));
6189 }
6190 if graphql.complexity_limit == 0 {
6191 issues.push(issue(
6192 "route",
6193 &r.name,
6194 "graphql.complexity_limit",
6195 "complexity_limit must be > 0 when graphql is enabled",
6196 ));
6197 }
6198 if graphql.complexity_coefficient == 0 {
6199 issues.push(issue(
6200 "route",
6201 &r.name,
6202 "graphql.complexity_coefficient",
6203 "complexity_coefficient must be > 0 (use 1 for \
6204 uniform cost; 0 would make every query free)",
6205 ));
6206 }
6207 }
6208 if let Some(pq) = &graphql.persisted_queries {
6209 if pq.enabled {
6210 for (hash, query) in &pq.store {
6211 if hash.trim().is_empty() {
6212 issues.push(issue(
6213 "route",
6214 &r.name,
6215 "graphql.persisted_queries.store",
6216 "persisted query hash must be a non-empty \
6217 string",
6218 ));
6219 }
6220 if query.trim().is_empty() {
6221 issues.push(issue(
6222 "route",
6223 &r.name,
6224 "graphql.persisted_queries.store",
6225 "persisted query text must be a non-empty \
6226 string",
6227 ));
6228 }
6229 }
6230 }
6231 }
6232 }
6233 if let Some(grpc_web) = &r.grpc_web {
6245 if grpc_web.enabled {
6246 if let Some(tc) = &grpc_web.transcoding {
6247 if tc.enabled {
6248 if tc.descriptors.is_empty() {
6249 issues.push(issue(
6250 "route",
6251 &r.name,
6252 "grpc_web.transcoding.descriptors",
6253 "transcoding is enabled but no descriptors \
6254 are listed: at least one descriptor is \
6255 required",
6256 ));
6257 }
6258 for (i, desc) in tc.descriptors.iter().enumerate() {
6259 check_file_readable(
6260 "route",
6261 &r.name,
6262 &format!("grpc_web.transcoding.descriptors[{i}].file"),
6263 &desc.file,
6264 &mut issues,
6265 );
6266 }
6267 }
6268 }
6269 }
6270 }
6271 if let Some(translation) = &r.translation {
6285 match translation.kind {
6286 TranslationKind::RestToGraphql => match &translation.graphql {
6287 None => {
6288 issues.push(issue(
6289 "route",
6290 &r.name,
6291 "translation.graphql",
6292 "kind rest_to_graphql requires a graphql block",
6293 ));
6294 }
6295 Some(graphql) => {
6296 if graphql.query_template.trim().is_empty() {
6297 issues.push(issue(
6298 "route",
6299 &r.name,
6300 "translation.graphql.query_template",
6301 "rest_to_graphql requires a query_template",
6302 ));
6303 }
6304 }
6305 },
6306 TranslationKind::GraphqlToRest => {
6307 if translation.graphql.is_none() {
6308 issues.push(issue(
6309 "route",
6310 &r.name,
6311 "translation.graphql",
6312 "kind graphql_to_rest requires a graphql block",
6313 ));
6314 }
6315 }
6316 TranslationKind::RestToSoap | TranslationKind::SoapToRest => {
6317 match &translation.soap {
6318 None => {
6319 issues.push(issue(
6320 "route",
6321 &r.name,
6322 "translation.soap",
6323 "kind requires a soap block",
6324 ));
6325 }
6326 Some(soap) => {
6327 if soap.operation.trim().is_empty() {
6328 issues.push(issue(
6329 "route",
6330 &r.name,
6331 "translation.soap.operation",
6332 "soap translation requires an operation name",
6333 ));
6334 }
6335 if translation.kind == TranslationKind::RestToSoap
6336 && soap.namespace.trim().is_empty()
6337 {
6338 issues.push(issue(
6339 "route",
6340 &r.name,
6341 "translation.soap.namespace",
6342 "rest_to_soap requires a namespace",
6343 ));
6344 }
6345 }
6346 }
6347 }
6348 }
6349 }
6350 let m = &r.r#match.path;
6351 for (field, entries) in [
6352 ("match.query", &r.r#match.query),
6353 ("match.cookies", &r.r#match.cookies),
6354 ] {
6355 for e in entries.iter() {
6356 if e.name.trim().is_empty() {
6357 issues.push(issue("route", &r.name, field, "matcher name is empty"));
6358 }
6359 if let Some(v) = &e.value {
6360 if v.is_empty() {
6361 issues.push(issue(
6362 "route",
6363 &r.name,
6364 field,
6365 format!("matcher '{}' carries an empty value; omit value for presence-only matching", e.name),
6366 ));
6367 }
6368 }
6369 }
6370 }
6371 if !m.value.starts_with('/') {
6372 issues.push(issue(
6373 "route",
6374 &r.name,
6375 "match.path.value",
6376 format!("path pattern '{}' must start with '/'", m.value),
6377 ));
6378 }
6379 if m.kind == PathMatchKind::Prefix && m.value.trim_end_matches('/').is_empty() {
6380 issues.push(issue(
6381 "route",
6382 &r.name,
6383 "match.path.value",
6384 format!(
6385 "prefix '{}' would match every path (it trims to the empty string); \
6386 use a regex route or an explicit catch-all route instead",
6387 m.value
6388 ),
6389 ));
6390 }
6391 match r.action {
6392 RouteAction::Redirect {
6393 status,
6394 ref scheme,
6395 ref host,
6396 path: ref redirect_path,
6397 } => {
6398 if !(300..=399).contains(&status) {
6399 issues.push(issue(
6400 "route",
6401 &r.name,
6402 "action.status",
6403 format!("redirect status {status} is not a 3xx redirect"),
6404 ));
6405 }
6406 if let Some(s) = scheme {
6409 if !s.eq_ignore_ascii_case("http") && !s.eq_ignore_ascii_case("https") {
6410 issues.push(issue(
6411 "route",
6412 &r.name,
6413 "action.scheme",
6414 format!("redirect scheme '{s}' must be http or https"),
6415 ));
6416 }
6417 }
6418 if let Some(h) = host {
6419 if h.is_empty() || !h.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
6420 issues.push(issue(
6421 "route",
6422 &r.name,
6423 "action.host",
6424 format!(
6425 "redirect host '{h}' contains whitespace or control \
6426 characters and cannot form a Location header"
6427 ),
6428 ));
6429 }
6430 }
6431 if let Some(p) = redirect_path {
6432 if !p.is_empty() && !p.starts_with('/') {
6433 issues.push(issue(
6434 "route",
6435 &r.name,
6436 "action.path",
6437 format!("redirect path '{p}' must start with '/' or be empty"),
6438 ));
6439 }
6440 if !p.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
6441 issues.push(issue(
6442 "route",
6443 &r.name,
6444 "action.path",
6445 format!(
6446 "redirect path '{p}' contains control characters and \
6447 cannot form a Location header"
6448 ),
6449 ));
6450 }
6451 }
6452 }
6453 RouteAction::Respond {
6454 status,
6455 ref headers,
6456 ..
6457 } => {
6458 if !(100..=599).contains(&status) {
6459 issues.push(issue(
6460 "route",
6461 &r.name,
6462 "action.status",
6463 format!("respond status {status} is not a valid HTTP status"),
6464 ));
6465 }
6466 for (name, value) in headers {
6467 if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
6468 issues.push(issue(
6469 "route",
6470 &r.name,
6471 "action.headers",
6472 format!("respond header name '{name}' is not a valid HTTP header name"),
6473 ));
6474 }
6475 if hyper::header::HeaderValue::from_str(value.as_str()).is_err() {
6476 issues.push(issue(
6477 "route",
6478 &r.name,
6479 "action.headers",
6480 format!(
6481 "respond header value for '{name}' contains characters that \
6482 cannot appear in an HTTP header value"
6483 ),
6484 ));
6485 }
6486 }
6487 }
6488 RouteAction::Proxy { rewrite: None } => {}
6489 RouteAction::Proxy {
6490 rewrite: Some(ref rw),
6491 } => validate_rewrite(&r.name, rw, &mut issues),
6492 RouteAction::Mock { ref mock } => validate_mock(&r.name, mock, &mut issues),
6493 RouteAction::Ai { .. } => {
6494 if gateway.ai.is_none() {
6498 issues.push(issue(
6499 "route",
6500 &r.name,
6501 "action.type",
6502 "an ai action requires a gateway-level ai block \
6503 (ai.providers + ai.models define what it can serve)",
6504 ));
6505 }
6506 }
6507 RouteAction::NanoService { ref nano } => {
6508 validate_nano_service(&r.name, nano, &mut issues);
6509 }
6510 }
6511 if let Some(rv) = &r.request_validation {
6516 validate_json_schema(
6517 &r.name,
6518 "request_validation.body_schema",
6519 &rv.body_schema,
6520 &mut issues,
6521 );
6522 }
6523 if let Some(m) = &r.mirror {
6525 if m.percentage > 100 {
6526 issues.push(issue(
6527 "route",
6528 &r.name,
6529 "mirror.percentage",
6530 "percentage must be in [0, 100]",
6531 ));
6532 }
6533 if m.upstream.trim().is_empty() {
6534 issues.push(issue(
6535 "route",
6536 &r.name,
6537 "mirror.upstream",
6538 "mirror upstream name must not be empty",
6539 ));
6540 } else if !gateway.upstreams.iter().any(|u| u.name == m.upstream) {
6541 issues.push(issue(
6542 "route",
6543 &r.name,
6544 "mirror.upstream",
6545 format!("mirror upstream '{}' not found in upstreams", m.upstream),
6546 ));
6547 }
6548 }
6549 if let Some(fi) = &r.fault_injection {
6551 if let Some(a) = &fi.abort {
6552 if a.percentage > 100 {
6553 issues.push(issue(
6554 "route",
6555 &r.name,
6556 "fault_injection.abort.percentage",
6557 "percentage must be in [0, 100]",
6558 ));
6559 }
6560 if a.status < crate::config::limits::MIN_FAULT_ABORT_STATUS
6561 || a.status > crate::config::limits::MAX_FAULT_ABORT_STATUS
6562 {
6563 issues.push(issue(
6564 "route",
6565 &r.name,
6566 "fault_injection.abort.status",
6567 format!(
6568 "status must be in [{}, {}]",
6569 crate::config::limits::MIN_FAULT_ABORT_STATUS,
6570 crate::config::limits::MAX_FAULT_ABORT_STATUS
6571 ),
6572 ));
6573 }
6574 }
6575 if let Some(d) = &fi.delay {
6576 if d.percentage > 100 {
6577 issues.push(issue(
6578 "route",
6579 &r.name,
6580 "fault_injection.delay.percentage",
6581 "percentage must be in [0, 100]",
6582 ));
6583 }
6584 if d.fixed_ms == 0 || d.fixed_ms > crate::config::limits::MAX_FAULT_DELAY_MS {
6585 issues.push(issue(
6586 "route",
6587 &r.name,
6588 "fault_injection.delay.fixed_ms",
6589 format!(
6590 "fixed_ms must be in [1, {}]",
6591 crate::config::limits::MAX_FAULT_DELAY_MS
6592 ),
6593 ));
6594 }
6595 }
6596 if fi.abort.is_none() && fi.delay.is_none() {
6599 issues.push(issue(
6600 "route",
6601 &r.name,
6602 "fault_injection",
6603 "fault_injection block must have at least one of abort or delay",
6604 ));
6605 }
6606 }
6607 if let Some(oidc_login) = &r.oidc_login {
6611 if oidc_login.provider.is_empty() {
6612 issues.push(issue(
6613 "route",
6614 &r.name,
6615 "oidc_login.provider",
6616 "oidc_login provider must not be empty",
6617 ));
6618 } else if !gateway
6619 .oidc_providers
6620 .iter()
6621 .any(|p| p.name == oidc_login.provider)
6622 {
6623 issues.push(issue(
6624 "route",
6625 &r.name,
6626 "oidc_login.provider",
6627 format!(
6628 "oidc_login provider '{}' not found in gateway.oidc_providers",
6629 oidc_login.provider
6630 ),
6631 ));
6632 }
6633 if !oidc_login.redirect_uri.starts_with('/') {
6634 issues.push(issue(
6635 "route",
6636 &r.name,
6637 "oidc_login.redirect_uri",
6638 "oidc_login redirect_uri must be a path starting with '/'",
6639 ));
6640 }
6641 if oidc_login.session_ttl_s == 0 {
6642 issues.push(issue(
6643 "route",
6644 &r.name,
6645 "oidc_login.session_ttl_s",
6646 "oidc_login session_ttl_s must be > 0",
6647 ));
6648 }
6649 }
6650 }
6651
6652 for u in &gateway.upstreams {
6653 if u.endpoints.is_empty() && u.dns_discovery.is_none() {
6657 issues.push(issue(
6658 "upstream",
6659 &u.name,
6660 "endpoints",
6661 "upstream has no endpoints",
6662 ));
6663 }
6664 if let Some(dns) = &u.dns_discovery {
6666 if dns.hostname.trim().is_empty() {
6667 issues.push(issue(
6668 "upstream",
6669 &u.name,
6670 "dns_discovery.hostname",
6671 "dns_discovery hostname is empty",
6672 ));
6673 }
6674 if dns.record_type != "A" && dns.record_type != "SRV" {
6675 issues.push(issue(
6676 "upstream",
6677 &u.name,
6678 "dns_discovery.record_type",
6679 "dns_discovery record_type must be \"A\" or \"SRV\"",
6680 ));
6681 }
6682 if dns.refresh_interval_s < crate::config::limits::MIN_DNS_DISCOVERY_REFRESH_S
6683 || dns.refresh_interval_s > crate::config::limits::MAX_DNS_DISCOVERY_REFRESH_S
6684 {
6685 issues.push(issue(
6686 "upstream",
6687 &u.name,
6688 "dns_discovery.refresh_interval_s",
6689 format!(
6690 "dns_discovery refresh_interval_s must be in {}..={}",
6691 crate::config::limits::MIN_DNS_DISCOVERY_REFRESH_S,
6692 crate::config::limits::MAX_DNS_DISCOVERY_REFRESH_S
6693 ),
6694 ));
6695 }
6696 }
6697 if let Some(ca) = &u.trusted_ca_file {
6705 let tls = matches!(
6706 u.protocol,
6707 crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6708 );
6709 if tls {
6710 check_trusted_ca_file("upstream", &u.name, "trusted_ca_file", ca, &mut issues);
6711 } else {
6712 issues.push(issue(
6713 "upstream",
6714 &u.name,
6715 "trusted_ca_file",
6716 "trusted_ca_file only applies to TLS upstreams (protocol https or http2); no \
6717 TLS is negotiated toward an http1 upstream",
6718 ));
6719 }
6720 }
6721 if u.use_system_roots {
6727 let tls = matches!(
6728 u.protocol,
6729 crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6730 );
6731 if !tls {
6732 issues.push(issue(
6733 "upstream",
6734 &u.name,
6735 "use_system_roots",
6736 "use_system_roots only applies to TLS upstreams (protocol https or http2); no \
6737 TLS is negotiated toward an http1 upstream",
6738 ));
6739 }
6740 if u.trusted_ca_file.is_some() {
6741 issues.push(issue(
6742 "upstream",
6743 &u.name,
6744 "use_system_roots",
6745 "use_system_roots and trusted_ca_file are mutually exclusive; pick one trust \
6746 source per upstream",
6747 ));
6748 }
6749 }
6750 if let Some(cp) = &u.cert_pinning {
6756 let tls = matches!(
6757 u.protocol,
6758 crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6759 );
6760 if !tls {
6761 issues.push(issue(
6762 "upstream",
6763 &u.name,
6764 "cert_pinning",
6765 "cert_pinning only applies to TLS upstreams (protocol https or http2); no \
6766 TLS is negotiated toward an http1 upstream",
6767 ));
6768 }
6769 if cp.pins.is_empty() {
6770 issues.push(issue(
6771 "upstream",
6772 &u.name,
6773 "cert_pinning.pins",
6774 "cert_pinning pins list must not be empty (a pin-less pinning block is an \
6775 authoring mistake, not 'pin nothing')",
6776 ));
6777 }
6778 for (i, pin) in cp.pins.iter().enumerate() {
6779 let h = &pin.spki_sha256;
6780 if h.len() != 64
6781 || !h
6782 .chars()
6783 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
6784 {
6785 issues.push(issue(
6786 "upstream",
6787 &u.name,
6788 &format!("cert_pinning.pins[{i}].spki_sha256"),
6789 "spki_sha256 must be 64 lowercase hex chars (SHA-256)",
6790 ));
6791 }
6792 }
6793 }
6794 if let Some(mtls) = &u.mtls {
6799 let tls = matches!(
6800 u.protocol,
6801 crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6802 );
6803 if !tls {
6804 issues.push(issue(
6805 "upstream",
6806 &u.name,
6807 "mtls",
6808 "mtls only applies to TLS upstreams (protocol https or http2); no \
6809 TLS is negotiated toward an http1 upstream",
6810 ));
6811 } else {
6812 if let Err(e) = std::fs::read_to_string(&mtls.client_cert_file) {
6814 issues.push(issue(
6815 "upstream",
6816 &u.name,
6817 "mtls.client_cert_file",
6818 format!(
6819 "client_cert_file '{}' cannot be read: {e}",
6820 mtls.client_cert_file
6821 ),
6822 ));
6823 }
6824 if let Err(e) = std::fs::read_to_string(&mtls.client_key_file) {
6825 issues.push(issue(
6826 "upstream",
6827 &u.name,
6828 "mtls.client_key_file",
6829 format!(
6830 "client_key_file '{}' cannot be read: {e}",
6831 mtls.client_key_file
6832 ),
6833 ));
6834 }
6835 }
6836 }
6837 let mut seen_targets = std::collections::BTreeSet::new();
6838 let mut total_vnodes: u64 = 0;
6839 for (i, e) in u.endpoints.iter().enumerate() {
6840 if e.address.trim().is_empty() {
6841 issues.push(issue(
6842 "upstream",
6843 &u.name,
6844 &format!("endpoints[{i}].address"),
6845 "endpoint address is empty",
6846 ));
6847 }
6848 if e.weight == 0 {
6849 issues.push(issue(
6850 "upstream",
6851 &u.name,
6852 &format!("endpoints[{i}].weight"),
6853 "endpoint weight must be > 0",
6854 ));
6855 }
6856 if !seen_targets.insert((e.address.trim(), e.port)) {
6863 issues.push(issue(
6864 "upstream",
6865 &u.name,
6866 &format!("endpoints[{i}].address"),
6867 format!(
6868 "duplicate endpoint {}/{}: address:port must be unique within an upstream",
6869 e.address.trim(),
6870 e.port
6871 ),
6872 ));
6873 }
6874 total_vnodes += crate::config::limits::KETAMA_VNODES * e.weight.max(1) as u64;
6875 }
6876 if u.load_balancer == crate::config::LoadBalancer::IpHash
6877 && total_vnodes > crate::config::limits::MAX_RING_VNODES
6878 {
6879 issues.push(issue(
6880 "upstream",
6881 &u.name,
6882 "endpoints.weight",
6883 format!(
6884 "ip_hash ring too large: total vnodes (160 * sum of weights) must be at most {}",
6885 crate::config::limits::MAX_RING_VNODES
6886 ),
6887 ));
6888 }
6889 if let Some(hash_on) = &u.hash_on {
6893 if !matches!(
6894 u.load_balancer,
6895 crate::config::LoadBalancer::IpHash | crate::config::LoadBalancer::Maglev
6896 ) {
6897 issues.push(issue(
6898 "upstream",
6899 &u.name,
6900 "hash_on",
6901 format!(
6902 "hash_on only applies to ip_hash or maglev load balancers; \
6903 '{}' does not use a hash key",
6904 match u.load_balancer {
6905 crate::config::LoadBalancer::RoundRobin => "round_robin",
6906 crate::config::LoadBalancer::LeastRequests => "least_requests",
6907 crate::config::LoadBalancer::Random => "random",
6908 crate::config::LoadBalancer::PeakEwma => "peak_ewma",
6909 _ => "this",
6910 }
6911 ),
6912 ));
6913 }
6914 match hash_on {
6916 crate::config::HashOn::Cookie { cookie } => {
6917 if cookie.trim().is_empty() {
6918 issues.push(issue(
6919 "upstream",
6920 &u.name,
6921 "hash_on.cookie",
6922 "hash_on cookie name must be a non-empty string",
6923 ));
6924 }
6925 }
6926 crate::config::HashOn::Header { header } => {
6927 if header.trim().is_empty() {
6928 issues.push(issue(
6929 "upstream",
6930 &u.name,
6931 "hash_on.header",
6932 "hash_on header name must be a non-empty string",
6933 ));
6934 }
6935 }
6936 _ => {}
6937 }
6938 }
6939 if u.connection_cap == Some(0) {
6940 issues.push(issue(
6941 "upstream",
6942 &u.name,
6943 "connection_cap",
6944 "connection_cap must be > 0",
6945 ));
6946 }
6947 if u.max_pending == Some(0) {
6948 issues.push(issue(
6949 "upstream",
6950 &u.name,
6951 "max_pending",
6952 "max_pending must be > 0 (unbounded is expressed by omitting \
6953 the field, so an explicit 0 is rejected as ambiguous)",
6954 ));
6955 }
6956 if let Some(b) = &u.breaker {
6957 for (field, v) in [
6958 (
6959 "breaker.consecutive_failures",
6960 u64::from(b.consecutive_failures),
6961 ),
6962 ("breaker.error_volume", u64::from(b.error_volume)),
6963 ("breaker.open_ms", b.open_ms),
6964 ("breaker.half_open_probes", u64::from(b.half_open_probes)),
6965 ] {
6966 if v == 0 {
6967 issues.push(issue(
6968 "upstream",
6969 &u.name,
6970 field,
6971 "circuit breaker value must be > 0",
6972 ));
6973 }
6974 }
6975 if !b.error_ratio.is_finite() || b.error_ratio <= 0.0 || b.error_ratio > 1.0 {
6976 issues.push(issue(
6977 "upstream",
6978 &u.name,
6979 "breaker.error_ratio",
6980 format!("error_ratio must be in (0, 1]; got {}", b.error_ratio),
6981 ));
6982 }
6983 }
6984 if u.slow_start_ms
6985 .is_some_and(|ms| ms > crate::config::limits::MAX_SLOW_START_MS)
6986 {
6987 issues.push(issue(
6988 "upstream",
6989 &u.name,
6990 "slow_start_ms",
6991 "slow_start_ms must be at most 10 minutes (600000)",
6992 ));
6993 }
6994 if let Some(h) = &u.health {
6995 for (field, v) in [
6999 ("health.window_ms", h.window_ms),
7000 ("health.eject_ms", h.eject_ms),
7001 ] {
7002 if v == 0 {
7003 issues.push(issue(
7004 "upstream",
7005 &u.name,
7006 field,
7007 "passive health value must be > 0",
7008 ));
7009 }
7010 }
7011 for (field, v) in [
7012 ("health.consecutive_failures", h.consecutive_failures),
7013 ("health.failure_min_volume", h.failure_min_volume),
7014 ("health.half_open_probes", h.half_open_probes),
7015 ] {
7016 if v == 0 {
7017 issues.push(issue(
7018 "upstream",
7019 &u.name,
7020 field,
7021 "passive health value must be > 0",
7022 ));
7023 }
7024 }
7025 if !h.failure_ratio.is_finite() || h.failure_ratio <= 0.0 || h.failure_ratio > 1.0 {
7026 issues.push(issue(
7027 "upstream",
7028 &u.name,
7029 "health.failure_ratio",
7030 format!("failure_ratio must be in (0, 1]; got {}", h.failure_ratio),
7031 ));
7032 }
7033 }
7034 if let Some(a) = &u.active_health {
7035 if u.health.is_none() {
7039 issues.push(issue(
7040 "upstream",
7041 &u.name,
7042 "active_health",
7043 "active health requires the passive `health` block (probe results report \
7044 into the same ejection machinery)",
7045 ));
7046 }
7047 for (field, v) in [
7048 ("active_health.interval_ms", a.interval_ms),
7049 ("active_health.timeout_ms", a.timeout_ms),
7050 ] {
7051 if v == 0 {
7052 issues.push(issue(
7053 "upstream",
7054 &u.name,
7055 field,
7056 "active health value must be > 0",
7057 ));
7058 }
7059 }
7060 for (field, v) in [
7062 ("active_health.success_threshold", a.success_threshold),
7063 ("active_health.failure_threshold", a.failure_threshold),
7064 ] {
7065 if v == 0 {
7066 issues.push(issue(
7067 "upstream",
7068 &u.name,
7069 field,
7070 "active health threshold must be > 0",
7071 ));
7072 }
7073 }
7074 if a.timeout_ms > a.interval_ms {
7075 issues.push(issue(
7076 "upstream",
7077 &u.name,
7078 "active_health.timeout_ms",
7079 "timeout_ms must be <= interval_ms (an overlapping probe would pile up)",
7080 ));
7081 }
7082 if a.jitter_ms > a.interval_ms {
7083 issues.push(issue(
7084 "upstream",
7085 &u.name,
7086 "active_health.jitter_ms",
7087 "jitter_ms must be <= interval_ms",
7088 ));
7089 }
7090 if a.kind == crate::config::ProbeKind::Http
7091 && (!a.path.starts_with('/') || a.path.bytes().any(|b| b.is_ascii_control()))
7092 {
7093 issues.push(issue(
7094 "upstream",
7095 &u.name,
7096 "active_health.path",
7097 format!(
7098 "probe path '{}' must start with '/' and contain no control characters",
7099 a.path
7100 ),
7101 ));
7102 }
7103 }
7104 if let Some(t) = &u.timeouts {
7105 for (field, v) in [
7106 ("connect_ms", t.connect_ms),
7107 ("read_ms", t.read_ms),
7108 ("write_ms", t.write_ms),
7109 ] {
7110 if v == Some(0) {
7111 issues.push(issue(
7112 "upstream",
7113 &u.name,
7114 &format!("timeouts.{field}"),
7115 "timeout must be > 0 milliseconds",
7116 ));
7117 }
7118 }
7119 if t.happy_eyeballs_ms
7123 .is_some_and(|ms| ms > crate::config::limits::MAX_HAPPY_EYEBALLS_MS)
7124 {
7125 issues.push(issue(
7126 "upstream",
7127 &u.name,
7128 "timeouts.happy_eyeballs_ms",
7129 format!(
7130 "happy_eyeballs_ms must be at most {} (0 disables racing)",
7131 crate::config::limits::MAX_HAPPY_EYEBALLS_MS
7132 ),
7133 ));
7134 }
7135 }
7136 if let Some(p) = &u.pool {
7142 if let Some(ms) = p.pool_idle_timeout_ms {
7143 if ms == 0 {
7144 issues.push(issue(
7145 "upstream",
7146 &u.name,
7147 "pool.pool_idle_timeout_ms",
7148 "pool_idle_timeout_ms must be > 0 (omit the field for the hyper default)",
7149 ));
7150 } else if ms > crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS {
7151 issues.push(issue(
7152 "upstream",
7153 &u.name,
7154 "pool.pool_idle_timeout_ms",
7155 format!(
7156 "pool_idle_timeout_ms must be at most {}",
7157 crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS
7158 ),
7159 ));
7160 }
7161 }
7162 if let Some(n) = p.pool_max_idle_per_host {
7163 if n > crate::config::limits::MAX_POOL_MAX_IDLE_PER_HOST {
7164 issues.push(issue(
7165 "upstream",
7166 &u.name,
7167 "pool.pool_max_idle_per_host",
7168 format!(
7169 "pool_max_idle_per_host must be at most {}",
7170 crate::config::limits::MAX_POOL_MAX_IDLE_PER_HOST
7171 ),
7172 ));
7173 }
7174 }
7175 if let Some(ms) = p.http2_keep_alive_interval_ms {
7176 if ms == 0 {
7177 issues.push(issue(
7178 "upstream",
7179 &u.name,
7180 "pool.http2_keep_alive_interval_ms",
7181 "http2_keep_alive_interval_ms must be > 0 (omit the field to disable PINGs)",
7182 ));
7183 } else if ms > crate::config::limits::MAX_HTTP2_KEEP_ALIVE_INTERVAL_MS {
7184 issues.push(issue(
7185 "upstream",
7186 &u.name,
7187 "pool.http2_keep_alive_interval_ms",
7188 format!(
7189 "http2_keep_alive_interval_ms must be at most {}",
7190 crate::config::limits::MAX_HTTP2_KEEP_ALIVE_INTERVAL_MS
7191 ),
7192 ));
7193 }
7194 }
7195 if let Some(ms) = p.http2_keep_alive_timeout_ms {
7196 if ms == 0 {
7197 issues.push(issue(
7198 "upstream",
7199 &u.name,
7200 "pool.http2_keep_alive_timeout_ms",
7201 "http2_keep_alive_timeout_ms must be > 0 (omit the field for the hyper default)",
7202 ));
7203 } else if ms > crate::config::limits::MAX_HTTP2_KEEP_ALIVE_TIMEOUT_MS {
7204 issues.push(issue(
7205 "upstream",
7206 &u.name,
7207 "pool.http2_keep_alive_timeout_ms",
7208 format!(
7209 "http2_keep_alive_timeout_ms must be at most {}",
7210 crate::config::limits::MAX_HTTP2_KEEP_ALIVE_TIMEOUT_MS
7211 ),
7212 ));
7213 }
7214 }
7215 if let Some(n) = p.max_concurrent_streams {
7216 if n == 0 {
7217 issues.push(issue(
7218 "upstream",
7219 &u.name,
7220 "pool.max_concurrent_streams",
7221 "max_concurrent_streams must be > 0 (omit the field for the hyper default)",
7222 ));
7223 } else if n > crate::config::limits::MAX_POOL_MAX_CONCURRENT_STREAMS {
7224 issues.push(issue(
7225 "upstream",
7226 &u.name,
7227 "pool.max_concurrent_streams",
7228 format!(
7229 "max_concurrent_streams must be at most {}",
7230 crate::config::limits::MAX_POOL_MAX_CONCURRENT_STREAMS
7231 ),
7232 ));
7233 }
7234 }
7235 if let Some(n) = p.pre_warm {
7237 if n > 64 {
7238 issues.push(issue(
7239 "upstream",
7240 &u.name,
7241 "pool.pre_warm",
7242 "pre_warm must be at most 64 per endpoint",
7243 ));
7244 }
7245 }
7246 if let Some(n) = p.per_endpoint_cap {
7247 if n == 0 {
7248 issues.push(issue(
7249 "upstream",
7250 &u.name,
7251 "pool.per_endpoint_cap",
7252 "per_endpoint_cap must be > 0 (omit the field for no per-endpoint cap)",
7253 ));
7254 } else if n > 1024 {
7255 issues.push(issue(
7256 "upstream",
7257 &u.name,
7258 "pool.per_endpoint_cap",
7259 "per_endpoint_cap must be at most 1024",
7260 ));
7261 }
7262 }
7263 if let Some(ms) = p.max_connection_age_ms {
7264 if ms == 0 {
7265 issues.push(issue(
7266 "upstream",
7267 &u.name,
7268 "pool.max_connection_age_ms",
7269 "max_connection_age_ms must be > 0 (omit the field for no max-age)",
7270 ));
7271 } else if ms > crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS {
7272 issues.push(issue(
7273 "upstream",
7274 &u.name,
7275 "pool.max_connection_age_ms",
7276 format!(
7277 "max_connection_age_ms must be at most {}",
7278 crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS
7279 ),
7280 ));
7281 }
7282 }
7283 }
7284 if let Some(r) = &u.retries {
7285 if r.attempts > crate::config::limits::MAX_RETRY_ATTEMPTS {
7286 issues.push(issue(
7287 "upstream",
7288 &u.name,
7289 "retries.attempts",
7290 format!(
7291 "retries.attempts must be at most {} (retries beyond the first attempt)",
7292 crate::config::limits::MAX_RETRY_ATTEMPTS
7293 ),
7294 ));
7295 }
7296 if r.backoff_base_ms == 0 {
7297 issues.push(issue(
7298 "upstream",
7299 &u.name,
7300 "retries.backoff_base_ms",
7301 "backoff_base_ms must be > 0",
7302 ));
7303 }
7304 if r.backoff_cap_ms < r.backoff_base_ms {
7305 issues.push(issue(
7306 "upstream",
7307 &u.name,
7308 "retries.backoff_cap_ms",
7309 "backoff_cap_ms must be >= backoff_base_ms",
7310 ));
7311 }
7312 if r.budget_percent == 0 || r.budget_percent > 100 {
7313 issues.push(issue(
7314 "upstream",
7315 &u.name,
7316 "retries.budget_percent",
7317 "budget_percent must be in (0, 100]",
7318 ));
7319 }
7320 if let Some(td) = r.total_deadline_ms {
7324 if td == 0 {
7325 issues.push(issue(
7326 "upstream",
7327 &u.name,
7328 "retries.total_deadline_ms",
7329 "total_deadline_ms must be > 0 (omit the field for unbounded)",
7330 ));
7331 } else if td > crate::config::limits::MAX_RETRY_TOTAL_DEADLINE_MS {
7332 issues.push(issue(
7333 "upstream",
7334 &u.name,
7335 "retries.total_deadline_ms",
7336 format!(
7337 "total_deadline_ms must be at most {}",
7338 crate::config::limits::MAX_RETRY_TOTAL_DEADLINE_MS
7339 ),
7340 ));
7341 }
7342 }
7343 for (i, status) in r.retry_statuses.iter().enumerate() {
7344 if !(400..=599).contains(status) {
7345 issues.push(issue(
7346 "upstream",
7347 &u.name,
7348 &format!("retries.retry_statuses[{i}]"),
7349 format!("retry status {status} is not a 4xx/5xx status"),
7350 ));
7351 }
7352 }
7353 if let Some(h) = &r.hedge {
7355 if h.hedge_after_ms < crate::config::limits::MIN_HEDGE_AFTER_MS
7356 || h.hedge_after_ms > crate::config::limits::MAX_HEDGE_AFTER_MS
7357 {
7358 issues.push(issue(
7359 "upstream",
7360 &u.name,
7361 "retries.hedge.hedge_after_ms",
7362 format!(
7363 "hedge_after_ms must be in [{}, {}]",
7364 crate::config::limits::MIN_HEDGE_AFTER_MS,
7365 crate::config::limits::MAX_HEDGE_AFTER_MS
7366 ),
7367 ));
7368 }
7369 if h.hedge_max == 0 || h.hedge_max > crate::config::limits::MAX_HEDGE_COPIES {
7370 issues.push(issue(
7371 "upstream",
7372 &u.name,
7373 "retries.hedge.hedge_max",
7374 format!(
7375 "hedge_max must be in [1, {}]",
7376 crate::config::limits::MAX_HEDGE_COPIES
7377 ),
7378 ));
7379 }
7380 if r.buffer_max_bytes == 0 {
7382 issues.push(issue(
7383 "upstream",
7384 &u.name,
7385 "retries.hedge",
7386 "hedging requires retries.buffer_max_bytes > 0 (the body must be \
7387 replayable to send a hedge copy)",
7388 ));
7389 }
7390 }
7391 }
7392 if let Some(o) = &u.oauth2_client_credentials {
7394 match o.token_endpoint.parse::<hyper::Uri>() {
7396 Ok(uri) => {
7397 if !matches!(uri.scheme_str(), Some("http") | Some("https"))
7398 || uri.host().is_none()
7399 {
7400 issues.push(issue(
7401 "upstream",
7402 &u.name,
7403 "oauth2_client_credentials.token_endpoint",
7404 format!("'{}' must be an absolute http(s) URL", o.token_endpoint),
7405 ));
7406 }
7407 }
7408 Err(_) => issues.push(issue(
7409 "upstream",
7410 &u.name,
7411 "oauth2_client_credentials.token_endpoint",
7412 format!("'{}' is not a valid URL", o.token_endpoint),
7413 )),
7414 }
7415 if o.client_id.is_empty() {
7416 issues.push(issue(
7417 "upstream",
7418 &u.name,
7419 "oauth2_client_credentials.client_id",
7420 "client_id must not be empty",
7421 ));
7422 }
7423 if o.client_secret.is_empty() {
7424 issues.push(issue(
7425 "upstream",
7426 &u.name,
7427 "oauth2_client_credentials.client_secret",
7428 "client_secret must not be empty",
7429 ));
7430 }
7431 if let Some(ttl) = o.token_cache_ttl_s {
7432 if ttl == 0 {
7433 issues.push(issue(
7434 "upstream",
7435 &u.name,
7436 "oauth2_client_credentials.token_cache_ttl_s",
7437 "token_cache_ttl_s must be > 0",
7438 ));
7439 }
7440 }
7441 if let Some(m) = &o.mtls {
7446 check_file_readable(
7447 "upstream",
7448 &u.name,
7449 "oauth2_client_credentials.mtls.client_cert",
7450 &m.client_cert,
7451 &mut issues,
7452 );
7453 check_file_readable(
7454 "upstream",
7455 &u.name,
7456 "oauth2_client_credentials.mtls.client_key",
7457 &m.client_key,
7458 &mut issues,
7459 );
7460 }
7461 }
7462 if let Some(pe) = &u.peak_ewma {
7464 if let Some(decay) = pe.decay_ms {
7465 if decay == 0 {
7466 issues.push(issue(
7467 "upstream",
7468 &u.name,
7469 "peak_ewma.decay_ms",
7470 "peak_ewma.decay_ms must be > 0 (the EWMA decay window)",
7471 ));
7472 }
7473 }
7474 if let Some(rtt) = pe.default_rtt_ms {
7475 if rtt == 0 {
7476 issues.push(issue(
7477 "upstream",
7478 &u.name,
7479 "peak_ewma.default_rtt_ms",
7480 "peak_ewma.default_rtt_ms must be > 0 (the initial cost estimate)",
7481 ));
7482 }
7483 }
7484 }
7485 if let Some(loc) = &u.locality {
7488 if loc.enforce_data_residency && loc.allowed_regions.is_empty() {
7491 issues.push(issue(
7492 "upstream",
7493 &u.name,
7494 "locality.allowed_regions",
7495 "locality.allowed_regions must be non-empty when \
7496 enforce_data_residency is true",
7497 ));
7498 }
7499 for (i, ep) in u.endpoints.iter().enumerate() {
7501 if let Some(r) = &ep.region {
7502 if r.is_empty() {
7503 issues.push(issue(
7504 "upstream",
7505 &u.name,
7506 &format!("endpoints[{i}].region"),
7507 "endpoint region must be a non-empty string",
7508 ));
7509 }
7510 }
7511 if let Some(z) = &ep.zone {
7512 if z.is_empty() {
7513 issues.push(issue(
7514 "upstream",
7515 &u.name,
7516 &format!("endpoints[{i}].zone"),
7517 "endpoint zone must be a non-empty string",
7518 ));
7519 }
7520 }
7521 }
7522 if loc.enforce_data_residency {
7526 let has_satisfiable = u.endpoints.iter().any(|ep| {
7527 ep.region
7528 .as_ref()
7529 .is_none_or(|r| loc.allowed_regions.contains(r))
7530 });
7531 if !has_satisfiable {
7532 issues.push(issue(
7533 "upstream",
7534 &u.name,
7535 "locality.allowed_regions",
7536 "no endpoint satisfies the allowed_regions constraint \
7537 (every endpoint has a region not in the allowed list)",
7538 ));
7539 }
7540 }
7541 for d in &loc.denied_regions {
7543 if loc.allowed_regions.contains(d) {
7544 issues.push(issue(
7545 "upstream",
7546 &u.name,
7547 "locality.denied_regions",
7548 format!(
7549 "region '{}' appears in both allowed_regions and \
7550 denied_regions (contradictory)",
7551 d
7552 ),
7553 ));
7554 }
7555 }
7556 }
7557 }
7558
7559 if let Some(m) = &gateway.mtls_consumer_mapping {
7561 if m.enabled {
7562 let consumer_names: std::collections::BTreeSet<&str> =
7563 gateway.consumers.iter().map(|c| c.name.as_str()).collect();
7564 for (i, entry) in m.consumers.iter().enumerate() {
7565 let stripped = entry.fingerprint.replace(':', "");
7568 if stripped.len() != 64 || !stripped.bytes().all(|b| b.is_ascii_hexdigit()) {
7569 issues.push(issue(
7570 "gateway",
7571 "(root)",
7572 &format!("mtls_consumer_mapping.consumers[{i}].fingerprint"),
7573 format!(
7574 "fingerprint '{}' must be 64 hex digits with optional ':' separators \
7575 (SHA-256 of the certificate DER)",
7576 entry.fingerprint
7577 ),
7578 ));
7579 }
7580 if !consumer_names.contains(entry.consumer.as_str()) {
7581 issues.push(issue(
7582 "gateway",
7583 "(root)",
7584 &format!("mtls_consumer_mapping.consumers[{i}].consumer"),
7585 format!(
7586 "consumer '{}' is not defined in the consumers list",
7587 entry.consumer
7588 ),
7589 ));
7590 }
7591 }
7592 for (cn, consumer) in &m.subject_cn_mapping {
7593 if cn.is_empty() {
7594 issues.push(issue(
7595 "gateway",
7596 "(root)",
7597 "mtls_consumer_mapping.subject_cn_mapping",
7598 "subject CN key must not be empty",
7599 ));
7600 }
7601 if !consumer_names.contains(consumer.as_str()) {
7602 issues.push(issue(
7603 "gateway",
7604 "(root)",
7605 "mtls_consumer_mapping.subject_cn_mapping",
7606 format!("consumer '{consumer}' is not defined in the consumers list"),
7607 ));
7608 }
7609 }
7610 }
7611 }
7612
7613 if let Some(f) = &gateway.mtls_forward_headers {
7615 if f.enabled {
7616 let prefix = f.prefix.as_str();
7619 if prefix.is_empty() {
7620 issues.push(issue(
7621 "gateway",
7622 "(root)",
7623 "mtls_forward_headers.prefix",
7624 "prefix must not be empty",
7625 ));
7626 }
7627 for suffix in &["Fingerprint", "Subject-CN", "Issuer-CN", "Not-After"] {
7629 let name = format!("{prefix}-{suffix}");
7630 if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
7631 issues.push(issue(
7632 "gateway",
7633 "(root)",
7634 "mtls_forward_headers.prefix",
7635 format!("'{name}' is not a valid HTTP header name"),
7636 ));
7637 }
7638 }
7639 }
7640 }
7641
7642 let mut hmac_key_ids: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
7646 for c in &gateway.consumers {
7647 if c.name.is_empty() || !c.name.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
7652 issues.push(issue(
7653 "consumer",
7654 &c.name,
7655 "name",
7656 "consumer name must be non-empty visible ASCII (0x21-0x7E): it is injected \
7657 upstream as the X-Consumer-Name header value",
7658 ));
7659 }
7660 if let Some(p) = c.priority {
7661 if p > 10 {
7662 issues.push(issue(
7663 "consumer",
7664 &c.name,
7665 "priority",
7666 format!("priority {p} is out of range: must be 0 (lowest) to 10 (highest)"),
7667 ));
7668 }
7669 }
7670 for (i, cred) in c.credentials.iter().enumerate() {
7671 let field = format!("credentials[{i}]");
7672 if let Credential::Hmac { key_id, .. } = cred {
7675 match hmac_key_ids.get_key_value(key_id.as_str()) {
7676 Some((_, other_consumer)) => {
7677 issues.push(issue(
7678 "consumer",
7679 &c.name,
7680 &field,
7681 format!(
7682 "hmac key_id '{key_id}' is already declared by consumer \
7683 '{other_consumer}' (key ids select the credential a signed \
7684 request presents; they must be unique)"
7685 ),
7686 ));
7687 }
7688 None => {
7689 hmac_key_ids.insert(key_id.as_str(), c.name.as_str());
7690 }
7691 }
7692 }
7693 let problem = match cred {
7694 Credential::ApiKey { key } if key.is_empty() => {
7695 Some("api key is empty".to_string())
7696 }
7697 Credential::ApiKey { key } => {
7705 match crate::config::credentials::parse_secret_reference(key) {
7706 None => None,
7707 Some(Ok(reference)) => reference.resolve().err(),
7708 Some(Err(malformed)) => Some(malformed),
7709 }
7710 }
7711 Credential::Hmac { key_id, secret } => {
7720 if key_id.is_empty() {
7721 Some("hmac key_id is empty".to_string())
7722 } else if !key_id.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
7723 Some(
7724 "hmac key_id must be visible ASCII (0x21-0x7E): it is presented \
7725 as the X-Dwara-Key-Id header value"
7726 .to_string(),
7727 )
7728 } else if key_id.len() > 128 {
7729 Some("hmac key_id is longer than 128 bytes".to_string())
7730 } else if secret.is_empty() {
7731 Some("hmac secret is empty".to_string())
7732 } else {
7733 match crate::config::credentials::parse_secret_reference(secret) {
7734 None => None,
7735 Some(Ok(reference)) => reference.resolve().err(),
7736 Some(Err(malformed)) => Some(malformed),
7737 }
7738 }
7739 }
7740 Credential::Jwt { issuer, .. } if issuer.is_empty() => {
7741 Some("jwt issuer is empty".to_string())
7742 }
7743 Credential::Mtls {
7749 subject,
7750 fingerprint,
7751 } => {
7752 let s = subject.as_deref().unwrap_or("");
7753 let f = fingerprint.as_deref().unwrap_or("");
7754 if s.is_empty() && f.is_empty() {
7755 Some(
7756 "mtls credential must set subject or fingerprint (both are empty)"
7757 .to_string(),
7758 )
7759 } else if subject.is_some() && s.is_empty() {
7760 Some("mtls subject is empty".to_string())
7761 } else if fingerprint.is_some() && f.is_empty() {
7762 Some("mtls fingerprint is empty".to_string())
7763 } else if subject.is_some() && fingerprint.is_some() {
7764 Some(
7765 "mtls credential must set subject or fingerprint, not both \
7766 (both are set; only the subject would be matched)"
7767 .to_string(),
7768 )
7769 } else {
7770 None
7771 }
7772 }
7773 _ => None,
7774 };
7775 if let Some(m) = problem {
7776 issues.push(issue("consumer", &c.name, &field, m));
7777 }
7778 }
7779 for p in &c.policies {
7780 if !policies.contains(p.as_str()) {
7781 issues.push(issue(
7782 "consumer",
7783 &c.name,
7784 "policies",
7785 format!("references unknown policy '{p}'"),
7786 ));
7787 }
7788 }
7789 if let Some(authz) = &c.authorization {
7792 validate_authz(
7793 "consumer",
7794 &c.name,
7795 authz,
7796 &consumers,
7797 &consumer_groups,
7798 &mut issues,
7799 );
7800 }
7801 if !c.tool_allowlist.is_empty() {
7805 let mcp_tools: Option<std::collections::BTreeSet<&str>> = gateway
7806 .ai
7807 .as_ref()
7808 .and_then(|ai| ai.mcp.as_ref())
7809 .map(|mcp| mcp.tools.keys().map(|s| s.as_str()).collect());
7810 match mcp_tools {
7811 None => {
7812 issues.push(issue(
7813 "consumer",
7814 &c.name,
7815 "tool_allowlist",
7816 "tool_allowlist is non-empty but no ai.mcp block is configured \
7817 (the tools do not exist)",
7818 ));
7819 }
7820 Some(tool_names) => {
7821 for tool in &c.tool_allowlist {
7822 if !tool_names.contains(tool.as_str()) {
7823 issues.push(issue(
7824 "consumer",
7825 &c.name,
7826 "tool_allowlist",
7827 format!(
7828 "tool_allowlist references unknown tool '{tool}' \
7829 (not in ai.mcp.tools)"
7830 ),
7831 ));
7832 }
7833 }
7834 }
7835 }
7836 }
7837 if let Some(tb) = &c.token_budget {
7840 if tb.tokens_per_min.is_none() && tb.cost_per_day_micros.is_none() {
7841 issues.push(issue(
7842 "consumer",
7843 &c.name,
7844 "token_budget",
7845 "a token_budget must set tokens_per_min and/or cost_per_day_micros \
7846 (an empty budget bounds nothing)",
7847 ));
7848 }
7849 if tb.tokens_per_min == Some(0) {
7850 issues.push(issue(
7851 "consumer",
7852 &c.name,
7853 "token_budget.tokens_per_min",
7854 "tokens_per_min must be > 0 (omit the field for no token window)",
7855 ));
7856 }
7857 if tb.cost_per_day_micros == Some(0) {
7858 issues.push(issue(
7859 "consumer",
7860 &c.name,
7861 "token_budget.cost_per_day_micros",
7862 "cost_per_day_micros must be > 0 (omit the field for no cost window)",
7863 ));
7864 }
7865 }
7866 }
7867
7868 for p in &gateway.policies {
7869 if let Some(rl) = &p.rate_limit {
7870 if rl.requests == 0 {
7871 issues.push(issue(
7872 "policy",
7873 &p.name,
7874 "rate_limit.requests",
7875 "rate limit requests must be > 0 (0 would block every request)",
7876 ));
7877 }
7878 if rl.window_seconds == 0 {
7879 issues.push(issue(
7880 "policy",
7881 &p.name,
7882 "rate_limit.window_seconds",
7883 "rate limit window_seconds must be > 0 (0 is a nonsensical window)",
7884 ));
7885 }
7886 }
7887 for (i, rule) in p.rate_limits.iter().enumerate() {
7888 let field = format!("rate_limits[{i}]");
7889 if rule.selector.is_empty() {
7890 issues.push(issue(
7891 "policy",
7892 &p.name,
7893 &format!("{field}.selector"),
7894 "rate limit selector must name at least one key component",
7895 ));
7896 }
7897 let rp = &rule.requests_per;
7898 if rp.per_second.is_none() && rp.minute.is_none() && rp.hour.is_none() {
7899 issues.push(issue(
7900 "policy",
7901 &p.name,
7902 &format!("{field}.requests_per"),
7903 "rate limit rule must set at least one window (s, minute, hour)",
7904 ));
7905 }
7906 for (window, value) in [
7907 ("s", rp.per_second),
7908 ("minute", rp.minute),
7909 ("hour", rp.hour),
7910 ] {
7911 if value == Some(0) {
7912 issues.push(issue(
7913 "policy",
7914 &p.name,
7915 &format!("{field}.requests_per.{window}"),
7916 "rate limit window rate must be > 0 (0 would block every request)",
7917 ));
7918 }
7919 }
7920 if rule.burst == Some(0) {
7921 issues.push(issue(
7922 "policy",
7923 &p.name,
7924 &format!("{field}.burst"),
7925 "rate limit burst must be >= 1 when present (omit for the default)",
7926 ));
7927 }
7928 }
7929 if let Some(an) = &p.anomaly {
7931 if an.enabled && an.signals.is_empty() {
7932 issues.push(issue(
7933 "policy",
7934 &p.name,
7935 "anomaly.signals",
7936 "anomaly scoring is enabled but no signals are configured (at least \
7937 one signal is required)",
7938 ));
7939 }
7940 if !an.threshold.is_finite() || an.threshold <= 0.0 || an.threshold > 1.0 {
7941 issues.push(issue(
7942 "policy",
7943 &p.name,
7944 "anomaly.threshold",
7945 format!("anomaly.threshold must be in (0, 1]; got {}", an.threshold),
7946 ));
7947 }
7948 if an.max_body_inspect_bytes == 0 {
7949 issues.push(issue(
7950 "policy",
7951 &p.name,
7952 "anomaly.max_body_inspect_bytes",
7953 "anomaly.max_body_inspect_bytes must be > 0 (the body_size signal \
7954 cap; use a large value to effectively disable the cap)",
7955 ));
7956 }
7957 }
7958 if let Some(ad) = &p.adaptive {
7960 if ad.ewma_window_secs == 0 {
7961 issues.push(issue(
7962 "policy",
7963 &p.name,
7964 "adaptive.ewma_window_secs",
7965 "adaptive.ewma_window_secs must be > 0 (0 is a nonsensical decay window)",
7966 ));
7967 }
7968 if !ad.min_factor.is_finite() || ad.min_factor <= 0.0 {
7969 issues.push(issue(
7970 "policy",
7971 &p.name,
7972 "adaptive.min_factor",
7973 format!(
7974 "adaptive.min_factor must be > 0 (never 0); got {}",
7975 ad.min_factor
7976 ),
7977 ));
7978 }
7979 if !ad.max_factor.is_finite() || ad.max_factor <= 0.0 {
7980 issues.push(issue(
7981 "policy",
7982 &p.name,
7983 "adaptive.max_factor",
7984 format!(
7985 "adaptive.max_factor must be > 0 (never unbounded); got {}",
7986 ad.max_factor
7987 ),
7988 ));
7989 }
7990 if ad.min_factor.is_finite()
7991 && ad.max_factor.is_finite()
7992 && ad.min_factor > 0.0
7993 && ad.max_factor > 0.0
7994 && ad.min_factor > ad.max_factor
7995 {
7996 issues.push(issue(
7997 "policy",
7998 &p.name,
7999 "adaptive.min_factor",
8000 format!(
8001 "adaptive.min_factor ({}) must be <= adaptive.max_factor ({})",
8002 ad.min_factor, ad.max_factor
8003 ),
8004 ));
8005 }
8006 if !ad.error_threshold.is_finite()
8007 || ad.error_threshold <= 0.0
8008 || ad.error_threshold > 1.0
8009 {
8010 issues.push(issue(
8011 "policy",
8012 &p.name,
8013 "adaptive.error_threshold",
8014 format!(
8015 "adaptive.error_threshold must be in (0, 1]; got {}",
8016 ad.error_threshold
8017 ),
8018 ));
8019 }
8020 if ad.latency_threshold_ms == 0 {
8021 issues.push(issue(
8022 "policy",
8023 &p.name,
8024 "adaptive.latency_threshold_ms",
8025 "adaptive.latency_threshold_ms must be > 0 (0 would mark every \
8026 response as slow)",
8027 ));
8028 }
8029 }
8030 }
8031
8032 issues
8033}
8034
8035fn validate_fips(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
8055 if !crate::security::fips::FipsMode::current().is_enabled() {
8056 return;
8057 }
8058 {
8059 use crate::security::fips;
8060
8061 for l in &gateway.listeners {
8069 if l.protocol != ListenerProtocol::Https {
8070 continue;
8071 }
8072 let Some(tls) = &l.tls else { continue };
8073 if tls.mode != TlsMode::Terminate {
8074 continue;
8075 }
8076 let mut cert_paths: Vec<&str> = Vec::new();
8078 if let Some(cf) = &tls.cert_file {
8079 cert_paths.push(cf.as_str());
8080 }
8081 for c in &tls.certificates {
8082 cert_paths.push(c.cert_file.as_str());
8083 }
8084 for cf in &cert_paths {
8085 if let Some(algo) = spki_algorithm_of_cert_file(cf) {
8086 if fips::is_signature_disallowed(&algo) {
8087 issues.push(issue(
8088 "listener",
8089 &l.name,
8090 "tls.cert_file",
8091 format!(
8092 "FIPS mode rejects certificate '{cf}': the leaf certificate's \
8093 signature algorithm '{algo}' is not on the FIPS-approved \
8094 allowlist (Ed25519 is not FIPS-validated in aws-lc-rs; use \
8095 RSA-PSS or ECDSA P-256/P-384 certificates instead)"
8096 ),
8097 ));
8098 }
8099 }
8100 }
8101 }
8102
8103 if let Some(admin) = &gateway.admin {
8105 if let Some(algo) = spki_algorithm_of_cert_file(&admin.tls.cert_file) {
8106 if fips::is_signature_disallowed(&algo) {
8107 issues.push(issue(
8108 "admin",
8109 "(root)",
8110 "tls.cert_file",
8111 format!(
8112 "FIPS mode rejects admin certificate '{}': the leaf certificate's \
8113 signature algorithm '{}' is not on the FIPS-approved allowlist \
8114 (Ed25519 is not FIPS-validated in aws-lc-rs; use RSA-PSS or \
8115 ECDSA P-256/P-384 certificates instead)",
8116 admin.tls.cert_file, algo
8117 ),
8118 ));
8119 }
8120 }
8121 }
8122
8123 for c in &gateway.consumers {
8130 for (i, cred) in c.credentials.iter().enumerate() {
8131 let field = format!("credentials[{i}]");
8132 let _ = cred;
8139 let _ = &field;
8140 }
8141 }
8142 }
8143}
8144
8145fn validate_pq(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
8161 use crate::config::{TlsMode, UpstreamProtocol};
8162 use crate::security::pq::PqMode;
8163
8164 let pq_feature = PqMode::current();
8165 let fips_active = crate::security::fips::FipsMode::current().is_enabled();
8166
8167 for l in &gateway.listeners {
8169 if let Some(tls) = &l.tls {
8170 if tls.pq {
8171 if tls.mode == TlsMode::Passthrough {
8174 issues.push(issue(
8175 "listener",
8176 &l.name,
8177 "tls.pq",
8178 "pq: true is only meaningful in tls.mode terminate (passthrough does \
8179 not terminate TLS, so the kx group list is irrelevant)",
8180 ));
8181 continue;
8182 }
8183 if fips_active {
8185 issues.push(issue(
8186 "listener",
8187 &l.name,
8188 "tls.pq",
8189 "pq: true is incompatible with FIPS mode: ML-KEM is not on the \
8190 FIPS-validated list for aws-lc-rs (the two features must not combine \
8191 unless both algorithms are on a validated list)",
8192 ));
8193 continue;
8194 }
8195 if !pq_feature.is_enabled() {
8197 issues.push(issue(
8198 "listener",
8199 &l.name,
8200 "tls.pq",
8201 "pq: true is configured but the `pq` cargo feature is OFF — PQ hybrid \
8202 key exchange is inert (no kx group is prepended). Build with \
8203 --features pq to enable post-quantum TLS",
8204 ));
8205 }
8206 }
8207 }
8208 }
8209
8210 for u in &gateway.upstreams {
8212 if u.pq {
8213 if matches!(u.protocol, UpstreamProtocol::Http1 | UpstreamProtocol::H2c) {
8215 issues.push(issue(
8216 "upstream",
8217 &u.name,
8218 "pq",
8219 "pq: true is only meaningful for https or http2 upstreams (http1 and h2c do \
8220 not negotiate TLS, so the kx group list is irrelevant)",
8221 ));
8222 continue;
8223 }
8224 if fips_active {
8226 issues.push(issue(
8227 "upstream",
8228 &u.name,
8229 "pq",
8230 "pq: true is incompatible with FIPS mode: ML-KEM is not on the \
8231 FIPS-validated list for aws-lc-rs (the two features must not combine \
8232 unless both algorithms are on a validated list)",
8233 ));
8234 continue;
8235 }
8236 if !pq_feature.is_enabled() {
8238 issues.push(issue(
8239 "upstream",
8240 &u.name,
8241 "pq",
8242 "pq: true is configured but the `pq` cargo feature is OFF — PQ hybrid \
8243 key exchange is inert (no kx group is prepended). Build with \
8244 --features pq to enable post-quantum TLS",
8245 ));
8246 }
8247 }
8248 }
8249}
8250
8251fn spki_algorithm_of_cert_file(path: &str) -> Option<String> {
8258 use rustls_pki_types::pem::PemObject;
8259
8260 let certs = rustls_pki_types::CertificateDer::pem_file_iter(path)
8261 .ok()?
8262 .collect::<Result<Vec<_>, _>>()
8263 .ok()?;
8264 let leaf = certs.first()?;
8265 spki_algorithm_of_leaf(leaf)
8266}
8267
8268fn spki_algorithm_of_leaf(cert: &rustls_pki_types::CertificateDer<'_>) -> Option<String> {
8273 let (tag, cert_content, _) = der_elem_local(cert.as_ref())?;
8275 if tag != 0x30 {
8276 return None;
8277 }
8278 let (tag, tbs, _) = der_elem_local(cert_content)?;
8279 if tag != 0x30 {
8280 return None;
8281 }
8282 let mut rest = tbs;
8283 if let Some((tag, _, tail)) = der_elem_local(rest) {
8285 if tag & 0xc0 == 0x80 {
8286 rest = tail;
8287 }
8288 }
8289 for _ in 0..5 {
8291 let (_, _, tail) = der_elem_local(rest)?;
8292 rest = tail;
8293 }
8294 let (tag, spki_content, _) = der_elem_local(rest)?;
8296 if tag != 0x30 {
8297 return None;
8298 }
8299 let (tag, algo_content, _) = der_elem_local(spki_content)?;
8301 if tag != 0x30 {
8302 return None;
8303 }
8304 let (oid_tag, oid, _) = der_elem_local(algo_content)?;
8306 if oid_tag != 0x06 {
8307 return None;
8308 }
8309 if oid == [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01] {
8319 Some("ecdsa".to_string())
8322 } else if oid == [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01] {
8323 Some("rsa".to_string())
8325 } else if oid == [0x2b, 0x65, 0x70] {
8326 Some("ed25519".to_string())
8328 } else {
8329 Some(format!("unknown:{}", hex_oid(oid)))
8334 }
8335}
8336
8337fn hex_oid(bytes: &[u8]) -> String {
8340 const TABLE: &[u8; 16] = b"0123456789abcdef";
8341 let mut out = String::with_capacity(bytes.len() * 2);
8342 for b in bytes {
8343 out.push(TABLE[usize::from(*b >> 4)] as char);
8344 out.push(TABLE[usize::from(*b & 0x0f)] as char);
8345 }
8346 out
8347}
8348
8349fn der_elem_local(buf: &[u8]) -> Option<(u8, &[u8], &[u8])> {
8355 let tag = *buf.first()?;
8356 let mut i = 1usize;
8357 let first = *buf.get(i)?;
8358 let len = if first < 0x80 {
8359 i += 1;
8360 first as usize
8361 } else {
8362 let n = (first & 0x7f) as usize;
8363 if n == 0 || n > 8 {
8364 return None;
8365 }
8366 i += 1;
8367 let mut l = 0usize;
8368 for &b in buf.get(i..i + n)? {
8369 l = (l << 8) | b as usize;
8370 }
8371 i += n;
8372 l
8373 };
8374 let end = len.checked_add(i).filter(|end| *end <= buf.len())?;
8375 Some((tag, &buf[i..end], &buf[end..]))
8376}
8377
8378fn validate_rewrite(route: &str, rw: &PathRewrite, issues: &mut Vec<ValidationIssue>) {
8381 match rw {
8382 PathRewrite::StripPrefix {} => {}
8383 PathRewrite::ReplacePrefix {
8384 prefix,
8385 replacement,
8386 } => {
8387 if !prefix.starts_with('/') {
8388 issues.push(issue(
8389 "route",
8390 route,
8391 "action.rewrite.prefix",
8392 format!("replace_prefix prefix '{prefix}' must start with '/'"),
8393 ));
8394 }
8395 if !replacement.is_empty() && !replacement.starts_with('/') {
8396 issues.push(issue(
8397 "route",
8398 route,
8399 "action.rewrite.replacement",
8400 format!(
8401 "replace_prefix replacement '{replacement}' must start with '/' or be \
8402 empty (empty turns the prefix into the root path)"
8403 ),
8404 ));
8405 }
8406 }
8407 PathRewrite::Regex {
8408 pattern,
8409 substitution,
8410 } => {
8411 if pattern.is_empty() {
8412 issues.push(issue(
8413 "route",
8414 route,
8415 "action.rewrite.pattern",
8416 "rewrite regex pattern is empty",
8417 ));
8418 }
8419 if substitution.is_empty() {
8420 issues.push(issue(
8421 "route",
8422 route,
8423 "action.rewrite.substitution",
8424 "rewrite substitution is empty (use replace_prefix or strip_prefix to \
8425 remove path material)",
8426 ));
8427 } else {
8428 if !substitution.starts_with(['/', '$', '{']) {
8435 issues.push(issue(
8436 "route",
8437 route,
8438 "action.rewrite.substitution",
8439 format!(
8440 "rewrite substitution '{substitution}' must start with '/' (or a \
8441 capture reference like '$1' / '${{name}}') so it expands to an \
8442 absolute path"
8443 ),
8444 ));
8445 }
8446 if substitution
8447 .chars()
8448 .any(|c| c.is_whitespace() || c == '?' || c == '#' || c.is_control())
8449 {
8450 issues.push(issue(
8451 "route",
8452 route,
8453 "action.rewrite.substitution",
8454 format!(
8455 "rewrite substitution '{substitution}' must not contain \
8456 whitespace, '?', '#', or control characters"
8457 ),
8458 ));
8459 }
8460 }
8461 }
8462 }
8463}
8464
8465fn validate_mock(route: &str, mock: &crate::config::MockAction, issues: &mut Vec<ValidationIssue>) {
8469 if !(100..=599).contains(&mock.status) {
8470 issues.push(issue(
8471 "route",
8472 route,
8473 "action.mock.status",
8474 format!(
8475 "mock status {} is not a valid HTTP status (100..=599)",
8476 mock.status
8477 ),
8478 ));
8479 }
8480 if let Some(delay) = mock.delay_ms {
8481 if delay > 30000 {
8482 issues.push(issue(
8483 "route",
8484 route,
8485 "action.mock.delay_ms",
8486 format!("mock delay_ms {delay} is out of range: must be 0..=30000"),
8487 ));
8488 }
8489 }
8490 if mock.body.is_some() && mock.body_file.is_some() {
8491 issues.push(issue(
8492 "route",
8493 route,
8494 "action.mock",
8495 "mock action sets both 'body' and 'body_file'; set at most one \
8496 (use body for an inline string, body_file for a fixture file)",
8497 ));
8498 }
8499 for (name, value) in &mock.headers {
8500 if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
8501 issues.push(issue(
8502 "route",
8503 route,
8504 "action.mock.headers",
8505 format!("mock header name '{name}' is not a valid HTTP header name"),
8506 ));
8507 }
8508 if hyper::header::HeaderValue::from_str(value.as_str()).is_err() {
8509 issues.push(issue(
8510 "route",
8511 route,
8512 "action.mock.headers",
8513 format!(
8514 "mock header value for '{name}' contains characters that \
8515 cannot appear in an HTTP header value"
8516 ),
8517 ));
8518 }
8519 }
8520 if let Some(path) = &mock.body_file {
8521 if path.trim().is_empty() {
8522 issues.push(issue(
8523 "route",
8524 route,
8525 "action.mock.body_file",
8526 "mock body_file path is empty",
8527 ));
8528 } else if std::fs::read(path).is_err() {
8529 issues.push(issue(
8530 "route",
8531 route,
8532 "action.mock.body_file",
8533 format!("mock body_file '{path}' is not a readable file"),
8534 ));
8535 }
8536 }
8537}
8538
8539fn validate_json_schema(
8543 route: &str,
8544 field: &str,
8545 schema: &crate::config::BodySchema,
8546 issues: &mut Vec<ValidationIssue>,
8547) {
8548 if let Some(t) = &schema.r#type {
8549 if !matches!(
8550 t.as_str(),
8551 "object" | "array" | "string" | "number" | "integer" | "boolean" | "null"
8552 ) {
8553 issues.push(issue(
8554 "route",
8555 route,
8556 field,
8557 format!("json schema type '{t}' is not a known JSON type"),
8558 ));
8559 }
8560 }
8561 if let Some(min) = schema.min_length {
8562 if min > u64::MAX / 4 {
8563 }
8566 }
8567 for (name, child) in &schema.properties {
8568 validate_json_schema(route, &format!("{field}.properties[{name}]"), child, issues);
8569 }
8570 if let Some(items) = &schema.items {
8571 validate_json_schema(route, &format!("{field}.items"), items, issues);
8572 }
8573 if let Some(ap) = &schema.additional_properties {
8574 if let crate::config::AdditionalProperties::Schema(s) = ap.as_ref() {
8575 validate_json_schema(route, &format!("{field}.additionalProperties"), s, issues);
8576 }
8577 }
8578}
8579
8580#[derive(Debug, Default)]
8627struct PrefixTrie {
8628 route_idxs: Vec<usize>,
8635 children: std::collections::HashMap<u8, Box<PrefixTrie>>,
8637}
8638
8639impl PrefixTrie {
8640 fn new() -> Self {
8641 Self::default()
8642 }
8643
8644 fn insert(&mut self, prefix: &str, idx: usize) {
8650 let mut node = self;
8651 for &byte in prefix.as_bytes() {
8652 node = node
8653 .children
8654 .entry(byte)
8655 .or_insert_with(|| Box::new(PrefixTrie::new()));
8656 }
8657 node.route_idxs.push(idx);
8658 }
8659
8660 fn longest_match(&self, path: &str) -> Option<usize> {
8664 let mut node = self;
8665 let mut best = node.route_idxs.first().copied();
8666 for &byte in path.as_bytes() {
8667 match node.children.get(&byte) {
8668 Some(child) => {
8669 node = child;
8670 if let Some(&idx) = node.route_idxs.first() {
8671 best = Some(idx);
8672 }
8673 }
8674 None => break,
8675 }
8676 }
8677 best
8678 }
8679
8680 fn collect_matches(&self, path: &str) -> Vec<&[usize]> {
8689 let mut node = self;
8690 let mut out = Vec::new();
8691 if !node.route_idxs.is_empty() {
8692 out.push(&node.route_idxs[..]);
8693 }
8694 for &byte in path.as_bytes() {
8695 match node.children.get(&byte) {
8696 Some(child) => {
8697 node = child;
8698 if !node.route_idxs.is_empty() {
8699 out.push(&node.route_idxs[..]);
8700 }
8701 }
8702 None => break,
8703 }
8704 }
8705 out
8706 }
8707}
8708
8709#[derive(Debug)]
8710pub struct RouteTable {
8711 exact: matchit::Router<usize>,
8712 #[allow(dead_code)]
8716 prefixes: Vec<(String, usize)>,
8717 prefix_trie: PrefixTrie,
8719 regex_set: regex::RegexSet,
8720 regex_indices: Vec<usize>,
8722 regex_scores: Vec<u32>,
8727 rewrite_regexes: Vec<Option<regex::Regex>>,
8731 rewrite_substitutions: Vec<Option<CompiledSubstitution>>,
8737 cors_origins: Vec<Option<crate::config::CompiledCorsOrigins>>,
8742 compression_types: Vec<Option<crate::config::CompiledContentTypeFilter>>,
8745 deprecations: Vec<Option<crate::config::CompiledDeprecation>>,
8749 accept_media_types: Vec<Option<String>>,
8759 request_body_ops: Vec<Option<crate::config::transforms::CompiledJsonTransform>>,
8766 response_body_ops: Vec<Option<crate::config::transforms::CompiledJsonTransform>>,
8769 masking: Vec<Option<crate::config::transforms::CompiledMasking>>,
8774 caches: Vec<Option<std::sync::Arc<crate::config::cache::CompiledRouteCache>>>,
8778 mock_bodies: Vec<Option<bytes::Bytes>>,
8783 effective_security_headers: Vec<Option<crate::config::transforms::SecurityHeaders>>,
8789}
8790
8791impl RouteTable {
8792 fn empty() -> Self {
8793 RouteTable {
8794 exact: matchit::Router::new(),
8795 prefixes: Vec::new(),
8796 prefix_trie: PrefixTrie::new(),
8797 regex_set: regex::RegexSet::empty(),
8798 regex_indices: Vec::new(),
8799 regex_scores: Vec::new(),
8800 rewrite_regexes: Vec::new(),
8801 rewrite_substitutions: Vec::new(),
8802 cors_origins: Vec::new(),
8803 compression_types: Vec::new(),
8804 deprecations: Vec::new(),
8805 accept_media_types: Vec::new(),
8806 request_body_ops: Vec::new(),
8807 response_body_ops: Vec::new(),
8808 masking: Vec::new(),
8809 caches: Vec::new(),
8810 mock_bodies: Vec::new(),
8811 effective_security_headers: Vec::new(),
8812 }
8813 }
8814
8815 pub fn find(&self, path: &str) -> Option<usize> {
8819 self.find_full(path).map(|(idx, _)| idx)
8820 }
8821
8822 pub fn find_full(&self, path: &str) -> Option<(usize, Vec<(String, String)>)> {
8827 if let Ok(m) = self.exact.at(path) {
8828 let params = m
8829 .params
8830 .iter()
8831 .map(|(k, v)| (k.to_string(), v.to_string()))
8832 .collect();
8833 return Some((*m.value, params));
8834 }
8835 let matches = self.regex_set.matches(path);
8839 let mut best: Option<(u32, usize)> = None; for i in matches.iter() {
8841 let score = self.regex_scores[i];
8842 if best.is_none_or(|(s, _)| score > s) {
8843 best = Some((score, i));
8844 }
8845 }
8846 if let Some((_, i)) = best {
8847 return Some((self.regex_indices[i], Vec::new()));
8848 }
8849 if let Some(idx) = self.prefix_trie.longest_match(path) {
8851 return Some((idx, Vec::new()));
8852 }
8853 None
8854 }
8855
8856 pub fn find_candidates(&self, path: &str) -> Vec<(usize, Vec<(String, String)>)> {
8864 let mut candidates = Vec::new();
8865
8866 if let Ok(m) = self.exact.at(path) {
8868 let params = m
8869 .params
8870 .iter()
8871 .map(|(k, v)| (k.to_string(), v.to_string()))
8872 .collect();
8873 candidates.push((*m.value, params));
8874 }
8875
8876 let matches = self.regex_set.matches(path);
8878 let mut regex_matches: Vec<(u32, usize)> = Vec::new();
8879 for i in matches.iter() {
8880 regex_matches.push((self.regex_scores[i], i));
8881 }
8882 if regex_matches.len() > 1 {
8886 regex_matches.sort_by(|a, b| b.0.cmp(&a.0));
8887 }
8888 for (_, i) in regex_matches {
8889 candidates.push((self.regex_indices[i], Vec::new()));
8890 }
8891
8892 for idxs in self.prefix_trie.collect_matches(path).iter().rev() {
8901 for &idx in *idxs {
8902 candidates.push((idx, Vec::new()));
8903 }
8904 }
8905
8906 candidates
8907 }
8908
8909 pub fn rewrite_regex(&self, idx: usize) -> Option<®ex::Regex> {
8912 self.rewrite_regexes.get(idx).and_then(|r| r.as_ref())
8913 }
8914
8915 pub fn rewrite_substitution(&self, idx: usize) -> Option<&CompiledSubstitution> {
8918 self.rewrite_substitutions.get(idx).and_then(|s| s.as_ref())
8919 }
8920
8921 pub fn cors_origins(&self, idx: usize) -> Option<&crate::config::CompiledCorsOrigins> {
8925 self.cors_origins.get(idx).and_then(|o| o.as_ref())
8926 }
8927
8928 pub fn compression_types(
8932 &self,
8933 idx: usize,
8934 ) -> Option<&crate::config::CompiledContentTypeFilter> {
8935 self.compression_types.get(idx).and_then(|t| t.as_ref())
8936 }
8937
8938 pub fn deprecation(&self, idx: usize) -> Option<&crate::config::CompiledDeprecation> {
8942 self.deprecations.get(idx).and_then(|d| d.as_ref())
8943 }
8944
8945 pub fn accept_media_type(&self, idx: usize) -> Option<&str> {
8951 self.accept_media_types.get(idx).and_then(|a| a.as_deref())
8952 }
8953
8954 pub fn request_body_ops(
8958 &self,
8959 idx: usize,
8960 ) -> Option<&crate::config::transforms::CompiledJsonTransform> {
8961 self.request_body_ops.get(idx).and_then(|t| t.as_ref())
8962 }
8963
8964 pub fn response_body_ops(
8968 &self,
8969 idx: usize,
8970 ) -> Option<&crate::config::transforms::CompiledJsonTransform> {
8971 self.response_body_ops.get(idx).and_then(|t| t.as_ref())
8972 }
8973
8974 pub fn masking(&self, idx: usize) -> Option<&crate::config::transforms::CompiledMasking> {
8978 self.masking.get(idx).and_then(|m| m.as_ref())
8979 }
8980
8981 pub fn cache(
8986 &self,
8987 idx: usize,
8988 ) -> Option<&std::sync::Arc<crate::config::cache::CompiledRouteCache>> {
8989 self.caches.get(idx).and_then(|c| c.as_ref())
8990 }
8991
8992 pub fn mock_body(&self, idx: usize) -> Option<&bytes::Bytes> {
8996 self.mock_bodies.get(idx).and_then(|b| b.as_ref())
8997 }
8998
8999 pub fn effective_security_headers(
9004 &self,
9005 idx: usize,
9006 ) -> Option<&crate::config::transforms::SecurityHeaders> {
9007 self.effective_security_headers
9008 .get(idx)
9009 .and_then(|sh| sh.as_ref())
9010 }
9011}
9012
9013#[derive(Debug, Clone)]
9022pub struct CompiledSubstitution {
9023 segments: Vec<SubstSegment>,
9024 capacity: usize,
9029}
9030
9031#[derive(Debug, Clone)]
9033enum SubstSegment {
9034 Literal(String),
9036 Numeric(usize),
9038 Named(String),
9042}
9043
9044impl CompiledSubstitution {
9045 pub fn compile(substitution: &str) -> Self {
9051 let mut segments = Vec::new();
9052 let mut capacity = 0;
9053 let mut rest = substitution;
9054 let mut literal = String::new();
9055
9056 while let Some(dollar) = rest.find('$') {
9057 literal.push_str(&rest[..dollar]);
9058 let after = &rest[dollar + 1..];
9059 if after.is_empty() {
9060 literal.push('$');
9061 rest = after;
9062 break;
9063 }
9064 if let Some(braced) = after.strip_prefix('{') {
9065 match braced.find('}') {
9066 Some(end) => {
9067 if !literal.is_empty() {
9068 capacity += literal.len();
9069 segments.push(SubstSegment::Literal(std::mem::take(&mut literal)));
9070 }
9071 let name = &braced[..end];
9072 if let Ok(i) = name.parse::<usize>() {
9073 segments.push(SubstSegment::Numeric(i));
9074 } else {
9075 segments.push(SubstSegment::Named(name.to_string()));
9076 }
9077 rest = &braced[end + 1..];
9078 }
9079 None => {
9080 literal.push('$');
9081 rest = after;
9082 }
9083 }
9084 } else {
9085 let end = after
9086 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
9087 .unwrap_or(after.len());
9088 if end == 0 {
9089 literal.push('$');
9090 rest = after;
9091 } else {
9092 if !literal.is_empty() {
9093 capacity += literal.len();
9094 segments.push(SubstSegment::Literal(std::mem::take(&mut literal)));
9095 }
9096 let name = &after[..end];
9097 if let Ok(i) = name.parse::<usize>() {
9098 segments.push(SubstSegment::Numeric(i));
9099 } else {
9100 segments.push(SubstSegment::Named(name.to_string()));
9101 }
9102 rest = &after[end..];
9103 }
9104 }
9105 }
9106 literal.push_str(rest);
9107 if !literal.is_empty() {
9108 capacity += literal.len();
9109 segments.push(SubstSegment::Literal(literal));
9110 }
9111
9112 CompiledSubstitution { segments, capacity }
9113 }
9114
9115 pub fn expand(&self, caps: ®ex::Captures<'_>, params: &[(String, String)]) -> String {
9120 let mut out = String::with_capacity(self.capacity);
9121 for seg in &self.segments {
9122 match seg {
9123 SubstSegment::Literal(s) => out.push_str(s),
9124 SubstSegment::Numeric(i) => {
9125 if let Some(m) = caps.get(*i) {
9126 out.push_str(m.as_str());
9127 }
9128 }
9129 SubstSegment::Named(name) => {
9130 if let Some(m) = caps.name(name) {
9131 out.push_str(m.as_str());
9132 } else if let Some((_, v)) = params.iter().find(|(n, _)| n == name) {
9133 out.push_str(v);
9134 }
9135 }
9136 }
9137 }
9138 out
9139 }
9140
9141 pub fn capacity(&self) -> usize {
9145 self.capacity
9146 }
9147}
9148
9149#[derive(Debug)]
9152pub struct Compiled {
9153 gateway: Arc<Gateway>,
9154 routes: Arc<RouteTable>,
9155 content_hash: u64,
9156 portal: Option<crate::lifecycle::DevPortal>,
9160}
9161
9162impl Compiled {
9163 pub fn gateway(&self) -> &Gateway {
9164 &self.gateway
9165 }
9166
9167 pub fn route_table(&self) -> &RouteTable {
9168 &self.routes
9169 }
9170
9171 pub fn content_hash(&self) -> u64 {
9172 self.content_hash
9173 }
9174
9175 pub fn portal(&self) -> Option<&crate::lifecycle::DevPortal> {
9177 self.portal.as_ref()
9178 }
9179}
9180
9181#[derive(Debug)]
9183pub struct Snapshot {
9184 generation: u64,
9185 content_hash: u64,
9186 gateway: Arc<Gateway>,
9187 routes: Arc<RouteTable>,
9188 portal: Option<crate::lifecycle::DevPortal>,
9190}
9191
9192impl Snapshot {
9193 pub fn empty() -> Self {
9196 Snapshot {
9197 generation: 0,
9198 content_hash: 0,
9199 gateway: Arc::new(Gateway {
9200 version: 1,
9201 trusted_proxies: vec![],
9202 listeners: Vec::new(),
9203 routes: Vec::new(),
9204 services: Vec::new(),
9205 upstreams: Vec::new(),
9206 consumers: Vec::new(),
9207 policies: Vec::new(),
9208 global_policies: Vec::new(),
9209 authorization: None,
9210 default_security_headers: None,
9211 waf: None,
9212 max_concurrent_requests: None,
9213 load_shed_dry_run: false,
9214 jwt_providers: Vec::new(),
9215 admin: None,
9216 allow_empty_routes: false,
9217 hmac_auth: None,
9218 webhooks: Vec::new(),
9219 analytics: None,
9220 analytics_stream: None,
9221 geoip: None,
9222 admission_queue: None,
9223 mtls_consumer_mapping: None,
9224 mtls_forward_headers: None,
9225 license: None,
9226 oidc_providers: Vec::new(),
9227 redis_rate_limiter: None,
9228 redis_quotas: None,
9229 redis_cache: None,
9230 config_convergence: None,
9231 plugins: Vec::new(),
9232 filter_chain: None,
9233 plugin_registry: None,
9234 ai: None,
9235 fleet: None,
9236 mesh: None,
9237 lifecycle: None,
9238 ssrf_filter: None,
9239 }),
9240 routes: Arc::new(RouteTable::empty()),
9241 portal: None,
9242 }
9243 }
9244
9245 pub fn generation(&self) -> u64 {
9246 self.generation
9247 }
9248
9249 pub fn from_compiled(compiled: Compiled) -> Self {
9255 Snapshot {
9256 generation: 0,
9257 content_hash: compiled.content_hash,
9258 gateway: compiled.gateway,
9259 routes: compiled.routes,
9260 portal: compiled.portal,
9261 }
9262 }
9263
9264 pub fn content_hash(&self) -> u64 {
9265 self.content_hash
9266 }
9267
9268 pub fn gateway(&self) -> &Gateway {
9269 &self.gateway
9270 }
9271
9272 pub fn route_table(&self) -> &RouteTable {
9273 &self.routes
9274 }
9275
9276 pub fn portal(&self) -> Option<&crate::lifecycle::DevPortal> {
9278 self.portal.as_ref()
9279 }
9280
9281 pub fn match_route(&self, path: &str) -> Option<&Route> {
9283 self.routes.find(path).map(|i| &self.gateway.routes[i])
9284 }
9285}
9286
9287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9289pub struct SnapshotInfo {
9290 pub generation: u64,
9291 pub content_hash: u64,
9296 pub route_count: usize,
9297}
9298
9299fn normalized_hash(yaml: &str) -> u64 {
9303 let mut hasher = DefaultHasher::new();
9304 yaml.hash(&mut hasher);
9305 hasher.finish()
9306}
9307
9308pub fn entity_content_hash<T: serde::Serialize>(entity: &T) -> Result<u64, String> {
9318 let yaml =
9319 serde_yaml_ng::to_string(entity).map_err(|e| format!("normalization failed: {e}"))?;
9320 Ok(normalized_hash(&yaml))
9321}
9322
9323fn regex_specificity_score(pattern: &str) -> u32 {
9337 pattern
9338 .chars()
9339 .filter(|c| {
9340 !matches!(
9341 c,
9342 '\\' | '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$'
9343 )
9344 })
9345 .count() as u32
9346}
9347
9348pub fn compile(gateway: &Gateway) -> Result<Compiled, CompileError> {
9353 let issues = validate(gateway);
9354 if !issues.is_empty() {
9355 return Err(CompileError::Validation(issues));
9356 }
9357
9358 let mut exact = matchit::Router::new();
9359 let mut prefixes = Vec::new();
9360 let mut prefix_trie = PrefixTrie::new();
9361 let mut regex_patterns = Vec::new();
9362 let mut regex_indices = Vec::new();
9363 let mut regex_scores = Vec::new();
9364 let mut rewrite_regexes: Vec<Option<regex::Regex>> = vec![None; gateway.routes.len()];
9365 let mut rewrite_substitutions: Vec<Option<CompiledSubstitution>> =
9366 vec![None; gateway.routes.len()];
9367
9368 for (idx, route) in gateway.routes.iter().enumerate() {
9369 let path = &route.r#match.path;
9370 match path.kind {
9371 PathMatchKind::Exact => {
9372 exact
9373 .insert(&path.value, idx)
9374 .map_err(|e| CompileError::RouteConflict {
9375 route: route.name.clone(),
9376 pattern: path.value.clone(),
9377 message: e.to_string(),
9378 })?;
9379 }
9380 PathMatchKind::Prefix => {
9381 let prefix = path.value.trim_end_matches('/').to_string();
9382 prefix_trie.insert(&prefix, idx);
9389 prefixes.push((prefix, idx));
9390 }
9391 PathMatchKind::Regex => {
9392 regex::Regex::new(&path.value).map_err(|e| CompileError::InvalidRegex {
9393 route: route.name.clone(),
9394 pattern: path.value.clone(),
9395 message: e.to_string(),
9396 })?;
9397 regex_scores.push(regex_specificity_score(&path.value));
9402 regex_patterns.push(path.value.clone());
9403 regex_indices.push(idx);
9404 }
9405 }
9406 if let RouteAction::Proxy {
9407 rewrite:
9408 Some(PathRewrite::Regex {
9409 pattern,
9410 substitution,
9411 }),
9412 } = &route.action
9413 {
9414 let compiled = regex::Regex::new(pattern).map_err(|e| CompileError::InvalidRegex {
9415 route: route.name.clone(),
9416 pattern: pattern.clone(),
9417 message: e.to_string(),
9418 })?;
9419 rewrite_regexes[idx] = Some(compiled);
9420 rewrite_substitutions[idx] = Some(CompiledSubstitution::compile(substitution));
9421 }
9422 }
9423
9424 let regex_set =
9425 regex::RegexSet::new(regex_patterns).map_err(|e| CompileError::Internal(e.to_string()))?;
9426
9427 let yaml = gateway_to_yaml(gateway)
9428 .map_err(|e| CompileError::Internal(format!("normalization failed: {e}")))?;
9429 let content_hash = normalized_hash(&yaml);
9430
9431 let cors_origins = gateway
9437 .routes
9438 .iter()
9439 .map(|r| {
9440 r.cors
9441 .as_ref()
9442 .map(crate::config::CompiledCorsOrigins::compile)
9443 })
9444 .collect();
9445 let compression_types = gateway
9446 .routes
9447 .iter()
9448 .map(|r| {
9449 r.compression
9450 .as_ref()
9451 .map(crate::config::CompiledContentTypeFilter::compile)
9452 })
9453 .collect();
9454 let deprecations = gateway
9459 .routes
9460 .iter()
9461 .map(|r| {
9462 r.deprecation
9463 .as_ref()
9464 .map(crate::config::CompiledDeprecation::compile)
9465 })
9466 .collect();
9467 let accept_media_types = gateway
9474 .routes
9475 .iter()
9476 .map(|r| {
9477 r.r#match
9478 .accept
9479 .as_deref()
9480 .and_then(crate::config::versioning::normalize_media_type)
9481 })
9482 .collect();
9483 let request_body_ops = gateway
9488 .routes
9489 .iter()
9490 .map(|r| {
9491 r.transforms
9492 .as_ref()
9493 .and_then(|t| t.request.as_ref())
9494 .and_then(|req| req.body.as_ref())
9495 .map(|b| crate::config::transforms::CompiledJsonTransform::compile(&b.json))
9496 })
9497 .collect();
9498 let response_body_ops = gateway
9499 .routes
9500 .iter()
9501 .map(|r| {
9502 r.transforms
9503 .as_ref()
9504 .and_then(|t| t.response.as_ref())
9505 .and_then(|resp| resp.body.as_ref())
9506 .map(|b| crate::config::transforms::CompiledJsonTransform::compile(&b.json))
9507 })
9508 .collect();
9509 let masking = gateway
9513 .routes
9514 .iter()
9515 .map(|r| {
9516 r.masking
9517 .as_ref()
9518 .map(crate::config::transforms::CompiledMasking::compile)
9519 })
9520 .collect();
9521 let caches = gateway
9525 .routes
9526 .iter()
9527 .map(|r| {
9528 r.cache.as_ref().map(|c| {
9529 std::sync::Arc::new(crate::config::cache::CompiledRouteCache::compile(
9530 c,
9531 r.r#match.accept.is_some(),
9532 r.cors.is_some(),
9533 ))
9534 })
9535 })
9536 .collect();
9537 let mock_bodies = gateway
9543 .routes
9544 .iter()
9545 .map(|r| match &r.action {
9546 RouteAction::Mock { mock } => mock
9547 .body_file
9548 .as_ref()
9549 .and_then(|path| std::fs::read(path).ok().map(bytes::Bytes::from)),
9550 _ => None,
9551 })
9552 .collect();
9553
9554 let effective_security_headers = gateway
9559 .routes
9560 .iter()
9561 .map(|r| {
9562 if let Some(sh) = &r.security_headers {
9563 Some(sh.clone())
9564 } else if !r.security_headers_opt_out {
9565 gateway.default_security_headers.clone()
9566 } else {
9567 None
9568 }
9569 })
9570 .collect();
9571
9572 let portal = gateway
9576 .lifecycle
9577 .as_ref()
9578 .and_then(|l| l.portal.as_ref())
9579 .filter(|p| p.enabled)
9580 .map(crate::lifecycle::DevPortal::build);
9581
9582 Ok(Compiled {
9583 gateway: Arc::new(gateway.clone()),
9584 portal,
9585 routes: Arc::new(RouteTable {
9586 exact,
9587 prefixes,
9588 prefix_trie,
9589 regex_set,
9590 regex_indices,
9591 regex_scores,
9592 rewrite_regexes,
9593 rewrite_substitutions,
9594 cors_origins,
9595 compression_types,
9596 deprecations,
9597 accept_media_types,
9598 request_body_ops,
9599 response_body_ops,
9600 masking,
9601 caches,
9602 mock_bodies,
9603 effective_security_headers,
9604 }),
9605 content_hash,
9606 })
9607}
9608
9609pub struct ConfigState {
9616 snapshot: ArcSwap<Snapshot>,
9617 generation: AtomicU64,
9618 publish_lock: Mutex<()>,
9621 events: std::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
9630}
9631
9632impl Default for ConfigState {
9633 fn default() -> Self {
9634 ConfigState {
9635 snapshot: ArcSwap::from_pointee(Snapshot::empty()),
9636 generation: AtomicU64::new(0),
9637 publish_lock: Mutex::new(()),
9638 events: std::sync::RwLock::new(None),
9639 }
9640 }
9641}
9642
9643impl ConfigState {
9644 pub fn new() -> Self {
9645 Self::default()
9646 }
9647
9648 pub fn with_event_bus(bus: Arc<crate::events::EventBus>) -> Self {
9652 let state = Self::default();
9653 state.attach_event_bus(bus);
9654 state
9655 }
9656
9657 pub fn attach_event_bus(&self, bus: Arc<crate::events::EventBus>) {
9660 let mut slot = self.events.write().expect("event bus slot poisoned");
9661 if slot.is_none() {
9662 *slot = Some(bus);
9663 }
9664 }
9665
9666 pub fn event_bus(&self) -> Option<Arc<crate::events::EventBus>> {
9668 self.events.read().expect("event bus slot poisoned").clone()
9669 }
9670
9671 fn event_emitter(&self) -> Option<crate::events::Emitter> {
9675 self.event_bus().map(|bus| bus.emitter())
9676 }
9677
9678 pub fn snapshot(&self) -> Arc<Snapshot> {
9680 self.snapshot.load_full()
9681 }
9682
9683 pub fn compile_and_publish(&self, gateway: &Gateway) -> Result<SnapshotInfo, CompileError> {
9694 let _guard = self.publish_lock.lock().unwrap_or_else(|p| p.into_inner());
9695 let emitter = self.event_emitter();
9696 let compiled = match compile(gateway) {
9697 Ok(compiled) => compiled,
9698 Err(err) => {
9699 if let Some(emitter) = &emitter {
9700 let issue_count = match &err {
9701 CompileError::Validation(issues) => issues.len(),
9702 _ => 1,
9705 };
9706 emitter.emit(
9707 crate::events::EventKind::ConfigRejected,
9708 crate::events::EventPayload {
9709 issue_count: Some(issue_count),
9710 generation: Some(self.snapshot.load().generation),
9711 ..crate::events::EventPayload::default()
9712 },
9713 );
9714 }
9715 return Err(err);
9716 }
9717 };
9718 let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
9719 let snapshot = Snapshot {
9720 generation,
9721 content_hash: compiled.content_hash,
9722 gateway: compiled.gateway,
9723 routes: compiled.routes,
9724 portal: compiled.portal,
9725 };
9726 let info = SnapshotInfo {
9727 generation,
9728 content_hash: snapshot.content_hash,
9729 route_count: snapshot.gateway.routes.len(),
9730 };
9731 self.snapshot.store(Arc::new(snapshot));
9732 if let Some(emitter) = &emitter {
9733 emitter.emit(
9734 crate::events::EventKind::ConfigPublished,
9735 crate::events::EventPayload {
9736 generation: Some(info.generation),
9737 content_hash: Some(info.content_hash),
9738 route_count: Some(info.route_count),
9739 ..crate::events::EventPayload::default()
9740 },
9741 );
9742 }
9743 Ok(info)
9744 }
9745}