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

88
group_vars/all/vars.yml Normal file
View File

@@ -0,0 +1,88 @@
# ============================================================
# Stoat.chat Fedora 44 - variables
# ============================================================
# Single source of truth for every tunable used by the stoat role.
# Override on the command line like:
# ansible-playbook -i inventory.ini site.yml -e stoat_domain=chat.example.com
# ============================================================
# ------------------------------------------------------------
# >>> REQUIRED <<<
# The domain that has an A/AAAA record pointing to this server.
# Let's Encrypt certificates (via the built-in Caddy) are issued
# for this name, and every URL Stoat advertises uses it.
# Example: stoat.example.com (NOT the public stoat.chat domain)
# ------------------------------------------------------------
stoat_domain: chat.example.com
# ------------------------------------------------------------
# Stoat application
# ------------------------------------------------------------
stoat_repo: https://github.com/stoatchat/self-hosted
stoat_install_dir: /opt/stoat/self-hosted
stoat_version: main
# Place Stoat behind an existing reverse proxy?
# false (recommended): the built-in Caddy terminates TLS on 80/443
# true: Caddy is moved to localhost:8880 and you must
# provide your own front proxy + certificate
stoat_reverse_proxy: false
# Enable camera / screen sharing via LiveKit?
# Requires firewall ports 7881/tcp and 50000-50100/udp (see below)
stoat_video: true
# ------------------------------------------------------------
# Podman (rootless)
# ------------------------------------------------------------
# Unprivileged system user that runs the rootless podman stack
stoat_service_user: stoat
podman_packages:
- podman
- podman-compose # compose file runner (supports service_healthy conditions)
- podman-docker # `docker` CLI compatibility, backed by podman
# Rootless containers cannot bind ports < 1024 by default.
# Caddy needs 80/443, so lower the unprivileged port floor.
podman_unprivileged_port_start: 80
# ------------------------------------------------------------
# Hardening - Fail2Ban
# ------------------------------------------------------------
fail2ban_port: "22"
fail2ban_maxretry: 3 # failed attempts before a ban
fail2ban_bantime: 3600 # ban length in seconds (1 hour)
# ------------------------------------------------------------
# Hardening - SSH
# ------------------------------------------------------------
sshd_permit_root_login: no
sshd_password_authentication: no
# Enable ONLY if a TOTP / WebUI-2FA PAM module is already
# configured in /etc/pam.d/sshd - otherwise you can lock yourself
# out. Leave false unless you know what you are doing.
sshd_mfa_required: false
# ------------------------------------------------------------
# Hardening - firewall (firewalld, default deny)
# ------------------------------------------------------------
# Services: ssh for administration, http/https for Caddy TLS
firewall_services:
- ssh
- http
- https
# Ports: LiveKit voice/video (signaling tcp + media udp range)
firewall_ports:
- "7881/tcp"
- "50000-50100/udp"
# ------------------------------------------------------------
# Base packages
# ------------------------------------------------------------
base_packages:
- git # clone the stoat repo
- curl
- openssl # generate_config.sh secret generation

View File

