From e8ee952951fdc6f86c365aba0b418498f4d503e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Mon, 21 Sep 2026 07:40:45 +0000 Subject: [PATCH 1/2] dev: add optional powerdns docker profile --- .docker/db/pdns-db.sql | 121 +++++++++++++++++++++++++++++++ .docker/pdns-auth/gpgsql.conf.j2 | 11 +++ .docker/pdns-auth/pdns.conf | 22 ++++++ .env.dist | 9 +++ docker-compose.yml | 67 +++++++++++++++++ docs/source/extending.rst | 33 +++++++++ 6 files changed, 263 insertions(+) create mode 100644 .docker/db/pdns-db.sql create mode 100644 .docker/pdns-auth/gpgsql.conf.j2 create mode 100644 .docker/pdns-auth/pdns.conf diff --git a/.docker/db/pdns-db.sql b/.docker/db/pdns-db.sql new file mode 100644 index 000000000..3edac0462 --- /dev/null +++ b/.docker/db/pdns-db.sql @@ -0,0 +1,121 @@ +-- PowerDNS database for the docker compose profile "powerdns". +-- +-- Run by the pdns-db-init service on every start: +-- psql -v ON_ERROR_STOP=1 -v pdns_db=... -v pdns_user=... -v pdns_password=... +-- -h db -U $POSTGRES_USER -d postgres -f /pdns-db.sql +-- Everything is idempotent, so it also works on an existing postgres-data +-- volume (Postgres initdb scripts would only run on an empty one). + +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'pdns_user', :'pdns_password') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'pdns_user') \gexec + +SELECT format('CREATE DATABASE %I OWNER %I', :'pdns_db', :'pdns_user') +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'pdns_db') \gexec + +\connect :pdns_db + +-- Create the tables as the PowerDNS role so it owns them and needs no grants. +SET ROLE :"pdns_user"; + +-- Schema of the generic PostgreSQL backend, PowerDNS Authoritative 5.0.x: +-- https://github.com/PowerDNS/pdns/blob/rel/auth-5.0.x/modules/gpgsqlbackend/schema.pgsql.sql +-- Verbatim except for the added IF NOT EXISTS clauses. + +CREATE TABLE IF NOT EXISTS domains ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type TEXT NOT NULL, + notified_serial BIGINT DEFAULT NULL, + account VARCHAR(40) DEFAULT NULL, + options TEXT DEFAULT NULL, + catalog TEXT DEFAULT NULL, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX IF NOT EXISTS name_index ON domains(name); +CREATE INDEX IF NOT EXISTS catalog_idx ON domains(catalog); + + +CREATE TABLE IF NOT EXISTS records ( + id BIGSERIAL PRIMARY KEY, + domain_id INT DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(65535) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + disabled BOOL DEFAULT 'f', + ordername VARCHAR(255), + auth BOOL DEFAULT 't', + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX IF NOT EXISTS rec_name_index ON records(name); +CREATE INDEX IF NOT EXISTS nametype_index ON records(name,type); +CREATE INDEX IF NOT EXISTS domain_id ON records(domain_id); +CREATE INDEX IF NOT EXISTS recordorder ON records (domain_id, ordername text_pattern_ops); + + +CREATE TABLE IF NOT EXISTS supermasters ( + ip INET NOT NULL, + nameserver VARCHAR(255) NOT NULL, + account VARCHAR(40) NOT NULL, + PRIMARY KEY(ip, nameserver) +); + + +CREATE TABLE IF NOT EXISTS comments ( + id SERIAL PRIMARY KEY, + domain_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) DEFAULT NULL, + comment VARCHAR(65535) NOT NULL, + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX IF NOT EXISTS comments_domain_id_idx ON comments (domain_id); +CREATE INDEX IF NOT EXISTS comments_name_type_idx ON comments (name, type); +CREATE INDEX IF NOT EXISTS comments_order_idx ON comments (domain_id, modified_at); + + +CREATE TABLE IF NOT EXISTS domainmetadata ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + kind VARCHAR(32), + content TEXT +); + +CREATE INDEX IF NOT EXISTS domainidmetaindex ON domainmetadata(domain_id); + + +CREATE TABLE IF NOT EXISTS cryptokeys ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + flags INT NOT NULL, + active BOOL, + published BOOL DEFAULT TRUE, + content TEXT +); + +CREATE INDEX IF NOT EXISTS domainidindex ON cryptokeys(domain_id); + + +CREATE TABLE IF NOT EXISTS tsigkeys ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + algorithm VARCHAR(50), + secret VARCHAR(255), + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX IF NOT EXISTS namealgoindex ON tsigkeys(name, algorithm); diff --git a/.docker/pdns-auth/gpgsql.conf.j2 b/.docker/pdns-auth/gpgsql.conf.j2 new file mode 100644 index 000000000..e7799bfd5 --- /dev/null +++ b/.docker/pdns-auth/gpgsql.conf.j2 @@ -0,0 +1,11 @@ +# gpgsql connection settings for the pdns-auth service. +# +# Rendered at container start by the image entrypoint (TEMPLATE_FILES=gpgsql) +# from the container environment into /etc/powerdns/pdns.d/gpgsql.conf, so +# pdns_server and pdnsutil use the same settings. Values come from .env via +# docker-compose.yml. +gpgsql-host={{ GPGSQL_HOST }} +gpgsql-port={{ GPGSQL_PORT }} +gpgsql-dbname={{ GPGSQL_DBNAME }} +gpgsql-user={{ GPGSQL_USER }} +gpgsql-password={{ GPGSQL_PASSWORD }} diff --git a/.docker/pdns-auth/pdns.conf b/.docker/pdns-auth/pdns.conf new file mode 100644 index 000000000..c7e3937ac --- /dev/null +++ b/.docker/pdns-auth/pdns.conf @@ -0,0 +1,22 @@ +# PowerDNS Authoritative Server for the local docker compose setup +# (profile "powerdns"), see https://doc.powerdns.com/authoritative/settings.html +# +# The gpgsql-* connection settings are rendered from the environment into +# pdns.d/gpgsql.conf at container start (see gpgsql.conf.j2), so pdnsutil +# sees them too. +launch=gpgsql +gpgsql-dnssec=no + +local-address=0.0.0.0,:: +include-dir=/etc/powerdns/pdns.d + +# HTTP API used by serveradmin_powerdns (POWERDNS_API_ENDPOINT / _SECRET_KEY) +webserver=yes +webserver-address=0.0.0.0 +webserver-port=8081 +webserver-allow-from=0.0.0.0/0,::/0 +api=yes +api-key=0815passwd + +# 7 logs every query, useful when debugging the sync; 6 is the normal level. +loglevel=7 diff --git a/.env.dist b/.env.dist index 50b2f95d9..0492a52a3 100644 --- a/.env.dist +++ b/.env.dist @@ -5,6 +5,15 @@ POSTGRES_PASSWORD=serveradmin POSTGRES_HOST=db POSTGRES_PORT=5432 +# Optional PowerDNS dev service (docker compose profile "powerdns", see docs/source/extending.rst). +# Enable it with: +# COMPOSE_PROFILES=powerdns +POSTGRES_POWERDNS_DB=powerdns +POSTGRES_POWERDNS_USER=powerdns +POSTGRES_POWERDNS_PASSWORD=powerdns +POSTGRES_POWERDNS_HOST=db +POSTGRES_POWERDNS_PORT=5432 + # Credentials for default super user (requires Django >= 3.x) DJANGO_SUPERUSER_USERNAME=serveradmin DJANGO_SUPERUSER_EMAIL=serveradmin@example.com diff --git a/docker-compose.yml b/docker-compose.yml index ebdadb67d..be8c07190 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,11 @@ services: ports: - "127.0.0.1:5432:5432" command: "-c config_file=/etc/postgresql/postgres.conf" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 12 web: build: .docker/web @@ -25,5 +30,67 @@ services: depends_on: - db + # --------------------------------------------------------------------------- + # Optional: PowerDNS Authoritative Server for developing DNS integrations + # (e.g. the serveradmin_powerdns app). Not started by default; enable with + # COMPOSE_PROFILES=powerdns in .env or `docker compose --profile powerdns up`. + # See docs/source/extending.rst. + + # Creates the separate PowerDNS database, role and gpgsql schema inside the + # db service. Idempotent, runs on every start, works with existing volumes. + pdns-db-init: + image: "postgres:17" + profiles: ["powerdns"] + environment: + - PGPASSWORD=${POSTGRES_PASSWORD} + volumes: + - ".docker/db/pdns-db.sql:/pdns-db.sql:ro" + command: + - psql + - -v + - ON_ERROR_STOP=1 + - -v + - pdns_db=${POSTGRES_POWERDNS_DB:-powerdns} + - -v + - pdns_user=${POSTGRES_POWERDNS_USER:-powerdns} + - -v + - pdns_password=${POSTGRES_POWERDNS_PASSWORD:-powerdns} + - -h + - ${POSTGRES_HOST:-db} + - -U + - ${POSTGRES_USER} + - -d + - postgres + - -f + - /pdns-db.sql + depends_on: + db: + condition: service_healthy + + pdns-auth: + image: "powerdns/pdns-auth-50:latest" + profiles: ["powerdns"] + volumes: + - ".docker/pdns-auth/pdns.conf:/etc/powerdns/pdns.conf:ro" + - ".docker/pdns-auth/gpgsql.conf.j2:/etc/powerdns/templates.d/gpgsql.j2:ro" + # The image entrypoint renders templates.d/gpgsql.j2 with these variables + # into pdns.d/gpgsql.conf (read by pdns_server and pdnsutil alike). + environment: + - TEMPLATE_FILES=gpgsql + - GPGSQL_HOST=${POSTGRES_POWERDNS_HOST:-db} + - GPGSQL_PORT=${POSTGRES_POWERDNS_PORT:-5432} + - GPGSQL_DBNAME=${POSTGRES_POWERDNS_DB:-powerdns} + - GPGSQL_USER=${POSTGRES_POWERDNS_USER:-powerdns} + - GPGSQL_PASSWORD=${POSTGRES_POWERDNS_PASSWORD:-powerdns} + ports: + - "127.0.0.1:1053:53" + - "127.0.0.1:1053:53/udp" + - "127.0.0.1:8081:8081" # HTTP API, key in .docker/pdns-auth/pdns.conf + depends_on: + db: + condition: service_healthy + pdns-db-init: + condition: service_completed_successfully + volumes: postgres-data: diff --git a/docs/source/extending.rst b/docs/source/extending.rst index e47f7d88b..e8b91a51a 100644 --- a/docs/source/extending.rst +++ b/docs/source/extending.rst @@ -40,6 +40,39 @@ host machines and run uv sync to have all modules available for your IDEs auto completion etc. +Optional: PowerDNS +^^^^^^^^^^^^^^^^^^ + +For developing DNS integrations (for example the ``serveradmin_powerdns`` +app) the compose file contains a PowerDNS Authoritative Server with a +PostgreSQL backend under the compose profile ``powerdns``. It is not started +by default. Enable it permanently in your ``.env``:: + + COMPOSE_PROFILES=powerdns + +or for a single run:: + + docker compose --profile powerdns up + +The profile adds two services: + +* ``pdns-db-init`` creates the database, role and schema for PowerDNS inside + the ``db`` service and exits. It is idempotent and also works on an + existing ``postgres-data`` volume. +* ``pdns-auth`` runs ``powerdns/pdns-auth-50`` with the gpgsql backend. The + HTTP API listens on http://127.0.0.1:8081 (API key ``0815passwd``, see + ``.docker/pdns-auth/pdns.conf``), DNS on ``127.0.0.1:1053``. Inside the + compose network the API is reachable as ``http://pdns-auth:8081``. + +The database connection is configured by the ``POSTGRES_POWERDNS_*`` variables +in ``.env`` (see ``.env.dist``). Useful commands:: + + alias pdns='docker compose exec pdns-auth pdnsutil' + pdns list-all-zones + curl -H 'X-API-Key: 0815passwd' http://127.0.0.1:8081/api/v1/servers/localhost/zones + dig @127.0.0.1 -p 1053 example.com SOA + + Database Dump ------------- From 2d6cb09603d4ee86d5c69b3bd64fefba96737609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Mon, 21 Sep 2026 11:45:46 +0000 Subject: [PATCH 2/2] Revert "db: faster ptr() as an inlinable SQL function" This reverts commit d95ce72da3b377c44e0aabb4e80a292cd89dbbdc. --- db/ptr.sql | 46 +++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/db/ptr.sql b/db/ptr.sql index 0952aec85..c7d022c73 100644 --- a/db/ptr.sql +++ b/db/ptr.sql @@ -1,20 +1,36 @@ --- Reverse DNS name of an address. Plain SQL (no STRICT, no sub-SELECT, only --- immutable functions) so Postgres inlines it into the calling query; it --- expands the address from the inet_send() wire bytes (4 header bytes, then --- the address), so the netmask is ignored. Kept in sync with --- serveradmin_extras/serveradmin_powerdns/migrations/0006_ptr_function.py. create or replace function public.ptr(intern_ip inet) returns text -language sql immutable parallel safe as $$ -select case family(intern_ip) - when 4 then - get_byte(inet_send(intern_ip), 7)::text || '.' || - get_byte(inet_send(intern_ip), 6)::text || '.' || - get_byte(inet_send(intern_ip), 5)::text || '.' || - get_byte(inet_send(intern_ip), 4)::text || '.in-addr.arpa' +immutable strict language plpgsql as $$ +declare + ip_part text; + ip_text text; + ip_arr text[]; + ip_filled boolean; +begin + if family(intern_ip) = 4 then + return ( + select string_agg(part, '.' order by ord desc) || '.in-addr.arpa' + from regexp_split_to_table(host(intern_ip), '\.') with ordinality as s(part, ord) + ); else - regexp_replace(reverse(encode(substring(inet_send(intern_ip) from 5), 'hex')), '(.)', '\1.', 'g') - || 'ip6.arpa' -end + ip_text := ''; + ip_arr := regexp_split_to_array(host(intern_ip), ':'); + ip_filled = false; + + foreach ip_part in array ip_arr loop + if length(ip_part) = 0 and not ip_filled then + ip_text := ip_text || repeat('0', 32 - (array_length(ip_arr, 1) - 1) * 4); + ip_filled = true; + else + ip_text := ip_text || lpad(ip_part, 4, '0'); + end if; + end loop; + + return ( + select string_agg(part[1], '.' order by ord desc) || '.ip6.arpa' + from regexp_matches(ip_text, '.', 'g') with ordinality as s(part, ord) + ); + end if; +end; $$; create index concurrently server_ptr_idx on public.server(public.ptr(intern_ip) text_pattern_ops);