241 lines
9 KiB
Rust
241 lines
9 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
/// 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<Entry>,
|
|
}
|
|
|
|
/// `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<String, String> {
|
|
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<S: AsRef<str>>(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::<AllowList>(wire).unwrap(), list);
|
|
assert_eq!(
|
|
serde_json::from_str::<AllowList>(r#"{"entries":[],"later":1}"#).unwrap(),
|
|
AllowList::default()
|
|
);
|
|
}
|
|
}
|