make squid's allowed domains editable at runtime in the swarm ui
This commit is contained in:
parent
86e80250bb
commit
c9abc2a42b
1 changed files with 2307 additions and 35 deletions
241
swarm-wire/src/egress_allow.rs
Normal file
241
swarm-wire/src/egress_allow.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue