Watch
0
0
Fork
You've already forked hyperhive
0

derive an agent's backend hosts into the egress allow list at the controller

This commit is contained in:
atlas 2026-10-29 01:10:38 +01:00 • committed by mara
commit c5a876b845

View file

@ -28,6 +28,10 @@ use crate::schedules::{live, queue, read_or_default, refused, write_revision};
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// Why an operator can neither add nor remove an entry the controller derived.
const DERIVED_REFUSED: &str = "this entry is kept automatically from the agent's backend environment; \
change or remove that variable instead";
/// Set from the hive module's `baseAllowedDomains` default by
/// `nix/host-modules/swarm-controller.nix`: the names, separated by spaces,
/// that every hive's proxy lets every authenticated agent reach. Unset means
@ -35,7 +39,7 @@ const MAX_ATTEMPTS: usize = 5;
/// the entries its own base list covers.
pub(crate) const ENV_BASE_DOMAINS: &str = "SWARM_CONTROLLER_SQUID_BASE_DOMAINS";
fn base_domains() -> Vec<String> {
pub(crate) fn base_domains() -> Vec<String> {
std::env::var(ENV_BASE_DOMAINS)
.unwrap_or_default()
.split_whitespace()
@ -60,7 +64,11 @@ pub(crate) fn checked(req: &AllowedDomain, base: &[String]) -> Result<Entry, Str
.map_err(|reason| format!("agent {a:?}: {reason}"))
})
.transpose()?;
Ok(Entry { domain, agent })
Ok(Entry {
domain,
agent,
derived: false,
})
}
/// What adding an entry to a list came to.
@ -95,6 +103,7 @@ fn view(list: AllowList) -> AllowedDomains {
.map(|e| AllowedDomain {
domain: e.domain,
agent: e.agent,
derived: e.derived,
})
.collect(),
}
@ -158,7 +167,7 @@ pub(crate) async fn get_allowed_domains(
(status = 400, description = "the domain is not a DNS name (an IP address, a \
wildcard, a single label and a port are refused), the proxy already allows \
it to everyone, the agent is not an identifier or not declared on any hive, \
or the list is full (problem+json)", body = String),
or the list is full, or the entry is marked derived (problem+json)", body = String),
(status = 422, description = "an unknown field"),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
@ -171,6 +180,9 @@ pub(crate) async fn add_allowed_domain(
State(state): State<AppState>,
Json(req): Json<AllowedDomain>,
) -> Result<Json<AllowedDomains>, ProblemDetails> {
if req.derived {
return Err(error_problem(StatusCode::BAD_REQUEST, DERIVED_REFUSED));
}
let entry = checked(&req, &base_domains())
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
let client = queue(&state).map_err(refused)?;
@ -219,6 +231,9 @@ pub(crate) async fn add_allowed_domain(
(status = 200, description = "the whole list after the removal", body = AllowedDomains),
(status = 400, description = "the domain is not a DNS name, or the agent is not an \
identifier (problem+json)", body = String),
(status = 409, description = "the entry is one the controller keeps from the agent's \
backend environment, which an operator cannot remove (problem+json)",
body = String),
(status = 422, description = "an unknown field"),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
@ -231,6 +246,9 @@ pub(crate) async fn remove_allowed_domain(
State(state): State<AppState>,
Json(req): Json<AllowedDomain>,
) -> Result<Json<AllowedDomains>, ProblemDetails> {
if req.derived {
return Err(error_problem(StatusCode::CONFLICT, DERIVED_REFUSED));
}
// The base list is not consulted: an entry stored before a name joined it
// can still be removed.
let entry = checked(&req, &[]).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
@ -241,6 +259,13 @@ pub(crate) async fn remove_allowed_domain(
.await
.map_err(|e| queue_problem(&e))?;
if !list.entries.contains(&entry) {
if list
.entries
.iter()
.any(|e| e.derived && e.domain == entry.domain && e.agent == entry.agent)
{
return Err(error_problem(StatusCode::CONFLICT, DERIVED_REFUSED));
}
return Ok(Json(view(list)));
}
list.entries.retain(|e| *e != entry);
@ -259,6 +284,87 @@ pub(crate) async fn remove_allowed_domain(
)))
}
/// The stored list, or `None` when no one has written it.
///
/// # Errors
/// A queue failure.
pub(crate) async fn current(client: &async_nats::Client) -> anyhow::Result<Option<AllowList>> {
let Some(store) = egress_allow::open(client).await? else {
return Ok(None);
};
let (list, _, _) = read_or_default::<AllowList>(&store, KEY).await?;
Ok(Some(list))
}
/// Re-read the list and write `change` of it, retrying a lost write, until it
/// is written or `change` leaves it as it is. Answers whether a write landed.
///
/// A list that was never written is written only when `change` makes it
/// non-empty: the key's existence is what makes the swarm's list replace each
/// hive's own, so a change that adds nothing must not create it.
///
/// # Errors
/// A queue failure, or `MAX_ATTEMPTS` conflicting writes.
pub(crate) async fn modify(
client: &async_nats::Client,
change: impl Fn(AllowList) -> AllowList,
) -> anyhow::Result<bool> {
let mut store = egress_allow::open(client).await?;
for _ in 0..MAX_ATTEMPTS {
let (mut list, revision) = match &store {
Some(store) => {
let (list, revision, _) = read_or_default::<AllowList>(store, KEY).await?;
(list, revision)
}
None => (AllowList::default(), None),
};
list.entries.sort();
let mut changed = change(list.clone());
changed.entries.sort();
changed.entries.dedup();
if changed == list && (revision.is_some() || changed.entries.is_empty()) {
return Ok(false);
}
if store.is_none() {
store = Some(egress_allow::open_or_create(client).await?);
}
let target = store.as_ref().expect("opened above");
if write_revision(target, KEY, &changed, revision)
.await?
.is_some()
{
return Ok(true);
}
}
anyhow::bail!("gave up after {MAX_ATTEMPTS} conflicting writes")
}
/// Drop every entry of a destroyed agent, derived and operator-added: a name
/// is free again once its agent is destroyed, and the next agent of that name
/// would inherit what it left. A failure is logged and does not stop the
/// destroy, as in `agent_permissions::clear_on_destroy`.
pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) {
let cleared = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
modify(&client, |mut list| {
list.entries.retain(|e| e.agent.as_deref() != Some(agent));
list
})
.await
}
.await;
match cleared {
Ok(_) => tracing::info!(hive, agent, "destroyed agent's allowed domains cleared"),
Err(e) => tracing::error!(
hive,
agent,
error = %format!("{e:#}"),
"clearing this agent's allowed domains FAILED; it was destroyed anyway, so a new \
agent of the same name inherits them. Declaring the agent destroyed again re-runs this."
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -269,6 +375,7 @@ mod tests {
AllowedDomain {
domain: domain.to_owned(),
agent: agent.map(str::to_owned),
derived: false,
}
}
@ -445,12 +552,14 @@ mod tests {
.map(|i| Entry {
domain: format!("host{i}.example.com"),
agent: None,
derived: false,
})
.collect(),
};
let new = Entry {
domain: "github.com".to_owned(),
agent: None,
derived: false,
};
let why = added(full.clone(), &new).err().expect("a full list");
assert!(why.contains("1000"), "{why}");