Files
vibedns/README.md
T
2026-08-16 21:18:45 -05:00

19 KiB

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, not 1.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.com covers a.b.example.com without 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, /readyz and 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/v1 with 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:

  1. Blacklists: Adult Content, Gambling, Malware
  2. Policy "Guest Filtering": all three blacklists, action NXDOMAIN
  3. Policy "Malware Only": Malware alone
  4. Network "Guest Wi-Fi" 100.64.30.0/24 → Guest Filtering
  5. 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:

  1. Client policy — blocklists apply even to names a local zone would answer
  2. Authoritative zones — these always win over recursion
  3. Recursion ACL — refuse if the client may not recurse
  4. Cache
  5. Upstream resolvers
  6. Cache the result

License

MIT.