Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/host-modules/hive-squid.nix

695 lines
31 KiB
Nix

# Egress proxy for this hive's agents: squid on the host, bound to the bridge
# IP, default-deny on the destination hostname. It filters CONNECT targets and
# plain-HTTP hosts and never decrypts: the one TLS key it loads is generated at
# each start, signs nothing and is trusted by no agent. A request through the
# proxy port 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: the base list, the allow-list
# the swarm keeps plus this host's `allowedDomains` (rendered by hive-c0re
# beside the users file) and the names
# the swarm lists for that agent alone. A third kind, set per agent
# (`internet_log`), reaches the same names but is also let through to any other
# destination the deny rules leave; the request is logged as `would_deny`.
# The swarm's deny list (rendered by hive-c0re into `denyFile`) is refused for
# all three before any allow, and logged as `egress_denied`.
# 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.
#
# Web traffic that ignores the proxy variables reaches squid anyway: hive-egress.nix
# redirects the TCP 80 and 443 flows of every agent that does not hold the flag
# into two intercept ports. Such a flow carries no credential, so the agent is
# the one whose container holds the flow's source address, which
# `hive-priv egress-whois` reads from the bindings and leases (the external ACL
# `hive_agent`); a source it cannot name is refused. The listed-names and deny
# rules are the same ones, matched on the identity from that helper. HTTPS is
# peeked at for its SNI and spliced: squid checks the name and never decrypts.
{
pkgs,
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
cfg = hyperhiveCfg.deploy.hive-controller.squid;
netCfg = hyperhiveCfg.network;
port = 3128;
listen = "${netCfg.bridgeIp}:${toString port}";
proxyUrl = "http://${netCfg.bridgeIp}:${toString port}";
exporterListen = "127.0.0.1:9301";
interceptCfg = netCfg.egress.intercept;
# The path every platform container's unit runs hive-priv by (hive-priv.nix
# points it at the current build). Squid runs `egress-whois` through it.
hivePriv = (import ./lib/container-rootfs-check.nix { inherit config; }).hivePriv;
# Written at each start of the unit by `interceptCert`: `https_port` requires
# a certificate and key, and nothing uses them, since every flow is spliced.
interceptCertFile = "/run/squid/intercept.crt";
interceptKeyFile = "/run/squid/intercept.key";
denyCanary = "deny-canary.invalid";
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` starts as and always includes: 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";
# The same string in `swarm_secret_client::proxy::SELFCHECK_UNRESTRICTED_USER`.
selfcheckUnrestrictedUser = "selfcheck-unrestricted.squid";
usersDir = builtins.dirOf cfg.usersFile;
# Destinations that are not the public internet: loopback, link-local, the
# bridge, and the private, shared (CGNAT), unspecified, multicast and
# reserved ranges. Squid checks the address it connects to, so a name an
# agent controls cannot point past them.
# `::` is listed because a connect to it lands on loopback; squid's
# built-in `to_localhost` has `0.0.0.0/32` and `::1` but not `::`. NAT64
# (`64:ff9b::/96`) and 6to4 (`2002::/16`) embed IPv4 addresses and are left
# out: they matter only on a host with such a route.
privateRanges = [
"10.0.0.0/8"
"172.16.0.0/12"
"192.168.0.0/16"
"100.64.0.0/10"
"0.0.0.0/8"
"224.0.0.0/4"
"240.0.0.0/4"
"fc00::/7"
"ff00::/8"
"::/128"
];
# Evaluated top to bottom, first match wins. The destination denies sit
# before every allow, `allow unrestricted` included: squid resolves names on
# the host and connects from it, so a name that answers 127.0.0.1,
# 169.254.169.254, the host's own LAN address or a private neighbour would
# otherwise reach services the bridge firewall keeps agents out of, and an
# unrestricted agent is still an agent. The same denies bind a listed name,
# so a listed name must resolve to a public address on the host's resolver.
# 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. Every
# 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. A `log`-mode agent's
# rule sits in the include after the agent's own names and before
# `deny !allowed_dst`, so it sees the same denies and only turns that last
# refusal into an allow. The `dst` denies cost an unlisted name a lookup on
# the host's resolver before it is refused.
# The swarm's deny list (`denyFile`) sits right after those destination
# denies and before `deny !icpt !authed`, so a denied name is refused before any
# credential check and before every allow, in every mode: its rules name the
# destination first, and a swarm-wide one names no user at all.
# A flow on an intercept port has no credential: `icpt` is those ports and
# `icpt_agent` the helper's answer for the flow's source, which also sets the
# user that `ext_user` ACLs and the log read. A source the helper cannot name
# is refused first. The first request of an intercepted HTTPS connection is
# the CONNECT squid builds from the destination address before it has read
# the ClientHello (`first_hop`); it passes once the address denies have, so
# squid can peek. The CONNECT it builds next has the SNI as its host and meets
# every rule; with no SNI the host stays an address, which the IP-literal
# denies refuse, and `host_verify_strict` answers 409 for an SNI that does not
# resolve to the destination. `!icpt` guards each rule that reads the proxy
# credential, since squid logs a warning when such an ACL meets an
# intercepted request; the rules for those read `ext_user`.
squidConf = ''
http_port ${listen}
http_port ${netCfg.bridgeIp}:${toString interceptCfg.httpPort} intercept name=icpt_http
https_port ${netCfg.bridgeIp}:${toString interceptCfg.httpsPort} intercept ssl-bump name=icpt_tls tls-cert=${interceptCertFile} tls-key=${interceptKeyFile} generate-host-certificates=off
# Never decrypt: peek at the ClientHello for the SNI, then splice every
# connection. The decision on a name is made by `http_access` on the CONNECT
# that carries it. A connection whose first bytes are not a ClientHello
# (or not an HTTP request, on the other port) is refused.
acl icpt myportname icpt_http icpt_tls
acl first_hop at_step SslBump1
ssl_bump peek first_hop
ssl_bump splice all
host_verify_strict on
# The agent that holds the source address of a flow on an intercept port.
# An answer is kept for 10 seconds, a refusal for 2.
external_acl_type hive_agent ttl=10 negative_ttl=2 children-startup=1 children-max=4 %SRC ${hivePriv} egress-whois
acl icpt_agent external hive_agent
pid_filename /run/squid/squid.pid
cache_log stdio:/var/log/squid/cache.log
coredump_dir /var/cache/squid
cache deny all
# One syslog line per request reaches the journal as squid.service.
# Cachemgr scrapes are left out.
access_log syslog:daemon.info squid !manager
# A request that a `log`-mode agent's rule (hive-c0re's include, below)
# let through carries the annotation `would_deny=1`. It gets a second line
# in its own format, so the first line's format stays what the
# dashboards and lints parse, and `swarm-logs query '"would_deny"'` finds
# exactly these requests.
logformat would_deny would_deny user=%[un method=%rm host=%>rd port=%>rP result=%Ss/%03>Hs
acl would_denied note would_deny 1
access_log syslog:daemon.info logformat=would_deny would_denied
# A request one of the deny list's rules (hive-c0re's `denyFile`, below)
# refused carries the annotation `egress_deny=1` and gets a line of its own,
# distinct from `would_deny` and from the 403 line every refusal has, in
# every mode.
logformat egress_denied egress_denied user=%[un method=%rm host=%>rd port=%>rP result=%Ss/%03>Hs
acl egress_denied_hit note egress_deny 1
access_log syslog:daemon.info logformat=egress_denied egress_denied_hit
# The helper reads the users file on every request and reloads it when its
# mtime moves. It exits when the file is missing, which is why the file is
# created before squid starts. A positive answer is cached for a minute, so
# that is also how long a removed user keeps working.
auth_param basic program ${config.services.squid.package}/libexec/basic_ncsa_auth ${cfg.usersFile}
auth_param basic realm hyperhive egress
auth_param basic credentialsttl 1 minute
acl SSL_ports port 443
acl Safe_ports port 80 443
acl authed proxy_auth REQUIRED
# The users whose agent holds the flag. Squid reads the file when it parses
# its configuration, so `squid-unrestricted-reload` reloads squid when
# hive-c0re rewrites it. An empty or missing file lists no one.
acl unrestricted proxy_auth "${cfg.unrestrictedFile}"
# Host-originated requests: the self-check and the exporter. A local
# connect to the bridge IP takes the bridge IP as its source.
acl selfcheck src ${netCfg.bridgeIp}/32
acl to_bridge dst ${netCfg.networkCidr}
acl to_private dst ${lib.concatStringsSep " " privateRanges}
# `dstdomain` on an IP-literal host does a reverse lookup, so a PTR record
# 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}"}
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.
http_access allow manager selfcheck
http_access deny manager
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access deny icpt !icpt_agent
http_access allow icpt first_hop CONNECT !to_localhost !to_linklocal !to_bridge !to_private
http_access deny ip_v4_host
http_access deny ip_v6_host
http_access deny to_localhost
http_access deny to_linklocal
http_access deny to_bridge
http_access deny to_private
include ${cfg.denyFile}
http_access deny !icpt !authed
${lib.optionalString (base != [ ]) "http_access allow base_dst"}
include ${cfg.agentAllowFile}
http_access allow !icpt unrestricted
http_access deny !allowed_dst
http_access allow allowed_dst
http_access deny all
'';
# A throwaway self-signed key and certificate for the intercept HTTPS port,
# written to the two paths given. Squid refuses to start without them and
# loads them to build its TLS context; it never presents them, since every
# connection is spliced.
interceptCert = pkgs.writeShellScript "squid-intercept-cert" ''
set -eu
umask 077
${lib.getExe pkgs.openssl} req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-nodes -subj /CN=hive-squid-intercept -days 3650 \
-keyout "$1" -out "$2"
'';
# `squid -k parse` on the configuration with the includes pointing at an empty
# file. The nixpkgs module runs the same parse on the real text, which
# cannot open the includes in the build sandbox.
squidConfCheck =
pkgs.runCommand "squid-conf-check"
{
nativeBuildInputs = [ config.services.squid.package ];
}
''
${interceptCert} key.pem cert.pem
sed -e "s|${interceptKeyFile}|$PWD/key.pem|; s|${interceptCertFile}|$PWD/cert.pem|" ${
pkgs.writeText "squid.conf" (
builtins.replaceStrings [ cfg.agentAllowFile cfg.denyFile ] [ "/dev/null" "/dev/null" ] squidConf
)
} > squid.conf
squid -k parse -f squid.conf
touch $out
'';
sandbox = {
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectClock = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
SystemCallArchitectures = "native";
CapabilityBoundingSet = "";
};
selfcheck = pkgs.writeShellApplication {
name = "squid-selfcheck";
runtimeInputs = [ pkgs.curl ];
text = ''
# The credential comes from the unit's imported credential, and goes to
# curl on its stdin so it never appears in a command line. Without one
# the requests are anonymous and the self-check reports it.
secret=""
credential_file="''${CREDENTIALS_DIRECTORY:-/nonexistent}/${cfg.selfcheck.credentialName}"
if [ -s "$credential_file" ]; then
secret=$(< "$credential_file")
fi
# <host> <user|anon>: the status squid answers a CONNECT with; 000 when
# the proxy is down. Both users log in with the one secret.
connect_status() {
{
if [ "$2" != anon ] && [ -n "$secret" ]; then
printf 'proxy-user = "%s:%s"\n' "$2" "$secret"
fi
} | curl -sS -m 10 -K - --interface ${netCfg.bridgeIp} -p -x ${proxyUrl} \
-o /dev/null -w '%{http_connect}' "https://$1/" 2>/dev/null || true
}
allow_status=$(connect_status ${cfg.selfcheck.canary} ${selfcheckUser})
deny_status=$(connect_status ${denyCanary} ${selfcheckUser})
anon_status=$(connect_status ${cfg.selfcheck.canary} anon)
unrestricted_status=$(connect_status ${cfg.selfcheck.unrestrictedCanary} ${selfcheckUnrestrictedUser})
ok=false
if [ "$allow_status" = 200 ] && [ "$deny_status" = 403 ] && [ "$anon_status" = 407 ] \
&& [ "$unrestricted_status" = 200 ]; then
ok=true
fi
credential=absent
if [ -n "$secret" ]; then
credential=present
fi
echo "squid-selfcheck ok=$ok allow_canary=${cfg.selfcheck.canary} allow_status=$allow_status deny_canary=${denyCanary} deny_status=$deny_status anon_status=$anon_status credential=$credential unrestricted_canary=${cfg.selfcheck.unrestrictedCanary} unrestricted_status=$unrestricted_status"
[ "$ok" = true ]
'';
};
in
{
options.services.hyperhive.deploy.hive-controller.squid = {
listen = lib.mkOption {
type = lib.types.str;
default = listen;
readOnly = true;
defaultText = lib.literalMD "`<network.bridgeIp>:3128`";
description = ''
The address squid listens on, as `<bridge IP>:<port>`. Read-only.
hive-c0re hands it to every container as the `HIVE_SQUID_PROXY` fact,
which is what tells an agent that this host's squid checks its proxy
credential and applies its `unrestricted_internet` flag.
'';
};
allowedDomains = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"github.com"
"crates.io"
];
description = ''
Hostnames squid always lets a client reach, by CONNECT or plain HTTP, on
this hive. The entries the swarm's controller keeps
(`/api/egress/allowed-domains`) add to this list and never replace it:
hive-c0re writes these names and the swarm-wide entries to
`allowedFile`. A hive-c0re that predates this rule replaced this list
with the controller's once the controller held an entry, so every hive
must run the new one before the controller creates its first entry.
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
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
every user.
'';
};
baseAllowedDomains = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = import ./lib/squid-base-domains.nix;
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. The swarm controller adds the host of an
agent's stored `ANTHROPIC_BASE_URL` to that agent's swarm entries; an
agent on the hand-placed environment file alone, or an `opencode`
provider, needs its host in the swarm UI or in `allowedDomains`.
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`. 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.
'';
};
usersFile = lib.mkOption {
type = lib.types.path;
default = "/var/lib/hive-squid/users";
description = ''
The `<user>:<hash>` file squid checks proxy credentials against. hive-c0re
renders it from the credentials in the swarm secret store, one line per
agent of this hive and one for the self-check, as SHA-512 crypt hashes.
An empty file, which is what exists until hive-c0re writes one, makes
every login fail, and every request to a listed name answers 407.
Owned by `hive-core` and readable by the `squid` group.
'';
};
unrestrictedFile = lib.mkOption {
type = lib.types.path;
default = "${builtins.dirOf cfg.usersFile}/unrestricted";
readOnly = true;
defaultText = lib.literalMD "`unrestricted`, in the directory of `usersFile`";
description = ''
The user names, one per line, that squid lets reach any destination the
deny rules leave open: the users of the agents that hold the swarm's
`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 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
`allowedDomains` and the swarm-wide entries once the controller has a
list, otherwise with `allowedDomains` alone. 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 !icpt !authed`, where the
base list is allowed: for each agent of this hive the swarm lists
names for, an ACL for its proxy user, one for the user the intercept
helper names, an ACL for its names and two `http_access allow` rules
joining them, one for the proxy port and one for the intercept ports.
After those, for each agent in `log` mode, the same pair of rules for
names `allowed_dst` lacks, which annotate the request `would_deny=1`. Empty until the controller has a
list or an agent is in `log` mode. Squid is reloaded when it changes. Owned by `hive-core` and
readable by the `squid` group.
'';
};
denyFile = lib.mkOption {
type = lib.types.path;
default = "${builtins.dirOf cfg.usersFile}/deny.conf";
readOnly = true;
defaultText = lib.literalMD "`deny.conf`, in the directory of `usersFile`";
description = ''
Squid configuration that hive-c0re generates from the swarm's deny list
(`/api/egress/denied-domains`) and the main configuration includes after
the destination denies and before `deny !icpt !authed` and every allow: a
`http_access deny` for the swarm-wide names, and two per agent of this
hive the swarm lists names for, one for the proxy port and one for the
intercept ports. A denied name is refused for every user
in every egress mode, whether or not an allow rule covers it. The file
is empty, and squid configured as without it, until the swarm lists a
name. 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 into it on every
pass, with or without a swarm 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";
readOnly = true;
description = ''
Name of the systemd credential the self-check imports its squid
password from. `squid-selfcheck-credential` writes it to
`/run/credstore/<name>`. Without one the self-check sends no
credential and reports `ok=false`.
'';
};
selfcheck.unrestrictedCanary = lib.mkOption {
type = lib.types.str;
default = "example.com";
description = ''
Hostname outside `allowedDomains` that the self-check CONNECTs to on
port 443, as its unrestricted user, and expects squid to let through.
Squid resolves it with this host's resolver and connects to it, so a
host with no route to it reports `ok=false`.
'';
};
selfcheck.canary = lib.mkOption {
type = lib.types.str;
default = "cache.nixos.org";
description = ''
Hostname the self-check CONNECTs to on port 443 and expects squid to
let through. It is added to the allowed names, so every restricted
agent can reach it too. It must resolve to a public address: squid
resolves it with this host's resolver, not the hive's, and a name
that answers a loopback, bridge or other private address (a swarm
service name on a hive, a split-horizon LAN) gets a 403 before
authentication, and the self-check reports `ok=false`.
'';
};
};
config = lib.mkIf hyperhiveCfg.deploy.hive-controller.enable {
# Reachable from agent containers and nowhere else: this opens the port on
# the bridge interface only.
services.hyperhive.network.exposeHostPorts = [
port
interceptCfg.httpPort
interceptCfg.httpsPort
];
services.hyperhive.network.egress.intercept = {
enable = lib.mkDefault true;
inherit (cfg) unrestrictedFile;
};
services.hyperhive.network.enable = lib.mkDefault true;
services.hyperhive.otel.scrapeTargets.squid = exporterListen;
# A host that also runs the swarm collector ships its whole journal
# already (./swarm-otel.nix), so naming the units there would send each
# line twice.
services.hyperhive.otel.journalUnits = lib.optionals (!hyperhiveCfg.deploy.swarm-otel.enable) [
"squid.service"
"squid-selfcheck.service"
];
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. 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 - -"
"f ${cfg.denyFile} 0640 hive-core squid - -"
];
# 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 the lists hive-c0re renders for squid";
wantedBy = [ "paths.target" ];
pathConfig.PathChanged = [
cfg.unrestrictedFile
cfg.allowedFile
cfg.agentAllowFile
cfg.denyFile
];
};
systemd.services.squid-unrestricted-reload = {
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;
serviceConfig = {
Type = "oneshot";
ExecStart = "${config.systemd.package}/bin/systemctl try-reload-or-restart squid.service";
};
};
assertions = [
{
assertion = !(builtins.elem cfg.selfcheck.unrestrictedCanary (allowed ++ base));
message = ''
services.hyperhive.deploy.hive-controller.squid.selfcheck.unrestrictedCanary must be a name outside
allowedDomains and baseAllowedDomains, or the self-check cannot tell an unrestricted user from a
restricted one.
'';
}
];
systemd.services.squid = {
# Before the module's own pre-start, which parses the configuration and
# so loads the certificate.
preStart = lib.mkBefore ''
${interceptCert} ${interceptKeyFile} ${interceptCertFile}
'';
serviceConfig = sandbox // {
User = "squid";
Group = "squid";
PIDFile = lib.mkForce "/run/squid/squid.pid";
RuntimeDirectory = "squid";
CacheDirectory = "squid";
LogsDirectory = "squid";
# The bridge address may not be assigned yet at first start.
Restart = "on-failure";
RestartSec = 5;
};
};
systemd.services.squid-exporter = {
description = "Prometheus exporter for squid";
wantedBy = [ "multi-user.target" ];
after = [ "squid.service" ];
serviceConfig = sandbox // {
ExecStart = lib.concatStringsSep " " [
"${pkgs.prometheus-squid-exporter}/bin/squid-exporter"
"-squid-hostname ${netCfg.bridgeIp}"
"-squid-port ${toString port}"
"-listen ${exporterListen}"
];
DynamicUser = true;
IPAddressAllow = [
"127.0.0.0/8"
netCfg.bridgeIp
];
IPAddressDeny = "any";
Restart = "on-failure";
RestartSec = 5;
};
};
systemd.services.squid-selfcheck = {
description = "Squid allow, deny and credential canaries";
after = [ "squid.service" ];
serviceConfig = sandbox // {
Type = "oneshot";
# A glob that matches nothing is not an error: a host with no
# credential yet still runs the self-check, and it reports that.
ImportCredential = cfg.selfcheck.credentialName;
ExecStart = lib.getExe selfcheck;
DynamicUser = true;
IPAddressAllow = netCfg.bridgeIp;
IPAddressDeny = "any";
};
};
systemd.timers.squid-selfcheck = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "1min";
OnUnitActiveSec = "1min";
};
};
};
}