- New unified flake with two nixpkgs channels (unstable for desktops, 25.11 for muffin)
- modules/common-{doas,shell-fish,nix}.nix extracted from duplicated blocks
- modules/desktop-common.nix: renamed from system/common.nix; secret paths point to secrets/desktop/
- hosts/{mreow,yarn}/default.nix import desktop-common; yarn imports modules/no-rgb.nix
- hosts/muffin/default.nix imports common-* + server-prefixed modules + services/; duplicate doas/fish/nix blocks removed; gc retention preserved as mkForce override
- modules/age-secrets.nix: file paths → ../secrets/server/*.age
- services/{minecraft,matrix/livekit}: secret paths → ../secrets/server/
- home/profiles/*.nix: ./progs/ → ../progs/
- hosts/{mreow,yarn}/home.nix: imports rewired to ../../home/profiles/ and ../../home/progs/
- home/progs/pi.nix and hosts/yarn/home.nix: secret reads → ../../secrets/home/
- tests/*.nix: ../modules/security.nix → ../modules/server-security.nix; ../modules/overlays.nix → ../lib/overlays.nix
- lib/default.nix: takes explicit lib param (defaults to nixpkgs-stable.lib)
125 lines
4.3 KiB
Nix
125 lines
4.3 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
pkgs.testers.runNixOSTest {
|
|
name = "fail2ban-caddy";
|
|
|
|
nodes = {
|
|
server =
|
|
{
|
|
config,
|
|
pkgs,
|
|
lib,
|
|
...
|
|
}:
|
|
{
|
|
imports = [
|
|
../modules/server-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"
|
|
'';
|
|
}
|