865 lines
31 KiB
Rust
865 lines
31 KiB
Rust
//! Squid's allow-list files: the destination names the swarm lets this hive's
|
|
//! agents reach through the egress proxy.
|
|
//!
|
|
//! swarm-controller keeps one list for the whole swarm
|
|
//! ([`swarm_wire::egress_allow`]); each entry is for every agent or for one.
|
|
//! Every [`INTERVAL`] this reads it and renders two files for squid:
|
|
//!
|
|
//! - [`ENV_ALLOWED_FILE`]: the swarm-wide names, one per line, which
|
|
//! `nix/host-modules/hive-squid.nix` reads as part of `allowed_dst`.
|
|
//! - [`ENV_AGENT_ALLOW_FILE`]: squid configuration `include`d where the base
|
|
//! list is allowed: for each of this hive's agents with entries, an `acl`
|
|
//! for its squid user, an `acl` for its names and the `http_access allow`
|
|
//! joining them, then the rules of the agents in `log` mode ([`log_rules`]).
|
|
//!
|
|
//! Squid reads both when it parses its configuration, so the path unit in
|
|
//! `nix/host-modules/hive-squid.nix` reloads it when either changes.
|
|
//!
|
|
//! The names `services.hyperhive.deploy.hive-controller.squid.allowedDomains` lists ([`ENV_SEED_FILE`])
|
|
//! are always allowed: the swarm-wide names written are those plus the swarm's,
|
|
//! with the per-agent rules on top. Until the swarm has a list the files hold
|
|
//! just those names. A file from the swarm's list starts with [`SWARM_MARKER`];
|
|
//! the queue not answering for the bucket is no withdrawal, so a file with the
|
|
//! marker is left alone then.
|
|
//!
|
|
//! Every entry is checked again with
|
|
//! [`swarm_wire::egress_allow::normalize_domain`], since the names end up in
|
|
//! squid's configuration, and skipped when it fails. An entry that
|
|
//! [`ENV_ALWAYS_ALLOWED`] or another entry of its list covers is dropped,
|
|
//! because squid ignores the narrower of two overlapping names in one ACL. A
|
|
//! failed read leaves both files as they are.
|
|
|
|
use std::fmt::Write as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
use swarm_queue_client::egress_allow::{self, Read};
|
|
use swarm_wire::egress_allow::{AllowList, covered_by, normalize_domain};
|
|
|
|
use super::squid_users::write_if_changed;
|
|
use crate::permissions::{self, InternetMode};
|
|
use crate::stats::sweep_health::{self, SweepHealth};
|
|
|
|
/// How often the list is read.
|
|
pub const INTERVAL: Duration = Duration::from_secs(30);
|
|
|
|
/// At [`INTERVAL`] this is ~90 seconds of failing reads.
|
|
const FAILURES_BEFORE_WARNING: u32 = 3;
|
|
|
|
/// The swarm-wide names file. Set by `nix/host-modules/hive-c0re` from
|
|
/// `services.hyperhive.deploy.hive-controller.squid.allowedFile`.
|
|
pub const ENV_ALLOWED_FILE: &str = "HIVE_SQUID_ALLOWED_FILE";
|
|
|
|
/// The per-agent squid configuration file. Set from
|
|
/// `services.hyperhive.deploy.hive-controller.squid.agentAllowFile`.
|
|
pub const ENV_AGENT_ALLOW_FILE: &str = "HIVE_SQUID_AGENT_ALLOW_FILE";
|
|
|
|
/// The file of names `services.hyperhive.deploy.hive-controller.squid.allowedDomains` lists, one per
|
|
/// line, which every rendering of the swarm-wide names includes.
|
|
pub const ENV_SEED_FILE: &str = "HIVE_SQUID_ALLOWED_SEED_FILE";
|
|
|
|
/// The names, separated by spaces, that squid allows every authenticated user
|
|
/// regardless of this list: the base list and the self-check canary.
|
|
pub const ENV_ALWAYS_ALLOWED: &str = "HIVE_SQUID_ALWAYS_ALLOWED";
|
|
|
|
/// The first line of a file rendered from the swarm's list.
|
|
const SWARM_MARKER: &str = "# source: swarm\n";
|
|
|
|
/// The first line of a file rendered from the seed.
|
|
const SEED_MARKER: &str = "# source: seed\n";
|
|
|
|
/// The annotation `log` rules put on a request, as `key=value`. The `note` ACL
|
|
/// in `nix/host-modules/hive-squid.nix` reads the key.
|
|
const MARK: &str = "would_deny=1";
|
|
|
|
/// The ACL that applies [`MARK`].
|
|
const MARK_ACL: &str = "log_mark";
|
|
|
|
/// The two files a pass writes.
|
|
struct Files<'a> {
|
|
allowed: &'a Path,
|
|
agent_allow: &'a Path,
|
|
}
|
|
|
|
/// What a pass left in the files.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Source {
|
|
/// The swarm's list.
|
|
Swarm,
|
|
/// The seed, since the swarm has no list.
|
|
Seed,
|
|
/// The swarm has no list now, but the files hold the one it had.
|
|
Kept,
|
|
}
|
|
|
|
fn env_path(name: &str) -> Option<PathBuf> {
|
|
std::env::var_os(name)
|
|
.filter(|v| !v.is_empty())
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
/// Read the list now and every [`INTERVAL`] until shutdown, when this host has
|
|
/// squid's two files.
|
|
pub async fn follow(client: async_nats::Client, mut shutdown: tokio::sync::watch::Receiver<bool>) {
|
|
let (Some(allowed), Some(agent_allow)) =
|
|
(env_path(ENV_ALLOWED_FILE), env_path(ENV_AGENT_ALLOW_FILE))
|
|
else {
|
|
return;
|
|
};
|
|
let seed = env_path(ENV_SEED_FILE);
|
|
let always = std::env::var(ENV_ALWAYS_ALLOWED).unwrap_or_default();
|
|
let mut health = SweepHealth::new("squid_allow_render", "warn", FAILURES_BEFORE_WARNING);
|
|
// The last `Source` that was logged, so a list that stays unset is
|
|
// announced once rather than every pass.
|
|
let mut announced: Option<&'static str> = None;
|
|
loop {
|
|
let files = Files {
|
|
allowed: &allowed,
|
|
agent_allow: &agent_allow,
|
|
};
|
|
match pass(&client, &files, seed.as_deref(), &always).await {
|
|
Ok(source) => {
|
|
health.record_ok();
|
|
announce(&source, &mut announced);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "squid allow list: the pass failed");
|
|
let err = format!("{e:#}");
|
|
health.record_err(|ctx| {
|
|
let age = ctx.since_last_ok.map_or_else(
|
|
|| "no success this session".to_owned(),
|
|
|d| format!("last ok {} ago", sweep_health::fmt_age(d)),
|
|
);
|
|
format!(
|
|
"reading the swarm's egress allow-list is failing ({} consecutive, \
|
|
{age}) — squid keeps the list last written: {err}",
|
|
ctx.consecutive
|
|
)
|
|
});
|
|
}
|
|
}
|
|
tokio::select! {
|
|
() = tokio::time::sleep(INTERVAL) => {}
|
|
_ = shutdown.changed() => return,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Log the first pass of a run that left the seed or the kept files.
|
|
fn announce(source: &Source, announced: &mut Option<&'static str>) {
|
|
let (key, message) = match source {
|
|
Source::Swarm => {
|
|
*announced = None;
|
|
return;
|
|
}
|
|
Source::Seed => (
|
|
"seed",
|
|
"squid allow list: the swarm has no list, so squid keeps this hive's own names",
|
|
),
|
|
Source::Kept => (
|
|
"kept",
|
|
"squid allow list: the swarm's list is not readable now, so squid keeps the last one written",
|
|
),
|
|
};
|
|
if *announced != Some(key) {
|
|
tracing::info!("{message}");
|
|
*announced = Some(key);
|
|
}
|
|
}
|
|
|
|
async fn pass(
|
|
client: &async_nats::Client,
|
|
files: &Files<'_>,
|
|
seed: Option<&Path>,
|
|
always: &str,
|
|
) -> Result<Source> {
|
|
// An unconnected client does not fail a JetStream request, it hangs on it.
|
|
swarm_queue_client::ensure_connected(client)?;
|
|
let agents: Vec<String> = crate::lifecycle::agents_for_meta_listing()
|
|
.await
|
|
.context("enumerating this hive's agents")?
|
|
.into_iter()
|
|
.map(|spec| spec.name)
|
|
.collect();
|
|
let perms = permissions::read_at(&permissions::path())
|
|
.context("reading the stored swarm permissions")?;
|
|
let logging = log_agents(&agents, &perms);
|
|
let read = egress_allow::read(client)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!(e))
|
|
.context("reading the swarm's egress allow-list")?;
|
|
sync_with(read, &agents, &logging, seed, always, files)
|
|
}
|
|
|
|
/// [`pass`] with the read, the agents and the files given. Each file is only
|
|
/// written when its rendering differs from what it holds.
|
|
fn sync_with(
|
|
read: Read,
|
|
agents: &[String],
|
|
logging: &[String],
|
|
seed: Option<&Path>,
|
|
always: &str,
|
|
files: &Files<'_>,
|
|
) -> Result<Source> {
|
|
let always: Vec<&str> = always.split_whitespace().collect();
|
|
let rendered = match read {
|
|
Read::Current(list) => render(&list, agents, logging, &always, &seed_names(seed)?),
|
|
Read::NotSet => {
|
|
if super::squid_users::read_or_empty(files.allowed)?.starts_with(SWARM_MARKER) {
|
|
return Ok(Source::Kept);
|
|
}
|
|
seeded(seed, logging)?
|
|
}
|
|
};
|
|
let changed_allowed = write_if_changed(files.allowed, &rendered.allowed)?;
|
|
let changed_agents = write_if_changed(files.agent_allow, &rendered.agent_allow)?;
|
|
if changed_allowed || changed_agents {
|
|
if let Some(counts) = &rendered.counts {
|
|
tracing::info!(file = %files.allowed.display(), "{}", counts.summary());
|
|
if !counts.skipped.is_empty() {
|
|
tracing::warn!(
|
|
skipped = ?counts.skipped,
|
|
"squid allow list: entries the swarm holds were skipped as invalid"
|
|
);
|
|
}
|
|
} else {
|
|
tracing::info!(
|
|
file = %files.allowed.display(),
|
|
"squid allow list: wrote this hive's own names, no swarm list"
|
|
);
|
|
}
|
|
}
|
|
Ok(if rendered.counts.is_some() {
|
|
Source::Swarm
|
|
} else {
|
|
Source::Seed
|
|
})
|
|
}
|
|
|
|
/// What the swarm's list rendered as.
|
|
struct Counts {
|
|
/// Swarm-wide names written.
|
|
swarm: usize,
|
|
/// Names written across this hive's agents.
|
|
agent: usize,
|
|
/// Entries left out because they are not valid names or agents.
|
|
skipped: Vec<String>,
|
|
}
|
|
|
|
impl Counts {
|
|
/// The info line a pass that changed a file logs.
|
|
fn summary(&self) -> String {
|
|
format!(
|
|
"squid allow list: {} swarm, {} agent entries",
|
|
self.swarm, self.agent
|
|
)
|
|
}
|
|
}
|
|
|
|
struct Rendered {
|
|
allowed: String,
|
|
agent_allow: String,
|
|
/// `None` for the seed.
|
|
counts: Option<Counts>,
|
|
}
|
|
|
|
/// 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<Vec<String>> {
|
|
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 allow rules.
|
|
fn seeded(seed: Option<&Path>, logging: &[String]) -> Result<Rendered> {
|
|
let mut allowed = SEED_MARKER.to_owned();
|
|
for name in seed_names(seed)? {
|
|
allowed.push_str(&name);
|
|
allowed.push('\n');
|
|
}
|
|
Ok(Rendered {
|
|
allowed,
|
|
agent_allow: format!("{SEED_MARKER}{}", log_rules(logging)),
|
|
counts: None,
|
|
})
|
|
}
|
|
|
|
/// `names` sorted, with each one that another covers dropped.
|
|
fn reduced(mut names: Vec<String>) -> Vec<String> {
|
|
names.sort();
|
|
names.dedup();
|
|
let all = names.clone();
|
|
names.retain(|n| {
|
|
let others: Vec<&String> = all.iter().filter(|o| *o != n).collect();
|
|
!covered_by(n, &others)
|
|
});
|
|
names
|
|
}
|
|
|
|
/// The agents among `agents` whose record is in `log` mode, sorted. A name that
|
|
/// is not an identifier is left out, since the rules put it in squid's
|
|
/// configuration.
|
|
fn log_agents(agents: &[String], perms: &permissions::Stored) -> Vec<String> {
|
|
let mut logging: Vec<String> = agents
|
|
.iter()
|
|
.filter(|a| hive_types::Ident::parse(a).is_ok())
|
|
.filter(|a| permissions::internet_mode(perms.get(*a)) == InternetMode::Log)
|
|
.cloned()
|
|
.collect();
|
|
logging.sort();
|
|
logging.dedup();
|
|
logging
|
|
}
|
|
|
|
/// The rules that let each of `logging` reach a name `allowed_dst` lacks and
|
|
/// mark the request `would_deny=1` for `access_log` (`nix/host-modules/hive-squid.nix`).
|
|
/// Empty for no agents.
|
|
///
|
|
/// These follow every `http_access allow` the agent's own names get, so a name
|
|
/// the agent is allowed never carries the mark. The mark is the last ACL of its
|
|
/// rule: squid applies `annotate_transaction` when it is reached, whatever the
|
|
/// rule decides after it. Everything in squid's configuration before the
|
|
/// include is a deny that applies to these agents as to any other.
|
|
fn log_rules(logging: &[String]) -> String {
|
|
if logging.is_empty() {
|
|
return String::new();
|
|
}
|
|
let mut rules = format!("acl {MARK_ACL} annotate_transaction {MARK}\n");
|
|
for agent in logging {
|
|
let _ = write!(
|
|
rules,
|
|
"acl agent_log_{agent} proxy_auth {agent}\n\
|
|
http_access allow agent_log_{agent} !allowed_dst {MARK_ACL}\n"
|
|
);
|
|
}
|
|
rules
|
|
}
|
|
|
|
/// The files for `list` and the seed's `own` names, for the agents this hive
|
|
/// runs, with `logging` of them in `log` mode.
|
|
fn render(
|
|
list: &AllowList,
|
|
agents: &[String],
|
|
logging: &[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<String>> =
|
|
std::collections::BTreeMap::new();
|
|
for entry in &list.entries {
|
|
let Ok(domain) = normalize_domain(&entry.domain) else {
|
|
skipped.push(entry.domain.clone());
|
|
continue;
|
|
};
|
|
match entry.agent.as_deref() {
|
|
None => swarm_wide.push(domain),
|
|
Some(agent) => {
|
|
if hive_types::Ident::parse(agent).is_err() {
|
|
skipped.push(format!("{} for {agent}", entry.domain));
|
|
} else if let Some(mine) = agents.iter().find(|a| *a == agent) {
|
|
per_agent.entry(mine.as_str()).or_default().push(domain);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
|
|
let mut allowed = SWARM_MARKER.to_owned();
|
|
for name in &swarm_wide {
|
|
allowed.push_str(name);
|
|
allowed.push('\n');
|
|
}
|
|
let mut agent_rules = Vec::new();
|
|
let mut agent_count = 0;
|
|
for (agent, mut names) in per_agent {
|
|
names.retain(|d| !covered_by(d, always) && !covered_by(d, &swarm_wide));
|
|
let names = reduced(names);
|
|
if names.is_empty() {
|
|
continue;
|
|
}
|
|
agent_count += names.len();
|
|
agent_rules.push(format!(
|
|
"acl agent_allow_{agent} proxy_auth {agent}\n\
|
|
acl agent_allow_{agent}_dst dstdomain {}\n\
|
|
http_access allow agent_allow_{agent} agent_allow_{agent}_dst\n",
|
|
names.join(" ")
|
|
));
|
|
}
|
|
Rendered {
|
|
allowed,
|
|
agent_allow: format!(
|
|
"{SWARM_MARKER}{}{}",
|
|
agent_rules.concat(),
|
|
log_rules(logging)
|
|
),
|
|
counts: Some(Counts {
|
|
swarm: swarm_wide.len(),
|
|
agent: agent_count,
|
|
skipped,
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use swarm_wire::egress_allow::Entry;
|
|
|
|
fn entry(domain: &str, agent: Option<&str>) -> Entry {
|
|
Entry {
|
|
domain: domain.to_owned(),
|
|
agent: agent.map(str::to_owned),
|
|
derived: false,
|
|
}
|
|
}
|
|
|
|
fn list(entries: &[(&str, Option<&str>)]) -> AllowList {
|
|
AllowList {
|
|
entries: entries.iter().map(|(d, a)| entry(d, *a)).collect(),
|
|
}
|
|
}
|
|
|
|
fn agents(names: &[&str]) -> Vec<String> {
|
|
names.iter().map(|n| (*n).to_owned()).collect()
|
|
}
|
|
|
|
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, &[])
|
|
}
|
|
|
|
#[test]
|
|
fn swarm_wide_names_are_sorted_one_per_line_after_the_marker() {
|
|
let r = rendered(
|
|
&[
|
|
("github.com", None),
|
|
("crates.io", None),
|
|
("GitHub.com", None),
|
|
],
|
|
&["atlas"],
|
|
);
|
|
assert_eq!(r.allowed, "# source: swarm\ncrates.io\ngithub.com\n");
|
|
let counts = r.counts.expect("a swarm list");
|
|
assert_eq!((counts.swarm, counts.agent), (2, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_list_renders_an_empty_set_of_names_and_no_rules() {
|
|
let r = rendered(&[], &["atlas"]);
|
|
assert_eq!(r.allowed, SWARM_MARKER);
|
|
assert_eq!(r.agent_allow, SWARM_MARKER);
|
|
let counts = r.counts.expect("an empty swarm list is still a swarm list");
|
|
assert_eq!((counts.swarm, counts.agent), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_names_become_a_user_rule_and_other_hives_agents_are_left_out() {
|
|
let r = rendered(
|
|
&[
|
|
("pypi.org", Some("atlas")),
|
|
("crates.io", Some("atlas")),
|
|
("github.com", Some("iris")),
|
|
("github.com", Some("../argus")),
|
|
],
|
|
&["atlas", "argus"],
|
|
);
|
|
assert_eq!(
|
|
r.agent_allow,
|
|
"# source: swarm\n\
|
|
acl agent_allow_atlas proxy_auth atlas\n\
|
|
acl agent_allow_atlas_dst dstdomain crates.io pypi.org\n\
|
|
http_access allow agent_allow_atlas agent_allow_atlas_dst\n"
|
|
);
|
|
let counts = r.counts.expect("a swarm list");
|
|
assert_eq!((counts.swarm, counts.agent), (0, 2));
|
|
// The name that could break out of the line is reported, not rendered.
|
|
assert_eq!(counts.skipped, ["github.com for ../argus"]);
|
|
assert!(!r.agent_allow.contains("iris"), "another hive's agent");
|
|
assert!(!r.agent_allow.contains("argus"), "an invalid name");
|
|
}
|
|
|
|
#[test]
|
|
fn an_entry_that_is_not_a_name_never_reaches_squid_configuration() {
|
|
let r = rendered(
|
|
&[
|
|
("github.com", None),
|
|
("1.2.3.4", None),
|
|
("*.example.com", Some("atlas")),
|
|
("example.com\nhttp_access allow all", Some("atlas")),
|
|
("example.com\nhttp_access allow all", None),
|
|
],
|
|
&["atlas"],
|
|
);
|
|
assert_eq!(r.allowed, "# source: swarm\ngithub.com\n");
|
|
assert_eq!(r.agent_allow, SWARM_MARKER);
|
|
assert!(!r.allowed.contains("http_access") && !r.agent_allow.contains("http_access"));
|
|
assert_eq!(r.counts.expect("a swarm list").skipped.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_squid_allows_everyone_or_the_swarm_already_lists_is_dropped() {
|
|
let r = rendered(
|
|
&[
|
|
("api.anthropic.com", None),
|
|
("a.claude.ai", None),
|
|
("cache.nixos.org", Some("atlas")),
|
|
(".github.com", None),
|
|
("api.github.com", None),
|
|
("api.github.com", Some("atlas")),
|
|
("crates.io", Some("atlas")),
|
|
],
|
|
&["atlas"],
|
|
);
|
|
assert_eq!(r.allowed, "# source: swarm\n.github.com\n");
|
|
assert_eq!(
|
|
r.agent_allow,
|
|
"# source: swarm\n\
|
|
acl agent_allow_atlas proxy_auth atlas\n\
|
|
acl agent_allow_atlas_dst dstdomain crates.io\n\
|
|
http_access allow agent_allow_atlas agent_allow_atlas_dst\n"
|
|
);
|
|
// Control: the same entries survive when nothing is always allowed.
|
|
let bare = render(
|
|
&list(&[("api.anthropic.com", None), ("a.claude.ai", None)]),
|
|
&agents(&["atlas"]),
|
|
&[],
|
|
&[],
|
|
&[],
|
|
);
|
|
assert_eq!(
|
|
bare.allowed,
|
|
"# source: swarm\na.claude.ai\napi.anthropic.com\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_log_line_carries_the_counts() {
|
|
let r = rendered(
|
|
&[
|
|
("github.com", None),
|
|
("crates.io", None),
|
|
("pypi.org", Some("atlas")),
|
|
("npmjs.org", Some("atlas")),
|
|
("docs.rs", Some("argus")),
|
|
],
|
|
&["atlas", "argus"],
|
|
);
|
|
let counts = r.counts.expect("a swarm list");
|
|
assert_eq!(
|
|
counts.summary(),
|
|
"squid allow list: 2 swarm, 3 agent entries"
|
|
);
|
|
}
|
|
|
|
fn log_list(logging: &[&str]) -> String {
|
|
render(
|
|
&list(&[("pypi.org", Some("atlas"))]),
|
|
&agents(&["atlas", "uma", "iris"]),
|
|
&agents(logging),
|
|
ALWAYS,
|
|
&[],
|
|
)
|
|
.agent_allow
|
|
}
|
|
|
|
/// A `log` agent's rule allows what `allowed_dst` lacks and carries the
|
|
/// mark last; it comes after every agent's own allow rule.
|
|
#[test]
|
|
fn a_log_agent_is_allowed_past_the_list_and_marked_after_all_own_rules() {
|
|
let text = log_list(&["uma"]);
|
|
assert_eq!(
|
|
text,
|
|
"# 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\
|
|
acl log_mark annotate_transaction would_deny=1\n\
|
|
acl agent_log_uma proxy_auth uma\n\
|
|
http_access allow agent_log_uma !allowed_dst log_mark\n"
|
|
);
|
|
// The rule of an agent with its own names also follows them.
|
|
let both = log_list(&["atlas"]);
|
|
let own = both
|
|
.find("http_access allow agent_allow_atlas ")
|
|
.expect("own rule");
|
|
let log = both
|
|
.find("http_access allow agent_log_atlas ")
|
|
.expect("log rule");
|
|
assert!(own < log, "{both}");
|
|
// Control: with no log agent there is no rule and no mark.
|
|
let none = log_list(&[]);
|
|
assert!(
|
|
!none.contains("log_mark") && !none.contains("would_deny"),
|
|
"{none}"
|
|
);
|
|
}
|
|
|
|
/// Every log rule ends in the mark, which squid applies when it is reached;
|
|
/// a rule that put it earlier would mark requests the agent is allowed.
|
|
#[test]
|
|
fn the_mark_is_the_last_acl_of_every_log_rule() {
|
|
let text = log_list(&["atlas", "iris", "uma"]);
|
|
let rules: Vec<&str> = text
|
|
.lines()
|
|
.filter(|l| l.starts_with("http_access allow agent_log_"))
|
|
.collect();
|
|
assert_eq!(rules.len(), 3, "{text}");
|
|
for rule in rules {
|
|
assert!(rule.ends_with(" !allowed_dst log_mark"), "{rule}");
|
|
}
|
|
assert_eq!(
|
|
text.matches("annotate_transaction").count(),
|
|
1,
|
|
"defined once"
|
|
);
|
|
}
|
|
|
|
/// Only `deny`-free, `allow`-only rules reach the include: the safety
|
|
/// denies that precede it in squid's configuration stay the only ones.
|
|
#[test]
|
|
fn a_log_agent_adds_allow_rules_and_no_deny() {
|
|
let text = log_list(&["atlas", "uma"]);
|
|
assert!(!text.contains("http_access deny"), "{text}");
|
|
assert!(text.contains("http_access allow"), "control");
|
|
}
|
|
|
|
/// The self-check user cannot be named by a log rule: it is not an agent
|
|
/// name, so `squid-selfcheck`'s deny canary stays refused.
|
|
#[test]
|
|
fn the_selfcheck_users_are_never_log_agents() {
|
|
for user in [
|
|
swarm_secret_client::proxy::SELFCHECK_USER,
|
|
swarm_secret_client::proxy::SELFCHECK_UNRESTRICTED_USER,
|
|
] {
|
|
assert!(hive_types::Ident::parse(user).is_err(), "{user}");
|
|
let perms = permissions::Stored::from([(
|
|
user.to_owned(),
|
|
serde_json::from_str(r#"{"unrestricted_internet":false,"internet_log":true}"#)
|
|
.expect("decodes"),
|
|
)]);
|
|
assert!(log_agents(&agents(&[user]), &perms).is_empty(), "{user}");
|
|
}
|
|
// Control: an agent in the same record shape is a log agent.
|
|
let perms = permissions::Stored::from([(
|
|
"uma".to_owned(),
|
|
serde_json::from_str(r#"{"unrestricted_internet":false,"internet_log":true}"#)
|
|
.expect("decodes"),
|
|
)]);
|
|
assert_eq!(log_agents(&agents(&["uma", "atlas"]), &perms), ["uma"]);
|
|
}
|
|
|
|
/// Only `log` records give rules; restricted, unrestricted and unrecorded
|
|
/// agents render as they did.
|
|
#[test]
|
|
fn only_log_mode_agents_get_rules() {
|
|
let perms: permissions::Stored = serde_json::from_str(
|
|
r#"{
|
|
"uma": {"unrestricted_internet": false, "internet_log": true},
|
|
"atlas": {"unrestricted_internet": false},
|
|
"iris": {"unrestricted_internet": true, "internet_log": true}
|
|
}"#,
|
|
)
|
|
.expect("decodes");
|
|
let all = agents(&["uma", "atlas", "iris", "argus"]);
|
|
assert_eq!(log_agents(&all, &perms), ["uma"]);
|
|
}
|
|
|
|
/// The seed's file carries the rules too: logging does not depend on the
|
|
/// swarm having an allow list.
|
|
#[test]
|
|
fn the_seed_files_carry_the_log_rules() {
|
|
let d = dir("forge.example\n");
|
|
sync_with(
|
|
Read::NotSet,
|
|
&agents(&["uma"]),
|
|
&agents(&["uma"]),
|
|
Some(&d.seed()),
|
|
"",
|
|
&Files {
|
|
allowed: &d.allowed(),
|
|
agent_allow: &d.agent_allow(),
|
|
},
|
|
)
|
|
.expect("renders");
|
|
let text = text(&d.agent_allow());
|
|
assert!(text.starts_with(SEED_MARKER), "{text}");
|
|
assert!(text.contains("http_access allow agent_log_uma !allowed_dst log_mark"));
|
|
}
|
|
|
|
struct Dir(tempfile::TempDir);
|
|
|
|
impl Dir {
|
|
fn allowed(&self) -> PathBuf {
|
|
self.0.path().join("allowed")
|
|
}
|
|
fn agent_allow(&self) -> PathBuf {
|
|
self.0.path().join("agent-allow.conf")
|
|
}
|
|
fn seed(&self) -> PathBuf {
|
|
self.0.path().join("seed")
|
|
}
|
|
}
|
|
|
|
fn dir(seed: &str) -> Dir {
|
|
let dir = Dir(tempfile::tempdir().expect("tempdir"));
|
|
std::fs::write(dir.seed(), seed).expect("seed");
|
|
dir
|
|
}
|
|
|
|
fn sync(d: &Dir, read: Read) -> Result<Source> {
|
|
sync_with(
|
|
read,
|
|
&agents(&["atlas"]),
|
|
&[],
|
|
Some(&d.seed()),
|
|
"api.anthropic.com cache.nixos.org",
|
|
&Files {
|
|
allowed: &d.allowed(),
|
|
agent_allow: &d.agent_allow(),
|
|
},
|
|
)
|
|
}
|
|
|
|
fn text(path: &Path) -> String {
|
|
std::fs::read_to_string(path).expect("the file is there")
|
|
}
|
|
|
|
#[test]
|
|
fn without_a_swarm_list_the_files_hold_the_seed() {
|
|
let d = dir("# a comment\nforge.example\n\n loop.example \n");
|
|
assert_eq!(sync(&d, Read::NotSet).expect("renders"), Source::Seed);
|
|
assert_eq!(
|
|
text(&d.allowed()),
|
|
"# source: seed\nforge.example\nloop.example\n"
|
|
);
|
|
assert_eq!(text(&d.agent_allow()), SEED_MARKER);
|
|
// A second pass writes nothing.
|
|
let ino = std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(d.allowed()).unwrap());
|
|
sync(&d, Read::NotSet).expect("renders");
|
|
assert_eq!(
|
|
std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(d.allowed()).unwrap()),
|
|
ino
|
|
);
|
|
}
|
|
|
|
/// 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_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()), "# 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");
|
|
}
|
|
|
|
#[test]
|
|
fn a_swarm_list_that_stops_answering_is_kept_and_a_seed_is_refreshed() {
|
|
let d = dir("forge.example\n");
|
|
sync(&d, Read::Current(list(&[("github.com", None)]))).expect("renders");
|
|
let before = (text(&d.allowed()), text(&d.agent_allow()));
|
|
assert_eq!(sync(&d, Read::NotSet).expect("renders"), Source::Kept);
|
|
assert_eq!((text(&d.allowed()), text(&d.agent_allow())), before);
|
|
// Control: a file that never held the swarm's list follows the seed.
|
|
let d = dir("forge.example\n");
|
|
std::fs::write(d.allowed(), "old.example\n").expect("a tmpfiles-seeded file");
|
|
assert_eq!(sync(&d, Read::NotSet).expect("renders"), Source::Seed);
|
|
assert_eq!(text(&d.allowed()), "# source: seed\nforge.example\n");
|
|
}
|
|
|
|
#[test]
|
|
fn announce_logs_a_run_of_unset_passes_once() {
|
|
let mut announced = None;
|
|
announce(&Source::Seed, &mut announced);
|
|
assert_eq!(announced, Some("seed"));
|
|
announce(&Source::Swarm, &mut announced);
|
|
assert_eq!(announced, None, "a list that appears resets the run");
|
|
announce(&Source::Kept, &mut announced);
|
|
assert_eq!(announced, Some("kept"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_files_are_group_readable() {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let d = dir("");
|
|
sync(&d, Read::Current(list(&[("github.com", None)]))).expect("renders");
|
|
for file in [d.allowed(), d.agent_allow()] {
|
|
let mode = std::fs::metadata(file).expect("stat").permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o640);
|
|
}
|
|
}
|
|
}
|