diff --git a/acme/truenas-certificate-deployer/README.md b/acme/truenas-certificate-deployer/README.md index ae2eb6f..1fe7515 100644 --- a/acme/truenas-certificate-deployer/README.md +++ b/acme/truenas-certificate-deployer/README.md @@ -17,7 +17,7 @@ certificate's PEM or private key in place, so each renewal receives a new ID. 2. If no matching record exists, import one with a temporary name ending in `_pending_<16-character full-chain hash>`. 3. Select its ID for the UI and request a UI restart when needed. Connect directly - to HTTPS and require an exact match against the expected leaf certificate. + to HTTPS and require an exact match against the expected leaf certifi4scate. 4. Confirm the selected UI certificate ID, then delete superseded managed records using non-forced deletion jobs. TrueNAS checks service dependencies and refuses deletion of certificates still referenced by services. diff --git a/database/.env b/database/.env index 83cc8a6..190e9d4 100644 --- a/database/.env +++ b/database/.env @@ -7,4 +7,4 @@ VCHORD_VERSION=1.1.1 PGPOOL_VERSION=4.7.2 LDAP2PG_VERSION=6.6.0 -MARIADB_VERSION=12 \ No newline at end of file +MARIADB_VERSION=13.0.2 diff --git a/database/compose.yaml b/database/compose.yaml index cf6438a..b238125 100644 --- a/database/compose.yaml +++ b/database/compose.yaml @@ -33,12 +33,12 @@ services: - PGPOOL_VERSION=${PGPOOL_VERSION:-4.7.2} mariadb: - image: containers.kritikos.io/system/mariadb-extended:${MARIADB_VERSION:-12} + image: containers.kritikos.io/system/mariadb-extended:${MARIADB_VERSION:-13.0.2} build: context: mariadb dockerfile: Containerfile args: - - MARIADB_VERSION=${MARIADB_VERSION:-12} + - MARIADB_VERSION=${MARIADB_VERSION:-13.0.2} ldap2mariadb: container_name: ldap2mariadb diff --git a/database/ldap2pg/Containerfile b/database/ldap2pg/Containerfile index f184bf2..7739b65 100644 --- a/database/ldap2pg/Containerfile +++ b/database/ldap2pg/Containerfile @@ -54,8 +54,6 @@ COPY --from=builder /out/ldap2pg /usr/bin/ldap2pg # Fetch the upstream docker entrypoint pinned to the same release tag. ADD --chmod=0755 https://raw.githubusercontent.com/dalibo/ldap2pg/v${LDAP2PG_VERSION}/docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -COPY --chmod=0755 provision-user-db.sh /workspace/provision-user-db.sh - ENTRYPOINT ["docker-entrypoint.sh"] RUN /usr/local/bin/docker-entrypoint.sh --version diff --git a/database/ldap2pg/provision-user-db.sh b/database/ldap2pg/provision-user-db.sh deleted file mode 100644 index 456c199..0000000 --- a/database/ldap2pg/provision-user-db.sh +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BOOTSTRAP_DB:?}" -: "${ADMIN_DB:?}" -: "${USERS_ROLE:?}" # group whose members each get a personal DB -: "${READERS_ROLE:?}" -: "${WRITERS_ROLE:?}" -: "${DDL_ROLE:?}" -: "${HARDEN_DB_PUBLIC:?}" # 1=yes: revoke PUBLIC connect on each user DB - -psql_bootstrap() { psql -v ON_ERROR_STOP=1 -d "$BOOTSTRAP_DB" "$@"; } -psql_userdb() { psql -v ON_ERROR_STOP=1 -d "$1" "${@:2}"; } - -qi() { - local s="$1" - s="${s//\"/\"\"}" - printf '"%s"' "$s" -} - -# ── 1. Ensure the admin (ldap2pg) database exists ────────────────────────── -echo "[*] Ensuring admin database exists: ${ADMIN_DB}" -psql_bootstrap -Atc "SELECT 1 FROM pg_database WHERE datname='${ADMIN_DB}';" | grep -qx 1 || \ - psql_bootstrap -c "CREATE DATABASE $(qi "$ADMIN_DB") OWNER postgres;" - -# ── 2. Discover LDAP-synced login roles ──────────────────────────────────── -echo "[*] Discovering user roles from PG group: ${USERS_ROLE}" -users="$(psql_bootstrap -Atc " - SELECT r.rolname - FROM pg_roles r - JOIN pg_auth_members m ON m.member = r.oid - JOIN pg_roles p ON p.oid = m.roleid - WHERE p.rolname = '${USERS_ROLE}' - AND r.rolcanlogin - AND r.rolname <> 'postgres' - AND r.rolname !~ '^pg_' - ORDER BY 1; -")" - -if [[ -z "${users}" ]]; then - echo "[*] No members in '${USERS_ROLE}'. Nothing to provision." - exit 0 -fi - -# ── 3. Per-user database provisioning ────────────────────────────────────── -while IFS= read -r u; do - [[ -z "$u" ]] && continue - echo - echo "[*] Provisioning DB for user: ${u}" - - # ── 3a. Create the database if missing ─────────────────────────────────── - if ! psql_bootstrap -Atc "SELECT 1 FROM pg_database WHERE datname='${u}';" | grep -qx 1; then - echo " - creating database $(qi "$u") owned by $(qi "$u")" - psql_bootstrap -c "CREATE DATABASE $(qi "$u") OWNER $(qi "$u");" - else - echo " - database exists" - fi - - # ── 3b. Fix ownership if needed ────────────────────────────────────────── - current_owner="$(psql_bootstrap -Atc " - SELECT pg_get_userbyid(datdba) - FROM pg_database - WHERE datname='${u}'; - ")" - if [[ "${current_owner}" != "${u}" ]]; then - echo " - fixing db owner: ${current_owner} -> ${u}" - psql_bootstrap -c "ALTER DATABASE $(qi "$u") OWNER TO $(qi "$u");" - else - echo " - db owner OK" - fi - - # ── 3c. Harden: revoke PUBLIC connect ──────────────────────────────────── - if [[ "${HARDEN_DB_PUBLIC}" == "1" ]]; then - psql_bootstrap -c "REVOKE ALL ON DATABASE $(qi "$u") FROM PUBLIC;" - fi - - # ── 3d. Owner DB-level grants ──────────────────────────────────────────── - psql_bootstrap -c "GRANT CONNECT, TEMPORARY, CREATE ON DATABASE $(qi "$u") TO $(qi "$u");" - - # ── 3e. Per-owner schema + public schema grants ────────────────────────── - psql_userdb "$u" -c "GRANT USAGE, CREATE ON SCHEMA public TO $(qi "$u");" >/dev/null - - # ── 3f. Fine-grained role grants (readers / writers / ddl) ─────────────── - # - # DB-level CONNECT so the marker roles can reach the database at all. - for role in "$READERS_ROLE" "$WRITERS_ROLE" "$DDL_ROLE"; do - psql_bootstrap -c "GRANT CONNECT ON DATABASE $(qi "$u") TO $(qi "$role");" 2>/dev/null || true - done - - # DB-level CREATE for the DDL role: required to install trusted extensions - # (e.g. CREATE EXTENSION vector) and to create schemas. - psql_bootstrap -c "GRANT CREATE ON DATABASE $(qi "$u") TO $(qi "$DDL_ROLE");" 2>/dev/null || true - - # Schema-level grants inside the user's database - for schema in public; do - # readers → read-only - psql_userdb "$u" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$READERS_ROLE");" >/dev/null - psql_userdb "$u" -c "GRANT SELECT ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$READERS_ROLE");" >/dev/null - psql_userdb "$u" -c "GRANT SELECT ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$READERS_ROLE");" >/dev/null - - # writers → read + DML - psql_userdb "$u" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$WRITERS_ROLE");" >/dev/null - psql_userdb "$u" -c "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$WRITERS_ROLE");" >/dev/null - psql_userdb "$u" -c "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$WRITERS_ROLE");" >/dev/null - - # ddl → full DDL (create/alter/drop via ownership proxy) - psql_userdb "$u" -c "GRANT USAGE, CREATE ON SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null - psql_userdb "$u" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null - psql_userdb "$u" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null - psql_userdb "$u" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null - - # ── Default privileges: apply to objects created in the future ──────── - psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON TABLES TO $(qi "$READERS_ROLE");" >/dev/null - psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON SEQUENCES TO $(qi "$READERS_ROLE");" >/dev/null - - psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $(qi "$WRITERS_ROLE");" >/dev/null - psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO $(qi "$WRITERS_ROLE");" >/dev/null - - psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON TABLES TO $(qi "$DDL_ROLE");" >/dev/null - psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON SEQUENCES TO $(qi "$DDL_ROLE");" >/dev/null - psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON FUNCTIONS TO $(qi "$DDL_ROLE");" >/dev/null - done - - echo " - DB + schema grants OK (owner, readers, writers, ddl)" -done <<< "${users}" - -# ── 4. Per-database scoped role grants ───────────────────────────────────── -# Discover PG roles matching _readers / _writers / _ddl -# and grant them ONLY on the corresponding database. -echo -echo "[*] Applying per-database scoped grants …" - -apply_perdb_grants() { - local db="$1" role="$2" kind="$3" - - # DB-level CONNECT - psql_bootstrap -c "GRANT CONNECT ON DATABASE $(qi "$db") TO $(qi "$role");" 2>/dev/null || true - - # DDL roles also need DB-level CREATE to install trusted extensions - # (e.g. CREATE EXTENSION vector) and to create schemas. - if [[ "$kind" == "ddl" ]]; then - psql_bootstrap -c "GRANT CREATE ON DATABASE $(qi "$db") TO $(qi "$role");" 2>/dev/null || true - fi - - for schema in public "$db"; do - case "$kind" in - readers) - psql_userdb "$db" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "GRANT SELECT ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "GRANT SELECT ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON TABLES TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON SEQUENCES TO $(qi "$role");" >/dev/null - ;; - writers) - psql_userdb "$db" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO $(qi "$role");" >/dev/null - ;; - ddl) - psql_userdb "$db" -c "GRANT USAGE, CREATE ON SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON TABLES TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON SEQUENCES TO $(qi "$role");" >/dev/null - psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON FUNCTIONS TO $(qi "$role");" >/dev/null - ;; - esac - done -} - -# Query PG for roles whose name matches _readers|_writers|_ddl -# and where the part matches an existing database. -perdb_roles="$(psql_bootstrap -Atc " - SELECT r.rolname, d.datname, - CASE - WHEN r.rolname LIKE '%\_readers' THEN 'readers' - WHEN r.rolname LIKE '%\_writers' THEN 'writers' - WHEN r.rolname LIKE '%\_ddl' THEN 'ddl' - END AS kind - FROM pg_roles r - JOIN pg_auth_members m ON m.member = r.oid - JOIN pg_roles p ON p.oid = m.roleid AND p.rolname = 'ldap_roles' - JOIN pg_database d ON d.datname = regexp_replace(r.rolname, '_(readers|writers|ddl)$', '') - WHERE r.rolname ~ '_(readers|writers|ddl)$' - AND NOT r.rolcanlogin - ORDER BY 1; -")" || true - -if [[ -n "${perdb_roles}" ]]; then - while IFS='|' read -r rolname dbname kind; do - [[ -z "$rolname" ]] && continue - echo " - granting ${kind} on DB '${dbname}' to role '${rolname}'" - apply_perdb_grants "$dbname" "$rolname" "$kind" - done <<< "${perdb_roles}" -else - echo " - no per-database scoped roles found" -fi - -echo -echo "[*] Done." diff --git a/database/mariadb/.gitattributes b/database/mariadb/.gitattributes new file mode 100644 index 0000000..c8b4677 --- /dev/null +++ b/database/mariadb/.gitattributes @@ -0,0 +1,7 @@ +Containerfile text eol=lf +docker-entrypoint.sh text eol=lf +pam.d/mariadb text eol=lf +conf.d/*.cnf text eol=lf +tests/*.py text eol=lf +README.md text eol=lf +.gitattributes text eol=lf diff --git a/database/mariadb/Containerfile b/database/mariadb/Containerfile index fdb9f6a..8f0d23a 100644 --- a/database/mariadb/Containerfile +++ b/database/mariadb/Containerfile @@ -1,54 +1,72 @@ -# syntax=docker/dockerfile:1.7 -ARG MARIADB_VERSION=latest +ARG MARIADB_VERSION=13.0.2 +FROM mariadb:${MARIADB_VERSION} AS base -FROM mariadb:${MARIADB_VERSION} +FROM base AS pam-build + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl libcrypt-dev libldap-dev libpam0g-dev libsasl2-dev perl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build/pam_ldap +RUN curl --fail --show-error --silent --location --proto '=https' --tlsv1.2 \ + https://codeload.github.com/PADL/pam_ldap/tar.gz/656448f091cbeb9efb3ece08e6868e40b8e7b6f8 \ + --output /tmp/pam_ldap.tar.gz \ + && printf '%s %s\n' \ + 1aa622e85621b9018025e3483956d854566a5bc0636a4e8d9d005093f02dd16b \ + /tmp/pam_ldap.tar.gz | sha256sum -c - \ + && tar -xzf /tmp/pam_ldap.tar.gz --strip-components=1 \ + && CFLAGS='-O2 -fPIC -std=gnu89' ./configure \ + --prefix=/usr/local \ + --with-ldap-lib=openldap \ + --with-ldap-conf-file=/etc/pam_ldap.conf \ + --enable-ssl \ + && PERL5LIB=/build/pam_ldap make -j2 \ + && nm -D pam_ldap.so | grep -E ' T pam_sm_authenticate(@|$)' \ + && nm -D pam_ldap.so | grep -E ' T pam_sm_acct_mgmt(@|$)' \ + && grep -F '#define HAVE_LDAP_INITIALIZE 1' config.h \ + && grep -F '#define HAVE_LDAP_START_TLS_S 1' config.h + +FROM base + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates libcrypt1 libldap2 libpam0g util-linux \ + && command -v setpriv \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=pam-build --chmod=0755 /build/pam_ldap/pam_ldap.so /usr/local/lib/security/pam_ldap_direct.so +COPY --from=pam-build /build/pam_ldap/COPYING /build/pam_ldap/COPYING.LIB /usr/share/doc/pam-ldap-direct/ -# ── AD/LDAP authentication via PAM ────────────────────────────────────────── -# MariaDB Community has no native LDAP auth plugin, so AD logins go through the -# auth_pam plugin -> a PAM "mariadb" service -> daemonless pam_ldap doing an -# LDAP search+bind against Active Directory. This is the analogue of the pgpool -# `ldap` method in pool_hba.conf; deliberately NO SSSD/nslcd daemon is added. -# -# libpam-ldap is the daemonless PAM/LDAP module. The auth_pam plugin ships with -# recent MariaDB; the separate plugin package (older layouts) is installed -# best-effort and also fixes the setuid auth_pam_tool helper permissions. -# Either source must leave auth_pam.so in place: 10-plugins.cnf loads it -# unconditionally, so a missing .so would only surface as a server that refuses -# to start. RUN set -eu; \ - apt-get update; \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - libpam-ldap \ - ca-certificates \ - gettext-base; \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - mariadb-plugin-auth-pam || true; \ - rm -rf /var/lib/apt/lists/*; \ + module=/usr/local/lib/security/pam_ldap_direct.so; \ + ldd "$module" > /tmp/pam-ldap-dependencies; \ + if grep -q 'not found' /tmp/pam-ldap-dependencies; then \ + cat /tmp/pam-ldap-dependencies >&2; \ + exit 1; \ + fi; \ + grep -F 'libldap' /tmp/pam-ldap-dependencies; \ + rm /tmp/pam-ldap-dependencies; \ plugin=/usr/lib/mysql/plugin/auth_pam.so; \ - [ -e "$plugin" ] || { echo "auth_pam: missing $plugin" >&2; exit 1; } + helper_dir=/usr/lib/mysql/plugin/auth_pam_tool_dir; \ + helper="$helper_dir/auth_pam_tool"; \ + test -f "$plugin" || { echo "Missing MariaDB auth_pam plugin: $plugin" >&2; exit 1; }; \ + test -f "$helper" || { echo "Missing MariaDB PAM helper: $helper" >&2; exit 1; }; \ + chown root:mysql "$helper_dir" "$helper"; \ + chmod 0750 "$helper_dir"; \ + chmod 4750 "$helper"; \ + gosu mysql test -x "$helper" + +RUN mkdir -p /etc/mysql/image.conf.d /etc/mysql/site.conf.d \ + && printf '%s\n' \ + '!includedir /etc/mysql/image.conf.d/' \ + '!includedir /etc/mysql/site.conf.d/' >> /etc/mysql/mariadb.cnf -# ── Layered configuration ─────────────────────────────────────────────────── -# Mirror the postgres image: image-owned defaults load first, user overrides -# last (last-wins). The stock image already reads /etc/mysql/conf.d and -# /etc/mysql/mariadb.conf.d; we append two include dirs AFTER those so: -# image.conf.d -> our defaults (override the base image's 50-server.cnf) -# site.conf.d -> a clean "mount your *.cnf here" area (wins over everything) -RUN set -eu; \ - mkdir -p /etc/mysql/image.conf.d /etc/mysql/site.conf.d; \ - { \ - echo '!includedir /etc/mysql/image.conf.d/'; \ - echo '!includedir /etc/mysql/site.conf.d/'; \ - } >> /etc/mysql/mariadb.cnf COPY conf.d/ /etc/mysql/image.conf.d/ - -# PAM service definition for MariaDB logins (auth_pam calls service "mariadb"). -COPY pam.d/mariadb /etc/pam.d/mariadb - -# Entrypoint wrapper: renders /etc/pam_ldap.conf from env (keeps the AD bind -# secret out of the image), then hands off to the stock mariadb entrypoint. +COPY --chmod=0644 pam.d/mariadb /etc/pam.d/mariadb COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/ldap-entrypoint.sh -# Report health via the image's bundled healthcheck helper. HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=15s \ CMD healthcheck.sh --connect --innodb_initialized || exit 1 diff --git a/database/mariadb/README.md b/database/mariadb/README.md new file mode 100644 index 0000000..3e0758d --- /dev/null +++ b/database/mariadb/README.md @@ -0,0 +1,143 @@ +# MariaDB with direct LDAP PAM authentication + +This image keeps MariaDB's `auth_pam` plugin and a direct LDAP PAM module. No nslcd or SSSD daemon +is configured or started. The existing SQL accounts `IDENTIFIED VIA pam USING 'mariadb'` and the +MaxScale clear-password pass-through configuration remain unchanged. + +## Why the package name was insufficient + +In Ubuntu 26.04, [libpam-ldap](https://packages.ubuntu.com/resolute/libpam-ldap) is a virtual package +provided by [libpam-ldapd](https://packages.ubuntu.com/resolute/libpam-ldapd). That implementation's +`pam_ldap.so` requires nslcd. An `apt-get install libpam-ldap` request therefore did not install +the direct module described by the old image comments. + +The [Containerfile](Containerfile) now builds the direct PADL implementation from +[commit 656448f091cbeb9efb3ece08e6868e40b8e7b6f8](https://github.com/PADL/pam_ldap/tree/656448f091cbeb9efb3ece08e6868e40b8e7b6f8). +The downloaded archive must match SHA256 +`1aa622e85621b9018025e3483956d854566a5bc0636a4e8d9d005093f02dd16b`. +The build and runtime stages share the same MariaDB base, avoiding cross-distribution library copies. +Only the built module and its license files are copied into the runtime stage. + +The module is installed at `/usr/local/lib/security/pam_ldap_direct.so` and selected by that +absolute path in [pam.d/mariadb](pam.d/mariadb). A distro module with the same generic +`pam_ldap.so` name cannot silently replace it in this PAM stack. + +This is legacy upstream code, not a claim that the distribution now supports it. Its revision, +dependencies and authentication behavior need review when updating the image. The build selects +the source's GNU C89 dialect and supplies its source directory in `PERL5LIB` for the upstream +version generator; neither change suppresses an authentication or TLS failure. + +## Runtime configuration + +The [entrypoint](docker-entrypoint.sh) continues to accept the existing variables: + +| Variable | Meaning | +| --- | --- | +| `LDAPURI` | One or more whitespace-separated LDAP endpoints, all using the same transport | +| `LDAP_BASE` | User-search base DN | +| `LDAPBINDDN` | Existing directory search account's bind DN | +| `LDAPPASSWORD` | Its password, supplied at runtime, never in the image or repository | +| `LDAP_LOGIN_ATTR` | Login-name attribute, such as `sAMAccountName` | +| `LDAP_TLS_REQCERT` | `demand` or `hard`; weaker verification is rejected | +| `CERTS_GID` | The actual host TLS-key group ID, retained in mysql's groups after the stock privilege drop | + +All values must be single-line, and required credentials have no defaults. The renderer writes +one root-only `/etc/pam_ldap.conf`, which is the path compiled into the direct module. It no longer +writes a second `/etc/ldap.conf`. + +PADL's verification directive is `tls_checkpeer yes`, not `tls_reqcert`. Its CA-file directive +is `tls_cacertfile`; this image points it to `/etc/ssl/certs/ca-certificates.crt`, installed by +`ca-certificates`. No private CA file is required for the lab's Let's Encrypt certificates. +Endpoints must still provide a valid full chain for their hostnames. + +`ldaps://` endpoints use LDAPS. `ldap://` endpoints explicitly use StartTLS before binding. +Mixing those transports in one endpoint list is rejected rather than silently selecting an +incompatible mode or permitting an unencrypted fallback. + +The MariaDB PAM helper remains setuid root so it can read the bind credential. The build checks +the MariaDB plugin/helper and direct module dependencies, restricts the helper directory to +root and mysql, and verifies mysql can execute the helper. Do not enable `no-new-privileges` +for this backend container: it would prevent that elevation. Startup now reports this +configuration error explicitly instead of proceeding to generic access-denied failures. + +The privilege check uses `setpriv --dump` from `util-linux`, which queries the kernel's +`PR_GET_NO_NEW_PRIVS` interface. It does not depend on a `NoNewPrivs` line in process status: +that field only appeared in Linux 4.10, whereas the kernel query exists since 3.5. This matters on +older Synology kernels. A confirmed flag value of 1 is still rejected; an unavailable or malformed +query fails explicitly rather than silently assuming privilege elevation will work. + +The stock MariaDB entrypoint, certificate group handling, data directory, replication settings +and database healthcheck remain in use. This change does not create accounts, change passwords, +reseed databases, or alter database ownership. + +## Build and rollout + +The default engine version is explicitly `13.0.2`, matching the running primary and replica +observed during this incident. The old custom tag `11.8` actually contained MariaDB 13.0.2; +do not deploy an actual 11.8 binary against the restored 13.0 data. + +From this image directory, the owner can build a distinctly named image: + +```sh +docker build -f Containerfile -t containers.kritikos.io/system/mariadb-extended:13.0.2-pam-direct . +``` + +Publish and pin the resulting image digest through the normal registry workflow. The assistant +does not build, push or deploy images to the lab. + +Deploy to zagreus first, retaining its data mount, server ID, read-only setting, replication +configuration, full-chain certificate and actual `CERTS_GID`. Check a direct AD-authenticated +MariaDB connection to zagreus before changing Plutus. Then update only Plutus's MariaDB container; +that briefly disconnects primary clients, but requires no NAS reboot and no PostgreSQL restart. + +Verify on each updated backend: + +```sh +docker exec mariadb mariadbd --version +docker exec mariadb cat /etc/pam.d/mariadb +docker exec mariadb ldd /usr/local/lib/security/pam_ldap_direct.so +``` + +The PAM service must name the explicit direct module, its dependencies must resolve, and the +engine must remain 13.0.2. Do not print `/etc/pam_ldap.conf`; it contains the directory bind password. + +Then, from a client, use a real directory user's password with verified TLS: + +```sh +mariadb --no-defaults --protocol=TCP --host=zagreus.ad.kritikos.io --port=3306 --user=kritikos --password --ssl --ssl-verify-server-cert --database=grimmory --execute='SELECT CURRENT_USER(), CURRENT_ROLE(), @@server_id;' +``` + +Repeat against plutus and finally `mariadb.kritikos.io` through MaxScale. A root or monitoring +account using native-password authentication does not validate PAM. Check replica status and +ordinary database health as well; this image does not change the existing replication channel. + +## Validation scope + +The pinned source was compiled and dynamically loaded locally under WSL with available PAM and +OpenLDAP headers. The compiled module exported `pam_sm_authenticate` and `pam_sm_acct_mgmt`, +linked directly to `libldap`, and detected both `ldap_initialize` and StartTLS support. +That was a local build, not execution inside the target Ubuntu image or against AD. + +[tests/test_pam_image.py](tests/test_pam_image.py) checks the pinned image recipe and runs a +rendered entrypoint in temporary directories with stubbed identity/group/stock-entrypoint commands. +It exercises TLS modes, configuration generation, credential redaction, missing components, +privilege constraints and argument forwarding without contacting hosts: + +```sh +python3 -m unittest discover -s tests -v +``` + +Entrypoint execution tests need a POSIX environment. The assistant has not built or deployed the +Docker image. On 2026-10-03, the owner verified the `kritikos` PAM/AD login directly against both +backends over verified TLS, with the expected aggregate role and primary/replica server IDs. + +The later successful read through MaxScale required a separate site configuration correction on +zagreus: `bind_address = *`, providing both IPv4 and IPv6 listeners. Its IPv4-only listener did not +cover the IPv6 addresses advertised by its hostname. This was a backend listener change, not an +LDAP password, grant or TLS-verification change. + +A later controlled test wrote one row through MaxScale to Plutus (server ID 1, read-only OFF) +and read the same row directly on zagreus (server ID 2, read-only ON), retaining the recorded +writer server ID 1. That verifies primary write routing and replication of a new row. It does not +establish failover behavior or crash durability. Cleanup of the disposable probe table was not +shown in those results. diff --git a/database/mariadb/docker-entrypoint.sh b/database/mariadb/docker-entrypoint.sh index 07c4e91..35b3e78 100644 --- a/database/mariadb/docker-entrypoint.sh +++ b/database/mariadb/docker-entrypoint.sh @@ -1,26 +1,64 @@ #!/usr/bin/env bash set -euo pipefail +set -f -# Render pam_ldap's config from the environment so the AD bind credentials are -# never baked into the image. pam_ldap performs a search+bind against AD: bind -# as the service account, find the user by ${LDAP_LOGIN_ATTR}, then re-bind as -# that DN with the supplied password to verify it — the same shape as pgpool's -# `ldap` method (see pool_hba.conf). : "${LDAPURI:?LDAPURI is required (e.g. ldaps://hecate.ad.kritikos.io)}" : "${LDAP_BASE:?LDAP_BASE is required (e.g. DC=ad,DC=kritikos,DC=io)}" : "${LDAPBINDDN:?LDAPBINDDN is required (AD service/bind account)}" : "${LDAPPASSWORD:?LDAPPASSWORD is required}" : "${LDAP_LOGIN_ATTR:?LDAP_LOGIN_ATTR is required (e.g. sAMAccountName)}" -# demand = validate the AD cert against the trust store; relax to 'allow' only -# for testing, or add your internal CA to the image and keep 'demand'. -: "${LDAP_TLS_REQCERT:?LDAP_TLS_REQCERT is required (demand|allow|never)}" +: "${LDAP_TLS_REQCERT:?LDAP_TLS_REQCERT is required (demand or hard)}" +: "${CERTS_GID:?CERTS_GID is required (GID owning the host TLS key)}" + +fail() { + printf 'ldap-entrypoint: %s\n' "$1" >&2 + exit 1 +} + +for setting in LDAPURI LDAP_BASE LDAPBINDDN LDAPPASSWORD LDAP_LOGIN_ATTR LDAP_TLS_REQCERT CERTS_GID; do + [[ ${!setting} != *$'\n'* && ${!setting} != *$'\r'* ]] \ + || fail "$setting must be a single line" +done + +case "${LDAP_TLS_REQCERT,,}" in + demand|hard) ;; + *) fail "LDAP_TLS_REQCERT must be demand or hard; LDAP certificate verification cannot be disabled" ;; +esac + +[[ $LDAP_LOGIN_ATTR =~ ^[A-Za-z][A-Za-z0-9-]*$ ]] \ + || fail "LDAP_LOGIN_ATTR must be an LDAP attribute name" +[[ $CERTS_GID =~ ^[0-9]+$ ]] || fail "CERTS_GID must be numeric" + +tls_mode='' +for uri in $LDAPURI; do + case "$uri" in + ldaps://*) mode=on ;; + ldap://*) mode=start_tls ;; + *) fail "LDAPURI must use ldaps:// or ldap:// with StartTLS" ;; + esac + [[ -z $tls_mode || $tls_mode == "$mode" ]] \ + || fail "LDAPURI cannot mix LDAPS and StartTLS endpoints" + tls_mode=$mode +done +[[ -n $tls_mode ]] || fail "LDAPURI must contain an endpoint" + +module=/usr/local/lib/security/pam_ldap_direct.so +helper=/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool +ca_file=/etc/ssl/certs/ca-certificates.crt +[[ -r $module ]] || fail "direct LDAP PAM module is missing: $module" +[[ -x $helper && -u $helper ]] || fail "MariaDB's setuid PAM helper is unavailable" +[[ -r $ca_file ]] || fail "system CA bundle is missing: $ca_file" +privileges=$(LC_ALL=C setpriv --dump) \ + || fail "cannot inspect the PAM helper privilege constraints" +no_new_privs=$(printf '%s\n' "$privileges" | sed -n 's/^no_new_privs:[[:space:]]*//p') +case "$no_new_privs" in + 0) ;; + 1) fail "no-new-privileges prevents MariaDB's PAM helper from reading its root-only configuration" ;; + *) fail "cannot determine the PAM helper privilege constraints" ;; +esac umask 077 -# pam_ldap.so on this image reads /etc/ldap.conf (the PADL default path), and -# libpam-ldap ships a placeholder there (base dc=example,dc=net) that otherwise -# shadows our config and makes every login fail. Render both paths. -for conf in /etc/ldap.conf /etc/pam_ldap.conf; do - cat > "$conf" < /etc/pam_ldap.conf < mysql with gosu, which rebuilds mysql's groups from -# /etc/group and discards the compose-added GID — mysql then can't read ssl_key -# and MariaDB aborts with "Unable to get private key". While still root, put -# mysql in a group with that GID so it survives the privilege drop. -: "${CERTS_GID:?CERTS_GID is required (GID owning the host TLS key)}" if [ "$(id -u)" = '0' ]; then if getent group "${CERTS_GID}" >/dev/null; then certgroup="$(getent group "${CERTS_GID}" | cut -d: -f1)" diff --git a/database/mariadb/pam.d/mariadb b/database/mariadb/pam.d/mariadb index f8daf50..b8f9702 100644 --- a/database/mariadb/pam.d/mariadb +++ b/database/mariadb/pam.d/mariadb @@ -1,6 +1,2 @@ -# PAM service for MariaDB auth_pam logins (accounts created IDENTIFIED VIA pam -# USING 'mariadb'). Authenticate + authorize against Active Directory through -# the daemonless pam_ldap module; its connection settings live in -# /etc/pam_ldap.conf, rendered at container start from the environment. -auth required pam_ldap.so -account required pam_ldap.so +auth required /usr/local/lib/security/pam_ldap_direct.so +account required /usr/local/lib/security/pam_ldap_direct.so diff --git a/database/mariadb/tests/test_pam_image.py b/database/mariadb/tests/test_pam_image.py new file mode 100644 index 0000000..2912e0b --- /dev/null +++ b/database/mariadb/tests/test_pam_image.py @@ -0,0 +1,247 @@ +import os +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +MODULE = "/usr/local/lib/security/pam_ldap_direct.so" +HELPER = "/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool" +CONF = "/etc/pam_ldap.conf" +CA = "/etc/ssl/certs/ca-certificates.crt" + + +class ImageRecipeTests(unittest.TestCase): + def test_linux_entrypoint_line_endings_are_preserved(self): + self.assertIn("docker-entrypoint.sh text eol=lf", (ROOT / ".gitattributes").read_text()) + self.assertNotIn(b"\r", (ROOT / "docker-entrypoint.sh").read_bytes()) + + def test_source_is_pinned_and_checked_before_build(self): + recipe = (ROOT / "Containerfile").read_text() + self.assertIn("ARG MARIADB_VERSION=13.0.2", recipe) + self.assertIn("656448f091cbeb9efb3ece08e6868e40b8e7b6f8", recipe) + self.assertIn("1aa622e85621b9018025e3483956d854566a5bc0636a4e8d9d005093f02dd16b", recipe) + self.assertLess(recipe.index("sha256sum -c -"), recipe.index("./configure")) + self.assertIn("--with-ldap-conf-file=/etc/pam_ldap.conf", recipe) + self.assertIn("PERL5LIB=/build/pam_ldap make", recipe) + + def test_runtime_does_not_install_the_virtual_ldap_package(self): + recipe = (ROOT / "Containerfile").read_text() + self.assertNotIn("libpam-ldap", recipe) + self.assertNotIn("nslcd", recipe) + self.assertNotIn("mariadb-plugin-auth-pam || true", recipe) + self.assertIn("libldap2", recipe) + self.assertIn("util-linux", recipe) + self.assertIn("command -v setpriv", recipe) + self.assertIn("ldd \"$module\"", recipe) + self.assertIn("not found", recipe) + self.assertIn("chmod 4750", recipe) + self.assertIn("gosu mysql test -x", recipe) + + def test_pam_stack_selects_the_exact_direct_module(self): + stack = (ROOT / "pam.d" / "mariadb").read_text().splitlines() + self.assertEqual( + [["auth", "required", MODULE], ["account", "required", MODULE]], + [line.split() for line in stack], + ) + + def test_stock_database_lifecycle_is_preserved(self): + recipe = (ROOT / "Containerfile").read_text() + entrypoint = (ROOT / "docker-entrypoint.sh").read_text() + self.assertIn('ENTRYPOINT ["ldap-entrypoint.sh"]', recipe) + self.assertIn('CMD ["mariadbd"]', recipe) + self.assertIn('exec docker-entrypoint.sh "$@"', entrypoint) + self.assertIn("healthcheck.sh --connect --innodb_initialized", recipe) + self.assertNotIn("nslcd", entrypoint) + self.assertIn("LC_ALL=C setpriv --dump", entrypoint) + self.assertNotIn("/proc/self/status", entrypoint) + + +@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), "Requires a local POSIX shell; no Docker or lab access.") +class EntrypointTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="mariadb-pam-entrypoint-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.log = self.root / "calls" + self.conf = self.root / "pam_ldap.conf" + self.module = self.root / "pam_ldap_direct.so" + self.helper = self.root / "auth_pam_tool" + self.ca = self.root / "ca.pem" + self.status = self.root / "status" + self.module.write_text("fixture-module") + self.helper.write_text("fixture-helper") + self.helper.chmod(0o4750) + self.ca.write_text("fixture-ca") + self.status.write_text("NoNewPrivs:\t0\n") + text = (ROOT / "docker-entrypoint.sh").read_text() + for old, new in ( + (MODULE, self.module), (HELPER, self.helper), (CONF, self.conf), + (CA, self.ca), ("/proc/self/status", self.status), + ): + text = text.replace(old, str(new)) + self.script = self.root / "entrypoint.sh" + self.script.write_text(text) + self.env = { + **os.environ, + "PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "LDAPURI": "ldaps://directory.example.test", + "LDAP_BASE": "DC=example,DC=test", + "LDAPBINDDN": "CN=reader,DC=example,DC=test", + "LDAPPASSWORD": "fixture-password-not-for-logs", + "LDAP_LOGIN_ATTR": "sAMAccountName", + "LDAP_TLS_REQCERT": "demand", + "CERTS_GID": "12002", + "TEST_CALLS": str(self.log), + "TEST_HAVE_GROUP": "1", + "TEST_UID": "0", + "TEST_NO_NEW_PRIVS": "0", + "TEST_SETPRIV_RC": "0", + } + self.stub("id", 'printf "%s\\n" "$TEST_UID"\n') + self.stub("getent", 'if [ "$TEST_HAVE_GROUP" = 1 ]; then printf "certgroup:x:%s:\\n" "$2"; else exit 2; fi\n') + self.stub("groupadd", 'printf "groupadd %s\\n" "$*" >> "$TEST_CALLS"\n') + self.stub("usermod", 'printf "usermod %s\\n" "$*" >> "$TEST_CALLS"\n') + self.stub( + "setpriv", + '[ "$1" = --dump ] || exit 9\n' + '[ "$TEST_SETPRIV_RC" = 0 ] || exit "$TEST_SETPRIV_RC"\n' + 'printf "uid: 999\\nno_new_privs: %s\\n" "$TEST_NO_NEW_PRIVS"\n', + ) + self.stub("docker-entrypoint.sh", 'printf "delegate %s\\n" "$*" >> "$TEST_CALLS"\n') + + def stub(self, name, body): + path = self.bin / name + path.write_text("#!/bin/sh\nset -eu\n" + body) + path.chmod(0o755) + + def run_entrypoint(self, expected=0): + result = subprocess.run( + ["bash", str(self.script), "mariadbd", "--server-id=2", "--read-only=ON"], + env=self.env, text=True, capture_output=True, timeout=5, + ) + self.assertEqual(expected, result.returncode, result.stdout + result.stderr) + self.assertNotIn(self.env.get("LDAPPASSWORD", "unused-secret"), result.stdout + result.stderr) + return result + + def test_ldaps_writes_only_the_direct_module_configuration(self): + self.run_entrypoint() + text = self.conf.read_text() + self.assertIn("uri ldaps://directory.example.test\n", text) + self.assertIn("ssl on\n", text) + self.assertIn("tls_checkpeer yes\n", text) + self.assertIn(f"tls_cacertfile {self.ca}\n", text) + self.assertIn("pam_login_attribute sAMAccountName\n", text) + self.assertIn("bindpw fixture-password-not-for-logs\n", text) + self.assertNotIn("tls_reqcert", text) + self.assertEqual(0o600, stat.S_IMODE(self.conf.stat().st_mode)) + self.assertIn("usermod -aG certgroup mysql", self.log.read_text()) + self.assertIn("delegate mariadbd --server-id=2 --read-only=ON", self.log.read_text()) + + def test_ldap_uses_starttls_not_plain_bind(self): + self.env["LDAPURI"] = "ldap://directory.example.test:389" + self.run_entrypoint() + self.assertIn("ssl start_tls\n", self.conf.read_text()) + self.assertIn("tls_checkpeer yes\n", self.conf.read_text()) + + def test_multiple_same_transport_uris_are_supported(self): + self.env["LDAPURI"] = "ldaps://one.example.test ldaps://two.example.test" + self.run_entrypoint() + self.assertIn("uri " + self.env["LDAPURI"], self.conf.read_text()) + + def test_mixed_transports_are_rejected(self): + self.env["LDAPURI"] = "ldaps://one.example.test ldap://two.example.test" + result = self.run_entrypoint(1) + self.assertIn("cannot mix", result.stderr) + self.assertFalse(self.log.exists()) + + def test_weak_tls_verification_is_rejected(self): + for value in ("allow", "try", "never"): + with self.subTest(value=value): + self.env["LDAP_TLS_REQCERT"] = value + self.run_entrypoint(1) + self.assertFalse(self.log.exists()) + + def test_hard_is_an_alias_for_verified_tls(self): + self.env["LDAP_TLS_REQCERT"] = "hard" + self.run_entrypoint() + self.assertIn("tls_checkpeer yes", self.conf.read_text()) + + def test_missing_secret_is_not_defaulted(self): + del self.env["LDAPPASSWORD"] + result = self.run_entrypoint(1) + self.assertIn("LDAPPASSWORD is required", result.stderr) + self.assertFalse(self.conf.exists()) + + def test_newline_injection_fails_without_logging_the_secret(self): + self.env["LDAPPASSWORD"] = "fixture-password-not-for-logs\ntls_checkpeer no" + result = self.run_entrypoint(1) + self.assertIn("must be a single line", result.stderr) + self.assertNotIn("fixture-password-not-for-logs", result.stderr) + self.assertFalse(self.conf.exists()) + + def test_missing_or_non_setuid_helper_is_reported_before_start(self): + self.helper.chmod(0o0750) + result = self.run_entrypoint(1) + self.assertIn("setuid PAM helper", result.stderr) + self.assertFalse(self.log.exists()) + + def test_missing_module_is_reported_before_start(self): + self.module.unlink() + result = self.run_entrypoint(1) + self.assertIn("direct LDAP PAM module is missing", result.stderr) + self.assertFalse(self.conf.exists()) + + def test_missing_trust_store_fails_closed(self): + self.ca.unlink() + result = self.run_entrypoint(1) + self.assertIn("system CA bundle", result.stderr) + self.assertFalse(self.log.exists()) + + def test_no_new_privileges_is_reported_before_start(self): + self.status.write_text("NoNewPrivs:\t1\n") + self.env["TEST_NO_NEW_PRIVS"] = "1" + result = self.run_entrypoint(1) + self.assertIn("no-new-privileges", result.stderr) + self.assertFalse(self.conf.exists()) + + def test_missing_proc_status_field_does_not_block_startup(self): + self.status.write_text("Name:\tmariadbd\nUid:\t999\t999\t999\t999\n") + self.run_entrypoint() + self.assertIn("delegate mariadbd", self.log.read_text()) + + def test_failed_privilege_query_is_not_ignored(self): + self.env["TEST_SETPRIV_RC"] = "1" + result = self.run_entrypoint(1) + self.assertIn("privilege constraints", result.stderr) + self.assertFalse(self.conf.exists()) + + def test_malformed_privilege_query_is_not_ignored(self): + self.env["TEST_NO_NEW_PRIVS"] = "unknown" + result = self.run_entrypoint(1) + self.assertIn("cannot determine", result.stderr) + self.assertFalse(self.conf.exists()) + + def test_certificate_group_is_created_when_missing(self): + self.env["TEST_HAVE_GROUP"] = "0" + self.run_entrypoint() + self.assertIn("groupadd -g 12002 hostcerts", self.log.read_text()) + self.assertIn("usermod -aG hostcerts mysql", self.log.read_text()) + + def test_invalid_login_attribute_and_gid_are_rejected(self): + for key, value in (("LDAP_LOGIN_ATTR", "uid)(objectClass)=*"), ("CERTS_GID", "not-a-gid")): + with self.subTest(key=key): + previous = self.env[key] + self.env[key] = value + self.run_entrypoint(1) + self.env[key] = previous + self.assertFalse(self.log.exists()) + + +if __name__ == "__main__": + unittest.main()