feat(secrets): real VAPID keypair for mastodon, slot-numbered DB passwords

VAPID: the two values are the halves of one P-256 keypair, not independent
secrets — the browser verifies that a push is signed by the private key matching
the public key it subscribed with. The RANDOMIZED* generators mint each
placeholder on its own, so they produced two unrelated strings and web push could
never have worked. Generate the pair in mastodon_install_post_setup the way stoat
already does, encoded as Mastodon's webpush gem expects: unpadded URL-safe base64
of the 32-byte private scalar and the 65-byte uncompressed public point, sliced
out of the SEC1 DER. Verified by rebuilding the key from the emitted private half
and re-deriving the public point — openssl accepts it and the point matches.

Generated once and never rotated (rotation would invalidate every subscription),
but a pair of the wrong shape is replaced, so an install carrying the old
unrelated strings heals itself on next install — their public half is 42 chars
where a real point is 87.

Slots: CFG_<APP>_DB_PASSWORD -> CFG_<APP>_DB_PASSWORD_1 and likewise for
DB_ROOT_PASSWORD, across mastodon, owncloud, mattermost, matrix, nextcloud and
bookstack, so a database credential is always a numbered slot and a second one is
just _2. Renaming a key means reconciliation drops the old and adds the new
holding its placeholder, so an existing install regenerates unless the value is
carried over first — documented, including that the old file survives as
.<app>.config.bak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 19:42:50 +01:00
parent be7d6813ef
commit 4685320353
19 changed files with 183 additions and 61 deletions

View File

@ -31,8 +31,8 @@ CFG_BOOKSTACK_ADMIN_PASSWORD=RANDOMIZEDPASSWORD3
# auto-generated, and (unlike a RANDOMIZED* placeholder in the compose)
# preserved across reinstalls. DB_PASSWORD is shared by the app + db services.
CFG_BOOKSTACK_APP_KEY=RANDOMIZEDAPPKEY1
CFG_BOOKSTACK_DB_PASSWORD=RANDOMIZEDPASSWORD1
CFG_BOOKSTACK_DB_ROOT_PASSWORD=RANDOMIZEDPASSWORD2
CFG_BOOKSTACK_DB_PASSWORD_1=RANDOMIZEDPASSWORD1
CFG_BOOKSTACK_DB_ROOT_PASSWORD_1=RANDOMIZEDPASSWORD2
#
# =============================================================================
# METADATA

View File

@ -15,7 +15,7 @@ services:
- DB_HOST=bookstack_db
- DB_PORT=3306
- DB_USERNAME=bookstack
- DB_PASSWORD=BOOKSTACK_DB_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_TAG|BOOKSTACK_DB_PASSWORD_DATA
- DB_PASSWORD=BOOKSTACK_DB_PASSWORD_1_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_1_TAG|BOOKSTACK_DB_PASSWORD_1_DATA
- DB_DATABASE=bookstackapp
volumes:
- SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA
@ -60,10 +60,10 @@ services:
- PUID=1000
- PGID=1000
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- MYSQL_ROOT_PASSWORD=BOOKSTACK_DB_ROOT_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_ROOT_PASSWORD_TAG|BOOKSTACK_DB_ROOT_PASSWORD_DATA
- MYSQL_ROOT_PASSWORD=BOOKSTACK_DB_ROOT_PASSWORD_1_DATA #LIBREPORTAL|BOOKSTACK_DB_ROOT_PASSWORD_1_TAG|BOOKSTACK_DB_ROOT_PASSWORD_1_DATA
- MYSQL_DATABASE=bookstackapp
- MYSQL_USER=bookstack
- MYSQL_PASSWORD=BOOKSTACK_DB_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_TAG|BOOKSTACK_DB_PASSWORD_DATA
- MYSQL_PASSWORD=BOOKSTACK_DB_PASSWORD_1_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_1_TAG|BOOKSTACK_DB_PASSWORD_1_DATA
volumes:
- ./db:/config
restart: unless-stopped

View File

