{ config, lib, pkgs, ... }: pkgs.testers.runNixOSTest { name = "fail2ban-caddy"; nodes = { server = { config, pkgs, lib, ... }: { imports = [ ../modules/security.nix ]; # Set up Caddy with basic auth (minimal config, no production stuff) # Using bcrypt hash generated with: caddy hash-password --plaintext testpass services.caddy = { enable = true; virtualHosts.":80".extraConfig = '' log { output file /var/log/caddy/access-server.log format json } basic_auth { testuser $2a$14$XqaQlGTdmofswciqrLlMz.rv0/jiGQq8aU.fP6mh6gCGiLf6Cl3.a } respond "Authenticated!" 200 ''; }; # Add the fail2ban jail for caddy-auth (same as in services/caddy.nix) services.fail2ban.jails.caddy-auth = { enabled = true; settings = { backend = "auto"; port = "http,https"; logpath = "/var/log/caddy/access-*.log"; maxretry = 3; # Lower for testing }; filter.Definition = { # Only match 401s where an Authorization header was actually sent failregex = ''^.*"remote_ip":"".*"Authorization":\["REDACTED"\].*"status":401.*$''; ignoreregex = ""; datepattern = ''"ts":{Epoch}\.''; }; }; # Create log directory and initial log file so fail2ban can start systemd.tmpfiles.rules = [ "d /var/log/caddy 755 caddy caddy" "f /var/log/caddy/access-server.log 644 caddy caddy" ]; networking.firewall.allowedTCPPorts = [ 80 ]; }; client = { environment.systemPackages = [ pkgs.curl ]; }; }; testScript = '' import time import re start_all() server.wait_for_unit("caddy.service") server.wait_for_unit("fail2ban.service") server.wait_for_open_port(80) time.sleep(2) with subtest("Verify caddy-auth jail is active"): status = server.succeed("fail2ban-client status") assert "caddy-auth" in status, f"caddy-auth jail not found in: {status}" with subtest("Verify correct password works"): # Use -4 to force IPv4 for consistency result = client.succeed("curl -4 -s -u testuser:testpass http://server/") print(f"Curl result: {result}") assert "Authenticated" in result, f"Auth should succeed: {result}" with subtest("Unauthenticated requests (browser probes) should not trigger ban"): # Simulate browser probe requests - no Authorization header sent # This is the normal HTTP Basic Auth challenge-response flow: # browser sends request without credentials, gets 401, then resends with credentials for i in range(5): client.execute("curl -4 -s http://server/ || true") time.sleep(0.5) time.sleep(3) status = server.succeed("fail2ban-client status caddy-auth") print(f"caddy-auth jail status after unauthenticated requests: {status}") match = re.search(r"Currently banned:\s*(\d+)", status) banned = int(match.group(1)) if match else 0 assert banned == 0, f"Unauthenticated 401s should NOT trigger ban, but {banned} IPs were banned: {status}" with subtest("Generate failed basic auth attempts (wrong password)"): # Use -4 to force IPv4 for consistent IP tracking # These send an Authorization header with wrong credentials for i in range(4): client.execute("curl -4 -s -u testuser:wrongpass http://server/ || true") time.sleep(1) with subtest("Verify IP is banned after wrong password attempts"): time.sleep(5) status = server.succeed("fail2ban-client status caddy-auth") print(f"caddy-auth jail status: {status}") # Check that at least 1 IP is banned match = re.search(r"Currently banned:\s*(\d+)", status) assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}" with subtest("Verify banned client cannot connect"): # Use -4 to test with same IP that was banned exit_code = client.execute("curl -4 -s --max-time 3 http://server/ 2>&1")[0] assert exit_code != 0, "Connection should be blocked" ''; }