make squid's allowed domains editable at runtime in the swarm ui
This commit is contained in:
parent
86e80250bb
commit
c9abc2a42b
4 changed files with 2307 additions and 35 deletions
|
|
@ -301,6 +301,13 @@ in
|
|||
# store under the identity above.
|
||||
HIVE_SQUID_USERS_FILE = config.services.hyperhive.squid.usersFile;
|
||||
HIVE_SQUID_UNRESTRICTED_FILE = config.services.hyperhive.squid.unrestrictedFile;
|
||||
# The swarm's egress allow-list, which `workers::squid_allow` renders into
|
||||
# the two files; the seed is the list the files hold until the swarm has one,
|
||||
# and the always-allowed names are those it never repeats.
|
||||
HIVE_SQUID_ALLOWED_FILE = config.services.hyperhive.squid.allowedFile;
|
||||
HIVE_SQUID_AGENT_ALLOW_FILE = config.services.hyperhive.squid.agentAllowFile;
|
||||
HIVE_SQUID_ALLOWED_SEED_FILE = config.services.hyperhive.squid.allowedSeedFile;
|
||||
HIVE_SQUID_ALWAYS_ALLOWED = lib.concatStringsSep " " config.services.hyperhive.squid.alwaysAllowed;
|
||||
# The egress proxy facts every container is handed (`HiveFacts` in
|
||||
# `hive-priv-sock`). They sit with the users file above because that is what
|
||||
# makes squid's credential check and per-agent policy real on this host: an
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
# must carry a per-agent proxy credential, checked against the users file
|
||||
# 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. An agent sends its credential
|
||||
# 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 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.
|
||||
{
|
||||
|
|
@ -26,6 +29,16 @@ let
|
|||
|
||||
allowed = lib.unique (cfg.allowedDomains ++ [ cfg.selfcheck.canary ]);
|
||||
base = lib.unique cfg.baseAllowedDomains;
|
||||
# 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.
|
||||
seedFile = pkgs.writeText "squid-allowed-seed" (
|
||||
lib.concatMapStrings (d: d + "\n") (
|
||||
lib.unique (lib.subtractLists [ cfg.selfcheck.canary ] cfg.allowedDomains)
|
||||
)
|
||||
);
|
||||
|
||||
# The squid user the self-check logs in as: `swarm_secret_client::proxy::SELFCHECK_USER`.
|
||||
selfcheckUser = "selfcheck.squid";
|
||||
|
|
@ -65,8 +78,10 @@ let
|
|||
# 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. 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
|
||||
# user is then let through to the base names, a user the swarm lists names
|
||||
# for to those names (the include, whose rules hive-c0re generates and whose
|
||||
# file may be empty), 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}
|
||||
|
|
@ -107,9 +122,8 @@ let
|
|||
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}"}
|
||||
acl allowed_dst dstdomain ${cfg.selfcheck.canary}
|
||||
acl allowed_dst dstdomain "${cfg.allowedFile}"
|
||||
|
||||
# Answered by squid itself, never forwarded, and addressed to the bridge IP
|
||||
# on a port outside Safe_ports, so it precedes the destination rules.
|
||||
|
|
@ -126,12 +140,30 @@ let
|
|||
http_access deny to_private
|
||||
http_access deny !authed
|
||||
${lib.optionalString (base != [ ]) "http_access allow base_dst"}
|
||||
include ${cfg.agentAllowFile}
|
||||
http_access allow unrestricted
|
||||
${lib.optionalString (allowed != [ ]) "http_access deny !allowed_dst"}
|
||||
${lib.optionalString (allowed != [ ]) "http_access allow allowed_dst"}
|
||||
http_access deny !allowed_dst
|
||||
http_access allow allowed_dst
|
||||
http_access deny all
|
||||
'';
|
||||
|
||||
# `squid -k parse` on the configuration with the include pointing at an empty
|
||||
# file. The nixpkgs module runs the same parse on the real text, which
|
||||
# cannot open the include in the build sandbox.
|
||||
squidConfCheck =
|
||||
pkgs.runCommand "squid-conf-check"
|
||||
{
|
||||
nativeBuildInputs = [ config.services.squid.package ];
|
||||
}
|
||||
''
|
||||
squid -k parse -f ${
|
||||
pkgs.writeText "squid.conf" (
|
||||
builtins.replaceStrings [ cfg.agentAllowFile ] [ "/dev/null" ] squidConf
|
||||
)
|
||||
}
|
||||
touch $out
|
||||
'';
|
||||
|
||||
sandbox = {
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
|
|
@ -222,8 +254,14 @@ in
|
|||
"crates.io"
|
||||
];
|
||||
description = ''
|
||||
Hostnames squid lets a client reach, by CONNECT or plain HTTP. Each
|
||||
entry matches that exact name. Everything else gets a 403, except
|
||||
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`.
|
||||
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
|
||||
listed: they resolve to the bridge or a private address, which the
|
||||
destination rules refuse, and agents reach them directly. The
|
||||
|
|
@ -238,11 +276,7 @@ in
|
|||
|
||||
baseAllowedDomains = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [
|
||||
"api.anthropic.com"
|
||||
"claude.ai"
|
||||
"platform.claude.com"
|
||||
];
|
||||
default = import ./lib/squid-base-domains.nix;
|
||||
example = [
|
||||
"api.anthropic.com"
|
||||
"claude.ai"
|
||||
|
|
@ -261,7 +295,9 @@ in
|
|||
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`.
|
||||
`allowedDomains`. A name the swarm's allow-list holds that this list
|
||||
already covers is left out of the rendered files, and the controller
|
||||
refuses it as an entry when it is among the defaults.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -289,12 +325,66 @@ in
|
|||
`unrestricted_internet` flag (set unless the swarm records it off), and
|
||||
the self-check's unrestricted user. hive-c0re renders it beside
|
||||
`usersFile`, and squid is reloaded when it changes. Every other user
|
||||
reaches only `allowedDomains`. An empty file, which is what exists
|
||||
reaches only the base list, the allow-list and its own names. An empty file, which is what exists
|
||||
until hive-c0re writes one, makes every user a restricted one.
|
||||
Owned by `hive-core` and readable by the `squid` group.
|
||||
'';
|
||||
};
|
||||
|
||||
allowedFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "${builtins.dirOf cfg.usersFile}/allowed";
|
||||
readOnly = true;
|
||||
defaultText = lib.literalMD "`allowed`, in the directory of `usersFile`";
|
||||
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
|
||||
`hive-core` and readable by the `squid` group.
|
||||
'';
|
||||
};
|
||||
|
||||
agentAllowFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "${builtins.dirOf cfg.usersFile}/agent-allow.conf";
|
||||
readOnly = true;
|
||||
defaultText = lib.literalMD "`agent-allow.conf`, in the directory of `usersFile`";
|
||||
description = ''
|
||||
Squid configuration that hive-c0re generates and the main configuration
|
||||
includes after every destination deny and `deny !authed`, where the
|
||||
base list is allowed: for each agent of this hive the swarm lists
|
||||
names for, an ACL for its user, an ACL for its names and the
|
||||
`http_access allow` joining the two. Empty until the controller has a
|
||||
list. Squid is reloaded when it changes. Owned by `hive-core` and
|
||||
readable by the `squid` group.
|
||||
'';
|
||||
};
|
||||
|
||||
allowedSeedFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = seedFile;
|
||||
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.
|
||||
'';
|
||||
};
|
||||
|
||||
alwaysAllowed = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = alwaysAllowed;
|
||||
readOnly = true;
|
||||
defaultText = lib.literalMD "`baseAllowedDomains` and the self-check canary";
|
||||
description = ''
|
||||
The names squid allows every authenticated user whatever the swarm
|
||||
lists. hive-c0re leaves a swarm allow-list entry out of the rendered
|
||||
files when one of these covers it. Read-only.
|
||||
'';
|
||||
};
|
||||
|
||||
selfcheck.credentialName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "hive-squid.selfcheck";
|
||||
|
|
@ -348,31 +438,45 @@ in
|
|||
services.squid = {
|
||||
enable = true;
|
||||
configText = squidConf;
|
||||
# The module's build-time parse fails on the `include` of a file only the
|
||||
# running host has; `squidConfCheck` parses the same text without it.
|
||||
validateConfig = false;
|
||||
};
|
||||
|
||||
system.extraDependencies = [ squidConfCheck ];
|
||||
|
||||
# The directory is setgid so the file hive-c0re renames into it is the
|
||||
# squid group's. The users file exists before squid starts, empty: the
|
||||
# helper exits on a missing file and squid then rejects every login. The
|
||||
# unrestricted list is created the same way, though squid treats a missing
|
||||
# one as empty.
|
||||
# one as empty. The allow-list file starts as a copy of `allowedDomains`
|
||||
# and an existing one is left alone, so the list hive-c0re wrote survives a
|
||||
# switch. The include is empty until hive-c0re has a list: squid does not
|
||||
# start on a missing one.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${usersDir} 2750 hive-core squid - -"
|
||||
"f ${cfg.usersFile} 0640 hive-core squid - -"
|
||||
"f ${cfg.unrestrictedFile} 0640 hive-core squid - -"
|
||||
"C ${cfg.allowedFile} 0640 hive-core squid - ${seedFile}"
|
||||
"f ${cfg.agentAllowFile} 0640 hive-core squid - -"
|
||||
];
|
||||
|
||||
# Squid reads the unrestricted list when it parses its configuration, not
|
||||
# when it changes. hive-c0re writes the list only when its contents change,
|
||||
# so each change is one reload. Nothing happens to a squid that is not
|
||||
# running; it reads the list when it starts.
|
||||
# Squid reads the unrestricted list, the allow-list and the per-agent include
|
||||
# when it parses its configuration, not when they change. hive-c0re writes
|
||||
# each only when its contents change, so each change is one reload. Nothing
|
||||
# happens to a squid that is not running; it reads them when it starts.
|
||||
systemd.paths.squid-unrestricted-reload = {
|
||||
description = "Watch squid's unrestricted-users list";
|
||||
description = "Watch the lists hive-c0re renders for squid";
|
||||
wantedBy = [ "paths.target" ];
|
||||
pathConfig.PathChanged = cfg.unrestrictedFile;
|
||||
pathConfig.PathChanged = [
|
||||
cfg.unrestrictedFile
|
||||
cfg.allowedFile
|
||||
cfg.agentAllowFile
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services.squid-unrestricted-reload = {
|
||||
description = "Reload squid to read its unrestricted-users list";
|
||||
description = "Reload squid to read the lists hive-c0re renders";
|
||||
# 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;
|
||||
|
|
|
|||
9
nix/host-modules/lib/squid-base-domains.nix
Normal file
9
nix/host-modules/lib/squid-base-domains.nix
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# The default of `services.hyperhive.squid.baseAllowedDomains`, and the names
|
||||
# swarm-controller refuses as allow-list entries because squid already allows
|
||||
# them to every authenticated user: the hosts Claude Code requires
|
||||
# (<https://code.claude.com/docs/en/network-config>).
|
||||
[
|
||||
"api.anthropic.com"
|
||||
"claude.ai"
|
||||
"platform.claude.com"
|
||||
]
|
||||
|
|
@ -864,6 +864,14 @@ in
|
|||
# token is in the store, written by `swarm-bao-grafana-controller-token`
|
||||
# on Grafana's host.
|
||||
SWARM_CONTROLLER_GRAFANA_URL = "https://${swarmCfg.grafana.domain}";
|
||||
# The names squid allows every authenticated agent by default. The
|
||||
# allow-list route refuses them as entries
|
||||
# (swarm-controller/src/egress_allow.rs). A hive that overrides
|
||||
# `squid.baseAllowedDomains` is not reflected here; its hive-c0re
|
||||
# skips the entries its own list covers.
|
||||
SWARM_CONTROLLER_SQUID_BASE_DOMAINS = lib.concatStringsSep " " (
|
||||
import ./lib/squid-base-domains.nix
|
||||
);
|
||||
}
|
||||
// queueEnv
|
||||
// forgeEnv
|
||||
|
|
|
|||
Loading…
Reference in a new issue