From 9919eea1385898dad909492003e2b565a81b9de6 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 05:28:46 +0100 Subject: [PATCH] stoat: add the ex-Revolt stack as the closest Discord equivalent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen containers: MongoDB, Valkey, RabbitMQ, MinIO and eleven Stoat services. Servers, channels, roles and voice/video through LiveKit — the nearest thing in the catalogue to Discord itself, at the price of being much the heaviest app in it. Does not federate. The compose service keys are deliberately kept identical to upstream's (database, redis, api, autumn, ...) while container_name is prefixed stoat-. Compose registers both on the network, so upstream's internal defaults keep resolving and LibrePortal still gets the prefixed names its port, firewall and backup layers key on. Upstream's Caddy is kept as the internal path router and Traefik simply proxies to it, which is upstream's own supported behind-a-reverse-proxy mode — reimplementing eight path routes as Traefik labels would be a second copy to keep in sync for nothing. The install hook is a non-interactive port of generate_config.sh, and it never rewrites an existing secrets.env: REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded, so regenerating it would orphan the whole media store. LiveKit's UDP media range is published literally rather than through the port table, because the firewall rebuild emits /tcp rules only and a range declared there would produce a wrong rule rather than no rule. Voice falls back to TCP 7881 until the range is opened by hand; the post-install notice says so. Co-Authored-By: Claude Opus 5 --- containers/stoat/docker-compose.yml | 369 ++++++++++++++++++ containers/stoat/resources/Caddyfile | 70 ++++ .../stoat/scripts/stoat_install_hooks.sh | 226 +++++++++++ containers/stoat/stoat.config | 87 +++++ containers/stoat/stoat.svg | 1 + .../source/files/arrays/function_manifest.sh | 45 ++- 6 files changed, 796 insertions(+), 2 deletions(-) create mode 100644 containers/stoat/docker-compose.yml create mode 100644 containers/stoat/resources/Caddyfile create mode 100644 containers/stoat/scripts/stoat_install_hooks.sh create mode 100644 containers/stoat/stoat.config create mode 100644 containers/stoat/stoat.svg diff --git a/containers/stoat/docker-compose.yml b/containers/stoat/docker-compose.yml new file mode 100644 index 0000000..aa7e624 --- /dev/null +++ b/containers/stoat/docker-compose.yml @@ -0,0 +1,369 @@ +# Stoat (formerly Revolt) — the closest open-source equivalent to Discord's +# model of servers, channels, roles and voice. +# +# Layout note, because it looks inconsistent at first glance: the compose +# *service keys* below (database, redis, api, autumn, ...) are deliberately kept +# identical to upstream's compose.yml, while container_name is prefixed with +# stoat- so nothing collides with other LibrePortal apps. Compose registers both +# the service key and the container name on the network, so upstream's internal +# defaults — the S3 endpoint baked into the file server, MINIO_DOMAIN, the +# service names in Revolt.toml — keep resolving, and LibrePortal still gets the +# prefixed container names its port, firewall and backup layers key on. +# +# Upstream fronts the whole stack with Caddy doing path routing (/api, /ws, +# /autumn, ...). That is kept as-is and Traefik simply proxies to it, which is +# upstream's own supported "behind another reverse proxy" mode — reimplementing +# eight path routes as Traefik labels would be a second copy to keep in sync for +# no benefit. Caddy is given HOSTNAME=:80 by the install hook so it serves plain +# HTTP and never tries to obtain its own certificate. + +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + # MongoDB — primary datastore. + # + # Upstream's healthcheck is kept rather than the LibrePortal HEALTHCHECK_TAG: + # half the stack uses `depends_on: condition: service_healthy` against it, so + # disabling the healthcheck would deadlock the boot order. Same for rabbit. + database: + container_name: stoat-database + image: mongo:8.0 #LIBREPORTAL|STOAT_MONGO_VERSION_TAG|8.0 + restart: unless-stopped + volumes: + - ./data/db:/data/db + environment: + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet + interval: 10s + timeout: 10s + retries: 5 + start_period: 10s + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + + # Valkey — event message broker and KV store. + redis: + container_name: stoat-redis + image: valkey/valkey:9-alpine #LIBREPORTAL|STOAT_VALKEY_VERSION_TAG|9-alpine + restart: unless-stopped + environment: + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 + + # RabbitMQ — internal message broker (push notifications, voice events). + rabbit: + container_name: stoat-rabbit + image: rabbitmq:4-alpine #LIBREPORTAL|STOAT_RABBITMQ_VERSION_TAG|4-alpine + restart: unless-stopped + environment: + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - RABBITMQ_DEFAULT_USER=stoat + - RABBITMQ_DEFAULT_PASS=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 + volumes: + - ./data/rabbit:/var/lib/rabbitmq + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 10s + timeout: 10s + retries: 3 + start_period: 20s + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_3 #LIBREPORTAL|IP_TAG_3|IP_DATA_3 + + # MinIO — S3-compatible object storage for uploads and avatars. + # + # The bucket-name aliases are load-bearing: the file server addresses + # buckets virtual-host style (.minio), so without these the DNS + # lookup fails and every upload errors. + minio: + container_name: stoat-minio + image: minio/minio:latest #LIBREPORTAL|STOAT_MINIO_VERSION_TAG|latest + restart: unless-stopped + command: server /data + volumes: + - ./data/minio:/data + environment: + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - MINIO_ROOT_USER=stoatminio + - MINIO_ROOT_PASSWORD=PASSWORD_DATA_2 #LIBREPORTAL|PASSWORD_TAG_2|PASSWORD_DATA_2 + - MINIO_DOMAIN=minio + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_4 #LIBREPORTAL|IP_TAG_4|IP_DATA_4 + aliases: + - minio + - revolt-uploads.minio + # Legacy bucket names, kept because instances created before + # the consolidation still address them. + - attachments.minio + - avatars.minio + - backgrounds.minio + - icons.minio + - banners.minio + - emojis.minio + + # One-shot: creates the uploads bucket, then exits. Not a failure when you + # see it stopped. + createbuckets: + container_name: stoat-createbuckets + image: minio/mc:latest #LIBREPORTAL|STOAT_MINIO_MC_VERSION_TAG|latest + depends_on: + - minio + # Credentials come in through the environment rather than being written + # into the entrypoint: a #LIBREPORTAL annotation only substitutes on the + # line it sits on, and inside a folded block scalar it would end up as + # literal text in the command anyway. $$ escapes the dollar so compose + # leaves it for the container's shell instead of interpolating it here. + environment: + - MC_USER=stoatminio + - MC_PASS=PASSWORD_DATA_2 #LIBREPORTAL|PASSWORD_TAG_2|PASSWORD_DATA_2 + entrypoint: > + /bin/sh -c " + while ! /usr/bin/mc ready minio; do + /usr/bin/mc alias set minio http://minio:9000 $$MC_USER $$MC_PASS; + echo 'Waiting minio...' && sleep 1; + done; + /usr/bin/mc mb --ignore-existing minio/revolt-uploads; + exit 0; + " + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_5 #LIBREPORTAL|IP_TAG_5|IP_DATA_5 + + # Caddy — internal path router for the whole stack. This is the only service + # Traefik talks to, and the only one carrying a Traefik router. + caddy: #LIBREPORTAL|SERVICE_TAG_1|caddy + container_name: stoat-caddy + image: caddy:2-alpine #LIBREPORTAL|STOAT_CADDY_VERSION_TAG|2-alpine + restart: unless-stopped + env_file: .env.web + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./stoat.json:/stoat.json:ro + - ./data/caddy-data:/data + - ./data/caddy-config:/config + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + libreportal.backup.db: "mongo:stoat-database:data/db:" + # Uploads live in MinIO, not on a filesystem the file server owns, + # so the object store's own data dir is what has to be captured. + libreportal.backup.files: "stoat-minio:/data:data/minio" + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + # TRAEFIK_PORT_1_BEGIN + traefik.http.routers.stoat-caddy.entrypoints: web,websecure + traefik.http.routers.stoat-caddy.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1 + traefik.http.routers.stoat-caddy.tls: true + traefik.http.routers.stoat-caddy.tls.certresolver: production + traefik.http.services.stoat-caddy.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.stoat-caddy.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1 + # TRAEFIK_PORT_1_END + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_6 #LIBREPORTAL|IP_TAG_6|IP_DATA_6 + aliases: + - caddy + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + # API server. + api: + container_name: stoat-api + image: ghcr.io/stoatchat/api:v0.15.1 #LIBREPORTAL|STOAT_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + depends_on: + database: + condition: service_healthy + redis: + condition: service_started + rabbit: + condition: service_healthy + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_7 #LIBREPORTAL|IP_TAG_7|IP_DATA_7 + aliases: + - api + + # Websocket / events service. + events: + container_name: stoat-events + image: ghcr.io/stoatchat/events:v0.15.1 #LIBREPORTAL|STOAT_EVENTS_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + depends_on: + database: + condition: service_healthy + redis: + condition: service_started + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_8 #LIBREPORTAL|IP_TAG_8|IP_DATA_8 + aliases: + - events + + # Autumn — file server. + autumn: + container_name: stoat-autumn + image: ghcr.io/stoatchat/file-server:v0.15.1 #LIBREPORTAL|STOAT_AUTUMN_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + depends_on: + database: + condition: service_healthy + createbuckets: + condition: service_started + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_9 #LIBREPORTAL|IP_TAG_9|IP_DATA_9 + aliases: + - autumn + + # January — link metadata and image proxy. + january: + container_name: stoat-january + image: ghcr.io/stoatchat/proxy:v0.15.1 #LIBREPORTAL|STOAT_JANUARY_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_10 #LIBREPORTAL|IP_TAG_10|IP_DATA_10 + aliases: + - january + + # Gifbox — Tenor proxy for the GIF picker. Inert until a Tenor API key is + # added to secrets.env; see the upstream Guides.md. + gifbox: + container_name: stoat-gifbox + image: ghcr.io/stoatchat/gifbox:v0.15.1 #LIBREPORTAL|STOAT_GIFBOX_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_11 #LIBREPORTAL|IP_TAG_11|IP_DATA_11 + aliases: + - gifbox + + # Scheduled task daemon. + crond: + container_name: stoat-crond + image: ghcr.io/stoatchat/crond:v0.15.1 #LIBREPORTAL|STOAT_CROND_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + depends_on: + database: + condition: service_healthy + minio: + condition: service_started + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_12 #LIBREPORTAL|IP_TAG_12|IP_DATA_12 + + # Push notification daemon. + pushd: + container_name: stoat-pushd + image: ghcr.io/stoatchat/pushd:v0.15.1 #LIBREPORTAL|STOAT_PUSHD_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + depends_on: + database: + condition: service_healthy + redis: + condition: service_started + rabbit: + condition: service_healthy + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_13 #LIBREPORTAL|IP_TAG_13|IP_DATA_13 + + # Voice ingress daemon — receives LiveKit's webhooks. + voice-ingress: + container_name: stoat-voice-ingress + image: ghcr.io/stoatchat/voice-ingress:v0.15.1 #LIBREPORTAL|STOAT_VOICE_INGRESS_VERSION_TAG|v0.15.1 + restart: unless-stopped + env_file: secrets.env + depends_on: + database: + condition: service_healthy + rabbit: + condition: service_healthy + volumes: + - ./Revolt.toml:/Revolt.toml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_14 #LIBREPORTAL|IP_TAG_14|IP_DATA_14 + aliases: + - voice-ingress + + # LiveKit — the WebRTC SFU behind voice and video. + # + # Its media ports cannot go through Traefik: WebRTC is UDP, and Traefik is + # an HTTP proxy. The TCP fallback port is declared in stoat.config so the + # port and firewall layers manage it. The UDP range is published literally + # below because LibrePortal's port table stores one port per row and its + # firewall rebuild emits /tcp rules only — a range declared there would + # produce a wrong rule rather than no rule. Open it yourself if voice needs + # to work from outside the LAN: + # sudo ufw allow 50000:50100/udp + # Voice still falls back to TCP 7881 without it, at the cost of latency. + livekit: + container_name: stoat-livekit + image: ghcr.io/stoatchat/livekit-server:v1.9.13 #LIBREPORTAL|STOAT_LIVEKIT_VERSION_TAG|v1.9.13 + restart: unless-stopped + command: --config /etc/livekit.yml + depends_on: + redis: + condition: service_started + ports: + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + - "50000-50100:50000-50100/udp" + volumes: + - ./livekit.yml:/etc/livekit.yml:ro + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_15 #LIBREPORTAL|IP_TAG_15|IP_DATA_15 + aliases: + - livekit + + # The web client itself. Served by Caddy at /. + web: + container_name: stoat-web + image: ghcr.io/stoatchat/for-web:0c31cf0 #LIBREPORTAL|STOAT_WEB_VERSION_TAG|0c31cf0 + restart: unless-stopped + env_file: .env.web + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_16 #LIBREPORTAL|IP_TAG_16|IP_DATA_16 + aliases: + - web diff --git a/containers/stoat/resources/Caddyfile b/containers/stoat/resources/Caddyfile new file mode 100644 index 0000000..98ab4bd --- /dev/null +++ b/containers/stoat/resources/Caddyfile @@ -0,0 +1,70 @@ +# Internal path router for the Stoat stack, taken from upstream's self-hosted +# repository. Traefik terminates TLS and proxies here, so HOSTNAME is set to +# ":80" in .env.web and Caddy neither requests nor serves certificates. +# +# The path prefixes below are not arbitrary — the web client is built with +# VITE_API_URL=https:///api and friends, so these routes and the URLs in +# .env.web / Revolt.toml have to agree. + +{$HOSTNAME} { + route /.well-known/stoat { + uri strip_prefix /.well-known/stoat + header { + Access-Control-Allow-Origin * + } + file_server { + root /stoat.json + } + } + + route /api* { + uri strip_prefix /api + reverse_proxy http://api:14702 { + header_down Location "^/" "/api/" + } + } + + route /ws { + uri strip_prefix /ws + reverse_proxy http://events:14703 { + header_down Location "^/" "/ws/" + } + } + + route /autumn* { + uri strip_prefix /autumn + reverse_proxy http://autumn:14704 { + header_down Location "^/" "/autumn/" + } + } + + route /january* { + uri strip_prefix /january + reverse_proxy http://january:14705 { + header_down Location "^/" "/january/" + } + } + + route /gifbox* { + uri strip_prefix /gifbox + reverse_proxy http://gifbox:14706 { + header_down Location "^/" "/gifbox/" + } + } + + route /livekit* { + uri strip_prefix /livekit + reverse_proxy http://livekit:7880 { + header_down Location "^/" "/livekit/" + } + } + + route /ingress* { + uri strip_prefix /ingress + reverse_proxy http://voice-ingress:8500 { + header_down Location "^/" "/ingress/" + } + } + + reverse_proxy http://web:5000 +} diff --git a/containers/stoat/scripts/stoat_install_hooks.sh b/containers/stoat/scripts/stoat_install_hooks.sh new file mode 100644 index 0000000..7ad50d1 --- /dev/null +++ b/containers/stoat/scripts/stoat_install_hooks.sh @@ -0,0 +1,226 @@ +#!/bin/bash + +# Stoat install hooks. +# +# Upstream configures an instance with an interactive generate_config.sh that +# asks for a domain and writes five files. This is the non-interactive +# equivalent, driven by the domain LibrePortal already knows and writing into +# the app's install directory. +# +# The one rule that matters here: secrets.env is generated ONCE and never +# rewritten. REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded to +# the instance, so regenerating it on a reinstall would permanently orphan the +# entire media store — which is exactly the failure upstream's script warns +# about at length. + +stoat_install_pre() +{ + local app_name="$1" + if ! appInstallCheckRequirements "$app_name" "$CFG_STOAT_REQUIRES"; then + stoat=n + return 1 + fi +} + +# The public host, read back from the deployed compose once tag substitution has +# filled it in. Everything else in this file is derived from it. +_stoatDomain() +{ + local app_name="$1" + tagsManagerGetTagContent "$containers_dir$app_name/docker-compose.yml" "DOMAINSUBNAME_TAG_1" +} + +# Generate secrets.env if it does not already exist. Returns without touching an +# existing file — see the warning at the top. +_stoatWriteSecrets() +{ + local secrets_file="$1" + + if [[ -s "$secrets_file" ]]; then + isNotice "Existing secrets.env found — keeping it (regenerating would orphan every uploaded file)." + return 0 + fi + + # VAPID keypair for web push. The public key is the uncompressed EC point, + # which is the last 65 bytes of the DER encoding, base64url-encoded without + # padding — that is what the browser Push API expects. + local vapid_pem vapid_private vapid_public + vapid_pem=$(mktemp) + openssl ecparam -name prime256v1 -genkey -noout -out "$vapid_pem" 2>/dev/null + vapid_private=$(base64 < "$vapid_pem" | tr -d '\n' | tr -d '=') + vapid_public=$(openssl ec -in "$vapid_pem" -outform DER 2>/dev/null | tail -c 65 | base64 | tr '/+' '_-' | tr -d '\n' | tr -d '=') + rm -f "$vapid_pem" + + local files_key livekit_key livekit_secret + files_key=$(openssl rand -base64 32) + livekit_key=$(openssl rand -hex 6) + livekit_secret=$(openssl rand -hex 24) + + runFileWrite "$secrets_file" </dev/null) + livekit_secret=$(grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='\K[^']*" "$app_dir/secrets.env" 2>/dev/null) + if [[ -z "$livekit_key" || -z "$livekit_secret" ]]; then + isError "Could not read the LiveKit credentials from secrets.env — voice will not work." + return 1 + fi + + # HOSTNAME=:80 is what puts Caddy in plain-HTTP mode behind Traefik. The + # VITE_* values are compiled into the browser bundle, so they must be the + # public https:// URLs, not internal container addresses. + local video_enabled="" + [[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true" + + runFileWrite "$app_dir/.env.web" <&1) + checkSuccess "Copying Caddyfile to $app_dir" + + runFileOp chown -R "$docker_install_user":"$docker_install_user" "$app_dir" + checkSuccess "Setting ownership on the $app_name install directory" +} + +stoat_install_post() +{ + local app_name="$1" + local domain + domain=$(_stoatDomain "$app_name") + + echo "" + isNotice "Stoat first run:" + echo "" + echo " Open https://${domain} and create an account — the first account" + echo " registered on a fresh instance becomes the instance owner." + echo "" + echo " Give it a few minutes on first boot: sixteen containers start in" + echo " dependency order, and the API restarts until MongoDB and RabbitMQ" + echo " both report healthy. 'docker compose ps' in the app directory" + echo " shows where it has got to." + echo "" + echo " Voice falls back to TCP 7881, which is already open. For proper" + echo " low-latency WebRTC from outside your LAN, also allow the UDP" + echo " media range — LibrePortal's firewall layer only emits TCP rules," + echo " so this one is manual:" + echo "" + echo " sudo ufw allow 50000:50100/udp" + echo "" +} diff --git a/containers/stoat/stoat.config b/containers/stoat/stoat.config new file mode 100644 index 0000000..5863383 --- /dev/null +++ b/containers/stoat/stoat.config @@ -0,0 +1,87 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh) +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# ENABLE_VIDEO = if true, allow camera and screen sharing (voice always works) +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_STOAT_APP_NAME=stoat +# Stoat bakes its public URL into the client bundle and into Revolt.toml at +# install time, and voice needs real TLS, so a domain behind Traefik is a +# prerequisite rather than a nicety. +CFG_STOAT_REQUIRES="domain,traefik" +CFG_STOAT_BACKUP=true +CFG_STOAT_BACKUP_STRATEGY=auto +# Manual, deliberately. This is a sixteen-service stack whose components are +# released together and expect matching versions; letting them roll forward +# unattended and independently is how you end up with an API talking to an +# incompatible events service. +CFG_STOAT_UPDATE_TYPE=manual +CFG_STOAT_COMPOSE_FILE=default +CFG_STOAT_HEALTHCHECK=true +# Stoat's own accounts back its clients, and /api must stay reachable without a +# forward-auth redirect in the way. +CFG_STOAT_AUTHELIA=false +CFG_STOAT_HEADSCALE=false +CFG_STOAT_ENABLE_VIDEO=true +CFG_STOAT_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# REQUIRES_SERVICE = name of another LibrePortal app that must be installed before this one can be configured +# +CFG_STOAT_CATEGORY="communication" +CFG_STOAT_TITLE="Stoat" +CFG_STOAT_DESCRIPTION="Discord-style Chat" +CFG_STOAT_LONG_DESCRIPTION="Stoat, formerly Revolt, is the open-source project that most closely reproduces Discord itself — servers, channels, roles, reactions, and voice and video through LiveKit. It is the heaviest app in this catalog by some distance: sixteen containers including MongoDB, Valkey, RabbitMQ and MinIO, so budget a couple of gigabytes of memory. Unlike Matrix it does not federate, so each instance is its own island" +CFG_STOAT_URL="https://github.com/stoatchat/self-hosted" +CFG_STOAT_ACTIONS="configure|install|restart|shutdown|uninstall" +CFG_STOAT_REQUIRES_SERVICE=traefik +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_STOAT_DOMAIN=1 +CFG_STOAT_WHITELIST=false +CFG_STOAT_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +# Only one HTTP port: Caddy fronts the entire stack internally, so /api, /ws, +# /autumn and the rest all arrive on this single host. +CFG_STOAT_PORT_1="stoat-caddy|webui|random:80|public|tcp|false|true|true|Web Interface||stoat" +# LiveKit's TCP fallback. Pinned rather than random on purpose: LiveKit +# advertises this exact port number to clients from livekit.yml, so a randomised +# external port would be advertised wrongly and voice would fail to connect. +# Not Traefik-managed — WebRTC is not HTTP. +CFG_STOAT_PORT_2="stoat-livekit|voice-tcp|7881:7881|public|tcp|false|false|false|LiveKit voice/video (TCP fallback)|" diff --git a/containers/stoat/stoat.svg b/containers/stoat/stoat.svg new file mode 100644 index 0000000..473c00c --- /dev/null +++ b/containers/stoat/stoat.svg @@ -0,0 +1 @@ + diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index d5f4dec..86b70a2 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -646,6 +646,11 @@ declare -gA LP_FN_MAP=( [manifestRemove]="backup/manifest/manifest_write.sh" [manifestWrite]="backup/manifest/manifest_write.sh" [mastodon_upgrade_verify]="mastodon/scripts/mastodon_upgrade_hooks.sh" + [matrix_install_post]="matrix/scripts/matrix_install_hooks.sh" + [matrix_install_post_compose]="matrix/scripts/matrix_install_hooks.sh" + [matrix_install_post_start]="matrix/scripts/matrix_install_hooks.sh" + [matrix_install_pre]="matrix/scripts/matrix_install_hooks.sh" + [_matrixServerName]="matrix/scripts/matrix_install_hooks.sh" [mattermostToolsMenu]="menu/tools/manage_mattermost.sh" [maybeRegenPoll]="task/crontab_task_processor.sh" [menuContinue]="menu/message/continue.sh" @@ -815,6 +820,8 @@ declare -gA LP_FN_MAP=( [restoreFirstRunBulk]="restore/restore_first_run.sh" [restoreFirstRunDiscover]="restore/restore_first_run.sh" [restorePickSnapshot]="restore/restore_app_pick.sh" + [rocketchat_install_post]="rocketchat/scripts/rocketchat_install_hooks.sh" + [rocketchat_install_post_start]="rocketchat/scripts/rocketchat_install_hooks.sh" [runAppCfg]="docker/command/run_privileged.sh" [runAsManager]="docker/command/run_privileged.sh" [runBackupOp]="docker/command/run_privileged.sh" @@ -868,15 +875,20 @@ declare -gA LP_FN_MAP=( [stalwart_install_message_data]="stalwart/scripts/stalwart_install_hooks.sh" [stalwart_install_post_start]="stalwart/scripts/stalwart_install_hooks.sh" [stalwart_install_provision]="stalwart/scripts/stalwart_install_hooks.sh" - [stalwart_wait_http]="stalwart/scripts/stalwart_install_hooks.sh" [stalwart_upgrade_admin_ui_code]="stalwart/scripts/stalwart_upgrade_hooks.sh" [stalwart_upgrade_check_admin_ui]="stalwart/scripts/stalwart_upgrade_hooks.sh" [stalwart_upgrade_verify]="stalwart/scripts/stalwart_upgrade_hooks.sh" + [stalwart_wait_http]="stalwart/scripts/stalwart_install_hooks.sh" [startInstall]="start/start_install.sh" [startLoad]="start/start_load.sh" [startOther]="start/start_other.sh" [startPreInstall]="start/start_preinstall.sh" [startScan]="start/start_scan.sh" + [_stoatDomain]="stoat/scripts/stoat_install_hooks.sh" + [stoat_install_post]="stoat/scripts/stoat_install_hooks.sh" + [stoat_install_post_compose]="stoat/scripts/stoat_install_hooks.sh" + [stoat_install_pre]="stoat/scripts/stoat_install_hooks.sh" + [_stoatWriteSecrets]="stoat/scripts/stoat_install_hooks.sh" [stopCrowdsec]="crowdsec/crowdsec.sh" [switchMigrateBackupApps]="docker/type_switcher/swap_docker_type.sh" [switchMigrateRestoreApps]="docker/type_switcher/swap_docker_type.sh" @@ -1689,6 +1701,11 @@ declare -gA LP_FN_ROOT=( [manifestRemove]="scripts" [manifestWrite]="scripts" [mastodon_upgrade_verify]="containers" + [matrix_install_post]="containers" + [matrix_install_post_compose]="containers" + [matrix_install_post_start]="containers" + [matrix_install_pre]="containers" + [_matrixServerName]="containers" [mattermostToolsMenu]="scripts" [maybeRegenPoll]="scripts" [menuContinue]="scripts" @@ -1858,6 +1875,8 @@ declare -gA LP_FN_ROOT=( [restoreFirstRunBulk]="scripts" [restoreFirstRunDiscover]="scripts" [restorePickSnapshot]="scripts" + [rocketchat_install_post]="containers" + [rocketchat_install_post_start]="containers" [runAppCfg]="scripts" [runAsManager]="scripts" [runBackupOp]="scripts" @@ -1911,15 +1930,20 @@ declare -gA LP_FN_ROOT=( [stalwart_install_message_data]="containers" [stalwart_install_post_start]="containers" [stalwart_install_provision]="containers" - [stalwart_wait_http]="containers" [stalwart_upgrade_admin_ui_code]="containers" [stalwart_upgrade_check_admin_ui]="containers" [stalwart_upgrade_verify]="containers" + [stalwart_wait_http]="containers" [startInstall]="scripts" [startLoad]="scripts" [startOther]="scripts" [startPreInstall]="scripts" [startScan]="scripts" + [_stoatDomain]="containers" + [stoat_install_post]="containers" + [stoat_install_post_compose]="containers" + [stoat_install_pre]="containers" + [_stoatWriteSecrets]="containers" [stopCrowdsec]="containers" [switchMigrateBackupApps]="scripts" [switchMigrateRestoreApps]="scripts" @@ -2766,6 +2790,11 @@ manifestReadFromSnapshot() { unset -f manifestReadFromSnapshot; __lpAutoload "${ manifestRemove() { unset -f manifestRemove; __lpAutoload "${install_scripts_dir}backup/manifest/manifest_write.sh"; manifestRemove "$@"; } manifestWrite() { unset -f manifestWrite; __lpAutoload "${install_scripts_dir}backup/manifest/manifest_write.sh"; manifestWrite "$@"; } mastodon_upgrade_verify() { unset -f mastodon_upgrade_verify; __lpAutoload "${install_containers_dir}mastodon/scripts/mastodon_upgrade_hooks.sh"; mastodon_upgrade_verify "$@"; } +matrix_install_post() { unset -f matrix_install_post; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; matrix_install_post "$@"; } +matrix_install_post_compose() { unset -f matrix_install_post_compose; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; matrix_install_post_compose "$@"; } +matrix_install_post_start() { unset -f matrix_install_post_start; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; matrix_install_post_start "$@"; } +matrix_install_pre() { unset -f matrix_install_pre; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; matrix_install_pre "$@"; } +_matrixServerName() { unset -f _matrixServerName; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; _matrixServerName "$@"; } mattermostToolsMenu() { unset -f mattermostToolsMenu; __lpAutoload "${install_scripts_dir}menu/tools/manage_mattermost.sh"; mattermostToolsMenu "$@"; } maybeRegenPoll() { unset -f maybeRegenPoll; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; maybeRegenPoll "$@"; } menuContinue() { unset -f menuContinue; __lpAutoload "${install_scripts_dir}menu/message/continue.sh"; menuContinue "$@"; } @@ -2935,6 +2964,8 @@ restoreFilesRehydratePreStart() { unset -f restoreFilesRehydratePreStart; __lpAu restoreFirstRunBulk() { unset -f restoreFirstRunBulk; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunBulk "$@"; } restoreFirstRunDiscover() { unset -f restoreFirstRunDiscover; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunDiscover "$@"; } restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_scripts_dir}restore/restore_app_pick.sh"; restorePickSnapshot "$@"; } +rocketchat_install_post() { unset -f rocketchat_install_post; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_install_hooks.sh"; rocketchat_install_post "$@"; } +rocketchat_install_post_start() { unset -f rocketchat_install_post_start; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_install_hooks.sh"; rocketchat_install_post_start "$@"; } runAppCfg() { unset -f runAppCfg; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runAppCfg "$@"; } runAsManager() { unset -f runAsManager; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runAsManager "$@"; } runBackupOp() { unset -f runBackupOp; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runBackupOp "$@"; } @@ -2982,16 +3013,26 @@ setupWizardTerminal() { unset -f setupWizardTerminal; __lpAutoload "${install_sc showInstructions() { unset -f showInstructions; __lpAutoload "${install_scripts_dir}menu/message/instructions.sh"; showInstructions "$@"; } sourceBackupLocations() { unset -f sourceBackupLocations; __lpAutoload "${install_scripts_dir}backup/locations/location_loader.sh"; sourceBackupLocations "$@"; } sshRemote() { unset -f sshRemote; __lpAutoload "${install_scripts_dir}network/ssh/ssh.sh"; sshRemote "$@"; } +stalwart_cli() { unset -f stalwart_cli; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_cli "$@"; } +stalwart_install_dns_provider() { unset -f stalwart_install_dns_provider; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_install_dns_provider "$@"; } +stalwart_install_first_mailbox() { unset -f stalwart_install_first_mailbox; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_install_first_mailbox "$@"; } stalwart_install_message_data() { unset -f stalwart_install_message_data; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_install_message_data "$@"; } stalwart_install_post_start() { unset -f stalwart_install_post_start; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_install_post_start "$@"; } +stalwart_install_provision() { unset -f stalwart_install_provision; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_install_provision "$@"; } stalwart_upgrade_admin_ui_code() { unset -f stalwart_upgrade_admin_ui_code; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_upgrade_hooks.sh"; stalwart_upgrade_admin_ui_code "$@"; } stalwart_upgrade_check_admin_ui() { unset -f stalwart_upgrade_check_admin_ui; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_upgrade_hooks.sh"; stalwart_upgrade_check_admin_ui "$@"; } stalwart_upgrade_verify() { unset -f stalwart_upgrade_verify; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_upgrade_hooks.sh"; stalwart_upgrade_verify "$@"; } +stalwart_wait_http() { unset -f stalwart_wait_http; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_wait_http "$@"; } startInstall() { unset -f startInstall; __lpAutoload "${install_scripts_dir}start/start_install.sh"; startInstall "$@"; } startLoad() { unset -f startLoad; __lpAutoload "${install_scripts_dir}start/start_load.sh"; startLoad "$@"; } startOther() { unset -f startOther; __lpAutoload "${install_scripts_dir}start/start_other.sh"; startOther "$@"; } startPreInstall() { unset -f startPreInstall; __lpAutoload "${install_scripts_dir}start/start_preinstall.sh"; startPreInstall "$@"; } startScan() { unset -f startScan; __lpAutoload "${install_scripts_dir}start/start_scan.sh"; startScan "$@"; } +_stoatDomain() { unset -f _stoatDomain; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatDomain "$@"; } +stoat_install_post() { unset -f stoat_install_post; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post "$@"; } +stoat_install_post_compose() { unset -f stoat_install_post_compose; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post_compose "$@"; } +stoat_install_pre() { unset -f stoat_install_pre; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_pre "$@"; } +_stoatWriteSecrets() { unset -f _stoatWriteSecrets; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteSecrets "$@"; } stopCrowdsec() { unset -f stopCrowdsec; __lpAutoload "${install_containers_dir}crowdsec/crowdsec.sh"; stopCrowdsec "$@"; } switchMigrateBackupApps() { unset -f switchMigrateBackupApps; __lpAutoload "${install_scripts_dir}docker/type_switcher/swap_docker_type.sh"; switchMigrateBackupApps "$@"; } switchMigrateRestoreApps() { unset -f switchMigrateRestoreApps; __lpAutoload "${install_scripts_dir}docker/type_switcher/swap_docker_type.sh"; switchMigrateRestoreApps "$@"; }