initial commit
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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}'
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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.",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+690
@@ -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
|
||||
}
|
||||
Vendored
+331
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>"))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+5
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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 |
@@ -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();
|
||||
});
|
||||
})();
|
||||
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
Reference in New Issue
Block a user