From 5706498565f6a352d0a52007be9f02feac94c030 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 18 Aug 2026 19:33:40 +0100 Subject: [PATCH] fix(secrets): move app credentials into .config, fix slot collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five apps (mastodon, owncloud, mattermost, matrix, stoat) took their generated secrets from the compose-side generator tags PASSWORD_TAG_/RANDOM_TAG_/ HEX_TAG_/VAPID_TAG_. Those mint a fresh secret on every templating run, so a reinstall handed the app a new database password while its data volume kept the one initdb was given, and the app came back up unable to open its own database. Move them to .config as RANDOMIZED* placeholders, reaching the compose via the #LIBREPORTAL|__TAG| mechanism tags_processor_app_config_values already provides. No new handler: the tag name is derived from the config key, so this is a config line plus a tag per secret. Generation is unchanged — still random on first install; the value is now remembered instead of re-rolled. Also fixes two things this exposed: - The RANDOMIZED* replacers matched unanchored. `sort -u` orders slots lexically (1, 10, 11, 2), so slot 1's pattern rewrote the prefix inside slot 10's placeholder and slots 10+ ended up holding slot 1's secret with a digit glued on — derivable, and invisible because the values weren't byte-identical. Anchoring with \b makes match order irrelevant. Verified at 20 slots across all four placeholder types: 64 keys, 64 distinct values, no prefix collisions. - generateRandomPassword drew from base64 without constraining the mix; measured over 2000 draws, 1 in 40 contained no digit at all. Retry until the result has both a digit and a letter, bounded so a pathological length can't spin. owncloud gains a fix in passing: its compose seeded the admin account from PASSWORD_TAG_2 while the WebUI displayed CFG_OWNCLOUD_ADMIN_PASSWORD, which was generated separately and never used. Both now read the same value. Co-Authored-By: Claude Opus 5 --- containers/mastodon/docker-compose.yml | 22 ++-- containers/mastodon/mastodon.config | 25 +++++ containers/matrix/docker-compose.yml | 2 +- containers/matrix/matrix.config | 6 + .../matrix/scripts/matrix_install_hooks.sh | 11 +- containers/mattermost/docker-compose.yml | 4 +- containers/mattermost/mattermost.config | 5 + containers/owncloud/docker-compose.yml | 20 ++-- containers/owncloud/owncloud.config | 11 ++ containers/stoat/docker-compose.yml | 6 +- containers/stoat/stoat.config | 7 ++ docs/guide/upgrade-notes.md | 49 ++++++-- .../bcrypt/password_process_bcrypt.sh | 2 +- scripts/config/password/password_generate.sh | 26 ++++- .../config/password/password_replace hex.sh | 6 +- .../config/password/password_replace vapid.sh | 6 +- scripts/config/password/password_replace.sh | 12 +- .../password/password_replace_appkey.sh | 6 +- .../config/password/password_user_replace.sh | 6 +- .../webui_create_app_field_mappings.sh | 105 ++++++++++++++++++ 20 files changed, 285 insertions(+), 52 deletions(-) diff --git a/containers/mastodon/docker-compose.yml b/containers/mastodon/docker-compose.yml index 01cb99e..711c4ea 100755 --- a/containers/mastodon/docker-compose.yml +++ b/containers/mastodon/docker-compose.yml @@ -15,18 +15,18 @@ services: - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA - LOCAL_DOMAIN=DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA - DB_HOST=mastodon-postgres - - DB_USER=RANDOM_DATA_1 #LIBREPORTAL|RANDOM_TAG_1|RANDOM_DATA_1 - - DB_PASS=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 - - DB_NAME=RANDOM_DATA_2 #LIBREPORTAL|RANDOM_TAG_2|RANDOM_DATA_2 + - 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_NAME=MASTODON_DB_NAME_DATA #LIBREPORTAL|MASTODON_DB_NAME_TAG|MASTODON_DB_NAME_DATA - REDIS_HOST=mastodon-redis - - SECRET_KEY_BASE=HEX_DATA_1 #LIBREPORTAL|HEX_TAG_1|HEX_DATA_1 - - OTP_SECRET=HEX_DATA_2 #LIBREPORTAL|HEX_TAG_2|HEX_DATA_2 - - VAPID_PRIVATE_KEY=VAPID_DATA_1 #LIBREPORTAL|VAPID_TAG_1|VAPID_DATA_1 - - VAPID_PUBLIC_KEY=VAPID_DATA_2 #LIBREPORTAL|VAPID_TAG_2|VAPID_DATA_2 + - SECRET_KEY_BASE=MASTODON_SECRET_KEY_BASE_DATA #LIBREPORTAL|MASTODON_SECRET_KEY_BASE_TAG|MASTODON_SECRET_KEY_BASE_DATA + - OTP_SECRET=MASTODON_OTP_SECRET_DATA #LIBREPORTAL|MASTODON_OTP_SECRET_TAG|MASTODON_OTP_SECRET_DATA + - VAPID_PRIVATE_KEY=MASTODON_VAPID_PRIVATE_KEY_DATA #LIBREPORTAL|MASTODON_VAPID_PRIVATE_KEY_TAG|MASTODON_VAPID_PRIVATE_KEY_DATA + - VAPID_PUBLIC_KEY=MASTODON_VAPID_PUBLIC_KEY_DATA #LIBREPORTAL|MASTODON_VAPID_PUBLIC_KEY_TAG|MASTODON_VAPID_PUBLIC_KEY_DATA - SMTP_SERVER= - SMTP_PORT=587 - SMTP_LOGIN= - - SMTP_PASSWORD=PASSWORD_DATA_2 #LIBREPORTAL|PASSWORD_TAG_2|PASSWORD_DATA_2 + - SMTP_PASSWORD= - SMTP_FROM_ADDRESS= - EMAIL_DELIVERY_METHOD=none - SMTP_AUTH_METHOD=none @@ -59,9 +59,9 @@ services: image: postgres:15 container_name: mastodon-postgres environment: - - POSTGRES_DB=RANDOM_DATA_2 #LIBREPORTAL|RANDOM_TAG_2|RANDOM_DATA_2 - - POSTGRES_USER=RANDOM_DATA_1 #LIBREPORTAL|RANDOM_TAG_1|RANDOM_DATA_1 - - POSTGRES_PASSWORD=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 + - 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 volumes: - ./postgres:/var/lib/postgresql/data networks: diff --git a/containers/mastodon/mastodon.config b/containers/mastodon/mastodon.config index ccbd85c..142d011 100755 --- a/containers/mastodon/mastodon.config +++ b/containers/mastodon/mastodon.config @@ -20,6 +20,31 @@ CFG_MASTODON_AUTHELIA=false CFG_MASTODON_HEADSCALE=false # # ============================================================================= +# SECRETS +# ============================================================================= +# These feed the compose via #LIBREPORTAL|MASTODON__TAG| tags. They are +# auto-generated on first install and — unlike a generator tag in the compose — +# preserved across reinstalls, which is what keeps them in step with the +# Postgres volume and with every logged-in session. +# +# DB_NAME / DB_USER / DB_PASSWORD = Postgres database, role and password. Set +# once by initdb when the volume is created; changing them afterwards without +# also changing them in Postgres locks Mastodon out of its own database. +# SECRET_KEY_BASE = signs and encrypts Rails session cookies. Rotating it logs +# 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. +# +CFG_MASTODON_DB_NAME=RANDOMIZEDUSERNAME1 +CFG_MASTODON_DB_USER=RANDOMIZEDUSERNAME2 +CFG_MASTODON_DB_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_MASTODON_SECRET_KEY_BASE=RANDOMIZEDHEX1 +CFG_MASTODON_OTP_SECRET=RANDOMIZEDHEX2 +CFG_MASTODON_VAPID_PRIVATE_KEY=RANDOMIZEDVAPID1 +CFG_MASTODON_VAPID_PUBLIC_KEY=RANDOMIZEDVAPID2 +# +# ============================================================================= # METADATA # ============================================================================= # CATEGORY = application category for grouping diff --git a/containers/matrix/docker-compose.yml b/containers/matrix/docker-compose.yml index 5213989..cdf54d8 100644 --- a/containers/matrix/docker-compose.yml +++ b/containers/matrix/docker-compose.yml @@ -103,7 +103,7 @@ services: environment: - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA - POSTGRES_USER=synapse - - POSTGRES_PASSWORD=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 + - POSTGRES_PASSWORD=MATRIX_DB_PASSWORD_DATA #LIBREPORTAL|MATRIX_DB_PASSWORD_TAG|MATRIX_DB_PASSWORD_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 2abe9f9..f304575 100644 --- a/containers/matrix/matrix.config +++ b/containers/matrix/matrix.config @@ -38,6 +38,12 @@ CFG_MATRIX_ENABLE_REGISTRATION=false 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 +# 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 # # ============================================================================= # METADATA diff --git a/containers/matrix/scripts/matrix_install_hooks.sh b/containers/matrix/scripts/matrix_install_hooks.sh index 78c4b34..e5a8f56 100644 --- a/containers/matrix/scripts/matrix_install_hooks.sh +++ b/containers/matrix/scripts/matrix_install_hooks.sh @@ -46,12 +46,13 @@ matrix_install_post_compose() return 1 fi - # Must match the password the compose handed to Postgres. Reading it back - # from the deployed compose is the only way to stay in step: the value is - # generated per-install by the password tag processor. + # 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 + # 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" "PASSWORD_TAG_1") - if [[ -z "$db_password" || "$db_password" == "PASSWORD_DATA_1" ]]; then + db_password=$(tagsManagerGetTagContent "$app_dir/docker-compose.yml" "MATRIX_DB_PASSWORD_TAG") + if [[ -z "$db_password" || "$db_password" == "MATRIX_DB_PASSWORD_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 48ac9af..eab60c7 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:PASSWORD_DATA_1@mattermost-postgres:5432/mattermost?sslmode=disable&connect_timeout=10 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 + - 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 # 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 @@ -83,7 +83,7 @@ services: environment: - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA - POSTGRES_USER=mattermost - - POSTGRES_PASSWORD=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 + - POSTGRES_PASSWORD=MATTERMOST_DB_PASSWORD_DATA #LIBREPORTAL|MATTERMOST_DB_PASSWORD_TAG|MATTERMOST_DB_PASSWORD_DATA - POSTGRES_DB=mattermost volumes: - ./postgres:/var/lib/postgresql/data diff --git a/containers/mattermost/mattermost.config b/containers/mattermost/mattermost.config index 4e83408..1a3f867 100644 --- a/containers/mattermost/mattermost.config +++ b/containers/mattermost/mattermost.config @@ -26,6 +26,11 @@ CFG_MATTERMOST_HEALTHCHECK=true 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 +# 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 # # ============================================================================= # METADATA diff --git a/containers/owncloud/docker-compose.yml b/containers/owncloud/docker-compose.yml index 488055d..a482291 100755 --- a/containers/owncloud/docker-compose.yml +++ b/containers/owncloud/docker-compose.yml @@ -20,12 +20,12 @@ services: - OWNCLOUD_DOMAIN=DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA - OWNCLOUD_TRUSTED_DOMAINS=TRUSTED_DOMAINS_DATA #LIBREPORTAL|TRUSTED_DOMAINS_TAG|TRUSTED_DOMAINS_DATA - OWNCLOUD_DB_TYPE=mysql - - OWNCLOUD_DB_NAME=RANDOM_DATA_3 #LIBREPORTAL|RANDOM_TAG_3|RANDOM_DATA_3 - - OWNCLOUD_DB_USERNAME=RANDOM_DATA_1 #LIBREPORTAL|RANDOM_TAG_1|RANDOM_DATA_1 - - OWNCLOUD_DB_PASSWORD=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 + - 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_HOST=owncloud-mariadb - - OWNCLOUD_ADMIN_USERNAME=RANDOM_DATA_2 #LIBREPORTAL|RANDOM_TAG_2|RANDOM_DATA_2 - - OWNCLOUD_ADMIN_PASSWORD=PASSWORD_DATA_2 #LIBREPORTAL|PASSWORD_TAG_2|PASSWORD_DATA_2 + - 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 - OWNCLOUD_MYSQL_UTF8MB4=true - OWNCLOUD_REDIS_ENABLED=true - OWNCLOUD_REDIS_HOST=owncloud-redis @@ -73,13 +73,13 @@ services: restart: unless-stopped hostname: mariadb environment: - - MYSQL_ROOT_PASSWORD=PASSWORD_DATA_3 #LIBREPORTAL|PASSWORD_TAG_3|PASSWORD_DATA_3 - - MYSQL_USER=RANDOM_DATA_1 #LIBREPORTAL|RANDOM_TAG_1|RANDOM_DATA_1 - - MYSQL_PASSWORD=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1 - - MYSQL_DATABASE=RANDOM_DATA_3 #LIBREPORTAL|RANDOM_TAG_3|RANDOM_DATA_3 + - MYSQL_ROOT_PASSWORD=OWNCLOUD_DB_ROOT_PASSWORD_DATA #LIBREPORTAL|OWNCLOUD_DB_ROOT_PASSWORD_TAG|OWNCLOUD_DB_ROOT_PASSWORD_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_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=PASSWORD_DATA_3"] #LIBREPORTAL|PASSWORD_TAG_3|PASSWORD_DATA_3 + test: ["CMD", "mysqladmin", "ping", "-u", "root", "--password=OWNCLOUD_DB_ROOT_PASSWORD_DATA"] #LIBREPORTAL|OWNCLOUD_DB_ROOT_PASSWORD_TAG|OWNCLOUD_DB_ROOT_PASSWORD_DATA interval: 10s timeout: 5s retries: 5 diff --git a/containers/owncloud/owncloud.config b/containers/owncloud/owncloud.config index 2a8c42f..0c6e685 100755 --- a/containers/owncloud/owncloud.config +++ b/containers/owncloud/owncloud.config @@ -25,9 +25,20 @@ CFG_OWNCLOUD_HEADSCALE=false # VERSION = specific version of the application to use # ADMIN_USERNAME = default admin username for the application # ADMIN_PASSWORD = default admin password (will be generated if set to RANDOMIZEDPASSWORD) +# DB_NAME / DB_USER / DB_PASSWORD = MariaDB schema + app account used by ownCloud +# DB_ROOT_PASSWORD = MariaDB root account; kept separate from DB_PASSWORD so the app user can be rotated without touching root +# +# Every value below feeds the compose via #LIBREPORTAL|OWNCLOUD__TAG| tags. +# They are generated on first install and preserved across reinstalls — which is +# what keeps them in step with the MariaDB volume, and what makes the admin +# credentials shown in the WebUI the ones ownCloud was actually seeded with. # 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 # # ============================================================================= # METADATA diff --git a/containers/stoat/docker-compose.yml b/containers/stoat/docker-compose.yml index aa7e624..39f319a 100644 --- a/containers/stoat/docker-compose.yml +++ b/containers/stoat/docker-compose.yml @@ -64,7 +64,7 @@ services: 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 + - RABBITMQ_DEFAULT_PASS=STOAT_RABBITMQ_PASSWORD_DATA #LIBREPORTAL|STOAT_RABBITMQ_PASSWORD_TAG|STOAT_RABBITMQ_PASSWORD_DATA volumes: - ./data/rabbit:/var/lib/rabbitmq healthcheck: @@ -92,7 +92,7 @@ services: 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_ROOT_PASSWORD=STOAT_MINIO_PASSWORD_DATA #LIBREPORTAL|STOAT_MINIO_PASSWORD_TAG|STOAT_MINIO_PASSWORD_DATA - MINIO_DOMAIN=minio networks: DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA @@ -123,7 +123,7 @@ services: # 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 + - MC_PASS=STOAT_MINIO_PASSWORD_DATA #LIBREPORTAL|STOAT_MINIO_PASSWORD_TAG|STOAT_MINIO_PASSWORD_DATA entrypoint: > /bin/sh -c " while ! /usr/bin/mc ready minio; do diff --git a/containers/stoat/stoat.config b/containers/stoat/stoat.config index 5863383..1165d4e 100644 --- a/containers/stoat/stoat.config +++ b/containers/stoat/stoat.config @@ -33,6 +33,13 @@ CFG_STOAT_AUTHELIA=false CFG_STOAT_HEADSCALE=false CFG_STOAT_ENABLE_VIDEO=true CFG_STOAT_MONITORING=false +# Service credentials fed to the compose via #LIBREPORTAL|STOAT__TAG|. +# Generated on first install and preserved across reinstalls: RabbitMQ writes +# its user into ./data/rabbit on first boot and ignores the env afterwards, so a +# regenerated password would leave the broker unreachable. MinIO's root password +# is shared by the object store and the bucket-creation job, which have to agree. +CFG_STOAT_RABBITMQ_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_STOAT_MINIO_PASSWORD=RANDOMIZEDPASSWORD2 # # ============================================================================= # METADATA diff --git a/docs/guide/upgrade-notes.md b/docs/guide/upgrade-notes.md index c06867c..fbea729 100644 --- a/docs/guide/upgrade-notes.md +++ b/docs/guide/upgrade-notes.md @@ -127,13 +127,44 @@ The VAPID keypair changed too; browsers re-subscribe to push on next login. Once the instance is healthy, remove the backup copy. -### Known limitation, both paths +## 0.2.0 — App credentials moved into `.config` -Re-templating an app regenerates every `PASSWORD_TAG_*` / `RANDOM_TAG_*` / -`HEX_TAG_*` / `VAPID_TAG_*` value — the generators mint a fresh secret on each run -and the tag manager writes it in. For any app whose database lives in a persistent -volume, that means a re-template can desynchronise the compose file from the -initialised database exactly as described above. This is not specific to Mastodon -or to this fix; app credentials that must survive re-templating are the ones held -in `.config` as `RANDOMIZEDPASSWORD` / `RANDOMIZEDUSERNAME`, which are -generated once and persisted. +**Affects:** existing installs of **mastodon, owncloud, mattermost, matrix** and +**stoat**. Nothing to do until you next reinstall one of them. + +### What changed + +Those five apps took their generated secrets from the compose-side generator tags +(`PASSWORD_TAG_`, `RANDOM_TAG_`, `HEX_TAG_`, `VAPID_TAG_`). Those mint +a fresh secret on **every** templating run, so a reinstall handed the app a brand +new database password while its data volume kept the one `initdb` was given. + +Their secrets now live in `.config` as `RANDOMIZED*` placeholders and reach +the compose through the same `#LIBREPORTAL|__TAG|` mechanism every other +config value uses — generated once on first install, then preserved. Passwords are +still randomly generated; nothing here asks you to choose one. + +### What an existing install sees + +Config reconciliation adds the new keys on update, still holding their +placeholders. The deployed compose is untouched until you reinstall, so the app +keeps running on its current credentials. + +On the next `libreportal app install `, the placeholders are filled with +freshly generated secrets — which will not match what the app's data volume was +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: + +```bash +grep -E 'POSTGRES_|MYSQL_|SECRET_KEY_BASE|OTP_SECRET|VAPID_|RABBITMQ_DEFAULT_PASS' //docker-compose.yml +``` + +then paste each value into the matching `CFG__` in +`//.config`, replacing the `RANDOMIZED*` placeholder. +The install will adopt what it finds rather than generating over it. If you skip +this, follow the database steps in the Mastodon section above — they apply to any +of the five, with that app's own database and role names. diff --git a/scripts/config/password/bcrypt/password_process_bcrypt.sh b/scripts/config/password/bcrypt/password_process_bcrypt.sh index aa265c1..4c3d999 100755 --- a/scripts/config/password/bcrypt/password_process_bcrypt.sh +++ b/scripts/config/password/bcrypt/password_process_bcrypt.sh @@ -35,7 +35,7 @@ processBcryptPassword() # Remove any single quotes from the bcrypt hash bcrypt_password=$(echo "$bcrypt_password" | tr -d "'") - local result; result=$(runCfgOp sed -i -E "s#$placeholder#$bcrypt_password#g" "$file") + local result; result=$(runCfgOp sed -i -E "s#$placeholder\\b#$bcrypt_password#g" "$file") checkSuccess "Use sed to replace placeholder with bcrypt hash" # Verify replacement diff --git a/scripts/config/password/password_generate.sh b/scripts/config/password/password_generate.sh index a7cd210..abb11ea 100755 --- a/scripts/config/password/password_generate.sh +++ b/scripts/config/password/password_generate.sh @@ -1,11 +1,29 @@ #!/bin/bash -generateRandomPassword() +generateRandomPassword() { local password="" local length=${CFG_GENERATED_PASS_LENGTH:-20} # Default to 20 if not set - - # Generate password with letters and numbers only (no special chars) - password=$(dd if=/dev/urandom bs=64 count=1 2>/dev/null | base64 | tr -d '+/=' | head -c $length) + local attempt + + # Letters and numbers only, no special characters: these values are embedded + # in connection URLs (Mattermost's Postgres DSN), interpolated into container + # entrypoints and written into YAML, and a symbol would need different + # escaping in each. + # + # Retry until the result carries at least one digit AND one letter. base64 of + # urandom is alphanumeric but says nothing about the mix — measured over 2000 + # draws at the default length, 1 in 40 came back with no digit at all, which + # trips any policy that requires one. At that hit rate the retry is free. + # Bounded rather than `while true` so a pathological length (or an empty + # /dev/urandom read) can't spin forever; a sub-2 length can't hold both + # classes at all, so it is accepted as-is. + for attempt in {1..50}; do + password=$(dd if=/dev/urandom bs=64 count=1 2>/dev/null | base64 | tr -d '+/=' | head -c $length) + [[ ${#password} -eq $length ]] || continue + [[ $length -lt 2 ]] && break + [[ "$password" == *[0-9]* && "$password" == *[A-Za-z]* ]] && break + done + echo "$password" } diff --git a/scripts/config/password/password_replace hex.sh b/scripts/config/password/password_replace hex.sh index 9947caa..e787608 100755 --- a/scripts/config/password/password_replace hex.sh +++ b/scripts/config/password/password_replace hex.sh @@ -7,13 +7,17 @@ replaceHexKeys() # Only scan for hex placeholders that actually exist in the file local existing_placeholders=$(runCfgOp grep -oE 'RANDOMIZEDHEX[0-9]*' "$file" 2>/dev/null | sort -u) + # \b on the substitution below: `sort -u` orders slots lexically (1, 10, 11, + # 2), so without it slot 1's pattern matches inside slot 10's placeholder and + # slots 10+ end up holding slot 1's secret with a digit appended. See + # password_replace.sh for the full description. if [[ -n "$existing_placeholders" ]]; then while IFS= read -r placeholder; do if [[ -n "$placeholder" ]]; then local hex_key hex_key=$(openssl rand -hex 32) - runCfgOp sed -i "s/${placeholder}/${hex_key}/g" "$file" + runCfgOp sed -i "s/${placeholder}\\b/${hex_key}/g" "$file" checkSuccess "Updated ${placeholder} in $(basename "$file") with a new hex key." fi done <<< "$existing_placeholders" diff --git a/scripts/config/password/password_replace vapid.sh b/scripts/config/password/password_replace vapid.sh index 9010a3e..7c795b4 100755 --- a/scripts/config/password/password_replace vapid.sh +++ b/scripts/config/password/password_replace vapid.sh @@ -7,13 +7,17 @@ replaceVAPIDKeys() # Only scan for VAPID placeholders that actually exist in the file local existing_placeholders=$(runCfgOp grep -oE 'RANDOMIZEDVAPID[0-9]*' "$file" 2>/dev/null | sort -u) + # \b on the substitution below: `sort -u` orders slots lexically (1, 10, 11, + # 2), so without it slot 1's pattern matches inside slot 10's placeholder and + # slots 10+ end up holding slot 1's secret with a digit appended. See + # password_replace.sh for the full description. if [[ -n "$existing_placeholders" ]]; then while IFS= read -r placeholder; do if [[ -n "$placeholder" ]]; then local vapid_key vapid_key=$(openssl rand -base64 32 | tr -d '+/=' | tr -cd '[:alnum:]') - runCfgOp sed -i "s/${placeholder}/${vapid_key}/g" "$file" + runCfgOp sed -i "s/${placeholder}\\b/${vapid_key}/g" "$file" checkSuccess "Updated ${placeholder} in $(basename "$file") with a new VAPID key." fi done <<< "$existing_placeholders" diff --git a/scripts/config/password/password_replace.sh b/scripts/config/password/password_replace.sh index eb92b8c..4b97524 100755 --- a/scripts/config/password/password_replace.sh +++ b/scripts/config/password/password_replace.sh @@ -4,14 +4,22 @@ replacePlainPasswords() { local file="$1" - # Only scan for placeholders that actually exist in the file + # Only scan for placeholders that actually exist in the file. + # + # The \b on the substitution below is load-bearing once a file uses ten or + # more slots. `sort -u` orders these lexically — 1, 10, 11, 2 — so an + # unanchored `s/RANDOMIZEDPASSWORD1//g` runs first and rewrites the + # RANDOMIZEDPASSWORD1 *inside* RANDOMIZEDPASSWORD10, leaving slot 10 holding + # slot 1's secret with a stray "0" on the end. Slots 10+ then share a secret + # derivable from slot 1, and nothing downstream notices because the values + # aren't byte-identical. The word boundary makes the match order irrelevant. local existing_placeholders=$(runCfgOp grep -oE 'RANDOMIZEDPASSWORD[0-9]+' "$file" 2>/dev/null | sort -u) if [[ -n "$existing_placeholders" ]]; then while IFS= read -r password_placeholder; do if [[ -n "$password_placeholder" ]]; then local random_password=$(generateRandomPassword) - runCfgOp sed -i 's/'"${password_placeholder}"'/'"${random_password}"'/g' "$file" + runCfgOp sed -i 's/'"${password_placeholder}"'\b/'"${random_password}"'/g' "$file" checkSuccess "Updated ${password_placeholder} in $(basename "$file")." fi done <<< "$existing_placeholders" diff --git a/scripts/config/password/password_replace_appkey.sh b/scripts/config/password/password_replace_appkey.sh index 60d3fe4..d1e2be4 100644 --- a/scripts/config/password/password_replace_appkey.sh +++ b/scripts/config/password/password_replace_appkey.sh @@ -10,12 +10,16 @@ replaceLaravelAppKeys() local existing_placeholders=$(runCfgOp grep -oE 'RANDOMIZEDAPPKEY[0-9]*' "$file" 2>/dev/null | sort -u) + # \b on the substitution below: `sort -u` orders slots lexically (1, 10, 11, + # 2), so without it slot 1's pattern matches inside slot 10's placeholder and + # slots 10+ end up holding slot 1's secret with a digit appended. See + # password_replace.sh for the full description. if [[ -n "$existing_placeholders" ]]; then while IFS= read -r placeholder; do if [[ -n "$placeholder" ]]; then local app_key app_key="base64:$(openssl rand -base64 32)" - runCfgOp sed -i "s#${placeholder}#${app_key}#g" "$file" + runCfgOp sed -i "s#${placeholder}\\b#${app_key}#g" "$file" checkSuccess "Updated ${placeholder} in $(basename "$file") with a new Laravel APP_KEY." fi done <<< "$existing_placeholders" diff --git a/scripts/config/password/password_user_replace.sh b/scripts/config/password/password_user_replace.sh index 0f7bedd..61788a3 100755 --- a/scripts/config/password/password_user_replace.sh +++ b/scripts/config/password/password_user_replace.sh @@ -7,11 +7,15 @@ replaceRandomUsernames() # Only scan for placeholders that actually exist in the file local existing_placeholders=$(runCfgOp grep -oE 'RANDOMIZEDUSERNAME[0-9]+' "$file" 2>/dev/null | sort -u) + # \b on the substitution below: `sort -u` orders slots lexically (1, 10, 11, + # 2), so without it slot 1's pattern matches inside slot 10's placeholder and + # slots 10+ end up holding slot 1's secret with a digit appended. See + # password_replace.sh for the full description. if [[ -n "$existing_placeholders" ]]; then while IFS= read -r username_placeholder; do if [[ -n "$username_placeholder" ]]; then local random_username=$(generateRandomUsername) - runCfgOp sed -i 's/'"${username_placeholder}"'/'"${random_username}"'/g' "$file" + runCfgOp sed -i 's/'"${username_placeholder}"'\b/'"${random_username}"'/g' "$file" checkSuccess "Updated ${username_placeholder} in $(basename "$file")." fi done <<< "$existing_placeholders" 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 1bb601a..1e742f2 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 @@ -357,6 +357,111 @@ PORTEOF "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 }, + "MASTODON_DB_NAME": { + "category": "advanced", + "label": "Database Name", + "type": "text", + "tooltip": "Postgres database Mastodon uses (internal to the docker network). Set by initdb when the volume is created — changing it afterwards needs the same change in Postgres.", + "advanced": true + }, + "MASTODON_DB_USER": { + "category": "advanced", + "label": "Database User", + "type": "text", + "tooltip": "Postgres role Mastodon connects with (internal to the docker network). Set by initdb when the volume is created.", + "advanced": true + }, + "MASTODON_DB_PASSWORD": { + "category": "advanced", + "label": "Database Password", + "type": "password", + "tooltip": "Postgres password for the Mastodon role (auto-generated; persists across reinstalls)", + "advanced": true + }, + "MASTODON_SECRET_KEY_BASE": { + "category": "advanced", + "label": "Secret Key Base", + "type": "password", + "tooltip": "Signs and encrypts session cookies (auto-generated). Changing it logs every user out.", + "advanced": true + }, + "MASTODON_OTP_SECRET": { + "category": "advanced", + "label": "OTP Secret", + "type": "password", + "tooltip": "Protects stored two-factor enrolments (auto-generated). Changing it invalidates them — anyone with 2FA on needs it reset before they can log in.", + "advanced": true + }, + "MASTODON_VAPID_PRIVATE_KEY": { + "category": "advanced", + "label": "VAPID Private Key", + "type": "password", + "tooltip": "Web Push identity (auto-generated). Changing it makes browsers re-subscribe.", + "advanced": true + }, + "MASTODON_VAPID_PUBLIC_KEY": { + "category": "advanced", + "label": "VAPID Public Key", + "type": "text", + "tooltip": "Web Push identity (auto-generated). Changing it makes browsers re-subscribe.", + "advanced": true + }, + "OWNCLOUD_DB_NAME": { + "category": "advanced", + "label": "Database Name", + "type": "text", + "tooltip": "MariaDB schema ownCloud uses (internal to the docker network)", + "advanced": true + }, + "OWNCLOUD_DB_USER": { + "category": "advanced", + "label": "Database User", + "type": "text", + "tooltip": "MariaDB account ownCloud connects with (internal to the docker network)", + "advanced": true + }, + "OWNCLOUD_DB_PASSWORD": { + "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": { + "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": { + "category": "advanced", + "label": "Database Password", + "type": "password", + "tooltip": "Postgres password for the mattermost role (auto-generated; persists across reinstalls)", + "advanced": true + }, + "MATRIX_DB_PASSWORD": { + "category": "advanced", + "label": "Database Password", + "type": "password", + "tooltip": "Postgres password for the synapse role (auto-generated; persists across reinstalls)", + "advanced": true + }, + "STOAT_RABBITMQ_PASSWORD": { + "category": "advanced", + "label": "RabbitMQ Password", + "type": "password", + "tooltip": "Broker password (auto-generated). RabbitMQ stores the user on first boot and ignores the value afterwards, so it persists across reinstalls.", + "advanced": true + }, + "STOAT_MINIO_PASSWORD": { + "category": "advanced", + "label": "MinIO Root Password", + "type": "password", + "tooltip": "Object store root password (auto-generated), shared by MinIO and the bucket-creation job.", + "advanced": true + }, "VAULTWARDEN_ADMIN_TOKEN": { "category": "general", "label": "Vaultwarden Admin Token",