PowerDNS Manager - A simple web UI for managing records in PowerDNS
  • Go 82.6%
  • templ 10.4%
  • JavaScript 3.4%
  • CSS 1.8%
  • Just 1.1%
  • Other 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Elisamuel Resto 6f75e53cde
All checks were successful
CI / Lint (push) Successful in 4m15s
CI / Test (mariadb) (push) Successful in 5m20s
CI / Test (mysql) (push) Successful in 5m38s
CI / Build (push) Successful in 3m12s
CI / Test (postgres) (push) Successful in 6m12s
CI / Test (sqlite) (push) Successful in 5m19s
Nightly / nightly (push) Successful in 5m39s
Clear staged-SOA banners when the changeset is applied
The Zone settings tab renders the staged SOA as two banners — "a change
is already staged" and "the SOA change is staged" — but an apply only
fired pm:staged, which refreshes the tray and masthead, not the settings
panel. So the banners stood over a change that was already live.

Fire pm:refresh alongside pm:staged on apply, and move the reload trigger
onto the stable #zone-settings div: the SOA and transfer forms swap their
output into it, which was replacing an inner wrapper together with its
listener, leaving nothing to reload the panel. Kept on the outer div, the
listener survives those swaps and reloads on pm:refresh, re-rendering with
SOAStaged false and no banner.

pm:refresh rather than pm:staged for the panel so staging does not clear
its own confirmation the instant it appears.
2026-08-09 02:03:48 -05:00
.forgejo CI: GitHub vs Forgejo 2026-08-08 04:22:55 -05:00
.githooks Initial commit - MVP 2026-08-01 02:06:00 -05:00
cmd/powerman Initial commit - MVP 2026-08-01 02:06:00 -05:00
internal Clear staged-SOA banners when the changeset is applied 2026-08-09 02:03:48 -05:00
scripts Initial commit - MVP 2026-08-01 02:06:00 -05:00
web Global .gitignore was causing issues -- added local whitelists 2026-08-08 03:39:50 -05:00
.editorconfig Initial commit - MVP 2026-08-01 02:06:00 -05:00
.gitattributes Initial commit - MVP 2026-08-01 02:06:00 -05:00
.gitignore Fix more global gitignore issues 2026-08-08 04:03:31 -05:00
.golangci.yml Initial commit - MVP 2026-08-01 02:06:00 -05:00
.goreleaser.yaml Limit releases to linux amd64 and arm64; Added LICENSE file 2026-08-08 13:17:33 -05:00
compose.dev.yml Initial commit - MVP 2026-08-01 02:06:00 -05:00
Dockerfile Workflow/Release changes, build changes, and change oidc default to false 2026-08-08 03:29:13 -05:00
go.mod Initial commit - MVP 2026-08-01 02:06:00 -05:00
go.sum Initial commit - MVP 2026-08-01 02:06:00 -05:00
justfile Initial commit - MVP 2026-08-01 02:06:00 -05:00
LICENSE Limit releases to linux amd64 and arm64; Added LICENSE file 2026-08-08 13:17:33 -05:00
powerman.example.yaml Workflow/Release changes, build changes, and change oidc default to false 2026-08-08 03:29:13 -05:00
README.md Limit releases to linux amd64 and arm64; Added LICENSE file 2026-08-08 13:17:33 -05:00

powerman

A management interface for the PowerDNS Authoritative Server. Zones, records, DNSSEC, TSIG keys, views and autoprimaries — through a web UI and a JSON API, with OIDC sign-in and per-zone permissions.

powerman never touches PowerDNS's database. Everything goes through the HTTP API, so it works against a server you do not otherwise control.

What you get

  • Changes are staged, reviewed, then applied. Editing a record does not call PowerDNS — it stages the change. A tray shows what is pending, a review panel shows each change field by field, and applying sends the lot as one request no matter how many records you touched.
  • Proper forms for every record type. An MX record asks for a priority and a mail server; SRV asks for priority, weight, port and target. Not a text box full of rdata.
  • BIND zone file import and export, with the import flowing through the same review panel as a hand-made edit.
  • One binary. Stylesheet, fonts, htmx, Alpine and date-fns are all embedded; no asset directory, no CDN, works air-gapped.
  • Any of four databases for powerman's own metadata: SQLite, MySQL, MariaDB or PostgreSQL. Migrations ship in the binary and run on startup.
  • Runs as one container or as a Kubernetes deployment. Redis is optional; with it, replicas share sessions, queued work and cached reads.
  • OIDC sign-in with PKCE, mapping your identity provider's groups onto roles, plus per-zone grants for finer control.
  • An audit trail of every change, retained even when the account that made it is deleted.
  • Structured logging with syslog severities, verbose at debug, with secrets redacted by the logging pipeline rather than by each call site.

