Files
stoat/README.md
2026-09-13 14:00:45 -06:00

175 lines
8.0 KiB
Markdown
Raw Permalink 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 **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 # podman, compose, SELinux container policy
│ └── stoat.yml # clone repo → generate config → systemd unit → start
├── handlers/main.yml
└── templates/
├── jail.local.j2 # fail2ban config
└── 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 is not touched by this playbook** — the server keeps its SSH configuration exactly as you have it. Fail2Ban still protects the login (`3` failed attempts → `1 h` ban, tunable). Ansible connects with your SSH password via `-k`:
```bash
ansible-playbook -i inventory.ini site.yml
```
---
## What the playbook does
| Stage | Actions |
|---|---|
| **System** | `dnf update` all packages; install `git`, `curl`, `openssl` |
| **Firewall** | installs + starts firewalld (native/minimal Fedora may not ship it); default-deny; allow `ssh`, `http`, `https`, `7881/tcp`, `50000-50100/udp` (LiveKit voice) |
| **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** | engine + `podman-compose` + `podman-docker`; runs as root (no unprivileged service user, no linger) |
| **SELinux** | keeps mode enforcing; installs `container-selinux` (container policy) + `policycoreutils-python-utils` / `setools-console` (`semanage`, `audit2why`); registers the deployment tree as `container_file_t` so rootful containers can use the bind mounts |
| **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) |
| `selinux_mode` | `enforcing` | SELinux mode the role enforces (config + setenforce) |
| `fail2ban_port` / `fail2ban_maxretry` / `fail2ban_bantime` | `22` / `3` / `3600` | sshd jail tuning |
| `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 as root:
```bash
# Stack status
systemctl status podman-compose-stoat
podman ps
# Logs (per service: api / events / web / livekit / database …)
podman logs -f api
# Restart the whole stack
systemctl restart podman-compose-stoat
# Update Stoat to new images (run in /opt/stoat/self-hosted)
podman-compose pull
podman-compose up -d
# Fail2Ban status
sudo fail2ban-client status sshd
# Open firewall UI changes
sudo firewall-cmd --list-all
```
---
## SELinux notes
Fedora 44 ships SELinux **enforcing**; the role pins that mode. Containers run as root under the `container_t` domain, so the role registers `/opt/stoat/self-hosted` as **`container_file_t`** (via `semanage fcontext` + `restorecon`) — that's what lets the rootful containers read/write the bind-mounted `data/` volumes and config files without per-volume `:Z` flags, and the registration is persistent (survives `restorecon`).
If a service misbehaves, check for denials before anything else:
```bash
sudo ausearch -m avc -ts recent | grep -i stoat # recent denials
# or find the exact rule suggestion:
sudo grep "denied" /var/log/audit/audit.log | tail -20 | audit2why
```
Common knobs if a denial shows up (only set what the denial actually requires):
```bash
# containers connecting to non-default ports on the host
sudo setsebool -P container_connect_any 1
# container needs to manage cgroups (systemd inside a container)
sudo setsebool -P container_manage_cgroup 1
# re-apply file contexts if you add bind mounts outside the
# deployment tree (the role only labels /opt/stoat/self-hosted):
sudo semanage fcontext -a -t container_file_t '/opt/stoat/moredata(/.*)?'
sudo restorecon -RF /opt/stoat/moredata
```
---
## 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` as root. 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` (after backing up secrets).
- **Security posture**: fail2ban, firewalld default-deny, security-only auto-updates, SELinux enforcing with `container_file_t` labels. SSH configuration is left exactly as you set it. (Aligned with the upstream guide's recommendations.)
- **Data persistence**: everything persists in `/opt/stoat/self-hosted/data/` (mongodb, rabbitmq, minio, caddy) — keep it out of the git clone/reset.
- System hardening + Stoat are one role run; to re-apply only config changes, re-running `site.yml` is idempotent.