310 lines
12 KiB
Nix
310 lines
12 KiB
Nix
# `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:
|
|
#
|
|
# agent (vlan 1) --> proxy (vlan 1 = the stand-in bridge, vlan 2 = uplink) --> web (vlan 2)
|
|
#
|
|
# `proxy` imports the squid module against stub options for the few hive
|
|
# options it reads, so no hive-c0re build is pulled in; the module-eval suite
|
|
# covers the real option wiring.
|
|
{ pkgs }:
|
|
let
|
|
bridgeIp = "192.168.1.1";
|
|
|
|
hiveStubs =
|
|
{ lib, config, ... }:
|
|
{
|
|
options.services.hyperhive = {
|
|
deploy.hive-controller.enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
};
|
|
swarm.serviceDomains = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [ "forge.t.local" ];
|
|
};
|
|
swarm.forge.domain = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "forge.t.local";
|
|
};
|
|
network = {
|
|
bridgeIp = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = bridgeIp;
|
|
};
|
|
networkCidr = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "192.168.1.0/24";
|
|
};
|
|
exposeHostPorts = lib.mkOption {
|
|
type = lib.types.listOf lib.types.port;
|
|
default = [ ];
|
|
};
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
};
|
|
};
|
|
otel = {
|
|
scrapeTargets = lib.mkOption {
|
|
type = lib.types.attrsOf lib.types.str;
|
|
default = { };
|
|
};
|
|
journalUnits = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [ ];
|
|
};
|
|
};
|
|
};
|
|
# The user hive-c0re runs as, which owns the users file squid reads.
|
|
config.users.users.hive-core = {
|
|
isSystemUser = true;
|
|
group = "hive-core";
|
|
};
|
|
config.users.groups.hive-core = { };
|
|
# The firewall hole hive-network.nix opens for `exposeHostPorts`.
|
|
config.networking.firewall.interfaces.eth1.allowedTCPPorts =
|
|
config.services.hyperhive.network.exposeHostPorts;
|
|
};
|
|
|
|
# Plain TCP on 443: a CONNECT is answered before any TLS, which is all the
|
|
# test reads.
|
|
listen443 = addrs: {
|
|
services.nginx = {
|
|
enable = true;
|
|
virtualHosts.default.listen = map (addr: {
|
|
inherit addr;
|
|
port = 443;
|
|
}) addrs;
|
|
};
|
|
};
|
|
in
|
|
pkgs.testers.runNixOSTest {
|
|
name = "hive-squid";
|
|
|
|
nodes = {
|
|
proxy =
|
|
{ lib, pkgs, ... }:
|
|
{
|
|
imports = [
|
|
hiveStubs
|
|
../host-modules/hive-squid.nix
|
|
# Listeners a leaked destination would reach: loopback and the bridge.
|
|
(listen443 [
|
|
"127.0.0.1"
|
|
bridgeIp
|
|
])
|
|
];
|
|
virtualisation.vlans = [
|
|
1
|
|
2
|
|
];
|
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
|
{
|
|
address = bridgeIp;
|
|
prefixLength = 24;
|
|
}
|
|
];
|
|
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [
|
|
{
|
|
address = "192.168.2.1";
|
|
prefixLength = 24;
|
|
}
|
|
];
|
|
# Names the proxy resolves, each a different destination class.
|
|
networking.hosts = {
|
|
"192.168.2.2" = [ "forge.t.local" ];
|
|
"127.0.0.1" = [ "loop.t.local" ];
|
|
${bridgeIp} = [ "bridge.t.local" ];
|
|
"169.254.169.254" = [ "meta.t.local" ];
|
|
};
|
|
# Renders the users file the way hive-c0re does.
|
|
environment.systemPackages = [ pkgs.mkpasswd ];
|
|
services.hyperhive.squid.allowedDomains = [
|
|
"forge.t.local"
|
|
"loop.t.local"
|
|
"bridge.t.local"
|
|
"meta.t.local"
|
|
];
|
|
};
|
|
|
|
web =
|
|
{ lib, ... }:
|
|
{
|
|
imports = [ (listen443 [ "0.0.0.0" ]) ];
|
|
virtualisation.vlans = [ 2 ];
|
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
|
{
|
|
address = "192.168.2.2";
|
|
prefixLength = 24;
|
|
}
|
|
];
|
|
networking.firewall.allowedTCPPorts = [ 443 ];
|
|
};
|
|
|
|
agent =
|
|
{ lib, ... }:
|
|
{
|
|
virtualisation.vlans = [ 1 ];
|
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
|
{
|
|
address = "192.168.1.3";
|
|
prefixLength = 24;
|
|
}
|
|
];
|
|
};
|
|
};
|
|
|
|
testScript = ''
|
|
import re
|
|
import secrets
|
|
|
|
# Drawn fresh each run. The users file holds only their hashes, as
|
|
# hive-c0re renders it.
|
|
PW = {u: secrets.token_urlsafe(24) for u in ("atlas", "selfcheck.squid", "ruth")}
|
|
ATLAS = "atlas:" + PW["atlas"]
|
|
SELFCHECK = "selfcheck.squid:" + PW["selfcheck.squid"]
|
|
RUTH = "ruth:" + PW["ruth"]
|
|
USERS = "/var/lib/hive-squid/users"
|
|
|
|
start_all()
|
|
proxy.wait_for_unit("squid.service")
|
|
proxy.wait_for_unit("squid-exporter.service")
|
|
proxy.wait_for_unit("nginx.service")
|
|
web.wait_for_unit("nginx.service")
|
|
proxy.wait_for_open_port(3128, "${bridgeIp}")
|
|
|
|
def connect(host, cred=None):
|
|
# The status squid answered the CONNECT with; 000 when none came back.
|
|
auth = f"-U '{cred}' " if cred else ""
|
|
return agent.succeed(
|
|
"curl -sS -m 15 -p -x http://${bridgeIp}:3128 " + auth + "-o /dev/null "
|
|
f"-w '%{{http_connect}}' https://{host}/ 2>/dev/null || true"
|
|
).strip()
|
|
|
|
def write_users(*creds):
|
|
# What hive-c0re does: hashes only, written aside and renamed into
|
|
# place, in squid's group. The sleep keeps two writes in different
|
|
# seconds, which is how the helper tells the file changed.
|
|
lines = " ".join(
|
|
f"printf '%s:%s\\n' {u} \"$(mkpasswd -m sha-512 -s <<< {p})\";"
|
|
for u, p in (c.split(":", 1) for c in creds)
|
|
)
|
|
proxy.succeed(
|
|
f"({lines}) > {USERS}.new && chown hive-core:squid {USERS}.new "
|
|
f"&& chmod 640 {USERS}.new && mv {USERS}.new {USERS} && sleep 1"
|
|
)
|
|
|
|
with subtest("squid starts on the empty users file and refuses every login"):
|
|
assert proxy.succeed(f"stat -c '%U:%G %a' {USERS}").strip() == "hive-core:squid 640"
|
|
assert proxy.succeed("stat -c %a /var/lib/hive-squid").strip() == "2750"
|
|
assert proxy.succeed(f"stat -c %s {USERS}").strip() == "0"
|
|
assert connect("forge.t.local", ATLAS) == "407", "no such user"
|
|
assert connect("deny-canary.invalid", ATLAS) == "403", "an unlisted name needs no login"
|
|
|
|
with subtest("the users file holds hashes, never a password"):
|
|
write_users(ATLAS, SELFCHECK)
|
|
users = proxy.succeed(f"cat {USERS}")
|
|
assert re.fullmatch(r"(atlas|selfcheck\.squid):\$6\$\S+\n(atlas|selfcheck\.squid):\$6\$\S+\n", users), users
|
|
assert not any(p in users for p in PW.values()), users
|
|
|
|
with subtest("an allowed name is tunnelled with a credential and challenged without one"):
|
|
assert connect("forge.t.local", ATLAS) == "200", "allow canary"
|
|
assert connect("forge.t.local") == "407", "no credential"
|
|
assert connect("forge.t.local", "atlas:" + secrets.token_urlsafe(24)) == "407", "wrong password"
|
|
assert connect("forge.t.local", "nobody:" + PW["atlas"]) == "407", "unknown user"
|
|
# 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"
|
|
|
|
with subtest("an unlisted name is refused with or without a credential"):
|
|
assert connect("deny-canary.invalid", ATLAS) == "403", "deny canary"
|
|
assert connect("deny-canary.invalid") == "403", "deny canary, anonymous"
|
|
|
|
with subtest("a name resolving to a host-local destination is refused"):
|
|
assert connect("loop.t.local", ATLAS) == "403", "loopback"
|
|
assert connect("bridge.t.local", ATLAS) == "403", "bridge address"
|
|
assert connect("meta.t.local", ATLAS) == "403", "link-local"
|
|
|
|
with subtest("IP literals are refused even when the address is reachable"):
|
|
assert connect("127.0.0.1", ATLAS) == "403"
|
|
assert connect("192.168.2.2", ATLAS) == "403"
|
|
|
|
with subtest("only a port-443 CONNECT is allowed"):
|
|
assert connect("forge.t.local:22", ATLAS) == "403"
|
|
assert connect("forge.t.local:80", ATLAS) == "403"
|
|
|
|
with subtest("an agent cannot read cachemgr"):
|
|
agent.succeed(
|
|
"test $(curl -s -m 15 -o /dev/null -w '%{http_code}' "
|
|
"http://${bridgeIp}:3128/squid-internal-mgr/counters) = 403"
|
|
)
|
|
|
|
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")
|
|
proxy.succeed("chmod 400 /run/credstore/hive-squid.selfcheck")
|
|
proxy.succeed("systemctl start squid-selfcheck.service")
|
|
proxy.succeed(
|
|
"journalctl -u squid-selfcheck.service -o cat | "
|
|
"grep -F 'squid-selfcheck ok=true allow_canary=forge.t.local "
|
|
"allow_status=200 deny_canary=deny-canary.invalid deny_status=403 "
|
|
"anon_status=407 credential=present'"
|
|
)
|
|
|
|
with subtest("the self-check fails, and says why, without a credential or with the wrong one"):
|
|
proxy.succeed("mv /run/credstore/hive-squid.selfcheck /run/credstore/kept")
|
|
proxy.fail("systemctl start squid-selfcheck.service")
|
|
proxy.succeed(
|
|
"journalctl -u squid-selfcheck.service -o cat | "
|
|
"grep -F 'squid-selfcheck ok=false allow_canary=forge.t.local "
|
|
"allow_status=407 deny_canary=deny-canary.invalid deny_status=403 "
|
|
"anon_status=407 credential=absent'"
|
|
)
|
|
proxy.succeed(f"printf '%s\\n' {secrets.token_urlsafe(24)} > /run/credstore/hive-squid.selfcheck")
|
|
proxy.fail("systemctl start squid-selfcheck.service")
|
|
proxy.succeed(
|
|
"journalctl -u squid-selfcheck.service -o cat | "
|
|
"grep -F 'allow_status=407 deny_canary=deny-canary.invalid deny_status=403 "
|
|
"anon_status=407 credential=present'"
|
|
)
|
|
proxy.succeed("mv -f /run/credstore/kept /run/credstore/hive-squid.selfcheck")
|
|
|
|
with subtest("squid's access log reaches the journal under squid.service, with the user"):
|
|
journal = proxy.succeed("journalctl -u squid.service -o cat")
|
|
assert re.search(r"192\.168\.1\.3 TCP_TUNNEL/200 .* CONNECT forge\.t\.local:443 atlas ", journal), journal
|
|
assert re.search(r"192\.168\.1\.3 TCP_DENIED/407 .* CONNECT forge\.t\.local:443 - ", journal), journal
|
|
assert re.search(r"192\.168\.1\.3 TCP_DENIED/403 .* CONNECT deny-canary\.invalid:443 ", journal), journal
|
|
assert re.search(r"192\.168\.1\.3 TCP_DENIED/403 .* CONNECT loop\.t\.local:443 ", journal), journal
|
|
# Exporter scrapes are cachemgr requests and stay out of the log.
|
|
assert "squid-internal-mgr" not in journal, journal
|
|
|
|
with subtest("the exporter serves squid's counters on loopback"):
|
|
metrics = proxy.succeed("curl -sf http://127.0.0.1:9301/metrics")
|
|
print("\n".join(l for l in metrics.splitlines() if l.startswith(("squid_up", "squid_client_http"))))
|
|
assert 'squid_up{host="${bridgeIp}"} 1' in metrics
|
|
m = re.search(r"^squid_client_http_requests_total (\S+)$", metrics, re.M)
|
|
assert m and float(m.group(1)) >= 9, metrics
|
|
m = re.search(r"^squid_client_http_errors_total (\S+)$", metrics, re.M)
|
|
assert m and float(m.group(1)) >= 1, metrics
|
|
proxy.fail("curl -sf -m 5 http://${bridgeIp}:9301/metrics")
|
|
|
|
with subtest("squid survives a reload"):
|
|
proxy.succeed("systemctl reload squid.service")
|
|
proxy.wait_for_unit("squid.service")
|
|
assert connect("forge.t.local", ATLAS) == "200"
|
|
|
|
with subtest("a user added while squid runs can log in, with no reload"):
|
|
write_users(ATLAS, SELFCHECK, RUTH)
|
|
assert connect("forge.t.local", RUTH) == "200"
|
|
assert connect("forge.t.local", ATLAS) == "200"
|
|
|
|
with subtest("squid starts on a missing users file, refuses logins, and recovers when it appears"):
|
|
proxy.succeed(f"rm {USERS}")
|
|
proxy.succeed("systemctl restart squid.service")
|
|
proxy.wait_for_unit("squid.service")
|
|
proxy.wait_for_open_port(3128, "${bridgeIp}")
|
|
assert connect("forge.t.local", ATLAS) == "407", "no file, no login"
|
|
assert connect("deny-canary.invalid", ATLAS) == "403", "an unlisted name still answers"
|
|
write_users(ATLAS, SELFCHECK)
|
|
assert connect("forge.t.local", ATLAS) == "200", "the file appeared"
|
|
'';
|
|
}
|