✨ Commits existing images to git
- Implemented sync-roles.sh to synchronize Active Directory group memberships as MariaDB roles and user accounts. - Created Containerfile for building a custom ldap2pg image with patched YAML decoding. - Added provision-user-db.sh for provisioning user databases and roles based on LDAP synchronization. - Introduced a new Containerfile for MariaDB with PAM authentication via pam_ldap. - Configured PAM service for MariaDB logins and added necessary plugins for LDAP authentication. - Implemented health checks and entrypoint scripts for MariaDB and pgpool containers. - Enhanced PostgreSQL image with additional extensions and custom pg_hba.conf for improved security and functionality.
This commit is contained in:
20 files changed
+1023
No files matched your search
@@ -0,0 +1,89 @@
|
|||||||
|
# images
|
||||||
|
|
||||||
|
Container images published to `containers.kritikos.io`, built with Docker Compose.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose build # all images
|
||||||
|
docker compose build postgres # one image
|
||||||
|
```
|
||||||
|
|
||||||
|
Every version is a build arg supplied from [database/.env](database/.env); see
|
||||||
|
[database/compose.yaml](database/compose.yaml) for the wiring.
|
||||||
|
|
||||||
|
## Alpine version vs. PostgreSQL major
|
||||||
|
|
||||||
|
`ALPINE_VERSION` and `PG_MAJOR` are not independent. The Alpine-based images
|
||||||
|
(`pgpool`, `ldap2pg`, `ldap2mariadb`) install PostgreSQL packages with
|
||||||
|
`apk add postgresql${PG_MAJOR}-dev` / `-client`, and an Alpine image's `apk`
|
||||||
|
repositories are fixed to that release's branch. Each branch packages only a
|
||||||
|
subset of PostgreSQL majors, and that subset changes every release:
|
||||||
|
|
||||||
|
| Alpine branch | `postgresql*-dev` available |
|
||||||
|
| ------------- | ----------------------------- |
|
||||||
|
| edge | 18, 17, 16 |
|
||||||
|
| v3.24 | 18, 17 (main), 16 (community) |
|
||||||
|
| v3.23 | 18, 17 (main), 16 (community) |
|
||||||
|
| v3.22 | 17, 16 (main), 15 (community) |
|
||||||
|
|
||||||
|
_Measured with `apk policy` inside each branch's image on 2026-08-15._
|
||||||
|
|
||||||
|
So `PG_MAJOR=18` requires Alpine 3.23 or newer. The coupling breaks in both
|
||||||
|
directions:
|
||||||
|
|
||||||
|
- Raising `PG_MAJOR` before Alpine's stable branch packages that major fails the
|
||||||
|
build — a September PostgreSQL release will not reach a stable Alpine until
|
||||||
|
that branch ships.
|
||||||
|
- Leaving `PG_MAJOR` behind while Alpine advances fails eventually too: ageing
|
||||||
|
majors are demoted from `main` to `community`, then dropped. 16 has already
|
||||||
|
fallen to community on v3.23, and 15 is gone from v3.23 onwards.
|
||||||
|
|
||||||
|
**Bump `ALPINE_VERSION` and `PG_MAJOR` together**, and confirm the target branch
|
||||||
|
carries `postgresql${PG_MAJOR}-dev` before doing so.
|
||||||
|
|
||||||
|
Pinning matters because `alpine:latest` is a mutable tag that follows Alpine's
|
||||||
|
roughly six-monthly releases, so the same commit can build against a different
|
||||||
|
base OS and a different package set on a later day. Note that `--no-cache` on
|
||||||
|
`apk add` does not pin anything — it only avoids a stale index, and package
|
||||||
|
versions still float within the branch.
|
||||||
|
|
||||||
|
`pgpool` is the image most exposed to this, since it is the one that *compiles*
|
||||||
|
against `postgresql${PG_MAJOR}-dev` rather than just installing a client. All
|
||||||
|
three Alpine-based images (`pgpool`, `ldap2pg`, `ldap2mariadb`) take
|
||||||
|
`ALPINE_VERSION` from `database/.env`, currently pinned to `3.23`.
|
||||||
|
|
||||||
|
## pgpool health check
|
||||||
|
|
||||||
|
pgpool ships no health endpoint of its own. Its `health_check_period` settings
|
||||||
|
govern how pgpool probes *its backends* to drive failover, and the PCP tools
|
||||||
|
(`pcp_node_info` and friends) need a `pcp.conf` user plus a password file, which
|
||||||
|
is more than a container probe should carry. The image therefore probes with
|
||||||
|
`pg_isready` against the pgpool port.
|
||||||
|
|
||||||
|
What that reports, measured against two live backends:
|
||||||
|
|
||||||
|
| Backend state | Health |
|
||||||
|
| --------------------------------- | ------ |
|
||||||
|
| all nodes up | green |
|
||||||
|
| standby down, degenerated | green |
|
||||||
|
| primary down, standby promoted | green |
|
||||||
|
| during failover (~one check cycle)| red |
|
||||||
|
| no node attached, or no primary | red |
|
||||||
|
|
||||||
|
So green means **pgpool can serve**, not that the cluster is healthy. Losing a
|
||||||
|
standby is invisible to it, and because pgpool keeps a node `down` until
|
||||||
|
`pcp_attach_node` is run, the probe stays green after a backend recovers while
|
||||||
|
the pool is still running on one node. Track node-level state with
|
||||||
|
`SHOW POOL_NODES` instead.
|
||||||
|
|
||||||
|
Two consequences for compose files consuming this image:
|
||||||
|
|
||||||
|
- `depends_on: condition: service_healthy` is the intended use — it stops
|
||||||
|
dependents from starting against a pool that has no primary.
|
||||||
|
- Do not restart pgpool on unhealthy. Bouncing it during a backend outage resets
|
||||||
|
its node status and drops the pool without fixing anything.
|
||||||
|
|
||||||
|
In a real streaming-replication cluster the survivor of a primary loss is in
|
||||||
|
recovery, so `find_primary_node` reports nothing until `failover_command`
|
||||||
|
promotes it; connections fail with `failed to create a backend connection` and
|
||||||
|
the probe goes red for that whole window. The check is therefore gated on
|
||||||
|
failover automation working, not merely on a node being up.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- database/compose.yaml
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALPINE_VERSION=3.24
|
||||||
|
GO_VERSION=1.26.3
|
||||||
|
|
||||||
|
PG_MAJOR=18
|
||||||
|
POSTGIS_MAJOR=3
|
||||||
|
VCHORD_VERSION=1.1.1
|
||||||
|
PGPOOL_VERSION=4.7.2
|
||||||
|
LDAP2PG_VERSION=6.6.0
|
||||||
|
|
||||||
|
MARIADB_VERSION=12
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: image-builder
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: containers.kritikos.io/system/postgres-extended:${PG_MAJOR:-18}
|
||||||
|
build:
|
||||||
|
context: postgres
|
||||||
|
dockerfile: Containerfile
|
||||||
|
args:
|
||||||
|
- PG_MAJOR=${PG_MAJOR:-18}
|
||||||
|
- POSTGIS_MAJOR=${POSTGIS_MAJOR:-3}
|
||||||
|
- VCHORD_VERSION=${VCHORD_VERSION:-1.1.1}
|
||||||
|
|
||||||
|
ldap2pg:
|
||||||
|
image: containers.kritikos.io/system/ldap2pg:${LDAP2PG_VERSION:-6.6.0}
|
||||||
|
build:
|
||||||
|
context: ldap2pg
|
||||||
|
dockerfile: Containerfile
|
||||||
|
args:
|
||||||
|
- ALPINE=${ALPINE_VERSION:-latest}
|
||||||
|
- GO_VERSION=${GO_VERSION:-1.26.3}
|
||||||
|
- LDAP2PG_VERSION=${LDAP2PG_VERSION:-6.6.0}
|
||||||
|
|
||||||
|
pgpool:
|
||||||
|
container_name: pgpool
|
||||||
|
image: containers.kritikos.io/system/pgpool:${PGPOOL_VERSION:-4.7.2}
|
||||||
|
build:
|
||||||
|
context: pgpool
|
||||||
|
dockerfile: Containerfile
|
||||||
|
args:
|
||||||
|
- ALPINE=${ALPINE_VERSION:-latest}
|
||||||
|
- PG_MAJOR=${PG_MAJOR:-18}
|
||||||
|
- PGPOOL_VERSION=${PGPOOL_VERSION:-4.7.2}
|
||||||
|
|
||||||
|
mariadb:
|
||||||
|
image: containers.kritikos.io/system/mariadb-extended:${MARIADB_VERSION:-12}
|
||||||
|
build:
|
||||||
|
context: mariadb
|
||||||
|
dockerfile: Containerfile
|
||||||
|
args:
|
||||||
|
- MARIADB_VERSION=${MARIADB_VERSION:-12}
|
||||||
|
|
||||||
|
ldap2mariadb:
|
||||||
|
container_name: ldap2mariadb
|
||||||
|
image: containers.kritikos.io/system/ldap2mariadb:latest
|
||||||
|
build:
|
||||||
|
context: ldap2mariadb
|
||||||
|
dockerfile: Containerfile
|
||||||
|
args:
|
||||||
|
- ALPINE=${ALPINE_VERSION:-latest}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ARG ALPINE=latest
|
||||||
|
FROM alpine:${ALPINE}
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
openldap-clients \
|
||||||
|
mariadb-client \
|
||||||
|
ca-certificates \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY --chmod=0755 sync-roles.sh /workspace/sync-roles.sh
|
||||||
|
COPY --chmod=0755 provision-user-db.sh /workspace/provision-user-db.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/bash"]
|
||||||
|
CMD ["sync-roles.sh"]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# provision-user-db.sh — MariaDB port of the postgres provisioner.
|
||||||
|
#
|
||||||
|
# For every PAM login account created by sync-roles.sh: create a personal
|
||||||
|
# database, make the user its de-facto owner, and grant the anchor roles
|
||||||
|
# (readers/writers/ddl) the right level of access on it. Then apply the same for
|
||||||
|
# per-database scoped roles (<db>_readers | <db>_writers | <db>_ddl).
|
||||||
|
#
|
||||||
|
# MariaDB makes this simpler than postgres:
|
||||||
|
# * database == schema, and GRANT ... ON `db`.* already covers FUTURE tables,
|
||||||
|
# so there is no ALTER DEFAULT PRIVILEGES loop to maintain.
|
||||||
|
# * there is no PUBLIC pseudo-role and no per-database owner to fix, so the
|
||||||
|
# "revoke PUBLIC" and "fix owner" steps simply disappear.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
: "${READERS_ROLE:?}"
|
||||||
|
: "${WRITERS_ROLE:?}"
|
||||||
|
: "${DDL_ROLE:?}"
|
||||||
|
|
||||||
|
: "${MYSQL_HOST:?}"; : "${MYSQL_PORT:?}"; : "${MYSQL_USER:?}"
|
||||||
|
MARIADB=(mariadb --host "${MYSQL_HOST}" --port "${MYSQL_PORT}"
|
||||||
|
--user "${MYSQL_USER}" --batch --skip-column-names)
|
||||||
|
sql() { "${MARIADB[@]}" -e "$1"; }
|
||||||
|
# Escaping is context-dependent: in a '...' literal a backslash escapes the next
|
||||||
|
# character (default sql_mode), while in a `...` identifier it is literal.
|
||||||
|
esc_str() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/''/g"; }
|
||||||
|
esc_id() { printf '%s' "$1" | sed 's/`/``/g'; }
|
||||||
|
|
||||||
|
grant_db_to_roles() {
|
||||||
|
local db; db="$(esc_id "$1")"
|
||||||
|
sql "GRANT SELECT ON \`${db}\`.* TO \`$(esc_id "$READERS_ROLE")\`;"
|
||||||
|
sql "GRANT SELECT, INSERT, UPDATE, DELETE ON \`${db}\`.* TO \`$(esc_id "$WRITERS_ROLE")\`;"
|
||||||
|
sql "GRANT ALL PRIVILEGES ON \`${db}\`.* TO \`$(esc_id "$DDL_ROLE")\`;"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1. Per-user personal databases ──────────────────────────────────────────
|
||||||
|
echo "[*] Discovering PAM login accounts"
|
||||||
|
users="$(sql "SELECT User FROM mysql.user WHERE plugin='pam' AND User <> '' ORDER BY User;")"
|
||||||
|
|
||||||
|
if [ -z "$users" ]; then
|
||||||
|
echo "[*] No PAM users found; nothing to provision."
|
||||||
|
else
|
||||||
|
while IFS= read -r u; do
|
||||||
|
[ -z "$u" ] && continue
|
||||||
|
edb="$(esc_id "$u")"; sdb="$(esc_str "$u")"
|
||||||
|
echo "[*] Provisioning DB for ${u}"
|
||||||
|
sql "CREATE DATABASE IF NOT EXISTS \`${edb}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci;"
|
||||||
|
# Owner-equivalent: full rights on their own DB, with grant option.
|
||||||
|
sql "GRANT ALL PRIVILEGES ON \`${edb}\`.* TO '${sdb}'@'%' WITH GRANT OPTION;"
|
||||||
|
grant_db_to_roles "$u"
|
||||||
|
done <<< "$users"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Per-database scoped roles (<db>_readers | _writers | _ddl) ────────────
|
||||||
|
echo "[*] Applying per-database scoped role grants"
|
||||||
|
perdb="$(sql "
|
||||||
|
SELECT User FROM mysql.user
|
||||||
|
WHERE is_role='Y' AND User REGEXP '_(readers|writers|ddl)\$'
|
||||||
|
ORDER BY User;")"
|
||||||
|
|
||||||
|
while IFS= read -r role; do
|
||||||
|
[ -z "$role" ] && continue
|
||||||
|
db="${role%_*}" # strip the trailing _readers/_writers/_ddl
|
||||||
|
kind="${role##*_}" # readers | writers | ddl
|
||||||
|
exists="$(sql "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(esc_str "$db")';")"
|
||||||
|
[ -z "$exists" ] && { echo " - skip ${role} (no database '${db}')"; continue; }
|
||||||
|
edb="$(esc_id "$db")"; erole="$(esc_id "$role")"
|
||||||
|
echo " - ${kind} on \`${db}\` -> ${role}"
|
||||||
|
case "$kind" in
|
||||||
|
readers) sql "GRANT SELECT ON \`${edb}\`.* TO \`${erole}\`;" ;;
|
||||||
|
writers) sql "GRANT SELECT, INSERT, UPDATE, DELETE ON \`${edb}\`.* TO \`${erole}\`;" ;;
|
||||||
|
ddl) sql "GRANT ALL PRIVILEGES ON \`${edb}\`.* TO \`${erole}\`;" ;;
|
||||||
|
esac
|
||||||
|
done <<< "$perdb"
|
||||||
|
|
||||||
|
echo "[*] Done."
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# sync-roles.sh — the ldap2pg analogue for MariaDB.
|
||||||
|
#
|
||||||
|
# There is no ldap2pg for MariaDB, so this reproduces the parts of ldap2pg.yaml
|
||||||
|
# we rely on: read AD group membership and materialise it as MariaDB roles plus
|
||||||
|
# per-user login accounts. Human accounts authenticate via PAM -> AD (no
|
||||||
|
# password is stored in MariaDB); the anchor roles carry the actual privileges,
|
||||||
|
# which provision-user-db.sh grants per database.
|
||||||
|
#
|
||||||
|
# Model (mirrors ldap2pg.yaml):
|
||||||
|
# Global groups under OU=Global,OU=Database,OU=Groups:
|
||||||
|
# owners | readers | writers | ddl -> members get the matching role
|
||||||
|
# Per-DB groups under OU=Database,OU=Groups:
|
||||||
|
# <db>_readers | <db>_writers | <db>_ddl -> scoped roles
|
||||||
|
#
|
||||||
|
# Those groups are engine-agnostic: they say WHAT privileges a member gets, not
|
||||||
|
# on which server. ENGINE_GROUP_DN is the orthogonal gate — a member is only
|
||||||
|
# provisioned here if they are also in that group, which is what keeps the
|
||||||
|
# shared role groups from creating MariaDB accounts for Postgres-only users.
|
||||||
|
#
|
||||||
|
# Connection: MYSQL_HOST/PORT/USER + MYSQL_PWD (read from the environment by the
|
||||||
|
# mariadb client, so the password never appears on the command line). Point this
|
||||||
|
# at the backend directly, NOT MaxScale — same as ldap2pg talking to the PG
|
||||||
|
# primary rather than through pgpool.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
: "${LDAPURI:?}"; : "${LDAPBINDDN:?}"; : "${LDAPPASSWORD:?}"
|
||||||
|
: "${GLOBAL_BASE:?}"
|
||||||
|
: "${PERDB_BASE:?}"
|
||||||
|
# Set but allowed to be empty: empty disables the engine gate entirely.
|
||||||
|
: "${ENGINE_GROUP_DN?}"
|
||||||
|
|
||||||
|
# LDAP_MATCHING_RULE_IN_CHAIN: matches through nested groups, so the engine gate
|
||||||
|
# can hold org groups (Daemons, DBAs) instead of only individual users.
|
||||||
|
ENGINE_FILTER=""
|
||||||
|
if [ -n "$ENGINE_GROUP_DN" ]; then
|
||||||
|
ENGINE_FILTER="(memberOf:1.2.840.113556.1.4.1941:=${ENGINE_GROUP_DN})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${MYSQL_HOST:?}"; : "${MYSQL_PORT:?}"; : "${MYSQL_USER:?}"
|
||||||
|
MARIADB=(mariadb --host "${MYSQL_HOST}" --port "${MYSQL_PORT}"
|
||||||
|
--user "${MYSQL_USER}" --batch --skip-column-names)
|
||||||
|
sql() { "${MARIADB[@]}" -e "$1"; }
|
||||||
|
|
||||||
|
# Escaping is context-dependent: in a '...' literal a backslash escapes the next
|
||||||
|
# character (default sql_mode), while in a `...` identifier it is literal.
|
||||||
|
esc_str() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/''/g"; }
|
||||||
|
esc_id() { printf '%s' "$1" | sed 's/`/``/g'; }
|
||||||
|
|
||||||
|
# Emit the sAMAccountName of every direct user member of the named AD group.
|
||||||
|
group_members() {
|
||||||
|
local base="$1" group="$2" dns
|
||||||
|
dns="$(ldapsearch -LLL -o ldif-wrap=no -H "$LDAPURI" \
|
||||||
|
-D "$LDAPBINDDN" -w "$LDAPPASSWORD" \
|
||||||
|
-b "$base" -s one "(&(objectClass=group)(cn=${group}))" member \
|
||||||
|
| sed -n 's/^member: //p')"
|
||||||
|
[ -z "$dns" ] && return 0
|
||||||
|
while IFS= read -r dn; do
|
||||||
|
[ -z "$dn" ] && continue
|
||||||
|
ldapsearch -LLL -o ldif-wrap=no -H "$LDAPURI" -D "$LDAPBINDDN" -w "$LDAPPASSWORD" \
|
||||||
|
-b "$dn" -s base \
|
||||||
|
"(&(objectCategory=person)(objectClass=user)(!(objectClass=computer))${ENGINE_FILTER})" \
|
||||||
|
sAMAccountName 2>/dev/null | sed -n 's/^sAMAccountName: //p'
|
||||||
|
done <<< "$dns"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_role() {
|
||||||
|
local role; role="$(esc_id "$1")"
|
||||||
|
sql "CREATE ROLE IF NOT EXISTS \`${role}\`;"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_user_in_role() {
|
||||||
|
local user role
|
||||||
|
user="$(esc_str "$1")"; role="$(esc_id "$2")"
|
||||||
|
# PAM login account: the password is verified against AD, nothing is stored.
|
||||||
|
sql "CREATE USER IF NOT EXISTS '${user}'@'%' IDENTIFIED VIA pam USING 'mariadb';"
|
||||||
|
sql "GRANT \`${role}\` TO '${user}'@'%';"
|
||||||
|
# MariaDB roles are inactive until selected, and it allows only ONE default
|
||||||
|
# role per user (no ALL, unlike MySQL) — make this role active at login.
|
||||||
|
sql "SET DEFAULT ROLE \`${role}\` FOR '${user}'@'%';"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[*] Ensuring anchor roles"
|
||||||
|
for r in owners readers writers ddl; do ensure_role "$r"; done
|
||||||
|
|
||||||
|
echo "[*] Syncing global AD groups -> roles"
|
||||||
|
for grp in owners readers writers ddl; do
|
||||||
|
while IFS= read -r sam; do
|
||||||
|
[ -z "$sam" ] && continue
|
||||||
|
sam="$(printf '%s' "$sam" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo " - ${sam} -> ${grp}"
|
||||||
|
ensure_user_in_role "$sam" "$grp"
|
||||||
|
done < <(group_members "$GLOBAL_BASE" "$grp")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[*] Syncing per-database AD groups -> scoped roles"
|
||||||
|
perdb_groups="$(ldapsearch -LLL -o ldif-wrap=no -H "$LDAPURI" \
|
||||||
|
-D "$LDAPBINDDN" -w "$LDAPPASSWORD" \
|
||||||
|
-b "$PERDB_BASE" -s one \
|
||||||
|
'(&(objectClass=group)(|(cn=*_readers)(cn=*_writers)(cn=*_ddl)))' cn \
|
||||||
|
| sed -n 's/^cn: //p')"
|
||||||
|
while IFS= read -r grp; do
|
||||||
|
[ -z "$grp" ] && continue
|
||||||
|
role="$(printf '%s' "$grp" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
ensure_role "$role"
|
||||||
|
while IFS= read -r sam; do
|
||||||
|
[ -z "$sam" ] && continue
|
||||||
|
sam="$(printf '%s' "$sam" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo " - ${sam} -> ${role}"
|
||||||
|
ensure_user_in_role "$sam" "$role"
|
||||||
|
done < <(group_members "$PERDB_BASE" "$grp")
|
||||||
|
done <<< "$perdb_groups"
|
||||||
|
|
||||||
|
echo "[*] Role sync complete."
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
ARG ALPINE=latest
|
||||||
|
ARG GO_VERSION=1.26.3
|
||||||
|
ARG LDAP2PG_VERSION=6.6.0
|
||||||
|
|
||||||
|
# ─── Build stage ───────────────────────────────────────────────────────
|
||||||
|
# ldap2pg 6.6.0 introduced strict YAML decoding but left the `joins`
|
||||||
|
# subsearch key half-renamed: the normalizer rewrites it to `subsearches`
|
||||||
|
# while the mapstructure tag on the target struct still says `joins`, so
|
||||||
|
# any config using the (documented) `joins:` key is rejected as
|
||||||
|
# "invalid keys: subsearches".
|
||||||
|
#
|
||||||
|
# The bug lives in a single struct tag; we grab the upstream source
|
||||||
|
# tarball for the pinned tag, patch that one line, and rebuild.
|
||||||
|
# Remove the whole builder stage and switch back to the released
|
||||||
|
# .apk once upstream ships the fix.
|
||||||
|
FROM golang:${GO_VERSION}-alpine AS builder
|
||||||
|
ARG LDAP2PG_VERSION
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl tar
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN curl -fsSL "https://github.com/dalibo/ldap2pg/archive/refs/tags/v${LDAP2PG_VERSION}.tar.gz" \
|
||||||
|
| tar -xz --strip-components=1
|
||||||
|
|
||||||
|
# Re-align the struct tag with the normalizer's canonical key name.
|
||||||
|
RUN grep -q 'mapstructure:"joins"' internal/ldap/search.go \
|
||||||
|
&& sed -i 's|mapstructure:"joins"|mapstructure:"subsearches"|' internal/ldap/search.go
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags "-s -w -X github.com/dalibo/ldap2pg/v6/internal/cmd.Version=${LDAP2PG_VERSION}" \
|
||||||
|
-o /out/ldap2pg .
|
||||||
|
|
||||||
|
# ─── Runtime stage ─────────────────────────────────────────────────────
|
||||||
|
FROM alpine:${ALPINE}
|
||||||
|
|
||||||
|
ARG LDAP2PG_VERSION
|
||||||
|
|
||||||
|
ENV LANG=en_US.utf8
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
RUN mkdir -p /docker-entrypoint.d \
|
||||||
|
&& apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
openldap-clients \
|
||||||
|
postgresql-client \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
#!/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 <dbname>_readers / <dbname>_writers / <dbname>_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 <something>_readers|_writers|_ddl
|
||||||
|
# and where the <something> 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."
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
ARG MARIADB_VERSION=latest
|
||||||
|
|
||||||
|
FROM mariadb:${MARIADB_VERSION}
|
||||||
|
|
||||||
|
# ── 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/*; \
|
||||||
|
plugin=/usr/lib/mysql/plugin/auth_pam.so; \
|
||||||
|
[ -e "$plugin" ] || { echo "auth_pam: missing $plugin" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── 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=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
|
||||||
|
|
||||||
|
ENTRYPOINT ["ldap-entrypoint.sh"]
|
||||||
|
CMD ["mariadbd"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Image-owned plugin preload (analogue of the postgres image's
|
||||||
|
# shared_preload_libraries in 10-extensions.conf). auth_pam provides Active
|
||||||
|
# Directory logins via PAM -> pam_ldap.
|
||||||
|
[mariadb]
|
||||||
|
plugin_load_add = auth_pam
|
||||||
|
|
||||||
|
# Make auth_pam use the mysql_clear_password protocol instead of the interactive
|
||||||
|
# "dialog" one. Required for MaxScale: its clear_pw_passthrough only speaks
|
||||||
|
# mysql_native_password / mysql_clear_password to backends, never dialog. The
|
||||||
|
# password is sent in cleartext, so TLS must be on (99-tls.cnf) — it is.
|
||||||
|
pam_use_cleartext_plugin = 1
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 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)}"
|
||||||
|
|
||||||
|
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
|
||||||
|
uri ${LDAPURI}
|
||||||
|
base ${LDAP_BASE}
|
||||||
|
binddn ${LDAPBINDDN}
|
||||||
|
bindpw ${LDAPPASSWORD}
|
||||||
|
scope sub
|
||||||
|
referrals no
|
||||||
|
pam_login_attribute ${LDAP_LOGIN_ATTR}
|
||||||
|
pam_filter objectclass=User
|
||||||
|
tls_cacertdir /etc/ssl/certs
|
||||||
|
tls_reqcert ${LDAP_TLS_REQCERT}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$conf"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 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)"
|
||||||
|
else
|
||||||
|
groupadd -g "${CERTS_GID}" hostcerts
|
||||||
|
certgroup=hostcerts
|
||||||
|
fi
|
||||||
|
usermod -aG "$certgroup" mysql
|
||||||
|
else
|
||||||
|
echo "ldap-entrypoint: not root (uid $(id -u)); left group membership unchanged (CERTS_GID=${CERTS_GID})" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec docker-entrypoint.sh "$@"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Runs once on first cluster init (empty datadir), the analogue of
|
||||||
|
# init_pgpool_user.sql. Creates the infrastructure accounts (MaxScale monitor +
|
||||||
|
# service, and the replication user) on the backend. All use native password
|
||||||
|
# auth (NOT pam) — they are service accounts, not human AD logins. Passwords
|
||||||
|
# come from the environment so nothing secret is committed to a .sql file, and
|
||||||
|
# the root password goes via MYSQL_PWD (read by the client) rather than -p so it
|
||||||
|
# never appears in the process list.
|
||||||
|
|
||||||
|
: "${MARIADB_ROOT_PASSWORD:?}"
|
||||||
|
: "${MAXSCALE_MONITOR_PASSWORD:?}"
|
||||||
|
: "${MAXSCALE_SERVICE_PASSWORD:?}"
|
||||||
|
: "${MARIADB_REPL_PASSWORD:?}"
|
||||||
|
|
||||||
|
MYSQL_PWD="${MARIADB_ROOT_PASSWORD}" mariadb --protocol=socket -uroot <<SQL
|
||||||
|
-- Monitor account: mariadbmon health checks, replication state, and failover.
|
||||||
|
CREATE USER IF NOT EXISTS 'maxscale_monitor'@'%' IDENTIFIED BY '${MAXSCALE_MONITOR_PASSWORD}';
|
||||||
|
GRANT BINLOG MONITOR, SLAVE MONITOR, REPLICATION SLAVE, REPLICATION MASTER ADMIN,
|
||||||
|
REPLICATION SLAVE ADMIN, READ_ONLY ADMIN, RELOAD, PROCESS,
|
||||||
|
SHOW DATABASES, EVENT
|
||||||
|
ON *.* TO 'maxscale_monitor'@'%';
|
||||||
|
|
||||||
|
-- Service account: reads the user tables so MaxScale can authenticate clients
|
||||||
|
-- and route them (pass-through auth defers the real check to the backend PAM).
|
||||||
|
CREATE USER IF NOT EXISTS 'maxscale_service'@'%' IDENTIFIED BY '${MAXSCALE_SERVICE_PASSWORD}';
|
||||||
|
GRANT SELECT ON mysql.* TO 'maxscale_service'@'%';
|
||||||
|
GRANT SHOW DATABASES ON *.* TO 'maxscale_service'@'%';
|
||||||
|
|
||||||
|
-- Replication account: the replica's IO thread uses this to stream the binlog.
|
||||||
|
CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED BY '${MARIADB_REPL_PASSWORD}';
|
||||||
|
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
|
||||||
|
SQL
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
ARG ALPINE=latest
|
||||||
|
ARG PG_MAJOR=18
|
||||||
|
ARG PGPOOL_VERSION=4.7.2
|
||||||
|
|
||||||
|
FROM alpine:${ALPINE} AS base
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
ARG PG_MAJOR
|
||||||
|
ARG PGPOOL_VERSION
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
linux-headers \
|
||||||
|
postgresql${PG_MAJOR}-dev \
|
||||||
|
openssl-dev \
|
||||||
|
openldap-dev \
|
||||||
|
libmemcached-dev \
|
||||||
|
wget
|
||||||
|
|
||||||
|
WORKDIR /tmp
|
||||||
|
|
||||||
|
RUN wget -qO- https://www.pgpool.net/source/pgpool-II-${PGPOOL_VERSION}.tar.gz | tar xz
|
||||||
|
|
||||||
|
WORKDIR /tmp/pgpool-II-${PGPOOL_VERSION}
|
||||||
|
RUN ./configure \
|
||||||
|
--prefix=/usr \
|
||||||
|
--sysconfdir=/etc/pgpool-II \
|
||||||
|
--with-openssl \
|
||||||
|
--with-ldap \
|
||||||
|
--with-memcached=/usr \
|
||||||
|
&& make -j"$(nproc)" \
|
||||||
|
&& make install DESTDIR=/build
|
||||||
|
|
||||||
|
WORKDIR /tmp/pgpool-II-${PGPOOL_VERSION}/src/sql
|
||||||
|
RUN make -j"$(nproc)" && make install DESTDIR=/build
|
||||||
|
|
||||||
|
# ── Runtime stage ──────────────────────────────────────────────────────────
|
||||||
|
FROM base
|
||||||
|
ARG PG_MAJOR
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
postgresql${PG_MAJOR}-client \
|
||||||
|
libldap \
|
||||||
|
openssl \
|
||||||
|
libmemcached-libs \
|
||||||
|
ca-certificates \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
# Copy built binaries, libs and default configs
|
||||||
|
COPY --from=builder /build/usr/ /usr/
|
||||||
|
COPY --from=builder /build/etc/ /etc/
|
||||||
|
|
||||||
|
# Runtime directories
|
||||||
|
RUN mkdir -p /var/run/pgpool /var/log/pgpool /etc/pgpool-II \
|
||||||
|
&& adduser -D -h /var/run/pgpool pgpool \
|
||||||
|
&& chown pgpool:pgpool /var/run/pgpool /var/log/pgpool
|
||||||
|
|
||||||
|
# Ship a minimal default config (overridden by bind-mount at runtime)
|
||||||
|
RUN set -eu; \
|
||||||
|
cp /etc/pgpool-II/pgpool.conf.sample /etc/pgpool-II/pgpool.conf; \
|
||||||
|
cp /etc/pgpool-II/pool_hba.conf.sample /etc/pgpool-II/pool_hba.conf; \
|
||||||
|
cp /etc/pgpool-II/pcp.conf.sample /etc/pgpool-II/pcp.conf
|
||||||
|
|
||||||
|
EXPOSE 9999 9898
|
||||||
|
|
||||||
|
# Green means pgpool can hand a client a backend connection, i.e. at least one
|
||||||
|
# node is attached AND a primary is identifiable -- NOT that the cluster is
|
||||||
|
# intact: a degenerated standby still reports healthy. Retries exist to ride out
|
||||||
|
# the ~1 health_check_period window where failover makes connections fail.
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=10s \
|
||||||
|
CMD pg_isready -h 127.0.0.1 -p 9999 || exit 1
|
||||||
|
|
||||||
|
# AES passwords in pool_passwd: mount the key anywhere and set PGPOOLKEYFILE to
|
||||||
|
# it; the entrypoint re-stages it at the permissions pgpool insists on.
|
||||||
|
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["pgpool", "-n", "-f", "/etc/pgpool-II/pgpool.conf", "-F", "/etc/pgpool-II/pcp.conf"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Clean up stale PID files from previous runs ───────────────────────────
|
||||||
|
rm -f /tmp/pgpool.pid /var/run/pgpool/pgpool.pid
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
ARG PG_MAJOR=18
|
||||||
|
ARG POSTGIS_MAJOR=3
|
||||||
|
ARG VCHORD_VERSION=1.1.1
|
||||||
|
|
||||||
|
FROM postgres:${PG_MAJOR:-18} AS base-image
|
||||||
|
WORKDIR /tmp
|
||||||
|
|
||||||
|
FROM base-image AS build-deps
|
||||||
|
ARG PG_MAJOR
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
wget \
|
||||||
|
curl \
|
||||||
|
build-essential \
|
||||||
|
cmake \
|
||||||
|
libkrb5-dev \
|
||||||
|
libicu-dev \
|
||||||
|
postgresql-server-dev-$PG_MAJOR \
|
||||||
|
&& apt-get install --reinstall -y ca-certificates \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
FROM build-deps AS downloads
|
||||||
|
WORKDIR /tmp
|
||||||
|
ARG PG_MAJOR
|
||||||
|
ARG VCHORD_VERSION
|
||||||
|
RUN wget https://github.com/tensorchord/VectorChord/releases/download/${VCHORD_VERSION}/postgresql-${PG_MAJOR}-vchord_${VCHORD_VERSION}-1_amd64.deb
|
||||||
|
|
||||||
|
FROM downloads AS timescaledb
|
||||||
|
ARG PG_MAJOR
|
||||||
|
WORKDIR /tmp/timescaledb
|
||||||
|
RUN URL_END=$(case "$PG_MAJOR" in ("12") echo "tag/2.11.2";; ("13") echo "tag/2.15.3";; (*) echo "latest";; esac) && \
|
||||||
|
ASSET_NAME=$(basename $(curl -LIs -o /dev/null -w %{url_effective} https://github.com/timescale/timescaledb/releases/${URL_END})) && \
|
||||||
|
curl --fail -L "https://github.com/timescale/timescaledb/archive/${ASSET_NAME}.tar.gz" | tar -zx --strip-components=1 -C . && ./bootstrap
|
||||||
|
WORKDIR /tmp/timescaledb/build
|
||||||
|
RUN make -j$(nproc) && \
|
||||||
|
make install
|
||||||
|
|
||||||
|
FROM base-image AS final
|
||||||
|
WORKDIR /tmp
|
||||||
|
ARG PG_MAJOR
|
||||||
|
ARG POSTGIS_MAJOR
|
||||||
|
ARG VCHORD_VERSION
|
||||||
|
|
||||||
|
## Install postgis, pg_hint_plan and pgvector
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
libldap-common \
|
||||||
|
postgresql-${PG_MAJOR}-postgis-${POSTGIS_MAJOR} \
|
||||||
|
postgresql-${PG_MAJOR}-postgis-${POSTGIS_MAJOR}-scripts \
|
||||||
|
postgis \
|
||||||
|
postgresql-${PG_MAJOR}-pgrouting \
|
||||||
|
postgresql-${PG_MAJOR}-pg-hint-plan \
|
||||||
|
postgresql-${PG_MAJOR}-pgvector \
|
||||||
|
&& apt-get install --reinstall -y ca-certificates
|
||||||
|
|
||||||
|
## Copy TimescaleDB from build image
|
||||||
|
COPY --from=timescaledb \
|
||||||
|
/usr/share/postgresql/$PG_MAJOR/extension/timescaledb* \
|
||||||
|
/usr/share/postgresql/$PG_MAJOR/extension/
|
||||||
|
COPY --from=timescaledb \
|
||||||
|
/usr/lib/postgresql/$PG_MAJOR/lib/timescaledb* \
|
||||||
|
/usr/lib/postgresql/$PG_MAJOR/lib/
|
||||||
|
|
||||||
|
## Install VectorChord
|
||||||
|
COPY --from=downloads /tmp/postgresql-${PG_MAJOR}-vchord_${VCHORD_VERSION}-1_amd64.deb /tmp/
|
||||||
|
RUN apt-get install -y --no-install-recommends ./postgresql-${PG_MAJOR}-vchord_${VCHORD_VERSION}-1_amd64.deb
|
||||||
|
|
||||||
|
## Mark curated extensions as trusted so non-superusers with CREATE on their
|
||||||
|
## database (e.g. app migration roles) can install them without superuser.
|
||||||
|
## These ship untrusted, otherwise yielding "permission denied to create
|
||||||
|
## extension". Safe because each only adds data types / index access methods /
|
||||||
|
## planner hints -- no filesystem, network, raw-storage, or SECURITY DEFINER
|
||||||
|
## access. Do NOT add postgis*, *_fdw, dblink, or storage-introspection modules.
|
||||||
|
RUN set -eu; \
|
||||||
|
for ext in vector vchord pg_hint_plan; do \
|
||||||
|
ctl="/usr/share/postgresql/${PG_MAJOR}/extension/${ext}.control"; \
|
||||||
|
[ -f "$ctl" ] || { echo "trusted-marking: missing $ctl" >&2; exit 1; }; \
|
||||||
|
grep -qE '^[[:space:]]*trusted[[:space:]]*=' "$ctl" \
|
||||||
|
|| printf 'trusted = true\n' >> "$ctl"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
## Layered configuration (applied to every newly initialized cluster).
|
||||||
|
## initdb seeds postgresql.conf from postgresql.conf.sample, so appending
|
||||||
|
## include_dir there pulls in our config regardless of the container command.
|
||||||
|
##
|
||||||
|
## Image-owned config lives under /usr/local/etc/postgresql (conf.d + pg_hba.conf);
|
||||||
|
## /etc/postgresql holds ONLY the user override dirs (conf.d, hba.d) so that
|
||||||
|
## path is a clean "mount your files here" area.
|
||||||
|
##
|
||||||
|
## postgresql.conf: our /usr/local/etc/postgresql/conf.d loaded first, user
|
||||||
|
## /etc/postgresql/conf.d last (last-wins => users override us, but a stray
|
||||||
|
## mount can't wipe our defaults e.g. shared_preload_libraries).
|
||||||
|
##
|
||||||
|
## pg_hba.conf: conf.d/20-hba.conf points hba_file at our authoritative
|
||||||
|
## /usr/local/etc/postgresql/pg_hba.conf, which fully replaces the initdb-
|
||||||
|
## generated file (and sidesteps the entrypoint's appended "host all all all
|
||||||
|
## <method>" line, written to the now-unused PGDATA copy). Tier order in that
|
||||||
|
## file: local allows -> include_dir /etc/postgresql/hba.d -> default-deny.
|
||||||
|
COPY conf.d/ /usr/local/etc/postgresql/conf.d/
|
||||||
|
COPY pg_hba.conf /usr/local/etc/postgresql/pg_hba.conf
|
||||||
|
RUN mkdir -p /etc/postgresql/conf.d \
|
||||||
|
/etc/postgresql/hba.d \
|
||||||
|
&& { \
|
||||||
|
echo "include_dir = '/usr/local/etc/postgresql/conf.d'"; \
|
||||||
|
echo "include_dir = '/etc/postgresql/conf.d'"; \
|
||||||
|
} >> /usr/share/postgresql/${PG_MAJOR}/postgresql.conf.sample
|
||||||
|
|
||||||
|
## Clean image
|
||||||
|
RUN rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& rm -rf /tmp/*
|
||||||
|
|
||||||
|
## Report health via pg_isready (env vars expand at runtime via /bin/sh -c).
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=2s \
|
||||||
|
CMD pg_isready --dbname="${POSTGRES_DB:-postgres}" --username="${POSTGRES_USER:-postgres}" || exit 1
|
||||||
|
|
||||||
|
WORKDIR /var/lib/postgresql/data
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Preloaded extension libraries for this image.
|
||||||
|
# Applied automatically to every newly initialized cluster via
|
||||||
|
# postgresql.conf.sample (include_dir), regardless of container command.
|
||||||
|
shared_preload_libraries = 'timescaledb,vchord,pg_hint_plan,pg_stat_statements'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Use the image's authoritative pg_hba.conf (which includes hba.user.d and a
|
||||||
|
# default-deny) instead of the initdb-generated one in PGDATA.
|
||||||
|
hba_file = '/usr/local/etc/postgresql/pg_hba.conf'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Authoritative pg_hba.conf for this image.
|
||||||
|
#
|
||||||
|
# Activated via the hba_file GUC (see conf.d/20-hba.conf), so it fully replaces
|
||||||
|
# the initdb-generated $PGDATA/pg_hba.conf. This also means the docker-entrypoint's
|
||||||
|
# appended "host all all all ${POSTGRES_HOST_AUTH_METHOD}" line (written to the
|
||||||
|
# PGDATA copy) is never consulted -- ordering here is authoritative.
|
||||||
|
#
|
||||||
|
# pg_hba.conf is FIRST-MATCH-WINS, so the tiers are ordered deliberately:
|
||||||
|
# 1. local/loopback allows (first: nothing can lock out local admin)
|
||||||
|
# 2. site rules (include_dir /etc/postgresql/hba.d: mount files here)
|
||||||
|
# 3. default-deny (last: fail closed)
|
||||||
|
#
|
||||||
|
# TYPE DATABASE USER ADDRESS METHOD
|
||||||
|
|
||||||
|
# 1. Local socket + loopback
|
||||||
|
# local all all trust
|
||||||
|
host all all 127.0.0.1/32 scram-sha-256
|
||||||
|
host all all ::1/128 scram-sha-256
|
||||||
|
|
||||||
|
# 2. Site-specific rules (user-provided; mount *.conf files into this dir)
|
||||||
|
include_dir "/etc/postgresql/hba.d"
|
||||||
|
|
||||||
|
# 3. Default-deny catch-all
|
||||||
|
host all all all reject
|
||||||
Reference in new issue
Block a user