derive an agent's backend hosts into the egress allow list at the controller
This commit is contained in:
parent
4f32948a97
commit
c5a876b845
1 changed files with 950 additions and 25 deletions
|
|
@ -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}");
|
||||
|
|
|
|||
Loading…
Reference in a new issue