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
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue