Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-wire/src/egress_allow.rs

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()
);
}
}