send agent traffic through squid once its hive announces it
This commit is contained in:
parent
86e9f49fe6
commit
b551eb02ff
12 changed files with 885 additions and 27 deletions
|
|
@ -71,6 +71,7 @@ in
|
|||
./claude-settings.nix
|
||||
./dashboard-links.nix
|
||||
./docs.nix
|
||||
./egress-proxy.nix
|
||||
./forge.nix
|
||||
./forge-accounts.nix
|
||||
./forge-token.nix
|
||||
|
|
|
|||
240
nix/agent-modules/egress-proxy.nix
Normal file
240
nix/agent-modules/egress-proxy.nix
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# This agent's own egress-proxy credential, fetched from the swarm secret store
|
||||
# by the agent itself, and the proxy environment built from it.
|
||||
#
|
||||
# `swarm-controller` mints the credential at
|
||||
# `swarm/agents/<agent>/proxy-credential`
|
||||
# (`swarm_secret_client::proxy::agent_credential_path`); the hive's squid checks
|
||||
# it against the hash `hive-c0re` renders. This unit logs in with the
|
||||
# certificate ./bao.nix keeps current, reads the agent's own path, and writes
|
||||
# `proxy.env` for the units below to load as an `EnvironmentFile=`:
|
||||
#
|
||||
# - `HTTPS_PROXY` and `HTTP_PROXY`, upper and lower case, with the credential in
|
||||
# the URL's userinfo, which Claude Code, Node, curl, git and reqwest send as
|
||||
# `Proxy-Authorization`;
|
||||
# - `NODE_USE_ENV_PROXY=1`, without which Node's global `fetch` ignores them;
|
||||
# - `NO_PROXY` and `no_proxy`.
|
||||
#
|
||||
# The file is written only when the credential AND the hive's facts are both
|
||||
# there. `HIVE_SQUID_PROXY` (the proxy's address) and `HIVE_EGRESS_NO_PROXY` (the
|
||||
# hosts that skip it) are written by a hive whose squid checks credentials and
|
||||
# applies each agent's flag; on any other hive the agent has no proxy variable
|
||||
# and reaches the internet directly. The units load the file with a leading `-`,
|
||||
# so an agent without it is unchanged. A failed read keeps the file already in
|
||||
# place, and units read it when they start, so an agent takes the proxy at its
|
||||
# next start.
|
||||
#
|
||||
# `NO_PROXY` is the loopback names and the hive's list as it is: squid refuses
|
||||
# every destination that resolves to the bridge, and the hive's resolver answers
|
||||
# the bridge address for the swarm's services. Node has no CIDR support, so the
|
||||
# list holds the bridge as an address.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.agent.bao;
|
||||
agentCfg = config.services.hyperhive.agent;
|
||||
|
||||
# The name `swarm-controller` minted the credential under — see ./queue-identity.nix.
|
||||
agentName = agentCfg.user.name;
|
||||
|
||||
# The server CA id ./bao.nix loads.
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
|
||||
unitName = "hive-agent-proxy-credential";
|
||||
|
||||
# The nix half of `swarm_secret_client::proxy::agent_credential_path` plus
|
||||
# `path::MOUNT`.
|
||||
credentialPath = "secret/swarm/agents/${agentName}/proxy-credential";
|
||||
|
||||
runtimeDir = unitName;
|
||||
envFile = "/run/${runtimeDir}/proxy.env";
|
||||
# Beside the file, so the rename that replaces it stays in one directory.
|
||||
stagingFile = "/run/${runtimeDir}/proxy.env.new";
|
||||
secretFile = "/run/${runtimeDir}/secret.new";
|
||||
# bao's stderr, in the unit's own `0700` directory rather than `/tmp`.
|
||||
errFile = "/run/${runtimeDir}/bao.err";
|
||||
|
||||
# Written by `hive-priv` into every container (./hive-facts.nix). The two
|
||||
# egress facts are read here and nowhere else: the generator there exports
|
||||
# only the keys it names, so neither reaches a unit's environment.
|
||||
factsFile = "/etc/hyperhive-hive-facts";
|
||||
|
||||
loopback = [
|
||||
"localhost"
|
||||
"127.0.0.1"
|
||||
"::1"
|
||||
];
|
||||
|
||||
# The store's address is the whole switch, as in ./bao.nix and
|
||||
# ./forge-token.nix.
|
||||
configured = cfg.addr != null;
|
||||
|
||||
storeRetry = import ../host-modules/lib/store-retry.nix { };
|
||||
|
||||
# Units that make outbound calls: the harness and the claude it starts, the
|
||||
# bash tool and the subagents it runs, MCP servers, and the pollers. They
|
||||
# exist under the same conditions as their definitions, because a unit that
|
||||
# is not defined would be generated inert.
|
||||
consumers = [
|
||||
"hive-agent"
|
||||
"hive-bash-daemon"
|
||||
"hive-subagent-daemon"
|
||||
"hive-mcp-http"
|
||||
"hive-forge-notify"
|
||||
]
|
||||
++ lib.optional (agentCfg.matrixAccounts != { }) "hive-matrix-daemon"
|
||||
++ lib.optional agentCfg.github.enable "hive-github-notify"
|
||||
++ lib.optional (agentCfg.icon != null && agentCfg.forge.url != null) "forge-avatar-sync";
|
||||
in
|
||||
{
|
||||
config = lib.mkIf configured {
|
||||
systemd.services = {
|
||||
${unitName} = {
|
||||
description = "fetch this agent's egress-proxy credential from the secret store";
|
||||
after = [
|
||||
"network.target"
|
||||
# Ordering only: that unit reports a store this agent cannot reach as
|
||||
# itself.
|
||||
"hive-agent-bao-identity.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
pkgs.openbao
|
||||
pkgs.coreutils
|
||||
pkgs.diffutils
|
||||
];
|
||||
# ../host-modules/lib/store-retry.nix.
|
||||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||||
serviceConfig = storeRetry.serviceConfig // {
|
||||
Type = "oneshot";
|
||||
# Not `RemainAfterExit`: the timer below has to be able to start this
|
||||
# unit again. `RuntimeDirectoryPreserve` keeps the file between runs.
|
||||
RemainAfterExit = false;
|
||||
TimeoutStartSec = 30;
|
||||
User = agentName;
|
||||
Group = agentName;
|
||||
RuntimeDirectory = runtimeDir;
|
||||
RuntimeDirectoryMode = "0700";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
UMask = "0177";
|
||||
LoadCredential = [ serverCaCredential ];
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = cfg.clientCertFile;
|
||||
BAO_CLIENT_KEY = cfg.clientKeyFile;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# No identity delivered: ./bao.nix's check reports that.
|
||||
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
|
||||
if [ ! -s "$f" ]; then
|
||||
echo "this agent has no store identity, so it cannot fetch its egress-proxy credential." >&2
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -r "$f" ]; then
|
||||
echo "cannot read $f, so this agent cannot present its store identity." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# The hive says its squid checks each agent's credential and applies
|
||||
# its flag by writing both facts. Without them (a hive that predates
|
||||
# them) there is no proxy to name, and the store is not asked.
|
||||
proxy= direct=
|
||||
if [ -r ${factsFile} ]; then
|
||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||
case "$key" in
|
||||
HIVE_SQUID_PROXY) proxy=$value ;;
|
||||
HIVE_EGRESS_NO_PROXY) direct=$value ;;
|
||||
esac
|
||||
done < ${factsFile}
|
||||
fi
|
||||
if [[ ! "$proxy" =~ ^[0-9]+(\.[0-9]+){3}:[0-9]+$ || ! "$direct" =~ ^[A-Za-z0-9.,_-]+$ ]]; then
|
||||
echo "this hive's facts name no squid proxy, so this agent stays direct." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
|
||||
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
|
||||
fi
|
||||
|
||||
err=${lib.escapeShellArg errFile}
|
||||
trap 'rm -f "$err" ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg secretFile}' EXIT
|
||||
|
||||
# 🩸 Every exit below that is not a rename leaves ${envFile} as it
|
||||
# was: a store that is briefly unreachable, or has no credential for
|
||||
# this agent yet, must not take a working proxy away, and must not
|
||||
# invent one.
|
||||
rm -f "$err"
|
||||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||||
echo "could not log in to the swarm secret store at $BAO_ADDR; the proxy settings in place, if any, are kept:" >&2
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 1
|
||||
fi
|
||||
export BAO_TOKEN
|
||||
|
||||
# ⚠️ Written by redirect, never echoed: the field is the secret.
|
||||
rm -f "$err" ${lib.escapeShellArg secretFile}
|
||||
if ! bao kv get -field=value ${lib.escapeShellArg credentialPath} > ${lib.escapeShellArg secretFile} 2>"$err"; then
|
||||
echo "no egress-proxy credential at ${credentialPath} yet; this agent sends no proxy variables until there is one." >&2
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 0
|
||||
fi
|
||||
secret="$(<${lib.escapeShellArg secretFile})"
|
||||
|
||||
# The controller mints base64url, which needs no escaping in a URL's
|
||||
# userinfo or an env file. Anything else is not that secret.
|
||||
if [[ ! "$secret" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
echo "the value at ${credentialPath} is not a base64url secret, so no proxy variables are written." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
no_proxy=${lib.escapeShellArg (lib.concatStringsSep "," loopback)},$direct
|
||||
url="http://${agentName}:$secret@$proxy"
|
||||
|
||||
rm -f ${lib.escapeShellArg stagingFile}
|
||||
{
|
||||
printf 'HTTPS_PROXY=%s\n' "$url"
|
||||
printf 'HTTP_PROXY=%s\n' "$url"
|
||||
printf 'https_proxy=%s\n' "$url"
|
||||
printf 'http_proxy=%s\n' "$url"
|
||||
printf 'NODE_USE_ENV_PROXY=1\n'
|
||||
printf 'NO_PROXY=%s\n' "$no_proxy"
|
||||
printf 'no_proxy=%s\n' "$no_proxy"
|
||||
} > ${lib.escapeShellArg stagingFile}
|
||||
|
||||
if cmp -s ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg envFile}; then
|
||||
echo "this agent's proxy settings are unchanged."
|
||||
exit 0
|
||||
fi
|
||||
mv -f ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg envFile}
|
||||
echo "wrote this agent's proxy settings from ${credentialPath}."
|
||||
'';
|
||||
};
|
||||
}
|
||||
// lib.genAttrs consumers (_: {
|
||||
# `wants`, not `requires`: a store outage must not stop the harness, which
|
||||
# then starts direct. `after` waits for one attempt.
|
||||
wants = [ "${unitName}.service" ];
|
||||
after = [ "${unitName}.service" ];
|
||||
# A list, which merges with the entries other modules give a unit.
|
||||
serviceConfig.EnvironmentFile = [ "-${envFile}" ];
|
||||
});
|
||||
|
||||
# The controller re-checks every agent's credential every five minutes; the
|
||||
# fetch picks one up within about ten more.
|
||||
systemd.timers.${unitName} = {
|
||||
description = "re-fetch this agent's egress-proxy credential from the secret store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnUnitInactiveSec = "10min";
|
||||
RandomizedDelaySec = "1min";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
# `OTEL_RESOURCE_ATTRIBUTES` by ./otel.nix (no extra labels when absent).
|
||||
# - `HIVE_AGENT_NATS_URL`, where the harness reaches the swarm queue (it fails
|
||||
# at boot when absent).
|
||||
# - `HIVE_SQUID_PROXY` and `HIVE_EGRESS_NO_PROXY`: the squid address and the hosts
|
||||
# that skip it, read only by ./egress-proxy.nix; the generator drops them.
|
||||
#
|
||||
# Any of them may be absent: a hive-c0re older than the agent's build writes
|
||||
# fewer. Two readers hand the lines on:
|
||||
|
|
|
|||
|
|
@ -183,7 +183,8 @@ in
|
|||
inherit (pkgs) lib;
|
||||
};
|
||||
# Executes what the two `module-eval-agent-*-bao` suites above only
|
||||
# evaluate: both fetch units' rendered scripts, against a stub `bao`.
|
||||
# evaluate: the rendered scripts of the forge-token, identity and
|
||||
# proxy-credential fetch units, against a stub `bao`.
|
||||
script-test-agent-bao-fetch = import ./script-tests/agent-bao-fetch.nix {
|
||||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,30 @@ let
|
|||
# sharing a host with the store. Deriving this from
|
||||
# `deploy.bao.enable` would be the co-location assumption itself.
|
||||
haveBaoClientIdentity = baoDeploy.clientCertFile != null && baoDeploy.clientKeyFile != null;
|
||||
|
||||
# What an agent reaches directly and never through squid. Squid refuses every
|
||||
# destination that resolves to the bridge, and the hive's resolver answers the
|
||||
# bridge address for the gateway's names, so each of these must skip it. A bare
|
||||
# name matches itself and its subdomains in curl, Node and reqwest.
|
||||
egressDirectHosts = lib.unique (
|
||||
lib.filter (n: n != null && n != "") (
|
||||
[
|
||||
# Node's NO_PROXY has no CIDR support, so the bridge is listed as the address.
|
||||
config.services.hyperhive.network.bridgeIp
|
||||
# The hive's own zone, which dnsmasq answers with the bridge IP.
|
||||
config.services.hyperhive.domain
|
||||
# The mTLS store and the queue: not in `serviceDomains`.
|
||||
config.services.hyperhive.swarm.bao.domain
|
||||
config.services.hyperhive.swarm.nats.domain
|
||||
]
|
||||
# The swarm's apex (the swarm UI and the controller) and its forge, matrix,
|
||||
# attic, logs, metrics and otel services, wherever they sit. The store and
|
||||
# queue names are not in it, hence their lines above.
|
||||
++ config.services.hyperhive.swarm.serviceDomains
|
||||
# Every name this host's gateway answers with the bridge IP.
|
||||
++ config.services.hyperhive.gateway.localNames
|
||||
)
|
||||
);
|
||||
in
|
||||
{
|
||||
# nix (the prebuild `nix build`, flake-check, and meta eval) writes
|
||||
|
|
@ -277,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 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
|
||||
# agent sends nothing through the proxy unless it reads both. Absent on a
|
||||
# hive that predates them, so its agents go direct.
|
||||
HIVE_SQUID_PROXY = config.services.hyperhive.squid.listen;
|
||||
HIVE_EGRESS_NO_PROXY = lib.concatStringsSep "," egressDirectHosts;
|
||||
}
|
||||
// lib.optionalAttrs (haveBaoClientIdentity && baoDeploy.serverCaFile != null) {
|
||||
# Absent means the system trust store — right for a deployment with a real
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
# 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. No agent sends a credential
|
||||
# yet; the exporter, journal forwarding and the self-check below are how a
|
||||
# deploy shows it works.
|
||||
# rules leave, or reaches only the listed names. 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.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
|
|
@ -19,6 +19,7 @@ let
|
|||
netCfg = hyperhiveCfg.network;
|
||||
|
||||
port = 3128;
|
||||
listen = "${netCfg.bridgeIp}:${toString port}";
|
||||
proxyUrl = "http://${netCfg.bridgeIp}:${toString port}";
|
||||
exporterListen = "127.0.0.1:9301";
|
||||
denyCanary = "deny-canary.invalid";
|
||||
|
|
@ -67,7 +68,7 @@ let
|
|||
# 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 ${netCfg.bridgeIp}:${toString port}
|
||||
http_port ${listen}
|
||||
pid_filename /run/squid/squid.pid
|
||||
cache_log stdio:/var/log/squid/cache.log
|
||||
coredump_dir /var/cache/squid
|
||||
|
|
@ -197,6 +198,19 @@ let
|
|||
in
|
||||
{
|
||||
options.services.hyperhive.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 = hyperhiveCfg.swarm.serviceDomains;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
# not executes").
|
||||
#
|
||||
# The agent side of the swarm-minted forge token: ../agent-modules/forge-token.nix
|
||||
# fetches it, and ../agent-modules/forge.nix's readers find it.
|
||||
# fetches it, and ../agent-modules/forge.nix's readers find it. The same fixtures
|
||||
# cover ../agent-modules/egress-proxy.nix, the agent's proxy credential and the
|
||||
# environment built from it.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
|
|
@ -31,6 +33,8 @@ let
|
|||
agentForgeBao = agentWith {
|
||||
services.hyperhive.agent.bao.addr = baoAddr;
|
||||
services.hyperhive.agent.forge.url = forgeUrl;
|
||||
# A second `EnvironmentFile=` on the harness, which the proxy file must join.
|
||||
services.hyperhive.agent.backendEnvironmentFile = "/var/lib/t/backend.env";
|
||||
services.hyperhive.agent.icon = pkgs.emptyFile;
|
||||
};
|
||||
|
||||
|
|
@ -42,8 +46,22 @@ let
|
|||
};
|
||||
|
||||
fetchUnit = machine: machine.systemd.services.hive-agent-forge-token;
|
||||
proxyUnit = machine: machine.systemd.services.hive-agent-proxy-credential;
|
||||
accountsUnit = machine: machine.systemd.services.hive-agent-forge-accounts;
|
||||
tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile;
|
||||
|
||||
proxyEnvEntry = "-/run/hive-agent-proxy-credential/proxy.env";
|
||||
# The units that make outbound calls and so load the proxy file. This fixture
|
||||
# has an icon, a forge and GitHub on, and no matrix accounts.
|
||||
proxyConsumers = [
|
||||
"hive-agent"
|
||||
"hive-bash-daemon"
|
||||
"hive-subagent-daemon"
|
||||
"hive-mcp-http"
|
||||
"hive-forge-notify"
|
||||
"hive-github-notify"
|
||||
"forge-avatar-sync"
|
||||
];
|
||||
in
|
||||
let
|
||||
cases = [
|
||||
|
|
@ -238,6 +256,137 @@ let
|
|||
name = "the account fetch re-runs every two minutes";
|
||||
ok = agentForgeBao.systemd.timers.hive-agent-forge-accounts.timerConfig.OnUnitInactiveSec == "2min";
|
||||
}
|
||||
{
|
||||
# ../agent-modules/egress-proxy.nix. Presence arm: the unit and its timer
|
||||
# exist, and the agent is told nothing until a credential is fetched.
|
||||
name = "an agent with a store address fetches its egress-proxy credential, and one without does not";
|
||||
ok =
|
||||
agentForgeBao.systemd.services ? hive-agent-proxy-credential
|
||||
&& agentForgeBao.systemd.timers ? hive-agent-proxy-credential
|
||||
&& !(agentForgeNoBao.systemd.services ? hive-agent-proxy-credential)
|
||||
&& !(agentForgeNoBao.systemd.timers ? hive-agent-proxy-credential);
|
||||
}
|
||||
{
|
||||
# The nix half of `swarm_secret_client::proxy::agent_credential_path`
|
||||
# plus the mount.
|
||||
name = "the proxy fetch reads the agent's own proxy-credential path, by redirect, and never echoes it";
|
||||
ok =
|
||||
let
|
||||
name = agentForgeBao.services.hyperhive.agent.user.name;
|
||||
s = (proxyUnit agentForgeBao).script;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/agents/${name}/proxy-credential" s
|
||||
&& lib.hasInfix "-field=value" s
|
||||
&& lib.hasInfix "> ${lib.escapeShellArg "/run/hive-agent-proxy-credential/secret.new"}" s
|
||||
&& !(lib.hasInfix "echo \"$(bao" s)
|
||||
&& !(lib.hasInfix "echo $(bao" s)
|
||||
&& !(lib.hasInfix "echo \"$secret" s)
|
||||
&& !(lib.hasInfix "printf '%s\\n' \"$secret" s);
|
||||
}
|
||||
{
|
||||
name = "the proxy fetch runs as the agent with its own store identity and keeps its file between runs";
|
||||
ok =
|
||||
let
|
||||
u = proxyUnit agentForgeBao;
|
||||
c = u.serviceConfig;
|
||||
in
|
||||
c.User == agentForgeBao.services.hyperhive.agent.user.name
|
||||
&& c.UMask == "0177"
|
||||
&& !c.RemainAfterExit
|
||||
&& c.RuntimeDirectory == "hive-agent-proxy-credential"
|
||||
&& c.RuntimeDirectoryMode == "0700"
|
||||
&& c.RuntimeDirectoryPreserve == "yes"
|
||||
&& u.environment.BAO_ADDR == baoAddr
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert";
|
||||
}
|
||||
{
|
||||
# Presence: every outbound unit loads the file, and the harness keeps the
|
||||
# entry it already had. Absence: an agent with no store loads none.
|
||||
name = "every outbound unit loads the proxy file optionally, beside its other environment files";
|
||||
ok =
|
||||
let
|
||||
svc = agentForgeBao.systemd.services;
|
||||
files = c: svc.${c}.serviceConfig.EnvironmentFile or [ ];
|
||||
in
|
||||
lib.all (
|
||||
c:
|
||||
lib.elem proxyEnvEntry (files c)
|
||||
&& lib.elem "hive-agent-proxy-credential.service" svc.${c}.after
|
||||
&& lib.elem "hive-agent-proxy-credential.service" svc.${c}.wants
|
||||
&& !(lib.elem "hive-agent-proxy-credential.service" (svc.${c}.requires or [ ]))
|
||||
) proxyConsumers
|
||||
&& lib.elem "-/var/lib/t/backend.env" (files "hive-agent")
|
||||
&& !(lib.any
|
||||
(
|
||||
c:
|
||||
lib.elem proxyEnvEntry (agentForgeNoBao.systemd.services.${c}.serviceConfig.EnvironmentFile or [ ])
|
||||
)
|
||||
[
|
||||
"hive-agent"
|
||||
"hive-bash-daemon"
|
||||
"hive-forge-notify"
|
||||
]
|
||||
);
|
||||
}
|
||||
{
|
||||
# The credential is in a file only the fetch writes. Nothing static (a
|
||||
# unit environment, the global one, the login shells) carries a proxy
|
||||
# variable, so an agent without a fetched credential goes direct.
|
||||
name = "no proxy variable is set where the credential is not";
|
||||
ok =
|
||||
let
|
||||
names = [
|
||||
"HTTPS_PROXY"
|
||||
"HTTP_PROXY"
|
||||
"https_proxy"
|
||||
"http_proxy"
|
||||
"NO_PROXY"
|
||||
"no_proxy"
|
||||
"NODE_USE_ENV_PROXY"
|
||||
];
|
||||
anywhere =
|
||||
m: n:
|
||||
m.systemd.globalEnvironment ? ${n}
|
||||
|| m.environment.variables ? ${n}
|
||||
|| lib.any (s: s.environment ? ${n}) (lib.attrValues m.systemd.services);
|
||||
in
|
||||
!(lib.any (anywhere agentForgeBao) names) && !(lib.any (anywhere agentForgeNoBao) names);
|
||||
}
|
||||
{
|
||||
# The hive's facts are the only source of the proxy address and of the
|
||||
# hosts that skip it, and they are read before the store is asked: a hive
|
||||
# without them gets no login and no file. The loopback names stay local.
|
||||
name = "the proxy file takes the proxy and the direct hosts from the hive's facts, before it asks the store";
|
||||
ok =
|
||||
let
|
||||
s = (proxyUnit agentForgeBao).script;
|
||||
at = needle: lib.stringLength (lib.head (lib.splitString needle s));
|
||||
in
|
||||
lib.hasInfix "HIVE_SQUID_PROXY) proxy=$value" s
|
||||
&& lib.hasInfix "HIVE_EGRESS_NO_PROXY) direct=$value" s
|
||||
&& lib.hasInfix "no_proxy=localhost,127.0.0.1,::1,$direct" s
|
||||
&& lib.hasInfix "/etc/hyperhive-hive-facts" s
|
||||
&& at "HIVE_SQUID_PROXY)" < at "bao login"
|
||||
&& !(lib.hasInfix "hyperhive-bridge-dns" s);
|
||||
}
|
||||
{
|
||||
name = "the proxy file names every variable the clients read, and the credential only in the URL's userinfo";
|
||||
ok =
|
||||
let
|
||||
s = (proxyUnit agentForgeBao).script;
|
||||
name = agentForgeBao.services.hyperhive.agent.user.name;
|
||||
in
|
||||
lib.all (v: lib.hasInfix "printf '${v}=%s\\n'" s) [
|
||||
"HTTPS_PROXY"
|
||||
"HTTP_PROXY"
|
||||
"https_proxy"
|
||||
"http_proxy"
|
||||
"NO_PROXY"
|
||||
"no_proxy"
|
||||
]
|
||||
&& lib.hasInfix "NODE_USE_ENV_PROXY=1" s
|
||||
&& lib.hasInfix "url=\"http://${name}:$secret@$proxy\"" s;
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "agent-forge-bao" cases
|
||||
|
|
|
|||
|
|
@ -622,6 +622,68 @@ let
|
|||
builtins.elem 3128 fw.interfaces.${hiveOtel.services.hyperhive.network.bridgeName}.allowedTCPPorts
|
||||
&& !(builtins.elem 3128 fw.allowedTCPPorts);
|
||||
}
|
||||
{
|
||||
# The facts that tell an agent this host's squid checks its credential and
|
||||
# applies its flag. Presence arm: a hive holding a store identity writes
|
||||
# both, and the direct hosts carry the names the brief's destinations
|
||||
# resolve under. Absence arm: a hive with no identity renders no users
|
||||
# file, so it announces no proxy.
|
||||
name = "hive-c0re hands agents the squid address and the hosts that skip it, only with a store identity";
|
||||
ok =
|
||||
let
|
||||
e = hiveOtelAllLocal.systemd.services.hive-c0re.environment;
|
||||
s = hiveOtelAllLocal.services.hyperhive;
|
||||
hosts = lib.splitString "," e.HIVE_EGRESS_NO_PROXY;
|
||||
in
|
||||
e.HIVE_SQUID_PROXY == "${s.network.bridgeIp}:3128"
|
||||
&& e.HIVE_SQUID_PROXY == s.squid.listen
|
||||
&& lib.all (h: lib.elem h hosts) (
|
||||
[
|
||||
s.network.bridgeIp
|
||||
s.swarm.domain
|
||||
s.swarm.bao.domain
|
||||
s.swarm.nats.domain
|
||||
s.swarm.attic.domain
|
||||
s.swarm.forge.domain
|
||||
s.swarm.matrix.gatewayHost
|
||||
s.swarm.victorialogs.domain
|
||||
s.swarm.victoriametrics.domain
|
||||
s.swarm.otel.domain
|
||||
s.otel.collector.host
|
||||
s.domain
|
||||
]
|
||||
++ s.gateway.localNames
|
||||
)
|
||||
&& !(lib.elem "example.com" hosts)
|
||||
&& lib.length hosts == lib.length (lib.unique hosts)
|
||||
&& !(hiveOtelBare.systemd.services.hive-c0re.environment ? HIVE_SQUID_PROXY)
|
||||
&& !(hiveOtelBare.systemd.services.hive-c0re.environment ? HIVE_EGRESS_NO_PROXY);
|
||||
}
|
||||
{
|
||||
# The store and the queue are in no list the gateway builds, so a hive whose
|
||||
# store is elsewhere gets them from their own options. Control: this
|
||||
# fixture runs no store, so the gateway does not name it.
|
||||
name = "a hive without a local store still lists the store and the queue as direct hosts";
|
||||
ok =
|
||||
let
|
||||
s = hiveOtelLeaf.services.hyperhive;
|
||||
hosts = lib.splitString "," hiveOtelLeaf.systemd.services.hive-c0re.environment.HIVE_EGRESS_NO_PROXY;
|
||||
in
|
||||
!(lib.elem s.swarm.bao.domain s.gateway.localNames)
|
||||
&& lib.elem s.swarm.bao.domain hosts
|
||||
&& lib.elem s.swarm.nats.domain hosts;
|
||||
}
|
||||
{
|
||||
# Squid's listener and the fact are one value.
|
||||
name = "the squid fact is the address squid binds";
|
||||
ok =
|
||||
let
|
||||
s = hiveOtelAllLocal.services.hyperhive;
|
||||
in
|
||||
lib.elem "http_port ${s.squid.listen}" (
|
||||
lib.splitString "\n" hiveOtelAllLocal.services.squid.configText
|
||||
);
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "hive-otel" cases
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# `checks.script-test-agent-bao-fetch` — runs the agent units that log in to
|
||||
# the swarm secret store and fetch a secret, ../agent-modules/forge-token.nix
|
||||
# and ../agent-modules/bao.nix, against a stub `bao`. The cases are in
|
||||
# ./agent-bao-fetch.sh.
|
||||
# the swarm secret store and fetch a secret, ../agent-modules/forge-token.nix,
|
||||
# ../agent-modules/bao.nix and ../agent-modules/egress-proxy.nix, against a stub
|
||||
# `bao`. The cases are in ./agent-bao-fetch.sh.
|
||||
#
|
||||
# What runs is the unit's rendered `ExecStart`, on the unit's own `PATH` with
|
||||
# the stub in front. The one edit is the unit's `/run/<unit>/` prefix, moved
|
||||
|
|
@ -77,6 +77,7 @@ pkgs.runCommand "hyperhive-script-test-agent-bao-fetch"
|
|||
(
|
||||
unitEnv "FORGE" "hive-agent-forge-token"
|
||||
// unitEnv "IDENT" "hive-agent-bao-identity"
|
||||
// unitEnv "PROXY" "hive-agent-proxy-credential"
|
||||
// {
|
||||
FAKE_BAO_BIN = "${fakeBao}/bin";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Cases for ./agent-bao-fetch.nix. Each case gets a fresh runtime directory and
|
||||
# credentials directory, runs one unit's script under the unit's `UMask=0377`
|
||||
# credentials directory, runs one unit's script under the unit's own `UMask=`
|
||||
# with a clean environment, and asserts on the exit code, the output, the
|
||||
# files left behind and the `bao` calls made.
|
||||
set -uo pipefail
|
||||
|
|
@ -48,7 +48,13 @@ setup() {
|
|||
client_cert=$creds/hive-agent-bao-cert client_key=$creds/hive-agent-bao-key
|
||||
fi
|
||||
: >"$log"
|
||||
if ! sed "s|/run/$unit/|$rt/|g" "${!script_var}" >"$dir/script"; then
|
||||
# The proxy fetch reads the egress facts the hive writes into a
|
||||
# container, moved under the build directory like /run.
|
||||
mkdir "$dir/etc"
|
||||
printf 'HIVE_DASHBOARD_PORT=7000\nHIVE_SQUID_PROXY=10.42.0.1:3128\nHIVE_EGRESS_NO_PROXY=10.42.0.1,t.hive,bao.t.local\n' >"$dir/etc/hyperhive-hive-facts"
|
||||
umask_value=0377
|
||||
if [ "$prefix" = PROXY ]; then umask_value=0177; fi
|
||||
if ! sed -e "s|/run/$unit/|$rt/|g" -e "s|/etc/hyperhive-|$dir/etc/hyperhive-|g" "${!script_var}" >"$dir/script"; then
|
||||
echo "cannot read the rendered script for $unit" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -68,7 +74,7 @@ setup() {
|
|||
go() {
|
||||
count=$((count + 1))
|
||||
(
|
||||
umask 0377
|
||||
umask "$umask_value"
|
||||
cd "$dir" || exit 99
|
||||
exec env -i \
|
||||
PATH="$dir/bin:$FAKE_BAO_BIN:$unit_path" \
|
||||
|
|
@ -382,6 +388,177 @@ for n in 1 2 3; do
|
|||
expect_pair cert2 key2
|
||||
done
|
||||
|
||||
# The proxy fetch writes the agent's proxy variables, credential included, to
|
||||
# one file the units load as an `EnvironmentFile=`. Nothing here may print the
|
||||
# credential, so every case checks the output for it.
|
||||
proxy_url=http://a1:the-secret@10.42.0.1:3128
|
||||
proxy_no=localhost,127.0.0.1,::1,10.42.0.1,t.hive,bao.t.local
|
||||
proxy_path=secret/swarm/agents/a1/proxy-credential
|
||||
proxy_kv_call="kv get -field=value $proxy_path"
|
||||
proxy_env() {
|
||||
printf 'HTTPS_PROXY=%s\nHTTP_PROXY=%s\nhttps_proxy=%s\nhttp_proxy=%s\nNODE_USE_ENV_PROXY=1\nNO_PROXY=%s\nno_proxy=%s\n' \
|
||||
"$proxy_url" "$proxy_url" "$proxy_url" "$proxy_url" "$1" "$1"
|
||||
}
|
||||
|
||||
expect_secret_unprinted() {
|
||||
if grep -qF -e the-secret -e s.fake-token "$dir/out" "$dir/err"; then
|
||||
fail "a secret reached the unit's output: $(cat "$dir/out" "$dir/err")"
|
||||
fi
|
||||
}
|
||||
|
||||
setup PROXY "no store identity delivered"
|
||||
: >"$creds/hive-agent-bao-cert"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_err "has no store identity"
|
||||
expect_calls
|
||||
expect_no_file "$rt/proxy.env"
|
||||
|
||||
setup PROXY "a first fetch writes the variables 0600, and prints no secret"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "wrote this agent's proxy settings from $proxy_path"
|
||||
expect_calls "$login_call" "$proxy_kv_call"
|
||||
expect_file "$rt/proxy.env" "$(proxy_env "$proxy_no")"
|
||||
if [ "$(stat -c %a "$rt/proxy.env")" != 600 ]; then fail "proxy.env is mode $(stat -c %a "$rt/proxy.env"), expected 600"; fi
|
||||
expect_no_file "$rt/proxy.env.new"
|
||||
expect_no_file "$rt/secret.new"
|
||||
expect_no_file "$rt/bao.err"
|
||||
expect_secret_unprinted
|
||||
|
||||
setup PROXY "no credential minted yet writes nothing"
|
||||
kv_rc=2
|
||||
kv_err="No value found at secret/data/swarm/agents/a1"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_err "no egress-proxy credential at $proxy_path yet"
|
||||
expect_calls "$login_call" "$proxy_kv_call"
|
||||
expect_no_file "$rt/proxy.env"
|
||||
expect_no_file "$rt/proxy.env.new"
|
||||
expect_no_file "$rt/secret.new"
|
||||
|
||||
setup PROXY "no credential minted yet leaves the file in place"
|
||||
proxy_env old.example >"$rt/proxy.env"
|
||||
chmod 0600 "$rt/proxy.env"
|
||||
kv_rc=2
|
||||
go
|
||||
expect_rc 0
|
||||
expect_file "$rt/proxy.env" "$(proxy_env old.example)"
|
||||
|
||||
setup PROXY "a store that refuses the login leaves the file in place"
|
||||
proxy_env old.example >"$rt/proxy.env"
|
||||
login_rc=2
|
||||
login_err=$'Error authenticating: Error making API request.\n\nCode: 503. Errors:\n\n* Vault is sealed\n'
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "could not log in to the swarm secret store"
|
||||
expect_err "Vault is sealed"
|
||||
expect_calls "$login_call"
|
||||
expect_file "$rt/proxy.env" "$(proxy_env old.example)"
|
||||
|
||||
setup PROXY "a store that does not answer leaves the file in place"
|
||||
proxy_env old.example >"$rt/proxy.env"
|
||||
login_rc=2
|
||||
login_err="Error authenticating: Put \"$bao_addr/v1/auth/cert/login\": dial tcp: lookup bao.t.local: no such host"
|
||||
go
|
||||
expect_rc 1
|
||||
expect_file "$rt/proxy.env" "$(proxy_env old.example)"
|
||||
|
||||
setup PROXY "0600 files already in place do not block the fetch"
|
||||
printf stale >"$rt/proxy.env.new"
|
||||
printf stale >"$rt/secret.new"
|
||||
printf stale >"$rt/bao.err"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_no_err "Permission denied"
|
||||
expect_file "$rt/proxy.env" "$(proxy_env "$proxy_no")"
|
||||
|
||||
setup PROXY "an unchanged credential leaves the file alone"
|
||||
proxy_env "$proxy_no" >"$rt/proxy.env"
|
||||
before=$(stat -c %i "$rt/proxy.env")
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "are unchanged"
|
||||
if [ "$(stat -c %i "$rt/proxy.env")" != "$before" ]; then fail "the unchanged file was replaced"; fi
|
||||
expect_no_file "$rt/proxy.env.new"
|
||||
|
||||
setup PROXY "a changed credential replaces the file whole"
|
||||
proxy_env old.example >"$rt/proxy.env"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_file "$rt/proxy.env" "$(proxy_env "$proxy_no")"
|
||||
|
||||
setup PROXY "a value that is not a base64url secret is refused and the file kept"
|
||||
proxy_env old.example >"$rt/proxy.env"
|
||||
kv_out=$'abc\nNO_PROXY=*'
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "is not a base64url secret"
|
||||
expect_file "$rt/proxy.env" "$(proxy_env old.example)"
|
||||
if grep -qF abc "$dir/out" "$dir/err"; then fail "the refused value was printed"; fi
|
||||
|
||||
setup PROXY "an empty value is refused"
|
||||
kv_out=
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "is not a base64url secret"
|
||||
expect_no_file "$rt/proxy.env"
|
||||
|
||||
# A hive that predates the facts, or whose squid does not announce itself, gets
|
||||
# no proxy: the store is not asked and nothing is written.
|
||||
no_fact() {
|
||||
expect_rc 0
|
||||
expect_err "this hive's facts name no squid proxy"
|
||||
expect_calls
|
||||
expect_no_file "$rt/proxy.env"
|
||||
expect_secret_unprinted
|
||||
}
|
||||
|
||||
setup PROXY "a hive that writes no egress facts means no proxy"
|
||||
printf 'HIVE_DASHBOARD_PORT=7000\nHYPERHIVE_HIVE_DOMAIN=t.hive\n' >"$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
no_fact
|
||||
|
||||
setup PROXY "no facts file means no proxy"
|
||||
rm "$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
no_fact
|
||||
|
||||
setup PROXY "a proxy fact without the direct hosts means no proxy"
|
||||
printf 'HIVE_SQUID_PROXY=10.42.0.1:3128\n' >"$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
no_fact
|
||||
|
||||
setup PROXY "direct hosts without a proxy fact mean no proxy"
|
||||
printf 'HIVE_EGRESS_NO_PROXY=10.42.0.1,t.hive\n' >"$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
no_fact
|
||||
|
||||
for bad in 'not-an-address' 'http://10.42.0.1:3128' '10.42.0.1' 'proxy.t.local:3128'; do
|
||||
setup PROXY "a proxy fact that is not an address and a port is refused: $bad"
|
||||
printf 'HIVE_SQUID_PROXY=%s\nHIVE_EGRESS_NO_PROXY=10.42.0.1\n' "$bad" >"$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
no_fact
|
||||
done
|
||||
|
||||
setup PROXY "direct hosts that are not host names are refused"
|
||||
printf 'HIVE_SQUID_PROXY=10.42.0.1:3128\nHIVE_EGRESS_NO_PROXY=a b,c\n' >"$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
no_fact
|
||||
|
||||
setup PROXY "no facts leave the file in place"
|
||||
proxy_env old.example >"$rt/proxy.env"
|
||||
rm "$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_file "$rt/proxy.env" "$(proxy_env old.example)"
|
||||
|
||||
setup PROXY "the direct hosts are used as the hive wrote them, after the loopback names"
|
||||
printf 'HIVE_SQUID_PROXY=10.9.8.7:3129\nHIVE_EGRESS_NO_PROXY=10.9.8.7,x.t.hive,y.t.hive\n' >"$dir/etc/hyperhive-hive-facts"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_file "$rt/proxy.env" "$(proxy_url=http://a1:the-secret@10.9.8.7:3129 proxy_env localhost,127.0.0.1,::1,10.9.8.7,x.t.hive,y.t.hive)"
|
||||
|
||||
if [ "$failures" -ne 0 ]; then
|
||||
echo "script-test-agent-bao-fetch: $failures failed assertions over $count runs" >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ check() {
|
|||
for sh in $SHELLS; do
|
||||
# Only what the snippet exported reaches the child's environment. No PATH:
|
||||
# the snippet must not need one.
|
||||
got=$(env -i "$sh" -c ". '$dir/profile'; exec '$envbin'" | grep -E '^(HYPERHIVE_|HIVE_DASHBOARD_PORT=|HIVE_AGENT_NATS_URL=|CLAUDE_|LD_PRELOAD=|__hyperhive)' | sort)
|
||||
got=$(env -i "$sh" -c ". '$dir/profile'; exec '$envbin'" | grep -E '^(HYPERHIVE_|HIVE_DASHBOARD_PORT=|HIVE_AGENT_NATS_URL=|HIVE_SQUID_PROXY=|HIVE_EGRESS_NO_PROXY=|CLAUDE_|LD_PRELOAD=|__hyperhive)' | sort)
|
||||
if [ "$got" != "$(printf '%s\n' "$want" | sort)" ]; then
|
||||
fail "$sh: a child of the sourcing shell saw:"$'\n'"$got"
|
||||
fi
|
||||
|
|
@ -68,6 +68,13 @@ HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES=team=a,env=prod
|
|||
HIVE_AGENT_NATS_URL=tls://nats.example.test:4222
|
||||
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=unknown-a1"
|
||||
|
||||
# The egress facts are for ./egress-proxy.nix's fetch, which reads the file
|
||||
# itself: a unit with `HIVE_SQUID_PROXY` in its environment would hold a proxy
|
||||
# address with no credential to go with it.
|
||||
printf 'HYPERHIVE_HIVE_NAME=pr1ma\nHIVE_SQUID_PROXY=10.42.0.1:3128\nHIVE_EGRESS_NO_PROXY=10.42.0.1,pr1ma.example.test\n' >"$facts"
|
||||
check "the egress facts stay out of the environment" "HYPERHIVE_HIVE_NAME=pr1ma
|
||||
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=pr1ma-a1"
|
||||
|
||||
: >"$facts"
|
||||
check "an empty file (no facts)" "CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=unknown-a1"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# `checks.vm-hive-squid`: runs ../host-modules/hive-squid.nix in a VM, which
|
||||
# the module-eval suite can only read. Three nodes on two networks:
|
||||
# the module-eval suite can only read, and checks that an agent with the proxy
|
||||
# variables of nix/agent-modules/egress-proxy.nix reaches squid and skips it
|
||||
# where it should. Three nodes on two networks:
|
||||
#
|
||||
# agent (vlan 1) --> proxy (vlan 1 = the stand-in bridge, vlan 2 = uplink) --> web (vlan 2)
|
||||
#
|
||||
|
|
@ -170,6 +172,8 @@ pkgs.testers.runNixOSTest {
|
|||
value = [ p.name ];
|
||||
}) privateNets
|
||||
);
|
||||
# The gateway: nginx answers on the bridge, which agents reach directly.
|
||||
networking.firewall.interfaces.eth1.allowedTCPPorts = [ 443 ];
|
||||
# Renders the users file the way hive-c0re does.
|
||||
environment.systemPackages = [ pkgs.mkpasswd ];
|
||||
services.hyperhive.squid.selfcheck.unrestrictedCanary = "other.t.local";
|
||||
|
|
@ -211,6 +215,8 @@ pkgs.testers.runNixOSTest {
|
|||
{ lib, ... }:
|
||||
{
|
||||
virtualisation.vlans = [ 1 ];
|
||||
# The bridge listener, by the name the proxy resolves to the same address.
|
||||
networking.hosts.${bridgeIp} = [ "bridge.t.local" ];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
||||
{
|
||||
address = "192.168.1.3";
|
||||
|
|
@ -357,6 +363,45 @@ pkgs.testers.runNixOSTest {
|
|||
"http://${bridgeIp}:3128/squid-internal-mgr/counters) = 403"
|
||||
)
|
||||
|
||||
# The variables an agent's `proxy.env` carries (nix/agent-modules/egress-proxy.nix),
|
||||
# as a curl on the agent reads them.
|
||||
def agent_env(cred, no_proxy):
|
||||
url = f"http://{cred}@${bridgeIp}:3128"
|
||||
pairs = {
|
||||
"HTTPS_PROXY": url, "HTTP_PROXY": url, "https_proxy": url, "http_proxy": url,
|
||||
"NO_PROXY": no_proxy, "no_proxy": no_proxy,
|
||||
}
|
||||
return " ".join(f"{k}='{v}'" for k, v in pairs.items())
|
||||
|
||||
def agent_get(cred, no_proxy, url):
|
||||
# The status of the answer, whoever gave it; 000 when none came.
|
||||
return agent.succeed(
|
||||
f"env {agent_env(cred, no_proxy)} curl -sS -m 15 -o /dev/null -w '%{{http_code}}' {url} 2>/dev/null || true"
|
||||
).strip()
|
||||
|
||||
def squid_saw(pattern):
|
||||
return re.search(pattern, proxy.succeed("journalctl -u squid.service -o cat"))
|
||||
|
||||
with subtest("an agent with the proxy variables reaches an allowed name through squid and a swarm-internal name directly"):
|
||||
NO_PROXY = "localhost,127.0.0.1,::1,bridge.t.local,${bridgeIp}"
|
||||
# Plain HTTP to the listeners' port 443, so the answer carries a status.
|
||||
assert agent_get(ATLAS, NO_PROXY, "http://forge.t.local:443/") not in ("000", "403", "407")
|
||||
assert squid_saw(r"192\.168\.1\.3 TCP_\w+/\d+ .* GET http://forge\.t\.local:443/ atlas "), "through squid, as atlas"
|
||||
assert agent_get(ATLAS, NO_PROXY, "http://bridge.t.local:443/") not in ("000", "403", "407")
|
||||
assert not squid_saw(r"GET http://bridge\.t\.local:443/ "), "a swarm-internal name never reached squid"
|
||||
# Control: without the exemption the same request goes to squid, which refuses the bridge.
|
||||
assert agent_get(ATLAS, "localhost", "http://bridge.t.local:443/") == "403"
|
||||
assert squid_saw(r"192\.168\.1\.3 TCP_DENIED/403 .* GET http://bridge\.t\.local:443/ "), "control: squid refuses it"
|
||||
# CONNECT takes the same path: HTTPS_PROXY names the proxy for https URLs.
|
||||
assert agent.succeed(
|
||||
f"env {agent_env(ATLAS, NO_PROXY)} curl -sS -m 15 -o /dev/null -w '%{{http_connect}}' https://forge.t.local/ 2>/dev/null || true"
|
||||
).strip() == "200"
|
||||
|
||||
with subtest("an agent without the proxy variables goes direct"):
|
||||
assert agent.succeed(
|
||||
"curl -sS -m 15 -o /dev/null -w '%{http_code}' http://bridge.t.local:443/ 2>/dev/null || true"
|
||||
).strip() not in ("000", "403", "407")
|
||||
|
||||
with subtest("the self-check passes with its credential"):
|
||||
proxy.succeed("install -d -m 0700 /run/credstore")
|
||||
proxy.succeed(f"printf '%s\\n' {PW['selfcheck.squid']} > /run/credstore/hive-squid.selfcheck")
|
||||
|
|
|
|||
Loading…
Reference in a new issue