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