Rolling a world back meant hand-work over ssh: stop the service, move the live save aside, unzip the right archive, chown into the container's subuid range, relabel, start. That is the wrong shape of task to do by hand, and it is always done under time pressure -- by construction, because the archive you want is being deleted while you work. PZ keeps BackupsCount=10 per set and writes one every BackupsPeriod=30 minutes, so a periodic backup is reachable for about five hours and then gone. On 2026-09-05 the snapshot the admins asked for (05:16, four minutes before the incident) had about 90 minutes of life left when the request came in. Same shape as the wipe: the Discord bot writes a trigger file into its own rw volume, zomboid-restore.path notices it, and zomboid-restore.service runs the script as the podman user. The bot gets no ssh, no systemd, and keeps only its existing read-only mount of the Zomboid volume. Two details carry most of the correctness. Resolution is by mtime, not by index. The rotation renames the files -- today's backup_7.zip is backup_8.zip half an hour from now, and a new backup_7.zip holds a different world -- so an index is valid only while the listing is fresh, which is not long enough to survive a human reading a confirmation prompt. The trigger names a set and an mtime; the script resolves the path itself, whitelists the filename, and refuses if nothing matches. It never accepts a path. Everything that can fail is checked before the server is touched. A rotated-out target, an archive with no debbzoid world in it, a bad action, a traversal attempt in the set name: each aborts with the server still running and writes a result file the bot reports back. The live world is moved aside rather than deleted, so a restore is undoable and the last three are kept. One thing PZ does not advertise: its backups do not cover the whole save directory. blam/, a mod's own state, is in none of them -- not the 05:16 archive and not the newest one. Restoring only what the archive holds therefore lands the world slightly *behind* the target rather than on it, so anything present in the displaced world and absent from the archive is carried across. The gregtime tag moves to 3.17.0 for the bot half of this -- `backups`, `restore <n>`, `restore confirm`, `restore undo`, gated to the same two admins as the wipe. That image is built and running on the host; its source is not committed yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QdWYhwUtwM2NQGukiRh12
809 lines
29 KiB
YAML
809 lines
29 KiB
YAML
---
|
|
- name: load vendored sophie modlist
|
|
ansible.builtin.include_vars:
|
|
file: zomboid_sophie_mods.yml
|
|
tags: zomboid-conf
|
|
|
|
- name: create zomboid host directory volumes
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ item }}"
|
|
state: directory
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0755
|
|
notify: restorecon podman
|
|
loop:
|
|
- "{{ zomboid_path }}/server"
|
|
- "{{ zomboid_path }}/data"
|
|
- "{{ zomboid_path }}/scripts"
|
|
|
|
# Not a container volume -- nothing mounts it. It holds output from host-side
|
|
# helpers that run as {{ podman_user }}, so it must be owned by that user rather
|
|
# than the container's subuid. Getting this wrong silently broke world resets:
|
|
# the script's first log line failed with EACCES and set -e killed it before it
|
|
# stopped the server, so `@bot` resets did nothing at all.
|
|
- name: create zomboid host-side config directories
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ zomboid_path }}/{{ item }}"
|
|
state: directory
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0755
|
|
loop:
|
|
- config-template
|
|
- config-backup
|
|
# Holds the world each restore displaces, so a restore is always undoable.
|
|
# Deliberately outside data/ -- that is the container's bind mount, and a
|
|
# stray world directory inside Saves/Multiplayer/ is something PZ would see.
|
|
- restore-backup
|
|
|
|
- name: create zomboid host-side log directory
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ zomboid_path }}/logs"
|
|
state: directory
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0755
|
|
|
|
- name: create podman bin directory
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ podman_home }}/bin"
|
|
state: directory
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
- name: deploy zomboid world reset script
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/world-reset.sh.j2
|
|
dest: "{{ podman_home }}/bin/zomboid-world-reset.sh"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
- name: deploy zomboid world reset path unit
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-world-reset.path.j2
|
|
dest: "{{ podman_home }}/.config/systemd/user/zomboid-world-reset.path"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify: reload zomboid systemd
|
|
|
|
- name: deploy zomboid world reset service unit
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-world-reset.service.j2
|
|
dest: "{{ podman_home }}/.config/systemd/user/zomboid-world-reset.service"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify: reload zomboid systemd
|
|
|
|
- name: deploy zomboid restore script
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-restore.sh.j2
|
|
dest: "{{ podman_home }}/bin/zomboid-restore.sh"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
- name: deploy zomboid restore path unit
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-restore.path.j2
|
|
dest: "{{ podman_home }}/.config/systemd/user/zomboid-restore.path"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify: reload zomboid systemd
|
|
|
|
- name: deploy zomboid restore service unit
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-restore.service.j2
|
|
dest: "{{ podman_home }}/.config/systemd/user/zomboid-restore.service"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify: reload zomboid systemd
|
|
|
|
- name: deploy zomboid stats script
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-stats.sh.j2
|
|
dest: "{{ podman_home }}/bin/zomboid-stats.sh"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
- name: create zomboid stats file with correct permissions
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ podman_volumes }}/zomboid-stats.json"
|
|
state: touch
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
modification_time: preserve
|
|
access_time: preserve
|
|
|
|
- name: deploy zomboid stats service unit
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-stats.service.j2
|
|
dest: "{{ podman_home }}/.config/systemd/user/zomboid-stats.service"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify: reload zomboid systemd
|
|
|
|
- name: deploy zomboid stats timer unit
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-stats.timer.j2
|
|
dest: "{{ podman_home }}/.config/systemd/user/zomboid-stats.timer"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify: reload zomboid systemd
|
|
|
|
- name: enable zomboid stats timer
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.systemd:
|
|
name: zomboid-stats.timer
|
|
scope: user
|
|
enabled: true
|
|
state: started
|
|
daemon_reload: true
|
|
|
|
- name: copy zomboid entrypoint script
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/entrypoint.sh.j2
|
|
dest: "{{ zomboid_path }}/scripts/entrypoint.sh"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0755
|
|
notify: restorecon podman
|
|
|
|
- name: copy zomboid steamcmd install script
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/install.scmd.j2
|
|
dest: "{{ zomboid_path }}/scripts/install.scmd"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0644
|
|
notify: restorecon podman
|
|
|
|
# Server config is seeded *before* first boot, so the server never generates a
|
|
# vanilla INI we then have to patch.
|
|
#
|
|
# The INI and SandboxVars started as the vendored Sophie 42 preset and were
|
|
# re-synced from the running debbzoid world on 2026-08-31 -- see the header of
|
|
# templates/zomboid/server.ini.j2 for the INI keys that moved and why.
|
|
#
|
|
# force is off by design: these files are a starting point, not a managed
|
|
# state. Stop the server, hand-edit them, regenerate the world -- Ansible will
|
|
# not clobber the edits on the next deploy. Re-push deliberately with
|
|
# -e zomboid_config_force=true.
|
|
- name: create zomboid server config directory
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ zomboid_path }}/data/Server"
|
|
state: directory
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0755
|
|
notify: restorecon podman
|
|
tags: zomboid-conf
|
|
|
|
- name: seed zomboid server ini
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/server.ini.j2
|
|
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}.ini"
|
|
force: "{{ zomboid_config_force | bool }}"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0644
|
|
notify: restorecon podman
|
|
tags: zomboid-conf
|
|
|
|
# Copied, not templated: these are Lua and must not go through Jinja.
|
|
- name: seed zomboid sandbox settings
|
|
become: true
|
|
ansible.builtin.copy:
|
|
src: zomboid/sophie/SandboxVars.lua
|
|
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_SandboxVars.lua"
|
|
force: "{{ zomboid_config_force | bool }}"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0644
|
|
notify: restorecon podman
|
|
tags: zomboid-conf
|
|
|
|
# Spawn config gets its own force switch, and it is not pedantry: these two
|
|
# files are the only part of the server config that a *running* world will pick
|
|
# up. PZ reads <name>_spawnregions.lua (and any serverfile it names) at every
|
|
# server start; SandboxVars is read once, when the world is created. So a spawn
|
|
# change is deployable on the live world -- push, restart, done -- while a
|
|
# SandboxVars change is not, and needs a wipe to mean anything.
|
|
#
|
|
# Sharing zomboid_config_force between them would mean force-pushing the whole
|
|
# preset to land a one-line spawnregions edit, which also rewrites the INI (a
|
|
# ResetID mismatch tells every client to reroll) and the world's SandboxVars.
|
|
#
|
|
# make deploy TAGS=zomboid-conf -e zomboid_spawn_force=true
|
|
#
|
|
# then restart the server -- with players offline -- for it to take effect.
|
|
- name: seed zomboid spawn config
|
|
become: true
|
|
ansible.builtin.copy:
|
|
src: "zomboid/sophie/{{ item }}.lua"
|
|
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_{{ item }}.lua"
|
|
force: "{{ zomboid_spawn_force | bool }}"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0644
|
|
loop:
|
|
- spawnregions
|
|
- spawnpoints
|
|
notify: restorecon podman
|
|
tags: zomboid-conf
|
|
|
|
- name: deploy zomboid log prune script
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/zomboid-log-prune.sh.j2
|
|
dest: "{{ podman_home }}/bin/zomboid-log-prune.sh"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
- name: deploy zomboid log prune units
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: "zomboid/zomboid-log-prune.{{ item }}.j2"
|
|
dest: "{{ podman_home }}/.config/systemd/user/zomboid-log-prune.{{ item }}"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
loop:
|
|
- service
|
|
- timer
|
|
notify: reload zomboid systemd
|
|
|
|
- name: enable zomboid log prune timer
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.systemd:
|
|
name: zomboid-log-prune.timer
|
|
scope: user
|
|
enabled: true
|
|
state: started
|
|
daemon_reload: true
|
|
|
|
# The settings this repo would deploy, kept where the server cannot overwrite them.
|
|
#
|
|
# Reference only -- nothing applies this automatically. A wipe deliberately leaves
|
|
# the live settings alone so hand tuning survives it.
|
|
#
|
|
# It exists because the live files cannot be trusted as a record of intent: PZ
|
|
# reads Server/<name>_SandboxVars.lua only when it creates a world, then writes
|
|
# the running world's settings back over it. The file in Server/ is an output as
|
|
# much as an input, and that is how a Sophie world quietly became an Apocalypse
|
|
# one -- 139 values reverted, loot from 0.35 back to 0.9, CharacterFreePoints
|
|
# from 0 to 60. When that happens again, this is what to copy back from.
|
|
#
|
|
# What it holds changed on 2026-08-31. It was the pristine Sophie preset; it is
|
|
# now a snapshot of the live debbzoid world, because the admins' tuning had by
|
|
# then diverged from the preset in the same 139 values and re-seeding from the
|
|
# preset would have thrown that tuning away rather than restored it.
|
|
#
|
|
# force is on here, and only here. Nothing on the host writes this directory --
|
|
# the server cannot see it -- so it has no hand edits to protect, and if it does
|
|
# not track the repo it is not a restore point, just an older world's settings.
|
|
- name: seed canonical zomboid world settings
|
|
become: true
|
|
ansible.builtin.copy:
|
|
src: "zomboid/sophie/{{ item }}.lua"
|
|
dest: "{{ zomboid_path }}/config-template/{{ item }}.lua"
|
|
force: true
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0644
|
|
loop:
|
|
- SandboxVars
|
|
- spawnregions
|
|
- spawnpoints
|
|
tags: zomboid-conf
|
|
|
|
# The server's own logs, not the container's. PZ holds these fds open for the
|
|
# life of the process, so copytruncate is mandatory -- a rename would leave it
|
|
# writing into an unlinked inode and the file would appear to stop growing.
|
|
- name: deploy zomboid logrotate config
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: zomboid/logrotate.j2
|
|
dest: /etc/logrotate.d/zomboid
|
|
owner: root
|
|
group: root
|
|
mode: 0644
|
|
|
|
# Set volume permissions for steam user (UID 1000) inside container
|
|
# This uses podman unshare to set ownership correctly for rootless podman
|
|
- name: set zomboid volume permissions for steam user
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.shell: |
|
|
podman unshare chown -R 1000:1000 {{ zomboid_path }}/server
|
|
podman unshare chown -R 1000:1000 {{ zomboid_path }}/data
|
|
changed_when: false
|
|
|
|
- name: flush handlers
|
|
ansible.builtin.meta: flush_handlers
|
|
|
|
- import_tasks: podman/podman-check.yml
|
|
vars:
|
|
container_name: zomboid
|
|
container_image: "{{ image }}"
|
|
|
|
- name: create zomboid container
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
containers.podman.podman_container:
|
|
name: zomboid
|
|
image: "{{ image }}"
|
|
restart_policy: on-failure:3
|
|
log_driver: k8s-file
|
|
log_opt:
|
|
max_size: 50m
|
|
env:
|
|
SERVER_NAME: "{{ zomboid_server_name }}"
|
|
MIN_RAM: "{{ zomboid_min_ram }}"
|
|
MAX_RAM: "{{ zomboid_max_ram }}"
|
|
AUTO_UPDATE: "true"
|
|
ADMIN_PASSWORD: "{{ zomboid_admin_password }}"
|
|
SERVER_PASSWORD: "{{ zomboid_password }}"
|
|
PUID: "1000"
|
|
PGID: "1000"
|
|
volumes:
|
|
- "{{ zomboid_path }}/server:/project-zomboid"
|
|
- "{{ zomboid_path }}/data:/project-zomboid-config"
|
|
- "{{ zomboid_path }}/scripts/entrypoint.sh:/entrypoint.sh:ro"
|
|
- "{{ zomboid_path }}/scripts/install.scmd:/home/steam/install.scmd:ro"
|
|
ports:
|
|
- "16261:16261/udp"
|
|
- "16262:16262/udp"
|
|
- "{{ zomboid_rcon_port }}:{{ zomboid_rcon_port }}/tcp"
|
|
command: /bin/bash /entrypoint.sh
|
|
|
|
- name: create systemd startup job for zomboid
|
|
include_tasks: podman/systemd-generate.yml
|
|
vars:
|
|
container_name: zomboid
|
|
|
|
# Make systemd actually supervise the container, so it restarts on any exit --
|
|
# including the clean one that `@bot restart` produces via RCON quit.
|
|
#
|
|
# `podman generate systemd` emits Type=forking with ExecStart=podman start and
|
|
# a PIDFile pointing at conmon. podman start returns immediately, so the process
|
|
# systemd is told to watch was never its child; it says so itself in the journal
|
|
# ("Supervising process N which is not our child. We'll most likely not notice
|
|
# when it exits") and it does not notice. The unit sat at active/running with
|
|
# NRestarts=0 while the container was exited(0) and the server was down. The
|
|
# PIDFile is worse than useless here: it embeds the container ID, so it goes
|
|
# stale every time a deploy recreates the container.
|
|
#
|
|
# Type=simple with `podman start -a` keeps podman in the foreground as systemd's
|
|
# own child, so the exit is seen and Restart=always fires.
|
|
#
|
|
# stdout/stderr are discarded on purpose. Attaching re-emits the container's
|
|
# output on podman's stdout, which systemd would capture straight back into the
|
|
# journal -- exactly the flood the k8s-file log driver exists to avoid. Nothing
|
|
# is lost: `podman logs` still serves it from the container's own log.
|
|
- name: configure zomboid systemd supervision
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.lineinfile:
|
|
path: "{{ podman_home }}/.config/systemd/user/zomboid.service"
|
|
regexp: "{{ item.regexp }}"
|
|
line: "{{ item.line }}"
|
|
state: "{{ item.state | default('present') }}"
|
|
insertafter: '^\[Service\]'
|
|
loop:
|
|
- { regexp: '^Restart=', line: 'Restart=always' }
|
|
- { regexp: '^Type=', line: 'Type=simple' }
|
|
- { regexp: '^ExecStart=', line: 'ExecStart=/usr/bin/podman start -a zomboid' }
|
|
- { regexp: '^StandardOutput=', line: 'StandardOutput=null' }
|
|
- { regexp: '^StandardError=', line: 'StandardError=null' }
|
|
- { regexp: '^PIDFile=', line: '', state: absent }
|
|
notify: reload zomboid systemd
|
|
|
|
# Firewall logging for player IP correlation
|
|
# Logs new UDP connections to Zomboid port for IP address tracking
|
|
- name: add firewall rule to log zomboid connections
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 0
|
|
-p udp --dport 16261 -m conntrack --ctstate NEW
|
|
-j LOG --log-prefix "ZOMBOID_CONN: " --log-level 4
|
|
register: firewall_result
|
|
changed_when: "'already' not in firewall_result.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
- name: add firewall rule to log zomboid connections (runtime)
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --direct --add-rule ipv4 filter INPUT 0
|
|
-p udp --dport 16261 -m conntrack --ctstate NEW
|
|
-j LOG --log-prefix "ZOMBOID_CONN: " --log-level 4
|
|
changed_when: false
|
|
failed_when: false
|
|
tags: firewall
|
|
|
|
# =============================================================================
|
|
# Add logging for port 16262 (mirrors existing 16261 logging)
|
|
# =============================================================================
|
|
- name: add firewall rule to log zomboid connections on 16262
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 0
|
|
-p udp --dport 16262 -m conntrack --ctstate NEW
|
|
-j LOG --log-prefix "ZOMBOID_CONN: " --log-level 4
|
|
register: firewall_result_16262
|
|
changed_when: "'already' not in firewall_result_16262.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
- name: add firewall rule to log zomboid connections on 16262 (runtime)
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --direct --add-rule ipv4 filter INPUT 0
|
|
-p udp --dport 16262 -m conntrack --ctstate NEW
|
|
-j LOG --log-prefix "ZOMBOID_CONN: " --log-level 4
|
|
changed_when: false
|
|
failed_when: false
|
|
tags: firewall
|
|
|
|
# =============================================================================
|
|
# Zomboid Rate Limiting and Query Flood Protection
|
|
# =============================================================================
|
|
# These rules mitigate Steam server query floods while allowing legitimate play.
|
|
# Query packets are typically 53 bytes; game traffic is larger and sustained.
|
|
#
|
|
# Rule priority: 0=logging (existing), 1=allow established, 2=rate limit queries
|
|
|
|
# Allow established/related connections without rate limiting
|
|
# This ensures active players aren't affected by query rate limits
|
|
- name: allow established zomboid connections on 16261
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 1
|
|
-p udp --dport 16261 -m conntrack --ctstate ESTABLISHED,RELATED
|
|
-j ACCEPT
|
|
register: established_result
|
|
changed_when: "'already' not in established_result.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
- name: allow established zomboid connections on 16262
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 1
|
|
-p udp --dport 16262 -m conntrack --ctstate ESTABLISHED,RELATED
|
|
-j ACCEPT
|
|
register: established_result_16262
|
|
changed_when: "'already' not in established_result_16262.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
# =============================================================================
|
|
# Smart Zomboid Traffic Filtering (Packet-Size Based)
|
|
# =============================================================================
|
|
# Distinguishes legitimate players from scanner bots:
|
|
# - Players send varied packet sizes (53, 37, 1472 bytes)
|
|
# - Scanners only send 53-byte query packets
|
|
#
|
|
# Rule priority:
|
|
# 0 = LOG all (existing above)
|
|
# 1 = ACCEPT established (existing above)
|
|
# 2 = Mark + ACCEPT non-query packets (verifies player)
|
|
# 3 = ACCEPT queries from verified IPs
|
|
# 4 = LOG rate-limited queries from unverified IPs
|
|
# 5 = DROP rate-limited queries from unverified IPs
|
|
|
|
# Priority 2: Mark IPs sending non-query packets as verified (1 hour TTL)
|
|
# Any packet NOT 53 bytes proves actual connection attempt
|
|
- name: mark verified players on 16261 (non-query packets)
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 2
|
|
-p udp --dport 16261 -m conntrack --ctstate NEW
|
|
-m length ! --length 53
|
|
-m recent --name zomboid_verified --set
|
|
-j ACCEPT
|
|
register: verify_result
|
|
changed_when: "'already' not in verify_result.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
- name: mark verified players on 16262 (non-query packets)
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 2
|
|
-p udp --dport 16262 -m conntrack --ctstate NEW
|
|
-m length ! --length 53
|
|
-m recent --name zomboid_verified --set
|
|
-j ACCEPT
|
|
register: verify_result_16262
|
|
changed_when: "'already' not in verify_result_16262.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
# Priority 3: Allow queries from verified players (within 1 hour)
|
|
- name: allow queries from verified players on 16261
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 3
|
|
-p udp --dport 16261 -m conntrack --ctstate NEW
|
|
-m length --length 53
|
|
-m recent --name zomboid_verified --rcheck --seconds 3600
|
|
-j ACCEPT
|
|
register: verified_query_result
|
|
changed_when: "'already' not in verified_query_result.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
- name: allow queries from verified players on 16262
|
|
become: true
|
|
ansible.builtin.command: >
|
|
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 3
|
|
-p udp --dport 16262 -m conntrack --ctstate NEW
|
|
-m length --length 53
|
|
-m recent --name zomboid_verified --rcheck --seconds 3600
|
|
-j ACCEPT
|
|
register: verified_query_result_16262
|
|
changed_when: "'already' not in verified_query_result_16262.stderr"
|
|
failed_when: false
|
|
notify: restart firewalld
|
|
tags: firewall
|
|
|
|
# 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 --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
|
|
changed_when: false
|
|
failed_when: false
|
|
tags: firewall
|
|
|
|
- name: remove broken log query rate-limit rule on 16261 (runtime)
|
|
become: true
|
|
ansible.builtin.command: >
|
|
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_16261
|
|
--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 16261 (permanent)
|
|
become: true
|
|
ansible.builtin.command: >
|
|
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
|
|
changed_when: false
|
|
failed_when: false
|
|
tags: firewall
|
|
|
|
- name: remove broken drop query rate-limit rule on 16261 (runtime)
|
|
become: true
|
|
ansible.builtin.command: >
|
|
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
|
|
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
|
|
|
|
# World reset is now triggered via Discord bot -> systemd path unit
|
|
# See zomboid-world-reset.path and zomboid-world-reset.service
|
|
- name: enable zomboid world reset path unit
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.systemd:
|
|
name: zomboid-world-reset.path
|
|
scope: user
|
|
enabled: true
|
|
state: started
|
|
daemon_reload: true
|
|
|
|
# Restore is triggered the same way -- Discord bot -> trigger file -> path unit.
|
|
# See zomboid-restore.path and zomboid-restore.service.
|
|
- name: enable zomboid restore path unit
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.systemd:
|
|
name: zomboid-restore.path
|
|
scope: user
|
|
enabled: true
|
|
state: started
|
|
daemon_reload: true
|