From 09a58913642e62ab82535665870991986be50859 Mon Sep 17 00:00:00 2001 From: boxswapper Date: Sat, 12 Sep 2026 14:31:27 -0600 Subject: [PATCH] initial opencode commit --- .../ses_f68dcefb0ffeMmH0HMoYkd9jKa.json | 10 ++ LICENSE | 21 +++ README.md | 146 ++++++++++++++++++ ansible.cfg | 10 ++ group_vars/all.yml | 31 ++++ harden_fedora_minecraft.yml | 12 ++ host_vars/external-apps.yml | 32 ++++ inventory.yml | 12 ++ requirements.yml | 3 + roles/auth/defaults/main.yml | 14 ++ roles/auth/tasks/main.yml | 70 +++++++++ roles/firewall/defaults/main.yml | 6 + roles/firewall/tasks/main.yml | 68 ++++++++ roles/minecraft/defaults/main.yml | 31 ++++ roles/minecraft/tasks/main.yml | 101 ++++++++++++ .../minecraft/templates/geyser-config.yml.j2 | 17 ++ roles/minecraft/templates/geyser.container.j2 | 18 +++ .../templates/minecraft-server.container.j2 | 33 ++++ roles/minecraft/templates/minecraft.pod.j2 | 14 ++ roles/packages/defaults/main.yml | 30 ++++ roles/packages/tasks/main.yml | 45 ++++++ roles/podman/defaults/main.yml | 8 + roles/podman/tasks/main.yml | 62 ++++++++ roles/selinux/defaults/main.yml | 2 + roles/selinux/tasks/main.yml | 22 +++ roles/ssh_harden/defaults/main.yml | 7 + roles/ssh_harden/handlers/main.yml | 10 ++ roles/ssh_harden/tasks/main.yml | 34 ++++ roles/ssh_harden/templates/fail2ban-local.j2 | 11 ++ .../templates/sshd-hardening.conf.j2 | 18 +++ 30 files changed, 898 insertions(+) create mode 100644 .omo/run-continuation/ses_f68dcefb0ffeMmH0HMoYkd9jKa.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ansible.cfg create mode 100644 group_vars/all.yml create mode 100644 harden_fedora_minecraft.yml create mode 100644 host_vars/external-apps.yml create mode 100644 inventory.yml create mode 100644 requirements.yml create mode 100644 roles/auth/defaults/main.yml create mode 100644 roles/auth/tasks/main.yml create mode 100644 roles/firewall/defaults/main.yml create mode 100644 roles/firewall/tasks/main.yml create mode 100644 roles/minecraft/defaults/main.yml create mode 100644 roles/minecraft/tasks/main.yml create mode 100644 roles/minecraft/templates/geyser-config.yml.j2 create mode 100644 roles/minecraft/templates/geyser.container.j2 create mode 100644 roles/minecraft/templates/minecraft-server.container.j2 create mode 100644 roles/minecraft/templates/minecraft.pod.j2 create mode 100644 roles/packages/defaults/main.yml create mode 100644 roles/packages/tasks/main.yml create mode 100644 roles/podman/defaults/main.yml create mode 100644 roles/podman/tasks/main.yml create mode 100644 roles/selinux/defaults/main.yml create mode 100644 roles/selinux/tasks/main.yml create mode 100644 roles/ssh_harden/defaults/main.yml create mode 100644 roles/ssh_harden/handlers/main.yml create mode 100644 roles/ssh_harden/tasks/main.yml create mode 100644 roles/ssh_harden/templates/fail2ban-local.j2 create mode 100644 roles/ssh_harden/templates/sshd-hardening.conf.j2 diff --git a/.omo/run-continuation/ses_f68dcefb0ffeMmH0HMoYkd9jKa.json b/.omo/run-continuation/ses_f68dcefb0ffeMmH0HMoYkd9jKa.json new file mode 100644 index 0000000..ff9e82b --- /dev/null +++ b/.omo/run-continuation/ses_f68dcefb0ffeMmH0HMoYkd9jKa.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_f68dcefb0ffeMmH0HMoYkd9jKa", + "updatedAt": "2026-09-12T20:30:25.640Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-09-12T20:30:25.640Z" + } + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..24db38f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 external-minecraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf2ad00 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Hardened Fedora Minecraft Server (Ansible) + +Ansible playbook that turns a fresh **Fedora 42** server into a hardened, +containerized **Minecraft** game server β€” Java *and* Bedrock clients β€” +running with **rootless Podman** via systemd Quadlet units. + +Everything is managed declaratively from `harden_fedora_minecraft.yml`: +OS hardening, firewall, SELinux, SSH access control, the Podman runtime, +and the Minecraft/Geyser containers are all deployed by one command. + +## Features + +- πŸ”’ **[Hardening]** SELinux enforcing, firewalld default-DROP policy, password + policy (pwquality + aging), root account locked, automatic security updates + (`dnf-automatic`). +- πŸ”‘ **[SSH]** Key-only authentication for a dedicated admin user, root login + disabled, `AllowUsers` restriction, and a `fail2ban` SSH jail. +- 🐳 **[Runtime]** Rootless Podman with user lingering, subuid/subgid ranges, + IPv4 forwarding, and the overlay storage driver. +- ⛏️ **[Minecraft]** Portable Java server built from + [`itzg/docker-minecraft-server`](https://github.com/itzg/docker-minecraft-server) + (PAPER by default, version/memory/difficulty configurable). +- πŸͺ¨ **[Geyser]** A second container, [`geysermc/geyser`](https://github.com/GeyserMC/geyser-docker), + in the same pod so **Bedrock** (phone/console) players can join the Java world. +- πŸ—„οΈ **[Data]** World data and backups live on the host in `/srv/minecraft` + with correct SELinux labels. + +## Architecture + +Both game containers run in a single Podman **pod** (shared network +namespace) as the non-root user `mcadmin`: + +``` + Internet + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ firewall (DROP)β”‚ only the ports below are allowed + β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + pod: minecraft (rootless, user mcadmin) + β”œβ”€β”€ minecraft-server JVM on 0.0.0.0:25565 (Java clients) + β”‚ └── world data β†’ /srv/minecraft/data + └── geyser Bedrock listener :19132/udp + └── config β†’ /srv/minecraft/geyser + └── forwards Bedrock players to 127.0.0.1:25565 +``` + +| Port | Protocol | Purpose | +|------|----------|---------| +| `22` | TCP | SSH (configurable via `ssh_port`) | +| `25565` | TCP | Minecraft Java server | +| `25565` | UDP | Optional Java server UDP (game query) | +| `19132` | UDP | Bedrock (Geyser), when `enable_geyser: true` | + +## Repository layout + +``` +. +β”œβ”€β”€ harden_fedora_minecraft.yml # entry point: hosts: external-apps β†’ 7 roles +β”œβ”€β”€ inventory.yml # target host definition +β”œβ”€β”€ ansible.cfg # inventory + role path + pipelining +β”œβ”€β”€ requirements.yml # Ansible collections (ansible.posix) +β”œβ”€β”€ group_vars/all.yml # shared variables +β”œβ”€β”€ host_vars/external-apps.yml # per-server settings (IP, SSH key, MC tuning) +└── roles/ + β”œβ”€β”€ packages/ # base + container tooling, full dnf update, dnf-automatic + β”œβ”€β”€ firewall/ # firewalld: DROP target, open SSH + Minecraft + Geyser + β”œβ”€β”€ selinux/ # enforcing mode + container_file_t context for /srv/minecraft + β”œβ”€β”€ auth/ # admin user, SSH key, sudoers, password policy, lock root + β”œβ”€β”€ ssh_harden/ # sshd drop-in + fail2ban jail + β”œβ”€β”€ podman/ # socket, linger, subuid/subgid, ip_forward, storage + └── minecraft/ # data dirs, Quadlet pod/container units, image pulls +``` + +## Prerequisites + +- Ansible control node (this repo) with Ansible Core β‰₯ 2.14 and `ansible.posix` +- A Fedora 42 target server reachable over SSH with an account that has sudo +- The `ansible.user` in the inventory must be able to `become` root on the target + +## Quick start + +```bash +# 1. Install the required collection +ansible-galaxy collection install -r requirements.yml + +# 2. Configure the target server (REQUIRED before first run) +# - host_vars/external-apps.yml +# * ansible_host: real IP or DNS name +# * admin_user_ssh_key: your real SSH public key +# * game settings to taste (version, memory, whitelist…) + +# 3. Run the playbook +ansible-playbook harden_fedora_minecraft.yml +``` + +> ⚠️ `admin_user_ssh_key` **must** be a real key before the first run β€” +> the playbook disables SSH password authentication and root login once +> the `ssh_harden` role executes. + +## Configuration + +All tunables live in `host_vars/external-apps.yml` (per server) and +`group_vars/all.yml` (cluster-wide). Highlights: + +| Variable | Default | Meaning | +|----------|---------|---------| +| `admin_user` | `mcadmin` | Non-root user running the containers | +| `minecraft_version` | `LATEST` | Server version (`1.21.1`, …) | +| `minecraft_server_type` | `PAPER` | PAPER / VANILLA / FABRIC / FORGE | +| `minecraft_memory` | `2G` | JVM memory limit | +| `minecraft_whitelist` | `""` | Comma-separated allowed usernames (empty = off) | +| `enable_geyser` | `true` | Deploy the Bedrock bridge | +| `geyser_port` | `19132` | Bedrock UDP port | +| `geyser_auth_type` | `online` | `offline` only with a paired Floodgate key | +| `ssh_port` | `22` | SSH port used by firewall + sshd | + +## Operations + +After the playbook has run, manage the servers as `mcadmin`: + +```bash +sudo machinectl shell mcadmin@ -- systemctl --user status minecraft-server geyser +sudo machinectl shell mcadmin@ -- systemctl --user restart minecraft-server +sudo machinectl shell mcadmin@ -- podman ps # container list +sudo machinectl shell mcadmin@ -- journalctl --user-unit minecraft-server -f +``` + +## References + +- [Ansible Documentation](https://docs.ansible.com/) +- [Fedora Project](https://fedoraproject.org/) +- [Podman](https://podman.io/) +- [Podman Quadlet (systemd.unit) man page](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) +- [Rootless Podman tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) +- [itzg/docker-minecraft-server](https://github.com/itzg/docker-minecraft-server) +- [GeyserMC β€” Bedrock ↔ Java bridge](https://geysermc.dev/) +- [GeyserMC/Geyser](https://github.com/GeyserMC/Geyser) +- [Geyser docker image](https://github.com/GeyserMC/geyser-docker) +- [Firewalld](https://firewalld.org/) +- [fail2ban](https://github.com/fail2ban/fail2ban) +- [SELinux quick docs (Fedora)](https://docs.fedoraproject.org/en-US/quick-docs/selinux-getting-started/) + +## License + +[MIT](./LICENSE) \ No newline at end of file diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..80b1a0f --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,10 @@ +[defaults] +inventory = inventory.yml +roles_path = roles +retry_files_enabled = False +interpreter_python = auto_silent +stdout_callback = yaml +gathering = smart + +[ssh_connection] +pipelining = True \ No newline at end of file diff --git a/group_vars/all.yml b/group_vars/all.yml new file mode 100644 index 0000000..5b56629 --- /dev/null +++ b/group_vars/all.yml @@ -0,0 +1,31 @@ +--- +# --------------------------------------------------------------------------- +# Variables shared by every host in the inventory. +# Role defaults live in roles//defaults/main.yml and are overridden here. +# --------------------------------------------------------------------------- + +# Administrative non-root user created by the auth role. +# This is the account the Minecraft containers run under (rootless Podman). +admin_user: mcadmin + +# SSH public key installed for {{ admin_user }}. MUST be set before first run +# or you will lock yourself out once ssh_harden disables password auth. +# Put your real key in host_vars/external-apps.yml. +admin_user_ssh_key: "" + +# SSH daemon port (firewall + ssh_harden will both use this). +ssh_port: 22 + +# Minecraft Java server port exposed to the internet. +minecraft_port: 25565 +minecraft_udp: false + +# Wher the Minecraft world/config data lives on the host. +minecraft_data_dir: /srv/minecraft + +# Geyser - Bedrock protocol bridge. Opens the Bedrock UDP port in the +# firewall (roles/firewall) and deploys the geyser.container Quadlet unit +# (roles/minecraft) next to the Java server. +enable_geyser: true +# Bedrock UDP port Geyser publishes (host + container, must match). +geyser_port: 19132 \ No newline at end of file diff --git a/harden_fedora_minecraft.yml b/harden_fedora_minecraft.yml new file mode 100644 index 0000000..96542c4 --- /dev/null +++ b/harden_fedora_minecraft.yml @@ -0,0 +1,12 @@ +--- +- name: Harden Fedora and deploy Minecraft server + hosts: external-apps + become: true + roles: + - packages + - firewall + - selinux + - auth + - ssh_harden + - podman + - minecraft diff --git a/host_vars/external-apps.yml b/host_vars/external-apps.yml new file mode 100644 index 0000000..c231b52 --- /dev/null +++ b/host_vars/external-apps.yml @@ -0,0 +1,32 @@ +--- +# Host-specific variables for the external-apps server. +# --------------------------------------------------------------------------- +# 1. Set ansible_host to the real public IP or DNS name. +# 2. Put your real SSH public key in admin_user_ssh_key. +# 3. Adjust Minecraft / Geyser settings as needed. +# --------------------------------------------------------------------------- + +# The server's real address (overrides the placeholder in inventory.yml). +ansible_host: 203.0.113.10 + +# Your SSH public key. This is what lets you log in after the playbook +# disables password authentication. Replace the placeholder below. +admin_user_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... your-public-key-here" + +# Minecraft server tuning (see roles/minecraft/defaults/main.yml for all options). +minecraft_memory: 2G # RAM allocated to the JVM +minecraft_version: LATEST # or a specific version, e.g. "1.21.1" +minecraft_server_type: PAPER # PAPER | VANILLA | FABRIC | FORGE | ... +minecraft_difficulty: normal +minecraft_max_players: "20" +minecraft_view_distance: "10" +minecraft_motd: "Hardened Fedora 42 Minecraft Server" + +# Comma-separated list of Minecraft usernames allowed to join. +# Empty string means the whitelist is disabled. +minecraft_whitelist: "" + +# Geyser - Bedrock protocol bridge (bedrock clients join the Java world). +# enable_geyser: true # set to false to skip the Geyser container +# geyser_port: 19132 # Bedrock UDP port (firewall + container) +# geyser_auth_type: online # online | offline (offline requires Floodgate) \ No newline at end of file diff --git a/inventory.yml b/inventory.yml new file mode 100644 index 0000000..ba8d069 --- /dev/null +++ b/inventory.yml @@ -0,0 +1,12 @@ +--- +all: + children: + minecraft_servers: + hosts: + external-apps: + # Replace with the server's real IP address or DNS name. + ansible_host: 203.0.113.10 + # Initial connection user (must have sudo on first run). + # After the playbook creates the admin user, switch to that user. + ansible_user: root + ansible_become: true \ No newline at end of file diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..9aed154 --- /dev/null +++ b/requirements.yml @@ -0,0 +1,3 @@ +--- +collections: + - ansible.posix \ No newline at end of file diff --git a/roles/auth/defaults/main.yml b/roles/auth/defaults/main.yml new file mode 100644 index 0000000..ce36b47 --- /dev/null +++ b/roles/auth/defaults/main.yml @@ -0,0 +1,14 @@ +--- +admin_user: mcadmin +admin_user_ssh_key: "" +admin_user_password_hash: "" # empty / unset keeps the account password-locked + +auth_minlen: 14 +auth_dcredit: -1 +auth_ucredit: -1 +auth_lcredit: -1 +auth_ocredit: -1 +auth_password_max_days: 90 +auth_password_min_days: 7 +auth_password_warn_age: 7 +auth_lock_root_password: true \ No newline at end of file diff --git a/roles/auth/tasks/main.yml b/roles/auth/tasks/main.yml new file mode 100644 index 0000000..d792c8f --- /dev/null +++ b/roles/auth/tasks/main.yml @@ -0,0 +1,70 @@ +--- +# Create the administrative user, install its SSH key, enforce password +# policy, and lock the root account. + +- name: Create the administrative user + ansible.builtin.user: + name: "{{ admin_user }}" + groups: wheel + append: true + create_home: true + home: "/home/{{ admin_user }}" + shell: /bin/bash + comment: "Minecraft server administrator" + password: "{{ admin_user_password_hash | default('!') }}" + password_expire_max: "{{ auth_password_max_days }}" + password_expire_min: "{{ auth_password_min_days }}" + state: present + +- name: Install the admin user's SSH public key + ansible.builtin.authorized_key: + user: "{{ admin_user }}" + state: present + key: "{{ admin_user_ssh_key }}" + when: admin_user_ssh_key | length > 0 + +- name: Allow the admin user passwordless sudo + ansible.builtin.copy: + content: "{{ admin_user }} ALL=(ALL) NOPASSWD: ALL\n" + dest: "/etc/sudoers.d/{{ admin_user }}" + owner: root + group: root + mode: "0440" + validate: /usr/sbin/visudo -cf %s + +- name: Enforce password complexity settings + ansible.builtin.lineinfile: + path: /etc/security/pwquality.conf + regexp: '^#?\s*{{ item.key }}\s*=' + line: "{{ item.key }} = {{ item.value }}" + loop: + - { key: minlen, value: "{{ auth_minlen }}" } + - { key: dcredit, value: "{{ auth_dcredit }}" } + - { key: ucredit, value: "{{ auth_ucredit }}" } + - { key: lcredit, value: "{{ auth_lcredit }}" } + - { key: ocredit, value: "{{ auth_ocredit }}" } + +- name: Enforce global password aging defaults + ansible.builtin.lineinfile: + path: /etc/login.defs + regexp: '^PASS_MAX_DAYS\s+' + line: "PASS_MAX_DAYS {{ auth_password_max_days }}" + +- name: Enforce minimum days between password changes + ansible.builtin.lineinfile: + path: /etc/login.defs + regexp: '^PASS_MIN_DAYS\s+' + line: "PASS_MIN_DAYS {{ auth_password_min_days }}" + +- name: Enforce password change warning + ansible.builtin.lineinfile: + path: /etc/login.defs + regexp: '^PASS_WARN_AGE\s+' + line: "PASS_WARN_AGE {{ auth_password_warn_age }}" + +- name: Lock the root account's password + ansible.builtin.user: + name: root + password: '!' + state: present + when: auth_lock_root_password | bool \ No newline at end of file diff --git a/roles/firewall/defaults/main.yml b/roles/firewall/defaults/main.yml new file mode 100644 index 0000000..d34415e --- /dev/null +++ b/roles/firewall/defaults/main.yml @@ -0,0 +1,6 @@ +--- +firewall_zone: public +firewall_zone_target: DROP +firewall_allow_ping: true +enable_geyser: true +geyser_port: 19132 \ No newline at end of file diff --git a/roles/firewall/tasks/main.yml b/roles/firewall/tasks/main.yml new file mode 100644 index 0000000..11f297a --- /dev/null +++ b/roles/firewall/tasks/main.yml @@ -0,0 +1,68 @@ +--- +# Firewall policy: default DROP, only SSH and the Minecraft port are exposed. + +- name: Ensure firewalld is running and enabled + ansible.builtin.systemd: + name: firewalld + enabled: true + state: started + +- name: Set the default zone target to DROP + ansible.posix.firewalld: + zone: "{{ firewall_zone }}" + target: "{{ firewall_zone_target }}" + permanent: true + immediate: true + state: present + +- name: Allow SSH service + ansible.posix.firewalld: + zone: "{{ firewall_zone }}" + service: ssh + permanent: true + immediate: true + state: enabled + +- name: Allow custom SSH port + ansible.posix.firewalld: + zone: "{{ firewall_zone }}" + port: "{{ ssh_port }}/tcp" + permanent: true + immediate: true + state: enabled + when: ssh_port != 22 + +- name: Allow Minecraft server port (TCP) + ansible.posix.firewalld: + zone: "{{ firewall_zone }}" + port: "{{ minecraft_port }}/tcp" + permanent: true + immediate: true + state: enabled + +- name: Allow Minecraft server port (UDP) + ansible.posix.firewalld: + zone: "{{ firewall_zone }}" + port: "{{ minecraft_port }}/udp" + permanent: true + immediate: true + state: enabled + when: minecraft_udp | bool + +- name: Allow Geyser Bedrock port (UDP) + ansible.posix.firewalld: + zone: "{{ firewall_zone }}" + port: "{{ geyser_port }}/udp" + permanent: true + immediate: true + state: enabled + when: enable_geyser | bool + +- name: Allow ICMP echo (ping) for basic connectivity checks + ansible.posix.firewalld: + zone: "{{ firewall_zone }}" + icmp_block_inversion: true + permanent: true + immediate: true + state: present + when: firewall_allow_ping | bool \ No newline at end of file diff --git a/roles/minecraft/defaults/main.yml b/roles/minecraft/defaults/main.yml new file mode 100644 index 0000000..cab397d --- /dev/null +++ b/roles/minecraft/defaults/main.yml @@ -0,0 +1,31 @@ +--- +minecraft_data_dir: /srv/minecraft +minecraft_image: docker.io/itzg/minecraft-server:latest +minecraft_server_type: PAPER +minecraft_version: LATEST +minecraft_memory: 2G +minecraft_difficulty: normal +minecraft_motd: "Hardened Fedora 42 Minecraft Server" +minecraft_max_players: "20" +minecraft_view_distance: "10" +minecraft_online_mode: "TRUE" +minecraft_whitelist: "" +# Port the Java server listens on INSIDE the container (host port +# publish is controlled by minecraft_port / minecraft_udp). +minecraft_container_port: 25565 +minecraft_prepull: true + +# Geyser - Bedrock protocol bridge (geysermc/geyser). Runs in the same pod +# as the Java server and publishes the Bedrock UDP port so Bedrock clients +# can join the Java world. +enable_geyser: true +geyser_image: docker.io/geysermc/geyser:latest +# Bedrock UDP port published on the host AND listened on by Geyser inside +# the container (pod-level publish, must match the firewall rule). +geyser_port: 19132 +# Inside the pod all containers share localhost, so Geyser talks to the Java +# server over 127.0.0.1:{{ minecraft_container_port }}. +geyser_remote_address: 127.0.0.1 +# online matches the Java server's ONLINE_MODE. Set to "offline" only if you +# also run Floodgate and pair the servers with a shared key. +geyser_auth_type: online \ No newline at end of file diff --git a/roles/minecraft/tasks/main.yml b/roles/minecraft/tasks/main.yml new file mode 100644 index 0000000..70e0840 --- /dev/null +++ b/roles/minecraft/tasks/main.yml @@ -0,0 +1,101 @@ +--- +# Minecraft server deployment: a Quadlet pod running the Java server +# (itzg/minecraft-server) and, when enabled, the Geyser bridge container +# (geysermc/geyser) so Bedrock clients can join. Everything runs rootless +# under the admin user. Containers share the pod's network namespace, so +# Geyser reaches the Java server at 127.0.0.1. + +- name: Build the list of data directories + ansible.builtin.set_fact: + minecraft_data_dirs: >- + {{ [minecraft_data_dir, minecraft_data_dir + '/data', minecraft_data_dir + '/backups'] + + ([minecraft_data_dir + '/geyser'] if enable_geyser | bool else []) }} + +- name: Create the Minecraft data directory tree + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0750" + loop: "{{ minecraft_data_dirs }}" + +- name: Restore SELinux context on the data directory + ansible.builtin.command: restorecon -Rv {{ minecraft_data_dir }} + changed_when: false + +- name: Create the Quadlet systemd unit directory + ansible.builtin.file: + path: "/home/{{ admin_user }}/.config/containers/systemd" + state: directory + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0700" + +- name: Deploy the Minecraft pod unit + ansible.builtin.template: + src: minecraft.pod.j2 + dest: "/home/{{ admin_user }}/.config/containers/systemd/minecraft.pod" + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0644" + +- name: Deploy the Minecraft Java server unit + ansible.builtin.template: + src: minecraft-server.container.j2 + dest: "/home/{{ admin_user }}/.config/containers/systemd/minecraft-server.container" + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0644" + +- name: Deploy the Geyser config file + ansible.builtin.template: + src: geyser-config.yml.j2 + dest: "{{ minecraft_data_dir }}/geyser/config.yml" + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0640" + when: enable_geyser | bool + +- name: Deploy the Geyser bridge unit + ansible.builtin.template: + src: geyser.container.j2 + dest: "/home/{{ admin_user }}/.config/containers/systemd/geyser.container" + owner: "{{ admin_user }}" + group: "{{ admin_user }}" + mode: "0644" + when: enable_geyser | bool + +- name: Build the list of images to pull + ansible.builtin.set_fact: + minecraft_images: "{{ [minecraft_image] + ([geyser_image] if enable_geyser | bool else []) }}" + when: minecraft_prepull | bool + +- name: Check if container images are already present + ansible.builtin.command: machinectl shell {{ admin_user }}@ -- podman image exists {{ item }} + loop: "{{ minecraft_images }}" + register: images_exist + changed_when: false + failed_when: false + when: minecraft_prepull | bool + +- name: Pull missing container images + ansible.builtin.command: machinectl shell {{ admin_user }}@ -- podman pull {{ item.item }} + loop: "{{ images_exist.results }}" + when: + - minecraft_prepull | bool + - item.rc != 0 + +- name: Build the list of user services to enable + ansible.builtin.set_fact: + minecraft_units: "{{ ['minecraft-pod.service', 'minecraft-server.service'] + (['geyser.service'] if enable_geyser | bool else []) }}" + +- name: Reload the user systemd daemon + ansible.builtin.command: machinectl shell {{ admin_user }}@ -- systemctl --user daemon-reload + changed_when: false + +- name: Enable and start the Minecraft services + ansible.builtin.command: machinectl shell {{ admin_user }}@ -- systemctl --user enable --now {{ item }} + loop: "{{ minecraft_units }}" + register: mc_services + changed_when: false \ No newline at end of file diff --git a/roles/minecraft/templates/geyser-config.yml.j2 b/roles/minecraft/templates/geyser-config.yml.j2 new file mode 100644 index 0000000..d2eb9ef --- /dev/null +++ b/roles/minecraft/templates/geyser-config.yml.j2 @@ -0,0 +1,17 @@ +# Managed by Ansible - minecraft role (Geyser bridge). +# Minimal config: Geyser merges these keys over its built-in defaults. +# The Java server shares the pod's network namespace, so it is reachable +# at 127.0.0.1:{{ minecraft_container_port }}. + +bedrock: + address: 0.0.0.0 + port: {{ geyser_port }} + clone-remote-port: false + enable-proxy-protocol: false + +remote: + address: {{ geyser_remote_address }} + port: {{ minecraft_container_port }} + auth-type: {{ geyser_auth_type }} + allow-passthrough-motd: true + allow-passthrough-player-counts: true \ No newline at end of file diff --git a/roles/minecraft/templates/geyser.container.j2 b/roles/minecraft/templates/geyser.container.j2 new file mode 100644 index 0000000..dd81486 --- /dev/null +++ b/roles/minecraft/templates/geyser.container.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=Geyser Bedrock bridge container ({{ geyser_image }}) +Wants=network-online.target +After=network-online.target + +[Container] +Label=app=geyser +ContainerName=geyser +Pod=minecraft.pod +Image={{ geyser_image }} +Volume={{ minecraft_data_dir }}/geyser:/geyser:Z + +[Service] +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/roles/minecraft/templates/minecraft-server.container.j2 b/roles/minecraft/templates/minecraft-server.container.j2 new file mode 100644 index 0000000..5216474 --- /dev/null +++ b/roles/minecraft/templates/minecraft-server.container.j2 @@ -0,0 +1,33 @@ +[Unit] +Description=Minecraft Java server container ({{ minecraft_image }}) +Wants=network-online.target +After=network-online.target + +[Container] +Label=app=minecraft-server +ContainerName=minecraft-server +Pod=minecraft.pod +Image={{ minecraft_image }} +Environment=EULA=TRUE +Environment=TYPE={{ minecraft_server_type }} +Environment=VERSION={{ minecraft_version }} +Environment=MEMORY={{ minecraft_memory }} +Environment=DIFFICULTY={{ minecraft_difficulty }} +Environment=MOTD={{ minecraft_motd }} +Environment=MAX_PLAYERS={{ minecraft_max_players }} +Environment=VIEW_DISTANCE={{ minecraft_view_distance }} +Environment=ONLINE_MODE={{ minecraft_online_mode }} +Environment=OVERRIDE_SERVER_PROPERTIES=TRUE +{% if minecraft_whitelist | length > 0 %} +Environment=ENABLE_WHITELIST=TRUE +Environment=WHITELIST={{ minecraft_whitelist }} +{% endif %} +Volume={{ minecraft_data_dir }}/data:/data:Z +Volume={{ minecraft_data_dir }}/backups:/backups:Z + +[Service] +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/roles/minecraft/templates/minecraft.pod.j2 b/roles/minecraft/templates/minecraft.pod.j2 new file mode 100644 index 0000000..eefdcd0 --- /dev/null +++ b/roles/minecraft/templates/minecraft.pod.j2 @@ -0,0 +1,14 @@ +[Unit] +Description=Minecraft pod (Java server + Geyser bridge) + +[Pod] +PublishPort={{ minecraft_port }}:25565/tcp +{% if minecraft_udp | bool %} +PublishPort={{ minecraft_port }}:25565/udp +{% endif %} +{% if enable_geyser | bool %} +PublishPort={{ geyser_port }}:{{ geyser_port }}/udp +{% endif %} + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/roles/packages/defaults/main.yml b/roles/packages/defaults/main.yml new file mode 100644 index 0000000..aafcb8b --- /dev/null +++ b/roles/packages/defaults/main.yml @@ -0,0 +1,30 @@ +--- +base_packages: + - wget + - curl + - tar + - unzip + - git + - vim-enhanced + - htop + - tmux + - firewalld + - fail2ban + - dnf-automatic + - policycoreutils-python-utils + - python3-pip + +podman_packages: + - container-tools + - podman + - podman-docker + - podman-compose + - fuse-overlayfs + - slirp4netns + - netavark + +qemu_packages: + - qemu-kvm-core + +install_qemu: false +enable_dnf_automatic: true \ No newline at end of file diff --git a/roles/packages/tasks/main.yml b/roles/packages/tasks/main.yml new file mode 100644 index 0000000..59c9c88 --- /dev/null +++ b/roles/packages/tasks/main.yml @@ -0,0 +1,45 @@ +--- +# Install base system packages, Podman tooling, and keep everything patched. + +- name: Install common system packages + ansible.builtin.dnf: + name: "{{ base_packages }}" + state: present + +- name: Install Podman container tooling + ansible.builtin.dnf: + name: "{{ podman_packages }}" + state: present + +- name: Install QEMU packages (optional - required for building images that need emulation) + ansible.builtin.dnf: + name: "{{ qemu_packages }}" + state: present + when: install_qemu | bool + +- name: Apply all available package updates + ansible.builtin.dnf: + name: "*" + state: latest + update_only: true + +- name: Enable automatic security updates + ansible.builtin.systemd: + name: dnf-automatic.timer + enabled: true + state: started + when: enable_dnf_automatic | bool + +- name: Configure dnf-automatic to apply updates + ansible.builtin.ini_file: + path: /etc/dnf/automatic.conf + section: commands + option: apply_updates + value: "yes" + when: enable_dnf_automatic | bool + +- name: Install container SELinux policy support + ansible.builtin.dnf: + name: container-selinux + state: present + when: ansible_selinux is defined and ansible_selinux.status == "enabled" \ No newline at end of file diff --git a/roles/podman/defaults/main.yml b/roles/podman/defaults/main.yml new file mode 100644 index 0000000..8e56e9d --- /dev/null +++ b/roles/podman/defaults/main.yml @@ -0,0 +1,8 @@ +--- +enable_podman_socket: true +podman_enable_linger: true +podman_manage_subuid: true +podman_subuid_start: 200000 +podman_manage_subgid: true +podman_subgid_start: 200000 +podman_storage_driver: overlay \ No newline at end of file diff --git a/roles/podman/tasks/main.yml b/roles/podman/tasks/main.yml new file mode 100644 index 0000000..2ac1e0e --- /dev/null +++ b/roles/podman/tasks/main.yml @@ -0,0 +1,62 @@ +--- +# Podman runtime preparation: rootless container support for the admin user. + +- name: Enable Podman socket activation + ansible.builtin.systemd: + name: podman.socket + enabled: true + state: started + when: enable_podman_socket | bool + +- name: Enable user lingering for rootless containers + ansible.builtin.command: loginctl enable-linger {{ admin_user }} + changed_when: false + when: podman_enable_linger | bool + +- name: Ensure subuid range for the admin user + ansible.builtin.lineinfile: + path: /etc/subuid + regexp: '^{{ admin_user }}:' + line: "{{ admin_user }}:{{ podman_subuid_start }}:65536" + when: podman_manage_subuid | bool + +- name: Ensure subgid range for the admin user + ansible.builtin.lineinfile: + path: /etc/subgid + regexp: '^{{ admin_user }}:' + line: "{{ admin_user }}:{{ podman_subgid_start }}:65536" + when: podman_manage_subgid | bool + +- name: Persist kernel modules for container networking + ansible.builtin.copy: + dest: /etc/modules-load.d/containers.conf + owner: root + group: root + mode: "0644" + content: "overlay\nbr_netfilter\n" + +- name: Load overlay kernel module + ansible.builtin.command: modprobe overlay + changed_when: false + +- name: Load br_netfilter kernel module + ansible.builtin.command: modprobe br_netfilter + changed_when: false + +- name: Enable IPv4 forwarding persistently + ansible.builtin.lineinfile: + path: /etc/sysctl.d/99-podman.conf + create: true + regexp: '^net\.ipv4\.ip_forward' + line: "net.ipv4.ip_forward = 1" + mode: "0644" + +- name: Apply IPv4 forwarding + ansible.builtin.command: sysctl -w net.ipv4.ip_forward=1 + changed_when: false + +- name: Configure Podman storage driver + ansible.builtin.lineinfile: + path: /etc/containers/storage.conf + regexp: '^driver\s*=' + line: 'driver = "{{ podman_storage_driver }}"' \ No newline at end of file diff --git a/roles/selinux/defaults/main.yml b/roles/selinux/defaults/main.yml new file mode 100644 index 0000000..7b7a326 --- /dev/null +++ b/roles/selinux/defaults/main.yml @@ -0,0 +1,2 @@ +--- +selinux_set_minecraft_fcontext: true \ No newline at end of file diff --git a/roles/selinux/tasks/main.yml b/roles/selinux/tasks/main.yml new file mode 100644 index 0000000..6583cfd --- /dev/null +++ b/roles/selinux/tasks/main.yml @@ -0,0 +1,22 @@ +--- +# SELinux hardening: enforcing mode plus correct labels for container data. + +- name: Enforce SELinux (targeted policy) + ansible.builtin.selinux: + policy: targeted + state: enforcing + +- name: Ensure semanage is available + ansible.builtin.dnf: + name: policycoreutils-python-utils + state: present + +- name: Add SELinux file context for the Minecraft data directory + ansible.builtin.command: + cmd: semanage fcontext -a -t container_file_t "{{ minecraft_data_dir }}(/.*)?" + register: selinux_fcontext + changed_when: selinux_fcontext.rc == 0 + failed_when: + - selinux_fcontext.rc != 0 + - "'already defined' not in (selinux_fcontext.stdout + selinux_fcontext.stderr)" + when: selinux_set_minecraft_fcontext | bool \ No newline at end of file diff --git a/roles/ssh_harden/defaults/main.yml b/roles/ssh_harden/defaults/main.yml new file mode 100644 index 0000000..6a9eda8 --- /dev/null +++ b/roles/ssh_harden/defaults/main.yml @@ -0,0 +1,7 @@ +--- +ssh_port: 22 +ssh_restrict_users: true +ssh_client_alive_interval: 300 +ssh_client_alive_count_max: 2 +ssh_max_auth_tries: 3 +enable_fail2ban: true \ No newline at end of file diff --git a/roles/ssh_harden/handlers/main.yml b/roles/ssh_harden/handlers/main.yml new file mode 100644 index 0000000..f087108 --- /dev/null +++ b/roles/ssh_harden/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: restart sshd + ansible.builtin.systemd: + name: sshd + state: restarted + +- name: restart fail2ban + ansible.builtin.systemd: + name: fail2ban + state: restarted \ No newline at end of file diff --git a/roles/ssh_harden/tasks/main.yml b/roles/ssh_harden/tasks/main.yml new file mode 100644 index 0000000..663cc61 --- /dev/null +++ b/roles/ssh_harden/tasks/main.yml @@ -0,0 +1,34 @@ +--- +# SSH hardening: key-only auth for the admin user via a drop-in config, +# plus fail2ban to rate-limit brute force attempts. + +- name: Deploy SSH server hardening drop-in + ansible.builtin.template: + src: sshd-hardening.conf.j2 + dest: /etc/ssh/sshd_config.d/99-ansible-hardening.conf + owner: root + group: root + mode: "0644" + notify: restart sshd + +- name: Validate sshd configuration before restart + ansible.builtin.command: /usr/sbin/sshd -t + register: sshd_test + changed_when: false + failed_when: sshd_test.rc != 0 + +- name: Deploy fail2ban SSH jail + ansible.builtin.template: + src: fail2ban-local.j2 + dest: /etc/fail2ban/jail.local + owner: root + group: root + mode: "0644" + notify: restart fail2ban + +- name: Ensure fail2ban is running and enabled + ansible.builtin.systemd: + name: fail2ban + enabled: true + state: started + when: enable_fail2ban | bool \ No newline at end of file diff --git a/roles/ssh_harden/templates/fail2ban-local.j2 b/roles/ssh_harden/templates/fail2ban-local.j2 new file mode 100644 index 0000000..aac1cf5 --- /dev/null +++ b/roles/ssh_harden/templates/fail2ban-local.j2 @@ -0,0 +1,11 @@ +# Managed by Ansible - ssh_harden role. + +[DEFAULT] +bantime = 1h +findtime = 10m +maxretry = 5 +ignoreip = 127.0.0.1/8 ::1 + +[sshd] +enabled = true +port = {{ ssh_port }} \ No newline at end of file diff --git a/roles/ssh_harden/templates/sshd-hardening.conf.j2 b/roles/ssh_harden/templates/sshd-hardening.conf.j2 new file mode 100644 index 0000000..9f7e135 --- /dev/null +++ b/roles/ssh_harden/templates/sshd-hardening.conf.j2 @@ -0,0 +1,18 @@ +# Managed by Ansible - ssh_harden role. +# Sorted last (99-) so this wins over other drop-ins and the main config. + +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +PermitEmptyPasswords no +UseDNS no +X11Forwarding no +PrintMotd no +MaxAuthTries {{ ssh_max_auth_tries }} +MaxSessions 4 +LoginGraceTime 30 +ClientAliveInterval {{ ssh_client_alive_interval }} +ClientAliveCountMax {{ ssh_client_alive_count_max }} +{% if ssh_port != 22 %}Port {{ ssh_port }}{% endif %} +{% if ssh_restrict_users %}AllowUsers {{ admin_user }}{% endif %} \ No newline at end of file