diff --git a/modules/sshd/default.nix b/modules/sshd/default.nix index 69ae442..4086fdb 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,25 +135,37 @@ 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"; }; }; }; }; + # 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)