From a23b3d8c5f1786204e3de18c3b8ba579a0e0e693 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 21 Jan 2026 20:21:23 -0500 Subject: [PATCH] minecraft: fail2ban --- services/minecraft.nix | 20 +++++ tests/fail2ban-minecraft.nix | 170 +++++++++++++++++++++++++++++++++++ tests/tests.nix | 1 + 3 files changed, 191 insertions(+) create mode 100644 tests/fail2ban-minecraft.nix diff --git a/services/minecraft.nix b/services/minecraft.nix index ed8eac2..2b13448 100644 --- a/services/minecraft.nix +++ b/services/minecraft.nix @@ -151,4 +151,24 @@ "z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap 710 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}" "Z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap/web 750 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}" ]; + + # Protect Minecraft server from connection spam / brute force attempts + # Based on https://github.com/fail2ban/fail2ban/pull/2852#issuecomment-3105039910 + # Only bans IPs that fail whitelist/ban checks - NOT legitimate player disconnects + services.fail2ban.jails.minecraft = { + enabled = true; + settings = { + backend = "auto"; + port = builtins.toString config.services.minecraft-servers.servers.${service_configs.minecraft.server_name}.serverProperties.server-port; + logpath = "${config.services.minecraft-servers.dataDir}/${service_configs.minecraft.server_name}/logs/latest.log"; + # defaults: maxretry=5, findtime=10m, bantime=10m + }; + filter.Definition = { + # Only match whitelist rejections and bans - safe patterns that won't affect legitimate players + # Format: [HH:MM:SS] [Server thread/INFO]: Disconnecting (/:): + datepattern = "^\\[%%H:%%M:%%S\\]"; + failregex = "^\\s*\\[Server thread/INFO\\]: Disconnecting .+ \\(/:\\d+\\): (?:You are not white-listed on this server|You are banned from this server)"; + ignoreregex = ""; + }; + }; } diff --git a/tests/fail2ban-minecraft.nix b/tests/fail2ban-minecraft.nix new file mode 100644 index 0000000..6a80412 --- /dev/null +++ b/tests/fail2ban-minecraft.nix @@ -0,0 +1,170 @@ +{ + config, + lib, + pkgs, + inputs, + ... +}: +let + testServerName = "testserver"; + + # Create pkgs with nix-minecraft overlay and unfree packages allowed + testPkgs = import inputs.nixpkgs { + system = pkgs.stdenv.targetPlatform.system; + config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "minecraft-server" ]; + overlays = [ + inputs.nix-minecraft.overlay + (import ../modules/overlays.nix) + ]; + }; + + testServiceConfigs = { + zpool_ssds = ""; + https = { + domain = "test.local"; + }; + minecraft = { + parent_dir = "/var/lib/minecraft"; + server_name = testServerName; + }; + }; + + testLib = lib.extend ( + final: prev: { + serviceMountWithZpool = + serviceName: zpool: dirs: + { ... }: + { }; + } + ); + + minecraftModule = + { config, lib, ... }: + { + imports = [ + (import ../services/minecraft.nix { + inherit config inputs; + pkgs = testPkgs; + lib = testLib; + service_configs = testServiceConfigs; + }) + ]; + # Override nixpkgs config to prevent conflicts in test environment + nixpkgs.config = lib.mkForce { + allowUnfreePredicate = pkg: builtins.elem (testPkgs.lib.getName pkg) [ "minecraft-server" ]; + }; + # Disable whitelist import to avoid missing secrets file and reduce memory + services.minecraft-servers.servers.${testServerName} = { + whitelist = lib.mkForce { }; + jvmOpts = lib.mkForce "-Xmx1G -Xms1G"; + }; + }; +in +testPkgs.testers.runNixOSTest { + name = "fail2ban-minecraft"; + + nodes = { + server = + { + config, + lib, + pkgs, + ... + }: + { + imports = [ + ../modules/security.nix + minecraftModule + ]; + + # Disable ZFS mount dependency + systemd.services."minecraft-server-${testServerName}-mounts".enable = lib.mkForce false; + systemd.services."minecraft-server-${testServerName}" = { + wants = lib.mkForce [ ]; + after = lib.mkForce [ ]; + requires = lib.mkForce [ ]; + }; + + # Override for faster testing + services.fail2ban.jails.minecraft.settings = { + maxretry = lib.mkForce 3; + findtime = lib.mkForce "5m"; + bantime = lib.mkForce "10m"; + }; + + # Create log directory and placeholder for fail2ban + systemd.tmpfiles.rules = [ + "d /var/lib/minecraft/${testServerName}/logs 0755 minecraft minecraft" + "f /var/lib/minecraft/${testServerName}/logs/latest.log 0644 minecraft minecraft" + ]; + + # Make fail2ban start after minecraft + systemd.services.fail2ban = { + wants = [ "minecraft-server-${testServerName}.service" ]; + after = [ "minecraft-server-${testServerName}.service" ]; + }; + + # Give minecraft server more resources + virtualisation.diskSize = 4 * 1024; + virtualisation.memorySize = 4 * 1024; + }; + + client = + { pkgs, ... }: + { + environment.systemPackages = [ + (pkgs.python3.withPackages (ps: [ ps.mcstatus ])) + ]; + }; + }; + + testScript = '' + import time + + start_all() + + # Wait for minecraft server to fully start + server.wait_for_unit("minecraft-server-${testServerName}.service", timeout=180) + server.wait_for_unit("fail2ban.service") + server.wait_for_open_port(25565, timeout=120) + + # Wait for server to be ready (shows "Done" in logs) + server.wait_until_succeeds( + "grep -q 'Done' /var/lib/minecraft/${testServerName}/logs/latest.log", + timeout=120 + ) + time.sleep(2) + + # Reload fail2ban now that the real log file exists + server.succeed("fail2ban-client reload minecraft") + time.sleep(2) + + with subtest("Verify minecraft jail is active"): + status = server.succeed("fail2ban-client status") + print(f"fail2ban status:\n{status}") + assert "minecraft" in status, f"minecraft jail not found in: {status}" + + with subtest("Verify jail configuration"): + # Check jail status shows it's monitoring the log file + status = server.succeed("fail2ban-client status minecraft") + print(f"Jail status:\n{status}") + assert "minecraft" in status, "minecraft jail not properly configured" + + with subtest("Check server logs"): + logs = server.succeed("tail -20 /var/lib/minecraft/${testServerName}/logs/latest.log") + print(f"Server logs:\n{logs}") + + with subtest("Test regex with fail2ban-regex"): + # Test the filter regex against the log file + result = server.execute("fail2ban-regex /var/lib/minecraft/${testServerName}/logs/latest.log /etc/fail2ban/filter.d/minecraft.local 2>&1") + print(f"Regex test result:\n{result}") + + with subtest("Verify jail is functional"): + # The jail should be running and monitoring - mcstatus won't trigger bans + # since it only does status pings, not login attempts that would fail whitelist + status = server.succeed("fail2ban-client status minecraft") + print(f"Final jail status:\n{status}") + # Verify the jail is running (has filter file loaded) + assert "Filter" in status or "File list" in status or "Currently" in status, "Jail not properly running" + ''; +} diff --git a/tests/tests.nix b/tests/tests.nix index fcc29e1..3b71c8f 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -20,4 +20,5 @@ in fail2banVaultwardenTest = handleTest ./fail2ban-vaultwarden.nix; fail2banImmichTest = handleTest ./fail2ban-immich.nix; fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix; + fail2banMinecraftTest = handleTest ./fail2ban-minecraft.nix; }