116 lines
4.9 KiB
Rust
116 lines
4.9 KiB
Rust
//! `/api/egress/allowed-domains` and `/api/egress/denied-domains` — the destinations the swarm lets
|
|
//! agents reach through their hive's egress proxy, and the ones it refuses.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// One allowed destination and who may reach it.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct AllowedDomain {
|
|
/// A DNS name of at least two labels, such as `github.com`, stored
|
|
/// lower-cased. One leading dot, as in `.githubusercontent.com`, also
|
|
/// allows every subdomain. IP addresses, wildcards, ports, paths and the
|
|
/// proxy's own always-allowed names are refused.
|
|
#[cfg_attr(feature = "schema", schema(example = "github.com"))]
|
|
pub domain: String,
|
|
/// The agent this entry is for. Absent means every agent in the swarm.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[cfg_attr(feature = "schema", schema(example = "atlas"))]
|
|
pub agent: Option<String>,
|
|
/// Set on an entry the controller keeps from the agent's backend
|
|
/// environment (the host of its `ANTHROPIC_BASE_URL`) or from an account an
|
|
/// operator linked to it. The list shows it as automatic; adding or
|
|
/// removing one is refused.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub derived: bool,
|
|
}
|
|
|
|
/// The swarm's allow-list: every entry, swarm-wide and per agent, sorted.
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct AllowedDomains {
|
|
pub entries: Vec<AllowedDomain>,
|
|
}
|
|
|
|
/// One refused destination and who it is refused for.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DeniedDomain {
|
|
/// A DNS name written as in [`AllowedDomain::domain`]: at least two
|
|
/// labels, lower-cased, one leading dot for the name and its subdomains.
|
|
#[cfg_attr(feature = "schema", schema(example = ".example.com"))]
|
|
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")]
|
|
#[cfg_attr(feature = "schema", schema(example = "atlas"))]
|
|
pub agent: Option<String>,
|
|
}
|
|
|
|
/// The swarm's deny list: every entry, swarm-wide and per agent, sorted, and
|
|
/// whether every hive has taken it.
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct DeniedDomains {
|
|
pub entries: Vec<DeniedDomain>,
|
|
/// The revision of the stored list, `0` when nobody has written one. A
|
|
/// hive reports the revision it last rendered into its proxy.
|
|
#[serde(default)]
|
|
pub revision: u64,
|
|
/// The hives whose proxy is not known to refuse these names yet: they have
|
|
/// not reported the list's revision (a hive that predates the deny list
|
|
/// never does, a new one takes a minute or so), or have not reported at
|
|
/// all. Empty when the list is empty and no hive reports otherwise.
|
|
#[serde(default)]
|
|
pub behind: Vec<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn an_entry_leaves_out_an_unset_agent_and_refuses_unknown_fields() {
|
|
let swarm_wide = AllowedDomain {
|
|
domain: "github.com".to_owned(),
|
|
agent: None,
|
|
derived: false,
|
|
};
|
|
let wire = r#"{"domain":"github.com"}"#;
|
|
assert_eq!(serde_json::to_string(&swarm_wide).unwrap(), wire);
|
|
assert_eq!(
|
|
serde_json::from_str::<AllowedDomain>(wire).unwrap(),
|
|
swarm_wide
|
|
);
|
|
let scoped = r#"{"domain":"crates.io","agent":"atlas"}"#;
|
|
assert_eq!(
|
|
serde_json::from_str::<AllowedDomain>(scoped)
|
|
.unwrap()
|
|
.agent
|
|
.as_deref(),
|
|
Some("atlas")
|
|
);
|
|
assert!(serde_json::from_str::<AllowedDomain>(r#"{"domain":"a.io","hive":"x"}"#).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_denied_entry_has_no_derived_flag_and_refuses_unknown_fields() {
|
|
let swarm_wide = DeniedDomain {
|
|
domain: ".example.com".to_owned(),
|
|
agent: None,
|
|
};
|
|
let wire = r#"{"domain":".example.com"}"#;
|
|
assert_eq!(serde_json::to_string(&swarm_wide).unwrap(), wire);
|
|
assert_eq!(
|
|
serde_json::from_str::<DeniedDomain>(wire).unwrap(),
|
|
swarm_wide
|
|
);
|
|
// A deny is never automatic, so the allow list's `derived` is not a field.
|
|
assert!(
|
|
serde_json::from_str::<DeniedDomain>(r#"{"domain":"a.io","derived":true}"#).is_err()
|
|
);
|
|
assert!(serde_json::from_str::<DeniedDomain>(r#"{"domain":"a.io","hive":"x"}"#).is_err());
|
|
}
|
|
}
|