From 1039363de697cea4e30ce656d627d27a4af1eae9 Mon Sep 17 00:00:00 2001 From: Pablo Zmdl Date: Mon, 23 Mar 2026 11:17:14 +0100 Subject: [PATCH] Allow arbitrary characters in passwords This converts some shell scripted commands to small golang tools ("aio-container-tools") in order to ensure proper string handling. In effect database passwords now can contain all characters, even emojis and quotes. AI-assistant: Copilot v1.0.7 (Claude Sonnet 4.6) Signed-off-by: Pablo Zmdl --- Containers/nextcloud/Dockerfile | 9 +- Containers/nextcloud/start.sh | 2 +- Containers/postgresql/Dockerfile | 10 ++ Containers/postgresql/healthcheck.sh | 4 +- Containers/postgresql/init-user-db.sh | 7 +- Containers/postgresql/start.sh | 10 +- aio-container-tools/README.md | 42 +++++++++ .../cmd/aio-pg-healthcheck/main.go | 92 +++++++++++++++++++ aio-container-tools/cmd/aio-pg-init/main.go | 78 ++++++++++++++++ aio-container-tools/go.mod | 10 ++ aio-container-tools/go.sum | 15 +++ aio-container-tools/internal/util/util.go | 49 ++++++++++ 12 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 aio-container-tools/README.md create mode 100644 aio-container-tools/cmd/aio-pg-healthcheck/main.go create mode 100644 aio-container-tools/cmd/aio-pg-init/main.go create mode 100644 aio-container-tools/go.mod create mode 100644 aio-container-tools/go.sum create mode 100644 aio-container-tools/internal/util/util.go diff --git a/Containers/nextcloud/Dockerfile b/Containers/nextcloud/Dockerfile index 8e66ff4a..9770149e 100644 --- a/Containers/nextcloud/Dockerfile +++ b/Containers/nextcloud/Dockerfile @@ -1,4 +1,11 @@ # syntax=docker/dockerfile:latest +FROM docker.io/library/golang:alpine AS aio-container-tools-builder + +# hadolint ignore=DL3022 +COPY --from=aio-container-tools . /tmp/aio-container-tools/ +RUN cd /tmp/aio-container-tools \ + && go build -o /usr/local/bin/aio-pg-healthcheck ./cmd/aio-pg-healthcheck + FROM php:8.3.30-fpm-alpine3.23 ENV PHP_MEMORY_LIMIT=512M @@ -17,6 +24,7 @@ COPY --chmod=775 Containers/nextcloud/*.sh / COPY --chmod=774 Containers/nextcloud/upgrade.exclude /upgrade.exclude COPY Containers/nextcloud/config/*.php / COPY Containers/nextcloud/supervisord.conf /supervisord.conf +COPY --from=aio-container-tools-builder /usr/local/bin/aio-pg-healthcheck /usr/local/bin/aio-pg-healthcheck # AIO cloning start # Do not remove or change this line! COPY app /usr/src/nextcloud/apps/nextcloud-aio @@ -226,7 +234,6 @@ RUN set -ex; \ openssl \ gnupg \ git \ - postgresql-client \ tzdata \ sudo \ grep \ diff --git a/Containers/nextcloud/start.sh b/Containers/nextcloud/start.sh index a5f38534..6061f79c 100644 --- a/Containers/nextcloud/start.sh +++ b/Containers/nextcloud/start.sh @@ -25,7 +25,7 @@ fi # Fix false database connection on old instances if [ -f "/var/www/html/config/config.php" ]; then sleep 2 - while ! sudo -E -u www-data psql -d "postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB" -c "select now()"; do + while ! sudo -E -u www-data /usr/local/bin/aio-pg-healthcheck; do echo "Waiting for the database to start..." sleep 5 done diff --git a/Containers/postgresql/Dockerfile b/Containers/postgresql/Dockerfile index 980ed423..34236331 100644 --- a/Containers/postgresql/Dockerfile +++ b/Containers/postgresql/Dockerfile @@ -1,8 +1,18 @@ # syntax=docker/dockerfile:latest +FROM docker.io/library/golang:alpine AS aio-container-tools-builder + +# hadolint ignore=DL3022 +COPY --from=aio-container-tools . /tmp/aio-container-tools/ +RUN cd /tmp/aio-container-tools \ + && go build -o /usr/local/bin/aio-pg-init ./cmd/aio-pg-init \ + && go build -o /usr/local/bin/aio-pg-healthcheck ./cmd/aio-pg-healthcheck + # From https://github.com/docker-library/postgres/blob/master/17/alpine3.23/Dockerfile FROM postgres:17.9-alpine COPY --chmod=775 start.sh /start.sh +COPY --from=aio-container-tools-builder /usr/local/bin/aio-pg-init /usr/local/bin/aio-pg-init +COPY --from=aio-container-tools-builder /usr/local/bin/aio-pg-healthcheck /usr/local/bin/aio-pg-healthcheck COPY --chmod=775 healthcheck.sh /healthcheck.sh COPY --chmod=775 init-user-db.sh /docker-entrypoint-initdb.d/init-user-db.sh diff --git a/Containers/postgresql/healthcheck.sh b/Containers/postgresql/healthcheck.sh index 9f303a3a..cceaeb20 100644 --- a/Containers/postgresql/healthcheck.sh +++ b/Containers/postgresql/healthcheck.sh @@ -2,6 +2,4 @@ test -f "/mnt/data/backup-is-running" && exit 0 -psql -d "postgresql://oc_$POSTGRES_USER:$POSTGRES_PASSWORD@127.0.0.1:11000/$POSTGRES_DB" -c "select now()" && exit 0 - -psql -d "postgresql://oc_$POSTGRES_USER:$POSTGRES_PASSWORD@127.0.0.1:5432/$POSTGRES_DB" -c "select now()" || exit 1 +POSTGRES_PORT=11000 /usr/local/bin/aio-pg-healthcheck debug || exec /usr/local/bin/aio-pg-healthcheck diff --git a/Containers/postgresql/init-user-db.sh b/Containers/postgresql/init-user-db.sh index cfd3827a..28c27ef9 100644 --- a/Containers/postgresql/init-user-db.sh +++ b/Containers/postgresql/init-user-db.sh @@ -3,12 +3,7 @@ set -ex touch "$DUMP_DIR/initialization.failed" -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE USER "oc_$POSTGRES_USER" WITH PASSWORD '$POSTGRES_PASSWORD' CREATEDB; - ALTER DATABASE "$POSTGRES_DB" OWNER TO "oc_$POSTGRES_USER"; - GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_DB" TO "oc_$POSTGRES_USER"; - GRANT ALL PRIVILEGES ON SCHEMA public TO "oc_$POSTGRES_USER"; -EOSQL +POSTGRES_DB_OWNER="oc_$POSTGRES_USER" /usr/local/bin/aio-pg-init rm "$DUMP_DIR/initialization.failed" diff --git a/Containers/postgresql/start.sh b/Containers/postgresql/start.sh index 551bb10e..b4d2db5a 100644 --- a/Containers/postgresql/start.sh +++ b/Containers/postgresql/start.sh @@ -4,6 +4,7 @@ DATADIR="/var/lib/postgresql/data" export DUMP_DIR="/mnt/data" DUMP_FILE="$DUMP_DIR/database-dump.sql" +# TODO: Do we need this? It's not used anywhere visible export PGPASSWORD="$POSTGRES_PASSWORD" # Don't start database as long as backup is running @@ -85,7 +86,7 @@ if ( [ -f "$DATADIR/PG_VERSION" ] && [ "$PG_MAJOR" != "$(cat "$DATADIR/PG_VERSIO exec docker-entrypoint.sh postgres & # Wait for creation - while ! psql -d "postgresql://oc_$POSTGRES_USER:$POSTGRES_PASSWORD@127.0.0.1:11000/$POSTGRES_DB" -c "select now()"; do + while ! env POSTGRES_PORT=11000 POSTGRES_USER="oc_$POSTGRES_USER" /usr/local/bin/aio-pg-healthcheck; do echo "Waiting for the database to start." sleep 5 done @@ -107,12 +108,7 @@ if ( [ -f "$DATADIR/PG_VERSION" ] && [ "$PG_MAJOR" != "$(cat "$DATADIR/PG_VERSIO exit 1 elif [ "$DB_OWNER" != "oc_$POSTGRES_USER" ]; then DIFFERENT_DB_OWNER=1 - psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE USER "$DB_OWNER" WITH PASSWORD '$POSTGRES_PASSWORD' CREATEDB; - ALTER DATABASE "$POSTGRES_DB" OWNER TO "$DB_OWNER"; - GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_DB" TO "$DB_OWNER"; - GRANT ALL PRIVILEGES ON SCHEMA public TO "$DB_OWNER"; -EOSQL + POSTGRES_DB_OWNER="$DB_OWNER" /usr/local/bin/aio-pg-init fi # Restore database diff --git a/aio-container-tools/README.md b/aio-container-tools/README.md new file mode 100644 index 00000000..bea1a1ad --- /dev/null +++ b/aio-container-tools/README.md @@ -0,0 +1,42 @@ +# aio-container-tools + +Standalone tools for Nextcloud AIO containers, for tasks that shouldn't be executed in a shell environment +(e.g. due to string handling issues). + +Golang was choosen because it doesn't require additional runtimes in the containers, and has a pretty easy +syntax that is comprehensible even for people without much experience with the language. + +The tools should be built in the container image build process, so they are built for the correct target +platform in multi-arch builds. See below for an example. + +## Build process + +To include the binary of `aio-pg-healhcheck` into your container image, include such a snippet into your Containerfile: + +```dockerfile +FROM docker.io/library/golang:alpine AS golang-builder + +# hadolint ignore=DL3022 +COPY --from=aio-container-tools . /tmp/aio-container-tools/ +RUN cd /tmp/aio-container-tools \ + && go build -o /usr/local/bin/aio-pg-healthcheck ./cmd/aio-pg-healthcheck + +FROM your-base-image +COPY --from=golang-builder /usr/local/bin/aio-pg-healthcheck /usr/local/bin/ +``` + +To build it you now have to pass the aio-container-tools directory as additional, named build-context like this: + +```bash +docker build \ + --build-context aio-container-tools=/path/to/all-in-one/aio-container-tools \ + . +``` + +#### Remote git variant (without local clone of this repo) + +```bash +docker build \ + --build-context aio-container-tools="https://github.com/nextcloud-releases/all-in-one.git#main:aio-container-tools" \ + . +``` diff --git a/aio-container-tools/cmd/aio-pg-healthcheck/main.go b/aio-container-tools/cmd/aio-pg-healthcheck/main.go new file mode 100644 index 00000000..c06d06cc --- /dev/null +++ b/aio-container-tools/cmd/aio-pg-healthcheck/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strconv" + + "github.com/jackc/pgx/v5" + "github.com/nextcloud/aio-container-tools/internal/util" +) + +// tryConnect opens a TCP connection to the given database host:port and runs SELECT 1. +// Returns nil on success, an error otherwise. +func tryConnect(ctx context.Context, host string, port uint16, user, password, database string) error { + util.Debugf("attempting connection: host=%s port=%d user=%s database=%s", host, port, user, database) + + cfg, err := pgx.ParseConfig("") + if err != nil { + return err + } + cfg.Host = host + cfg.Port = port + cfg.User = user + cfg.Password = password + cfg.Database = database + + conn, err := pgx.ConnectConfig(ctx, cfg) + if err != nil { + util.Debugf("connection failed: %v", err) + return err + } + defer conn.Close(ctx) + + util.Debugf("connection established, running SELECT 1") + var result string + if err := conn.QueryRow(ctx, "SELECT 1").Scan(&result); err != nil { + util.Debugf("SELECT 1 failed: %v", err) + return err + } + util.Debugf("SELECT 1 returned %q", result) + return nil +} + +// envOrDefault returns the value of the named environment variable, +// or the provided default if the variable is unset or empty. +func envOrDefault(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + util.Debugf("env %s = %q", key, v) + return v + } + util.Debugf("env %s not set, using default %q", key, defaultVal) + return defaultVal +} + +func main() { + debug := flag.Bool("debug", false, "enable debug output") + flag.Parse() + util.SetDebug(*debug) + + util.Debugf("reading required environment variables") + pgUser := util.RequireEnv("POSTGRES_USER") + pgPassword := util.RequireEnv("POSTGRES_PASSWORD") + pgDB := util.RequireEnv("POSTGRES_DB") + + ctx := context.Background() + + pgHost := envOrDefault("POSTGRES_HOST", "127.0.0.1") + + var pgPort uint16 = 5432 + if portStr := os.Getenv("POSTGRES_PORT"); portStr != "" { + util.Debugf("env POSTGRES_PORT = %q", portStr) + p, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid POSTGRES_PORT %q: %v\n", portStr, err) + os.Exit(1) + } + pgPort = uint16(p) + } else { + util.Debugf("env POSTGRES_PORT not set, using default port %d", pgPort) + } + + util.Debugf("connecting to: host=%s port=%d user=%s", pgHost, pgPort, pgUser) + if err := tryConnect(ctx, pgHost, pgPort, pgUser, pgPassword, pgDB); err == nil { + util.Debugf("connection succeeded, exiting 0") + os.Exit(0) + } + + util.Debugf("connection failed, exiting 1") + os.Exit(1) +} diff --git a/aio-container-tools/cmd/aio-pg-init/main.go b/aio-container-tools/cmd/aio-pg-init/main.go new file mode 100644 index 00000000..05ec0f63 --- /dev/null +++ b/aio-container-tools/cmd/aio-pg-init/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "flag" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/nextcloud/aio-container-tools/internal/util" +) + +// quoteLiteral safely quotes a string as a PostgreSQL string literal. +// Single quotes are escaped by doubling them. This is safe with +// standard_conforming_strings=on (default since PostgreSQL 9.1). +func quoteLiteral(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// main reimplements init-user-db.sh: +// - Creates $POSTGRES_DB_OWNER (falling back to $POSTGRES_USER) with $POSTGRES_PASSWORD and CREATEDB +// - Transfers ownership of $POSTGRES_DB to that user +// - Grants all privileges on the database and public schema +// - Connects using $POSTGRES_USER in all cases +func main() { + debug := flag.Bool("debug", false, "enable debug output") + flag.Parse() + util.SetDebug(*debug) + + util.Debugf("reading required environment variables") + pgUser := util.RequireEnv("POSTGRES_USER") + pgPassword := util.RequireEnv("POSTGRES_PASSWORD") + pgDB := util.RequireEnv("POSTGRES_DB") + pgDBOwner := util.OptionalEnv("POSTGRES_DB_OWNER", pgUser) + + util.Debugf("building connection config: host=/var/run/postgresql port=5432 user=%s database=%s", pgUser, pgDB) + cfg, err := pgx.ParseConfig("") + if err != nil { + util.ErrorOut(fmt.Errorf("building connection config: %w", err)) + } + cfg.Host = "/var/run/postgresql" + cfg.Port = 5432 + cfg.User = pgUser + cfg.Password = pgPassword + cfg.Database = pgDB + + ctx := context.Background() + util.Debugf("connecting to postgres via unix socket") + conn, err := pgx.ConnectConfig(ctx, cfg) + if err != nil { + util.ErrorOut(fmt.Errorf("connecting to postgres: %w", err)) + } + defer conn.Close(ctx) + util.Debugf("connected successfully") + + dbOwner := pgDBOwner + util.Debugf("dbOwner = %q (from POSTGRES_DB_OWNER=%q, POSTGRES_USER=%q)", dbOwner, pgDBOwner, pgUser) + // pgx.Identifier.Sanitize() double-quotes and escapes the identifier safely. + dbOwnerIdent := pgx.Identifier{dbOwner}.Sanitize() + dbIdent := pgx.Identifier{pgDB}.Sanitize() + util.Debugf("quoted dbOwnerIdent = %s, dbIdent = %s", dbOwnerIdent, dbIdent) + + statements := []string{ + fmt.Sprintf("CREATE USER %s WITH PASSWORD %s CREATEDB", dbOwnerIdent, quoteLiteral(pgPassword)), + fmt.Sprintf("ALTER DATABASE %s OWNER TO %s", dbIdent, dbOwnerIdent), + fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE %s TO %s", dbIdent, dbOwnerIdent), + fmt.Sprintf("GRANT ALL PRIVILEGES ON SCHEMA public TO %s", dbOwnerIdent), + } + + for i, stmt := range statements { + util.Debugf("executing statement %d/%d: %s", i+1, len(statements), stmt) + if _, err := conn.Exec(ctx, stmt); err != nil { + util.ErrorOut(fmt.Errorf("executing statement: %w", err)) + } + util.Debugf("statement %d/%d succeeded", i+1, len(statements)) + } + util.Debugf("all statements executed successfully") +} diff --git a/aio-container-tools/go.mod b/aio-container-tools/go.mod new file mode 100644 index 00000000..289ab938 --- /dev/null +++ b/aio-container-tools/go.mod @@ -0,0 +1,10 @@ +module github.com/nextcloud/aio-container-tools + +go 1.25.1 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/aio-container-tools/go.sum b/aio-container-tools/go.sum new file mode 100644 index 00000000..38c2503d --- /dev/null +++ b/aio-container-tools/go.sum @@ -0,0 +1,15 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/aio-container-tools/internal/util/util.go b/aio-container-tools/internal/util/util.go new file mode 100644 index 00000000..8434db62 --- /dev/null +++ b/aio-container-tools/internal/util/util.go @@ -0,0 +1,49 @@ +package util + +import ( + "fmt" + "log" + "os" +) + +var debugEnabled bool + +// SetDebug enables or disables debug output. +func SetDebug(enabled bool) { + debugEnabled = enabled +} + +// Debugf prints a formatted debug message to stdout when debug mode is enabled. +func Debugf(format string, args ...any) { + if debugEnabled { + fmt.Printf("[debug] "+format+"\n", args...) + } +} + +// RequireEnv returns the value of the named environment variable. +// It writes an error to stderr and exits with code 1 if the variable is unset or empty. +func RequireEnv(key string) string { + v := os.Getenv(key) + if v == "" { + fmt.Fprintf(os.Stderr, "required environment variable %q is not set\n", key) + os.Exit(1) + } + Debugf("env %s = %q", key, v) + return v +} + +// OptionalEnv returns the value of the named environment variable, or fallback if it is unset or empty. +func OptionalEnv(key, fallback string) string { + v := os.Getenv(key) + if v == "" { + Debugf("env %s unset, using fallback %q", key, fallback) + return fallback + } + Debugf("env %s = %q", key, v) + return v +} + +// ErrorOut logs the error with a standard prefix and exits with code 1. +func ErrorOut(err error) { + log.Fatalf("error: %v", err) +}