From 7e2f4ce7c8af5b95347f170dd58a38d1d9e6eb77 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 29 Oct 2026 01:47:25 +0100 Subject: [PATCH] always include a hive's own allowedDomains in the rendered swarm-wide list --- docs/networking/network.md | 14 ++- docs/swarm/ui.md | 5 +- .../src/agent/detail/AllowedDomains.tsx | 10 +- hive-c0re/src/workers/squid_allow.rs | 118 ++++++++++++++---- nix/host-modules/hive-squid.nix | 31 ++--- nix/vm-tests/hive-squid.nix | 8 +- swarm-controller/README.md | 4 +- swarm-controller/src/backend_egress.rs | 2 +- swarm-controller/src/egress_allow.rs | 24 ++-- 9 files changed, 141 insertions(+), 75 deletions(-) diff --git a/docs/networking/network.md b/docs/networking/network.md index e3d15215..e7e10c28 100644 --- a/docs/networking/network.md +++ b/docs/networking/network.md @@ -307,8 +307,8 @@ host. It never decrypts TLS, so it holds no signing key. It caches nothing. which the rules below refuse, and agents reach them directly. Each entry matches that exact name, or with a leading dot the name and its subdomains. A user on the unrestricted list reaches any other name too, on ports 80 and 443. Everyone else gets a 403 for it. - `allowedDomains` is the hive's own list. Squid uses it until the swarm has a - list of its own (next bullet). + `allowedDomains` is the hive's own list, always allowed whether or not the + swarm has a list of its own (next bullet). - **Swarm allow-list.** The operator adds domains in the swarm UI ([allowed domains](../swarm/ui.md#allowed-domains)); the swarm controller keeps them in one value of the `egress-allow` bucket, each for every agent @@ -344,11 +344,13 @@ host. It never decrypts TLS, so it holds no signing key. It caches nothing. the `opencode` preset's `acp.opencode.provider.baseUrl`, get no derived entry, so add one in the swarm UI. A `hive-c0re` that predates the field renders a derived entry like any other. - The swarm's list replaces the hive's `allowedDomains` as soon as it exists, - even when empty: add the names the hive allowed to the swarm's list - before relying on it. Until the controller has a list (it predates the + The swarm's list adds to the hive's `allowedDomains`: `allowedFile` holds + those names plus the swarm-wide entries, so removing an entry never removes a + name the hive lists itself. Until the controller has a list (it predates the feature, or nobody added an entry) `allowedFile` holds `allowedDomains` and - `agent-allow.conf` is empty. A queue that stops answering for the bucket + `agent-allow.conf` is empty. A `hive-c0re` older than this rule replaces + `allowedDomains` with the swarm's list once the controller has one, so every + hive must run the new `hive-c0re` before the controller's first entry exists. A queue that stops answering for the bucket leaves a file the swarm wrote untouched, and a failed read leaves both files untouched. The first line of a file written from the swarm's list is a `# source: swarm` comment. The queue grant a hive needs to read the list diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index d5670db6..c198a189 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -655,9 +655,8 @@ an entry that exists changes nothing. A saved change isn't instant. Each hive reads the list about every 30 seconds and reloads its proxy, so the name is reachable within a minute. The proxy's private-range rules still apply: an entry whose name resolves to a -loopback, private or bridge address stays blocked. The first entry makes the -swarm's list replace the names a hive's own `allowedDomains` lists, so add -those names here too; a hive keeps its own list until then. See [the egress +loopback, private or bridge address stays blocked. An entry adds to the names a +hive's own `allowedDomains` lists, which stay allowed. See [the egress proxy](../networking/network.md#egress-proxy-squid) for what each hive does when the controller is old or unreachable. diff --git a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx index ff3851f2..7341e405 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx @@ -10,9 +10,8 @@ // allows everyone) shows as it arrives, next to the box. The hive reads a // change within about 30 seconds. // -// The first entry makes the swarm's list replace the names the hive's own -// configuration allows, so a name the hive allowed and the swarm's list lacks -// stops being allowed once hives read it. An agent that holds the unrestricted +// An entry adds to the names the hive's own configuration allows, which stay +// allowed. An agent that holds the unrestricted // internet flag reaches any name the proxy's private-range rules leave, so an // entry matters only to a restricted one. // @@ -223,9 +222,8 @@ function AddForm({ such as github.com; a leading dot, as in{" "} .githubusercontent.com, also allows every subdomain. IP addresses and wildcards are refused. A name that resolves to a private - address stays blocked. The first entry makes the swarm's list replace - the names each hive's own configuration allows. The hive reads a change - within about 30 seconds. + address stays blocked. The entry adds to the names each hive's own + configuration allows. The hive reads a change within about 30 seconds.

void save(e)}> Result { let always: Vec<&str> = always.split_whitespace().collect(); let rendered = match read { - Read::Current(list) => render(&list, agents, &always), + Read::Current(list) => render(&list, agents, &always, &seed_names(seed)?), Read::NotSet => { if super::squid_users::read_or_empty(files.allowed)?.starts_with(SWARM_MARKER) { return Ok(Source::Kept); @@ -251,18 +251,27 @@ struct Rendered { counts: Option, } -/// The files for the seed: its names as they are, and no per-agent rules. -fn seeded(seed: Option<&Path>) -> Result { - let names = match seed { +/// The names in the seed file, trimmed, without blank lines and comments. The +/// file is this hive's own configuration, so its names are not re-checked. +fn seed_names(seed: Option<&Path>) -> Result> { + let text = match seed { Some(path) => super::squid_users::read_or_empty(path)?, None => String::new(), }; + Ok(text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_owned) + .collect()) +} + +/// The files for the seed: its names as they are, and no per-agent rules. +fn seeded(seed: Option<&Path>) -> Result { let mut allowed = SEED_MARKER.to_owned(); - for line in names.lines().map(str::trim) { - if !line.is_empty() && !line.starts_with('#') { - allowed.push_str(line); - allowed.push('\n'); - } + for name in seed_names(seed)? { + allowed.push_str(&name); + allowed.push('\n'); } Ok(Rendered { allowed, @@ -283,8 +292,9 @@ fn reduced(mut names: Vec) -> Vec { names } -/// The files for `list`, for the agents this hive runs. -fn render(list: &AllowList, agents: &[String], always: &[&str]) -> Rendered { +/// The files for `list` and the seed's `own` names, for the agents this hive +/// runs. +fn render(list: &AllowList, agents: &[String], always: &[&str], own: &[String]) -> Rendered { let mut skipped = Vec::new(); let mut swarm_wide = Vec::new(); let mut per_agent: std::collections::BTreeMap<&str, Vec> = @@ -305,6 +315,7 @@ fn render(list: &AllowList, agents: &[String], always: &[&str]) -> Rendered { } } } + swarm_wide.extend(own.iter().map(|name| name.to_ascii_lowercase())); swarm_wide.retain(|d| !covered_by(d, always)); let swarm_wide = reduced(swarm_wide); @@ -366,7 +377,7 @@ mod tests { const ALWAYS: &[&str] = &["api.anthropic.com", ".claude.ai", "cache.nixos.org"]; fn rendered(entries: &[(&str, Option<&str>)], mine: &[&str]) -> Rendered { - render(&list(entries), &agents(mine), ALWAYS) + render(&list(entries), &agents(mine), ALWAYS, &[]) } #[test] @@ -464,6 +475,7 @@ mod tests { &list(&[("api.anthropic.com", None), ("a.claude.ai", None)]), &agents(&["atlas"]), &[], + &[], ); assert_eq!( bare.allowed, @@ -545,18 +557,74 @@ mod tests { ); } + /// The seed is the hive's own additive list: a swarm list, empty or not, + /// renders the seed's names with it. #[test] - fn a_swarm_list_replaces_the_seed_even_when_empty() { + fn a_swarm_list_adds_to_the_seed_even_when_empty() { let d = dir("forge.example\n"); sync(&d, Read::NotSet).expect("seeded"); assert!(text(&d.allowed()).contains("forge.example"), "control"); let empty = Read::Current(AllowList::default()); assert_eq!(sync(&d, empty).expect("renders"), Source::Swarm); - assert_eq!(text(&d.allowed()), SWARM_MARKER); - let named = Read::Current(list(&[("github.com", None), ("pypi.org", Some("atlas"))])); + assert_eq!(text(&d.allowed()), "# source: swarm\nforge.example\n"); + let named = Read::Current(list(&[("github.com", None)])); sync(&d, named).expect("renders"); + assert_eq!( + text(&d.allowed()), + "# source: swarm\nforge.example\ngithub.com\n" + ); + assert_eq!(text(&d.agent_allow()), SWARM_MARKER); + } + + #[test] + fn a_per_agent_entry_renders_on_top_of_the_seed() { + let d = dir("forge.example\n"); + let read = Read::Current(list(&[("pypi.org", Some("atlas"))])); + sync(&d, read).expect("renders"); + assert_eq!(text(&d.allowed()), "# source: swarm\nforge.example\n"); + assert_eq!( + text(&d.agent_allow()), + "# source: swarm\n\ + acl agent_allow_atlas proxy_auth atlas\n\ + acl agent_allow_atlas_dst dstdomain pypi.org\n\ + http_access allow agent_allow_atlas agent_allow_atlas_dst\n" + ); + } + + #[test] + fn a_seed_name_the_swarm_repeats_or_squid_allows_anyway_is_written_once() { + // `cache.nixos.org` is always allowed; `.example.com` covers the + // swarm's `api.example.com`. + let d = dir("Forge.Example\n.example.com\ncache.nixos.org\n"); + let read = Read::Current(list(&[ + ("forge.example", None), + ("api.example.com", None), + ("github.com", None), + ])); + sync(&d, read).expect("renders"); + assert_eq!( + text(&d.allowed()), + "# source: swarm\n.example.com\nforge.example\ngithub.com\n" + ); + } + + #[test] + fn no_seed_file_is_no_seed_names() { + let d = dir(""); + std::fs::remove_file(d.seed()).expect("remove"); + let read = Read::Current(list(&[("github.com", None)])); + sync_with( + read, + &agents(&["atlas"]), + None, + "", + &Files { + allowed: &d.allowed(), + agent_allow: &d.agent_allow(), + }, + ) + .expect("renders"); assert_eq!(text(&d.allowed()), "# source: swarm\ngithub.com\n"); - assert!(text(&d.agent_allow()).contains("acl agent_allow_atlas proxy_auth atlas")); } #[test] diff --git a/nix/host-modules/hive-squid.nix b/nix/host-modules/hive-squid.nix index 0083363d..3909a9fe 100644 --- a/nix/host-modules/hive-squid.nix +++ b/nix/host-modules/hive-squid.nix @@ -5,8 +5,8 @@ # hive-c0re renders. A credential's agent either has the swarm's # `unrestricted_internet` flag, and may then reach any destination the deny # rules leave, or reaches only the listed names: the base list, the allow-list -# the swarm keeps (rendered by hive-c0re beside the users file, with this -# host's `allowedDomains` as the list until the swarm has one) and the names +# the swarm keeps plus this host's `allowedDomains` (rendered by hive-c0re +# beside the users file) and the names # the swarm lists for that agent alone. An agent sends its credential # once it has fetched it (../agent-modules/egress-proxy.nix); the exporter, # journal forwarding and the self-check below show the proxy works. @@ -32,8 +32,8 @@ let # The names squid allows every authenticated user whatever the swarm lists. # hive-c0re skips an allow-list entry that one of them already covers. alwaysAllowed = base ++ [ cfg.selfcheck.canary ]; - # What `allowedFile` holds until the swarm has an allow-list: this hive's - # own `allowedDomains`, one per line. The canary is in the configuration. + # What `allowedFile` starts as and always includes: this hive's own + # `allowedDomains`, one per line. The canary is in the configuration. seedFile = pkgs.writeText "squid-allowed-seed" ( lib.concatMapStrings (d: d + "\n") ( lib.unique (lib.subtractLists [ cfg.selfcheck.canary ] cfg.allowedDomains) @@ -254,12 +254,13 @@ in "crates.io" ]; description = '' - Hostnames squid lets a client reach, by CONNECT or plain HTTP, until the - swarm has an allow-list of its own: the list the swarm's controller - keeps (`/api/egress/allowed-domains`) replaces this one as soon as it - holds an entry or has had one, and this option is the list again only - while the controller has none, for instance because it predates the - feature. hive-c0re writes whichever list applies to `allowedFile`. + Hostnames squid always lets a client reach, by CONNECT or plain HTTP, on + this hive. The entries the swarm's controller keeps + (`/api/egress/allowed-domains`) add to this list and never replace it: + hive-c0re writes these names and the swarm-wide entries to + `allowedFile`. A hive-c0re that predates this rule replaced this list + with the controller's once the controller held an entry, so every hive + must run the new one before the controller creates its first entry. Each entry matches that exact name, or that name and its subdomains with a leading dot. Everything else gets a 403, except for an unrestricted user. The swarm's own service names are not @@ -340,9 +341,9 @@ in description = '' The names squid lets every restricted user reach, one per line, read as part of its allow ACL beside the self-check canary. It starts as a copy - of `allowedDomains`. hive-c0re rewrites it every pass: with the swarm's - swarm-wide entries once the controller has a list, otherwise with - `allowedDomains`. Squid is reloaded when it changes. Owned by + of `allowedDomains`. hive-c0re rewrites it every pass: with + `allowedDomains` and the swarm-wide entries once the controller has a + list, otherwise with `allowedDomains` alone. Squid is reloaded when it changes. Owned by `hive-core` and readable by the `squid` group. ''; }; @@ -369,8 +370,8 @@ in readOnly = true; defaultText = lib.literalMD "`allowedDomains`, one per line, minus the self-check canary"; description = '' - The names `allowedFile` starts as and hive-c0re writes to it again while - the swarm has no allow-list. Read-only. + The names `allowedFile` starts as and hive-c0re writes into it on every + pass, with or without a swarm allow-list. Read-only. ''; }; diff --git a/nix/vm-tests/hive-squid.nix b/nix/vm-tests/hive-squid.nix index 37c4d3c1..f0f71599 100644 --- a/nix/vm-tests/hive-squid.nix +++ b/nix/vm-tests/hive-squid.nix @@ -520,13 +520,13 @@ pkgs.testers.runNixOSTest { wait_status("other.t.local", RUTH, "403") assert connect("other.t.local", UMA) == "200", "control: uma is still listed" - with subtest("the swarm's allow-list replaces the hive's names, opens one to every restricted user, and a per-agent rule opens one to that agent alone"): + with subtest("the swarm's allow-list adds to the hive's names, opens one to every restricted user, and a per-agent rule opens one to that agent alone"): seeded = proxy.succeed(f"cat {ALLOWED}") assert connect("swarm.t.local", RUTH) == "403", "control: not allowed yet" assert connect("agentonly.t.local", ATLAS) == "403", "control: not allowed yet" assert connect("forge.t.local", RUTH) == "200", "control: the hive's own name" write_allow( - "# source: swarm\nswarm.t.local\n", + "# source: swarm\n" + seeded + "swarm.t.local\n", "# source: swarm\n" "acl agent_allow_atlas proxy_auth atlas\n" "acl agent_allow_atlas_dst dstdomain agentonly.t.local priv10.t.local\n" @@ -537,13 +537,13 @@ pkgs.testers.runNixOSTest { assert connect("swarm.t.local", ATLAS) == "200", "swarm-wide, another user" assert connect("agentonly.t.local", RUTH) == "403", "the per-agent name, another restricted user" assert connect("agentonly.t.local") == "407", "no credential" - assert connect("forge.t.local", RUTH) == "403", "the hive's own name is replaced" + assert connect("forge.t.local", RUTH) == "200", "the hive's own name stays" assert connect("priv10.t.local", ATLAS) == "403", "a per-agent name that resolves privately" assert connect("agentonly.t.local", UMA) == "200", "control: unrestricted reaches it anyway" write_allow(seeded, "") wait_status("swarm.t.local", RUTH, "403") wait_status("agentonly.t.local", ATLAS, "403") - assert connect("forge.t.local", RUTH) == "200", "the hive's own name is back" + assert connect("forge.t.local", RUTH) == "200", "the hive's own name after the swarm list is gone" with subtest("squid starts on a missing list, treats every user as restricted, and recovers when it appears"): proxy.succeed(f"rm {LIST}") diff --git a/swarm-controller/README.md b/swarm-controller/README.md index 213f9bf3..46047ece 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -66,8 +66,8 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients. hive's egress proxy, each for every agent or for one, as one value in the `egress-allow` KV bucket, which this daemon alone writes (`GET`/`POST`/`DELETE /api/egress/allowed-domains`); every hive reads it and - renders the proxy's allow-list files, and it replaces the list the hive's own - configuration names. It also keeps a `derived` entry per agent for the host + renders the proxy's allow-list files, which add to the names the hive's own + configuration lists. It also keeps a `derived` entry per agent for the host of its stored backend environment's `ANTHROPIC_BASE_URL` → [`docs/networking/network.md`](../docs/networking/network.md#egress-proxy-squid). - **Agent instructions** — each agent's `AGENTS.md` as its harness last diff --git a/swarm-controller/src/backend_egress.rs b/swarm-controller/src/backend_egress.rs index 7ba14a58..90d917ed 100644 --- a/swarm-controller/src/backend_egress.rs +++ b/swarm-controller/src/backend_egress.rs @@ -508,7 +508,7 @@ mod tests { .await .expect("the queue answers") .is_none(), - "no bucket: each hive keeps its own list" + "no bucket: nothing was written" ); // Control: a host does create it. put( diff --git a/swarm-controller/src/egress_allow.rs b/swarm-controller/src/egress_allow.rs index ec1e1147..9d16b9ad 100644 --- a/swarm-controller/src/egress_allow.rs +++ b/swarm-controller/src/egress_allow.rs @@ -5,8 +5,9 @@ //! (`swarm_wire::egress_allow`), which this daemon alone writes. Every hive //! reads it and renders the entries for the agents it runs: a swarm-wide entry //! joins the proxy's allow list for everyone, an entry with an agent is that -//! agent's alone. Once the key exists it replaces the list a hive's own nix -//! configuration names, so an empty list is a list. +//! agent's alone. A hive always allows the names its own nix configuration +//! lists (`services.hyperhive.squid.allowedDomains`) as well, so these entries +//! only add to them. //! //! Each name goes into the proxy's configuration, so it is checked here with //! `swarm_wire::egress_allow::normalize_domain` (the hive checks again) and @@ -127,7 +128,7 @@ async fn open_or_create( path = "/api/egress/allowed-domains", responses( (status = 200, description = "every entry, swarm-wide and per agent, sorted; empty \ - when the swarm has not set a list, in which case each hive keeps its own", + when the swarm has not set a list", body = AllowedDomains), (status = 503, description = "no swarm queue is configured on this host, or it is \ not connected (problem+json)", body = String), @@ -154,10 +155,8 @@ pub(crate) async fn get_allowed_domains( /// Allow one destination, for every agent or for one. /// -/// Adding an entry that is already there changes nothing. The first entry -/// makes the swarm's list replace each hive's own, so a name the hive's nix -/// configuration allows and this list lacks stops being allowed once hives read -/// it. +/// Adding an entry that is already there changes nothing. The entry adds to +/// the names each hive's nix configuration already allows. #[utoipa::path( post, path = "/api/egress/allowed-domains", @@ -221,8 +220,8 @@ pub(crate) async fn add_allowed_domain( /// Stop allowing one destination for the scope it was added with. /// -/// Removing an entry that is not there changes nothing. Removing the last one -/// leaves an empty list, which still replaces each hive's own. +/// Removing an entry that is not there changes nothing. A name a hive's nix +/// configuration lists stays allowed on that hive. #[utoipa::path( delete, path = "/api/egress/allowed-domains", @@ -300,8 +299,7 @@ pub(crate) async fn current(client: &async_nats::Client) -> anyhow::Result