squid: base allow list for every authed user, and no start limit on the list reload
This commit is contained in:
parent
10fdf06334
commit
e78a529ec2
5 changed files with 82 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue