The PowerDNS Authoritative API has a single token with full control over every
zone. This proxy sits in front of that API and gives each client its own token
instead, scoped to the zones and records it actually needs. A certbot hook, for
example, can get write access to nothing but _acme-challenge records.
- Multiple tokens per PowerDNS instance, each represented by an environment
- Per-zone access: whole zone, selected records, or regex patterns
- Read-only zones and admin zones (create/delete the zone itself)
- Optional access to subzones and regex-matched zones
- Server-wide permissions for search, config, statistics, TSIG keys and DNSSEC keys
- Prometheus metrics, audit logging and optional Sentry integration
- Compatible with the PowerDNS API v1 (
X-API-Keyheader,{"error": ...}responses)
The client authenticates with a token; the config only stores its SHA-512 hash:
token=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 40 ; echo '')
token_hash=$(echo -n "$token" | sha512sum | cut -f 1 -d " ")
echo "Token: $token"
echo "Hash: $token_hash"config.yaml:
pdns_api_url: "https://powerdns-api.example.com"
pdns_api_token: "your-powerdns-api-key"
environments:
- name: "example-env"
token_sha512: "<hash from step 1>"
zones:
- name: "example.com"A full example is available in config-example.yml.
Containers are published to
GHCR.
Versioned tags (v1.12.3, ...) are published alongside latest; pinning a
release tag instead of latest is recommended for production (see the
releases for the
latest tag):
docker run -v ./config:/config \
-p 8000:8000 \
-e PROXY_CONFIG_PATH=/config/config.yaml \
-e LOG_LEVEL=INFO \
-e LOG_FORMAT=json \
--name powerdns-api-proxy \
ghcr.io/akquinet/powerdns-api-proxy:latestThe container listens on port 8000 and runs as a non-root user (UID 1000),
so the mounted config must be readable by that user.
Send the token from step 1 in the X-API-Key header, exactly like with the
PowerDNS API. Only localhost is accepted as the server ID:
curl -H "X-API-Key: $token" \
http://localhost:8000/api/v1/servers/localhost/zones
curl -H "X-API-Key: $token" \
http://localhost:8000/info/allowedflowchart LR
client[Client] -->|own token, X-API-Key| proxy["Proxy<br/>per-env permissions"]
proxy -->|pdns_api_token| pdns[PowerDNS API]
Each client sends its own token; the proxy maps it to an environment, checks
zone and record permissions, then forwards allowed requests to the PowerDNS
API using the upstream pdns_api_token.
Every API client gets its own token, and each token maps to an environment.
Only the SHA-512 hash of a token is stored in the config (token_sha512),
never the token itself. An environment defines which zones the client may
access and which server-wide permissions it has.
environments:
- name: "Test1"
token_sha512: "1954a12ef0bf45b3a1797437509037f178af846d880115d57668a8aaa05732deedcbbd02bfa296b4f4e043b437b733fd6131933cfdc0fb50c4cf7f9f2bdaa836"
zones:
- name: "example.com"A zone entry always grants read access to that zone. Write access depends on what is listed under it:
- No
recordsand noregex_records: the client may write all records in the zone (all_recordsis enabled automatically) and the ACME service is enabled as well. records: only the listed record names may be written.regex_records: only record names fully matching one of the regexes may be written.
environments:
- name: "Test1"
zones:
- name: "example.com"
records:
- "test.example.com"
regex_records:
- "_acme-challenge.service-.*.example.com"Zone names and record names are matched in full (re.fullmatch); a trailing
dot is ignored on both sides.
Additional zone options:
-
read_only: trueremoves write access within the zone entirely. It cannot be combined withadmin: trueorcryptokeys: true, since those enable writes; a config that does so is rejected at startup. -
subzones: trueapplies the same permissions to all subzones. -
regex: truetreatsnameas a regex, e.g. all zones ending in.example.com:zones: - name: ".*\\.example.com" regex: true
-
admin: trueallows creating and deleting the zone. -
cryptokeys: trueallows managing the DNSSEC keys of the zone.
A zone can additionally be selected by the upstream zone attributes kind
(Master, Slave, Native, Producer, Consumer), account and catalog.
kind is compared case-insensitively, account must match exactly, and
catalog is compared like a zone name, ignoring an optional trailing dot.
environments:
- name: "Test1"
zones:
- name: ".*\\.example.com"
regex: true
kind: "Master"
- name: "public.example.com."
account: "public"
- name: "example.com."
catalog: "default-catalog.invalid"The attributes are matched in addition to the name: a zone is allowed when its
name (or regex) matches and every configured attribute matches. Attributes are
empty by default, which disables the filter.
A zone entry without a name matches by attribute only, so all zones with that
kind, account or catalog are allowed without listing their names:
environments:
- name: "Test1"
zones:
- kind: "Master"
- account: "public"A zone entry needs either a name or one of the attribute filters. An entry
without a name cannot set regex, records, regex_records or subzones,
because those compare against the zone name. Permissions like admin or
read_only on an attribute-only entry apply to every zone it matches, so treat
such an entry as a wildcard. Entries with a name are matched first (exact
name, then subzones, then regex), so an attribute-only entry only applies to
zones that no named entry matches.
The filter applies to the zone listing, to requests for a single zone and to a
zone's cryptokeys endpoints. Creating a zone requires a matching entry with a
name; attribute-only entries never grant creation, because the attributes of a
zone that does not exist in PowerDNS yet cannot be checked.
When several entries match a zone, the first one whose name and attributes match wins. Named entries are checked before attribute-only entries. If a named entry matches the zone name but not its attributes, the request is denied and attribute-only entries are not consulted, so an explicit entry always restricts its zone. Among attribute-only entries, the first one whose attributes match wins, which also determines the permissions that apply.
In a global_read_only environment all zones stay readable: the zone listing is
not filtered and GET for a single zone is forwarded without an attribute
check. Attribute filters only constrain writes there; PATCH, PUT, DELETE,
notify and rectify are still attribute-checked, and zones that are not
listed stay read-only. Cryptokeys management always requires the cryptokeys
permission and stays attribute-checked.
For a zone that lists individual records, ACME challenge writes must be
enabled explicitly:
zones:
- name: "example.com"
records:
- "test.example.com"
services:
acme: trueThis permits writes to _acme-challenge.test.example.com. For zones without a
records list, ACME is enabled automatically because all records are writable
anyway.
- Missing or unknown
X-API-Keytoken:401 - Known token without permission:
403 GET /info/allowedlists the zones a token may access;GET /info/zone-allowed?zone=<zone>checks a single zone and shows which config entry grants access. When the matching entry filters bykind,accountorcatalog, the zone is fetched from PowerDNS to verify those attributes; inglobal_read_onlyenvironments every zone is reported as readable.
Error responses follow the PowerDNS format:
{"error": "Zone not allowed"}Upstream errors 400, 404, 409 and 422 are passed through with the
PowerDNS error message; other upstream failures are reported as 500.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET, HEAD | / |
none | Index page; returns 404 when index_enabled: false |
| GET | /docs |
none | Swagger UI; disabled with api_docs_enabled: false |
| GET | /health |
none | Liveness probe; always 200, does not call upstream |
| GET | /health/pdns |
token | Checks the upstream PowerDNS API (200 or 500) |
| GET | /metrics |
basic auth | Prometheus metrics |
| GET | /info/allowed |
token | Zones the token may access |
| GET | /info/zone-allowed?zone=<zone> |
token | Whether a zone is allowed and which entry matches |
| GET | /api |
token | Proxy and API version info |
For /metrics, environments with metrics_proxy: true authenticate with
HTTP basic auth when metrics_require_auth: true (the default):
- username: name of the environment
- password: token
With metrics_require_auth: false no authentication is required.
The proxy exposes the following
PowerDNS API v1
endpoints below /api/v1. They all require a valid token, and the server ID
must be localhost; any other value returns 404.
| Endpoint | Additional permission |
|---|---|
GET /servers, GET /servers/{server_id} |
none |
GET /servers/{server_id}/config, .../config/{name} |
global_config: true |
GET /servers/{server_id}/statistics |
global_statistics: true |
GET /servers/{server_id}/zones |
Response is filtered to the allowed zones |
POST /servers/{server_id}/zones |
Zone allowed and admin: true |
GET /servers/{server_id}/zones/{zone_id} |
Read access to the zone |
PUT /servers/{server_id}/zones/{zone_id} |
admin: true |
PATCH /servers/{server_id}/zones/{zone_id} |
Write access (records/regex), not read_only |
DELETE /servers/{server_id}/zones/{zone_id} |
admin: true |
PUT .../zones/{zone_id}/notify, .../rectify |
Read access to the zone |
GET /servers/{server_id}/search-data |
global_search: true |
.../zones/{zone_id}/cryptokeys... |
cryptokeys: true on the zone or global_cryptokeys: true |
/servers/{server_id}/tsigkeys... |
global_tsigkeys: true |
Zone IDs, key IDs and other path parameters are validated before they are
forwarded; values PowerDNS would not understand as-is are rejected with 400.
The configuration is YAML and is read once at startup, so restart the proxy to
apply changes. The path is passed via the PROXY_CONFIG_PATH environment
variable.
| Key | Default | Description |
|---|---|---|
pdns_api_url |
required | Base URL of the upstream PowerDNS API |
pdns_api_token |
required | API token of the upstream PowerDNS API |
pdns_api_verify_ssl |
true |
Verify the upstream TLS certificate |
environments |
required | List of environments (tokens) |
metrics_enabled |
true |
Expose /metrics |
metrics_require_auth |
true |
Require basic auth for /metrics |
api_docs_enabled |
true |
Serve the Swagger UI at /docs |
index_enabled |
true |
Serve the index page at / |
index_html |
built-in page | HTML returned by / |
| Key | Default | Description |
|---|---|---|
name |
required | Name of the environment; also the metrics basic-auth username |
token_sha512 |
required | SHA-512 hash of the client token (128 hex characters) |
zones |
[] |
Zones the token may access |
global_read_only |
false |
Read access to all zones; zones that are not listed stay read-only |
global_search |
false |
Access to /search-data |
global_cryptokeys |
false |
DNSSEC key management in all zones |
global_tsigkeys |
false |
Read and modify TSIG keys |
global_config |
false |
Read the upstream server configuration |
global_statistics |
false |
Read upstream server statistics |
metrics_proxy |
false |
Access /metrics via basic auth |
environments:
- name: "monitoring"
token_sha512: "..."
global_read_only: true
global_search: true
global_statistics: true
metrics_proxy: true| Key | Default | Description |
|---|---|---|
name |
"" |
Zone name, or a regex when regex: true; required unless a kind/account/catalog filter is set |
description |
"" |
Free-form description; visible in /info/allowed |
regex |
false |
Treat name as a regex |
records |
[] |
Record names with write access |
regex_records |
[] |
Record regexes with write access |
read_only |
false |
Disable all write access within the zone; cannot be combined with admin or cryptokeys |
admin |
false |
Allow creating and deleting the zone |
subzones |
false |
Apply the permissions to all subzones |
cryptokeys |
false |
Allow DNSSEC key management in the zone |
kind |
"" |
Filter by upstream zone kind (Master, Slave, Native, Producer, Consumer); case-insensitive |
account |
"" |
Filter by upstream zone account (exact match) |
catalog |
"" |
Filter by upstream catalog zone (trailing dot optional) |
services.acme |
see above | Allow ACME challenge records |
all_records is not configured directly: it is enabled automatically when a
zone lists neither records nor regex_records.
| Variable | Default | Description |
|---|---|---|
PROXY_CONFIG_PATH |
required | Path to the YAML configuration |
LOG_LEVEL |
DEBUG |
DEBUG, INFO, WARNING or ERROR |
LOG_FORMAT |
text |
text or json; applies to application and uvicorn access logs |
LOG_FILE |
log |
Log file path; rotated at 100 MB with 5 backups; set to false to disable file logging |
LISTEN_HOST |
* |
Address to bind to |
LISTEN_PORT |
8000 |
Port to listen on |
AUDIT_LOGGING |
true |
Set to false to disable audit logging |
AUDIT_LOG_CLIENT_IP |
false |
Set to true to include client IPs in audit logs |
FORWARDED_ALLOW_IPS |
127.0.0.1 |
Peers trusted to set X-Forwarded-For |
SENTRY_DSN |
unset | Enables Sentry error reporting when set |
SENTRY_TRACES_SAMPLE_RATE |
0.1 |
Sentry trace sample rate (only used with SENTRY_DSN) |
ENVIRONMENT |
DEV |
Environment name reported to Sentry |
The proxy exposes Prometheus metrics on /metrics using
prometheus-fastapi-instrumentator.
In addition to the default metrics, http_requests_environment counts requests
by environment, method, status and handler.
Set metrics_enabled: false to disable the endpoint, and
metrics_require_auth: false to drop the basic-auth requirement.
The API documentation is available at /docs. It can be disabled, including
the OpenAPI schema, with:
api_docs_enabled: false # default is trueThe index page at / can be disabled and customized:
index_enabled: false # default is true
# Single line HTML
index_html: "<html><body><h1>PowerDNS API Proxy</h1></body></html>"
# Or multiline HTML
index_html: |
<html>
<body>
<h1>PowerDNS API Proxy</h1>
<p>Custom content here</p>
</body>
</html>Logs are written to stderr and, unless LOG_FILE=false is set, also to a
rotating log file. With LOG_FORMAT=json, application logs and uvicorn access
logs are emitted as JSON. See
Environment variables for all log settings.
Every request to /api/v1/... is automatically logged to stderr with
structured data via middleware. This includes successful operations and
forbidden attempts (403); requests without a valid token are logged with the
environment name UNAUTHENTICATED. The /api/v1/servers/localhost prefix is
stripped from the logged path.
Each audit log entry contains:
timestamp: ISO 8601 timestamplevel: Log level (INFO)event_type:"audit"for easy filteringaudit: Structured audit data object containing:environment: Name of the authenticated environment/tokenclient_ip: IP address of the client that made the request. Only present whenAUDIT_LOG_CLIENT_IP=true;"unknown"when the peer address is unavailable, otherwise omitted. See Running Behind a Reverse Proxy.method: HTTP method (GET, POST, PUT, PATCH, DELETE)path: Resource path that was accessed/modifiedstatus_code: HTTP response status codepayload: Request payload (optional, for write operations; omitted for sensitive endpoints like/cryptokeysand/tsigkeys)query_params: Query parameters (optional, for GET requests)
The upstream request and response logs for the /cryptokeys and /tsigkeys
endpoints replace the body with <redacted>, because they carry DNSSEC private
key and TSIG secret material.
Client IP addresses are personal data, so they are not logged by default.
AUDIT_LOG_CLIENT_IP=true does not affect the uvicorn access log, which logs
the client address independently; to suppress IPs entirely, raise the log level
of the uvicorn.access logger as well.
INFO - 2026-03-26 12:03:21,323 - powerdns_api_proxy - proxy.py - audit - 70 - AUDIT: Test1 PATCH /zones/example.com 204 payload={'rrsets': []}
INFO - 2026-03-26 12:03:21,323 - powerdns_api_proxy - proxy.py - audit - 70 - AUDIT: Test1 GET /zones 200 query_params={'rrsets': 'true'}
INFO - 2026-03-26 12:03:21,323 - powerdns_api_proxy - proxy.py - audit - 70 - AUDIT: Test1 DELETE /zones/test.com 403
With AUDIT_LOG_CLIENT_IP=true, the client IP is reported next to the
environment. The text format only prints a real address, never the unknown
marker:
INFO - 2026-03-26 12:03:21,323 - powerdns_api_proxy - proxy.py - audit - 70 - AUDIT: Test1 203.0.113.9 PATCH /zones/example.com 204 payload={'rrsets': []}
{"timestamp": "2026-03-26T11:39:29", "level": "INFO", "event_type": "audit", "audit": {"environment": "Test1", "method": "PATCH", "path": "/zones/example.com", "status_code": 204, "payload": {"rrsets": [...]}}}With AUDIT_LOG_CLIENT_IP=true, client_ip is reported next to the
environment:
{"timestamp": "2026-03-26T11:39:29", "level": "INFO", "event_type": "audit", "audit": {"environment": "Test1", "client_ip": "203.0.113.9", "method": "PATCH", "path": "/zones/example.com", "status_code": 204, "payload": {"rrsets": [...]}}}With JSON format enabled, jq can filter and analyze audit logs
piped from the container:
# Filter only audit events
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit")'
# Filter by environment
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and .audit.environment == "Test1")'
# Filter by HTTP method
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and .audit.method == "PATCH")'
# Filter by zone
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and (.audit.path | contains("/zones/example.com")))'
# Extract only key audit fields
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit") | {timestamp, environment: .audit.environment, method: .audit.method, path: .audit.path, status: .audit.status_code}'
# Show failed attempts (403)
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and .audit.status_code == 403)'
# Filter by client IP (requires AUDIT_LOG_CLIENT_IP=true)
docker logs powerdns-api-proxy 2>&1 | jq 'select(.event_type == "audit" and .audit.client_ip == "203.0.113.9")'When the proxy runs behind a reverse proxy, client_ip comes from
request.client.host, which uvicorn rewrites from X-Forwarded-For only when
the immediate peer is listed in FORWARDED_ALLOW_IPS (default: 127.0.0.1).
Trusted addresses become the client_ip and the uvicorn access-log address;
untrusted headers are ignored and the real TCP peer is used.
Set FORWARDED_ALLOW_IPS to the address of the reverse proxy that connects to
this service (CIDR ranges and comma-separated hosts are supported):
FORWARDED_ALLOW_IPS=10.244.0.0/16 # CIDR ranges are supported
FORWARDED_ALLOW_IPS=10.0.0.7,10.0.0.8 # or a comma-separated list of hostsMinimal nginx configuration:
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}Avoid FORWARDED_ALLOW_IPS=*: it trusts every peer and lets any client forge
its own client_ip. Note that $proxy_add_x_forwarded_for appends to
whatever the client sent, which is correct behind another proxy but lets a
direct client prepend a forged hop; at the edge, overwrite the header instead
(proxy_set_header X-Forwarded-For $remote_addr;). With several hops, list
every intermediate proxy in FORWARDED_ALLOW_IPS.
uv is the recommended tool for Python package management in this repository.
Install uv and sync the locked environment:
make setupThis runs uv sync, which creates .venv and installs the dependencies from
uv.lock, including the dev group, and installs the
prek Git hook. Prefix commands with uv run to
execute them in that environment.
uv is only used to manage dependencies, so a plain virtualenv works too
(requires pip >= 25.1 for --group):
python3 -m venv .venv
source .venv/bin/activate
pip install -e . --group dev
prek installThe Makefile targets wrap uv run; without uv, invoke the tools directly from
the activated virtualenv (e.g. pytest, prek run --all-files).
| Target | Description |
|---|---|
make setup |
uv sync + install the prek Git hook |
make run |
Run the proxy with auto-reload (requires PROXY_CONFIG_PATH) |
make unit |
Run the unit tests |
make lint |
Run all hooks via prek (ruff, mypy, hadolint, ...) |
make fmt |
Apply ruff check --fix and ruff format |
make test |
lint + unit + integration |
make integration |
Run the integration tests |
make build-docker |
Build the Docker image |
make run-docker |
Build and run the Docker image |
make run-docker-debug |
Build and run with auto-reload on port 8000 |
cp config-example.yml config.yml
export PROXY_CONFIG_PATH=./config.yml
export LOG_FILE=false # avoid writing a log file next to the checkout
make runOn saving Python files, FastAPI reloads automatically. The Docker variant is
make run-docker-debug (needs PROXY_CONFIG_PATH set in the environment).
The config is loaded at import time, so PROXY_CONFIG_PATH must point at valid
YAML before the powerdns_api_proxy.proxy module is imported; tests set it
explicitly.
Please do not report security vulnerabilities through public issues. See SECURITY.md for the private reporting channels and what to expect.
See CONTRIBUTING.md for how to propose changes, the commit
message conventions and the pull request checklist. In short: discuss larger
changes in an issue first, and make sure make test passes.
Parts of this application's source code were created with assistance from an AI coding assistant and reviewed by human contributors.
Independent community project, not affiliated with or endorsed by Open-Xchange AG or the PowerDNS project. PowerDNS is a trademark of Open-Xchange AG.