//! The egress allow-list KV bucket: one key holding the destination names the //! swarm lets agents reach through their hive's squid. //! //! **swarm-controller is the only writer**, and creates the bucket on its //! first write. Each [`Entry`] is a domain with a scope: every agent in the //! swarm, or one agent. Until the controller has written the key, a hive //! keeps the list its own nix configuration names. //! //! **Every hive reads the one key** ([`direct_get_subject`]), with a grant that //! covers the bucket read-only, and renders the entries for the agents it runs. //! The names go into squid's configuration, so [`normalize_domain`] is the one //! gate both ends use: the controller at write, the hive again at render. /// The KV bucket the list is published into. pub const BUCKET: &str = "egress-allow"; /// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`. pub const STREAM: &str = "KV_egress-allow"; /// The key the whole list is stored under. pub const KEY: &str = "list"; /// The subject the list is stored under. #[must_use] pub fn key_subject() -> String { format!("$KV.{BUCKET}.{KEY}") } /// The request subject that reads the current list. #[must_use] pub fn direct_get_subject() -> String { format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject()) } /// The most entries a list holds, which keeps the value far below the queue's /// message size and the rendered squid configuration small. pub const MAX_ENTRIES: usize = 1000; /// One allowed destination and who may reach it. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] pub struct Entry { /// A name as [`normalize_domain`] returns it. pub domain: String, /// The agent this entry is for; absent means every agent in the swarm. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option, } /// The value under [`BUCKET`]/[`KEY`]. No `deny_unknown_fields`: a field a /// newer controller adds must not cost an older hive the entries it reads. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AllowList { #[serde(default)] pub entries: Vec, } /// `raw` as the name stored and rendered, or why it is refused. /// /// A DNS name of at least two labels, lower-cased, with at most one leading /// dot. The dot is squid's `dstdomain` form for the name and all its /// subdomains. Refused: IP literals (a final label that does not start with a /// letter), single labels, wildcards, ports, paths, whitespace inside, a /// trailing dot and anything outside `[a-z0-9.-]`. /// /// # Errors /// A caller-ready message naming the rule the input breaks. pub fn normalize_domain(raw: &str) -> Result { let domain = raw.trim().to_ascii_lowercase(); let name = domain.strip_prefix('.').unwrap_or(&domain); if name.len() > 253 { return Err(format!("{raw:?} is longer than 253 characters")); } let labels: Vec<&str> = name.split('.').collect(); if labels.len() < 2 { return Err(format!( "{raw:?} is not a domain name: it needs at least two labels, such as example.com" )); } for label in &labels { let ok = (1..=63).contains(&label.len()) && label .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') && !label.starts_with('-') && !label.ends_with('-'); if !ok { return Err(format!( "{raw:?} is not a domain name: labels are 1 to 63 letters, digits or hyphens, \ not starting or ending with a hyphen, joined by dots; wildcards, ports and \ paths are refused, and the only dot form is one leading dot for the name and \ its subdomains" )); } } if !labels .last() .is_some_and(|tld| tld.starts_with(|c: char| c.is_ascii_lowercase())) { return Err(format!( "{raw:?} is not a domain name: the last label must start with a letter, which \ also refuses IP addresses" )); } Ok(domain) } /// Whether squid's `dstdomain` list `names` already matches everything /// `domain` does. An exact name matches itself; a name with a leading dot also /// matches its subdomains. Both sides are in [`normalize_domain`]'s form. #[must_use] pub fn covered_by>(domain: &str, names: &[S]) -> bool { names.iter().any(|n| { let n = n.as_ref(); match (domain.strip_prefix('.'), n.strip_prefix('.')) { // Every subdomain of the entry is a subdomain of the list's name. (Some(d), Some(base)) => d == base || d.ends_with(&format!(".{base}")), // An exact name never covers a name that has subdomains. (Some(_), None) => false, (None, Some(base)) => domain == base || domain.ends_with(&format!(".{base}")), (None, None) => domain == n, } }) } #[cfg(test)] mod tests { use super::*; /// The names the hive's grant in `swarm-nats-auth` is built from. #[test] fn names_match_the_queue_grant() { assert_eq!(STREAM, format!("KV_{BUCKET}")); assert_eq!(key_subject(), "$KV.egress-allow.list"); assert_eq!( direct_get_subject(), "$JS.API.DIRECT.GET.KV_egress-allow.$KV.egress-allow.list" ); } #[test] fn a_dns_name_is_kept_lower_cased_with_one_optional_leading_dot() { for (raw, stored) in [ ("github.com", "github.com"), (" GitHub.COM ", "github.com"), (".githubusercontent.com", ".githubusercontent.com"), ("a-b.c1.example.co.uk", "a-b.c1.example.co.uk"), ("xn--bcher-kva.example", "xn--bcher-kva.example"), ] { assert_eq!(normalize_domain(raw).as_deref(), Ok(stored), "{raw:?}"); } } #[test] fn an_ip_literal_a_wildcard_or_anything_but_a_name_is_refused() { for bad in [ "", ".", "localhost", ".com", "1.2.3.4", ".1.2.3.4", "127.0.0.1", "0x7f.1", "0x7f.0x1", "[::1]", "::1", "fd00::1", "*.example.com", "*", "..example.com", "example..com", "example.com.", "example.com:443", "example.com/path", "https://example.com", "exa mple.com", "example.com\nhttp_access allow all", "-a.example.com", "a-.example.com", "a_b.example.com", "bücher.example", "example.123", ] { assert!(normalize_domain(bad).is_err(), "{bad:?} was accepted"); } let long = format!("{}.example.com", "a".repeat(64)); assert!(normalize_domain(&long).is_err(), "a 64-character label"); let too_long = format!("{}.com", vec!["a".repeat(60); 5].join(".")); assert!(normalize_domain(&too_long).is_err(), "over 253 characters"); // Control: the boundaries just inside the limits are names. assert!(normalize_domain(&format!("{}.example.com", "a".repeat(63))).is_ok()); } #[test] fn covered_by_follows_squids_dstdomain_matching() { let base = ["api.anthropic.com", ".claude.ai"]; assert!(covered_by("api.anthropic.com", &base)); assert!( covered_by("claude.ai", &base), "the dot form covers the apex" ); assert!(covered_by("a.claude.ai", &base)); assert!(covered_by(".a.claude.ai", &base), "a narrower dot form"); assert!(covered_by(".claude.ai", &base)); // An exact base name does not cover its subdomains or a dot form. assert!(!covered_by("x.api.anthropic.com", &base)); assert!(!covered_by(".api.anthropic.com", &base)); // A broader dot form is new access. assert!(!covered_by(".anthropic.com", &base)); assert!( !covered_by("notclaude.ai", &base), "a suffix is not a subdomain" ); assert!(!covered_by("github.com", &base)); assert!(!covered_by("github.com", &[] as &[&str])); } #[test] fn the_stored_value_leaves_out_what_is_unset_and_tolerates_new_fields() { let list = AllowList { entries: vec![ Entry { domain: "github.com".to_owned(), agent: None, }, Entry { domain: "crates.io".to_owned(), agent: Some("atlas".to_owned()), }, ], }; let wire = r#"{"entries":[{"domain":"github.com"},{"domain":"crates.io","agent":"atlas"}]}"#; assert_eq!(serde_json::to_string(&list).unwrap(), wire); assert_eq!(serde_json::from_str::(wire).unwrap(), list); assert_eq!( serde_json::from_str::(r#"{"entries":[],"later":1}"#).unwrap(), AllowList::default() ); } }