neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기An authoritative DNS server you can run with one command, backed by PostgreSQL, with a web admin panel themed with Tabler
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
PowerDNS with PostgreSQL and a Tabler admin panel
An authoritative DNS server you can run with one command, backed by PostgreSQL,
with a web admin panel themed with Tabler that supports
local accounts, LDAP / Active Directory, OAuth 2.0 / OpenID Connect and SAML 2.0.
The panel never writes DNS data itself. Every zone and record change goes
through the PowerDNS HTTP API, so serial bumping, DNSSEC signing and record
validation stay where they belong. The two halves of the database are separated
by schema and by role: the panel's PostgreSQL user has no rights on the
PowerDNS tables.README 전문 보기 ↗An authoritative DNS server you can run with one command, backed by PostgreSQL, with a web admin panel themed with Tabler that supports local accounts, LDAP / Active Directory, OAuth 2.0 / OpenID Connect and SAML 2.0.
Documentation, with screenshots: https://powerdns.stoifl.app
clients :53 tcp/udp
│
▼
┌──────────────┐ HTTP ┌──────────────┐ forwards local zones
│ webui │ ─────► │ recursor │ ──────────────┐
│ Flask + │ │ PowerDNS │ │
│ Tabler │ │ Recursor │ ──► upstream │
└──────┬───────┘ └──────────────┘ forwarders │
│ ▼
│ HTTP API ┌──────────────┐
└────────────────────────────────────► │ pdns │
│ │ PowerDNS │
│ schema: pdnsadmin │ Authoritative│
│ (users, grants, audit log) └──────┬───────┘
│ │ schema: public
└───────────────────┬─────────────────────────┘ (domains, records)
▼
┌──────────────┐
│ db │ PostgreSQL 18
└──────────────┘
The panel never writes DNS data itself. Every zone and record change goes through the PowerDNS HTTP API, so serial bumping, DNSSEC signing and record validation stay where they belong. The two halves of the database are separated by schema and by role: the panel's PostgreSQL user has no rights on the PowerDNS tables.
The recursor is the front door on port 53. It answers for the zones this stack
hosts by forwarding them to the authoritative server, and sends everything else
to whatever the Forwarding page says. Forwarding has to live there because
the Authoritative Server cannot do it: recursor= was removed in PowerDNS
Authoritative 4.1.
git clone https://github.com/timothestoifl24/powerdns.git
cd powerdns
./scripts/generate-secrets.sh # writes secrets/ and .env, prints the admin password
docker compose up -d --build
Then open http://localhost:9191 and sign in as admin with the password the
script printed. Change it under My profile straight away.
Check that DNS is actually answering:
dig @127.0.0.1 example.com SOA
If docker compose up reports that port 53 is already in use, a local resolver
has it — see Troubleshooting.
| Path | Contents |
|---|---|
secrets/db_superuser_password | PostgreSQL bootstrap superuser, used only by initdb |
secrets/pdns_db_password | PostgreSQL password for the PowerDNS role |
secrets/webui_db_password | PostgreSQL password for the panel's role |
secrets/pdns_api_key | Shared secret for the PowerDNS HTTP API |
secrets/recursor_api_key | Shared secret for the Recursor HTTP API |
secrets/webui_secret_key | Flask session signing key |
secrets/webui_admin_password | Password for the first-run administrator |
.env | Non-secret settings, copied from .env.example |
secrets/ and .env are gitignored. Every secret can also be supplied as a
plain environment variable instead of a file — each one accepts both FOO and
FOO_FILE, and the file wins when both are set.
.
├── compose.yml the four services, wired together
├── .env.example every setting, documented
├── db/
│ ├── Dockerfile postgres:18-alpine
│ ├── schema/powerdns.sql PowerDNS 4.9 gpgsql schema, verbatim
│ └── initdb/
│ ├── 00-roles.sh both unprivileged roles
│ └── 01-load-powerdns-schema.sh loads the schema as the pdns role
├── pdns/
│ ├── Dockerfile Debian trixie + pdns-server + pdns-backend-pgsql
│ ├── entrypoint.sh renders pdns.conf, waits for the database
│ └── pdns.conf.template
├── recursor/
│ ├── Dockerfile Debian trixie + pdns-recursor
│ ├── entrypoint.sh renders recursor.yml
│ └── recursor.yml.template
├── webui/
│ ├── Dockerfile Tabler vendored at build time, then the Flask app
│ ├── app/ the panel
│ └── tests/ 517 tests, incl. an in-memory PowerDNS and recursor
├── scripts/generate-secrets.sh
└── docs/ the documentation site (VitePress)
| Role | Can do |
|---|---|
| admin | Everything, including user administration and settings |
| operator | Create, edit and delete every zone; no user administration |
| user | Read and edit only the zones explicitly granted to them |
Grants for the user role are managed per account under
Administration → Users → (a user) → Zone access.
Actions → Zone settings on a zone page configures the zone itself rather
than the records in it: its kind (and a slave's masters), the apex nameservers,
the SOA — primary nameserver, the administrator's email address, and the
refresh/retry/expire/negative-TTL timers — and which reverse zones the zone is
linked to. The address is stored the way DNS wants it, so first.last@…
becomes first\.last.… and is shown back to you as an address.
The serial is shown but not editable: PowerDNS bumps it on every change, and a serial edited by hand would either be overwritten or stop secondaries transferring. A slave zone takes its nameservers and SOA from its master, so those fields are not offered for one.
Anyone who can edit a zone's records can use the page — the record editor
already reaches the same NS and SOA sets. Changing the kind is reserved for
operators.
Both halves of a record are managed together rather than as two chores:
A zone can be created with its reverse zones. Tick the box on the
new-zone form and give the networks in CIDR notation — 192.0.2.0/24 becomes
2.0.192.in-addr.arpa, 2001:db8::/48 becomes
0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa — and they are created alongside the
forward zone with the same kind, nameservers and DNSSEC setting. A network
spanning several delegation boundaries becomes one zone per boundary.
A zone can be linked to its reverse zones. Tick them on the zone settings
page — or create one from a network there in the same save. A linked zone
opens the record editor with the reverse record already ticked and names the
zone the PTR will go into, and its linked zones are preferred when more
than one could take the record.
An A/AAAA record can own its PTR. Tick Also create a matching PTR
record in the record editor and the panel writes the PTR into whichever
reverse zone covers the address. It then follows the forward record: repoint
it, rename it, disable it or delete it and the PTR is updated or removed with
it. Untick the box to drop the PTR, and edit the PTR by hand to take it over
— the panel then leaves it alone for good.
Neither creates anything behind your back: if no reverse zone covers an address, the forward record is saved and the panel says so. Zone access applies to both ends, so a user granted only the forward zone is told the PTR was left alone rather than being refused the save. The link itself is panel metadata — both records live in PowerDNS and are written through its API like every other change.
The guide has the full table of what happens on each kind of change.
Backends can be combined. With local and LDAP both on, the sign-in form tries the local account first and falls back to the directory.
Providers can be configured two ways, and both can be used at once:
.env, which .env.example documents with a worked example for each
kind. This is what a configuration-as-code deployment wants.The environment always wins. A provider declared in .env cannot be
overridden from the UI: it is listed there as read-only, and a database entry
that collides with it is ignored and labelled Shadowed rather than silently
applied. So adopting the UI never puts your committed configuration at the
mercy of whoever holds an administrator account.
Administration → Sign-in providers manages LDAP, OAuth/OpenID Connect and SAML providers. Each one has a Test button that contacts the provider straight away -- binding to the directory, reading the OIDC discovery document, or fetching the IdP metadata -- so a new provider is verified without asking someone to attempt a sign-in and interpret the failure.
Configuration is validated at save time, not at sign-in: a provider missing its token URL, base DN or IdP certificate is refused with the reason.
A few properties are worth knowing:
SECRET_KEY using HKDF. A database dump, replica or backup does not hand
over your client secrets and bind passwords. The trade-off is that changing
SECRET_KEY makes stored secrets unreadable -- the panel says so plainly
and each secret has to be entered again.Providers are read per request, so a change takes effect immediately across every gunicorn worker without a restart.
On by default. Passwords are hashed with scrypt. Set LOCAL_AUTH_ENABLED=false
once single sign-on works, to force everyone through the identity provider.
LDAP_ENABLED=true
LDAP_URI=ldaps://dc1.example.com:636
LDAP_BIND_DN=CN=svc-dns,OU=Service Accounts,DC=example,DC=com
LDAP_BIND_PASSWORD=…
LDAP_BASE_DN=DC=example,DC=com
LDAP_USERNAME_ATTRIBUTE=sAMAccountName # OpenLDAP: uid
LDAP_ADMIN_GROUP=DNS-Admins
LDAP_DEFAULT_ROLE=none # refuse anyone in no mapped group
The panel binds as the service account to find the user, then binds as that
user to check the password — it never reads a password hash. Group names may be
given bare (DNS-Admins) or as a full DN; memberOf values are matched either
way, case-insensitively.
For failover, give LDAP_URI several servers separated by commas:
LDAP_URI=ldaps://dc1.example.com:636,ldaps://dc2.example.com:636
They are tried in order, so the preferred domain controller goes first; the next one is used when it cannot be reached. A server that fails to answer is skipped for a minute rather than retried on every sign-in, and picked up again on its own once it is back. The user is always authenticated against the same server that answered the search, so a replica that has not caught up cannot reject a password for an account it has not seen yet.
Name your providers, then configure each one. The name becomes the environment prefix and the URL segment.
OAUTH_PROVIDERS=keycloak,github
# OpenID Connect: one discovery URL is enough
OAUTH_KEYCLOAK_DISPLAY_NAME=Company SSO
OAUTH_KEYCLOAK_CLIENT_ID=powerdns-admin
OAUTH_KEYCLOAK_CLIENT_SECRET=…
OAUTH_KEYCLOAK_DISCOVERY_URL=https://sso.example.com/realms/main/.well-known/openid-configuration
OAUTH_KEYCLOAK_GROUPS_CLAIM=groups
OAUTH_KEYCLOAK_ADMIN_GROUP=dns-admins
# Plain OAuth 2.0, for providers without discovery
OAUTH_GITHUB_CLIENT_ID=…
OAUTH_GITHUB_CLIENT_SECRET=…
OAUTH_GITHUB_AUTHORIZE_URL=https://github.com/login/oauth/authorize
OAUTH_GITHUB_TOKEN_URL=https://github.com/login/oauth/access_token
OAUTH_GITHUB_USERINFO_URL=https://api.github.com/user
OAUTH_GITHUB_USERNAME_CLAIM=login
OAUTH_GITHUB_ICON=ti-brand-github
Register the redirect URI ${BASE_URL}/auth/oauth/<name>/callback with the
provider. BASE_URL must be set for this to work behind a reverse proxy —
otherwise the panel hands the provider the internal URL it sees, and the
provider's exact-match check fails.
SAML_ENABLED=true
BASE_URL=https://dns.example.com
SAML_IDP_METADATA_URL=https://sso.example.com/realms/main/protocol/saml/descriptor
SAML_ATTR_GROUPS=groups
SAML_ADMIN_GROUP=dns-admins
SAML_DEFAULT_ROLE=none
Give the identity provider the SP metadata served at
${BASE_URL}/auth/saml/metadata. The assertion consumer service is
${BASE_URL}/auth/saml/acs. Signature validation is on by default; if the IdP
requires signed requests, supply SAML_SP_X509_CERT and SAML_SP_PRIVATE_KEY.
The first time someone signs in through LDAP, OAuth or SAML, an account is created for them. Their name, e-mail and role are refreshed from the provider on every sign-in, so a group change takes effect immediately.
Three rules are worth knowing:
Open their entry under Administration → Users. The groups the directory
actually sent at their last sign-in are listed there, which is what the mapping
is compared against. A group matches by its full DN or by its first component,
ignoring case, so CN=DNS-Admins,OU=Groups,DC=example,DC=com matches a
configured DNS-Admins.
An empty list means the directory sent no groups at all. The usual causes:
The wrong group attribute. Lookups are case-insensitive and fall back to the attributes directories actually publish, so this is rarer than it was, but a directory that publishes membership only on the group object needs a group search base instead.
Nested Active Directory groups. memberOf reports only direct
membership, so a user in a group that is itself a member of the mapped group
is invisible. Set a group search base and this group filter:
(member:1.2.840.113556.1.4.1941:={dn})
Set *_DEFAULT_ROLE=none to refuse anyone who is not in a mapped group. If no
group mapping is configured at all, everyone who authenticates gets
*_DEFAULT_ROLE (which defaults to user).
Administration is under Forwarding, for operators and administrators.
The authoritative server answers only for zones it holds. Anything else — an
internal Active Directory domain, a reverse zone that belongs to another team,
or the public internet — has to be forwarded, and forwarding is a Recursor
feature. PowerDNS Authoritative dropped its recursor= setting in 4.1, so the
stack runs a recursor alongside it and the panel drives that recursor's API.
Global forwarders decide where anything with no more specific rule goes. Set them to your upstream resolvers to make this a forwarding resolver; leave them empty and it walks down from the root servers itself.
Forward zones send one namespace somewhere specific:
| Zone | Forward to | Use |
|---|---|---|
corp.internal | 10.0.0.5 | An internal domain another server owns |
10.in-addr.arpa | 10.0.0.5 | Reverse lookups for a private range |
ad.example.com | 10.0.0.5, 10.0.0.6 | Active Directory, two controllers |
Forwarders must be IP addresses, not host names: a resolver reads them from
its configuration before it is able to resolve anything. Add :port for
anything other than 53, and write IPv6 with a port as [2001:db8::1]:5353.
Leave Ask the forwarder to recurse off when the target is authoritative for the zone, such as a domain controller. Turn it on when the target is itself a resolver — most public resolvers refuse a query that does not set that bit, which is why global forwarders always set it.
The recursor is what clients query, so a zone this stack hosts would otherwise be looked up on the public internet. The panel keeps one forward rule per authoritative zone pointing at the authoritative server, marked local zone on the page. They are maintained automatically — creating or deleting a zone updates them, opening the Forwarding page reconciles them, and there is a button to re-check on demand.
A rule you created yourself is never touched by that reconciliation, even if it covers a zone of the same name. Only rules pointing at the authoritative server are treated as the panel's.
RECURSOR_DNSSEC defaults to process-no-validate, which is a deliberate
choice rather than a shortcut.
A zone you forward is answered by a server outside the public DNS chain, so it
cannot be validated against that chain: the resolver asks the root whether the
name is signed, is told it does not exist there, and returns SERVFAIL for an
answer that is perfectly correct. This is not an edge case — every deployment
forwards at least one such zone, because each zone this stack is authoritative
for is forwarded to the authoritative server. Validating here would mean
SERVFAIL for your own zones to any client that sets the DNSSEC OK bit,
which today is most of them, dig included.
DNSSEC records are still served to clients that ask for them, so a validating resolver downstream can check for itself.
To validate here instead, turn it on and name every internal zone as a negative trust anchor — every forward zone, and every zone this server is authoritative for:
RECURSOR_DNSSEC=process
RECURSOR_NEGATIVE_TRUSTANCHORS=corp.internal,10.in-addr.arpa,example.com
Miss one and it returns SERVFAIL, so this is worth doing only if you need validation of public names at this resolver rather than at the client.
RECURSOR_ALLOW_FROM defaults to private networks and loopback only. A
recursor reachable from the internet will be found within days and used to
amplify denial-of-service attacks against other people, so widen this only to
networks you control. The CI smoke test fails the build if the running
configuration allows 0.0.0.0/0.
Useful when a zone answers wrongly and you want to know which half is at fault. It is not published by default:
AUTH_DNS_BIND_ADDRESS=127.0.0.1
AUTH_DNS_PORT=5300
dig @127.0.0.1 -p 5300 www.example.com # the authoritative server alone
dig @127.0.0.1 www.example.com # through the recursor, as clients see it
Remove the recursor service and unset RECURSOR_API_URL. The Forwarding page
then explains that it is unavailable rather than failing, and the nav entry is
hidden. Publish the authoritative server on DNS_PORT again if you do this.
BASE_URL=https://dns.example.com
SESSION_COOKIE_SECURE=true # required over HTTPS
TRUSTED_PROXY_COUNT=1 # number of proxies in front of the panel
TRUSTED_PROXY_COUNT defaults to 0, meaning X-Forwarded-For is ignored —
any client can set that header, and trusting it blindly would let them forge
the address recorded in the audit log. Set it to the real number of proxies.
Leave SESSION_COOKIE_SECURE=false only for plain HTTP: a secure cookie is
never sent over HTTP, so login appears to succeed and then immediately fails.
Common settings have their own variables (see .env.example). Anything else
can be set with a PDNS_SETTING_ prefix, where underscores become dashes:
environment:
PDNS_SETTING_allow_axfr_ir: 10.0.0.0/8 # -> allow-axfr-ir=10.0.0.0/8
PDNS_SETTING_max_tcp_connections: "100" # -> max-tcp-connections=100
These are written to /etc/powerdns/pdns.d/50-env-overrides.conf, which
PowerDNS reads after the main config, so they win.
pdns/Dockerfile installs pdns-server and pdns-backend-pgsql from Debian
trixie, which carries PowerDNS Authoritative 4.9.x. To track upstream releases
more closely, add repo.powerdns.com in that Dockerfile instead. The schema in
db/initdb/01-powerdns-schema.sql matches 4.9 — check the upstream schema
before moving to a different major version.
Scripts in db/initdb/ run only when the data directory is empty, i.e. on
the first start of a fresh volume. Changing them does nothing to an existing
database; apply PowerDNS schema migrations by hand from the upstream release
notes. The panel's own tables are created by the application at start-up, so
those keep themselves up to date.
The roles above are created by db/initdb/00-roles.sh, and everything in
/docker-entrypoint-initdb.d runs only on the first start of an empty data
directory. An existing deployment therefore keeps the old arrangement, where
the PowerDNS role is the bootstrap superuser, until it is migrated by hand.
It keeps working as it is. To migrate without recreating the volume:
# 1. Create the new secret (existing secret files are left untouched).
./scripts/generate-secrets.sh
# 2. Create the bootstrap superuser, using the role that is currently one.
docker compose up -d db
docker compose exec -T db psql -U pdns -d pdns \
-v pw="$(cat secrets/db_superuser_password)" <<'SQL'
SELECT format('CREATE ROLE postgres LOGIN SUPERUSER PASSWORD %L', :'pw')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') \gexec
SQL
# 3. Demote PowerDNS's role. It keeps ownership of its tables, so it retains
# full access to them -- it just loses the rest of the cluster.
docker compose exec -T db psql -U postgres -d pdns -c \
"ALTER ROLE pdns NOSUPERUSER NOCREATEDB NOCREATEROLE;
ALTER SCHEMA public OWNER TO pdns;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;"
# 4. Restart on the new configuration.
docker compose down && docker compose up -d
Verify:
docker compose exec -T db psql -U postgres -d pdns -c \
"SELECT rolname, rolsuper, rolcreaterole FROM pg_roles
WHERE rolname IN ('pdns','pdnsadmin')"
Both rows should read f | f. If you would rather start clean and have no
zones worth keeping, docker compose down -v and a fresh up gets there in
one step -- it destroys all DNS data.
cd webui
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt pytest pytest-cov ruff
pytest # 192 tests, no database or PowerDNS needed
ruff check app tests
ruff format app tests
The suite runs against in-memory SQLite and a fake PowerDNS that speaks the
real HTTP API, so the client's URL building and error handling are exercised
too. CI additionally brings the whole stack up with docker compose, creates a
zone through the API and resolves a record with dig — the end-to-end path
this repository exists to provide.
To run the panel directly against a compose stack:
export DATABASE_URL="postgresql+psycopg://pdnsadmin:$(cat ../secrets/webui_db_password)@localhost:5432/pdns"
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
export PDNS_API_URL=http://localhost:8081 PDNS_API_KEY="$(cat ../secrets/pdns_api_key)"
export SESSION_COOKIE_SECURE=false
python -m app.cli init && flask --app wsgi:application run --debug
(The compose file does not publish 5432 or 8081 to the host; add the ports temporarily if you want to develop this way.)
docs/ is a VitePress site, published to
https://powerdns.stoifl.app by .github/workflows/docs.yml on every push to
main.
cd docs
npm install
npm run dev # http://localhost:5173
npm run build # output in docs/.vitepress/dist
The screenshots are captured from a running stack by
docs/scripts/capture-screenshots.mjs; see the Regenerating these section of
the screenshots page for the command.
Changes worth telling somebody about go under ## [Unreleased] in
CHANGELOG.md as they land. To release, rename that heading to
the version, add the date and the compare link at the foot of the file, and push
a tag:
git tag -a v1.2.3 -m 'v1.2.3'
git push origin v1.2.3
.github/workflows/release.yml takes it from there. It checks that the tagged
commit is on main and that CI passed on it, publishes all four images tagged
1.2.3 and 1.2 (and latest, unless the tag is a prerelease like
v1.2.3-rc1), and creates the GitHub release.
Images are published for linux/amd64 and linux/arm64 (aarch64 —
the same architecture under its other name). Both the branch builds and the
releases go through .github/workflows/images.yml, so the two can never drift
apart on which platforms they cover.
Each platform is built on a runner of its own architecture rather than under
QEMU: emulated dpkg and pip dominate the run time, and an emulator that
diverges from real hardware produces failures that reproduce nowhere else.
Every leg pushes an untagged image and reports only its digest; a single merge
job then assembles those digests into the manifest list that carries the tags,
so a tag never resolves to a half-published set of architectures. It ends by
reading the manifest back out of the registry and failing if either platform
is missing.
The end-to-end compose smoke test runs on both architectures too, so arm64
is a build that has been started, queried over real DNS and signed into —
not merely one that compiled.
The release notes are assembled rather than typed: the CHANGELOG.md section
for that version leads, and beneath it GitHub lists every pull request merged
since the previous tag, sorted into the categories in .github/release.yml.
Labelling a pull request is therefore what decides where it appears —
breaking, security, feature/enhancement, dependencies, bug/fix,
docs, ci. An unlabelled one still shows up, under Other changes.
Permission denied reading /run/secrets/... and the database container
exits. The files under secrets/ must be world-readable (0644). Compose
bind-mounts them into the containers with their host ownership intact, and the
processes that read them are unprivileged — postgres (uid 70) runs the initdb
scripts and the panel runs as uid 10001. The 0700 directory is what keeps
other users on the host out. ./scripts/generate-secrets.sh sets this
correctly and repairs an existing secrets/ directory; to fix it by hand:
chmod 0700 secrets && chmod 0644 secrets/*
The stack started once with an error and now the panel cannot connect.
PostgreSQL runs the scripts in db/initdb/ only while the data directory is
empty. If one of them fails, the directory is left half-initialised and every
later start logs "Database directory appears to contain a database; Skipping
initialization" — so the panel's role and schema never get created. Start over
with a clean volume:
docker compose down -v && docker compose up -d --build
down -v deletes the volume, and with it every zone. On a system with real
data, dump the database first.
failed to bind host port for 0.0.0.0:53: address already in use.
Something already listens on port 53 — on most Ubuntu and Fedora systems that
is systemd-resolved, which binds 127.0.0.53:53. Either publish DNS on a
different host port:
echo 'DNS_PORT=5353' >> .env
docker compose up -d
dig @127.0.0.1 -p 5353 example.com SOA
or free port 53 by disabling the stub listener (DNSStubListener=no in
/etc/systemd/resolved.conf, then systemctl restart systemd-resolved). The
container always serves on 53 internally; DNS_PORT only affects the host
side. DNS_PORT is the recursor, which is the front door; the authoritative
server has its own AUTH_DNS_PORT and is unpublished by default.
On Podman, set DNS_BIND_ADDRESS to a real address rather than doing either
of those. Podman resolves container names with aardvark-dns, which binds port
53 on the bridge address (172.29.0.1 here). 0.0.0.0:53 takes that address
too, so the recursor and aardvark-dns cannot both run: with systemd-resolved
up you get the bind error above, and with it stopped you get a stack that
starts but cannot resolve db. Naming an address avoids both, and lets the
resolver stay running. See
the Podman section in setup.
A zone resolves on the authoritative server but not through the recursor. The recursor needs a forward rule pointing at the authoritative server for each zone this stack hosts. Compare the two:
dig @127.0.0.1 -p 5300 www.example.com # needs AUTH_DNS_PORT published
dig @127.0.0.1 www.example.com
If the first answers and the second does not, open Forwarding — that reconciles the rules — or press Re-check local zone forwarding. The zone should be listed with a local zone badge.
Forward zones disappeared after docker compose down. They live in the
recursor-api volume. down -v removes it along with the database. Without
-v they survive.
Zone already exists when adding a forward zone for a private reverse zone.
The recursor serves the RFC 1918 reverse zones itself, so it already knows
names like 168.192.in-addr.arpa. The panel replaces them rather than failing,
so saving again from the Forwarding page works; the error only appears if you
POST to the recursor's API by hand.
Login appears to succeed but bounces straight back to the sign-in page.
SESSION_COOKIE_SECURE=true over plain HTTP: the browser never sends the
session cookie back. Set it to false for HTTP, or put the panel behind TLS.
The OAuth provider rejects the redirect URI. Set BASE_URL to the public
URL of the panel. Without it the panel derives the callback from the request it
sees, which behind a proxy is the internal hostname, and providers match the
registered URI exactly. Administration → Settings prints the exact URI to
register.
The PowerDNS API port is not published to the host. Only the webui
container reaches it, over the compose network, and
webserver-allow-from restricts it further.
The panel runs as an unprivileged user. PowerDNS starts as root only to bind
port 53 and read its secrets, then drops to the pdns user.
Every state-changing request needs a CSRF token. The SAML assertion consumer is the one exemption — it is a cross-site POST by design, authenticated by the assertion's own XML signature.
Failed logins are rate limited per username and address. The counter is
per-process, so with several gunicorn workers the effective allowance is
roughly LOGIN_MAX_ATTEMPTS × workers.
Tabler is vendored into the image at build time, so the Content-Security -Policy needs no external origins and the panel works on an isolated network.
Every change made through the panel is written to an append-only audit log, visible under Administration → Audit log. Entries survive deletion of the user who made them.
Provider secrets are encrypted at rest with a key derived from
SECRET_KEY, so the auth_providers table does not expose OAuth client
secrets, LDAP bind passwords or SAML private keys to anyone holding a
database dump. Rotating SECRET_KEY invalidates them by design.
No application role is a superuser. POSTGRES_USER names a bootstrap
role that exists only to run initdb and the scripts in
/docker-entrypoint-initdb.d; nothing connects as it afterwards. PowerDNS
and the panel each get their own NOSUPERUSER NOCREATEDB NOCREATEROLE role:
| Role | Owns | Can read |
|---|---|---|
postgres | nothing at runtime | — (bootstrap only) |
pdns | schema public and the PowerDNS tables | its own tables |
pdnsadmin | schema pdnsadmin | its own tables |
NOCREATEROLE matters as much as NOSUPERUSER: a role that can create
roles can grant itself anything. Neither role can read the other's tables,
and CI proves it — the smoke test asserts both cross-schema reads fail with
permission denied, rather than trusting that the grants are right.
GPL-3.0. See LICENSE.