- Go 82.6%
- templ 10.4%
- JavaScript 3.4%
- CSS 1.8%
- Just 1.1%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
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. |
||
| .forgejo | ||
| .githooks | ||
| cmd/powerman | ||
| internal | ||
| scripts | ||
| web | ||
| .editorconfig | ||
| .gitattributes | ||
| .gitignore | ||
| .golangci.yml | ||
| .goreleaser.yaml | ||
| compose.dev.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| LICENSE | ||
| powerman.example.yaml | ||
| README.md | ||
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=yesand anapi-keyset. - 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:
powerman.yaml— searched in./,./config/and/etc/powerman/, or named with--config/POWERMAN_CONFIG_FILE.envin the working directory- 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.