initial opencode commit

This commit is contained in:
2026-09-12 14:31:27 -06:00
parent a2deef0f5a
commit 09a5891364
30 changed files with 898 additions and 0 deletions

View File

@@ -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"
}
}
}

21
LICENSE Normal file
View File

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

146
README.md Normal file
View File

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

10
ansible.cfg Normal file
View File

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

31
group_vars/all.yml Normal file
View File

@@ -0,0 +1,31 @@
---
# ---------------------------------------------------------------------------
# Variables shared by every host in the inventory.
# Role defaults live in roles/<role>/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

View File

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

View File

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

12
inventory.yml Normal file
View File

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

3
requirements.yml Normal file
View File

@@ -0,0 +1,3 @@
---
collections:
- ansible.posix

View File

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

70
roles/auth/tasks/main.yml Normal file
View File

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

View File

@@ -0,0 +1,6 @@
---
firewall_zone: public
firewall_zone_target: DROP
firewall_allow_ping: true
enable_geyser: true
geyser_port: 19132

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }}"'

View File

@@ -0,0 +1,2 @@
---
selinux_set_minecraft_fcontext: true

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
---
- name: restart sshd
ansible.builtin.systemd:
name: sshd
state: restarted
- name: restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted

View File

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

View File

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

View File

@@ -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 %}