Files
stoat/README.md
2026-09-13 13:18:42 -06:00

157 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Stoat — Fedora 44 self-hosted chat deployment
Ansible automation that **hardens a Fedora 44 server**, installs **rootless Podman**, and deploys a self-hosted **[Stoat](https://stoat.chat)** instance (open-source community chat, formerly Revolt) using the official [`stoatchat/self-hosted`](https://github.com/stoatchat/self-hosted) stack.
**Target host:** `stoat-demo`
---
## Project layout
```
├── site.yml # main playbook — applies the role
├── inventory.ini # server inventory (stoat-demo)
├── group_vars/all/vars.yml # ★ all deployment variables live here
└── roles/stoat/
├── tasks/
│ ├── main.yml # imports, in order
│ ├── system.yml # dnf full update + base packages
│ ├── harden.yml # firewalld, sshd, fail2ban, dnf5-automatic, logrotate
│ ├── podman.yml # rootless podman, compose, service user, linger
│ └── stoat.yml # clone repo → generate config → systemd unit → start
├── handlers/main.yml
└── templates/
├── jail.local.j2 # fail2ban config
├── sshd_hardening.conf.j2 # sshd drop-in
└── podman-compose-stoat.service.j2 # systemd unit for the stack
```
---
## Before you run
1. **Set the real domain** in `group_vars/all/vars.yml`:
```yaml
stoat_domain: chat.yourdomain.com # ← A/AAAA record must point at the server
```
Let's Encrypt certificates are issued for this name, and every URL Stoat advertises (web client, API, WebSocket, LiveKit) uses it. Do **not** use the public `stoat.chat` domain for your own instance.
2. **Set the SSH user** in `inventory.ini`:
```ini
stoat-demo ansible_host=stoat-demo ansible_user=YOUR_SSH_USER
```
3. **SSH password login is intentionally enabled** (`sshd_password_authentication: yes`).
- Root login stays disabled — use your SSH user + `sudo`.
- Fail2Ban is the brute-force defense: `3` failed attempts → `1 h` ban (tunable).
- If you ever want key-only auth again, flip the var to `no`.
- Ansible connects with a password via `-k`:
```bash
ansible-playbook -i inventory.ini site.yml -k
```
---
## What the playbook does
| Stage | Actions |
|---|---|
| **System** | `dnf update` all packages; install `git`, `curl`, `openssl` |
| **Firewall** | firewalld default-deny; allow `ssh`, `http`, `https`, `7881/tcp`, `50000-50100/udp` (LiveKit voice) |
| **SSH** | drop-in `00-hardening.conf`: `PermitRootLogin no`, `PasswordAuthentication yes`; config validated with `sshd -t` before restart |
| **Fail2Ban** | `sshd` jail (3 retries → 1 h ban, incremental for repeat offenders) |
| **Auto-updates** | `dnf5-plugin-automatic`, security-only, `apply_updates = yes`, started timer |
| **Logs** | logrotate rule for fail2ban logs (daily, 7 rotations, compressed) |
| **Podman** | rootless engine + `podman-compose` + `podman-docker`; `net.ipv4.ip_unprivileged_port_start=80` so Caddy can bind 80/443; dedicated `stoat` service user with lingering enabled |
| **Stoat** | clone `stoatchat/self-hosted` to `/opt/stoat/self-hosted`; run `generate_config.sh <domain>` (answers: built-in Caddy, video enabled); start stack under `podman-compose-stoat.service` |
---
## Network / ports
| Port | Purpose |
|---|---|
| 22/tcp | SSH |
| 80/tcp, 443/tcp | Caddy reverse proxy (automatic Let's Encrypt TLS) |
| 7881/tcp | LiveKit WebRTC signaling |
| 50000–50100/udp | LiveKit voice/video media (cannot be proxied) |
---
## Variables reference (`group_vars/all/vars.yml`)
| Variable | Default | Meaning |
|---|---|---|
| `stoat_domain` | `chat.example.com` | **Required** — public domain for this instance |
| `stoat_install_dir` | `/opt/stoat/self-hosted` | where the stack lives on the server |
| `stoat_version` | `main` | git ref of `self-hosted` repo |
| `stoat_reverse_proxy` | `false` | `true` = move Caddy to `:8880`, bring your own front proxy/TLS |
| `stoat_video` | `true` | enable camera/screen sharing (LiveKit) |
| `stoat_service_user` | `stoat` | unprivileged user running rootless podman |
| `podman_unprivileged_port_start` | `80` | lowest port rootless containers may bind |
| `fail2ban_port` / `fail2ban_maxretry` / `fail2ban_bantime` | `22` / `3` / `3600` | sshd jail tuning |
| `sshd_permit_root_login` | `no` | root SSH access |
| `sshd_password_authentication` | `yes` | password login (fail2ban guards it) |
| `sshd_mfa_required` | `false` | only enable if a 2FA PAM module exists, else you lock yourself out |
| `firewall_services` / `firewall_ports` | ssh,http,https / 7881, UDP range | firewalld allow rules |
Override any variable per-run, without editing files:
```bash
ansible-playbook -i inventory.ini site.yml -k -e "stoat_domain=chat.example.org fail2ban_maxretry=5"
```
---
## After deployment
1. **First account = instance owner.** Open `https://<your-domain>` and register.
2. **Make it invite-only** (recommended for a private instance): in `/opt/stoat/self-hosted/Revolt.toml` set `invite_only = true`, then restart the API service.
3. **Back up `secrets.env`** — it holds the VAPID push keys, file-encryption key, and LiveKit credentials. Losing it means losing access to **all uploaded files** and breaking existing sessions. Copy it somewhere safe:
```bash
scp stoat-demo:/opt/stoat/self-hosted/secrets.env ~/backups/
```
4. **Caddy terminates TLS itself** — do not put Cloudflare proxy (orange cloud) in front of the domain; ACME needs direct access to the server (grey cloud / direct DNS only).
---
## Day-to-day operations
All commands run on the server, mostly as the `stoat` user (`sudo -u stoat` when needed):
```bash
# Stack status
systemctl status podman-compose-stoat
podman ps
# Logs (per service: api / events / web / livekit / database …)
sudo -u stoat podman logs -f api
# Restart the whole stack
sudo systemctl restart podman-compose-stoat
# Update Stoat to new images (run in /opt/stoat/self-hosted as stoat)
sudo -u stoat podman-compose pull
sudo -u stoat podman-compose up -d
# Fail2Ban status
sudo fail2ban-client status sshd
# Open firewall UI changes
sudo firewall-cmd --list-all
```
---
## Caveats & notes
- **First start pulls ~2 GB of images** and initializes MongoDB — allow a few minutes; the systemd unit tolerates up to 15 min (`TimeoutStartSec=900`).
- **Podman, not Docker**: the upstream stack is docker-compose based; this deployment runs it with `podman-compose` (rootless). It supports the compose features this stack needs (`depends_on: condition: service_healthy`, bind mounts, port ranges).
- **`generate_config.sh` is guarded** by `creates:` — it never overwrites existing `secrets.env`/`Revolt.toml` unintentionally. To regenerate config deliberately, run it manually with `--overwrite` as the `stoat` user (after backing up secrets).
- **Security posture**: rootless containers, non-root SSH user, fail2ban, firewalld default-deny, security-only auto-updates. (Aligned with the upstream guide's recommendations.)
- **Rootless bind mounts**: data persists in `/opt/stoat/self-hosted/data/` (mongodb, rabbitmq, minio, caddy).
- System hardening + Stoat are one role run; to re-apply only config changes, re-running `site.yml` is idempotent.