diff --git a/group_vars/all/vars.yml b/group_vars/all/vars.yml new file mode 100644 index 0000000..a3d78ee --- /dev/null +++ b/group_vars/all/vars.yml @@ -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 \ No newline at end of file diff --git a/harden_fedora44.yml b/harden_fedora44.yml deleted file mode 100644 index 60b133c..0000000 --- a/harden_fedora44.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/roles/stoat/handlers/main.yml b/roles/stoat/handlers/main.yml new file mode 100644 index 0000000..1b72546 --- /dev/null +++ b/roles/stoat/handlers/main.yml @@ -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 \ No newline at end of file diff --git a/roles/stoat/tasks/harden.yml b/roles/stoat/tasks/harden.yml new file mode 100644 index 0000000..ce31bf8 --- /dev/null +++ b/roles/stoat/tasks/harden.yml @@ -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 + } \ No newline at end of file diff --git a/roles/stoat/tasks/main.yml b/roles/stoat/tasks/main.yml new file mode 100644 index 0000000..a8f18be --- /dev/null +++ b/roles/stoat/tasks/main.yml @@ -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 \ No newline at end of file diff --git a/roles/stoat/tasks/podman.yml b/roles/stoat/tasks/podman.yml new file mode 100644 index 0000000..935a925 --- /dev/null +++ b/roles/stoat/tasks/podman.yml @@ -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/ 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 \ No newline at end of file diff --git a/roles/stoat/tasks/stoat.yml b/roles/stoat/tasks/stoat.yml new file mode 100644 index 0000000..0094394 --- /dev/null +++ b/roles/stoat/tasks/stoat.yml @@ -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 \ No newline at end of file diff --git a/roles/stoat/tasks/system.yml b/roles/stoat/tasks/system.yml new file mode 100644 index 0000000..a19bdd2 --- /dev/null +++ b/roles/stoat/tasks/system.yml @@ -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 \ No newline at end of file diff --git a/jail.local.j2 b/roles/stoat/templates/jail.local.j2 similarity index 85% rename from jail.local.j2 rename to roles/stoat/templates/jail.local.j2 index 0259af8..3cd1160 100644 --- a/jail.local.j2 +++ b/roles/stoat/templates/jail.local.j2 @@ -1,6 +1,7 @@ # -------------------------------------------------------------- # 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] @@ -25,13 +26,14 @@ maxretry = {{ fail2ban_maxretry }} 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] #enabled = true #port = http,https #filter = http-auth -#logpath = /var/log/nginx/access.log +#logpath = /var/log/caddy/access.log #maxretry = {{ fail2ban_maxretry }} #bantime = {{ fail2ban_bantime }} diff --git a/roles/stoat/templates/podman-compose-stoat.service.j2 b/roles/stoat/templates/podman-compose-stoat.service.j2 new file mode 100644 index 0000000..992fd4c --- /dev/null +++ b/roles/stoat/templates/podman-compose-stoat.service.j2 @@ -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 \ No newline at end of file diff --git a/roles/stoat/templates/sshd_hardening.conf.j2 b/roles/stoat/templates/sshd_hardening.conf.j2 new file mode 100644 index 0000000..da43bc1 --- /dev/null +++ b/roles/stoat/templates/sshd_hardening.conf.j2 @@ -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 %} \ No newline at end of file diff --git a/site.yml b/site.yml new file mode 100644 index 0000000..587d3b8 --- /dev/null +++ b/site.yml @@ -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 \ No newline at end of file