fix(secrets): move app credentials into <app>.config, fix slot collision

Five apps (mastodon, owncloud, mattermost, matrix, stoat) took their generated
secrets from the compose-side generator tags PASSWORD_TAG_<n>/RANDOM_TAG_<n>/
HEX_TAG_<n>/VAPID_TAG_<n>. 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 <app>.config as RANDOMIZED* placeholders, reaching the compose via
the #LIBREPORTAL|<APP>_<KEY>_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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 19:33:40 +01:00
parent c11052b753
commit 5706498565
20 changed files with 285 additions and 52 deletions

View File

@ -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:

View File

@ -20,6 +20,31 @@ CFG_MASTODON_AUTHELIA=false
CFG_MASTODON_HEADSCALE=false
#
# =============================================================================
# SECRETS
# =============================================================================
# These feed the compose via #LIBREPORTAL|MASTODON_<KEY>_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

View File

@ -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

View File

@ -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

View File

@ -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

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: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

View File

@ -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

View File

@ -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

View File

@ -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_<KEY>_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

View File

@ -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

View File

@ -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_<KEY>_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

View File

@ -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 `<app>.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 `<app>.config` as `RANDOMIZEDPASSWORD<n>` / `RANDOMIZEDUSERNAME<n>`, 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_<n>`, `RANDOM_TAG_<n>`, `HEX_TAG_<n>`, `VAPID_TAG_<n>`). 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 `<app>.config` as `RANDOMIZED*` placeholders and reach
the compose through the same `#LIBREPORTAL|<APP>_<KEY>_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 <app>`, 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' <containers-dir>/<app>/docker-compose.yml
```
then paste each value into the matching `CFG_<APP>_<KEY>` in
`<containers-dir>/<app>/<app>.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.

View File

@ -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

View File

@ -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"
}

View File

@ -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"

View File

@ -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"

View File

@ -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/<secret>/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"

View File

@ -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"

View File

@ -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"

View File

@ -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",