Watch
0
0
Fork
You've already forked hyperhive
0

squid blocks a runtime deny list from the controller in every egress mode, ahead of the allow list

This commit is contained in:
atlas 2026-11-04 22:00:42 +01:00 • committed by mara
commit dd5fde848f
2 changed files with 1814 additions and 24 deletions

View file

@ -1,5 +1,6 @@
//! The egress allow-list KV bucket: one key holding the destination names the
//! swarm lets agents reach through their hive's squid.
//! swarm lets agents reach through their hive's squid, and a second holding the
//! names it refuses.
//!
//! **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
@ -10,6 +11,11 @@
//! 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 deny list is its own key ([`DENY_KEY`]) of the same bucket, with its own
//! type ([`DenyList`]): a reader that predates it never reads the key, and a
//! controller that predates it never rewrites it, so neither side can drop the
//! other's entries by writing its own value back.
/// The KV bucket the list is published into.
pub const BUCKET: &str = "egress-allow";
@ -32,6 +38,21 @@ pub fn direct_get_subject() -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject())
}
/// The key the deny list is stored under, beside [`KEY`].
pub const DENY_KEY: &str = "deny";
/// The subject the deny list is stored under.
#[must_use]
pub fn deny_key_subject() -> String {
format!("$KV.{BUCKET}.{DENY_KEY}")
}
/// The request subject that reads the current deny list.
#[must_use]
pub fn deny_direct_get_subject() -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", deny_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;
@ -89,6 +110,29 @@ impl Entry {
}
}
/// One refused destination and who it is refused for.
///
/// A distinct type from [`Entry`]: a deny is never derived from an agent's
/// environment or a linked account, so it carries neither field and nothing
/// but an operator's action can put one in the list.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct DenyEntry {
/// A name as [`normalize_domain`] returns it.
pub domain: String,
/// The agent this entry is for; absent means every agent in the swarm. An
/// agent's entry adds to the swarm-wide ones and never lifts one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
/// The value under [`BUCKET`]/[`DENY_KEY`]. No `deny_unknown_fields`, as for
/// [`AllowList`].
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DenyList {
#[serde(default)]
pub entries: Vec<DenyEntry>,
}
/// 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)]
@ -180,6 +224,39 @@ mod tests {
);
}
#[test]
fn the_deny_key_is_its_own_subject_of_the_same_bucket() {
assert_eq!(deny_key_subject(), "$KV.egress-allow.deny");
assert_eq!(
deny_direct_get_subject(),
"$JS.API.DIRECT.GET.KV_egress-allow.$KV.egress-allow.deny"
);
assert_ne!(deny_key_subject(), key_subject());
}
#[test]
fn a_deny_entry_round_trips_and_tolerates_fields_it_does_not_know() {
let list = DenyList {
entries: vec![
DenyEntry {
domain: ".evil.example".to_owned(),
agent: None,
},
DenyEntry {
domain: "bad.io".to_owned(),
agent: Some("atlas".to_owned()),
},
],
};
let wire =
r#"{"entries":[{"domain":".evil.example"},{"domain":"bad.io","agent":"atlas"}]}"#;
assert_eq!(serde_json::to_string(&list).unwrap(), wire);
assert_eq!(serde_json::from_str::<DenyList>(wire).unwrap(), list);
let later: DenyList =
serde_json::from_str(r#"{"entries":[{"domain":"a.io","later":1}],"x":1}"#).unwrap();
assert_eq!(later.entries.len(), 1);
}
#[test]
fn a_dns_name_is_kept_lower_cased_with_one_optional_leading_dot() {
for (raw, stored) in [

View file

@ -18,6 +18,20 @@ pub const TOLERANT_PERMISSIONS: &str = "tolerant-permissions";
/// into a custom one only on a hive that advertises it.
pub const NO_CONFIG_CHECKOUT: &str = "no-config-checkout";
/// The snapshot field holding the revision of the egress deny list
/// (`crate::egress_allow::DENY_KEY`) the hive last rendered into its squid
/// include, `0` when the swarm has no deny list. A hive that predates the
/// field publishes none.
pub const EGRESS_DENY_APPLIED: &str = "egress_deny_applied";
/// The deny-list revision a stored snapshot says its hive rendered: `None` for
/// a snapshot without the field (an older hive, or one that has not read the
/// list yet) or with one that is not a number.
#[must_use]
pub fn egress_deny_applied(snapshot: &serde_json::Value) -> Option<u64> {
snapshot.get(EGRESS_DENY_APPLIED)?.as_u64()
}
/// The part of a hive's status snapshot this module describes.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Capabilities {
@ -66,6 +80,21 @@ mod tests {
assert!(!Capabilities::of_snapshot(&new).has("other"));
}
#[test]
fn the_applied_deny_revision_is_read_only_when_it_is_a_number() {
let at = |v: serde_json::Value| {
egress_deny_applied(&serde_json::json!({ EGRESS_DENY_APPLIED: v }))
};
assert_eq!(at(serde_json::json!(7)), Some(7));
assert_eq!(at(serde_json::json!(0)), Some(0));
assert_eq!(at(serde_json::json!("7")), None);
assert_eq!(
egress_deny_applied(&serde_json::json!({"status": "ok"})),
None
);
assert_eq!(egress_deny_applied(&serde_json::json!(null)), None);
}
/// A snapshot that is not an object, or whose field is mistyped, grants
/// nothing rather than failing the read.
#[test]