Files
server-config/tests/fail2ban-caddy.nix

125 lines
4.3 KiB
Nix

{
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":"<HOST>".*"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"
'';
}