> 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`, 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. ```bash git clone https://github.com/owen/vibedns.git cd vibedns go build -o vibedns ./cmd/vibedns ``` For a release build with version information: ```bash 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. ```bash GOOS=linux GOARCH=arm64 go build -o vibedns-arm64 ./cmd/vibedns ``` Run the tests: ```bash go test ./... ``` --- ## Installation ### Native ```bash 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: ```bash sudo systemctl disable --now systemd-resolved sudo rm -f /etc/resolv.conf echo 'nameserver 127.0.0.1' | sudo tee /etc/resolv.conf ``` ### Docker ```bash 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: 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: ```bash 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: ```bash vibedns config check # validate, with an explicit open-resolver check vibedns config show # print every effective setting ``` --- ## DNS configuration examples ### A forward zone ```bash # 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 ```bash 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: ```text # 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. ```bash export VIBEDNS_TOKEN=vibedns_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx export VIBEDNS=http://127.0.0.1:8080 AUTH="Authorization: Bearer $VIBEDNS_TOKEN" ``` ```bash # 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: ```json { "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. ```bash 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: ```bash 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-`, 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 `