From a7602ad4289941c03bb15c67888df599619ee85e Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Sun, 31 May 2026 18:03:57 +0900 Subject: [PATCH 1/2] sshd: use exponential fail2ban backoff instead of fixed week-long bans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sshd-aggressive jail used maxretry=1 with a flat 7-day bantime, so a single preauth disconnect from a legitimate client — a banner exchange, PQ-kex downgrade, or ControlMaster probe — locked the source IP out for a week. An admin on a dynamic address was banned this way and could only recover via the wg-admin mesh. Enable bantime-increment so bans grow as bantime * 2^(offenses-1) from a 5-minute base to a 1-week cap, and raise sshd-aggressive maxretry to 3. A one-off mistake now costs 5 minutes while persistent abusers still escalate to long bans. overalljails accounts offenses per-IP across both jails rather than per-filter. --- modules/sshd/default.nix | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/modules/sshd/default.nix b/modules/sshd/default.nix index 69ae442..7121891 100644 --- a/modules/sshd/default.nix +++ b/modules/sshd/default.nix @@ -26,8 +26,9 @@ let fail2ban = { maxRetry = 3; findTime = 600; - banTime = 86400; - aggressiveBanTime = 604800; + # Exponential backoff: ban doubles per re-offense from base to cap. + baseBanTime = 300; + maxBanTime = 604800; }; rateLimiting = { @@ -109,6 +110,16 @@ in enable = true; maxretry = fail2ban.maxRetry; + # overalljails counts offenses per-IP across jails, not per-filter. + bantime-increment = { + enable = true; + maxtime = toString fail2ban.maxBanTime; + rndtime = "60"; # jitter to avoid synchronized ban expiry + overalljails = true; + }; + + # Only ignoreIP truly whitelists: it is checked before banning, whereas the + # iptables ACCEPT sits below the f2b jump chain and cannot override a ban. ignoreIP = [ "127.0.0.1/8" "::1/128" @@ -124,19 +135,21 @@ in filter = "sshd"; maxretry = fail2ban.maxRetry; findtime = fail2ban.findTime; - bantime = fail2ban.banTime; + bantime = fail2ban.baseBanTime; backend = "systemd"; }; }; + # maxretry 3 (not 1): one preauth disconnect from a legit client must not + # mean an instant ban. Backoff handles repeat abusers. sshd-aggressive = { settings = { enabled = true; inherit (ssh) port; filter = "sshd[mode=aggressive]"; - maxretry = 1; - findtime = fail2ban.banTime; - bantime = fail2ban.aggressiveBanTime; + maxretry = fail2ban.maxRetry; + findtime = fail2ban.findTime; + bantime = fail2ban.baseBanTime; backend = "systemd"; }; }; From 8b1633725252372506537ba9fe206c65ccb716e0 Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Sun, 31 May 2026 18:27:57 +0900 Subject: [PATCH 2/2] sshd: stop the aggressive fail2ban jail from banning legit sessions The sshd[mode=aggressive] filter treats "Connection closed by authenticating user X [preauth]" as an attack. sshd also logs that line when a normal client reaches the auth phase and then drops -- e.g. an unconfirmed Secretive/Touch-ID prompt or a multiplexed control connection -- so a single such event banned the admin's own address. Add an sshd.local filter overlay whose ignoreregex matches only the "authenticating user " variant, which carries a real username. Anonymous scanners close before offering a username, so they lack this qualifier and are still caught. fail2ban merges NAME.local after NAME.conf, so this overrides the empty stock ignoreregex. Verified with fail2ban-regex against eta's live journal (aggressive mode): 9 legit auth-phase drops now ignored, 5569 attack matches retained. --- modules/sshd/default.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/modules/sshd/default.nix b/modules/sshd/default.nix index 7121891..4086fdb 100644 --- a/modules/sshd/default.nix +++ b/modules/sshd/default.nix @@ -156,6 +156,16 @@ in }; }; + # Spare clients that reach the auth phase then drop (e.g. an unconfirmed + # Secretive/Touch-ID prompt) from the aggressive jail. Anonymous floods lack + # the "authenticating user" qualifier, so they are still caught. + environment.etc."fail2ban/filter.d/sshd.local" = lib.mkIf isBastion { + text = '' + [Definition] + ignoreregex = ^(?:Connection closed|Disconnected) by authenticating user \S+ port \d+(?: \[preauth\])?\s*$ + ''; + }; + # ========== firewall ========== # Bastion: SSH exposed to internet with rate limiting # Non-bastion: SSH only via WireGuard (wg-admin)