fix: stop the query filter banning every player who tries to connect

The anti-scanner rules dropped 53-byte Steam query packets above 1/hour with a
burst of 2, per source IP, on the theory that only scanners send queries and
that real players would already have been marked verified by the priority-2
rule when they sent something larger.

That premise is inverted. A client's first contact with the server IS a 53-byte
query, so nobody can be verified before querying, and nobody can query more
than twice an hour without being dropped. The counters said so plainly: five
packets had ever matched the verified-accept rule, against 30,563 drops.
fail2ban then banned each dropped player for a week -- 32 live bans, 550 total,
firing every ten minutes, every one of them a residential address.

Keeps the shape of the protection and moves the threshold somewhere no real
client reaches: opening the server browser or retrying a connection is a
handful of queries, a flood is thousands. Removes the old rules first so hosts
carrying them converge instead of stacking a second copy.

The bans themselves were also hooked into INPUT for tcp only, so they never
blocked the UDP game traffic they were meant to -- the drop rule was doing all
the damage on its own. Cleared the outstanding 32.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-08-26 13:26:45 -04:00
co-authored by Claude Opus 5
parent a4bb78585b
commit 32748d1786
3 changed files with 162 additions and 27 deletions
+8
View File
@@ -83,6 +83,14 @@ zomboid_mods_excluded:
mod_id: IconsInventory
reason: Sophie recommends removing Icon Inventory
# Per-source-IP ceiling on 53-byte Steam/PZ query packets before they are
# logged and dropped. Set well above anything a real client produces -- opening
# the server browser or retrying a connection is a handful of queries, while a
# scanner flood is thousands. The previous 1/hour burst 2 was below normal play
# and made the server unreachable. See containers/home/zomboid.yml.
zomboid_query_rate_limit: 60/minute
zomboid_query_burst: 120
# How long to keep PZ's own log archive under data/Logs.
#
# PZ rolls logs into dated directories and never prunes them; logrotate cannot
@@ -438,71 +438,198 @@
notify: restart firewalld
tags: firewall
# Priority 4: LOG rate-limited queries from unverified IPs
# Very aggressive: 2 burst, then 1 per hour
# Note: Uses same hashlimit name as DROP rule to share bucket
- name: log rate-limited queries from unverified IPs on 16261
# Priority 4/5: query flood limiting.
#
# The original thresholds were 1/hour burst 2 per source IP, on the theory that
# only scanners send 53-byte query packets and that real players would first be
# marked verified by the priority-2 rule when they sent something else. That
# premise is inverted: a client's *first* contact is a 53-byte query, so nobody
# can be verified before they query, and nobody can query more than twice an
# hour without being dropped. The counters were unambiguous -- 5 packets ever
# matched the verified-accept rule against 30,563 drops -- and fail2ban then
# banned the dropped players for a week each, several an hour, all residential
# IPs. The server was unreachable for everyone.
#
# Remove the old rules so hosts carrying them converge, then re-add the same
# shape at a threshold no real client reaches. Opening the server browser sends
# a handful of queries; a flood sends thousands.
- name: remove broken log query rate-limit rule on 16261 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: unverified_log_result
changed_when: "'already' not in unverified_log_result.stderr"
changed_when: false
failed_when: false
notify: restart firewalld
tags: firewall
- name: log rate-limited queries from unverified IPs on 16262
- name: remove broken log query rate-limit rule on 16261 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
firewall-cmd --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: unverified_log_result_16262
changed_when: "'already' not in unverified_log_result_16262.stderr"
changed_when: false
failed_when: false
notify: restart firewalld
tags: firewall
# Priority 5: DROP rate-limited queries from unverified IPs
# Note: Uses same hashlimit name as LOG rule to share bucket
- name: drop rate-limited queries from unverified IPs on 16261
- name: remove broken drop query rate-limit rule on 16261 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j DROP
register: unverified_drop_result
changed_when: "'already' not in unverified_drop_result.stderr"
changed_when: false
failed_when: false
notify: restart firewalld
tags: firewall
- name: drop rate-limited queries from unverified IPs on 16262
- name: remove broken drop query rate-limit rule on 16261 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
firewall-cmd --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j DROP
changed_when: false
failed_when: false
tags: firewall
- name: remove broken log query rate-limit rule on 16262 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
changed_when: false
failed_when: false
tags: firewall
- name: remove broken log query rate-limit rule on 16262 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
changed_when: false
failed_when: false
tags: firewall
- name: remove broken drop query rate-limit rule on 16262 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j DROP
register: unverified_drop_result_16262
changed_when: "'already' not in unverified_drop_result_16262.stderr"
changed_when: false
failed_when: false
tags: firewall
- name: remove broken drop query rate-limit rule on 16262 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j DROP
changed_when: false
failed_when: false
tags: firewall
- name: log query floods on 16261
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: qflood_log_16261
changed_when: "'already' not in qflood_log_16261.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
- name: drop query floods on 16261
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j DROP
register: qflood_drop_16261
changed_when: "'already' not in qflood_drop_16261.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
- name: log query floods on 16262
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: qflood_log_16262
changed_when: "'already' not in qflood_log_16262.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
- name: drop query floods on 16262
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j DROP
register: qflood_drop_16262
changed_when: "'already' not in qflood_drop_16262.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
+1 -1
View File
@@ -123,7 +123,7 @@
- import_tasks: containers/home/gregtime.yml
vars:
image: localhost/greg-time-bot:3.15.0
image: localhost/greg-time-bot:3.16.1
tags: gregtime
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken