Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/link_egress.rs

638 lines
23 KiB
Rust

//! The hosts an agent's linked accounts reach, kept as derived entries of the
//! egress allow list (`crate::egress_allow`), so a restricted agent with a
//! linked GitHub, external matrix or external forge account reaches that
//! service through its hive's proxy.
//!
//! An entry is marked `derived` and carries the account as its `source`
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`), so the
//! backend environment's entries (`crate::backend_egress`) and each account's
//! are replaced and dropped independently. A hive renders it like any other.
//!
//! The controller alone writes the three leaves the entries come from; the
//! agent's store policy grants it `create` on its own `backend-env` leaf and
//! nothing else (`swarm-secret-client/src/policy.rs`), so what these leaves
//! hold is the operator's choice, unlike the backend environment's.
//! [`added`] and [`removed`] follow the operator's link and unlink, and
//! [`reconcile`] makes the entries match the leaves at start: it adds the
//! entries of accounts linked before this existed and drops those of accounts
//! whose leaf is gone, and an agent whose leaves cannot be read keeps what it
//! has.
//!
//! The `homeserver` and `url` of an account go through
//! [`crate::backend_egress::host_of`] and `egress_allow::checked`, the check
//! an operator's own entry gets. An agent's `matrixAccounts.*.homeserver` in
//! its own configuration is not read: the controller never sees it.
use std::collections::{BTreeMap, BTreeSet};
use problem_details::ProblemDetails;
use swarm_wire::egress_allow::{AllowList, Entry, LINK_SOURCE_PREFIX, MAX_ENTRIES};
use crate::backend_egress::host_of;
use crate::egress_allow::{base_domains, checked, current, modify};
use crate::linked_accounts::{AccountKind, AccountStore, linked_accounts};
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::queue;
/// What the GitHub token is used against: `gh`'s API, git over HTTPS, and the
/// raw file, archive and release-asset downloads.
const GITHUB_HOSTS: [&str; 5] = [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com",
"objects.githubusercontent.com",
];
/// An account the operator links to an agent.
pub(crate) enum Link<'a> {
Github,
Matrix {
account: &'a str,
homeserver: Option<&'a str>,
},
Forge {
label: &'a str,
url: &'a str,
},
}
pub(crate) fn github_source() -> String {
format!("{LINK_SOURCE_PREFIX}github")
}
pub(crate) fn matrix_source(account: &str) -> String {
format!("{LINK_SOURCE_PREFIX}matrix:{account}")
}
pub(crate) fn forge_source(label: &str) -> String {
format!("{LINK_SOURCE_PREFIX}forge:{label}")
}
impl Link<'_> {
fn source(&self) -> String {
match self {
Self::Github => github_source(),
Self::Matrix { account, .. } => matrix_source(account),
Self::Forge { label, .. } => forge_source(label),
}
}
/// The entries for `agent`: one per host that passes
/// `egress_allow::checked`. A host that does not (an IP address,
/// `localhost`, a name the base list already allows) is logged and left
/// out. The URL is never logged: it can carry credentials.
fn entries(&self, agent: &str, base: &[String]) -> Vec<Entry> {
let source = self.source();
let hosts: Vec<Option<&str>> = match self {
Self::Github => GITHUB_HOSTS.iter().map(|h| Some(*h)).collect(),
Self::Matrix { homeserver, .. } => homeserver.iter().map(|u| host_of(u)).collect(),
Self::Forge { url, .. } => vec![host_of(url)],
};
let mut entries = Vec::new();
for host in hosts {
let Some(host) = host else {
tracing::warn!(%agent, %source, "link egress: not an http(s) URL; no entry");
continue;
};
let request = swarm_controller_api::egress::AllowedDomain {
domain: host.to_owned(),
agent: Some(agent.to_owned()),
derived: false,
};
match checked(&request, base) {
Ok(entry) => entries.push(Entry {
derived: true,
source: Some(source.clone()),
..entry
}),
Err(why) => {
tracing::warn!(%agent, %source, %why, "link egress: no entry for this host");
}
}
}
entries
}
}
/// `list` without the entries `stale` names, plus `add`. A full list takes no
/// more entries.
fn replaced(mut list: AllowList, stale: impl Fn(&Entry) -> bool, add: &[Entry]) -> AllowList {
list.entries.retain(|e| !stale(e));
for entry in add {
if list.entries.len() >= MAX_ENTRIES {
tracing::warn!(
domain = %entry.domain,
"link egress: the allow list is full; no entry for this host"
);
break;
}
list.entries.push(entry.clone());
}
list
}
/// Whether `e` is `agent`'s entry derived from exactly `source`.
fn is_of(e: &Entry, agent: &str, source: &str) -> bool {
e.derived && e.agent.as_deref() == Some(agent) && e.source.as_deref() == Some(source)
}
/// Replace `agent`'s entries of `source` with `entries`. A host without a
/// queue (`state.status` unset) has no allow list, so nothing happens.
async fn follow(
state: &AppState,
agent: &str,
source: &str,
entries: Vec<Entry>,
stored: &str,
) -> Result<(), ProblemDetails> {
if state.status.is_none() {
return Ok(());
}
let updated = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
modify(&client, |list| {
replaced(list, |e| is_of(e, agent, source), &entries)
})
.await
}
.await;
match updated {
Ok(true) => tracing::info!(%agent, %source, "link egress: derived entries updated"),
Ok(false) => {}
Err(e) => {
tracing::warn!(%agent, %source, error = %format!("{e:#}"), "link egress: updating the allow list failed");
return Err(error_problem(
wanted_error_status(&e),
&format!(
"the account is {stored}, but the allowed domains were not updated \
({e:#}); the controller corrects them when it next starts"
),
));
}
}
Ok(())
}
/// Give `agent` the entries of the account `link` just stored.
///
/// # Errors
/// A problem naming that the account is linked but the list was not updated.
pub(crate) async fn added(
state: &AppState,
agent: &str,
link: &Link<'_>,
) -> Result<(), ProblemDetails> {
let entries = link.entries(agent, &base_domains());
follow(state, agent, &link.source(), entries, "linked").await
}
/// Take from `agent` the entries of the account stored at `source`, which was
/// just deleted.
///
/// # Errors
/// A problem naming that the account is deleted but the list was not updated.
pub(crate) async fn removed(
state: &AppState,
agent: &str,
source: &str,
) -> Result<(), ProblemDetails> {
follow(state, agent, source, Vec::new(), "deleted").await
}
/// The entries the accounts stored for `agent` call for.
async fn wanted_entries(
store: &impl AccountStore,
agent: &str,
base: &[String],
) -> Result<Vec<Entry>, swarm_secret_client::Error> {
let mut entries = Vec::new();
for account in linked_accounts(store, agent).await? {
let link = match account.kind {
AccountKind::Github => Link::Github,
AccountKind::Matrix if !account.reserved => Link::Matrix {
account: &account.name,
homeserver: account.host.as_deref(),
},
AccountKind::Forgejo => match account.host.as_deref() {
Some(url) => Link::Forge {
label: &account.name,
url,
},
None => continue,
},
AccountKind::Matrix | AccountKind::Backend => continue,
};
entries.extend(link.entries(agent, base));
}
Ok(entries)
}
/// `list` with, for each agent in `desired`, its link-derived entries replaced
/// by the given ones. Entries of other agents, operator-added entries and
/// entries derived from the backend environment are untouched.
fn synced(list: AllowList, desired: &BTreeMap<String, Vec<Entry>>) -> AllowList {
let add: Vec<Entry> = desired.values().flatten().cloned().collect();
replaced(
list,
|e| e.from_link() && e.agent.as_ref().is_some_and(|a| desired.contains_key(a)),
&add,
)
}
/// Make the link-derived entries match the stored accounts of every agent in
/// `live` and of every agent that has one: add what a stored account calls for,
/// drop what no stored account does. Logs the counts.
///
/// An agent whose accounts cannot be read keeps its entries and is counted in
/// the answer, which is how many agents that was.
///
/// # Errors
/// A queue failure reading or writing the list.
pub(crate) async fn reconcile(
client: &async_nats::Client,
store: &impl AccountStore,
live: &BTreeSet<String>,
) -> anyhow::Result<usize> {
let before = current(client).await?.unwrap_or_default();
let mut agents = live.clone();
agents.extend(
before
.entries
.iter()
.filter(|e| e.from_link())
.filter_map(|e| e.agent.clone()),
);
let base = base_domains();
let mut desired = BTreeMap::new();
let mut unreadable = 0;
for agent in agents {
match wanted_entries(store, &agent, &base).await {
Ok(entries) => {
desired.insert(agent, entries);
}
Err(e) => {
tracing::warn!(%agent, error = %e, "link egress: reading the linked accounts failed; its entries stay");
unreadable += 1;
}
}
}
let had: BTreeSet<&Entry> = before.entries.iter().filter(|e| e.from_link()).collect();
let after = synced(before.clone(), &desired);
let has: BTreeSet<&Entry> = after.entries.iter().filter(|e| e.from_link()).collect();
let (added, removed) = (has.difference(&had).count(), had.difference(&has).count());
modify(client, |list| synced(list, &desired)).await?;
tracing::info!(
agents = desired.len(),
unreadable,
added,
removed,
"link egress: entries match the linked accounts"
);
Ok(unreadable)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::extract::{Path, State};
use serde_json::json;
use super::*;
use crate::agent_limits::tests::declared;
use crate::linked_accounts::tests::FakeStore;
use crate::test_nats::TestNats;
fn base() -> Vec<String> {
vec!["api.anthropic.com".to_owned()]
}
fn hosts(link: &Link<'_>) -> Vec<String> {
link.entries("atlas", &base())
.into_iter()
.map(|e| e.domain)
.collect()
}
fn entry(domain: &str, agent: &str, source: Option<&str>) -> Entry {
Entry {
domain: domain.to_owned(),
agent: Some(agent.to_owned()),
derived: source.is_some(),
source: source.map(str::to_owned),
}
}
#[test]
fn each_kind_yields_the_hosts_its_service_is_reached_at() {
let github = Link::Github.entries("atlas", &base());
assert_eq!(
github,
[
entry("github.com", "atlas", Some("link:github")),
entry("api.github.com", "atlas", Some("link:github")),
entry("raw.githubusercontent.com", "atlas", Some("link:github")),
entry("codeload.github.com", "atlas", Some("link:github")),
entry(
"objects.githubusercontent.com",
"atlas",
Some("link:github")
),
]
);
// A port, a path, a trailing slash and a scheme-less value all reduce
// to the bare host.
for homeserver in [
"https://Matrix.Example.org:8448/_matrix/",
"https://matrix.example.org/",
"matrix.example.org",
] {
let link = Link::Matrix {
account: "work",
homeserver: Some(homeserver),
};
assert_eq!(hosts(&link), ["matrix.example.org"], "{homeserver}");
assert_eq!(
link.entries("atlas", &base()),
[entry(
"matrix.example.org",
"atlas",
Some("link:matrix:work")
)]
);
}
let forge = Link::Forge {
label: "oss",
url: "https://forge.example.org:3000/sub",
};
assert_eq!(
forge.entries("atlas", &base()),
[entry("forge.example.org", "atlas", Some("link:forge:oss"))]
);
}
#[test]
fn a_host_the_swarm_would_refuse_gets_no_entry() {
for url in [
"http://10.0.0.5:3000",
"https://localhost",
"ftp://forge.example.org",
"https://",
"https://*.example.org",
// The base list already allows it.
"https://api.anthropic.com",
] {
let forge = Link::Forge { label: "x", url };
assert_eq!(hosts(&forge), Vec::<String>::new(), "{url:?}");
}
// Controls: a public name gets one, and a matrix account with no
// homeserver has none to derive.
let forge = Link::Forge {
label: "x",
url: "https://forge.example.org",
};
assert_eq!(hosts(&forge), ["forge.example.org"]);
let matrix = Link::Matrix {
account: "work",
homeserver: None,
};
assert_eq!(hosts(&matrix), Vec::<String>::new());
}
#[test]
fn unlinking_removes_only_that_sources_entries() {
let list = AllowList {
entries: vec![
entry("github.com", "atlas", Some("link:github")),
entry("github.com", "iris", Some("link:github")),
entry("forge.example.org", "atlas", Some("link:forge:oss")),
entry("forge.example.org", "atlas", Some("link:forge:work")),
entry("llm.example.com", "atlas", Some("backend-env")),
entry("old.example.com", "atlas", Some("backend-env")),
entry("github.com", "atlas", None),
],
};
let after = replaced(
list.clone(),
|e| is_of(e, "atlas", &forge_source("oss")),
&[],
);
assert_eq!(after.entries.len(), list.entries.len() - 1);
assert!(!after.entries.contains(&entry(
"forge.example.org",
"atlas",
Some("link:forge:oss")
)));
// Control: the same lookup finds the entry it removed.
assert!(
list.entries
.iter()
.any(|e| is_of(e, "atlas", "link:forge:oss"))
);
// The manual entry for the same name as a derived one stays.
let after = replaced(list, |e| is_of(e, "atlas", &github_source()), &[]);
assert!(after.entries.contains(&entry("github.com", "atlas", None)));
assert!(
!after
.entries
.contains(&entry("github.com", "atlas", Some("link:github")))
);
}
/// The state of a test swarm whose `pr1ma` declares `atlas`, with `store`
/// as the secret store.
async fn swarm(client: &async_nats::Client, store: &Arc<FakeStore>) -> AppState {
AppState {
accounts: crate::linked_accounts::Accounts::Fake(Arc::clone(store)),
..declared(client).await
}
}
async fn list_of(client: &async_nats::Client) -> Vec<Entry> {
let mut entries = current(client)
.await
.expect("the queue answers")
.map(|l| l.entries)
.unwrap_or_default();
entries.sort();
entries
}
fn sorted(mut entries: Vec<Entry>) -> Vec<Entry> {
entries.sort();
entries
}
fn github_entries(agent: &str) -> Vec<Entry> {
GITHUB_HOSTS
.iter()
.map(|h| entry(h, agent, Some("link:github")))
.collect()
}
fn path(parts: &str) -> Path<(String, String)> {
Path(("pr1ma".to_owned(), parts.to_owned()))
}
#[tokio::test]
async fn linking_and_unlinking_through_the_routes_moves_the_entries() {
let nats = TestNats::start();
let client = nats.client().await;
let store = Arc::default();
let state = swarm(&client, &store).await;
let _ = crate::github_account::put_github_account(
State(state.clone()),
path("atlas"),
axum::Json(serde_json::from_value(json!({"token": "t"})).expect("a body")),
)
.await
.expect("linked");
let _ = crate::forge_account::put_forge_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "oss".to_owned())),
axum::Json(
serde_json::from_value(
json!({"url": "https://forge.example.org:3000/", "token": "t"}),
)
.expect("a body"),
),
)
.await
.expect("linked");
let _ = crate::matrix_account::put_matrix_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
axum::Json(
serde_json::from_value(json!({
"mode": "token", "token": "t", "homeserver": "https://matrix.example.org/"
}))
.expect("a body"),
),
)
.await
.expect("linked");
let forge = entry("forge.example.org", "atlas", Some("link:forge:oss"));
let matrix = entry("matrix.example.org", "atlas", Some("link:matrix:work"));
let mut all = github_entries("atlas");
all.extend([forge.clone(), matrix.clone()]);
assert_eq!(list_of(&client).await, sorted(all));
// The backend environment's own save and delete leave them alone.
crate::backend_env::put_backend_env(
State(state.clone()),
path("atlas"),
axum::Json(
serde_json::from_value(
json!({"env": "ANTHROPIC_BASE_URL=https://llm.example.com\n"}),
)
.expect("a body"),
),
)
.await
.expect("stored");
crate::linked_accounts::delete_backend_env(State(state.clone()), path("atlas"))
.await
.expect("deleted");
assert_eq!(list_of(&client).await.len(), 2 + GITHUB_HOSTS.len());
crate::linked_accounts::delete_github_account(State(state.clone()), path("atlas"))
.await
.expect("deleted");
assert_eq!(
list_of(&client).await,
sorted(vec![forge.clone(), matrix.clone()])
);
crate::linked_accounts::delete_forge_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "oss".to_owned())),
)
.await
.expect("deleted");
assert_eq!(list_of(&client).await, [matrix]);
crate::linked_accounts::delete_matrix_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
axum::extract::Query(serde_json::from_value(json!({})).expect("a query")),
)
.await
.expect("deleted");
assert_eq!(list_of(&client).await, []);
}
#[tokio::test]
async fn the_backfill_adds_entries_for_existing_links_and_prunes_orphans() {
let nats = TestNats::start();
let client = nats.client().await;
let store =
FakeStore::default().with("swarm/agents/atlas/github-token", json!({"value": "t"}));
let store = store.with(
"swarm/agents/atlas/matrix/work",
json!({"value": "t", "homeserver": "https://matrix.example.org"}),
);
// The agent's own account has a homeserver of its own and no entry.
let store = store.with(
"swarm/agents/atlas/matrix/main",
json!({"value": "t", "homeserver": "https://hive.example.org"}),
);
modify(&client, |mut list| {
list.entries.extend([
// A link from before the host list grew: the pass adds the rest.
entry("github.com", "atlas", Some("link:github")),
// An orphan: iris has no leaf for it.
entry("github.com", "iris", Some("link:github")),
// An orphan on a live agent whose forge leaf is gone.
entry("forge.example.org", "atlas", Some("link:forge:old")),
entry("llm.example.com", "atlas", Some("backend-env")),
entry("crates.io", "atlas", None),
]);
list
})
.await
.expect("seed");
let live = BTreeSet::from(["atlas".to_owned()]);
assert_eq!(reconcile(&client, &store, &live).await.expect("pass"), 0);
let mut want = github_entries("atlas");
want.extend([
entry("matrix.example.org", "atlas", Some("link:matrix:work")),
entry("llm.example.com", "atlas", Some("backend-env")),
entry("crates.io", "atlas", None),
]);
assert_eq!(list_of(&client).await, sorted(want.clone()));
// A second pass changes nothing.
reconcile(&client, &store, &live).await.expect("again");
assert_eq!(list_of(&client).await, sorted(want));
}
#[tokio::test]
async fn a_store_error_prunes_nothing_and_adds_nothing() {
let nats = TestNats::start();
let client = nats.client().await;
let seeded = vec![
entry("github.com", "iris", Some("link:github")),
entry("forge.example.org", "atlas", Some("link:forge:old")),
];
modify(&client, |mut list| {
list.entries.extend(seeded.clone());
list
})
.await
.expect("seed");
let live = BTreeSet::from(["atlas".to_owned()]);
let unreadable = reconcile(&client, &FakeStore::denying(), &live)
.await
.expect("pass");
assert_eq!(unreadable, 2, "atlas and iris");
assert_eq!(list_of(&client).await, sorted(seeded.clone()));
// Control: a store that answers, and holds nothing, prunes both.
reconcile(&client, &FakeStore::default(), &live)
.await
.expect("pass");
assert_eq!(list_of(&client).await, []);
}
}