# `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" ''; }