@@ -1,186 +0,0 @@
---
# --------------------------------------------------------------
# Ansible playbook: Harden a Fedora 44 server & install Fail2Ban
# --------------------------------------------------------------
# Requirements:
# - Ansible >= 2.14
# - A reachable Fedora 44 host with SSH access (password or key)
# - `sudo` rights for the control user (via become: true)
# --------------------------------------------------------------
- name: Harden Fedora 44 & install Fail2Ban
hosts: all
become: true # elevates to root for package/service ops
vars:
# ---- Tunables -------------------------------------------------
# Change these to match your environment
fail2ban_port: "22" # default SSH jail; can be "http","https",etc.
fail2ban_maxretry: 3 # how many attempts before ban
fail2ban_bantime: 3600 # seconds (1 h)
# -----------------------------------------------------------------
# Ensure we are on Fedora 44 (you can relax this if you wish)
fedora_release: "44"
tasks:
# --------------------------------------------------------------
# Verify OS version (informational, can be removed)
# --------------------------------------------------------------
- name: Gather os fact
ansible.builtin.debug:
var: ansible_distribution_version
- name: Ensure we are running Fedora {{ fedora_release }}
ansible.builtin.assert:
that:
- ansible_distribution == "Fedora"
- ansible_distribution_version == fedora_release
fail_msg: "This playbook targets Fedora {{ fedora_release }} only."
success_msg: "Running on the correct Fedora version."
# --------------------------------------------------------------
# Update / upgrade all packages
# --------------------------------------------------------------
- name: Update all packages to latest versions
ansible.builtin.dnf:
name: "*"
state: latest
update_cache: true
# --------------------------------------------------------------
# Install essential hardening packages
# --------------------------------------------------------------
- name: Install fail2ban, firewalld, and sudo
ansible.builtin.dnf:
name:
- fail2ban
- firewalld
- sudo
state: present
# --------------------------------------------------------------
# Start & enable firewalld (default-deny all, allow SSH)
# --------------------------------------------------------------
- name: Start and enable firewalld
ansible.builtin.systemd:
name: firewalld
state: started
enabled: true
- name: Allow SSH service through firewalld
ansible.builtin.command:
cmd: firewall-cmd --permanent --add-service=ssh
changed_when: false # idempotent check handled by firewall_cmd
- name: Reload firewalld to apply rules
ansible.builtin.command:
cmd: firewall-cmd --reload
changed_when: false
# --------------------------------------------------------------
# Harden SSH (disable root login, enforce key‑only auth)
# --------------------------------------------------------------
- name: Ensure SSH config directory exists
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
mode: "0755"
- name: Disable root login via SSH
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config.d/00-disable-root.conf
create: true
mode: "0644"
line: "PermitRootLogin no"
state: present
- name: Enforce password-auth disabled (key-only)
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config.d/00-key-only.conf
create: true
mode: "0644"
line: "PasswordAuthentication no"
state: present
- name: Require MFA (optional) — add if you use Duo, etc.
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config.d/00-mfa.conf
create: true
mode: "0644"
line: "AuthenticationMethods publickey,keyboard-interactive:pam"
state: present
- name: Restart sshd to apply changes
ansible.builtin.service:
name: sshd
state: restarted
# --------------------------------------------------------------
# Configure Fail2Ban
# --------------------------------------------------------------
- name: template fail2ban jail.local
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
mode: "0644"
notify: Restart fail2ban
- name: Ensure fail2ban is started & enabled
ansible.builtin.service:
name: fail2ban
state: started
enabled: true
# --------------------------------------------------------------
# (Optional) Install & configure unattended‑security updates
# --------------------------------------------------------------
- name: Install dnf-automatic (security-only)
ansible.builtin.dnf:
name: dnf-automatic
state: present
- name: Enable security-only automatic updates
ansible.builtin.lineinfile:
path: /etc/dnf/automatic.conf
regexp: "^update_type"
line: "update_type = security"
state: present
- name: Enable and start dnf-automatic.timer
ansible.builtin.systemd:
name: "@dnf-automatic.timer"
state: started
enabled: true
# --------------------------------------------------------------
# Log rotation for security‑relevant logs
# --------------------------------------------------------------
- name: Ensure rsyslog is running
ansible.builtin.service:
name: rsyslog
state: started
enabled: true
- name: Add logrotate rule for fail2ban logs
ansible.builtin.lineinfile:
path: /etc/logrotate.d/fail2ban
create: true
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
}
# --------------------------------------------------------------
# 9 Handlers
# --------------------------------------------------------------
handlers:
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted

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

@@ -1,6 +1,7 @@
# -------------------------------------------------------------- # --------------------------------------------------------------
# Fail2Ban jail.local - default settings for Fedora 44 # Fail2Ban jail.local - default settings for Fedora 44
# Values like {{ fail2ban_port }} come from the playbook vars # Values like {{ fail2ban_port }} come from the vars file
# (group_vars/all/vars.yml)
# -------------------------------------------------------------- # --------------------------------------------------------------
[DEFAULT] [DEFAULT]
@@ -25,13 +26,14 @@ maxretry = {{ fail2ban_maxretry }}
bantime = {{ fail2ban_bantime }} bantime = {{ fail2ban_bantime }}
# -------------------------------------------------------------- # --------------------------------------------------------------
# HTTP auth jail - uncomment if you run a web server # HTTP auth jail - Caddy (Stoat's proxy) logs are inside the
# podman container; enable only if you mount /var/log for it.
# -------------------------------------------------------------- # --------------------------------------------------------------
#[http-auth] #[http-auth]
#enabled = true #enabled = true
#port = http,https #port = http,https
#filter = http-auth #filter = http-auth
#logpath = /var/log/nginx/access.log #logpath = /var/log/caddy/access.log
#maxretry = {{ fail2ban_maxretry }} #maxretry = {{ fail2ban_maxretry }}
#bantime = {{ fail2ban_bantime }} #bantime = {{ fail2ban_bantime }}

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

14
site.yml Normal file
View File

@@ -0,0 +1,14 @@
---
# ============================================================
# Stoat.chat Fedora 44 server
# Harden the host, install rootless Podman, deploy Stoat.
#
# Usage:
# ansible-playbook -i inventory.ini site.yml
# ============================================================
- name: Harden Fedora 44 and deploy Stoat (podman)
hosts: servers
become: true
roles:
- stoat