Files
deploy_home/ansible/roles/podman/tasks/containers/home/zomboid.yml
Bastian de Byl 9d562c7188 feat: smart zomboid traffic filtering with packet-size detection
Replace per-IP hashlimit with smarter filtering that distinguishes
legitimate players from scanner bots based on packet behavior:
- Players send varied packet sizes (53, 37, 1472 bytes)
- Scanners only send 53-byte query packets

New firewall rule chain:
- Priority 2: Mark + ACCEPT non-query packets (verifies player)
- Priority 3: ACCEPT queries from verified IPs (1 hour TTL)
- Priority 4: LOG rate-limited queries from unverified IPs
- Priority 5: DROP rate-limited queries (2 burst, then 1/hour)

Also includes:
- Fail2ban zomboid jail with tighter thresholds (5 retries/4h, 1w ban)
- Graylog streams for zomboid-connections, zomboid-ratelimit, fail2ban
- GeoIP pipeline enrichment for zomboid traffic
- Fluent-bit inputs for ratelimit logs and fail2ban events
- Remove Legendary Katana mod (Workshop 3418366499) - removed from Steam
- Bump Immich to v2.5.0
- Fix fulfillr config (nil → null)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 15:09:26 -05:00

519 lines
18 KiB
YAML

---
- 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"
- "{{ zomboid_path }}/logs"
- 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 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
# 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: journald
env:
SERVER_NAME: "{{ zomboid_server_names[zomboid_server_mode] }}"
MIN_RAM: 8g
MAX_RAM: 24g
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
# Ensure zomboid restarts on any exit (including admin-triggered restarts)
- name: configure zomboid systemd to always restart
become: true
become_user: "{{ podman_user }}"
ansible.builtin.lineinfile:
path: "{{ podman_home }}/.config/systemd/user/zomboid.service"
regexp: "^Restart="
line: "Restart=always"
notify: reload zomboid systemd
# Check if server INI exists (generated on first server run)
- name: check if zomboid server ini exists
become: true
ansible.builtin.stat:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
register: zomboid_ini_stat
tags: zomboid-conf
# Backup settings (requires server to have run once to generate ini)
- name: configure zomboid backup settings
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "SaveWorldEveryMinutes", value: "10" }
- { key: "BackupsPeriod", value: "30" }
- { key: "BackupsCount", value: "10" }
# B42 Linux server fix: disable Lua checksum to allow mods to load
- { key: "DoLuaChecksum", value: "false" }
# Server password
- { key: "Password", value: "{{ zomboid_password }}" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Discord integration (uses Gregbot token, posts /all chat to Discord)
- name: configure zomboid discord integration
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "DiscordEnable", value: "true" }
- { key: "DiscordToken", value: "{{ zomboid_discord_token }}" }
- { key: "DiscordChannel", value: "zomboidbot" }
- { key: "DiscordChannelID", value: "1451961291194241095" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# RCON configuration for remote administration
- name: configure zomboid rcon
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "RCONPort", value: "{{ zomboid_rcon_port }}" }
- { key: "RCONPassword", value: "{{ zomboid_admin_password }}" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Mod configuration for 'modded' server profile
- name: configure zomboid mods for modded server
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "Mods", value: "{{ zomboid_mods.mod_ids }}" }
- { key: "WorkshopItems", value: "{{ zomboid_mods.workshop_items }}" }
when:
- zomboid_server_mode == 'modded'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Mod configuration for 'b42revamp' server profile (168 mods from Steam collection)
- name: configure zomboid mods for b42revamp server
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "Mods", value: "{{ zomboid_mods_b42revamp.mod_ids }}" }
- { key: "WorkshopItems", value: "{{ zomboid_mods_b42revamp.workshop_items }}" }
when:
- zomboid_server_mode == 'b42revamp'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Map configuration (for modded servers with custom maps)
- name: configure zomboid map
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^Map="
line: "Map={{ zomboid_maps[zomboid_server_mode] }}"
when:
- zomboid_server_mode != 'vanilla'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# 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: 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
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 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"
failed_when: false
notify: restart firewalld
tags: firewall
- name: log rate-limited queries from unverified IPs 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 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
register: unverified_log_result_16262
changed_when: "'already' not in unverified_log_result_16262.stderr"
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
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 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"
failed_when: false
notify: restart firewalld
tags: firewall
- name: drop rate-limited queries from unverified IPs 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 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"
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