✨ Adds MariaDB image with direct LDAP PAM authentication

- Introduced a new Containerfile to build a MariaDB image with direct LDAP PAM support.
- Implemented a custom PAM module (`pam_ldap_direct.so`) built from the PADL source to avoid dependencies on `libpam-ldap` and `nslcd`.
- Updated the entrypoint script to dynamically generate the PAM configuration file (`/etc/pam_ldap.conf`) based on environment variables.
- Added a README.md to document the image usage, configuration, and build process.
- Created a test suite to validate the image build and entrypoint behavior, ensuring proper handling of environment variables and configuration generation.
- Configured health checks and ensured the stock MariaDB entrypoint and lifecycle are preserved.
This commit is contained in:
akritikos committed 2026-10-09 14:13:03 +03:00
1 parent a4fc4e2ae2
commit f5a5204b75
11 files changed
+519 -282

No files matched your search

+7
View File
@@ -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
+61 -43
View File
@@ -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
+143
View File
@@ -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.
+55 -24
View File
@@ -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" <<EOF
cat > /etc/pam_ldap.conf <<EOF
uri ${LDAPURI}
base ${LDAP_BASE}
binddn ${LDAPBINDDN}
@@ -29,19 +67,12 @@ scope sub
referrals no
pam_login_attribute ${LDAP_LOGIN_ATTR}
pam_filter objectclass=User
tls_cacertdir /etc/ssl/certs
tls_reqcert ${LDAP_TLS_REQCERT}
ssl ${tls_mode}
tls_cacertfile ${ca_file}
tls_checkpeer yes
EOF
chmod 600 "$conf"
done
chmod 600 /etc/pam_ldap.conf
# Grant mysql read access to the host TLS key (mode 0640, owned by GID
# ${CERTS_GID}). compose exposes that GID via `group_add`, but the stock
# entrypoint drops root -> 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)"
+2 -6
View File
@@ -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
+247
View File
@@ -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()