The last 30 days of journalctl -u ssh on this machine hold 0 Failed password lines and 4 Invalid user lines, from two addresses. One is 127.0.0.1. The other is in 100.64.0.0/10. In that same window sshd accepted 177 public keys and 0 passwords. fail2ban's maxretry and CrowdSec's crowdsecurity/ssh-bf both sit at 5, so this journal stays under the cut and the chart is almost flat. What the two tools do differently is a burst I sent through a veth to 192.0.2.1 port 2222, one tool at a time.
The host sshd stayed on port 22. The lab daemon was a second sshd, bound only to that veth address. Twelve thousand SSH attempts, counted properly is a generated count from 10.0.2.2, and it is the method for reading a log like this. The 30 days below are this host's own journal.
What 30 days of this journal holds
journalctl -u ssh, 30 days | Count |
|---|---|
Failed password | 0 |
Invalid user | 4 |
| Addresses on those four lines | 2 |
Accepted publickey | 177 |
Accepted password | 0 |
Two days carry the four invalid users, one day with 3 and one day with 1. Every other day in the window is 0. A threshold of 5 stays above both of those days.
journalctl -u ssh --since "30 days ago" -o cat --no-pager | awk '
/^Invalid user / {inv++}
/^Failed password / {fail++}
/^Accepted publickey / {pk++}
/^Accepted password / {pw++}
END {
print "invalid", inv+0
print "failed", fail+0
print "accepted publickey", pk+0
print "accepted password", pw+0
}
'fail2ban 1.1.1, where Ubuntu 24.04 still has 1.0.2
This machine's apt candidate for fail2ban on Ubuntu 24.04 is 1.0.2-3ubuntu0.1. I installed fail2ban_1.1.1-1.upstream2_all.deb from the 1.1.1 release, and fail2ban-server -V printed 1.1.1. Debian's tracker lists trixie at 1.1.0-8, and testing and sid at 1.1.1-1. The same tracker lists CVE-2026-109786 against the sshd filter through 1.1.1, before commit ddd879d. A username in the log can steer the ban onto a different address. bookworm, trixie, and sid are still marked vulnerable there.
The fail2ban repository listed 18,937 stars.
/etc/fail2ban/jail.local for the lab was this.
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
banaction = nftables-multiport
backend = polling
[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.logThe lab daemon was OpenSSH_9.6p1 Ubuntu-3ubuntu13.20, with PasswordAuthentication yes, MaxAuthTries 1, and AllowUsers banlab. The client was sshpass with the password nope and one password prompt.
ip netns exec banatk sshpass -p nope ssh \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
-o NumberOfPasswordPrompts=1 \
-o ConnectTimeout=4 \
-b 192.0.2.11 -p 2222 [email protected] trueEight addresses, 192.0.2.11 through 192.0.2.18, eight tries each, run together. Four tries per address reached sshd and wrote Failed password. The fifth was refused in 4 to 8 ms. The eighth try in that same burst took 1.0 to 1.05 seconds, and the client still said connection refused. After the burst, one follow-up per address was refused in 4 to 5 ms. fail2ban-client status sshd showed 8 banned addresses.
Each of those reached connections writes Failed password and maximum authentication attempts exceeded. fail2ban-regex on the lines for 192.0.2.11 matched 4 of the first and 4 of the second. maxretry is 5, so the counter is past the cut while a connection that already reached sshd is still finishing. Back to back, the fifth TCP connection is the one that ate the reject. With 15 seconds between tries, from 192.0.2.40, three connections reached sshd in about 3.66 seconds each and the fourth was refused in 8 ms. findtime is 10 minutes, so a 15 second gap stays inside the window.
The nftables rule was on table inet f2b-table, chain f2b-chain, hook input, priority filter - 1.
tcp dport 2222 ip saddr @addr-set-sshd reject with icmp port-unreachableThat reject is why the client sees connection refused, in milliseconds, on port 2222 only. fail2ban-server RSS after these bans was 20,428 kB. bantime was 1 hour. bantime.increment was on, and each address was banned once, so a longer second ban was outside this run.
A Failed password line with the syslog prefix stripped matched 0 times. The same failure as rsyslog wrote it in auth.log matched once. The jail watches /var/log/auth.log. An sshd -E file, which has no syslog prefix, would have left the ban counter at 0.

CrowdSec 1.8.2, and a decision that still lets sshd answer
This machine's apt candidate for CrowdSec on Ubuntu 24.04 is 1.4.6-6ubuntu0.24.04.2. The package I installed is 1.8.2. cscli version printed v1.8.2-debian-pragmatic-amd64-3d369d64, Go 1.27.1, libre2. The CrowdSec repository listed 15,434 stars. The binary matches the 1.8.2 release.
The engine log said it loaded 62 scenarios, then Starting tail (offset: 0, whence: 2) on /var/log/auth.log. It was reading new lines. The 30-day journal was already on disk, and this process was past it.
crowdsecurity/ssh-bf is a leaky bucket. Capacity 5, leak speed 10s, grouped by source address, blackhole 1 minute, filter evt.Meta.log_type == 'ssh_failed-auth'. crowdsecurity/ssh-slow-bf is capacity 10 and leak speed 60s, same filter. The profile default_ip_remediation writes a ban of 4 hours. The user-enum variants want distinct usernames. Every try here was banlab, and those variants stayed at zero decisions.
I stopped fail2ban before this burst, so all eight passwords per address landed in auth.log. The addresses were 192.0.2.21 through 192.0.2.28, eight tries each, same client. All eight tries per address reached sshd. auth.log has 8 Failed password lines for each of them. cscli decisions list showed 8 bans on crowdsecurity/ssh-bf, with 7 in the events column and a little under 4 hours left on a decision whose profile is 4 hours. A follow-up from 192.0.2.21, decision already listed, still reached sshd in 3.654 seconds.
RSS for that process was 157,068 kB before the burst, while the decision list was empty, and 160,968 kB after the eight bans. 157,068 kB is 153 MiB. On a 1 GB guest that is about 15% of the RAM, with an empty ban list.
The packaged service, on its first community-blocklist pull, logged received 0 new entries, and the same line calls that expected on a fresh install. The comparison engine then ran from a config with the online client removed, so the 192.0.2.0/24 addresses stayed on this machine. The on-disk console.yaml has share_manual_decisions: false, share_custom: true, share_tainted: true, and share_context: false.
Then one address, slowly. 192.0.2.30, 15 seconds of quiet after each try. After each of the first 14, cscli decisions list --ip 192.0.2.30 was an empty list, and each try reached sshd in about 3.66 seconds. The fifteenth reached sshd in 3.717 seconds and is the line that produced the alert: crowdsecurity/ssh-slow-bf, 15 events, capacity 10, leak speed 1m0s. fail2ban, on the same 15 second spacing, had refused the fourth try.
The bouncer is what drops the packet
The decision was already in cscli while sshd was still answering. The firewall bouncer I ran is v0.0.36 (Go 1.25.2). The sample config sets update_frequency to 10s and deny_action to DROP, nftables table crowdsec, chain priority -10, hooks input and forward. I set the poll to 1 second and left the action at DROP. The set held 192.0.2.21 through 192.0.2.28 at 0.105 seconds after the process started.
The chain is hook input, priority filter - 10.
ip saddr @crowdsec-blacklists-crowdsec dropThere is no port match. The address is dropped, whichever port it tries. With ConnectTimeout 3, 192.0.2.21 returned in 3.01 seconds. At that moment 192.0.2.30 had no decision and still reached sshd in 3.717 seconds. Once ssh-slow-bf existed, the next try from .30 returned in 3.009 seconds. Bouncer RSS was 18,444 kB. The drop counter on the set read 22 packets after those clients waited out the timeout.

| fail2ban 1.1.1 | CrowdSec 1.8.2, engine only | Engine plus bouncer 0.0.36 | |
|---|---|---|---|
| Fast burst, per address | 4 reached sshd, 5th refused in 4 to 8 ms | 8 reached sshd, decision listed, a later try still reached in 3.654 s | that later shape timed out at 3.01 s |
| 15 seconds apart | 3 reached, 4th refused in 8 ms | 14 with an empty decision list, 15th reached in 3.717 s | next try timed out at 3.009 s |
| What the packet hits | reject on tcp dport 2222 | sshd | drop on the source address |
| RSS | 20,428 kB | 157,068 kB empty, 160,968 kB after the fast burst | bouncer 18,444 kB on top |
Keys, and a filter that sits in front of sshd
The same 30 days accepted 177 public keys and 0 passwords. A wrong password against a keys-only daemon is a line in a log. The ban starts after the lines that tripped it have already been written. That is the four reached connections on fail2ban, and the fifteen on ssh-slow-bf.
On a Proxmox VM from NodeRings, the ruleset in front of the guest is the firewall. The panel is /organization/firewall, and the address group tab is ?t=address-group. Creating a group is POST /v1/firewall/address-groups with name and prefixes, each prefix an ip and a prefix length. Updating one is PATCH /v1/firewall/address-groups/{id}. The server assigns the id. The docs say the operator pushes that ruleset to the Proxmox VM firewall, and that VirtFusion and SolusVM 2 guests keep the provider's own networking. A tight ruleset can cut off SSH on 22 or ICMP. Recovery is to detach it from the NodeRings side. This lab stopped at the nftables rules on this host. The API and the hypervisor path are unmeasured.
The Docker Compose on a VPS post already measured a drop on a bridge in front of a guest. The guest firewall was unchanged between the open scan and the filtered scan, and the filtered scan was the bridge rule. The service inside the guest was still listening.
Commands
The journal count is the awk above. The jail is the ini above. Status and decisions:
sudo fail2ban-client status sshd
sudo cscli decisions listThe burst client, bound to one lab address:
sshpass -p nope ssh \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
-o NumberOfPasswordPrompts=1 \
-o ConnectTimeout=4 \
-b 192.0.2.21 -p 2222 [email protected] trueConnectTimeout 4 was the fast burst. The drop tests used 3, which is why those clients returned at 3.01 seconds instead of hanging.
FAQ
Does a different SSH port change the count?
The lab daemon listened on 2222, and fail2ban's reject matches 2222. A scanner that only tries 22 misses that socket. The 30-day chart is the host sshd on 22, and the count there is the four invalid users. Moving the port changes which packets arrive. A password-enabled daemon on the new port is still a password-enabled daemon.
Where does this meet Docker's nftables?
This lab had no Docker. fail2ban's chain hooks input at filter - 1 and matches the jail's port. The bouncer hooks input at filter - 10 and matches the source address. Docker's published ports take their own chains. The iptables page says extra filtering on the guest belongs in DOCKER-USER. The drop in the compose post was on the bridge, outside the guest.
What left this machine for the central API?
The fresh service log recorded 0 new community-blocklist entries, and the line says that is what a new install receives. The comparison engine ran with the online client removed, so the 192.0.2.0/24 bans stayed local. console.yaml on the packaged install reads share_manual_decisions: false, share_custom: true, share_tainted: true, share_context: false.
What if both are installed?
fail2ban was stopped before the CrowdSec burst. Its reject on the fifth try ends the log at four Failed password lines per address, and the ssh-bf decision here was built from eight. Whichever rule lands first changes the count the other tool gets to see.
