From 4685320353bd8fb8d50d1453ce5414beed8967fc Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 19:42:50 +0100 Subject: [PATCH] feat(secrets): real VAPID keypair for mastodon, slot-numbered DB passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__DB_PASSWORD -> CFG__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 ..config.bak. Co-Authored-By: Claude Opus 5 --- containers/bookstack/bookstack.config | 4 +- containers/bookstack/docker-compose.yml | 6 +- containers/mastodon/docker-compose.yml | 10 +-- containers/mastodon/mastodon.config | 10 ++- .../scripts/mastodon_install_hooks.sh | 71 +++++++++++++++++++ containers/matrix/docker-compose.yml | 4 +- containers/matrix/matrix.config | 4 +- .../matrix/scripts/matrix_install_hooks.sh | 6 +- containers/mattermost/docker-compose.yml | 6 +- containers/mattermost/mattermost.config | 4 +- containers/nextcloud/docker-compose.yml | 6 +- containers/nextcloud/nextcloud.config | 4 +- containers/owncloud/docker-compose.yml | 8 +-- containers/owncloud/owncloud.config | 4 +- containers/rocketchat/docker-compose.yml | 2 +- containers/stoat/docker-compose.yml | 40 ++++++----- docs/guide/upgrade-notes.md | 38 +++++++++- .../source/files/arrays/function_manifest.sh | 3 + .../webui_create_app_field_mappings.sh | 14 ++-- 19 files changed, 183 insertions(+), 61 deletions(-) create mode 100644 containers/mastodon/scripts/mastodon_install_hooks.sh diff --git a/containers/bookstack/bookstack.config b/containers/bookstack/bookstack.config index 581ea1d..ca7e7d0 100644 --- a/containers/bookstack/bookstack.config +++ b/containers/bookstack/bookstack.config @@ -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 diff --git a/containers/bookstack/docker-compose.yml b/containers/bookstack/docker-compose.yml index 2409b44..af967b2 100755 --- a/containers/bookstack/docker-compose.yml +++ b/containers/bookstack/docker-compose.yml @@ -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 diff --git a/containers/mastodon/docker-compose.yml b/containers/mastodon/docker-compose.yml index 711c4ea..756d5ef 100755 --- a/containers/mastodon/docker-compose.yml +++ b/containers/mastodon/docker-compose.yml @@ -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 diff --git a/containers/mastodon/mastodon.config b/containers/mastodon/mastodon.config index 142d011..4588a60 100755 --- a/containers/mastodon/mastodon.config +++ b/containers/mastodon/mastodon.config @@ -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 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 diff --git a/containers/mastodon/scripts/mastodon_install_hooks.sh b/containers/mastodon/scripts/mastodon_install_hooks.sh new file mode 100644 index 0000000..800d0b5 --- /dev/null +++ b/containers/mastodon/scripts/mastodon_install_hooks.sh @@ -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." +} diff --git a/containers/matrix/docker-compose.yml b/containers/matrix/docker-compose.yml index cdf54d8..2da64a5 100644 --- a/containers/matrix/docker-compose.yml +++ b/containers/matrix/docker-compose.yml @@ -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 diff --git a/containers/matrix/matrix.config b/containers/matrix/matrix.config index f304575..9c8bfe7 100644 --- a/containers/matrix/matrix.config +++ b/containers/matrix/matrix.config @@ -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 diff --git a/containers/matrix/scripts/matrix_install_hooks.sh b/containers/matrix/scripts/matrix_install_hooks.sh index e5a8f56..390f711 100644 --- a/containers/matrix/scripts/matrix_install_hooks.sh +++ b/containers/matrix/scripts/matrix_install_hooks.sh @@ -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 diff --git a/containers/mattermost/docker-compose.yml b/containers/mattermost/docker-compose.yml index eab60c7..92d5487 100644 --- a/containers/mattermost/docker-compose.yml +++ b/containers/mattermost/docker-compose.yml @@ -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 diff --git a/containers/mattermost/mattermost.config b/containers/mattermost/mattermost.config index 1a3f867..a6dcd93 100644 --- a/containers/mattermost/mattermost.config +++ b/containers/mattermost/mattermost.config @@ -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 diff --git a/containers/nextcloud/docker-compose.yml b/containers/nextcloud/docker-compose.yml index 5940fdb..2387915 100644 --- a/containers/nextcloud/docker-compose.yml +++ b/containers/nextcloud/docker-compose.yml @@ -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: diff --git a/containers/nextcloud/nextcloud.config b/containers/nextcloud/nextcloud.config index af73b93..3fd3aff 100755 --- a/containers/nextcloud/nextcloud.config +++ b/containers/nextcloud/nextcloud.config @@ -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 # # ============================================================================= diff --git a/containers/owncloud/docker-compose.yml b/containers/owncloud/docker-compose.yml index a482291..c0d117f 100755 --- a/containers/owncloud/docker-compose.yml +++ b/containers/owncloud/docker-compose.yml @@ -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 diff --git a/containers/owncloud/owncloud.config b/containers/owncloud/owncloud.config index 0c6e685..1bf6b4d 100755 --- a/containers/owncloud/owncloud.config +++ b/containers/owncloud/owncloud.config @@ -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 diff --git a/containers/rocketchat/docker-compose.yml b/containers/rocketchat/docker-compose.yml index f019c33..436e798 100644 --- a/containers/rocketchat/docker-compose.yml +++ b/containers/rocketchat/docker-compose.yml @@ -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 diff --git a/containers/stoat/docker-compose.yml b/containers/stoat/docker-compose.yml index 39f319a..17880ae 100644 --- a/containers/stoat/docker-compose.yml +++ b/containers/stoat/docker-compose.yml @@ -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 +# ` 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 (.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 diff --git a/docs/guide/upgrade-notes.md b/docs/guide/upgrade-notes.md index fbea729..555c10b 100644 --- a/docs/guide/upgrade-notes.md +++ b/docs/guide/upgrade-notes.md @@ -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__DB_PASSWORD` became `CFG__DB_PASSWORD_1`, and +`CFG__DB_ROOT_PASSWORD` became `CFG__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 +`..config.bak` sibling, so the old value is still there: + +```bash +grep DB_PASSWORD //..config.bak +``` + +Paste each value into the matching `_1` key in `//.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' //docker-compose.yml diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 86b70a2..8e6c2c0 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -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 "$@"; } diff --git a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh index 1e742f2..55fc859 100755 --- a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh +++ b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh @@ -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",