initial commit

This commit is contained in:
2026-09-13 13:13:15 -06:00
parent 3075996ad5
commit ace2aeba12
12 changed files with 418 additions and 189 deletions

View File

@@ -0,0 +1,10 @@
---
# Handlers for the stoat role
- name: Reload firewalld
ansible.builtin.command: firewall-cmd --reload
changed_when: false
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted

View File

@@ -0,0 +1,130 @@
---
# ------------------------------------------------------------
# Firewall (firewalld) - default deny, allow only what is needed
# ------------------------------------------------------------
- name: Start and enable firewalld
ansible.builtin.systemd:
name: firewalld
state: started
enabled: true
- name: Check which firewall services are already allowed
ansible.builtin.command: "firewall-cmd --query-service={{ item }}"
loop: "{{ firewall_services }}"
register: fw_service_check
changed_when: false
failed_when: fw_service_check.rc not in [0, 1]
- name: Allow firewall services (ssh, http, https)
ansible.builtin.command: "firewall-cmd --permanent --add-service={{ item.item }}"
loop: "{{ fw_service_check.results }}"
when: item.rc | int != 0
notify: Reload firewalld
- name: Check which firewall ports are already allowed
ansible.builtin.command: "firewall-cmd --query-port={{ item }}"
loop: "{{ firewall_ports }}"
register: fw_port_check
changed_when: false
failed_when: fw_port_check.rc not in [0, 1]
- name: Allow firewall ports (LiveKit voice/video)
ansible.builtin.command: "firewall-cmd --permanent --add-port={{ item.item }}"
loop: "{{ fw_port_check.results }}"
when: item.rc | int != 0
notify: Reload firewalld
# ------------------------------------------------------------
# SSH hardening (Drop-in config, validated before restart)
# ------------------------------------------------------------
- name: Deploy sshd hardening drop-in
ansible.builtin.template:
src: sshd_hardening.conf.j2
dest: /etc/ssh/sshd_config.d/00-hardening.conf
owner: root
group: root
mode: "0644"
register: sshd_conf
- name: Validate sshd configuration
ansible.builtin.command: sshd -t
changed_when: false
when: sshd_conf is changed
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted
when: sshd_conf is changed
# ------------------------------------------------------------
# Fail2Ban - brute force protection
# ------------------------------------------------------------
- name: Install fail2ban
ansible.builtin.dnf:
name: fail2ban
state: present
- name: Deploy fail2ban jail.local
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
owner: root
group: root
mode: "0644"
notify: Restart fail2ban
- name: Enable and start fail2ban
ansible.builtin.systemd:
name: fail2ban
state: started
enabled: true
# ------------------------------------------------------------
# Automatic security updates (dnf5-automatic)
# ------------------------------------------------------------
- name: Install dnf5-plugin-automatic
ansible.builtin.dnf:
name: dnf5-plugin-automatic
state: present
- name: Enable automatic installation of updates
ansible.builtin.lineinfile:
path: /etc/dnf/automatic.conf
insertafter: "^\\[commands\\]"
regexp: "^#?\\s*apply_updates\\s*="
line: "apply_updates = yes"
- name: Restrict automatic updates to security errata
ansible.builtin.lineinfile:
path: /etc/dnf/automatic.conf
insertafter: "^\\[commands\\]"
regexp: "^#?\\s*upgrade_type\\s*="
line: "upgrade_type = security"
- name: Enable and start dnf5-automatic.timer
ansible.builtin.systemd:
name: dnf5-automatic.timer
state: started
enabled: true
# ------------------------------------------------------------
# Log rotation for fail2ban logs
# ------------------------------------------------------------
- name: Add logrotate rule for fail2ban
ansible.builtin.copy:
dest: /etc/logrotate.d/fail2ban
owner: root
group: root
mode: "0644"
content: |
/var/log/fail2ban/*.log {
daily
rotate 7
compress
missingok
notifempty
postrotate
/usr/bin/fail2ban-client reload >/dev/null 2>&1 || true
endscript
}

View File

@@ -0,0 +1,17 @@
---
# stoat role - task order matters:
# system -> update + baseline packages (git needed by stoat tasks)
# harden -> firewall, ssh, fail2ban, automatic updates, logrotate
# podman -> rootless podman + service user
# stoat -> clone repo, generate config, systemd-managed compose stack
- name: System update and base packages
ansible.builtin.import_tasks: system.yml
- name: Harden the host
ansible.builtin.import_tasks: harden.yml
- name: Install rootless podman
ansible.builtin.import_tasks: podman.yml
- name: Deploy Stoat
ansible.builtin.import_tasks: stoat.yml

View File

@@ -0,0 +1,33 @@
---
# ------------------------------------------------------------
# Podman - rootless engine, compose support, unprivileged user
# ------------------------------------------------------------
- name: Install podman stack
ansible.builtin.dnf:
name: "{{ podman_packages }}"
state: present
# Rootless podman cannot bind host ports < 1024 without this.
# Caddy (Stoat's TLS reverse proxy) needs 80/443.
- name: Allow rootless containers to bind privileged ports
ansible.builtin.sysctl:
name: net.ipv4.ip_unprivileged_port_start
value: "{{ podman_unprivileged_port_start }}"
sysctl_file: /etc/sysctl.d/99-podman.conf
state: present
reload: true
- name: Create dedicated service user for rootless podman
ansible.builtin.user:
name: "{{ stoat_service_user }}"
system: true
shell: /bin/bash
create_home: true
comment: "Stoat rootless podman service user"
register: stoat_system_user
# Keeps /run/user/<uid> alive and lets rootless containers start
# on boot without an interactive login.
- name: Enable lingering for the service user
ansible.builtin.command: "loginctl enable-linger {{ stoat_service_user }}"
changed_when: false

View File

@@ -0,0 +1,75 @@
---
# ------------------------------------------------------------
# Stoat deployment - rootless podman-compose, managed by systemd
# ------------------------------------------------------------
- name: Clone Stoat self-hosted repository
ansible.builtin.git:
repo: "{{ stoat_repo }}"
dest: "{{ stoat_install_dir }}"
version: "{{ stoat_version }}"
force: true
become: true
become_user: "{{ stoat_service_user }}"
become_method: su
environment:
HOME: "/home/{{ stoat_service_user }}"
- name: Ensure generate_config.sh is executable
ansible.builtin.file:
path: "{{ stoat_install_dir }}/generate_config.sh"
mode: "0755"
become: true
become_user: "{{ stoat_service_user }}"
become_method: su
# The compose file bind-mounts these directories; create them up
# front so rootless podman does not fail on missing sources.
- name: Create data directories referenced by compose bind mounts
ansible.builtin.file:
path: "{{ stoat_install_dir }}/data/{{ item }}"
state: directory
owner: "{{ stoat_service_user }}"
group: "{{ stoat_service_user }}"
mode: "0755"
loop:
- db
- rabbit
- minio
- caddy-data
- caddy-config
# generate_config.sh is interactive; it reads two questions from
# stdin: reverse-proxy? [y/N] and camera/screen sharing? [Y/n].
# `creates` makes this idempotent - the script refuses to overwrite
# existing config without --overwrite, which is a destructive
# operation we never want Ansible to do by accident.
- name: Generate Stoat configuration and secrets
ansible.builtin.shell: >-
printf '{{ 'y' if stoat_reverse_proxy else 'n' }}\n{{ 'y' if stoat_video else 'n' }}\n'
| ./generate_config.sh {{ stoat_domain }}
args:
chdir: "{{ stoat_install_dir }}"
creates: "{{ stoat_install_dir }}/Revolt.toml"
become: true
become_user: "{{ stoat_service_user }}"
become_method: su
environment:
HOME: "/home/{{ stoat_service_user }}"
- name: Deploy podman-compose systemd unit
ansible.builtin.template:
src: podman-compose-stoat.service.j2
dest: /etc/systemd/system/podman-compose-stoat.service
owner: root
group: root
mode: "0644"
# First start pulls ~2 GB of images + initializes MongoDB, which can
# take several minutes. TimeoutStartSec covers this inside the unit.
- name: Enable and start the Stoat service (first pull may take minutes)
ansible.builtin.systemd:
name: podman-compose-stoat
state: started
enabled: true
daemon_reload: true
timeout: 900

View File

@@ -0,0 +1,14 @@
---
# ------------------------------------------------------------
# System: bring the host up to date and install baseline tools
# ------------------------------------------------------------
- name: Update all packages to latest versions
ansible.builtin.dnf:
name: "*"
state: latest
update_cache: true
- name: Install base packages
ansible.builtin.dnf:
name: "{{ base_packages }}"
state: present

View File

@@ -0,0 +1,46 @@
# --------------------------------------------------------------
# Fail2Ban jail.local - default settings for Fedora 44
# Values like {{ fail2ban_port }} come from the vars file
# (group_vars/all/vars.yml)
# --------------------------------------------------------------
[DEFAULT]
# Ban IPs after maxretry failures for bantime seconds
bantime = {{ fail2ban_bantime }}
maxretry = {{ fail2ban_maxretry }}
# Raise ban time incrementally for repeat offenders
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 24h
# --------------------------------------------------------------
# SSH jail - protects against brute-force login attempts
# --------------------------------------------------------------
[sshd]
enabled = true
port = {{ fail2ban_port }}
filter = sshd
logpath = /var/log/secure
backend = systemd
maxretry = {{ fail2ban_maxretry }}
bantime = {{ fail2ban_bantime }}
# --------------------------------------------------------------
# HTTP auth jail - Caddy (Stoat's proxy) logs are inside the
# podman container; enable only if you mount /var/log for it.
# --------------------------------------------------------------
#[http-auth]
#enabled = true
#port = http,https
#filter = http-auth
#logpath = /var/log/caddy/access.log
#maxretry = {{ fail2ban_maxretry }}
#bantime = {{ fail2ban_bantime }}
# --------------------------------------------------------------
# Add more jails below as needed, e.g.:
# [vsftpd] - FTP brute force
# [postfix] - mail smtp auth failures
# [dovecot] - IMAP/POP3 brute force
# Each jail needs: enabled = true, filter, logpath
# --------------------------------------------------------------

View File

@@ -0,0 +1,24 @@
[Unit]
Description=Stoat - self-hosted chat (podman-compose)
Documentation=https://github.com/stoatchat/self-hosted
After=network-online.target
Wants=network-online.target
[Service]
# oneshot + RemainAfterExit: the unit tracks compose state while
# the containers themselves run detached under rootless podman.
Type=oneshot
RemainAfterExit=yes
User={{ stoat_service_user }}
Group={{ stoat_service_user }}
WorkingDirectory={{ stoat_install_dir }}
# Rootless podman needs the per-user runtime directory; %U expands
# to the UID of `User=`. Lingering (enable-linger) keeps it alive.
Environment=XDG_RUNTIME_DIR=/run/user/%U
ExecStart=/usr/bin/podman-compose up -d --remove-orphans
ExecStop=/usr/bin/podman-compose down
# First start pulls ~2 GB of images; be generous.
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,8 @@
# Managed by Ansible - stoat role (sshd hardening drop-in)
# Values come from group_vars/all/vars.yml
PermitRootLogin {{ sshd_permit_root_login }}
PasswordAuthentication {{ sshd_password_authentication }}
{% if sshd_mfa_required %}
AuthenticationMethods publickey,keyboard-interactive:pam
{% endif %}