Getting started

Requirements

  • Go 1.26 and just to build; nothing but the binary to run.
  • A PowerDNS Authoritative Server with api=yes and an api-key set.
  • An OIDC provider (Keycloak, Dex, Authentik, Entra, Auth0 — anything with discovery). Optional for a local trial: see Running without authentication.

Build and run

just setup                     # tools, Tailwind, vendored JS, git hooks
cp powerman.example.yaml powerman.yaml
$EDITOR powerman.yaml          # at minimum: oidc.*, powerdns.*, auth.encryption_key
just build
./powerman

Generate the encryption key with:

openssl rand -base64 32

On Windows, run scripts/bootstrap.ps1 once first — the justfile and git hooks are POSIX shell scripts, and Git for Windows does not put sh.exe on PATH by default.

The first account to sign in is made an administrator, so a fresh installation is reachable before oidc.admin_groups is configured. Set that afterwards so administration follows your identity provider.

Running without authentication

Setting up an identity provider to try powerman out is a lot of work for a five-minute look. oidc.enabled: false skips it:

POWERMAN_OIDC_ENABLED=false ./powerman

Nobody signs in and no provider is contacted. Every request runs as a built-in anonymous account holding the admin role — that name is what appears in the account menu, in the audit trail and in the logs, so a change made in this mode is still attributable to how it was made, if not to whom. The rest of the oidc block is ignored rather than rejected, so turning authentication back on is one flag rather than a rebuilt configuration.

Anyone who can reach the address has full administrative access. powerman says so at startup and in the account menu; it does not stop you. Do not run this way anywhere that is not already as trusted as the DNS it manages, and prefer real sign-in even behind an authenticating proxy — powerman's per-zone permissions and audit trail have nothing to work with when everyone is the same account.

Configuration

Settings come from three places, each overriding the last:

  1. powerman.yaml — searched in ./, ./config/ and /etc/powerman/, or named with --config / POWERMAN_CONFIG_FILE
  2. .env in the working directory
  3. the process environment

Every setting has one environment spelling: uppercase the dotted path, replace dots with underscores, prefix with POWERMAN_.

Setting Environment variable
oidc.client_id POWERMAN_OIDC_CLIENT_ID
database.dsn POWERMAN_DATABASE_DSN
http_client.tls.ca_file POWERMAN_HTTP_CLIENT_TLS_CA_FILE

A POWERMAN_* variable matching no setting is reported as a warning at startup rather than silently ignored, so a typo shows up immediately.

powerman.example.yaml documents every setting.

Database

Set database.dsn; the scheme selects the engine.

database:
  dsn: "sqlite://./powerman.db"
  # dsn: "postgres://user:pass@host:5432/powerman?sslmode=require"
  # dsn: "mysql://user:pass@tcp(host:3306)/powerman"
  # dsn: "mariadb://user:pass@tcp(host:3306)/powerman"

Migrations run on startup. With database.auto_migrate: false — sensible when several instances start together — apply them yourself:

powerman migrate up
powerman migrate status
powerman migrate version

Secrets from files

Docker secrets and Kubernetes secret volumes present credentials as files. Any setting can be read from one by adding _FILE to its variable:

POWERMAN_POWERDNS_API_KEY_FILE=/run/secrets/pdns_api_key
POWERMAN_AUTH_ENCRYPTION_KEY_FILE=/run/secrets/encryption_key

The file's contents become the setting, with trailing whitespace trimmed — so echo secret > file does not leave a newline in your API key. A _FILE variable pointing at an unreadable file stops startup: beginning with a silently empty credential fails later and further from the cause. Setting both the variable and its _FILE form uses the variable.

Redis, and running more than one replica

Redis is optional. Without it, powerman keeps sessions in its own database and runs the work queue in-process — the right shape for a single container.

redis:
  mode: "sentinel"           # standalone | sentinel | cluster, or empty to disable
  sentinel:
    master_name: "mymaster"
    addresses: ["s1:26379", "s2:26379", "s3:26379"]
    password: ""             # Sentinel's own password, NOT redis.password
cluster:
  mode: "replicated"         # requires Redis, and refuses to start without it

Sentinel is the supported high-availability topology. Redis Cluster works for sessions and caching, but it cannot back the queue: asynq's documentation warns that some of its Lua scripts are not cluster-safe, so powerman refuses to start rather than let that surface as an intermittent failure to apply a change.

Durable or ephemeral? Durable (appendonly yes, appendfsync everysec) is recommended, and ephemeral is supported. powerman is built so the difference is small: staged changes live in the relational database, not Redis, because a staged change is somebody's unfinished work. Wiping Redis costs you

in Redis cost of losing it
sessions everyone signs in again
queued applies recovered — see below
cached reads refetched
sparkline history charts restart empty

A queued apply that vanishes is recovered: the changeset row survives in the database, and a reconciler re-enqueues anything left mid-apply. Do not point powerman at a Redis database shared with another application — the queue scans keys by pattern.

For separate worker pods, run powerman worker in its own Deployment and set queue.embedded: false on the web replicas.

Reaching PowerDNS over HTTPS

A plain http:// API on a trusted network needs no TLS configuration at all. For an HTTPS endpoint using a private CA, trust the CA rather than disabling verification:

http_client:
  tls:
    ca_file: /etc/ssl/internal-ca.pem

The CA is added to the system roots rather than replacing them, so the same client still reaches your identity provider. insecure_skip_verify exists but logs a warning naming the host on every client it builds.

Logging

log.level accepts syslog severities — debug, info, notice, warning, error, crit, alert, emerg — plus the numeric equivalents and the usual aliases. log.format is text or json.

HTTP requests, SQL statements and outbound PowerDNS calls are logged at debug, which is deliberately verbose. Turning it on does not expose secrets: API keys, TSIG material, tokens and cookies are redacted by the logging pipeline, and session identifiers are replaced by a stable short digest.

Every line carries the request_id, which also appears in API error responses — so a user's screenshot is enough to find the matching log lines.

Editing records

Saving a record form does not call PowerDNS. It stages the change: the table updates immediately and marks the row pending, so what you see is always the state you are building toward. A tray at the foot of the page counts what is staged, and Review opens a panel showing each change with the same field labels the form used:

~ Modified   MX   mail
             Priority     10 → 20
             Mail server  mx1.example.org.

+ Added      A    www
             IPv4 address 192.0.2.10

Applying sends everything as a single PATCH — fifty edited records are one request, one serial bump, one notify to your secondaries.

If the zone changed while your edits were staged, the apply is refused rather than overwriting the other change. Your work is kept and rebased onto the current zone so you can look at it again.

Importing a zone file

Paste or upload a BIND zone file and powerman parses it, diffs it against the live zone, and stages the difference — so an import lands in the same review panel as a hand-made edit. Parse errors are reported with the line number.

By default an import adds and updates but never deletes: "import this file" reads as "make these records exist", not "delete everything else". Ask for a replacing import if you mean the second thing, and every deletion appears in the review before it happens. The zone's SOA and its DNSSEC records are left to PowerDNS, since an imported SOA usually carries a stale serial.

$INCLUDE is rejected — in an uploaded file it is a server-side file read.

The API

/api/v1 mirrors PowerDNS's resource model:

GET    /api/v1/servers/{server}/zones
POST   /api/v1/servers/{server}/zones
GET    /api/v1/servers/{server}/zones/{zone}
PATCH  /api/v1/servers/{server}/zones/{zone}/rrsets
DELETE /api/v1/servers/{server}/zones/{zone}
PUT    /api/v1/servers/{server}/zones/{zone}/{notify,axfr-retrieve,rectify}
       …/cryptokeys, …/metadata/{kind}
       …/tsigkeys, …/autoprimaries, …/views, …/networks
       …/search, …/statistics, …/config, …/cache/flush

{server} is a registered server's name or id, or default.

Requests authenticate with a bearer token from POST /api/v1/auth/token, which itself uses the session cookie. Tokens are short-lived and are revoked the moment the session ends. Errors are RFC 9457 problem documents.

Handlers content-negotiate: Accept: application/json returns JSON, an htmx request returns an HTML fragment. The browser and a script use the same routes.

Deployment

docker run -p 8080:8080 \
  -e POWERMAN_OIDC_ISSUER=https://id.example.com \
  -e POWERMAN_OIDC_CLIENT_ID=powerman \
  -e POWERMAN_POWERDNS_URL=http://pdns:8081 \
  -e POWERMAN_POWERDNS_API_KEY=\
  -e POWERMAN_AUTH_ENCRYPTION_KEY=\
  -e POWERMAN_DATABASE_DSN=postgres://… \
  ghcr.io/ofkm/powerman

Probe /healthz for liveness and /readyz for readiness. /healthz deliberately touches nothing else, so a database blip does not restart a healthy process.

Behind a reverse proxy, set http.base_url to the URL browsers actually use and http.trusted_proxy: true so forwarded addresses are honoured. Leave that off otherwise — any client can send those headers.

auth.encryption_key protects the PowerDNS API keys and JWT signing keys at rest. Losing it means re-entering every API key; changing it makes powerman refuse to start rather than silently fail later.

Development

just dev      # templ, tailwind and the server, all watching
just lint     # what CI enforces
just test     # lint, then the tests

A pre-commit hook runs just test and rejects the commit on failure; just setup installs it.

License

MIT.