--- - 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 - 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 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