The deployment had drifted badly. It was pinned to the Steam `-beta unstable`
branch, which stopped being right the moment B42 became the default at 42.20,
and it wrote every mod ID with the `\` prefix that B42 required only during
that unstable period and now rejects. Three server profiles were carried
around (vanilla, modded, b42revamp) whose mod lists were hand-curated blobs
from a Steam collection that has since moved on.
Moves to the Sophie 42 community preset, vendored from GerDeathstar/sophie-pz
("Sophie 42 Files.zip", 2026-07-31 == the b42 release tag): 283 mod IDs, 256
workshop items, map_distanciado over Muldraugh, plus its SandboxVars,
spawnregions and spawnpoints. Sophie's gameplay settings are kept exactly as
shipped -- PVP with its damage modifiers, the safety system, MaxPlayers=32,
PauseEmpty, no sleep, safehouses off. Only the keys this deployment actually
owns are templated over the top.
The modlist is stored as YAML lists rather than the INI's semicolon blobs, so
the next Sophie update produces a diff you can read. Exclusions live in
zomboid_mods_excluded with a reason each, seeded with IconsInventory -- already
absent upstream, listed so a re-vendor cannot quietly bring it back.
Config is now seeded before first boot instead of patched after it. The old
approach could not write the INI until the server had generated one, so every
setting went through lineinfile guarded on a stat; seeding the whole file from
the preset removes the chicken-and-egg and puts the config in git. force is
off by design: these files are a starting point, not managed state, so the
server can be stopped, hand-edited and regenerated without Ansible clobbering
the edits. Push them again deliberately with -e zomboid_config_force=true.
The B41 Discord keys were dead. B42 replaced DiscordChannel/DiscordChannelID
with DiscordChatChannel, which takes a channel name rather than a snowflake,
so the DiscordChannelID=... this role had been appending was a key the server
ignores and the chat bridge has not been working.
Renaming the server to debbzoid is what starts the new world: PZ derives the
INI, save directory and player DB from the name, so gregboid stays on disk
untouched as a rollback.
Re-enabled, because the reason it was off is now fixed. It was disabled for
saturating the SSD -- ~10 MB/s of log writes into the shared journal, which
starved Gitea CI badly enough to stretch a firmware build to 17 minutes. The
container now logs to its own rotating k8s-file instead of the journal every
other service shares, and logrotate caps the server's own server-console.txt
and Logs/ at 100M keeping 3. copytruncate is mandatory there: PZ holds those
fds for its whole life, so a rename would leave it writing into an unlinked
inode. Memory is unchanged; idle draw is nowhere near MAX_RAM.
Also stops the entrypoint recursively chowning the install tree on every boot.
With 256 workshop mods that is a large inode walk and pure churn once the
first run has set ownership.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
489 lines
17 KiB
YAML
489 lines
17 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"
|
|
- "{{ 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
|
|
|
|
# Server config is seeded from the vendored Sophie 42 preset *before* first
|
|
# boot, so the server never generates a vanilla INI we then have to patch.
|
|
#
|
|
# 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 from sophie preset
|
|
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 and spawn config from sophie preset
|
|
become: true
|
|
ansible.builtin.copy:
|
|
src: "zomboid/sophie/{{ item }}.lua"
|
|
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_{{ item }}.lua"
|
|
force: "{{ zomboid_config_force | bool }}"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_user }}"
|
|
mode: 0644
|
|
loop:
|
|
- SandboxVars
|
|
- spawnregions
|
|
- spawnpoints
|
|
notify: restorecon podman
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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
|