squid blocks a runtime deny list from the controller in every egress mode, ahead of the allow list
This commit is contained in:
parent
99ab0ae77f
commit
dd5fde848f
2 changed files with 1814 additions and 24 deletions
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue