{ 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 = { failregex = ''^.*"remote_ip":"".*"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("Generate failed basic auth attempts"): # Use -4 to force IPv4 for consistent IP tracking 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"): 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" ''; }