From bd4293f53efa3428566612d123b6b76d15f86d98 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 13 Jun 2026 14:30:40 +0200 Subject: [PATCH 1/2] fix(nexus-db): enforce UTF8 encoding at install time (issue #8) A PostgreSQL cluster/database freezes its encoding at initdb / CREATE DATABASE time; a C (non-UTF-8) locale yields a SQL_ASCII cluster, which makes psycopg3 return bytes and crashes SQLAlchemy. Harden the installer and add a reusable pattern for future DB installers: - ensure_utf8_locale_active: generate AND activate en_US.UTF-8 for the install process before the server package runs initdb; abort if the locale is not actually available - create the database explicitly with TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE/LC_CTYPE 'en_US.UTF-8' instead of inheriting the cluster default - assert_db_encoding_utf8: post-install guard, abort with an actionable message if pg_encoding_to_char is not UTF8 (catches old SQL_ASCII DBs on re-run too) - credentials/README docs use su - postgres -c (minimal LXCs have no sudo) --- README.md | 2 ++ install/nexus-db-install.sh | 27 ++++++++++++++++++----- lib/install.func | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 917d226..13aaf9a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Two files per app, both sourcing the shared libs via `curl`: - **`ct/.sh`** runs on the PVE host: prompts → unprivileged LXC → pushes a config env file into the container → bootstraps the installer. Apps that need Docker (authentik, runner) enable `nesting+keyctl` automatically. The standard prompts include an **SSH root login choice** (`SSH_ROOT_LOGIN`, default yes for homelab convenience; `no` keeps the Debian key-only default) — applied inside the container as an sshd drop-in by `configure_ssh_root_login`. - **`install/-install.sh`** runs inside the LXC: packages, unprivileged app user, secrets generated on-host (never printed), systemd units, a `/root/.credentials` notes file — then shreds the bootstrap env. Idempotent where it matters: re-runs skip what exists. `setup_base_apt` also fixes the bare-template **locale situation**: `C.UTF-8` is exported up front (glibc built-in, covers the first apt run without perl warnings), then `en_US.UTF-8` is generated and set as the system default. +**DB installers** carry one extra rule: a PostgreSQL cluster/database freezes its encoding at initdb / `CREATE DATABASE` time and it can never be changed afterwards. A C (non-UTF-8) locale yields a `SQL_ASCII` cluster — psycopg3 then hands text back as bytes and SQLAlchemy crashes. So the pattern (helpers `ensure_utf8_locale_active` + `assert_db_encoding_utf8` in `lib/install.func`) is: make a UTF-8 locale **active** before the server package runs initdb, create the database **explicitly** with `TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE/LC_CTYPE 'en_US.UTF-8'` (never inherit the cluster default), and **verify** `pg_encoding_to_char` returns `UTF8` before finishing — a wrong encoding aborts the install (it's DB damage, see wiki → Lessons). Maintenance examples use `su - postgres -c …`, not `sudo` — these minimal LXCs have no sudo. + Shared libs: [`lib/build.func`](lib/build.func) (host-side: prompts, LXC create, bootstrap) and [`lib/install.func`](lib/install.func) (in-container: apt, users, systemd, http-wait). ## Security conventions diff --git a/install/nexus-db-install.sh b/install/nexus-db-install.sh index 9bf3f77..1f57a44 100755 --- a/install/nexus-db-install.sh +++ b/install/nexus-db-install.sh @@ -47,6 +47,12 @@ else msg_warn "PGDG repo already present, skipping" fi +# The server package runs initdb for the `main` cluster on install — its +# encoding is frozen there. Make a UTF-8 locale active for THIS process first +# so the cluster is never created as SQL_ASCII (issue #8: a pre-fix LXC was +# provisioned under LANG=C and ended up SQL_ASCII). +ensure_utf8_locale_active en_US.UTF-8 + msg_info "Installing PostgreSQL $PG_MAJOR + pgvector..." apt-get install -y -qq "postgresql-$PG_MAJOR" "postgresql-$PG_MAJOR-pgvector" >/dev/null msg_ok "PostgreSQL $(psql --version | awk '{print $3}') installed" @@ -114,14 +120,22 @@ else fi if [[ "$(run_psql -c "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")" != "1" ]]; then - run_psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER" + # Explicit encoding/collation from template0 — never inherit the cluster + # default, which may be SQL_ASCII if initdb ran under a broken locale + # (issue #8). template0 is required to override LC_COLLATE/LC_CTYPE. + run_psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0" # Only the owner may connect — no PUBLIC access. run_psql -c "REVOKE CONNECT ON DATABASE $DB_NAME FROM PUBLIC" - msg_ok "Database $DB_NAME created (owner $DB_USER, PUBLIC revoked)" + msg_ok "Database $DB_NAME created (UTF8, owner $DB_USER, PUBLIC revoked)" else - msg_warn "Database $DB_NAME already exists, skipping" + msg_warn "Database $DB_NAME already exists, skipping creation" fi +# Encoding guard (issue #8): catch both a freshly mis-created DB and a +# pre-existing SQL_ASCII database from an old provisioning. Abort before the +# app ever connects — a wrong encoding is DB damage, not a warning. +assert_db_encoding_utf8 "$DB_NAME" + # pgvector: CREATE EXTENSION needs superuser; installed now (per ADR-0002: # "Extension ab Tag 1 installiert, ungenutzt bis Phase 2"). run_psql -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS vector" >/dev/null @@ -141,9 +155,12 @@ Password: $DB_PASS DSN for /etc/nexus/env on the nexus LXC (NEXUS_DATABASE_URL): postgresql+psycopg://$DB_USER:$DB_PASS@$IP_SELF:$DB_PORT/$DB_NAME +Encoding: UTF8 (LC_COLLATE/LC_CTYPE en_US.UTF-8) — verified at install time. + Access policy (pg_hba): only $NEXUS_APP_IP/32 may connect; all other -hosts are rejected. Local socket stays peer-auth for maintenance: - pct exec -- runuser -u postgres -- psql -d $DB_NAME +hosts are rejected. Local socket stays peer-auth for maintenance (these +minimal LXCs have no sudo — use su, not sudo): + pct exec -- su - postgres -c "psql -d $DB_NAME" EOF chmod 600 "$CRED_FILE" msg_ok "Credentials written to $CRED_FILE (chmod 600)" diff --git a/lib/install.func b/lib/install.func index 2bc3a5c..d1bf1a1 100644 --- a/lib/install.func +++ b/lib/install.func @@ -54,6 +54,50 @@ apt_cleanup() { apt-get autoclean -qq >/dev/null || true } +# ── database installers: UTF-8 locale before initdb ────────────────────────── +# Pattern for every DB installer. A PostgreSQL cluster/database freezes its +# encoding at initdb / CREATE DATABASE time and it cannot be changed later — +# a C (non-UTF-8) locale yields a SQL_ASCII cluster. psycopg3 then returns +# text as bytes and SQLAlchemy crashes on server-version detection; the app +# reports "db: unreachable". So: GENERATE the UTF-8 locale AND make it active +# for THIS process before the server package runs its automatic initdb, then +# fail loudly if it is not actually available (generating alone is not enough +# — the locale must be active when initdb runs). +ensure_utf8_locale_active() { + local loc="${1:-en_US.UTF-8}" + msg_info "Ensuring $loc is generated and active (DB encoding is frozen at initdb)..." + if ! locale -a 2>/dev/null | tr 'A-Z' 'a-z' | grep -q '^en_us\.utf-\?8$'; then + sed -i 's/^# *en_US\.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen + locale-gen >/dev/null + fi + if ! locale -a 2>/dev/null | tr 'A-Z' 'a-z' | grep -q '^en_us\.utf-\?8$'; then + msg_err "Locale $loc not available after locale-gen — refusing to continue (initdb would create a SQL_ASCII cluster)" + return 1 + fi + # Activate for the current process so any automatic initdb during the + # server package install inherits a UTF-8 locale, not the bare-template C. + export LANG="$loc" LC_ALL="$loc" + msg_ok "Locale active for initdb: LANG=$LANG" +} + +# Post-install guard: a database MUST be UTF8. Encoding is irreversible, so a +# wrong value is database damage — abort with a clear, actionable message +# instead of shipping a broken cluster. Reads via `su - postgres -c` (minimal +# LXCs have no sudo). +assert_db_encoding_utf8() { + local db="$1" enc + enc="$(su - postgres -c "psql -X -qAt -c \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname='$db'\"")" + if [[ "$enc" != "UTF8" ]]; then + msg_err "Database '$db' has encoding '${enc:-}', expected UTF8." + msg_err "Encoding is frozen at creation time — this is DB damage, not cosmetic." + msg_err "Fix: regenerate the locale (locale-gen en_US.UTF-8) and recreate the DB" + msg_err " with: CREATE DATABASE $db ... TEMPLATE template0 ENCODING 'UTF8'" + msg_err " LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8';" + return 1 + fi + msg_ok "Encoding check: database '$db' is UTF8" +} + # ── ssh ────────────────────────────────────────────────────────────────────── # SSH-Root-Login gemäß Host-Prompt (prompt_lxc_config setzt SSH_ROOT_LOGIN, # bootstrap_install_script reicht es als Env durch; Default: yes). From 6543fd77d8f1d79733703ce633282d0fd5450a67 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 13 Jun 2026 14:34:35 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(nexus-db):=20address=20codex=20review?= =?UTF-8?q?=20=E2=80=94=20early=20encoding=20guard,=20robust=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assert encoding BEFORE role/password mutation on re-run, so an old SQL_ASCII DB aborts with no side effects (codex finding 1) - ensure_utf8_locale_active honours its locale argument consistently in match, locale.gen line and export (codex finding 2) - assert_db_encoding_utf8 uses argv-clean runuser psql with :'db' literal binding instead of nested su -c shell; docs keep su - postgres -c (codex finding 3) --- install/nexus-db-install.sh | 15 ++++++++++----- lib/install.func | 22 ++++++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/install/nexus-db-install.sh b/install/nexus-db-install.sh index 1f57a44..f5dafad 100755 --- a/install/nexus-db-install.sh +++ b/install/nexus-db-install.sh @@ -101,6 +101,13 @@ systemctl restart postgresql # ── role + database + extension (idempotent, password kept on re-run) ───────── run_psql() { runuser -u postgres -- psql -v ON_ERROR_STOP=1 -qAt "$@"; } +# Re-run safety (issue #8): if the database already exists, verify its +# encoding BEFORE touching roles/passwords. An old SQL_ASCII database must +# abort the run with NO side effects — not after rotating credentials. +if [[ "$(run_psql -c "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")" == "1" ]]; then + assert_db_encoding_utf8 "$DB_NAME" +fi + if [[ "$(run_psql -c "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")" != "1" ]]; then msg_info "Creating role $DB_USER + database $DB_NAME..." DB_PASS="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)" @@ -126,16 +133,14 @@ if [[ "$(run_psql -c "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")" != " run_psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0" # Only the owner may connect — no PUBLIC access. run_psql -c "REVOKE CONNECT ON DATABASE $DB_NAME FROM PUBLIC" + # Verify what we just created (the pre-existing case was already checked + # before the role block, issue #8). + assert_db_encoding_utf8 "$DB_NAME" msg_ok "Database $DB_NAME created (UTF8, owner $DB_USER, PUBLIC revoked)" else msg_warn "Database $DB_NAME already exists, skipping creation" fi -# Encoding guard (issue #8): catch both a freshly mis-created DB and a -# pre-existing SQL_ASCII database from an old provisioning. Abort before the -# app ever connects — a wrong encoding is DB damage, not a warning. -assert_db_encoding_utf8 "$DB_NAME" - # pgvector: CREATE EXTENSION needs superuser; installed now (per ADR-0002: # "Extension ab Tag 1 installiert, ungenutzt bis Phase 2"). run_psql -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS vector" >/dev/null diff --git a/lib/install.func b/lib/install.func index d1bf1a1..a8fd0fc 100644 --- a/lib/install.func +++ b/lib/install.func @@ -63,14 +63,21 @@ apt_cleanup() { # for THIS process before the server package runs its automatic initdb, then # fail loudly if it is not actually available (generating alone is not enough # — the locale must be active when initdb runs). +# Generates+activates a UTF-8 locale (default en_US.UTF-8); the argument +# honours other UTF-8 locales consistently (match, locale.gen line and the +# exported value all derive from it). Matching normalises case and dashes so +# the canonical `en_US.UTF-8` matches `locale -a`'s `en_US.utf8`. ensure_utf8_locale_active() { local loc="${1:-en_US.UTF-8}" + local norm; norm="$(printf '%s' "$loc" | tr 'A-Z' 'a-z' | tr -d '-')" + _locale_present() { locale -a 2>/dev/null | tr 'A-Z' 'a-z' | tr -d '-' | grep -qx "$norm"; } msg_info "Ensuring $loc is generated and active (DB encoding is frozen at initdb)..." - if ! locale -a 2>/dev/null | tr 'A-Z' 'a-z' | grep -q '^en_us\.utf-\?8$'; then - sed -i 's/^# *en_US\.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen + if ! _locale_present; then + # Uncomment the matching `# UTF-8` line, then generate. + sed -i "s/^# *${loc} UTF-8/${loc} UTF-8/" /etc/locale.gen locale-gen >/dev/null fi - if ! locale -a 2>/dev/null | tr 'A-Z' 'a-z' | grep -q '^en_us\.utf-\?8$'; then + if ! _locale_present; then msg_err "Locale $loc not available after locale-gen — refusing to continue (initdb would create a SQL_ASCII cluster)" return 1 fi @@ -82,11 +89,14 @@ ensure_utf8_locale_active() { # Post-install guard: a database MUST be UTF8. Encoding is irreversible, so a # wrong value is database damage — abort with a clear, actionable message -# instead of shipping a broken cluster. Reads via `su - postgres -c` (minimal -# LXCs have no sudo). +# instead of shipping a broken cluster. Uses argv-clean `runuser ... psql` +# with a quoted :'db' literal binding (robust regardless of caller); the +# credentials/README docs use `su - postgres -c` for hand maintenance (these +# minimal LXCs have no sudo). assert_db_encoding_utf8() { local db="$1" enc - enc="$(su - postgres -c "psql -X -qAt -c \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname='$db'\"")" + enc="$(runuser -u postgres -- psql -X -qAt -v db="$db" \ + -c "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = :'db'")" if [[ "$enc" != "UTF8" ]]; then msg_err "Database '$db' has encoding '${enc:-}', expected UTF8." msg_err "Encoding is frozen at creation time — this is DB damage, not cosmetic."