@ -3,7 +3,7 @@ networks:
external: true
services:
mastodon-service: #LIBREPORTAL|SERVICE_TAG_1|mastodon-service
mastodon-service: #LIBREPORTAL|SERVICE_TAG_3|mastodon-service
container_name: mastodon-service
image: tootsuite/mastodon:v4.6 #LIBREPORTAL|MASTODON_VERSION_TAG|v4.6
ports:
@ -16,7 +16,7 @@ services:
- LOCAL_DOMAIN=DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA
- DB_HOST=mastodon-postgres
- DB_USER=MASTODON_DB_USER_DATA #LIBREPORTAL|MASTODON_DB_USER_TAG|MASTODON_DB_USER_DATA
- DB_PASS=MASTODON_DB_PASSWORD_DATA #LIBREPORTAL|MASTODON_DB_PASSWORD_TAG|MASTODON_DB_PASSWORD_DATA
- DB_PASS=MASTODON_DB_PASSWORD_1_DATA #LIBREPORTAL|MASTODON_DB_PASSWORD_1_TAG|MASTODON_DB_PASSWORD_1_DATA
- DB_NAME=MASTODON_DB_NAME_DATA #LIBREPORTAL|MASTODON_DB_NAME_TAG|MASTODON_DB_NAME_DATA
- REDIS_HOST=mastodon-redis
- SECRET_KEY_BASE=MASTODON_SECRET_KEY_BASE_DATA #LIBREPORTAL|MASTODON_SECRET_KEY_BASE_TAG|MASTODON_SECRET_KEY_BASE_DATA
@ -55,20 +55,20 @@ services:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_3 #LIBREPORTAL|IP_TAG_3|IP_DATA_3
mastodon-postgres:
mastodon-postgres: #LIBREPORTAL|SERVICE_TAG_1|mastodon-postgres
image: postgres:15
container_name: mastodon-postgres
environment:
- POSTGRES_DB=MASTODON_DB_NAME_DATA #LIBREPORTAL|MASTODON_DB_NAME_TAG|MASTODON_DB_NAME_DATA
- POSTGRES_USER=MASTODON_DB_USER_DATA #LIBREPORTAL|MASTODON_DB_USER_TAG|MASTODON_DB_USER_DATA
- POSTGRES_PASSWORD=MASTODON_DB_PASSWORD_DATA #LIBREPORTAL|MASTODON_DB_PASSWORD_TAG|MASTODON_DB_PASSWORD_DATA
- POSTGRES_PASSWORD=MASTODON_DB_PASSWORD_1_DATA #LIBREPORTAL|MASTODON_DB_PASSWORD_1_TAG|MASTODON_DB_PASSWORD_1_DATA
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
mastodon-redis:
mastodon-redis: #LIBREPORTAL|SERVICE_TAG_2|mastodon-redis
image: redis:7
container_name: mastodon-redis
command: redis-server --save 60 1 --loglevel warning

View File

@ -34,11 +34,17 @@ CFG_MASTODON_HEADSCALE=false
# every user out.
# OTP_SECRET = protects stored two-factor enrolments. Rotating it invalidates
# them, and anyone with 2FA on needs it reset before they can log in.
# VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY = Web Push identity.
# VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY = Web Push identity. These two are the
# halves of one P-256 keypair, so they cannot be generated independently the
# way the other secrets are — mastodon_install_post_setup replaces the pair
# below with a real one on first install, and never touches it again.
# RANDOMIZEDVAPID<n> is kept here only so the tag always has something
# substitutable: if key generation ever fails, Mastodon starts with push
# broken rather than with a literal placeholder as its key.
#
CFG_MASTODON_DB_NAME=RANDOMIZEDUSERNAME1
CFG_MASTODON_DB_USER=RANDOMIZEDUSERNAME2
CFG_MASTODON_DB_PASSWORD=RANDOMIZEDPASSWORD1
CFG_MASTODON_DB_PASSWORD_1=RANDOMIZEDPASSWORD1
CFG_MASTODON_SECRET_KEY_BASE=RANDOMIZEDHEX1
CFG_MASTODON_OTP_SECRET=RANDOMIZEDHEX2
CFG_MASTODON_VAPID_PRIVATE_KEY=RANDOMIZEDVAPID1

View File

@ -0,0 +1,71 @@
#!/bin/bash
# Mastodon install hooks.
# Web Push identity.
# ---------------------------------------------------------------------------
# The two VAPID values are not independent secrets — they are the two halves of
# one P-256 keypair, and the browser verifies that the push request is signed by
# the private key matching the public key it subscribed with. The framework's
# RANDOMIZED* generators mint each placeholder on its own, so they can produce
# two well-formed strings but never a *pair*: what shipped before was two
# unrelated random values, and web push could not work with them. Stoat hits the
# same wall and solves it the same way, in its own install hook.
#
# Mastodon's webpush gem expects unpadded URL-safe base64 of the raw key
# material: the 32-byte private scalar (43 chars) and the 65-byte uncompressed
# public point (87 chars). Both are sliced out of the SEC1 DER, whose layout for
# prime256v1 is a fixed 121 bytes — a 2-byte SEQUENCE header, INTEGER 1, then
# `04 20` introducing the private scalar at offset 7, and the public point as
# the trailing field. Both slices were cross-checked against `openssl ec -text`.
#
# Runs at install_post_setup: the deployed config exists by then (so there is
# something to write to) and the compose has not been templated yet (so the pair
# reaches the compose on this same install).
#
# Generated once, then left alone. Rotating the pair invalidates every push
# subscription clients are holding, so an existing well-formed pair is never
# replaced — including across reinstalls.
mastodon_install_post_setup()
{
local app_name="${1:-mastodon}"
local config_file="${containers_dir}${app_name}/${app_name}.config"
[[ -f "$config_file" ]] || return 0
# Keep a pair that already has the right shape. The length test is also what
# retires the old values: two independent RANDOMIZEDVAPID strings are the
# wrong length for the public half, so an install carrying them regenerates
# once and is correct from then on.
if [[ "$CFG_MASTODON_VAPID_PRIVATE_KEY" =~ ^[A-Za-z0-9_-]{43}$ \
&& "$CFG_MASTODON_VAPID_PUBLIC_KEY" =~ ^[A-Za-z0-9_-]{87}$ ]]; then
return 0
fi
local pem der
pem=$(mktemp) || return 0
der=$(mktemp) || { rm -f "$pem"; return 0; }
if ! openssl ecparam -name prime256v1 -genkey -noout -out "$pem" 2>/dev/null \
|| ! openssl ec -in "$pem" -outform DER -out "$der" 2>/dev/null; then
rm -f "$pem" "$der"
isError "Could not generate a VAPID keypair for $app_name — web push will not work."
return 0
fi
local vapid_private vapid_public
vapid_private=$(head -c 39 "$der" | tail -c 32 | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
vapid_public=$(tail -c 65 "$der" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
rm -f "$pem" "$der"
# Refuse to write a malformed pair over a working one — a short read or an
# openssl build with a different DER layout would otherwise quietly break
# push instead of leaving it as it was.
if [[ ! "$vapid_private" =~ ^[A-Za-z0-9_-]{43}$ || ! "$vapid_public" =~ ^[A-Za-z0-9_-]{87}$ ]]; then
isError "Generated VAPID keypair had an unexpected shape — leaving $app_name's push keys alone."
return 0
fi
updateConfigOption "CFG_MASTODON_VAPID_PRIVATE_KEY" "$vapid_private" "$config_file"
updateConfigOption "CFG_MASTODON_VAPID_PUBLIC_KEY" "$vapid_public" "$config_file"
isSuccessful "Generated a VAPID keypair for $app_name web push."
}

View File

@ -96,14 +96,14 @@ services:
# No `user:` override — the postgres entrypoint starts as root, chowns
# PGDATA and drops privileges, which works under rootless because
# container-root is the install user that owns the mount.
matrix-postgres:
matrix-postgres: #LIBREPORTAL|SERVICE_TAG_3|matrix-postgres
image: postgres:15-alpine
container_name: matrix-postgres
restart: unless-stopped
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=MATRIX_DB_PASSWORD_DATA #LIBREPORTAL|MATRIX_DB_PASSWORD_TAG|MATRIX_DB_PASSWORD_DATA
- POSTGRES_PASSWORD=MATRIX_DB_PASSWORD_1_DATA #LIBREPORTAL|MATRIX_DB_PASSWORD_1_TAG|MATRIX_DB_PASSWORD_1_DATA
- POSTGRES_DB=synapse
# Not optional. Synapse refuses to start against a database with any
# other collation or ctype — it needs deterministic byte ordering for

View File

@ -39,11 +39,11 @@ CFG_MATRIX_ADMIN_USERNAME=admin
CFG_MATRIX_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1
CFG_MATRIX_MONITORING=false
# Postgres password for the `synapse` role, fed to the compose via
# #LIBREPORTAL|MATRIX_DB_PASSWORD_TAG| and written into homeserver.yaml by the
# #LIBREPORTAL|MATRIX_DB_PASSWORD_1_TAG| and written into homeserver.yaml by the
# install hook. Generated on first install and preserved across reinstalls —
# initdb sets it once when the volume is created, so a regenerated value would
# leave Synapse unable to open its own database.
CFG_MATRIX_DB_PASSWORD=RANDOMIZEDPASSWORD2
CFG_MATRIX_DB_PASSWORD_1=RANDOMIZEDPASSWORD2
#
# =============================================================================
# METADATA

View File

@ -47,12 +47,12 @@ matrix_install_post_compose()
fi
# Must match the password the compose handed to Postgres. Read it back from
# the deployed compose rather than from CFG_MATRIX_DB_PASSWORD: this hook
# the deployed compose rather than from CFG_MATRIX_DB_PASSWORD_1: this hook
# runs after templating, so the compose is the settled value, and it stays
# correct even on an install whose config still holds the placeholder.
local db_password
db_password=$(tagsManagerGetTagContent "$app_dir/docker-compose.yml" "MATRIX_DB_PASSWORD_TAG")
if [[ -z "$db_password" || "$db_password" == "MATRIX_DB_PASSWORD_DATA" ]]; then
db_password=$(tagsManagerGetTagContent "$app_dir/docker-compose.yml" "MATRIX_DB_PASSWORD_1_TAG")
if [[ -z "$db_password" || "$db_password" == "MATRIX_DB_PASSWORD_1_DATA" ]]; then
isError "Database password was not generated in the compose file — aborting Synapse configuration."
return 1
fi

View File

@ -34,7 +34,7 @@ services:
# Fixed role and database name, random password. The database is
# only reachable on the internal docker network, and a generated
# username buys nothing while making manual psql recovery painful.
- MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:MATTERMOST_DB_PASSWORD_DATA@mattermost-postgres:5432/mattermost?sslmode=disable&connect_timeout=10 #LIBREPORTAL|MATTERMOST_DB_PASSWORD_TAG|MATTERMOST_DB_PASSWORD_DATA
- MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:MATTERMOST_DB_PASSWORD_1_DATA@mattermost-postgres:5432/mattermost?sslmode=disable&connect_timeout=10 #LIBREPORTAL|MATTERMOST_DB_PASSWORD_1_TAG|MATTERMOST_DB_PASSWORD_1_DATA
# Mattermost builds every absolute link (invites, password resets,
# CORS and websocket origin checks) from this. Wrong value and the
# web client connects but the websocket is rejected, which shows up
@ -76,14 +76,14 @@ services:
# root, chowns PGDATA to the postgres user and then drops privileges. Under
# rootless Docker container-root *is* the install user on the host, so it
# owns ./postgres and the chown succeeds. Pinning a uid would break that.
mattermost-postgres:
mattermost-postgres: #LIBREPORTAL|SERVICE_TAG_2|mattermost-postgres
image: postgres:15-alpine
container_name: mattermost-postgres
restart: unless-stopped
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- POSTGRES_USER=mattermost
- POSTGRES_PASSWORD=MATTERMOST_DB_PASSWORD_DATA #LIBREPORTAL|MATTERMOST_DB_PASSWORD_TAG|MATTERMOST_DB_PASSWORD_DATA
- POSTGRES_PASSWORD=MATTERMOST_DB_PASSWORD_1_DATA #LIBREPORTAL|MATTERMOST_DB_PASSWORD_1_TAG|MATTERMOST_DB_PASSWORD_1_DATA
- POSTGRES_DB=mattermost
volumes:
- ./postgres:/var/lib/postgresql/data

View File

@ -27,10 +27,10 @@ CFG_MATTERMOST_AUTHELIA=false
CFG_MATTERMOST_HEADSCALE=false
CFG_MATTERMOST_MONITORING=false
# Postgres password for the `mattermost` role, fed to the compose via
# #LIBREPORTAL|MATTERMOST_DB_PASSWORD_TAG| (both the server's datasource URL and
# #LIBREPORTAL|MATTERMOST_DB_PASSWORD_1_TAG| (both the server's datasource URL and
# the database's own env). Generated on first install and preserved across
# reinstalls — initdb sets it once when the volume is created.
CFG_MATTERMOST_DB_PASSWORD=RANDOMIZEDPASSWORD1
CFG_MATTERMOST_DB_PASSWORD_1=RANDOMIZEDPASSWORD1
#
# =============================================================================
# METADATA

View File

@ -16,7 +16,7 @@ services:
- MYSQL_HOST=nextcloud-db
- MYSQL_DATABASE=NEXTCLOUD_DB_NAME_DATA #LIBREPORTAL|NEXTCLOUD_DB_NAME_TAG|NEXTCLOUD_DB_NAME_DATA
- MYSQL_USER=NEXTCLOUD_DB_USER_DATA #LIBREPORTAL|NEXTCLOUD_DB_USER_TAG|NEXTCLOUD_DB_USER_DATA
- MYSQL_PASSWORD=NEXTCLOUD_DB_PASSWORD_DATA #LIBREPORTAL|NEXTCLOUD_DB_PASSWORD_TAG|NEXTCLOUD_DB_PASSWORD_DATA
- MYSQL_PASSWORD=NEXTCLOUD_DB_PASSWORD_1_DATA #LIBREPORTAL|NEXTCLOUD_DB_PASSWORD_1_TAG|NEXTCLOUD_DB_PASSWORD_1_DATA
- NEXTCLOUD_ADMIN_USER=NEXTCLOUD_ADMIN_USER_DATA #LIBREPORTAL|NEXTCLOUD_ADMIN_USER_TAG|NEXTCLOUD_ADMIN_USER_DATA
- NEXTCLOUD_ADMIN_PASSWORD=NEXTCLOUD_ADMIN_PASSWORD_DATA #LIBREPORTAL|NEXTCLOUD_ADMIN_PASSWORD_TAG|NEXTCLOUD_ADMIN_PASSWORD_DATA
- NEXTCLOUD_TRUSTED_DOMAINS=NEXTCLOUD_TRUSTED_DOMAINS_DATA #LIBREPORTAL|NEXTCLOUD_TRUSTED_DOMAINS_TAG|NEXTCLOUD_TRUSTED_DOMAINS_DATA
@ -49,10 +49,10 @@ services:
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
environment:
- MARIADB_ROOT_PASSWORD=NEXTCLOUD_DB_ROOT_PASSWORD_DATA #LIBREPORTAL|NEXTCLOUD_DB_ROOT_PASSWORD_TAG|NEXTCLOUD_DB_ROOT_PASSWORD_DATA
- MARIADB_ROOT_PASSWORD=NEXTCLOUD_DB_ROOT_PASSWORD_1_DATA #LIBREPORTAL|NEXTCLOUD_DB_ROOT_PASSWORD_1_TAG|NEXTCLOUD_DB_ROOT_PASSWORD_1_DATA
- MARIADB_DATABASE=NEXTCLOUD_DB_NAME_DATA #LIBREPORTAL|NEXTCLOUD_DB_NAME_TAG|NEXTCLOUD_DB_NAME_DATA
- MARIADB_USER=NEXTCLOUD_DB_USER_DATA #LIBREPORTAL|NEXTCLOUD_DB_USER_TAG|NEXTCLOUD_DB_USER_DATA
- MARIADB_PASSWORD=NEXTCLOUD_DB_PASSWORD_DATA #LIBREPORTAL|NEXTCLOUD_DB_PASSWORD_TAG|NEXTCLOUD_DB_PASSWORD_DATA
- MARIADB_PASSWORD=NEXTCLOUD_DB_PASSWORD_1_DATA #LIBREPORTAL|NEXTCLOUD_DB_PASSWORD_1_TAG|NEXTCLOUD_DB_PASSWORD_1_DATA
volumes:
- ./db_data:/var/lib/mysql
networks:

View File

@ -27,8 +27,8 @@ CFG_NEXTCLOUD_ADMIN_USER=admin
CFG_NEXTCLOUD_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1
CFG_NEXTCLOUD_DB_NAME=nextcloud
CFG_NEXTCLOUD_DB_USER=nextcloud
CFG_NEXTCLOUD_DB_PASSWORD=RANDOMIZEDPASSWORD2
CFG_NEXTCLOUD_DB_ROOT_PASSWORD=RANDOMIZEDPASSWORD3
CFG_NEXTCLOUD_DB_PASSWORD_1=RANDOMIZEDPASSWORD2
CFG_NEXTCLOUD_DB_ROOT_PASSWORD_1=RANDOMIZEDPASSWORD3
CFG_NEXTCLOUD_AUTH_PROFILE=multi_user
#
# =============================================================================

View File

@ -22,7 +22,7 @@ services:
- OWNCLOUD_DB_TYPE=mysql
- OWNCLOUD_DB_NAME=OWNCLOUD_DB_NAME_DATA #LIBREPORTAL|OWNCLOUD_DB_NAME_TAG|OWNCLOUD_DB_NAME_DATA
- OWNCLOUD_DB_USERNAME=OWNCLOUD_DB_USER_DATA #LIBREPORTAL|OWNCLOUD_DB_USER_TAG|OWNCLOUD_DB_USER_DATA
- OWNCLOUD_DB_PASSWORD=OWNCLOUD_DB_PASSWORD_DATA #LIBREPORTAL|OWNCLOUD_DB_PASSWORD_TAG|OWNCLOUD_DB_PASSWORD_DATA
- OWNCLOUD_DB_PASSWORD=OWNCLOUD_DB_PASSWORD_1_DATA #LIBREPORTAL|OWNCLOUD_DB_PASSWORD_1_TAG|OWNCLOUD_DB_PASSWORD_1_DATA
- OWNCLOUD_DB_HOST=owncloud-mariadb
- OWNCLOUD_ADMIN_USERNAME=OWNCLOUD_ADMIN_USERNAME_DATA #LIBREPORTAL|OWNCLOUD_ADMIN_USERNAME_TAG|OWNCLOUD_ADMIN_USERNAME_DATA
- OWNCLOUD_ADMIN_PASSWORD=OWNCLOUD_ADMIN_PASSWORD_DATA #LIBREPORTAL|OWNCLOUD_ADMIN_PASSWORD_TAG|OWNCLOUD_ADMIN_PASSWORD_DATA
@ -73,13 +73,13 @@ services:
restart: unless-stopped
hostname: mariadb
environment:
- MYSQL_ROOT_PASSWORD=OWNCLOUD_DB_ROOT_PASSWORD_DATA #LIBREPORTAL|OWNCLOUD_DB_ROOT_PASSWORD_TAG|OWNCLOUD_DB_ROOT_PASSWORD_DATA
- MYSQL_ROOT_PASSWORD=OWNCLOUD_DB_ROOT_PASSWORD_1_DATA #LIBREPORTAL|OWNCLOUD_DB_ROOT_PASSWORD_1_TAG|OWNCLOUD_DB_ROOT_PASSWORD_1_DATA
- MYSQL_USER=OWNCLOUD_DB_USER_DATA #LIBREPORTAL|OWNCLOUD_DB_USER_TAG|OWNCLOUD_DB_USER_DATA
- MYSQL_PASSWORD=OWNCLOUD_DB_PASSWORD_DATA #LIBREPORTAL|OWNCLOUD_DB_PASSWORD_TAG|OWNCLOUD_DB_PASSWORD_DATA
- MYSQL_PASSWORD=OWNCLOUD_DB_PASSWORD_1_DATA #LIBREPORTAL|OWNCLOUD_DB_PASSWORD_1_TAG|OWNCLOUD_DB_PASSWORD_1_DATA
- MYSQL_DATABASE=OWNCLOUD_DB_NAME_DATA #LIBREPORTAL|OWNCLOUD_DB_NAME_TAG|OWNCLOUD_DB_NAME_DATA
command: ["--max-allowed-packet=128M", "--innodb-log-file-size=64M"]
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-u", "root", "--password=OWNCLOUD_DB_ROOT_PASSWORD_DATA"] #LIBREPORTAL|OWNCLOUD_DB_ROOT_PASSWORD_TAG|OWNCLOUD_DB_ROOT_PASSWORD_DATA
test: ["CMD", "mysqladmin", "ping", "-u", "root", "--password=OWNCLOUD_DB_ROOT_PASSWORD_1_DATA"] #LIBREPORTAL|OWNCLOUD_DB_ROOT_PASSWORD_1_TAG|OWNCLOUD_DB_ROOT_PASSWORD_1_DATA
interval: 10s
timeout: 5s
retries: 5

View File

@ -37,8 +37,8 @@ CFG_OWNCLOUD_ADMIN_USERNAME=RANDOMIZEDUSERNAME1
CFG_OWNCLOUD_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1
CFG_OWNCLOUD_DB_NAME=RANDOMIZEDUSERNAME2
CFG_OWNCLOUD_DB_USER=RANDOMIZEDUSERNAME3
CFG_OWNCLOUD_DB_PASSWORD=RANDOMIZEDPASSWORD2
CFG_OWNCLOUD_DB_ROOT_PASSWORD=RANDOMIZEDPASSWORD3
CFG_OWNCLOUD_DB_PASSWORD_1=RANDOMIZEDPASSWORD2
CFG_OWNCLOUD_DB_ROOT_PASSWORD_1=RANDOMIZEDPASSWORD3
#
# =============================================================================
# METADATA

View File

@ -78,7 +78,7 @@ services:
# No `user:` override: the entrypoint starts as root, chowns /data/db and
# then drops to the mongodb user. Under rootless Docker container-root is
# the install user on the host, which owns the bind mount, so that works.
rocketchat-db:
rocketchat-db: #LIBREPORTAL|SERVICE_TAG_2|rocketchat-db
image: mongo:8.0 #LIBREPORTAL|ROCKETCHAT_MONGO_VERSION_TAG|8.0
container_name: rocketchat-db
restart: unless-stopped

View File

@ -10,6 +10,14 @@
# service names in Revolt.toml — keep resolving, and LibrePortal still gets the
# prefixed container names its port, firewall and backup layers key on.
#
# SERVICE_TAG_n carries the compose *key*, not the container name, for two
# reasons: ipUpdateComposeTags allocates exactly one IP per SERVICE_TAG and fills
# IP_TAG_i only where SERVICE_TAG_i exists (so every service with an
# ipv4_address needs one, index-aligned, or it deploys with a literal
# IP_DATA_n and compose refuses to start), and `libreportal app restart <app>
# <service>` passes the value straight to `docker compose restart`, which only
# understands keys.
#
# 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
@ -27,7 +35,7 @@ services:
# 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:
database: #LIBREPORTAL|SERVICE_TAG_1|database
container_name: stoat-database
image: mongo:8.0 #LIBREPORTAL|STOAT_MONGO_VERSION_TAG|8.0
restart: unless-stopped
@ -46,7 +54,7 @@ services:
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
# Valkey — event message broker and KV store.
redis:
redis: #LIBREPORTAL|SERVICE_TAG_2|redis
container_name: stoat-redis
image: valkey/valkey:9-alpine #LIBREPORTAL|STOAT_VALKEY_VERSION_TAG|9-alpine
restart: unless-stopped
@ -57,7 +65,7 @@ services:
ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2
# RabbitMQ — internal message broker (push notifications, voice events).
rabbit:
rabbit: #LIBREPORTAL|SERVICE_TAG_3|rabbit
container_name: stoat-rabbit
image: rabbitmq:4-alpine #LIBREPORTAL|STOAT_RABBITMQ_VERSION_TAG|4-alpine
restart: unless-stopped
@ -82,7 +90,7 @@ services:
# The bucket-name aliases are load-bearing: the file server addresses
# buckets virtual-host style (<bucket>.minio), so without these the DNS
# lookup fails and every upload errors.
minio:
minio: #LIBREPORTAL|SERVICE_TAG_4|minio
container_name: stoat-minio
image: minio/minio:latest #LIBREPORTAL|STOAT_MINIO_VERSION_TAG|latest
restart: unless-stopped
@ -111,7 +119,7 @@ services:
# One-shot: creates the uploads bucket, then exits. Not a failure when you
# see it stopped.
createbuckets:
createbuckets: #LIBREPORTAL|SERVICE_TAG_5|createbuckets
container_name: stoat-createbuckets
image: minio/mc:latest #LIBREPORTAL|STOAT_MINIO_MC_VERSION_TAG|latest
depends_on:
@ -139,7 +147,7 @@ services:
# 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
caddy: #LIBREPORTAL|SERVICE_TAG_6|caddy
container_name: stoat-caddy
image: caddy:2-alpine #LIBREPORTAL|STOAT_CADDY_VERSION_TAG|2-alpine
restart: unless-stopped
@ -184,7 +192,7 @@ services:
# GLUETUN_ON_END
# API server.
api:
api: #LIBREPORTAL|SERVICE_TAG_7|api
container_name: stoat-api
image: ghcr.io/stoatchat/api:v0.15.1 #LIBREPORTAL|STOAT_VERSION_TAG|v0.15.1
restart: unless-stopped
@ -205,7 +213,7 @@ services:
- api
# Websocket / events service.
events:
events: #LIBREPORTAL|SERVICE_TAG_8|events
container_name: stoat-events
image: ghcr.io/stoatchat/events:v0.15.1 #LIBREPORTAL|STOAT_EVENTS_VERSION_TAG|v0.15.1
restart: unless-stopped
@ -224,7 +232,7 @@ services:
- events
# Autumn — file server.
autumn:
autumn: #LIBREPORTAL|SERVICE_TAG_9|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
@ -243,7 +251,7 @@ services:
- autumn
# January — link metadata and image proxy.
january:
january: #LIBREPORTAL|SERVICE_TAG_10|january
container_name: stoat-january
image: ghcr.io/stoatchat/proxy:v0.15.1 #LIBREPORTAL|STOAT_JANUARY_VERSION_TAG|v0.15.1
restart: unless-stopped
@ -258,7 +266,7 @@ services:
# Gifbox — Tenor proxy for the GIF picker. Inert until a Tenor API key is
# added to secrets.env; see the upstream Guides.md.
gifbox:
gifbox: #LIBREPORTAL|SERVICE_TAG_11|gifbox
container_name: stoat-gifbox
image: ghcr.io/stoatchat/gifbox:v0.15.1 #LIBREPORTAL|STOAT_GIFBOX_VERSION_TAG|v0.15.1
restart: unless-stopped
@ -272,7 +280,7 @@ services:
- gifbox
# Scheduled task daemon.
crond:
crond: #LIBREPORTAL|SERVICE_TAG_12|crond
container_name: stoat-crond
image: ghcr.io/stoatchat/crond:v0.15.1 #LIBREPORTAL|STOAT_CROND_VERSION_TAG|v0.15.1
restart: unless-stopped
@ -289,7 +297,7 @@ services:
ipv4_address: IP_DATA_12 #LIBREPORTAL|IP_TAG_12|IP_DATA_12
# Push notification daemon.
pushd:
pushd: #LIBREPORTAL|SERVICE_TAG_13|pushd
container_name: stoat-pushd
image: ghcr.io/stoatchat/pushd:v0.15.1 #LIBREPORTAL|STOAT_PUSHD_VERSION_TAG|v0.15.1
restart: unless-stopped
@ -308,7 +316,7 @@ services:
ipv4_address: IP_DATA_13 #LIBREPORTAL|IP_TAG_13|IP_DATA_13
# Voice ingress daemon — receives LiveKit's webhooks.
voice-ingress:
voice-ingress: #LIBREPORTAL|SERVICE_TAG_14|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
@ -337,7 +345,7 @@ services:
# 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:
livekit: #LIBREPORTAL|SERVICE_TAG_15|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
@ -357,7 +365,7 @@ services:
- livekit
# The web client itself. Served by Caddy at /.
web:
web: #LIBREPORTAL|SERVICE_TAG_16|web
container_name: stoat-web
image: ghcr.io/stoatchat/for-web:0c31cf0 #LIBREPORTAL|STOAT_WEB_VERSION_TAG|0c31cf0
restart: unless-stopped

View File

@ -156,8 +156,42 @@ initialised with. That is the same desync the old mechanism caused on every
reinstall; the difference is that it now happens at most once, because the values
are preserved from then on.
If you have one of these installed and want to avoid it, capture the credentials
**before** reinstalling. With the app stopped:
### Database password keys are now numbered slots
`CFG_<APP>_DB_PASSWORD` became `CFG_<APP>_DB_PASSWORD_1`, and
`CFG_<APP>_DB_ROOT_PASSWORD` became `CFG_<APP>_DB_ROOT_PASSWORD_1`, so a database
credential is always a numbered slot and an app that needs a second one just adds
`_2`. This also touches **nextcloud** and **bookstack**, which were otherwise
unaffected by the change above.
A rename is a delete plus an add as far as config reconciliation is concerned: the
old key is dropped and the new one arrives holding its `RANDOMIZED*` placeholder,
so the next install generates a fresh password rather than keeping the current
one. To carry the existing password over, copy it into the new key before
reinstalling — reconciliation leaves the previous file as a hidden
`.<app>.config.bak` sibling, so the old value is still there:
```bash
grep DB_PASSWORD <containers-dir>/<app>/.<app>.config.bak
```
Paste each value into the matching `_1` key in `<containers-dir>/<app>/<app>.config`.
### Mastodon web push
`VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` were generated as two independent
random strings, which is not a keypair — web push could never have worked. They
are now generated together as a P-256 keypair by the install hook, in the format
Mastodon's webpush gem expects. Any install still carrying the old values
regenerates the pair once on its next install (the old public half is the wrong
length to be a valid point, which is what triggers it) and is correct from then
on. Existing push subscriptions were not working anyway, so nothing is lost;
clients re-subscribe on next login.
### Carrying credentials across, generally
If you have one of these installed and want to avoid regeneration, capture the
credentials **before** reinstalling. With the app stopped:
```bash
grep -E 'POSTGRES_|MYSQL_|SECRET_KEY_BASE|OTP_SECRET|VAPID_|RABBITMQ_DEFAULT_PASS' <containers-dir>/<app>/docker-compose.yml

View File

@ -645,6 +645,7 @@ declare -gA LP_FN_MAP=(
[manifestReadFromSnapshot]="backup/manifest/manifest_read.sh"
[manifestRemove]="backup/manifest/manifest_write.sh"
[manifestWrite]="backup/manifest/manifest_write.sh"
[mastodon_install_post_setup]="mastodon/scripts/mastodon_install_hooks.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"
@ -1700,6 +1701,7 @@ declare -gA LP_FN_ROOT=(
[manifestReadFromSnapshot]="scripts"
[manifestRemove]="scripts"
[manifestWrite]="scripts"
[mastodon_install_post_setup]="containers"
[mastodon_upgrade_verify]="containers"
[matrix_install_post]="containers"
[matrix_install_post_compose]="containers"
@ -2789,6 +2791,7 @@ manifestReadField() { unset -f manifestReadField; __lpAutoload "${install_script
manifestReadFromSnapshot() { unset -f manifestReadFromSnapshot; __lpAutoload "${install_scripts_dir}backup/manifest/manifest_read.sh"; manifestReadFromSnapshot "$@"; }
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_install_post_setup() { unset -f mastodon_install_post_setup; __lpAutoload "${install_containers_dir}mastodon/scripts/mastodon_install_hooks.sh"; mastodon_install_post_setup "$@"; }
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 "$@"; }

View File

@ -343,14 +343,14 @@ PORTEOF
"advanced": true,
"default": "nextcloud"
},
"NEXTCLOUD_DB_PASSWORD": {
"NEXTCLOUD_DB_PASSWORD_1": {
"category": "advanced",
"label": "Database Password",
"type": "password",
"tooltip": "MariaDB password for the Nextcloud user (auto-generated; persists across reinstalls)",
"advanced": true
},
"NEXTCLOUD_DB_ROOT_PASSWORD": {
"NEXTCLOUD_DB_ROOT_PASSWORD_1": {
"category": "advanced",
"label": "Database Root Password",
"type": "password",
@ -371,7 +371,7 @@ PORTEOF
"tooltip": "Postgres role Mastodon connects with (internal to the docker network). Set by initdb when the volume is created.",
"advanced": true
},
"MASTODON_DB_PASSWORD": {
"MASTODON_DB_PASSWORD_1": {
"category": "advanced",
"label": "Database Password",
"type": "password",
@ -420,28 +420,28 @@ PORTEOF
"tooltip": "MariaDB account ownCloud connects with (internal to the docker network)",
"advanced": true
},
"OWNCLOUD_DB_PASSWORD": {
"OWNCLOUD_DB_PASSWORD_1": {
"category": "advanced",
"label": "Database Password",
"type": "password",
"tooltip": "MariaDB password for the ownCloud user (auto-generated; persists across reinstalls)",
"advanced": true
},
"OWNCLOUD_DB_ROOT_PASSWORD": {
"OWNCLOUD_DB_ROOT_PASSWORD_1": {
"category": "advanced",
"label": "Database Root Password",
"type": "password",
"tooltip": "MariaDB root password (auto-generated; kept separate from the app user password so root can be left alone if you rotate the app account)",
"advanced": true
},
"MATTERMOST_DB_PASSWORD": {
"MATTERMOST_DB_PASSWORD_1": {
"category": "advanced",
"label": "Database Password",
"type": "password",
"tooltip": "Postgres password for the mattermost role (auto-generated; persists across reinstalls)",
"advanced": true
},
"MATRIX_DB_PASSWORD": {
"MATRIX_DB_PASSWORD_1": {
"category": "advanced",
"label": "Database Password",
"type": "password",