Watch
0
0
Fork
You've already forked hyperhive
0

squid: base allow list for every authed user, and no start limit on the list reload

This commit is contained in:
atlas 2026-10-28 18:37:58 +01:00 • committed by mara
commit e78a529ec2
5 changed files with 82 additions and 12 deletions

View file

@ -289,6 +289,18 @@ host. It never decrypts TLS, so it holds no signing key. It caches nothing.
restricted. Agents must not start sending credentials until every hive runs
the `hive-c0re` that writes it, or a legacy agent loses general internet.
- **Base list.** `services.hyperhive.squid.baseAllowedDomains` is open to every
authenticated user, whatever its flag: what the agent harness needs off the
swarm. The default is the hosts Claude Code requires
(<https://code.claude.com/docs/en/network-config>): `api.anthropic.com` for
the model API and the usage probe (`hive-agent/src/claude_usage_watch.rs`),
`claude.ai` for the account login, and `platform.claude.com` for the OAuth
token exchange and refresh. An agent whose `ANTHROPIC_BASE_URL` or ACP
provider points elsewhere needs that host added to the option: the value
sits in the agent's own environment file, which the host doesn't read. The
list omits the plugin marketplace clone from `github.com`, since a failed add
isn't fatal. The rules below still refuse an entry that resolves to a
private address.
- **Allow-list.** `services.hyperhive.squid.allowedDomains` defaults to empty,
plus the self-check canary (`services.hyperhive.squid.selfcheck.canary`), so
the canary widens the restricted list by that one name. The swarm's own

View file

@ -323,7 +323,7 @@ that value. Read it with
`swarm_queue_client::agent_permissions::unrestricted`. The hive reads it for
the agent's squid policy: [the egress proxy](../networking/network.md#egress-proxy-squid)
lets an unrestricted agent's credential reach any destination, and holds the
others to the allow-list. An agent sends its credential once it has fetched it, from its next start.
others to the base list and the allow-list. An agent sends its credential once it has fetched it, from its next start.
**How a change applies** —
- The agent's harness reads its own key every 10 seconds and builds every

View file

@ -25,6 +25,7 @@ let
denyCanary = "deny-canary.invalid";
allowed = lib.unique (cfg.allowedDomains ++ [ cfg.selfcheck.canary ]);
base = lib.unique cfg.baseAllowedDomains;
# The squid user the self-check logs in as: `swarm_secret_client::proxy::SELFCHECK_USER`.
selfcheckUser = "selfcheck.squid";
@ -63,9 +64,9 @@ let
# The IP-literal
# denies come first because `dstdomain` on an IP literal does a reverse
# lookup. The credential check sits right before the first allow, so only a
# request that would otherwise be let through gets the 407 challenge. An
# unrestricted user is then let through to any other destination on a safe
# port, and every other user to the listed names. The `dst` denies cost an
# request that would otherwise be let through gets the 407 challenge. Every
# user is then let through to the base names, an unrestricted user to any other
# destination on a safe port, and every other user to the listed names. The `dst` denies cost an
# unlisted name a lookup on the host's resolver before it is refused.
squidConf = ''
http_port ${listen}
@ -105,6 +106,7 @@ let
# an agent controls could match an allowed name.
acl ip_v4_host url_regex ^([a-z]+://)?[0-9.]+(:[0-9]+)?(/|$)
acl ip_v6_host url_regex ^([a-z]+://)?\[[0-9a-fA-F:.]+\](:[0-9]+)?(/|$)
${lib.optionalString (base != [ ]) "acl base_dst dstdomain ${lib.concatStringsSep " " base}"}
${lib.optionalString (
allowed != [ ]
) "acl allowed_dst dstdomain ${lib.concatStringsSep " " allowed}"}
@ -123,6 +125,7 @@ let
http_access deny to_bridge
http_access deny to_private
http_access deny !authed
${lib.optionalString (base != [ ]) "http_access allow base_dst"}
http_access allow unrestricted
${lib.optionalString (allowed != [ ]) "http_access deny !allowed_dst"}
${lib.optionalString (allowed != [ ]) "http_access allow allowed_dst"}
@ -225,13 +228,43 @@ in
listed: they resolve to the bridge or a private address, which the
destination rules refuse, and agents reach them directly. The
self-check canary is allowed on top of this list, so it widens the
restricted allow list by that one name. A name must resolve, on this host's resolver, to a public
address: one in a loopback, link-local, private, shared (100.64.0.0/10),
multicast or reserved range (or is `::`), or in the bridge subnet, gets a 403 for
restricted allow list by that one name. A name must
resolve, on this host's resolver, to a public address: one in a
loopback, link-local, private, shared (100.64.0.0/10), multicast or
reserved range (or is `::`), or in the bridge subnet, gets a 403 for
every user.
'';
};
baseAllowedDomains = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"api.anthropic.com"
"claude.ai"
"platform.claude.com"
];
example = [
"api.anthropic.com"
"claude.ai"
"platform.claude.com"
"openrouter.ai"
];
description = ''
Hostnames every authenticated user may reach, whether or not its agent
holds the `unrestricted_internet` flag: what the agent harness needs
off the swarm to work at all. The defaults are the hosts Claude Code
requires. `api.anthropic.com` serves the model API and the usage
probe, `claude.ai` the account login, and `platform.claude.com` the
OAuth token exchange and refresh. An agent whose `claude` or ACP provider
points elsewhere (`ANTHROPIC_BASE_URL`, an `opencode` provider) needs
that host listed here: the value lives in the agent's own backend
environment, which this host does not read. Swarm-internal names are
not listed, since agents reach them directly. The destination denies
still apply to every entry, and the list is separate from
`allowedDomains`.
'';
};
usersFile = lib.mkOption {
type = lib.types.path;
default = "/var/lib/hive-squid/users";
@ -340,6 +373,9 @@ in
systemd.services.squid-unrestricted-reload = {
description = "Reload squid to read its unrestricted-users list";
# Several list writes in a few seconds must not trip the start limit: a
# tripped limit also fails the path unit, and later writes then reload nothing.
startLimitIntervalSec = 0;
serviceConfig = {
Type = "oneshot";
ExecStart = "${config.systemd.package}/bin/systemctl try-reload-or-restart squid.service";
@ -348,10 +384,10 @@ in
assertions = [
{
assertion = !(builtins.elem cfg.selfcheck.unrestrictedCanary allowed);
assertion = !(builtins.elem cfg.selfcheck.unrestrictedCanary (allowed ++ base));
message = ''
services.hyperhive.squid.selfcheck.unrestrictedCanary must be a name outside
allowedDomains, or the self-check cannot tell an unrestricted user from a
allowedDomains and baseAllowedDomains, or the self-check cannot tell an unrestricted user from a
restricted one.
'';
}

View file

@ -497,11 +497,13 @@ let
lines = lib.splitString "\n" hiveOtel.services.squid.configText;
at = l: lib.lists.findFirstIndex (x: x == l) null lines;
allow = at "http_access allow allowed_dst";
base = at "http_access allow base_dst";
unrestricted = at "http_access allow unrestricted";
authed = at "http_access deny !authed";
firstAllow = if unrestricted < allow then unrestricted else allow;
firstAllow = lib.min base (lib.min unrestricted allow);
in
allow != null
&& base != null
&& unrestricted != null
&& authed != null
&& at "http_access deny to_localhost" != null
@ -529,13 +531,15 @@ let
{
# Control: the allow ACL line is found and holds the canary, so the
# absence of the swarm's service names is not a lookup of nothing.
name = "squid's default allow-list is the self-check canary alone, with no swarm service name";
name = "squid's default allow-list is the self-check canary alone and its base list is the Claude Code hosts, with no swarm service name";
ok =
let
lines = lib.splitString "\n" hiveOtel.services.squid.configText;
acl = lib.findFirst (l: lib.hasPrefix "acl allowed_dst dstdomain " l) "" (map lib.trim lines);
baseAcl = lib.findFirst (l: lib.hasPrefix "acl base_dst dstdomain " l) "" (map lib.trim lines);
in
acl == "acl allowed_dst dstdomain ${hiveOtel.services.hyperhive.squid.selfcheck.canary}"
&& baseAcl == "acl base_dst dstdomain api.anthropic.com claude.ai platform.claude.com"
&& hiveOtel.services.hyperhive.squid.selfcheck.canary == "cache.nixos.org"
&& !(builtins.elem hiveOtel.services.hyperhive.swarm.forge.domain (lib.splitString " " acl));
}
@ -580,6 +584,8 @@ let
in
at "http_access allow unrestricted" != null
&& at "http_access deny !allowed_dst" != null
&& at "http_access deny !authed" < at "http_access allow base_dst"
&& at "http_access allow base_dst" < at "http_access allow unrestricted"
&& at "http_access allow unrestricted" < at "http_access deny !allowed_dst"
&& at "http_access deny !allowed_dst" < at "http_access allow allowed_dst"
&& builtins.elem ''acl unrestricted proxy_auth "${list}"'' lines
@ -597,6 +603,7 @@ let
svc = hiveOtel.systemd.services.squid-unrestricted-reload.serviceConfig;
in
path.pathConfig.PathChanged == list
&& hiveOtel.systemd.services.squid-unrestricted-reload.startLimitIntervalSec == 0
&& builtins.elem "paths.target" path.wantedBy
&& lib.hasSuffix "systemctl try-reload-or-restart squid.service" svc.ExecStart
&& builtins.elem "f ${list} 0640 hive-core squid - -" rules

View file

@ -150,11 +150,15 @@ pkgs.testers.runNixOSTest {
"203.0.113.2" = [
"forge.t.local"
"cache.t.local"
"api.t.local"
"other.t.local"
"deny-canary.invalid"
];
"127.0.0.1" = [ "loop.t.local" ];
${bridgeIp} = [ "bridge.t.local" ];
${bridgeIp} = [
"bridge.t.local"
"basebridge.t.local"
];
"169.254.169.254" = [ "meta.t.local" ];
"fd00:99::2" = [ "ula.t.local" ];
"::" = [ "any6.t.local" ];
@ -171,6 +175,10 @@ pkgs.testers.runNixOSTest {
environment.systemPackages = [ pkgs.mkpasswd ];
services.hyperhive.squid.selfcheck.unrestrictedCanary = "other.t.local";
services.hyperhive.squid.selfcheck.canary = "cache.t.local";
services.hyperhive.squid.baseAllowedDomains = [
"api.t.local"
"basebridge.t.local"
];
services.hyperhive.squid.allowedDomains = [
"forge.t.local"
"loop.t.local"
@ -324,6 +332,13 @@ pkgs.testers.runNixOSTest {
# DES would accept the first eight characters; the hash must not.
assert connect("forge.t.local", "atlas:" + PW["atlas"][:8]) == "407", "a prefix of the password"
with subtest("a base-list name is open to a restricted user, with a credential, and nothing else is"):
assert connect("api.t.local", ATLAS) == "200", "restricted user, base name"
assert connect("api.t.local") == "407", "no credential"
assert connect("api.t.local", "atlas:" + secrets.token_urlsafe(24)) == "407", "wrong password"
assert connect("other.t.local", ATLAS) == "403", "control: an unlisted name is still refused"
assert connect("basebridge.t.local", ATLAS) == "403", "a base name that resolves to the bridge"
with subtest("with an empty list every user is restricted"):
assert connect("other.t.local", UMA) == "403", "uma, not listed yet"
assert connect("other.t.local", ATLAS) == "403"