The preview iframe stayed blank after a redeploy because /static/js/app.js is served with a 24h Cache-Control and no versioning, so browsers kept using the pre-existing cached copy that predated the preview code. Static CSS/JS references now carry a ?v=<build commit> query string so a new build is never masked by a stale cache. Also makes the CSP's frame-src explicit for the preview iframe rather than relying on the default-src fallback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
Scroll to the bottom for an IMPORTANT message.
VibeDNS
The AI generated DNS server designed to solve an idiots neiche problems
An authoritative DNS server, recursive resolver and network-wide filtering
appliance in a single Go binary, with a Bootstrap 5 management interface.
It is meant to be the DNS infrastructure for a home lab, a small office or a
lab network: the thing you point your DHCP server at. It answers
authoritatively for your internal zones, resolves everything else through
upstream resolvers, caches the results, and applies per-subnet blocklists so
the guest Wi-Fi and the server VLAN can have different rules.
Everything ships in one binary — HTML templates, CSS, JavaScript, the icon
font, and the schema migrations are all embedded. There is no Node.js, no build
step, and no CDN. The management interface works on a network with no Internet
access, which matters because a DNS server's UI should not need working DNS to
render.
Features
Authoritative DNS
- Forward, reverse IPv4 (
in-addr.arpa) and reverse IPv6 (ip6.arpa) zones - Reverse zones created from a subnet — enter
192.168.1.0/24, not1.168.192.in-addr.arpa - Automatic SOA management with automatic serial increments, and a manual override for migrations from another server
- Wildcards with correct closest-encloser semantics, CNAME chasing, delegation referrals with glue, and empty non-terminals answering NODATA rather than NXDOMAIN
- BIND-compatible zone file import and export
- Zone cloning, enable/disable, bulk record editing, cross-zone record search
Recursive resolver
- Forwarding to configurable upstreams with per-server health tracking
- Selection strategies: fastest, sequential, round robin, random
- Configurable timeout, retries and concurrency ceiling
- EDNS(0), DNSSEC record pass-through, automatic TCP fallback on truncation
- Closed by default: recursion is restricted to an explicit network list
Cache
- Sharded, LRU-bounded, entirely in memory
- TTL decay so clients never see a TTL that stands still
- Negative caching per RFC 2308, serve-stale per RFC 8767, background prefetch
- Browse, search and evict individual entries from the UI
Filtering
- Per-subnet policies: guest Wi-Fi and a trusted LAN can use different blocklists
- Reusable blacklists and allowlists shared across policies
- Allowlist matches always override blacklist matches
- Exact, subdomain and wildcard matching — blocking
example.comcoversa.b.example.comwithout storing a single extra row - Block actions: NXDOMAIN (default), REFUSED, or sinkhole to a configurable address
- Bulk import from plain lists, hosts files and Adblock-style rules, in one transaction; a 300,000 line list imports in seconds
Operations
- Query log with full filtering, retention limits and automatic cleanup
- Audit log of every administrative change, from the UI, the API and the CLI
/healthz,/readyzand Prometheus/metrics- Per-client DNS rate limiting with exemptions for trusted infrastructure
- Automatic SQLite backups using
VACUUM INTO, with a safe restore workflow - Versioned configuration export and import
- REST API under
/api/v1with revocable API tokens
Build
Go 1.26 or newer. No other toolchain is required.
git clone https://github.com/owen/vibedns.git
cd vibedns
go build -o vibedns ./cmd/vibedns
For a release build with version information:
go build -trimpath \
-ldflags="-s -w -X github.com/owen/vibedns/internal/version.Version=1.0.0" \
-o vibedns ./cmd/vibedns
CGO_ENABLED=0 is the default and works: the SQLite driver is pure Go, so the
binary is static and cross-compiles without a C toolchain.
GOOS=linux GOARCH=arm64 go build -o vibedns-arm64 ./cmd/vibedns
Run the tests:
go test ./...
Installation
Native
sudo useradd --system --home-dir /var/lib/vibedns --shell /usr/sbin/nologin vibedns
sudo install -m 0755 vibedns /usr/local/bin/vibedns
sudo install -d -o vibedns -g vibedns -m 0750 /var/lib/vibedns
# Bind port 53 without running as root
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/vibedns
sudo install -m 0644 deploy/vibedns.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now vibedns
# The generated administrator password is printed once, to the journal
sudo journalctl -u vibedns -n 40 --no-pager
Most distributions run systemd-resolved on port 53. Disable it first:
sudo systemctl disable --now systemd-resolved
sudo rm -f /etc/resolv.conf
echo 'nameserver 127.0.0.1' | sudo tee /etc/resolv.conf
Docker
docker compose up -d
docker compose logs vibedns # the generated password is printed once
Initial setup
On first start the server creates the database, applies migrations, generates a strong administrator password and prints it once:
VibeDNS 0.1.0 starting
Database: /var/lib/vibedns/dns.db
DNS UDP: 0.0.0.0:53
DNS TCP: 0.0.0.0:53
Management: http://127.0.0.1:8080
Zones: 0 (0 records)
Filtering: 6 networks, 0 blocked domains
Recursion: enabled for 8 network(s), 3 upstream(s)
Initial administrator:
Username: admin
Password: <generated-password>
This password will not be displayed again.
Change it at http://127.0.0.1:8080/account
The interface shows a banner until you replace that password. To supply your
own instead, set VIBEDNS_ADMIN_PASSWORD before the first start.
Lost the password? The database file is the credential:
sudo -u vibedns vibedns admin reset-password --db /var/lib/vibedns/dns.db
Configuration
Two layers, deliberately separated.
Startup settings — needed before the database is open. CLI flags and environment variables only.
| Flag | Environment variable | Default | Purpose |
|---|---|---|---|
--db |
VIBEDNS_DB_PATH |
./data/dns.db |
SQLite database file |
--http |
VIBEDNS_HTTP_ADDR |
127.0.0.1:8080 |
Management interface |
--dns |
VIBEDNS_DNS_ADDR |
0.0.0.0:53 |
DNS listeners (UDP and TCP) |
--log-level |
VIBEDNS_LOG_LEVEL |
info |
debug, info, warn, error |
--log-format |
VIBEDNS_LOG_FORMAT |
text |
text or json |
--admin-username |
VIBEDNS_ADMIN_USERNAME |
admin |
Initial administrator |
| — | VIBEDNS_ADMIN_PASSWORD |
generated | Initial password |
An address given on the command line is written back to the database, so the running process and the stored configuration always agree.
Everything else lives in SQLite and is edited from Settings in the UI or
through /api/v1/settings. Changes to zones, records, policies, lists,
upstreams and cache behaviour take effect immediately — no restart. Only the
listen addresses need one, and the UI says so where that applies.
Inspect the effective configuration without starting the server:
vibedns config check # validate, with an explicit open-resolver check
vibedns config show # print every effective setting
DNS configuration examples
A forward zone
# UI: Zones → Forward Zones → Add Zone
# Then add records from the zone page.
A typical internal zone:
| Name | Type | Value | TTL |
|---|---|---|---|
@ |
A | 192.0.2.10 |
3600 |
www |
CNAME | example.com. |
3600 |
mail |
A | 192.0.2.20 |
3600 |
@ |
MX | 10 mail.example.com. |
3600 |
@ |
TXT | v=spf1 mx -all |
3600 |
* |
A | 192.0.2.99 |
300 |
Names are relative to the zone apex; @ is the apex itself and * is a
wildcard. Quoting and 255-character chunking for TXT records is handled for you.
Reverse DNS
The point of the reverse zone form is that you never calculate a zone name. Enter the subnet and the apex is derived:
| You enter | Zone created |
|---|---|
192.168.1.0/24 |
1.168.192.in-addr.arpa. |
10.0.0.0/8 |
10.in-addr.arpa. |
172.16.0.0/16 |
16.172.in-addr.arpa. |
2001:db8::/32 |
8.b.d.0.1.0.0.2.ip6.arpa. |
Reverse delegation only happens on octet boundaries for IPv4 and nibble
boundaries for IPv6. A /25 is rounded up to the enclosing /24, and the UI
tells you it did.
Add PTR records using the last octet as the name:
| Name | Type | Value |
|---|---|---|
10 |
PTR | host.example.com. |
20 |
PTR | mail.example.com. |
That answers 10.1.168.192.in-addr.arpa → host.example.com.
Importing an existing zone
curl -H "Authorization: Bearer $VIBEDNS_TOKEN" \
--data-binary @example.com.zone \
"http://127.0.0.1:8080/api/v1/zones/1/import?mode=replace"
The whole file is validated before anything is written, so a syntax error on line 400 never leaves the zone half-imported.
Blacklist examples
Create a list under Policies → Blacklists, then import into it. Three formats are understood, and may be mixed in one file:
# plain list
example.com
tracker.example.net
# hosts file
0.0.0.0 ads.example.com
127.0.0.1 telemetry.example.net
:: bad.example
# Adblock-style host rules
||analytics.example.org^
Comments, blank lines, IP-only lines, localhost entries and duplicates are
skipped, and the import summary reports exactly what happened:
Imported hosts.txt: 184,291 lines processed, 172,004 domains added, 12,201 duplicates skipped, 6 invalid entries, 80 comments or blank lines ignored.
With match subdomains on (the default), blocking example.com also covers
www.example.com and a.b.example.com — matching walks the name's suffixes
rather than storing every possible subdomain.
Per-subnet policies
The arrangement from the brief:
- Blacklists: Adult Content, Gambling, Malware
- Policy "Guest Filtering": all three blacklists, action NXDOMAIN
- Policy "Malware Only": Malware alone
- Network "Guest Wi-Fi"
100.64.30.0/24→ Guest Filtering - Network "SecureLAN"
100.64.10.0/24→ Malware Only
A client is matched against the most specific network containing its address. To let one domain through everywhere, add it to an allowlist attached to the same policy — an allowlist match always beats a blacklist match, so you never have to edit an imported list.
Check what any name would do, as any client, under Tools.
API examples
Create a token under Settings → API. It is shown once.
export VIBEDNS_TOKEN=vibedns_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export VIBEDNS=http://127.0.0.1:8080
AUTH="Authorization: Bearer $VIBEDNS_TOKEN"
# List zones
curl -H "$AUTH" $VIBEDNS/api/v1/zones
# Create a zone
curl -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name":"internal.example","admin_email":"hostmaster@internal.example"}' \
$VIBEDNS/api/v1/zones
# Create a reverse zone from a subnet
curl -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"cidr":"192.168.1.0/24","kind":"reverse4"}' \
$VIBEDNS/api/v1/zones
# Add a record
curl -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name":"www","type":"A","data":"192.0.2.10","ttl":3600}' \
$VIBEDNS/api/v1/zones/1/records
# Search records across every zone
curl -H "$AUTH" "$VIBEDNS/api/v1/records?search=192.0.2&type=A"
# Import a blocklist (streamed, no size limit beyond the configured one)
curl -H "$AUTH" --data-binary @hosts.txt \
$VIBEDNS/api/v1/blacklists/1/import
# Statistics
curl -H "$AUTH" $VIBEDNS/api/v1/stats
# Cache
curl -H "$AUTH" $VIBEDNS/api/v1/cache
curl -H "$AUTH" -X DELETE $VIBEDNS/api/v1/cache
curl -H "$AUTH" -X DELETE "$VIBEDNS/api/v1/cache?name=example.com"
# What would this name do?
curl -H "$AUTH" "$VIBEDNS/api/v1/tools/lookup?name=example.com&type=A&client=100.64.30.5"
curl -H "$AUTH" "$VIBEDNS/api/v1/tools/domain-check?domain=ads.example.com"
# Configuration export
curl -H "$AUTH" $VIBEDNS/api/v1/config/export > vibedns-config.json
Errors are JSON with an actionable message:
{
"error": {
"status": 400,
"message": "an A record needs a valid IPv4 address, for example 192.0.2.10 (got \"not-an-ip\")",
"code": "invalid_request"
}
}
The administrator's Basic credentials also work, which is convenient for
interactive curl. Automation should use a token: tokens are individually
revocable and cannot change the administrator's credentials.
Backup and restore
Backups use SQLite's VACUUM INTO, which writes a transactionally consistent
copy while the database is in use. Copying the .db file with cp would
capture a torn snapshot whose committed data still lives in the write-ahead log.
vibedns database backup --db /var/lib/vibedns/dns.db --output /var/backups/vibedns
vibedns database stats --db /var/lib/vibedns/dns.db
vibedns database vacuum --db /var/lib/vibedns/dns.db
Enable scheduled backups under Settings → Database, with an interval and a retention count. Backups can be downloaded from the UI or the API.
Restore is deliberately two-step. Overwriting the database underneath a running process would leave open connections reading a file that no longer exists, so a restore is staged and applied on the next start:
vibedns database restore --file /var/backups/vibedns/vibedns-20250101-030000.db
sudo systemctl restart vibedns
The database being replaced is preserved next to it as
dns.db.pre-restore-<timestamp>, so a restore that turns out to be the wrong
choice is still recoverable. In the UI you must retype the backup's file name
to confirm.
Security considerations
This server is not an open resolver, and takes work to become one.
Recursion is restricted to an explicit allow list, seeded with RFC1918 and ULA
ranges. vibedns config check fails loudly if that list ever contains
0.0.0.0/0 or ::/0. Authoritative answers remain available to clients that
are not allowed to recurse, so tightening the ACL does not break your own zones.
Credentials. The administrator password is stored as an Argon2id hash (64 MiB, 3 passes). Because HTTP Basic replays credentials on every request, successful verifications are cached briefly in memory, keyed by a MAC of the password — otherwise every page load would cost 64 MiB and tens of milliseconds. Changing the password clears that cache immediately. Repeated failures from one address are locked out.
API tokens are 256 bits from the system CSPRNG, stored as a SHA-256 hash with a short clear-text prefix for lookup. A fast hash is correct here precisely because a token has no low-entropy guess space — and it is verified on every API request.
CSRF. Basic authentication does not protect state-changing requests, so the UI carries a signed, account-bound, double-submitted token on every form. The API applies the same check only where it is meaningful: bearer tokens are never sent automatically by a browser, and a JSON body cannot be produced by a cross-origin form without a preflight.
Headers. A strict Content-Security-Policy (script-src 'self', no inline
scripts, no eval), X-Frame-Options: DENY, nosniff, and HSTS when served
over TLS. Page data reaches JavaScript through data- attributes rather than
inline <script> blocks, so the policy needs no exceptions.
Everything else. All SQL is parameterised. The database and its backups are
created 0600. Templates escape by default. Uploads and request bodies are
size-limited. Rate limiting applies to both DNS and the management interface.
Passwords and token secrets never appear in logs, audit entries or the
configuration export.
Recommended deployment: bind the management interface to 127.0.0.1 and
reach it over SSH or a VPN. If it must be exposed, put it behind a reverse proxy
with TLS and set the trusted-proxy list so X-Forwarded-For is honoured only
from that proxy — trusting it unconditionally would let any client forge its
address and slip past the sign-in rate limiter.
CLI
vibedns start the server (serve is the default)
vibedns serve start explicitly
vibedns version version and build information
vibedns config check validate configuration, exit non-zero on problems
vibedns config show print every effective setting
vibedns admin reset-password generate or set a new administrator password
vibedns admin show show the administrator account
vibedns database migrate apply pending schema migrations
vibedns database backup write a consistent backup
vibedns database restore --file stage a restore for the next start
vibedns database vacuum reclaim space after large deletions
vibedns database stats size and row counts
Architecture
cmd/vibedns/ entry point
internal/
api/ REST API (/api/v1)
app/ service layer: validation, auditing, invalidation
auditlog/ administrative change log
auth/ Argon2id, API tokens, CSRF, middleware
authoritative/ in-memory zone index and answer engine
backup/ VACUUM INTO backups and staged restore
blacklist/ domain matcher and bulk-import parsers
cache/ sharded resolver cache
cli/ command line interface
config/ bootstrap config and DB-backed settings
database/ SQLite access, schema, migrations
dnsengine/ UDP/TCP listeners and the query pipeline
metrics/ counters and Prometheus exposition
models/ shared data types
netutil/ address helpers
policy/ CIDR-indexed policy evaluation
querylog/ buffered query logging
ratelimit/ per-client token buckets
resolver/ upstream forwarding and recursion ACL
runtimecfg/ immutable configuration snapshots
validate/ DNS name and record validation
web/ HTML handlers, templates, middleware
zonefile/ BIND zone file import and export
web/
templates/ embedded HTML
static/ embedded CSS, JS, fonts
The DNS data path never touches SQLite. Settings, the compiled zone index,
the compiled policy index and the recursion ACL live in one immutable
Snapshot behind an atomic pointer. A query reads that pointer once and works
entirely from immutable data — no locks, no database, nothing a writer can
block. Configuration changes build a fresh snapshot and swap it in; queries
already in flight finish against the old one. Reloads are debounced, so
importing a list one API call at a time still results in a bounded number of
rebuilds.
Blocklists are shared by pointer between policies, so a 200,000 domain list used by five policies is held in memory exactly once.
Resolution order for each query:
- Client policy — blocklists apply even to names a local zone would answer
- Authoritative zones — these always win over recursion
- Recursion ACL — refuse if the client may not recurse
- Cache
- Upstream resolvers
- Cache the result
License
GNU General Public License v3.0.
TL;DR
This is entirely written (by AI) software I made because I couldn't find a local homelab DNS server that did what I wanted without being either way too complicated or missing half the stuff I actually care about.
I built it for myself, my homelab, and the way I want to manage DNS. I put it online because somebody else might find it useful too.
That does not mean I am trying to turn this into some giant community-driven DNS project.
- Yes, it is written by AI (with help from me on ideas and implementations).
- Yes, some of the code is probably bad.
- Yes, there are probably much better ways to do some of this.
- No, I probably will not implement the feature you want.
- No, I am not going to rewrite half the project because you think I picked the wrong database, framework, DNS library, or whatever else.
And no, I am not going to be upset if you think this software sucks.
If you like it, use it.
If you don't like it, don't use it.
If you want to fork it and make it better, please do. That is literally what forks are for.
This is not trying to replace BIND, PowerDNS, Technitium, AdGuard Home, Pi-hole, or whatever enterprise DNS platform you are about to compare it to. I just wanted something simple that I could run locally, manage from a decent web UI, and not spend an entire afternoon configuring.
I couldn't find exactly what I wanted, so naturally I made the extremely reasonable decision to have AI help me write a DNS server.
There will be bugs. There will probably be questionable code. There may be parts of this project that make experienced software engineers stare at their screen for a minute.
That is okay.
Please do not open an issue just to tell me the entire architecture is wrong. Please do not write me an essay explaining why this should have been written in Rust. Please do not @ me because you disagree with how I implemented DNS caching.
Useful bug reports are fine. PRs are fine. Forks are fine.
But this is ultimately software I made to solve my problem.
Use it, fork it, laugh at it, improve it, or don't use it.
That is all. Please don't @ me.