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

@ -56,7 +56,22 @@ pub enum Read {
NotSet,
}
/// Read the current list with one direct get.
/// What reading the deny list found.
#[cfg(feature = "egress-allow")]
#[derive(Debug)]
pub enum DenyRead {
/// The controller has written a deny list, which may be empty, at this
/// revision of its key: the stream sequence of the write, which is what the
/// controller's own read of the key reports as its revision.
Current {
list: swarm_wire::egress_allow::DenyList,
revision: u64,
},
/// There is no deny list to read, for the reasons [`Read::NotSet`] gives.
NotSet,
}
/// Read the current allow-list with one direct get.
///
/// # Errors
///
@ -66,15 +81,50 @@ pub enum Read {
pub async fn read(
client: &async_nats::Client,
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
let Some(message) = last_value(client, &swarm_wire::egress_allow::key_subject()).await? else {
return Ok(Read::NotSet);
};
Ok(Read::Current(serde_json::from_slice(&message.payload)?))
}
/// Read the current deny list with one direct get.
///
/// # Errors
///
/// As [`read`].
#[cfg(feature = "egress-allow")]
pub async fn read_deny(
client: &async_nats::Client,
) -> Result<DenyRead, Box<dyn std::error::Error + Send + Sync>> {
let Some(message) = last_value(client, &swarm_wire::egress_allow::deny_key_subject()).await?
else {
return Ok(DenyRead::NotSet);
};
Ok(DenyRead::Current {
list: serde_json::from_slice(&message.payload)?,
revision: message.sequence,
})
}
/// The last message under `subject` of the bucket's stream, or `None` when
/// there is none or it is a KV delete marker.
#[cfg(feature = "egress-allow")]
async fn last_value(
client: &async_nats::Client,
subject: &str,
) -> Result<
Option<async_nats::jetstream::message::StreamMessage>,
Box<dyn std::error::Error + Send + Sync>,
> {
use async_nats::jetstream::stream::DirectGetErrorKind;
use swarm_wire::egress_allow::{STREAM, key_subject};
use swarm_wire::egress_allow::STREAM;
let js = async_nats::jetstream::new(client.clone());
let stream = js.get_stream_no_info(STREAM).await?;
let message = match stream.direct_get_last_for_subject(key_subject()).await {
let message = match stream.direct_get_last_for_subject(subject).await {
Ok(message) => message,
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => return Ok(Read::NotSet),
Err(e) if is_no_responders(&e) => return Ok(Read::NotSet),
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => return Ok(None),
Err(e) if is_no_responders(&e) => return Ok(None),
Err(e) => return Err(e.into()),
};
// A KV delete or purge is stored as an empty marker carrying this header.
@ -82,10 +132,7 @@ pub async fn read(
.headers
.get("KV-Operation")
.is_some_and(|op| matches!(op.as_str(), "DEL" | "PURGE"));
if removed {
return Ok(Read::NotSet);
}
Ok(Read::Current(serde_json::from_slice(&message.payload)?))
Ok((!removed).then_some(message))
}
#[cfg(feature = "egress-allow")]
@ -96,3 +143,75 @@ fn is_no_responders(e: &async_nats::jetstream::stream::DirectGetError) -> bool {
if status == async_nats::StatusCode::NO_RESPONDERS
)
}
#[cfg(all(test, feature = "kv", feature = "egress-allow"))]
mod tests {
use swarm_wire::egress_allow::{DENY_KEY, DenyEntry, DenyList, KEY};
use super::{DenyRead, Read, open_or_create, read, read_deny};
use crate::test_nats::TestNats;
/// The deny list is read from its own key with the revision of the write,
/// the allow-list's key is not mistaken for it, and a deleted or never
/// written key reads as unset.
#[tokio::test]
async fn the_deny_list_is_read_from_its_own_key_with_the_revision_it_was_written_at() {
let nats = TestNats::start();
let client = nats.client().await;
assert!(matches!(
read_deny(&client).await.expect("no bucket"),
DenyRead::NotSet
));
let store = open_or_create(&client).await.expect("bucket");
assert!(matches!(
read_deny(&client).await.expect("no key"),
DenyRead::NotSet
));
let list = DenyList {
entries: vec![DenyEntry {
domain: ".evil.example".to_owned(),
agent: None,
}],
};
store
.put(
KEY,
br#"{"entries":[{"domain":"allowed.example"}]}"#.to_vec().into(),
)
.await
.expect("an allow-list write");
assert!(
matches!(
read_deny(&client).await.expect("still unset"),
DenyRead::NotSet
),
"the allow-list's key is not the deny list"
);
let revision = store
.put(DENY_KEY, serde_json::to_vec(&list).expect("encodes").into())
.await
.expect("a deny-list write");
match read_deny(&client).await.expect("read") {
DenyRead::Current {
list: got,
revision: at,
} => {
assert_eq!(got, list);
assert_eq!(at, revision, "the revision the controller's write answered");
}
DenyRead::NotSet => panic!("the list was written"),
}
assert!(
matches!(read(&client).await.expect("allow read"), Read::Current(l) if l.entries.len() == 1),
"control: the allow-list read still finds its own key"
);
store.delete(DENY_KEY).await.expect("deleted");
assert!(matches!(
read_deny(&client).await.expect("deleted"),
DenyRead::NotSet
));
}
}

View file

@ -487,7 +487,14 @@ pub mod forge_events;
#[cfg(feature = "router-todo")]
pub mod router_todo;
#[cfg(all(test, any(feature = "forge-events", feature = "router-todo")))]
#[cfg(all(
test,
any(
feature = "forge-events",
feature = "router-todo",
all(feature = "kv", feature = "egress-allow")
)
))]
mod test_nats;
/// Only the fields this needs; authelia returns several.