initial commit
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
# 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: <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:
|
||||
|
||||
```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-<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.
|
||||
Reference in New Issue
Block a user