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 restricted. Agents must not start sending credentials until every hive runs
the `hive-c0re` that writes it, or a legacy agent loses general internet. 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, - **Allow-list.** `services.hyperhive.squid.allowedDomains` defaults to empty,
plus the self-check canary (`services.hyperhive.squid.selfcheck.canary`), so 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 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 `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) 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 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** — **How a change applies** —
- The agent's harness reads its own key every 10 seconds and builds every - 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"; denyCanary = "deny-canary.invalid";
allowed = lib.unique (cfg.allowedDomains ++ [ cfg.selfcheck.canary ]); 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`. # The squid user the self-check logs in as: `swarm_secret_client::proxy::SELFCHECK_USER`.
selfcheckUser = "selfcheck.squid"; selfcheckUser = "selfcheck.squid";
@ -63,9 +64,9 @@ let
# The IP-literal # The IP-literal
# denies come first because `dstdomain` on an IP literal does a reverse # 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 # 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 # request that would otherwise be let through gets the 407 challenge. Every
# unrestricted user is then let through to any other destination on a safe # user is then let through to the base names, an unrestricted user to any other
# port, and every other user to the listed names. The `dst` denies cost an # 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. # unlisted name a lookup on the host's resolver before it is refused.
squidConf = '' squidConf = ''
http_port ${listen} http_port ${listen}
@ -105,6 +106,7 @@ let
# an agent controls could match an allowed name. # an agent controls could match an allowed name.
acl ip_v4_host url_regex ^([a-z]+://)?[0-9.]+(:[0-9]+)?(/|$) 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]+)?(/|$) 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 ( ${lib.optionalString (
allowed != [ ] allowed != [ ]
) "acl allowed_dst dstdomain ${lib.concatStringsSep " " allowed}"} ) "acl allowed_dst dstdomain ${lib.concatStringsSep " " allowed}"}
@ -123,6 +125,7 @@ let
http_access deny to_bridge http_access deny to_bridge
http_access deny to_private http_access deny to_private
http_access deny !authed http_access deny !authed
${lib.optionalString (base != [ ]) "http_access allow base_dst"}
http_access allow unrestricted http_access allow unrestricted
${lib.optionalString (allowed != [ ]) "http_access deny !allowed_dst"} ${lib.optionalString (allowed != [ ]) "http_access deny !allowed_dst"}
${lib.optionalString (allowed != [ ]) "http_access allow 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 listed: they resolve to the bridge or a private address, which the
destination rules refuse, and agents reach them directly. The destination rules refuse, and agents reach them directly. The
self-check canary is allowed on top of this list, so it widens 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 restricted allow list by that one name. A name must
address: one in a loopback, link-local, private, shared (100.64.0.0/10), resolve, on this host's resolver, to a public address: one in a
multicast or reserved range (or is `::`), or in the bridge subnet, gets a 403 for 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. 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 { usersFile = lib.mkOption {
type = lib.types.path; type = lib.types.path;
default = "/var/lib/hive-squid/users"; default = "/var/lib/hive-squid/users";
@ -340,6 +373,9 @@ in
systemd.services.squid-unrestricted-reload = { systemd.services.squid-unrestricted-reload = {
description = "Reload squid to read its unrestricted-users list"; 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 = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
ExecStart = "${config.systemd.package}/bin/systemctl try-reload-or-restart squid.service"; ExecStart = "${config.systemd.package}/bin/systemctl try-reload-or-restart squid.service";
@ -348,10 +384,10 @@ in
assertions = [ assertions = [
{ {
assertion = !(builtins.elem cfg.selfcheck.unrestrictedCanary allowed); assertion = !(builtins.elem cfg.selfcheck.unrestrictedCanary (allowed ++ base));
message = '' message = ''
services.hyperhive.squid.selfcheck.unrestrictedCanary must be a name outside 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. restricted one.
''; '';
} }

View file

@ -497,11 +497,13 @@ let
lines = lib.splitString "\n" hiveOtel.services.squid.configText; lines = lib.splitString "\n" hiveOtel.services.squid.configText;
at = l: lib.lists.findFirstIndex (x: x == l) null lines; at = l: lib.lists.findFirstIndex (x: x == l) null lines;
allow = at "http_access allow allowed_dst"; allow = at "http_access allow allowed_dst";
base = at "http_access allow base_dst";
unrestricted = at "http_access allow unrestricted"; unrestricted = at "http_access allow unrestricted";
authed = at "http_access deny !authed"; authed = at "http_access deny !authed";
firstAllow = if unrestricted < allow then unrestricted else allow; firstAllow = lib.min base (lib.min unrestricted allow);
in in
allow != null allow != null
&& base != null
&& unrestricted != null && unrestricted != null
&& authed != null && authed != null
&& at "http_access deny to_localhost" != 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 # 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. # 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 = ok =
let let
lines = lib.splitString "\n" hiveOtel.services.squid.configText; lines = lib.splitString "\n" hiveOtel.services.squid.configText;
acl = lib.findFirst (l: lib.hasPrefix "acl allowed_dst dstdomain " l) "" (map lib.trim lines); 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 in
acl == "acl allowed_dst dstdomain ${hiveOtel.services.hyperhive.squid.selfcheck.canary}" 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" && hiveOtel.services.hyperhive.squid.selfcheck.canary == "cache.nixos.org"
&& !(builtins.elem hiveOtel.services.hyperhive.swarm.forge.domain (lib.splitString " " acl)); && !(builtins.elem hiveOtel.services.hyperhive.swarm.forge.domain (lib.splitString " " acl));
} }
@ -580,6 +584,8 @@ let
in in
at "http_access allow unrestricted" != null at "http_access allow unrestricted" != null
&& at "http_access deny !allowed_dst" != 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 allow unrestricted" < at "http_access deny !allowed_dst"
&& at "http_access deny !allowed_dst" < at "http_access allow allowed_dst" && at "http_access deny !allowed_dst" < at "http_access allow allowed_dst"
&& builtins.elem ''acl unrestricted proxy_auth "${list}"'' lines && builtins.elem ''acl unrestricted proxy_auth "${list}"'' lines
@ -597,6 +603,7 @@ let
svc = hiveOtel.systemd.services.squid-unrestricted-reload.serviceConfig; svc = hiveOtel.systemd.services.squid-unrestricted-reload.serviceConfig;
in in
path.pathConfig.PathChanged == list path.pathConfig.PathChanged == list
&& hiveOtel.systemd.services.squid-unrestricted-reload.startLimitIntervalSec == 0
&& builtins.elem "paths.target" path.wantedBy && builtins.elem "paths.target" path.wantedBy
&& lib.hasSuffix "systemctl try-reload-or-restart squid.service" svc.ExecStart && lib.hasSuffix "systemctl try-reload-or-restart squid.service" svc.ExecStart
&& builtins.elem "f ${list} 0640 hive-core squid - -" rules && builtins.elem "f ${list} 0640 hive-core squid - -" rules

View file

@ -150,11 +150,15 @@ pkgs.testers.runNixOSTest {
"203.0.113.2" = [ "203.0.113.2" = [
"forge.t.local" "forge.t.local"
"cache.t.local" "cache.t.local"
"api.t.local"
"other.t.local" "other.t.local"
"deny-canary.invalid" "deny-canary.invalid"
]; ];
"127.0.0.1" = [ "loop.t.local" ]; "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" ]; "169.254.169.254" = [ "meta.t.local" ];
"fd00:99::2" = [ "ula.t.local" ]; "fd00:99::2" = [ "ula.t.local" ];
"::" = [ "any6.t.local" ]; "::" = [ "any6.t.local" ];
@ -171,6 +175,10 @@ pkgs.testers.runNixOSTest {
environment.systemPackages = [ pkgs.mkpasswd ]; environment.systemPackages = [ pkgs.mkpasswd ];
services.hyperhive.squid.selfcheck.unrestrictedCanary = "other.t.local"; services.hyperhive.squid.selfcheck.unrestrictedCanary = "other.t.local";
services.hyperhive.squid.selfcheck.canary = "cache.t.local"; services.hyperhive.squid.selfcheck.canary = "cache.t.local";
services.hyperhive.squid.baseAllowedDomains = [
"api.t.local"
"basebridge.t.local"
];
services.hyperhive.squid.allowedDomains = [ services.hyperhive.squid.allowedDomains = [
"forge.t.local" "forge.t.local"
"loop.t.local" "loop.t.local"
@ -324,6 +332,13 @@ pkgs.testers.runNixOSTest {
# DES would accept the first eight characters; the hash must not. # 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" 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"): 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", UMA) == "403", "uma, not listed yet"
assert connect("other.t.local", ATLAS) == "403" assert connect("other.t.local", ATLAS) == "403"