initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
.git
.gitignore
.dockerignore
Dockerfile
docker-compose.yml
README.md
docs/
data/
backups/
*.db
*.db-wal
*.db-shm
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json
secrets.*
vibedns
vibedns-*
dist/
out/
.idea/
.vscode/
+36
View File
@@ -0,0 +1,36 @@
# Build output
/vibedns
/vibedns-*
/dist/
/out/
# Runtime data — never commit a database or a backup
/data/
*.db
*.db-wal
*.db-shm
*.db.restore-pending
*.db.pre-restore-*
/backups/
# Local configuration and credentials
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json
secrets.*
# Go
*.test
*.out
coverage.*
# Editors and OS
.idea/
.vscode/
*.swp
.DS_Store
+69
View File
@@ -0,0 +1,69 @@
# Build stage.
#
# CGO stays off: the SQLite driver is pure Go, so the result is a static binary
# that runs on any base image, including scratch.
FROM golang:1.26-alpine AS build
WORKDIR /src
# Dependencies first, so a source-only change reuses this layer.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w \
-X github.com/owen/vibedns/internal/version.Version=${VERSION} \
-X github.com/owen/vibedns/internal/version.Commit=${COMMIT} \
-X github.com/owen/vibedns/internal/version.BuildDate=${BUILD_DATE}" \
-o /out/vibedns ./cmd/vibedns
# Verify the templates and assets really are embedded, so a broken build fails
# here rather than at the first page load in production.
RUN CGO_ENABLED=0 go test ./internal/web/ -run TestStaticAssetsEmbedded -count=1
# Runtime stage.
FROM alpine:3.20
# ca-certificates is not needed for DNS itself, but keeps outbound HTTPS working
# if an operator ever fetches a blocklist from the host. tzdata makes log
# timestamps and retention windows follow the configured timezone.
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -g 10001 -S vibedns \
&& adduser -u 10001 -S -G vibedns -h /var/lib/vibedns vibedns \
&& mkdir -p /var/lib/vibedns \
&& chown -R vibedns:vibedns /var/lib/vibedns
COPY --from=build /out/vibedns /usr/local/bin/vibedns
# Allow binding port 53 as an unprivileged user. Without this the container
# would have to run as root just to open the DNS socket.
RUN apk add --no-cache libcap \
&& setcap 'cap_net_bind_service=+ep' /usr/local/bin/vibedns \
&& apk del libcap
USER vibedns
WORKDIR /var/lib/vibedns
VOLUME ["/var/lib/vibedns"]
EXPOSE 53/udp 53/tcp 8080/tcp
ENV VIBEDNS_DB_PATH=/var/lib/vibedns/dns.db \
VIBEDNS_HTTP_ADDR=0.0.0.0:8080 \
VIBEDNS_DNS_ADDR=0.0.0.0:53
# /healthz never touches the database, so a database problem does not cause the
# orchestrator to kill a process that is still answering from cache and zones.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
ENTRYPOINT ["/usr/local/bin/vibedns"]
CMD ["serve"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 VibeDNS contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+86
View File
@@ -0,0 +1,86 @@
# VibeDNS
#
# The binary is self-contained: templates, assets and migrations are embedded,
# and the SQLite driver is pure Go, so no CGO or C toolchain is involved.
BINARY := vibedns
PKG := ./cmd/vibedns
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
VERSION_PKG := github.com/owen/vibedns/internal/version
LDFLAGS := -s -w \
-X $(VERSION_PKG).Version=$(VERSION) \
-X $(VERSION_PKG).Commit=$(COMMIT) \
-X $(VERSION_PKG).BuildDate=$(BUILD_DATE)
export CGO_ENABLED := 0
.PHONY: all
all: build
.PHONY: build
build: ## Build the binary
go build -trimpath -ldflags="$(LDFLAGS)" -o $(BINARY) $(PKG)
.PHONY: install
install: ## Install into GOPATH/bin
go install -trimpath -ldflags="$(LDFLAGS)" $(PKG)
.PHONY: test
test: ## Run the test suite
go test ./...
.PHONY: test-race
test-race: ## Run the tests with the race detector
CGO_ENABLED=1 go test -race ./...
.PHONY: cover
cover: ## Write and open a coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
.PHONY: vet
vet: ## Run go vet
go vet ./...
.PHONY: fmt
fmt: ## Format the source
gofmt -s -w .
.PHONY: check
check: fmt vet test ## Format, vet and test
.PHONY: run
run: build ## Run against a local database on non-privileged ports
./$(BINARY) serve --db ./data/dns.db --dns 127.0.0.1:5353 --http 127.0.0.1:8080
.PHONY: clean
clean: ## Remove build output
rm -f $(BINARY) coverage.out
rm -rf dist out
.PHONY: dist
dist: ## Cross-compile release binaries
@mkdir -p dist
@for target in linux/amd64 linux/arm64 linux/arm darwin/amd64 darwin/arm64 freebsd/amd64; do \
os=$${target%/*}; arch=$${target#*/}; \
echo "building $$os/$$arch"; \
GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags="$(LDFLAGS)" \
-o dist/$(BINARY)-$$os-$$arch $(PKG) || exit 1; \
done
@echo "binaries written to dist/"
.PHONY: docker
docker: ## Build the container image
docker build \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
--build-arg BUILD_DATE=$(BUILD_DATE) \
-t vibedns:$(VERSION) -t vibedns:latest .
.PHONY: help
help: ## List targets
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
+540
View File
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
# Security policy
Please do not open a public issue for a suspected vulnerability or include
credentials, tokens, database files, logs, or private DNS data in a report.
Use the repository's **Security** tab and select **Report a vulnerability** to
send the maintainers a private report. Include the affected version, impact,
reproduction steps, and a minimal proof of concept with all sensitive values
removed.
+18
View File
@@ -0,0 +1,18 @@
// Command vibedns is an authoritative DNS server, recursive resolver and
// filtering appliance with a web management interface.
//
// Everything it needs at runtime — HTML templates, CSS, JavaScript, fonts and
// schema migrations — is embedded in this binary. Deployment is copying one
// file and pointing it at a database path.
package main
import (
"context"
"os"
"github.com/owen/vibedns/internal/cli"
)
func main() {
os.Exit(cli.Main(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}
+71
View File
@@ -0,0 +1,71 @@
[Unit]
Description=VibeDNS - The AI generated DNS server designed to solve an idiots neiche problems
Documentation=https://github.com/owen/vibedns
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=vibedns
Group=vibedns
ExecStart=/usr/local/bin/vibedns serve \
--db /var/lib/vibedns/dns.db \
--http 127.0.0.1:8080 \
--dns 0.0.0.0:53
# The administrator password is generated on first start and printed to the
# journal exactly once. To set your own instead, put it in an environment file
# readable only by root and uncomment the line below:
# echo 'VIBEDNS_ADMIN_PASSWORD=...' > /etc/vibedns.env && chmod 600 /etc/vibedns.env
# EnvironmentFile=-/etc/vibedns.env
Restart=on-failure
RestartSec=5s
# Binding port 53 without running as root. This is the whole reason the service
# can drop to an unprivileged user.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# The database and backups are the only paths that need to be writable.
StateDirectory=vibedns
StateDirectoryMode=0750
ReadWritePaths=/var/lib/vibedns
# Sandboxing. Each of these closes off something a DNS server has no business
# touching, so a flaw in parsing a hostile response has far less to work with.
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
UMask=0077
# Resource ceilings. A DNS server holds many concurrent sockets; the memory
# limit is generous enough for a large cache plus several big blocklists.
LimitNOFILE=65535
MemoryMax=2G
TasksMax=512
# Journal identification
SyslogIdentifier=vibedns
[Install]
WantedBy=multi-user.target
+64
View File
@@ -0,0 +1,64 @@
# VibeDNS via Docker Compose.
#
# docker compose up -d
# docker compose logs vibedns # the generated admin password is printed once
#
# Then open http://127.0.0.1:8080 and sign in as "admin".
services:
vibedns:
build:
context: .
args:
VERSION: "0.1.0"
image: vibedns:latest
container_name: vibedns
restart: unless-stopped
ports:
# DNS needs both transports: TCP is not optional, it is how large
# answers are carried after a truncated UDP reply.
- "53:53/udp"
- "53:53/tcp"
# The management interface is bound to loopback on the host. Reach it
# over SSH or a VPN rather than exposing it to the network.
- "127.0.0.1:8080:8080/tcp"
volumes:
- vibedns-data:/var/lib/vibedns
environment:
VIBEDNS_DB_PATH: /var/lib/vibedns/dns.db
VIBEDNS_HTTP_ADDR: 0.0.0.0:8080
VIBEDNS_DNS_ADDR: 0.0.0.0:53
VIBEDNS_LOG_LEVEL: info
VIBEDNS_LOG_FORMAT: text
# Set your own initial password instead of the generated one:
# VIBEDNS_ADMIN_USERNAME: admin
# VIBEDNS_ADMIN_PASSWORD: change-me-to-something-long
# Binding port 53 without running the whole container as root.
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
vibedns-data:
driver: local
+25
View File
@@ -0,0 +1,25 @@
module github.com/owen/vibedns
go 1.26.5
require (
github.com/miekg/dns v1.1.72
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/tools v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+58
View File
@@ -0,0 +1,58 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+289
View File
@@ -0,0 +1,289 @@
// Package api implements the versioned REST interface under /api/v1.
//
// It is a thin JSON layer over the same service package the web UI uses, so
// validation, auditing and cache invalidation behave identically whichever
// interface a change arrives through.
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
)
// Server serves the REST API.
type Server struct {
app *app.App
log *slog.Logger
}
// New creates the API server.
func New(a *app.App, log *slog.Logger) *Server {
return &Server{app: a, log: log}
}
// Handler returns the API handler, already wrapped in authentication.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return s.authenticate(mux)
}
// --- response helpers ---------------------------------------------------
// errorBody is the single error shape every failing endpoint returns.
type errorBody struct {
Error struct {
Status int `json:"status"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
} `json:"error"`
}
// listBody wraps collections so pagination can be added without breaking
// clients that already parse the response.
type listBody struct {
Items any `json:"items"`
Total int `json:"total"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
// The status line is already sent; nothing useful is left to do.
return
}
}
// writeError renders a service error as JSON, logging server-side faults.
func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
status := app.StatusOf(err)
if app.IsInternal(err) {
s.log.Error("api request failed",
"method", r.Method, "path", r.URL.Path, "error", err)
}
var body errorBody
body.Error.Status = status
body.Error.Message = app.MessageOf(err)
body.Error.Code = codeFor(status)
writeJSON(w, status, body)
}
func codeFor(status int) string {
switch status {
case http.StatusBadRequest:
return "invalid_request"
case http.StatusUnauthorized:
return "unauthorized"
case http.StatusForbidden:
return "forbidden"
case http.StatusNotFound:
return "not_found"
case http.StatusConflict:
return "conflict"
case http.StatusTooManyRequests:
return "rate_limited"
default:
return "internal_error"
}
}
// handler is an API handler that may return an error for central rendering.
type handler func(http.ResponseWriter, *http.Request) error
func (s *Server) h(fn handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
s.writeError(w, r, err)
}
}
}
// decode reads a JSON request body into v.
//
// Unknown fields are rejected so a typo in a field name fails loudly instead
// of being silently ignored, which is the difference between a caller noticing
// their script is wrong and quietly not applying a setting.
func decode(r *http.Request, v any) error {
if r.Body == nil {
return app.Invalid("A JSON request body is required.")
}
dec := json.NewDecoder(io.LimitReader(r.Body, 32<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return app.Invalid("A JSON request body is required.")
}
return app.Invalid("The request body is not valid JSON: %v", err)
}
return nil
}
// pathID reads an {id} path parameter.
func pathID(r *http.Request, name string) (int64, error) {
raw := r.PathValue(name)
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, app.Invalid("%q is not a valid identifier.", raw)
}
return id, nil
}
// query reads a trimmed query parameter.
func query(r *http.Request, key string) string {
return strings.TrimSpace(r.URL.Query().Get(key))
}
// queryInt reads an integer query parameter.
func queryInt(r *http.Request, key string, def int) int {
raw := query(r, key)
if raw == "" {
return def
}
v, err := strconv.Atoi(raw)
if err != nil {
return def
}
return v
}
// queryInt64 reads an int64 query parameter.
func queryInt64(r *http.Request, key string) int64 {
v, err := strconv.ParseInt(query(r, key), 10, 64)
if err != nil {
return 0
}
return v
}
// queryBool reads a boolean query parameter.
func queryBool(r *http.Request, key string) bool {
v, err := strconv.ParseBool(query(r, key))
return err == nil && v
}
// limitOffset reads pagination parameters with sane bounds.
func limitOffset(r *http.Request, defLimit int) (int, int) {
limit := queryInt(r, "limit", defLimit)
switch {
case limit < 1:
limit = defLimit
case limit > 1000:
limit = 1000
}
offset := queryInt(r, "offset", 0)
if offset < 0 {
offset = 0
}
return limit, offset
}
// actor builds the audit actor for an API request.
func (s *Server) actor(r *http.Request) auditlog.Actor {
p, _ := auth.PrincipalFrom(r.Context())
source := auditlog.SourceAPI
if p.Kind == auth.KindAdmin {
// A browser session hitting the API is still the administrator, but it
// arrived over the API surface.
source = auditlog.SourceAPI
}
return auditlog.Actor{Name: p.Name, Source: source, ClientIP: p.ClientIP}
}
// authenticate enforces credentials on every API route.
//
// Both bearer tokens and the administrator's Basic credentials are accepted:
// tokens for automation, Basic so that the same URLs work from a browser or
// curl session without minting a token first.
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.app.Auth.Authenticate(r, true)
if err != nil {
s.writeAuthError(w, r, err)
return
}
ctx := auth.WithPrincipal(r.Context(), p)
r = r.WithContext(ctx)
if csrfRequired(r, p) {
if err := s.app.Auth.CheckCSRF(r, p); err != nil {
s.writeError(w, r, app.Forbidden("%s", err.Error()))
return
}
}
next.ServeHTTP(w, r)
})
}
// csrfRequired decides whether a request needs a CSRF token.
//
// CSRF protection exists because a browser replays HTTP Basic credentials on
// cross-site requests. The narrow case where that is actually exploitable is a
// request an attacker's page can cause the browser to send *without* a preflight
// — that is, a form submission, which is limited to the three CORS-safelisted
// content types. Anything else (a JSON body, or any custom header) forces a
// preflight the attacker's origin cannot pass.
//
// So tokens, safe methods, and non-form requests are exempt; a Basic-auth
// request carrying a form-shaped body is not.
func csrfRequired(r *http.Request, p auth.Principal) bool {
if p.Kind == auth.KindToken {
return false // never sent automatically by a browser
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return false
}
if r.Header.Get(auth.CSRFHeaderName) != "" {
return true // a token was offered, so verify it
}
ct := strings.ToLower(strings.TrimSpace(strings.SplitN(r.Header.Get("Content-Type"), ";", 2)[0]))
switch ct {
case "application/x-www-form-urlencoded", "multipart/form-data", "text/plain", "":
// Forgeable by a cross-origin form: require the token.
return true
default:
// Any other content type triggers a CORS preflight, which a
// cross-origin attacker cannot satisfy.
return false
}
}
func (s *Server) writeAuthError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, auth.ErrLockedOut):
w.Header().Set("Retry-After", "300")
s.writeError(w, r, &app.Error{
Status: http.StatusTooManyRequests,
Message: "Too many failed authentication attempts from this address. Try again shortly.",
})
case errors.Is(err, auth.ErrNoAdmin):
s.writeError(w, r, &app.Error{
Status: http.StatusServiceUnavailable,
Message: "No administrator account exists yet.",
})
default:
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer realm=%q, Basic realm=%q", auth.Realm, auth.Realm))
s.writeError(w, r, &app.Error{
Status: http.StatusUnauthorized,
Message: "Authentication is required. Send an API token as a bearer token, or use the administrator's Basic credentials.",
})
}
}
+788
View File
@@ -0,0 +1,788 @@
package api
import (
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/zonefile"
)
// --- Zones --------------------------------------------------------------
func (s *Server) listZones(w http.ResponseWriter, r *http.Request) error {
zones, err := s.app.Zones(r.Context(), database.ZoneFilter{
Kind: query(r, "kind"),
Search: query(r, "search"),
})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{Items: nonNil(zones), Total: len(zones)})
return nil
}
func (s *Server) getZone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, err := s.app.Zone(r.Context(), id)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, zone)
return nil
}
func (s *Server) createZone(w http.ResponseWriter, r *http.Request) error {
var in app.ZoneInput
if err := decode(r, &in); err != nil {
return err
}
zone, err := s.app.CreateZone(r.Context(), s.actor(r), in)
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, zone)
return nil
}
func (s *Server) updateZone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in app.ZoneInput
if err := decode(r, &in); err != nil {
return err
}
zone, err := s.app.UpdateZone(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, zone)
return nil
}
func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := s.app.DeleteZone(r.Context(), s.actor(r), id); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func (s *Server) cloneZone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := decode(r, &in); err != nil {
return err
}
zone, err := s.app.CloneZone(r.Context(), s.actor(r), id, in.Name, in.Description)
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, zone)
return nil
}
func (s *Server) exportZone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, body, err := s.app.ExportZoneFile(r.Context(), id)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/dns; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\""+zonefile.SuggestFilename(zone.Name)+"\"")
_, _ = w.Write(body)
return nil
}
// importZone accepts a zone file as the raw request body, which is what
// `curl --data-binary @example.zone` sends.
func (s *Server) importZone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
mode := app.ImportMode(query(r, "mode"))
if mode == "" {
mode = app.ImportMerge
}
result, err := s.app.ImportZoneFile(r.Context(), s.actor(r), id, r.Body, mode)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, result)
return nil
}
// --- Records ------------------------------------------------------------
func (s *Server) recordFilter(r *http.Request, zoneID int64) database.RecordFilter {
limit, offset := limitOffset(r, 100)
return database.RecordFilter{
ZoneID: zoneID,
Search: query(r, "search"),
Type: query(r, "type"),
Enabled: query(r, "status"),
Limit: limit,
Offset: offset,
}
}
func (s *Server) listRecords(w http.ResponseWriter, r *http.Request) error {
f := s.recordFilter(r, queryInt64(r, "zone_id"))
records, total, err := s.app.Records(r.Context(), f)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{
Items: nonNil(records), Total: total, Limit: f.Limit, Offset: f.Offset,
})
return nil
}
func (s *Server) listZoneRecords(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if _, err := s.app.Zone(r.Context(), id); err != nil {
return err
}
f := s.recordFilter(r, id)
records, total, err := s.app.Records(r.Context(), f)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{
Items: nonNil(records), Total: total, Limit: f.Limit, Offset: f.Offset,
})
return nil
}
func (s *Server) getRecord(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
rec, err := s.app.Record(r.Context(), id)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, rec)
return nil
}
func (s *Server) createRecord(w http.ResponseWriter, r *http.Request) error {
zoneID, err := pathID(r, "id")
if err != nil {
return err
}
var in app.RecordInput
if err := decode(r, &in); err != nil {
return err
}
rec, err := s.app.CreateRecord(r.Context(), s.actor(r), zoneID, in)
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, rec)
return nil
}
func (s *Server) updateRecord(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in app.RecordInput
if err := decode(r, &in); err != nil {
return err
}
rec, err := s.app.UpdateRecord(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, rec)
return nil
}
func (s *Server) deleteRecord(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := s.app.DeleteRecord(r.Context(), s.actor(r), id); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// listRecordTypes publishes the record type catalogue, including the field
// definitions the UI builds its editors from.
func (s *Server) listRecordTypes(w http.ResponseWriter, r *http.Request) error {
types := s.app.RecordTypes()
writeJSON(w, http.StatusOK, listBody{Items: types, Total: len(types)})
return nil
}
// --- Networks -----------------------------------------------------------
func (s *Server) listNetworks(w http.ResponseWriter, r *http.Request) error {
nets, err := s.app.Networks(r.Context(), query(r, "search"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{Items: nonNil(nets), Total: len(nets)})
return nil
}
func (s *Server) getNetwork(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
n, err := s.app.Network(r.Context(), id)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, n)
return nil
}
func (s *Server) createNetwork(w http.ResponseWriter, r *http.Request) error {
var in app.NetworkInput
if err := decode(r, &in); err != nil {
return err
}
n, err := s.app.CreateNetwork(r.Context(), s.actor(r), in)
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, n)
return nil
}
func (s *Server) updateNetwork(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in app.NetworkInput
if err := decode(r, &in); err != nil {
return err
}
n, err := s.app.UpdateNetwork(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, n)
return nil
}
func (s *Server) deleteNetwork(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := s.app.DeleteNetwork(r.Context(), s.actor(r), id); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// --- Policies -----------------------------------------------------------
func (s *Server) listPolicies(w http.ResponseWriter, r *http.Request) error {
p, err := s.app.Policies(r.Context())
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{Items: nonNil(p), Total: len(p)})
return nil
}
func (s *Server) getPolicy(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
p, err := s.app.Policy(r.Context(), id)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, p)
return nil
}
func (s *Server) createPolicy(w http.ResponseWriter, r *http.Request) error {
var in app.PolicyInput
if err := decode(r, &in); err != nil {
return err
}
p, err := s.app.CreatePolicy(r.Context(), s.actor(r), in)
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, p)
return nil
}
func (s *Server) updatePolicy(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in app.PolicyInput
if err := decode(r, &in); err != nil {
return err
}
p, err := s.app.UpdatePolicy(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, p)
return nil
}
func (s *Server) deletePolicy(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := s.app.DeletePolicy(r.Context(), s.actor(r), id); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// --- Domain lists -------------------------------------------------------
// kindFor maps the URL segment to the stored list kind.
func kindFor(segment string) string {
if segment == "allowlists" {
return models.KindAllowlist
}
return models.KindBlacklist
}
func (s *Server) listListsFor(segment string) handler {
kind := kindFor(segment)
return func(w http.ResponseWriter, r *http.Request) error {
lists, err := s.app.DomainLists(r.Context(), kind, query(r, "search"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{Items: nonNil(lists), Total: len(lists)})
return nil
}
}
func (s *Server) createListFor(segment string) handler {
kind := kindFor(segment)
return func(w http.ResponseWriter, r *http.Request) error {
var in app.ListInput
if err := decode(r, &in); err != nil {
return err
}
in.Kind = kind // the URL decides the kind, not the body
l, err := s.app.CreateDomainList(r.Context(), s.actor(r), in)
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, l)
return nil
}
}
func (s *Server) getList(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
l, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, l)
return nil
}
func (s *Server) updateList(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in app.ListInput
if err := decode(r, &in); err != nil {
return err
}
l, err := s.app.UpdateDomainList(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, l)
return nil
}
func (s *Server) deleteList(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := s.app.DeleteDomainList(r.Context(), s.actor(r), id); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func (s *Server) listDomains(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
limit, offset := limitOffset(r, 100)
entries, total, err := s.app.DomainEntries(r.Context(), id, query(r, "search"), limit, offset)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
})
return nil
}
func (s *Server) addDomain(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in struct {
Domain string `json:"domain"`
MatchSubdomains *bool `json:"match_subdomains"`
Comment string `json:"comment"`
}
if err := decode(r, &in); err != nil {
return err
}
// Subdomain matching is the useful default: it is what makes a blocklist
// of a few hundred thousand names cover the millions of hosts beneath them.
match := true
if in.MatchSubdomains != nil {
match = *in.MatchSubdomains
}
entry, err := s.app.AddDomain(r.Context(), s.actor(r), id, in.Domain, match, in.Comment)
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, entry)
return nil
}
func (s *Server) deleteDomain(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := s.app.DeleteDomain(r.Context(), s.actor(r), id); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func (s *Server) clearDomains(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
n, err := s.app.ClearDomains(r.Context(), s.actor(r), id)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"removed": n})
return nil
}
// importDomains reads the list from the raw request body, so a caller can pipe
// a multi-megabyte hosts file straight in with --data-binary.
func (s *Server) importDomains(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
match := true
if v := query(r, "match_subdomains"); v != "" {
match = queryBool(r, "match_subdomains")
}
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, r.Body, match)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, summary)
return nil
}
func (s *Server) exportDomains(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if _, err := s.app.ExportDomains(r.Context(), id, w); err != nil {
return err
}
return nil
}
// --- Cache --------------------------------------------------------------
func (s *Server) getCache(w http.ResponseWriter, r *http.Request) error {
writeJSON(w, http.StatusOK, s.app.CacheView())
return nil
}
func (s *Server) flushCache(w http.ResponseWriter, r *http.Request) error {
if name := query(r, "name"); name != "" {
n, err := s.app.FlushCacheName(r.Context(), s.actor(r), name)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"removed": n, "name": name})
return nil
}
n, err := s.app.FlushCache(r.Context(), s.actor(r))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"removed": n})
return nil
}
func (s *Server) listCacheEntries(w http.ResponseWriter, r *http.Request) error {
limit, offset := limitOffset(r, 100)
entries, total := s.app.CacheEntries(query(r, "search"), limit, offset)
writeJSON(w, http.StatusOK, listBody{
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
})
return nil
}
func (s *Server) deleteCacheEntry(w http.ResponseWriter, r *http.Request) error {
name := query(r, "name")
qtype := query(r, "type")
if name == "" || qtype == "" {
return app.Invalid("Both the name and type query parameters are required.")
}
if err := s.app.DeleteCacheEntry(r.Context(), s.actor(r), name, qtype, queryBool(r, "dnssec")); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// --- Settings -----------------------------------------------------------
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) error {
writeJSON(w, http.StatusOK, s.app.Settings())
return nil
}
// updateSettings merges the submitted fields over the current settings, so a
// caller can change one value without restating the whole configuration.
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) error {
next := s.app.Settings()
if err := decode(r, &next); err != nil {
return err
}
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupDNS, next); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.app.Settings())
return nil
}
// --- Statistics and logs ------------------------------------------------
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) error {
dash, err := s.app.Dashboard(r.Context(), queryInt(r, "top", 10))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, dash)
return nil
}
func (s *Server) listQueryLog(w http.ResponseWriter, r *http.Request) error {
limit, offset := limitOffset(r, 100)
f := database.QueryLogFilter{
Domain: query(r, "domain"),
ClientIP: query(r, "client"),
QType: query(r, "type"),
Rcode: query(r, "rcode"),
Source: query(r, "source"),
Blocked: query(r, "blocked"),
NetworkID: queryInt64(r, "network_id"),
Limit: limit,
Offset: offset,
}
entries, total, err := s.app.QueryLogs(r.Context(), f)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
})
return nil
}
func (s *Server) clearQueryLog(w http.ResponseWriter, r *http.Request) error {
n, err := s.app.ClearQueryLog(r.Context(), s.actor(r))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"removed": n})
return nil
}
func (s *Server) listAuditLog(w http.ResponseWriter, r *http.Request) error {
limit, offset := limitOffset(r, 100)
f := database.AuditFilter{
Search: query(r, "search"),
ObjectType: query(r, "object_type"),
Source: query(r, "source"),
Limit: limit,
Offset: offset,
}
entries, total, err := s.app.AuditLogs(r.Context(), f)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, listBody{
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
})
return nil
}
// --- Operations ---------------------------------------------------------
func (s *Server) listBackups(w http.ResponseWriter, r *http.Request) error {
backups, err := s.app.BackupList()
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{
"status": s.app.BackupStatus(),
"backups": nonNil(backups),
})
return nil
}
func (s *Server) createBackup(w http.ResponseWriter, r *http.Request) error {
info, err := s.app.RunBackup(r.Context(), s.actor(r))
if err != nil {
return err
}
writeJSON(w, http.StatusCreated, info)
return nil
}
func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return s.app.WriteConfigExport(r.Context(), w, queryBool(r, "include_domains"))
}
func (s *Server) importConfig(w http.ResponseWriter, r *http.Request) error {
report, err := s.app.ImportConfig(r.Context(), s.actor(r), r.Body, queryBool(r, "apply_settings"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, report)
return nil
}
// --- Tools --------------------------------------------------------------
// reverseZone previews the zone apex a subnet maps to. The zone creation form
// calls this as the operator types.
func (s *Server) reverseZone(w http.ResponseWriter, r *http.Request) error {
cidr := query(r, "cidr")
if cidr == "" {
return app.Invalid("The cidr query parameter is required.")
}
name, note, err := s.app.ReverseZoneName(cidr)
if err != nil {
return err
}
kind, _ := validate.ReverseZoneKindForCIDR(cidr)
writeJSON(w, http.StatusOK, map[string]any{
"cidr": cidr,
"zone": strings.TrimSuffix(name, "."),
"fqdn": name,
"kind": kind,
"note": note,
})
return nil
}
func (s *Server) lookup(w http.ResponseWriter, r *http.Request) error {
name := query(r, "name")
if name == "" {
return app.Invalid("The name query parameter is required.")
}
result, err := s.app.Lookup(r.Context(), name, query(r, "type"), query(r, "client"), queryBool(r, "dnssec"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, result)
return nil
}
// domainCheck reports which policy lists cover a name.
func (s *Server) domainCheck(w http.ResponseWriter, r *http.Request) error {
name := query(r, "domain")
if name == "" {
return app.Invalid("The domain query parameter is required.")
}
hits, err := s.app.LookupDomain(r.Context(), name)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{
"domain": name,
"matches": nonNil(hits),
})
return nil
}
// nonNil turns a nil slice into an empty one so JSON responses always carry
// [] rather than null, which is what most clients expect from a collection.
func nonNil[T any](v []T) []T {
if v == nil {
return []T{}
}
return v
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"net/http"
"github.com/owen/vibedns/internal/app"
)
// routes registers every REST endpoint.
//
// Paths follow the usual collection/item shape, and PUT and PATCH are both
// accepted for updates: the service layer carries forward any field a caller
// omits, so a partial update behaves the way PATCH callers expect.
func (s *Server) routes(mux *http.ServeMux) {
const v1 = "/api/v1"
// Zones.
mux.HandleFunc("GET "+v1+"/zones", s.h(s.listZones))
mux.HandleFunc("POST "+v1+"/zones", s.h(s.createZone))
mux.HandleFunc("GET "+v1+"/zones/{id}", s.h(s.getZone))
mux.HandleFunc("PUT "+v1+"/zones/{id}", s.h(s.updateZone))
mux.HandleFunc("PATCH "+v1+"/zones/{id}", s.h(s.updateZone))
mux.HandleFunc("DELETE "+v1+"/zones/{id}", s.h(s.deleteZone))
mux.HandleFunc("POST "+v1+"/zones/{id}/clone", s.h(s.cloneZone))
mux.HandleFunc("GET "+v1+"/zones/{id}/export", s.h(s.exportZone))
mux.HandleFunc("POST "+v1+"/zones/{id}/import", s.h(s.importZone))
// Records, both nested under a zone and flat.
mux.HandleFunc("GET "+v1+"/zones/{id}/records", s.h(s.listZoneRecords))
mux.HandleFunc("POST "+v1+"/zones/{id}/records", s.h(s.createRecord))
mux.HandleFunc("GET "+v1+"/records", s.h(s.listRecords))
mux.HandleFunc("GET "+v1+"/records/{id}", s.h(s.getRecord))
mux.HandleFunc("PUT "+v1+"/records/{id}", s.h(s.updateRecord))
mux.HandleFunc("PATCH "+v1+"/records/{id}", s.h(s.updateRecord))
mux.HandleFunc("DELETE "+v1+"/records/{id}", s.h(s.deleteRecord))
mux.HandleFunc("GET "+v1+"/record-types", s.h(s.listRecordTypes))
// Client networks.
mux.HandleFunc("GET "+v1+"/networks", s.h(s.listNetworks))
mux.HandleFunc("POST "+v1+"/networks", s.h(s.createNetwork))
mux.HandleFunc("GET "+v1+"/networks/{id}", s.h(s.getNetwork))
mux.HandleFunc("PUT "+v1+"/networks/{id}", s.h(s.updateNetwork))
mux.HandleFunc("PATCH "+v1+"/networks/{id}", s.h(s.updateNetwork))
mux.HandleFunc("DELETE "+v1+"/networks/{id}", s.h(s.deleteNetwork))
// Policies.
mux.HandleFunc("GET "+v1+"/policies", s.h(s.listPolicies))
mux.HandleFunc("POST "+v1+"/policies", s.h(s.createPolicy))
mux.HandleFunc("GET "+v1+"/policies/{id}", s.h(s.getPolicy))
mux.HandleFunc("PUT "+v1+"/policies/{id}", s.h(s.updatePolicy))
mux.HandleFunc("PATCH "+v1+"/policies/{id}", s.h(s.updatePolicy))
mux.HandleFunc("DELETE "+v1+"/policies/{id}", s.h(s.deletePolicy))
// Blacklists and allowlists share one implementation, differing only in
// the kind they filter and create.
for _, kind := range []string{"blacklists", "allowlists"} {
k := kind
mux.HandleFunc("GET "+v1+"/"+k, s.h(s.listListsFor(k)))
mux.HandleFunc("POST "+v1+"/"+k, s.h(s.createListFor(k)))
mux.HandleFunc("GET "+v1+"/"+k+"/{id}", s.h(s.getList))
mux.HandleFunc("PUT "+v1+"/"+k+"/{id}", s.h(s.updateList))
mux.HandleFunc("PATCH "+v1+"/"+k+"/{id}", s.h(s.updateList))
mux.HandleFunc("DELETE "+v1+"/"+k+"/{id}", s.h(s.deleteList))
mux.HandleFunc("GET "+v1+"/"+k+"/{id}/domains", s.h(s.listDomains))
mux.HandleFunc("POST "+v1+"/"+k+"/{id}/domains", s.h(s.addDomain))
mux.HandleFunc("DELETE "+v1+"/"+k+"/{id}/domains", s.h(s.clearDomains))
mux.HandleFunc("POST "+v1+"/"+k+"/{id}/import", s.h(s.importDomains))
mux.HandleFunc("GET "+v1+"/"+k+"/{id}/export", s.h(s.exportDomains))
}
mux.HandleFunc("DELETE "+v1+"/domains/{id}", s.h(s.deleteDomain))
// Cache.
mux.HandleFunc("GET "+v1+"/cache", s.h(s.getCache))
mux.HandleFunc("DELETE "+v1+"/cache", s.h(s.flushCache))
mux.HandleFunc("GET "+v1+"/cache/entries", s.h(s.listCacheEntries))
mux.HandleFunc("DELETE "+v1+"/cache/entries", s.h(s.deleteCacheEntry))
// Settings.
mux.HandleFunc("GET "+v1+"/settings", s.h(s.getSettings))
mux.HandleFunc("PUT "+v1+"/settings", s.h(s.updateSettings))
mux.HandleFunc("PATCH "+v1+"/settings", s.h(s.updateSettings))
// Statistics and logs.
mux.HandleFunc("GET "+v1+"/stats", s.h(s.getStats))
mux.HandleFunc("GET "+v1+"/querylog", s.h(s.listQueryLog))
mux.HandleFunc("DELETE "+v1+"/querylog", s.h(s.clearQueryLog))
mux.HandleFunc("GET "+v1+"/auditlog", s.h(s.listAuditLog))
// Operations.
mux.HandleFunc("GET "+v1+"/backups", s.h(s.listBackups))
mux.HandleFunc("POST "+v1+"/backups", s.h(s.createBackup))
mux.HandleFunc("GET "+v1+"/config/export", s.h(s.exportConfig))
mux.HandleFunc("POST "+v1+"/config/import", s.h(s.importConfig))
// Tools.
mux.HandleFunc("GET "+v1+"/tools/reverse-zone", s.h(s.reverseZone))
mux.HandleFunc("GET "+v1+"/tools/lookup", s.h(s.lookup))
mux.HandleFunc("GET "+v1+"/tools/domain-check", s.h(s.domainCheck))
// Service metadata. Both the bare path and the trailing-slash form serve
// the index, because ServeMux redirects the former to the latter.
mux.HandleFunc("GET "+v1, s.h(s.index))
mux.HandleFunc("GET "+v1+"/{$}", s.h(s.index))
mux.HandleFunc("GET "+v1+"/", s.h(s.notFound))
}
// index describes the API surface, so a caller can discover it with one GET.
func (s *Server) index(w http.ResponseWriter, r *http.Request) error {
writeJSON(w, http.StatusOK, map[string]any{
"version": "v1",
"resources": []string{
"/api/v1/zones", "/api/v1/records", "/api/v1/networks",
"/api/v1/policies", "/api/v1/blacklists", "/api/v1/allowlists",
"/api/v1/cache", "/api/v1/settings", "/api/v1/stats",
"/api/v1/querylog", "/api/v1/auditlog", "/api/v1/backups",
"/api/v1/config/export", "/api/v1/config/import",
"/api/v1/tools/lookup", "/api/v1/tools/reverse-zone",
},
})
return nil
}
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) error {
return app.NotFound("No API endpoint matches %s %s.", r.Method, r.URL.Path)
}
+237
View File
@@ -0,0 +1,237 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/resolver"
)
// Admin returns the administrator account.
func (a *App) Admin(ctx context.Context) (models.Admin, error) {
admin, err := a.DB.Admin(ctx)
if errors.Is(err, database.ErrNotFound) {
return admin, NotFound("No administrator account exists.")
}
if err != nil {
return admin, Internal(err, "The administrator account could not be loaded.")
}
return admin, nil
}
// ChangeCredentials updates the administrator username and/or password.
//
// The current password is always required: knowing the session is
// authenticated is not enough, because HTTP Basic credentials are replayed by
// the browser and a stolen session should not be able to lock out the owner.
func (a *App) ChangeCredentials(ctx context.Context, actor auditlog.Actor,
currentPassword, newUsername, newPassword, confirmPassword string) error {
admin, err := a.Admin(ctx)
if err != nil {
return err
}
ok, verr := auth.VerifyPassword(admin.PasswordHash, currentPassword)
if verr != nil {
return Internal(verr, "The stored password could not be verified.")
}
if !ok {
return Forbidden("The current password is incorrect.")
}
username := strings.TrimSpace(newUsername)
if username == "" {
username = admin.Username
}
if err := config.ValidateUsername(username); err != nil {
return Invalid("%s", err.Error())
}
hash := admin.PasswordHash
passwordChanged := false
if newPassword != "" {
if newPassword != confirmPassword {
return Invalid("The new passwords do not match.")
}
if err := auth.ValidatePassword(newPassword); err != nil {
return Invalid("%s", err.Error())
}
if newPassword == currentPassword {
return Invalid("The new password must differ from the current one.")
}
hash, err = auth.HashPassword(newPassword)
if err != nil {
return Internal(err, "The new password could not be stored.")
}
passwordChanged = true
} else if auth.NeedsRehash(admin.PasswordHash) {
// Take the opportunity to upgrade an old hash while we have the
// plaintext in hand.
if h, herr := auth.HashPassword(currentPassword); herr == nil {
hash = h
}
}
if username == admin.Username && !passwordChanged {
return Invalid("Nothing was changed.")
}
if err := a.DB.UpdateAdminCredentials(ctx, username, hash, false); err != nil {
return Internal(err, "The credentials could not be saved.")
}
// The old password must stop working immediately.
a.Auth.InvalidateCredentials()
what := []string{}
if username != admin.Username {
what = append(what, "username")
}
if passwordChanged {
what = append(what, "password")
}
a.Audit.Record(ctx, actor, "admin.credentials_changed", auditlog.ObjectAdmin, "1", username,
auditlog.Changes("changed", strings.Join(what, " and ")))
return nil
}
// --- API tokens ---------------------------------------------------------
// APITokens lists every token. Secrets are never included.
func (a *App) APITokens(ctx context.Context) ([]models.APIToken, error) {
tokens, err := a.DB.APITokens(ctx)
if err != nil {
return nil, Internal(err, "The API tokens could not be loaded.")
}
return tokens, nil
}
// CreateAPIToken mints a token. The secret is returned once and never stored.
func (a *App) CreateAPIToken(ctx context.Context, actor auditlog.Actor, name, description string) (models.APIToken, error) {
name = strings.TrimSpace(name)
if name == "" {
return models.APIToken{}, Invalid("A token name is required.")
}
if len(name) > 100 {
return models.APIToken{}, Invalid("The token name must be 100 characters or fewer.")
}
tok, err := auth.GenerateToken()
if err != nil {
return models.APIToken{}, Internal(err, "The token could not be generated.")
}
created, err := a.DB.CreateAPIToken(ctx, name, strings.TrimSpace(description), tok.Prefix, tok.Hash)
if err != nil {
return models.APIToken{}, translate(err, "Token not found.",
fmt.Sprintf("An API token named %q already exists.", name))
}
created.Secret = tok.Secret
// The audit entry records that a token was created, never its value.
a.Audit.RecordID(ctx, actor, "token.create", auditlog.ObjectToken, created.ID, name,
auditlog.Changes("prefix", tok.Prefix))
return created, nil
}
// SetAPITokenEnabled enables or disables a token.
func (a *App) SetAPITokenEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
tok, err := a.DB.APIToken(ctx, id)
if err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
if err := a.DB.SetAPITokenEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
action := "token.disable"
if enabled {
action = "token.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectToken, id, tok.Name, "")
return nil
}
// DeleteAPIToken revokes a token permanently.
func (a *App) DeleteAPIToken(ctx context.Context, actor auditlog.Actor, id int64) error {
tok, err := a.DB.APIToken(ctx, id)
if err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
if err := a.DB.DeleteAPIToken(ctx, id); err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "token.revoke", auditlog.ObjectToken, id, tok.Name, "")
return nil
}
// --- Diagnostics --------------------------------------------------------
// resolverCheck is a thin seam so settings.go can probe an upstream without
// importing the resolver package itself.
func resolverCheck(ctx context.Context, addr, qname string, s config.Settings) (time.Duration, string, error) {
timeout := time.Duration(s.Resolver.TimeoutMS) * time.Millisecond
return resolver.Check(ctx, addr, qname, timeout)
}
// LookupResult is the outcome of the UI's built-in query tool.
type LookupResult struct {
Question string `json:"question"`
Rcode string `json:"rcode"`
Source string `json:"source"`
Answers []string `json:"answers"`
Authority []string `json:"authority"`
Duration time.Duration `json:"duration"`
}
// Lookup runs a query through the full server pipeline, exactly as a client on
// the given address would experience it.
func (a *App) Lookup(ctx context.Context, name, qtype, clientIP string, dnssec bool) (*LookupResult, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, Invalid("Enter a name to look up.")
}
fqdn := dns.Fqdn(name)
t, ok := dns.StringToType[strings.ToUpper(strings.TrimSpace(qtype))]
if !ok {
if qtype == "" {
t = dns.TypeA
} else {
return nil, Invalid("%q is not a known record type.", qtype)
}
}
client, ok := netipAddr(clientIP)
if !ok {
return nil, Invalid("%q is not a valid client IP address.", clientIP)
}
start := time.Now()
msg, source, err := a.DNS.Resolve(ctx, fqdn, t, client, dnssec)
if err != nil {
return nil, Internal(err, "The lookup could not be completed.")
}
res := &LookupResult{
Question: fmt.Sprintf("%s %s", fqdn, dns.TypeToString[t]),
Rcode: dns.RcodeToString[msg.Rcode],
Source: source,
Duration: time.Since(start),
}
for _, rr := range msg.Answer {
res.Answers = append(res.Answers, rr.String())
}
for _, rr := range msg.Ns {
res.Authority = append(res.Authority, rr.String())
}
return res, nil
}
+320
View File
@@ -0,0 +1,320 @@
package app
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/backup"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/dnsengine"
"github.com/owen/vibedns/internal/metrics"
"github.com/owen/vibedns/internal/querylog"
"github.com/owen/vibedns/internal/ratelimit"
"github.com/owen/vibedns/internal/resolver"
"github.com/owen/vibedns/internal/runtimecfg"
"github.com/owen/vibedns/internal/version"
)
// keyCSRFSecret stores the CSRF signing key so tokens survive a restart.
const keyCSRFSecret = "security.csrf_key"
// App wires every component together and exposes the operations the
// management interface performs.
type App struct {
Boot config.Bootstrap
DB *database.DB
Runtime *runtimecfg.Manager
Cache *cache.Cache
Resolver *resolver.Resolver
Limiter *ratelimit.Limiter
Metrics *metrics.Metrics
QueryLog *querylog.Logger
Audit *auditlog.Logger
Auth *auth.Authenticator
Backups *backup.Manager
DNS *dnsengine.Server
Log *slog.Logger
cancel context.CancelFunc
started time.Time
}
// New builds the application. The database must already be migrated.
func New(ctx context.Context, boot config.Bootstrap, db *database.DB, log *slog.Logger) (*App, error) {
rt, err := runtimecfg.New(ctx, db, log)
if err != nil {
return nil, fmt.Errorf("build the initial configuration snapshot: %w", err)
}
settings := rt.Settings()
csrfKey, err := loadOrCreateCSRFKey(ctx, db)
if err != nil {
return nil, err
}
a := &App{
Boot: boot,
DB: db,
Runtime: rt,
Log: log,
started: time.Now(),
}
a.Metrics = metrics.New(version.Version)
a.Cache = cache.New(cacheConfig(settings))
a.Resolver = resolver.New(resolverConfig(settings))
a.Limiter = ratelimit.New(rateLimitConfig(settings))
a.QueryLog = querylog.New(db, log, queryLogConfig(settings))
a.Audit = auditlog.New(db, log)
a.Auth = auth.New(db, log, csrfKey)
a.Auth.SetTrustedProxies(settings.HTTP.TrustedProxies)
a.Backups = backup.New(db, log, backupConfig(settings))
a.DNS = dnsengine.New(dnsengine.Options{
Runtime: rt,
Cache: a.Cache,
Resolver: a.Resolver,
Limiter: a.Limiter,
Metrics: a.Metrics,
QueryLog: a.QueryLog,
Log: log,
})
// Every subsystem picks up new settings from the same reload event, so a
// change in the UI takes effect without a restart.
rt.OnReload(func(s *runtimecfg.Snapshot) {
a.Cache.SetConfig(cacheConfig(s.Settings))
a.Resolver.SetConfig(resolverConfig(s.Settings))
a.Limiter.SetConfig(rateLimitConfig(s.Settings))
a.QueryLog.SetConfig(queryLogConfig(s.Settings))
a.Backups.SetConfig(backupConfig(s.Settings))
a.Auth.SetTrustedProxies(s.Settings.HTTP.TrustedProxies)
})
a.Metrics.SetGaugeSource(a.gauges)
return a, nil
}
func cacheConfig(s config.Settings) cache.Config {
return cache.Config{
Enabled: s.Cache.Enabled,
MaxEntries: s.Cache.MaxEntries,
MinTTL: uint32(s.Cache.MinTTL),
MaxTTL: uint32(s.Cache.MaxTTL),
NegativeTTL: uint32(s.Cache.NegativeTTL),
ServeStale: s.Cache.ServeStale,
StaleTTL: uint32(s.Cache.StaleTTL),
Prefetch: s.Cache.Prefetch,
PrefetchPercent: s.Cache.PrefetchPercent,
}
}
func resolverConfig(s config.Settings) resolver.Config {
return resolver.Config{
Upstreams: s.Resolver.Upstreams,
Timeout: time.Duration(s.Resolver.TimeoutMS) * time.Millisecond,
Retries: s.Resolver.Retries,
Strategy: s.Resolver.Strategy,
DNSSEC: s.Resolver.DNSSEC,
EDNSUDPSize: uint16(s.DNS.EDNSUDPSize),
MaxConcurrent: s.Resolver.MaxConcurrent,
}
}
func rateLimitConfig(s config.Settings) ratelimit.Config {
return ratelimit.Config{
Enabled: s.RateLimit.Enabled,
QPS: s.RateLimit.QPS,
Burst: s.RateLimit.Burst,
Exempt: s.RateLimit.ExemptNetworks,
}
}
func queryLogConfig(s config.Settings) querylog.Config {
return querylog.Config{
Enabled: s.QueryLog.Enabled,
RetentionDays: s.QueryLog.RetentionDays,
MaxRows: s.QueryLog.MaxRows,
CleanupMinutes: s.QueryLog.CleanupMinutes,
IgnoreNetworks: s.QueryLog.IgnoreNetworks,
IgnoreDomains: s.QueryLog.IgnoreDomains,
}
}
func backupConfig(s config.Settings) backup.Config {
return backup.Config{
Enabled: s.Backup.Enabled,
Directory: s.Backup.Directory,
IntervalHours: s.Backup.IntervalHours,
Retention: s.Backup.Retention,
}
}
// loadOrCreateCSRFKey fetches the persisted CSRF signing key, creating one on
// first run. Persisting it means tokens in open browser tabs survive a restart.
func loadOrCreateCSRFKey(ctx context.Context, db *database.DB) ([]byte, error) {
if v, ok, err := db.Setting(ctx, keyCSRFSecret); err != nil {
return nil, fmt.Errorf("read the CSRF signing key: %w", err)
} else if ok && v != "" {
key, err := base64.RawStdEncoding.DecodeString(v)
if err == nil && len(key) >= 32 {
return key, nil
}
}
encoded, err := auth.RandomKey(32)
if err != nil {
return nil, err
}
if err := db.SetSetting(ctx, keyCSRFSecret, encoded); err != nil {
return nil, fmt.Errorf("store the CSRF signing key: %w", err)
}
key, err := base64.RawStdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("decode the CSRF signing key: %w", err)
}
return key, nil
}
// Start launches every background worker and binds the DNS listeners.
func (a *App) Start(ctx context.Context) error {
ctx, a.cancel = context.WithCancel(ctx)
a.Runtime.Start(ctx)
a.QueryLog.Start(ctx)
a.Backups.Start(ctx)
done := ctx.Done()
go a.Cache.Run(done, func() time.Duration {
return time.Duration(a.Runtime.Settings().Cache.CleanupSeconds) * time.Second
})
go a.Limiter.Run(done, time.Minute, 10*time.Minute)
go a.pruneAuditLoop(ctx)
if err := a.DNS.Start(ctx); err != nil {
a.cancel()
return err
}
return nil
}
// Shutdown stops the DNS listeners and drains the background workers.
func (a *App) Shutdown(ctx context.Context) error {
err := a.DNS.Shutdown(ctx)
if a.cancel != nil {
a.cancel()
}
a.QueryLog.Stop()
a.Runtime.Stop()
a.Backups.Stop()
return err
}
// pruneAuditLoop keeps the audit log bounded.
func (a *App) pruneAuditLoop(ctx context.Context) {
t := time.NewTicker(6 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
max := a.Runtime.Settings().Logging.AuditMaxRows
if n, err := a.DB.PruneAuditLogs(ctx, max); err != nil {
a.Log.Warn("could not prune the audit log", "error", err)
} else if n > 0 {
a.Log.Debug("pruned audit log", "rows", n)
}
}
}
}
// Settings returns the active runtime settings.
func (a *App) Settings() config.Settings { return a.Runtime.Settings() }
// Snapshot returns the active configuration snapshot.
func (a *App) Snapshot() *runtimecfg.Snapshot { return a.Runtime.Current() }
// StartedAt returns when the application started.
func (a *App) StartedAt() time.Time { return a.started }
// Uptime returns how long the application has been running.
func (a *App) Uptime() time.Duration { return time.Since(a.started) }
// Reload rebuilds the configuration snapshot immediately.
func (a *App) Reload(ctx context.Context) error {
if err := a.Runtime.Reload(ctx); err != nil {
return Internal(err, "The configuration could not be reloaded.")
}
return nil
}
// gauges samples live values for the metrics endpoint.
func (a *App) gauges() metrics.Gauges {
snap := a.Runtime.Current()
cs := a.Cache.Stats()
rs := a.Resolver.Stats()
g := metrics.Gauges{
CacheEntries: int64(cs.Entries),
CacheBytes: cs.Bytes,
Zones: int64(snap.ZoneCount),
Records: int64(snap.RecordCount),
BlacklistDomains: int64(snap.BlacklistDomains),
AllowlistDomains: int64(snap.AllowlistDomains),
Networks: int64(snap.NetworkCount),
UpstreamsTotal: int64(rs.Upstreams),
UpstreamsHealthy: int64(rs.Healthy),
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if n, err := a.DB.QueryLogCount(ctx); err == nil {
g.QueryLogRows = n
}
return g
}
// Ready reports whether the server is able to answer queries. It backs /readyz.
func (a *App) Ready(ctx context.Context) error {
if !a.DNS.Running() {
return errors.New("DNS listeners are not running")
}
if err := a.DB.PingContext(ctx); err != nil {
return fmt.Errorf("database is unreachable: %w", err)
}
if a.Runtime.Current() == nil {
return errors.New("configuration has not been loaded")
}
return nil
}
// translate converts a storage error into a user-facing one.
func translate(err error, notFound, conflict string) error {
switch {
case err == nil:
return nil
case errors.Is(err, database.ErrNotFound):
return NotFound("%s", notFound)
case errors.Is(err, database.ErrConflict):
return Conflict("%s", conflict)
default:
return Internal(err, "The change could not be saved.")
}
}
// Hostname returns the machine name, shown on the dashboard.
func Hostname() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "unknown"
}
return h
}
+96
View File
@@ -0,0 +1,96 @@
package app
import (
"context"
"fmt"
"os"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/backup"
)
// BackupStatus returns the backup configuration and inventory.
func (a *App) BackupStatus() backup.Status { return a.Backups.Status() }
// BackupList lists the available backup files, newest first.
func (a *App) BackupList() ([]backup.Info, error) {
list, err := a.Backups.List()
if err != nil {
return nil, Internal(err, "The backup directory could not be read.")
}
return list, nil
}
// RunBackup creates a backup immediately.
func (a *App) RunBackup(ctx context.Context, actor auditlog.Actor) (backup.Info, error) {
info, err := a.Backups.Run(ctx)
if err != nil {
return info, Invalid("Database backup failed: %s", err.Error())
}
a.Audit.Record(ctx, actor, "backup.create", auditlog.ObjectBackup, info.Name, info.Name,
auditlog.Changes("bytes", fmt.Sprint(info.SizeBytes)))
return info, nil
}
// OpenBackup opens a backup file for download.
func (a *App) OpenBackup(name string) (*os.File, backup.Info, error) {
path, err := backup.Resolve(a.Backups.Directory(), name)
if err != nil {
return nil, backup.Info{}, NotFound("%s", err.Error())
}
f, err := os.Open(path)
if err != nil {
return nil, backup.Info{}, Internal(err, "The backup could not be opened.")
}
fi, err := f.Stat()
if err != nil {
f.Close()
return nil, backup.Info{}, Internal(err, "The backup could not be read.")
}
return f, backup.Info{Name: name, Path: path, SizeBytes: fi.Size(), CreatedAt: fi.ModTime()}, nil
}
// DeleteBackup removes a backup file.
func (a *App) DeleteBackup(ctx context.Context, actor auditlog.Actor, name string) error {
if err := backup.Delete(a.Backups.Directory(), name); err != nil {
return NotFound("%s", err.Error())
}
a.Audit.Record(ctx, actor, "backup.delete", auditlog.ObjectBackup, name, name, "")
return nil
}
// StageRestore validates a backup and schedules it to replace the live
// database on the next start.
//
// The swap is deliberately deferred: overwriting the database file while
// connections are open would leave the running process reading a file that no
// longer exists. The operator restarts, and the restore is applied cleanly
// before anything opens the database.
func (a *App) StageRestore(ctx context.Context, actor auditlog.Actor, name string) error {
path, err := backup.Resolve(a.Backups.Directory(), name)
if err != nil {
return NotFound("%s", err.Error())
}
if err := backup.StageRestore(a.DB.Path(), path); err != nil {
return Invalid("%s", err.Error())
}
a.Audit.Record(ctx, actor, "backup.restore_staged", auditlog.ObjectBackup, name, name,
"applies on the next restart")
a.Log.Warn("database restore staged; it will be applied on the next start", "backup", name)
return nil
}
// PendingRestore reports whether a restore is waiting for a restart.
func (a *App) PendingRestore() bool {
_, pending := backup.PendingRestore(a.DB.Path())
return pending
}
// CancelRestore discards a staged restore.
func (a *App) CancelRestore(ctx context.Context, actor auditlog.Actor) error {
if err := backup.CancelRestore(a.DB.Path()); err != nil {
return Internal(err, "The staged restore could not be cancelled.")
}
a.Audit.Record(ctx, actor, "backup.restore_cancelled", auditlog.ObjectBackup, "", "", "")
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package app
import (
"context"
"fmt"
"strings"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/cache"
)
// CacheStats returns the cache counters for the dashboard and cache page.
func (a *App) CacheStats() cache.Stats { return a.Cache.Stats() }
// CacheEntries browses the cache.
func (a *App) CacheEntries(search string, limit, offset int) ([]cache.EntryView, int) {
return a.Cache.Entries(search, limit, offset)
}
// FlushCache empties the resolver cache.
func (a *App) FlushCache(ctx context.Context, actor auditlog.Actor) (int, error) {
n := a.Cache.Flush()
a.Audit.Record(ctx, actor, "cache.flush", auditlog.ObjectCache, "", "resolver cache",
auditlog.Changes("entries", fmt.Sprint(n)))
return n, nil
}
// FlushCacheName removes every cached entry for one name.
func (a *App) FlushCacheName(ctx context.Context, actor auditlog.Actor, name string) (int, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, Invalid("Enter a name to remove from the cache.")
}
n := a.Cache.FlushName(name)
if n == 0 {
return 0, NotFound("%s is not in the cache.", strings.TrimSuffix(dns.Fqdn(name), "."))
}
a.Audit.Record(ctx, actor, "cache.flush_name", auditlog.ObjectCache, "", name,
auditlog.Changes("entries", fmt.Sprint(n)))
return n, nil
}
// DeleteCacheEntry removes one specific cached response.
func (a *App) DeleteCacheEntry(ctx context.Context, actor auditlog.Actor, name, qtype string, do bool) error {
name = strings.ToLower(dns.Fqdn(strings.TrimSpace(name)))
t, ok := dns.StringToType[strings.ToUpper(strings.TrimSpace(qtype))]
if !ok {
return Invalid("%q is not a known record type.", qtype)
}
key := cache.Key{Name: name, Type: t, Class: dns.ClassINET, DO: do}
if !a.Cache.Delete(key) {
return NotFound("%s %s is not in the cache.", strings.TrimSuffix(name, "."), strings.ToUpper(qtype))
}
a.Audit.Record(ctx, actor, "cache.delete_entry", auditlog.ObjectCache, "", key.String(), "")
return nil
}
// ResetStats zeroes the runtime counters.
func (a *App) ResetStats(ctx context.Context, actor auditlog.Actor) {
a.Metrics.Reset()
a.Cache.ResetStats()
a.Audit.Record(ctx, actor, "stats.reset", auditlog.ObjectCache, "", "statistics", "")
}
+337
View File
@@ -0,0 +1,337 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/version"
)
// ConfigExportVersion is the schema version of the export format. It is
// checked on import so a future format change fails loudly rather than being
// half-applied.
const ConfigExportVersion = 1
// ConfigExport is a portable snapshot of the configuration.
//
// It deliberately excludes the administrator password hash and every API token
// hash: an export is meant to be copied between machines and checked into a
// configuration repository, so it must not carry credentials.
type ConfigExport struct {
FormatVersion int `json:"format_version"`
ExportedAt time.Time `json:"exported_at"`
AppVersion string `json:"app_version"`
Settings map[string]string `json:"settings"`
Zones []ExportedZone `json:"zones"`
Networks []ExportedNetwork `json:"networks"`
Policies []ExportedPolicy `json:"policies"`
Lists []ExportedList `json:"lists"`
}
// ExportedZone is a zone with its records.
type ExportedZone struct {
models.Zone
Records []models.Record `json:"records"`
}
// ExportedNetwork is a network with the names of its policies.
type ExportedNetwork struct {
models.Network
PolicyNames []string `json:"policy_names"`
}
// ExportedPolicy is a policy with the names of its lists.
type ExportedPolicy struct {
models.Policy
ListNames []string `json:"list_names"`
}
// ExportedList is a domain list with its domains.
type ExportedList struct {
models.DomainList
Domains []ExportedDomain `json:"domains"`
}
// ExportedDomain is one entry in a domain list.
type ExportedDomain struct {
Domain string `json:"domain"`
MatchSubdomains bool `json:"match_subdomains"`
Comment string `json:"comment,omitempty"`
}
// ExportConfig builds a configuration export.
//
// includeDomains controls whether imported blocklists are included. A single
// blocklist can hold hundreds of thousands of domains that are reproducible
// from their source URL, so the default export omits them.
func (a *App) ExportConfig(ctx context.Context, includeDomains bool) (*ConfigExport, error) {
stored, err := a.DB.Settings(ctx)
if err != nil {
return nil, Internal(err, "The settings could not be exported.")
}
// The CSRF signing key is a secret and is regenerated per installation.
delete(stored, keyCSRFSecret)
out := &ConfigExport{
FormatVersion: ConfigExportVersion,
ExportedAt: time.Now().UTC(),
AppVersion: version.Version,
Settings: stored,
}
zones, err := a.DB.Zones(ctx, database.ZoneFilter{})
if err != nil {
return nil, Internal(err, "The zones could not be exported.")
}
for _, z := range zones {
recs, err := a.DB.ZoneRecordsRaw(ctx, z.ID)
if err != nil {
return nil, Internal(err, "The zone records could not be exported.")
}
out.Zones = append(out.Zones, ExportedZone{Zone: z, Records: recs})
}
nets, err := a.DB.Networks(ctx, "", true)
if err != nil {
return nil, Internal(err, "The networks could not be exported.")
}
for _, n := range nets {
e := ExportedNetwork{Network: n}
for _, p := range n.Policies {
e.PolicyNames = append(e.PolicyNames, p.Name)
}
e.Network.Policies = nil // names carry the relationship instead of IDs
out.Networks = append(out.Networks, e)
}
policies, err := a.DB.Policies(ctx)
if err != nil {
return nil, Internal(err, "The policies could not be exported.")
}
for _, p := range policies {
e := ExportedPolicy{Policy: p}
e.ListNames = append(append([]string{}, p.BlacklistName...), p.AllowlistName...)
e.Policy.BlacklistIDs, e.Policy.AllowlistIDs = nil, nil
out.Policies = append(out.Policies, e)
}
lists, err := a.DB.DomainLists(ctx, "", "")
if err != nil {
return nil, Internal(err, "The lists could not be exported.")
}
for _, l := range lists {
e := ExportedList{DomainList: l}
if includeDomains {
err := a.DB.ExportDomains(ctx, l.ID, func(domain string, sub bool) {
e.Domains = append(e.Domains, ExportedDomain{Domain: domain, MatchSubdomains: sub})
})
if err != nil {
return nil, Internal(err, "The list domains could not be exported.")
}
}
out.Lists = append(out.Lists, e)
}
return out, nil
}
// WriteConfigExport writes an export as indented JSON.
func (a *App) WriteConfigExport(ctx context.Context, w io.Writer, includeDomains bool) error {
export, err := a.ExportConfig(ctx, includeDomains)
if err != nil {
return err
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(export); err != nil {
return Internal(err, "The export could not be written.")
}
return nil
}
// ImportReport summarises what a configuration import created.
type ImportReport struct {
Zones int `json:"zones"`
Records int `json:"records"`
Networks int `json:"networks"`
Policies int `json:"policies"`
Lists int `json:"lists"`
Domains int `json:"domains"`
Settings int `json:"settings"`
Skipped int `json:"skipped"`
Conflicts []string `json:"conflicts,omitempty"`
}
// ImportConfig applies a configuration export.
//
// Objects that already exist are skipped rather than overwritten, and reported
// in Conflicts, so an import can never silently destroy configuration that is
// already in production.
func (a *App) ImportConfig(ctx context.Context, actor auditlog.Actor, r io.Reader, applySettings bool) (*ImportReport, error) {
var in ConfigExport
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return nil, Invalid("The import file could not be read as a VibeDNS configuration export: %v", err)
}
if in.FormatVersion != ConfigExportVersion {
return nil, Invalid("This export uses format version %d, but this server understands version %d.",
in.FormatVersion, ConfigExportVersion)
}
rep := &ImportReport{}
// Lists first: policies reference them by name.
listIDs := map[string]int64{}
for _, l := range in.Lists {
existing, err := a.DB.DomainLists(ctx, l.Kind, l.Name)
if err != nil {
return nil, Internal(err, "Existing lists could not be checked.")
}
var id int64
found := false
for _, e := range existing {
if e.Name == l.Name && e.Kind == l.Kind {
id, found = e.ID, true
break
}
}
if !found {
created, err := a.DB.CreateDomainList(ctx, l.DomainList)
if err != nil {
rep.Skipped++
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("list %q could not be created", l.Name))
continue
}
id = created.ID
rep.Lists++
} else {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("list %q already exists and was left unchanged", l.Name))
}
listIDs[l.Kind+"/"+l.Name] = id
if len(l.Domains) > 0 {
rows := make([]database.ImportDomain, 0, len(l.Domains))
for _, d := range l.Domains {
rows = append(rows, database.ImportDomain{
Domain: d.Domain, MatchSubdomains: d.MatchSubdomains, Comment: d.Comment,
})
}
imported, _, err := a.DB.ImportDomains(ctx, id, rows)
if err != nil {
return nil, Internal(err, "The list domains could not be imported.")
}
rep.Domains += imported
}
}
// Policies next: networks reference them by name.
policyIDs := map[string]int64{}
existingPolicies, err := a.DB.Policies(ctx)
if err != nil {
return nil, Internal(err, "Existing policies could not be checked.")
}
for _, p := range existingPolicies {
policyIDs[p.Name] = p.ID
}
for _, p := range in.Policies {
if _, exists := policyIDs[p.Name]; exists {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("policy %q already exists and was left unchanged", p.Name))
continue
}
var ids []int64
for _, name := range p.ListNames {
if id, ok := listIDs[models.KindBlacklist+"/"+name]; ok {
ids = append(ids, id)
} else if id, ok := listIDs[models.KindAllowlist+"/"+name]; ok {
ids = append(ids, id)
}
}
created, err := a.DB.CreatePolicy(ctx, p.Policy, ids)
if err != nil {
rep.Skipped++
continue
}
policyIDs[p.Name] = created.ID
rep.Policies++
}
// Networks.
existingNets, err := a.DB.Networks(ctx, "", false)
if err != nil {
return nil, Internal(err, "Existing networks could not be checked.")
}
netNames := map[string]bool{}
for _, n := range existingNets {
netNames[n.Name] = true
}
for _, n := range in.Networks {
if netNames[n.Name] {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("network %q already exists and was left unchanged", n.Name))
continue
}
var ids []int64
for _, name := range n.PolicyNames {
if id, ok := policyIDs[name]; ok {
ids = append(ids, id)
}
}
if _, err := a.DB.CreateNetwork(ctx, n.Network, ids); err != nil {
rep.Skipped++
continue
}
rep.Networks++
}
// Zones and their records.
for _, z := range in.Zones {
if _, err := a.DB.ZoneByName(ctx, z.Name); err == nil {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("zone %s already exists and was left unchanged", z.Name))
continue
}
zone := z.Zone
zone.ID = 0
created, err := a.DB.CreateZone(ctx, zone)
if err != nil {
rep.Skipped++
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("zone %s could not be created", z.Name))
continue
}
rep.Zones++
if len(z.Records) > 0 {
if err := a.DB.AppendZoneRecords(ctx, created.ID, z.Records); err != nil {
return nil, Internal(err, "The zone records could not be imported.")
}
rep.Records += len(z.Records)
}
}
if applySettings && len(in.Settings) > 0 {
settings := config.LoadSettings(in.Settings)
if err := settings.Validate(); err != nil {
rep.Conflicts = append(rep.Conflicts,
fmt.Sprintf("settings were not applied because they are invalid: %v", err))
} else {
delete(in.Settings, keyCSRFSecret)
if err := a.DB.SetSettings(ctx, in.Settings); err != nil {
return nil, Internal(err, "The settings could not be imported.")
}
rep.Settings = len(in.Settings)
}
}
a.Audit.Record(ctx, actor, "config.import", auditlog.ObjectConfig, "", "configuration import",
auditlog.Changes(
"zones", fmt.Sprint(rep.Zones), "records", fmt.Sprint(rep.Records),
"networks", fmt.Sprint(rep.Networks), "policies", fmt.Sprint(rep.Policies),
"lists", fmt.Sprint(rep.Lists), "domains", fmt.Sprint(rep.Domains)))
a.Runtime.RequestReload()
return rep, nil
}
+79
View File
@@ -0,0 +1,79 @@
// Package app is the service layer. It holds every business operation the
// management interface offers, so the HTML handlers and the REST API share one
// implementation of validation, auditing and cache invalidation rather than
// each growing their own.
package app
import (
"errors"
"fmt"
"net/http"
)
// Error is a user-facing failure with an HTTP status attached.
//
// Messages are written for an administrator reading them in a toast or a JSON
// response: they say what went wrong and, where useful, what to do about it.
// Internal detail goes in the wrapped error, which is logged but never shown.
type Error struct {
Status int
Message string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *Error) Unwrap() error { return e.Err }
// Invalid reports a client mistake such as a malformed record.
func Invalid(format string, args ...any) *Error {
return &Error{Status: http.StatusBadRequest, Message: fmt.Sprintf(format, args...)}
}
// NotFound reports a missing object.
func NotFound(format string, args ...any) *Error {
return &Error{Status: http.StatusNotFound, Message: fmt.Sprintf(format, args...)}
}
// Conflict reports a uniqueness violation.
func Conflict(format string, args ...any) *Error {
return &Error{Status: http.StatusConflict, Message: fmt.Sprintf(format, args...)}
}
// Forbidden reports an operation the caller may not perform.
func Forbidden(format string, args ...any) *Error {
return &Error{Status: http.StatusForbidden, Message: fmt.Sprintf(format, args...)}
}
// Internal wraps an unexpected failure. The message is safe to show; err is
// logged server-side.
func Internal(err error, format string, args ...any) *Error {
return &Error{Status: http.StatusInternalServerError, Message: fmt.Sprintf(format, args...), Err: err}
}
// StatusOf maps any error to an HTTP status code.
func StatusOf(err error) int {
var e *Error
if errors.As(err, &e) {
return e.Status
}
return http.StatusInternalServerError
}
// MessageOf returns the user-facing message for an error, falling back to a
// generic sentence so internal detail never leaks into a response.
func MessageOf(err error) string {
var e *Error
if errors.As(err, &e) {
return e.Message
}
return "An unexpected error occurred. Check the server log for details."
}
// IsInternal reports whether an error should be logged with its full detail.
func IsInternal(err error) bool { return StatusOf(err) >= 500 }
+442
View File
@@ -0,0 +1,442 @@
package app_test
import (
"context"
"io"
"log/slog"
"net/netip"
"path/filepath"
"strings"
"testing"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
)
// newTestApp builds a fully wired application against a temporary database,
// without binding any listener.
func newTestApp(t *testing.T) *app.App {
t.Helper()
ctx := context.Background()
path := filepath.Join(t.TempDir(), "test.db")
db, err := database.Open(path)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
boot := config.DefaultBootstrap()
boot.DBPath = path
a, err := app.New(ctx, boot, db, log)
if err != nil {
t.Fatalf("build app: %v", err)
}
return a
}
func testActor() auditlog.Actor {
return auditlog.Actor{Name: "test", Source: auditlog.SourceCLI, ClientIP: "127.0.0.1"}
}
// TestZoneLifecycleAndResolution walks the path an operator actually takes:
// create a zone, add records, and confirm the DNS engine serves them.
func TestZoneLifecycleAndResolution(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{
Name: "example.com",
Description: "integration test zone",
AdminEmail: "hostmaster@example.com",
})
if err != nil {
t.Fatalf("create zone: %v", err)
}
if zone.Name != "example.com." {
t.Errorf("zone name = %q, want the normalised form", zone.Name)
}
records := []app.RecordInput{
{Name: "@", Type: "A", Data: "192.0.2.10"},
{Name: "www", Type: "CNAME", Data: "example.com."},
{Name: "mail", Type: "A", Data: "192.0.2.20"},
{Name: "@", Type: "MX", Data: "10 mail.example.com."},
{Name: "txt", Type: "TXT", Fields: map[string]string{"text": "hello world"}},
}
for _, in := range records {
if _, err := a.CreateRecord(ctx, testActor(), zone.ID, in); err != nil {
t.Fatalf("create record %s %s: %v", in.Name, in.Type, err)
}
}
// The snapshot is rebuilt on demand rather than waiting for the debounce.
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
snap := a.Snapshot()
if snap.ZoneCount != 1 {
t.Fatalf("indexed zones = %d, want 1", snap.ZoneCount)
}
if len(snap.Problems) != 0 {
t.Errorf("build problems: %v", snap.Problems)
}
client := netip.MustParseAddr("127.0.0.1")
tests := []struct {
name string
qname string
qtype uint16
rcode int
wantIn string
answers int
}{
{"apex A", "example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.10", 1},
{"host A", "mail.example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.20", 1},
{"CNAME is followed", "www.example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.10", 2},
{"MX", "example.com.", dns.TypeMX, dns.RcodeSuccess, "mail.example.com.", 1},
{"TXT", "txt.example.com.", dns.TypeTXT, dns.RcodeSuccess, "hello world", 1},
{"NODATA", "mail.example.com.", dns.TypeTXT, dns.RcodeSuccess, "", 0},
{"NXDOMAIN", "missing.example.com.", dns.TypeA, dns.RcodeNameError, "", 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
msg, source, err := a.DNS.Resolve(ctx, tc.qname, tc.qtype, client, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != tc.rcode {
t.Errorf("rcode = %s, want %s", dns.RcodeToString[msg.Rcode], dns.RcodeToString[tc.rcode])
}
if len(msg.Answer) != tc.answers {
t.Errorf("answers = %d, want %d: %v", len(msg.Answer), tc.answers, msg.Answer)
}
if source != models.SourceAuthoritative {
t.Errorf("source = %q, want authoritative", source)
}
if tc.wantIn != "" {
var found bool
for _, rr := range msg.Answer {
if strings.Contains(rr.String(), tc.wantIn) {
found = true
}
}
if !found {
t.Errorf("no answer contains %q: %v", tc.wantIn, msg.Answer)
}
}
})
}
}
func TestCNAMEConflictIsRejected(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "example.com"})
if err != nil {
t.Fatalf("create zone: %v", err)
}
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "www", Type: "A", Data: "192.0.2.1"}); err != nil {
t.Fatalf("create A: %v", err)
}
// A CNAME cannot coexist with the A record already at that name.
_, err = a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "www", Type: "CNAME", Data: "other.example.com."})
if err == nil {
t.Fatal("expected the conflicting CNAME to be rejected")
}
if app.StatusOf(err) != 400 {
t.Errorf("status = %d, want 400", app.StatusOf(err))
}
// And a CNAME at the apex is always wrong.
_, err = a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "@", Type: "CNAME", Data: "other.example.com."})
if err == nil {
t.Error("expected an apex CNAME to be rejected")
}
}
func TestReverseZoneCreationFromCIDR(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{
CIDR: "192.168.1.0/24",
Kind: "reverse4",
})
if err != nil {
t.Fatalf("create reverse zone: %v", err)
}
if zone.Name != "1.168.192.in-addr.arpa." {
t.Errorf("zone name = %q, want 1.168.192.in-addr.arpa.", zone.Name)
}
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "10", Type: "PTR", Data: "host.example.com."}); err != nil {
t.Fatalf("create PTR: %v", err)
}
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
msg, _, err := a.DNS.Resolve(ctx, "10.1.168.192.in-addr.arpa.", dns.TypePTR,
netip.MustParseAddr("127.0.0.1"), false)
if err != nil {
t.Fatalf("resolve PTR: %v", err)
}
if len(msg.Answer) != 1 {
t.Fatalf("PTR answers = %d, want 1", len(msg.Answer))
}
if ptr, ok := msg.Answer[0].(*dns.PTR); !ok || ptr.Ptr != "host.example.com." {
t.Errorf("PTR answer = %v", msg.Answer[0])
}
}
// TestPolicyBlocking exercises the filtering path end to end: import a
// blocklist, attach it to a policy and a network, and confirm the DNS engine
// blocks a matching query from a client in that network.
func TestPolicyBlocking(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
list, err := a.CreateDomainList(ctx, testActor(), app.ListInput{
Kind: models.KindBlacklist, Name: "Test Blocks",
})
if err != nil {
t.Fatalf("create list: %v", err)
}
summary, err := a.ImportDomains(ctx, testActor(), list.ID,
strings.NewReader("0.0.0.0 ads.example\ntracker.example.net\n# a comment\n"), true)
if err != nil {
t.Fatalf("import: %v", err)
}
if summary.Imported != 2 {
t.Fatalf("imported = %d, want 2", summary.Imported)
}
policy, err := a.CreatePolicy(ctx, testActor(), app.PolicyInput{
Name: "Test Policy", BlockAction: "nxdomain", ListIDs: []int64{list.ID},
})
if err != nil {
t.Fatalf("create policy: %v", err)
}
if _, err := a.CreateNetwork(ctx, testActor(), app.NetworkInput{
Name: "Test Net", CIDR: "100.64.30.0/24", PolicyIDs: []int64{policy.ID},
}); err != nil {
t.Fatalf("create network: %v", err)
}
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
inNetwork := netip.MustParseAddr("100.64.30.5")
outside := netip.MustParseAddr("192.0.2.1")
// A blocked name from inside the network.
msg, source, err := a.DNS.Resolve(ctx, "ads.example.", dns.TypeA, inNetwork, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != dns.RcodeNameError {
t.Errorf("rcode = %s, want NXDOMAIN", dns.RcodeToString[msg.Rcode])
}
if source != models.SourceBlocked {
t.Errorf("source = %q, want blocked", source)
}
// A subdomain of a blocked name is covered without being stored.
msg, _, _ = a.DNS.Resolve(ctx, "cdn.ads.example.", dns.TypeA, inNetwork, false)
if msg.Rcode != dns.RcodeNameError {
t.Errorf("subdomain rcode = %s, want NXDOMAIN", dns.RcodeToString[msg.Rcode])
}
// The same name from a client outside the network is not blocked. With no
// upstream reachable in a test it will fail to resolve, but it must not be
// blocked, and it must not be refused for a private client.
_, source, _ = a.DNS.Resolve(ctx, "ads.example.", dns.TypeA, outside, false)
if source == models.SourceBlocked {
t.Error("a client outside the configured network was filtered")
}
}
// TestRecursionIsRefusedByDefaultForPublicClients is the open-resolver guard.
func TestRecursionIsRefusedForClientsOutsideTheACL(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
// A public address is not in the default private-network ACL.
public := netip.MustParseAddr("203.0.113.50")
msg, source, err := a.DNS.Resolve(ctx, "example.org.", dns.TypeA, public, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED for a client outside the recursion ACL",
dns.RcodeToString[msg.Rcode])
}
if source != models.SourceRefused {
t.Errorf("source = %q, want refused", source)
}
}
// TestAuthoritativeAnswersSurviveRecursionDenial: a client that may not
// recurse must still get answers for zones we are authoritative for.
func TestAuthoritativeAnswersWorkWithoutRecursionRights(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "internal.example"})
if err != nil {
t.Fatalf("create zone: %v", err)
}
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "@", Type: "A", Data: "192.0.2.1"}); err != nil {
t.Fatalf("create record: %v", err)
}
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
public := netip.MustParseAddr("203.0.113.50")
msg, source, err := a.DNS.Resolve(ctx, "internal.example.", dns.TypeA, public, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != dns.RcodeSuccess {
t.Errorf("rcode = %s, want NOERROR: an authoritative zone must answer "+
"even when the client may not recurse", dns.RcodeToString[msg.Rcode])
}
if source != models.SourceAuthoritative {
t.Errorf("source = %q, want authoritative", source)
}
}
func TestSettingsValidationRejectsOpenResolver(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
next := a.Settings()
next.Resolver.AllowNetworks = nil // would deny everyone
if err := a.SaveSettings(ctx, testActor(), app.GroupResolver, next); err == nil {
t.Error("an empty recursion ACL with recursion enabled should be rejected")
}
next = a.Settings()
next.Resolver.Upstreams = nil
if err := a.SaveSettings(ctx, testActor(), app.GroupResolver, next); err == nil {
t.Error("enabling recursion with no upstreams should be rejected")
}
}
func TestConfigExportOmitsSecrets(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
if _, err := a.CreateAPIToken(ctx, testActor(), "test-token", ""); err != nil {
t.Fatalf("create token: %v", err)
}
var buf strings.Builder
if err := a.WriteConfigExport(ctx, &buf, false); err != nil {
t.Fatalf("export: %v", err)
}
out := buf.String()
for _, forbidden := range []string{"argon2id", "password_hash", "token_hash", "csrf_key", "vibedns_"} {
if strings.Contains(out, forbidden) {
t.Errorf("the configuration export contains %q, which must never leave the server", forbidden)
}
}
}
func TestAuditLogRecordsChanges(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
if _, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "audited.example"}); err != nil {
t.Fatalf("create zone: %v", err)
}
entries, total, err := a.AuditLogs(ctx, database.AuditFilter{Limit: 10})
if err != nil {
t.Fatalf("read audit log: %v", err)
}
if total == 0 {
t.Fatal("no audit entry was recorded for a zone creation")
}
var found bool
for _, e := range entries {
if e.Action == "zone.create" && e.ObjectName == "audited.example." {
found = true
}
}
if !found {
t.Errorf("no zone.create entry found in %v", entries)
}
}
func TestBulkRecordOperations(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "bulk.example"})
if err != nil {
t.Fatalf("create zone: %v", err)
}
var ids []int64
for _, name := range []string{"a", "b", "c"} {
rec, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: name, Type: "A", Data: "192.0.2.1"})
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
ids = append(ids, rec.ID)
}
n, err := a.BulkRecords(ctx, testActor(), zone.ID, ids, app.BulkDisable)
if err != nil {
t.Fatalf("bulk disable: %v", err)
}
if n != 3 {
t.Errorf("disabled %d, want 3", n)
}
// Disabled records must not be served.
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
msg, _, _ := a.DNS.Resolve(ctx, "a.bulk.example.", dns.TypeA, netip.MustParseAddr("127.0.0.1"), false)
if len(msg.Answer) != 0 {
t.Errorf("a disabled record was still served: %v", msg.Answer)
}
n, err = a.BulkRecords(ctx, testActor(), zone.ID, ids, app.BulkDelete)
if err != nil {
t.Fatalf("bulk delete: %v", err)
}
if n != 3 {
t.Errorf("deleted %d, want 3", n)
}
}
+647
View File
@@ -0,0 +1,647 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// --- Client networks ----------------------------------------------------
// NetworkInput is the editable surface of a client network.
type NetworkInput struct {
Name string `json:"name"`
CIDR string `json:"cidr"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
PolicyIDs []int64 `json:"policy_ids"`
}
// Networks lists client networks with their policy assignments.
func (a *App) Networks(ctx context.Context, search string) ([]models.Network, error) {
nets, err := a.DB.Networks(ctx, search, true)
if err != nil {
return nil, Internal(err, "The network list could not be loaded.")
}
return nets, nil
}
// Network loads one client network.
func (a *App) Network(ctx context.Context, id int64) (models.Network, error) {
n, err := a.DB.Network(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return n, NotFound("Network %d was not found.", id)
}
if err != nil {
return n, Internal(err, "The network could not be loaded.")
}
return n, nil
}
func (a *App) normaliseNetwork(in NetworkInput, base models.Network) (models.Network, error) {
n := base
if name := strings.TrimSpace(in.Name); name != "" {
n.Name = name
}
if n.Name == "" {
return n, Invalid("A network name is required.")
}
if cidr := strings.TrimSpace(in.CIDR); cidr != "" {
p, err := config.ParseCIDROrIP(cidr)
if err != nil {
return n, Invalid("Subnet %q: %s", cidr, err.Error())
}
n.CIDR = p.String()
}
if n.CIDR == "" {
return n, Invalid("A subnet in CIDR notation is required, for example 192.168.1.0/24.")
}
n.Description = strings.TrimSpace(in.Description)
if in.Enabled != nil {
n.Enabled = *in.Enabled
} else if base.ID == 0 {
n.Enabled = true
}
return n, nil
}
// CreateNetwork stores a client network.
func (a *App) CreateNetwork(ctx context.Context, actor auditlog.Actor, in NetworkInput) (models.Network, error) {
n, err := a.normaliseNetwork(in, models.Network{})
if err != nil {
return models.Network{}, err
}
created, err := a.DB.CreateNetwork(ctx, n, in.PolicyIDs)
if err != nil {
return models.Network{}, translate(err, "Network not found.",
fmt.Sprintf("A network named %q already exists.", n.Name))
}
a.Audit.RecordID(ctx, actor, "network.create", auditlog.ObjectNetwork, created.ID, created.Name,
auditlog.Changes("cidr", created.CIDR, "policies", fmt.Sprint(len(in.PolicyIDs))))
a.Runtime.RequestReload()
return created, nil
}
// UpdateNetwork saves a client network and its policy assignments.
func (a *App) UpdateNetwork(ctx context.Context, actor auditlog.Actor, id int64, in NetworkInput) (models.Network, error) {
existing, err := a.Network(ctx, id)
if err != nil {
return models.Network{}, err
}
n, err := a.normaliseNetwork(in, existing)
if err != nil {
return models.Network{}, err
}
n.ID = id
updated, err := a.DB.UpdateNetwork(ctx, n, in.PolicyIDs)
if err != nil {
return models.Network{}, translate(err, fmt.Sprintf("Network %d was not found.", id),
fmt.Sprintf("A network named %q already exists.", n.Name))
}
a.Audit.RecordID(ctx, actor, "network.update", auditlog.ObjectNetwork, id, updated.Name,
auditlog.Changes("cidr", updated.CIDR, "policies", fmt.Sprint(len(in.PolicyIDs))))
a.Runtime.RequestReload()
return updated, nil
}
// SetNetworkEnabled toggles a client network.
func (a *App) SetNetworkEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
n, err := a.Network(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetNetworkEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Network %d was not found.", id), "")
}
action := "network.disable"
if enabled {
action = "network.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectNetwork, id, n.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeleteNetwork removes a client network.
func (a *App) DeleteNetwork(ctx context.Context, actor auditlog.Actor, id int64) error {
n, err := a.Network(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteNetwork(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Network %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "network.delete", auditlog.ObjectNetwork, id, n.Name,
auditlog.Changes("cidr", n.CIDR))
a.Runtime.RequestReload()
return nil
}
// --- Policies -----------------------------------------------------------
// PolicyInput is the editable surface of a policy.
type PolicyInput struct {
Name string `json:"name"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
BlockAction string `json:"block_action"`
SinkholeIPv4 string `json:"sinkhole_ipv4"`
SinkholeIPv6 string `json:"sinkhole_ipv6"`
BlockTTL uint32 `json:"block_ttl"`
ListIDs []int64 `json:"list_ids"`
}
// Policies lists every policy.
func (a *App) Policies(ctx context.Context) ([]models.Policy, error) {
p, err := a.DB.Policies(ctx)
if err != nil {
return nil, Internal(err, "The policy list could not be loaded.")
}
return p, nil
}
// Policy loads one policy.
func (a *App) Policy(ctx context.Context, id int64) (models.Policy, error) {
p, err := a.DB.Policy(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return p, NotFound("Policy %d was not found.", id)
}
if err != nil {
return p, Internal(err, "The policy could not be loaded.")
}
return p, nil
}
func (a *App) normalisePolicy(in PolicyInput, base models.Policy) (models.Policy, error) {
p := base
if name := strings.TrimSpace(in.Name); name != "" {
p.Name = name
}
if p.Name == "" {
return p, Invalid("A policy name is required.")
}
p.Description = strings.TrimSpace(in.Description)
action := models.BlockAction(strings.ToLower(strings.TrimSpace(in.BlockAction)))
if action == "" {
action = base.BlockAction
}
if action == "" {
action = models.BlockNXDOMAIN
}
if !action.Valid() {
return p, Invalid("Block action %q must be nxdomain, refused or sinkhole.", in.BlockAction)
}
p.BlockAction = action
p.SinkholeIPv4 = strings.TrimSpace(in.SinkholeIPv4)
if p.SinkholeIPv4 == "" {
p.SinkholeIPv4 = "0.0.0.0"
}
p.SinkholeIPv6 = strings.TrimSpace(in.SinkholeIPv6)
if p.SinkholeIPv6 == "" {
p.SinkholeIPv6 = "::"
}
if action == models.BlockSinkhole {
if err := requireIP(p.SinkholeIPv4, true); err != nil {
return p, Invalid("Sinkhole IPv4 address: %s", err.Error())
}
if err := requireIP(p.SinkholeIPv6, false); err != nil {
return p, Invalid("Sinkhole IPv6 address: %s", err.Error())
}
}
p.BlockTTL = in.BlockTTL
if p.BlockTTL == 0 {
p.BlockTTL = base.BlockTTL
}
if p.BlockTTL == 0 {
p.BlockTTL = 60
}
if p.BlockTTL > 86400 {
return p, Invalid("The block TTL must be 86400 seconds or less.")
}
if in.Enabled != nil {
p.Enabled = *in.Enabled
} else if base.ID == 0 {
p.Enabled = true
}
return p, nil
}
func requireIP(s string, wantV4 bool) error {
p, err := config.ParseCIDROrIP(s)
if err != nil {
return errors.New("must be a valid IP address")
}
if p.Addr().Is4() != wantV4 {
if wantV4 {
return errors.New("must be an IPv4 address")
}
return errors.New("must be an IPv6 address")
}
return nil
}
// CreatePolicy stores a policy.
func (a *App) CreatePolicy(ctx context.Context, actor auditlog.Actor, in PolicyInput) (models.Policy, error) {
p, err := a.normalisePolicy(in, models.Policy{})
if err != nil {
return models.Policy{}, err
}
created, err := a.DB.CreatePolicy(ctx, p, in.ListIDs)
if err != nil {
return models.Policy{}, translate(err, "Policy not found.",
fmt.Sprintf("A policy named %q already exists.", p.Name))
}
a.Audit.RecordID(ctx, actor, "policy.create", auditlog.ObjectPolicy, created.ID, created.Name,
auditlog.Changes("action", string(created.BlockAction), "lists", fmt.Sprint(len(in.ListIDs))))
a.Runtime.RequestReload()
return created, nil
}
// UpdatePolicy saves a policy.
func (a *App) UpdatePolicy(ctx context.Context, actor auditlog.Actor, id int64, in PolicyInput) (models.Policy, error) {
existing, err := a.Policy(ctx, id)
if err != nil {
return models.Policy{}, err
}
p, err := a.normalisePolicy(in, existing)
if err != nil {
return models.Policy{}, err
}
p.ID = id
updated, err := a.DB.UpdatePolicy(ctx, p, in.ListIDs)
if err != nil {
return models.Policy{}, translate(err, fmt.Sprintf("Policy %d was not found.", id),
fmt.Sprintf("A policy named %q already exists.", p.Name))
}
a.Audit.RecordID(ctx, actor, "policy.update", auditlog.ObjectPolicy, id, updated.Name,
auditlog.Changes("action", string(updated.BlockAction), "lists", fmt.Sprint(len(in.ListIDs))))
a.Runtime.RequestReload()
return updated, nil
}
// SetPolicyEnabled toggles a policy.
func (a *App) SetPolicyEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
p, err := a.Policy(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetPolicyEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Policy %d was not found.", id), "")
}
action := "policy.disable"
if enabled {
action = "policy.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectPolicy, id, p.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeletePolicy removes a policy.
func (a *App) DeletePolicy(ctx context.Context, actor auditlog.Actor, id int64) error {
p, err := a.Policy(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeletePolicy(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Policy %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "policy.delete", auditlog.ObjectPolicy, id, p.Name, "")
a.Runtime.RequestReload()
return nil
}
// --- Domain lists -------------------------------------------------------
// ListInput is the editable surface of a blacklist or allowlist.
type ListInput struct {
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
SourceURL string `json:"source_url"`
}
// DomainLists returns blacklists, allowlists, or both when kind is empty.
func (a *App) DomainLists(ctx context.Context, kind, search string) ([]models.DomainList, error) {
lists, err := a.DB.DomainLists(ctx, kind, search)
if err != nil {
return nil, Internal(err, "The list could not be loaded.")
}
return lists, nil
}
// DomainList loads one list.
func (a *App) DomainList(ctx context.Context, id int64) (models.DomainList, error) {
l, err := a.DB.DomainList(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return l, NotFound("List %d was not found.", id)
}
if err != nil {
return l, Internal(err, "The list could not be loaded.")
}
return l, nil
}
// CreateDomainList stores a blacklist or allowlist.
func (a *App) CreateDomainList(ctx context.Context, actor auditlog.Actor, in ListInput) (models.DomainList, error) {
kind := strings.ToLower(strings.TrimSpace(in.Kind))
if kind != models.KindBlacklist && kind != models.KindAllowlist {
return models.DomainList{}, Invalid("List kind must be blacklist or allowlist.")
}
name := strings.TrimSpace(in.Name)
if name == "" {
return models.DomainList{}, Invalid("A list name is required.")
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
l := models.DomainList{
Kind: kind,
Name: name,
Description: strings.TrimSpace(in.Description),
Enabled: enabled,
SourceURL: strings.TrimSpace(in.SourceURL),
}
created, err := a.DB.CreateDomainList(ctx, l)
if err != nil {
return models.DomainList{}, translate(err, "List not found.",
fmt.Sprintf("A %s named %q already exists.", kind, name))
}
a.Audit.RecordID(ctx, actor, "list.create", auditlog.ObjectList, created.ID, created.Name,
auditlog.Changes("kind", created.Kind))
a.Runtime.RequestReload()
return created, nil
}
// UpdateDomainList saves list metadata.
func (a *App) UpdateDomainList(ctx context.Context, actor auditlog.Actor, id int64, in ListInput) (models.DomainList, error) {
existing, err := a.DomainList(ctx, id)
if err != nil {
return models.DomainList{}, err
}
if name := strings.TrimSpace(in.Name); name != "" {
existing.Name = name
}
existing.Description = strings.TrimSpace(in.Description)
existing.SourceURL = strings.TrimSpace(in.SourceURL)
if in.Enabled != nil {
existing.Enabled = *in.Enabled
}
updated, err := a.DB.UpdateDomainList(ctx, existing)
if err != nil {
return models.DomainList{}, translate(err, fmt.Sprintf("List %d was not found.", id),
fmt.Sprintf("A list named %q already exists.", existing.Name))
}
a.Audit.RecordID(ctx, actor, "list.update", auditlog.ObjectList, id, updated.Name, "")
a.Runtime.RequestReload()
return updated, nil
}
// SetDomainListEnabled toggles a list.
func (a *App) SetDomainListEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
l, err := a.DomainList(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetDomainListEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("List %d was not found.", id), "")
}
action := "list.disable"
if enabled {
action = "list.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectList, id, l.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeleteDomainList removes a list and every domain in it.
func (a *App) DeleteDomainList(ctx context.Context, actor auditlog.Actor, id int64) error {
l, err := a.DomainList(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteDomainList(ctx, id); err != nil {
return translate(err, fmt.Sprintf("List %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "list.delete", auditlog.ObjectList, id, l.Name,
auditlog.Changes("domains", fmt.Sprint(l.DomainCount)))
a.Runtime.RequestReload()
return nil
}
// --- Domain entries -----------------------------------------------------
// DomainEntries pages through a list's domains.
func (a *App) DomainEntries(ctx context.Context, listID int64, search string, limit, offset int) ([]models.DomainEntry, int, error) {
entries, total, err := a.DB.DomainEntries(ctx, listID, search, limit, offset)
if err != nil {
return nil, 0, Internal(err, "The domains could not be loaded.")
}
return entries, total, nil
}
// AddDomain adds one domain to a list.
func (a *App) AddDomain(ctx context.Context, actor auditlog.Actor, listID int64, domain string, matchSubdomains bool, comment string) (models.DomainEntry, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return models.DomainEntry{}, err
}
d := strings.TrimSpace(domain)
if strings.HasPrefix(d, "*.") {
d = strings.TrimPrefix(d, "*.")
matchSubdomains = true
}
normalised, err := validate.NormaliseDomain(d)
if err != nil {
return models.DomainEntry{}, Invalid("%s", err.Error())
}
entry, err := a.DB.AddDomain(ctx, models.DomainEntry{
ListID: listID,
Domain: normalised,
MatchSubdomains: matchSubdomains,
Enabled: true,
Comment: strings.TrimSpace(comment),
})
if err != nil {
return models.DomainEntry{}, translate(err, "List not found.",
fmt.Sprintf("%s is already in %s.", normalised, l.Name))
}
a.Audit.RecordID(ctx, actor, "domain.add", auditlog.ObjectDomain, entry.ID, normalised,
auditlog.Changes("list", l.Name))
a.Runtime.RequestReload()
return entry, nil
}
// UpdateDomain saves an existing domain entry.
func (a *App) UpdateDomain(ctx context.Context, actor auditlog.Actor, listID, id int64, domain string, matchSubdomains, enabled bool, comment string) error {
normalised, err := validate.NormaliseDomain(strings.TrimPrefix(strings.TrimSpace(domain), "*."))
if err != nil {
return Invalid("%s", err.Error())
}
e := models.DomainEntry{
ID: id, ListID: listID, Domain: normalised,
MatchSubdomains: matchSubdomains, Enabled: enabled, Comment: strings.TrimSpace(comment),
}
if err := a.DB.UpdateDomain(ctx, e); err != nil {
return translate(err, fmt.Sprintf("Domain %d was not found.", id),
fmt.Sprintf("%s is already in this list.", normalised))
}
a.Audit.RecordID(ctx, actor, "domain.update", auditlog.ObjectDomain, id, normalised, "")
a.Runtime.RequestReload()
return nil
}
// DeleteDomain removes one domain from a list.
func (a *App) DeleteDomain(ctx context.Context, actor auditlog.Actor, id int64) error {
if err := a.DB.DeleteDomain(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Domain %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "domain.delete", auditlog.ObjectDomain, id, "", "")
a.Runtime.RequestReload()
return nil
}
// ClearDomains empties a list.
func (a *App) ClearDomains(ctx context.Context, actor auditlog.Actor, listID int64) (int64, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return 0, err
}
n, err := a.DB.ClearDomains(ctx, listID)
if err != nil {
return 0, Internal(err, "The list could not be cleared.")
}
a.Audit.RecordID(ctx, actor, "list.clear", auditlog.ObjectList, listID, l.Name,
auditlog.Changes("removed", fmt.Sprint(n)))
a.Runtime.RequestReload()
return n, nil
}
// ImportDomains parses a domain list and stores it.
//
// Parsing happens fully in memory and the insert runs as a single transaction
// with one prepared statement, so a list of several hundred thousand domains
// is one commit rather than one commit per domain.
func (a *App) ImportDomains(ctx context.Context, actor auditlog.Actor, listID int64,
r io.Reader, matchSubdomains bool) (models.ImportSummary, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return models.ImportSummary{}, err
}
parsed, summary := blacklist.Parse(r, blacklist.ParseOptions{DefaultMatchSubdomains: matchSubdomains})
if len(parsed) == 0 {
if summary.LinesProcessed == 0 {
return summary, Invalid("The import was empty.")
}
return summary, Invalid("No valid domains were found in %d lines. "+
"Supported formats are a plain domain list, a hosts file, or Adblock-style ||domain^ rules.",
summary.LinesProcessed)
}
rows := make([]database.ImportDomain, 0, len(parsed))
for _, p := range parsed {
rows = append(rows, database.ImportDomain{Domain: p.Domain, MatchSubdomains: p.MatchSubdomains})
}
imported, duplicates, err := a.DB.ImportDomains(ctx, listID, rows)
if err != nil {
return summary, Internal(err, "The domains could not be imported.")
}
// The parser counts duplicates within the file; the database reports
// domains that were already present. The summary shows the total.
summary.Imported = imported
summary.Duplicates += duplicates
a.Audit.RecordID(ctx, actor, "list.import", auditlog.ObjectList, listID, l.Name,
auditlog.Changes(
"imported", fmt.Sprint(summary.Imported),
"duplicates", fmt.Sprint(summary.Duplicates),
"invalid", fmt.Sprint(summary.Invalid),
"lines", fmt.Sprint(summary.LinesProcessed)))
a.Runtime.RequestReload()
return summary, nil
}
// ExportDomains writes a list as a plain domain list.
func (a *App) ExportDomains(ctx context.Context, listID int64, w io.Writer) (models.DomainList, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return l, err
}
fmt.Fprintf(w, "# %s\n", l.Name)
if l.Description != "" {
fmt.Fprintf(w, "# %s\n", l.Description)
}
fmt.Fprintf(w, "# %d domains exported by VibeDNS\n", l.DomainCount)
err = a.DB.ExportDomains(ctx, listID, func(domain string, matchSubdomains bool) {
if matchSubdomains {
fmt.Fprintln(w, domain)
return
}
// A domain that must match exactly is written in a form the importer
// will not silently widen.
fmt.Fprintf(w, "%s # exact\n", domain)
})
if err != nil {
return l, Internal(err, "The domains could not be exported.")
}
return l, nil
}
// LookupDomain reports which lists cover a name, for the "why was this
// blocked?" tool.
type LookupHit struct {
ListID int64 `json:"list_id"`
ListName string `json:"list_name"`
Kind string `json:"kind"`
Matched string `json:"matched_domain"`
}
// LookupDomain checks a name against every compiled list.
func (a *App) LookupDomain(ctx context.Context, name string) ([]LookupHit, error) {
domain, err := validate.NormaliseDomain(name)
if err != nil {
return nil, Invalid("%s", err.Error())
}
lists, err := a.DB.DomainLists(ctx, "", "")
if err != nil {
return nil, Internal(err, "The lists could not be loaded.")
}
sets := a.Snapshot().Policy.Sets()
var hits []LookupHit
for _, l := range lists {
set, ok := sets[l.ID]
if !ok {
continue // list is disabled, so it was not compiled
}
if matched, found := set.Match(domain); found {
hits = append(hits, LookupHit{
ListID: l.ID, ListName: l.Name, Kind: l.Kind, Matched: matched,
})
}
}
return hits, nil
}
+331
View File
@@ -0,0 +1,331 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// RecordInput is the editable surface of a resource record.
//
// Data may be supplied either as finished rdata (the advanced editor and the
// REST API) or as the individual fields of a type-specific editor, which the
// service assembles and quotes correctly.
type RecordInput struct {
Name string `json:"name"`
Type string `json:"type"`
Data string `json:"data"`
Fields map[string]string `json:"fields,omitempty"`
TTL *uint32 `json:"ttl"`
Enabled *bool `json:"enabled"`
Comment string `json:"comment"`
}
// Records lists records matching a filter, with the total match count.
func (a *App) Records(ctx context.Context, f database.RecordFilter) ([]models.Record, int, error) {
recs, total, err := a.DB.Records(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The record list could not be loaded.")
}
return recs, total, nil
}
// Record loads one record.
func (a *App) Record(ctx context.Context, id int64) (models.Record, error) {
r, err := a.DB.Record(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return r, NotFound("Record %d was not found.", id)
}
if err != nil {
return r, Internal(err, "The record could not be loaded.")
}
return r, nil
}
// CreateRecord validates and stores a record.
func (a *App) CreateRecord(ctx context.Context, actor auditlog.Actor, zoneID int64, in RecordInput) (models.Record, error) {
zone, err := a.Zone(ctx, zoneID)
if err != nil {
return models.Record{}, err
}
rec, err := a.prepareRecord(ctx, zone, in, 0)
if err != nil {
return models.Record{}, err
}
created, err := a.DB.CreateRecord(ctx, rec)
if err != nil {
return models.Record{}, Internal(err, "The record could not be saved.")
}
a.Audit.RecordID(ctx, actor, "record.create", auditlog.ObjectRecord, created.ID,
validate.AbsoluteName(created.Name, zone.Name),
auditlog.Changes("zone", zone.Name, "type", created.Type, "data", created.Data))
a.Runtime.RequestReload()
return created, nil
}
// UpdateRecord validates and saves an existing record.
func (a *App) UpdateRecord(ctx context.Context, actor auditlog.Actor, id int64, in RecordInput) (models.Record, error) {
existing, err := a.Record(ctx, id)
if err != nil {
return models.Record{}, err
}
zone, err := a.Zone(ctx, existing.ZoneID)
if err != nil {
return models.Record{}, err
}
// Carry forward anything the caller did not supply, so a PATCH-style
// update does not silently blank fields.
if strings.TrimSpace(in.Name) == "" {
in.Name = existing.Name
}
if strings.TrimSpace(in.Type) == "" {
in.Type = existing.Type
}
if in.Enabled == nil {
e := existing.Enabled
in.Enabled = &e
}
rec, err := a.prepareRecord(ctx, zone, in, id)
if err != nil {
return models.Record{}, err
}
rec.ID = id
rec.ZoneID = existing.ZoneID
updated, err := a.DB.UpdateRecord(ctx, rec)
if err != nil {
return models.Record{}, translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "record.update", auditlog.ObjectRecord, id,
validate.AbsoluteName(updated.Name, zone.Name),
auditlog.Changes("zone", zone.Name, "type", updated.Type, "data", updated.Data))
a.Runtime.RequestReload()
return updated, nil
}
// prepareRecord validates a record against its zone. excludeID lets an update
// ignore the record being edited when checking for conflicts.
func (a *App) prepareRecord(ctx context.Context, zone models.Zone, in RecordInput, excludeID int64) (models.Record, error) {
rtype, err := validate.NormaliseType(in.Type)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
name, err := validate.NormaliseRecordName(in.Name, zone.Name)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
data := strings.TrimSpace(in.Data)
if data == "" && len(in.Fields) > 0 {
data, err = validate.AssembleRData(rtype, in.Fields)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
}
if data == "" {
return models.Record{}, Invalid("Record data is required for a %s record.", rtype)
}
// TXT-style types are quoted for the caller when they clearly are not.
if (rtype == "TXT" || rtype == "SPF") && !strings.HasPrefix(data, `"`) {
data = validate.QuoteTXT(data)
}
ttl := zone.DefaultTTL
if in.TTL != nil {
if *in.TTL < 1 || *in.TTL > 604800 {
return models.Record{}, Invalid("The TTL must be between 1 and 604800 seconds.")
}
ttl = *in.TTL
}
if name == "@" {
if err := validate.ApexRestricted(rtype); err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
}
// Compile the record now so a malformed value is reported here, with a
// message about this record, rather than as a warning at index-build time.
if _, err := validate.BuildRR(zone.Name, name, rtype, data, ttl); err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
if err := a.checkCNAMEConflict(ctx, zone, name, rtype, excludeID); err != nil {
return models.Record{}, err
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
rec := models.Record{
ZoneID: zone.ID,
Name: name,
Type: rtype,
Data: data,
Enabled: enabled,
Comment: strings.TrimSpace(in.Comment),
}
if in.TTL != nil {
rec.TTL = in.TTL
}
return rec, nil
}
// checkCNAMEConflict enforces the RFC 1034 rule that a CNAME may not coexist
// with other data at the same name.
func (a *App) checkCNAMEConflict(ctx context.Context, zone models.Zone, name, rtype string, excludeID int64) error {
existing, _, err := a.DB.Records(ctx, database.RecordFilter{ZoneID: zone.ID})
if err != nil {
return Internal(err, "Existing records could not be checked.")
}
var types []string
for _, r := range existing {
if r.ID == excludeID || r.Name != name {
continue
}
types = append(types, r.Type)
}
if err := validate.CNAMEConflict(rtype, types); err != nil {
return Invalid("%s", err.Error())
}
return nil
}
// SetRecordEnabled toggles one record.
func (a *App) SetRecordEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
rec, err := a.Record(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetRecordEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
action := "record.disable"
if enabled {
action = "record.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectRecord, id, rec.Name,
auditlog.Changes("type", rec.Type))
a.Runtime.RequestReload()
return nil
}
// DeleteRecord removes one record.
func (a *App) DeleteRecord(ctx context.Context, actor auditlog.Actor, id int64) error {
rec, err := a.Record(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteRecord(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "record.delete", auditlog.ObjectRecord, id, rec.Name,
auditlog.Changes("type", rec.Type, "data", rec.Data))
a.Runtime.RequestReload()
return nil
}
// BulkAction names a bulk operation on selected records.
type BulkAction string
// Supported bulk operations.
const (
BulkEnable BulkAction = "enable"
BulkDisable BulkAction = "disable"
BulkDelete BulkAction = "delete"
)
// BulkRecords applies an action to several records of one zone.
func (a *App) BulkRecords(ctx context.Context, actor auditlog.Actor, zoneID int64, ids []int64, action BulkAction) (int, error) {
zone, err := a.Zone(ctx, zoneID)
if err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, Invalid("Select at least one record first.")
}
var n int
switch action {
case BulkDelete:
n, err = a.DB.DeleteRecords(ctx, zoneID, ids)
case BulkEnable:
n, err = a.DB.SetRecordsEnabled(ctx, zoneID, ids, true)
case BulkDisable:
n, err = a.DB.SetRecordsEnabled(ctx, zoneID, ids, false)
default:
return 0, Invalid("Unknown bulk action %q.", action)
}
if err != nil {
return 0, Internal(err, "The selected records could not be updated.")
}
a.Audit.RecordID(ctx, actor, "record.bulk_"+string(action), auditlog.ObjectRecord, zoneID, zone.Name,
auditlog.Changes("records", fmt.Sprint(n)))
a.Runtime.RequestReload()
return n, nil
}
// RecordTypes returns the record type catalogue for the editors.
func (a *App) RecordTypes() []validate.TypeInfo { return validate.TypeInfos() }
// RecordTypesInUse lists the distinct types present in a zone, for filters.
func (a *App) RecordTypesInUse(ctx context.Context, zoneID int64) ([]string, error) {
types, err := a.DB.RecordTypesInUse(ctx, zoneID)
if err != nil {
return nil, Internal(err, "Record types could not be loaded.")
}
return types, nil
}
// PTRSuggestion describes the reverse record the UI offers to create alongside
// an address record.
type PTRSuggestion struct {
ZoneID int64 `json:"zone_id"`
ZoneName string `json:"zone_name"`
Name string `json:"name"`
Data string `json:"data"`
}
// SuggestPTR finds the reverse zone covering an address and returns the PTR
// record that would point back at hostname.
func (a *App) SuggestPTR(ctx context.Context, ip, hostname string) (*PTRSuggestion, error) {
ptrName, err := validate.PTRName(ip)
if err != nil {
return nil, Invalid("%s", err.Error())
}
target, err := validate.NormaliseFQDN(hostname)
if err != nil {
return nil, Invalid("%s", err.Error())
}
zones, err := a.DB.Zones(ctx, database.ZoneFilter{Kind: "reverse"})
if err != nil {
return nil, Internal(err, "Reverse zones could not be loaded.")
}
var best models.Zone
for _, z := range zones {
if validate.IsSubdomain(ptrName, z.Name) && len(z.Name) > len(best.Name) {
best = z
}
}
if best.ID == 0 {
return nil, NotFound("No reverse zone covers %s. Create one first.", ip)
}
rel, err := validate.NormaliseRecordName(ptrName, best.Name)
if err != nil {
return nil, Invalid("%s", err.Error())
}
return &PTRSuggestion{ZoneID: best.ID, ZoneName: best.Name, Name: rel, Data: target}, nil
}
+122
View File
@@ -0,0 +1,122 @@
package app
import (
"context"
"fmt"
"sort"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
)
// RestartRequired lists the settings that only take effect after a restart,
// because they control a bound socket.
var RestartRequired = map[string]string{
config.KeyDNSUDPListen: "DNS UDP listen address",
config.KeyDNSTCPListen: "DNS TCP listen address",
config.KeyHTTPListen: "Management HTTP listen address",
}
// SettingsGroup names a page of the settings interface.
type SettingsGroup string
// Settings pages.
const (
GroupDNS SettingsGroup = "dns"
GroupResolver SettingsGroup = "resolver"
GroupCache SettingsGroup = "cache"
GroupLogging SettingsGroup = "logging"
GroupHTTP SettingsGroup = "http"
GroupBackup SettingsGroup = "backup"
GroupRateLimit SettingsGroup = "ratelimit"
)
// SaveSettings validates and persists a complete settings object.
//
// Validation runs against the merged result rather than the submitted fields,
// so a change that would leave the server in an unusable state — recursion on
// with no upstreams, or an empty ACL — is rejected before it is stored.
func (a *App) SaveSettings(ctx context.Context, actor auditlog.Actor, group SettingsGroup, next config.Settings) error {
next.Normalise()
if err := next.Validate(); err != nil {
return Invalid("%s", err.Error())
}
current := a.Settings()
changed := diffSettings(current.ToMap(), next.ToMap())
if len(changed) == 0 {
return nil
}
// Only the keys belonging to this group are written, so two administrators
// editing different pages cannot overwrite each other's work.
toWrite := map[string]string{}
full := next.ToMap()
for _, k := range changed {
toWrite[k] = full[k]
}
if err := a.DB.SetSettings(ctx, toWrite); err != nil {
return Internal(err, "The settings could not be saved.")
}
a.Audit.Record(ctx, actor, "settings.update", auditlog.ObjectSettings, string(group), string(group),
auditlog.Changes("keys", strings.Join(changed, " ")))
if err := a.Runtime.Reload(ctx); err != nil {
return Internal(err, "The settings were saved but could not be applied. Restart the server.")
}
return nil
}
// diffSettings returns the keys whose values differ.
func diffSettings(before, after map[string]string) []string {
var changed []string
for k, v := range after {
if before[k] != v {
changed = append(changed, k)
}
}
sort.Strings(changed)
return changed
}
// PendingRestart reports which changed settings need a restart to take effect.
func (a *App) PendingRestart(ctx context.Context) []string {
current := a.Settings()
udp, tcp := a.DNS.ListenAddrs()
var pending []string
if current.DNS.UDPListen != udp {
pending = append(pending, fmt.Sprintf("DNS UDP address (listening on %s, configured as %s)",
udp, current.DNS.UDPListen))
}
if current.DNS.TCPListen != tcp {
pending = append(pending, fmt.Sprintf("DNS TCP address (listening on %s, configured as %s)",
tcp, current.DNS.TCPListen))
}
return pending
}
// TestUpstream probes one upstream resolver on demand.
func (a *App) TestUpstream(ctx context.Context, addr, qname string) (string, error) {
s := a.Settings()
if strings.TrimSpace(qname) == "" {
qname = "example.com"
}
upstreams := config.SplitLines(addr)
if len(upstreams) == 0 {
return "", Invalid("Enter an upstream resolver address.")
}
target := upstreams[0]
if !strings.Contains(target, ":") {
target += ":53"
}
rtt, rcode, err := resolverCheck(ctx, target, qname, s)
if err != nil {
return "", Invalid("%s", err.Error())
}
return fmt.Sprintf("%s answered %s in %.0f ms", target, rcode, float64(rtt.Microseconds())/1000), nil
}
+305
View File
@@ -0,0 +1,305 @@
package app
import (
"context"
"net/netip"
"strings"
"time"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/metrics"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/ratelimit"
"github.com/owen/vibedns/internal/resolver"
)
// Dashboard is everything the overview page and /api/v1/stats report.
type Dashboard struct {
Uptime time.Duration `json:"uptime"`
UptimeText string `json:"uptime_text"`
StartedAt time.Time `json:"started_at"`
Hostname string `json:"hostname"`
Version string `json:"version"`
TotalQueries int64 `json:"total_queries"`
QueriesPerSec float64 `json:"queries_per_second"`
Authoritative int64 `json:"authoritative_queries"`
Recursive int64 `json:"recursive_queries"`
Blocked int64 `json:"blocked_queries"`
Refused int64 `json:"refused_queries"`
RateLimited int64 `json:"ratelimited_queries"`
Errors int64 `json:"errors"`
AvgQueryMS float64 `json:"avg_query_ms"`
AvgResolverMS float64 `json:"avg_resolver_ms"`
BlockRate float64 `json:"block_rate"`
CacheHits int64 `json:"cache_hits"`
CacheMisses int64 `json:"cache_misses"`
CacheHitRate float64 `json:"cache_hit_rate"`
CacheEntries int `json:"cache_entries"`
CacheBytes int64 `json:"cache_bytes"`
CacheEnabled bool `json:"cache_enabled"`
Zones int `json:"zones"`
Records int `json:"records"`
Blacklists int `json:"blacklists"`
BlacklistDomains int `json:"blacklist_domains"`
Allowlists int `json:"allowlists"`
AllowlistDomains int `json:"allowlist_domains"`
Networks int `json:"networks"`
Policies int `json:"policies"`
QueriesByType []metrics.LabelValue `json:"queries_by_type"`
QueriesByRcode []metrics.LabelValue `json:"queries_by_rcode"`
QueriesBySource []metrics.LabelValue `json:"queries_by_source"`
TopDomains []database.NameCount `json:"top_domains"`
TopBlocked []database.NameCount `json:"top_blocked"`
TopClients []database.NameCount `json:"top_clients"`
Activity []database.TimeBucket `json:"activity"`
Upstreams []resolver.Status `json:"upstreams"`
RateLimit ratelimit.Stats `json:"rate_limit"`
QueryLog QueryLogStatus `json:"query_log"`
Recent []models.QueryLogEntry `json:"recent"`
RecursionEnabled bool `json:"recursion_enabled"`
DNSRunning bool `json:"dns_running"`
QueryLogEnabled bool `json:"query_log_enabled"`
}
// QueryLogStatus summarises the query log for the dashboard.
type QueryLogStatus struct {
Enabled bool `json:"enabled"`
Rows int64 `json:"rows"`
Written int64 `json:"written"`
Dropped int64 `json:"dropped"`
}
// activityWindow is how far back the dashboard chart looks.
const activityWindow = 24 * time.Hour
// activityBuckets is how many points the chart plots.
const activityBuckets = 48
// Dashboard assembles the overview data.
//
// The counters come from memory; only the top-N lists and the activity chart
// touch SQLite, and they are read-only aggregate queries over an indexed
// timestamp column.
func (a *App) Dashboard(ctx context.Context, topN int) (*Dashboard, error) {
if topN <= 0 {
topN = 10
}
m := a.Metrics
snap := a.Snapshot()
cs := a.Cache.Stats()
d := &Dashboard{
Uptime: a.Uptime(),
UptimeText: FormatDuration(a.Uptime()),
StartedAt: a.StartedAt(),
Hostname: Hostname(),
TotalQueries: m.QueriesTotal.Load(),
QueriesPerSec: m.QueriesPerSecond(),
Authoritative: m.Authoritative.Load(),
Recursive: m.Recursive.Load(),
Blocked: m.Blocked.Load(),
Refused: m.Refused.Load(),
RateLimited: m.RateLimited.Load(),
Errors: m.Errors.Load(),
AvgQueryMS: m.AvgQueryMS(),
AvgResolverMS: m.AvgResolverMS(),
CacheHits: m.CacheHits.Load(),
CacheMisses: m.CacheMisses.Load(),
CacheHitRate: m.CacheHitRate(),
CacheEntries: cs.Entries,
CacheBytes: cs.Bytes,
CacheEnabled: cs.Enabled,
Zones: snap.ZoneCount,
Records: snap.RecordCount,
BlacklistDomains: snap.BlacklistDomains,
AllowlistDomains: snap.AllowlistDomains,
Networks: snap.NetworkCount,
QueriesByType: m.ByType(),
QueriesByRcode: m.ByRcode(),
QueriesBySource: m.BySource(),
Upstreams: a.Resolver.Statuses(),
RateLimit: a.Limiter.Stats(),
RecursionEnabled: snap.Settings.DNS.Recursion,
DNSRunning: a.DNS.Running(),
QueryLogEnabled: snap.Settings.QueryLog.Enabled,
}
if d.TotalQueries > 0 {
d.BlockRate = float64(d.Blocked) / float64(d.TotalQueries) * 100
}
// Zone and record counts from the database include disabled objects, which
// the operator still wants to see on the dashboard.
if zones, records, err := a.DB.CountZonesAndRecords(ctx); err == nil {
d.Zones, d.Records = zones, records
}
if bl, bd, al, ad, err := a.DB.CountDomainLists(ctx); err == nil {
d.Blacklists, d.BlacklistDomains = bl, bd
d.Allowlists, d.AllowlistDomains = al, ad
}
if policies, err := a.DB.Policies(ctx); err == nil {
d.Policies = len(policies)
}
qs := a.QueryLog.Stats()
d.QueryLog = QueryLogStatus{Enabled: qs.Enabled, Written: qs.Written, Dropped: qs.Dropped}
if n, err := a.DB.QueryLogCount(ctx); err == nil {
d.QueryLog.Rows = n
}
// Query-log derived panels are only meaningful when logging is on.
if snap.Settings.QueryLog.Enabled {
since := time.Now().Add(-activityWindow)
if v, err := a.DB.TopQueried(ctx, since, topN); err == nil {
d.TopDomains = v
}
if v, err := a.DB.TopBlocked(ctx, since, topN); err == nil {
d.TopBlocked = v
}
if v, err := a.DB.TopClients(ctx, since, topN); err == nil {
d.TopClients = v
}
if v, err := a.DB.ActivityBuckets(ctx, since, activityWindow/activityBuckets, activityBuckets); err == nil {
d.Activity = v
}
if v, _, err := a.DB.QueryLogs(ctx, database.QueryLogFilter{Limit: 15}); err == nil {
d.Recent = v
}
}
return d, nil
}
// QueryLogs searches the query log.
func (a *App) QueryLogs(ctx context.Context, f database.QueryLogFilter) ([]models.QueryLogEntry, int, error) {
entries, total, err := a.DB.QueryLogs(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The query log could not be loaded.")
}
return entries, total, nil
}
// ClearQueryLog empties the query log.
func (a *App) ClearQueryLog(ctx context.Context, actor auditlog.Actor) (int64, error) {
n, err := a.DB.TruncateQueryLogs(ctx)
if err != nil {
return 0, Internal(err, "The query log could not be cleared.")
}
a.Audit.Record(ctx, actor, "querylog.clear", auditlog.ObjectQueryLog, "", "query log",
auditlog.Changes("rows", formatInt(n)))
return n, nil
}
// AuditLogs searches the audit log.
func (a *App) AuditLogs(ctx context.Context, f database.AuditFilter) ([]models.AuditEntry, int, error) {
entries, total, err := a.DB.AuditLogs(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The audit log could not be loaded.")
}
return entries, total, nil
}
// DatabaseStats reports database size and row counts.
func (a *App) DatabaseStats(ctx context.Context) (database.Stats, error) {
s, err := a.DB.Stats(ctx)
if err != nil {
return s, Internal(err, "Database statistics could not be read.")
}
return s, nil
}
// CacheStatsView bundles cache counters with its configuration for the UI.
type CacheStatsView struct {
cache.Stats
MinTTL int `json:"min_ttl"`
MaxTTL int `json:"max_ttl"`
NegativeTTL int `json:"negative_ttl"`
ServeStale bool `json:"serve_stale"`
StaleTTL int `json:"stale_ttl"`
Prefetch bool `json:"prefetch"`
}
// CacheView returns cache counters together with the active configuration.
func (a *App) CacheView() CacheStatsView {
s := a.Settings().Cache
return CacheStatsView{
Stats: a.Cache.Stats(),
MinTTL: s.MinTTL,
MaxTTL: s.MaxTTL,
NegativeTTL: s.NegativeTTL,
ServeStale: s.ServeStale,
StaleTTL: s.StaleTTL,
Prefetch: s.Prefetch,
}
}
// FormatDuration renders a duration the way an operator reads uptime.
func FormatDuration(d time.Duration) string {
if d < time.Minute {
return formatInt(int64(d.Seconds())) + "s"
}
days := int64(d.Hours()) / 24
hours := int64(d.Hours()) % 24
mins := int64(d.Minutes()) % 60
var parts []string
if days > 0 {
parts = append(parts, formatInt(days)+"d")
}
if hours > 0 {
parts = append(parts, formatInt(hours)+"h")
}
if mins > 0 || len(parts) == 0 {
parts = append(parts, formatInt(mins)+"m")
}
return strings.Join(parts, " ")
}
func formatInt(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var buf [24]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// netipAddr parses a client address for the lookup tool, defaulting to
// loopback when the field is left blank.
func netipAddr(s string) (netip.Addr, bool) {
s = strings.TrimSpace(s)
if s == "" {
return netip.MustParseAddr("127.0.0.1"), true
}
addr, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
+377
View File
@@ -0,0 +1,377 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/zonefile"
)
// ZoneInput is the editable surface of a zone.
type ZoneInput struct {
Name string `json:"name"`
Kind string `json:"kind"`
CIDR string `json:"cidr"` // reverse zones may be created from a subnet instead
Description string `json:"description"`
Enabled *bool `json:"enabled"`
DefaultTTL uint32 `json:"default_ttl"`
PrimaryNS string `json:"primary_ns"`
AdminEmail string `json:"admin_email"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
Minimum uint32 `json:"minimum"`
AutoSerial *bool `json:"auto_serial"`
Serial *uint32 `json:"serial"`
}
// Zones lists zones matching a filter.
func (a *App) Zones(ctx context.Context, f database.ZoneFilter) ([]models.Zone, error) {
zones, err := a.DB.Zones(ctx, f)
if err != nil {
return nil, Internal(err, "The zone list could not be loaded.")
}
return zones, nil
}
// Zone loads one zone.
func (a *App) Zone(ctx context.Context, id int64) (models.Zone, error) {
z, err := a.DB.Zone(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return z, NotFound("Zone %d was not found.", id)
}
if err != nil {
return z, Internal(err, "The zone could not be loaded.")
}
return z, nil
}
// CreateZone validates and stores a new zone.
//
// A reverse zone may be given either as an explicit apex name or as the subnet
// it covers, which is what the UI sends: administrators should not have to
// reverse octets by hand.
func (a *App) CreateZone(ctx context.Context, actor auditlog.Actor, in ZoneInput) (models.Zone, error) {
z, note, err := a.normaliseZoneInput(in, models.Zone{})
if err != nil {
return models.Zone{}, err
}
created, err := a.DB.CreateZone(ctx, z)
if err != nil {
return models.Zone{}, translate(err,
"Zone not found.",
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(z.Name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.create", auditlog.ObjectZone, created.ID, created.Name,
auditlog.Changes("kind", string(created.Kind), "ttl", fmt.Sprint(created.DefaultTTL)))
a.Runtime.RequestReload()
if note != "" {
a.Log.Info("reverse zone name derived from subnet", "zone", created.Name, "note", note)
}
return created, nil
}
// ReverseZoneName previews the zone apex a subnet maps to, for the UI's live
// hint under the CIDR field.
func (a *App) ReverseZoneName(cidr string) (name, note string, err error) {
name, note, err = validate.ReverseZone(cidr)
if err != nil {
return "", "", Invalid("%s", err.Error())
}
return name, note, nil
}
// UpdateZone saves zone metadata.
func (a *App) UpdateZone(ctx context.Context, actor auditlog.Actor, id int64, in ZoneInput) (models.Zone, error) {
existing, err := a.Zone(ctx, id)
if err != nil {
return models.Zone{}, err
}
z, _, err := a.normaliseZoneInput(in, existing)
if err != nil {
return models.Zone{}, err
}
z.ID = id
z.CreatedAt = existing.CreatedAt
updated, err := a.DB.UpdateZone(ctx, z)
if err != nil {
return models.Zone{}, translate(err,
fmt.Sprintf("Zone %d was not found.", id),
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(z.Name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.update", auditlog.ObjectZone, id, updated.Name,
auditlog.Changes("serial", fmt.Sprint(updated.Serial), "ttl", fmt.Sprint(updated.DefaultTTL)))
a.Runtime.RequestReload()
return updated, nil
}
// normaliseZoneInput validates input and merges it over an existing zone.
func (a *App) normaliseZoneInput(in ZoneInput, base models.Zone) (models.Zone, string, error) {
z := base
var note string
name := strings.TrimSpace(in.Name)
if cidr := strings.TrimSpace(in.CIDR); cidr != "" && name == "" {
derived, n, err := validate.ReverseZone(cidr)
if err != nil {
return z, "", Invalid("%s", err.Error())
}
name = derived
note = n
kind, err := validate.ReverseZoneKindForCIDR(cidr)
if err == nil {
in.Kind = kind
}
}
if name == "" && base.Name == "" {
return z, "", Invalid("A zone name is required.")
}
if name != "" {
normalised, err := validate.NormaliseZoneName(name)
if err != nil {
return z, "", Invalid("%s", err.Error())
}
z.Name = normalised
}
kind := models.ZoneKind(strings.TrimSpace(in.Kind))
if kind == "" {
kind = models.ZoneKind(validate.ZoneKindForName(z.Name))
}
if !kind.Valid() {
return z, "", Invalid("Zone kind %q must be forward, reverse4 or reverse6.", in.Kind)
}
z.Kind = kind
z.Description = strings.TrimSpace(in.Description)
if in.Enabled != nil {
z.Enabled = *in.Enabled
} else if base.ID == 0 {
z.Enabled = true
}
z.DefaultTTL = in.DefaultTTL
if z.DefaultTTL == 0 {
z.DefaultTTL = base.DefaultTTL
}
if z.DefaultTTL == 0 {
z.DefaultTTL = a.Settings().DNS.DefaultTTL
}
if z.DefaultTTL < 1 || z.DefaultTTL > 604800 {
return z, "", Invalid("The default TTL must be between 1 and 604800 seconds.")
}
z.PrimaryNS = strings.TrimSpace(in.PrimaryNS)
if z.PrimaryNS == "" {
z.PrimaryNS = base.PrimaryNS
}
if z.PrimaryNS == "" {
z.PrimaryNS = "ns1." + z.Name
}
ns, err := validate.NormaliseFQDN(z.PrimaryNS)
if err != nil {
return z, "", Invalid("Primary name server: %s", err.Error())
}
z.PrimaryNS = ns
z.AdminEmail = strings.TrimSpace(in.AdminEmail)
if z.AdminEmail == "" {
z.AdminEmail = base.AdminEmail
}
if z.AdminEmail == "" {
z.AdminEmail = "hostmaster@" + strings.TrimSuffix(z.Name, ".")
}
z.Refresh = orDefault(in.Refresh, base.Refresh, 7200)
z.Retry = orDefault(in.Retry, base.Retry, 3600)
z.Expire = orDefault(in.Expire, base.Expire, 1209600)
z.Minimum = orDefault(in.Minimum, base.Minimum, 3600)
if in.AutoSerial != nil {
z.AutoSerial = *in.AutoSerial
} else if base.ID == 0 {
z.AutoSerial = true
}
if in.Serial != nil {
// A manual serial override is allowed, which matters when migrating a
// zone from another server that is already at a higher serial.
if *in.Serial == 0 {
return z, "", Invalid("The serial must be at least 1.")
}
z.Serial = *in.Serial
} else if z.Serial == 0 {
z.Serial = 1
}
return z, note, nil
}
func orDefault(v, fallback, def uint32) uint32 {
if v != 0 {
return v
}
if fallback != 0 {
return fallback
}
return def
}
// SetZoneEnabled toggles a zone.
func (a *App) SetZoneEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
z, err := a.Zone(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetZoneEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Zone %d was not found.", id), "")
}
action := "zone.disable"
if enabled {
action = "zone.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectZone, id, z.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeleteZone removes a zone and all of its records.
func (a *App) DeleteZone(ctx context.Context, actor auditlog.Actor, id int64) error {
z, err := a.Zone(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteZone(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Zone %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "zone.delete", auditlog.ObjectZone, id, z.Name,
auditlog.Changes("records", fmt.Sprint(z.RecordCount)))
a.Runtime.RequestReload()
return nil
}
// CloneZone copies a zone under a new name.
func (a *App) CloneZone(ctx context.Context, actor auditlog.Actor, id int64, newName, description string) (models.Zone, error) {
src, err := a.Zone(ctx, id)
if err != nil {
return models.Zone{}, err
}
name, err := validate.NormaliseZoneName(newName)
if err != nil {
return models.Zone{}, Invalid("%s", err.Error())
}
if name == src.Name {
return models.Zone{}, Invalid("The new zone name must differ from the zone being cloned.")
}
clone, err := a.DB.CloneZone(ctx, id, name, strings.TrimSpace(description))
if err != nil {
return models.Zone{}, translate(err,
fmt.Sprintf("Zone %d was not found.", id),
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.clone", auditlog.ObjectZone, clone.ID, clone.Name,
auditlog.Changes("source", src.Name))
a.Runtime.RequestReload()
return clone, nil
}
// --- Zone file import and export ---------------------------------------
// ImportMode selects how an imported zone file is applied.
type ImportMode string
const (
// ImportReplace discards the zone's existing records.
ImportReplace ImportMode = "replace"
// ImportMerge adds the imported records to what is already there.
ImportMerge ImportMode = "merge"
)
// ImportResult reports the outcome of a zone file import.
type ImportResult struct {
Zone models.Zone `json:"zone"`
Summary zonefile.ParseSummary `json:"summary"`
Created bool `json:"zone_created"`
}
// ImportZoneFile parses a BIND zone file and stores its records.
//
// The whole file is validated before anything is written, so a syntax error
// halfway through never leaves a zone half-imported.
func (a *App) ImportZoneFile(ctx context.Context, actor auditlog.Actor, zoneID int64,
r io.Reader, mode ImportMode) (*ImportResult, error) {
z, err := a.Zone(ctx, zoneID)
if err != nil {
return nil, err
}
parsed, err := zonefile.Parse(r, z.Name, z.DefaultTTL)
if err != nil {
return nil, Invalid("%s", err.Error())
}
if problems := zonefile.ValidateRecords(z.Name, parsed.Records, z.DefaultTTL); len(problems) > 0 {
return nil, Invalid("The zone file contains records this server cannot store:\n%s",
strings.Join(problems, "\n"))
}
switch mode {
case ImportMerge:
err = a.DB.AppendZoneRecords(ctx, zoneID, parsed.Records)
default:
mode = ImportReplace
err = a.DB.ReplaceZoneRecords(ctx, zoneID, parsed.Records)
}
if err != nil {
return nil, Internal(err, "The imported records could not be saved.")
}
// Adopt the SOA timers from the file, but keep our own serial management
// unless the file's serial is higher.
if parsed.SOA != nil {
updated := z
zonefile.ZoneMetadataFromSOA(&updated, parsed.SOA)
if updated.Serial < z.Serial {
updated.Serial = z.Serial
}
if _, err := a.DB.UpdateZone(ctx, updated); err != nil {
a.Log.Warn("could not apply imported SOA values", "zone", z.Name, "error", err)
} else {
z = updated
}
}
a.Audit.RecordID(ctx, actor, "zone.import", auditlog.ObjectZone, zoneID, z.Name,
auditlog.Changes("mode", string(mode), "records", fmt.Sprint(parsed.Summary.RecordsParsed)))
a.Runtime.RequestReload()
return &ImportResult{Zone: z, Summary: parsed.Summary}, nil
}
// ExportZoneFile renders a zone as a BIND zone file.
func (a *App) ExportZoneFile(ctx context.Context, zoneID int64) (models.Zone, []byte, error) {
z, err := a.Zone(ctx, zoneID)
if err != nil {
return z, nil, err
}
recs, err := a.DB.ZoneRecordsRaw(ctx, zoneID)
if err != nil {
return z, nil, Internal(err, "The zone records could not be loaded.")
}
var buf bytes.Buffer
if err := zonefile.Export(&buf, z, recs); err != nil {
return z, nil, Internal(err, "The zone file could not be generated.")
}
return z, buf.Bytes(), nil
}
+120
View File
@@ -0,0 +1,120 @@
// Package auditlog records administrative changes made through the web UI, the
// REST API and the CLI.
//
// Audit writes are rare compared to DNS queries, so they are synchronous and
// best-effort: a failure to record an audit entry is logged but never fails the
// operation the administrator asked for.
package auditlog
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
)
// Sources an audit entry can originate from.
const (
SourceWeb = "web"
SourceAPI = "api"
SourceCLI = "cli"
SourceSystem = "system"
)
// Object types recorded in the audit log.
const (
ObjectZone = "zone"
ObjectRecord = "record"
ObjectNetwork = "network"
ObjectPolicy = "policy"
ObjectList = "list"
ObjectDomain = "domain"
ObjectSettings = "settings"
ObjectCache = "cache"
ObjectAdmin = "admin"
ObjectToken = "api_token"
ObjectBackup = "backup"
ObjectConfig = "config"
ObjectQueryLog = "query_log"
)
// Logger writes audit entries.
type Logger struct {
db *database.DB
log *slog.Logger
}
// New creates an audit logger.
func New(db *database.DB, log *slog.Logger) *Logger {
return &Logger{db: db, log: log}
}
// Actor identifies who performed an action and from where.
type Actor struct {
Name string
Source string
ClientIP string
}
// SystemActor is used for changes the server makes on its own behalf.
func SystemActor() Actor { return Actor{Name: "system", Source: SourceSystem} }
// CLIActor is used for changes made through the command line.
func CLIActor() Actor { return Actor{Name: "cli", Source: SourceCLI} }
// Record writes an audit entry. Secrets must never be passed in details: this
// is enforced by convention at the call sites, which pass names and counts
// rather than values.
func (l *Logger) Record(ctx context.Context, a Actor, action, objectType, objectID, objectName, details string) {
if l == nil || l.db == nil {
return
}
e := models.AuditEntry{
Actor: a.Name,
Source: defaultString(a.Source, SourceWeb),
ClientIP: a.ClientIP,
Action: action,
ObjectType: objectType,
ObjectID: objectID,
ObjectName: objectName,
Details: truncate(details, 2000),
}
if err := l.db.InsertAudit(ctx, e); err != nil {
l.log.Error("could not write audit entry", "error", err, "action", action)
}
}
// RecordID is Record with an integer object ID.
func (l *Logger) RecordID(ctx context.Context, a Actor, action, objectType string, id int64, objectName, details string) {
l.Record(ctx, a, action, objectType, strconv.FormatInt(id, 10), objectName, details)
}
// Changes renders a set of field changes as an audit detail string.
func Changes(pairs ...string) string {
if len(pairs)%2 != 0 {
return strings.Join(pairs, " ")
}
var parts []string
for i := 0; i < len(pairs); i += 2 {
parts = append(parts, fmt.Sprintf("%s=%s", pairs[i], pairs[i+1]))
}
return strings.Join(parts, ", ")
}
func defaultString(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+277
View File
@@ -0,0 +1,277 @@
package auth
import (
"strings"
"testing"
"time"
)
// defaultTestTTL is long enough that nothing expires mid-test.
const defaultTestTTL = 5 * time.Minute
func TestPasswordHashingRoundTrip(t *testing.T) {
const password = "correct horse battery staple"
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash: %v", err)
}
if strings.Contains(hash, password) {
t.Fatal("the hash contains the plaintext password")
}
if !strings.HasPrefix(hash, "$argon2id$") {
t.Errorf("hash = %q, want the Argon2id PHC format", hash)
}
ok, err := VerifyPassword(hash, password)
if err != nil {
t.Fatalf("verify: %v", err)
}
if !ok {
t.Error("the correct password did not verify")
}
ok, err = VerifyPassword(hash, "wrong password entirely")
if err != nil {
t.Fatalf("verify wrong: %v", err)
}
if ok {
t.Error("an incorrect password verified")
}
}
func TestHashesAreSalted(t *testing.T) {
a, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
b, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
if a == b {
t.Error("two hashes of the same password are identical; the salt is not random")
}
}
func TestVerifyRejectsMalformedHashes(t *testing.T) {
for _, bad := range []string{
"", "not-a-hash", "$argon2id$", "$argon2id$v=19$m=1$x$y",
"$bcrypt$v=19$m=65536,t=3,p=2$c2FsdA$aGFzaA",
} {
if _, err := VerifyPassword(bad, "password"); err == nil {
t.Errorf("VerifyPassword(%q) returned no error for a malformed hash", bad)
}
}
}
func TestValidatePassword(t *testing.T) {
tests := []struct {
name string
in string
wantErr bool
}{
{"long enough", "a-perfectly-fine-phrase", false},
{"exactly the minimum", strings.Repeat("x", 12), true}, // repeated character
{"mixed at the minimum", "aB3$xY9!zQ2w", false},
{"too short", "short", true},
{"empty", "", true},
{"contains password", "mypassword123456", true},
{"contains the product name", "vibedns-is-great-here", true},
{"single repeated character", strings.Repeat("a", 20), true},
{"control character", "abcdefghijkl\x00mnop", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidatePassword(tc.in)
if tc.wantErr && err == nil {
t.Error("expected an error, got none")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestGeneratePassword(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 50; i++ {
p, err := GeneratePassword(20)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(p) != 20 {
t.Fatalf("length = %d, want 20", len(p))
}
if seen[p] {
t.Fatal("generated the same password twice")
}
seen[p] = true
if err := ValidatePassword(p); err != nil {
t.Errorf("a generated password failed the policy: %v", err)
}
}
// Short requests are raised to a safe floor rather than honoured.
p, _ := GeneratePassword(4)
if len(p) < 12 {
t.Errorf("short request produced %d characters, want at least 12", len(p))
}
}
func TestTokenGeneration(t *testing.T) {
tok, err := GenerateToken()
if err != nil {
t.Fatalf("generate: %v", err)
}
if !strings.HasPrefix(tok.Secret, "vibedns_") {
t.Errorf("secret = %q, want the vibedns_ prefix for secret scanners", tok.Secret)
}
if len(tok.Prefix) != TokenPrefixLen {
t.Errorf("prefix length = %d, want %d", len(tok.Prefix), TokenPrefixLen)
}
if strings.Contains(tok.Hash, tok.Secret) {
t.Error("the stored hash contains the secret")
}
if !VerifyToken(tok.Hash, tok.Secret) {
t.Error("the generated token did not verify against its own hash")
}
if VerifyToken(tok.Hash, "vibedns_someothervalue") {
t.Error("a different token verified against the hash")
}
got, err := TokenPrefix(tok.Secret)
if err != nil {
t.Fatalf("prefix: %v", err)
}
if got != tok.Prefix {
t.Errorf("extracted prefix = %q, want %q", got, tok.Prefix)
}
}
func TestTokensAreUnique(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
tok, err := GenerateToken()
if err != nil {
t.Fatal(err)
}
if seen[tok.Secret] {
t.Fatal("generated the same token twice")
}
seen[tok.Secret] = true
}
}
func TestTokenPrefixRejectsShortInput(t *testing.T) {
if _, err := TokenPrefix("vibedns_ab"); err == nil {
t.Error("expected an error for a truncated token")
}
}
func TestCSRFTokenLifecycle(t *testing.T) {
a := New(nil, nil, []byte("a-test-signing-key-of-sufficient-length"))
token := a.IssueCSRFToken("admin")
if token == "" {
t.Fatal("no token issued")
}
if !a.ValidateCSRFToken(token, "admin") {
t.Error("a freshly issued token did not validate")
}
// A token is bound to the account it was issued for.
if a.ValidateCSRFToken(token, "someone-else") {
t.Error("a token validated for a different account")
}
if a.ValidateCSRFToken("garbage", "admin") {
t.Error("a garbage token validated")
}
if a.ValidateCSRFToken("", "admin") {
t.Error("an empty token validated")
}
// A token signed with a different key must not validate.
other := New(nil, nil, []byte("a-completely-different-signing-key-xx"))
if other.ValidateCSRFToken(token, "admin") {
t.Error("a token validated under a different signing key")
}
}
func TestNeedsRehash(t *testing.T) {
current, err := HashPassword("some password here")
if err != nil {
t.Fatal(err)
}
if NeedsRehash(current) {
t.Error("a hash produced with the current parameters should not need rehashing")
}
// A hash with weaker parameters should be upgraded on next sign-in.
weak := "$argon2id$v=19$m=1024,t=1,p=1$c2FsdHNhbHQ$aGFzaGhhc2hoYXNoaGFzaA"
if !NeedsRehash(weak) {
t.Error("a weak hash should be flagged for rehashing")
}
if !NeedsRehash("not-a-hash") {
t.Error("an unparseable hash should be flagged for rehashing")
}
}
func TestCredentialCache(t *testing.T) {
c := newCredentialCache(defaultTestTTL)
const user, pass, hash = "admin", "the password", "stored-hash-value"
if c.valid(user, pass, hash) {
t.Error("an empty cache reported a valid credential")
}
c.store(user, pass, hash)
if !c.valid(user, pass, hash) {
t.Error("a stored credential did not validate")
}
if c.valid(user, "wrong password", hash) {
t.Error("a wrong password validated against the cache")
}
// A changed stored hash means the password was rotated: the cached entry
// must stop being authoritative immediately.
if c.valid(user, pass, "a-different-stored-hash") {
t.Error("the cache validated against a stale password hash")
}
c.reset()
if c.valid(user, pass, hash) {
t.Error("the cache still validated after being reset")
}
}
func TestAttemptLimiter(t *testing.T) {
l := newAttemptLimiter(3, defaultTestTTL)
const key = "192.0.2.1"
if !l.allow(key) {
t.Fatal("a fresh address was blocked")
}
for i := 0; i < 3; i++ {
l.fail(key)
}
if l.allow(key) {
t.Error("the address should be locked out after reaching the failure limit")
}
// A different address is unaffected.
if !l.allow("192.0.2.2") {
t.Error("an unrelated address was locked out")
}
// A success clears the record.
l2 := newAttemptLimiter(3, defaultTestTTL)
l2.fail(key)
l2.fail(key)
l2.succeed(key)
l2.fail(key)
if !l2.allow(key) {
t.Error("a successful sign-in should reset the failure count")
}
}
+504
View File
@@ -0,0 +1,504 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
"net/netip"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/netutil"
)
// Realm is the HTTP Basic authentication realm.
const Realm = "vibedns management"
// PrincipalKind distinguishes an interactive administrator from automation.
type PrincipalKind string
const (
KindAdmin PrincipalKind = "admin"
KindToken PrincipalKind = "token"
)
// Principal is the authenticated identity attached to a request.
type Principal struct {
Name string
Kind PrincipalKind
TokenID int64
ClientIP string
}
// IsAdmin reports whether the principal is the interactive administrator.
func (p Principal) IsAdmin() bool { return p.Kind == KindAdmin }
type ctxKey struct{}
// WithPrincipal stores a principal on a request context.
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, ctxKey{}, p)
}
// PrincipalFrom retrieves the principal from a request context.
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(ctxKey{}).(Principal)
return p, ok
}
// Authenticator verifies credentials for the web UI and the REST API.
type Authenticator struct {
db *database.DB
log *slog.Logger
csrfKey []byte
verifier *credentialCache
attempts *attemptLimiter
// trusted lists proxies whose X-Forwarded-For header we believe.
trustedMu sync.RWMutex
trusted *netutil.PrefixSet
}
// New creates an authenticator. csrfKey must be a stable secret; it is
// persisted so that tokens issued before a restart stay valid.
func New(db *database.DB, log *slog.Logger, csrfKey []byte) *Authenticator {
return &Authenticator{
db: db,
log: log,
csrfKey: csrfKey,
verifier: newCredentialCache(5 * time.Minute),
attempts: newAttemptLimiter(10, 5*time.Minute),
trusted: netutil.NewPrefixSet(nil),
}
}
// SetTrustedProxies configures which peers may set X-Forwarded-For.
func (a *Authenticator) SetTrustedProxies(cidrs []string) {
a.trustedMu.Lock()
a.trusted = netutil.NewPrefixSet(cidrs)
a.trustedMu.Unlock()
}
// ClientIP resolves the client address, honouring X-Forwarded-For only when the
// immediate peer is a configured trusted proxy. Trusting the header
// unconditionally would let any client forge its own address and bypass the
// login rate limiter.
func (a *Authenticator) ClientIP(r *http.Request) string {
peer, ok := netutil.AddrFromHostPort(r.RemoteAddr)
if !ok {
return r.RemoteAddr
}
a.trustedMu.RLock()
trusted := a.trusted
a.trustedMu.RUnlock()
if trusted.Contains(peer) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// The left-most entry is the original client.
first := strings.TrimSpace(strings.Split(xff, ",")[0])
if addr, err := netip.ParseAddr(first); err == nil {
return addr.Unmap().String()
}
}
if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
if addr, err := netip.ParseAddr(xr); err == nil {
return addr.Unmap().String()
}
}
}
return peer.String()
}
// Errors returned by credential verification.
var (
ErrUnauthorised = errors.New("authentication required")
ErrLockedOut = errors.New("too many failed sign-in attempts")
ErrNoAdmin = errors.New("no administrator account exists")
)
// Authenticate verifies the credentials on a request.
//
// It accepts either HTTP Basic credentials (the interactive administrator) or
// a bearer API token. Tokens are rejected for the HTML interface by the caller,
// which passes allowTokens=false.
func (a *Authenticator) Authenticate(r *http.Request, allowTokens bool) (Principal, error) {
clientIP := a.ClientIP(r)
if !a.attempts.allow(clientIP) {
return Principal{}, ErrLockedOut
}
if allowTokens {
if secret, ok := bearerToken(r); ok {
p, err := a.verifyToken(r.Context(), secret, clientIP)
if err != nil {
a.attempts.fail(clientIP)
return Principal{}, err
}
a.attempts.succeed(clientIP)
return p, nil
}
}
username, password, ok := r.BasicAuth()
if !ok {
return Principal{}, ErrUnauthorised
}
p, err := a.verifyPassword(r.Context(), username, password, clientIP)
if err != nil {
a.attempts.fail(clientIP)
a.log.Warn("failed sign-in attempt", "username", username, "client", clientIP)
return Principal{}, err
}
a.attempts.succeed(clientIP)
return p, nil
}
func (a *Authenticator) verifyPassword(ctx context.Context, username, password, clientIP string) (Principal, error) {
admin, err := a.db.Admin(ctx)
if errors.Is(err, database.ErrNotFound) {
return Principal{}, ErrNoAdmin
}
if err != nil {
return Principal{}, fmt.Errorf("load administrator: %w", err)
}
// Compare the username in constant time so it cannot be probed by timing.
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(admin.Username)) == 1
// HTTP Basic sends credentials on every request, including every page load.
// Running Argon2id each time would cost 64 MiB and tens of milliseconds per
// request, so a successful verification is remembered briefly, keyed by a
// MAC of the password rather than the password itself.
if userOK && a.verifier.valid(username, password, admin.PasswordHash) {
return Principal{Name: admin.Username, Kind: KindAdmin, ClientIP: clientIP}, nil
}
passOK, err := VerifyPassword(admin.PasswordHash, password)
if err != nil {
a.log.Error("stored administrator password hash is unusable", "error", err)
return Principal{}, ErrUnauthorised
}
if !userOK || !passOK {
return Principal{}, ErrUnauthorised
}
a.verifier.store(username, password, admin.PasswordHash)
_ = a.db.TouchAdminLogin(ctx)
return Principal{Name: admin.Username, Kind: KindAdmin, ClientIP: clientIP}, nil
}
func (a *Authenticator) verifyToken(ctx context.Context, secret, clientIP string) (Principal, error) {
prefix, err := TokenPrefix(secret)
if err != nil {
return Principal{}, ErrUnauthorised
}
candidates, err := a.db.APITokensByPrefix(ctx, prefix)
if err != nil {
return Principal{}, fmt.Errorf("look up API token: %w", err)
}
for _, c := range candidates {
if VerifyToken(c.Hash, secret) {
go func(id int64) {
tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = a.db.TouchAPIToken(tctx, id)
}(c.ID)
return Principal{Name: c.Name, Kind: KindToken, TokenID: c.ID, ClientIP: clientIP}, nil
}
}
return Principal{}, ErrUnauthorised
}
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")), true
}
if v := r.Header.Get("X-API-Token"); v != "" {
return strings.TrimSpace(v), true
}
return "", false
}
// InvalidateCredentials clears the verification cache. It is called after a
// password change so the old password stops working immediately.
func (a *Authenticator) InvalidateCredentials() { a.verifier.reset() }
// --- credential cache ---------------------------------------------------
type cachedCred struct {
mac []byte
hashSeen string
expires time.Time
}
type credentialCache struct {
mu sync.RWMutex
key []byte
ttl time.Duration
items map[string]cachedCred
}
func newCredentialCache(ttl time.Duration) *credentialCache {
key := make([]byte, 32)
// A failure here is not fatal: an all-zero key only weakens the cache
// index, which never leaves this process and is not a stored secret.
if s, err := RandomKey(32); err == nil {
copy(key, s)
}
return &credentialCache{key: key, ttl: ttl, items: map[string]cachedCred{}}
}
func (c *credentialCache) mac(password string) []byte {
h := hmac.New(sha256.New, c.key)
h.Write([]byte(password))
return h.Sum(nil)
}
func (c *credentialCache) valid(username, password, currentHash string) bool {
c.mu.RLock()
item, ok := c.items[username]
c.mu.RUnlock()
if !ok || time.Now().After(item.expires) {
return false
}
// A changed stored hash means the password was rotated; the cache entry is
// no longer authoritative.
if item.hashSeen != currentHash {
return false
}
return hmac.Equal(item.mac, c.mac(password))
}
func (c *credentialCache) store(username, password, currentHash string) {
c.mu.Lock()
c.items[username] = cachedCred{
mac: c.mac(password),
hashSeen: currentHash,
expires: time.Now().Add(c.ttl),
}
c.mu.Unlock()
}
func (c *credentialCache) reset() {
c.mu.Lock()
c.items = map[string]cachedCred{}
c.mu.Unlock()
}
// --- failed attempt limiting -------------------------------------------
type attemptState struct {
failures int
until time.Time
last time.Time
}
// attemptLimiter slows down credential guessing per source address.
type attemptLimiter struct {
mu sync.Mutex
items map[string]*attemptState
max int
lockout time.Duration
lastGC time.Time
}
func newAttemptLimiter(max int, lockout time.Duration) *attemptLimiter {
return &attemptLimiter{items: map[string]*attemptState{}, max: max, lockout: lockout}
}
func (l *attemptLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
l.gcLocked()
st, ok := l.items[key]
if !ok {
return true
}
if time.Now().Before(st.until) {
return false
}
return true
}
func (l *attemptLimiter) fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.items[key]
if !ok {
st = &attemptState{}
l.items[key] = st
}
st.failures++
st.last = time.Now()
if st.failures >= l.max {
st.until = time.Now().Add(l.lockout)
st.failures = 0
}
}
func (l *attemptLimiter) succeed(key string) {
l.mu.Lock()
delete(l.items, key)
l.mu.Unlock()
}
// gcLocked drops stale entries so the map cannot grow without bound.
func (l *attemptLimiter) gcLocked() {
now := time.Now()
if now.Sub(l.lastGC) < time.Minute {
return
}
l.lastGC = now
for k, st := range l.items {
if now.After(st.until) && now.Sub(st.last) > l.lockout {
delete(l.items, k)
}
}
}
// --- CSRF ---------------------------------------------------------------
// CSRFCookieName is the double-submit cookie the browser echoes back.
const CSRFCookieName = "vibedns_csrf"
// CSRFFieldName is the form field carrying the token.
const CSRFFieldName = "_csrf"
// CSRFHeaderName is the header carrying the token for fetch() calls.
const CSRFHeaderName = "X-CSRF-Token"
const csrfTokenTTL = 12 * time.Hour
// IssueCSRFToken mints a token bound to a user and an expiry.
//
// HTTP Basic credentials are replayed by the browser on every request,
// including cross-site form posts, so Basic auth alone does not protect
// state-changing requests. The token is signed, tied to the account, and
// double-submitted: an attacker on another origin can neither read the cookie
// nor forge the signature.
func (a *Authenticator) IssueCSRFToken(username string) string {
expiry := time.Now().Add(csrfTokenTTL).Unix()
payload := fmt.Sprintf("%s|%d", username, expiry)
mac := a.csrfMAC(payload)
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + mac))
}
func (a *Authenticator) csrfMAC(payload string) string {
h := hmac.New(sha256.New, a.csrfKey)
h.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// ValidateCSRFToken checks a token's signature, expiry and account binding.
func (a *Authenticator) ValidateCSRFToken(token, username string) bool {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return false
}
parts := strings.Split(string(raw), "|")
if len(parts) != 3 {
return false
}
payload := parts[0] + "|" + parts[1]
if !hmac.Equal([]byte(a.csrfMAC(payload)), []byte(parts[2])) {
return false
}
if parts[0] != username {
return false
}
var expiry int64
if _, err := fmt.Sscanf(parts[1], "%d", &expiry); err != nil {
return false
}
return time.Now().Unix() < expiry
}
// SetCSRFCookie writes the double-submit cookie.
func SetCSRFCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{
Name: CSRFCookieName,
Value: token,
Path: "/",
HttpOnly: false, // the page's JavaScript reads it for fetch() calls
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: int(csrfTokenTTL / time.Second),
})
}
// CheckCSRF validates a state-changing browser request.
//
// The submitted token must be present, correctly signed for this account, and
// identical to the cookie value.
func (a *Authenticator) CheckCSRF(r *http.Request, p Principal) error {
// API tokens are not sent automatically by browsers, so a request
// authenticated by one cannot be cross-site forged.
if p.Kind == KindToken {
return nil
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return nil
}
submitted := r.Header.Get(CSRFHeaderName)
if submitted == "" {
submitted = r.PostFormValue(CSRFFieldName)
}
if submitted == "" {
return errors.New("this request is missing its CSRF token; reload the page and try again")
}
cookie, err := r.Cookie(CSRFCookieName)
if err != nil || cookie.Value == "" {
return errors.New("the CSRF cookie is missing; make sure cookies are enabled, then reload the page")
}
if subtle.ConstantTimeCompare([]byte(submitted), []byte(cookie.Value)) != 1 {
return errors.New("the CSRF token does not match; reload the page and try again")
}
if !a.ValidateCSRFToken(submitted, p.Name) {
return errors.New("the CSRF token has expired; reload the page and try again")
}
return nil
}
// EnsureAdmin creates the administrator account if one does not exist,
// returning the generated password when it had to invent one.
func (a *Authenticator) EnsureAdmin(ctx context.Context, username, password string) (created bool, generated string, err error) {
if _, err := a.db.Admin(ctx); err == nil {
return false, "", nil
} else if !errors.Is(err, database.ErrNotFound) {
return false, "", err
}
mustChange := false
if password == "" {
password, err = GeneratePassword(20)
if err != nil {
return false, "", err
}
generated = password
mustChange = true
}
hash, err := HashPassword(password)
if err != nil {
return false, "", err
}
if err := a.db.CreateAdmin(ctx, username, hash, mustChange); err != nil {
return false, "", err
}
return true, generated, nil
}
// Admin returns the administrator record.
func (a *Authenticator) Admin(ctx context.Context) (models.Admin, error) { return a.db.Admin(ctx) }
+176
View File
@@ -0,0 +1,176 @@
// Package auth handles administrator credentials, API tokens, CSRF protection
// and the HTTP middleware that enforces them.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"runtime"
"strings"
"unicode"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters.
//
// 64 MiB with three passes is the interactive profile from the Argon2 RFC:
// costly enough that an offline attack on a stolen hash is expensive, cheap
// enough that a login takes well under a second on the small machines this
// server is meant to run on.
const (
argonTime = 3
argonMemory = 64 * 1024 // KiB
argonKeyLen = 32
argonSaltLen = 16
)
func argonThreads() uint8 {
n := runtime.NumCPU()
if n > 4 {
n = 4
}
if n < 1 {
n = 1
}
return uint8(n)
}
// ErrInvalidHash is returned when a stored hash cannot be parsed.
var ErrInvalidHash = errors.New("stored password hash is malformed")
// HashPassword derives an Argon2id hash in the standard PHC string format, so
// the parameters travel with the hash and can be raised later without
// invalidating existing credentials.
func HashPassword(password string) (string, error) {
if password == "" {
return "", errors.New("password must not be empty")
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
threads := argonThreads()
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, threads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// VerifyPassword checks a password against a stored PHC hash in constant time.
func VerifyPassword(encoded, password string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, ErrInvalidHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, ErrInvalidHash
}
if version != argon2.Version {
return false, fmt.Errorf("%w: unsupported Argon2 version %d", ErrInvalidHash, version)
}
var memory, time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, ErrInvalidHash
}
salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4])
if err != nil {
return false, ErrInvalidHash
}
want, err := base64.RawStdEncoding.Strict().DecodeString(parts[5])
if err != nil {
return false, ErrInvalidHash
}
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// NeedsRehash reports whether a stored hash uses weaker parameters than the
// current policy, so it can be upgraded on the next successful login.
func NeedsRehash(encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return true
}
var memory, time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return true
}
return memory < argonMemory || time < argonTime
}
// passwordAlphabet avoids characters that are easy to confuse when a generated
// password is read off a terminal and typed into a browser.
const passwordAlphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// GeneratePassword returns a cryptographically random password.
func GeneratePassword(length int) (string, error) {
if length < 12 {
length = 12
}
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate password: %w", err)
}
out := make([]byte, length)
for i, b := range buf {
out[i] = passwordAlphabet[int(b)%len(passwordAlphabet)]
}
return string(out), nil
}
// MinPasswordLength is the shortest password the UI will accept.
const MinPasswordLength = 12
// ValidatePassword enforces a modest password policy. It follows current NIST
// guidance: length carries the weight, and arbitrary composition rules are
// avoided in favour of rejecting obviously weak choices.
func ValidatePassword(password string) error {
if len(password) < MinPasswordLength {
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
}
if len(password) > 1024 {
return errors.New("password must be at most 1024 characters")
}
for _, r := range password {
if unicode.IsControl(r) {
return errors.New("password must not contain control characters")
}
}
lower := strings.ToLower(password)
for _, weak := range []string{"password", "12345678", "qwerty", "vibedns", "changeme", "vibedns"} {
if strings.Contains(lower, weak) {
return fmt.Errorf("password must not contain the common string %q", weak)
}
}
if isSingleRepeatedRune(password) {
return errors.New("password must not be a single repeated character")
}
return nil
}
func isSingleRepeatedRune(s string) bool {
if s == "" {
return false
}
first := rune(s[0])
for _, r := range s {
if r != first {
return false
}
}
return true
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// TokenPrefixLen is how many characters of a token are stored in the clear to
// narrow the database lookup. It is not a secret: it only identifies which row
// to compare against.
const TokenPrefixLen = 8
// tokenLabel prefixes every issued token so a leaked string is recognisable in
// logs and secret scanners.
const tokenLabel = "vibedns_"
// Token is a freshly minted API credential.
type Token struct {
Secret string // shown to the operator exactly once
Prefix string // stored in the clear, used to find the row
Hash string // stored, never reversible
}
// GenerateToken creates a 256-bit API token.
func GenerateToken() (Token, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return Token{}, fmt.Errorf("generate API token: %w", err)
}
body := base64.RawURLEncoding.EncodeToString(buf)
secret := tokenLabel + body
return Token{
Secret: secret,
Prefix: body[:TokenPrefixLen],
Hash: HashToken(secret),
}, nil
}
// HashToken hashes an API token with SHA-256.
//
// Unlike a human-chosen password, an API token is 256 bits of output from a
// CSPRNG, so there is no low-entropy guess space for an attacker to search: a
// fast hash is sufficient and, unlike Argon2id, can be computed on every API
// request without adding tens of milliseconds and 64 MiB of allocation to each
// one.
func HashToken(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// TokenPrefix extracts the lookup prefix from a presented token.
func TokenPrefix(secret string) (string, error) {
body := strings.TrimPrefix(secret, tokenLabel)
if len(body) < TokenPrefixLen {
return "", errors.New("API token is malformed")
}
return body[:TokenPrefixLen], nil
}
// VerifyToken compares a presented token against a stored hash in constant
// time.
func VerifyToken(storedHash, secret string) bool {
got := HashToken(secret)
return subtle.ConstantTimeCompare([]byte(got), []byte(storedHash)) == 1
}
// RandomKey returns n cryptographically random bytes, base64 encoded. It backs
// the CSRF signing key.
func RandomKey(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate random key: %w", err)
}
return base64.RawStdEncoding.EncodeToString(buf), nil
}
+322
View File
@@ -0,0 +1,322 @@
package authoritative
import (
"strings"
"github.com/miekg/dns"
)
// maxCNAMEChain bounds in-zone CNAME following so a loop cannot hang a query.
const maxCNAMEChain = 12
// Answer builds an authoritative reply for the question in req.
//
// It returns nil when no configured zone covers the question, which tells the
// caller to fall through to the cache and the recursive resolver.
func (idx *Index) Answer(req *dns.Msg, do bool) *dns.Msg {
if idx == nil || len(req.Question) == 0 {
return nil
}
q := req.Question[0]
if q.Qclass != dns.ClassINET && q.Qclass != dns.ClassANY {
return nil
}
z := idx.Lookup(q.Name)
if z == nil {
return nil
}
return z.Answer(req, do)
}
// Answer builds an authoritative reply for req from this zone.
func (z *Zone) Answer(req *dns.Msg, do bool) *dns.Msg {
q := req.Question[0]
qname := strings.ToLower(dns.Fqdn(q.Name))
qtype := q.Qtype
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
m.Compress = true
// A delegation between the apex and the queried name means the answer
// belongs to a child zone: return a referral rather than our own data.
if z.hasDelegations {
if dp := z.delegationFor(qname, qtype); dp != "" {
z.writeReferral(m, dp, do)
return m
}
}
name := qname
for depth := 0; depth < maxCNAMEChain; depth++ {
node, synthesised := z.resolveNode(name)
if node == nil {
// The name has no data. Distinguish "exists but no records of this
// type" (NODATA) from "does not exist at all" (NXDOMAIN).
if _, exists := z.ents[name]; exists || depth > 0 {
z.writeNoData(m, do)
} else {
m.Rcode = dns.RcodeNameError
z.writeNoData(m, do)
}
return m
}
// A CNAME is followed unless the client asked for the CNAME itself.
if cnames := node.types[dns.TypeCNAME]; len(cnames) > 0 && qtype != dns.TypeCNAME && qtype != dns.TypeANY {
rr := materialise(cnames[0], name, synthesised)
m.Answer = append(m.Answer, rr)
z.appendSignatures(m, node, dns.TypeCNAME, name, synthesised, do)
target := strings.ToLower(rr.(*dns.CNAME).Target)
if !dns.IsSubDomain(z.Name, target) {
// The chain leaves our zone; the client (or the recursor in
// front of it) has to continue from here.
m.Authoritative = true
return m
}
name = target
continue
}
if qtype == dns.TypeANY {
for _, t := range node.typeList() {
for _, rr := range node.types[t] {
m.Answer = append(m.Answer, materialise(rr, name, synthesised))
}
}
if len(m.Answer) == 0 {
z.writeNoData(m, do)
} else {
z.addAuthorityNS(m, do)
}
return m
}
rrs := node.types[qtype]
if len(rrs) == 0 {
z.writeNoData(m, do)
return m
}
for _, rr := range rrs {
m.Answer = append(m.Answer, materialise(rr, name, synthesised))
}
z.appendSignatures(m, node, qtype, name, synthesised, do)
z.addAuthorityNS(m, do)
z.addAdditional(m, do)
return m
}
// Chain too long: return what we have rather than looping.
return m
}
// resolveNode finds the RRsets for a name, falling back to wildcard synthesis
// using the RFC 4592 closest-encloser rule.
func (z *Zone) resolveNode(name string) (node *nameNode, synthesised bool) {
if n, ok := z.names[name]; ok {
return n, false
}
if _, exists := z.ents[name]; exists {
return nil, false // empty non-terminal: exists, but holds no data
}
if len(z.wildcards) == 0 {
return nil, false
}
ce := z.closestEncloser(name)
if wn, ok := z.wildcards["*."+ce]; ok {
return wn, true
}
return nil, false
}
// closestEncloser returns the deepest ancestor of name that exists in the zone.
func (z *Zone) closestEncloser(name string) string {
n := name
for {
if n == z.Name {
return z.Name
}
i, end := dns.NextLabel(n, 0)
if end {
return z.Name
}
n = n[i:]
if !dns.IsSubDomain(z.Name, n) {
return z.Name
}
if _, ok := z.ents[n]; ok {
return n
}
}
}
// delegationFor returns the deepest delegation point at or above qname, or "".
// A DS query at the delegation point itself is answered from the parent side,
// so it is not treated as a referral.
func (z *Zone) delegationFor(qname string, qtype uint16) string {
n := qname
for {
if n == z.Name || !dns.IsSubDomain(z.Name, n) {
return ""
}
if _, ok := z.delegations[n]; ok {
if n == qname && qtype == dns.TypeDS {
return ""
}
return n
}
i, end := dns.NextLabel(n, 0)
if end {
return ""
}
n = n[i:]
}
}
// writeReferral fills the authority section with the child zone's NS records
// and the additional section with any in-zone glue.
func (z *Zone) writeReferral(m *dns.Msg, delegation string, do bool) {
m.Authoritative = false
node := z.delegations[delegation]
if node == nil {
return
}
for _, rr := range node.types[dns.TypeNS] {
m.Ns = append(m.Ns, dns.Copy(rr))
}
// A signed delegation carries a DS RRset (or a proof of its absence).
if dsNode, ok := z.names[delegation]; ok && do {
for _, rr := range dsNode.types[dns.TypeDS] {
m.Ns = append(m.Ns, dns.Copy(rr))
}
for _, rr := range dsNode.types[dns.TypeRRSIG] {
if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == dns.TypeDS {
m.Ns = append(m.Ns, dns.Copy(rr))
}
}
}
z.addGlueFor(m, m.Ns)
}
// writeNoData puts the SOA in the authority section, which is what tells a
// resolver how long to cache the negative answer.
func (z *Zone) writeNoData(m *dns.Msg, do bool) {
if z.soa == nil {
return
}
soa := dns.Copy(z.soa).(*dns.SOA)
// RFC 2308: the negative caching TTL is the lesser of the SOA TTL and the
// SOA MINIMUM field.
if soa.Minttl < soa.Hdr.Ttl {
soa.Hdr.Ttl = soa.Minttl
}
m.Ns = append(m.Ns, soa)
if do {
if apex, ok := z.names[z.Name]; ok {
for _, rr := range apex.types[dns.TypeRRSIG] {
if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == dns.TypeSOA {
m.Ns = append(m.Ns, dns.Copy(rr))
}
}
}
}
}
// addAuthorityNS adds the zone's NS RRset to a positive answer, except when the
// answer already is that RRset.
func (z *Zone) addAuthorityNS(m *dns.Msg, do bool) {
if len(m.Answer) == 0 || len(z.ns) == 0 {
return
}
if h := m.Answer[0].Header(); h.Rrtype == dns.TypeNS && h.Name == z.Name {
return
}
if h := m.Answer[0].Header(); h.Rrtype == dns.TypeSOA {
return
}
for _, rr := range z.ns {
m.Ns = append(m.Ns, dns.Copy(rr))
}
if do {
if apex, ok := z.names[z.Name]; ok {
for _, rr := range apex.types[dns.TypeRRSIG] {
if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == dns.TypeNS {
m.Ns = append(m.Ns, dns.Copy(rr))
}
}
}
}
}
// addAdditional supplies address records for names referenced by the answer,
// saving the client a follow-up query.
func (z *Zone) addAdditional(m *dns.Msg, do bool) {
z.addGlueFor(m, m.Answer)
z.addGlueFor(m, m.Ns)
}
func (z *Zone) addGlueFor(m *dns.Msg, section []dns.RR) {
seen := map[string]bool{}
for _, rr := range m.Extra {
seen[strings.ToLower(rr.Header().Name)] = true
}
for _, rr := range section {
var target string
switch v := rr.(type) {
case *dns.MX:
target = v.Mx
case *dns.SRV:
target = v.Target
case *dns.NS:
target = v.Ns
default:
continue
}
target = strings.ToLower(dns.Fqdn(target))
if target == "" || seen[target] || !dns.IsSubDomain(z.Name, target) {
continue
}
node, ok := z.names[target]
if !ok {
continue
}
seen[target] = true
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
for _, arr := range node.types[t] {
m.Extra = append(m.Extra, dns.Copy(arr))
}
}
}
}
// appendSignatures adds the RRSIGs covering an RRset when the client set DO.
//
// Zones served here are not signed by this application; signatures are only
// present when a pre-signed zone file was imported. Serving them unchanged
// keeps such zones verifiable, and leaves room for an in-process signer later.
func (z *Zone) appendSignatures(m *dns.Msg, node *nameNode, covered uint16, name string, synthesised, do bool) {
if !do {
return
}
for _, rr := range node.types[dns.TypeRRSIG] {
sig, ok := rr.(*dns.RRSIG)
if !ok || sig.TypeCovered != covered {
continue
}
m.Answer = append(m.Answer, materialise(rr, name, synthesised))
}
}
// materialise copies an RR, rewriting the owner name when the record came from
// a wildcard node.
func materialise(rr dns.RR, owner string, synthesised bool) dns.RR {
c := dns.Copy(rr)
if synthesised {
c.Header().Name = owner
}
return c
}
+266
View File
@@ -0,0 +1,266 @@
package authoritative
import (
"testing"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
)
func ttlPtr(v uint32) *uint32 { return &v }
// testZone builds a small but representative zone: an apex, a delegation, a
// wildcard, a CNAME chain and an empty non-terminal.
func testIndex(t *testing.T) *Index {
t.Helper()
zone := models.Zone{
ID: 1, Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
Serial: 7, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 300,
}
recs := []models.Record{
{ZoneID: 1, Name: "@", Type: "NS", Data: "ns1.example.com.", Enabled: true},
{ZoneID: 1, Name: "@", Type: "A", Data: "192.0.2.1", Enabled: true},
{ZoneID: 1, Name: "ns1", Type: "A", Data: "192.0.2.53", Enabled: true},
{ZoneID: 1, Name: "www", Type: "CNAME", Data: "example.com.", Enabled: true},
{ZoneID: 1, Name: "mail", Type: "A", Data: "192.0.2.20", Enabled: true},
{ZoneID: 1, Name: "mail", Type: "AAAA", Data: "2001:db8::20", Enabled: true},
{ZoneID: 1, Name: "@", Type: "MX", Data: "10 mail.example.com.", Enabled: true},
{ZoneID: 1, Name: "*.wild", Type: "A", Data: "192.0.2.99", Enabled: true},
{ZoneID: 1, Name: "deep.ent.chain", Type: "TXT", Data: `"hello"`, Enabled: true},
{ZoneID: 1, Name: "sub", Type: "NS", Data: "ns1.sub.example.com.", Enabled: true},
{ZoneID: 1, Name: "ns1.sub", Type: "A", Data: "192.0.2.60", Enabled: true},
{ZoneID: 1, Name: "short", Type: "A", Data: "192.0.2.7", TTL: ttlPtr(60), Enabled: true},
}
idx, problems := Build([]models.Zone{zone}, map[int64][]models.Record{1: recs})
for _, p := range problems {
t.Fatalf("unexpected build problem: %v", p)
}
return idx
}
func query(t *testing.T, idx *Index, name string, qtype uint16) *dns.Msg {
t.Helper()
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(name), qtype)
return idx.Answer(req, false)
}
func TestAnswerBasicLookups(t *testing.T) {
idx := testIndex(t)
tests := []struct {
name string
qname string
qtype uint16
rcode int
wantAns int
wantFirst string
aa bool
}{
{"apex A", "example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.1", true},
{"host A", "mail.example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.20", true},
{"host AAAA", "mail.example.com.", dns.TypeAAAA, dns.RcodeSuccess, 1, "2001:db8::20", true},
{"case insensitive", "MAIL.Example.COM.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.20", true},
{"nodata", "mail.example.com.", dns.TypeTXT, dns.RcodeSuccess, 0, "", true},
{"nxdomain", "nope.example.com.", dns.TypeA, dns.RcodeNameError, 0, "", true},
{"wildcard", "anything.wild.example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.99", true},
{"wildcard nodata", "anything.wild.example.com.", dns.TypeTXT, dns.RcodeSuccess, 0, "", true},
{"explicit MX", "example.com.", dns.TypeMX, dns.RcodeSuccess, 1, "mail.example.com.", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := query(t, idx, tc.qname, tc.qtype)
if m == nil {
t.Fatal("expected an authoritative answer, got none")
}
if m.Rcode != tc.rcode {
t.Errorf("rcode = %s, want %s", dns.RcodeToString[m.Rcode], dns.RcodeToString[tc.rcode])
}
if len(m.Answer) != tc.wantAns {
t.Fatalf("answer count = %d, want %d (%v)", len(m.Answer), tc.wantAns, m.Answer)
}
if m.Authoritative != tc.aa {
t.Errorf("AA = %v, want %v", m.Authoritative, tc.aa)
}
if tc.wantAns == 0 {
if len(m.Ns) == 0 {
t.Error("negative answer should carry a SOA in the authority section")
} else if _, ok := m.Ns[0].(*dns.SOA); !ok {
t.Errorf("authority section = %T, want *dns.SOA", m.Ns[0])
}
return
}
switch rr := m.Answer[0].(type) {
case *dns.A:
if rr.A.String() != tc.wantFirst {
t.Errorf("A = %s, want %s", rr.A, tc.wantFirst)
}
case *dns.AAAA:
if rr.AAAA.String() != tc.wantFirst {
t.Errorf("AAAA = %s, want %s", rr.AAAA, tc.wantFirst)
}
case *dns.MX:
if rr.Mx != tc.wantFirst {
t.Errorf("MX = %s, want %s", rr.Mx, tc.wantFirst)
}
}
})
}
}
func TestWildcardOwnerNameIsRewritten(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "host.wild.example.com.", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(m.Answer))
}
if got := m.Answer[0].Header().Name; got != "host.wild.example.com." {
t.Errorf("owner name = %q, want the queried name, not the wildcard", got)
}
}
func TestEmptyNonTerminalIsNoDataNotNXDOMAIN(t *testing.T) {
idx := testIndex(t)
// "chain.example.com." and "ent.chain.example.com." hold no records but
// exist because a name below them does.
for _, name := range []string{"chain.example.com.", "ent.chain.example.com."} {
m := query(t, idx, name, dns.TypeA)
if m.Rcode != dns.RcodeSuccess {
t.Errorf("%s: rcode = %s, want NOERROR (empty non-terminal)",
name, dns.RcodeToString[m.Rcode])
}
}
m := query(t, idx, "missing.chain.example.com.", dns.TypeA)
if m.Rcode != dns.RcodeNameError {
t.Errorf("truly missing name: rcode = %s, want NXDOMAIN", dns.RcodeToString[m.Rcode])
}
}
func TestCNAMEIsFollowedInZone(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "www.example.com.", dns.TypeA)
if len(m.Answer) != 2 {
t.Fatalf("answer count = %d, want CNAME plus target A: %v", len(m.Answer), m.Answer)
}
if _, ok := m.Answer[0].(*dns.CNAME); !ok {
t.Errorf("first answer = %T, want *dns.CNAME", m.Answer[0])
}
if a, ok := m.Answer[1].(*dns.A); !ok || a.A.String() != "192.0.2.1" {
t.Errorf("second answer = %v, want the apex A record", m.Answer[1])
}
// Asking for the CNAME itself must not follow the chain.
m = query(t, idx, "www.example.com.", dns.TypeCNAME)
if len(m.Answer) != 1 {
t.Fatalf("CNAME query answer count = %d, want 1", len(m.Answer))
}
}
func TestDelegationReturnsReferral(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "host.sub.example.com.", dns.TypeA)
if m.Authoritative {
t.Error("a referral must not set the AA bit")
}
if len(m.Answer) != 0 {
t.Errorf("referral answer section = %v, want empty", m.Answer)
}
if len(m.Ns) == 0 {
t.Fatal("referral must carry NS records in the authority section")
}
if _, ok := m.Ns[0].(*dns.NS); !ok {
t.Errorf("authority = %T, want *dns.NS", m.Ns[0])
}
var glued bool
for _, rr := range m.Extra {
if a, ok := rr.(*dns.A); ok && a.A.String() == "192.0.2.60" {
glued = true
}
}
if !glued {
t.Error("referral should include in-zone glue for the child name server")
}
}
func TestAdditionalSectionCarriesMXAddresses(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "example.com.", dns.TypeMX)
var haveA, haveAAAA bool
for _, rr := range m.Extra {
switch v := rr.(type) {
case *dns.A:
haveA = haveA || v.A.String() == "192.0.2.20"
case *dns.AAAA:
haveAAAA = haveAAAA || v.AAAA.String() == "2001:db8::20"
}
}
if !haveA || !haveAAAA {
t.Errorf("MX answer should glue the exchange addresses; extra = %v", m.Extra)
}
}
func TestSOAIsSynthesisedAndSerialUsed(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "example.com.", dns.TypeSOA)
if len(m.Answer) != 1 {
t.Fatalf("SOA answer count = %d, want 1", len(m.Answer))
}
soa, ok := m.Answer[0].(*dns.SOA)
if !ok {
t.Fatalf("answer = %T, want *dns.SOA", m.Answer[0])
}
if soa.Serial != 7 {
t.Errorf("serial = %d, want the zone serial 7", soa.Serial)
}
if soa.Mbox != `hostmaster.example.com.` {
t.Errorf("mbox = %q, want the email address in RNAME form", soa.Mbox)
}
}
func TestPerRecordTTLOverridesZoneDefault(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "short.example.com.", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(m.Answer))
}
if got := m.Answer[0].Header().Ttl; got != 60 {
t.Errorf("TTL = %d, want the per-record value 60", got)
}
m = query(t, idx, "mail.example.com.", dns.TypeA)
if got := m.Answer[0].Header().Ttl; got != 3600 {
t.Errorf("TTL = %d, want the zone default 3600", got)
}
}
func TestOutOfZoneQueryIsNotAnswered(t *testing.T) {
idx := testIndex(t)
if m := query(t, idx, "example.org.", dns.TypeA); m != nil {
t.Errorf("expected no authoritative answer for an unconfigured zone, got %v", m)
}
}
func TestDisabledZoneIsNotServed(t *testing.T) {
zone := models.Zone{ID: 1, Name: "off.example.", Enabled: false, DefaultTTL: 300}
idx, _ := Build([]models.Zone{zone}, nil)
if idx.Lookup("off.example.") != nil {
t.Error("a disabled zone must not be indexed")
}
}
func TestBuildReportsInvalidRecordsWithoutFailingTheZone(t *testing.T) {
zone := models.Zone{ID: 1, Name: "example.com.", Enabled: true, DefaultTTL: 300}
recs := []models.Record{
{ZoneID: 1, Name: "good", Type: "A", Data: "192.0.2.1", Enabled: true},
{ZoneID: 1, Name: "bad", Type: "A", Data: "not-an-address", Enabled: true},
}
idx, problems := Build([]models.Zone{zone}, map[int64][]models.Record{1: recs})
if len(problems) != 1 {
t.Fatalf("problems = %d, want 1", len(problems))
}
if m := query(t, idx, "good.example.com.", dns.TypeA); len(m.Answer) != 1 {
t.Error("a single bad record must not take the rest of the zone offline")
}
}
+354
View File
@@ -0,0 +1,354 @@
// Package authoritative builds an immutable in-memory index of the configured
// zones and answers queries from it.
//
// The index is rebuilt from SQLite whenever configuration changes and then
// swapped in atomically, so the DNS data path never touches the database and
// never takes a lock that a writer could hold.
package authoritative
import (
"fmt"
"sort"
"strings"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// nameNode holds every RRset owned by one name.
type nameNode struct {
types map[uint16][]dns.RR
}
func (n *nameNode) add(rr dns.RR) {
t := rr.Header().Rrtype
n.types[t] = append(n.types[t], rr)
}
func (n *nameNode) typeList() []uint16 {
out := make([]uint16, 0, len(n.types))
for t := range n.types {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// Zone is one compiled authoritative zone.
type Zone struct {
ID int64
Name string // normalised FQDN, e.g. "example.com."
Kind models.ZoneKind
DefaultTTL uint32
soa *dns.SOA
ns []dns.RR
names map[string]*nameNode // owner name -> RRsets
wildcards map[string]*nameNode // "*.parent." -> RRsets
// ents contains every name that exists in the zone, including empty
// non-terminals. It is what separates NXDOMAIN from NODATA.
ents map[string]struct{}
// delegations lists non-apex names that carry NS records.
delegations map[string]*nameNode
// maxDelegationDepth caps the ancestor walk when looking for a referral.
hasDelegations bool
}
// SOA returns the zone's start-of-authority record.
func (z *Zone) SOA() *dns.SOA { return z.soa }
// RecordCount reports how many RRs the compiled zone holds.
func (z *Zone) RecordCount() int {
n := 0
for _, node := range z.names {
for _, rrs := range node.types {
n += len(rrs)
}
}
for _, node := range z.wildcards {
for _, rrs := range node.types {
n += len(rrs)
}
}
return n
}
// Index maps names to the zone that is authoritative for them.
type Index struct {
zones map[string]*Zone
// maxLabels bounds the suffix walk in Lookup.
maxLabels int
}
// BuildError describes a record that could not be compiled into the index.
// These are reported to the operator but never prevent the server from
// starting: one bad record must not take the whole zone offline.
type BuildError struct {
ZoneID int64
ZoneName string
RecordID int64
Name string
Type string
Err error
}
func (e BuildError) Error() string {
return fmt.Sprintf("zone %s record %s %s: %v", e.ZoneName, e.Name, e.Type, e.Err)
}
// Build compiles zones and their records into a queryable index. Disabled
// zones are skipped entirely.
func Build(zones []models.Zone, records map[int64][]models.Record) (*Index, []BuildError) {
idx := &Index{zones: make(map[string]*Zone, len(zones))}
var problems []BuildError
for _, mz := range zones {
if !mz.Enabled {
continue
}
z, errs := buildZone(mz, records[mz.ID])
problems = append(problems, errs...)
idx.zones[z.Name] = z
if n := dns.CountLabel(z.Name); n > idx.maxLabels {
idx.maxLabels = n
}
}
return idx, problems
}
func buildZone(mz models.Zone, recs []models.Record) (*Zone, []BuildError) {
z := &Zone{
ID: mz.ID,
Name: mz.Name,
Kind: mz.Kind,
DefaultTTL: mz.DefaultTTL,
names: make(map[string]*nameNode, len(recs)+1),
wildcards: map[string]*nameNode{},
ents: make(map[string]struct{}, len(recs)+1),
delegations: map[string]*nameNode{},
}
var problems []BuildError
for _, r := range recs {
owner := validate.AbsoluteName(r.Name, mz.Name)
ttl := r.EffectiveTTL(mz.DefaultTTL)
rr, err := validate.BuildRR(mz.Name, r.Name, r.Type, r.Data, ttl)
if err != nil {
problems = append(problems, BuildError{
ZoneID: mz.ID, ZoneName: mz.Name, RecordID: r.ID,
Name: r.Name, Type: r.Type, Err: err,
})
continue
}
// The parser resolves the owner against the origin; normalise anyway so
// map keys are always lowercase.
rr.Header().Name = strings.ToLower(rr.Header().Name)
owner = rr.Header().Name
if strings.HasPrefix(owner, "*.") {
node := z.wildcards[owner]
if node == nil {
node = &nameNode{types: map[uint16][]dns.RR{}}
z.wildcards[owner] = node
}
node.add(rr)
// A wildcard's parent exists as a name for ENT purposes.
z.addENT(strings.TrimPrefix(owner, "*."))
continue
}
node := z.names[owner]
if node == nil {
node = &nameNode{types: map[uint16][]dns.RR{}}
z.names[owner] = node
}
node.add(rr)
z.addENT(owner)
if rr.Header().Rrtype == dns.TypeSOA {
if soa, ok := rr.(*dns.SOA); ok && owner == mz.Name {
z.soa = soa
}
}
if rr.Header().Rrtype == dns.TypeNS {
if owner == mz.Name {
z.ns = append(z.ns, rr)
} else {
dn := z.delegations[owner]
if dn == nil {
dn = &nameNode{types: map[uint16][]dns.RR{}}
z.delegations[owner] = dn
}
dn.add(rr)
z.hasDelegations = true
}
}
}
// Automatic SOA management: a zone always answers with a SOA, whether or
// not one was explicitly stored.
if z.soa == nil {
z.soa = synthesiseSOA(mz)
apex := z.node(mz.Name)
apex.types[dns.TypeSOA] = []dns.RR{z.soa}
z.addENT(mz.Name)
}
// Likewise a zone should always have at least one apex NS record.
if len(z.ns) == 0 {
ns := synthesiseNS(mz)
apex := z.node(mz.Name)
apex.types[dns.TypeNS] = append(apex.types[dns.TypeNS], ns)
z.ns = append(z.ns, ns)
}
return z, problems
}
func (z *Zone) node(name string) *nameNode {
n := z.names[name]
if n == nil {
n = &nameNode{types: map[uint16][]dns.RR{}}
z.names[name] = n
}
return n
}
// addENT records a name and every ancestor of it up to the apex, so that a
// query for an intermediate name returns NODATA rather than NXDOMAIN.
func (z *Zone) addENT(name string) {
for n := name; n != "" && dns.IsSubDomain(z.Name, n); {
if _, ok := z.ents[n]; ok {
break // ancestors already recorded
}
z.ents[n] = struct{}{}
if n == z.Name {
break
}
i, end := dns.NextLabel(n, 0)
if end {
break
}
n = n[i:]
}
}
func synthesiseSOA(mz models.Zone) *dns.SOA {
ns := mz.PrimaryNS
if ns == "" {
ns = "ns1." + mz.Name
}
if !strings.HasSuffix(ns, ".") {
ns += "."
}
mbox := mailboxName(mz.AdminEmail, mz.Name)
return &dns.SOA{
Hdr: dns.RR_Header{
Name: mz.Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET,
Ttl: mz.DefaultTTL,
},
Ns: strings.ToLower(ns),
Mbox: strings.ToLower(mbox),
Serial: mz.Serial,
Refresh: nonZero(mz.Refresh, 7200),
Retry: nonZero(mz.Retry, 3600),
Expire: nonZero(mz.Expire, 1209600),
Minttl: nonZero(mz.Minimum, 3600),
}
}
func synthesiseNS(mz models.Zone) dns.RR {
ns := mz.PrimaryNS
if ns == "" {
ns = "ns1." + mz.Name
}
if !strings.HasSuffix(ns, ".") {
ns += "."
}
return &dns.NS{
Hdr: dns.RR_Header{Name: mz.Name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: mz.DefaultTTL},
Ns: strings.ToLower(ns),
}
}
// mailboxName converts an email address into SOA RNAME form.
func mailboxName(email, zone string) string {
e := strings.TrimSpace(strings.ToLower(email))
if e == "" {
return "hostmaster." + zone
}
if strings.HasSuffix(e, ".") && !strings.Contains(e, "@") {
return e // already in RNAME form
}
at := strings.LastIndex(e, "@")
if at < 0 {
if !strings.HasSuffix(e, ".") {
e += "."
}
return e
}
local := strings.ReplaceAll(e[:at], ".", `\.`)
domain := e[at+1:]
if !strings.HasSuffix(domain, ".") {
domain += "."
}
return local + "." + domain
}
func nonZero(v, def uint32) uint32 {
if v == 0 {
return def
}
return v
}
// Lookup finds the most specific zone authoritative for qname, or nil.
func (idx *Index) Lookup(qname string) *Zone {
if idx == nil || len(idx.zones) == 0 {
return nil
}
name := strings.ToLower(dns.Fqdn(qname))
for {
if z, ok := idx.zones[name]; ok {
return z
}
if name == "." || name == "" {
return nil
}
i, end := dns.NextLabel(name, 0)
if end {
return nil
}
name = name[i:]
}
}
// Zones returns the compiled zones, ordered by name.
func (idx *Index) Zones() []*Zone {
if idx == nil {
return nil
}
out := make([]*Zone, 0, len(idx.zones))
for _, z := range idx.zones {
out = append(out, z)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Len reports the number of compiled zones.
func (idx *Index) Len() int {
if idx == nil {
return 0
}
return len(idx.zones)
}
// Zone returns a compiled zone by exact apex name.
func (idx *Index) Zone(name string) *Zone {
if idx == nil {
return nil
}
return idx.zones[strings.ToLower(dns.Fqdn(name))]
}
+463
View File
@@ -0,0 +1,463 @@
// Package backup creates and restores SQLite database backups.
//
// Backups use SQLite's VACUUM INTO, which writes a transactionally consistent
// copy of the database while it is being written to. Copying the .db file with
// the filesystem would capture a torn snapshot whose committed data lives in a
// write-ahead log the copy does not include.
package backup
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/database"
)
// pendingSuffix marks a restore staged for the next start.
const pendingSuffix = ".restore-pending"
// Info describes one backup file.
type Info struct {
Name string `json:"name"`
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
// SizeMB renders the size for the UI.
func (i Info) SizeMB() float64 { return float64(i.SizeBytes) / (1024 * 1024) }
// Manager runs manual and scheduled backups.
type Manager struct {
db *database.DB
log *slog.Logger
mu sync.RWMutex
enabled bool
dir string
interval time.Duration
retention int
running sync.Mutex // serialises backup runs
lastRun time.Time
lastError string
wg sync.WaitGroup
once sync.Once
}
// Config controls the backup schedule.
type Config struct {
Enabled bool
Directory string
IntervalHours int
Retention int
}
// New creates a backup manager.
func New(db *database.DB, log *slog.Logger, cfg Config) *Manager {
m := &Manager{db: db, log: log}
m.SetConfig(cfg)
return m
}
// SetConfig replaces the backup configuration.
func (m *Manager) SetConfig(cfg Config) {
if cfg.IntervalHours < 1 {
cfg.IntervalHours = 24
}
if cfg.Retention < 1 {
cfg.Retention = 7
}
m.mu.Lock()
m.enabled = cfg.Enabled
m.dir = strings.TrimSpace(cfg.Directory)
m.interval = time.Duration(cfg.IntervalHours) * time.Hour
m.retention = cfg.Retention
m.mu.Unlock()
}
// Directory returns the configured backup directory.
func (m *Manager) Directory() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.dir
}
// Run creates a backup now and prunes old ones.
func (m *Manager) Run(ctx context.Context) (Info, error) {
m.running.Lock()
defer m.running.Unlock()
m.mu.RLock()
dir, retention := m.dir, m.retention
m.mu.RUnlock()
if dir == "" {
return Info{}, errors.New("no backup directory is configured")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return Info{}, fmt.Errorf("create backup directory %s: %w", dir, err)
}
name := fmt.Sprintf("vibedns-%s.db", time.Now().UTC().Format("20060102-150405"))
path := filepath.Join(dir, name)
// VACUUM INTO fails if the target exists, which is exactly the behaviour we
// want: a backup must never silently overwrite another.
if _, err := os.Stat(path); err == nil {
return Info{}, fmt.Errorf("a backup named %s already exists", name)
}
// Checkpointing first keeps the WAL small and the copy quick.
if err := m.db.Checkpoint(ctx); err != nil {
m.log.Warn("could not checkpoint the write-ahead log before backup", "error", err)
}
if _, err := m.db.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
m.recordError(err)
return Info{}, fmt.Errorf("write backup to %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
m.log.Warn("could not restrict backup file permissions", "path", path, "error", err)
}
fi, err := os.Stat(path)
if err != nil {
m.recordError(err)
return Info{}, fmt.Errorf("verify backup %s: %w", path, err)
}
m.mu.Lock()
m.lastRun = time.Now()
m.lastError = ""
m.mu.Unlock()
info := Info{Name: name, Path: path, SizeBytes: fi.Size(), CreatedAt: fi.ModTime()}
m.log.Info("database backup created", "path", path, "bytes", info.SizeBytes)
if removed, err := Prune(dir, retention); err != nil {
m.log.Warn("could not prune old backups", "error", err)
} else if removed > 0 {
m.log.Info("pruned old backups", "removed", removed, "retention", retention)
}
return info, nil
}
func (m *Manager) recordError(err error) {
m.mu.Lock()
m.lastError = err.Error()
m.mu.Unlock()
}
// List returns the backups in the configured directory, newest first.
func (m *Manager) List() ([]Info, error) {
return List(m.Directory())
}
// List returns the backups in dir, newest first.
func List(dir string) ([]Info, error) {
if dir == "" {
return nil, nil
}
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read backup directory %s: %w", dir, err)
}
var out []Info
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
out = append(out, Info{
Name: e.Name(),
Path: filepath.Join(dir, e.Name()),
SizeBytes: fi.Size(),
CreatedAt: fi.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
// Prune deletes all but the newest `keep` backups.
func Prune(dir string, keep int) (int, error) {
if keep < 1 {
return 0, nil
}
backups, err := List(dir)
if err != nil {
return 0, err
}
if len(backups) <= keep {
return 0, nil
}
removed := 0
for _, b := range backups[keep:] {
if err := os.Remove(b.Path); err != nil {
return removed, fmt.Errorf("remove old backup %s: %w", b.Name, err)
}
removed++
}
return removed, nil
}
// Resolve validates that name refers to a backup inside dir and returns its
// full path. It exists to keep a crafted name from escaping the directory.
func Resolve(dir, name string) (string, error) {
if dir == "" {
return "", errors.New("no backup directory is configured")
}
clean := filepath.Base(filepath.Clean("/" + name))
if clean == "." || clean == "/" || clean == "" {
return "", fmt.Errorf("%q is not a valid backup name", name)
}
if !strings.HasSuffix(clean, ".db") {
return "", fmt.Errorf("%q is not a backup file", name)
}
path := filepath.Join(dir, clean)
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("backup %s was not found", clean)
}
return path, nil
}
// Delete removes one backup by name.
func Delete(dir, name string) error {
path, err := Resolve(dir, name)
if err != nil {
return err
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("delete backup %s: %w", name, err)
}
return nil
}
// Start launches the scheduled backup loop.
func (m *Manager) Start(ctx context.Context) {
m.once.Do(func() {
m.wg.Add(1)
go m.loop(ctx)
})
}
// Stop waits for the scheduler to exit.
func (m *Manager) Stop() { m.wg.Wait() }
func (m *Manager) loop(ctx context.Context) {
defer m.wg.Done()
// Check every few minutes rather than sleeping for the whole interval, so
// a settings change takes effect promptly.
const tick = 5 * time.Minute
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.mu.RLock()
enabled, interval, last := m.enabled, m.interval, m.lastRun
m.mu.RUnlock()
if !enabled {
continue
}
if !last.IsZero() && time.Since(last) < interval {
continue
}
if _, err := m.Run(ctx); err != nil {
m.log.Error("scheduled backup failed", "error", err)
}
}
}
}
// Status describes the backup subsystem for the settings page.
type Status struct {
Enabled bool `json:"enabled"`
Directory string `json:"directory"`
IntervalHours int `json:"interval_hours"`
Retention int `json:"retention"`
LastRun time.Time `json:"last_run"`
LastError string `json:"last_error,omitempty"`
Count int `json:"count"`
TotalBytes int64 `json:"total_bytes"`
}
// Status returns the current backup status.
func (m *Manager) Status() Status {
m.mu.RLock()
s := Status{
Enabled: m.enabled,
Directory: m.dir,
IntervalHours: int(m.interval / time.Hour),
Retention: m.retention,
LastRun: m.lastRun,
LastError: m.lastError,
}
m.mu.RUnlock()
if backups, err := List(s.Directory); err == nil {
s.Count = len(backups)
for _, b := range backups {
s.TotalBytes += b.SizeBytes
}
}
return s
}
// --- Restore ------------------------------------------------------------
// StageRestore validates a backup and stages it to replace the live database
// on the next start.
//
// Overwriting the database file underneath a running process would leave open
// connections reading a file that no longer exists, so the swap is deferred to
// startup, where nothing is holding the database open.
func StageRestore(dbPath, backupPath string) error {
if err := Verify(backupPath); err != nil {
return err
}
pending := dbPath + pendingSuffix
if err := copyFile(backupPath, pending, 0o600); err != nil {
return fmt.Errorf("stage restore: %w", err)
}
return nil
}
// PendingRestore reports whether a restore is staged.
func PendingRestore(dbPath string) (string, bool) {
p := dbPath + pendingSuffix
if _, err := os.Stat(p); err == nil {
return p, true
}
return "", false
}
// CancelRestore discards a staged restore.
func CancelRestore(dbPath string) error {
p := dbPath + pendingSuffix
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cancel staged restore: %w", err)
}
return nil
}
// ApplyPendingRestore swaps a staged backup into place. It must be called
// before the database is opened.
//
// The database being replaced is preserved alongside it, so a restore that
// turns out to be the wrong choice is still recoverable.
func ApplyPendingRestore(dbPath string, log *slog.Logger) (bool, error) {
pending := dbPath + pendingSuffix
if _, err := os.Stat(pending); err != nil {
return false, nil
}
if _, err := os.Stat(dbPath); err == nil {
safety := fmt.Sprintf("%s.pre-restore-%s", dbPath, time.Now().UTC().Format("20060102-150405"))
if err := os.Rename(dbPath, safety); err != nil {
return false, fmt.Errorf("preserve the current database before restoring: %w", err)
}
log.Info("previous database preserved", "path", safety)
}
// The WAL and shared-memory sidecars belong to the replaced database and
// would corrupt the restored one.
for _, suffix := range []string{"-wal", "-shm"} {
if err := os.Remove(dbPath + suffix); err != nil && !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("remove stale %s file: %w", suffix, err)
}
}
if err := os.Rename(pending, dbPath); err != nil {
return false, fmt.Errorf("move the staged database into place: %w", err)
}
if err := os.Chmod(dbPath, 0o600); err != nil {
log.Warn("could not restrict restored database permissions", "error", err)
}
log.Info("database restored from backup", "path", dbPath)
return true, nil
}
// Verify checks that a file is a usable vibedns database.
func Verify(path string) error {
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("backup file %s cannot be read: %w", filepath.Base(path), err)
}
if fi.Size() < 512 {
return fmt.Errorf("backup file %s is too small to be a database", filepath.Base(path))
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=query_only(1)")
if err != nil {
return fmt.Errorf("backup file %s could not be opened: %w", filepath.Base(path), err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var check string
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&check); err != nil {
return fmt.Errorf("backup file %s failed its integrity check: %w", filepath.Base(path), err)
}
if check != "ok" {
return fmt.Errorf("backup file %s failed its integrity check: %s", filepath.Base(path), check)
}
var n int
err = db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n)
if err != nil || n == 0 {
return fmt.Errorf("%s does not look like a vibedns database: no migration table was found",
filepath.Base(path))
}
return nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Sync(); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}
+262
View File
@@ -0,0 +1,262 @@
package blacklist
import (
"strings"
"testing"
"github.com/owen/vibedns/internal/models"
)
func TestMatchExactAndSubdomains(t *testing.T) {
b := NewBuilder(1, "Test", models.KindBlacklist, 4)
b.Add("example.com", true) // covers subdomains
b.Add("exact.example.net", false) // this name only
b.Add("*.wild.example", false) // wildcard syntax implies subdomains
set := b.Build()
tests := []struct {
name string
query string
want bool
}{
{"exact match on a subdomain entry", "example.com", true},
{"one level down", "www.example.com", true},
{"several levels down", "a.b.c.example.com", true},
{"trailing dot is ignored", "www.example.com.", true},
{"case is ignored", "WWW.Example.COM", true},
{"sibling is not matched", "notexample.com", false},
{"parent is not matched", "com", false},
{"exact-only entry matches itself", "exact.example.net", true},
{"exact-only entry does not cover subdomains", "www.exact.example.net", false},
{"wildcard entry matches the base", "wild.example", true},
{"wildcard entry covers subdomains", "anything.wild.example", true},
{"unlisted name", "example.org", false},
{"empty query", "", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
matched, ok := set.Match(tc.query)
if ok != tc.want {
t.Errorf("Match(%q) = %v (matched %q), want %v", tc.query, ok, matched, tc.want)
}
})
}
}
// TestSubdomainCoverageIsNotStored is the memory property the design depends
// on: covering every subdomain must not cost an entry per subdomain.
func TestSubdomainCoverageIsNotStored(t *testing.T) {
b := NewBuilder(1, "Test", models.KindBlacklist, 1)
b.Add("example.com", true)
set := b.Build()
if set.Len() != 1 {
t.Fatalf("stored %d entries, want exactly 1", set.Len())
}
for _, name := range []string{
"a.example.com", "b.a.example.com", "c.b.a.example.com",
"very.deeply.nested.name.example.com",
} {
if _, ok := set.Match(name); !ok {
t.Errorf("%s should be covered by the single stored entry", name)
}
}
}
func TestEmptySetMatchesNothing(t *testing.T) {
set := NewBuilder(1, "Empty", models.KindBlacklist, 0).Build()
if _, ok := set.Match("example.com"); ok {
t.Error("an empty set must not match")
}
var nilSet *Set
if _, ok := nilSet.Match("example.com"); ok {
t.Error("a nil set must not match")
}
}
func TestParsePlainList(t *testing.T) {
input := `# A comment
example.com
bad.example
tracker.example.net
; another comment style
! adblock comment
`
domains, summary := ParseString(input, ParseOptions{DefaultMatchSubdomains: true})
want := []string{"example.com", "bad.example", "tracker.example.net"}
if len(domains) != len(want) {
t.Fatalf("parsed %d domains, want %d: %v", len(domains), len(want), domains)
}
for i, d := range domains {
if d.Domain != want[i] {
t.Errorf("domain[%d] = %q, want %q", i, d.Domain, want[i])
}
}
if summary.Imported != 3 {
t.Errorf("imported = %d, want 3", summary.Imported)
}
if summary.Ignored != 4 {
t.Errorf("ignored = %d, want 4 comments and blanks", summary.Ignored)
}
}
func TestParseHostsFile(t *testing.T) {
input := `# Hosts-style blocklist
0.0.0.0 example.com
127.0.0.1 tracker.example.net
:: bad.example
0.0.0.0 multi-a.example multi-b.example
127.0.0.1 localhost
::1 ip6-localhost
0.0.0.0
192.168.1.1 printer.local
`
domains, summary := ParseString(input, ParseOptions{DefaultMatchSubdomains: true})
got := map[string]bool{}
for _, d := range domains {
got[d.Domain] = true
}
for _, want := range []string{
"example.com", "tracker.example.net", "bad.example",
"multi-a.example", "multi-b.example", "printer.local",
} {
if !got[want] {
t.Errorf("expected %q to be imported; got %v", want, keys(got))
}
}
// Loopback names are hosts-file boilerplate, not blockable domains.
for _, unwanted := range []string{"localhost", "ip6-localhost"} {
if got[unwanted] {
t.Errorf("%q should not have been imported", unwanted)
}
}
if summary.LinesProcessed != 9 {
t.Errorf("lines processed = %d, want 9", summary.LinesProcessed)
}
}
func TestParseAdblockRules(t *testing.T) {
input := `[Adblock Plus 2.0]
||ads.example.com^
||tracker.example.net^$third-party
@@||allowed.example.com^
||example.org/path/to/thing
##.banner-class
`
domains, _ := ParseString(input, ParseOptions{})
got := map[string]bool{}
for _, d := range domains {
got[d.Domain] = true
}
if !got["ads.example.com"] {
t.Error("a plain ||domain^ rule should be imported")
}
if !got["tracker.example.net"] {
t.Error("a ||domain^ rule with options should import the domain part")
}
if got["allowed.example.com"] {
t.Error("an @@ exception rule must not become a block entry")
}
// An Adblock host rule implies subdomain coverage.
for _, d := range domains {
if d.Domain == "ads.example.com" && !d.MatchSubdomains {
t.Error("an Adblock host rule should cover subdomains")
}
}
}
func TestParseDeduplicatesWithinFile(t *testing.T) {
input := "example.com\nexample.com\nEXAMPLE.COM\nexample.com.\n"
domains, summary := ParseString(input, ParseOptions{})
if len(domains) != 1 {
t.Errorf("parsed %d domains, want 1 after normalisation", len(domains))
}
if summary.Duplicates != 3 {
t.Errorf("duplicates = %d, want 3", summary.Duplicates)
}
}
func TestParseRejectsInvalidEntries(t *testing.T) {
input := "example.com\nnot a domain at all\n-bad-.example\nvalid.example\n"
domains, summary := ParseString(input, ParseOptions{})
got := map[string]bool{}
for _, d := range domains {
got[d.Domain] = true
}
if !got["example.com"] || !got["valid.example"] {
t.Errorf("valid entries were dropped: %v", keys(got))
}
if summary.Invalid == 0 {
t.Error("expected invalid entries to be counted")
}
if len(summary.InvalidSamples) == 0 {
t.Error("expected a sample of the rejected lines for the operator")
}
}
func TestParseIgnoresIPOnlyAndSingleLabel(t *testing.T) {
input := "192.0.2.1\nlocalhost\ncom\nvalid.example\n"
domains, _ := ParseString(input, ParseOptions{})
for _, d := range domains {
if d.Domain != "valid.example" {
t.Errorf("unexpected import %q", d.Domain)
}
}
}
func TestParseLargeInput(t *testing.T) {
// A realistic blocklist shape: confirm parsing scales and counts correctly.
var b strings.Builder
const n = 50000
for i := 0; i < n; i++ {
b.WriteString("0.0.0.0 host")
b.WriteString(itoa(i))
b.WriteString(".example.com\n")
}
domains, summary := ParseString(b.String(), ParseOptions{DefaultMatchSubdomains: true})
if len(domains) != n {
t.Errorf("parsed %d domains, want %d", len(domains), n)
}
if summary.Imported != n {
t.Errorf("imported = %d, want %d", summary.Imported, n)
}
if summary.Invalid != 0 {
t.Errorf("invalid = %d, want 0", summary.Invalid)
}
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func itoa(i int) string {
if i == 0 {
return "0"
}
var buf [12]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
return string(buf[pos:])
}
+145
View File
@@ -0,0 +1,145 @@
// Package blacklist implements the domain lookup structure used by blacklists
// and allowlists, plus the parsers for bulk imports.
//
// The matcher is built for lists with hundreds of thousands of entries. It
// stores one map entry per configured domain and answers "is this name, or any
// parent of it, listed?" by walking the name's suffixes, which is bounded by
// the label count rather than by the size of the list. Subdomain coverage
// therefore costs nothing extra: blocking example.com automatically covers
// a.b.example.com without storing a single additional row.
package blacklist
import (
"strings"
)
// Match flags stored per domain. A single map keeps the memory footprint of a
// large list to one entry per domain rather than one per match mode.
const (
flagExact uint8 = 1 << 0 // matches the domain itself only
flagSuffix uint8 = 1 << 1 // matches the domain and every subdomain
)
// Set is an immutable compiled domain list.
type Set struct {
ID int64
Name string
Kind string
domains map[string]uint8
}
// Builder accumulates domains before freezing them into a Set.
type Builder struct {
id int64
name string
kind string
domains map[string]uint8
}
// NewBuilder starts building a list. sizeHint pre-sizes the map, which matters
// when loading a list with hundreds of thousands of domains.
func NewBuilder(id int64, name, kind string, sizeHint int) *Builder {
if sizeHint < 8 {
sizeHint = 8
}
return &Builder{id: id, name: name, kind: kind, domains: make(map[string]uint8, sizeHint)}
}
// Add records one domain. The domain must already be normalised: lowercase,
// no trailing dot. A leading "*." is understood as a subdomain wildcard.
func (b *Builder) Add(domain string, matchSubdomains bool) {
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
if domain == "" {
return
}
if strings.HasPrefix(domain, "*.") {
domain = domain[2:]
matchSubdomains = true
if domain == "" {
return
}
}
if matchSubdomains {
b.domains[domain] |= flagSuffix | flagExact
} else {
b.domains[domain] |= flagExact
}
}
// Len reports how many distinct domains have been added.
func (b *Builder) Len() int { return len(b.domains) }
// Build freezes the builder into a Set.
func (b *Builder) Build() *Set {
return &Set{ID: b.id, Name: b.name, Kind: b.kind, domains: b.domains}
}
// Len reports the number of domains in the set.
func (s *Set) Len() int {
if s == nil {
return 0
}
return len(s.domains)
}
// Match reports whether name is covered by this list, returning the listed
// domain that matched.
//
// name may be given with or without a trailing dot and in any case.
func (s *Set) Match(name string) (string, bool) {
if s == nil || len(s.domains) == 0 {
return "", false
}
n := normaliseQuery(name)
if n == "" {
return "", false
}
// Exact match on the full name.
if f, ok := s.domains[n]; ok && f&flagExact != 0 {
return n, true
}
// Walk up the parents; each one only matches if it was added as a
// subdomain-covering entry.
rest := n
for {
i := strings.IndexByte(rest, '.')
if i < 0 {
return "", false
}
rest = rest[i+1:]
if rest == "" {
return "", false
}
if f, ok := s.domains[rest]; ok && f&flagSuffix != 0 {
return rest, true
}
}
}
// Contains reports whether the exact domain is present in the list, ignoring
// subdomain coverage. It backs the "is this already in the list?" check.
func (s *Set) Contains(domain string) bool {
if s == nil {
return false
}
_, ok := s.domains[normaliseQuery(domain)]
return ok
}
// normaliseQuery lowercases a query name and removes the trailing dot.
func normaliseQuery(name string) string {
n := strings.TrimSpace(name)
if n == "" {
return ""
}
n = strings.TrimSuffix(n, ".")
// Fast path: most query names are already lowercase.
for i := 0; i < len(n); i++ {
if c := n[i]; c >= 'A' && c <= 'Z' {
return strings.ToLower(n)
}
}
return n
}
+228
View File
@@ -0,0 +1,228 @@
package blacklist
import (
"bufio"
"io"
"net/netip"
"strings"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// ParsedDomain is one domain extracted from an import.
type ParsedDomain struct {
Domain string
MatchSubdomains bool
}
// ParseOptions tunes bulk import behaviour.
type ParseOptions struct {
// DefaultMatchSubdomains sets the match mode for entries that do not carry
// explicit wildcard syntax.
DefaultMatchSubdomains bool
// MaxInvalidSamples caps how many rejected lines are reported back.
MaxInvalidSamples int
}
// hostsPlaceholders are the addresses a hosts-file blocklist points at. Lines
// using any other address are still parsed, but these are the common ones.
var localhostNames = map[string]bool{
"localhost": true,
"localhost.localdomain": true,
"local": true,
"ip6-localhost": true,
"ip6-loopback": true,
"ip6-localnet": true,
"ip6-mcastprefix": true,
"ip6-allnodes": true,
"ip6-allrouters": true,
"ip6-allhosts": true,
"broadcasthost": true,
}
// Parse reads a domain list and returns the normalised, de-duplicated domains
// alongside a summary of what happened to every line.
//
// It accepts three shapes, mixed freely in one file:
//
// example.com plain list
// 0.0.0.0 ads.example.com hosts file
// ||tracker.example.net^ Adblock-style host rule
//
// Comments (#, ;, !), blank lines, IP-only lines and localhost entries are
// ignored. Everything that survives is normalised to lowercase without a
// trailing dot.
func Parse(r io.Reader, opts ParseOptions) ([]ParsedDomain, models.ImportSummary) {
if opts.MaxInvalidSamples <= 0 {
opts.MaxInvalidSamples = 10
}
var summary models.ImportSummary
// Pre-size for a large list; growth is amortised anyway.
out := make([]ParsedDomain, 0, 1024)
seen := make(map[string]struct{}, 1024)
sc := bufio.NewScanner(r)
// Blocklist lines are short, but a stray long line must not abort the scan.
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
summary.LinesProcessed++
line := strings.TrimSpace(sc.Text())
if line == "" || isComment(line) {
summary.Ignored++
continue
}
line = stripTrailingComment(line)
if line == "" {
summary.Ignored++
continue
}
candidates, wildcard, ok := extractCandidates(line)
if !ok {
summary.Ignored++
continue
}
if len(candidates) == 0 {
summary.Invalid++
addSample(&summary, line, opts.MaxInvalidSamples)
continue
}
anyValid := false
for _, c := range candidates {
domain, err := validate.NormaliseDomain(c)
if err != nil {
continue
}
if localhostNames[domain] || !strings.Contains(domain, ".") {
// Single-label names are hosts-file noise, not blockable domains.
continue
}
if isIPLiteral(domain) {
continue
}
anyValid = true
if _, dup := seen[domain]; dup {
summary.Duplicates++
continue
}
seen[domain] = struct{}{}
out = append(out, ParsedDomain{
Domain: domain,
MatchSubdomains: wildcard || opts.DefaultMatchSubdomains,
})
summary.Imported++
}
if !anyValid {
summary.Invalid++
addSample(&summary, line, opts.MaxInvalidSamples)
}
}
if err := sc.Err(); err != nil {
// A read failure still returns what was parsed so far; the caller
// decides whether a partial import is acceptable.
return out, summary
}
return out, summary
}
func addSample(s *models.ImportSummary, line string, max int) {
if len(s.InvalidSamples) >= max {
return
}
if len(line) > 120 {
line = line[:120] + "..."
}
s.InvalidSamples = append(s.InvalidSamples, line)
}
func isComment(line string) bool {
switch line[0] {
case '#', ';':
return true
case '!':
// Adblock comment, but "!" never starts a host rule.
return true
case '[':
// Adblock header such as [Adblock Plus 2.0]
return true
}
return false
}
// stripTrailingComment removes an inline comment while leaving the rest.
func stripTrailingComment(line string) string {
if i := strings.IndexAny(line, "#;"); i >= 0 {
line = line[:i]
}
return strings.TrimSpace(line)
}
// extractCandidates pulls the domain-shaped tokens out of one line.
//
// The bool return reports whether the line should count as "ignored" rather
// than "invalid" — used for rules this importer deliberately does not support,
// such as Adblock element-hiding or exception rules.
func extractCandidates(line string) (domains []string, wildcard bool, supported bool) {
// Adblock-style rules.
if strings.HasPrefix(line, "@@") {
return nil, false, false // exception rule: not a block entry
}
if strings.HasPrefix(line, "||") {
rest := strings.TrimPrefix(line, "||")
rest = strings.TrimSuffix(rest, "^")
rest = strings.TrimSuffix(rest, "^$all")
if i := strings.IndexAny(rest, "/^$*"); i >= 0 {
// A path or option makes this a URL rule, which DNS cannot express.
if i == 0 {
return nil, false, false
}
rest = rest[:i]
}
return []string{rest}, true, true
}
if strings.ContainsAny(line, "/$") && !strings.HasPrefix(line, "0.0.0.0") {
// dnsmasq address=/example.com/0.0.0.0
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
parts := strings.Split(line, "/")
if len(parts) >= 2 && parts[1] != "" {
return []string{parts[1]}, true, true
}
return nil, false, false
}
return nil, false, false
}
fields := strings.Fields(line)
if len(fields) == 0 {
return nil, false, false
}
// Hosts-file syntax: the first field is an IP address, the rest are names.
if isIPLiteral(fields[0]) {
if len(fields) == 1 {
return nil, false, false // an address on its own carries no domain
}
return fields[1:], false, true
}
// Plain list: one domain per line. Extra fields are treated as noise.
first := fields[0]
if strings.HasPrefix(first, "*.") {
return []string{strings.TrimPrefix(first, "*.")}, true, true
}
return []string{first}, false, true
}
func isIPLiteral(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// ParseString is a convenience wrapper for textarea input.
func ParseString(s string, opts ParseOptions) ([]ParsedDomain, models.ImportSummary) {
return Parse(strings.NewReader(s), opts)
}
+690
View File
@@ -0,0 +1,690 @@
// Package cache implements the resolver cache: a sharded, LRU-bounded store of
// DNS responses with TTL decay, negative caching, stale serving and prefetch.
//
// The cache lives entirely in memory. It is never persisted, because a cache
// that survives a restart would serve answers whose TTLs it can no longer
// reason about.
package cache
import (
"container/list"
"hash/fnv"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
)
// shardCount must be a power of two.
const shardCount = 64
// Config controls cache behaviour. It is swapped in wholesale on change.
type Config struct {
Enabled bool
MaxEntries int
MinTTL uint32
MaxTTL uint32
NegativeTTL uint32
ServeStale bool
StaleTTL uint32
Prefetch bool
PrefetchPercent int
}
// Key identifies a cached response. The DO bit is part of the key because a
// DNSSEC-aware answer carries RRSIG records that a non-DO client must not see.
type Key struct {
Name string // lowercase FQDN
Type uint16
Class uint16
DO bool
}
// String renders the key in the form shown in the cache browser.
func (k Key) String() string {
s := k.Name + " " + dns.TypeToString[k.Type]
if k.DO {
s += " +dnssec"
}
return s
}
func (k Key) hash() uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(k.Name))
_, _ = h.Write([]byte{byte(k.Type >> 8), byte(k.Type), byte(k.Class >> 8), byte(k.Class)})
if k.DO {
_, _ = h.Write([]byte{1})
}
return h.Sum32()
}
// KeyFor builds a cache key from a question.
func KeyFor(q dns.Question, do bool) Key {
return Key{Name: strings.ToLower(dns.Fqdn(q.Name)), Type: q.Qtype, Class: q.Qclass, DO: do}
}
// entry is one cached response.
type entry struct {
key Key
msg *dns.Msg // stored with original TTLs
stored time.Time
ttl uint32 // seconds the answer is fresh for
origTTL uint32 // TTL at insertion, used for the prefetch threshold
rcode int
size int
elem *list.Element // position in the shard LRU
negative bool
}
// expiresAt returns the instant the entry stops being fresh.
func (e *entry) expiresAt() time.Time {
return e.stored.Add(time.Duration(e.ttl) * time.Second)
}
type shard struct {
mu sync.RWMutex
entries map[Key]*entry
lru *list.List // front = most recently used
bytes int64
}
// Cache is the resolver cache.
type Cache struct {
shards [shardCount]*shard
cfgMu sync.RWMutex
cfg Config
hits atomic.Int64
misses atomic.Int64
staleHits atomic.Int64
insertions atomic.Int64
evictions atomic.Int64
expiries atomic.Int64
// prefetch is invoked asynchronously when a fresh-but-ageing entry is hit.
prefetchMu sync.RWMutex
prefetch func(Key)
inflight sync.Map // Key -> struct{}, dedupes prefetch requests
}
// New creates a cache with the given configuration.
func New(cfg Config) *Cache {
c := &Cache{}
for i := range c.shards {
c.shards[i] = &shard{entries: map[Key]*entry{}, lru: list.New()}
}
c.SetConfig(cfg)
return c
}
// SetConfig replaces the cache configuration. Shrinking MaxEntries evicts down
// to the new bound, and disabling the cache flushes it.
func (c *Cache) SetConfig(cfg Config) {
if cfg.MaxEntries <= 0 {
cfg.MaxEntries = 10_000
}
if cfg.MaxTTL == 0 {
cfg.MaxTTL = 86400
}
if cfg.PrefetchPercent <= 0 || cfg.PrefetchPercent >= 100 {
cfg.PrefetchPercent = 10
}
c.cfgMu.Lock()
c.cfg = cfg
c.cfgMu.Unlock()
if !cfg.Enabled {
c.Flush()
return
}
c.enforceBound()
}
// Config returns the current configuration.
func (c *Cache) Config() Config {
c.cfgMu.RLock()
defer c.cfgMu.RUnlock()
return c.cfg
}
// SetPrefetcher registers the callback used to refresh ageing entries.
func (c *Cache) SetPrefetcher(fn func(Key)) {
c.prefetchMu.Lock()
c.prefetch = fn
c.prefetchMu.Unlock()
}
func (c *Cache) shardFor(k Key) *shard {
return c.shards[k.hash()&(shardCount-1)]
}
// Result describes a cache lookup outcome.
type Result struct {
Msg *dns.Msg
Hit bool
Stale bool
Age time.Duration
Expiry time.Time
}
// Get looks up a response. The returned message is a copy with TTLs decayed by
// the time the entry has spent in the cache, so clients never see a TTL that
// stands still.
func (c *Cache) Get(k Key, req *dns.Msg) Result {
cfg := c.Config()
if !cfg.Enabled {
return Result{}
}
sh := c.shardFor(k)
sh.mu.RLock()
e, ok := sh.entries[k]
if !ok {
sh.mu.RUnlock()
c.misses.Add(1)
return Result{}
}
stored, ttl, origTTL := e.stored, e.ttl, e.origTTL
msg := e.msg
sh.mu.RUnlock()
age := time.Since(stored)
elapsed := uint32(age / time.Second)
switch {
case elapsed < ttl:
remaining := ttl - elapsed
out := decayed(msg, req, remaining, elapsed)
c.hits.Add(1)
c.touch(sh, k)
if cfg.Prefetch && shouldPrefetch(remaining, origTTL, cfg.PrefetchPercent) {
c.triggerPrefetch(k)
}
return Result{Msg: out, Hit: true, Age: age, Expiry: stored.Add(time.Duration(ttl) * time.Second)}
case cfg.ServeStale && cfg.StaleTTL > 0 && elapsed < ttl+cfg.StaleTTL:
// RFC 8767: serve the expired answer with a short TTL while a fresh one
// is fetched, rather than failing the client outright.
const staleClientTTL = 30
out := decayed(msg, req, staleClientTTL, elapsed)
c.staleHits.Add(1)
c.hits.Add(1)
c.triggerPrefetch(k)
return Result{Msg: out, Hit: true, Stale: true, Age: age,
Expiry: stored.Add(time.Duration(ttl) * time.Second)}
default:
c.remove(sh, k)
c.expiries.Add(1)
c.misses.Add(1)
return Result{}
}
}
func shouldPrefetch(remaining, orig uint32, percent int) bool {
if orig == 0 {
return false
}
threshold := orig * uint32(percent) / 100
if threshold < 1 {
threshold = 1
}
return remaining <= threshold
}
func (c *Cache) triggerPrefetch(k Key) {
c.prefetchMu.RLock()
fn := c.prefetch
c.prefetchMu.RUnlock()
if fn == nil {
return
}
if _, loaded := c.inflight.LoadOrStore(k, struct{}{}); loaded {
return
}
go func() {
defer c.inflight.Delete(k)
fn(k)
}()
}
// decayed copies a stored message for a specific request, reducing every TTL by
// the number of seconds the entry has been cached.
func decayed(stored *dns.Msg, req *dns.Msg, remaining, elapsed uint32) *dns.Msg {
out := stored.Copy()
if req != nil {
out.Id = req.Id
out.Question = req.Question
out.RecursionDesired = req.RecursionDesired
}
adjust := func(rrs []dns.RR) {
for _, rr := range rrs {
if rr.Header().Rrtype == dns.TypeOPT {
continue
}
t := rr.Header().Ttl
if t <= elapsed {
rr.Header().Ttl = remaining
continue
}
nt := t - elapsed
if nt < 1 {
nt = 1
}
rr.Header().Ttl = nt
}
}
adjust(out.Answer)
adjust(out.Ns)
adjust(out.Extra)
return out
}
// Put stores a response. It returns the TTL the entry was stored with, or 0 if
// the response was not cacheable.
func (c *Cache) Put(k Key, msg *dns.Msg) uint32 {
cfg := c.Config()
if !cfg.Enabled || msg == nil {
return 0
}
if !cacheable(msg) {
return 0
}
negative := isNegative(msg)
ttl := responseTTL(msg, negative, cfg)
if ttl == 0 {
return 0
}
stored := msg.Copy()
// The OPT record describes the transport of one exchange, not the data, so
// it must not be replayed to a different client.
stored.Extra = stripOPT(stored.Extra)
stored.Id = 0
e := &entry{
key: k,
msg: stored,
stored: time.Now(),
ttl: ttl,
origTTL: ttl,
rcode: msg.Rcode,
size: estimateSize(k, stored),
negative: negative,
}
sh := c.shardFor(k)
sh.mu.Lock()
if old, ok := sh.entries[k]; ok {
sh.lru.Remove(old.elem)
sh.bytes -= int64(old.size)
}
e.elem = sh.lru.PushFront(k)
sh.entries[k] = e
sh.bytes += int64(e.size)
sh.mu.Unlock()
c.insertions.Add(1)
c.enforceBound()
return ttl
}
// cacheable rejects responses that must never be reused.
func cacheable(msg *dns.Msg) bool {
if msg.Truncated {
return false
}
switch msg.Rcode {
case dns.RcodeSuccess, dns.RcodeNameError:
return true
default:
// SERVFAIL, REFUSED and friends are transient or client specific.
return false
}
}
func isNegative(msg *dns.Msg) bool {
return msg.Rcode == dns.RcodeNameError || len(msg.Answer) == 0
}
// responseTTL derives the cache lifetime from the response, clamped to the
// configured bounds. Negative answers use the SOA MINIMUM per RFC 2308.
func responseTTL(msg *dns.Msg, negative bool, cfg Config) uint32 {
if negative {
ttl := cfg.NegativeTTL
if soa := findSOA(msg.Ns); soa != nil {
t := soa.Minttl
if soa.Hdr.Ttl < t {
t = soa.Hdr.Ttl
}
if t < ttl || ttl == 0 {
ttl = t
}
}
if ttl == 0 {
return 0
}
return clampTTL(ttl, cfg)
}
ttl := uint32(0)
first := true
for _, section := range [][]dns.RR{msg.Answer, msg.Ns} {
for _, rr := range section {
if rr.Header().Rrtype == dns.TypeOPT {
continue
}
t := rr.Header().Ttl
if first || t < ttl {
ttl = t
first = false
}
}
}
if first {
return 0 // nothing with a TTL to key off
}
return clampTTL(ttl, cfg)
}
func clampTTL(ttl uint32, cfg Config) uint32 {
if cfg.MinTTL > 0 && ttl < cfg.MinTTL {
ttl = cfg.MinTTL
}
if cfg.MaxTTL > 0 && ttl > cfg.MaxTTL {
ttl = cfg.MaxTTL
}
return ttl
}
func findSOA(rrs []dns.RR) *dns.SOA {
for _, rr := range rrs {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
return nil
}
func stripOPT(rrs []dns.RR) []dns.RR {
out := rrs[:0]
for _, rr := range rrs {
if rr.Header().Rrtype == dns.TypeOPT {
continue
}
out = append(out, rr)
}
return out
}
// estimateSize approximates the heap cost of an entry, for the memory readout.
func estimateSize(k Key, msg *dns.Msg) int {
const entryOverhead = 160 // struct, map bucket and list element
return entryOverhead + len(k.Name) + msg.Len()
}
func (c *Cache) touch(sh *shard, k Key) {
sh.mu.Lock()
if e, ok := sh.entries[k]; ok && e.elem != nil {
sh.lru.MoveToFront(e.elem)
}
sh.mu.Unlock()
}
func (c *Cache) remove(sh *shard, k Key) {
sh.mu.Lock()
if e, ok := sh.entries[k]; ok {
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
}
sh.mu.Unlock()
}
// Delete removes one entry. It reports whether the entry was present.
func (c *Cache) Delete(k Key) bool {
sh := c.shardFor(k)
sh.mu.Lock()
defer sh.mu.Unlock()
e, ok := sh.entries[k]
if !ok {
return false
}
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
return true
}
// Flush empties the cache and returns how many entries were dropped.
func (c *Cache) Flush() int {
total := 0
for _, sh := range c.shards {
sh.mu.Lock()
total += len(sh.entries)
sh.entries = map[Key]*entry{}
sh.lru.Init()
sh.bytes = 0
sh.mu.Unlock()
}
return total
}
// FlushName removes every entry for one name, across all types.
func (c *Cache) FlushName(name string) int {
name = strings.ToLower(dns.Fqdn(name))
removed := 0
for _, sh := range c.shards {
sh.mu.Lock()
for k, e := range sh.entries {
if k.Name == name {
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
removed++
}
}
sh.mu.Unlock()
}
return removed
}
// enforceBound evicts least-recently-used entries until the cache fits.
//
// The bound is applied per shard so that eviction never has to lock the whole
// cache at once.
func (c *Cache) enforceBound() {
cfg := c.Config()
if cfg.MaxEntries <= 0 {
return
}
perShard := cfg.MaxEntries / shardCount
if perShard < 1 {
perShard = 1
}
for _, sh := range c.shards {
sh.mu.Lock()
for len(sh.entries) > perShard {
back := sh.lru.Back()
if back == nil {
break
}
k := back.Value.(Key)
if e, ok := sh.entries[k]; ok {
sh.bytes -= int64(e.size)
delete(sh.entries, k)
}
sh.lru.Remove(back)
c.evictions.Add(1)
}
sh.mu.Unlock()
}
}
// Cleanup drops entries that are past both their TTL and their stale window.
// It returns the number removed.
func (c *Cache) Cleanup() int {
cfg := c.Config()
grace := time.Duration(0)
if cfg.ServeStale {
grace = time.Duration(cfg.StaleTTL) * time.Second
}
now := time.Now()
removed := 0
for _, sh := range c.shards {
sh.mu.Lock()
for k, e := range sh.entries {
if now.After(e.expiresAt().Add(grace)) {
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
removed++
}
}
sh.mu.Unlock()
}
c.expiries.Add(int64(removed))
return removed
}
// Run starts the periodic cleanup loop. It returns when done is closed.
func (c *Cache) Run(done <-chan struct{}, interval func() time.Duration) {
for {
d := interval()
if d <= 0 {
d = time.Minute
}
select {
case <-done:
return
case <-time.After(d):
c.Cleanup()
}
}
}
// Stats is a snapshot of cache counters for the dashboard and metrics.
type Stats struct {
Enabled bool `json:"enabled"`
Entries int `json:"entries"`
MaxEntries int `json:"max_entries"`
Bytes int64 `json:"bytes"`
Hits int64 `json:"hits"`
Misses int64 `json:"misses"`
StaleHits int64 `json:"stale_hits"`
Insertions int64 `json:"insertions"`
Evictions int64 `json:"evictions"`
Expirations int64 `json:"expirations"`
HitRate float64 `json:"hit_rate"`
}
// Stats returns the current counters.
func (c *Cache) Stats() Stats {
s := Stats{
Enabled: c.Config().Enabled,
MaxEntries: c.Config().MaxEntries,
Hits: c.hits.Load(),
Misses: c.misses.Load(),
StaleHits: c.staleHits.Load(),
Insertions: c.insertions.Load(),
Evictions: c.evictions.Load(),
Expirations: c.expiries.Load(),
}
for _, sh := range c.shards {
sh.mu.RLock()
s.Entries += len(sh.entries)
s.Bytes += sh.bytes
sh.mu.RUnlock()
}
if total := s.Hits + s.Misses; total > 0 {
s.HitRate = float64(s.Hits) / float64(total) * 100
}
return s
}
// ResetStats zeroes the counters without touching the cached data.
func (c *Cache) ResetStats() {
c.hits.Store(0)
c.misses.Store(0)
c.staleHits.Store(0)
c.insertions.Store(0)
c.evictions.Store(0)
c.expiries.Store(0)
}
// EntryView describes one cached entry for the cache browser.
type EntryView struct {
Name string `json:"name"`
Type string `json:"type"`
DO bool `json:"dnssec"`
Rcode string `json:"rcode"`
Answers int `json:"answers"`
TTL int64 `json:"ttl"`
Stored time.Time `json:"stored"`
Expires time.Time `json:"expires"`
Size int `json:"size"`
Negative bool `json:"negative"`
Stale bool `json:"stale"`
}
// Entries returns cached entries matching a substring of the name, newest
// first, capped at limit. It also returns the total number of matches.
func (c *Cache) Entries(search string, limit, offset int) ([]EntryView, int) {
search = strings.ToLower(strings.TrimSpace(search))
now := time.Now()
var out []EntryView
for _, sh := range c.shards {
sh.mu.RLock()
for k, e := range sh.entries {
if search != "" && !strings.Contains(k.Name, search) {
continue
}
expires := e.expiresAt()
out = append(out, EntryView{
Name: k.Name,
Type: dns.TypeToString[k.Type],
DO: k.DO,
Rcode: dns.RcodeToString[e.rcode],
Answers: len(e.msg.Answer),
TTL: int64(expires.Sub(now) / time.Second),
Stored: e.stored,
Expires: expires,
Size: e.size,
Negative: e.negative,
Stale: now.After(expires),
})
}
sh.mu.RUnlock()
}
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Type < out[j].Type
})
total := len(out)
if offset > total {
offset = total
}
out = out[offset:]
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, total
}
+331
View File
@@ -0,0 +1,331 @@
package cache
import (
"testing"
"time"
"github.com/miekg/dns"
)
func testConfig() Config {
return Config{
Enabled: true, MaxEntries: 1000, MinTTL: 0, MaxTTL: 86400,
NegativeTTL: 300, ServeStale: false, StaleTTL: 0,
}
}
func reply(name string, ttl uint32, ip string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
m = m.SetReply(m)
m.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: []byte{192, 0, 2, 1},
}}
return m
}
func keyFor(name string) Key {
return Key{Name: dns.Fqdn(name), Type: dns.TypeA, Class: dns.ClassINET}
}
func request(name string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
return m
}
func TestPutAndGet(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
if res := c.Get(k, request("example.com")); res.Hit {
t.Fatal("expected a miss on an empty cache")
}
if ttl := c.Put(k, reply("example.com", 300, "192.0.2.1")); ttl != 300 {
t.Fatalf("stored TTL = %d, want 300", ttl)
}
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit after storing")
}
if len(res.Msg.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(res.Msg.Answer))
}
s := c.Stats()
if s.Hits != 1 || s.Misses != 1 {
t.Errorf("hits/misses = %d/%d, want 1/1", s.Hits, s.Misses)
}
}
func TestTTLIsClamped(t *testing.T) {
tests := []struct {
name string
min, max uint32
ttl uint32
want uint32
}{
{"below minimum is raised", 60, 86400, 5, 60},
{"above maximum is capped", 0, 3600, 100000, 3600},
{"within bounds is unchanged", 60, 3600, 300, 300},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := testConfig()
cfg.MinTTL, cfg.MaxTTL = tc.min, tc.max
c := New(cfg)
if got := c.Put(keyFor("example.com"), reply("example.com", tc.ttl, "192.0.2.1")); got != tc.want {
t.Errorf("stored TTL = %d, want %d", got, tc.want)
}
})
}
}
// TestTTLDecays is the property that matters most: a cached answer must not
// hand out a TTL that stands still, or downstream caches never expire it.
func TestTTLDecays(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 300, "192.0.2.1"))
// Reach in and age the entry rather than sleeping for real.
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-100 * time.Second)
sh.mu.Unlock()
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit while still fresh")
}
got := res.Msg.Answer[0].Header().Ttl
if got > 201 || got < 199 {
t.Errorf("served TTL = %d, want about 200 after 100 seconds", got)
}
}
func TestExpiredEntryIsAMiss(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-30 * time.Second)
sh.mu.Unlock()
if res := c.Get(k, request("example.com")); res.Hit {
t.Error("an expired entry must not be served when stale serving is off")
}
}
func TestServeStale(t *testing.T) {
cfg := testConfig()
cfg.ServeStale = true
cfg.StaleTTL = 3600
c := New(cfg)
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-60 * time.Second)
sh.mu.Unlock()
res := c.Get(k, request("example.com"))
if !res.Hit || !res.Stale {
t.Fatalf("expected a stale hit, got hit=%v stale=%v", res.Hit, res.Stale)
}
if got := res.Msg.Answer[0].Header().Ttl; got == 0 || got > 60 {
t.Errorf("stale TTL = %d, want a short positive value", got)
}
// Past the stale window it must miss.
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-7200 * time.Second)
sh.mu.Unlock()
if res := c.Get(k, request("example.com")); res.Hit {
t.Error("an entry past the stale window must not be served")
}
}
func TestNegativeCachingUsesSOAMinimum(t *testing.T) {
c := New(testConfig())
m := new(dns.Msg)
m.SetQuestion("missing.example.com.", dns.TypeA)
m = m.SetReply(m)
m.Rcode = dns.RcodeNameError
m.Ns = []dns.RR{&dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "hostmaster.example.com.",
Minttl: 120,
}}
k := Key{Name: "missing.example.com.", Type: dns.TypeA, Class: dns.ClassINET}
// RFC 2308: the negative TTL is the lesser of the SOA TTL and its MINIMUM.
if ttl := c.Put(k, m); ttl != 120 {
t.Errorf("negative TTL = %d, want the SOA minimum of 120", ttl)
}
if res := c.Get(k, request("missing.example.com")); !res.Hit {
t.Error("a negative answer should be cached")
}
}
func TestUncacheableResponses(t *testing.T) {
c := New(testConfig())
t.Run("servfail is not cached", func(t *testing.T) {
m := reply("fail.example.com", 300, "192.0.2.1")
m.Rcode = dns.RcodeServerFailure
if ttl := c.Put(keyFor("fail.example.com"), m); ttl != 0 {
t.Errorf("stored a SERVFAIL with TTL %d; transient failures must not be cached", ttl)
}
})
t.Run("truncated is not cached", func(t *testing.T) {
m := reply("trunc.example.com", 300, "192.0.2.1")
m.Truncated = true
if ttl := c.Put(keyFor("trunc.example.com"), m); ttl != 0 {
t.Errorf("stored a truncated response with TTL %d", ttl)
}
})
}
// TestOPTIsNotReplayed guards a subtle correctness bug: the OPT record
// describes one client's transport, not the data, so replaying it to another
// client would advertise the wrong buffer size.
func TestOPTIsNotReplayed(t *testing.T) {
c := New(testConfig())
m := reply("example.com", 300, "192.0.2.1")
m.SetEdns0(4096, true)
k := keyFor("example.com")
c.Put(k, m)
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit")
}
if res.Msg.IsEdns0() != nil {
t.Error("the cached response still carries an OPT record from the original exchange")
}
}
// TestDOBitSeparatesEntries: a DNSSEC answer carries RRSIGs that a non-DO
// client must never receive, so the two must not share a cache entry.
func TestDOBitSeparatesEntries(t *testing.T) {
c := New(testConfig())
plain := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: false}
signed := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: true}
c.Put(plain, reply("example.com", 300, "192.0.2.1"))
if res := c.Get(signed, request("example.com")); res.Hit {
t.Error("a DO query was served from the non-DO cache entry")
}
}
func TestFlushAndDelete(t *testing.T) {
c := New(testConfig())
for _, n := range []string{"a.example.com", "b.example.com", "c.example.com"} {
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
}
if got := c.Stats().Entries; got != 3 {
t.Fatalf("entries = %d, want 3", got)
}
if !c.Delete(keyFor("a.example.com")) {
t.Error("Delete reported the entry was absent")
}
if got := c.Stats().Entries; got != 2 {
t.Errorf("entries after delete = %d, want 2", got)
}
if n := c.Flush(); n != 2 {
t.Errorf("Flush removed %d, want 2", n)
}
if got := c.Stats().Entries; got != 0 {
t.Errorf("entries after flush = %d, want 0", got)
}
}
func TestFlushName(t *testing.T) {
c := New(testConfig())
c.Put(Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
c.Put(Key{Name: "example.com.", Type: dns.TypeAAAA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
c.Put(Key{Name: "other.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("other.com", 300, "192.0.2.1"))
if n := c.FlushName("example.com"); n != 2 {
t.Errorf("FlushName removed %d entries, want 2 (both types)", n)
}
if got := c.Stats().Entries; got != 1 {
t.Errorf("entries remaining = %d, want 1", got)
}
}
func TestDisabledCacheStoresNothing(t *testing.T) {
cfg := testConfig()
cfg.Enabled = false
c := New(cfg)
if ttl := c.Put(keyFor("example.com"), reply("example.com", 300, "192.0.2.1")); ttl != 0 {
t.Error("a disabled cache must not store entries")
}
if res := c.Get(keyFor("example.com"), request("example.com")); res.Hit {
t.Error("a disabled cache must not report hits")
}
}
func TestEvictionRespectsBound(t *testing.T) {
cfg := testConfig()
// One entry per shard; the bound is applied per shard.
cfg.MaxEntries = shardCount
c := New(cfg)
for i := 0; i < shardCount*20; i++ {
name := dns.Fqdn("host" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ".example.com")
c.Put(Key{Name: name, Type: dns.TypeA, Class: dns.ClassINET}, reply(name, 300, "192.0.2.1"))
}
s := c.Stats()
if s.Entries > shardCount {
t.Errorf("entries = %d, want at most %d after eviction", s.Entries, shardCount)
}
if s.Evictions == 0 {
t.Error("expected evictions to be recorded")
}
}
func TestCleanupRemovesExpired(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-time.Hour)
sh.mu.Unlock()
if n := c.Cleanup(); n != 1 {
t.Errorf("Cleanup removed %d, want 1", n)
}
}
func TestEntriesBrowsing(t *testing.T) {
c := New(testConfig())
for _, n := range []string{"alpha.example.com", "beta.example.com", "gamma.test"} {
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
}
all, total := c.Entries("", 10, 0)
if total != 3 || len(all) != 3 {
t.Errorf("browse all: got %d of %d, want 3 of 3", len(all), total)
}
filtered, total := c.Entries("example.com", 10, 0)
if total != 2 || len(filtered) != 2 {
t.Errorf("browse filtered: got %d of %d, want 2 of 2", len(filtered), total)
}
}
+210
View File
@@ -0,0 +1,210 @@
// Package cli implements the command line interface.
//
// `serve` is the default when no subcommand is given, so running the binary
// with no arguments starts the server, which is the overwhelmingly common case.
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/version"
)
// Command is one subcommand.
type Command struct {
Name string
Summary string
Usage string
Run func(ctx context.Context, env *Env, args []string) error
}
// Env carries what every command needs.
type Env struct {
Boot config.Bootstrap
Stdout io.Writer
Stderr io.Writer
Log *slog.Logger
}
// ExitError carries a specific process exit status.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string { return e.Err.Error() }
func (e *ExitError) Unwrap() error { return e.Err }
// Exit wraps an error with an exit code.
func Exit(code int, format string, args ...any) error {
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
}
// commands is the full command table.
func commands() []*Command {
return []*Command{
serveCommand(),
versionCommand(),
configCommand(),
adminCommand(),
databaseCommand(),
}
}
// Main parses arguments and runs the selected command.
func Main(ctx context.Context, args []string, stdout, stderr io.Writer) int {
env := &Env{Boot: config.DefaultBootstrap(), Stdout: stdout, Stderr: stderr}
// Find the subcommand: the first argument that is not a flag. This lets
// both `vibedns --db x serve` and `vibedns serve --db x` work.
name := ""
rest := args
for i, a := range args {
if !strings.HasPrefix(a, "-") {
name = a
rest = append(append([]string{}, args[:i]...), args[i+1:]...)
break
}
}
switch name {
case "help", "-h", "--help", "":
if name == "" && !wantsHelp(args) {
// No subcommand: serve.
return run(ctx, env, serveCommand(), args)
}
usage(stdout)
return 0
}
for _, c := range commands() {
if c.Name == name {
return run(ctx, env, c, rest)
}
}
fmt.Fprintf(stderr, "unknown command %q\n\n", name)
usage(stderr)
return 2
}
func wantsHelp(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
return true
}
}
return false
}
func run(ctx context.Context, env *Env, c *Command, args []string) int {
err := c.Run(ctx, env, args)
if err == nil {
return 0
}
if errors.Is(err, flag.ErrHelp) {
return 0
}
var ee *ExitError
if errors.As(err, &ee) {
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, ee.Err)
return ee.Code
}
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, err)
return 1
}
func usage(w io.Writer) {
fmt.Fprintf(w, `%s %s — authoritative DNS server, recursive resolver and filtering appliance
Usage:
vibedns [command] [flags]
Commands:
`, version.Name, version.Version)
for _, c := range commands() {
fmt.Fprintf(w, " %-20s %s\n", c.Name, c.Summary)
}
fmt.Fprintf(w, `
Running with no command starts the server.
Common flags:
--db PATH SQLite database file (default %s)
--http ADDR management interface address (default %s)
--dns ADDR DNS listen address for UDP and TCP (default %s)
--log-level LEVEL debug, info, warn or error
--log-format FORMAT text or json
Environment variables:
%-22s database file
%-22s management interface address
%-22s DNS listen address
%-22s initial administrator username
%-22s initial administrator password
%-22s log level
%-22s log format
Examples:
vibedns
vibedns serve --db /var/lib/vibedns/dns.db --http 127.0.0.1:8080
vibedns config check
vibedns admin reset-password
vibedns database backup --output /var/backups
vibedns version
`,
config.DefaultDBPath, config.DefaultHTTPAddr, config.DefaultDNSAddr,
config.EnvDBPath, config.EnvHTTPAddr, config.EnvDNSAddr,
config.EnvAdminUsername, config.EnvAdminPassword,
config.EnvLogLevel, config.EnvLogFormat)
}
// newFlagSet builds a flag set that prints its own usage on error.
func newFlagSet(env *Env, c *Command) *flag.FlagSet {
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
fs.SetOutput(env.Stderr)
fs.Usage = func() {
fmt.Fprintf(env.Stderr, "%s\n\n", c.Usage)
fs.PrintDefaults()
}
return fs
}
// versionCommand prints build information.
func versionCommand() *Command {
return &Command{
Name: "version",
Summary: "print version and build information",
Usage: "Usage: vibedns version",
Run: func(ctx context.Context, env *Env, args []string) error {
fmt.Fprint(env.Stdout, version.Long())
return nil
},
}
}
// confirm asks for an interactive yes/no answer. It returns false when stdin
// is not a terminal, so a piped invocation can never be silently destructive.
func confirm(env *Env, prompt string) bool {
fi, err := os.Stdin.Stat()
if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 {
fmt.Fprintf(env.Stderr,
"%s\nRefusing to continue without an interactive confirmation; pass --yes to proceed.\n", prompt)
return false
}
fmt.Fprintf(env.Stdout, "%s [y/N]: ", prompt)
var answer string
_, _ = fmt.Fscanln(os.Stdin, &answer)
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
+596
View File
@@ -0,0 +1,596 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/backup"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
)
// --- config -------------------------------------------------------------
func configCommand() *Command {
return &Command{
Name: "config",
Summary: "inspect and validate configuration (check, show)",
Usage: "Usage: vibedns config <check|show> [flags]\n\n" +
" check validate the bootstrap and stored configuration\n" +
" show print the effective configuration",
Run: runConfig,
}
}
func runConfig(ctx context.Context, env *Env, args []string) error {
sub, rest := splitSub(args)
switch sub {
case "check", "":
return runConfigCheck(ctx, env, rest)
case "show":
return runConfigShow(ctx, env, rest)
default:
return Exit(2, "unknown subcommand %q; expected check or show", sub)
}
}
// runConfigCheck validates everything it can without binding a port, so it is
// safe to run on a live server and useful in a deployment pipeline.
func runConfigCheck(ctx context.Context, env *Env, args []string) error {
c := configCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
w := env.Stdout
problems := 0
report := func(ok bool, label, detail string) {
if ok {
fmt.Fprintf(w, " ok %-28s %s\n", label, detail)
return
}
problems++
fmt.Fprintf(w, " FAIL %-28s %s\n", label, detail)
}
fmt.Fprintf(w, "Configuration check\n\n")
if err := env.Boot.Validate(); err != nil {
report(false, "startup options", err.Error())
} else {
report(true, "startup options", "valid")
}
if _, err := os.Stat(env.Boot.DBPath); err != nil {
report(true, "database", fmt.Sprintf("%s (will be created)", env.Boot.DBPath))
fmt.Fprintf(w, "\n%d problem(s) found.\n", problems)
if problems > 0 {
return Exit(1, "configuration check failed")
}
return nil
}
db, err := database.Open(env.Boot.DBPath)
if err != nil {
report(false, "database", err.Error())
return Exit(1, "configuration check failed")
}
defer db.Close()
report(true, "database", env.Boot.DBPath)
statuses, err := db.MigrationStatuses(ctx)
if err != nil {
report(false, "migrations", err.Error())
} else {
pending, drift := 0, 0
for _, m := range statuses {
if !m.Applied {
pending++
}
if m.Drifted {
drift++
}
}
switch {
case drift > 0:
report(false, "migrations", fmt.Sprintf("%d applied migration(s) no longer match the embedded files", drift))
case pending > 0:
report(true, "migrations", fmt.Sprintf("%d pending, will apply on start", pending))
default:
report(true, "migrations", fmt.Sprintf("all %d applied", len(statuses)))
}
}
stored, err := db.Settings(ctx)
if err != nil {
report(false, "settings", err.Error())
return Exit(1, "configuration check failed")
}
settings := config.LoadSettings(stored)
if err := settings.Validate(); err != nil {
report(false, "settings", err.Error())
} else {
report(true, "settings", "valid")
}
// The open-resolver check is the one worth being loud about.
if settings.DNS.Recursion {
wide := widestAllowance(settings.Resolver.AllowNetworks)
if wide != "" {
report(false, "recursion ACL",
fmt.Sprintf("%s allows the entire Internet to use this server as a resolver", wide))
} else {
report(true, "recursion ACL",
fmt.Sprintf("%d network(s) permitted", len(settings.Resolver.AllowNetworks)))
}
} else {
report(true, "recursion", "disabled")
}
if _, err := db.Admin(ctx); errors.Is(err, database.ErrNotFound) {
report(true, "administrator", "not created yet, will be generated on first start")
} else if err != nil {
report(false, "administrator", err.Error())
} else {
report(true, "administrator", "present")
}
if settings.Backup.Enabled {
dir := settings.Backup.Directory
if err := os.MkdirAll(dir, 0o750); err != nil {
report(false, "backup directory", err.Error())
} else {
report(true, "backup directory", dir)
}
}
fmt.Fprintf(w, "\n%d problem(s) found.\n", problems)
if problems > 0 {
return Exit(1, "configuration check failed")
}
return nil
}
// widestAllowance returns the first ACL entry that covers the whole Internet.
func widestAllowance(allow []string) string {
for _, a := range allow {
switch strings.TrimSpace(a) {
case "0.0.0.0/0", "::/0":
return a
}
}
return ""
}
func runConfigShow(ctx context.Context, env *Env, args []string) error {
c := configCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
db, err := database.Open(env.Boot.DBPath)
if err != nil {
return Exit(1, "%v", err)
}
defer db.Close()
stored, err := db.Settings(ctx)
if err != nil {
return Exit(1, "%v", err)
}
settings := config.LoadSettings(stored)
keys := settings.ToMap()
names := make([]string, 0, len(keys))
for k := range keys {
names = append(names, k)
}
sortStrings(names)
fmt.Fprintf(env.Stdout, "# effective configuration for %s\n", env.Boot.DBPath)
for _, k := range names {
v := keys[k]
if strings.Contains(v, "\n") {
v = strings.ReplaceAll(v, "\n", ",")
}
fmt.Fprintf(env.Stdout, "%-32s %s\n", k, v)
}
return nil
}
// --- admin --------------------------------------------------------------
func adminCommand() *Command {
return &Command{
Name: "admin",
Summary: "manage the administrator account (reset-password, show)",
Usage: "Usage: vibedns admin <reset-password|show> [flags]\n\n" +
" reset-password generate or set a new administrator password\n" +
" show print the administrator username",
Run: runAdmin,
}
}
func runAdmin(ctx context.Context, env *Env, args []string) error {
sub, rest := splitSub(args)
switch sub {
case "reset-password":
return runResetPassword(ctx, env, rest)
case "show":
return runAdminShow(ctx, env, rest)
default:
return Exit(2, "unknown subcommand %q; expected reset-password or show", sub)
}
}
// runResetPassword is the recovery path for a lost password. It requires
// filesystem access to the database, which is the only credential it can
// sensibly demand.
func runResetPassword(ctx context.Context, env *Env, args []string) error {
c := adminCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
password := fs.String("password", "",
"new password (omit to generate one; prefer omitting, since arguments are visible in the process list)")
username := fs.String("username", "", "also change the username")
if err := fs.Parse(args); err != nil {
return err
}
db, err := database.Open(env.Boot.DBPath)
if err != nil {
return Exit(1, "%v", err)
}
defer db.Close()
if _, err := db.Migrate(ctx); err != nil {
return Exit(1, "%v", err)
}
admin, err := db.Admin(ctx)
if errors.Is(err, database.ErrNotFound) {
return Exit(1, "no administrator account exists yet; start the server once to create one")
}
if err != nil {
return Exit(1, "%v", err)
}
newPassword := *password
generated := false
if newPassword == "" {
newPassword, err = auth.GeneratePassword(20)
if err != nil {
return Exit(1, "%v", err)
}
generated = true
}
if err := auth.ValidatePassword(newPassword); err != nil {
return Exit(2, "%v", err)
}
name := admin.Username
if *username != "" {
if err := config.ValidateUsername(*username); err != nil {
return Exit(2, "%v", err)
}
name = *username
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return Exit(1, "%v", err)
}
// The reset flag is set so the UI keeps prompting until a human picks a
// password of their own.
if err := db.UpdateAdminCredentials(ctx, name, hash, generated); err != nil {
return Exit(1, "%v", err)
}
log := newLogger("error", "text", env.Stderr)
audit := auditlog.New(db, log)
audit.Record(ctx, auditlog.CLIActor(), "admin.password_reset", auditlog.ObjectAdmin, "1", name,
"password reset from the command line")
fmt.Fprintf(env.Stdout, "\nAdministrator credentials updated.\n\n")
fmt.Fprintf(env.Stdout, " Username: %s\n", name)
if generated {
fmt.Fprintf(env.Stdout, " Password: %s\n", newPassword)
fmt.Fprintf(env.Stdout, "\nThis password will not be displayed again.\n")
} else {
fmt.Fprintf(env.Stdout, " Password: (as supplied)\n")
}
fmt.Fprintf(env.Stdout, "\nRestart the server, or wait a few minutes, for the change to take effect\n"+
"on sessions that authenticated with the old password.\n\n")
return nil
}
func runAdminShow(ctx context.Context, env *Env, args []string) error {
c := adminCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
db, err := database.Open(env.Boot.DBPath)
if err != nil {
return Exit(1, "%v", err)
}
defer db.Close()
admin, err := db.Admin(ctx)
if errors.Is(err, database.ErrNotFound) {
return Exit(1, "no administrator account exists yet")
}
if err != nil {
return Exit(1, "%v", err)
}
fmt.Fprintf(env.Stdout, "Username: %s\n", admin.Username)
fmt.Fprintf(env.Stdout, "Created: %s\n", admin.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(env.Stdout, "Updated: %s\n", admin.UpdatedAt.Format("2006-01-02 15:04:05"))
if admin.LastLoginAt != nil {
fmt.Fprintf(env.Stdout, "Last sign-in: %s\n", admin.LastLoginAt.Format("2006-01-02 15:04:05"))
} else {
fmt.Fprintf(env.Stdout, "Last sign-in: never\n")
}
fmt.Fprintf(env.Stdout, "Must change password: %t\n", admin.MustChangePassword)
return nil
}
// --- database -----------------------------------------------------------
func databaseCommand() *Command {
return &Command{
Name: "database",
Summary: "database maintenance (migrate, backup, restore, vacuum, stats)",
Usage: "Usage: vibedns database <migrate|backup|restore|vacuum|stats> [flags]\n\n" +
" migrate apply pending schema migrations\n" +
" backup write a consistent backup copy\n" +
" restore stage a backup to be applied on the next start\n" +
" vacuum reclaim space after large deletions\n" +
" stats print size and row counts",
Run: runDatabase,
}
}
func runDatabase(ctx context.Context, env *Env, args []string) error {
sub, rest := splitSub(args)
switch sub {
case "migrate":
return runMigrate(ctx, env, rest)
case "backup":
return runBackup(ctx, env, rest)
case "restore":
return runRestore(ctx, env, rest)
case "vacuum":
return runVacuum(ctx, env, rest)
case "stats":
return runDBStats(ctx, env, rest)
default:
return Exit(2, "unknown subcommand %q; expected migrate, backup, restore, vacuum or stats", sub)
}
}
func openDB(env *Env, fs interface{ Parse([]string) error }, args []string) (*database.DB, error) {
if err := fs.Parse(args); err != nil {
return nil, err
}
db, err := database.Open(env.Boot.DBPath)
if err != nil {
return nil, Exit(1, "%v", err)
}
return db, nil
}
func runMigrate(ctx context.Context, env *Env, args []string) error {
c := databaseCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
db, err := openDB(env, fs, args)
if err != nil {
return err
}
defer db.Close()
before, _ := db.SchemaVersion(ctx)
applied, err := db.Migrate(ctx)
if err != nil {
return Exit(1, "%v", err)
}
after, _ := db.SchemaVersion(ctx)
if applied == 0 {
fmt.Fprintf(env.Stdout, "Database is already at schema version %d; nothing to do.\n", after)
return nil
}
fmt.Fprintf(env.Stdout, "Applied %d migration(s): schema version %d to %d.\n", applied, before, after)
return nil
}
func runBackup(ctx context.Context, env *Env, args []string) error {
c := databaseCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
output := fs.String("output", "", "directory to write the backup into (defaults to the configured one)")
db, err := openDB(env, fs, args)
if err != nil {
return err
}
defer db.Close()
if _, err := db.Migrate(ctx); err != nil {
return Exit(1, "%v", err)
}
stored, err := db.Settings(ctx)
if err != nil {
return Exit(1, "%v", err)
}
settings := config.LoadSettings(stored)
dir := *output
if dir == "" {
dir = settings.Backup.Directory
}
if dir == "" {
dir = filepath.Join(filepath.Dir(env.Boot.DBPath), "backups")
}
log := newLogger("error", "text", env.Stderr)
mgr := backup.New(db, log, backup.Config{
Enabled: true,
Directory: dir,
IntervalHours: settings.Backup.IntervalHours,
Retention: settings.Backup.Retention,
})
info, err := mgr.Run(ctx)
if err != nil {
return Exit(1, "%v", err)
}
audit := auditlog.New(db, log)
audit.Record(ctx, auditlog.CLIActor(), "backup.create", auditlog.ObjectBackup, info.Name, info.Name,
fmt.Sprintf("bytes=%d", info.SizeBytes))
fmt.Fprintf(env.Stdout, "Backup written to %s (%.1f MB).\n", info.Path, info.SizeMB())
return nil
}
func runRestore(ctx context.Context, env *Env, args []string) error {
c := databaseCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
file := fs.String("file", "", "backup file to restore from (required)")
yes := fs.Bool("yes", false, "skip the interactive confirmation")
if err := fs.Parse(args); err != nil {
return err
}
if *file == "" {
return Exit(2, "--file is required; pass the path to a backup produced by `database backup`")
}
if err := backup.Verify(*file); err != nil {
return Exit(1, "%v", err)
}
prompt := fmt.Sprintf(
"Restoring %s will replace every zone, record, policy and setting in %s.\n"+
"The current database is preserved alongside it. Continue?",
*file, env.Boot.DBPath)
if !*yes && !confirm(env, prompt) {
fmt.Fprintln(env.Stdout, "Restore cancelled.")
return nil
}
if err := backup.StageRestore(env.Boot.DBPath, *file); err != nil {
return Exit(1, "%v", err)
}
fmt.Fprintf(env.Stdout,
"Restore staged. It is applied the next time the server starts.\n"+
"Restart the service now to complete it.\n")
return nil
}
func runVacuum(ctx context.Context, env *Env, args []string) error {
c := databaseCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
db, err := openDB(env, fs, args)
if err != nil {
return err
}
defer db.Close()
before, _ := db.Stats(ctx)
if err := db.Checkpoint(ctx); err != nil {
return Exit(1, "checkpoint the write-ahead log: %v", err)
}
if err := db.Vacuum(ctx); err != nil {
return Exit(1, "vacuum: %v", err)
}
after, _ := db.Stats(ctx)
fmt.Fprintf(env.Stdout, "Vacuum complete: %s reclaimed (%s to %s).\n",
humanSize(before.SizeBytes-after.SizeBytes),
humanSize(before.SizeBytes), humanSize(after.SizeBytes))
return nil
}
func runDBStats(ctx context.Context, env *Env, args []string) error {
c := databaseCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
db, err := openDB(env, fs, args)
if err != nil {
return err
}
defer db.Close()
s, err := db.Stats(ctx)
if err != nil {
return Exit(1, "%v", err)
}
fmt.Fprintf(env.Stdout, "Path: %s\n", s.Path)
fmt.Fprintf(env.Stdout, "Size: %s\n", humanSize(s.SizeBytes))
fmt.Fprintf(env.Stdout, "Write-ahead log: %s\n", humanSize(s.WALBytes))
fmt.Fprintf(env.Stdout, "Schema version: %d\n", s.SchemaVer)
fmt.Fprintf(env.Stdout, "Free pages: %d of %d\n", s.FreePages, s.PageCount)
fmt.Fprintf(env.Stdout, "\nZones: %d\n", s.Zones)
fmt.Fprintf(env.Stdout, "Records: %d\n", s.Records)
fmt.Fprintf(env.Stdout, "List domains: %d\n", s.Domains)
fmt.Fprintf(env.Stdout, "Query log rows: %d\n", s.QueryLogs)
fmt.Fprintf(env.Stdout, "Audit log rows: %d\n", s.AuditLogs)
fmt.Fprintf(env.Stdout, "API tokens: %d\n", s.APITokens)
return nil
}
// --- helpers ------------------------------------------------------------
// splitSub pulls the second-level subcommand out of the argument list.
func splitSub(args []string) (string, []string) {
for i, a := range args {
if !strings.HasPrefix(a, "-") {
return a, append(append([]string{}, args[:i]...), args[i+1:]...)
}
}
return "", args
}
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
func humanSize(b int64) string {
const unit = 1024
if b < 0 {
return "0 B"
}
if b < unit {
return fmt.Sprintf("%d B", b)
}
f := float64(b)
for _, u := range []string{"KB", "MB", "GB", "TB"} {
f /= unit
if f < unit {
return fmt.Sprintf("%.1f %s", f, u)
}
}
return fmt.Sprintf("%.1f PB", f)
}
+258
View File
@@ -0,0 +1,258 @@
package cli
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/owen/vibedns/internal/api"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/backup"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/version"
"github.com/owen/vibedns/internal/web"
)
func serveCommand() *Command {
return &Command{
Name: "serve",
Summary: "run the DNS server and management interface (default)",
Usage: "Usage: vibedns serve [flags]",
Run: runServe,
}
}
func runServe(ctx context.Context, env *Env, args []string) error {
c := serveCommand()
fs := newFlagSet(env, c)
env.Boot.BindFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
env.Boot.NoteFlagsSet(fs)
if err := env.Boot.Validate(); err != nil {
return Exit(2, "%v", err)
}
log := newLogger(env.Boot.LogLevel, env.Boot.LogFormat, env.Stderr)
env.Log = log
// A staged restore is applied before anything opens the database, which is
// the only moment it can be swapped safely.
if _, err := backup.ApplyPendingRestore(env.Boot.DBPath, log); err != nil {
return Exit(1, "could not apply the staged database restore: %v", err)
}
db, err := database.Open(env.Boot.DBPath)
if err != nil {
return Exit(1, "%v", err)
}
defer db.Close()
applied, err := db.Migrate(ctx)
if err != nil {
return Exit(1, "%v", err)
}
if applied > 0 {
log.Info("database migrations applied", "count", applied)
}
application, err := app.New(ctx, env.Boot, db, log)
if err != nil {
return Exit(1, "%v", err)
}
// Startup-critical addresses on the command line win over stored settings,
// which is what makes a misconfigured listen address recoverable.
if err := applyAddressOverrides(ctx, application, env.Boot); err != nil {
return Exit(1, "%v", err)
}
generated, err := ensureAdmin(ctx, application, env.Boot)
if err != nil {
return Exit(1, "%v", err)
}
// Re-read the settings after any override, then check them before binding.
settings := application.Settings()
if err := settings.Validate(); err != nil {
log.Warn("stored settings have a problem", "error", err)
}
apiServer := api.New(application, log)
webServer, err := web.New(web.Options{App: application, Log: log, API: apiServer.Handler()})
if err != nil {
return Exit(1, "could not prepare the management interface: %v", err)
}
runCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
if err := application.Start(runCtx); err != nil {
return Exit(1, "%v", err)
}
httpAddr := settings.HTTP.Listen
if err := webServer.Start(httpAddr); err != nil {
_ = application.Shutdown(context.Background())
return Exit(1, "%v", err)
}
printBanner(env, application, settings, generated)
<-runCtx.Done()
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := webServer.Shutdown(shutdownCtx); err != nil {
log.Warn("management interface did not stop cleanly", "error", err)
}
if err := application.Shutdown(shutdownCtx); err != nil {
log.Warn("DNS server did not stop cleanly", "error", err)
}
if err := db.Checkpoint(shutdownCtx); err != nil {
log.Warn("could not checkpoint the write-ahead log", "error", err)
}
log.Info("stopped")
return nil
}
// applyAddressOverrides persists listen addresses supplied on the command line
// or in the environment, so the running process and the stored configuration
// agree about where it is listening.
func applyAddressOverrides(ctx context.Context, a *app.App, boot config.Bootstrap) error {
next := a.Settings()
changed := false
if boot.DNSAddrOverridden() {
if next.DNS.UDPListen != boot.DNSUDPAddr || next.DNS.TCPListen != boot.DNSTCPAddr {
next.DNS.UDPListen = boot.DNSUDPAddr
next.DNS.TCPListen = boot.DNSTCPAddr
changed = true
}
}
if boot.HTTPAddrOverridden() && next.HTTP.Listen != boot.HTTPAddr {
next.HTTP.Listen = boot.HTTPAddr
changed = true
}
if !changed {
return nil
}
next.Normalise()
if err := a.DB.SetSettings(ctx, next.ToMap()); err != nil {
return fmt.Errorf("store the listen addresses: %w", err)
}
return a.Runtime.Reload(ctx)
}
// ensureAdmin creates the administrator on first run, returning the generated
// password when one had to be invented.
func ensureAdmin(ctx context.Context, a *app.App, boot config.Bootstrap) (string, error) {
username := boot.AdminUsername
if username == "" {
username = "admin"
}
password := boot.AdminPassword
if password != "" {
if err := auth.ValidatePassword(password); err != nil {
return "", fmt.Errorf("the administrator password supplied in %s is unusable: %w",
config.EnvAdminPassword, err)
}
}
_, generated, err := a.Auth.EnsureAdmin(ctx, username, password)
if err != nil {
return "", fmt.Errorf("create the administrator account: %w", err)
}
return generated, nil
}
// printBanner writes the startup summary an operator reads once.
func printBanner(env *Env, a *app.App, settings config.Settings, generatedPassword string) {
w := env.Stdout
admin, _ := a.Admin(context.Background())
scheme := "http"
host := settings.HTTP.Listen
if strings.HasPrefix(host, "0.0.0.0:") {
host = "127.0.0.1:" + strings.TrimPrefix(host, "0.0.0.0:")
} else if strings.HasPrefix(host, "[::]:") {
host = "127.0.0.1:" + strings.TrimPrefix(host, "[::]:")
}
fmt.Fprintf(w, "\n%s %s starting\n\n", version.Name, version.Version)
fmt.Fprintf(w, " Database: %s\n", a.DB.Path())
fmt.Fprintf(w, " DNS UDP: %s\n", settings.DNS.UDPListen)
fmt.Fprintf(w, " DNS TCP: %s\n", settings.DNS.TCPListen)
fmt.Fprintf(w, " Management: %s://%s\n", scheme, host)
snap := a.Snapshot()
fmt.Fprintf(w, " Zones: %d (%d records)\n", snap.ZoneCount, snap.RecordCount)
fmt.Fprintf(w, " Filtering: %d networks, %s blocked domains\n",
snap.NetworkCount, formatCount(snap.BlacklistDomains))
if settings.DNS.Recursion {
fmt.Fprintf(w, " Recursion: enabled for %d network(s), %d upstream(s)\n",
len(settings.Resolver.AllowNetworks), len(settings.Resolver.Upstreams))
} else {
fmt.Fprintf(w, " Recursion: disabled (authoritative only)\n")
}
if generatedPassword != "" {
fmt.Fprintf(w, "\n Initial administrator:\n")
fmt.Fprintf(w, " Username: %s\n", admin.Username)
fmt.Fprintf(w, " Password: %s\n", generatedPassword)
fmt.Fprintf(w, "\n This password will not be displayed again.\n")
fmt.Fprintf(w, " Change it at %s://%s/account\n", scheme, host)
}
fmt.Fprintln(w)
}
func formatCount(n int) string {
s := fmt.Sprintf("%d", n)
if n < 1000 {
return s
}
var out []string
for len(s) > 3 {
out = append([]string{s[len(s)-3:]}, out...)
s = s[:len(s)-3]
}
return strings.Join(append([]string{s}, out...), ",")
}
// newLogger builds the structured logger.
func newLogger(level, format string, w io.Writer) *slog.Logger {
var lv slog.Level
switch strings.ToLower(level) {
case "debug":
lv = slog.LevelDebug
case "warn":
lv = slog.LevelWarn
case "error":
lv = slog.LevelError
default:
lv = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lv}
var h slog.Handler
if strings.ToLower(format) == "json" {
h = slog.NewJSONHandler(w, opts)
} else {
h = slog.NewTextHandler(w, opts)
}
return slog.New(h)
}
+193
View File
@@ -0,0 +1,193 @@
// Package config holds two distinct kinds of configuration.
//
// Bootstrap holds the startup-critical values that must be known before the
// database is open: where the database lives, which addresses to listen on and
// the initial administrator. It comes from CLI flags and environment
// variables.
//
// Settings holds everything else. It lives in SQLite, is editable from the web
// UI and can mostly be changed without restarting.
package config
import (
"errors"
"flag"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
// Bootstrap is the startup configuration.
type Bootstrap struct {
DBPath string
HTTPAddr string
DNSUDPAddr string
DNSTCPAddr string
AdminUsername string
AdminPassword string
LogLevel string
LogFormat string
// dnsAddrSet records whether --dns was given, so that a stored setting is
// only overridden when the operator explicitly asked for it.
dnsAddrSet bool
httpAddrSet bool
}
// Default values used when neither a flag nor an environment variable is set.
const (
DefaultDBPath = "./data/dns.db"
DefaultHTTPAddr = "127.0.0.1:8080"
DefaultDNSAddr = "0.0.0.0:53"
)
// Environment variable names.
const (
EnvDBPath = "VIBEDNS_DB_PATH"
EnvHTTPAddr = "VIBEDNS_HTTP_ADDR"
EnvDNSAddr = "VIBEDNS_DNS_ADDR"
EnvAdminUsername = "VIBEDNS_ADMIN_USERNAME"
EnvAdminPassword = "VIBEDNS_ADMIN_PASSWORD"
EnvLogLevel = "VIBEDNS_LOG_LEVEL"
EnvLogFormat = "VIBEDNS_LOG_FORMAT"
)
// DefaultBootstrap returns the built-in defaults with environment overrides
// applied.
func DefaultBootstrap() Bootstrap {
b := Bootstrap{
DBPath: envOr(EnvDBPath, DefaultDBPath),
HTTPAddr: envOr(EnvHTTPAddr, DefaultHTTPAddr),
AdminUsername: envOr(EnvAdminUsername, "admin"),
AdminPassword: os.Getenv(EnvAdminPassword),
LogLevel: envOr(EnvLogLevel, "info"),
LogFormat: envOr(EnvLogFormat, "text"),
}
dnsAddr := envOr(EnvDNSAddr, DefaultDNSAddr)
b.DNSUDPAddr = dnsAddr
b.DNSTCPAddr = dnsAddr
if _, ok := os.LookupEnv(EnvDNSAddr); ok {
b.dnsAddrSet = true
}
if _, ok := os.LookupEnv(EnvHTTPAddr); ok {
b.httpAddrSet = true
}
return b
}
// BindFlags registers the bootstrap flags on fs.
func (b *Bootstrap) BindFlags(fs *flag.FlagSet) {
fs.StringVar(&b.DBPath, "db", b.DBPath, "path to the SQLite database file")
fs.StringVar(&b.HTTPAddr, "http", b.HTTPAddr, "management HTTP listen address")
fs.StringVar(&b.DNSUDPAddr, "dns", b.DNSUDPAddr, "DNS listen address for both UDP and TCP")
fs.StringVar(&b.LogLevel, "log-level", b.LogLevel, "log level: debug, info, warn, error")
fs.StringVar(&b.LogFormat, "log-format", b.LogFormat, "log format: text or json")
fs.StringVar(&b.AdminUsername, "admin-username", b.AdminUsername,
"administrator username created on first run")
}
// NoteFlagsSet records which addressing flags were explicitly provided so that
// stored settings are respected otherwise.
func (b *Bootstrap) NoteFlagsSet(fs *flag.FlagSet) {
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "dns":
b.dnsAddrSet = true
b.DNSTCPAddr = b.DNSUDPAddr
case "http":
b.httpAddrSet = true
}
})
}
// DNSAddrOverridden reports whether the DNS listen address was given on the
// command line or in the environment.
func (b Bootstrap) DNSAddrOverridden() bool { return b.dnsAddrSet }
// HTTPAddrOverridden reports whether the HTTP listen address was overridden.
func (b Bootstrap) HTTPAddrOverridden() bool { return b.httpAddrSet }
// Validate checks the bootstrap configuration and returns an actionable error.
func (b Bootstrap) Validate() error {
if strings.TrimSpace(b.DBPath) == "" {
return errors.New("database path must not be empty")
}
if !filepath.IsAbs(b.DBPath) {
if _, err := filepath.Abs(b.DBPath); err != nil {
return fmt.Errorf("database path %q cannot be resolved: %w", b.DBPath, err)
}
}
for label, addr := range map[string]string{
"management HTTP address": b.HTTPAddr,
"DNS UDP address": b.DNSUDPAddr,
"DNS TCP address": b.DNSTCPAddr,
} {
if err := validateListenAddr(addr); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
}
switch strings.ToLower(b.LogLevel) {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("log level %q must be one of debug, info, warn, error", b.LogLevel)
}
switch strings.ToLower(b.LogFormat) {
case "text", "json":
default:
return fmt.Errorf("log format %q must be text or json", b.LogFormat)
}
if b.AdminUsername != "" {
if err := ValidateUsername(b.AdminUsername); err != nil {
return err
}
}
return nil
}
// validateListenAddr accepts "host:port" with an optional empty host.
func validateListenAddr(addr string) error {
if strings.TrimSpace(addr) == "" {
return errors.New("must not be empty")
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("%q is not a valid host:port address", addr)
}
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return fmt.Errorf("%q has an invalid port", addr)
}
if host != "" && net.ParseIP(host) == nil {
// Allow host names for the HTTP listener; reject obvious nonsense.
if strings.ContainsAny(host, " \t/\\") {
return fmt.Errorf("%q has an invalid host", addr)
}
}
return nil
}
// ValidateUsername enforces a conservative username policy.
func ValidateUsername(u string) error {
if len(u) < 2 || len(u) > 64 {
return errors.New("username must be between 2 and 64 characters")
}
for _, r := range u {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '.', r == '-', r == '_', r == '@':
default:
return errors.New("username may contain only letters, digits and the characters . - _ @")
}
}
return nil
}
func envOr(key, def string) string {
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
return v
}
return def
}
+661
View File
@@ -0,0 +1,661 @@
package config
import (
"fmt"
"net"
"net/netip"
"net/url"
"strconv"
"strings"
)
// Setting keys. Keeping them as constants means a typo is a compile error
// rather than a silently ignored setting.
const (
KeyDNSUDPListen = "dns.udp_listen"
KeyDNSTCPListen = "dns.tcp_listen"
KeyDNSRecursion = "dns.recursion_enabled"
KeyDNSEDNSEnabled = "dns.edns_enabled"
KeyDNSEDNSUDPSize = "dns.edns_udp_size"
KeyDNSDefaultTTL = "dns.default_ttl"
KeyDNSTCPIdle = "dns.tcp_idle_timeout_s"
KeyDNSExposeVer = "dns.expose_version"
KeyDNSMaxUDPSize = "dns.max_udp_response"
KeyResolverUpstreams = "resolver.upstreams"
KeyResolverTimeout = "resolver.timeout_ms"
KeyResolverRetries = "resolver.retries"
KeyResolverStrategy = "resolver.strategy"
KeyResolverAllow = "resolver.allow_networks"
KeyResolverDeny = "resolver.deny_networks"
KeyResolverPreferV6 = "resolver.prefer_ipv6"
KeyResolverDNSSEC = "resolver.dnssec_enabled"
KeyResolverMaxConc = "resolver.max_concurrent"
KeyCacheEnabled = "cache.enabled"
KeyCacheMaxEntries = "cache.max_entries"
KeyCacheMinTTL = "cache.min_ttl"
KeyCacheMaxTTL = "cache.max_ttl"
KeyCacheNegativeTTL = "cache.negative_ttl"
KeyCacheServeStale = "cache.serve_stale"
KeyCacheStaleTTL = "cache.stale_ttl"
KeyCachePrefetch = "cache.prefetch_enabled"
KeyCachePrefetchPct = "cache.prefetch_threshold_pct"
KeyCacheCleanup = "cache.cleanup_interval_s"
KeyQueryLogEnabled = "querylog.enabled"
KeyQueryLogRetention = "querylog.retention_days"
KeyQueryLogMaxRows = "querylog.max_rows"
KeyQueryLogCleanup = "querylog.cleanup_interval_min"
KeyQueryLogIgnoreNet = "querylog.ignore_networks"
KeyQueryLogIgnoreDom = "querylog.ignore_domains"
KeyRateLimitEnabled = "ratelimit.enabled"
KeyRateLimitQPS = "ratelimit.qps"
KeyRateLimitBurst = "ratelimit.burst"
KeyRateLimitExempt = "ratelimit.exempt_networks"
KeyHTTPListen = "http.listen"
KeyHTTPBaseURL = "http.base_url"
KeyHTTPTrustedProxy = "http.trusted_proxies"
KeyHTTPMetrics = "http.metrics_enabled"
KeyHTTPMetricsPublic = "http.metrics_public"
KeyHTTPMaxUploadMB = "http.max_upload_mb"
KeyHTTPRateLimit = "http.rate_limit_per_min"
KeyBackupEnabled = "backup.enabled"
KeyBackupDir = "backup.dir"
KeyBackupInterval = "backup.interval_hours"
KeyBackupRetention = "backup.retention"
KeyLogLevel = "log.level"
KeyLogFormat = "log.format"
KeyAuditMaxRows = "log.audit_max_rows"
)
// DNSSettings covers the listeners and protocol behaviour.
type DNSSettings struct {
UDPListen string `json:"udp_listen"`
TCPListen string `json:"tcp_listen"`
Recursion bool `json:"recursion_enabled"`
EDNSEnabled bool `json:"edns_enabled"`
EDNSUDPSize int `json:"edns_udp_size"`
MaxUDPResponse int `json:"max_udp_response"`
DefaultTTL uint32 `json:"default_ttl"`
TCPIdleSeconds int `json:"tcp_idle_timeout_s"`
ExposeVersion bool `json:"expose_version"`
}
// ResolverSettings covers upstream forwarding and recursion ACLs.
type ResolverSettings struct {
Upstreams []string `json:"upstreams"`
TimeoutMS int `json:"timeout_ms"`
Retries int `json:"retries"`
Strategy string `json:"strategy"`
AllowNetworks []string `json:"allow_networks"`
DenyNetworks []string `json:"deny_networks"`
PreferIPv6 bool `json:"prefer_ipv6"`
DNSSEC bool `json:"dnssec_enabled"`
MaxConcurrent int `json:"max_concurrent"`
}
// Server selection strategies.
const (
StrategySequential = "sequential"
StrategyRoundRobin = "round_robin"
StrategyRandom = "random"
StrategyFastest = "fastest"
)
// CacheSettings covers the resolver cache.
type CacheSettings struct {
Enabled bool `json:"enabled"`
MaxEntries int `json:"max_entries"`
MinTTL int `json:"min_ttl"`
MaxTTL int `json:"max_ttl"`
NegativeTTL int `json:"negative_ttl"`
ServeStale bool `json:"serve_stale"`
StaleTTL int `json:"stale_ttl"`
Prefetch bool `json:"prefetch_enabled"`
PrefetchPercent int `json:"prefetch_threshold_pct"`
CleanupSeconds int `json:"cleanup_interval_s"`
}
// QueryLogSettings covers DNS query logging and its retention.
type QueryLogSettings struct {
Enabled bool `json:"enabled"`
RetentionDays int `json:"retention_days"`
MaxRows int `json:"max_rows"`
CleanupMinutes int `json:"cleanup_interval_min"`
IgnoreNetworks []string `json:"ignore_networks"`
IgnoreDomains []string `json:"ignore_domains"`
}
// RateLimitSettings covers per-client DNS rate limiting.
type RateLimitSettings struct {
Enabled bool `json:"enabled"`
QPS int `json:"qps"`
Burst int `json:"burst"`
ExemptNetworks []string `json:"exempt_networks"`
}
// HTTPSettings covers the management interface.
type HTTPSettings struct {
Listen string `json:"listen"`
BaseURL string `json:"base_url"`
TrustedProxies []string `json:"trusted_proxies"`
MetricsEnabled bool `json:"metrics_enabled"`
MetricsPublic bool `json:"metrics_public"`
MaxUploadMB int `json:"max_upload_mb"`
RateLimitPerMin int `json:"rate_limit_per_min"`
}
// BackupSettings covers automatic database backups.
type BackupSettings struct {
Enabled bool `json:"enabled"`
Directory string `json:"dir"`
IntervalHours int `json:"interval_hours"`
Retention int `json:"retention"`
}
// LoggingSettings covers application log output.
type LoggingSettings struct {
Level string `json:"level"`
Format string `json:"format"`
AuditMaxRows int `json:"audit_max_rows"`
}
// Settings is the complete runtime configuration held in SQLite.
type Settings struct {
DNS DNSSettings `json:"dns"`
Resolver ResolverSettings `json:"resolver"`
Cache CacheSettings `json:"cache"`
QueryLog QueryLogSettings `json:"query_log"`
RateLimit RateLimitSettings `json:"rate_limit"`
HTTP HTTPSettings `json:"http"`
Backup BackupSettings `json:"backup"`
Logging LoggingSettings `json:"logging"`
}
// DefaultSettings returns a safe, closed-by-default configuration.
//
// Recursion is enabled but the allow list contains only loopback and private
// address space, so a fresh install is never an open resolver.
func DefaultSettings() Settings {
return Settings{
DNS: DNSSettings{
UDPListen: DefaultDNSAddr,
TCPListen: DefaultDNSAddr,
Recursion: true,
EDNSEnabled: true,
EDNSUDPSize: 1232, // conservative post-DNS-flag-day value
MaxUDPResponse: 1232,
DefaultTTL: 3600,
TCPIdleSeconds: 8,
ExposeVersion: false,
},
Resolver: ResolverSettings{
Upstreams: []string{"1.1.1.1:53", "1.0.0.1:53", "9.9.9.9:53"},
TimeoutMS: 2000,
Retries: 2,
Strategy: StrategyFastest,
AllowNetworks: DefaultPrivateNetworks(),
DenyNetworks: nil,
PreferIPv6: false,
DNSSEC: true,
MaxConcurrent: 256,
},
Cache: CacheSettings{
Enabled: true,
MaxEntries: 100_000,
MinTTL: 5,
MaxTTL: 86400,
NegativeTTL: 900,
ServeStale: true,
StaleTTL: 3600,
Prefetch: true,
PrefetchPercent: 10,
CleanupSeconds: 60,
},
QueryLog: QueryLogSettings{
Enabled: true,
RetentionDays: 7,
MaxRows: 1_000_000,
CleanupMinutes: 30,
},
RateLimit: RateLimitSettings{
Enabled: true,
QPS: 200,
Burst: 400,
ExemptNetworks: []string{"127.0.0.0/8", "::1/128"},
},
HTTP: HTTPSettings{
Listen: DefaultHTTPAddr,
MetricsEnabled: true,
MetricsPublic: false,
MaxUploadMB: 64,
RateLimitPerMin: 600,
},
Backup: BackupSettings{
Enabled: false,
Directory: "./data/backups",
IntervalHours: 24,
Retention: 7,
},
Logging: LoggingSettings{
Level: "info",
Format: "text",
AuditMaxRows: 50_000,
},
}
}
// DefaultPrivateNetworks lists the RFC1918/RFC4193 ranges used as the initial
// recursion ACL.
func DefaultPrivateNetworks() []string {
return []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
}
}
// LoadSettings overlays stored values on top of the defaults. Unparseable or
// missing values fall back to the default for that field, so a corrupted row
// can never prevent the server from starting.
func LoadSettings(stored map[string]string) Settings {
s := DefaultSettings()
g := getter{stored}
s.DNS.UDPListen = g.str(KeyDNSUDPListen, s.DNS.UDPListen)
s.DNS.TCPListen = g.str(KeyDNSTCPListen, s.DNS.TCPListen)
s.DNS.Recursion = g.boolean(KeyDNSRecursion, s.DNS.Recursion)
s.DNS.EDNSEnabled = g.boolean(KeyDNSEDNSEnabled, s.DNS.EDNSEnabled)
s.DNS.EDNSUDPSize = g.integer(KeyDNSEDNSUDPSize, s.DNS.EDNSUDPSize)
s.DNS.MaxUDPResponse = g.integer(KeyDNSMaxUDPSize, s.DNS.MaxUDPResponse)
s.DNS.DefaultTTL = uint32(g.integer(KeyDNSDefaultTTL, int(s.DNS.DefaultTTL)))
s.DNS.TCPIdleSeconds = g.integer(KeyDNSTCPIdle, s.DNS.TCPIdleSeconds)
s.DNS.ExposeVersion = g.boolean(KeyDNSExposeVer, s.DNS.ExposeVersion)
s.Resolver.Upstreams = g.lines(KeyResolverUpstreams, s.Resolver.Upstreams)
s.Resolver.TimeoutMS = g.integer(KeyResolverTimeout, s.Resolver.TimeoutMS)
s.Resolver.Retries = g.integer(KeyResolverRetries, s.Resolver.Retries)
s.Resolver.Strategy = g.str(KeyResolverStrategy, s.Resolver.Strategy)
s.Resolver.AllowNetworks = g.lines(KeyResolverAllow, s.Resolver.AllowNetworks)
s.Resolver.DenyNetworks = g.lines(KeyResolverDeny, s.Resolver.DenyNetworks)
s.Resolver.PreferIPv6 = g.boolean(KeyResolverPreferV6, s.Resolver.PreferIPv6)
s.Resolver.DNSSEC = g.boolean(KeyResolverDNSSEC, s.Resolver.DNSSEC)
s.Resolver.MaxConcurrent = g.integer(KeyResolverMaxConc, s.Resolver.MaxConcurrent)
s.Cache.Enabled = g.boolean(KeyCacheEnabled, s.Cache.Enabled)
s.Cache.MaxEntries = g.integer(KeyCacheMaxEntries, s.Cache.MaxEntries)
s.Cache.MinTTL = g.integer(KeyCacheMinTTL, s.Cache.MinTTL)
s.Cache.MaxTTL = g.integer(KeyCacheMaxTTL, s.Cache.MaxTTL)
s.Cache.NegativeTTL = g.integer(KeyCacheNegativeTTL, s.Cache.NegativeTTL)
s.Cache.ServeStale = g.boolean(KeyCacheServeStale, s.Cache.ServeStale)
s.Cache.StaleTTL = g.integer(KeyCacheStaleTTL, s.Cache.StaleTTL)
s.Cache.Prefetch = g.boolean(KeyCachePrefetch, s.Cache.Prefetch)
s.Cache.PrefetchPercent = g.integer(KeyCachePrefetchPct, s.Cache.PrefetchPercent)
s.Cache.CleanupSeconds = g.integer(KeyCacheCleanup, s.Cache.CleanupSeconds)
s.QueryLog.Enabled = g.boolean(KeyQueryLogEnabled, s.QueryLog.Enabled)
s.QueryLog.RetentionDays = g.integer(KeyQueryLogRetention, s.QueryLog.RetentionDays)
s.QueryLog.MaxRows = g.integer(KeyQueryLogMaxRows, s.QueryLog.MaxRows)
s.QueryLog.CleanupMinutes = g.integer(KeyQueryLogCleanup, s.QueryLog.CleanupMinutes)
s.QueryLog.IgnoreNetworks = g.lines(KeyQueryLogIgnoreNet, s.QueryLog.IgnoreNetworks)
s.QueryLog.IgnoreDomains = g.lines(KeyQueryLogIgnoreDom, s.QueryLog.IgnoreDomains)
s.RateLimit.Enabled = g.boolean(KeyRateLimitEnabled, s.RateLimit.Enabled)
s.RateLimit.QPS = g.integer(KeyRateLimitQPS, s.RateLimit.QPS)
s.RateLimit.Burst = g.integer(KeyRateLimitBurst, s.RateLimit.Burst)
s.RateLimit.ExemptNetworks = g.lines(KeyRateLimitExempt, s.RateLimit.ExemptNetworks)
s.HTTP.Listen = g.str(KeyHTTPListen, s.HTTP.Listen)
s.HTTP.BaseURL = g.str(KeyHTTPBaseURL, s.HTTP.BaseURL)
s.HTTP.TrustedProxies = g.lines(KeyHTTPTrustedProxy, s.HTTP.TrustedProxies)
s.HTTP.MetricsEnabled = g.boolean(KeyHTTPMetrics, s.HTTP.MetricsEnabled)
s.HTTP.MetricsPublic = g.boolean(KeyHTTPMetricsPublic, s.HTTP.MetricsPublic)
s.HTTP.MaxUploadMB = g.integer(KeyHTTPMaxUploadMB, s.HTTP.MaxUploadMB)
s.HTTP.RateLimitPerMin = g.integer(KeyHTTPRateLimit, s.HTTP.RateLimitPerMin)
s.Backup.Enabled = g.boolean(KeyBackupEnabled, s.Backup.Enabled)
s.Backup.Directory = g.str(KeyBackupDir, s.Backup.Directory)
s.Backup.IntervalHours = g.integer(KeyBackupInterval, s.Backup.IntervalHours)
s.Backup.Retention = g.integer(KeyBackupRetention, s.Backup.Retention)
s.Logging.Level = g.str(KeyLogLevel, s.Logging.Level)
s.Logging.Format = g.str(KeyLogFormat, s.Logging.Format)
s.Logging.AuditMaxRows = g.integer(KeyAuditMaxRows, s.Logging.AuditMaxRows)
s.Normalise()
return s
}
// ToMap renders the settings back into their stored representation.
func (s Settings) ToMap() map[string]string {
return map[string]string{
KeyDNSUDPListen: s.DNS.UDPListen,
KeyDNSTCPListen: s.DNS.TCPListen,
KeyDNSRecursion: boolStr(s.DNS.Recursion),
KeyDNSEDNSEnabled: boolStr(s.DNS.EDNSEnabled),
KeyDNSEDNSUDPSize: itoa(s.DNS.EDNSUDPSize),
KeyDNSMaxUDPSize: itoa(s.DNS.MaxUDPResponse),
KeyDNSDefaultTTL: itoa(int(s.DNS.DefaultTTL)),
KeyDNSTCPIdle: itoa(s.DNS.TCPIdleSeconds),
KeyDNSExposeVer: boolStr(s.DNS.ExposeVersion),
KeyResolverUpstreams: strings.Join(s.Resolver.Upstreams, "\n"),
KeyResolverTimeout: itoa(s.Resolver.TimeoutMS),
KeyResolverRetries: itoa(s.Resolver.Retries),
KeyResolverStrategy: s.Resolver.Strategy,
KeyResolverAllow: strings.Join(s.Resolver.AllowNetworks, "\n"),
KeyResolverDeny: strings.Join(s.Resolver.DenyNetworks, "\n"),
KeyResolverPreferV6: boolStr(s.Resolver.PreferIPv6),
KeyResolverDNSSEC: boolStr(s.Resolver.DNSSEC),
KeyResolverMaxConc: itoa(s.Resolver.MaxConcurrent),
KeyCacheEnabled: boolStr(s.Cache.Enabled),
KeyCacheMaxEntries: itoa(s.Cache.MaxEntries),
KeyCacheMinTTL: itoa(s.Cache.MinTTL),
KeyCacheMaxTTL: itoa(s.Cache.MaxTTL),
KeyCacheNegativeTTL: itoa(s.Cache.NegativeTTL),
KeyCacheServeStale: boolStr(s.Cache.ServeStale),
KeyCacheStaleTTL: itoa(s.Cache.StaleTTL),
KeyCachePrefetch: boolStr(s.Cache.Prefetch),
KeyCachePrefetchPct: itoa(s.Cache.PrefetchPercent),
KeyCacheCleanup: itoa(s.Cache.CleanupSeconds),
KeyQueryLogEnabled: boolStr(s.QueryLog.Enabled),
KeyQueryLogRetention: itoa(s.QueryLog.RetentionDays),
KeyQueryLogMaxRows: itoa(s.QueryLog.MaxRows),
KeyQueryLogCleanup: itoa(s.QueryLog.CleanupMinutes),
KeyQueryLogIgnoreNet: strings.Join(s.QueryLog.IgnoreNetworks, "\n"),
KeyQueryLogIgnoreDom: strings.Join(s.QueryLog.IgnoreDomains, "\n"),
KeyRateLimitEnabled: boolStr(s.RateLimit.Enabled),
KeyRateLimitQPS: itoa(s.RateLimit.QPS),
KeyRateLimitBurst: itoa(s.RateLimit.Burst),
KeyRateLimitExempt: strings.Join(s.RateLimit.ExemptNetworks, "\n"),
KeyHTTPListen: s.HTTP.Listen,
KeyHTTPBaseURL: s.HTTP.BaseURL,
KeyHTTPTrustedProxy: strings.Join(s.HTTP.TrustedProxies, "\n"),
KeyHTTPMetrics: boolStr(s.HTTP.MetricsEnabled),
KeyHTTPMetricsPublic: boolStr(s.HTTP.MetricsPublic),
KeyHTTPMaxUploadMB: itoa(s.HTTP.MaxUploadMB),
KeyHTTPRateLimit: itoa(s.HTTP.RateLimitPerMin),
KeyBackupEnabled: boolStr(s.Backup.Enabled),
KeyBackupDir: s.Backup.Directory,
KeyBackupInterval: itoa(s.Backup.IntervalHours),
KeyBackupRetention: itoa(s.Backup.Retention),
KeyLogLevel: s.Logging.Level,
KeyLogFormat: s.Logging.Format,
KeyAuditMaxRows: itoa(s.Logging.AuditMaxRows),
}
}
// Normalise clamps values into sane ranges. It never rejects: it is applied
// after loading so that odd stored values degrade rather than break startup.
func (s *Settings) Normalise() {
s.DNS.EDNSUDPSize = clamp(s.DNS.EDNSUDPSize, 512, 65535)
s.DNS.MaxUDPResponse = clamp(s.DNS.MaxUDPResponse, 512, 65535)
s.DNS.TCPIdleSeconds = clamp(s.DNS.TCPIdleSeconds, 1, 120)
if s.DNS.DefaultTTL == 0 {
s.DNS.DefaultTTL = 3600
}
s.Resolver.TimeoutMS = clamp(s.Resolver.TimeoutMS, 100, 30000)
s.Resolver.Retries = clamp(s.Resolver.Retries, 0, 10)
s.Resolver.MaxConcurrent = clamp(s.Resolver.MaxConcurrent, 1, 10000)
switch s.Resolver.Strategy {
case StrategySequential, StrategyRoundRobin, StrategyRandom, StrategyFastest:
default:
s.Resolver.Strategy = StrategyFastest
}
s.Resolver.Upstreams = normaliseUpstreams(s.Resolver.Upstreams)
s.Cache.MaxEntries = clamp(s.Cache.MaxEntries, 0, 10_000_000)
s.Cache.MinTTL = clamp(s.Cache.MinTTL, 0, 86400)
s.Cache.MaxTTL = clamp(s.Cache.MaxTTL, 1, 604800)
if s.Cache.MinTTL > s.Cache.MaxTTL {
s.Cache.MinTTL = s.Cache.MaxTTL
}
s.Cache.NegativeTTL = clamp(s.Cache.NegativeTTL, 0, 86400)
s.Cache.StaleTTL = clamp(s.Cache.StaleTTL, 0, 604800)
s.Cache.PrefetchPercent = clamp(s.Cache.PrefetchPercent, 1, 90)
s.Cache.CleanupSeconds = clamp(s.Cache.CleanupSeconds, 5, 3600)
s.QueryLog.RetentionDays = clamp(s.QueryLog.RetentionDays, 0, 3650)
s.QueryLog.MaxRows = clamp(s.QueryLog.MaxRows, 0, 100_000_000)
s.QueryLog.CleanupMinutes = clamp(s.QueryLog.CleanupMinutes, 1, 1440)
s.RateLimit.QPS = clamp(s.RateLimit.QPS, 1, 1_000_000)
s.RateLimit.Burst = clamp(s.RateLimit.Burst, 1, 1_000_000)
if s.RateLimit.Burst < s.RateLimit.QPS {
s.RateLimit.Burst = s.RateLimit.QPS
}
s.HTTP.MaxUploadMB = clamp(s.HTTP.MaxUploadMB, 1, 4096)
s.HTTP.RateLimitPerMin = clamp(s.HTTP.RateLimitPerMin, 10, 1_000_000)
s.HTTP.BaseURL = strings.TrimRight(strings.TrimSpace(s.HTTP.BaseURL), "/")
s.Backup.IntervalHours = clamp(s.Backup.IntervalHours, 1, 8760)
s.Backup.Retention = clamp(s.Backup.Retention, 1, 1000)
switch strings.ToLower(s.Logging.Level) {
case "debug", "info", "warn", "error":
s.Logging.Level = strings.ToLower(s.Logging.Level)
default:
s.Logging.Level = "info"
}
switch strings.ToLower(s.Logging.Format) {
case "text", "json":
s.Logging.Format = strings.ToLower(s.Logging.Format)
default:
s.Logging.Format = "text"
}
s.Logging.AuditMaxRows = clamp(s.Logging.AuditMaxRows, 100, 10_000_000)
}
// Validate reports configuration errors that should be shown to the operator
// rather than silently corrected.
func (s Settings) Validate() error {
if err := validateListenAddr(s.DNS.UDPListen); err != nil {
return fmt.Errorf("DNS UDP listen address: %w", err)
}
if err := validateListenAddr(s.DNS.TCPListen); err != nil {
return fmt.Errorf("DNS TCP listen address: %w", err)
}
if err := validateListenAddr(s.HTTP.Listen); err != nil {
return fmt.Errorf("HTTP listen address: %w", err)
}
if s.DNS.Recursion && len(s.Resolver.Upstreams) == 0 {
return fmt.Errorf("recursion is enabled but no upstream resolvers are configured")
}
for _, u := range s.Resolver.Upstreams {
if err := validateUpstream(u); err != nil {
return fmt.Errorf("upstream resolver %q: %w", u, err)
}
}
if s.DNS.Recursion && len(s.Resolver.AllowNetworks) == 0 {
return fmt.Errorf("recursion is enabled but the allowed-networks list is empty, " +
"which would deny every client; add at least one network")
}
for label, list := range map[string][]string{
"allowed recursion network": s.Resolver.AllowNetworks,
"denied recursion network": s.Resolver.DenyNetworks,
"rate limit exempt network": s.RateLimit.ExemptNetworks,
"query log ignored network": s.QueryLog.IgnoreNetworks,
"trusted proxy": s.HTTP.TrustedProxies,
} {
for _, c := range list {
if _, err := ParseCIDROrIP(c); err != nil {
return fmt.Errorf("%s %q: %w", label, c, err)
}
}
}
if s.HTTP.BaseURL != "" {
if _, err := url.Parse(s.HTTP.BaseURL); err != nil {
return fmt.Errorf("base URL %q is not a valid URL", s.HTTP.BaseURL)
}
}
if s.Backup.Enabled && strings.TrimSpace(s.Backup.Directory) == "" {
return fmt.Errorf("backups are enabled but no backup directory is set")
}
return nil
}
// ParseCIDROrIP accepts either a CIDR block or a bare address, returning a
// prefix. A bare address becomes a host route (/32 or /128).
func ParseCIDROrIP(s string) (netip.Prefix, error) {
s = strings.TrimSpace(s)
if s == "" {
return netip.Prefix{}, fmt.Errorf("must not be empty")
}
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil {
return netip.Prefix{}, fmt.Errorf("not a valid CIDR block")
}
return p.Masked(), nil
}
addr, err := netip.ParseAddr(s)
if err != nil {
return netip.Prefix{}, fmt.Errorf("not a valid IP address or CIDR block")
}
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
// normaliseUpstreams trims entries and appends the default port where missing.
func normaliseUpstreams(in []string) []string {
var out []string
seen := map[string]bool{}
for _, u := range in {
u = strings.TrimSpace(u)
if u == "" || strings.HasPrefix(u, "#") {
continue
}
if _, _, err := net.SplitHostPort(u); err != nil {
// Bare IPv6 addresses need brackets before a port can be appended.
if strings.Count(u, ":") >= 2 && !strings.HasPrefix(u, "[") {
u = "[" + u + "]:53"
} else {
u = u + ":53"
}
}
if seen[u] {
continue
}
seen[u] = true
out = append(out, u)
}
return out
}
func validateUpstream(u string) error {
host, port, err := net.SplitHostPort(u)
if err != nil {
return fmt.Errorf("expected host:port")
}
if net.ParseIP(host) == nil {
return fmt.Errorf("host must be a literal IP address, not a name " +
"(resolving upstream names would require the resolver we are configuring)")
}
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return fmt.Errorf("invalid port %q", port)
}
return nil
}
// --- small helpers ------------------------------------------------------
type getter struct{ m map[string]string }
func (g getter) str(key, def string) string {
if v, ok := g.m[key]; ok {
if t := strings.TrimSpace(v); t != "" {
return t
}
}
return def
}
func (g getter) boolean(key string, def bool) bool {
v, ok := g.m[key]
if !ok {
return def
}
b, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return def
}
return b
}
func (g getter) integer(key string, def int) int {
v, ok := g.m[key]
if !ok {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
// lines splits a multi-line setting, dropping blanks and comments. An empty
// stored value means "explicitly empty" and overrides the default.
func (g getter) lines(key string, def []string) []string {
v, ok := g.m[key]
if !ok {
return def
}
return SplitLines(v)
}
// SplitLines parses a textarea-style list: one entry per line, "#" comments
// and blank lines removed. Commas are also accepted as separators.
func SplitLines(v string) []string {
var out []string
for _, line := range strings.FieldsFunc(v, func(r rune) bool {
return r == '\n' || r == '\r' || r == ','
}) {
line = strings.TrimSpace(line)
if i := strings.Index(line, "#"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
if line == "" {
continue
}
out = append(out, line)
}
return out
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func itoa(i int) string { return strconv.Itoa(i) }
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+205
View File
@@ -0,0 +1,205 @@
// Package database owns the SQLite connection, the migration runner and every
// SQL statement in the application. Higher layers talk to *DB and never build
// SQL themselves, which keeps parameterisation and transaction handling in one
// auditable place.
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite" // pure-Go driver: no cgo, single static binary
)
// DB wraps the SQLite handle with the helpers the rest of the app needs.
type DB struct {
*sql.DB
path string
}
// Common storage errors surfaced to the HTTP layer as 404/409 responses.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("already exists")
)
// Open opens (creating if necessary) the SQLite database at path and applies
// the connection pragmas the application relies on.
//
// The file is created with 0600 and its parent directory with 0750: the
// database holds the administrator password hash and API token hashes, so it
// must not be world readable.
func Open(path string) (*DB, error) {
if path == "" {
return nil, errors.New("database path is empty")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create database directory %s: %w", dir, err)
}
// _txlock=immediate makes database/sql issue BEGIN IMMEDIATE, so SQLite's
// busy handler can actually resolve writer contention instead of failing
// with SQLITE_BUSY when a deferred transaction tries to upgrade.
dsn := "file:" + url.PathEscape(path) + "?" +
"_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(15000)" +
"&_pragma=synchronous(NORMAL)" +
"&_txlock=immediate"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// SQLite serialises writes; a small pool avoids piling up blocked writers
// while still allowing concurrent WAL readers.
sqlDB.SetMaxOpenConns(8)
sqlDB.SetMaxIdleConns(8)
sqlDB.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("connect to database: %w", err)
}
db := &DB{DB: sqlDB, path: path}
if err := db.hardenPermissions(); err != nil {
sqlDB.Close()
return nil, err
}
return db, nil
}
// Path returns the on-disk location of the database.
func (db *DB) Path() string { return db.path }
// hardenPermissions restricts the database and its WAL sidecars to the owner.
func (db *DB) hardenPermissions() error {
for _, suffix := range []string{"", "-wal", "-shm"} {
p := db.path + suffix
if _, err := os.Stat(p); err != nil {
continue // sidecars may not exist yet
}
if err := os.Chmod(p, 0o600); err != nil {
return fmt.Errorf("secure %s: %w", p, err)
}
}
return nil
}
// Checkpoint flushes the write-ahead log into the main database file. It runs
// before backups so the copied file is complete.
func (db *DB) Checkpoint(ctx context.Context) error {
_, err := db.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`)
return err
}
// Vacuum rebuilds the database, reclaiming space after large deletions.
func (db *DB) Vacuum(ctx context.Context) error {
_, err := db.ExecContext(ctx, `VACUUM`)
return err
}
// Stats describes database size and row counts for the settings UI.
type Stats struct {
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
WALBytes int64 `json:"wal_bytes"`
PageSize int64 `json:"page_size"`
PageCount int64 `json:"page_count"`
FreePages int64 `json:"free_pages"`
Zones int64 `json:"zones"`
Records int64 `json:"records"`
Domains int64 `json:"domains"`
QueryLogs int64 `json:"query_logs"`
AuditLogs int64 `json:"audit_logs"`
APITokens int64 `json:"api_tokens"`
SchemaVer int `json:"schema_version"`
}
// Stats collects database size and row-count information.
func (db *DB) Stats(ctx context.Context) (Stats, error) {
s := Stats{Path: db.path}
if fi, err := os.Stat(db.path); err == nil {
s.SizeBytes = fi.Size()
}
if fi, err := os.Stat(db.path + "-wal"); err == nil {
s.WALBytes = fi.Size()
}
_ = db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&s.PageSize)
_ = db.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&s.PageCount)
_ = db.QueryRowContext(ctx, `PRAGMA freelist_count`).Scan(&s.FreePages)
counts := []struct {
table string
dst *int64
}{
{"zones", &s.Zones},
{"records", &s.Records},
{"domain_entries", &s.Domains},
{"query_logs", &s.QueryLogs},
{"audit_logs", &s.AuditLogs},
{"api_tokens", &s.APITokens},
}
for _, c := range counts {
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+c.table).Scan(c.dst); err != nil {
return s, fmt.Errorf("count %s: %w", c.table, err)
}
}
v, err := db.SchemaVersion(ctx)
if err != nil {
return s, err
}
s.SchemaVer = v
return s, nil
}
// InTx runs fn inside a transaction, committing on success and rolling back on
// error or panic.
func (db *DB) InTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
// unixPtr converts a nullable epoch-seconds column into a *time.Time.
func unixPtr(n sql.NullInt64) *time.Time {
if !n.Valid {
return nil
}
t := time.Unix(n.Int64, 0)
return &t
}
// nullInt64 converts a *int64 into a driver-friendly nullable value.
func nullInt64(v *int64) any {
if v == nil {
return nil
}
return *v
}
+443
View File
@@ -0,0 +1,443 @@
package database
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"github.com/owen/vibedns/internal/models"
)
// newTestDB opens a migrated database in a temporary directory.
func newTestDB(t *testing.T) *DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestMigrationsApplyAndAreIdempotent(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "migrate.db")
db, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
n, err := db.Migrate(ctx)
if err != nil {
t.Fatalf("first migrate: %v", err)
}
if n == 0 {
t.Fatal("expected migrations to be applied on a fresh database")
}
// A second run must be a no-op, which is what makes it safe to run on
// every start.
again, err := db.Migrate(ctx)
if err != nil {
t.Fatalf("second migrate: %v", err)
}
if again != 0 {
t.Errorf("second migrate applied %d migrations, want 0", again)
}
v, err := db.SchemaVersion(ctx)
if err != nil {
t.Fatalf("schema version: %v", err)
}
if v < 1 {
t.Errorf("schema version = %d, want at least 1", v)
}
statuses, err := db.MigrationStatuses(ctx)
if err != nil {
t.Fatalf("statuses: %v", err)
}
for _, s := range statuses {
if !s.Applied {
t.Errorf("migration %d_%s was not applied", s.Version, s.Name)
}
if s.Drifted {
t.Errorf("migration %d_%s reports drift on a fresh database", s.Version, s.Name)
}
}
}
func TestSeedDataExists(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
lists, err := db.DomainLists(ctx, models.KindBlacklist, "")
if err != nil {
t.Fatalf("list blacklists: %v", err)
}
if len(lists) == 0 {
t.Error("expected the seed migration to create starter blacklists")
}
nets, err := db.Networks(ctx, "", true)
if err != nil {
t.Fatalf("list networks: %v", err)
}
if len(nets) == 0 {
t.Error("expected the seed migration to create private-range networks")
}
// The seeded networks must be private ranges, never the whole Internet.
for _, n := range nets {
if n.CIDR == "0.0.0.0/0" || n.CIDR == "::/0" {
t.Errorf("seed data contains an internet-wide network %q", n.CIDR)
}
}
}
func TestZoneCRUD(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
zone := models.Zone{
Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
Serial: 1, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 3600, AutoSerial: true,
}
created, err := db.CreateZone(ctx, zone)
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == 0 {
t.Fatal("created zone has no ID")
}
// Duplicate names must be rejected.
if _, err := db.CreateZone(ctx, zone); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate create error = %v, want ErrConflict", err)
}
loaded, err := db.Zone(ctx, created.ID)
if err != nil {
t.Fatalf("load: %v", err)
}
if loaded.Name != "example.com." {
t.Errorf("name = %q, want example.com.", loaded.Name)
}
if _, err := db.Zone(ctx, 99999); !errors.Is(err, ErrNotFound) {
t.Errorf("missing zone error = %v, want ErrNotFound", err)
}
loaded.Description = "updated"
if _, err := db.UpdateZone(ctx, loaded); err != nil {
t.Fatalf("update: %v", err)
}
if err := db.DeleteZone(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if err := db.DeleteZone(ctx, created.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("second delete error = %v, want ErrNotFound", err)
}
}
func TestRecordsCascadeAndSerialBump(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
zone, err := db.CreateZone(ctx, models.Zone{
Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "a@example.com",
Serial: 1, AutoSerial: true,
})
if err != nil {
t.Fatalf("create zone: %v", err)
}
if _, err := db.CreateRecord(ctx, models.Record{
ZoneID: zone.ID, Name: "www", Type: "A", Data: "192.0.2.1", Enabled: true,
}); err != nil {
t.Fatalf("create record: %v", err)
}
// Adding a record must advance the serial, which is how secondaries learn
// the zone changed.
after, err := db.Zone(ctx, zone.ID)
if err != nil {
t.Fatalf("reload zone: %v", err)
}
if after.Serial <= zone.Serial {
t.Errorf("serial = %d, want greater than %d after a record change", after.Serial, zone.Serial)
}
// Deleting the zone must take its records with it.
if err := db.DeleteZone(ctx, zone.ID); err != nil {
t.Fatalf("delete zone: %v", err)
}
recs, total, err := db.Records(ctx, RecordFilter{ZoneID: zone.ID})
if err != nil {
t.Fatalf("list records: %v", err)
}
if total != 0 || len(recs) != 0 {
t.Errorf("records remained after the zone was deleted: %d", total)
}
}
func TestManualSerialIsPreserved(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
zone, err := db.CreateZone(ctx, models.Zone{
Name: "manual.example.", Kind: models.ZoneForward, Enabled: true,
DefaultTTL: 300, PrimaryNS: "ns1.manual.example.", AdminEmail: "a@manual.example",
Serial: 2024010101, AutoSerial: false,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := db.CreateRecord(ctx, models.Record{
ZoneID: zone.ID, Name: "@", Type: "A", Data: "192.0.2.1", Enabled: true,
}); err != nil {
t.Fatalf("create record: %v", err)
}
after, _ := db.Zone(ctx, zone.ID)
if after.Serial != 2024010101 {
t.Errorf("serial = %d, want the manual value to be left alone", after.Serial)
}
}
func TestImportDomainsCountsDuplicates(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
list, err := db.CreateDomainList(ctx, models.DomainList{
Kind: models.KindBlacklist, Name: "Import Test", Enabled: true,
})
if err != nil {
t.Fatalf("create list: %v", err)
}
rows := []ImportDomain{
{Domain: "a.example", MatchSubdomains: true},
{Domain: "b.example", MatchSubdomains: true},
{Domain: "c.example", MatchSubdomains: true},
}
imported, dupes, err := db.ImportDomains(ctx, list.ID, rows)
if err != nil {
t.Fatalf("import: %v", err)
}
if imported != 3 || dupes != 0 {
t.Errorf("first import = %d imported, %d duplicates; want 3, 0", imported, dupes)
}
// Re-importing the same rows plus one new one.
rows = append(rows, ImportDomain{Domain: "d.example", MatchSubdomains: true})
imported, dupes, err = db.ImportDomains(ctx, list.ID, rows)
if err != nil {
t.Fatalf("second import: %v", err)
}
if imported != 1 || dupes != 3 {
t.Errorf("second import = %d imported, %d duplicates; want 1, 3", imported, dupes)
}
loaded, err := db.DomainList(ctx, list.ID)
if err != nil {
t.Fatalf("reload list: %v", err)
}
if loaded.DomainCount != 4 {
t.Errorf("domain count = %d, want 4", loaded.DomainCount)
}
}
// TestImportLargeBatchIsOneTransaction exercises the path a real blocklist
// takes. If this were one transaction per domain it would take minutes.
func TestImportLargeBatch(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
list, err := db.CreateDomainList(ctx, models.DomainList{
Kind: models.KindBlacklist, Name: "Large", Enabled: true,
})
if err != nil {
t.Fatalf("create list: %v", err)
}
const n = 20000
rows := make([]ImportDomain, 0, n)
for i := 0; i < n; i++ {
rows = append(rows, ImportDomain{
Domain: "host" + itoa(i) + ".example.com",
MatchSubdomains: true,
})
}
imported, _, err := db.ImportDomains(ctx, list.ID, rows)
if err != nil {
t.Fatalf("bulk import: %v", err)
}
if imported != n {
t.Errorf("imported = %d, want %d", imported, n)
}
// The snapshot query must return them all for the in-memory matcher.
count := 0
if err := db.SnapshotDomains(ctx, func(SnapshotDomainEntry) { count++ }); err != nil {
t.Fatalf("snapshot: %v", err)
}
if count != n {
t.Errorf("snapshot returned %d domains, want %d", count, n)
}
}
func TestSettingsRoundTrip(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
if err := db.SetSetting(ctx, "test.key", "value"); err != nil {
t.Fatalf("set: %v", err)
}
v, ok, err := db.Setting(ctx, "test.key")
if err != nil || !ok || v != "value" {
t.Errorf("get = %q, %v, %v; want \"value\", true, nil", v, ok, err)
}
// Writing again must update rather than fail on the primary key.
if err := db.SetSetting(ctx, "test.key", "changed"); err != nil {
t.Fatalf("overwrite: %v", err)
}
v, _, _ = db.Setting(ctx, "test.key")
if v != "changed" {
t.Errorf("after overwrite = %q, want \"changed\"", v)
}
if _, ok, _ := db.Setting(ctx, "missing.key"); ok {
t.Error("a missing key should report ok=false")
}
}
func TestQueryLogPruning(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
var entries []models.QueryLogEntry
for i := 0; i < 100; i++ {
entries = append(entries, models.QueryLogEntry{
Timestamp: time.Now(), ClientIP: "192.0.2.1",
QName: "example.com.", QType: "A", Rcode: "NOERROR",
Source: models.SourceCache, Protocol: "udp",
})
}
if err := db.InsertQueryLogs(ctx, entries); err != nil {
t.Fatalf("insert: %v", err)
}
n, err := db.QueryLogCount(ctx)
if err != nil || n != 100 {
t.Fatalf("count = %d, %v; want 100", n, err)
}
// Trim to the newest 40 rows.
removed, err := db.PruneQueryLogs(ctx, 0, 40)
if err != nil {
t.Fatalf("prune: %v", err)
}
if removed != 60 {
t.Errorf("pruned %d rows, want 60", removed)
}
n, _ = db.QueryLogCount(ctx)
if n != 40 {
t.Errorf("rows remaining = %d, want 40", n)
}
}
func TestAuditLog(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
err := db.InsertAudit(ctx, models.AuditEntry{
Actor: "admin", Source: "web", ClientIP: "192.0.2.1",
Action: "zone.create", ObjectType: "zone", ObjectName: "example.com.",
})
if err != nil {
t.Fatalf("insert: %v", err)
}
entries, total, err := db.AuditLogs(ctx, AuditFilter{Limit: 10})
if err != nil {
t.Fatalf("read: %v", err)
}
if total != 1 || len(entries) != 1 {
t.Fatalf("got %d of %d, want 1 of 1", len(entries), total)
}
if entries[0].Action != "zone.create" {
t.Errorf("action = %q", entries[0].Action)
}
}
func TestAPITokenLookup(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
tok, err := db.CreateAPIToken(ctx, "test", "a description", "abcd1234", "hash-value")
if err != nil {
t.Fatalf("create: %v", err)
}
if tok.ID == 0 {
t.Fatal("no ID assigned")
}
candidates, err := db.APITokensByPrefix(ctx, "abcd1234")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if len(candidates) != 1 || candidates[0].Hash != "hash-value" {
t.Errorf("lookup returned %v", candidates)
}
// A disabled token must not be returned as a candidate at all.
if err := db.SetAPITokenEnabled(ctx, tok.ID, false); err != nil {
t.Fatalf("disable: %v", err)
}
candidates, _ = db.APITokensByPrefix(ctx, "abcd1234")
if len(candidates) != 0 {
t.Error("a disabled token was returned by the prefix lookup")
}
}
func TestForeignKeysAreEnforced(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
// A record pointing at a zone that does not exist must be rejected;
// without PRAGMA foreign_keys this would silently succeed.
_, err := db.CreateRecord(ctx, models.Record{
ZoneID: 99999, Name: "www", Type: "A", Data: "192.0.2.1", Enabled: true,
})
if err == nil {
t.Error("expected a foreign key violation for an orphaned record")
}
}
func itoa(i int) string {
if i == 0 {
return "0"
}
var buf [12]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
return string(buf[pos:])
}
+202
View File
@@ -0,0 +1,202 @@
package database
import (
"context"
"crypto/sha256"
"database/sql"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Migration is one versioned schema change, embedded in the binary.
type Migration struct {
Version int
Name string
SQL string
}
// MigrationStatus reports whether a migration has been applied.
type MigrationStatus struct {
Version int `json:"version"`
Name string `json:"name"`
Applied bool `json:"applied"`
AppliedAt int64 `json:"applied_at,omitempty"`
Checksum string `json:"checksum"`
Drifted bool `json:"drifted"`
}
// loadMigrations reads and orders the embedded migration files. File names must
// look like "0001_description.sql".
func loadMigrations() ([]Migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read embedded migrations: %w", err)
}
var out []Migration
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
base := strings.TrimSuffix(e.Name(), ".sql")
parts := strings.SplitN(base, "_", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("migration %q: expected NNNN_name.sql", e.Name())
}
v, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("migration %q: bad version prefix: %w", e.Name(), err)
}
body, err := migrationFS.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, fmt.Errorf("read migration %q: %w", e.Name(), err)
}
out = append(out, Migration{Version: v, Name: parts[1], SQL: string(body)})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
for i := 1; i < len(out); i++ {
if out[i].Version == out[i-1].Version {
return nil, fmt.Errorf("duplicate migration version %d", out[i].Version)
}
}
return out, nil
}
func checksum(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// ensureMigrationTable creates the migration bookkeeping table.
func (db *DB) ensureMigrationTable(ctx context.Context) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at INTEGER NOT NULL DEFAULT (unixepoch())
)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
return nil
}
type appliedMigration struct {
name string
checksum string
appliedAt int64
}
func (db *DB) appliedMigrations(ctx context.Context) (map[int]appliedMigration, error) {
rows, err := db.QueryContext(ctx, `SELECT version, name, checksum, applied_at FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
out := map[int]appliedMigration{}
for rows.Next() {
var v int
var a appliedMigration
if err := rows.Scan(&v, &a.name, &a.checksum, &a.appliedAt); err != nil {
return nil, err
}
out[v] = a
}
return out, rows.Err()
}
// Migrate applies every pending migration in version order. It returns the
// number of migrations that were applied.
func (db *DB) Migrate(ctx context.Context) (int, error) {
if err := db.ensureMigrationTable(ctx); err != nil {
return 0, err
}
migrations, err := loadMigrations()
if err != nil {
return 0, err
}
applied, err := db.appliedMigrations(ctx)
if err != nil {
return 0, err
}
count := 0
for _, m := range migrations {
sum := checksum(m.SQL)
if prev, ok := applied[m.Version]; ok {
if prev.checksum != sum {
return count, fmt.Errorf(
"migration %04d_%s was modified after being applied (expected checksum %s, found %s); "+
"roll the change into a new migration instead of editing history",
m.Version, m.Name, prev.checksum, sum)
}
continue
}
// Each migration is one transaction: a failure leaves no partial schema.
err := db.InTx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
return fmt.Errorf("apply migration %04d_%s: %w", m.Version, m.Name, err)
}
_, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version, name, checksum) VALUES (?, ?, ?)`,
m.Version, m.Name, sum)
return err
})
if err != nil {
return count, err
}
count++
}
return count, nil
}
// SchemaVersion returns the highest applied migration version, or 0.
func (db *DB) SchemaVersion(ctx context.Context) (int, error) {
if err := db.ensureMigrationTable(ctx); err != nil {
return 0, err
}
var v int
err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&v)
if err != nil {
return 0, fmt.Errorf("read schema version: %w", err)
}
return v, nil
}
// MigrationStatuses lists every known migration and whether it is applied.
func (db *DB) MigrationStatuses(ctx context.Context) ([]MigrationStatus, error) {
if err := db.ensureMigrationTable(ctx); err != nil {
return nil, err
}
migrations, err := loadMigrations()
if err != nil {
return nil, err
}
applied, err := db.appliedMigrations(ctx)
if err != nil {
return nil, err
}
out := make([]MigrationStatus, 0, len(migrations))
for _, m := range migrations {
sum := checksum(m.SQL)
st := MigrationStatus{Version: m.Version, Name: m.Name, Checksum: sum[:12]}
if a, ok := applied[m.Version]; ok {
st.Applied = true
st.AppliedAt = a.appliedAt
st.Drifted = a.checksum != sum
}
out = append(out, st)
}
return out, nil
}
@@ -0,0 +1,182 @@
-- Initial schema.
--
-- Design notes:
-- * Every searchable entity gets a real relational table. Only genuinely
-- list-shaped configuration (upstream resolvers, ACL networks) lives as a
-- JSON value inside the settings key/value table.
-- * Timestamps are stored as unix epoch seconds (INTEGER) so that they sort
-- and range-scan cheaply and carry no timezone ambiguity.
-- * Booleans are INTEGER 0/1.
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE admin_user (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_login_at INTEGER
);
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_used_at INTEGER
);
CREATE INDEX idx_api_tokens_prefix ON api_tokens (token_prefix);
CREATE TABLE zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- normalised FQDN with trailing dot
kind TEXT NOT NULL DEFAULT 'forward'
CHECK (kind IN ('forward', 'reverse4', 'reverse6')),
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
default_ttl INTEGER NOT NULL DEFAULT 3600,
primary_ns TEXT NOT NULL,
admin_email TEXT NOT NULL,
serial INTEGER NOT NULL DEFAULT 1,
refresh INTEGER NOT NULL DEFAULT 7200,
retry INTEGER NOT NULL DEFAULT 3600,
expire INTEGER NOT NULL DEFAULT 1209600,
minimum INTEGER NOT NULL DEFAULT 3600,
auto_serial INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_zones_enabled ON zones (enabled);
CREATE INDEX idx_zones_kind ON zones (kind);
CREATE TABLE records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id INTEGER NOT NULL REFERENCES zones (id) ON DELETE CASCADE,
name TEXT NOT NULL, -- relative to the apex; '@' is the apex itself
type TEXT NOT NULL,
data TEXT NOT NULL, -- rdata in zone-file presentation format
ttl INTEGER, -- NULL inherits zones.default_ttl
enabled INTEGER NOT NULL DEFAULT 1,
comment TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_records_zone ON records (zone_id);
CREATE INDEX idx_records_zone_name ON records (zone_id, name);
CREATE INDEX idx_records_zone_name_type ON records (zone_id, name, type);
CREATE INDEX idx_records_type ON records (type);
CREATE TABLE networks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
cidr TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_networks_enabled ON networks (enabled);
CREATE TABLE policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
block_action TEXT NOT NULL DEFAULT 'nxdomain'
CHECK (block_action IN ('nxdomain', 'refused', 'sinkhole')),
sinkhole_ipv4 TEXT NOT NULL DEFAULT '0.0.0.0',
sinkhole_ipv6 TEXT NOT NULL DEFAULT '::',
block_ttl INTEGER NOT NULL DEFAULT 60,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE domain_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('blacklist', 'allowlist')),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
source_url TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE (kind, name)
);
CREATE TABLE domain_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES domain_lists (id) ON DELETE CASCADE,
domain TEXT NOT NULL, -- normalised: lowercase, no trailing dot
match_subdomains INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
comment TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE (list_id, domain)
);
CREATE INDEX idx_domain_entries_list ON domain_entries (list_id);
CREATE INDEX idx_domain_entries_domain ON domain_entries (domain);
-- Policy assignments.
CREATE TABLE network_policies (
network_id INTEGER NOT NULL REFERENCES networks (id) ON DELETE CASCADE,
policy_id INTEGER NOT NULL REFERENCES policies (id) ON DELETE CASCADE,
PRIMARY KEY (network_id, policy_id)
);
CREATE INDEX idx_network_policies_policy ON network_policies (policy_id);
CREATE TABLE policy_lists (
policy_id INTEGER NOT NULL REFERENCES policies (id) ON DELETE CASCADE,
list_id INTEGER NOT NULL REFERENCES domain_lists (id) ON DELETE CASCADE,
PRIMARY KEY (policy_id, list_id)
);
CREATE INDEX idx_policy_lists_list ON policy_lists (list_id);
CREATE TABLE query_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, -- unix milliseconds
client_ip TEXT NOT NULL,
network_id INTEGER,
network_name TEXT NOT NULL DEFAULT '',
qname TEXT NOT NULL,
qtype TEXT NOT NULL,
rcode TEXT NOT NULL,
source TEXT NOT NULL,
cache_hit INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT 0,
policy_id INTEGER,
policy_name TEXT NOT NULL DEFAULT '',
blacklist_id INTEGER,
blacklist_name TEXT NOT NULL DEFAULT '',
matched_rule TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'udp',
duration_us INTEGER NOT NULL DEFAULT 0,
answer_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_query_logs_ts ON query_logs (ts);
CREATE INDEX idx_query_logs_client ON query_logs (client_ip, ts);
CREATE INDEX idx_query_logs_qname ON query_logs (qname, ts);
CREATE INDEX idx_query_logs_blocked ON query_logs (blocked, ts);
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, -- unix milliseconds
actor TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'web',
client_ip TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
object_type TEXT NOT NULL DEFAULT '',
object_id TEXT NOT NULL DEFAULT '',
object_name TEXT NOT NULL DEFAULT '',
details TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_audit_logs_ts ON audit_logs (ts);
CREATE INDEX idx_audit_logs_object ON audit_logs (object_type, ts);
@@ -0,0 +1,31 @@
-- Baseline data that makes a fresh install immediately useful without
-- creating an open resolver: a private-network ACL, an empty malware
-- blacklist, and a default policy bound to RFC1918 / ULA space.
INSERT INTO domain_lists (kind, name, description) VALUES
('blacklist', 'Malware', 'Known malware and command-and-control domains.'),
('blacklist', 'Advertising', 'Advertising and tracking domains.'),
('blacklist', 'Adult Content', 'Adult content domains.'),
('blacklist', 'Gambling', 'Gambling and betting domains.'),
('blacklist', 'Guest Network Custom Blocks', 'Locally maintained blocks for guest networks.'),
('allowlist', 'Global Allowlist', 'Domains that must never be blocked.');
INSERT INTO policies (name, description, block_action) VALUES
('Default Protection', 'Malware blocking applied to local networks.', 'nxdomain');
INSERT INTO policy_lists (policy_id, list_id)
SELECT p.id, l.id
FROM policies p, domain_lists l
WHERE p.name = 'Default Protection'
AND l.name IN ('Malware', 'Global Allowlist');
INSERT INTO networks (name, cidr, description) VALUES
('Private IPv4 10.0.0.0/8', '10.0.0.0/8', 'RFC1918 private address space.'),
('Private IPv4 172.16.0.0/12', '172.16.0.0/12', 'RFC1918 private address space.'),
('Private IPv4 192.168.0.0/16', '192.168.0.0/16', 'RFC1918 private address space.'),
('Loopback IPv4', '127.0.0.0/8', 'Local host.'),
('Loopback IPv6', '::1/128', 'Local host.'),
('Unique Local IPv6', 'fc00::/7', 'RFC4193 unique local addresses.');
INSERT INTO network_policies (network_id, policy_id)
SELECT n.id, p.id FROM networks n, policies p WHERE p.name = 'Default Protection';
+207
View File
@@ -0,0 +1,207 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/owen/vibedns/internal/models"
)
// Admin returns the administrator account, or ErrNotFound before first setup.
func (db *DB) Admin(ctx context.Context) (models.Admin, error) {
var a models.Admin
var created, updated int64
var lastLogin sql.NullInt64
var mustChange int
err := db.QueryRowContext(ctx, `
SELECT username, password_hash, must_change_password, created_at, updated_at, last_login_at
FROM admin_user WHERE id = 1`).
Scan(&a.Username, &a.PasswordHash, &mustChange, &created, &updated, &lastLogin)
switch {
case errors.Is(err, sql.ErrNoRows):
return a, ErrNotFound
case err != nil:
return a, fmt.Errorf("load administrator: %w", err)
}
a.MustChangePassword = mustChange != 0
a.CreatedAt = time.Unix(created, 0)
a.UpdatedAt = time.Unix(updated, 0)
a.LastLoginAt = unixPtr(lastLogin)
return a, nil
}
// CreateAdmin inserts the single administrator row. It fails if one exists.
func (db *DB) CreateAdmin(ctx context.Context, username, passwordHash string, mustChange bool) error {
_, err := db.ExecContext(ctx, `
INSERT INTO admin_user (id, username, password_hash, must_change_password)
VALUES (1, ?, ?, ?)`, username, passwordHash, boolInt(mustChange))
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("create administrator: %w", err)
}
return nil
}
// UpdateAdminCredentials replaces the username and/or password hash.
func (db *DB) UpdateAdminCredentials(ctx context.Context, username, passwordHash string, mustChange bool) error {
res, err := db.ExecContext(ctx, `
UPDATE admin_user
SET username = ?, password_hash = ?, must_change_password = ?, updated_at = unixepoch()
WHERE id = 1`, username, passwordHash, boolInt(mustChange))
if err != nil {
return fmt.Errorf("update administrator: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchAdminLogin records a successful authentication.
func (db *DB) TouchAdminLogin(ctx context.Context) error {
_, err := db.ExecContext(ctx, `UPDATE admin_user SET last_login_at = unixepoch() WHERE id = 1`)
return err
}
// --- API tokens ---------------------------------------------------------
// CreateAPIToken stores a new token. Only the prefix and hash are persisted.
func (db *DB) CreateAPIToken(ctx context.Context, name, description, prefix, hash string) (models.APIToken, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO api_tokens (name, description, token_prefix, token_hash)
VALUES (?, ?, ?, ?)`, name, description, prefix, hash)
if err != nil {
if isUniqueViolation(err) {
return models.APIToken{}, ErrConflict
}
return models.APIToken{}, fmt.Errorf("create API token: %w", err)
}
id, _ := res.LastInsertId()
return db.APIToken(ctx, id)
}
// APIToken loads one token by ID.
func (db *DB) APIToken(ctx context.Context, id int64) (models.APIToken, error) {
rows, err := db.queryTokens(ctx, `WHERE id = ?`, id)
if err != nil {
return models.APIToken{}, err
}
if len(rows) == 0 {
return models.APIToken{}, ErrNotFound
}
return rows[0], nil
}
// APITokens lists all tokens, newest first.
func (db *DB) APITokens(ctx context.Context) ([]models.APIToken, error) {
return db.queryTokens(ctx, `ORDER BY created_at DESC, id DESC`)
}
func (db *DB) queryTokens(ctx context.Context, where string, args ...any) ([]models.APIToken, error) {
q := `SELECT id, name, description, token_prefix, enabled, created_at, last_used_at FROM api_tokens ` + where
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list API tokens: %w", err)
}
defer rows.Close()
var out []models.APIToken
for rows.Next() {
var t models.APIToken
var enabled int
var created int64
var lastUsed sql.NullInt64
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.Prefix, &enabled, &created, &lastUsed); err != nil {
return nil, err
}
t.Enabled = enabled != 0
t.CreatedAt = time.Unix(created, 0)
t.LastUsedAt = unixPtr(lastUsed)
out = append(out, t)
}
return out, rows.Err()
}
// APITokenCandidate is a stored token hash keyed by its lookup prefix.
type APITokenCandidate struct {
ID int64
Name string
Hash string
}
// APITokensByPrefix returns enabled tokens whose prefix matches. The prefix
// narrows the search; the caller still verifies the hash in constant time.
func (db *DB) APITokensByPrefix(ctx context.Context, prefix string) ([]APITokenCandidate, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, name, token_hash FROM api_tokens WHERE token_prefix = ? AND enabled = 1`, prefix)
if err != nil {
return nil, fmt.Errorf("lookup API token: %w", err)
}
defer rows.Close()
var out []APITokenCandidate
for rows.Next() {
var c APITokenCandidate
if err := rows.Scan(&c.ID, &c.Name, &c.Hash); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// TouchAPIToken records that a token was just used. Errors are non-fatal to the
// request path, so callers may ignore them.
func (db *DB) TouchAPIToken(ctx context.Context, id int64) error {
_, err := db.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = unixepoch() WHERE id = ?`, id)
return err
}
// SetAPITokenEnabled enables or disables a token without deleting it.
func (db *DB) SetAPITokenEnabled(ctx context.Context, id int64, enabled bool) error {
res, err := db.ExecContext(ctx, `UPDATE api_tokens SET enabled = ? WHERE id = ?`, boolInt(enabled), id)
if err != nil {
return fmt.Errorf("update API token: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteAPIToken permanently revokes a token.
func (db *DB) DeleteAPIToken(ctx context.Context, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete API token: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// isUniqueViolation detects SQLite UNIQUE/PRIMARY KEY constraint failures
// without depending on driver-specific error types.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique constraint failed") ||
strings.Contains(msg, "constraint failed: unique")
}
+409
View File
@@ -0,0 +1,409 @@
package database
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/owen/vibedns/internal/models"
)
// --- Query log ----------------------------------------------------------
// InsertQueryLogs writes a batch of query log rows in one transaction. The
// query logger buffers rows in memory and calls this periodically so DNS
// resolution never waits on disk.
func (db *DB) InsertQueryLogs(ctx context.Context, entries []models.QueryLogEntry) error {
if len(entries) == 0 {
return nil
}
return db.InTx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO query_logs (ts, client_ip, network_id, network_name, qname, qtype, rcode,
source, cache_hit, blocked, policy_id, policy_name, blacklist_id, blacklist_name,
matched_rule, protocol, duration_us, answer_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, e := range entries {
_, err := stmt.ExecContext(ctx,
e.Timestamp.UnixMilli(), e.ClientIP, nullInt64(e.NetworkID), e.NetworkName,
e.QName, e.QType, e.Rcode, e.Source, boolInt(e.CacheHit), boolInt(e.Blocked),
nullInt64(e.PolicyID), e.PolicyName, nullInt64(e.BlacklistID), e.BlacklistName,
e.MatchedRule, e.Protocol, e.DurationUS, e.AnswerCount)
if err != nil {
return fmt.Errorf("write query log: %w", err)
}
}
return nil
})
}
// QueryLogFilter narrows a query log search.
type QueryLogFilter struct {
Domain string
ClientIP string
QType string
Rcode string
Source string
Blocked string // "", "blocked", "allowed"
NetworkID int64
From time.Time
To time.Time
Limit int
Offset int
}
func (f QueryLogFilter) where() (string, []any) {
var conds []string
var args []any
if s := strings.TrimSpace(f.Domain); s != "" {
conds = append(conds, "qname LIKE ?")
args = append(args, "%"+strings.ToLower(s)+"%")
}
if s := strings.TrimSpace(f.ClientIP); s != "" {
conds = append(conds, "client_ip LIKE ?")
args = append(args, "%"+s+"%")
}
if s := strings.ToUpper(strings.TrimSpace(f.QType)); s != "" {
conds = append(conds, "qtype = ?")
args = append(args, s)
}
if s := strings.ToUpper(strings.TrimSpace(f.Rcode)); s != "" {
conds = append(conds, "rcode = ?")
args = append(args, s)
}
if s := strings.TrimSpace(f.Source); s != "" {
conds = append(conds, "source = ?")
args = append(args, s)
}
switch f.Blocked {
case "blocked":
conds = append(conds, "blocked = 1")
case "allowed":
conds = append(conds, "blocked = 0")
}
if f.NetworkID > 0 {
conds = append(conds, "network_id = ?")
args = append(args, f.NetworkID)
}
if !f.From.IsZero() {
conds = append(conds, "ts >= ?")
args = append(args, f.From.UnixMilli())
}
if !f.To.IsZero() {
conds = append(conds, "ts <= ?")
args = append(args, f.To.UnixMilli())
}
if len(conds) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conds, " AND "), args
}
// QueryLogs returns matching rows newest first, plus the total match count.
func (db *DB) QueryLogs(ctx context.Context, f QueryLogFilter) ([]models.QueryLogEntry, int, error) {
whereSQL, args := f.where()
var total int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM query_logs`+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count query logs: %w", err)
}
q := `SELECT id, ts, client_ip, network_id, network_name, qname, qtype, rcode, source,
cache_hit, blocked, policy_id, policy_name, blacklist_id, blacklist_name, matched_rule,
protocol, duration_us, answer_count
FROM query_logs` + whereSQL + ` ORDER BY ts DESC, id DESC`
qargs := args
if f.Limit > 0 {
q += ` LIMIT ? OFFSET ?`
qargs = append(append([]any{}, args...), f.Limit, f.Offset)
}
rows, err := db.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, fmt.Errorf("read query logs: %w", err)
}
defer rows.Close()
var out []models.QueryLogEntry
for rows.Next() {
var e models.QueryLogEntry
var ts int64
var netID, polID, blID sql.NullInt64
var cacheHit, blocked int
err := rows.Scan(&e.ID, &ts, &e.ClientIP, &netID, &e.NetworkName, &e.QName, &e.QType,
&e.Rcode, &e.Source, &cacheHit, &blocked, &polID, &e.PolicyName, &blID,
&e.BlacklistName, &e.MatchedRule, &e.Protocol, &e.DurationUS, &e.AnswerCount)
if err != nil {
return nil, 0, err
}
e.Timestamp = time.UnixMilli(ts)
e.CacheHit = cacheHit != 0
e.Blocked = blocked != 0
if netID.Valid {
v := netID.Int64
e.NetworkID = &v
}
if polID.Valid {
v := polID.Int64
e.PolicyID = &v
}
if blID.Valid {
v := blID.Int64
e.BlacklistID = &v
}
out = append(out, e)
}
return out, total, rows.Err()
}
// PruneQueryLogs enforces the retention policy. Rows older than retentionDays
// are removed first, then the table is trimmed to maxRows newest entries.
// Either limit may be zero to disable it.
func (db *DB) PruneQueryLogs(ctx context.Context, retentionDays, maxRows int) (int64, error) {
var deleted int64
if retentionDays > 0 {
cutoff := time.Now().AddDate(0, 0, -retentionDays).UnixMilli()
res, err := db.ExecContext(ctx, `DELETE FROM query_logs WHERE ts < ?`, cutoff)
if err != nil {
return deleted, fmt.Errorf("prune query logs by age: %w", err)
}
n, _ := res.RowsAffected()
deleted += n
}
if maxRows > 0 {
res, err := db.ExecContext(ctx, `
DELETE FROM query_logs WHERE id NOT IN (
SELECT id FROM query_logs ORDER BY ts DESC, id DESC LIMIT ?
)`, maxRows)
if err != nil {
return deleted, fmt.Errorf("prune query logs by count: %w", err)
}
n, _ := res.RowsAffected()
deleted += n
}
return deleted, nil
}
// TruncateQueryLogs empties the query log table.
func (db *DB) TruncateQueryLogs(ctx context.Context) (int64, error) {
res, err := db.ExecContext(ctx, `DELETE FROM query_logs`)
if err != nil {
return 0, fmt.Errorf("clear query logs: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// NameCount is a domain/client aggregate used by the dashboard top-N lists.
type NameCount struct {
Name string `json:"name"`
Count int64 `json:"count"`
Extra string `json:"extra,omitempty"`
}
// TopQueried returns the most frequently queried names since `since`.
func (db *DB) TopQueried(ctx context.Context, since time.Time, limit int) ([]NameCount, error) {
return db.topBy(ctx, `SELECT qname, COUNT(*) c FROM query_logs WHERE ts >= ? GROUP BY qname ORDER BY c DESC LIMIT ?`,
since.UnixMilli(), limit)
}
// TopBlocked returns the most frequently blocked names since `since`.
func (db *DB) TopBlocked(ctx context.Context, since time.Time, limit int) ([]NameCount, error) {
return db.topBy(ctx, `SELECT qname, COUNT(*) c FROM query_logs WHERE blocked = 1 AND ts >= ? GROUP BY qname ORDER BY c DESC LIMIT ?`,
since.UnixMilli(), limit)
}
// TopClients returns the busiest clients since `since`.
func (db *DB) TopClients(ctx context.Context, since time.Time, limit int) ([]NameCount, error) {
rows, err := db.QueryContext(ctx, `
SELECT client_ip, COUNT(*) c, COALESCE(MAX(network_name), '')
FROM query_logs WHERE ts >= ? GROUP BY client_ip ORDER BY c DESC LIMIT ?`,
since.UnixMilli(), limit)
if err != nil {
return nil, fmt.Errorf("top clients: %w", err)
}
defer rows.Close()
var out []NameCount
for rows.Next() {
var n NameCount
if err := rows.Scan(&n.Name, &n.Count, &n.Extra); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (db *DB) topBy(ctx context.Context, q string, args ...any) ([]NameCount, error) {
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("aggregate query logs: %w", err)
}
defer rows.Close()
var out []NameCount
for rows.Next() {
var n NameCount
if err := rows.Scan(&n.Name, &n.Count); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
// TimeBucket is one point on the dashboard activity chart.
type TimeBucket struct {
Start time.Time `json:"start"`
Total int64 `json:"total"`
Blocked int64 `json:"blocked"`
Cached int64 `json:"cached"`
}
// ActivityBuckets groups query log rows into fixed-width time buckets covering
// the window [since, now].
func (db *DB) ActivityBuckets(ctx context.Context, since time.Time, bucket time.Duration, count int) ([]TimeBucket, error) {
if bucket <= 0 || count <= 0 {
return nil, nil
}
width := bucket.Milliseconds()
start := since.UnixMilli()
rows, err := db.QueryContext(ctx, `
SELECT (ts - ?) / ? AS b, COUNT(*), SUM(blocked), SUM(cache_hit)
FROM query_logs WHERE ts >= ?
GROUP BY b ORDER BY b`, start, width, start)
if err != nil {
return nil, fmt.Errorf("bucket query logs: %w", err)
}
defer rows.Close()
buckets := make([]TimeBucket, count)
for i := range buckets {
buckets[i].Start = time.UnixMilli(start + int64(i)*width)
}
for rows.Next() {
var idx, total int64
var blocked, cached sql.NullInt64
if err := rows.Scan(&idx, &total, &blocked, &cached); err != nil {
return nil, err
}
if idx < 0 || idx >= int64(count) {
continue
}
buckets[idx].Total = total
buckets[idx].Blocked = blocked.Int64
buckets[idx].Cached = cached.Int64
}
return buckets, rows.Err()
}
// QueryLogCount returns the number of stored rows.
func (db *DB) QueryLogCount(ctx context.Context) (int64, error) {
var n int64
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM query_logs`).Scan(&n)
return n, err
}
// --- Audit log ----------------------------------------------------------
// InsertAudit appends an administrative audit entry.
func (db *DB) InsertAudit(ctx context.Context, e models.AuditEntry) error {
if e.Timestamp.IsZero() {
e.Timestamp = time.Now()
}
_, err := db.ExecContext(ctx, `
INSERT INTO audit_logs (ts, actor, source, client_ip, action, object_type, object_id, object_name, details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UnixMilli(), e.Actor, e.Source, e.ClientIP, e.Action,
e.ObjectType, e.ObjectID, e.ObjectName, e.Details)
if err != nil {
return fmt.Errorf("write audit log: %w", err)
}
return nil
}
// AuditFilter narrows an audit log search.
type AuditFilter struct {
Search string
ObjectType string
Source string
Limit int
Offset int
}
// AuditLogs returns audit entries newest first, plus the total match count.
func (db *DB) AuditLogs(ctx context.Context, f AuditFilter) ([]models.AuditEntry, int, error) {
var conds []string
var args []any
if s := strings.TrimSpace(f.Search); s != "" {
conds = append(conds, "(action LIKE ? OR object_name LIKE ? OR details LIKE ? OR actor LIKE ?)")
pat := "%" + s + "%"
args = append(args, pat, pat, pat, pat)
}
if f.ObjectType != "" {
conds = append(conds, "object_type = ?")
args = append(args, f.ObjectType)
}
if f.Source != "" {
conds = append(conds, "source = ?")
args = append(args, f.Source)
}
whereSQL := ""
if len(conds) > 0 {
whereSQL = " WHERE " + strings.Join(conds, " AND ")
}
var total int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count audit logs: %w", err)
}
q := `SELECT id, ts, actor, source, client_ip, action, object_type, object_id, object_name, details
FROM audit_logs` + whereSQL + ` ORDER BY ts DESC, id DESC`
qargs := args
if f.Limit > 0 {
q += ` LIMIT ? OFFSET ?`
qargs = append(append([]any{}, args...), f.Limit, f.Offset)
}
rows, err := db.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, fmt.Errorf("read audit logs: %w", err)
}
defer rows.Close()
var out []models.AuditEntry
for rows.Next() {
var e models.AuditEntry
var ts int64
if err := rows.Scan(&e.ID, &ts, &e.Actor, &e.Source, &e.ClientIP, &e.Action,
&e.ObjectType, &e.ObjectID, &e.ObjectName, &e.Details); err != nil {
return nil, 0, err
}
e.Timestamp = time.UnixMilli(ts)
out = append(out, e)
}
return out, total, rows.Err()
}
// PruneAuditLogs trims the audit log to the newest maxRows entries.
func (db *DB) PruneAuditLogs(ctx context.Context, maxRows int) (int64, error) {
if maxRows <= 0 {
return 0, nil
}
res, err := db.ExecContext(ctx, `
DELETE FROM audit_logs WHERE id NOT IN (
SELECT id FROM audit_logs ORDER BY ts DESC, id DESC LIMIT ?
)`, maxRows)
if err != nil {
return 0, fmt.Errorf("prune audit logs: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+891
View File
@@ -0,0 +1,891 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/owen/vibedns/internal/models"
)
// --- Networks -----------------------------------------------------------
// Networks lists client networks. When withPolicies is true each network is
// populated with the policies assigned to it.
func (db *DB) Networks(ctx context.Context, search string, withPolicies bool) ([]models.Network, error) {
q := `SELECT id, name, cidr, description, enabled, created_at, updated_at FROM networks`
var args []any
if s := strings.TrimSpace(search); s != "" {
q += ` WHERE name LIKE ? OR cidr LIKE ? OR description LIKE ?`
pat := "%" + s + "%"
args = append(args, pat, pat, pat)
}
q += ` ORDER BY name`
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list networks: %w", err)
}
defer rows.Close()
var out []models.Network
index := map[int64]int{}
for rows.Next() {
var n models.Network
var enabled int
var created, updated int64
if err := rows.Scan(&n.ID, &n.Name, &n.CIDR, &n.Description, &enabled, &created, &updated); err != nil {
return nil, err
}
n.Enabled = enabled != 0
n.CreatedAt = time.Unix(created, 0)
n.UpdatedAt = time.Unix(updated, 0)
index[n.ID] = len(out)
out = append(out, n)
}
if err := rows.Err(); err != nil {
return nil, err
}
if !withPolicies || len(out) == 0 {
return out, nil
}
// One extra query joins in every assignment rather than N+1 lookups.
prows, err := db.QueryContext(ctx, `
SELECT np.network_id, p.id, p.name, p.description, p.enabled, p.block_action,
p.sinkhole_ipv4, p.sinkhole_ipv6, p.block_ttl
FROM network_policies np JOIN policies p ON p.id = np.policy_id
ORDER BY p.name`)
if err != nil {
return nil, fmt.Errorf("list network policies: %w", err)
}
defer prows.Close()
for prows.Next() {
var nid int64
var p models.Policy
var enabled int
if err := prows.Scan(&nid, &p.ID, &p.Name, &p.Description, &enabled, &p.BlockAction,
&p.SinkholeIPv4, &p.SinkholeIPv6, &p.BlockTTL); err != nil {
return nil, err
}
p.Enabled = enabled != 0
if i, ok := index[nid]; ok {
out[i].Policies = append(out[i].Policies, p)
}
}
return out, prows.Err()
}
// Network loads one network with its policy assignments.
func (db *DB) Network(ctx context.Context, id int64) (models.Network, error) {
var n models.Network
var enabled int
var created, updated int64
err := db.QueryRowContext(ctx,
`SELECT id, name, cidr, description, enabled, created_at, updated_at FROM networks WHERE id = ?`, id).
Scan(&n.ID, &n.Name, &n.CIDR, &n.Description, &enabled, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return n, ErrNotFound
}
if err != nil {
return n, fmt.Errorf("load network: %w", err)
}
n.Enabled = enabled != 0
n.CreatedAt = time.Unix(created, 0)
n.UpdatedAt = time.Unix(updated, 0)
ids, err := db.networkPolicyIDs(ctx, id)
if err != nil {
return n, err
}
for _, pid := range ids {
p, err := db.Policy(ctx, pid)
if err != nil {
return n, err
}
n.Policies = append(n.Policies, p)
}
return n, nil
}
func (db *DB) networkPolicyIDs(ctx context.Context, networkID int64) ([]int64, error) {
rows, err := db.QueryContext(ctx,
`SELECT policy_id FROM network_policies WHERE network_id = ?`, networkID)
if err != nil {
return nil, fmt.Errorf("load policy assignments: %w", err)
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// CreateNetwork inserts a network and its policy assignments.
func (db *DB) CreateNetwork(ctx context.Context, n models.Network, policyIDs []int64) (models.Network, error) {
var id int64
err := db.InTx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`INSERT INTO networks (name, cidr, description, enabled) VALUES (?, ?, ?, ?)`,
n.Name, n.CIDR, n.Description, boolInt(n.Enabled))
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("create network: %w", err)
}
id, _ = res.LastInsertId()
return setNetworkPoliciesTx(ctx, tx, id, policyIDs)
})
if err != nil {
return models.Network{}, err
}
return db.Network(ctx, id)
}
// UpdateNetwork saves a network and replaces its policy assignments.
func (db *DB) UpdateNetwork(ctx context.Context, n models.Network, policyIDs []int64) (models.Network, error) {
err := db.InTx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
UPDATE networks SET name = ?, cidr = ?, description = ?, enabled = ?, updated_at = unixepoch()
WHERE id = ?`, n.Name, n.CIDR, n.Description, boolInt(n.Enabled), n.ID)
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("update network: %w", err)
}
if k, _ := res.RowsAffected(); k == 0 {
return ErrNotFound
}
return setNetworkPoliciesTx(ctx, tx, n.ID, policyIDs)
})
if err != nil {
return models.Network{}, err
}
return db.Network(ctx, n.ID)
}
func setNetworkPoliciesTx(ctx context.Context, tx *sql.Tx, networkID int64, policyIDs []int64) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM network_policies WHERE network_id = ?`, networkID); err != nil {
return fmt.Errorf("clear policy assignments: %w", err)
}
if len(policyIDs) == 0 {
return nil
}
stmt, err := tx.PrepareContext(ctx,
`INSERT OR IGNORE INTO network_policies (network_id, policy_id) VALUES (?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, pid := range policyIDs {
if _, err := stmt.ExecContext(ctx, networkID, pid); err != nil {
return fmt.Errorf("assign policy %d: %w", pid, err)
}
}
return nil
}
// DeleteNetwork removes a network; assignments cascade.
func (db *DB) DeleteNetwork(ctx context.Context, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM networks WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete network: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetNetworkEnabled toggles a network.
func (db *DB) SetNetworkEnabled(ctx context.Context, id int64, enabled bool) error {
res, err := db.ExecContext(ctx,
`UPDATE networks SET enabled = ?, updated_at = unixepoch() WHERE id = ?`, boolInt(enabled), id)
if err != nil {
return fmt.Errorf("update network: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// --- Policies -----------------------------------------------------------
const policyColumns = `id, name, description, enabled, block_action, sinkhole_ipv4, sinkhole_ipv6,
block_ttl, created_at, updated_at`
func scanPolicy(sc interface{ Scan(...any) error }) (models.Policy, error) {
var p models.Policy
var enabled int
var created, updated int64
err := sc.Scan(&p.ID, &p.Name, &p.Description, &enabled, &p.BlockAction,
&p.SinkholeIPv4, &p.SinkholeIPv6, &p.BlockTTL, &created, &updated)
if err != nil {
return p, err
}
p.Enabled = enabled != 0
p.CreatedAt = time.Unix(created, 0)
p.UpdatedAt = time.Unix(updated, 0)
return p, nil
}
// Policies lists every policy with its list assignments and network usage.
func (db *DB) Policies(ctx context.Context) ([]models.Policy, error) {
rows, err := db.QueryContext(ctx, `SELECT `+policyColumns+` FROM policies ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list policies: %w", err)
}
defer rows.Close()
var out []models.Policy
index := map[int64]int{}
for rows.Next() {
p, err := scanPolicy(rows)
if err != nil {
return nil, err
}
index[p.ID] = len(out)
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return out, nil
}
lrows, err := db.QueryContext(ctx, `
SELECT pl.policy_id, l.id, l.kind, l.name
FROM policy_lists pl JOIN domain_lists l ON l.id = pl.list_id
ORDER BY l.name`)
if err != nil {
return nil, fmt.Errorf("list policy lists: %w", err)
}
defer lrows.Close()
for lrows.Next() {
var pid, lid int64
var kind, name string
if err := lrows.Scan(&pid, &lid, &kind, &name); err != nil {
return nil, err
}
i, ok := index[pid]
if !ok {
continue
}
if kind == models.KindAllowlist {
out[i].AllowlistIDs = append(out[i].AllowlistIDs, lid)
out[i].AllowlistName = append(out[i].AllowlistName, name)
} else {
out[i].BlacklistIDs = append(out[i].BlacklistIDs, lid)
out[i].BlacklistName = append(out[i].BlacklistName, name)
}
}
if err := lrows.Err(); err != nil {
return nil, err
}
nrows, err := db.QueryContext(ctx,
`SELECT policy_id, COUNT(*) FROM network_policies GROUP BY policy_id`)
if err != nil {
return nil, err
}
defer nrows.Close()
for nrows.Next() {
var pid int64
var c int
if err := nrows.Scan(&pid, &c); err != nil {
return nil, err
}
if i, ok := index[pid]; ok {
out[i].NetworkCount = c
}
}
return out, nrows.Err()
}
// Policy loads one policy with its list assignments.
func (db *DB) Policy(ctx context.Context, id int64) (models.Policy, error) {
row := db.QueryRowContext(ctx, `SELECT `+policyColumns+` FROM policies WHERE id = ?`, id)
p, err := scanPolicy(row)
if errors.Is(err, sql.ErrNoRows) {
return p, ErrNotFound
}
if err != nil {
return p, fmt.Errorf("load policy: %w", err)
}
rows, err := db.QueryContext(ctx, `
SELECT l.id, l.kind, l.name FROM policy_lists pl
JOIN domain_lists l ON l.id = pl.list_id
WHERE pl.policy_id = ? ORDER BY l.name`, id)
if err != nil {
return p, fmt.Errorf("load policy lists: %w", err)
}
defer rows.Close()
for rows.Next() {
var lid int64
var kind, name string
if err := rows.Scan(&lid, &kind, &name); err != nil {
return p, err
}
if kind == models.KindAllowlist {
p.AllowlistIDs = append(p.AllowlistIDs, lid)
p.AllowlistName = append(p.AllowlistName, name)
} else {
p.BlacklistIDs = append(p.BlacklistIDs, lid)
p.BlacklistName = append(p.BlacklistName, name)
}
}
return p, rows.Err()
}
// CreatePolicy inserts a policy with its list assignments.
func (db *DB) CreatePolicy(ctx context.Context, p models.Policy, listIDs []int64) (models.Policy, error) {
var id int64
err := db.InTx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
INSERT INTO policies (name, description, enabled, block_action, sinkhole_ipv4, sinkhole_ipv6, block_ttl)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.Description, boolInt(p.Enabled), string(p.BlockAction),
p.SinkholeIPv4, p.SinkholeIPv6, p.BlockTTL)
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("create policy: %w", err)
}
id, _ = res.LastInsertId()
return setPolicyListsTx(ctx, tx, id, listIDs)
})
if err != nil {
return models.Policy{}, err
}
return db.Policy(ctx, id)
}
// UpdatePolicy saves a policy and replaces its list assignments.
func (db *DB) UpdatePolicy(ctx context.Context, p models.Policy, listIDs []int64) (models.Policy, error) {
err := db.InTx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
UPDATE policies SET name = ?, description = ?, enabled = ?, block_action = ?,
sinkhole_ipv4 = ?, sinkhole_ipv6 = ?, block_ttl = ?, updated_at = unixepoch()
WHERE id = ?`,
p.Name, p.Description, boolInt(p.Enabled), string(p.BlockAction),
p.SinkholeIPv4, p.SinkholeIPv6, p.BlockTTL, p.ID)
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("update policy: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return setPolicyListsTx(ctx, tx, p.ID, listIDs)
})
if err != nil {
return models.Policy{}, err
}
return db.Policy(ctx, p.ID)
}
func setPolicyListsTx(ctx context.Context, tx *sql.Tx, policyID int64, listIDs []int64) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM policy_lists WHERE policy_id = ?`, policyID); err != nil {
return fmt.Errorf("clear policy lists: %w", err)
}
if len(listIDs) == 0 {
return nil
}
stmt, err := tx.PrepareContext(ctx,
`INSERT OR IGNORE INTO policy_lists (policy_id, list_id) VALUES (?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, lid := range listIDs {
if _, err := stmt.ExecContext(ctx, policyID, lid); err != nil {
return fmt.Errorf("assign list %d: %w", lid, err)
}
}
return nil
}
// DeletePolicy removes a policy; assignments cascade.
func (db *DB) DeletePolicy(ctx context.Context, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM policies WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete policy: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetPolicyEnabled toggles a policy.
func (db *DB) SetPolicyEnabled(ctx context.Context, id int64, enabled bool) error {
res, err := db.ExecContext(ctx,
`UPDATE policies SET enabled = ?, updated_at = unixepoch() WHERE id = ?`, boolInt(enabled), id)
if err != nil {
return fmt.Errorf("update policy: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// --- Domain lists -------------------------------------------------------
// DomainLists returns blacklists or allowlists (kind may be "" for both) with
// domain counts and the policies that reference them.
func (db *DB) DomainLists(ctx context.Context, kind, search string) ([]models.DomainList, error) {
q := `SELECT l.id, l.kind, l.name, l.description, l.enabled, l.source_url, l.created_at, l.updated_at,
(SELECT COUNT(*) FROM domain_entries e WHERE e.list_id = l.id) AS domain_count
FROM domain_lists l`
var where []string
var args []any
if kind != "" {
where = append(where, "l.kind = ?")
args = append(args, kind)
}
if s := strings.TrimSpace(search); s != "" {
where = append(where, "(l.name LIKE ? OR l.description LIKE ?)")
pat := "%" + s + "%"
args = append(args, pat, pat)
}
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY l.name"
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list domain lists: %w", err)
}
defer rows.Close()
var out []models.DomainList
index := map[int64]int{}
for rows.Next() {
var l models.DomainList
var enabled int
var created, updated int64
if err := rows.Scan(&l.ID, &l.Kind, &l.Name, &l.Description, &enabled, &l.SourceURL,
&created, &updated, &l.DomainCount); err != nil {
return nil, err
}
l.Enabled = enabled != 0
l.CreatedAt = time.Unix(created, 0)
l.UpdatedAt = time.Unix(updated, 0)
index[l.ID] = len(out)
out = append(out, l)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return out, nil
}
urows, err := db.QueryContext(ctx, `
SELECT pl.list_id, p.name FROM policy_lists pl
JOIN policies p ON p.id = pl.policy_id ORDER BY p.name`)
if err != nil {
return nil, err
}
defer urows.Close()
for urows.Next() {
var lid int64
var name string
if err := urows.Scan(&lid, &name); err != nil {
return nil, err
}
if i, ok := index[lid]; ok {
out[i].UsedBy = append(out[i].UsedBy, name)
}
}
return out, urows.Err()
}
// DomainList loads one list with its domain count and referencing policies.
func (db *DB) DomainList(ctx context.Context, id int64) (models.DomainList, error) {
var l models.DomainList
var enabled int
var created, updated int64
err := db.QueryRowContext(ctx, `
SELECT l.id, l.kind, l.name, l.description, l.enabled, l.source_url, l.created_at, l.updated_at,
(SELECT COUNT(*) FROM domain_entries e WHERE e.list_id = l.id)
FROM domain_lists l WHERE l.id = ?`, id).
Scan(&l.ID, &l.Kind, &l.Name, &l.Description, &enabled, &l.SourceURL, &created, &updated, &l.DomainCount)
if errors.Is(err, sql.ErrNoRows) {
return l, ErrNotFound
}
if err != nil {
return l, fmt.Errorf("load domain list: %w", err)
}
l.Enabled = enabled != 0
l.CreatedAt = time.Unix(created, 0)
l.UpdatedAt = time.Unix(updated, 0)
rows, err := db.QueryContext(ctx, `
SELECT p.name FROM policy_lists pl JOIN policies p ON p.id = pl.policy_id
WHERE pl.list_id = ? ORDER BY p.name`, id)
if err != nil {
return l, err
}
defer rows.Close()
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return l, err
}
l.UsedBy = append(l.UsedBy, n)
}
return l, rows.Err()
}
// CreateDomainList inserts a blacklist or allowlist.
func (db *DB) CreateDomainList(ctx context.Context, l models.DomainList) (models.DomainList, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO domain_lists (kind, name, description, enabled, source_url)
VALUES (?, ?, ?, ?, ?)`,
l.Kind, l.Name, l.Description, boolInt(l.Enabled), l.SourceURL)
if err != nil {
if isUniqueViolation(err) {
return models.DomainList{}, ErrConflict
}
return models.DomainList{}, fmt.Errorf("create domain list: %w", err)
}
id, _ := res.LastInsertId()
return db.DomainList(ctx, id)
}
// UpdateDomainList saves list metadata.
func (db *DB) UpdateDomainList(ctx context.Context, l models.DomainList) (models.DomainList, error) {
res, err := db.ExecContext(ctx, `
UPDATE domain_lists SET name = ?, description = ?, enabled = ?, source_url = ?,
updated_at = unixepoch()
WHERE id = ?`, l.Name, l.Description, boolInt(l.Enabled), l.SourceURL, l.ID)
if err != nil {
if isUniqueViolation(err) {
return models.DomainList{}, ErrConflict
}
return models.DomainList{}, fmt.Errorf("update domain list: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return models.DomainList{}, ErrNotFound
}
return db.DomainList(ctx, l.ID)
}
// DeleteDomainList removes a list and every domain in it.
func (db *DB) DeleteDomainList(ctx context.Context, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM domain_lists WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete domain list: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetDomainListEnabled toggles a list.
func (db *DB) SetDomainListEnabled(ctx context.Context, id int64, enabled bool) error {
res, err := db.ExecContext(ctx,
`UPDATE domain_lists SET enabled = ?, updated_at = unixepoch() WHERE id = ?`, boolInt(enabled), id)
if err != nil {
return fmt.Errorf("update domain list: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// --- Domain entries -----------------------------------------------------
// DomainEntries pages through the domains of one list.
func (db *DB) DomainEntries(ctx context.Context, listID int64, search string, limit, offset int) ([]models.DomainEntry, int, error) {
where := " WHERE list_id = ?"
args := []any{listID}
if s := strings.TrimSpace(search); s != "" {
where += " AND domain LIKE ?"
args = append(args, "%"+strings.ToLower(s)+"%")
}
var total int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domain_entries`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count domains: %w", err)
}
q := `SELECT id, list_id, domain, match_subdomains, enabled, comment, created_at
FROM domain_entries` + where + ` ORDER BY domain`
qargs := args
if limit > 0 {
q += " LIMIT ? OFFSET ?"
qargs = append(append([]any{}, args...), limit, offset)
}
rows, err := db.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, fmt.Errorf("list domains: %w", err)
}
defer rows.Close()
var out []models.DomainEntry
for rows.Next() {
var e models.DomainEntry
var sub, enabled int
var created int64
if err := rows.Scan(&e.ID, &e.ListID, &e.Domain, &sub, &enabled, &e.Comment, &created); err != nil {
return nil, 0, err
}
e.MatchSubdomains = sub != 0
e.Enabled = enabled != 0
e.CreatedAt = time.Unix(created, 0)
out = append(out, e)
}
return out, total, rows.Err()
}
// AddDomain inserts a single domain. It returns ErrConflict when the domain is
// already present in that list.
func (db *DB) AddDomain(ctx context.Context, e models.DomainEntry) (models.DomainEntry, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO domain_entries (list_id, domain, match_subdomains, enabled, comment)
VALUES (?, ?, ?, ?, ?)`,
e.ListID, e.Domain, boolInt(e.MatchSubdomains), boolInt(e.Enabled), e.Comment)
if err != nil {
if isUniqueViolation(err) {
return models.DomainEntry{}, ErrConflict
}
return models.DomainEntry{}, fmt.Errorf("add domain: %w", err)
}
id, _ := res.LastInsertId()
e.ID = id
e.CreatedAt = time.Now()
db.touchList(ctx, e.ListID)
return e, nil
}
// UpdateDomain saves an existing domain entry.
func (db *DB) UpdateDomain(ctx context.Context, e models.DomainEntry) error {
res, err := db.ExecContext(ctx, `
UPDATE domain_entries SET domain = ?, match_subdomains = ?, enabled = ?, comment = ?
WHERE id = ?`, e.Domain, boolInt(e.MatchSubdomains), boolInt(e.Enabled), e.Comment, e.ID)
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("update domain: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
db.touchList(ctx, e.ListID)
return nil
}
// DeleteDomain removes one domain entry.
func (db *DB) DeleteDomain(ctx context.Context, id int64) error {
var listID int64
_ = db.QueryRowContext(ctx, `SELECT list_id FROM domain_entries WHERE id = ?`, id).Scan(&listID)
res, err := db.ExecContext(ctx, `DELETE FROM domain_entries WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete domain: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
db.touchList(ctx, listID)
return nil
}
// ClearDomains removes every domain from a list and returns how many went.
func (db *DB) ClearDomains(ctx context.Context, listID int64) (int64, error) {
res, err := db.ExecContext(ctx, `DELETE FROM domain_entries WHERE list_id = ?`, listID)
if err != nil {
return 0, fmt.Errorf("clear domains: %w", err)
}
n, _ := res.RowsAffected()
db.touchList(ctx, listID)
return n, nil
}
func (db *DB) touchList(ctx context.Context, listID int64) {
if listID == 0 {
return
}
_, _ = db.ExecContext(ctx, `UPDATE domain_lists SET updated_at = unixepoch() WHERE id = ?`, listID)
}
// ImportDomains bulk-inserts normalised domains into a list.
//
// Everything happens inside one transaction with a single prepared statement,
// so importing a few hundred thousand domains is one commit rather than one
// commit per domain. INSERT OR IGNORE gives duplicate detection for free.
func (db *DB) ImportDomains(ctx context.Context, listID int64, domains []ImportDomain) (imported, duplicates int, err error) {
if len(domains) == 0 {
return 0, 0, nil
}
err = db.InTx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx, `
INSERT OR IGNORE INTO domain_entries (list_id, domain, match_subdomains, enabled, comment)
VALUES (?, ?, ?, 1, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, d := range domains {
res, err := stmt.ExecContext(ctx, listID, d.Domain, boolInt(d.MatchSubdomains), d.Comment)
if err != nil {
return fmt.Errorf("import %q: %w", d.Domain, err)
}
if n, _ := res.RowsAffected(); n > 0 {
imported++
} else {
duplicates++
}
}
_, err = tx.ExecContext(ctx, `UPDATE domain_lists SET updated_at = unixepoch() WHERE id = ?`, listID)
return err
})
if err != nil {
return 0, 0, err
}
return imported, duplicates, nil
}
// ImportDomain is one normalised domain destined for a list.
type ImportDomain struct {
Domain string
MatchSubdomains bool
Comment string
}
// SnapshotDomainEntry is the minimal shape the in-memory matcher needs.
type SnapshotDomainEntry struct {
ListID int64
Domain string
MatchSubdomains bool
}
// SnapshotDomains streams every enabled domain of every enabled list. It is
// called on configuration change, never on the DNS query path.
func (db *DB) SnapshotDomains(ctx context.Context, fn func(SnapshotDomainEntry)) error {
rows, err := db.QueryContext(ctx, `
SELECT e.list_id, e.domain, e.match_subdomains
FROM domain_entries e
JOIN domain_lists l ON l.id = e.list_id
WHERE e.enabled = 1 AND l.enabled = 1`)
if err != nil {
return fmt.Errorf("snapshot domains: %w", err)
}
defer rows.Close()
for rows.Next() {
var e SnapshotDomainEntry
var sub int
if err := rows.Scan(&e.ListID, &e.Domain, &sub); err != nil {
return err
}
e.MatchSubdomains = sub != 0
fn(e)
}
return rows.Err()
}
// CountDomainLists returns blacklist and total-domain counts for the dashboard.
func (db *DB) CountDomainLists(ctx context.Context) (blacklists, blacklistDomains, allowlists, allowlistDomains int, err error) {
err = db.QueryRowContext(ctx, `
SELECT
(SELECT COUNT(*) FROM domain_lists WHERE kind = 'blacklist'),
(SELECT COUNT(*) FROM domain_entries e JOIN domain_lists l ON l.id = e.list_id WHERE l.kind = 'blacklist'),
(SELECT COUNT(*) FROM domain_lists WHERE kind = 'allowlist'),
(SELECT COUNT(*) FROM domain_entries e JOIN domain_lists l ON l.id = e.list_id WHERE l.kind = 'allowlist')`).
Scan(&blacklists, &blacklistDomains, &allowlists, &allowlistDomains)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("count domain lists: %w", err)
}
return
}
// ExportDomains streams every domain of a list in sorted order.
func (db *DB) ExportDomains(ctx context.Context, listID int64, fn func(domain string, matchSubdomains bool)) error {
rows, err := db.QueryContext(ctx,
`SELECT domain, match_subdomains FROM domain_entries WHERE list_id = ? ORDER BY domain`, listID)
if err != nil {
return fmt.Errorf("export domains: %w", err)
}
defer rows.Close()
for rows.Next() {
var d string
var sub int
if err := rows.Scan(&d, &sub); err != nil {
return err
}
fn(d, sub != 0)
}
return rows.Err()
}
// SnapshotNetworks loads enabled networks with the IDs of their enabled
// policies, for building the CIDR index.
func (db *DB) SnapshotNetworks(ctx context.Context) ([]models.Network, map[int64][]int64, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, name, cidr, description, enabled, created_at, updated_at
FROM networks WHERE enabled = 1`)
if err != nil {
return nil, nil, fmt.Errorf("snapshot networks: %w", err)
}
defer rows.Close()
var nets []models.Network
for rows.Next() {
var n models.Network
var enabled int
var created, updated int64
if err := rows.Scan(&n.ID, &n.Name, &n.CIDR, &n.Description, &enabled, &created, &updated); err != nil {
return nil, nil, err
}
n.Enabled = enabled != 0
n.CreatedAt = time.Unix(created, 0)
n.UpdatedAt = time.Unix(updated, 0)
nets = append(nets, n)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
arows, err := db.QueryContext(ctx, `
SELECT np.network_id, np.policy_id FROM network_policies np
JOIN policies p ON p.id = np.policy_id
WHERE p.enabled = 1`)
if err != nil {
return nil, nil, fmt.Errorf("snapshot policy assignments: %w", err)
}
defer arows.Close()
assign := map[int64][]int64{}
for arows.Next() {
var nid, pid int64
if err := arows.Scan(&nid, &pid); err != nil {
return nil, nil, err
}
assign[nid] = append(assign[nid], pid)
}
return nets, assign, arows.Err()
}
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"context"
"database/sql"
"fmt"
)
// Settings returns every stored setting as a key/value map.
func (db *DB) Settings(ctx context.Context) (map[string]string, error) {
rows, err := db.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("load settings: %w", err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
// Setting reads a single setting. It returns ("", false, nil) when unset.
func (db *DB) Setting(ctx context.Context, key string) (string, bool, error) {
var v string
err := db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
switch {
case err == sql.ErrNoRows:
return "", false, nil
case err != nil:
return "", false, fmt.Errorf("read setting %s: %w", key, err)
}
return v, true, nil
}
// SetSetting writes one setting.
func (db *DB) SetSetting(ctx context.Context, key, value string) error {
_, err := db.ExecContext(ctx, `
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, unixepoch())
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()`,
key, value)
if err != nil {
return fmt.Errorf("write setting %s: %w", key, err)
}
return nil
}
// SetSettings writes several settings atomically.
func (db *DB) SetSettings(ctx context.Context, values map[string]string) error {
if len(values) == 0 {
return nil
}
return db.InTx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, unixepoch())
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()`)
if err != nil {
return fmt.Errorf("prepare setting write: %w", err)
}
defer stmt.Close()
for k, v := range values {
if _, err := stmt.ExecContext(ctx, k, v); err != nil {
return fmt.Errorf("write setting %s: %w", k, err)
}
}
return nil
})
}
// DeleteSetting removes a setting, reverting it to its built-in default.
func (db *DB) DeleteSetting(ctx context.Context, key string) error {
_, err := db.ExecContext(ctx, `DELETE FROM settings WHERE key = ?`, key)
return err
}
+654
View File
@@ -0,0 +1,654 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/owen/vibedns/internal/models"
)
const zoneColumns = `id, name, kind, description, enabled, default_ttl, primary_ns, admin_email,
serial, refresh, retry, expire, minimum, auto_serial, created_at, updated_at`
func scanZone(sc interface{ Scan(...any) error }) (models.Zone, error) {
var z models.Zone
var enabled, autoSerial int
var created, updated int64
err := sc.Scan(&z.ID, &z.Name, &z.Kind, &z.Description, &enabled, &z.DefaultTTL,
&z.PrimaryNS, &z.AdminEmail, &z.Serial, &z.Refresh, &z.Retry, &z.Expire,
&z.Minimum, &autoSerial, &created, &updated)
if err != nil {
return z, err
}
z.Enabled = enabled != 0
z.AutoSerial = autoSerial != 0
z.CreatedAt = time.Unix(created, 0)
z.UpdatedAt = time.Unix(updated, 0)
return z, nil
}
// ZoneFilter narrows a zone listing.
type ZoneFilter struct {
Kind string // "", "forward", "reverse4", "reverse6", or "reverse" for both
Search string
}
// Zones lists zones with their record counts, ordered by name.
func (db *DB) Zones(ctx context.Context, f ZoneFilter) ([]models.Zone, error) {
var where []string
var args []any
switch f.Kind {
case "":
// no filter
case "reverse":
where = append(where, "z.kind IN ('reverse4','reverse6')")
default:
where = append(where, "z.kind = ?")
args = append(args, f.Kind)
}
if s := strings.TrimSpace(f.Search); s != "" {
where = append(where, "(z.name LIKE ? OR z.description LIKE ?)")
pat := "%" + s + "%"
args = append(args, pat, pat)
}
q := `SELECT ` + prefixCols(zoneColumns, "z") + `,
(SELECT COUNT(*) FROM records r WHERE r.zone_id = z.id) AS record_count
FROM zones z`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY z.name"
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list zones: %w", err)
}
defer rows.Close()
var out []models.Zone
for rows.Next() {
var z models.Zone
var enabled, autoSerial int
var created, updated int64
err := rows.Scan(&z.ID, &z.Name, &z.Kind, &z.Description, &enabled, &z.DefaultTTL,
&z.PrimaryNS, &z.AdminEmail, &z.Serial, &z.Refresh, &z.Retry, &z.Expire,
&z.Minimum, &autoSerial, &created, &updated, &z.RecordCount)
if err != nil {
return nil, err
}
z.Enabled = enabled != 0
z.AutoSerial = autoSerial != 0
z.CreatedAt = time.Unix(created, 0)
z.UpdatedAt = time.Unix(updated, 0)
out = append(out, z)
}
return out, rows.Err()
}
// prefixCols qualifies a comma separated column list with a table alias.
func prefixCols(cols, alias string) string {
parts := strings.Split(cols, ",")
for i, p := range parts {
parts[i] = alias + "." + strings.TrimSpace(p)
}
return strings.Join(parts, ", ")
}
// Zone loads a single zone by ID.
func (db *DB) Zone(ctx context.Context, id int64) (models.Zone, error) {
row := db.QueryRowContext(ctx, `SELECT `+zoneColumns+` FROM zones WHERE id = ?`, id)
z, err := scanZone(row)
if errors.Is(err, sql.ErrNoRows) {
return z, ErrNotFound
}
if err != nil {
return z, fmt.Errorf("load zone: %w", err)
}
return z, nil
}
// ZoneByName loads a zone by its normalised FQDN.
func (db *DB) ZoneByName(ctx context.Context, name string) (models.Zone, error) {
row := db.QueryRowContext(ctx, `SELECT `+zoneColumns+` FROM zones WHERE name = ?`, name)
z, err := scanZone(row)
if errors.Is(err, sql.ErrNoRows) {
return z, ErrNotFound
}
if err != nil {
return z, fmt.Errorf("load zone: %w", err)
}
return z, nil
}
// CreateZone inserts a zone. The caller is responsible for having validated and
// normalised the zone name.
func (db *DB) CreateZone(ctx context.Context, z models.Zone) (models.Zone, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO zones (name, kind, description, enabled, default_ttl, primary_ns, admin_email,
serial, refresh, retry, expire, minimum, auto_serial)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
z.Name, string(z.Kind), z.Description, boolInt(z.Enabled), z.DefaultTTL, z.PrimaryNS,
z.AdminEmail, z.Serial, z.Refresh, z.Retry, z.Expire, z.Minimum, boolInt(z.AutoSerial))
if err != nil {
if isUniqueViolation(err) {
return models.Zone{}, ErrConflict
}
return models.Zone{}, fmt.Errorf("create zone: %w", err)
}
id, _ := res.LastInsertId()
return db.Zone(ctx, id)
}
// UpdateZone saves zone metadata. Records are managed separately.
func (db *DB) UpdateZone(ctx context.Context, z models.Zone) (models.Zone, error) {
res, err := db.ExecContext(ctx, `
UPDATE zones SET name = ?, kind = ?, description = ?, enabled = ?, default_ttl = ?,
primary_ns = ?, admin_email = ?, serial = ?, refresh = ?, retry = ?, expire = ?,
minimum = ?, auto_serial = ?, updated_at = unixepoch()
WHERE id = ?`,
z.Name, string(z.Kind), z.Description, boolInt(z.Enabled), z.DefaultTTL, z.PrimaryNS,
z.AdminEmail, z.Serial, z.Refresh, z.Retry, z.Expire, z.Minimum, boolInt(z.AutoSerial), z.ID)
if err != nil {
if isUniqueViolation(err) {
return models.Zone{}, ErrConflict
}
return models.Zone{}, fmt.Errorf("update zone: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return models.Zone{}, ErrNotFound
}
return db.Zone(ctx, z.ID)
}
// SetZoneEnabled toggles a zone without touching its records.
func (db *DB) SetZoneEnabled(ctx context.Context, id int64, enabled bool) error {
res, err := db.ExecContext(ctx,
`UPDATE zones SET enabled = ?, updated_at = unixepoch() WHERE id = ?`, boolInt(enabled), id)
if err != nil {
return fmt.Errorf("update zone: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteZone removes a zone and, by foreign key cascade, all of its records.
func (db *DB) DeleteZone(ctx context.Context, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM zones WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete zone: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CloneZone copies a zone and every record into a new zone name. Record names
// are copied verbatim; rdata that referenced the old apex is rewritten so the
// clone is self-consistent.
func (db *DB) CloneZone(ctx context.Context, srcID int64, newName, description string) (models.Zone, error) {
var newID int64
err := db.InTx(ctx, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `SELECT `+zoneColumns+` FROM zones WHERE id = ?`, srcID)
src, err := scanZone(row)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("load source zone: %w", err)
}
res, err := tx.ExecContext(ctx, `
INSERT INTO zones (name, kind, description, enabled, default_ttl, primary_ns, admin_email,
serial, refresh, retry, expire, minimum, auto_serial)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)`,
newName, string(src.Kind), description, boolInt(src.Enabled), src.DefaultTTL,
src.PrimaryNS, src.AdminEmail, src.Refresh, src.Retry, src.Expire, src.Minimum,
boolInt(src.AutoSerial))
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("create cloned zone: %w", err)
}
newID, _ = res.LastInsertId()
// REPLACE rewrites references to the source apex inside rdata so that
// e.g. "www CNAME example.com." becomes "www CNAME clone.example."
_, err = tx.ExecContext(ctx, `
INSERT INTO records (zone_id, name, type, data, ttl, enabled, comment)
SELECT ?, name, type, REPLACE(data, ?, ?), ttl, enabled, comment
FROM records WHERE zone_id = ? AND type <> 'SOA'`,
newID, src.Name, newName, srcID)
if err != nil {
return fmt.Errorf("copy records: %w", err)
}
return nil
})
if err != nil {
return models.Zone{}, err
}
return db.Zone(ctx, newID)
}
// BumpSerial increments a zone's SOA serial if auto-serial is enabled.
// Serials wrap according to RFC 1982 arithmetic, which SQLite's modulo gives us
// for free by wrapping past 2^32-1 back to 1.
func (db *DB) BumpSerial(ctx context.Context, zoneID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE zones
SET serial = CASE WHEN serial >= 4294967295 THEN 1 ELSE serial + 1 END,
updated_at = unixepoch()
WHERE id = ? AND auto_serial = 1`, zoneID)
return err
}
func bumpSerialTx(ctx context.Context, tx *sql.Tx, zoneID int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE zones
SET serial = CASE WHEN serial >= 4294967295 THEN 1 ELSE serial + 1 END,
updated_at = unixepoch()
WHERE id = ? AND auto_serial = 1`, zoneID)
return err
}
// --- Records ------------------------------------------------------------
const recordColumns = `id, zone_id, name, type, data, ttl, enabled, comment, created_at, updated_at`
func scanRecord(sc interface{ Scan(...any) error }) (models.Record, error) {
var r models.Record
var ttl sql.NullInt64
var enabled int
var created, updated int64
err := sc.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &r.Data, &ttl, &enabled, &r.Comment, &created, &updated)
if err != nil {
return r, err
}
if ttl.Valid {
v := uint32(ttl.Int64)
r.TTL = &v
}
r.Enabled = enabled != 0
r.CreatedAt = time.Unix(created, 0)
r.UpdatedAt = time.Unix(updated, 0)
return r, nil
}
// RecordFilter narrows a record listing.
type RecordFilter struct {
ZoneID int64 // 0 means all zones
Search string // matches name or data
Type string
Enabled string // "", "enabled", "disabled"
Limit int
Offset int
}
func (f RecordFilter) whereClause() (string, []any) {
var where []string
var args []any
if f.ZoneID > 0 {
where = append(where, "r.zone_id = ?")
args = append(args, f.ZoneID)
}
if s := strings.TrimSpace(f.Search); s != "" {
where = append(where, "(r.name LIKE ? OR r.data LIKE ? OR r.comment LIKE ?)")
pat := "%" + s + "%"
args = append(args, pat, pat, pat)
}
if t := strings.ToUpper(strings.TrimSpace(f.Type)); t != "" {
where = append(where, "r.type = ?")
args = append(args, t)
}
switch f.Enabled {
case "enabled":
where = append(where, "r.enabled = 1")
case "disabled":
where = append(where, "r.enabled = 0")
}
if len(where) == 0 {
return "", nil
}
return " WHERE " + strings.Join(where, " AND "), args
}
// Records lists records matching a filter, together with the total match count
// (ignoring limit/offset) so the UI can paginate.
func (db *DB) Records(ctx context.Context, f RecordFilter) ([]models.Record, int, error) {
whereSQL, args := f.whereClause()
var total int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM records r`+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count records: %w", err)
}
q := `SELECT ` + prefixCols(recordColumns, "r") + `, z.name
FROM records r JOIN zones z ON z.id = r.zone_id` + whereSQL +
` ORDER BY z.name, CASE r.name WHEN '@' THEN 0 ELSE 1 END, r.name, r.type`
qargs := args
if f.Limit > 0 {
q += " LIMIT ? OFFSET ?"
qargs = append(append([]any{}, args...), f.Limit, f.Offset)
}
rows, err := db.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, fmt.Errorf("list records: %w", err)
}
defer rows.Close()
var out []models.Record
for rows.Next() {
var r models.Record
var ttl sql.NullInt64
var enabled int
var created, updated int64
err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &r.Data, &ttl, &enabled,
&r.Comment, &created, &updated, &r.ZoneName)
if err != nil {
return nil, 0, err
}
if ttl.Valid {
v := uint32(ttl.Int64)
r.TTL = &v
}
r.Enabled = enabled != 0
r.CreatedAt = time.Unix(created, 0)
r.UpdatedAt = time.Unix(updated, 0)
out = append(out, r)
}
return out, total, rows.Err()
}
// Record loads a single record.
func (db *DB) Record(ctx context.Context, id int64) (models.Record, error) {
row := db.QueryRowContext(ctx, `SELECT `+recordColumns+` FROM records WHERE id = ?`, id)
r, err := scanRecord(row)
if errors.Is(err, sql.ErrNoRows) {
return r, ErrNotFound
}
if err != nil {
return r, fmt.Errorf("load record: %w", err)
}
return r, nil
}
// ZoneRecordsRaw returns every record of a zone in insertion order, used by the
// zone-file exporter.
func (db *DB) ZoneRecordsRaw(ctx context.Context, zoneID int64) ([]models.Record, error) {
rows, err := db.QueryContext(ctx,
`SELECT `+recordColumns+` FROM records WHERE zone_id = ?
ORDER BY CASE type WHEN 'SOA' THEN 0 WHEN 'NS' THEN 1 ELSE 2 END,
CASE name WHEN '@' THEN 0 ELSE 1 END, name, type`, zoneID)
if err != nil {
return nil, fmt.Errorf("list zone records: %w", err)
}
defer rows.Close()
var out []models.Record
for rows.Next() {
r, err := scanRecord(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// CreateRecord inserts a record and bumps the zone serial.
func (db *DB) CreateRecord(ctx context.Context, r models.Record) (models.Record, error) {
var id int64
err := db.InTx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
INSERT INTO records (zone_id, name, type, data, ttl, enabled, comment)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
r.ZoneID, r.Name, r.Type, r.Data, ttlArg(r.TTL), boolInt(r.Enabled), r.Comment)
if err != nil {
return fmt.Errorf("create record: %w", err)
}
id, _ = res.LastInsertId()
return bumpSerialTx(ctx, tx, r.ZoneID)
})
if err != nil {
return models.Record{}, err
}
return db.Record(ctx, id)
}
// UpdateRecord saves a record and bumps the zone serial.
func (db *DB) UpdateRecord(ctx context.Context, r models.Record) (models.Record, error) {
err := db.InTx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
UPDATE records SET name = ?, type = ?, data = ?, ttl = ?, enabled = ?, comment = ?,
updated_at = unixepoch()
WHERE id = ?`,
r.Name, r.Type, r.Data, ttlArg(r.TTL), boolInt(r.Enabled), r.Comment, r.ID)
if err != nil {
return fmt.Errorf("update record: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return bumpSerialTx(ctx, tx, r.ZoneID)
})
if err != nil {
return models.Record{}, err
}
return db.Record(ctx, r.ID)
}
// SetRecordEnabled toggles a single record.
func (db *DB) SetRecordEnabled(ctx context.Context, id int64, enabled bool) error {
return db.InTx(ctx, func(tx *sql.Tx) error {
var zoneID int64
err := tx.QueryRowContext(ctx, `SELECT zone_id FROM records WHERE id = ?`, id).Scan(&zoneID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE records SET enabled = ?, updated_at = unixepoch() WHERE id = ?`,
boolInt(enabled), id); err != nil {
return fmt.Errorf("update record: %w", err)
}
return bumpSerialTx(ctx, tx, zoneID)
})
}
// DeleteRecord removes a record and bumps the zone serial.
func (db *DB) DeleteRecord(ctx context.Context, id int64) error {
return db.InTx(ctx, func(tx *sql.Tx) error {
var zoneID int64
err := tx.QueryRowContext(ctx, `SELECT zone_id FROM records WHERE id = ?`, id).Scan(&zoneID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM records WHERE id = ?`, id); err != nil {
return fmt.Errorf("delete record: %w", err)
}
return bumpSerialTx(ctx, tx, zoneID)
})
}
// DeleteRecords removes several records belonging to one zone in a single
// transaction. It returns the number of rows deleted.
func (db *DB) DeleteRecords(ctx context.Context, zoneID int64, ids []int64) (int, error) {
if len(ids) == 0 {
return 0, nil
}
var deleted int
err := db.InTx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx, `DELETE FROM records WHERE id = ? AND zone_id = ?`)
if err != nil {
return err
}
defer stmt.Close()
for _, id := range ids {
res, err := stmt.ExecContext(ctx, id, zoneID)
if err != nil {
return fmt.Errorf("delete record %d: %w", id, err)
}
n, _ := res.RowsAffected()
deleted += int(n)
}
return bumpSerialTx(ctx, tx, zoneID)
})
return deleted, err
}
// SetRecordsEnabled toggles several records of one zone at once.
func (db *DB) SetRecordsEnabled(ctx context.Context, zoneID int64, ids []int64, enabled bool) (int, error) {
if len(ids) == 0 {
return 0, nil
}
var updated int
err := db.InTx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx,
`UPDATE records SET enabled = ?, updated_at = unixepoch() WHERE id = ? AND zone_id = ?`)
if err != nil {
return err
}
defer stmt.Close()
for _, id := range ids {
res, err := stmt.ExecContext(ctx, boolInt(enabled), id, zoneID)
if err != nil {
return fmt.Errorf("update record %d: %w", id, err)
}
n, _ := res.RowsAffected()
updated += int(n)
}
return bumpSerialTx(ctx, tx, zoneID)
})
return updated, err
}
// ReplaceZoneRecords swaps a zone's entire record set in one transaction. It
// backs the zone-file import "replace" mode.
func (db *DB) ReplaceZoneRecords(ctx context.Context, zoneID int64, recs []models.Record) error {
return db.InTx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM records WHERE zone_id = ?`, zoneID); err != nil {
return fmt.Errorf("clear zone records: %w", err)
}
return insertRecordsTx(ctx, tx, zoneID, recs)
})
}
// AppendZoneRecords adds records to a zone in one transaction.
func (db *DB) AppendZoneRecords(ctx context.Context, zoneID int64, recs []models.Record) error {
return db.InTx(ctx, func(tx *sql.Tx) error {
return insertRecordsTx(ctx, tx, zoneID, recs)
})
}
func insertRecordsTx(ctx context.Context, tx *sql.Tx, zoneID int64, recs []models.Record) error {
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO records (zone_id, name, type, data, ttl, enabled, comment)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, r := range recs {
if _, err := stmt.ExecContext(ctx, zoneID, r.Name, r.Type, r.Data,
ttlArg(r.TTL), boolInt(r.Enabled), r.Comment); err != nil {
return fmt.Errorf("insert record %s %s: %w", r.Name, r.Type, err)
}
}
return bumpSerialTx(ctx, tx, zoneID)
}
func ttlArg(ttl *uint32) any {
if ttl == nil {
return nil
}
return int64(*ttl)
}
// ZoneSnapshotRow is one row of the bulk snapshot query that feeds the
// in-memory authoritative index.
type ZoneSnapshotRow struct {
Zone models.Zone
Record *models.Record // nil for a zone with no records
}
// SnapshotZones loads every enabled zone with its enabled records in a single
// query. This is the only place the DNS data path touches SQLite, and it runs
// on configuration change rather than per query.
func (db *DB) SnapshotZones(ctx context.Context) ([]models.Zone, map[int64][]models.Record, error) {
zones, err := db.Zones(ctx, ZoneFilter{})
if err != nil {
return nil, nil, err
}
rows, err := db.QueryContext(ctx, `
SELECT `+recordColumns+`
FROM records
WHERE enabled = 1 AND zone_id IN (SELECT id FROM zones WHERE enabled = 1)`)
if err != nil {
return nil, nil, fmt.Errorf("snapshot records: %w", err)
}
defer rows.Close()
byZone := map[int64][]models.Record{}
for rows.Next() {
r, err := scanRecord(rows)
if err != nil {
return nil, nil, err
}
byZone[r.ZoneID] = append(byZone[r.ZoneID], r)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return zones, byZone, nil
}
// CountZonesAndRecords returns totals for the dashboard.
func (db *DB) CountZonesAndRecords(ctx context.Context) (zones, records int, err error) {
err = db.QueryRowContext(ctx,
`SELECT (SELECT COUNT(*) FROM zones), (SELECT COUNT(*) FROM records)`).Scan(&zones, &records)
if err != nil {
return 0, 0, fmt.Errorf("count zones and records: %w", err)
}
return zones, records, nil
}
// RecordTypesInUse lists the distinct record types present, for filter menus.
func (db *DB) RecordTypesInUse(ctx context.Context, zoneID int64) ([]string, error) {
q := `SELECT DISTINCT type FROM records`
var args []any
if zoneID > 0 {
q += ` WHERE zone_id = ?`
args = append(args, zoneID)
}
q += ` ORDER BY type`
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
+335
View File
@@ -0,0 +1,335 @@
// Package dnsengine contains the UDP and TCP listeners and the query pipeline
// that ties the authoritative index, the policy engine, the cache and the
// recursive resolver together.
package dnsengine
import (
"context"
"net"
"net/netip"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/netutil"
"github.com/owen/vibedns/internal/policy"
"github.com/owen/vibedns/internal/runtimecfg"
"github.com/owen/vibedns/internal/version"
)
// outcome records everything the query log and metrics need about one query.
type outcome struct {
source string
rcode int
cacheHit bool
blocked bool
answerCount int
decision policy.Decision
upstream string
}
// ServeDNS implements dns.Handler. It is the single entry point for every
// query, over both UDP and TCP.
func (s *Server) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
start := time.Now()
protocol := "udp"
if _, ok := w.RemoteAddr().(*net.TCPAddr); ok {
protocol = "tcp"
}
client := netutil.AddrFromNetAddr(w.RemoteAddr())
snap := s.runtime.Current()
// Rate limiting happens before any work is done. Exceeding clients are
// dropped without a response: replying would let an attacker use us as an
// amplifier, which is exactly what the limiter exists to prevent.
if !s.limiter.Allow(client) {
s.metrics.RateLimited.Add(1)
s.metrics.ObserveQuery(qtypeName(req), "DROPPED", models.SourceRateLimited, protocol, time.Since(start))
s.logQuery(req, client, protocol, outcome{source: models.SourceRateLimited, rcode: -1}, snap, start)
return
}
resp, out := s.respond(req, client, snap)
if resp == nil {
return
}
s.finalise(req, resp, protocol, snap)
if err := w.WriteMsg(resp); err != nil {
s.log.Debug("could not write DNS response", "client", client.String(), "error", err)
}
out.rcode = resp.Rcode
out.answerCount = len(resp.Answer)
elapsed := time.Since(start)
s.metrics.ObserveQuery(qtypeName(req), dns.RcodeToString[resp.Rcode], out.source, protocol, elapsed)
s.logQueryOutcome(req, client, protocol, out, snap, elapsed)
}
// respond produces the reply message and describes how it was produced.
func (s *Server) respond(req *dns.Msg, client netip.Addr, snap *runtimecfg.Snapshot) (*dns.Msg, outcome) {
if req.Opcode != dns.OpcodeQuery {
return errorReply(req, dns.RcodeNotImplemented), outcome{source: models.SourceError}
}
if len(req.Question) != 1 {
// Multiple questions in one message are not defined by any RFC and no
// real client sends them.
return errorReply(req, dns.RcodeFormatError), outcome{source: models.SourceError}
}
q := req.Question[0]
qname := strings.ToLower(dns.Fqdn(q.Name))
do := requestDO(req)
if q.Qclass == dns.ClassCHAOS {
return s.chaosReply(req, snap), outcome{source: models.SourceLocal}
}
if q.Qclass != dns.ClassINET {
return errorReply(req, dns.RcodeRefused), outcome{source: models.SourceRefused}
}
// 1. Client policy. Blocking runs first so a policy applies even to names
// that a local zone would otherwise answer.
decision := snap.Policy.Evaluate(client, qname)
if decision.Blocked {
s.metrics.Blocked.Add(1)
return s.blockReply(req, decision), outcome{
source: models.SourceBlocked, blocked: true, decision: decision,
}
}
// 2. Authoritative zones always win over recursion.
if resp := snap.Zones.Answer(req, do); resp != nil {
s.metrics.Authoritative.Add(1)
return resp, outcome{source: models.SourceAuthoritative, decision: decision}
}
// 3. Recursion, subject to the ACL. Authoritative answers above remain
// available to clients that are not allowed to recurse.
if !snap.Settings.DNS.Recursion {
s.metrics.Refused.Add(1)
return errorReply(req, dns.RcodeRefused), outcome{source: models.SourceRefused, decision: decision}
}
if !snap.ACL.Allowed(client) {
s.metrics.Refused.Add(1)
s.log.Debug("recursion denied by ACL", "client", client.String(), "name", qname)
return errorReply(req, dns.RcodeRefused), outcome{source: models.SourceRefused, decision: decision}
}
// 4. Cache.
key := cache.KeyFor(dns.Question{Name: qname, Qtype: q.Qtype, Qclass: q.Qclass}, do)
if res := s.cache.Get(key, req); res.Hit {
s.metrics.CacheHits.Add(1)
src := models.SourceCache
if res.Stale {
s.metrics.StaleServed.Add(1)
src = models.SourceStale
}
res.Msg.RecursionAvailable = true
return res.Msg, outcome{source: src, cacheHit: true, decision: decision}
}
s.metrics.CacheMisses.Add(1)
// 5. Forward upstream.
ctx, cancel := context.WithTimeout(s.ctx, s.forwardBudget(snap))
defer cancel()
fstart := time.Now()
result, err := s.resolver.Resolve(ctx, req)
s.metrics.ObserveResolver(time.Since(fstart), err != nil)
if err != nil {
s.log.Debug("recursive resolution failed", "name", qname, "type", qtypeName(req), "error", err)
s.metrics.Errors.Add(1)
return errorReply(req, dns.RcodeServerFailure), outcome{source: models.SourceError, decision: decision}
}
s.metrics.Recursive.Add(1)
// 6. Cache the answer.
s.cache.Put(key, result.Msg)
resp := result.Msg
resp.Id = req.Id
resp.Question = req.Question
resp.RecursionAvailable = true
return resp, outcome{source: models.SourceRecursive, decision: decision, upstream: result.Upstream}
}
// forwardBudget bounds the total time spent forwarding one query, leaving the
// client's own timeout some headroom.
func (s *Server) forwardBudget(snap *runtimecfg.Snapshot) time.Duration {
per := time.Duration(snap.Settings.Resolver.TimeoutMS) * time.Millisecond
attempts := snap.Settings.Resolver.Retries + 1
total := per * time.Duration(attempts)
if total > 15*time.Second {
total = 15 * time.Second
}
if total < per {
total = per
}
return total
}
// blockReply builds the response for a policy-blocked query.
func (s *Server) blockReply(req *dns.Msg, d policy.Decision) *dns.Msg {
q := req.Question[0]
m := new(dns.Msg)
m.SetReply(req)
m.RecursionAvailable = true
m.Authoritative = true
ttl := uint32(60)
if d.Policy != nil && d.Policy.TTL > 0 {
ttl = d.Policy.TTL
}
switch d.Action() {
case models.BlockRefused:
m.Rcode = dns.RcodeRefused
return m
case models.BlockSinkhole:
switch q.Qtype {
case dns.TypeA:
if d.Policy != nil && d.Policy.SinkholeV4.IsValid() {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: d.Policy.SinkholeV4.AsSlice(),
})
}
case dns.TypeAAAA:
if d.Policy != nil && d.Policy.SinkholeV6.IsValid() {
m.Answer = append(m.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl},
AAAA: d.Policy.SinkholeV6.AsSlice(),
})
}
}
if len(m.Answer) == 0 {
// Sinkholing only makes sense for address queries; everything else
// gets an empty NOERROR so clients do not retry in a loop.
m.Ns = append(m.Ns, syntheticSOA(q.Name, ttl))
}
return m
default: // NXDOMAIN
m.Rcode = dns.RcodeNameError
m.Ns = append(m.Ns, syntheticSOA(q.Name, ttl))
return m
}
}
// syntheticSOA gives a blocked or synthesised negative answer something for the
// client to derive a negative cache TTL from.
func syntheticSOA(name string, ttl uint32) *dns.SOA {
return &dns.SOA{
Hdr: dns.RR_Header{
Name: dns.Fqdn(name), Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl,
},
Ns: "localhost.",
Mbox: "hostmaster." + dns.Fqdn(name),
Serial: 1,
Refresh: 3600,
Retry: 600,
Expire: 86400,
Minttl: ttl,
}
}
// chaosReply answers version.bind and hostname.bind in the CHAOS class.
func (s *Server) chaosReply(req *dns.Msg, snap *runtimecfg.Snapshot) *dns.Msg {
q := req.Question[0]
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
if q.Qtype != dns.TypeTXT {
m.Rcode = dns.RcodeRefused
return m
}
name := strings.ToLower(q.Name)
if !snap.Settings.DNS.ExposeVersion {
// Revealing the software version by default only helps an attacker.
m.Rcode = dns.RcodeRefused
return m
}
var value string
switch name {
case "version.bind.", "version.server.":
value = version.Name + " " + version.Version
case "hostname.bind.", "id.server.":
value = s.hostname
default:
m.Rcode = dns.RcodeRefused
return m
}
m.Answer = append(m.Answer, &dns.TXT{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassCHAOS, Ttl: 0},
Txt: []string{value},
})
return m
}
// finalise applies EDNS to the response and truncates it if it will not fit in
// the client's UDP buffer.
func (s *Server) finalise(req, resp *dns.Msg, protocol string, snap *runtimecfg.Snapshot) {
resp.Compress = true
advertised := uint16(dns.MinMsgSize) // 512, the pre-EDNS limit
if opt := req.IsEdns0(); opt != nil && snap.Settings.DNS.EDNSEnabled {
clientSize := opt.UDPSize()
if clientSize < dns.MinMsgSize {
clientSize = dns.MinMsgSize
}
ourSize := uint16(snap.Settings.DNS.EDNSUDPSize)
if clientSize < ourSize {
advertised = clientSize
} else {
advertised = ourSize
}
// Echo an OPT record so the client knows we speak EDNS, mirroring the
// DO bit it asked for.
resp.SetEdns0(ourSize, opt.Do())
}
if protocol == "tcp" {
return // TCP carries up to 64 KiB; no truncation needed
}
maxUDP := uint16(snap.Settings.DNS.MaxUDPResponse)
if advertised < maxUDP {
maxUDP = advertised
}
if resp.Len() > int(maxUDP) {
resp.Truncate(int(maxUDP))
if resp.Truncated {
s.metrics.TruncatedResp.Add(1)
}
}
}
func errorReply(req *dns.Msg, rcode int) *dns.Msg {
m := new(dns.Msg)
m.SetRcode(req, rcode)
m.RecursionAvailable = true
return m
}
func requestDO(req *dns.Msg) bool {
opt := req.IsEdns0()
return opt != nil && opt.Do()
}
func qtypeName(req *dns.Msg) string {
if len(req.Question) == 0 {
return "NONE"
}
if s, ok := dns.TypeToString[req.Question[0].Qtype]; ok {
return s
}
return "TYPE" + strconv.Itoa(int(req.Question[0].Qtype))
}
+315
View File
@@ -0,0 +1,315 @@
package dnsengine
import (
"context"
"errors"
"fmt"
"log/slog"
"net/netip"
"os"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/metrics"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/querylog"
"github.com/owen/vibedns/internal/ratelimit"
"github.com/owen/vibedns/internal/resolver"
"github.com/owen/vibedns/internal/runtimecfg"
)
// Server runs the UDP and TCP DNS listeners.
type Server struct {
runtime *runtimecfg.Manager
cache *cache.Cache
resolver *resolver.Resolver
limiter *ratelimit.Limiter
metrics *metrics.Metrics
qlog *querylog.Logger
log *slog.Logger
hostname string
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
udp *dns.Server
tcp *dns.Server
running bool
udpAddr string
tcpAddr string
startErr chan error
wg sync.WaitGroup
}
// Options bundles the dependencies the DNS server needs.
type Options struct {
Runtime *runtimecfg.Manager
Cache *cache.Cache
Resolver *resolver.Resolver
Limiter *ratelimit.Limiter
Metrics *metrics.Metrics
QueryLog *querylog.Logger
Log *slog.Logger
}
// New creates a DNS server. Call Start to bind the listeners.
func New(opts Options) *Server {
host, err := os.Hostname()
if err != nil || host == "" {
host = "vibedns"
}
s := &Server{
runtime: opts.Runtime,
cache: opts.Cache,
resolver: opts.Resolver,
limiter: opts.Limiter,
metrics: opts.Metrics,
qlog: opts.QueryLog,
log: opts.Log,
hostname: host,
startErr: make(chan error, 2),
}
// Refreshing an ageing cache entry keeps popular names warm without the
// client ever waiting on the upstream.
s.cache.SetPrefetcher(s.prefetch)
return s
}
// Start binds both listeners. It returns once they are accepting queries, or
// with an error explaining which address could not be bound.
func (s *Server) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return errors.New("DNS server is already running")
}
s.ctx, s.cancel = context.WithCancel(ctx)
snap := s.runtime.Current()
s.udpAddr = snap.Settings.DNS.UDPListen
s.tcpAddr = snap.Settings.DNS.TCPListen
udpSize := snap.Settings.DNS.EDNSUDPSize
if udpSize < dns.MinMsgSize {
udpSize = dns.MinMsgSize
}
idle := time.Duration(snap.Settings.DNS.TCPIdleSeconds) * time.Second
ready := make(chan struct{}, 2)
s.udp = &dns.Server{
Addr: s.udpAddr,
Net: "udp",
Handler: s,
UDPSize: udpSize,
NotifyStartedFunc: func() { ready <- struct{}{} },
}
s.tcp = &dns.Server{
Addr: s.tcpAddr,
Net: "tcp",
Handler: s,
IdleTimeout: func() time.Duration { return idle },
ReadTimeout: idle + 2*time.Second,
NotifyStartedFunc: func() { ready <- struct{}{} },
}
errCh := make(chan error, 2)
s.wg.Add(2)
go func() {
defer s.wg.Done()
if err := s.udp.ListenAndServe(); err != nil {
errCh <- fmt.Errorf("DNS UDP listener on %s: %w", s.udpAddr, describeBindError(err, s.udpAddr))
}
}()
go func() {
defer s.wg.Done()
if err := s.tcp.ListenAndServe(); err != nil {
errCh <- fmt.Errorf("DNS TCP listener on %s: %w", s.tcpAddr, describeBindError(err, s.tcpAddr))
}
}()
// Wait for both listeners to report ready, or for one to fail.
started := 0
deadline := time.After(10 * time.Second)
for started < 2 {
select {
case <-ready:
started++
case err := <-errCh:
s.cancel()
return err
case <-deadline:
s.cancel()
return fmt.Errorf("DNS listeners did not become ready within 10 seconds")
}
}
s.running = true
s.log.Info("DNS listeners started", "udp", s.udpAddr, "tcp", s.tcpAddr)
// Surface a listener that dies later.
go func() {
select {
case err := <-errCh:
s.log.Error("DNS listener stopped unexpectedly", "error", err)
case <-s.ctx.Done():
}
}()
return nil
}
// describeBindError turns a raw bind failure into something actionable.
func describeBindError(err error, addr string) error {
msg := err.Error()
switch {
case strings.Contains(msg, "permission denied"):
return fmt.Errorf("%w (binding a port below 1024 needs root, or grant the "+
"binary CAP_NET_BIND_SERVICE with: setcap 'cap_net_bind_service=+ep' ./vibedns)", err)
case strings.Contains(msg, "address already in use"):
return fmt.Errorf("%w (another DNS server is already listening on %s; on many "+
"systems that is systemd-resolved, which can be disabled with: "+
"systemctl disable --now systemd-resolved)", err, addr)
default:
return err
}
}
// Shutdown stops both listeners.
func (s *Server) Shutdown(ctx context.Context) error {
s.mu.Lock()
udp, tcp, running := s.udp, s.tcp, s.running
s.running = false
s.mu.Unlock()
if !running {
return nil
}
if s.cancel != nil {
s.cancel()
}
var firstErr error
if udp != nil {
if err := udp.ShutdownContext(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
if tcp != nil {
if err := tcp.ShutdownContext(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
s.wg.Wait()
s.log.Info("DNS listeners stopped")
return firstErr
}
// Running reports whether the listeners are up.
func (s *Server) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// ListenAddrs returns the bound addresses for the status page.
func (s *Server) ListenAddrs() (udp, tcp string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.udpAddr, s.tcpAddr
}
// prefetch refreshes a cache entry in the background.
func (s *Server) prefetch(k cache.Key) {
snap := s.runtime.Current()
if !snap.Settings.DNS.Recursion {
return
}
ctx, cancel := context.WithTimeout(s.ctx, s.forwardBudget(snap))
defer cancel()
req := new(dns.Msg)
req.SetQuestion(k.Name, k.Type)
req.Question[0].Qclass = k.Class
req.RecursionDesired = true
if k.DO {
req.SetEdns0(uint16(snap.Settings.DNS.EDNSUDPSize), true)
}
res, err := s.resolver.Resolve(ctx, req)
if err != nil {
s.log.Debug("cache prefetch failed", "name", k.Name, "error", err)
return
}
s.cache.Put(k, res.Msg)
}
// Resolve performs a query through the full pipeline on behalf of the UI's
// "test a lookup" tool, without going over the network.
func (s *Server) Resolve(ctx context.Context, name string, qtype uint16, client netip.Addr, do bool) (*dns.Msg, string, error) {
snap := s.runtime.Current()
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(name), qtype)
req.RecursionDesired = true
if do {
req.SetEdns0(uint16(snap.Settings.DNS.EDNSUDPSize), true)
}
resp, out := s.respond(req, client, snap)
if resp == nil {
return nil, "", errors.New("no response was produced")
}
return resp, out.source, nil
}
// logQueryOutcome writes one query log entry.
func (s *Server) logQueryOutcome(req *dns.Msg, client netip.Addr, protocol string,
out outcome, snap *runtimecfg.Snapshot, elapsed time.Duration) {
if s.qlog == nil || !s.qlog.Enabled() {
return
}
q := req.Question[0]
rcode := "DROPPED"
if out.rcode >= 0 {
rcode = dns.RcodeToString[out.rcode]
}
e := models.QueryLogEntry{
Timestamp: time.Now(),
ClientIP: client.String(),
QName: strings.ToLower(dns.Fqdn(q.Name)),
QType: qtypeName(req),
Rcode: rcode,
Source: out.source,
CacheHit: out.cacheHit,
Blocked: out.blocked,
Protocol: protocol,
DurationUS: elapsed.Microseconds(),
AnswerCount: out.answerCount,
}
d := out.decision
e.NetworkID = d.NetworkID()
e.NetworkName = d.NetworkName()
e.PolicyID = d.PolicyID()
e.PolicyName = d.PolicyName()
if out.blocked {
e.BlacklistID = d.ListRef()
e.BlacklistName = d.ListName
e.MatchedRule = d.MatchedDomain
}
s.qlog.Log(e)
}
// logQuery records a query that never produced a response, such as one dropped
// by the rate limiter.
func (s *Server) logQuery(req *dns.Msg, client netip.Addr, protocol string,
out outcome, snap *runtimecfg.Snapshot, start time.Time) {
if len(req.Question) == 0 {
return
}
s.logQueryOutcome(req, client, protocol, out, snap, time.Since(start))
}
+336
View File
@@ -0,0 +1,336 @@
// Package metrics collects counters for the dashboard and renders them in the
// Prometheus text exposition format.
//
// The exposition format is small and stable, so it is written directly rather
// than pulling in the Prometheus client library and its dependency tree. The
// counters are plain atomics on the query path.
package metrics
import (
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// labelCounter is a set of counters keyed by a single label value.
type labelCounter struct {
mu sync.RWMutex
values map[string]*atomic.Int64
}
func newLabelCounter() *labelCounter {
return &labelCounter{values: map[string]*atomic.Int64{}}
}
func (c *labelCounter) Inc(label string) {
c.mu.RLock()
v, ok := c.values[label]
c.mu.RUnlock()
if ok {
v.Add(1)
return
}
c.mu.Lock()
if v, ok = c.values[label]; !ok {
v = &atomic.Int64{}
c.values[label] = v
}
c.mu.Unlock()
v.Add(1)
}
// Snapshot returns the counters sorted by label.
func (c *labelCounter) Snapshot() []LabelValue {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]LabelValue, 0, len(c.values))
for k, v := range c.values {
out = append(out, LabelValue{Label: k, Value: v.Load()})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Value != out[j].Value {
return out[i].Value > out[j].Value
}
return out[i].Label < out[j].Label
})
return out
}
// LabelValue is one labelled counter reading.
type LabelValue struct {
Label string `json:"label"`
Value int64 `json:"value"`
}
// histogram is a fixed-bucket latency histogram in seconds.
type histogram struct {
bounds []float64
counts []atomic.Int64
sum atomic.Uint64 // float64 bits
total atomic.Int64
}
func newHistogram(bounds []float64) *histogram {
return &histogram{bounds: bounds, counts: make([]atomic.Int64, len(bounds)+1)}
}
func (h *histogram) Observe(d time.Duration) {
v := d.Seconds()
i := sort.SearchFloat64s(h.bounds, v)
h.counts[i].Add(1)
h.total.Add(1)
for {
old := h.sum.Load()
nv := float64FromBits(old) + v
if h.sum.CompareAndSwap(old, float64ToBits(nv)) {
return
}
}
}
func (h *histogram) write(w io.Writer, name, help string) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s histogram\n", name, help, name)
var cumulative int64
for i, b := range h.bounds {
cumulative += h.counts[i].Load()
fmt.Fprintf(w, "%s_bucket{le=\"%s\"} %d\n", name, strconv.FormatFloat(b, 'g', -1, 64), cumulative)
}
cumulative += h.counts[len(h.bounds)].Load()
fmt.Fprintf(w, "%s_bucket{le=\"+Inf\"} %d\n", name, cumulative)
fmt.Fprintf(w, "%s_sum %s\n", name, strconv.FormatFloat(float64FromBits(h.sum.Load()), 'g', -1, 64))
fmt.Fprintf(w, "%s_count %d\n", name, h.total.Load())
}
// Mean returns the average observation in milliseconds.
func (h *histogram) Mean() float64 {
n := h.total.Load()
if n == 0 {
return 0
}
return float64FromBits(h.sum.Load()) / float64(n) * 1000
}
// The sum is kept as float64 bits inside an atomic so that Observe stays
// lock-free on the query path.
func float64ToBits(f float64) uint64 { return math.Float64bits(f) }
func float64FromBits(u uint64) float64 { return math.Float64frombits(u) }
// Gauges are values sampled at scrape time rather than counted incrementally.
type Gauges struct {
CacheEntries int64
CacheBytes int64
Zones int64
Records int64
BlacklistDomains int64
AllowlistDomains int64
Networks int64
UpstreamsHealthy int64
UpstreamsTotal int64
QueryLogRows int64
}
// Metrics holds every counter the application exports.
type Metrics struct {
start time.Time
QueriesTotal atomic.Int64
Authoritative atomic.Int64
Recursive atomic.Int64
CacheHits atomic.Int64
CacheMisses atomic.Int64
StaleServed atomic.Int64
Blocked atomic.Int64
Refused atomic.Int64
RateLimited atomic.Int64
Errors atomic.Int64
TruncatedResp atomic.Int64
UDPQueries atomic.Int64
TCPQueries atomic.Int64
ResolverQueries atomic.Int64
ResolverErrors atomic.Int64
byType *labelCounter
byRcode *labelCounter
bySource *labelCounter
queryDuration *histogram
resolverLatency *histogram
gaugeFn atomic.Value // func() Gauges
buildVersion string
}
// New creates a metrics registry.
func New(version string) *Metrics {
return &Metrics{
start: time.Now(),
byType: newLabelCounter(),
byRcode: newLabelCounter(),
bySource: newLabelCounter(),
buildVersion: version,
// Buckets chosen around the latencies a DNS server actually sees:
// sub-millisecond for cache and authoritative hits, tens of
// milliseconds for upstream queries.
queryDuration: newHistogram([]float64{
0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5,
}),
resolverLatency: newHistogram([]float64{
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5,
}),
}
}
// SetGaugeSource registers the callback used to sample gauges at scrape time.
func (m *Metrics) SetGaugeSource(fn func() Gauges) { m.gaugeFn.Store(fn) }
// Uptime returns how long the process has been serving.
func (m *Metrics) Uptime() time.Duration { return time.Since(m.start) }
// StartedAt returns the process start time.
func (m *Metrics) StartedAt() time.Time { return m.start }
// ObserveQuery records one completed query.
func (m *Metrics) ObserveQuery(qtype, rcode, source, protocol string, d time.Duration) {
m.QueriesTotal.Add(1)
m.byType.Inc(qtype)
m.byRcode.Inc(rcode)
m.bySource.Inc(source)
m.queryDuration.Observe(d)
if protocol == "tcp" {
m.TCPQueries.Add(1)
} else {
m.UDPQueries.Add(1)
}
}
// ObserveResolver records one upstream exchange.
func (m *Metrics) ObserveResolver(d time.Duration, err bool) {
m.ResolverQueries.Add(1)
if err {
m.ResolverErrors.Add(1)
return
}
m.resolverLatency.Observe(d)
}
// QueriesPerSecond returns the average query rate since start.
func (m *Metrics) QueriesPerSecond() float64 {
secs := time.Since(m.start).Seconds()
if secs < 1 {
secs = 1
}
return float64(m.QueriesTotal.Load()) / secs
}
// CacheHitRate returns the cache hit percentage.
func (m *Metrics) CacheHitRate() float64 {
h, ms := m.CacheHits.Load(), m.CacheMisses.Load()
if h+ms == 0 {
return 0
}
return float64(h) / float64(h+ms) * 100
}
// AvgQueryMS returns the mean query duration in milliseconds.
func (m *Metrics) AvgQueryMS() float64 { return m.queryDuration.Mean() }
// AvgResolverMS returns the mean upstream latency in milliseconds.
func (m *Metrics) AvgResolverMS() float64 { return m.resolverLatency.Mean() }
// ByType returns query counts per record type.
func (m *Metrics) ByType() []LabelValue { return m.byType.Snapshot() }
// ByRcode returns response counts per rcode.
func (m *Metrics) ByRcode() []LabelValue { return m.byRcode.Snapshot() }
// BySource returns response counts per answer source.
func (m *Metrics) BySource() []LabelValue { return m.bySource.Snapshot() }
// Reset zeroes every counter. Used by the "reset statistics" action.
func (m *Metrics) Reset() {
m.start = time.Now()
for _, c := range []*atomic.Int64{
&m.QueriesTotal, &m.Authoritative, &m.Recursive, &m.CacheHits, &m.CacheMisses,
&m.StaleServed, &m.Blocked, &m.Refused, &m.RateLimited, &m.Errors,
&m.TruncatedResp, &m.UDPQueries, &m.TCPQueries, &m.ResolverQueries, &m.ResolverErrors,
} {
c.Store(0)
}
m.byType = newLabelCounter()
m.byRcode = newLabelCounter()
m.bySource = newLabelCounter()
}
// WritePrometheus renders every metric in the Prometheus text format.
func (m *Metrics) WritePrometheus(w io.Writer) {
counter := func(name, help string, v int64) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", name, help, name, name, v)
}
gauge := func(name, help string, v any) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s gauge\n%s %v\n", name, help, name, name, v)
}
fmt.Fprintf(w, "# HELP vibedns_build_info Build information.\n# TYPE vibedns_build_info gauge\n")
fmt.Fprintf(w, "vibedns_build_info{version=%q} 1\n", escapeLabel(m.buildVersion))
gauge("vibedns_uptime_seconds", "Seconds since the DNS server started.",
strconv.FormatFloat(time.Since(m.start).Seconds(), 'f', 3, 64))
counter("vibedns_dns_queries_total", "Total DNS queries received.", m.QueriesTotal.Load())
counter("vibedns_dns_queries_authoritative_total", "Queries answered from a local authoritative zone.", m.Authoritative.Load())
counter("vibedns_dns_queries_recursive_total", "Queries answered by forwarding upstream.", m.Recursive.Load())
counter("vibedns_dns_queries_blocked_total", "Queries blocked by policy.", m.Blocked.Load())
counter("vibedns_dns_queries_refused_total", "Queries refused, mostly recursion denied by ACL.", m.Refused.Load())
counter("vibedns_dns_queries_ratelimited_total", "Queries dropped by the per-client rate limiter.", m.RateLimited.Load())
counter("vibedns_dns_errors_total", "Queries that ended in a server error.", m.Errors.Load())
counter("vibedns_dns_responses_truncated_total", "Responses truncated, prompting TCP retry.", m.TruncatedResp.Load())
counter("vibedns_dns_queries_udp_total", "Queries received over UDP.", m.UDPQueries.Load())
counter("vibedns_dns_queries_tcp_total", "Queries received over TCP.", m.TCPQueries.Load())
counter("vibedns_cache_hits_total", "Cache lookups that were served from cache.", m.CacheHits.Load())
counter("vibedns_cache_misses_total", "Cache lookups that missed.", m.CacheMisses.Load())
counter("vibedns_cache_stale_served_total", "Responses served from the stale cache window.", m.StaleServed.Load())
counter("vibedns_resolver_queries_total", "Queries forwarded to an upstream resolver.", m.ResolverQueries.Load())
counter("vibedns_resolver_errors_total", "Upstream resolver failures.", m.ResolverErrors.Load())
writeLabelled(w, "vibedns_dns_queries_by_type_total", "Queries by record type.", "type", m.byType.Snapshot())
writeLabelled(w, "vibedns_dns_responses_by_rcode_total", "Responses by rcode.", "rcode", m.byRcode.Snapshot())
writeLabelled(w, "vibedns_dns_responses_by_source_total", "Responses by answer source.", "source", m.bySource.Snapshot())
m.queryDuration.write(w, "vibedns_dns_query_duration_seconds", "End-to-end query handling time.")
m.resolverLatency.write(w, "vibedns_resolver_latency_seconds", "Upstream resolver round-trip time.")
if fn, ok := m.gaugeFn.Load().(func() Gauges); ok && fn != nil {
g := fn()
gauge("vibedns_cache_entries", "Entries currently held in the resolver cache.", g.CacheEntries)
gauge("vibedns_cache_bytes", "Estimated memory used by the resolver cache.", g.CacheBytes)
gauge("vibedns_zones", "Configured authoritative zones.", g.Zones)
gauge("vibedns_records", "Configured resource records.", g.Records)
gauge("vibedns_blacklist_domains", "Domains across all blacklists.", g.BlacklistDomains)
gauge("vibedns_allowlist_domains", "Domains across all allowlists.", g.AllowlistDomains)
gauge("vibedns_client_networks", "Configured client networks.", g.Networks)
gauge("vibedns_resolver_upstreams", "Configured upstream resolvers.", g.UpstreamsTotal)
gauge("vibedns_resolver_upstreams_healthy", "Upstream resolvers currently considered healthy.", g.UpstreamsHealthy)
gauge("vibedns_query_log_rows", "Rows currently stored in the query log.", g.QueryLogRows)
}
}
func writeLabelled(w io.Writer, name, help, label string, values []LabelValue) {
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n", name, help, name)
for _, v := range values {
fmt.Fprintf(w, "%s{%s=%q} %d\n", name, label, escapeLabel(v.Label), v.Value)
}
}
func escapeLabel(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
return s
}
+268
View File
@@ -0,0 +1,268 @@
// Package models holds the plain data types shared between the storage layer,
// the DNS engine and the HTTP layer. Nothing here depends on a database or on
// the DNS library, which keeps the type definitions usable from every package.
package models
import "time"
// ZoneKind distinguishes forward lookup zones from the two reverse families.
type ZoneKind string
const (
ZoneForward ZoneKind = "forward"
ZoneReverse4 ZoneKind = "reverse4"
ZoneReverse6 ZoneKind = "reverse6"
)
// Valid reports whether k is a kind the application understands.
func (k ZoneKind) Valid() bool {
switch k {
case ZoneForward, ZoneReverse4, ZoneReverse6:
return true
}
return false
}
// Label returns a human readable name for the zone kind.
func (k ZoneKind) Label() string {
switch k {
case ZoneReverse4:
return "Reverse IPv4"
case ZoneReverse6:
return "Reverse IPv6"
default:
return "Forward"
}
}
// Zone is an authoritative DNS zone.
type Zone struct {
ID int64 `json:"id"`
Name string `json:"name"` // normalised FQDN, always ends in "."
Kind ZoneKind `json:"kind"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
DefaultTTL uint32 `json:"default_ttl"`
PrimaryNS string `json:"primary_ns"`
AdminEmail string `json:"admin_email"`
Serial uint32 `json:"serial"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
Minimum uint32 `json:"minimum"`
AutoSerial bool `json:"auto_serial"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// RecordCount is populated by list queries; it is not stored.
RecordCount int `json:"record_count,omitempty"`
}
// Record is a single resource record inside a zone. Name is stored relative to
// the zone apex ("@" for the apex itself) and Data holds the rdata in the usual
// zone-file presentation format.
type Record struct {
ID int64 `json:"id"`
ZoneID int64 `json:"zone_id"`
Name string `json:"name"`
Type string `json:"type"`
Data string `json:"data"`
TTL *uint32 `json:"ttl"` // nil means "inherit the zone default"
Enabled bool `json:"enabled"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// ZoneName is filled in by cross-zone queries for display purposes.
ZoneName string `json:"zone_name,omitempty"`
}
// EffectiveTTL resolves the record TTL against the zone default.
func (r Record) EffectiveTTL(zoneDefault uint32) uint32 {
if r.TTL != nil {
return *r.TTL
}
return zoneDefault
}
// BlockAction is what a policy does with a query that matched a blacklist.
type BlockAction string
const (
BlockNXDOMAIN BlockAction = "nxdomain"
BlockRefused BlockAction = "refused"
BlockSinkhole BlockAction = "sinkhole"
)
// Valid reports whether a is a supported block action.
func (a BlockAction) Valid() bool {
switch a {
case BlockNXDOMAIN, BlockRefused, BlockSinkhole:
return true
}
return false
}
// Label returns a display name for the block action.
func (a BlockAction) Label() string {
switch a {
case BlockRefused:
return "REFUSED"
case BlockSinkhole:
return "Sinkhole"
default:
return "NXDOMAIN"
}
}
// Network is a client subnet that policies can be attached to.
type Network struct {
ID int64 `json:"id"`
Name string `json:"name"`
CIDR string `json:"cidr"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Policies []Policy `json:"policies,omitempty"`
}
// Policy groups a set of blacklists and allowlists with a block action.
type Policy struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
BlockAction BlockAction `json:"block_action"`
SinkholeIPv4 string `json:"sinkhole_ipv4"`
SinkholeIPv6 string `json:"sinkhole_ipv6"`
BlockTTL uint32 `json:"block_ttl"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
BlacklistIDs []int64 `json:"blacklist_ids,omitempty"`
AllowlistIDs []int64 `json:"allowlist_ids,omitempty"`
NetworkCount int `json:"network_count,omitempty"`
BlacklistName []string `json:"blacklist_names,omitempty"`
AllowlistName []string `json:"allowlist_names,omitempty"`
}
// DomainList is either a blacklist or an allowlist; the Kind field says which.
type DomainList struct {
ID int64 `json:"id"`
Kind string `json:"kind"` // "blacklist" or "allowlist"
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
SourceURL string `json:"source_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DomainCount int `json:"domain_count"`
UsedBy []string `json:"used_by,omitempty"`
}
// List kinds.
const (
KindBlacklist = "blacklist"
KindAllowlist = "allowlist"
)
// DomainEntry is one domain inside a DomainList.
type DomainEntry struct {
ID int64 `json:"id"`
ListID int64 `json:"list_id"`
Domain string `json:"domain"`
MatchSubdomains bool `json:"match_subdomains"`
Enabled bool `json:"enabled"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
}
// APIToken is a bearer credential for automation. The secret itself is never
// stored: only a prefix (for lookup) and an Argon2id hash.
type APIToken struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Prefix string `json:"prefix"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
// Secret is populated only in the response that creates the token.
Secret string `json:"secret,omitempty"`
}
// Admin is the single administrator account.
type Admin struct {
Username string `json:"username"`
PasswordHash string `json:"-"`
MustChangePassword bool `json:"must_change_password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at"`
}
// QueryLogEntry is one logged DNS query.
type QueryLogEntry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
ClientIP string `json:"client_ip"`
NetworkID *int64 `json:"network_id"`
NetworkName string `json:"network_name"`
QName string `json:"qname"`
QType string `json:"qtype"`
Rcode string `json:"rcode"`
Source string `json:"source"`
CacheHit bool `json:"cache_hit"`
Blocked bool `json:"blocked"`
PolicyID *int64 `json:"policy_id"`
PolicyName string `json:"policy_name"`
BlacklistID *int64 `json:"blacklist_id"`
BlacklistName string `json:"blacklist_name"`
MatchedRule string `json:"matched_rule"`
Protocol string `json:"protocol"`
DurationUS int64 `json:"duration_us"`
AnswerCount int `json:"answer_count"`
}
// DurationMS renders the query duration in milliseconds for the UI.
func (q QueryLogEntry) DurationMS() float64 { return float64(q.DurationUS) / 1000.0 }
// Answer source values recorded in the query log and exported in metrics.
const (
SourceAuthoritative = "authoritative"
SourceCache = "cache"
SourceStale = "stale"
SourceRecursive = "recursive"
SourceBlocked = "blocked"
SourceRefused = "refused"
SourceRateLimited = "ratelimited"
SourceError = "error"
SourceLocal = "local"
)
// AuditEntry records one administrative change.
type AuditEntry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
Actor string `json:"actor"`
Source string `json:"source"` // web | api | cli | system
ClientIP string `json:"client_ip"`
Action string `json:"action"`
ObjectType string `json:"object_type"`
ObjectID string `json:"object_id"`
ObjectName string `json:"object_name"`
Details string `json:"details"`
}
// ImportSummary reports the outcome of a bulk domain import.
type ImportSummary struct {
LinesProcessed int `json:"lines_processed"`
Imported int `json:"imported"`
Duplicates int `json:"duplicates"`
Invalid int `json:"invalid"`
Ignored int `json:"ignored"` // comments and blank lines
InvalidSamples []string `json:"invalid_samples,omitempty"`
}
+120
View File
@@ -0,0 +1,120 @@
// Package netutil holds small address helpers shared by the DNS server, the
// rate limiter and the HTTP layer.
package netutil
import (
"net"
"net/netip"
"strings"
)
// PrefixSet answers "is this address in one of these networks?".
//
// It backs rate-limit exemptions, query-log ignore lists and trusted-proxy
// configuration. Invalid entries are skipped rather than rejected: these lists
// are conveniences, and the security-critical recursion ACL uses a stricter
// constructor that reports errors.
type PrefixSet struct {
prefixes []netip.Prefix
}
// NewPrefixSet compiles a list of CIDR blocks or bare addresses.
func NewPrefixSet(in []string) *PrefixSet {
s := &PrefixSet{}
for _, v := range in {
if p, ok := ParsePrefix(v); ok {
s.prefixes = append(s.prefixes, p)
}
}
return s
}
// ParsePrefix accepts a CIDR block or a bare address, returning a masked
// prefix. A bare address becomes a host route.
func ParsePrefix(v string) (netip.Prefix, bool) {
v = strings.TrimSpace(v)
if v == "" {
return netip.Prefix{}, false
}
if strings.Contains(v, "/") {
p, err := netip.ParsePrefix(v)
if err != nil {
return netip.Prefix{}, false
}
return p.Masked(), true
}
addr, err := netip.ParseAddr(v)
if err != nil {
return netip.Prefix{}, false
}
a := addr.Unmap()
return netip.PrefixFrom(a, a.BitLen()), true
}
// Contains reports whether addr falls inside the set.
func (s *PrefixSet) Contains(addr netip.Addr) bool {
if s == nil || len(s.prefixes) == 0 {
return false
}
ip := addr.Unmap()
for _, p := range s.prefixes {
if p.Addr().Is4() == ip.Is4() && p.Contains(ip) {
return true
}
}
return false
}
// Empty reports whether the set has no usable entries.
func (s *PrefixSet) Empty() bool { return s == nil || len(s.prefixes) == 0 }
// Len reports how many prefixes the set holds.
func (s *PrefixSet) Len() int {
if s == nil {
return 0
}
return len(s.prefixes)
}
// AddrFromNetAddr extracts the IP from a net.Addr, which is how both the DNS
// listeners and net/http hand us the client address.
func AddrFromNetAddr(a net.Addr) netip.Addr {
switch v := a.(type) {
case *net.UDPAddr:
if addr, ok := netip.AddrFromSlice(v.IP); ok {
return addr.Unmap()
}
case *net.TCPAddr:
if addr, ok := netip.AddrFromSlice(v.IP); ok {
return addr.Unmap()
}
}
if a == nil {
return netip.Addr{}
}
host, _, err := net.SplitHostPort(a.String())
if err != nil {
host = a.String()
}
addr, err := netip.ParseAddr(host)
if err != nil {
return netip.Addr{}
}
return addr.Unmap()
}
// AddrFromHostPort parses a "host:port" or bare-host string into an address.
func AddrFromHostPort(s string) (netip.Addr, bool) {
s = strings.TrimSpace(s)
if s == "" {
return netip.Addr{}, false
}
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
addr, err := netip.ParseAddr(strings.Trim(s, "[]"))
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
+298
View File
@@ -0,0 +1,298 @@
// Package policy decides what happens to a query based on where it came from.
//
// A client address is matched to the most specific configured network, the
// policies attached to that network are consulted, and the query name is
// checked against their allowlists and then their blacklists. Allowlists always
// win, so an operator can carve an exception out of a large imported blocklist
// without editing it.
package policy
import (
"net/netip"
"sort"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/models"
)
// Policy is a compiled policy: an action plus the lists it consults.
type Policy struct {
ID int64
Name string
Action models.BlockAction
SinkholeV4 netip.Addr
SinkholeV6 netip.Addr
TTL uint32
Blacklists []*blacklist.Set
Allowlists []*blacklist.Set
}
// Network is a compiled client network with its policies attached.
type Network struct {
ID int64
Name string
Prefix netip.Prefix
Policies []*Policy
}
// Index is the immutable policy lookup structure.
type Index struct {
// networks is sorted most-specific first so the first containing prefix
// found is the right one.
networks []*Network
policies map[int64]*Policy
lists map[int64]*blacklist.Set
}
// Decision is the outcome of evaluating a query against the policy set.
type Decision struct {
Network *Network
Policy *Policy
Blocked bool
Allowed bool // an allowlist explicitly permitted the name
ListID int64
ListName string
MatchedDomain string
}
// Action returns the block action to apply, defaulting to NXDOMAIN.
func (d Decision) Action() models.BlockAction {
if d.Policy == nil || !d.Policy.Action.Valid() {
return models.BlockNXDOMAIN
}
return d.Policy.Action
}
// NetworkID returns the matched network ID, or nil when no network matched.
func (d Decision) NetworkID() *int64 {
if d.Network == nil {
return nil
}
id := d.Network.ID
return &id
}
// NetworkName returns the matched network name, or "".
func (d Decision) NetworkName() string {
if d.Network == nil {
return ""
}
return d.Network.Name
}
// PolicyID returns the matched policy ID, or nil.
func (d Decision) PolicyID() *int64 {
if d.Policy == nil {
return nil
}
id := d.Policy.ID
return &id
}
// PolicyName returns the matched policy name, or "".
func (d Decision) PolicyName() string {
if d.Policy == nil {
return ""
}
return d.Policy.Name
}
// ListRef returns the matched list ID, or nil.
func (d Decision) ListRef() *int64 {
if d.ListID == 0 {
return nil
}
id := d.ListID
return &id
}
// Build compiles the policy index from stored configuration.
//
// lists maps a domain-list ID to its compiled matcher. Sets are shared by
// pointer between policies, so a 200,000 domain blocklist used by five
// policies is held in memory exactly once.
func Build(networks []models.Network, assignments map[int64][]int64,
policies []models.Policy, lists map[int64]*blacklist.Set) *Index {
idx := &Index{
policies: make(map[int64]*Policy, len(policies)),
lists: lists,
}
for _, mp := range policies {
if !mp.Enabled {
continue
}
p := &Policy{
ID: mp.ID,
Name: mp.Name,
Action: mp.BlockAction,
TTL: mp.BlockTTL,
}
if p.TTL == 0 {
p.TTL = 60
}
if a, err := netip.ParseAddr(mp.SinkholeIPv4); err == nil && a.Is4() {
p.SinkholeV4 = a
}
if a, err := netip.ParseAddr(mp.SinkholeIPv6); err == nil && !a.Is4() {
p.SinkholeV6 = a
}
for _, id := range mp.BlacklistIDs {
if s, ok := lists[id]; ok && s.Len() > 0 {
p.Blacklists = append(p.Blacklists, s)
}
}
for _, id := range mp.AllowlistIDs {
if s, ok := lists[id]; ok && s.Len() > 0 {
p.Allowlists = append(p.Allowlists, s)
}
}
idx.policies[p.ID] = p
}
for _, mn := range networks {
if !mn.Enabled {
continue
}
prefix, err := parsePrefix(mn.CIDR)
if err != nil {
continue // validation happens on save; skip unusable rows here
}
n := &Network{ID: mn.ID, Name: mn.Name, Prefix: prefix}
for _, pid := range assignments[mn.ID] {
if p, ok := idx.policies[pid]; ok {
n.Policies = append(n.Policies, p)
}
}
idx.networks = append(idx.networks, n)
}
// Most specific prefix first; ties broken by name for deterministic output.
sort.SliceStable(idx.networks, func(i, j int) bool {
a, b := idx.networks[i], idx.networks[j]
if a.Prefix.Bits() != b.Prefix.Bits() {
return a.Prefix.Bits() > b.Prefix.Bits()
}
return a.Name < b.Name
})
return idx
}
func parsePrefix(s string) (netip.Prefix, error) {
p, err := netip.ParsePrefix(s)
if err != nil {
addr, aerr := netip.ParseAddr(s)
if aerr != nil {
return netip.Prefix{}, err
}
return netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen()), nil
}
return p.Masked(), nil
}
// MatchNetwork returns the most specific network containing addr, or nil.
func (idx *Index) MatchNetwork(addr netip.Addr) *Network {
if idx == nil {
return nil
}
a := addr.Unmap()
for _, n := range idx.networks {
if n.Prefix.Addr().Is4() != a.Is4() {
continue
}
if n.Prefix.Contains(a) {
return n
}
}
return nil
}
// Evaluate decides whether a query from addr for qname should be blocked.
//
// qname may carry a trailing dot and any casing.
func (idx *Index) Evaluate(addr netip.Addr, qname string) Decision {
if idx == nil {
return Decision{}
}
n := idx.MatchNetwork(addr)
if n == nil || len(n.Policies) == 0 {
return Decision{Network: n}
}
d := Decision{Network: n}
// Allowlists are consulted across every policy on the network first, so an
// exception in one policy cannot be defeated by a blocklist in another.
for _, p := range n.Policies {
for _, set := range p.Allowlists {
if matched, ok := set.Match(qname); ok {
d.Allowed = true
d.Policy = p
d.ListID = set.ID
d.ListName = set.Name
d.MatchedDomain = matched
return d
}
}
}
for _, p := range n.Policies {
for _, set := range p.Blacklists {
if matched, ok := set.Match(qname); ok {
d.Blocked = true
d.Policy = p
d.ListID = set.ID
d.ListName = set.Name
d.MatchedDomain = matched
return d
}
}
}
return d
}
// Networks returns the compiled networks, most specific first.
func (idx *Index) Networks() []*Network {
if idx == nil {
return nil
}
return idx.networks
}
// Sets returns the compiled domain lists keyed by list ID. It backs the
// "which lists cover this name?" diagnostic in the UI.
func (idx *Index) Sets() map[int64]*blacklist.Set {
if idx == nil {
return nil
}
return idx.lists
}
// Stats summarises the compiled index for the dashboard.
type Stats struct {
Networks int `json:"networks"`
Policies int `json:"policies"`
Lists int `json:"lists"`
BlockedDomains int64 `json:"blocked_domains"`
AllowedDomains int64 `json:"allowed_domains"`
}
// Stats computes counts over the compiled index.
func (idx *Index) Stats() Stats {
s := Stats{}
if idx == nil {
return s
}
s.Networks = len(idx.networks)
s.Policies = len(idx.policies)
s.Lists = len(idx.lists)
for _, set := range idx.lists {
if set.Kind == models.KindAllowlist {
s.AllowedDomains += int64(set.Len())
} else {
s.BlockedDomains += int64(set.Len())
}
}
return s
}
+229
View File
@@ -0,0 +1,229 @@
package policy
import (
"net/netip"
"testing"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/models"
)
func buildSet(id int64, name, kind string, domains map[string]bool) *blacklist.Set {
b := blacklist.NewBuilder(id, name, kind, len(domains))
for d, sub := range domains {
b.Add(d, sub)
}
return b.Build()
}
// testIndex mirrors the example in the brief: a guest network with three
// blacklists, a secure LAN with malware only, and a global allowlist.
func testIndex(t *testing.T) *Index {
t.Helper()
malware := buildSet(1, "Malware", models.KindBlacklist, map[string]bool{
"evil.example": true,
"c2.example.net": true,
})
adult := buildSet(2, "Adult Content", models.KindBlacklist, map[string]bool{
"adult.example": true,
})
gambling := buildSet(3, "Gambling", models.KindBlacklist, map[string]bool{
"bet.example": true,
})
allow := buildSet(4, "Global Allowlist", models.KindAllowlist, map[string]bool{
"safe.adult.example": false, // exact match only
})
lists := map[int64]*blacklist.Set{1: malware, 2: adult, 3: gambling, 4: allow}
policies := []models.Policy{
{
ID: 10, Name: "Guest Filtering", Enabled: true,
BlockAction: models.BlockNXDOMAIN, BlockTTL: 60,
BlacklistIDs: []int64{1, 2, 3}, AllowlistIDs: []int64{4},
SinkholeIPv4: "0.0.0.0", SinkholeIPv6: "::",
},
{
ID: 11, Name: "Malware Only", Enabled: true,
BlockAction: models.BlockSinkhole, BlockTTL: 30,
BlacklistIDs: []int64{1},
SinkholeIPv4: "192.0.2.1", SinkholeIPv6: "2001:db8::1",
},
{
ID: 12, Name: "Disabled Policy", Enabled: false,
BlockAction: models.BlockRefused, BlacklistIDs: []int64{1, 2, 3},
},
}
networks := []models.Network{
{ID: 100, Name: "Guest Wi-Fi", CIDR: "100.64.30.0/24", Enabled: true},
{ID: 101, Name: "SecureLAN", CIDR: "100.64.10.0/24", Enabled: true},
{ID: 102, Name: "Broad", CIDR: "100.64.0.0/16", Enabled: true},
{ID: 103, Name: "Disabled Net", CIDR: "10.9.0.0/16", Enabled: false},
{ID: 104, Name: "IPv6 LAN", CIDR: "2001:db8:1::/48", Enabled: true},
}
assignments := map[int64][]int64{
100: {10},
101: {11},
102: {12}, // only a disabled policy
103: {10},
104: {11},
}
return Build(networks, assignments, policies, lists)
}
func addr(t *testing.T, s string) netip.Addr {
t.Helper()
a, err := netip.ParseAddr(s)
if err != nil {
t.Fatalf("bad test address %q: %v", s, err)
}
return a
}
func TestMatchNetworkPrefersMostSpecific(t *testing.T) {
idx := testIndex(t)
tests := []struct {
ip string
want string
}{
{"100.64.30.5", "Guest Wi-Fi"}, // /24 beats the enclosing /16
{"100.64.10.5", "SecureLAN"},
{"100.64.99.5", "Broad"}, // only the /16 covers it
{"10.9.0.1", ""}, // network is disabled
{"203.0.113.1", ""}, // no network covers it
{"2001:db8:1::5", "IPv6 LAN"},
}
for _, tc := range tests {
t.Run(tc.ip, func(t *testing.T) {
n := idx.MatchNetwork(addr(t, tc.ip))
got := ""
if n != nil {
got = n.Name
}
if got != tc.want {
t.Errorf("network for %s = %q, want %q", tc.ip, got, tc.want)
}
})
}
}
func TestEvaluateBlocking(t *testing.T) {
idx := testIndex(t)
tests := []struct {
name string
client string
qname string
wantBlocked bool
wantList string
wantAction models.BlockAction
}{
{"guest blocked by malware", "100.64.30.5", "evil.example", true, "Malware", models.BlockNXDOMAIN},
{"guest blocked by adult", "100.64.30.5", "adult.example", true, "Adult Content", models.BlockNXDOMAIN},
{"guest blocked by gambling", "100.64.30.5", "bet.example", true, "Gambling", models.BlockNXDOMAIN},
{"guest subdomain blocked", "100.64.30.5", "www.adult.example", true, "Adult Content", models.BlockNXDOMAIN},
{"guest deep subdomain blocked", "100.64.30.5", "a.b.c.adult.example", true, "Adult Content", models.BlockNXDOMAIN},
{"guest clean name allowed", "100.64.30.5", "example.org", false, "", ""},
{"lan blocked by malware", "100.64.10.5", "evil.example", true, "Malware", models.BlockSinkhole},
{"lan not filtered for adult", "100.64.10.5", "adult.example", false, "", ""},
{"lan not filtered for gambling", "100.64.10.5", "bet.example", false, "", ""},
{"disabled policy filters nothing", "100.64.99.5", "evil.example", false, "", ""},
{"unknown client is unfiltered", "203.0.113.1", "evil.example", false, "", ""},
{"ipv6 client uses its policy", "2001:db8:1::5", "c2.example.net", true, "Malware", models.BlockSinkhole},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := idx.Evaluate(addr(t, tc.client), tc.qname)
if d.Blocked != tc.wantBlocked {
t.Fatalf("blocked = %v, want %v", d.Blocked, tc.wantBlocked)
}
if !tc.wantBlocked {
return
}
if d.ListName != tc.wantList {
t.Errorf("list = %q, want %q", d.ListName, tc.wantList)
}
if d.Action() != tc.wantAction {
t.Errorf("action = %q, want %q", d.Action(), tc.wantAction)
}
})
}
}
// TestAllowlistOverridesBlacklist is the rule that lets an operator carve an
// exception out of a large imported blocklist without editing it.
func TestAllowlistOverridesBlacklist(t *testing.T) {
idx := testIndex(t)
client := addr(t, "100.64.30.5")
// safe.adult.example is on the allowlist even though adult.example (and
// therefore all of its subdomains) is blacklisted.
d := idx.Evaluate(client, "safe.adult.example")
if d.Blocked {
t.Errorf("allowlisted name was blocked by %q", d.ListName)
}
if !d.Allowed {
t.Error("expected the decision to record an explicit allow")
}
// The allowlist entry is exact-only, so a sibling stays blocked.
if d := idx.Evaluate(client, "other.adult.example"); !d.Blocked {
t.Error("an exact-only allowlist entry must not cover sibling names")
}
}
func TestQueryNameNormalisation(t *testing.T) {
idx := testIndex(t)
client := addr(t, "100.64.30.5")
for _, name := range []string{"evil.example", "evil.example.", "EVIL.EXAMPLE", "Evil.Example."} {
if d := idx.Evaluate(client, name); !d.Blocked {
t.Errorf("%q was not blocked; names must match regardless of case or trailing dot", name)
}
}
}
func TestIPv4MappedClientAddress(t *testing.T) {
idx := testIndex(t)
// A UDP socket on a dual-stack listener reports IPv4 clients in the
// ::ffff:a.b.c.d form; it must still match an IPv4 network.
mapped := netip.MustParseAddr("::ffff:100.64.30.5")
n := idx.MatchNetwork(mapped)
if n == nil || n.Name != "Guest Wi-Fi" {
t.Errorf("IPv4-mapped address matched %v, want Guest Wi-Fi", n)
}
}
func TestStats(t *testing.T) {
idx := testIndex(t)
s := idx.Stats()
if s.Networks != 4 {
t.Errorf("networks = %d, want 4 enabled", s.Networks)
}
if s.Policies != 2 {
t.Errorf("policies = %d, want 2 enabled", s.Policies)
}
if s.BlockedDomains != 4 {
t.Errorf("blocked domains = %d, want 4", s.BlockedDomains)
}
if s.AllowedDomains != 1 {
t.Errorf("allowed domains = %d, want 1", s.AllowedDomains)
}
}
func TestNilIndexIsSafe(t *testing.T) {
var idx *Index
if d := idx.Evaluate(netip.MustParseAddr("192.0.2.1"), "example.com"); d.Blocked {
t.Error("a nil index must not block anything")
}
if idx.MatchNetwork(netip.MustParseAddr("192.0.2.1")) != nil {
t.Error("a nil index must match no network")
}
}
+240
View File
@@ -0,0 +1,240 @@
// Package querylog buffers DNS query records in memory and flushes them to
// SQLite in batches.
//
// Logging must never slow a query down or block on disk, so Log() is a
// non-blocking send onto a bounded channel: if the writer falls behind, records
// are dropped and counted rather than backing up into the resolver.
package querylog
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/netutil"
)
// Config controls query logging.
type Config struct {
Enabled bool
RetentionDays int
MaxRows int
CleanupMinutes int
IgnoreNetworks []string
IgnoreDomains []string
}
const (
bufferSize = 8192
batchSize = 512
flushInterval = time.Second
)
// Logger writes query log entries to the database.
type Logger struct {
db *database.DB
log *slog.Logger
ch chan models.QueryLogEntry
mu sync.RWMutex
enabled bool
ignoreNetworks *netutil.PrefixSet
ignoreDomains *blacklist.Set
retentionDays int
maxRows int
cleanupMinutes int
written atomic.Int64
dropped atomic.Int64
pruned atomic.Int64
wg sync.WaitGroup
once sync.Once
}
// New creates a query logger. Call Start to begin draining the buffer.
func New(db *database.DB, log *slog.Logger, cfg Config) *Logger {
l := &Logger{
db: db,
log: log,
ch: make(chan models.QueryLogEntry, bufferSize),
}
l.SetConfig(cfg)
return l
}
// SetConfig replaces the logging configuration.
func (l *Logger) SetConfig(cfg Config) {
b := blacklist.NewBuilder(0, "querylog-ignore", models.KindBlacklist, len(cfg.IgnoreDomains))
for _, d := range cfg.IgnoreDomains {
b.Add(d, true) // ignoring a domain ignores its subdomains too
}
l.mu.Lock()
l.enabled = cfg.Enabled
l.ignoreNetworks = netutil.NewPrefixSet(cfg.IgnoreNetworks)
l.ignoreDomains = b.Build()
l.retentionDays = cfg.RetentionDays
l.maxRows = cfg.MaxRows
l.cleanupMinutes = cfg.CleanupMinutes
l.mu.Unlock()
}
// Enabled reports whether logging is currently on.
func (l *Logger) Enabled() bool {
l.mu.RLock()
defer l.mu.RUnlock()
return l.enabled
}
// Log queues one entry. It never blocks: a full buffer means the writer cannot
// keep up, and dropping is preferable to delaying DNS responses.
func (l *Logger) Log(e models.QueryLogEntry) {
l.mu.RLock()
enabled := l.enabled
ignoreNets := l.ignoreNetworks
ignoreDoms := l.ignoreDomains
l.mu.RUnlock()
if !enabled {
return
}
if !ignoreNets.Empty() {
if addr, ok := netutil.AddrFromHostPort(e.ClientIP); ok && ignoreNets.Contains(addr) {
return
}
}
if ignoreDoms.Len() > 0 {
if _, matched := ignoreDoms.Match(e.QName); matched {
return
}
}
select {
case l.ch <- e:
default:
l.dropped.Add(1)
}
}
// Start launches the writer and the retention cleaner.
func (l *Logger) Start(ctx context.Context) {
l.once.Do(func() {
l.wg.Add(2)
go l.writeLoop(ctx)
go l.cleanupLoop(ctx)
})
}
// Stop waits for the writer to drain and exit.
func (l *Logger) Stop() { l.wg.Wait() }
func (l *Logger) writeLoop(ctx context.Context) {
defer l.wg.Done()
batch := make([]models.QueryLogEntry, 0, batchSize)
t := time.NewTicker(flushInterval)
defer t.Stop()
flush := func() {
if len(batch) == 0 {
return
}
// Use a detached context so a shutdown does not discard buffered rows.
wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
if err := l.db.InsertQueryLogs(wctx, batch); err != nil {
l.log.Error("could not write query log batch", "error", err, "rows", len(batch))
} else {
l.written.Add(int64(len(batch)))
}
cancel()
batch = batch[:0]
}
for {
select {
case <-ctx.Done():
// Drain whatever is still queued before exiting.
for {
select {
case e := <-l.ch:
batch = append(batch, e)
if len(batch) >= batchSize {
flush()
}
default:
flush()
return
}
}
case e := <-l.ch:
batch = append(batch, e)
if len(batch) >= batchSize {
flush()
}
case <-t.C:
flush()
}
}
}
func (l *Logger) cleanupLoop(ctx context.Context) {
defer l.wg.Done()
for {
l.mu.RLock()
every := time.Duration(l.cleanupMinutes) * time.Minute
l.mu.RUnlock()
if every <= 0 {
every = 30 * time.Minute
}
select {
case <-ctx.Done():
return
case <-time.After(every):
l.Prune(ctx)
}
}
}
// Prune enforces the retention policy immediately.
func (l *Logger) Prune(ctx context.Context) int64 {
l.mu.RLock()
days, rows := l.retentionDays, l.maxRows
l.mu.RUnlock()
if days <= 0 && rows <= 0 {
return 0
}
n, err := l.db.PruneQueryLogs(ctx, days, rows)
if err != nil {
l.log.Error("could not prune query log", "error", err)
return 0
}
if n > 0 {
l.pruned.Add(n)
l.log.Debug("pruned query log", "rows", n)
}
return n
}
// Stats reports logger activity.
type Stats struct {
Enabled bool `json:"enabled"`
Written int64 `json:"written"`
Dropped int64 `json:"dropped"`
Pruned int64 `json:"pruned"`
Buffered int `json:"buffered"`
}
// Stats returns the logger counters.
func (l *Logger) Stats() Stats {
return Stats{
Enabled: l.Enabled(),
Written: l.written.Load(),
Dropped: l.dropped.Load(),
Pruned: l.pruned.Load(),
Buffered: len(l.ch),
}
}
+204
View File
@@ -0,0 +1,204 @@
// Package ratelimit provides per-client token buckets for DNS abuse
// protection.
//
// The limiter is sharded by client address so that a busy resolver does not
// serialise every query behind one mutex, and idle buckets are swept
// periodically so a flood of unique source addresses cannot grow the map
// without bound.
package ratelimit
import (
"hash/maphash"
"net/netip"
"sync"
"sync/atomic"
"time"
"github.com/owen/vibedns/internal/netutil"
)
const shardCount = 32
// Config controls the limiter.
type Config struct {
Enabled bool
QPS int
Burst int
Exempt []string
}
// bucket is a token bucket that refills continuously.
type bucket struct {
tokens float64
lastFill time.Time
lastSeen time.Time
}
type shard struct {
mu sync.Mutex
buckets map[netip.Addr]*bucket
}
// Limiter enforces a per-client query rate.
type Limiter struct {
shards [shardCount]*shard
seed maphash.Seed
mu sync.RWMutex
enabled bool
qps float64
burst float64
exempt *netutil.PrefixSet
allowed atomic.Int64
denied atomic.Int64
clients atomic.Int64
}
// New creates a limiter.
func New(cfg Config) *Limiter {
l := &Limiter{seed: maphash.MakeSeed()}
for i := range l.shards {
l.shards[i] = &shard{buckets: map[netip.Addr]*bucket{}}
}
l.SetConfig(cfg)
return l
}
// SetConfig replaces the limiter configuration.
func (l *Limiter) SetConfig(cfg Config) {
if cfg.QPS < 1 {
cfg.QPS = 1
}
if cfg.Burst < cfg.QPS {
cfg.Burst = cfg.QPS
}
l.mu.Lock()
l.enabled = cfg.Enabled
l.qps = float64(cfg.QPS)
l.burst = float64(cfg.Burst)
l.exempt = netutil.NewPrefixSet(cfg.Exempt)
l.mu.Unlock()
}
func (l *Limiter) shardFor(addr netip.Addr) *shard {
b, _ := addr.MarshalBinary()
h := maphash.Bytes(l.seed, b)
return l.shards[h%shardCount]
}
// Allow reports whether a query from addr may be answered.
//
// Exempt networks — internal infrastructure, by default loopback — are never
// limited, so a busy local forwarder cannot be throttled by accident.
func (l *Limiter) Allow(addr netip.Addr) bool {
l.mu.RLock()
enabled, qps, burst, exempt := l.enabled, l.qps, l.burst, l.exempt
l.mu.RUnlock()
if !enabled {
return true
}
if !addr.IsValid() {
return true
}
if exempt.Contains(addr) {
l.allowed.Add(1)
return true
}
now := time.Now()
sh := l.shardFor(addr)
sh.mu.Lock()
b, ok := sh.buckets[addr]
if !ok {
b = &bucket{tokens: burst, lastFill: now}
sh.buckets[addr] = b
l.clients.Add(1)
} else {
elapsed := now.Sub(b.lastFill).Seconds()
if elapsed > 0 {
b.tokens += elapsed * qps
if b.tokens > burst {
b.tokens = burst
}
b.lastFill = now
}
}
b.lastSeen = now
if b.tokens >= 1 {
b.tokens--
sh.mu.Unlock()
l.allowed.Add(1)
return true
}
sh.mu.Unlock()
l.denied.Add(1)
return false
}
// Sweep drops buckets that have been idle for longer than maxIdle and returns
// how many were removed.
func (l *Limiter) Sweep(maxIdle time.Duration) int {
cutoff := time.Now().Add(-maxIdle)
removed := 0
for _, sh := range l.shards {
sh.mu.Lock()
for addr, b := range sh.buckets {
if b.lastSeen.Before(cutoff) {
delete(sh.buckets, addr)
removed++
}
}
sh.mu.Unlock()
}
l.clients.Add(-int64(removed))
return removed
}
// Run sweeps idle buckets until done is closed.
func (l *Limiter) Run(done <-chan struct{}, interval, maxIdle time.Duration) {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
l.Sweep(maxIdle)
}
}
}
// Reset clears every bucket.
func (l *Limiter) Reset() {
for _, sh := range l.shards {
sh.mu.Lock()
sh.buckets = map[netip.Addr]*bucket{}
sh.mu.Unlock()
}
l.clients.Store(0)
}
// Stats reports limiter activity.
type Stats struct {
Enabled bool `json:"enabled"`
Allowed int64 `json:"allowed"`
Denied int64 `json:"denied"`
TrackedClients int64 `json:"tracked_clients"`
}
// Stats returns the limiter counters.
func (l *Limiter) Stats() Stats {
l.mu.RLock()
enabled := l.enabled
l.mu.RUnlock()
return Stats{
Enabled: enabled,
Allowed: l.allowed.Load(),
Denied: l.denied.Load(),
TrackedClients: l.clients.Load(),
}
}
+94
View File
@@ -0,0 +1,94 @@
package resolver
import (
"fmt"
"net/netip"
"strings"
)
// ACL decides which clients may use recursion.
//
// It is deliberately closed by default: an empty allow list denies everyone.
// Denies are evaluated before allows, so a narrow exclusion can be carved out
// of a broad allowance.
type ACL struct {
allow []netip.Prefix
deny []netip.Prefix
}
// NewACL compiles allow and deny lists. Entries may be CIDR blocks or bare
// addresses; an invalid entry is reported rather than silently dropped,
// because a typo in an ACL is a security-relevant mistake.
func NewACL(allow, deny []string) (*ACL, error) {
a := &ACL{}
var err error
if a.allow, err = parsePrefixes(allow, "allowed"); err != nil {
return nil, err
}
if a.deny, err = parsePrefixes(deny, "denied"); err != nil {
return nil, err
}
return a, nil
}
func parsePrefixes(in []string, label string) ([]netip.Prefix, error) {
var out []netip.Prefix
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("%s network %q is not a valid CIDR block", label, s)
}
out = append(out, p.Masked())
continue
}
addr, err := netip.ParseAddr(s)
if err != nil {
return nil, fmt.Errorf("%s network %q is not a valid IP address or CIDR block", label, s)
}
addr = addr.Unmap()
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
// Allowed reports whether addr may use recursion.
func (a *ACL) Allowed(addr netip.Addr) bool {
if a == nil {
return false
}
ip := addr.Unmap()
for _, p := range a.deny {
if p.Addr().Is4() == ip.Is4() && p.Contains(ip) {
return false
}
}
for _, p := range a.allow {
if p.Addr().Is4() == ip.Is4() && p.Contains(ip) {
return true
}
}
return false
}
// Describe renders the ACL for the settings page.
func (a *ACL) Describe() (allow, deny []string) {
if a == nil {
return nil, nil
}
for _, p := range a.allow {
allow = append(allow, p.String())
}
for _, p := range a.deny {
deny = append(deny, p.String())
}
return allow, deny
}
// Prefix sets for non-security lists (rate-limit exemptions, query-log ignore
// lists) live in package netutil. This package keeps only the recursion ACL,
// whose constructor deliberately reports errors instead of skipping entries.
+436
View File
@@ -0,0 +1,436 @@
// Package resolver performs recursive resolution by forwarding to configured
// upstream servers.
//
// Forwarding rather than full iteration is a deliberate choice for an
// appliance of this size: it is far simpler to get right, it inherits the
// upstream's own cache and DNSSEC validation, and it avoids shipping a root
// hints file that goes stale. The interface is narrow enough that a full
// iterative resolver could be dropped in behind it later.
package resolver
import (
"context"
"errors"
"fmt"
"math/rand"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/config"
)
// Config controls upstream behaviour. It is replaced wholesale on change.
type Config struct {
Upstreams []string
Timeout time.Duration
Retries int
Strategy string
DNSSEC bool
EDNSUDPSize uint16
MaxConcurrent int
}
// Upstream tracks one configured server and its observed health.
type Upstream struct {
Addr string
order int
// latencyUS is an exponentially weighted moving average in microseconds.
latencyUS atomic.Int64
queries atomic.Int64
failures atomic.Int64
// consecutive failures; after failureThreshold the server is rested.
consecutive atomic.Int64
downUntil atomic.Int64 // unix nanos
lastError atomic.Value // string
lastUsed atomic.Int64 // unix nanos
}
const (
failureThreshold = 3
restPeriod = 20 * time.Second
// initialLatency seeds the EWMA so an unqueried server is neither
// unfairly preferred nor permanently ignored.
initialLatencyUS = 50_000
)
func newUpstream(addr string, order int) *Upstream {
u := &Upstream{Addr: addr, order: order}
u.latencyUS.Store(initialLatencyUS)
u.lastError.Store("")
return u
}
func (u *Upstream) healthy() bool {
until := u.downUntil.Load()
return until == 0 || time.Now().UnixNano() >= until
}
func (u *Upstream) recordSuccess(d time.Duration) {
// EWMA with alpha = 1/4, cheap and stable enough for server selection.
prev := u.latencyUS.Load()
next := (prev*3 + d.Microseconds()) / 4
u.latencyUS.Store(next)
u.queries.Add(1)
u.consecutive.Store(0)
u.downUntil.Store(0)
u.lastUsed.Store(time.Now().UnixNano())
}
func (u *Upstream) recordFailure(err error) {
u.failures.Add(1)
u.queries.Add(1)
u.lastUsed.Store(time.Now().UnixNano())
if err != nil {
u.lastError.Store(err.Error())
}
if u.consecutive.Add(1) >= failureThreshold {
u.downUntil.Store(time.Now().Add(restPeriod).UnixNano())
}
}
// Status is a point-in-time view of one upstream for the resolver page.
type Status struct {
Address string `json:"address"`
Healthy bool `json:"healthy"`
Queries int64 `json:"queries"`
Failures int64 `json:"failures"`
LatencyMS float64 `json:"latency_ms"`
LastError string `json:"last_error,omitempty"`
LastUsed *time.Time `json:"last_used,omitempty"`
}
// Status renders the upstream's current state.
func (u *Upstream) Status() Status {
s := Status{
Address: u.Addr,
Healthy: u.healthy(),
Queries: u.queries.Load(),
Failures: u.failures.Load(),
LatencyMS: float64(u.latencyUS.Load()) / 1000,
}
if v, ok := u.lastError.Load().(string); ok {
s.LastError = v
}
if n := u.lastUsed.Load(); n > 0 {
t := time.Unix(0, n)
s.LastUsed = &t
}
return s
}
// Resolver forwards queries to upstream servers.
type Resolver struct {
mu sync.RWMutex
cfg Config
upstreams []*Upstream
rrCounter atomic.Uint64
sem chan struct{}
semMu sync.Mutex
udpClient *dns.Client
tcpClient *dns.Client
queries atomic.Int64
failures atomic.Int64
truncated atomic.Int64
latencyUS atomic.Int64 // EWMA across all upstreams
}
// Common resolver errors surfaced to the operator.
var (
ErrNoUpstreams = errors.New("no upstream resolvers are configured")
ErrAllFailed = errors.New("every upstream resolver failed to answer")
)
// New creates a resolver with the given configuration.
func New(cfg Config) *Resolver {
r := &Resolver{}
r.udpClient = &dns.Client{Net: "udp"}
r.tcpClient = &dns.Client{Net: "tcp"}
r.SetConfig(cfg)
return r
}
// SetConfig replaces the resolver configuration. Upstreams that are still
// present keep their health statistics so a settings change does not discard
// what we have learned about them.
func (r *Resolver) SetConfig(cfg Config) {
if cfg.Timeout <= 0 {
cfg.Timeout = 2 * time.Second
}
if cfg.MaxConcurrent <= 0 {
cfg.MaxConcurrent = 256
}
if cfg.EDNSUDPSize == 0 {
cfg.EDNSUDPSize = 1232
}
r.mu.Lock()
prev := map[string]*Upstream{}
for _, u := range r.upstreams {
prev[u.Addr] = u
}
ups := make([]*Upstream, 0, len(cfg.Upstreams))
for i, addr := range cfg.Upstreams {
if u, ok := prev[addr]; ok {
u.order = i
ups = append(ups, u)
continue
}
ups = append(ups, newUpstream(addr, i))
}
r.cfg = cfg
r.upstreams = ups
r.udpClient.Timeout = cfg.Timeout
r.tcpClient.Timeout = cfg.Timeout
r.mu.Unlock()
r.semMu.Lock()
r.sem = make(chan struct{}, cfg.MaxConcurrent)
r.semMu.Unlock()
}
// Config returns the active configuration.
func (r *Resolver) Config() Config {
r.mu.RLock()
defer r.mu.RUnlock()
return r.cfg
}
// Upstreams returns the configured upstreams in configuration order.
func (r *Resolver) Upstreams() []*Upstream {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Upstream, len(r.upstreams))
copy(out, r.upstreams)
sort.Slice(out, func(i, j int) bool { return out[i].order < out[j].order })
return out
}
// Statuses renders every upstream's health for the UI.
func (r *Resolver) Statuses() []Status {
ups := r.Upstreams()
out := make([]Status, 0, len(ups))
for _, u := range ups {
out = append(out, u.Status())
}
return out
}
// order returns the upstreams to try, in the order dictated by the strategy.
func (r *Resolver) order() []*Upstream {
r.mu.RLock()
strategy := r.cfg.Strategy
ups := make([]*Upstream, len(r.upstreams))
copy(ups, r.upstreams)
r.mu.RUnlock()
switch strategy {
case config.StrategyRandom:
rand.Shuffle(len(ups), func(i, j int) { ups[i], ups[j] = ups[j], ups[i] })
case config.StrategyRoundRobin:
if len(ups) > 1 {
n := int(r.rrCounter.Add(1)-1) % len(ups)
ups = append(ups[n:], ups[:n]...)
}
case config.StrategyFastest:
sort.SliceStable(ups, func(i, j int) bool {
return ups[i].latencyUS.Load() < ups[j].latencyUS.Load()
})
default: // sequential
sort.SliceStable(ups, func(i, j int) bool { return ups[i].order < ups[j].order })
}
// Regardless of strategy, servers that are resting go last rather than
// being removed: if every server is unhealthy we must still try something.
sort.SliceStable(ups, func(i, j int) bool {
return ups[i].healthy() && !ups[j].healthy()
})
return ups
}
// Result carries a forwarded answer and where it came from.
type Result struct {
Msg *dns.Msg
Upstream string
RTT time.Duration
Attempts int
TCP bool
}
// Resolve forwards a query upstream and returns the first usable answer.
//
// The request is copied before being modified, so the caller's message is never
// mutated.
func (r *Resolver) Resolve(ctx context.Context, req *dns.Msg) (*Result, error) {
cfg := r.Config()
ups := r.order()
if len(ups) == 0 {
return nil, ErrNoUpstreams
}
if err := r.acquire(ctx); err != nil {
return nil, err
}
defer r.release()
out := req.Copy()
out.Id = dns.Id()
out.RecursionDesired = true
r.applyEDNS(out, cfg)
attempts := cfg.Retries + 1
if attempts > len(ups) {
attempts = len(ups)
}
if attempts < 1 {
attempts = 1
}
var lastErr error
for i := 0; i < attempts; i++ {
u := ups[i%len(ups)]
res, err := r.exchange(ctx, u, out, cfg)
if err != nil {
lastErr = err
u.recordFailure(err)
r.failures.Add(1)
if ctx.Err() != nil {
break
}
continue
}
u.recordSuccess(res.RTT)
r.queries.Add(1)
prev := r.latencyUS.Load()
r.latencyUS.Store((prev*3 + res.RTT.Microseconds()) / 4)
res.Attempts = i + 1
return res, nil
}
if lastErr == nil {
lastErr = ErrAllFailed
}
return nil, fmt.Errorf("%w: %v", ErrAllFailed, lastErr)
}
// exchange performs one upstream query, falling back to TCP when the UDP
// answer comes back truncated.
func (r *Resolver) exchange(ctx context.Context, u *Upstream, req *dns.Msg, cfg Config) (*Result, error) {
qctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
msg, rtt, err := r.udpClient.ExchangeContext(qctx, req, u.Addr)
if err != nil {
return nil, fmt.Errorf("query %s over UDP: %w", u.Addr, err)
}
if msg.Truncated {
r.truncated.Add(1)
tctx, tcancel := context.WithTimeout(ctx, cfg.Timeout)
defer tcancel()
tmsg, trtt, terr := r.tcpClient.ExchangeContext(tctx, req, u.Addr)
if terr != nil {
// The truncated UDP answer is still better than nothing.
return &Result{Msg: msg, Upstream: u.Addr, RTT: rtt}, nil
}
return &Result{Msg: tmsg, Upstream: u.Addr, RTT: rtt + trtt, TCP: true}, nil
}
return &Result{Msg: msg, Upstream: u.Addr, RTT: rtt}, nil
}
// applyEDNS attaches our own OPT record, replacing whatever the client sent.
// The client's advertised buffer size describes its link, not ours.
func (r *Resolver) applyEDNS(m *dns.Msg, cfg Config) {
m.Extra = stripOPT(m.Extra)
opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
opt.SetUDPSize(cfg.EDNSUDPSize)
if cfg.DNSSEC {
opt.SetDo(true)
}
m.Extra = append(m.Extra, opt)
}
func stripOPT(rrs []dns.RR) []dns.RR {
out := make([]dns.RR, 0, len(rrs))
for _, rr := range rrs {
if rr.Header().Rrtype == dns.TypeOPT {
continue
}
out = append(out, rr)
}
return out
}
func (r *Resolver) acquire(ctx context.Context) error {
r.semMu.Lock()
sem := r.sem
r.semMu.Unlock()
select {
case sem <- struct{}{}:
return nil
case <-ctx.Done():
return fmt.Errorf("resolver is at its concurrency limit: %w", ctx.Err())
}
}
func (r *Resolver) release() {
r.semMu.Lock()
sem := r.sem
r.semMu.Unlock()
select {
case <-sem:
default:
}
}
// Stats summarises resolver activity.
type Stats struct {
Queries int64 `json:"queries"`
Failures int64 `json:"failures"`
Truncated int64 `json:"truncated"`
AvgLatencyMS float64 `json:"avg_latency_ms"`
Upstreams int `json:"upstreams"`
Healthy int `json:"healthy"`
}
// Stats returns the resolver counters.
func (r *Resolver) Stats() Stats {
s := Stats{
Queries: r.queries.Load(),
Failures: r.failures.Load(),
Truncated: r.truncated.Load(),
AvgLatencyMS: float64(r.latencyUS.Load()) / 1000,
}
for _, u := range r.Upstreams() {
s.Upstreams++
if u.healthy() {
s.Healthy++
}
}
return s
}
// Check performs a one-off probe against a single upstream, used by the
// "test resolver" button in the UI.
func Check(ctx context.Context, addr, qname string, timeout time.Duration) (time.Duration, string, error) {
c := &dns.Client{Net: "udp", Timeout: timeout}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(qname), dns.TypeA)
m.RecursionDesired = true
m.SetEdns0(1232, true)
qctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resp, rtt, err := c.ExchangeContext(qctx, m, addr)
if err != nil {
return 0, "", fmt.Errorf("%s did not answer: %w", addr, err)
}
return rtt, dns.RcodeToString[resp.Rcode], nil
}
+308
View File
@@ -0,0 +1,308 @@
// Package runtimecfg owns the in-memory view of everything the DNS data path
// needs: settings, the compiled zone index, the compiled policy index and the
// recursion ACL.
//
// The whole view is an immutable Snapshot behind an atomic pointer. Query
// handling reads the pointer once and then works entirely from immutable data,
// so it never blocks on a lock and never touches SQLite. Configuration changes
// build a brand new snapshot and swap it in; readers already in flight finish
// against the old one.
package runtimecfg
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/owen/vibedns/internal/authoritative"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/policy"
"github.com/owen/vibedns/internal/resolver"
)
// Snapshot is an immutable view of the runtime configuration.
type Snapshot struct {
Settings config.Settings
Zones *authoritative.Index
Policy *policy.Index
ACL *resolver.ACL
BuiltAt time.Time
BuildMS int64
Problems []authoritative.BuildError
// Counts are computed at build time for the dashboard, so the UI never
// has to walk the indexes.
ZoneCount int
RecordCount int
BlacklistDomains int
AllowlistDomains int
NetworkCount int
}
// Manager builds and publishes snapshots.
type Manager struct {
db *database.DB
log *slog.Logger
cur atomic.Pointer[Snapshot]
buildMu sync.Mutex // serialises snapshot construction
subMu sync.RWMutex
subs []func(*Snapshot)
trigger chan struct{}
wg sync.WaitGroup
once sync.Once
reloads atomic.Int64
failures atomic.Int64
}
// New creates a manager and builds the first snapshot.
func New(ctx context.Context, db *database.DB, log *slog.Logger) (*Manager, error) {
m := &Manager{
db: db,
log: log,
trigger: make(chan struct{}, 1),
}
if err := m.Reload(ctx); err != nil {
return nil, err
}
return m, nil
}
// Current returns the active snapshot. It is never nil after New succeeds.
func (m *Manager) Current() *Snapshot { return m.cur.Load() }
// Settings is a shorthand for the active settings.
func (m *Manager) Settings() config.Settings { return m.cur.Load().Settings }
// OnReload registers a callback invoked after every successful reload. It is
// how the cache, resolver, rate limiter and query logger pick up new settings.
func (m *Manager) OnReload(fn func(*Snapshot)) {
m.subMu.Lock()
m.subs = append(m.subs, fn)
m.subMu.Unlock()
}
// Reload rebuilds the snapshot from the database and publishes it.
func (m *Manager) Reload(ctx context.Context) error {
m.buildMu.Lock()
defer m.buildMu.Unlock()
start := time.Now()
snap, err := m.build(ctx)
if err != nil {
m.failures.Add(1)
return err
}
snap.BuildMS = time.Since(start).Milliseconds()
m.cur.Store(snap)
m.reloads.Add(1)
m.subMu.RLock()
subs := make([]func(*Snapshot), len(m.subs))
copy(subs, m.subs)
m.subMu.RUnlock()
for _, fn := range subs {
fn(snap)
}
m.log.Debug("configuration reloaded",
"zones", snap.ZoneCount, "records", snap.RecordCount,
"blacklist_domains", snap.BlacklistDomains, "networks", snap.NetworkCount,
"duration_ms", snap.BuildMS)
for _, p := range snap.Problems {
m.log.Warn("record skipped while building the zone index",
"zone", p.ZoneName, "name", p.Name, "type", p.Type, "error", p.Err)
}
return nil
}
// build assembles a snapshot. It performs a handful of bulk queries rather
// than per-object lookups, so a reload is cheap even with large lists.
func (m *Manager) build(ctx context.Context) (*Snapshot, error) {
stored, err := m.db.Settings(ctx)
if err != nil {
return nil, err
}
settings := config.LoadSettings(stored)
zones, records, err := m.db.SnapshotZones(ctx)
if err != nil {
return nil, err
}
zoneIdx, problems := authoritative.Build(zones, records)
lists, err := m.buildDomainSets(ctx)
if err != nil {
return nil, err
}
networks, assignments, err := m.db.SnapshotNetworks(ctx)
if err != nil {
return nil, err
}
policies, err := m.db.Policies(ctx)
if err != nil {
return nil, err
}
policyIdx := policy.Build(networks, assignments, policies, lists)
acl, err := resolver.NewACL(settings.Resolver.AllowNetworks, settings.Resolver.DenyNetworks)
if err != nil {
// A bad ACL must not silently become permissive; fall back to an
// empty allow list, which denies recursion to everyone.
m.log.Error("recursion ACL is invalid, denying recursion to all clients", "error", err)
acl, _ = resolver.NewACL(nil, nil)
}
snap := &Snapshot{
Settings: settings,
Zones: zoneIdx,
Policy: policyIdx,
ACL: acl,
BuiltAt: time.Now(),
Problems: problems,
}
snap.ZoneCount = zoneIdx.Len()
for _, z := range zoneIdx.Zones() {
snap.RecordCount += z.RecordCount()
}
pstats := policyIdx.Stats()
snap.BlacklistDomains = int(pstats.BlockedDomains)
snap.AllowlistDomains = int(pstats.AllowedDomains)
snap.NetworkCount = pstats.Networks
return snap, nil
}
// buildDomainSets compiles every enabled domain list into a matcher. Sets are
// shared by pointer across policies, so a list used by several policies costs
// memory exactly once.
func (m *Manager) buildDomainSets(ctx context.Context) (map[int64]*blacklist.Set, error) {
meta, err := m.db.DomainLists(ctx, "", "")
if err != nil {
return nil, err
}
builders := make(map[int64]*blacklist.Builder, len(meta))
for _, l := range meta {
if !l.Enabled {
continue
}
builders[l.ID] = blacklist.NewBuilder(l.ID, l.Name, l.Kind, l.DomainCount)
}
err = m.db.SnapshotDomains(ctx, func(e database.SnapshotDomainEntry) {
if b, ok := builders[e.ListID]; ok {
b.Add(e.Domain, e.MatchSubdomains)
}
})
if err != nil {
return nil, err
}
out := make(map[int64]*blacklist.Set, len(builders))
for id, b := range builders {
out[id] = b.Build()
}
return out, nil
}
// RequestReload schedules a reload without blocking the caller.
//
// Requests arriving while one is pending are coalesced, so importing a hundred
// thousand domains one API call at a time still results in a bounded number of
// index rebuilds.
func (m *Manager) RequestReload() {
select {
case m.trigger <- struct{}{}:
default:
}
}
// Start launches the debounced reload worker.
func (m *Manager) Start(ctx context.Context) {
m.once.Do(func() {
m.wg.Add(1)
go m.reloadLoop(ctx)
})
}
// Stop waits for the reload worker to exit.
func (m *Manager) Stop() { m.wg.Wait() }
const reloadDebounce = 250 * time.Millisecond
func (m *Manager) reloadLoop(ctx context.Context) {
defer m.wg.Done()
for {
select {
case <-ctx.Done():
return
case <-m.trigger:
// Coalesce a burst of changes into one rebuild.
timer := time.NewTimer(reloadDebounce)
drain:
for {
select {
case <-m.trigger:
if !timer.Stop() {
<-timer.C
}
timer.Reset(reloadDebounce)
case <-timer.C:
break drain
case <-ctx.Done():
timer.Stop()
return
}
}
if err := m.Reload(ctx); err != nil {
m.log.Error("could not reload configuration", "error", err)
}
}
}
}
// Stats reports reload activity for the dashboard.
type Stats struct {
Reloads int64 `json:"reloads"`
Failures int64 `json:"failures"`
LastBuiltAt time.Time `json:"last_built_at"`
LastBuildMS int64 `json:"last_build_ms"`
Problems int `json:"problems"`
}
// Stats returns reload counters.
func (m *Manager) Stats() Stats {
snap := m.Current()
s := Stats{Reloads: m.reloads.Load(), Failures: m.failures.Load()}
if snap != nil {
s.LastBuiltAt = snap.BuiltAt
s.LastBuildMS = snap.BuildMS
s.Problems = len(snap.Problems)
}
return s
}
// ZoneProblems returns the records that could not be compiled, for display on
// the zone pages.
func (m *Manager) ZoneProblems(zoneID int64) []authoritative.BuildError {
snap := m.Current()
if snap == nil {
return nil
}
var out []authoritative.BuildError
for _, p := range snap.Problems {
if zoneID == 0 || p.ZoneID == zoneID {
out = append(out, p)
}
}
return out
}
+272
View File
@@ -0,0 +1,272 @@
// Package validate holds DNS name and record validation shared by the web UI,
// the REST API and the zone-file importer. Keeping it in one place means the
// three entry points cannot drift apart on what they accept.
package validate
import (
"errors"
"fmt"
"net/netip"
"strings"
"github.com/miekg/dns"
)
// MaxNameLength is the DNS wire-format limit for a fully qualified name.
const MaxNameLength = 253
// MaxLabelLength is the wire-format limit for a single label.
const MaxLabelLength = 63
// NormaliseFQDN lowercases a name and ensures a single trailing dot.
// It returns an error describing what is wrong rather than a bare "invalid".
func NormaliseFQDN(name string) (string, error) {
n := strings.TrimSpace(name)
if n == "" {
return "", errors.New("name must not be empty")
}
if n == "." {
return ".", nil
}
n = strings.ToLower(n)
n = strings.TrimSuffix(n, ".")
if n == "" {
return "", errors.New("name must not be empty")
}
if len(n)+1 > MaxNameLength {
return "", fmt.Errorf("name is %d characters, which exceeds the %d character DNS limit",
len(n)+1, MaxNameLength)
}
for _, label := range strings.Split(n, ".") {
if err := validateLabel(label, false); err != nil {
return "", err
}
}
return n + ".", nil
}
// NormaliseDomain lowercases a domain and strips the trailing dot. This is the
// form stored in blacklists and allowlists.
func NormaliseDomain(name string) (string, error) {
fqdn, err := NormaliseFQDN(name)
if err != nil {
return "", err
}
return strings.TrimSuffix(fqdn, "."), nil
}
// validateLabel checks one label. Wildcard "*" is only legal as the leftmost
// label, which the caller signals with allowWildcard.
func validateLabel(label string, allowWildcard bool) error {
if label == "" {
return errors.New("name contains an empty label (two dots in a row, or a leading dot)")
}
if label == "*" {
if allowWildcard {
return nil
}
return errors.New("wildcard \"*\" is only allowed as the leftmost label")
}
if len(label) > MaxLabelLength {
return fmt.Errorf("label %q is %d characters, which exceeds the %d character limit",
label, len(label), MaxLabelLength)
}
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return fmt.Errorf("label %q must not start or end with a hyphen", label)
}
for _, r := range label {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '-', r == '_':
// Underscores are not legal in host names but are required by SRV,
// TLSA, DKIM and ACME challenge records, so we permit them.
default:
return fmt.Errorf("label %q contains the invalid character %q; "+
"use punycode for internationalised names", label, r)
}
}
return nil
}
// NormaliseZoneName validates and normalises a zone apex name.
func NormaliseZoneName(name string) (string, error) {
n := strings.TrimSpace(name)
if n == "" {
return "", errors.New("zone name must not be empty")
}
if strings.HasPrefix(n, "*") {
return "", errors.New("a zone name may not be a wildcard")
}
fqdn, err := NormaliseFQDN(n)
if err != nil {
return "", err
}
if fqdn == "." {
return "", errors.New("the root zone cannot be served by this application")
}
return fqdn, nil
}
// NormaliseRecordName normalises a record name that is relative to a zone apex.
// The apex itself is stored as "@". A name given as a full FQDN inside the zone
// is converted to its relative form.
func NormaliseRecordName(name, zone string) (string, error) {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" || n == "@" {
return "@", nil
}
// Absolute name: it must fall inside the zone.
if strings.HasSuffix(n, ".") {
if n == zone {
return "@", nil
}
if !strings.HasSuffix(n, "."+zone) && !strings.HasSuffix(n, zone) {
return "", fmt.Errorf("name %q is not inside zone %s", name, zone)
}
n = strings.TrimSuffix(n, "."+zone)
n = strings.TrimSuffix(n, ".")
if n == "" {
return "@", nil
}
}
labels := strings.Split(n, ".")
for i, l := range labels {
if err := validateLabel(l, i == 0); err != nil {
return "", err
}
}
if len(n)+1+len(zone) > MaxNameLength {
return "", fmt.Errorf("the fully qualified name would exceed the %d character DNS limit", MaxNameLength)
}
return n, nil
}
// AbsoluteName joins a relative record name with its zone apex.
func AbsoluteName(name, zone string) string {
if name == "@" || name == "" {
return zone
}
if strings.HasSuffix(name, ".") {
return name
}
return name + "." + zone
}
// IsSubdomain reports whether child is equal to or below parent. Both must be
// normalised FQDNs.
func IsSubdomain(child, parent string) bool {
if parent == "." {
return true
}
if child == parent {
return true
}
return strings.HasSuffix(child, "."+parent)
}
// --- Reverse zones ------------------------------------------------------
// ReverseZone converts a CIDR block into the in-addr.arpa or ip6.arpa zone
// that covers it.
//
// DNS reverse delegation only happens on octet boundaries for IPv4 and nibble
// boundaries for IPv6. When the supplied prefix is finer than that, the zone
// for the nearest enclosing boundary is returned along with an explanatory
// note, so the UI can tell the operator what it actually created.
func ReverseZone(cidr string) (zone string, note string, err error) {
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
if err != nil {
addr, aerr := netip.ParseAddr(strings.TrimSpace(cidr))
if aerr != nil {
return "", "", fmt.Errorf("%q is not a valid CIDR block or IP address", cidr)
}
p = netip.PrefixFrom(addr, addr.BitLen())
}
p = p.Masked()
if p.Addr().Is4() {
bits := p.Bits()
use := (bits / 8) * 8
if use == 0 {
return "", "", errors.New("an IPv4 reverse zone needs at least a /8 prefix")
}
if use != bits {
note = fmt.Sprintf("Reverse DNS delegation for IPv4 happens on octet boundaries, "+
"so /%d was rounded to the enclosing /%d zone.", bits, use)
}
octets := p.Addr().As4()
var labels []string
for i := use/8 - 1; i >= 0; i-- {
labels = append(labels, fmt.Sprintf("%d", octets[i]))
}
return strings.Join(labels, ".") + ".in-addr.arpa.", note, nil
}
bits := p.Bits()
use := (bits / 4) * 4
if use == 0 {
return "", "", errors.New("an IPv6 reverse zone needs at least a /4 prefix")
}
if use != bits {
note = fmt.Sprintf("Reverse DNS delegation for IPv6 happens on nibble boundaries, "+
"so /%d was rounded to the enclosing /%d zone.", bits, use)
}
nibbles := ipv6Nibbles(p.Addr())
var labels []string
for i := use/4 - 1; i >= 0; i-- {
labels = append(labels, nibbles[i])
}
return strings.Join(labels, ".") + ".ip6.arpa.", note, nil
}
func ipv6Nibbles(a netip.Addr) []string {
b := a.As16()
out := make([]string, 0, 32)
const hex = "0123456789abcdef"
for _, x := range b {
out = append(out, string(hex[x>>4]), string(hex[x&0x0f]))
}
return out
}
// PTRName returns the full reverse DNS name for a single IP address, e.g.
// 192.0.2.10 becomes 10.2.0.192.in-addr.arpa.
func PTRName(ip string) (string, error) {
addr, err := netip.ParseAddr(strings.TrimSpace(ip))
if err != nil {
return "", fmt.Errorf("%q is not a valid IP address", ip)
}
name, err := dns.ReverseAddr(addr.String())
if err != nil {
return "", fmt.Errorf("cannot build a reverse name for %s: %w", ip, err)
}
return strings.ToLower(name), nil
}
// ReverseZoneKindForCIDR reports which reverse zone family a CIDR belongs to.
func ReverseZoneKindForCIDR(cidr string) (string, error) {
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
if err != nil {
addr, aerr := netip.ParseAddr(strings.TrimSpace(cidr))
if aerr != nil {
return "", fmt.Errorf("%q is not a valid CIDR block or IP address", cidr)
}
p = netip.PrefixFrom(addr, addr.BitLen())
}
if p.Addr().Is4() {
return "reverse4", nil
}
return "reverse6", nil
}
// ZoneKindForName infers the zone family from an apex name.
func ZoneKindForName(zone string) string {
switch {
case strings.HasSuffix(zone, ".in-addr.arpa."):
return "reverse4"
case strings.HasSuffix(zone, ".ip6.arpa."):
return "reverse6"
default:
return "forward"
}
}
+541
View File
@@ -0,0 +1,541 @@
package validate
import (
"errors"
"fmt"
"net/netip"
"strconv"
"strings"
"github.com/miekg/dns"
)
// Field describes one input in a type-specific record editor. The web UI builds
// its forms from this metadata, so adding a record type here gives it a proper
// editor without writing a new template.
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text, number, textarea, select
Placeholder string `json:"placeholder,omitempty"`
Help string `json:"help,omitempty"`
Required bool `json:"required"`
Quote bool `json:"quote,omitempty"` // rdata field must be a quoted string
Options []string `json:"options,omitempty"`
Width int `json:"width,omitempty"` // Bootstrap column width, 12-grid
}
// TypeInfo describes a record type and its editor.
type TypeInfo struct {
Type string `json:"type"`
Description string `json:"description"`
Fields []Field `json:"fields"`
Common bool `json:"common"`
}
func num(key, label, placeholder, help string, width int) Field {
return Field{Key: key, Label: label, Type: "number", Placeholder: placeholder,
Help: help, Required: true, Width: width}
}
func txt(key, label, placeholder, help string, width int) Field {
return Field{Key: key, Label: label, Type: "text", Placeholder: placeholder,
Help: help, Required: true, Width: width}
}
// recordTypes drives both validation and the UI editors.
var recordTypes = []TypeInfo{
{Type: "A", Description: "IPv4 address", Common: true, Fields: []Field{
txt("address", "IPv4 address", "192.0.2.10", "A single IPv4 address.", 12),
}},
{Type: "AAAA", Description: "IPv6 address", Common: true, Fields: []Field{
txt("address", "IPv6 address", "2001:db8::10", "A single IPv6 address.", 12),
}},
{Type: "CNAME", Description: "Canonical name alias", Common: true, Fields: []Field{
txt("target", "Target", "example.com.", "The name this record is an alias for. End with a dot for an absolute name.", 12),
}},
{Type: "MX", Description: "Mail exchanger", Common: true, Fields: []Field{
num("preference", "Preference", "10", "Lower values are preferred.", 3),
txt("exchange", "Mail server", "mail.example.com.", "Host name of the mail server. Must not be a CNAME.", 9),
}},
{Type: "TXT", Description: "Free-form text", Common: true, Fields: []Field{
{Key: "text", Label: "Text", Type: "textarea", Required: true, Quote: true, Width: 12,
Placeholder: "v=spf1 include:_spf.example.com ~all",
Help: "Quoting and 255-character chunking are handled automatically."},
}},
{Type: "NS", Description: "Name server delegation", Common: true, Fields: []Field{
txt("nameserver", "Name server", "ns1.example.com.", "Authoritative name server for this name.", 12),
}},
{Type: "SRV", Description: "Service location", Common: true, Fields: []Field{
num("priority", "Priority", "10", "Lower values are preferred.", 3),
num("weight", "Weight", "20", "Relative weight among equal priorities.", 3),
num("port", "Port", "5060", "TCP or UDP port of the service.", 3),
txt("target", "Target", "sip.example.com.", "Host providing the service.", 3),
}},
{Type: "PTR", Description: "Reverse pointer", Common: true, Fields: []Field{
txt("target", "Points to", "host.example.com.", "The host name this address belongs to.", 12),
}},
{Type: "CAA", Description: "Certificate authority authorisation", Common: true, Fields: []Field{
num("flags", "Flags", "0", "128 marks the property as critical.", 2),
{Key: "tag", Label: "Tag", Type: "select", Required: true, Width: 3,
Options: []string{"issue", "issuewild", "iodef", "contactemail", "contactphone"}},
{Key: "value", Label: "Value", Type: "text", Required: true, Quote: true, Width: 7,
Placeholder: "letsencrypt.org", Help: "The CA domain, or a mailto:/https: URL for iodef."},
}},
{Type: "SOA", Description: "Start of authority", Fields: []Field{
txt("ns", "Primary name server", "ns1.example.com.", "", 6),
txt("mbox", "Responsible party", "hostmaster.example.com.", "The @ in the email address becomes a dot.", 6),
num("serial", "Serial", "1", "", 4),
num("refresh", "Refresh", "7200", "", 4),
num("retry", "Retry", "3600", "", 4),
num("expire", "Expire", "1209600", "", 6),
num("minimum", "Minimum / negative TTL", "3600", "", 6),
}},
{Type: "NAPTR", Description: "Naming authority pointer", Fields: []Field{
num("order", "Order", "100", "", 3),
num("preference", "Preference", "10", "", 3),
{Key: "flags", Label: "Flags", Type: "text", Quote: true, Width: 3, Placeholder: "U"},
{Key: "service", Label: "Service", Type: "text", Quote: true, Width: 3, Placeholder: "E2U+sip"},
{Key: "regexp", Label: "Regexp", Type: "text", Quote: true, Width: 8,
Placeholder: `!^.*$!sip:info@example.com!`},
txt("replacement", "Replacement", ".", "Use a single dot when a regexp is given.", 4),
}},
{Type: "TLSA", Description: "TLS certificate association", Fields: []Field{
num("usage", "Usage", "3", "0-3; 3 is a domain-issued certificate.", 3),
num("selector", "Selector", "1", "0 full certificate, 1 public key.", 3),
num("matching_type", "Matching type", "1", "0 exact, 1 SHA-256, 2 SHA-512.", 3),
txt("certificate", "Certificate data", "abc123...", "Hex encoded association data.", 3),
}},
{Type: "SSHFP", Description: "SSH host key fingerprint", Fields: []Field{
num("algorithm", "Algorithm", "4", "1 RSA, 2 DSA, 3 ECDSA, 4 Ed25519.", 4),
num("type", "Fingerprint type", "2", "1 SHA-1, 2 SHA-256.", 4),
txt("fingerprint", "Fingerprint", "abc123...", "Hex encoded fingerprint.", 4),
}},
{Type: "SVCB", Description: "Service binding", Fields: []Field{
num("priority", "Priority", "1", "0 selects alias mode.", 3),
txt("target", "Target", "svc.example.com.", "", 4),
{Key: "params", Label: "Parameters", Type: "text", Width: 5,
Placeholder: "alpn=h2,h3 port=8443", Help: "Space separated key=value pairs."},
}},
{Type: "HTTPS", Description: "HTTPS service binding", Common: true, Fields: []Field{
num("priority", "Priority", "1", "0 selects alias mode.", 3),
txt("target", "Target", ".", "A single dot means the owner name itself.", 4),
{Key: "params", Label: "Parameters", Type: "text", Width: 5,
Placeholder: "alpn=h2,h3 ipv4hint=192.0.2.10", Help: "Space separated key=value pairs."},
}},
{Type: "DS", Description: "Delegation signer", Fields: []Field{
num("key_tag", "Key tag", "12345", "", 3),
num("algorithm", "Algorithm", "13", "8 RSASHA256, 13 ECDSAP256SHA256, 15 ED25519.", 3),
num("digest_type", "Digest type", "2", "1 SHA-1, 2 SHA-256, 4 SHA-384.", 3),
txt("digest", "Digest", "abc123...", "Hex encoded digest.", 3),
}},
{Type: "DNSKEY", Description: "DNSSEC public key", Fields: []Field{
num("flags", "Flags", "257", "256 zone signing key, 257 key signing key.", 3),
num("protocol", "Protocol", "3", "Always 3.", 3),
num("algorithm", "Algorithm", "13", "8 RSASHA256, 13 ECDSAP256SHA256, 15 ED25519.", 3),
{Key: "public_key", Label: "Public key", Type: "textarea", Required: true, Width: 12,
Placeholder: "base64 encoded key material"},
}},
{Type: "DNAME", Description: "Delegation name redirection", Fields: []Field{
txt("target", "Target", "example.net.", "Rewrites the entire subtree below this name.", 12),
}},
{Type: "SPF", Description: "Legacy sender policy (prefer TXT)", Fields: []Field{
{Key: "text", Label: "Policy", Type: "textarea", Required: true, Quote: true, Width: 12,
Placeholder: "v=spf1 mx ~all"},
}},
{Type: "LOC", Description: "Geographic location", Fields: []Field{
{Key: "raw", Label: "Location", Type: "text", Required: true, Width: 12,
Placeholder: "51 30 12.748 N 0 7 39.611 W 0.00m"},
}},
{Type: "RAW", Description: "Advanced: any record type, entered by hand", Fields: []Field{
{Key: "rtype", Label: "Record type", Type: "text", Required: true, Width: 4,
Placeholder: "URI", Help: "Any type name known to the DNS library, or TYPE65534 for unknown types."},
{Key: "rdata", Label: "Record data", Type: "textarea", Required: true, Width: 8,
Placeholder: `10 1 "https://example.com/"`,
Help: "Rdata exactly as it would appear in a zone file. Unknown types use the RFC 3597 form: \\# 4 0A0B0C0D"},
}},
}
// TypeInfos returns the record type catalogue used by the UI.
func TypeInfos() []TypeInfo { return recordTypes }
// TypeInfoFor looks up one record type's editor definition.
func TypeInfoFor(t string) (TypeInfo, bool) {
t = strings.ToUpper(strings.TrimSpace(t))
for _, ti := range recordTypes {
if ti.Type == t {
return ti, true
}
}
return TypeInfo{}, false
}
// KnownType reports whether the DNS library understands a type name. This
// accepts far more types than have dedicated editors, including the RFC 3597
// TYPEnnnnn form.
func KnownType(t string) bool {
t = strings.ToUpper(strings.TrimSpace(t))
if _, ok := dns.StringToType[t]; ok {
return true
}
if strings.HasPrefix(t, "TYPE") {
if n, err := strconv.Atoi(t[4:]); err == nil && n > 0 && n <= 65535 {
return true
}
}
return false
}
// NormaliseType uppercases and checks a record type name.
func NormaliseType(t string) (string, error) {
t = strings.ToUpper(strings.TrimSpace(t))
if t == "" {
return "", errors.New("record type must not be empty")
}
if t == "RAW" {
return "", errors.New("choose a concrete record type in the advanced editor")
}
if !KnownType(t) {
return "", fmt.Errorf("%q is not a known DNS record type; "+
"use the advanced editor with the TYPEnnnnn form for unassigned types", t)
}
switch t {
case "ANY", "AXFR", "IXFR", "OPT", "TSIG", "TKEY":
return "", fmt.Errorf("%s is a meta record type and cannot be stored in a zone", t)
}
return t, nil
}
// BuildRR assembles and validates a resource record.
//
// The zone origin is handed to the parser so that relative names in rdata (and
// "@") resolve exactly the way they would in a real zone file.
func BuildRR(zone, name, rtype, data string, ttl uint32) (dns.RR, error) {
rtype, err := NormaliseType(rtype)
if err != nil {
return nil, err
}
data = strings.TrimSpace(data)
if data == "" {
return nil, fmt.Errorf("%s record data must not be empty", rtype)
}
if err := preflight(rtype, data); err != nil {
return nil, err
}
owner := name
if owner == "" {
owner = "@"
}
line := fmt.Sprintf("%s %d IN %s %s", owner, ttl, rtype, data)
zp := dns.NewZoneParser(strings.NewReader(line), zone, "record")
zp.SetDefaultTTL(ttl)
rr, ok := zp.Next()
if err := zp.Err(); err != nil {
return nil, fmt.Errorf("invalid %s record data: %s", rtype, cleanParseError(err))
}
if !ok || rr == nil {
return nil, fmt.Errorf("invalid %s record data: %q could not be parsed", rtype, data)
}
if _, more := zp.Next(); more {
return nil, fmt.Errorf("%s record data must be a single record", rtype)
}
return rr, nil
}
// preflight catches the mistakes users actually make, so they get a sentence
// they can act on instead of a parser offset.
func preflight(rtype, data string) error {
switch rtype {
case "A":
addr, err := netip.ParseAddr(strings.TrimSpace(data))
if err != nil || !addr.Is4() {
return fmt.Errorf("an A record needs a valid IPv4 address, for example 192.0.2.10 (got %q)", data)
}
case "AAAA":
addr, err := netip.ParseAddr(strings.TrimSpace(data))
if err != nil || addr.Is4() {
return fmt.Errorf("an AAAA record needs a valid IPv6 address, for example 2001:db8::10 (got %q)", data)
}
case "MX":
f := strings.Fields(data)
if len(f) != 2 {
return fmt.Errorf("an MX record needs a preference and a host name, for example: 10 mail.example.com.")
}
if _, err := strconv.ParseUint(f[0], 10, 16); err != nil {
return fmt.Errorf("the MX preference %q must be a number between 0 and 65535", f[0])
}
case "SRV":
f := strings.Fields(data)
if len(f) != 4 {
return fmt.Errorf("an SRV record needs priority, weight, port and target, " +
"for example: 10 20 5060 sip.example.com.")
}
for i, label := range []string{"priority", "weight", "port"} {
if _, err := strconv.ParseUint(f[i], 10, 16); err != nil {
return fmt.Errorf("the SRV %s %q must be a number between 0 and 65535", label, f[i])
}
}
case "CAA":
f := strings.Fields(data)
if len(f) < 3 {
return fmt.Errorf(`a CAA record needs flags, a tag and a quoted value, ` +
`for example: 0 issue "letsencrypt.org"`)
}
case "CNAME", "PTR", "NS", "DNAME":
if strings.Fields(data) == nil || len(strings.Fields(data)) != 1 {
return fmt.Errorf("a %s record takes exactly one name, for example: host.example.com.", rtype)
}
case "TXT", "SPF":
if !strings.HasPrefix(strings.TrimSpace(data), `"`) {
return fmt.Errorf("%s record data must be quoted; the record editor does this for you", rtype)
}
}
return nil
}
// cleanParseError strips the parser's file/line noise, which is meaningless
// for a single record typed into a form.
func cleanParseError(err error) string {
msg := err.Error()
if i := strings.Index(msg, "dns: "); i >= 0 {
msg = msg[i+len("dns: "):]
}
if i := strings.Index(msg, " at record:"); i >= 0 {
msg = msg[:i]
}
if i := strings.Index(msg, "\" at line"); i >= 0 {
msg = msg[:i+1]
}
return msg
}
// QuoteTXT turns free text into one or more quoted character-strings, splitting
// at the 255 byte limit that a single DNS character-string may hold.
func QuoteTXT(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return `""`
}
// Already-quoted input is passed through so operators can hand-craft
// multi-string records.
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) && len(s) > 1 {
return s
}
const maxChunk = 255
var chunks []string
for len(s) > maxChunk {
chunks = append(chunks, s[:maxChunk])
s = s[maxChunk:]
}
chunks = append(chunks, s)
for i, c := range chunks {
chunks[i] = `"` + escapeCharString(c) + `"`
}
return strings.Join(chunks, " ")
}
func escapeCharString(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for _, r := range s {
switch r {
case '"', '\\':
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}
// AssembleRData joins editor field values into zone-file rdata, applying
// quoting where the record type requires it.
func AssembleRData(rtype string, values map[string]string) (string, error) {
info, ok := TypeInfoFor(rtype)
if !ok {
return "", fmt.Errorf("no editor is defined for record type %q", rtype)
}
var parts []string
for _, f := range info.Fields {
v := strings.TrimSpace(values[f.Key])
if v == "" {
if f.Required {
return "", fmt.Errorf("%s is required for a %s record", f.Label, rtype)
}
continue
}
if f.Quote {
v = QuoteTXT(v)
}
parts = append(parts, v)
}
if len(parts) == 0 {
return "", fmt.Errorf("%s record data must not be empty", rtype)
}
return strings.Join(parts, " "), nil
}
// SplitRData splits stored rdata back into editor field values so an existing
// record can be edited in its dedicated form.
func SplitRData(rtype, data string) map[string]string {
out := map[string]string{}
info, ok := TypeInfoFor(rtype)
if !ok {
return out
}
// Types whose final field swallows the remainder of the line.
fields := info.Fields
if len(fields) == 0 {
return out
}
// A single quoted field (TXT, SPF) takes the whole rdata verbatim.
if len(fields) == 1 {
v := data
if fields[0].Quote {
v = UnquoteTXT(data)
}
out[fields[0].Key] = v
return out
}
toks := tokeniseRData(data)
for i, f := range fields {
if i >= len(toks) {
break
}
if i == len(fields)-1 && len(toks) > len(fields) {
// Trailing field absorbs everything left (e.g. SVCB parameters).
rest := strings.Join(toks[i:], " ")
if f.Quote {
rest = UnquoteTXT(rest)
}
out[f.Key] = rest
break
}
v := toks[i]
if f.Quote {
v = UnquoteTXT(v)
}
out[f.Key] = v
}
return out
}
// tokeniseRData splits on whitespace while keeping quoted strings together.
func tokeniseRData(s string) []string {
var out []string
var cur strings.Builder
inQuote, escaped, started := false, false, false
flush := func() {
if started {
out = append(out, cur.String())
cur.Reset()
started = false
}
}
for _, r := range s {
switch {
case escaped:
cur.WriteRune(r)
escaped = false
started = true
case r == '\\':
cur.WriteRune(r)
escaped = true
started = true
case r == '"':
cur.WriteRune(r)
inQuote = !inQuote
started = true
case (r == ' ' || r == '\t') && !inQuote:
flush()
default:
cur.WriteRune(r)
started = true
}
}
flush()
return out
}
// UnquoteTXT reverses QuoteTXT, concatenating adjacent character-strings.
func UnquoteTXT(s string) string {
s = strings.TrimSpace(s)
if !strings.Contains(s, `"`) {
return s
}
var b strings.Builder
inQuote, escaped := false, false
for _, r := range s {
switch {
case escaped:
b.WriteRune(r)
escaped = false
case r == '\\' && inQuote:
escaped = true
case r == '"':
inQuote = !inQuote
case inQuote:
b.WriteRune(r)
}
}
return b.String()
}
// CNAMEConflict reports whether adding a record of type newType at a name that
// already holds the given types would create an illegal combination.
//
// RFC 1034 forbids a CNAME from coexisting with any other data at the same
// name, with the DNSSEC types being the standard exception.
func CNAMEConflict(newType string, existing []string) error {
newType = strings.ToUpper(newType)
hasCNAME := false
otherTypes := 0
for _, t := range existing {
t = strings.ToUpper(t)
if t == "CNAME" {
hasCNAME = true
continue
}
if !dnssecCompatible(t) {
otherTypes++
}
}
if newType == "CNAME" {
if hasCNAME {
return errors.New("this name already has a CNAME record; a name may only have one")
}
if otherTypes > 0 {
return errors.New("a CNAME cannot coexist with other records at the same name; " +
"remove the existing records or use a different name")
}
return nil
}
if hasCNAME && !dnssecCompatible(newType) {
return fmt.Errorf("this name already has a CNAME record, so it cannot also have a %s record", newType)
}
return nil
}
// dnssecCompatible reports whether a type is permitted alongside a CNAME.
func dnssecCompatible(t string) bool {
switch strings.ToUpper(t) {
case "RRSIG", "NSEC", "NSEC3", "KEY", "DS":
return true
}
return false
}
// ApexRestricted reports whether a record type is illegal at a zone apex.
func ApexRestricted(rtype string) error {
switch strings.ToUpper(rtype) {
case "CNAME":
return errors.New("a CNAME cannot be placed at the zone apex; " +
"use an A, AAAA or HTTPS record instead")
case "DNAME":
return errors.New("a DNAME cannot be placed at the zone apex")
}
return nil
}
+357
View File
@@ -0,0 +1,357 @@
package validate
import (
"strings"
"testing"
)
func TestNormaliseFQDN(t *testing.T) {
tests := []struct {
in string
want string
wantErr bool
}{
{"example.com", "example.com.", false},
{"example.com.", "example.com.", false},
{"EXAMPLE.COM", "example.com.", false},
{" example.com ", "example.com.", false},
{"a.b.c.example.com", "a.b.c.example.com.", false},
{"_dmarc.example.com", "_dmarc.example.com.", false}, // underscores are needed by SRV/DKIM
{".", ".", false},
{"", "", true},
{"example..com", "", true},
{"-bad.example.com", "", true},
{"bad-.example.com", "", true},
{"exa mple.com", "", true},
{strings.Repeat("a", 64) + ".example.com", "", true}, // label too long
{strings.Repeat("a.", 130) + "example.com", "", true}, // name too long
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got, err := NormaliseFQDN(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("expected an error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestNormaliseRecordName(t *testing.T) {
const zone = "example.com."
tests := []struct {
in string
want string
wantErr bool
}{
{"", "@", false},
{"@", "@", false},
{"www", "www", false},
{"WWW", "www", false},
{"www.example.com.", "www", false}, // absolute inside the zone
{"example.com.", "@", false}, // the apex itself
{"*", "*", false}, // wildcard
{"*.sub", "*.sub", false},
{"a.b.c", "a.b.c", false},
{"www.example.org.", "", true}, // outside the zone
{"sub.*", "", true}, // wildcard must be leftmost
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got, err := NormaliseRecordName(tc.in, zone)
if tc.wantErr {
if err == nil {
t.Errorf("expected an error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// TestReverseZone covers the feature that spares the operator from reversing
// octets by hand.
func TestReverseZone(t *testing.T) {
tests := []struct {
cidr string
want string
wantNote bool
wantErr bool
}{
{"192.168.1.0/24", "1.168.192.in-addr.arpa.", false, false},
{"10.0.0.0/8", "10.in-addr.arpa.", false, false},
{"172.16.0.0/16", "16.172.in-addr.arpa.", false, false},
{"192.0.2.10", "10.2.0.192.in-addr.arpa.", false, false}, // bare host
// Non-octet boundaries round down to the enclosing zone with a note.
{"192.168.1.0/25", "1.168.192.in-addr.arpa.", true, false},
{"10.1.2.3/30", "2.1.10.in-addr.arpa.", true, false},
{"2001:db8::/32", "8.b.d.0.1.0.0.2.ip6.arpa.", false, false},
{"2001:db8:1::/48", "1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", false, false},
{"2001:db8::/33", "8.b.d.0.1.0.0.2.ip6.arpa.", true, false},
{"not-a-cidr", "", false, true},
{"192.168.1.0/4", "", false, true}, // finer than a /8 leaves no IPv4 zone
}
for _, tc := range tests {
t.Run(tc.cidr, func(t *testing.T) {
zone, note, err := ReverseZone(tc.cidr)
if tc.wantErr {
if err == nil {
t.Errorf("expected an error, got %q", zone)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if zone != tc.want {
t.Errorf("zone = %q, want %q", zone, tc.want)
}
if tc.wantNote && note == "" {
t.Error("expected a note explaining the rounding")
}
if !tc.wantNote && note != "" {
t.Errorf("unexpected note: %s", note)
}
})
}
}
func TestPTRName(t *testing.T) {
tests := []struct{ ip, want string }{
{"192.0.2.10", "10.2.0.192.in-addr.arpa."},
{"10.1.2.3", "3.2.1.10.in-addr.arpa."},
}
for _, tc := range tests {
got, err := PTRName(tc.ip)
if err != nil {
t.Fatalf("PTRName(%q): %v", tc.ip, err)
}
if got != tc.want {
t.Errorf("PTRName(%q) = %q, want %q", tc.ip, got, tc.want)
}
}
if _, err := PTRName("not-an-ip"); err == nil {
t.Error("expected an error for an invalid address")
}
}
func TestBuildRRValidation(t *testing.T) {
const zone = "example.com."
tests := []struct {
name string
rtype string
data string
wantErr bool
}{
{"valid A", "A", "192.0.2.1", false},
{"A with IPv6", "A", "2001:db8::1", true},
{"A with garbage", "A", "not-an-ip", true},
{"valid AAAA", "AAAA", "2001:db8::1", false},
{"AAAA with IPv4", "AAAA", "192.0.2.1", true},
{"valid CNAME", "CNAME", "target.example.com.", false},
{"CNAME with two names", "CNAME", "a.example.com. b.example.com.", true},
{"valid MX", "MX", "10 mail.example.com.", false},
{"MX without preference", "MX", "mail.example.com.", true},
{"MX with bad preference", "MX", "abc mail.example.com.", true},
{"valid TXT", "TXT", `"some text"`, false},
{"unquoted TXT", "TXT", "some text", true},
{"valid SRV", "SRV", "10 20 5060 sip.example.com.", false},
{"SRV missing fields", "SRV", "10 20 sip.example.com.", true},
{"valid CAA", "CAA", `0 issue "letsencrypt.org"`, false},
{"valid TLSA", "TLSA", "3 1 1 abcdef0123456789", false},
{"valid SSHFP", "SSHFP", "4 2 abcdef0123456789", false},
{"valid DS", "DS", "12345 13 2 abcdef0123456789", false},
{"valid HTTPS", "HTTPS", "1 . alpn=h2,h3", false},
{"valid NS", "NS", "ns1.example.com.", false},
{"valid PTR", "PTR", "host.example.com.", false},
{"unknown type", "NOTATYPE", "whatever", true},
{"meta type rejected", "ANY", "whatever", true},
{"RFC3597 unknown type", "TYPE65280", `\# 4 0A0B0C0D`, false},
{"empty data", "A", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := BuildRR(zone, "test", tc.rtype, tc.data, 3600)
if tc.wantErr && err == nil {
t.Error("expected an error, got none")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
// TestCNAMEConflict covers the RFC 1034 rule that trips people up most often.
func TestCNAMEConflict(t *testing.T) {
tests := []struct {
name string
newType string
existing []string
wantErr bool
}{
{"CNAME on an empty name", "CNAME", nil, false},
{"second CNAME", "CNAME", []string{"CNAME"}, true},
{"CNAME alongside an A", "CNAME", []string{"A"}, true},
{"A alongside a CNAME", "A", []string{"CNAME"}, true},
{"MX alongside a CNAME", "MX", []string{"CNAME"}, true},
{"A alongside another A", "A", []string{"A"}, false},
{"A alongside AAAA", "A", []string{"AAAA"}, false},
// DNSSEC types are the standard exception.
{"RRSIG alongside a CNAME", "RRSIG", []string{"CNAME"}, false},
{"CNAME alongside RRSIG only", "CNAME", []string{"RRSIG", "NSEC"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := CNAMEConflict(tc.newType, tc.existing)
if tc.wantErr && err == nil {
t.Error("expected a conflict, got none")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected conflict: %v", err)
}
})
}
}
func TestApexRestrictions(t *testing.T) {
if err := ApexRestricted("CNAME"); err == nil {
t.Error("a CNAME at the apex must be rejected")
}
if err := ApexRestricted("DNAME"); err == nil {
t.Error("a DNAME at the apex must be rejected")
}
for _, ok := range []string{"A", "AAAA", "MX", "TXT", "NS", "HTTPS"} {
if err := ApexRestricted(ok); err != nil {
t.Errorf("%s should be allowed at the apex: %v", ok, err)
}
}
}
func TestQuoteTXT(t *testing.T) {
tests := []struct{ in, want string }{
{"hello", `"hello"`},
{"", `""`},
{`already "quoted"`, `"already \"quoted\""`},
{`"pre-quoted"`, `"pre-quoted"`},
}
for _, tc := range tests {
if got := QuoteTXT(tc.in); got != tc.want {
t.Errorf("QuoteTXT(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// A string past the 255-byte character-string limit must be split into
// several quoted chunks, not truncated.
long := strings.Repeat("a", 600)
got := QuoteTXT(long)
if strings.Count(got, `"`) != 6 {
t.Errorf("a 600 character string produced %d quotes, want 6 (three chunks)", strings.Count(got, `"`))
}
if _, err := BuildRR("example.com.", "long", "TXT", got, 300); err != nil {
t.Errorf("chunked TXT did not parse: %v", err)
}
}
func TestAssembleAndSplitRData(t *testing.T) {
tests := []struct {
rtype string
fields map[string]string
want string
}{
{"A", map[string]string{"address": "192.0.2.1"}, "192.0.2.1"},
{"MX", map[string]string{"preference": "10", "exchange": "mail.example.com."}, "10 mail.example.com."},
{"SRV", map[string]string{"priority": "10", "weight": "20", "port": "5060", "target": "sip.example.com."},
"10 20 5060 sip.example.com."},
{"TXT", map[string]string{"text": "v=spf1 -all"}, `"v=spf1 -all"`},
{"CAA", map[string]string{"flags": "0", "tag": "issue", "value": "letsencrypt.org"},
`0 issue "letsencrypt.org"`},
}
for _, tc := range tests {
t.Run(tc.rtype, func(t *testing.T) {
got, err := AssembleRData(tc.rtype, tc.fields)
if err != nil {
t.Fatalf("assemble: %v", err)
}
if got != tc.want {
t.Errorf("assembled = %q, want %q", got, tc.want)
}
if _, err := BuildRR("example.com.", "test", tc.rtype, got, 3600); err != nil {
t.Errorf("assembled rdata does not parse: %v", err)
}
// Round trip: splitting must return the values we started with.
split := SplitRData(tc.rtype, got)
for k, v := range tc.fields {
if split[k] != v {
t.Errorf("round trip field %q = %q, want %q", k, split[k], v)
}
}
})
}
}
func TestAssembleRequiresMandatoryFields(t *testing.T) {
if _, err := AssembleRData("MX", map[string]string{"exchange": "mail.example.com."}); err == nil {
t.Error("expected an error when a required field is missing")
}
}
func TestTypeCatalogue(t *testing.T) {
// Every type the brief asks for must have a dedicated editor.
required := []string{
"A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "PTR", "CAA", "SOA",
"NAPTR", "TLSA", "SSHFP", "SVCB", "HTTPS", "DS", "DNSKEY",
}
for _, want := range required {
info, ok := TypeInfoFor(want)
if !ok {
t.Errorf("no editor is defined for %s", want)
continue
}
if len(info.Fields) == 0 {
t.Errorf("%s has an editor with no fields", want)
}
}
if _, ok := TypeInfoFor("RAW"); !ok {
t.Error("the advanced raw editor is missing")
}
}
func TestIsSubdomain(t *testing.T) {
tests := []struct {
child, parent string
want bool
}{
{"www.example.com.", "example.com.", true},
{"example.com.", "example.com.", true},
{"a.b.example.com.", "example.com.", true},
{"notexample.com.", "example.com.", false},
{"example.org.", "example.com.", false},
{"anything.", ".", true},
}
for _, tc := range tests {
if got := IsSubdomain(tc.child, tc.parent); got != tc.want {
t.Errorf("IsSubdomain(%q, %q) = %v, want %v", tc.child, tc.parent, got, tc.want)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
// Package version carries build identification for the binary.
package version
import (
"fmt"
"runtime"
"runtime/debug"
)
// These are overridable at build time with -ldflags.
var (
Version = "0.1.0"
Commit = ""
BuildDate = ""
)
// Name is the product name shown in the UI and on the CLI.
const Name = "VibeDNS"
func init() {
if Commit != "" {
return
}
info, ok := debug.ReadBuildInfo()
if !ok {
return
}
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if len(s.Value) > 12 {
Commit = s.Value[:12]
} else {
Commit = s.Value
}
case "vcs.time":
if BuildDate == "" {
BuildDate = s.Value
}
}
}
}
// Short returns just the semantic version.
func Short() string { return Version }
// Long returns a multi-line description suitable for `vibedns version`.
func Long() string {
s := fmt.Sprintf("%s %s\n", Name, Version)
if Commit != "" {
s += fmt.Sprintf("commit: %s\n", Commit)
}
if BuildDate != "" {
s += fmt.Sprintf("built: %s\n", BuildDate)
}
s += fmt.Sprintf("go: %s\n", runtime.Version())
s += fmt.Sprintf("platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
return s
}
// UserAgent identifies the server in outbound HTTP requests.
func UserAgent() string { return Name + "/" + Version }
+105
View File
@@ -0,0 +1,105 @@
package web
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
)
// Flash is a one-shot notification shown after a redirect.
//
// The interface uses HTTP Basic authentication and therefore has no session to
// hang messages off, so a flash travels in a short-lived cookie that is
// cleared as soon as it is rendered. This keeps the post/redirect/get pattern
// intact: a refresh after saving never re-submits the form.
type Flash struct {
Level string `json:"l"` // success, danger, warning, info
Message string `json:"m"`
}
const flashCookie = "vibedns_flash"
// maxFlashCookie bounds the cookie so a very long error message cannot exceed
// what browsers accept.
const maxFlashCookie = 3500
// setFlash queues a message for the next page render.
func setFlash(w http.ResponseWriter, r *http.Request, level, message string) {
f := Flash{Level: level, Message: message}
raw, err := json.Marshal([]Flash{f})
if err != nil {
return
}
value := base64.RawURLEncoding.EncodeToString(raw)
if len(value) > maxFlashCookie {
short := Flash{Level: level, Message: truncate(600, message)}
raw, _ = json.Marshal([]Flash{short})
value = base64.RawURLEncoding.EncodeToString(raw)
}
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: value,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: 60,
})
}
// takeFlashes reads and clears any queued messages.
func takeFlashes(w http.ResponseWriter, r *http.Request) []Flash {
c, err := r.Cookie(flashCookie)
if err != nil || c.Value == "" {
return nil
}
// Clear it immediately so a refresh does not show the message twice.
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil {
return nil
}
var out []Flash
if err := json.Unmarshal(raw, &out); err != nil {
return nil
}
for i := range out {
switch out[i].Level {
case "success", "danger", "warning", "info":
default:
out[i].Level = "info"
}
}
return out
}
// jsonMarshal encodes a value for embedding inside a <script> block.
//
// Go's html/template will not escape inside a script context, so the sequences
// that could terminate the element or be reinterpreted by a JavaScript parser
// are escaped here.
func jsonMarshal(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
s := string(b)
r := strings.NewReplacer(
"<", `<`,
">", `>`,
"&", `&`,
"", ``,
"", ``,
)
return []byte(r.Replace(s)), nil
}
+321
View File
@@ -0,0 +1,321 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/version"
)
// handleDashboard renders the operational overview.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) error {
dash, err := s.app.Dashboard(r.Context(), 10)
if err != nil {
return err
}
dash.Version = version.Version
udp, tcp := s.app.DNS.ListenAddrs()
data := s.base(r, "Dashboard", "dashboard")
data.Data = map[string]any{
"D": dash,
"UDPAddr": udp,
"TCPAddr": tcp,
"DBPath": s.app.DB.Path(),
"Snapshot": s.app.Snapshot(),
}
s.render(w, r, "dashboard", data)
return nil
}
// handleNotFound renders the 404 page for unmatched paths.
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) error {
s.renderError(w, r, http.StatusNotFound, "That page does not exist.")
return nil
}
func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/img/favicon.svg", http.StatusFound)
}
// --- observability ------------------------------------------------------
// handleHealthz reports process liveness. It never touches the database, so a
// database problem does not cause an orchestrator to kill a process that could
// still be serving cached and authoritative answers.
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, "ok\nversion=%s\nuptime=%s\n", version.Version, app.FormatDuration(s.app.Uptime()))
}
// handleReadyz reports whether the server can actually answer queries.
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := s.app.Ready(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "not ready: %v\n", err)
return
}
fmt.Fprintln(w, "ready")
}
// handleMetrics exposes Prometheus metrics.
//
// The endpoint reveals query volumes and cache behaviour, so it requires
// authentication unless the operator has explicitly made it public — which is
// reasonable when it is bound to a private interface behind a scraper.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
settings := s.app.Settings()
if !settings.HTTP.MetricsEnabled {
http.Error(w, "The metrics endpoint is disabled.", http.StatusNotFound)
return
}
if !settings.HTTP.MetricsPublic {
if _, err := s.app.Auth.Authenticate(r, true); err != nil {
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", auth.Realm))
http.Error(w, "Authentication required.", http.StatusUnauthorized)
return
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
s.app.Metrics.WritePrometheus(w)
}
// --- resolver -----------------------------------------------------------
func (s *Server) handleResolver(w http.ResponseWriter, r *http.Request) error {
data := s.base(r, "Resolver", "resolver")
data.Data = map[string]any{
"Settings": s.app.Settings(),
"Upstreams": s.app.Resolver.Statuses(),
"Stats": s.app.Resolver.Stats(),
"ACL": s.app.Snapshot().ACL,
}
s.render(w, r, "resolver", data)
return nil
}
func (s *Server) handleResolverTest(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
msg, err := s.app.TestUpstream(r.Context(), formString(r, "address"), formString(r, "name"))
if err != nil {
return err
}
setFlash(w, r, "success", msg)
return s.redirect(w, r, "/resolver")
}
// --- cache --------------------------------------------------------------
func (s *Server) handleCache(w http.ResponseWriter, r *http.Request) error {
search := formString(r, "q")
p := newPagination(r, 50)
entries, total := s.app.CacheEntries(search, p.PerPage, p.Offset)
data := s.base(r, "Cache", "cache")
data.Data = map[string]any{
"Stats": s.app.CacheView(),
"Entries": entries,
"Pagination": p.withTotal(total),
"Search": search,
}
s.render(w, r, "cache", data)
return nil
}
func (s *Server) handleCacheFlush(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
if name := formString(r, "name"); name != "" {
n, err := s.app.FlushCacheName(r.Context(), s.actor(r), name)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Removed %d cached entries for %s.", n, name))
return s.redirect(w, r, "/cache")
}
n, err := s.app.FlushCache(r.Context(), s.actor(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Cache flushed: %s entries removed.", humanNumber(n)))
return s.redirect(w, r, "/cache")
}
func (s *Server) handleCacheDelete(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := formString(r, "name")
qtype := formString(r, "type")
do := formBool(r, "dnssec")
if err := s.app.DeleteCacheEntry(r.Context(), s.actor(r), name, qtype, do); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Removed %s %s from the cache.", strings.TrimSuffix(name, "."), strings.ToUpper(qtype)))
s.redirectBack(w, r)
return nil
}
// --- query log ----------------------------------------------------------
func (s *Server) handleQueryLog(w http.ResponseWriter, r *http.Request) error {
p := newPagination(r, 50)
f := database.QueryLogFilter{
Domain: formString(r, "domain"),
ClientIP: formString(r, "client"),
QType: formString(r, "type"),
Rcode: formString(r, "rcode"),
Source: formString(r, "source"),
Blocked: formString(r, "blocked"),
Limit: p.PerPage,
Offset: p.Offset,
}
if v := formString(r, "network"); v != "" {
if id, err := parseInt64(v); err == nil {
f.NetworkID = id
}
}
if from, ok := parseDate(formString(r, "from"), false); ok {
f.From = from
}
if to, ok := parseDate(formString(r, "to"), true); ok {
f.To = to
}
entries, total, err := s.app.QueryLogs(r.Context(), f)
if err != nil {
return err
}
networks, _ := s.app.Networks(r.Context(), "")
data := s.base(r, "Query Log", "querylog")
data.Data = map[string]any{
"Entries": entries,
"Pagination": p.withTotal(total),
"Filter": f,
"Networks": networks,
"Enabled": s.app.Settings().QueryLog.Enabled,
"Stats": s.app.QueryLog.Stats(),
}
s.render(w, r, "querylog", data)
return nil
}
func (s *Server) handleQueryLogClear(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.ClearQueryLog(r.Context(), s.actor(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Cleared %s query log rows.", humanNumber(n)))
return s.redirect(w, r, "/querylog")
}
// --- audit log ----------------------------------------------------------
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) error {
p := newPagination(r, 50)
f := database.AuditFilter{
Search: formString(r, "q"),
ObjectType: formString(r, "object"),
Source: formString(r, "source"),
Limit: p.PerPage,
Offset: p.Offset,
}
entries, total, err := s.app.AuditLogs(r.Context(), f)
if err != nil {
return err
}
data := s.base(r, "Audit Log", "audit")
data.Data = map[string]any{
"Entries": entries,
"Pagination": p.withTotal(total),
"Filter": f,
}
s.render(w, r, "audit", data)
return nil
}
// --- tools --------------------------------------------------------------
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) error {
data := s.base(r, "Tools", "tools")
// The form map is always present, even empty: the template indexes into it
// to repopulate the fields after a submission.
data.Data = map[string]any{
"Result": nil,
"Form": map[string]string{"name": "", "type": "A", "client": ""},
}
s.render(w, r, "tools", data)
return nil
}
func (s *Server) handleToolsLookup(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := formString(r, "name")
qtype := formString(r, "type")
client := formString(r, "client")
dnssec := formBool(r, "dnssec")
result, lookupErr := s.app.Lookup(r.Context(), name, qtype, client, dnssec)
hits, _ := s.app.LookupDomain(r.Context(), name)
data := s.base(r, "Tools", "tools")
form := map[string]string{"name": name, "type": qtype, "client": client}
if dnssec {
form["dnssec"] = "on"
}
payload := map[string]any{"Result": result, "Form": form, "ListHits": hits}
if lookupErr != nil {
payload["Error"] = app.MessageOf(lookupErr)
}
data.Data = payload
s.render(w, r, "tools", data)
return nil
}
// --- account ------------------------------------------------------------
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) error {
admin, err := s.app.Admin(r.Context())
if err != nil {
return err
}
data := s.base(r, "Account", "account")
data.Data = map[string]any{"Admin": admin}
s.render(w, r, "account", data)
return nil
}
func (s *Server) handleAccountSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
err := s.app.ChangeCredentials(r.Context(), s.actor(r),
r.FormValue("current_password"),
formString(r, "username"),
r.FormValue("new_password"),
r.FormValue("confirm_password"))
if err != nil {
return err
}
setFlash(w, r, "success",
"Credentials updated. Your browser will ask you to sign in again with the new details.")
return s.redirect(w, r, "/account")
}
+583
View File
@@ -0,0 +1,583 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/models"
)
// --- Client networks ----------------------------------------------------
func (s *Server) handleNetworks(w http.ResponseWriter, r *http.Request) error {
search := formString(r, "q")
networks, err := s.app.Networks(r.Context(), search)
if err != nil {
return err
}
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "Client Networks", "networks")
data.Data = map[string]any{
"Networks": networks,
"Policies": policies,
"Search": search,
}
s.render(w, r, "networks", data)
return nil
}
func (s *Server) handleNetworkNew(w http.ResponseWriter, r *http.Request) error {
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "New Client Network", "networks")
data.Data = map[string]any{
"Network": models.Network{Enabled: true},
"Policies": policies,
"Selected": map[int64]bool{},
"IsNew": true,
"FormAction": "/policies/networks/new",
}
s.render(w, r, "network_form", data)
return nil
}
func networkInputFromForm(r *http.Request) app.NetworkInput {
enabled := formBool(r, "enabled")
return app.NetworkInput{
Name: formString(r, "name"),
CIDR: formString(r, "cidr"),
Description: formString(r, "description"),
Enabled: &enabled,
PolicyIDs: formInt64s(r, "policy_id"),
}
}
func (s *Server) handleNetworkCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.CreateNetwork(r.Context(), s.actor(r), networkInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Network %s created.", n.Name))
return s.redirect(w, r, "/policies/networks")
}
func (s *Server) handleNetworkEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
network, err := s.app.Network(r.Context(), id)
if err != nil {
return err
}
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
selected := map[int64]bool{}
for _, p := range network.Policies {
selected[p.ID] = true
}
data := s.base(r, network.Name, "networks")
data.Data = map[string]any{
"Network": network,
"Policies": policies,
"Selected": selected,
"IsNew": false,
"FormAction": fmt.Sprintf("/policies/networks/%d", id),
}
s.render(w, r, "network_form", data)
return nil
}
func (s *Server) handleNetworkUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.UpdateNetwork(r.Context(), s.actor(r), id, networkInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Network %s saved.", n.Name))
return s.redirect(w, r, "/policies/networks")
}
func (s *Server) handleNetworkToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetNetworkEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Network "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleNetworkDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteNetwork(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Network deleted.")
return s.redirect(w, r, "/policies/networks")
}
func enabledWord(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
// --- Policies -----------------------------------------------------------
func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) error {
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "Policy Rules", "policies")
data.Data = map[string]any{"Policies": policies}
s.render(w, r, "policies", data)
return nil
}
func (s *Server) handlePolicyNew(w http.ResponseWriter, r *http.Request) error {
lists, err := s.app.DomainLists(r.Context(), "", "")
if err != nil {
return err
}
data := s.base(r, "New Policy", "policies")
data.Data = map[string]any{
"Policy": models.Policy{
Enabled: true, BlockAction: models.BlockNXDOMAIN, BlockTTL: 60,
SinkholeIPv4: "0.0.0.0", SinkholeIPv6: "::",
},
"Blacklists": filterLists(lists, models.KindBlacklist),
"Allowlists": filterLists(lists, models.KindAllowlist),
"Selected": map[int64]bool{},
"IsNew": true,
"FormAction": "/policies/rules/new",
}
s.render(w, r, "policy_form", data)
return nil
}
func filterLists(lists []models.DomainList, kind string) []models.DomainList {
var out []models.DomainList
for _, l := range lists {
if l.Kind == kind {
out = append(out, l)
}
}
return out
}
func policyInputFromForm(r *http.Request) app.PolicyInput {
enabled := formBool(r, "enabled")
return app.PolicyInput{
Name: formString(r, "name"),
Description: formString(r, "description"),
Enabled: &enabled,
BlockAction: formString(r, "block_action"),
SinkholeIPv4: formString(r, "sinkhole_ipv4"),
SinkholeIPv6: formString(r, "sinkhole_ipv6"),
BlockTTL: formUint32(r, "block_ttl", 0),
ListIDs: formInt64s(r, "list_id"),
}
}
func (s *Server) handlePolicyCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
p, err := s.app.CreatePolicy(r.Context(), s.actor(r), policyInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Policy %s created.", p.Name))
return s.redirect(w, r, "/policies")
}
func (s *Server) handlePolicyEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
policy, err := s.app.Policy(r.Context(), id)
if err != nil {
return err
}
lists, err := s.app.DomainLists(r.Context(), "", "")
if err != nil {
return err
}
selected := map[int64]bool{}
for _, lid := range policy.BlacklistIDs {
selected[lid] = true
}
for _, lid := range policy.AllowlistIDs {
selected[lid] = true
}
data := s.base(r, policy.Name, "policies")
data.Data = map[string]any{
"Policy": policy,
"Blacklists": filterLists(lists, models.KindBlacklist),
"Allowlists": filterLists(lists, models.KindAllowlist),
"Selected": selected,
"IsNew": false,
"FormAction": fmt.Sprintf("/policies/rules/%d", id),
}
s.render(w, r, "policy_form", data)
return nil
}
func (s *Server) handlePolicyUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
p, err := s.app.UpdatePolicy(r.Context(), s.actor(r), id, policyInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Policy %s saved.", p.Name))
return s.redirect(w, r, "/policies")
}
func (s *Server) handlePolicyToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetPolicyEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Policy "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handlePolicyDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeletePolicy(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Policy deleted.")
return s.redirect(w, r, "/policies")
}
// --- Domain lists -------------------------------------------------------
func (s *Server) handleBlacklists(w http.ResponseWriter, r *http.Request) error {
return s.renderLists(w, r, models.KindBlacklist, "Blacklists", "blacklists")
}
func (s *Server) handleAllowlists(w http.ResponseWriter, r *http.Request) error {
return s.renderLists(w, r, models.KindAllowlist, "Allowlists", "allowlists")
}
func (s *Server) renderLists(w http.ResponseWriter, r *http.Request, kind, title, nav string) error {
search := formString(r, "q")
lists, err := s.app.DomainLists(r.Context(), kind, search)
if err != nil {
return err
}
data := s.base(r, title, nav)
data.Data = map[string]any{
"Lists": lists,
"Kind": kind,
"Search": search,
"IsBlacklist": kind == models.KindBlacklist,
}
s.render(w, r, "lists", data)
return nil
}
func (s *Server) handleListCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
in := app.ListInput{
Kind: formString(r, "kind"),
Name: formString(r, "name"),
Description: formString(r, "description"),
SourceURL: formString(r, "source_url"),
Enabled: &enabled,
}
l, err := s.app.CreateDomainList(r.Context(), s.actor(r), in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s created.", l.Name))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", l.ID))
}
func (s *Server) handleListDetail(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
p := newPagination(r, 100)
search := formString(r, "q")
entries, total, err := s.app.DomainEntries(r.Context(), id, search, p.PerPage, p.Offset)
if err != nil {
return err
}
nav := "blacklists"
if list.Kind == models.KindAllowlist {
nav = "allowlists"
}
data := s.base(r, list.Name, nav)
data.Data = map[string]any{
"List": list,
"Entries": entries,
"Pagination": p.withTotal(total),
"Search": search,
}
s.render(w, r, "list_detail", data)
return nil
}
func (s *Server) handleListUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
in := app.ListInput{
Name: formString(r, "name"),
Description: formString(r, "description"),
SourceURL: formString(r, "source_url"),
Enabled: &enabled,
}
l, err := s.app.UpdateDomainList(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s saved.", l.Name))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
func (s *Server) handleListToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetDomainListEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "List "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleListDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
if err := s.app.DeleteDomainList(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s deleted.", list.Name))
target := "/policies/blacklists"
if list.Kind == models.KindAllowlist {
target = "/policies/allowlists"
}
return s.redirect(w, r, target)
}
func (s *Server) handleListClear(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.ClearDomains(r.Context(), s.actor(r), id)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Removed %s domains.", humanNumber(n)))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
// handleListImport accepts a pasted list or an uploaded file.
func (s *Server) handleListImport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
maxMB := s.app.Settings().HTTP.MaxUploadMB
if err := parseMultipart(r, 8); err != nil {
return err
}
matchSubdomains := formBool(r, "match_subdomains")
pasted := r.FormValue("content")
var reader = strings.NewReader(pasted)
if file, header, ferr := r.FormFile("file"); ferr == nil {
defer file.Close()
// The upload is streamed straight into the parser rather than being
// buffered as a string: blocklists routinely run to tens of megabytes.
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, file, matchSubdomains)
if err != nil {
return err
}
s.flashImportSummary(w, r, header.Filename, summary)
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
if strings.TrimSpace(pasted) == "" {
return app.Invalid("Paste a list of domains or choose a file to upload (up to %d MB).", maxMB)
}
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, reader, matchSubdomains)
if err != nil {
return err
}
s.flashImportSummary(w, r, "", summary)
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
// flashImportSummary reports exactly what the import did, which is the only
// way an operator can tell a 300,000 line file was handled correctly.
func (s *Server) flashImportSummary(w http.ResponseWriter, r *http.Request, filename string, sum models.ImportSummary) {
var b strings.Builder
if filename != "" {
fmt.Fprintf(&b, "Imported %s: ", filename)
} else {
b.WriteString("Import complete: ")
}
fmt.Fprintf(&b, "%s lines processed, %s domains added",
humanNumber(sum.LinesProcessed), humanNumber(sum.Imported))
if sum.Duplicates > 0 {
fmt.Fprintf(&b, ", %s duplicates skipped", humanNumber(sum.Duplicates))
}
if sum.Invalid > 0 {
fmt.Fprintf(&b, ", %s invalid entries", humanNumber(sum.Invalid))
}
if sum.Ignored > 0 {
fmt.Fprintf(&b, ", %s comments or blank lines ignored", humanNumber(sum.Ignored))
}
b.WriteString(".")
setFlash(w, r, "success", b.String())
}
func (s *Server) handleListExport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
filename := strings.ReplaceAll(strings.ToLower(list.Name), " ", "-") + ".txt"
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Streamed straight to the client: a large list must not be buffered.
if _, err := s.app.ExportDomains(r.Context(), id, w); err != nil {
s.log.Error("list export failed mid-stream", "list", list.Name, "error", err)
}
return nil
}
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
entry, err := s.app.AddDomain(r.Context(), s.actor(r), id,
formString(r, "domain"), formBool(r, "match_subdomains"), formString(r, "comment"))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Added %s.", entry.Domain))
s.redirectBack(w, r)
return nil
}
func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteDomain(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Domain removed.")
s.redirectBack(w, r)
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package web
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/validate"
)
// recordInputFromForm assembles a record from either the type-specific editor
// (field_* inputs) or the advanced raw editor.
func recordInputFromForm(r *http.Request) app.RecordInput {
enabled := formBool(r, "enabled")
in := app.RecordInput{
Name: formString(r, "name"),
Type: formString(r, "type"),
TTL: formUint32Ptr(r, "ttl"),
Enabled: &enabled,
Comment: formString(r, "comment"),
}
if formBool(r, "advanced") {
// The advanced editor supplies rdata verbatim, and may override the
// record type with one that has no dedicated editor.
in.Data = formString(r, "data")
if t := formString(r, "field_rtype"); t != "" {
in.Type = t
}
if d := formString(r, "field_rdata"); d != "" {
in.Data = d
}
return in
}
fields := map[string]string{}
for key, values := range r.Form {
if !strings.HasPrefix(key, "field_") || len(values) == 0 {
continue
}
fields[strings.TrimPrefix(key, "field_")] = values[0]
}
if len(fields) > 0 {
in.Fields = fields
} else {
in.Data = formString(r, "data")
}
return in
}
func (s *Server) handleRecordCreate(w http.ResponseWriter, r *http.Request) error {
zoneID, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
rec, err := s.app.CreateRecord(r.Context(), s.actor(r), zoneID, recordInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Added %s %s record.", rec.Name, rec.Type))
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
rec, err := s.app.UpdateRecord(r.Context(), s.actor(r), id, recordInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Saved %s %s record.", rec.Name, rec.Type))
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteRecord(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Record deleted.")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetRecordEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
state := "disabled"
if enabled {
state = "enabled"
}
setFlash(w, r, "success", "Record "+state+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleRecordBulk(w http.ResponseWriter, r *http.Request) error {
zoneID, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
ids := formInt64s(r, "record_id")
action := app.BulkAction(formString(r, "action"))
n, err := s.app.BulkRecords(r.Context(), s.actor(r), zoneID, ids, action)
if err != nil {
return err
}
verb := map[app.BulkAction]string{
app.BulkDelete: "deleted",
app.BulkEnable: "enabled",
app.BulkDisable: "disabled",
}[action]
setFlash(w, r, "success", fmt.Sprintf("%d record%s %s.", n, plural(n), verb))
s.redirectBack(w, r)
return nil
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// recordFieldValues splits a stored record back into editor fields, so an
// existing record opens in its dedicated form rather than as raw rdata.
func recordFieldValues(rtype, data string) map[string]string {
return validate.SplitRData(rtype, data)
}
// copyLimited copies at most n bytes, reporting an error past the limit.
func copyLimited(dst io.Writer, src io.Reader, n int64) (int64, error) {
return io.Copy(dst, io.LimitReader(src, n))
}
+398
View File
@@ -0,0 +1,398 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/config"
)
// settingsPage renders one settings tab with the current configuration.
func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request, page, subnav, title string, extra map[string]any) {
data := s.base(r, title, "settings")
data.Subnav = subnav
payload := map[string]any{"S": s.app.Settings()}
for k, v := range extra {
payload[k] = v
}
data.Data = payload
s.render(w, r, page, data)
}
// --- DNS ----------------------------------------------------------------
func (s *Server) handleSettingsDNS(w http.ResponseWriter, r *http.Request) error {
udp, tcp := s.app.DNS.ListenAddrs()
s.settingsPage(w, r, "settings_dns", "dns", "DNS Settings", map[string]any{
"BoundUDP": udp,
"BoundTCP": tcp,
"Running": s.app.DNS.Running(),
})
return nil
}
func (s *Server) handleSettingsDNSSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.DNS.UDPListen = formString(r, "udp_listen")
next.DNS.TCPListen = formString(r, "tcp_listen")
next.DNS.Recursion = formBool(r, "recursion")
next.DNS.EDNSEnabled = formBool(r, "edns_enabled")
next.DNS.EDNSUDPSize = formInt(r, "edns_udp_size", next.DNS.EDNSUDPSize)
next.DNS.MaxUDPResponse = formInt(r, "max_udp_response", next.DNS.MaxUDPResponse)
next.DNS.DefaultTTL = formUint32(r, "default_ttl", next.DNS.DefaultTTL)
next.DNS.TCPIdleSeconds = formInt(r, "tcp_idle", next.DNS.TCPIdleSeconds)
next.DNS.ExposeVersion = formBool(r, "expose_version")
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupDNS, next); err != nil {
return err
}
setFlash(w, r, "success", "DNS settings saved.")
return s.redirect(w, r, "/settings/dns")
}
// --- Resolver -----------------------------------------------------------
func (s *Server) handleSettingsResolver(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_resolver", "resolver", "Resolver Settings", map[string]any{
"Upstreams": s.app.Resolver.Statuses(),
"Strategies": []string{
config.StrategyFastest, config.StrategySequential,
config.StrategyRoundRobin, config.StrategyRandom,
},
})
return nil
}
func (s *Server) handleSettingsResolverSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Resolver.Upstreams = config.SplitLines(r.FormValue("upstreams"))
next.Resolver.TimeoutMS = formInt(r, "timeout_ms", next.Resolver.TimeoutMS)
next.Resolver.Retries = formInt(r, "retries", next.Resolver.Retries)
next.Resolver.Strategy = formString(r, "strategy")
next.Resolver.AllowNetworks = config.SplitLines(r.FormValue("allow_networks"))
next.Resolver.DenyNetworks = config.SplitLines(r.FormValue("deny_networks"))
next.Resolver.PreferIPv6 = formBool(r, "prefer_ipv6")
next.Resolver.DNSSEC = formBool(r, "dnssec")
next.Resolver.MaxConcurrent = formInt(r, "max_concurrent", next.Resolver.MaxConcurrent)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupResolver, next); err != nil {
return err
}
setFlash(w, r, "success", "Resolver settings saved and applied.")
return s.redirect(w, r, "/settings/resolver")
}
// --- Cache --------------------------------------------------------------
func (s *Server) handleSettingsCache(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_cache", "cache", "Cache Settings", map[string]any{
"Stats": s.app.CacheStats(),
})
return nil
}
func (s *Server) handleSettingsCacheSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Cache.Enabled = formBool(r, "enabled")
next.Cache.MaxEntries = formInt(r, "max_entries", next.Cache.MaxEntries)
next.Cache.MinTTL = formInt(r, "min_ttl", next.Cache.MinTTL)
next.Cache.MaxTTL = formInt(r, "max_ttl", next.Cache.MaxTTL)
next.Cache.NegativeTTL = formInt(r, "negative_ttl", next.Cache.NegativeTTL)
next.Cache.ServeStale = formBool(r, "serve_stale")
next.Cache.StaleTTL = formInt(r, "stale_ttl", next.Cache.StaleTTL)
next.Cache.Prefetch = formBool(r, "prefetch")
next.Cache.PrefetchPercent = formInt(r, "prefetch_pct", next.Cache.PrefetchPercent)
next.Cache.CleanupSeconds = formInt(r, "cleanup_seconds", next.Cache.CleanupSeconds)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupCache, next); err != nil {
return err
}
setFlash(w, r, "success", "Cache settings saved and applied.")
return s.redirect(w, r, "/settings/cache")
}
// --- Logging ------------------------------------------------------------
func (s *Server) handleSettingsLogging(w http.ResponseWriter, r *http.Request) error {
rows, _ := s.app.DB.QueryLogCount(r.Context())
s.settingsPage(w, r, "settings_logging", "logging", "Logging Settings", map[string]any{
"Stats": s.app.QueryLog.Stats(),
"QueryLogRows": rows,
})
return nil
}
func (s *Server) handleSettingsLoggingSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.QueryLog.Enabled = formBool(r, "querylog_enabled")
next.QueryLog.RetentionDays = formInt(r, "retention_days", next.QueryLog.RetentionDays)
next.QueryLog.MaxRows = formInt(r, "max_rows", next.QueryLog.MaxRows)
next.QueryLog.CleanupMinutes = formInt(r, "cleanup_minutes", next.QueryLog.CleanupMinutes)
next.QueryLog.IgnoreNetworks = config.SplitLines(r.FormValue("ignore_networks"))
next.QueryLog.IgnoreDomains = config.SplitLines(r.FormValue("ignore_domains"))
next.Logging.Level = formString(r, "log_level")
next.Logging.Format = formString(r, "log_format")
next.Logging.AuditMaxRows = formInt(r, "audit_max_rows", next.Logging.AuditMaxRows)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupLogging, next); err != nil {
return err
}
setFlash(w, r, "success", "Logging settings saved and applied.")
return s.redirect(w, r, "/settings/logging")
}
// --- HTTP and rate limiting ---------------------------------------------
func (s *Server) handleSettingsHTTP(w http.ResponseWriter, r *http.Request) error {
s.settingsPage(w, r, "settings_http", "http", "Web Server Settings", map[string]any{
"RateLimit": s.app.Limiter.Stats(),
"Bound": s.app.Boot.HTTPAddr,
})
return nil
}
func (s *Server) handleSettingsHTTPSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.HTTP.Listen = formString(r, "listen")
next.HTTP.BaseURL = formString(r, "base_url")
next.HTTP.TrustedProxies = config.SplitLines(r.FormValue("trusted_proxies"))
next.HTTP.MetricsEnabled = formBool(r, "metrics_enabled")
next.HTTP.MetricsPublic = formBool(r, "metrics_public")
next.HTTP.MaxUploadMB = formInt(r, "max_upload_mb", next.HTTP.MaxUploadMB)
next.HTTP.RateLimitPerMin = formInt(r, "http_rate_limit", next.HTTP.RateLimitPerMin)
next.RateLimit.Enabled = formBool(r, "dns_ratelimit_enabled")
next.RateLimit.QPS = formInt(r, "dns_qps", next.RateLimit.QPS)
next.RateLimit.Burst = formInt(r, "dns_burst", next.RateLimit.Burst)
next.RateLimit.ExemptNetworks = config.SplitLines(r.FormValue("exempt_networks"))
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupHTTP, next); err != nil {
return err
}
setFlash(w, r, "success", "Web server settings saved.")
return s.redirect(w, r, "/settings/http")
}
// --- Database and backups -----------------------------------------------
func (s *Server) handleSettingsDatabase(w http.ResponseWriter, r *http.Request) error {
stats, err := s.app.DatabaseStats(r.Context())
if err != nil {
return err
}
backups, err := s.app.BackupList()
if err != nil {
// A missing or unreadable directory should not hide the whole page.
s.log.Warn("could not list backups", "error", err)
}
migrations, _ := s.app.DB.MigrationStatuses(r.Context())
s.settingsPage(w, r, "settings_database", "database", "Database Settings", map[string]any{
"DBStats": stats,
"Backups": backups,
"BackupStatus": s.app.BackupStatus(),
"Migrations": migrations,
"PendingRestore": s.app.PendingRestore(),
})
return nil
}
func (s *Server) handleSettingsDatabaseSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.Backup.Enabled = formBool(r, "backup_enabled")
next.Backup.Directory = formString(r, "backup_dir")
next.Backup.IntervalHours = formInt(r, "interval_hours", next.Backup.IntervalHours)
next.Backup.Retention = formInt(r, "retention", next.Backup.Retention)
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupBackup, next); err != nil {
return err
}
setFlash(w, r, "success", "Backup settings saved.")
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupNow(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
info, err := s.app.RunBackup(r.Context(), s.actor(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Backup %s created (%s).", info.Name, humanBytes(info.SizeBytes)))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupDownload(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
f, info, err := s.app.OpenBackup(name)
if err != nil {
return err
}
defer f.Close()
w.Header().Set("Content-Type", "application/vnd.sqlite3")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", info.Name))
w.Header().Set("Content-Length", fmt.Sprint(info.SizeBytes))
if _, err := copyLimited(w, f, info.SizeBytes); err != nil {
s.log.Warn("backup download interrupted", "backup", info.Name, "error", err)
}
return nil
}
func (s *Server) handleBackupDelete(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := r.PathValue("name")
if err := s.app.DeleteBackup(r.Context(), s.actor(r), name); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Backup %s deleted.", name))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleBackupRestore(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
name := r.PathValue("name")
// Restoring replaces every zone, record and policy. Requiring the file
// name to be retyped makes it very hard to do by accident.
if formString(r, "confirm") != name {
return app.Invalid("Type the backup file name exactly (%s) to confirm the restore.", name)
}
if err := s.app.StageRestore(r.Context(), s.actor(r), name); err != nil {
return err
}
setFlash(w, r, "warning", fmt.Sprintf(
"Backup %s is staged. It replaces the live database the next time this server starts. "+
"Restart now to apply it, or cancel below.", name))
return s.redirect(w, r, "/settings/database")
}
func (s *Server) handleRestoreCancel(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
if err := s.app.CancelRestore(r.Context(), s.actor(r)); err != nil {
return err
}
setFlash(w, r, "success", "The staged restore was cancelled.")
return s.redirect(w, r, "/settings/database")
}
// --- API tokens and configuration transfer ------------------------------
func (s *Server) handleSettingsAPI(w http.ResponseWriter, r *http.Request) error {
tokens, err := s.app.APITokens(r.Context())
if err != nil {
return err
}
// A token secret is shown exactly once, immediately after creation, via a
// single-use flash carried across the redirect.
s.settingsPage(w, r, "settings_api", "api", "API Settings", map[string]any{
"Tokens": tokens,
"BaseURL": s.app.Settings().HTTP.BaseURL,
})
return nil
}
func (s *Server) handleTokenCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
tok, err := s.app.CreateAPIToken(r.Context(), s.actor(r), formString(r, "name"), formString(r, "description"))
if err != nil {
return err
}
setFlash(w, r, "warning", fmt.Sprintf(
"Token %q created. Copy it now, it is not shown again: %s", tok.Name, tok.Secret))
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleTokenToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetAPITokenEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Token "+enabledWord(enabled)+".")
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleTokenDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteAPIToken(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Token revoked.")
return s.redirect(w, r, "/settings/api")
}
func (s *Server) handleConfigExport(w http.ResponseWriter, r *http.Request) error {
includeDomains := formBool(r, "include_domains")
filename := fmt.Sprintf("vibedns-config-%s.json", s.app.StartedAt().UTC().Format("20060102"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
return s.app.WriteConfigExport(r.Context(), w, includeDomains)
}
func (s *Server) handleConfigImport(w http.ResponseWriter, r *http.Request) error {
if err := parseMultipart(r, 16); err != nil {
return err
}
file, _, ferr := r.FormFile("file")
if ferr != nil {
return app.Invalid("Choose a configuration export file to import.")
}
defer file.Close()
report, err := s.app.ImportConfig(r.Context(), s.actor(r), file, formBool(r, "apply_settings"))
if err != nil {
return err
}
msg := fmt.Sprintf("Imported %d zones, %d records, %d networks, %d policies, %d lists and %s domains.",
report.Zones, report.Records, report.Networks, report.Policies, report.Lists, humanNumber(report.Domains))
if len(report.Conflicts) > 0 {
msg += " Existing objects were left unchanged: " + strings.Join(report.Conflicts, "; ") + "."
}
setFlash(w, r, "success", msg)
return s.redirect(w, r, "/settings/database")
}
+336
View File
@@ -0,0 +1,336 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/zonefile"
)
func (s *Server) handleZones(w http.ResponseWriter, r *http.Request) error {
return s.renderZoneList(w, r, "forward", "Forward Zones", "zones")
}
func (s *Server) handleZonesReverse(w http.ResponseWriter, r *http.Request) error {
return s.renderZoneList(w, r, "reverse", "Reverse Zones", "zones-reverse")
}
func (s *Server) renderZoneList(w http.ResponseWriter, r *http.Request, kind, title, nav string) error {
search := formString(r, "q")
zones, err := s.app.Zones(r.Context(), database.ZoneFilter{Kind: kind, Search: search})
if err != nil {
return err
}
data := s.base(r, title, nav)
data.Data = map[string]any{
"Zones": zones,
"Kind": kind,
"Search": search,
"Reverse": kind == "reverse",
}
s.render(w, r, "zones", data)
return nil
}
func (s *Server) handleZoneNew(w http.ResponseWriter, r *http.Request) error {
kind := formString(r, "kind")
if kind == "" {
kind = "forward"
}
data := s.base(r, "New Zone", navForKind(kind))
data.Data = map[string]any{
"Zone": models.Zone{DefaultTTL: s.app.Settings().DNS.DefaultTTL, Enabled: true, AutoSerial: true, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 3600},
"Kind": kind,
"IsNew": true,
"FormAction": "/zones/new",
}
s.render(w, r, "zone_form", data)
return nil
}
func navForKind(kind string) string {
if strings.HasPrefix(kind, "reverse") {
return "zones-reverse"
}
return "zones"
}
func (s *Server) handleZoneCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
in := zoneInputFromForm(r)
zone, err := s.app.CreateZone(r.Context(), s.actor(r), in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone %s created.", strings.TrimSuffix(zone.Name, ".")))
return s.redirect(w, r, fmt.Sprintf("/zones/%d", zone.ID))
}
func zoneInputFromForm(r *http.Request) app.ZoneInput {
enabled := formBool(r, "enabled")
autoSerial := formBool(r, "auto_serial")
in := app.ZoneInput{
Name: formString(r, "name"),
Kind: formString(r, "kind"),
CIDR: formString(r, "cidr"),
Description: formString(r, "description"),
Enabled: &enabled,
DefaultTTL: formUint32(r, "default_ttl", 0),
PrimaryNS: formString(r, "primary_ns"),
AdminEmail: formString(r, "admin_email"),
Refresh: formUint32(r, "refresh", 0),
Retry: formUint32(r, "retry", 0),
Expire: formUint32(r, "expire", 0),
Minimum: formUint32(r, "minimum", 0),
AutoSerial: &autoSerial,
}
// A serial is only taken from the form when the operator asked to override
// it, so a normal save never rewinds an automatically managed serial.
if formBool(r, "override_serial") {
in.Serial = formUint32Ptr(r, "serial")
}
return in
}
func (s *Server) handleZoneEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, err := s.app.Zone(r.Context(), id)
if err != nil {
return err
}
data := s.base(r, "Edit "+strings.TrimSuffix(zone.Name, "."), navForKind(string(zone.Kind)))
data.Data = map[string]any{
"Zone": zone,
"Kind": string(zone.Kind),
"IsNew": false,
"FormAction": fmt.Sprintf("/zones/%d/edit", id),
}
s.render(w, r, "zone_form", data)
return nil
}
func (s *Server) handleZoneUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
zone, err := s.app.UpdateZone(r.Context(), s.actor(r), id, zoneInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone %s saved.", strings.TrimSuffix(zone.Name, ".")))
return s.redirect(w, r, fmt.Sprintf("/zones/%d", zone.ID))
}
func (s *Server) handleZoneToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetZoneEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
state := "disabled"
if enabled {
state = "enabled"
}
setFlash(w, r, "success", "Zone "+state+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleZoneDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
zone, err := s.app.Zone(r.Context(), id)
if err != nil {
return err
}
if err := s.app.DeleteZone(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone %s and its records were deleted.", strings.TrimSuffix(zone.Name, ".")))
target := "/zones"
if zone.Kind != models.ZoneForward {
target = "/zones/reverse"
}
return s.redirect(w, r, target)
}
func (s *Server) handleZoneClone(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
clone, err := s.app.CloneZone(r.Context(), s.actor(r), id, formString(r, "name"), formString(r, "description"))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Zone cloned to %s.", strings.TrimSuffix(clone.Name, ".")))
return s.redirect(w, r, fmt.Sprintf("/zones/%d", clone.ID))
}
func (s *Server) handleZoneExport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, body, err := s.app.ExportZoneFile(r.Context(), id)
if err != nil {
return err
}
filename := zonefile.SuggestFilename(zone.Name)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
_, _ = w.Write(body)
return nil
}
func (s *Server) handleZoneImport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseMultipart(r, s.app.Settings().HTTP.MaxUploadMB); err != nil {
return err
}
mode := app.ImportMode(formString(r, "mode"))
body := formString(r, "content")
var reader = strings.NewReader(body)
if file, header, ferr := r.FormFile("file"); ferr == nil {
defer file.Close()
buf := new(strings.Builder)
if _, err := copyLimited(buf, file, int64(s.app.Settings().HTTP.MaxUploadMB)*1024*1024); err != nil {
return app.Invalid("The uploaded file %q could not be read.", header.Filename)
}
reader = strings.NewReader(buf.String())
} else if strings.TrimSpace(body) == "" {
return app.Invalid("Choose a zone file to upload, or paste its contents.")
}
result, err := s.app.ImportZoneFile(r.Context(), s.actor(r), id, reader, mode)
if err != nil {
return err
}
msg := fmt.Sprintf("Imported %d records into %s.",
result.Summary.RecordsParsed, strings.TrimSuffix(result.Zone.Name, "."))
if result.Summary.Skipped > 0 {
msg += fmt.Sprintf(" %d entries were skipped.", result.Summary.Skipped)
}
setFlash(w, r, "success", msg)
return s.redirect(w, r, fmt.Sprintf("/zones/%d", id))
}
// handleZoneRecords renders one zone's record table.
func (s *Server) handleZoneRecords(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
zone, err := s.app.Zone(r.Context(), id)
if err != nil {
return err
}
p := newPagination(r, 100)
f := database.RecordFilter{
ZoneID: id,
Search: formString(r, "q"),
Type: formString(r, "type"),
Enabled: formString(r, "status"),
Limit: p.PerPage,
Offset: p.Offset,
}
records, total, err := s.app.Records(r.Context(), f)
if err != nil {
return err
}
types, _ := s.app.RecordTypesInUse(r.Context(), id)
data := s.base(r, strings.TrimSuffix(zone.Name, "."), navForKind(string(zone.Kind)))
data.Data = map[string]any{
"Zone": zone,
"Records": records,
"Pagination": p.withTotal(total),
"Filter": f,
"TypesInUse": types,
"RecordTypes": s.app.RecordTypes(),
"Problems": s.app.Runtime.ZoneProblems(id),
"CommonTypes": commonTypes(s.app.RecordTypes()),
}
s.render(w, r, "records", data)
return nil
}
func commonTypes(all []validate.TypeInfo) []validate.TypeInfo {
var out []validate.TypeInfo
for _, t := range all {
if t.Common {
out = append(out, t)
}
}
return out
}
// handleRecordsAll is the cross-zone record search.
func (s *Server) handleRecordsAll(w http.ResponseWriter, r *http.Request) error {
p := newPagination(r, 50)
f := database.RecordFilter{
Search: formString(r, "q"),
Type: formString(r, "type"),
Enabled: formString(r, "status"),
Limit: p.PerPage,
Offset: p.Offset,
}
if v := formString(r, "zone"); v != "" {
if id, err := parseInt64(v); err == nil {
f.ZoneID = id
}
}
records, total, err := s.app.Records(r.Context(), f)
if err != nil {
return err
}
zones, _ := s.app.Zones(r.Context(), database.ZoneFilter{})
types, _ := s.app.RecordTypesInUse(r.Context(), 0)
data := s.base(r, "Records", "records")
data.Data = map[string]any{
"Records": records,
"Pagination": p.withTotal(total),
"Filter": f,
"Zones": zones,
"TypesInUse": types,
}
s.render(w, r, "records_all", data)
return nil
}
+365
View File
@@ -0,0 +1,365 @@
package web_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/owen/vibedns/internal/api"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/web"
)
const testPassword = "an-adequate-test-phrase"
// newTestServer builds the whole HTTP stack against a temporary database and
// seeds one of every object, so page rendering is exercised with real data
// rather than only against empty tables.
func newTestServer(t *testing.T) (http.Handler, *app.App) {
t.Helper()
ctx := context.Background()
path := filepath.Join(t.TempDir(), "web.db")
db, err := database.Open(path)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
boot := config.DefaultBootstrap()
boot.DBPath = path
a, err := app.New(ctx, boot, db, log)
if err != nil {
t.Fatalf("build app: %v", err)
}
if _, _, err := a.Auth.EnsureAdmin(ctx, "admin", testPassword); err != nil {
t.Fatalf("create admin: %v", err)
}
actor := auditlog.Actor{Name: "test", Source: auditlog.SourceCLI}
zone, err := a.CreateZone(ctx, actor, app.ZoneInput{Name: "example.com"})
if err != nil {
t.Fatalf("seed zone: %v", err)
}
for _, in := range []app.RecordInput{
{Name: "@", Type: "A", Data: "192.0.2.10"},
{Name: "www", Type: "CNAME", Data: "example.com."},
{Name: "txt", Type: "TXT", Data: `"hello"`},
} {
if _, err := a.CreateRecord(ctx, actor, zone.ID, in); err != nil {
t.Fatalf("seed record: %v", err)
}
}
if _, err := a.CreateZone(ctx, actor, app.ZoneInput{CIDR: "192.168.1.0/24", Kind: "reverse4"}); err != nil {
t.Fatalf("seed reverse zone: %v", err)
}
list, err := a.CreateDomainList(ctx, actor, app.ListInput{Kind: models.KindBlacklist, Name: "Seeded"})
if err != nil {
t.Fatalf("seed list: %v", err)
}
if _, err := a.ImportDomains(ctx, actor, list.ID, strings.NewReader("ads.example\n"), true); err != nil {
t.Fatalf("seed domains: %v", err)
}
policy, err := a.CreatePolicy(ctx, actor, app.PolicyInput{
Name: "Seeded Policy", BlockAction: "nxdomain", ListIDs: []int64{list.ID},
})
if err != nil {
t.Fatalf("seed policy: %v", err)
}
if _, err := a.CreateNetwork(ctx, actor, app.NetworkInput{
Name: "Seeded Net", CIDR: "100.64.30.0/24", PolicyIDs: []int64{policy.ID},
}); err != nil {
t.Fatalf("seed network: %v", err)
}
if _, err := a.CreateAPIToken(ctx, actor, "seeded-token", ""); err != nil {
t.Fatalf("seed token: %v", err)
}
srv, err := web.New(web.Options{App: a, Log: log, API: api.New(a, log).Handler()})
if err != nil {
t.Fatalf("build web server: %v", err)
}
return srv.Handler(), a
}
func get(t *testing.T, h http.Handler, path string, auth bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if auth {
req.SetBasicAuth("admin", testPassword)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestEveryPageRenders walks every GET route in the interface. A template that
// indexes a value the handler forgot to supply fails here rather than as a 500
// the first time an operator opens that page.
func TestEveryPageRenders(t *testing.T) {
h, _ := newTestServer(t)
paths := []string{
"/", "/dashboard",
"/zones", "/zones/reverse", "/zones/new", "/zones/new?kind=reverse4",
"/zones/1", "/zones/1/edit", "/zones/1?q=www&type=A&status=enabled",
"/records", "/records?search=192.0.2&type=A",
"/resolver", "/cache", "/cache?q=example",
"/policies", "/policies/networks", "/policies/networks/new", "/policies/networks/1",
"/policies/rules/new", "/policies/rules/1",
"/policies/blacklists", "/policies/allowlists", "/policies/lists/1",
"/policies/lists/1?q=ads",
"/querylog", "/querylog?domain=example&blocked=blocked",
"/audit", "/audit?q=zone",
"/tools", "/account",
"/settings", "/settings/dns", "/settings/resolver", "/settings/cache",
"/settings/logging", "/settings/http", "/settings/database", "/settings/api",
}
for _, p := range paths {
t.Run(p, func(t *testing.T) {
rec := get(t, h, p, true)
if rec.Code >= 500 {
t.Fatalf("GET %s = %d\n%s", p, rec.Code, truncateBody(rec.Body.String()))
}
if rec.Code != http.StatusOK && rec.Code != http.StatusSeeOther &&
rec.Code != http.StatusFound {
t.Errorf("GET %s = %d, want 200 or a redirect", p, rec.Code)
}
// A rendered page must actually contain the layout, not a stub.
if rec.Code == http.StatusOK && strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
body := rec.Body.String()
if !strings.Contains(body, "</html>") {
t.Errorf("GET %s produced a truncated page", p)
}
}
})
}
}
func truncateBody(s string) string {
if len(s) > 800 {
return s[:800] + "..."
}
return s
}
// TestPagesRequireAuthentication is the guard that every administrative route
// is actually protected.
func TestPagesRequireAuthentication(t *testing.T) {
h, _ := newTestServer(t)
protected := []string{
"/", "/zones", "/records", "/resolver", "/cache", "/policies",
"/policies/networks", "/policies/blacklists", "/querylog", "/audit",
"/tools", "/account", "/settings/dns", "/settings/api",
"/api/v1/zones", "/api/v1/settings", "/api/v1/stats",
}
for _, p := range protected {
t.Run(p, func(t *testing.T) {
if rec := get(t, h, p, false); rec.Code != http.StatusUnauthorized {
t.Errorf("GET %s without credentials = %d, want 401", p, rec.Code)
}
})
}
}
func TestPublicEndpoints(t *testing.T) {
h, _ := newTestServer(t)
t.Run("healthz", func(t *testing.T) {
rec := get(t, h, "/healthz", false)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "ok") {
t.Errorf("body = %q", rec.Body.String())
}
})
t.Run("readyz reports not ready without listeners", func(t *testing.T) {
// The DNS listeners are not started in this test, so readiness must
// report that rather than claiming everything is fine.
rec := get(t, h, "/readyz", false)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 when the DNS listeners are down", rec.Code)
}
})
t.Run("static assets", func(t *testing.T) {
for _, p := range []string{
"/static/css/app.css", "/static/css/bootstrap.min.css",
"/static/js/app.js", "/static/fonts/bootstrap-icons.woff2",
} {
rec := get(t, h, p, false)
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", p, rec.Code)
}
if rec.Body.Len() == 0 {
t.Errorf("GET %s returned an empty body", p)
}
}
})
t.Run("metrics requires auth by default", func(t *testing.T) {
if rec := get(t, h, "/metrics", false); rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401: metrics must not be public by default", rec.Code)
}
rec := get(t, h, "/metrics", true)
if rec.Code != http.StatusOK {
t.Fatalf("authenticated status = %d, want 200", rec.Code)
}
for _, want := range []string{"vibedns_dns_queries_total", "vibedns_build_info", "vibedns_cache_entries"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("metrics output is missing %q", want)
}
}
})
}
func TestSecurityHeaders(t *testing.T) {
h, _ := newTestServer(t)
rec := get(t, h, "/", true)
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
}
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("header %s = %q, want %q", k, got, v)
}
}
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" {
t.Fatal("no Content-Security-Policy header")
}
// The policy must not permit inline scripts: page data travels in data-
// attributes precisely so it does not have to.
if strings.Contains(csp, "script-src") && strings.Contains(csp, "'unsafe-inline' 'self'") {
t.Error("the CSP allows inline scripts")
}
for _, want := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP is missing %q: %s", want, csp)
}
}
}
// TestStateChangingRequestNeedsCSRF confirms the protection is actually wired
// up, not merely present in the code.
func TestStateChangingRequestNeedsCSRF(t *testing.T) {
h, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/zones/1/delete", nil)
req.SetBasicAuth("admin", testPassword)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("POST without a CSRF token = %d, want 403", rec.Code)
}
}
func TestAPIReturnsJSON(t *testing.T) {
h, _ := newTestServer(t)
rec := get(t, h, "/api/v1/zones", true)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("content type = %q, want JSON", ct)
}
if !strings.Contains(rec.Body.String(), "example.com.") {
t.Errorf("the seeded zone is missing from the response: %s", truncateBody(rec.Body.String()))
}
// A missing object must be a JSON 404, not an HTML error page.
rec = get(t, h, "/api/v1/zones/9999", true)
if rec.Code != http.StatusNotFound {
t.Errorf("missing zone status = %d, want 404", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"code"`) {
t.Errorf("error body is not the standard shape: %s", rec.Body.String())
}
}
func TestAPITokenAuthentication(t *testing.T) {
h, a := newTestServer(t)
ctx := context.Background()
tok, err := a.CreateAPIToken(ctx, auditlog.Actor{Name: "test"}, "auth-test", "")
if err != nil {
t.Fatalf("create token: %v", err)
}
if tok.Secret == "" {
t.Fatal("no secret returned at creation")
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/zones", nil)
req.Header.Set("Authorization", "Bearer "+tok.Secret)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("bearer token status = %d, want 200", rec.Code)
}
// A wrong token must be rejected.
req = httptest.NewRequest(http.MethodGet, "/api/v1/zones", nil)
req.Header.Set("Authorization", "Bearer vibedns_thisisnotarealtokenvalue")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("invalid token status = %d, want 401", rec.Code)
}
}
// TestAPIWriteWithTokenSkipsCSRF: automation must be able to write without
// obtaining a CSRF token, since a bearer token cannot be replayed by a browser.
func TestAPIWriteWithTokenSkipsCSRF(t *testing.T) {
h, a := newTestServer(t)
ctx := context.Background()
tok, err := a.CreateAPIToken(ctx, auditlog.Actor{Name: "test"}, "write-test", "")
if err != nil {
t.Fatalf("create token: %v", err)
}
body := strings.NewReader(`{"name":"api-created.example"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/zones", body)
req.Header.Set("Authorization", "Bearer "+tok.Secret)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want 201: %s", rec.Code, truncateBody(rec.Body.String()))
}
}
func TestNotFoundPage(t *testing.T) {
h, _ := newTestServer(t)
rec := get(t, h, "/no/such/page", true)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
+592
View File
@@ -0,0 +1,592 @@
package web
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"math"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/version"
webui "github.com/owen/vibedns/web"
)
// PageData is the envelope every template receives. Page-specific values live
// under .Data; everything else is chrome the layout needs.
type PageData struct {
Title string
Nav string
Subnav string
User auth.Principal
CSRF string
Flashes []Flash
Alerts []Alert
Version string
Now time.Time
Data any
Query url.Values
BasePath string
}
// Alert is a persistent banner such as "a restart is required".
type Alert struct {
Level string // warning, danger, info
Title string
Message string
Link string
LinkText string
}
// templates holds one parsed template set per page.
type templates struct {
sets map[string]*template.Template
}
// layoutFiles are parsed into every page set.
var layoutFiles = []string{"layout.html", "partials.html"}
// loadTemplates parses each page against the shared layout.
//
// Each page gets its own template set rather than one global set, because Go
// templates are keyed by name: two pages both defining "content" in a single
// set would silently overwrite each other.
func loadTemplates(funcs template.FuncMap) (*templates, error) {
src := webui.Templates()
pages, err := fs.Glob(src, "pages/*.html")
if err != nil {
return nil, fmt.Errorf("list page templates: %w", err)
}
if len(pages) == 0 {
return nil, fmt.Errorf("no page templates were found in the binary")
}
t := &templates{sets: make(map[string]*template.Template, len(pages))}
for _, page := range pages {
name := strings.TrimSuffix(path.Base(page), ".html")
files := append(append([]string{}, layoutFiles...), page)
set, err := template.New("layout.html").Funcs(funcs).ParseFS(src, files...)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", page, err)
}
t.sets[name] = set
}
return t, nil
}
// render executes a page template into a buffer first, so a template error
// produces a proper error page instead of a half-written response.
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data PageData) {
set, ok := s.tmpl.sets[page]
if !ok {
s.log.Error("template not found", "page", page)
s.renderError(w, r, http.StatusInternalServerError, "This page could not be rendered.")
return
}
data.Version = version.Version
data.Now = time.Now()
if data.Query == nil {
data.Query = r.URL.Query()
}
data.Flashes = append(data.Flashes, takeFlashes(w, r)...)
data.Alerts = append(data.Alerts, s.systemAlerts(r)...)
var buf bytes.Buffer
if err := set.ExecuteTemplate(&buf, "layout.html", data); err != nil {
s.log.Error("could not render page", "page", page, "error", err)
s.renderError(w, r, http.StatusInternalServerError, "This page could not be rendered.")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if _, err := buf.WriteTo(w); err != nil {
s.log.Debug("could not write response", "error", err)
}
}
// renderError shows a friendly error page. Stack traces and internal error
// text never reach the browser.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
set, ok := s.tmpl.sets["error"]
if !ok {
http.Error(w, message, status)
return
}
data := PageData{
Title: http.StatusText(status),
Version: version.Version,
Now: time.Now(),
Data: map[string]any{
"Status": status,
"Text": http.StatusText(status),
"Message": message,
},
}
if p, ok := auth.PrincipalFrom(r.Context()); ok {
data.User = p
data.CSRF = s.app.Auth.IssueCSRFToken(p.Name)
}
var buf bytes.Buffer
if err := set.ExecuteTemplate(&buf, "layout.html", data); err != nil {
http.Error(w, message, status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
// systemAlerts assembles the banners shown across every page.
func (s *Server) systemAlerts(r *http.Request) []Alert {
var out []Alert
if admin, err := s.app.Admin(r.Context()); err == nil && admin.MustChangePassword {
out = append(out, Alert{
Level: "warning",
Title: "Change the generated password",
Message: "This account still uses the password printed at first startup. Set your own before exposing the interface.",
Link: "/account",
LinkText: "Change it now",
})
}
if pending := s.app.PendingRestart(r.Context()); len(pending) > 0 {
out = append(out, Alert{
Level: "info",
Title: "Restart required",
Message: "These settings are saved but will not take effect until the server restarts: " + strings.Join(pending, "; ") + ".",
})
}
if s.app.PendingRestore() {
out = append(out, Alert{
Level: "danger",
Title: "Database restore staged",
Message: "A backup will replace the live database the next time this server starts.",
Link: "/settings/database",
LinkText: "Review",
})
}
if problems := s.app.Snapshot().Problems; len(problems) > 0 {
out = append(out, Alert{
Level: "warning",
Title: fmt.Sprintf("%d record(s) could not be loaded", len(problems)),
Message: "Some records are invalid and are not being served. " +
"Open the affected zone to see which ones.",
Link: "/zones",
LinkText: "Review zones",
})
}
return out
}
// templateFuncs are the helpers available to every template.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"num": humanNumber,
"bytes": humanBytes,
"pct": formatPercent,
"ms": formatMillis,
"duration": formatDuration,
"timeAgo": timeAgo,
"datetime": formatDateTime,
"dateOnly": func(t time.Time) string { return t.Local().Format("2006-01-02") },
"timeOnly": func(t time.Time) string { return t.Local().Format("15:04:05") },
"rfc3339": func(t time.Time) string { return t.UTC().Format(time.RFC3339) },
"zeroTime": func(t time.Time) bool { return t.IsZero() },
"dict": dict,
"list": func(v ...any) []any { return v },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"seq": seq,
"join": strings.Join,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"contains": strings.Contains,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"title": titleCase,
"trimDot": func(s string) string { return strings.TrimSuffix(s, ".") },
"truncate": truncate,
"default": defaultValue,
"yesno": func(b bool) string {
if b {
return "Yes"
}
return "No"
},
"badgeFor": badgeFor,
"rcodeBadge": rcodeBadge,
"sourceBadge": sourceBadge,
"typeBadge": typeBadge,
"withQuery": withQuery,
"pages": paginationRange,
"json": toJSON,
"rdataFields": validate.SplitRData,
"boolstr": boolString,
"toggleIcon": toggleIcon,
"toggleVerb": toggleVerb,
"statusWord": statusWord,
"pick": ternary,
"nl2br": nl2br,
"lines": func(s string) []string { return strings.Split(strings.TrimSpace(s), "\n") },
"joinLines": func(v []string) string { return strings.Join(v, "\n") },
}
}
func humanNumber(v any) string {
var n int64
switch t := v.(type) {
case int:
n = int64(t)
case int32:
n = int64(t)
case int64:
n = t
case uint32:
n = int64(t)
case float64:
n = int64(t)
default:
return fmt.Sprint(v)
}
s := strconv.FormatInt(n, 10)
neg := strings.HasPrefix(s, "-")
s = strings.TrimPrefix(s, "-")
var out []string
for len(s) > 3 {
out = append([]string{s[len(s)-3:]}, out...)
s = s[:len(s)-3]
}
out = append([]string{s}, out...)
res := strings.Join(out, ",")
if neg {
return "-" + res
}
return res
}
func humanBytes(v any) string {
var n float64
switch t := v.(type) {
case int:
n = float64(t)
case int64:
n = float64(t)
case float64:
n = t
default:
return fmt.Sprint(v)
}
const unit = 1024.0
if n < unit {
return fmt.Sprintf("%.0f B", n)
}
units := []string{"KB", "MB", "GB", "TB"}
for _, u := range units {
n /= unit
if n < unit {
return fmt.Sprintf("%.1f %s", n, u)
}
}
return fmt.Sprintf("%.1f PB", n)
}
func formatPercent(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return "0.0%"
}
return fmt.Sprintf("%.1f%%", v)
}
func formatMillis(v float64) string {
if v < 1 {
return fmt.Sprintf("%.2f ms", v)
}
return fmt.Sprintf("%.1f ms", v)
}
func formatDuration(d time.Duration) string {
if d < time.Millisecond {
return fmt.Sprintf("%.0f µs", float64(d.Microseconds()))
}
if d < time.Second {
return fmt.Sprintf("%.1f ms", float64(d.Microseconds())/1000)
}
return d.Round(time.Millisecond).String()
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return "never"
}
return t.Local().Format("2006-01-02 15:04:05")
}
func timeAgo(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < 0:
return "just now"
case d < time.Minute:
return fmt.Sprintf("%d seconds ago", int(d.Seconds()))
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "a minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "an hour ago"
}
return fmt.Sprintf("%d hours ago", h)
case d < 30*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "yesterday"
}
return fmt.Sprintf("%d days ago", days)
default:
return t.Local().Format("2006-01-02")
}
}
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict needs an even number of arguments")
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
m[key] = values[i+1]
}
return m, nil
}
func seq(from, to int) []int {
if to < from {
return nil
}
out := make([]int, 0, to-from+1)
for i := from; i <= to; i++ {
out = append(out, i)
}
return out
}
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func truncate(n int, s string) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}
func defaultValue(def, v any) any {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) == "" {
return def
}
case nil:
return def
case int:
if t == 0 {
return def
}
}
return v
}
// badgeFor maps an enabled flag to a Bootstrap badge class.
func badgeFor(enabled bool) string {
if enabled {
return "text-bg-success"
}
return "text-bg-secondary"
}
func rcodeBadge(rcode string) string {
switch strings.ToUpper(rcode) {
case "NOERROR":
return "text-bg-success"
case "NXDOMAIN":
return "text-bg-warning"
case "REFUSED", "SERVFAIL", "DROPPED":
return "text-bg-danger"
default:
return "text-bg-secondary"
}
}
func sourceBadge(source string) string {
switch source {
case "authoritative":
return "text-bg-primary"
case "cache":
return "text-bg-info"
case "stale":
return "text-bg-warning"
case "recursive":
return "text-bg-secondary"
case "blocked":
return "text-bg-danger"
case "refused", "ratelimited":
return "text-bg-dark"
case "error":
return "text-bg-danger"
default:
return "text-bg-light text-dark"
}
}
// typeBadge colours a record type so the record table scans quickly.
func typeBadge(t string) string {
switch strings.ToUpper(t) {
case "A", "AAAA":
return "type-addr"
case "CNAME", "DNAME":
return "type-alias"
case "MX", "SRV", "NAPTR", "SVCB", "HTTPS":
return "type-service"
case "NS", "SOA":
return "type-auth"
case "TXT", "SPF", "CAA":
return "type-text"
case "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "TLSA", "SSHFP":
return "type-sec"
case "PTR":
return "type-ptr"
default:
return "type-other"
}
}
// withQuery rebuilds the current query string with one key replaced, which is
// what pagination and sort links need.
func withQuery(q url.Values, pairs ...any) template.URL {
next := url.Values{}
for k, v := range q {
next[k] = append([]string{}, v...)
}
for i := 0; i+1 < len(pairs); i += 2 {
key := fmt.Sprint(pairs[i])
val := fmt.Sprint(pairs[i+1])
if val == "" {
next.Del(key)
} else {
next.Set(key, val)
}
}
if len(next) == 0 {
return template.URL("?")
}
return template.URL("?" + next.Encode())
}
// paginationRange returns the page numbers to show around the current page.
func paginationRange(current, total int) []int {
if total <= 1 {
return nil
}
const window = 2
start := current - window
if start < 1 {
start = 1
}
end := current + window
if end > total {
end = total
}
return seq(start, end)
}
// toJSON renders a value as JSON for a data- attribute.
//
// It deliberately returns a plain string rather than template.JS: the value is
// always placed in an HTML attribute, where html/template escapes it, and the
// page reads it back with JSON.parse. That keeps every byte of page data out
// of inline <script> blocks, which the Content-Security-Policy forbids.
func toJSON(v any) (string, error) {
b, err := jsonMarshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
// ternary picks between two values, for the small either/or choices templates
// make inline (an icon name, a CSS class) where a full if/else is noise.
// It is registered as "pick" because "if" is a template keyword.
func ternary(cond bool, whenTrue, whenFalse any) any {
if cond {
return whenTrue
}
return whenFalse
}
// boolString renders a bool as a form value the server will parse back.
func boolString(b bool) string {
if b {
return "true"
}
return "false"
}
// toggleIcon picks the switch icon showing the current state.
func toggleIcon(enabled bool) string {
if enabled {
return "bi-toggle-on"
}
return "bi-toggle-off"
}
// toggleVerb names the action a toggle button performs.
func toggleVerb(currentlyEnabled bool) string {
if currentlyEnabled {
return "Disable"
}
return "Enable"
}
// statusWord labels the current state.
func statusWord(enabled bool) string {
if enabled {
return "Enabled"
}
return "Disabled"
}
func nl2br(s string) template.HTML {
escaped := template.HTMLEscapeString(s)
return template.HTML(strings.ReplaceAll(escaped, "\n", "<br>"))
}
+512
View File
@@ -0,0 +1,512 @@
// Package web serves the Bootstrap management interface, the observability
// endpoints and the static assets. The REST API is mounted underneath it.
package web
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/netutil"
webui "github.com/owen/vibedns/web"
)
// Server is the HTTP management interface.
type Server struct {
app *app.App
log *slog.Logger
tmpl *templates
http *http.Server
apiMount http.Handler
limiter *httpLimiter
mu sync.Mutex
addr string
}
// Options configures the HTTP server.
type Options struct {
App *app.App
Log *slog.Logger
// API is mounted at /api/v1 when non-nil.
API http.Handler
}
// New creates the HTTP server and parses the embedded templates.
func New(opts Options) (*Server, error) {
tmpl, err := loadTemplates(templateFuncs())
if err != nil {
return nil, err
}
s := &Server{
app: opts.App,
log: opts.Log,
tmpl: tmpl,
apiMount: opts.API,
limiter: newHTTPLimiter(),
}
return s, nil
}
// Handler builds the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
var h http.Handler = mux
h = s.withBodyLimit(h)
h = s.withRateLimit(h)
h = s.withRequestLog(h)
h = s.withSecurityHeaders(h)
h = s.withRecover(h)
return h
}
// Start binds the management listener.
func (s *Server) Start(addr string) error {
h := s.Handler()
s.mu.Lock()
s.addr = addr
s.http = &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 2 * time.Minute, // large blocklist uploads
WriteTimeout: 5 * time.Minute, // large exports
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug),
}
srv := s.http
s.mu.Unlock()
ln, err := listen(addr)
if err != nil {
return err
}
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("management HTTP server stopped", "error", err)
}
}()
s.log.Info("management interface started", "address", addr)
return nil
}
// Shutdown stops the management listener.
func (s *Server) Shutdown(ctx context.Context) error {
s.mu.Lock()
srv := s.http
s.mu.Unlock()
if srv == nil {
return nil
}
return srv.Shutdown(ctx)
}
// routes registers every endpoint.
func (s *Server) routes(mux *http.ServeMux) {
// Public endpoints. Static assets are unauthenticated because they are
// Bootstrap and our own CSS: requiring credentials for them would force an
// Argon2 verification on every asset request for no benefit.
static := http.FileServer(http.FS(webui.Static()))
mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(static)))
mux.HandleFunc("GET /favicon.ico", s.handleFavicon)
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /metrics", s.handleMetrics)
if s.apiMount != nil {
mux.Handle("/api/v1/", s.apiMount)
}
// Everything below requires the administrator.
page := s.protect
mux.Handle("GET /{$}", page(s.handleDashboard))
mux.Handle("GET /dashboard", page(s.handleDashboard))
// Zones.
mux.Handle("GET /zones", page(s.handleZones))
mux.Handle("GET /zones/reverse", page(s.handleZonesReverse))
mux.Handle("GET /zones/new", page(s.handleZoneNew))
mux.Handle("POST /zones/new", page(s.handleZoneCreate))
mux.Handle("GET /zones/{id}", page(s.handleZoneRecords))
mux.Handle("GET /zones/{id}/edit", page(s.handleZoneEdit))
mux.Handle("POST /zones/{id}/edit", page(s.handleZoneUpdate))
mux.Handle("POST /zones/{id}/delete", page(s.handleZoneDelete))
mux.Handle("POST /zones/{id}/toggle", page(s.handleZoneToggle))
mux.Handle("POST /zones/{id}/clone", page(s.handleZoneClone))
mux.Handle("GET /zones/{id}/export", page(s.handleZoneExport))
mux.Handle("POST /zones/{id}/import", page(s.handleZoneImport))
// Records.
mux.Handle("GET /records", page(s.handleRecordsAll))
mux.Handle("POST /zones/{id}/records", page(s.handleRecordCreate))
mux.Handle("POST /zones/{id}/records/bulk", page(s.handleRecordBulk))
mux.Handle("POST /records/{id}/edit", page(s.handleRecordUpdate))
mux.Handle("POST /records/{id}/delete", page(s.handleRecordDelete))
mux.Handle("POST /records/{id}/toggle", page(s.handleRecordToggle))
// Resolver and cache.
mux.Handle("GET /resolver", page(s.handleResolver))
mux.Handle("POST /resolver/test", page(s.handleResolverTest))
mux.Handle("GET /cache", page(s.handleCache))
mux.Handle("POST /cache/flush", page(s.handleCacheFlush))
mux.Handle("POST /cache/delete", page(s.handleCacheDelete))
// Policies.
mux.Handle("GET /policies", page(s.handlePolicies))
mux.Handle("GET /policies/networks", page(s.handleNetworks))
mux.Handle("GET /policies/networks/new", page(s.handleNetworkNew))
mux.Handle("POST /policies/networks/new", page(s.handleNetworkCreate))
mux.Handle("GET /policies/networks/{id}", page(s.handleNetworkEdit))
mux.Handle("POST /policies/networks/{id}", page(s.handleNetworkUpdate))
mux.Handle("POST /policies/networks/{id}/delete", page(s.handleNetworkDelete))
mux.Handle("POST /policies/networks/{id}/toggle", page(s.handleNetworkToggle))
mux.Handle("GET /policies/rules/new", page(s.handlePolicyNew))
mux.Handle("POST /policies/rules/new", page(s.handlePolicyCreate))
mux.Handle("GET /policies/rules/{id}", page(s.handlePolicyEdit))
mux.Handle("POST /policies/rules/{id}", page(s.handlePolicyUpdate))
mux.Handle("POST /policies/rules/{id}/delete", page(s.handlePolicyDelete))
mux.Handle("POST /policies/rules/{id}/toggle", page(s.handlePolicyToggle))
mux.Handle("GET /policies/blacklists", page(s.handleBlacklists))
mux.Handle("GET /policies/allowlists", page(s.handleAllowlists))
mux.Handle("POST /policies/lists/new", page(s.handleListCreate))
mux.Handle("GET /policies/lists/{id}", page(s.handleListDetail))
mux.Handle("POST /policies/lists/{id}", page(s.handleListUpdate))
mux.Handle("POST /policies/lists/{id}/delete", page(s.handleListDelete))
mux.Handle("POST /policies/lists/{id}/toggle", page(s.handleListToggle))
mux.Handle("POST /policies/lists/{id}/clear", page(s.handleListClear))
mux.Handle("POST /policies/lists/{id}/import", page(s.handleListImport))
mux.Handle("GET /policies/lists/{id}/export", page(s.handleListExport))
mux.Handle("POST /policies/lists/{id}/domains", page(s.handleDomainAdd))
mux.Handle("POST /policies/domains/{id}/delete", page(s.handleDomainDelete))
// Logs.
mux.Handle("GET /querylog", page(s.handleQueryLog))
mux.Handle("POST /querylog/clear", page(s.handleQueryLogClear))
mux.Handle("GET /audit", page(s.handleAuditLog))
// Tools.
mux.Handle("GET /tools", page(s.handleTools))
mux.Handle("POST /tools/lookup", page(s.handleToolsLookup))
// Settings.
mux.Handle("GET /settings", page(s.redirectTo("/settings/dns")))
mux.Handle("GET /settings/dns", page(s.handleSettingsDNS))
mux.Handle("POST /settings/dns", page(s.handleSettingsDNSSave))
mux.Handle("GET /settings/resolver", page(s.handleSettingsResolver))
mux.Handle("POST /settings/resolver", page(s.handleSettingsResolverSave))
mux.Handle("GET /settings/cache", page(s.handleSettingsCache))
mux.Handle("POST /settings/cache", page(s.handleSettingsCacheSave))
mux.Handle("GET /settings/logging", page(s.handleSettingsLogging))
mux.Handle("POST /settings/logging", page(s.handleSettingsLoggingSave))
mux.Handle("GET /settings/http", page(s.handleSettingsHTTP))
mux.Handle("POST /settings/http", page(s.handleSettingsHTTPSave))
mux.Handle("GET /settings/database", page(s.handleSettingsDatabase))
mux.Handle("POST /settings/database", page(s.handleSettingsDatabaseSave))
mux.Handle("POST /settings/database/backup", page(s.handleBackupNow))
mux.Handle("GET /settings/database/backup/{name}", page(s.handleBackupDownload))
mux.Handle("POST /settings/database/backup/{name}/delete", page(s.handleBackupDelete))
mux.Handle("POST /settings/database/backup/{name}/restore", page(s.handleBackupRestore))
mux.Handle("POST /settings/database/restore/cancel", page(s.handleRestoreCancel))
mux.Handle("GET /settings/api", page(s.handleSettingsAPI))
mux.Handle("POST /settings/api/tokens", page(s.handleTokenCreate))
mux.Handle("POST /settings/api/tokens/{id}/delete", page(s.handleTokenDelete))
mux.Handle("POST /settings/api/tokens/{id}/toggle", page(s.handleTokenToggle))
mux.Handle("GET /settings/config/export", page(s.handleConfigExport))
mux.Handle("POST /settings/config/import", page(s.handleConfigImport))
// Account.
mux.Handle("GET /account", page(s.handleAccount))
mux.Handle("POST /account", page(s.handleAccountSave))
// Anything unmatched. Registered without a method so it does not conflict
// with the "/api/v1/" prefix pattern: ServeMux rejects a method-specific
// catch-all that is more general in path than an existing prefix route.
mux.Handle("/", page(s.handleNotFound))
}
// handlerFunc is a page handler that may return an error for central handling.
type handlerFunc func(http.ResponseWriter, *http.Request) error
// protect wraps a page handler with authentication and CSRF enforcement.
func (s *Server) protect(fn handlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.app.Auth.Authenticate(r, false)
if err != nil {
s.challenge(w, r, err)
return
}
ctx := auth.WithPrincipal(r.Context(), p)
r = r.WithContext(ctx)
// The CSRF cookie is refreshed on every page view so it never expires
// out from under an open tab.
token := s.app.Auth.IssueCSRFToken(p.Name)
if r.Method == http.MethodGet {
auth.SetCSRFCookie(w, r, token)
} else if err := s.app.Auth.CheckCSRF(r, p); err != nil {
s.log.Warn("rejected a request that failed the CSRF check",
"path", r.URL.Path, "client", p.ClientIP)
s.renderError(w, r, http.StatusForbidden, err.Error())
return
}
if err := fn(w, r); err != nil {
s.handleError(w, r, err)
}
})
}
// challenge sends the Basic authentication challenge, or a lockout message.
func (s *Server) challenge(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, auth.ErrLockedOut):
w.Header().Set("Retry-After", "300")
s.renderError(w, r, http.StatusTooManyRequests,
"Too many failed sign-in attempts from this address. Try again in a few minutes.")
case errors.Is(err, auth.ErrNoAdmin):
s.renderError(w, r, http.StatusServiceUnavailable,
"No administrator account exists yet. Restart the server to create one.")
default:
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", auth.Realm))
s.renderError(w, r, http.StatusUnauthorized, "Sign in to continue.")
}
}
// handleError turns a service error into a flash or an error page.
func (s *Server) handleError(w http.ResponseWriter, r *http.Request, err error) {
if app.IsInternal(err) {
s.log.Error("request failed", "path", r.URL.Path, "method", r.Method, "error", err)
}
status := app.StatusOf(err)
message := app.MessageOf(err)
// A failed form submission returns the operator to the page they were on
// with the reason shown, rather than dumping them on an error page.
if r.Method == http.MethodPost && status < 500 {
setFlash(w, r, "danger", message)
s.redirectBack(w, r)
return
}
s.renderError(w, r, status, message)
}
// redirectBack returns to the referring page, or a supplied fallback.
func (s *Server) redirectBack(w http.ResponseWriter, r *http.Request) {
target := r.FormValue("return_to")
if target == "" {
target = safeReferer(r)
}
if target == "" {
target = "/"
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// safeReferer only accepts a same-origin path, so a crafted Referer cannot
// turn an error into an open redirect.
func safeReferer(r *http.Request) string {
ref := r.Header.Get("Referer")
if ref == "" {
return ""
}
if strings.HasPrefix(ref, "/") && !strings.HasPrefix(ref, "//") {
return ref
}
if u, err := parseURL(ref); err == nil && u.Host == r.Host {
return u.RequestURI()
}
return ""
}
func (s *Server) redirectTo(target string) handlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
}
// redirect issues a see-other redirect after a successful mutation.
func (s *Server) redirect(w http.ResponseWriter, r *http.Request, target string) error {
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
// actor builds the audit actor for the current request.
func (s *Server) actor(r *http.Request) auditlog.Actor {
p, _ := auth.PrincipalFrom(r.Context())
source := auditlog.SourceWeb
if p.Kind == auth.KindToken {
source = auditlog.SourceAPI
}
return auditlog.Actor{Name: p.Name, Source: source, ClientIP: p.ClientIP}
}
// base builds the common page envelope.
func (s *Server) base(r *http.Request, title, nav string) PageData {
p, _ := auth.PrincipalFrom(r.Context())
return PageData{
Title: title,
Nav: nav,
User: p,
CSRF: s.app.Auth.IssueCSRFToken(p.Name),
Query: r.URL.Query(),
}
}
// pathID reads an {id} path parameter.
func pathID(r *http.Request, name string) (int64, error) {
raw := r.PathValue(name)
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, app.Invalid("%q is not a valid identifier.", raw)
}
return id, nil
}
// --- middleware ---------------------------------------------------------
func (s *Server) withRecover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic while handling request",
"path", r.URL.Path, "panic", rec, "stack", string(debug.Stack()))
// The stack trace goes to the log, never to the browser.
s.renderError(w, r, http.StatusInternalServerError,
"An unexpected error occurred. Check the server log for details.")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), interest-cohort=()")
// Everything is served from this origin: no CDN, no inline event
// handlers, no eval. 'unsafe-inline' is allowed for style attributes
// only, which Bootstrap components set programmatically.
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self'; "+
"connect-src 'self'; "+
"form-action 'self'; "+
"frame-ancestors 'none'; "+
"base-uri 'none'; "+
"object-src 'none'")
if r.TLS != nil {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
// statusRecorder captures the response status for logging.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusRecorder) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func (s *Server) withRequestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if strings.HasPrefix(r.URL.Path, "/static/") {
return // asset noise
}
level := slog.LevelDebug
if rec.status >= 500 {
level = slog.LevelError
} else if rec.status >= 400 {
level = slog.LevelWarn
}
s.log.Log(r.Context(), level, "http request",
"method", r.Method, "path", r.URL.Path, "status", rec.status,
"bytes", rec.bytes, "duration_ms", time.Since(start).Milliseconds(),
"client", s.app.Auth.ClientIP(r))
})
}
// withBodyLimit caps request bodies so an oversized upload cannot exhaust
// memory. The limit follows the configured upload size.
func (s *Server) withBodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
limit := int64(s.app.Settings().HTTP.MaxUploadMB) * 1024 * 1024
if limit <= 0 {
limit = 64 * 1024 * 1024
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
// withRateLimit applies a coarse per-address request limit to the management
// interface. It is a brute-force and runaway-script guard, not a DoS defence.
func (s *Server) withRateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
perMin := s.app.Settings().HTTP.RateLimitPerMin
addr, ok := netutil.AddrFromHostPort(s.app.Auth.ClientIP(r))
if ok && !s.limiter.allow(addr.String(), perMin) {
w.Header().Set("Retry-After", "60")
http.Error(w, "Too many requests. Slow down and try again shortly.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// cacheStatic marks embedded assets as immutable. They only change when the
// binary changes, and the binary is what serves them.
func cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
next.ServeHTTP(w, r)
})
}
+264
View File
@@ -0,0 +1,264 @@
package web
import (
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/app"
)
// listen binds the management address, explaining common failures.
func listen(addr string) (net.Listener, error) {
ln, err := net.Listen("tcp", addr)
if err == nil {
return ln, nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "address already in use"):
return nil, fmt.Errorf("management interface cannot bind %s: the address is already in use", addr)
case strings.Contains(msg, "permission denied"):
return nil, fmt.Errorf("management interface cannot bind %s: permission denied "+
"(ports below 1024 need root or CAP_NET_BIND_SERVICE)", addr)
default:
return nil, fmt.Errorf("management interface cannot bind %s: %w", addr, err)
}
}
func parseURL(s string) (*url.URL, error) { return url.Parse(s) }
// httpLimiter is a coarse fixed-window request counter per client address.
type httpLimiter struct {
mu sync.Mutex
windows map[string]*window
lastGC time.Time
}
type window struct {
count int
start time.Time
}
func newHTTPLimiter() *httpLimiter {
return &httpLimiter{windows: map[string]*window{}}
}
func (l *httpLimiter) allow(key string, perMinute int) bool {
if perMinute <= 0 {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if now.Sub(l.lastGC) > 5*time.Minute {
for k, w := range l.windows {
if now.Sub(w.start) > 2*time.Minute {
delete(l.windows, k)
}
}
l.lastGC = now
}
w, ok := l.windows[key]
if !ok || now.Sub(w.start) >= time.Minute {
l.windows[key] = &window{count: 1, start: now}
return true
}
w.count++
return w.count <= perMinute
}
// --- form helpers -------------------------------------------------------
// formString reads a trimmed form value.
func formString(r *http.Request, key string) string {
return strings.TrimSpace(r.FormValue(key))
}
// formBool reads a checkbox. Boolean fields pair a hidden false value with a
// checkbox true value, so a checked field arrives as ["false", "true"]. Treat
// the field as true when any submitted value is true instead of relying on
// FormValue, which only returns the first value.
func formBool(r *http.Request, key string) bool {
if err := r.ParseForm(); err != nil {
return false
}
for _, raw := range r.Form[key] {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "on", "yes":
return true
}
}
return false
}
// formBoolPtr returns nil when the field was not submitted at all.
func formBoolPtr(r *http.Request, key string) *bool {
if err := r.ParseForm(); err != nil {
return nil
}
if _, ok := r.Form[key]; !ok {
return nil
}
v := formBool(r, key)
return &v
}
// formInt reads an integer form field, falling back to def when empty.
func formInt(r *http.Request, key string, def int) int {
raw := formString(r, key)
if raw == "" {
return def
}
v, err := strconv.Atoi(raw)
if err != nil {
return def
}
return v
}
// formUint32 reads an unsigned form field.
func formUint32(r *http.Request, key string, def uint32) uint32 {
raw := formString(r, key)
if raw == "" {
return def
}
v, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return def
}
return uint32(v)
}
// formUint32Ptr returns nil when the field is empty, which distinguishes
// "inherit the zone default" from an explicit value.
func formUint32Ptr(r *http.Request, key string) *uint32 {
raw := formString(r, key)
if raw == "" {
return nil
}
v, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return nil
}
out := uint32(v)
return &out
}
// formInt64s reads a repeated integer field, such as a set of checkboxes.
func formInt64s(r *http.Request, key string) []int64 {
var out []int64
for _, raw := range r.Form[key] {
v, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil && v > 0 {
out = append(out, v)
}
}
return out
}
// parseForm parses the request body, reporting an oversized upload clearly.
func parseForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
if strings.Contains(err.Error(), "http: request body too large") {
return app.Invalid("The submitted data is larger than the configured upload limit.")
}
return app.Invalid("The form data could not be read: %v", err)
}
return nil
}
// parseMultipart parses a file upload up to the configured limit.
func parseMultipart(r *http.Request, maxMemoryMB int) error {
if maxMemoryMB <= 0 {
maxMemoryMB = 8
}
if err := r.ParseMultipartForm(int64(maxMemoryMB) * 1024 * 1024); err != nil {
if strings.Contains(err.Error(), "http: request body too large") {
return app.Invalid("The uploaded file is larger than the configured upload limit.")
}
return app.Invalid("The upload could not be read: %v", err)
}
return nil
}
// pagination computes offsets from query parameters.
type pagination struct {
Page int
PerPage int
Offset int
Total int
Pages int
HasPrev bool
HasNext bool
From int
To int
}
func newPagination(r *http.Request, defaultPerPage int) pagination {
page := formInt(r, "page", 1)
if page < 1 {
page = 1
}
per := formInt(r, "per_page", defaultPerPage)
switch {
case per < 10:
per = 10
case per > 500:
per = 500
}
return pagination{Page: page, PerPage: per, Offset: (page - 1) * per}
}
// withTotal fills in the derived fields once the row count is known.
func (p pagination) withTotal(total int) pagination {
p.Total = total
p.Pages = (total + p.PerPage - 1) / p.PerPage
if p.Pages < 1 {
p.Pages = 1
}
p.HasPrev = p.Page > 1
p.HasNext = p.Page < p.Pages
p.From = p.Offset + 1
p.To = p.Offset + p.PerPage
if p.To > total {
p.To = total
}
if total == 0 {
p.From = 0
}
return p
}
// parseInt64 parses a numeric query parameter.
func parseInt64(s string) (int64, error) {
return strconv.ParseInt(strings.TrimSpace(s), 10, 64)
}
// parseDate reads a date or datetime filter from a form field. endOfDay
// extends a bare date to 23:59:59 so a "to" filter includes that whole day.
func parseDate(s string, endOfDay bool) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02"} {
t, err := time.ParseInLocation(layout, s, time.Local)
if err != nil {
continue
}
if endOfDay && layout == "2006-01-02" {
t = t.Add(24*time.Hour - time.Second)
}
return t, true
}
return time.Time{}, false
}
+33
View File
@@ -0,0 +1,33 @@
package web
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestFormBool(t *testing.T) {
tests := []struct {
name string
values url.Values
want bool
}{
{name: "missing", values: url.Values{}, want: false},
{name: "hidden unchecked value", values: url.Values{"enabled": {"false"}}, want: false},
{name: "hidden and checked values", values: url.Values{"enabled": {"false", "true"}}, want: true},
{name: "checked value first", values: url.Values{"enabled": {"true", "false"}}, want: true},
{name: "browser checkbox value", values: url.Values{"enabled": {"on"}}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := tt.values.Encode()
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if got := formBool(req, "enabled"); got != tt.want {
t.Fatalf("formBool() = %t, want %t for %q", got, tt.want, body)
}
})
}
}
+120
View File
@@ -0,0 +1,120 @@
package web
import (
"io/fs"
"path"
"strings"
"testing"
webui "github.com/owen/vibedns/web"
)
// TestTemplatesParse fails the build if any embedded template has a syntax
// error or calls a function that is not registered. Without this, a typo in a
// template only surfaces when a user opens that particular page.
func TestTemplatesParse(t *testing.T) {
tmpl, err := loadTemplates(templateFuncs())
if err != nil {
t.Fatalf("templates did not parse: %v", err)
}
pages, err := fs.Glob(webui.Templates(), "pages/*.html")
if err != nil {
t.Fatalf("could not list pages: %v", err)
}
if len(pages) == 0 {
t.Fatal("no page templates were embedded")
}
for _, p := range pages {
name := strings.TrimSuffix(path.Base(p), ".html")
if _, ok := tmpl.sets[name]; !ok {
t.Errorf("page %s produced no template set", name)
}
}
t.Logf("parsed %d page templates", len(pages))
}
// TestEveryRoutedPageHasTemplate guards against a handler rendering a page name
// that does not exist, which would otherwise be a 500 at runtime.
func TestEveryRoutedPageHasTemplate(t *testing.T) {
tmpl, err := loadTemplates(templateFuncs())
if err != nil {
t.Fatalf("templates did not parse: %v", err)
}
// Every name passed to s.render anywhere in the package.
rendered := []string{
"dashboard", "error", "zones", "zone_form", "records", "records_all",
"resolver", "cache", "networks", "network_form", "policies", "policy_form",
"lists", "list_detail", "querylog", "audit", "tools", "account",
"settings_dns", "settings_resolver", "settings_cache", "settings_logging",
"settings_http", "settings_database", "settings_api",
}
for _, name := range rendered {
if _, ok := tmpl.sets[name]; !ok {
t.Errorf("handler renders %q but no such template exists", name)
}
}
}
// TestStaticAssetsEmbedded confirms the offline assets actually made it into
// the binary. The management interface must work without Internet access.
func TestStaticAssetsEmbedded(t *testing.T) {
static := webui.Static()
required := []string{
"css/bootstrap.min.css",
"css/bootstrap-icons.min.css",
"css/app.css",
"js/bootstrap.bundle.min.js",
"js/chart.umd.js",
"js/app.js",
"fonts/bootstrap-icons.woff2",
"img/favicon.svg",
}
for _, name := range required {
f, err := static.Open(name)
if err != nil {
t.Errorf("static asset %s is missing from the binary: %v", name, err)
continue
}
info, err := f.Stat()
if err == nil && info.Size() == 0 {
t.Errorf("static asset %s is empty", name)
}
f.Close()
}
}
// TestNoCDNReferences guards the offline guarantee: a stray absolute URL in a
// template or stylesheet would silently break the UI on an air-gapped network.
func TestNoCDNReferences(t *testing.T) {
check := func(fsys fs.FS, label string) {
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
switch path.Ext(p) {
case ".html", ".css", ".js":
default:
return nil
}
body, err := fs.ReadFile(fsys, p)
if err != nil {
return err
}
for _, bad := range []string{"https://cdn.", "http://cdn.", "//cdn.jsdelivr", "//unpkg.com"} {
if strings.Contains(string(body), bad) {
t.Errorf("%s/%s references an external CDN (%q); assets must be served from the binary",
label, p, bad)
}
}
return nil
})
if err != nil {
t.Fatalf("walking %s: %v", label, err)
}
}
check(webui.Templates(), "templates")
check(webui.Static(), "static")
}
+371
View File
@@ -0,0 +1,371 @@
// Package zonefile converts between BIND-format zone files and the record
// rows stored in SQLite.
//
// Parsing is delegated to the DNS library's zone parser, so $ORIGIN, $TTL,
// $INCLUDE-free multi-line records, comments and every record type it knows
// are handled exactly as a real name server would handle them.
package zonefile
import (
"bufio"
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// ParseSummary reports what an import contained.
type ParseSummary struct {
RecordsParsed int `json:"records_parsed"`
Skipped int `json:"skipped"`
SOAFound bool `json:"soa_found"`
OutOfZone int `json:"out_of_zone"`
Warnings []string `json:"warnings,omitempty"`
}
// ParseResult is the outcome of reading a zone file.
type ParseResult struct {
Records []models.Record
SOA *dns.SOA
Summary ParseSummary
}
// maxWarnings caps how much detail a badly formed file can generate.
const maxWarnings = 25
// Parse reads a zone file and converts it into storable records.
//
// origin must be a normalised FQDN. Records outside the origin are reported
// and skipped rather than silently dropped, because importing them would
// create data the server can never serve.
func Parse(r io.Reader, origin string, defaultTTL uint32) (*ParseResult, error) {
origin = strings.ToLower(dns.Fqdn(origin))
if defaultTTL == 0 {
defaultTTL = 3600
}
res := &ParseResult{}
zp := dns.NewZoneParser(bufio.NewReader(r), origin, "zonefile")
zp.SetDefaultTTL(defaultTTL)
zp.SetIncludeAllowed(false) // $INCLUDE would read arbitrary local files
for {
rr, ok := zp.Next()
if !ok {
break
}
if rr == nil {
continue
}
owner := strings.ToLower(rr.Header().Name)
if !dns.IsSubDomain(origin, owner) {
res.Summary.OutOfZone++
res.Summary.Skipped++
addWarning(&res.Summary, fmt.Sprintf(
"%s %s is outside zone %s and was skipped",
owner, dns.TypeToString[rr.Header().Rrtype], origin))
continue
}
if soa, isSOA := rr.(*dns.SOA); isSOA {
if owner == origin && res.SOA == nil {
res.SOA = soa
res.Summary.SOAFound = true
}
// The SOA is stored as zone metadata, not as a record row: the
// authoritative engine regenerates it so serials stay managed.
continue
}
name, err := relativeName(owner, origin)
if err != nil {
res.Summary.Skipped++
addWarning(&res.Summary, err.Error())
continue
}
rdata := RData(rr)
if strings.TrimSpace(rdata) == "" {
res.Summary.Skipped++
addWarning(&res.Summary, fmt.Sprintf("%s %s had empty record data and was skipped",
owner, dns.TypeToString[rr.Header().Rrtype]))
continue
}
rec := models.Record{
Name: name,
Type: typeName(rr.Header().Rrtype),
Data: rdata,
Enabled: true,
}
if ttl := rr.Header().Ttl; ttl != defaultTTL {
t := ttl
rec.TTL = &t
}
res.Records = append(res.Records, rec)
res.Summary.RecordsParsed++
}
if err := zp.Err(); err != nil {
return nil, fmt.Errorf("zone file could not be parsed: %s", cleanError(err))
}
if res.Summary.RecordsParsed == 0 && res.SOA == nil {
return nil, fmt.Errorf("no records were found in the file; check that it is a BIND zone file for %s",
strings.TrimSuffix(origin, "."))
}
return res, nil
}
func addWarning(s *ParseSummary, msg string) {
if len(s.Warnings) < maxWarnings {
s.Warnings = append(s.Warnings, msg)
}
}
// typeName renders a type, falling back to the RFC 3597 TYPEnnnnn form.
func typeName(t uint16) string {
if s, ok := dns.TypeToString[t]; ok {
return s
}
return fmt.Sprintf("TYPE%d", t)
}
// relativeName converts an absolute owner name into its in-zone relative form.
func relativeName(owner, origin string) (string, error) {
if owner == origin {
return "@", nil
}
if !strings.HasSuffix(owner, "."+origin) {
return "", fmt.Errorf("%s is not inside zone %s", owner, origin)
}
return strings.TrimSuffix(owner, "."+origin), nil
}
// RData returns just the record data portion of an RR.
//
// The DNS library formats a record as "name<TAB>ttl<TAB>class<TAB>type<TAB>rdata",
// so splitting on the first four tabs isolates the rdata without re-implementing
// per-type formatting.
func RData(rr dns.RR) string {
s := rr.String()
parts := strings.SplitN(s, "\t", 5)
if len(parts) < 5 {
// Fall back to trimming the header's own rendering.
header := rr.Header().String()
return strings.TrimSpace(strings.TrimPrefix(s, header))
}
return strings.TrimSpace(parts[4])
}
func cleanError(err error) string {
msg := err.Error()
msg = strings.TrimPrefix(msg, "dns: ")
return msg
}
// --- Export -------------------------------------------------------------
// Export writes a zone as a BIND-compatible zone file.
func Export(w io.Writer, zone models.Zone, records []models.Record) error {
bw := bufio.NewWriter(w)
defer bw.Flush()
fmt.Fprintf(bw, ";; Zone file for %s\n", zone.Name)
fmt.Fprintf(bw, ";; Exported by VibeDNS on %s\n", time.Now().UTC().Format(time.RFC3339))
if zone.Description != "" {
fmt.Fprintf(bw, ";; %s\n", singleLine(zone.Description))
}
fmt.Fprintf(bw, ";;\n")
fmt.Fprintf(bw, "$ORIGIN %s\n", zone.Name)
fmt.Fprintf(bw, "$TTL %d\n\n", zone.DefaultTTL)
// The SOA is always written from zone metadata so the exported serial
// matches what the server is actually answering with.
soa := soaFor(zone)
fmt.Fprintf(bw, "%s\n\n", soa.String())
byType := map[string][]models.Record{}
for _, r := range records {
if strings.EqualFold(r.Type, "SOA") {
continue // regenerated above
}
byType[strings.ToUpper(r.Type)] = append(byType[strings.ToUpper(r.Type)], r)
}
// NS records first, then everything else alphabetically: this is the
// conventional layout and makes diffs between exports readable.
order := []string{"NS"}
var rest []string
for t := range byType {
if t != "NS" {
rest = append(rest, t)
}
}
sort.Strings(rest)
order = append(order, rest...)
for _, t := range order {
recs := byType[t]
if len(recs) == 0 {
continue
}
sort.SliceStable(recs, func(i, j int) bool {
if recs[i].Name == recs[j].Name {
return recs[i].Data < recs[j].Data
}
if recs[i].Name == "@" {
return true
}
if recs[j].Name == "@" {
return false
}
return recs[i].Name < recs[j].Name
})
fmt.Fprintf(bw, ";; %s records\n", t)
for _, r := range recs {
writeRecord(bw, zone, r)
}
fmt.Fprintln(bw)
}
return bw.Flush()
}
func writeRecord(w io.Writer, zone models.Zone, r models.Record) {
name := r.Name
if name == "" {
name = "@"
}
ttl := r.EffectiveTTL(zone.DefaultTTL)
prefix := ""
if !r.Enabled {
// Disabled records are preserved as comments so an export/import round
// trip does not silently discard them.
prefix = ";; DISABLED "
}
line := fmt.Sprintf("%s%-24s %-7d IN %-8s %s", prefix, name, ttl, strings.ToUpper(r.Type), r.Data)
if r.Comment != "" {
line += " ; " + singleLine(r.Comment)
}
fmt.Fprintln(w, line)
}
func soaFor(zone models.Zone) *dns.SOA {
ns := zone.PrimaryNS
if ns == "" {
ns = "ns1." + zone.Name
}
if !strings.HasSuffix(ns, ".") {
ns += "."
}
return &dns.SOA{
Hdr: dns.RR_Header{
Name: zone.Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: zone.DefaultTTL,
},
Ns: strings.ToLower(ns),
Mbox: mailbox(zone.AdminEmail, zone.Name),
Serial: zone.Serial,
Refresh: orDefault(zone.Refresh, 7200),
Retry: orDefault(zone.Retry, 3600),
Expire: orDefault(zone.Expire, 1209600),
Minttl: orDefault(zone.Minimum, 3600),
}
}
func mailbox(email, zone string) string {
e := strings.TrimSpace(strings.ToLower(email))
if e == "" {
return "hostmaster." + zone
}
at := strings.LastIndex(e, "@")
if at < 0 {
return dns.Fqdn(e)
}
local := strings.ReplaceAll(e[:at], ".", `\.`)
return local + "." + dns.Fqdn(e[at+1:])
}
func orDefault(v, def uint32) uint32 {
if v == 0 {
return def
}
return v
}
func singleLine(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
// ZoneMetadataFromSOA copies parsed SOA values onto a zone, which is how an
// imported zone picks up its timers and serial.
func ZoneMetadataFromSOA(z *models.Zone, soa *dns.SOA) {
if soa == nil {
return
}
z.PrimaryNS = soa.Ns
z.AdminEmail = emailFromMailbox(soa.Mbox)
z.Serial = soa.Serial
z.Refresh = soa.Refresh
z.Retry = soa.Retry
z.Expire = soa.Expire
z.Minimum = soa.Minttl
}
// emailFromMailbox converts an SOA RNAME back into an email address.
func emailFromMailbox(mbox string) string {
m := strings.TrimSuffix(mbox, ".")
if m == "" {
return ""
}
// The first unescaped dot separates the local part from the domain.
var local strings.Builder
i := 0
for ; i < len(m); i++ {
if m[i] == '\\' && i+1 < len(m) {
local.WriteByte(m[i+1])
i++
continue
}
if m[i] == '.' {
break
}
local.WriteByte(m[i])
}
if i >= len(m) {
return m
}
return local.String() + "@" + m[i+1:]
}
// SuggestFilename returns a sensible download name for a zone export.
func SuggestFilename(zone string) string {
name := strings.TrimSuffix(zone, ".")
if name == "" {
name = "zone"
}
return name + ".zone"
}
// ValidateRecords re-parses exported records to confirm they will compile.
// It is used by the importer to reject a file before anything is written.
func ValidateRecords(origin string, recs []models.Record, defaultTTL uint32) []string {
var problems []string
for _, r := range recs {
if _, err := validate.BuildRR(origin, r.Name, r.Type, r.Data, r.EffectiveTTL(defaultTTL)); err != nil {
problems = append(problems, fmt.Sprintf("%s %s: %v", r.Name, r.Type, err))
if len(problems) >= maxWarnings {
break
}
}
}
return problems
}
+243
View File
@@ -0,0 +1,243 @@
package zonefile
import (
"bytes"
"strings"
"testing"
"github.com/owen/vibedns/internal/models"
)
const sampleZone = `$ORIGIN example.com.
$TTL 3600
@ IN SOA ns1.example.com. hostmaster.example.com. (
2024010101 ; serial
7200 ; refresh
3600 ; retry
1209600 ; expire
300 ) ; minimum
@ IN NS ns1.example.com.
@ IN NS ns2.example.com.
@ IN A 192.0.2.10
@ IN MX 10 mail.example.com.
www IN CNAME example.com.
mail IN A 192.0.2.20
mail IN AAAA 2001:db8::20
ns1 IN A 192.0.2.53
txt IN TXT "v=spf1 mx -all"
_sip._tcp IN SRV 10 20 5060 sip.example.com.
short 60 IN A 192.0.2.99
*.wild IN A 192.0.2.100
`
func TestParseZoneFile(t *testing.T) {
res, err := Parse(strings.NewReader(sampleZone), "example.com.", 3600)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !res.Summary.SOAFound || res.SOA == nil {
t.Fatal("the SOA was not picked up")
}
if res.SOA.Serial != 2024010101 {
t.Errorf("serial = %d, want 2024010101", res.SOA.Serial)
}
if res.SOA.Minttl != 300 {
t.Errorf("SOA minimum = %d, want 300", res.SOA.Minttl)
}
// The SOA is zone metadata, not a record row.
for _, r := range res.Records {
if r.Type == "SOA" {
t.Error("the SOA should not be stored as a record")
}
}
byName := map[string][]models.Record{}
for _, r := range res.Records {
byName[r.Name] = append(byName[r.Name], r)
}
if len(byName["@"]) != 4 { // 2 NS, 1 A, 1 MX
t.Errorf("apex records = %d, want 4", len(byName["@"]))
}
if got := byName["www"]; len(got) != 1 || got[0].Type != "CNAME" {
t.Errorf("www = %v, want a single CNAME", got)
}
if len(byName["mail"]) != 2 {
t.Errorf("mail records = %d, want 2 (A and AAAA)", len(byName["mail"]))
}
if _, ok := byName["*.wild"]; !ok {
t.Error("the wildcard record was not parsed")
}
if _, ok := byName["_sip._tcp"]; !ok {
t.Error("the underscore-prefixed SRV name was not parsed")
}
// A record whose TTL differs from the file default keeps an explicit TTL;
// one that matches inherits (nil).
for _, r := range byName["short"] {
if r.TTL == nil || *r.TTL != 60 {
t.Errorf("short record TTL = %v, want an explicit 60", r.TTL)
}
}
for _, r := range byName["mail"] {
if r.TTL != nil {
t.Errorf("mail record TTL = %v, want nil (inherit the zone default)", *r.TTL)
}
}
}
func TestParseRejectsOutOfZoneRecords(t *testing.T) {
const zone = `$ORIGIN example.com.
$TTL 3600
@ IN A 192.0.2.1
other.org. IN A 192.0.2.2
`
res, err := Parse(strings.NewReader(zone), "example.com.", 3600)
if err != nil {
t.Fatalf("parse: %v", err)
}
if res.Summary.OutOfZone != 1 {
t.Errorf("out-of-zone count = %d, want 1", res.Summary.OutOfZone)
}
if len(res.Summary.Warnings) == 0 {
t.Error("expected a warning naming the skipped record")
}
for _, r := range res.Records {
if strings.Contains(r.Name, "other") {
t.Error("an out-of-zone record was imported")
}
}
}
func TestParseRejectsMalformedFile(t *testing.T) {
const bad = `$ORIGIN example.com.
@ IN A this-is-not-an-address
`
if _, err := Parse(strings.NewReader(bad), "example.com.", 3600); err == nil {
t.Error("expected a parse error for invalid record data")
}
}
func TestParseEmptyFile(t *testing.T) {
if _, err := Parse(strings.NewReader("; just a comment\n"), "example.com.", 3600); err == nil {
t.Error("expected an error for a file with no records")
}
}
func TestExportRoundTrip(t *testing.T) {
zone := models.Zone{
ID: 1, Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
Serial: 42, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 300,
}
ttl := uint32(60)
records := []models.Record{
{Name: "@", Type: "NS", Data: "ns1.example.com.", Enabled: true},
{Name: "@", Type: "A", Data: "192.0.2.10", Enabled: true},
{Name: "@", Type: "MX", Data: "10 mail.example.com.", Enabled: true},
{Name: "www", Type: "CNAME", Data: "example.com.", Enabled: true},
{Name: "mail", Type: "A", Data: "192.0.2.20", Enabled: true},
{Name: "short", Type: "A", Data: "192.0.2.99", TTL: &ttl, Enabled: true},
{Name: "txt", Type: "TXT", Data: `"hello world"`, Enabled: true, Comment: "a note"},
}
var buf bytes.Buffer
if err := Export(&buf, zone, records); err != nil {
t.Fatalf("export: %v", err)
}
out := buf.String()
for _, want := range []string{"$ORIGIN example.com.", "$TTL 3600", "SOA", "42"} {
if !strings.Contains(out, want) {
t.Errorf("export is missing %q\n%s", want, out)
}
}
// Re-importing the export must reproduce the same records.
res, err := Parse(strings.NewReader(out), "example.com.", 3600)
if err != nil {
t.Fatalf("re-parse the export: %v", err)
}
if len(res.Records) != len(records) {
t.Errorf("round trip produced %d records, want %d", len(res.Records), len(records))
}
if res.SOA == nil || res.SOA.Serial != 42 {
t.Error("the serial did not survive the round trip")
}
}
func TestExportPreservesDisabledRecordsAsComments(t *testing.T) {
zone := models.Zone{
Name: "example.com.", DefaultTTL: 3600, Serial: 1,
PrimaryNS: "ns1.example.com.", AdminEmail: "a@example.com",
}
records := []models.Record{
{Name: "on", Type: "A", Data: "192.0.2.1", Enabled: true},
{Name: "off", Type: "A", Data: "192.0.2.2", Enabled: false},
}
var buf bytes.Buffer
if err := Export(&buf, zone, records); err != nil {
t.Fatalf("export: %v", err)
}
out := buf.String()
if !strings.Contains(out, "DISABLED") {
t.Error("a disabled record should be preserved as a comment, not dropped")
}
// It must be commented out, so re-importing does not re-enable it.
res, err := Parse(strings.NewReader(out), "example.com.", 3600)
if err != nil {
t.Fatalf("re-parse: %v", err)
}
for _, r := range res.Records {
if r.Name == "off" {
t.Error("a disabled record was re-imported as active")
}
}
}
func TestMailboxConversion(t *testing.T) {
tests := []struct{ email, want string }{
{"hostmaster@example.com", "hostmaster.example.com."},
{"first.last@example.com", `first\.last.example.com.`},
{"", "hostmaster.example.com."},
}
for _, tc := range tests {
if got := mailbox(tc.email, "example.com."); got != tc.want {
t.Errorf("mailbox(%q) = %q, want %q", tc.email, got, tc.want)
}
}
// And back again.
backTests := []struct{ mbox, want string }{
{"hostmaster.example.com.", "hostmaster@example.com"},
{`first\.last.example.com.`, "first.last@example.com"},
}
for _, tc := range backTests {
if got := emailFromMailbox(tc.mbox); got != tc.want {
t.Errorf("emailFromMailbox(%q) = %q, want %q", tc.mbox, got, tc.want)
}
}
}
func TestValidateRecordsCatchesBadData(t *testing.T) {
records := []models.Record{
{Name: "good", Type: "A", Data: "192.0.2.1"},
{Name: "bad", Type: "A", Data: "not-an-address"},
}
problems := ValidateRecords("example.com.", records, 3600)
if len(problems) != 1 {
t.Errorf("problems = %d, want 1: %v", len(problems), problems)
}
}
func TestSuggestFilename(t *testing.T) {
if got := SuggestFilename("example.com."); got != "example.com.zone" {
t.Errorf("filename = %q, want example.com.zone", got)
}
}
+42
View File
@@ -0,0 +1,42 @@
// Package webui embeds the HTML templates and static assets.
//
// It lives at the repository root next to the files it embeds, because Go's
// embed directive cannot reach outside its own directory. The HTTP handlers
// that consume these assets live in internal/web.
//
// Everything the browser needs — Bootstrap, Bootstrap Icons and their web
// font, and Chart.js — is vendored here rather than loaded from a CDN, so the
// management interface works on a network with no Internet access. That is not
// a hypothetical: a DNS server whose own UI needs working DNS to render is
// unusable in exactly the situation an administrator most needs it.
package webui
import (
"embed"
"io/fs"
)
//go:embed templates
var templatesFS embed.FS
//go:embed static
var staticFS embed.FS
// Templates returns the template tree rooted at the templates directory.
func Templates() fs.FS {
sub, err := fs.Sub(templatesFS, "templates")
if err != nil {
// Unreachable: the directory is embedded at build time.
panic("webui: templates directory is missing from the binary: " + err.Error())
}
return sub
}
// Static returns the static asset tree rooted at the static directory.
func Static() fs.FS {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("webui: static directory is missing from the binary: " + err.Error())
}
return sub
}
+361
View File
@@ -0,0 +1,361 @@
/* vibedns management interface
*
* A thin layer on top of Bootstrap 5: an app shell with a fixed sidebar,
* a small set of component styles, and colour tokens that follow Bootstrap's
* own light/dark switch so the whole UI flips with one attribute.
*/
:root {
--app-sidebar-width: 248px;
--app-navbar-height: 56px;
--app-brand: #1b6ec2;
--app-brand-dark: #14508c;
--app-sidebar-bg: #ffffff;
--app-sidebar-border: var(--bs-border-color);
--app-sidebar-link: #495057;
--app-sidebar-link-hover-bg: #f1f3f5;
--app-sidebar-active-bg: #e7f1fb;
--app-sidebar-active-color: var(--app-brand-dark);
--app-body-bg: #f6f8fa;
--app-card-shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .08);
}
[data-bs-theme="dark"] {
--app-brand: #4c9be8;
--app-brand-dark: #7cb8f0;
--app-sidebar-bg: #17191d;
--app-sidebar-border: #2b2f36;
--app-sidebar-link: #adb5bd;
--app-sidebar-link-hover-bg: #22252b;
--app-sidebar-active-bg: #1d2b3a;
--app-sidebar-active-color: #8fc3f5;
--app-body-bg: #101214;
--app-card-shadow: 0 1px 2px rgba(0, 0, 0, .4);
}
body {
background-color: var(--app-body-bg);
font-size: .9375rem;
}
.skip-link {
position: absolute;
top: .5rem;
left: .5rem;
z-index: 2000;
background: var(--bs-body-bg);
padding: .5rem .75rem;
border-radius: .375rem;
}
/* --- Shell ------------------------------------------------------------ */
.app-navbar {
background: linear-gradient(90deg, var(--app-brand-dark), var(--app-brand));
min-height: var(--app-navbar-height);
box-shadow: 0 1px 3px rgba(16, 24, 40, .15);
z-index: 1030;
}
.app-navbar .navbar-brand {
color: #fff;
font-weight: 600;
letter-spacing: .01em;
}
.app-navbar .navbar-brand:hover { color: #fff; }
.status-dot {
width: .55rem;
height: .55rem;
border-radius: 50%;
background: #adb5bd;
display: inline-block;
}
.status-dot.status-up {
background: #2fb344;
box-shadow: 0 0 0 3px rgba(47, 179, 68, .25);
}
.status-dot.status-down {
background: #d63939;
box-shadow: 0 0 0 3px rgba(214, 57, 57, .25);
}
.app-shell { display: flex; align-items: flex-start; }
.app-sidebar {
width: var(--app-sidebar-width);
flex: 0 0 var(--app-sidebar-width);
position: sticky;
top: var(--app-navbar-height);
height: calc(100vh - var(--app-navbar-height));
overflow-y: auto;
background: var(--app-sidebar-bg);
border-right: 1px solid var(--app-sidebar-border);
}
.app-main { flex: 1 1 auto; min-width: 0; }
.app-offcanvas { width: var(--app-sidebar-width); }
.sidebar-nav { padding: .75rem .5rem 2rem; }
.sidebar-heading {
font-size: .6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--bs-secondary-color);
padding: 1rem .75rem .35rem;
}
.sidebar-link {
display: flex;
align-items: center;
gap: .625rem;
padding: .5rem .75rem;
margin-bottom: .0625rem;
border-radius: .375rem;
color: var(--app-sidebar-link);
text-decoration: none;
font-weight: 500;
transition: background-color .12s ease, color .12s ease;
}
.sidebar-link i { font-size: 1rem; width: 1.25rem; text-align: center; flex: none; }
.sidebar-link:hover {
background: var(--app-sidebar-link-hover-bg);
color: var(--bs-body-color);
}
.sidebar-link.active {
background: var(--app-sidebar-active-bg);
color: var(--app-sidebar-active-color);
font-weight: 600;
}
/* --- Typography and cards --------------------------------------------- */
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: .125rem;
}
.card {
border-color: var(--bs-border-color);
box-shadow: var(--app-card-shadow);
}
.card-header {
background: transparent;
border-bottom-color: var(--bs-border-color);
font-weight: 600;
padding-block: .75rem;
}
.stat-card .card-body { padding: 1rem 1.125rem; }
.stat-label {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
line-height: 1.2;
margin-top: .25rem;
font-variant-numeric: tabular-nums;
}
.stat-sub { font-size: .8125rem; color: var(--bs-secondary-color); margin-top: .125rem; }
.stat-icon { font-size: 1.125rem; color: var(--bs-secondary-color); opacity: .75; }
/* --- Tables ------------------------------------------------------------ */
.table { --bs-table-bg: transparent; margin-bottom: 0; }
.table > thead > tr > th {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
border-bottom-width: 1px;
white-space: nowrap;
}
.table > tbody > tr > td { vertical-align: middle; }
.table-hover > tbody > tr:hover > * { --bs-table-accent-bg: var(--app-sidebar-link-hover-bg); }
.table-compact > :not(caption) > * > * { padding: .5rem .625rem; }
.mono {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
}
.truncate-cell {
max-width: 26rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions { white-space: nowrap; text-align: right; }
.row-actions .btn { --bs-btn-padding-y: .1875rem; --bs-btn-padding-x: .4375rem; }
/* Disabled rows read as inactive without disappearing. */
tr.is-disabled > td:not(.row-actions) { opacity: .55; }
/* --- Record type chips ------------------------------------------------- */
.type-chip {
display: inline-block;
min-width: 3.75rem;
text-align: center;
padding: .1875rem .4375rem;
border-radius: .25rem;
font-size: .75rem;
font-weight: 600;
font-family: var(--bs-font-monospace);
border: 1px solid transparent;
}
.type-addr { background: #e7f1fb; color: #14508c; border-color: #c9e0f7; }
.type-alias { background: #f3e8fd; color: #6b21a8; border-color: #e4d0fb; }
.type-service { background: #e6f7f1; color: #0f6b52; border-color: #c5ecdf; }
.type-auth { background: #fef3e2; color: #92500e; border-color: #fbe0b8; }
.type-text { background: #eef0f2; color: #41474d; border-color: #dde1e5; }
.type-sec { background: #fde8ec; color: #9b1c33; border-color: #f8ccd6; }
.type-ptr { background: #e8f4fd; color: #0b5d7a; border-color: #cbe6f6; }
.type-other { background: #eef0f2; color: #41474d; border-color: #dde1e5; }
[data-bs-theme="dark"] .type-addr { background: #12283d; color: #8fc3f5; border-color: #1d3d5c; }
[data-bs-theme="dark"] .type-alias { background: #2a1a3d; color: #cba6f7; border-color: #3d2757; }
[data-bs-theme="dark"] .type-service { background: #12302a; color: #7ad4b3; border-color: #1c4a3f; }
[data-bs-theme="dark"] .type-auth { background: #362508; color: #f0c674; border-color: #4d360f; }
[data-bs-theme="dark"] .type-text { background: #22252b; color: #adb5bd; border-color: #32363d; }
[data-bs-theme="dark"] .type-sec { background: #3a151f; color: #f2a1b2; border-color: #55202e; }
[data-bs-theme="dark"] .type-ptr { background: #102b36; color: #86cfe8; border-color: #1a4252; }
[data-bs-theme="dark"] .type-other { background: #22252b; color: #adb5bd; border-color: #32363d; }
/* --- Empty states ------------------------------------------------------ */
.empty-state { color: var(--bs-secondary-color); }
.empty-icon {
font-size: 2.75rem;
color: var(--bs-secondary-color);
opacity: .45;
}
/* --- Forms ------------------------------------------------------------- */
.form-label { font-weight: 500; margin-bottom: .3125rem; }
.form-text { font-size: .8125rem; }
.settings-nav .nav-link {
--bs-nav-pills-border-radius: .375rem;
padding: .375rem .75rem;
font-size: .875rem;
font-weight: 500;
color: var(--bs-body-color);
}
.settings-nav .nav-link:not(.active):hover { background: var(--app-sidebar-link-hover-bg); }
.form-section + .form-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--bs-border-color); }
.form-section-title { font-size: .9375rem; font-weight: 600; margin-bottom: .875rem; }
textarea.list-input {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
min-height: 7.5rem;
}
/* Sticky action bar for long settings forms. */
.form-actions {
position: sticky;
bottom: 0;
background: var(--bs-body-bg);
border-top: 1px solid var(--bs-border-color);
padding: .875rem 1.125rem;
margin: 1.5rem -1.125rem -1.125rem;
border-radius: 0 0 var(--bs-card-border-radius) var(--bs-card-border-radius);
}
/* --- Charts ------------------------------------------------------------ */
.chart-wrap { position: relative; height: 260px; }
.chart-wrap-sm { position: relative; height: 200px; }
/* --- Misc -------------------------------------------------------------- */
.bulk-bar {
display: none;
align-items: center;
gap: .75rem;
padding: .625rem .875rem;
background: var(--app-sidebar-active-bg);
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
margin-bottom: .75rem;
}
.bulk-bar.is-visible { display: flex; }
.token-secret {
font-family: var(--bs-font-monospace);
font-size: .875rem;
word-break: break-all;
background: var(--bs-tertiary-bg);
padding: .625rem .75rem;
border-radius: .375rem;
border: 1px solid var(--bs-border-color);
}
.answer-block {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 0;
}
.progress-thin { height: .375rem; }
.list-meta { display: flex; flex-wrap: wrap; gap: 1.25rem; }
.list-meta > div { min-width: 7rem; }
.list-meta dt {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
font-weight: 600;
}
.list-meta dd { font-size: 1.125rem; font-weight: 600; margin-bottom: 0; }
@media (max-width: 991.98px) {
.app-sidebar { display: none; }
}
@media print {
.app-sidebar, .app-navbar, .toast-container, .row-actions { display: none !important; }
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="VibeDNS">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2a86d8"/>
<stop offset="100%" stop-color="#14508c"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="13" fill="url(#g)"/>
<g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round">
<circle cx="32" cy="32" r="16"/>
<ellipse cx="32" cy="32" rx="7" ry="16"/>
<path d="M16.6 27h30.8M16.6 37h30.8"/>
</g>
<circle cx="32" cy="32" r="4.2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+540
View File
@@ -0,0 +1,540 @@
/* vibedns management interface behaviour.
*
* Deliberately small and dependency-free beyond Bootstrap's own bundle: theme
* persistence, toast display, confirmation dialogs, bulk selection, the
* record-type editor, and a couple of small conveniences. There is no build
* step and nothing is fetched from the network.
*/
(function () {
'use strict';
// --- Theme ------------------------------------------------------------
var THEME_KEY = 'vibedns.theme';
function storedTheme() {
try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; }
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (e) { /* private mode */ }
}
function initTheme() {
var saved = storedTheme();
if (!saved) {
saved = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', saved);
var toggle = document.getElementById('themeToggle');
if (toggle) {
toggle.addEventListener('click', function () {
var current = document.documentElement.getAttribute('data-bs-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
}
}
// Apply before first paint to avoid a flash of the wrong theme.
(function () {
var saved = storedTheme();
if (saved) { document.documentElement.setAttribute('data-bs-theme', saved); }
})();
// --- Toasts -----------------------------------------------------------
function initToasts() {
document.querySelectorAll('#toastContainer .toast').forEach(function (el) {
var autohide = el.getAttribute('data-autohide') !== 'false';
new bootstrap.Toast(el, { autohide: autohide, delay: 6000 }).show();
});
}
/** Shows a transient message without a page reload. */
window.vibednsToast = function (level, message) {
// Successful actions already update the page, so avoid distracting green
// confirmation boxes. Warnings and errors remain visible.
if (level === 'success') { return; }
var container = document.getElementById('toastContainer');
if (!container) { return; }
var el = document.createElement('div');
el.className = 'toast align-items-center border-0 text-bg-' + level;
el.setAttribute('role', 'alert');
var body = document.createElement('div');
body.className = 'd-flex';
var text = document.createElement('div');
text.className = 'toast-body';
text.textContent = message;
var close = document.createElement('button');
close.type = 'button';
close.className = 'btn-close btn-close-white me-2 m-auto';
close.setAttribute('data-bs-dismiss', 'toast');
close.setAttribute('aria-label', 'Close');
body.appendChild(text);
body.appendChild(close);
el.appendChild(body);
container.appendChild(el);
new bootstrap.Toast(el, { delay: 5000 }).show();
el.addEventListener('hidden.bs.toast', function () { el.remove(); });
};
// --- Confirmation dialogs --------------------------------------------
function initConfirmations() {
var modalEl = document.getElementById('confirmModal');
if (!modalEl) { return; }
var modal = new bootstrap.Modal(modalEl);
var bodyEl = document.getElementById('confirmModalBody');
var acceptEl = document.getElementById('confirmModalAccept');
var pendingForm = null;
var pendingButton = null;
function ask(form, button, message) {
pendingForm = form;
pendingButton = button;
bodyEl.textContent = message || 'This action cannot be undone.';
acceptEl.textContent = (form && form.dataset.confirmLabel) || 'Confirm';
modal.show();
}
// A whole form marked js-confirm (single-action buttons such as Delete).
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form.classList || !form.classList.contains('js-confirm')) { return; }
if (form.dataset.confirmed === 'true') { return; }
ev.preventDefault();
ask(form, null, form.dataset.confirm);
});
// An individual submit button inside a multi-action form (bulk toolbars),
// where only one of the buttons is destructive.
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('button.js-confirm-bulk');
if (!btn || btn.dataset.confirmed === 'true') { return; }
var form = btn.form;
if (!form) { return; }
ev.preventDefault();
ask(form, btn, btn.dataset.confirm);
});
acceptEl.addEventListener('click', function () {
if (!pendingForm) { return; }
modal.hide();
if (pendingButton) {
pendingButton.dataset.confirmed = 'true';
// requestSubmit keeps the button's name/value in the submission, which
// is how the server knows which bulk action was chosen.
pendingForm.requestSubmit(pendingButton);
} else {
pendingForm.dataset.confirmed = 'true';
pendingForm.submit();
}
pendingForm = null;
pendingButton = null;
});
modalEl.addEventListener('hidden.bs.modal', function () {
pendingForm = null;
pendingButton = null;
});
}
// --- Filter forms -----------------------------------------------------
/** Submits a filter form shortly after the user stops typing. */
function initAutoFilters() {
document.querySelectorAll('form[data-autosubmit]').forEach(function (form) {
var timer = null;
form.querySelectorAll('input[type="search"], input[type="text"]').forEach(function (input) {
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(function () { form.requestSubmit(); }, 400);
});
});
form.querySelectorAll('select').forEach(function (select) {
select.addEventListener('change', function () { form.requestSubmit(); });
});
});
}
// --- Bulk selection ---------------------------------------------------
function initBulkSelect() {
document.querySelectorAll('[data-bulk-scope]').forEach(function (scope) {
var master = scope.querySelector('[data-bulk-all]');
var boxes = function () { return scope.querySelectorAll('[data-bulk-item]'); };
var bar = scope.querySelector('[data-bulk-bar]');
var count = scope.querySelector('[data-bulk-count]');
function refresh() {
var selected = scope.querySelectorAll('[data-bulk-item]:checked').length;
if (bar) { bar.classList.toggle('is-visible', selected > 0); }
if (count) { count.textContent = String(selected); }
if (master) {
var total = boxes().length;
master.checked = total > 0 && selected === total;
master.indeterminate = selected > 0 && selected < total;
}
}
if (master) {
master.addEventListener('change', function () {
boxes().forEach(function (b) { b.checked = master.checked; });
refresh();
});
}
scope.addEventListener('change', function (ev) {
if (ev.target.matches('[data-bulk-item]')) { refresh(); }
});
refresh();
});
}
// --- Copy to clipboard ------------------------------------------------
function initCopyButtons() {
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-copy]');
if (!btn) { return; }
ev.preventDefault();
var text = btn.getAttribute('data-copy');
var target = btn.getAttribute('data-copy-target');
if (target) {
var el = document.querySelector(target);
if (el) { text = el.textContent.trim(); }
}
if (!text) { return; }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
window.vibednsToast('success', 'Copied to clipboard.');
}, function () {
window.vibednsToast('warning', 'The browser refused clipboard access.');
});
} else {
window.vibednsToast('warning', 'Clipboard access needs a secure (HTTPS) connection.');
}
});
}
// --- Reverse zone preview --------------------------------------------
/** Shows which reverse zone a subnet will produce, before submitting. */
function initReversePreview() {
var input = document.getElementById('reverseCidr');
var out = document.getElementById('reversePreview');
if (!input || !out) { return; }
var timer = null;
function update() {
var value = input.value.trim();
if (!value) { out.textContent = ''; return; }
fetch('/api/v1/tools/reverse-zone?cidr=' + encodeURIComponent(value), {
headers: { 'Accept': 'application/json' }
}).then(function (r) { return r.json(); }).then(function (data) {
if (data.error) {
out.className = 'form-text text-danger';
out.textContent = data.error.message || 'That is not a valid subnet.';
return;
}
out.className = 'form-text text-success';
out.textContent = 'Zone: ' + data.zone + (data.note ? ' — ' + data.note : '');
}).catch(function () {
out.textContent = '';
});
}
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(update, 300);
});
if (input.value) { update(); }
}
// --- Page data --------------------------------------------------------
/* Page data is delivered in data- attributes rather than inline <script>
* blocks, because the Content-Security-Policy only allows scripts served
* from this origin. */
function readData(selector, attr) {
var el = document.querySelector(selector);
if (!el) { return null; }
var raw = el.getAttribute(attr);
if (!raw) { return null; }
try { return JSON.parse(raw); } catch (e) { return null; }
}
// --- Record editor ----------------------------------------------------
/* The record form is generated from the type catalogue the server embeds in
* the page, so every supported type gets a proper labelled editor without a
* hand-written form per type. */
function initRecordEditor() {
var root = document.getElementById('recordEditor');
if (!root) { return; }
var catalogue = readData('#recordEditor', 'data-types');
var values = readData('#recordEditor', 'data-values') || {};
if (!catalogue) { return; }
var typeSelect = root.querySelector('[data-record-type]');
var fieldsEl = root.querySelector('[data-record-fields]');
var rawWrap = root.querySelector('[data-record-raw]');
var rawInput = rawWrap ? rawWrap.querySelector('textarea, input') : null;
var advancedToggle = root.querySelector('[data-record-advanced]');
if (!typeSelect || !fieldsEl) { return; }
function infoFor(type) {
for (var i = 0; i < catalogue.length; i++) {
if (catalogue[i].type === type) { return catalogue[i]; }
}
return null;
}
function buildField(field, value) {
var col = document.createElement('div');
col.className = 'col-12 col-md-' + (field.width || 12);
var label = document.createElement('label');
label.className = 'form-label';
label.textContent = field.label;
label.setAttribute('for', 'field_' + field.key);
col.appendChild(label);
var input;
if (field.type === 'textarea') {
input = document.createElement('textarea');
input.rows = 3;
} else if (field.type === 'select') {
input = document.createElement('select');
(field.options || []).forEach(function (opt) {
var o = document.createElement('option');
o.value = opt;
o.textContent = opt;
if (opt === value) { o.selected = true; }
input.appendChild(o);
});
} else {
input = document.createElement('input');
input.type = field.type === 'number' ? 'number' : 'text';
}
input.className = field.type === 'select' ? 'form-select' : 'form-control';
input.name = 'field_' + field.key;
input.id = 'field_' + field.key;
if (field.placeholder) { input.placeholder = field.placeholder; }
if (field.required) { input.required = true; }
if (input.tagName !== 'SELECT' && value != null) { input.value = value; }
col.appendChild(input);
if (field.help) {
var help = document.createElement('div');
help.className = 'form-text';
help.textContent = field.help;
col.appendChild(help);
}
return col;
}
function render() {
var type = typeSelect.value;
var advanced = advancedToggle && advancedToggle.checked;
var info = infoFor(type);
fieldsEl.innerHTML = '';
if (advanced || !info) {
fieldsEl.classList.add('d-none');
if (rawWrap) { rawWrap.classList.remove('d-none'); }
if (rawInput) { rawInput.disabled = false; }
return;
}
fieldsEl.classList.remove('d-none');
if (rawWrap) { rawWrap.classList.add('d-none'); }
if (rawInput) { rawInput.disabled = true; }
info.fields.forEach(function (field) {
fieldsEl.appendChild(buildField(field, values[field.key]));
});
}
typeSelect.addEventListener('change', function () {
// Values from the previously selected type no longer apply.
values = {};
render();
});
if (advancedToggle) { advancedToggle.addEventListener('change', render); }
render();
// One modal serves both "add" and "edit": the button that opened it
// carries the record in data- attributes, which are read here.
var modalEl = document.getElementById('recordModal');
if (!modalEl) { return; }
var form = document.getElementById('recordForm');
var titleEl = document.getElementById('recordModalLabel');
modalEl.addEventListener('show.bs.modal', function (ev) {
var trigger = ev.relatedTarget;
if (!trigger) { return; }
var d = trigger.dataset;
var isNew = d.recordNew !== undefined;
if (form && d.recordAction) { form.action = d.recordAction; }
if (titleEl) { titleEl.textContent = isNew ? 'Add record' : 'Edit record'; }
var nameEl = document.getElementById('recordName');
var ttlEl = document.getElementById('recordTTL');
var commentEl = document.getElementById('recordComment');
var enabledEl = document.getElementById('recordEnabled');
var rawEl = document.getElementById('recordData');
if (nameEl) { nameEl.value = isNew ? '@' : (d.recordName || '@'); }
if (ttlEl) { ttlEl.value = isNew ? '' : (d.recordTtl || ''); }
if (commentEl) { commentEl.value = isNew ? '' : (d.recordComment || ''); }
if (enabledEl) { enabledEl.checked = isNew ? true : d.recordEnabled === 'true'; }
if (rawEl) { rawEl.value = isNew ? '' : (d.recordData || ''); }
if (advancedToggle) { advancedToggle.checked = false; }
if (!isNew && d.recordRtype) { typeSelect.value = d.recordRtype; }
if (isNew) { typeSelect.value = 'A'; }
values = {};
if (!isNew && d.recordValues) {
try { values = JSON.parse(d.recordValues) || {}; } catch (e) { values = {}; }
}
render();
});
}
// --- Assorted behaviour ----------------------------------------------
/* The Content-Security-Policy forbids inline handlers, so anything that
* would normally be an onclick attribute is delegated from here. */
function initDelegatedActions() {
document.addEventListener('click', function (ev) {
if (ev.target.closest('[data-history-back]')) {
ev.preventDefault();
history.back();
}
});
// The restore modal is shared by every backup row; the row that opened it
// supplies the file name.
var restoreModal = document.getElementById('restoreModal');
if (restoreModal) {
restoreModal.addEventListener('show.bs.modal', function (ev) {
var trigger = ev.relatedTarget;
if (!trigger) { return; }
var name = trigger.getAttribute('data-backup-name') || '';
var form = document.getElementById('restoreForm');
var nameEl = document.getElementById('restoreName');
var confirmEl = document.getElementById('restoreConfirm');
if (form) {
form.action = '/settings/database/backup/' + encodeURIComponent(name) + '/restore';
}
if (nameEl) { nameEl.textContent = name; }
if (confirmEl) { confirmEl.value = ''; confirmEl.placeholder = name; }
});
}
}
// --- Charts -----------------------------------------------------------
function chartColours() {
var dark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
return {
grid: dark ? 'rgba(255,255,255,.08)' : 'rgba(16,24,40,.08)',
text: dark ? '#adb5bd' : '#6c757d',
series: ['#1b6ec2', '#d63939', '#2fb344', '#f59f00', '#7048e8', '#0ca678']
};
}
function initCharts() {
if (typeof Chart === 'undefined') { return; }
var c = chartColours();
Chart.defaults.color = c.text;
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
Chart.defaults.animation = false;
var activity = readData('#chartData', 'data-activity');
var types = readData('#chartData', 'data-types');
var activityEl = document.getElementById('activityChart');
if (activityEl && activity) {
var labels = activity.map(function (b) {
var d = new Date(b.start);
return d.getHours().toString().padStart(2, '0') + ':' +
d.getMinutes().toString().padStart(2, '0');
});
new Chart(activityEl, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Total', data: activity.map(function (b) { return b.total; }),
borderColor: c.series[0], backgroundColor: 'rgba(27,110,194,.12)',
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
},
{
label: 'Blocked', data: activity.map(function (b) { return b.blocked; }),
borderColor: c.series[1], backgroundColor: 'rgba(214,57,57,.12)',
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
},
{
label: 'Cached', data: activity.map(function (b) { return b.cached; }),
borderColor: c.series[2], backgroundColor: 'rgba(47,179,68,.10)',
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
}
]
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, usePointStyle: true } } },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
y: { beginAtZero: true, grid: { color: c.grid }, ticks: { precision: 0 } }
}
}
});
}
var typeEl = document.getElementById('typeChart');
if (typeEl && types && types.length) {
new Chart(typeEl, {
type: 'doughnut',
data: {
labels: types.map(function (t) { return t.label; }),
datasets: [{
data: types.map(function (t) { return t.value; }),
backgroundColor: c.series,
borderWidth: 0
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '62%',
plugins: { legend: { position: 'right', labels: { boxWidth: 12, usePointStyle: true } } }
}
});
}
}
// --- Boot -------------------------------------------------------------
document.addEventListener('DOMContentLoaded', function () {
initTheme();
initToasts();
initConfirmations();
initAutoFilters();
initBulkSelect();
initCopyButtons();
initReversePreview();
initRecordEditor();
initDelegatedActions();
initCharts();
});
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} · VibeDNS</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<a class="visually-hidden-focusable skip-link" href="#main">Skip to main content</a>
<nav class="navbar navbar-expand-lg app-navbar sticky-top">
<div class="container-fluid">
<button class="btn btn-sm btn-outline-light d-lg-none me-2" type="button"
data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas"
aria-controls="sidebarOffcanvas" aria-label="Open navigation">
<i class="bi bi-list"></i>
</button>
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<i class="bi bi-hdd-network-fill"></i>
<span>VibeDNS</span>
</a>
<div class="ms-auto d-flex align-items-center gap-3">
<button class="btn btn-sm btn-outline-light" type="button" id="themeToggle"
title="Switch between light and dark" aria-label="Switch colour theme">
<i class="bi bi-circle-half"></i>
</button>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i>{{.User.Name}}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/account"><i class="bi bi-key me-2"></i>Account</a></li>
<li><a class="dropdown-item" href="/settings/api"><i class="bi bi-code-slash me-2"></i>API tokens</a></li>
<li><hr class="dropdown-divider"></li>
<li><span class="dropdown-item-text text-body-secondary small">Version {{.Version}}</span></li>
</ul>
</div>
</div>
</div>
</nav>
<div class="app-shell">
<aside class="app-sidebar d-none d-lg-block">
{{template "sidebar" .}}
</aside>
<div class="offcanvas offcanvas-start app-offcanvas" tabindex="-1" id="sidebarOffcanvas"
aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarOffcanvasLabel">Navigation</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body p-0">
{{template "sidebar" .}}
</div>
</div>
<main class="app-main" id="main">
<div class="container-fluid py-4">
{{range .Alerts}}
<div class="alert alert-{{.Level}} d-flex align-items-start gap-3" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1"></i>
<div class="flex-grow-1">
<div class="fw-semibold">{{.Title}}</div>
<div class="small">{{.Message}}</div>
</div>
{{if .Link}}
<a href="{{.Link}}" class="btn btn-sm btn-outline-dark flex-shrink-0">{{.LinkText}}</a>
{{end}}
</div>
{{end}}
{{block "content" .}}{{end}}
</div>
</main>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer">
{{range .Flashes}}
{{if ne .Level "success"}}
<div class="toast align-items-center border-0 text-bg-{{.Level}}" role="alert"
aria-live="assertive" aria-atomic="true" data-autohide="{{if eq .Level "danger"}}false{{else}}true{{end}}">
<div class="d-flex">
<div class="toast-body">{{.Message}}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
{{end}}
</div>
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true" aria-labelledby="confirmModalLabel">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmModalLabel">Are you sure?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-0" id="confirmModalBody">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmModalAccept">Confirm</button>
</div>
</div>
</div>
</div>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
{{define "content"}}
{{$a := .Data.Admin}}
<h1 class="page-title mb-1">Account</h1>
<p class="text-body-secondary mb-4">The single administrator account for this server.</p>
{{template "settingsnav" dict "Subnav" "account"}}
<div class="row g-3">
<div class="col-12 col-lg-7">
<div class="card">
<div class="card-header">Change credentials</div>
<div class="card-body">
{{if $a.MustChangePassword}}
<div class="alert alert-warning d-flex align-items-start gap-2">
<i class="bi bi-shield-exclamation mt-1"></i>
<div>
This account still uses the password that was generated and printed at first
startup. Set your own before exposing the interface to anyone else.
</div>
</div>
{{end}}
<form method="post" action="/account" autocomplete="off">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="mb-3">
<label class="form-label" for="currentPassword">
Current password <span class="text-danger">*</span>
</label>
<input type="password" class="form-control" id="currentPassword" name="current_password"
required autocomplete="current-password">
<div class="form-text">
Always required, even for a username change. Browsers replay HTTP Basic
credentials automatically, so proving you know the password is what
distinguishes you from a hijacked tab.
</div>
</div>
<hr>
<div class="mb-3">
<label class="form-label" for="newUsername">Username</label>
<input type="text" class="form-control" id="newUsername" name="username"
value="{{$a.Username}}" autocomplete="username">
<div class="form-text">Letters, digits and the characters . - _ @</div>
</div>
<div class="mb-3">
<label class="form-label" for="newPassword">New password</label>
<input type="password" class="form-control" id="newPassword" name="new_password"
autocomplete="new-password" minlength="12">
<div class="form-text">
At least 12 characters. Leave blank to keep the current password.
Stored as an Argon2id hash, never in plain text.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="confirmPassword">Confirm new password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirm_password"
autocomplete="new-password">
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle me-1"></i>
After saving, your browser will prompt for the new credentials. The old password
stops working immediately.
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save credentials
</button>
</form>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card mb-3">
<div class="card-header">Account details</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-5 text-body-secondary">Username</dt>
<dd class="col-7 mono">{{$a.Username}}</dd>
<dt class="col-5 text-body-secondary">Created</dt>
<dd class="col-7">{{datetime $a.CreatedAt}}</dd>
<dt class="col-5 text-body-secondary">Last updated</dt>
<dd class="col-7">{{datetime $a.UpdatedAt}}</dd>
<dt class="col-5 text-body-secondary">Last sign-in</dt>
<dd class="col-7">
{{if $a.LastLoginAt}}{{datetime $a.LastLoginAt}}{{else}}never{{end}}
</dd>
<dt class="col-5 text-body-secondary">Password</dt>
<dd class="col-7">
<span class="badge text-bg-success">Argon2id</span>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">How authentication works</div>
<div class="card-body small text-body-secondary">
<p>
The management interface uses HTTP Basic authentication over a single
administrator account. There is no session cookie to steal, but browsers do
replay the credentials on every request, so state-changing requests additionally
carry a signed CSRF token.
</p>
<p>
Repeated failed sign-ins from one address are locked out for a few minutes.
</p>
<p class="mb-0">
For automation, use an <a href="/settings/api">API token</a> rather than the
administrator password: tokens are individually revocable and carry no ability to
change credentials.
</p>
</div>
</div>
</div>
</div>
{{end}}
+86
View File
@@ -0,0 +1,86 @@
{{define "content"}}
{{$f := .Data.Filter}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Audit Log</h1>
<p class="text-body-secondary mb-0">
Every administrative change, whether made in this interface, through the REST API,
or on the command line. Passwords and token secrets are never recorded.
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-end mb-3">
<div class="col-12 col-md-4">
<label class="form-label small" for="aSearch">Search</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="aSearch" name="q" value="{{$f.Search}}"
placeholder="Action, object or detail...">
</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label small" for="aObject">Object type</label>
<select class="form-select form-select-sm" id="aObject" name="object">
<option value="">All</option>
{{range list "zone" "record" "network" "policy" "list" "domain" "settings" "cache" "admin" "api_token" "backup" "config" "query_log"}}
<option value="{{.}}" {{if eq . $f.ObjectType}}selected{{end}}>{{title .}}</option>
{{end}}
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small" for="aSource">Source</label>
<select class="form-select form-select-sm" id="aSource" name="source">
<option value="">All</option>
{{range list "web" "api" "cli" "system"}}
<option value="{{.}}" {{if eq . $f.Source}}selected{{end}}>{{upper .}}</option>
{{end}}
</select>
</div>
<div class="col-auto">
<a href="/audit" class="btn btn-sm btn-link">Clear filters</a>
</div>
</form>
{{if .Data.Entries}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Time</th><th>Actor</th><th>Source</th><th>Action</th>
<th>Object</th><th>Details</th><th>Client</th>
</tr>
</thead>
<tbody>
{{range .Data.Entries}}
<tr>
<td class="small text-nowrap" title="{{datetime .Timestamp}}">{{timeAgo .Timestamp}}</td>
<td class="small fw-semibold">{{.Actor}}</td>
<td><span class="badge text-bg-light text-dark">{{upper .Source}}</span></td>
<td class="mono small">{{.Action}}</td>
<td class="small">
{{if .ObjectName}}
<span class="mono">{{truncate 40 .ObjectName}}</span>
{{end}}
{{if .ObjectType}}
<div class="text-body-secondary">{{.ObjectType}}{{if .ObjectID}} #{{.ObjectID}}{{end}}</div>
{{end}}
</td>
<td class="small text-body-secondary truncate-cell" title="{{.Details}}">{{.Details}}</td>
<td class="mono small text-body-secondary">{{.ClientIP}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else}}
{{template "empty" dict "Icon" "bi-clipboard-check" "Title" "No audit entries match"
"Message" "Administrative changes are recorded here as they happen. Clear the filters if you expected to see something."}}
{{end}}
</div>
</div>
{{end}}
+116
View File
@@ -0,0 +1,116 @@
{{define "content"}}
{{$c := .Data.Stats}}{{$csrf := .CSRF}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Cache</h1>
<p class="text-body-secondary mb-0">Responses held in memory to answer repeat queries instantly.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="/settings/cache" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-gear me-1"></i>Cache settings
</a>
{{template "confirmform" dict
"Action" "/cache/flush" "CSRF" $csrf
"Label" "Flush entire cache" "Icon" "bi-trash"
"Class" "btn btn-sm btn-danger"
"Message" "Flush every cached response? Queries will go back upstream until the cache refills."}}
</div>
</div>
{{if not $c.Enabled}}
<div class="alert alert-secondary d-flex align-items-center gap-2">
<i class="bi bi-info-circle"></i>
<div>The cache is turned off. Every recursive query is forwarded upstream.</div>
</div>
{{end}}
<div class="row g-3 mb-4">
{{template "stat" dict "Label" "Entries" "Value" (num $c.Entries) "Icon" "bi-database"
"Sub" (printf "limit %s" (num $c.MaxEntries))}}
{{template "stat" dict "Label" "Hit rate" "Value" (pct $c.HitRate) "Icon" "bi-lightning-charge" "Tone" "success"}}
{{template "stat" dict "Label" "Hits" "Value" (num $c.Hits) "Icon" "bi-check-circle" "Tone" "success"
"Sub" (printf "%s served stale" (num $c.StaleHits))}}
{{template "stat" dict "Label" "Misses" "Value" (num $c.Misses) "Icon" "bi-x-circle"}}
{{template "stat" dict "Label" "Memory estimate" "Value" (bytes $c.Bytes) "Icon" "bi-memory"}}
{{template "stat" dict "Label" "Insertions" "Value" (num $c.Insertions) "Icon" "bi-box-arrow-in-down"}}
{{template "stat" dict "Label" "Evictions" "Value" (num $c.Evictions) "Icon" "bi-box-arrow-up"
"Sub" "least recently used first"}}
{{template "stat" dict "Label" "Expirations" "Value" (num $c.Expirations) "Icon" "bi-hourglass-bottom"}}
</div>
<div class="card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Cached entries</span>
<span class="small text-body-secondary">
TTL clamped to {{$c.MinTTL}}{{$c.MaxTTL}}s · negative {{$c.NegativeTTL}}s
{{if $c.ServeStale}}· serves stale for {{$c.StaleTTL}}s{{end}}
{{if $c.Prefetch}}· prefetch on{{end}}
</span>
</div>
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{.Data.Search}}"
placeholder="Search cached names..." aria-label="Search cached entries">
</div>
</div>
<div class="col-auto">
<noscript><button class="btn btn-sm btn-outline-secondary" type="submit">Search</button></noscript>
</div>
</form>
{{if .Data.Entries}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Name</th><th>Type</th><th>Result</th>
<th class="text-end">Answers</th><th class="text-end">TTL left</th>
<th class="text-end">Size</th><th>Cached</th><th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Entries}}
<tr {{if .Stale}}class="is-disabled"{{end}}>
<td class="mono truncate-cell" title="{{.Name}}">{{trimDot .Name}}</td>
<td>
<span class="type-chip {{typeBadge .Type}}">{{.Type}}</span>
{{if .DO}}<span class="badge text-bg-light text-dark ms-1" title="DNSSEC answer">+do</span>{{end}}
</td>
<td>
<span class="badge {{rcodeBadge .Rcode}}">{{.Rcode}}</span>
{{if .Negative}}<span class="badge text-bg-light text-dark ms-1">negative</span>{{end}}
</td>
<td class="text-end">{{.Answers}}</td>
<td class="text-end">
{{if .Stale}}<span class="text-warning">stale</span>{{else}}{{.TTL}}s{{end}}
</td>
<td class="text-end small text-body-secondary">{{bytes .Size}}</td>
<td class="small text-body-secondary">{{timeAgo .Stored}}</td>
<td class="row-actions">
{{template "confirmform" dict
"Action" "/cache/delete" "CSRF" $csrf
"Icon" "bi-trash" "Title" "Remove this entry"
"Fields" (dict "name" .Name "type" .Type "dnssec" (boolstr .DO))
"Message" (printf "Remove the cached %s answer for %s?" .Type (trimDot .Name))}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else if .Data.Search}}
{{template "empty" dict "Icon" "bi-search" "Title" "Nothing cached matches that name"
"Message" "The entry may have expired, or the name may never have been queried."}}
{{else}}
{{template "empty" dict "Icon" "bi-lightning-charge" "Title" "The cache is empty"
"Message" "Recursive answers are cached here as clients query them."}}
{{end}}
</div>
</div>
{{end}}
+270
View File
@@ -0,0 +1,270 @@
{{define "content"}}
{{$d := .Data.D}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="text-body-secondary mb-0">
{{$d.Hostname}} · up {{$d.UptimeText}} · VibeDNS {{.Version}}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="/tools" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-search me-1"></i>Test a lookup
</a>
<a href="/querylog" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-journal-text me-1"></i>Query log
</a>
</div>
</div>
{{/* Service status strip */}}
<div class="card mb-4">
<div class="card-body py-3">
<div class="row g-3 align-items-center">
<div class="col-12 col-md-4 d-flex align-items-center gap-2">
<span class="status-dot {{if $d.DNSRunning}}status-up{{else}}status-down{{end}}"></span>
<div>
<div class="fw-semibold">{{if $d.DNSRunning}}DNS listeners running{{else}}DNS listeners down{{end}}</div>
<div class="small text-body-secondary mono">
UDP {{.Data.UDPAddr}} · TCP {{.Data.TCPAddr}}
</div>
</div>
</div>
<div class="col-6 col-md-3 d-flex align-items-center gap-2">
<span class="status-dot {{if $d.RecursionEnabled}}status-up{{else}}status-down{{end}}"></span>
<div>
<div class="fw-semibold">Recursion {{if $d.RecursionEnabled}}on{{else}}off{{end}}</div>
<div class="small text-body-secondary">
{{len $d.Upstreams}} upstream{{if ne (len $d.Upstreams) 1}}s{{end}}
</div>
</div>
</div>
<div class="col-6 col-md-3 d-flex align-items-center gap-2">
<span class="status-dot {{if $d.CacheEnabled}}status-up{{else}}status-down{{end}}"></span>
<div>
<div class="fw-semibold">Cache {{if $d.CacheEnabled}}on{{else}}off{{end}}</div>
<div class="small text-body-secondary">{{num $d.CacheEntries}} entries</div>
</div>
</div>
<div class="col-12 col-md-2 text-md-end">
<div class="small text-body-secondary">Queries per second</div>
<div class="fs-5 fw-semibold">{{printf "%.1f" $d.QueriesPerSec}}</div>
</div>
</div>
</div>
</div>
{{/* Headline counters */}}
<div class="row g-3 mb-4">
{{template "stat" dict "Label" "Total queries" "Value" (num $d.TotalQueries) "Icon" "bi-arrow-left-right"
"Sub" (printf "%s avg response" (ms $d.AvgQueryMS))}}
{{template "stat" dict "Label" "Blocked" "Value" (num $d.Blocked) "Icon" "bi-shield-slash" "Tone" "danger"
"Sub" (printf "%s of all queries" (pct $d.BlockRate))}}
{{template "stat" dict "Label" "Cache hit rate" "Value" (pct $d.CacheHitRate) "Icon" "bi-lightning-charge" "Tone" "success"
"Sub" (printf "%s hits · %s misses" (num $d.CacheHits) (num $d.CacheMisses))}}
{{template "stat" dict "Label" "Authoritative" "Value" (num $d.Authoritative) "Icon" "bi-diagram-3" "Tone" "primary"
"Sub" (printf "%s zones · %s records" (num $d.Zones) (num $d.Records))}}
{{template "stat" dict "Label" "Recursive" "Value" (num $d.Recursive) "Icon" "bi-globe2"
"Sub" (printf "%s upstream latency" (ms $d.AvgResolverMS))}}
{{template "stat" dict "Label" "Cache entries" "Value" (num $d.CacheEntries) "Icon" "bi-database"
"Sub" (bytes $d.CacheBytes)}}
{{template "stat" dict "Label" "Blacklist domains" "Value" (num $d.BlacklistDomains) "Icon" "bi-list-ul"
"Sub" (printf "across %s blacklists" (num $d.Blacklists))}}
{{template "stat" dict "Label" "Refused" "Value" (num $d.Refused) "Icon" "bi-hand-index" "Tone" "warning"
"Sub" (printf "%s rate limited · %s errors" (num $d.RateLimited) (num $d.Errors))}}
</div>
{{/* Activity + type breakdown */}}
<div class="row g-3 mb-4">
<div class="col-12 col-xl-8">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Query activity — last 24 hours</span>
{{if not $d.QueryLogEnabled}}
<span class="badge text-bg-secondary">query logging off</span>
{{end}}
</div>
<div class="card-body">
{{if and $d.QueryLogEnabled $d.Activity}}
<div class="chart-wrap"><canvas id="activityChart" aria-label="Query activity chart" role="img"></canvas></div>
{{else}}
{{template "empty" dict "Icon" "bi-graph-up" "Title" "No activity to chart yet"
"Message" "Query logging must be enabled for the activity chart. Once queries arrive they appear here."}}
{{end}}
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card h-100">
<div class="card-header">Queries by type</div>
<div class="card-body">
{{if $d.QueriesByType}}
<div class="chart-wrap-sm"><canvas id="typeChart" aria-label="Queries by record type" role="img"></canvas></div>
{{else}}
{{template "empty" dict "Icon" "bi-pie-chart" "Title" "No queries yet"
"Message" "Point a client at this server and the breakdown appears here."}}
{{end}}
</div>
</div>
</div>
</div>
{{/* Top lists */}}
<div class="row g-3 mb-4">
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Top queried domains</div>
{{if $d.TopDomains}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<tbody>
{{range $d.TopDomains}}
<tr>
<td class="mono truncate-cell" title="{{.Name}}">{{trimDot .Name}}</td>
<td class="text-end fw-semibold">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-bar-chart" "Title" "Nothing yet" "Message" "Queries from the last 24 hours appear here."}}
</div>
{{end}}
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Top blocked domains</div>
{{if $d.TopBlocked}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<tbody>
{{range $d.TopBlocked}}
<tr>
<td class="mono truncate-cell" title="{{.Name}}">{{trimDot .Name}}</td>
<td class="text-end fw-semibold text-danger">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-shield-check" "Title" "Nothing blocked"
"Message" "Assign a blacklist to a client network to start filtering."}}
</div>
{{end}}
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Top clients</div>
{{if $d.TopClients}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<tbody>
{{range $d.TopClients}}
<tr>
<td>
<div class="mono">{{.Name}}</div>
{{if .Extra}}<div class="small text-body-secondary">{{.Extra}}</div>{{end}}
</td>
<td class="text-end fw-semibold align-middle">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-people" "Title" "No clients yet" "Message" "Clients that query this server appear here."}}
</div>
{{end}}
</div>
</div>
</div>
{{/* Upstreams and recent activity */}}
<div class="row g-3">
<div class="col-12 col-xl-5">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Upstream resolvers</span>
<a href="/settings/resolver" class="btn btn-sm btn-outline-secondary">Configure</a>
</div>
<div class="table-responsive">
<table class="table table-sm table-compact">
<thead>
<tr><th>Server</th><th>Status</th><th class="text-end">Latency</th><th class="text-end">Queries</th></tr>
</thead>
<tbody>
{{range $d.Upstreams}}
<tr>
<td class="mono">{{.Address}}</td>
<td>
{{if .Healthy}}<span class="badge text-bg-success">healthy</span>
{{else}}<span class="badge text-bg-danger" title="{{.LastError}}">resting</span>{{end}}
</td>
<td class="text-end">{{ms .LatencyMS}}</td>
<td class="text-end">{{num .Queries}}</td>
</tr>
{{else}}
<tr><td colspan="4" class="text-center text-body-secondary py-3">No upstream resolvers configured.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recent DNS activity</span>
<a href="/querylog" class="btn btn-sm btn-outline-secondary">View all</a>
</div>
{{if $d.Recent}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<thead>
<tr><th>Time</th><th>Client</th><th>Query</th><th>Type</th><th>Result</th><th>Source</th></tr>
</thead>
<tbody>
{{range $d.Recent}}
<tr>
<td class="text-body-secondary small text-nowrap">{{timeOnly .Timestamp}}</td>
<td class="mono small">{{.ClientIP}}</td>
<td class="mono truncate-cell" title="{{.QName}}">{{trimDot .QName}}</td>
<td><span class="type-chip {{typeBadge .QType}}">{{.QType}}</span></td>
<td><span class="badge {{rcodeBadge .Rcode}}">{{.Rcode}}</span></td>
<td><span class="badge {{sourceBadge .Source}}">{{.Source}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-clock-history" "Title" "No recent activity"
"Message" "Recent queries appear here once query logging is enabled and clients start resolving."}}
</div>
{{end}}
</div>
</div>
</div>
{{/* Chart data travels in data- attributes rather than an inline script, so the
page needs no CSP exception. app.js reads and parses it. */}}
<div id="chartData" hidden
data-activity="{{json $d.Activity}}"
data-types="{{json $d.QueriesByType}}"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/chart.umd.js"></script>
{{end}}

Some files were not shown because too many files have changed in this diff Show More