commit 1e05a01bcf3d1755ce112bacba4e0d72064710e1 Author: Owen Rummage Date: Sun Aug 16 21:18:45 2026 -0500 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e2cc3ad --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24dfc85 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7bc7dbd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c5beb89 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d083d16 --- /dev/null +++ b/Makefile @@ -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}' diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e38a4e --- /dev/null +++ b/README.md @@ -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: + + This password will not be displayed again. + Change it at http://127.0.0.1:8080/account +``` + +The interface shows a banner until you replace that password. To supply your +own instead, set `VIBEDNS_ADMIN_PASSWORD` before the first start. + +Lost the password? The database file is the credential: + +```bash +sudo -u vibedns vibedns admin reset-password --db /var/lib/vibedns/dns.db +``` + +--- + +## Configuration + +Two layers, deliberately separated. + +**Startup settings** — needed before the database is open. CLI flags and +environment variables only. + +| Flag | Environment variable | Default | Purpose | +|---|---|---|---| +| `--db` | `VIBEDNS_DB_PATH` | `./data/dns.db` | SQLite database file | +| `--http` | `VIBEDNS_HTTP_ADDR` | `127.0.0.1:8080` | Management interface | +| `--dns` | `VIBEDNS_DNS_ADDR` | `0.0.0.0:53` | DNS listeners (UDP and TCP) | +| `--log-level` | `VIBEDNS_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | +| `--log-format` | `VIBEDNS_LOG_FORMAT` | `text` | `text` or `json` | +| `--admin-username` | `VIBEDNS_ADMIN_USERNAME` | `admin` | Initial administrator | +| — | `VIBEDNS_ADMIN_PASSWORD` | generated | Initial password | + +An address given on the command line is written back to the database, so the +running process and the stored configuration always agree. + +**Everything else** lives in SQLite and is edited from *Settings* in the UI or +through `/api/v1/settings`. Changes to zones, records, policies, lists, +upstreams and cache behaviour take effect immediately — no restart. Only the +listen addresses need one, and the UI says so where that applies. + +Inspect the effective configuration without starting the server: + +```bash +vibedns config check # validate, with an explicit open-resolver check +vibedns config show # print every effective setting +``` + +--- + +## DNS configuration examples + +### A forward zone + +```bash +# UI: Zones → Forward Zones → Add Zone +# Then add records from the zone page. +``` + +A typical internal zone: + +| Name | Type | Value | TTL | +|---|---|---|---| +| `@` | A | `192.0.2.10` | 3600 | +| `www` | CNAME | `example.com.` | 3600 | +| `mail` | A | `192.0.2.20` | 3600 | +| `@` | MX | `10 mail.example.com.` | 3600 | +| `@` | TXT | `v=spf1 mx -all` | 3600 | +| `*` | A | `192.0.2.99` | 300 | + +Names are relative to the zone apex; `@` is the apex itself and `*` is a +wildcard. Quoting and 255-character chunking for TXT records is handled for you. + +### Reverse DNS + +The point of the reverse zone form is that you never calculate a zone name. +Enter the subnet and the apex is derived: + +| You enter | Zone created | +|---|---| +| `192.168.1.0/24` | `1.168.192.in-addr.arpa.` | +| `10.0.0.0/8` | `10.in-addr.arpa.` | +| `172.16.0.0/16` | `16.172.in-addr.arpa.` | +| `2001:db8::/32` | `8.b.d.0.1.0.0.2.ip6.arpa.` | + +Reverse delegation only happens on octet boundaries for IPv4 and nibble +boundaries for IPv6. A `/25` is rounded up to the enclosing `/24`, and the UI +tells you it did. + +Add PTR records using the last octet as the name: + +| Name | Type | Value | +|---|---|---| +| `10` | PTR | `host.example.com.` | +| `20` | PTR | `mail.example.com.` | + +That answers `10.1.168.192.in-addr.arpa → host.example.com`. + +### Importing an existing zone + +```bash +curl -H "Authorization: Bearer $VIBEDNS_TOKEN" \ + --data-binary @example.com.zone \ + "http://127.0.0.1:8080/api/v1/zones/1/import?mode=replace" +``` + +The whole file is validated before anything is written, so a syntax error on +line 400 never leaves the zone half-imported. + +--- + +## Blacklist examples + +Create a list under *Policies → Blacklists*, then import into it. Three formats +are understood, and may be mixed in one file: + +```text +# plain list +example.com +tracker.example.net + +# hosts file +0.0.0.0 ads.example.com +127.0.0.1 telemetry.example.net +:: bad.example + +# Adblock-style host rules +||analytics.example.org^ +``` + +Comments, blank lines, IP-only lines, `localhost` entries and duplicates are +skipped, and the import summary reports exactly what happened: + +> Imported hosts.txt: 184,291 lines processed, 172,004 domains added, +> 12,201 duplicates skipped, 6 invalid entries, 80 comments or blank lines ignored. + +With *match subdomains* on (the default), blocking `example.com` also covers +`www.example.com` and `a.b.example.com` — matching walks the name's suffixes +rather than storing every possible subdomain. + +### Per-subnet policies + +The arrangement from the brief: + +1. **Blacklists**: Adult Content, Gambling, Malware +2. **Policy "Guest Filtering"**: all three blacklists, action NXDOMAIN +3. **Policy "Malware Only"**: Malware alone +4. **Network "Guest Wi-Fi"** `100.64.30.0/24` → Guest Filtering +5. **Network "SecureLAN"** `100.64.10.0/24` → Malware Only + +A client is matched against the *most specific* network containing its address. +To let one domain through everywhere, add it to an allowlist attached to the +same policy — an allowlist match always beats a blacklist match, so you never +have to edit an imported list. + +Check what any name would do, as any client, under *Tools*. + +--- + +## API examples + +Create a token under *Settings → API*. It is shown once. + +```bash +export VIBEDNS_TOKEN=vibedns_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx +export VIBEDNS=http://127.0.0.1:8080 +AUTH="Authorization: Bearer $VIBEDNS_TOKEN" +``` + +```bash +# List zones +curl -H "$AUTH" $VIBEDNS/api/v1/zones + +# Create a zone +curl -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"name":"internal.example","admin_email":"hostmaster@internal.example"}' \ + $VIBEDNS/api/v1/zones + +# Create a reverse zone from a subnet +curl -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"cidr":"192.168.1.0/24","kind":"reverse4"}' \ + $VIBEDNS/api/v1/zones + +# Add a record +curl -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"name":"www","type":"A","data":"192.0.2.10","ttl":3600}' \ + $VIBEDNS/api/v1/zones/1/records + +# Search records across every zone +curl -H "$AUTH" "$VIBEDNS/api/v1/records?search=192.0.2&type=A" + +# Import a blocklist (streamed, no size limit beyond the configured one) +curl -H "$AUTH" --data-binary @hosts.txt \ + $VIBEDNS/api/v1/blacklists/1/import + +# Statistics +curl -H "$AUTH" $VIBEDNS/api/v1/stats + +# Cache +curl -H "$AUTH" $VIBEDNS/api/v1/cache +curl -H "$AUTH" -X DELETE $VIBEDNS/api/v1/cache +curl -H "$AUTH" -X DELETE "$VIBEDNS/api/v1/cache?name=example.com" + +# What would this name do? +curl -H "$AUTH" "$VIBEDNS/api/v1/tools/lookup?name=example.com&type=A&client=100.64.30.5" +curl -H "$AUTH" "$VIBEDNS/api/v1/tools/domain-check?domain=ads.example.com" + +# Configuration export +curl -H "$AUTH" $VIBEDNS/api/v1/config/export > vibedns-config.json +``` + +Errors are JSON with an actionable message: + +```json +{ + "error": { + "status": 400, + "message": "an A record needs a valid IPv4 address, for example 192.0.2.10 (got \"not-an-ip\")", + "code": "invalid_request" + } +} +``` + +The administrator's Basic credentials also work, which is convenient for +interactive `curl`. Automation should use a token: tokens are individually +revocable and cannot change the administrator's credentials. + +--- + +## Backup and restore + +Backups use SQLite's `VACUUM INTO`, which writes a transactionally consistent +copy while the database is in use. Copying the `.db` file with `cp` would +capture a torn snapshot whose committed data still lives in the write-ahead log. + +```bash +vibedns database backup --db /var/lib/vibedns/dns.db --output /var/backups/vibedns +vibedns database stats --db /var/lib/vibedns/dns.db +vibedns database vacuum --db /var/lib/vibedns/dns.db +``` + +Enable scheduled backups under *Settings → Database*, with an interval and a +retention count. Backups can be downloaded from the UI or the API. + +**Restore is deliberately two-step.** Overwriting the database underneath a +running process would leave open connections reading a file that no longer +exists, so a restore is *staged* and applied on the next start: + +```bash +vibedns database restore --file /var/backups/vibedns/vibedns-20250101-030000.db +sudo systemctl restart vibedns +``` + +The database being replaced is preserved next to it as +`dns.db.pre-restore-`, so a restore that turns out to be the wrong +choice is still recoverable. In the UI you must retype the backup's file name +to confirm. + +--- + +## Security considerations + +**This server is not an open resolver, and takes work to become one.** +Recursion is restricted to an explicit allow list, seeded with RFC1918 and ULA +ranges. `vibedns config check` fails loudly if that list ever contains +`0.0.0.0/0` or `::/0`. Authoritative answers remain available to clients that +are not allowed to recurse, so tightening the ACL does not break your own zones. + +**Credentials.** The administrator password is stored as an Argon2id hash +(64 MiB, 3 passes). Because HTTP Basic replays credentials on every request, +successful verifications are cached briefly in memory, keyed by a MAC of the +password — otherwise every page load would cost 64 MiB and tens of milliseconds. +Changing the password clears that cache immediately. Repeated failures from one +address are locked out. + +API tokens are 256 bits from the system CSPRNG, stored as a SHA-256 hash with a +short clear-text prefix for lookup. A fast hash is correct here precisely +because a token has no low-entropy guess space — and it is verified on every +API request. + +**CSRF.** Basic authentication does not protect state-changing requests, so the +UI carries a signed, account-bound, double-submitted token on every form. The +API applies the same check only where it is meaningful: bearer tokens are never +sent automatically by a browser, and a JSON body cannot be produced by a +cross-origin form without a preflight. + +**Headers.** A strict Content-Security-Policy (`script-src 'self'`, no inline +scripts, no `eval`), `X-Frame-Options: DENY`, `nosniff`, and HSTS when served +over TLS. Page data reaches JavaScript through `data-` attributes rather than +inline ` + +{{block "scripts" .}}{{end}} + + diff --git a/web/templates/pages/account.html b/web/templates/pages/account.html new file mode 100644 index 0000000..80fbb14 --- /dev/null +++ b/web/templates/pages/account.html @@ -0,0 +1,127 @@ +{{define "content"}} +{{$a := .Data.Admin}} + +

Account

+

The single administrator account for this server.

+ +{{template "settingsnav" dict "Subnav" "account"}} + +
+
+
+
Change credentials
+
+ {{if $a.MustChangePassword}} +
+ +
+ This account still uses the password that was generated and printed at first + startup. Set your own before exposing the interface to anyone else. +
+
+ {{end}} + +
+ + +
+ + +
+ 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. +
+
+ +
+ +
+ + +
Letters, digits and the characters . - _ @
+
+ +
+ + +
+ At least 12 characters. Leave blank to keep the current password. + Stored as an Argon2id hash, never in plain text. +
+
+ +
+ + +
+ +
+ + After saving, your browser will prompt for the new credentials. The old password + stops working immediately. +
+ + +
+
+
+
+ +
+
+
Account details
+
+
+
Username
+
{{$a.Username}}
+ +
Created
+
{{datetime $a.CreatedAt}}
+ +
Last updated
+
{{datetime $a.UpdatedAt}}
+ +
Last sign-in
+
+ {{if $a.LastLoginAt}}{{datetime $a.LastLoginAt}}{{else}}never{{end}} +
+ +
Password
+
+ Argon2id +
+
+
+
+ +
+
How authentication works
+
+

+ 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. +

+

+ Repeated failed sign-ins from one address are locked out for a few minutes. +

+

+ For automation, use an API token rather than the + administrator password: tokens are individually revocable and carry no ability to + change credentials. +

+
+
+
+
+{{end}} diff --git a/web/templates/pages/audit.html b/web/templates/pages/audit.html new file mode 100644 index 0000000..c7dbe71 --- /dev/null +++ b/web/templates/pages/audit.html @@ -0,0 +1,86 @@ +{{define "content"}} +{{$f := .Data.Filter}} + +
+
+

Audit Log

+

+ Every administrative change, whether made in this interface, through the REST API, + or on the command line. Passwords and token secrets are never recorded. +

+
+
+ +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+ +
+ + {{if .Data.Entries}} +
+ + + + + + + + + {{range .Data.Entries}} + + + + + + + + + + {{end}} + +
TimeActorSourceActionObjectDetailsClient
{{timeAgo .Timestamp}}{{.Actor}}{{upper .Source}}{{.Action}} + {{if .ObjectName}} + {{truncate 40 .ObjectName}} + {{end}} + {{if .ObjectType}} +
{{.ObjectType}}{{if .ObjectID}} #{{.ObjectID}}{{end}}
+ {{end}} +
{{.Details}}{{.ClientIP}}
+
+ {{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}} +
+
+{{end}} diff --git a/web/templates/pages/cache.html b/web/templates/pages/cache.html new file mode 100644 index 0000000..7775d67 --- /dev/null +++ b/web/templates/pages/cache.html @@ -0,0 +1,116 @@ +{{define "content"}} +{{$c := .Data.Stats}}{{$csrf := .CSRF}} + +
+
+

Cache

+

Responses held in memory to answer repeat queries instantly.

+
+
+ + Cache settings + + {{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."}} +
+
+ +{{if not $c.Enabled}} +
+ +
The cache is turned off. Every recursive query is forwarded upstream.
+
+{{end}} + +
+ {{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"}} +
+ +
+
+ Cached entries + + 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}} + +
+
+
+
+
+ + +
+
+
+ +
+
+ + {{if .Data.Entries}} +
+ + + + + + + + + + {{range .Data.Entries}} + + + + + + + + + + + {{end}} + +
NameTypeResultAnswersTTL leftSizeCachedActions
{{trimDot .Name}} + {{.Type}} + {{if .DO}}+do{{end}} + + {{.Rcode}} + {{if .Negative}}negative{{end}} + {{.Answers}} + {{if .Stale}}stale{{else}}{{.TTL}}s{{end}} + {{bytes .Size}}{{timeAgo .Stored}} + {{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))}} +
+
+ {{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}} +
+
+{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html new file mode 100644 index 0000000..b6a442d --- /dev/null +++ b/web/templates/pages/dashboard.html @@ -0,0 +1,270 @@ +{{define "content"}} +{{$d := .Data.D}} + +
+
+

Dashboard

+

+ {{$d.Hostname}} · up {{$d.UptimeText}} · VibeDNS {{.Version}} +

+
+ +
+ +{{/* Service status strip */}} +
+
+
+
+ +
+
{{if $d.DNSRunning}}DNS listeners running{{else}}DNS listeners down{{end}}
+
+ UDP {{.Data.UDPAddr}} · TCP {{.Data.TCPAddr}} +
+
+
+
+ +
+
Recursion {{if $d.RecursionEnabled}}on{{else}}off{{end}}
+
+ {{len $d.Upstreams}} upstream{{if ne (len $d.Upstreams) 1}}s{{end}} +
+
+
+
+ +
+
Cache {{if $d.CacheEnabled}}on{{else}}off{{end}}
+
{{num $d.CacheEntries}} entries
+
+
+
+
Queries per second
+
{{printf "%.1f" $d.QueriesPerSec}}
+
+
+
+
+ +{{/* Headline counters */}} +
+ {{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))}} +
+ +{{/* Activity + type breakdown */}} +
+
+
+
+ Query activity — last 24 hours + {{if not $d.QueryLogEnabled}} + query logging off + {{end}} +
+
+ {{if and $d.QueryLogEnabled $d.Activity}} +
+ {{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}} +
+
+
+
+
+
Queries by type
+
+ {{if $d.QueriesByType}} +
+ {{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}} +
+
+
+
+ +{{/* Top lists */}} +
+
+
+
Top queried domains
+ {{if $d.TopDomains}} +
+ + + {{range $d.TopDomains}} + + + + + {{end}} + +
{{trimDot .Name}}{{num .Count}}
+
+ {{else}} +
+ {{template "empty" dict "Icon" "bi-bar-chart" "Title" "Nothing yet" "Message" "Queries from the last 24 hours appear here."}} +
+ {{end}} +
+
+ +
+
+
Top blocked domains
+ {{if $d.TopBlocked}} +
+ + + {{range $d.TopBlocked}} + + + + + {{end}} + +
{{trimDot .Name}}{{num .Count}}
+
+ {{else}} +
+ {{template "empty" dict "Icon" "bi-shield-check" "Title" "Nothing blocked" + "Message" "Assign a blacklist to a client network to start filtering."}} +
+ {{end}} +
+
+ +
+
+
Top clients
+ {{if $d.TopClients}} +
+ + + {{range $d.TopClients}} + + + + + {{end}} + +
+
{{.Name}}
+ {{if .Extra}}
{{.Extra}}
{{end}} +
{{num .Count}}
+
+ {{else}} +
+ {{template "empty" dict "Icon" "bi-people" "Title" "No clients yet" "Message" "Clients that query this server appear here."}} +
+ {{end}} +
+
+
+ +{{/* Upstreams and recent activity */}} +
+
+
+
+ Upstream resolvers + Configure +
+
+ + + + + + {{range $d.Upstreams}} + + + + + + + {{else}} + + {{end}} + +
ServerStatusLatencyQueries
{{.Address}} + {{if .Healthy}}healthy + {{else}}resting{{end}} + {{ms .LatencyMS}}{{num .Queries}}
No upstream resolvers configured.
+
+
+
+ +
+
+
+ Recent DNS activity + View all +
+ {{if $d.Recent}} +
+ + + + + + {{range $d.Recent}} + + + + + + + + + {{end}} + +
TimeClientQueryTypeResultSource
{{timeOnly .Timestamp}}{{.ClientIP}}{{trimDot .QName}}{{.QType}}{{.Rcode}}{{.Source}}
+
+ {{else}} +
+ {{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."}} +
+ {{end}} +
+
+
+ +{{/* Chart data travels in data- attributes rather than an inline script, so the + page needs no CSP exception. app.js reads and parses it. */}} + +{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/pages/error.html b/web/templates/pages/error.html new file mode 100644 index 0000000..20fdc3b --- /dev/null +++ b/web/templates/pages/error.html @@ -0,0 +1,19 @@ +{{define "content"}} +
+
+
+
+
{{.Data.Status}}
+

{{.Data.Text}}

+

{{.Data.Message}}

+ {{/* No inline handlers: the Content-Security-Policy allows scripts + only from our own origin, so "go back" is wired up in app.js. */}} +
+ Dashboard + +
+
+
+
+
+{{end}} diff --git a/web/templates/pages/list_detail.html b/web/templates/pages/list_detail.html new file mode 100644 index 0000000..da7eabf --- /dev/null +++ b/web/templates/pages/list_detail.html @@ -0,0 +1,281 @@ +{{define "content"}} +{{$l := .Data.List}}{{$csrf := .CSRF}} +{{$back := printf "/policies/lists/%d" $l.ID}} +{{$isBlack := eq $l.Kind "blacklist"}} + + + +
+
+

{{$l.Name}}

+ {{if $l.Description}}

{{$l.Description}}

{{end}} +
+
+ + + Export + + +
+ + +
+
+
+ +
+
+
+
+
Domains
+
{{num $l.DomainCount}}
+
+
+
Status
+
{{statusWord $l.Enabled}}
+
+
+
Updated
+
{{timeAgo $l.UpdatedAt}}
+
+
+
Used by
+
+ {{if $l.UsedBy}} +
+ {{range $l.UsedBy}}{{.}}{{end}} +
+ {{else}} + no policies + {{end}} +
+
+
+
+
+ +{{if and (eq $l.DomainCount 0) (not .Data.Search)}} +{{template "empty" dict "Icon" (pick $isBlack "bi-shield-slash" "bi-shield-check") "Title" "This list is empty" + "Message" "Import a domain list to fill it. Plain lists, hosts files and Adblock-style rules are all understood, and a file with hundreds of thousands of lines is inserted in a single transaction."}} +
+ +
+{{else}} +
+
+
+
+
+ + +
+
+
+ {{if .Data.Search}}Clear{{end}} +
+
+ + {{if .Data.Entries}} +
+ + + + + + + + + {{range .Data.Entries}} + + + + + + + + + {{end}} + +
DomainMatchStatusCommentAddedActions
{{.Domain}} + {{if .MatchSubdomains}} + + + subdomains + + {{else}} + exact only + {{end}} + + + {{if $isBlack}}Blocked{{else}}Allowed{{end}} + + {{.Comment}}{{timeAgo .CreatedAt}} + {{template "confirmform" dict + "Action" (printf "/policies/domains/%d/delete" .ID) "CSRF" $csrf + "ReturnTo" $back "Icon" "bi-trash" + "Message" (printf "Remove %s from %s?" .Domain $l.Name)}} +
+
+ {{template "pagination" dict "P" .Data.Pagination "Q" .Query}} + {{else}} + {{template "empty" dict "Icon" "bi-search" "Title" "No domains match that search" + "Message" "Try a different term, or clear the search box."}} + {{end}} +
+
+{{end}} + +{{/* ---- Import modal ---- */}} + + +{{/* ---- Add domain modal ---- */}} + + +{{/* ---- Edit list modal ---- */}} + +{{end}} diff --git a/web/templates/pages/lists.html b/web/templates/pages/lists.html new file mode 100644 index 0000000..40b8910 --- /dev/null +++ b/web/templates/pages/lists.html @@ -0,0 +1,156 @@ +{{define "content"}} +{{$csrf := .CSRF}}{{$kind := .Data.Kind}}{{$isBlack := .Data.IsBlacklist}} + +
+
+

{{.Title}}

+

+ {{if $isBlack}} + Reusable sets of domains to block. One list can be attached to several policies. + {{else}} + Domains that must never be blocked. An allowlist match always beats a blacklist match. + {{end}} +

+
+ +
+ +
+
+
+
+
+ + +
+
+
+ + {{if .Data.Lists}} +
+ + + + + + + + + {{range .Data.Lists}} + + + + + + + + + {{end}} + +
ListDomainsUsed byUpdatedStatusActions
+ {{.Name}} + {{if .Description}}
{{truncate 80 .Description}}
{{end}} + {{if .SourceURL}} +
{{truncate 60 .SourceURL}}
+ {{end}} +
{{num .DomainCount}} + {{if .UsedBy}} +
+ {{range .UsedBy}}{{.}}{{end}} +
+ {{else}} + not used by any policy + {{end}} +
{{timeAgo .UpdatedAt}}{{statusWord .Enabled}} +
+ + + + + + + {{template "postform" dict + "Action" (printf "/policies/lists/%d/toggle" .ID) "CSRF" $csrf + "ReturnTo" (printf "/policies/%ss" $kind) + "Fields" (dict "enabled" (boolstr (not .Enabled))) + "Icon" (toggleIcon .Enabled) + "Class" "btn btn-sm btn-outline-secondary" + "Title" (printf "%s this list" (toggleVerb .Enabled))}} + {{template "confirmform" dict + "Action" (printf "/policies/lists/%d/delete" .ID) "CSRF" $csrf + "Icon" "bi-trash" + "Message" (printf "Delete %q and all %d of its domains? This cannot be undone." .Name .DomainCount)}} +
+
+
+ + {{else if .Data.Search}} + {{template "empty" dict "Icon" "bi-search" "Title" "No lists match that search" + "Message" "Try a different term, or clear the search box."}} + {{else}} + {{if $isBlack}} + {{template "empty" dict "Icon" "bi-shield-slash" "Title" "No blacklists yet" + "Message" "Create a list such as Malware, Advertising or Adult Content, then import domains into it from a plain list, a hosts file, or an Adblock-style rule set."}} + {{else}} + {{template "empty" dict "Icon" "bi-shield-check" "Title" "No allowlists yet" + "Message" "An allowlist holds the domains that must always resolve, even when a blacklist covers them. Attach it to the same policy as the blacklist you want to override."}} + {{end}} +
+ +
+ {{end}} +
+
+ + +{{end}} diff --git a/web/templates/pages/network_form.html b/web/templates/pages/network_form.html new file mode 100644 index 0000000..2823b38 --- /dev/null +++ b/web/templates/pages/network_form.html @@ -0,0 +1,119 @@ +{{define "content"}} +{{$n := .Data.Network}}{{$new := .Data.IsNew}}{{$sel := .Data.Selected}} + + + +

{{if $new}}Add a client network{{else}}{{$n.Name}}{{end}}

+{{if not $new}}

{{$n.CIDR}}

{{else}}
{{end}} + +
+ + +
+
+
+
Network
+
+
+ + +
+
+ + +
+ IPv4 or IPv6 in CIDR notation. A bare address is treated as a single host. +
+
+
+ + +
+
+ + + +
+
+
+ +
+
+ + Cancel +
+
+
+ +
+
+
DNS Policies
+
+

+ Tick every policy that should apply to clients in this subnet. Policies are + evaluated together: an allowlist in any of them overrides a blacklist match in + any other. +

+ + {{if .Data.Policies}} +
+ {{range .Data.Policies}} + + {{end}} +
+ {{else}} + {{template "empty" dict "Icon" "bi-sliders" "Title" "No policies exist yet" + "Message" "A policy pairs a set of blacklists and allowlists with a block action. Create one, then come back and assign it here."}} + + {{end}} +
+
+
+
+
+{{end}} diff --git a/web/templates/pages/networks.html b/web/templates/pages/networks.html new file mode 100644 index 0000000..2d630e2 --- /dev/null +++ b/web/templates/pages/networks.html @@ -0,0 +1,113 @@ +{{define "content"}} +{{$csrf := .CSRF}} + +
+
+

Client Networks

+

+ Subnets that DNS policies are applied to. A query is matched against the most + specific network containing the client's address. +

+
+ + Add Network + +
+ +{{if not .Data.Policies}} +
+ +
+ There are no policies to assign yet. A network without a policy is matched but + filters nothing. + Create a policy first. +
+
+{{end}} + +
+
+
+
+
+ + +
+
+
+ + {{if .Data.Networks}} +
+ {{range .Data.Networks}} +
+
+
+
+
+

+ {{.Name}} +

+
{{.CIDR}}
+
+ {{statusWord .Enabled}} +
+ + {{if .Description}} +

{{.Description}}

+ {{end}} + +
+
DNS Policies
+ {{if .Policies}} +
+ {{range .Policies}} + + {{.Name}} + · {{.BlockAction.Label}} + + {{end}} +
+ {{else}} + + No policies assigned — queries from this network are not filtered. + + {{end}} +
+ +
+ + Edit policies + + {{template "postform" dict + "Action" (printf "/policies/networks/%d/toggle" .ID) "CSRF" $csrf + "ReturnTo" "/policies/networks" + "Fields" (dict "enabled" (boolstr (not .Enabled))) + "Icon" (toggleIcon .Enabled) "Label" (toggleVerb .Enabled) + "Class" "btn btn-sm btn-outline-secondary"}} + {{template "confirmform" dict + "Action" (printf "/policies/networks/%d/delete" .ID) "CSRF" $csrf + "Icon" "bi-trash" "Label" "Delete" + "Message" (printf "Delete the network %q (%s)? Clients in this range will stop matching any policy." .Name .CIDR)}} +
+
+
+
+ {{end}} +
+ + {{else if .Data.Search}} + {{template "empty" dict "Icon" "bi-search" "Title" "No networks match that search" + "Message" "Try a different term, or clear the search box."}} + {{else}} + {{template "empty" dict "Icon" "bi-router" "Title" "No client networks yet" + "Message" "A client network binds a subnet to one or more policies. For example, a guest Wi-Fi range on 100.64.30.0/24 might use the adult content, gambling and malware blacklists, while a trusted LAN uses malware only."}} + + {{end}} +
+
+{{end}} diff --git a/web/templates/pages/policies.html b/web/templates/pages/policies.html new file mode 100644 index 0000000..4d445b9 --- /dev/null +++ b/web/templates/pages/policies.html @@ -0,0 +1,95 @@ +{{define "content"}} +{{$csrf := .CSRF}} + +
+
+

Policy Rules

+

+ A policy pairs a set of blacklists and allowlists with what to do when a name matches. +

+
+ + Add Policy + +
+ +
+
+ {{if .Data.Policies}} +
+ + + + + + + + + + {{range .Data.Policies}} + + + + + + + + + + {{end}} + +
PolicyBlocksAllowsActionNetworksStatusActions
+ {{.Name}} + {{if .Description}}
{{truncate 70 .Description}}
{{end}} +
+ {{if .BlacklistName}} +
+ {{range .BlacklistName}} + {{.}} + {{end}} +
+ {{else}}{{end}} +
+ {{if .AllowlistName}} +
+ {{range .AllowlistName}} + {{.}} + {{end}} +
+ {{else}}{{end}} +
+ {{.BlockAction.Label}} + {{if eq (printf "%s" .BlockAction) "sinkhole"}} +
{{.SinkholeIPv4}} / {{.SinkholeIPv6}}
+ {{end}} +
{{.NetworkCount}}{{statusWord .Enabled}} +
+ + + + {{template "postform" dict + "Action" (printf "/policies/rules/%d/toggle" .ID) "CSRF" $csrf + "ReturnTo" "/policies" + "Fields" (dict "enabled" (boolstr (not .Enabled))) + "Icon" (toggleIcon .Enabled) + "Class" "btn btn-sm btn-outline-secondary" + "Title" (printf "%s this policy" (toggleVerb .Enabled))}} + {{template "confirmform" dict + "Action" (printf "/policies/rules/%d/delete" .ID) "CSRF" $csrf + "Icon" "bi-trash" + "Message" (printf "Delete the policy %q? It will be removed from %d network(s)." .Name .NetworkCount)}} +
+
+
+ {{else}} + {{template "empty" dict "Icon" "bi-sliders" "Title" "No policies yet" + "Message" "A policy decides what happens when a query matches one of its blacklists — return NXDOMAIN, refuse the query, or answer with a sinkhole address. Assign the policy to a client network to put it into effect."}} + + {{end}} +
+
+{{end}} diff --git a/web/templates/pages/policy_form.html b/web/templates/pages/policy_form.html new file mode 100644 index 0000000..ea4b3a6 --- /dev/null +++ b/web/templates/pages/policy_form.html @@ -0,0 +1,167 @@ +{{define "content"}} +{{$p := .Data.Policy}}{{$new := .Data.IsNew}}{{$sel := .Data.Selected}} + + + +

{{if $new}}Create a policy{{else}}Edit {{$p.Name}}{{end}}

+ +
+ + +
+
+
+
Policy
+
+
+ + +
+
+ + +
+
+ + + +
+
+
+ +
+
When a name is blocked
+
+
+ + +
+ NXDOMAIN is the usual choice: clients treat it as a normal negative answer + and stop retrying. +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ Seconds a client should cache the block. A short value makes list changes + take effect quickly. +
+
+
+
+ +
+
+ + Cancel +
+
+
+ +
+
+
+ Blacklists +
+
+

+ A query matching any of these lists is blocked using the response chosen on the left. +

+ {{if .Data.Blacklists}} +
+ {{range .Data.Blacklists}} + + {{end}} +
+ {{else}} +

No blacklists exist yet.

+ Create a blacklist + {{end}} +
+
+ +
+
+ Allowlists +
+
+

+ A name on any allowlist is never blocked, whatever the blacklists say. This is + how you carve an exception out of a large imported list without editing it. +

+ {{if .Data.Allowlists}} +
+ {{range .Data.Allowlists}} + + {{end}} +
+ {{else}} +

No allowlists exist yet.

+ Create an allowlist + {{end}} +
+
+
+
+
+{{end}} diff --git a/web/templates/pages/querylog.html b/web/templates/pages/querylog.html new file mode 100644 index 0000000..2e2ff1a --- /dev/null +++ b/web/templates/pages/querylog.html @@ -0,0 +1,151 @@ +{{define "content"}} +{{$f := .Data.Filter}}{{$csrf := .CSRF}} + +
+
+

Query Log

+

+ Every query this server answered, when logging is enabled. +

+
+
+ + Logging settings + + {{template "confirmform" dict + "Action" "/querylog/clear" "CSRF" $csrf + "Label" "Clear log" "Icon" "bi-trash" + "Class" "btn btn-sm btn-outline-danger" + "Message" "Delete every row in the query log? This cannot be undone."}} +
+
+ +{{if not .Data.Enabled}} +
+ +
+ Query logging is turned off, so no new queries are being recorded. + Turn it on to populate this page and the + dashboard charts. +
+
+{{end}} + +{{if gt .Data.Stats.Dropped 0}} +
+ +
+ {{num .Data.Stats.Dropped}} log entries were dropped because the writer could not keep up. + Query answering is never delayed by logging, so under sustained load some records are + discarded rather than queued. +
+
+{{end}} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear filters +
+
+ + {{if .Data.Entries}} +
+ + + + + + + + + {{range .Data.Entries}} + + + + + + + + + + + + {{end}} + +
TimeClientNetworkQueryTypeResultSourceMatchedTook
{{timeOnly .Timestamp}}{{.ClientIP}}{{default "—" .NetworkName}}{{trimDot .QName}}{{.QType}} + {{.Rcode}} + {{if .Blocked}}blocked{{end}} + {{.Source}} + {{if .Blocked}} + {{.BlacklistName}} + {{if .MatchedRule}}
{{.MatchedRule}}
{{end}} + {{else if .CacheHit}} + cache hit + {{else}} + + {{end}} +
{{ms .DurationMS}}
+
+ {{template "pagination" dict "P" .Data.Pagination "Q" .Query}} + + {{else}} + {{template "empty" dict "Icon" "bi-journal-text" "Title" "No queries match" + "Message" "Either nothing has been logged yet, or the filters above exclude everything. Clear the filters to see the whole log."}} + {{end}} +
+
+{{end}} diff --git a/web/templates/pages/records.html b/web/templates/pages/records.html new file mode 100644 index 0000000..9231872 --- /dev/null +++ b/web/templates/pages/records.html @@ -0,0 +1,368 @@ +{{define "content"}} +{{$z := .Data.Zone}}{{$csrf := .CSRF}}{{$f := .Data.Filter}} +{{$backTo := printf "/zones/%d" $z.ID}} + + + +
+
+

{{trimDot $z.Name}}

+

+ {{if $z.Description}}{{$z.Description}} · {{end}} + serial {{$z.Serial}} · default TTL {{$z.DefaultTTL}}s · + {{statusWord $z.Enabled}} +

+
+
+ +
+ + +
+
+
+ +{{if .Data.Problems}} +
+
+ + {{len .Data.Problems}} record{{if ne (len .Data.Problems) 1}}s{{end}} in this zone could not be loaded and are not being served +
+
    + {{range .Data.Problems}} +
  • {{.Name}} {{.Type}} — {{.Err}}
  • + {{end}} +
+
+{{end}} + +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ + {{if or $f.Search $f.Type $f.Enabled}} + Clear + {{end}} +
+
+ +
+ + + +
+ 0 selected +
+ + + +
+
+ + {{if .Data.Records}} +
+ + + + + + + + + + + + + + {{range .Data.Records}} + + + + + + + + + + {{end}} + +
+ + NameTypeValueTTLStatusActions
+ + {{.Name}}{{.Type}}{{.Data}} + {{if .TTL}}{{.TTL}}{{else}}{{$z.DefaultTTL}}{{end}} + {{statusWord .Enabled}} +
+ + {{template "postform" dict + "Action" (printf "/records/%d/toggle" .ID) "CSRF" $csrf "ReturnTo" $backTo + "Fields" (dict "enabled" (boolstr (not .Enabled))) + "Icon" (toggleIcon .Enabled) + "Class" "btn btn-sm btn-outline-secondary" + "Title" (printf "%s this record" (toggleVerb .Enabled))}} + {{template "confirmform" dict + "Action" (printf "/records/%d/delete" .ID) "CSRF" $csrf "ReturnTo" $backTo + "Icon" "bi-trash" + "Message" (printf "Delete the %s record for %s?" .Type .Name)}} +
+
+
+ {{template "pagination" dict "P" .Data.Pagination "Q" .Query}} + + {{else if or $f.Search $f.Type $f.Enabled}} + {{template "empty" dict "Icon" "bi-search" "Title" "No records match those filters" + "Message" "Adjust or clear the filters above to see the rest of the zone."}} + {{else}} + {{template "empty" dict "Icon" "bi-list-columns-reverse" "Title" "This zone has no records yet" + "Message" "A zone needs at least an address record to be useful. The SOA and apex NS records are maintained for you automatically."}} +
+ +
+ {{end}} +
+
+
+ +{{/* ---- Record editor modal ---- */}} + + +{{/* ---- Import modal ---- */}} + + +{{/* ---- Clone modal ---- */}} + +{{end}} diff --git a/web/templates/pages/records_all.html b/web/templates/pages/records_all.html new file mode 100644 index 0000000..b720ad3 --- /dev/null +++ b/web/templates/pages/records_all.html @@ -0,0 +1,107 @@ +{{define "content"}} +{{$csrf := .CSRF}}{{$f := .Data.Filter}} + +
+
+

Records

+

Search every record across all zones.

+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ + {{if or $f.Search $f.Type $f.Enabled $f.ZoneID}} + Clear + {{end}} +
+
+ + {{if .Data.Records}} +
+ + + + + + + + + {{range .Data.Records}} + + + + + + + + + + {{end}} + +
ZoneNameTypeValueTTLStatusActions
+ {{trimDot .ZoneName}} + {{.Name}}{{.Type}}{{.Data}}{{if .TTL}}{{.TTL}}{{else}}{{end}}{{statusWord .Enabled}} +
+ + + + {{template "postform" dict + "Action" (printf "/records/%d/toggle" .ID) "CSRF" $csrf "ReturnTo" "/records" + "Fields" (dict "enabled" (boolstr (not .Enabled))) + "Icon" (toggleIcon .Enabled) + "Class" "btn btn-sm btn-outline-secondary" + "Title" (printf "%s this record" (toggleVerb .Enabled))}} + {{template "confirmform" dict + "Action" (printf "/records/%d/delete" .ID) "CSRF" $csrf "ReturnTo" "/records" + "Icon" "bi-trash" + "Message" (printf "Delete the %s record for %s in %s?" .Type .Name (trimDot .ZoneName))}} +
+
+
+ {{template "pagination" dict "P" .Data.Pagination "Q" .Query}} + {{else if or $f.Search $f.Type $f.Enabled $f.ZoneID}} + {{template "empty" dict "Icon" "bi-search" "Title" "No records match those filters" + "Message" "Adjust or clear the filters above."}} + {{else}} + {{template "empty" dict "Icon" "bi-list-columns-reverse" "Title" "No records yet" + "Message" "Create a zone and add records to it, and they will all be searchable from here."}} + + {{end}} +
+
+{{end}} diff --git a/web/templates/pages/resolver.html b/web/templates/pages/resolver.html new file mode 100644 index 0000000..65c0617 --- /dev/null +++ b/web/templates/pages/resolver.html @@ -0,0 +1,148 @@ +{{define "content"}} +{{$s := .Data.Settings}}{{$st := .Data.Stats}} + +
+
+

Resolver

+

+ Recursive resolution is performed by forwarding to the upstream servers below. +

+
+ + Resolver settings + +
+ +{{if not $s.DNS.Recursion}} +
+ +
+ Recursion is turned off. This server answers only for its own authoritative zones; + every other query is refused. +
+
+{{end}} + +
+ {{template "stat" dict "Label" "Forwarded queries" "Value" (num $st.Queries) "Icon" "bi-globe2"}} + {{template "stat" dict "Label" "Failures" "Value" (num $st.Failures) "Icon" "bi-exclamation-triangle" "Tone" "danger"}} + {{template "stat" dict "Label" "Average latency" "Value" (ms $st.AvgLatencyMS) "Icon" "bi-stopwatch"}} + {{template "stat" dict "Label" "Healthy upstreams" "Value" (printf "%d / %d" $st.Healthy $st.Upstreams) "Icon" "bi-heart-pulse" "Tone" "success"}} +
+ +
+
+
+
Upstream servers
+ {{if .Data.Upstreams}} +
+ + + + + + + + + + {{range .Data.Upstreams}} + + + + + + + + + {{end}} + +
ServerStatusLatencyQueriesFailuresLast used
{{.Address}} + {{if .Healthy}} + healthy + {{else}} + resting + {{end}} + {{if .LastError}} +
{{truncate 60 .LastError}}
+ {{end}} +
{{ms .LatencyMS}}{{num .Queries}}{{num .Failures}} + {{if .LastUsed}}{{timeAgo .LastUsed}}{{else}}never{{end}} +
+
+
+

+ A server that fails three times in a row is rested for twenty seconds and moved + to the back of the selection order. The current strategy is + {{$s.Resolver.Strategy}}. +

+
+ {{else}} +
+ {{template "empty" dict "Icon" "bi-globe2" "Title" "No upstream resolvers configured" + "Message" "Recursive resolution needs at least one upstream server. Add one in the resolver settings."}} +
+ {{end}} +
+
+ +
+
+
Test an upstream
+
+
+ +
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
Recursion access control
+
+

+ Recursion is refused for any client not listed here. This is what keeps the + server from becoming an open resolver. +

+
+
Allowed networks
+ {{if $s.Resolver.AllowNetworks}} +
+ {{range $s.Resolver.AllowNetworks}} + {{.}} + {{end}} +
+ {{else}} +
None — recursion is denied to every client.
+ {{end}} +
+ {{if $s.Resolver.DenyNetworks}} +
+
Denied networks
+
+ {{range $s.Resolver.DenyNetworks}} + {{.}} + {{end}} +
+
Denies are evaluated before allows.
+
+ {{end}} +
+
+
+
+{{end}} diff --git a/web/templates/pages/settings_api.html b/web/templates/pages/settings_api.html new file mode 100644 index 0000000..3f99c64 --- /dev/null +++ b/web/templates/pages/settings_api.html @@ -0,0 +1,162 @@ +{{define "content"}} +{{$csrf := .CSRF}} + +

Settings

+

Server configuration, stored in the database.

+ +{{template "settingsnav" .}} + +
+
+
+
+ API tokens + +
+ + {{if .Data.Tokens}} +
+ + + + + + + + + {{range .Data.Tokens}} + + + + + + + + + {{end}} + +
NamePrefixCreatedLast usedStatusActions
+ {{.Name}} + {{if .Description}}
{{truncate 70 .Description}}
{{end}} +
vibedns_{{.Prefix}}…{{timeAgo .CreatedAt}} + {{if .LastUsedAt}}{{timeAgo .LastUsedAt}}{{else}}never{{end}} + {{statusWord .Enabled}} +
+ {{template "postform" dict + "Action" (printf "/settings/api/tokens/%d/toggle" .ID) "CSRF" $csrf + "Fields" (dict "enabled" (boolstr (not .Enabled))) + "Icon" (toggleIcon .Enabled) + "Class" "btn btn-sm btn-outline-secondary" + "Title" (printf "%s this token" (toggleVerb .Enabled))}} + {{template "confirmform" dict + "Action" (printf "/settings/api/tokens/%d/delete" .ID) "CSRF" $csrf + "Icon" "bi-trash" "Title" "Revoke permanently" + "Message" (printf "Revoke the token %q? Any automation using it will stop working immediately." .Name)}} +
+
+
+ {{else}} +
+ {{template "empty" dict "Icon" "bi-key" "Title" "No API tokens yet" + "Message" "A token lets a script use the REST API without the administrator password. Tokens are stored hashed, shown only once when created, and can be revoked individually."}} +
+ +
+
+ {{end}} +
+ +
+
Using the API
+
+

+ Every resource lives under /api/v1. Authenticate with a + bearer token, or with the administrator's HTTP Basic credentials. +

+ +
+
List zones
+
curl -H "Authorization: Bearer $VIBEDNS_TOKEN" \
+  {{default "http://127.0.0.1:8080" .Data.BaseURL}}/api/v1/zones
+
+ +
+
Add an A record
+
curl -X POST -H "Authorization: Bearer $VIBEDNS_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"name":"www","type":"A","data":"192.0.2.10","ttl":3600}' \
+  {{default "http://127.0.0.1:8080" .Data.BaseURL}}/api/v1/zones/1/records
+
+ +
+
Import a blocklist
+
curl -X POST -H "Authorization: Bearer $VIBEDNS_TOKEN" \
+  --data-binary @hosts.txt \
+  {{default "http://127.0.0.1:8080" .Data.BaseURL}}/api/v1/blacklists/1/import
+
+
+
+
+ +
+
+
How tokens are stored
+
+

+ A token is 256 bits from the system random source. Only a short prefix — enough to + find the right row — and a SHA-256 hash are stored; the token itself is shown once, + at creation, and cannot be recovered afterwards. +

+

+ A fast hash is appropriate here precisely because a token is not a human-chosen + password: there is no small guess space to search, and the hash is verified on + every API request. +

+

+ Tokens carry the same authority as the administrator over the API, but cannot be + used to sign in to this interface or to change the administrator's credentials. +

+
+
+
+
+ + +{{end}} diff --git a/web/templates/pages/settings_cache.html b/web/templates/pages/settings_cache.html new file mode 100644 index 0000000..4352ab9 --- /dev/null +++ b/web/templates/pages/settings_cache.html @@ -0,0 +1,168 @@ +{{define "content"}} +{{$s := .Data.S}}{{$st := .Data.Stats}} + +

Settings

+

Server configuration, stored in the database.

+ +{{template "settingsnav" .}} + +
+ + +
+
+
+
Resolver cache
+
+ +
+
+ + + +
+
+ Turning the cache off flushes it immediately and sends every recursive query + upstream. +
+
+ +
+
Size
+
+
+ + +
+ Least recently used entries are evicted past this limit. Currently holding + {{num $st.Entries}} entries using roughly {{bytes $st.Bytes}}. +
+
+
+ + +
+ Seconds between sweeps that drop fully expired entries. +
+
+
+
+ +
+
Time to live
+
+
+ + +
Raises very short TTLs
+
+
+ + +
Caps very long TTLs
+
+
+ + +
+ How long NXDOMAIN and empty answers are remembered, capped by the SOA + minimum in the response. +
+
+
+
+ +
+
Stale answers
+
+ + + +
+
+
+ + +
+ Seconds past expiry an answer may still be used. This keeps names + resolving when an upstream is briefly unreachable, rather than failing + the client outright. +
+
+
+
+ +
+
Prefetch
+
+ + + +
+
+
+ +
+ + % +
+
+ An entry hit with less than this share of its TTL remaining is refreshed + in the background, so the client never waits on the upstream. +
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ Current cache + Browse +
+
+
+
Entries
+
{{num $st.Entries}}
+
Hit rate
+
{{pct $st.HitRate}}
+
Hits
+
{{num $st.Hits}}
+
Misses
+
{{num $st.Misses}}
+
Served stale
+
{{num $st.StaleHits}}
+
Evictions
+
{{num $st.Evictions}}
+
Memory
+
{{bytes $st.Bytes}}
+
+
+
+
+
+
+{{end}} diff --git a/web/templates/pages/settings_database.html b/web/templates/pages/settings_database.html new file mode 100644 index 0000000..bf8e9b4 --- /dev/null +++ b/web/templates/pages/settings_database.html @@ -0,0 +1,275 @@ +{{define "content"}} +{{$s := .Data.S}}{{$db := .Data.DBStats}}{{$csrf := .CSRF}}{{$bs := .Data.BackupStatus}} + +

Settings

+

Server configuration, stored in the database.

+ +{{template "settingsnav" .}} + +{{if .Data.PendingRestore}} +
+ +
+
A database restore is staged
+
+ The next time this server starts, the live database will be replaced by the staged + backup. The current database is preserved alongside it, so this remains reversible. +
+
+ {{template "confirmform" dict + "Action" "/settings/database/restore/cancel" "CSRF" $csrf + "Label" "Cancel restore" "Icon" "bi-x-lg" + "Class" "btn btn-sm btn-outline-dark" + "Message" "Discard the staged restore and keep the current database?"}} +
+{{end}} + +
+
+
+
Database
+
+
+
Path
+
{{$db.Path}}
+
Size
+
{{bytes $db.SizeBytes}}
+
Write-ahead log
+
{{bytes $db.WALBytes}}
+
Schema version
+
{{$db.SchemaVer}}
+
Zones
+
{{num $db.Zones}}
+
Records
+
{{num $db.Records}}
+
List domains
+
{{num $db.Domains}}
+
Query log rows
+
{{num $db.QueryLogs}}
+
Audit rows
+
{{num $db.AuditLogs}}
+
+
+
+ +
+
Schema migrations
+
+ + + {{range .Data.Migrations}} + + + + + {{end}} + +
{{.Version}}_{{.Name}} + {{if .Drifted}}drift + {{else if .Applied}}applied + {{else}}pending{{end}} +
+
+
+ +
+
Configuration transfer
+
+

+ A versioned JSON export of zones, records, networks, policies, list definitions and + settings. Password hashes and API token secrets are deliberately excluded. +

+ +
+ +
+ + +
+
+ + +
+
+ Objects that already exist are kept as they are and reported, never overwritten. +
+ +
+
+
+
+ +
+
+ +
+
Automatic backups
+
+
+ + + +
+ +
+
+ + +
+
+ +
+ + h +
+
+
+ +
+ + files +
+
+
+ +
+ + Backups use SQLite's VACUUM INTO, which writes a + transactionally consistent copy while the database is in use. Copying the file + with cp instead would capture a torn snapshot whose + committed data still lives in the write-ahead log. +
+ +
+ +
+
+
+
+ +
+
+ + Backups + {{if $bs.Count}} + + — {{$bs.Count}} file{{if ne $bs.Count 1}}s{{end}}, {{bytes $bs.TotalBytes}} + + {{end}} + + {{template "postform" dict + "Action" "/settings/database/backup" "CSRF" $csrf + "Label" "Back up now" "Icon" "bi-play-fill" + "Class" "btn btn-sm btn-primary"}} +
+ + {{if $bs.LastError}} +
+
+ + Last backup failed: {{$bs.LastError}} +
+
+ {{end}} + + {{if .Data.Backups}} +
+ + + + + + {{range .Data.Backups}} + + + + + + + {{end}} + +
FileSizeCreatedActions
{{.Name}}{{bytes .SizeBytes}}{{timeAgo .CreatedAt}} +
+ + + + + {{template "confirmform" dict + "Action" (printf "/settings/database/backup/%s/delete" .Name) "CSRF" $csrf + "Icon" "bi-trash" "Title" "Delete this backup" + "Message" (printf "Delete the backup file %s?" .Name)}} +
+
+
+ {{else}} +
+ {{template "empty" dict "Icon" "bi-archive" "Title" "No backups yet" + "Message" "Run one now, or enable the schedule above. Backups are written to the directory configured here and are restricted to the owning user."}} +
+ {{end}} +
+
+
+ +{{/* ---- Restore modal ---- */}} + +{{end}} diff --git a/web/templates/pages/settings_dns.html b/web/templates/pages/settings_dns.html new file mode 100644 index 0000000..0a0c154 --- /dev/null +++ b/web/templates/pages/settings_dns.html @@ -0,0 +1,157 @@ +{{define "content"}} +{{$s := .Data.S}} + +

Settings

+

Server configuration, stored in the database.

+ +{{template "settingsnav" .}} + +
+ + +
+
+
+
DNS service
+
+ +
+
Listeners
+
+
+ + +
+ Currently bound to {{.Data.BoundUDP}}. + Use [::]:53 to accept IPv4 and IPv6 together. +
+
+
+ + +
+ Currently bound to {{.Data.BoundTCP}}. + TCP is required: it is how large answers and zone transfers are carried. +
+
+
+
+ +
+
Recursion
+
+ + + +
+
+ With this off, the server answers only from its own authoritative zones and + refuses everything else. Which clients may recurse is controlled on the + Resolver page — that list is what prevents an + open resolver. +
+
+ +
+
EDNS and message sizes
+
+ + + +
+
+
+ + +
+ 1232 bytes is the widely recommended value: it stays under the smallest + path MTU in common use, so answers are not silently lost to fragmentation. +
+
+
+ + +
+ Larger answers are truncated, which tells the client to retry over TCP. +
+
+
+
+ +
+
Defaults and behaviour
+
+
+ + +
Seconds
+
+
+ + +
Seconds a TCP connection is kept open between queries
+
+
+
+ + + +
+ Off by default: publishing the software version only helps someone + looking for a matching exploit. +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
Service status
+
+
+ + + {{if .Data.Running}}Listeners running{{else}}Listeners stopped{{end}} + +
+
+
UDP
+
{{.Data.BoundUDP}}
+
TCP
+
{{.Data.BoundTCP}}
+
Recursion
+
{{if $s.DNS.Recursion}}enabled{{else}}disabled{{end}}
+
+
+
+
+
+
+{{end}} diff --git a/web/templates/pages/settings_http.html b/web/templates/pages/settings_http.html new file mode 100644 index 0000000..c66bf26 --- /dev/null +++ b/web/templates/pages/settings_http.html @@ -0,0 +1,203 @@ +{{define "content"}} +{{$s := .Data.S}} + +

Settings

+

Server configuration, stored in the database.

+ +{{template "settingsnav" .}} + +
+ + +
+
+
+
Management interface
+
+ +
+
+
+ + +
+ Currently bound to {{.Data.Bound}}. + Binding to loopback and reaching it over SSH or a VPN keeps the + interface off the network entirely. +
+
+
+ + +
+ Only needed behind a reverse proxy, for building absolute links. +
+
+
+
+ +
+
Reverse proxy
+ + +
+ X-Forwarded-For is honoured only when the request arrives from one of + these addresses. Leave empty when there is no proxy: trusting the header + unconditionally would let any client forge its own address and slip past the + sign-in rate limiter. +
+
+ +
+
Limits
+
+
+ +
+ + MB +
+
+ Applies to blocklist and zone file imports. Large public blocklists are + commonly 5–50 MB. +
+
+
+ +
+ + /min +
+
Per client address, excluding static assets.
+
+
+
+ +
+
Metrics
+
+ + + +
+
+ + + +
+
+ Metrics reveal query volumes and cache behaviour. Leave authentication on + unless the endpoint is reachable only by your scraper. +
+
+
+
+ +
+
DNS rate limiting
+
+
+ + + +
+
+ Clients over the limit are dropped without a reply. Answering would let an + attacker use this server to amplify traffic at a spoofed victim, which is the + abuse the limiter exists to prevent. +
+ +
+
+ + +
+
+ + +
Never below the rate
+
+
+ + +
+ Trusted infrastructure — a downstream forwarder or a busy mail server — + is never limited. +
+
+
+ +
+ +
+
+
+
+ +
+
+
Rate limiter activity
+
+
+
State
+
+ {{if .Data.RateLimit.Enabled}}on + {{else}}off{{end}} +
+
Allowed
+
{{num .Data.RateLimit.Allowed}}
+
Denied
+
+ {{num .Data.RateLimit.Denied}} +
+
Tracked clients
+
{{num .Data.RateLimit.TrackedClients}}
+
+
+
+ +
+
Observability endpoints
+
+
+
/healthz
+
Process liveness. Never touches the database.
+
/readyz
+
Readiness: listeners up and database reachable.
+
/metrics
+
+ Prometheus exposition. + {{if not $s.HTTP.MetricsEnabled}}disabled + {{else if $s.HTTP.MetricsPublic}}public + {{else}}authenticated{{end}} +
+
+
+
+
+
+
+{{end}} diff --git a/web/templates/pages/settings_logging.html b/web/templates/pages/settings_logging.html new file mode 100644 index 0000000..a08b69b --- /dev/null +++ b/web/templates/pages/settings_logging.html @@ -0,0 +1,149 @@ +{{define "content"}} +{{$s := .Data.S}} + +

Settings

+

Server configuration, stored in the database.

+ +{{template "settingsnav" .}} + +
+ + +
+
+
+
Query logging
+
+ +
+
+ + + +
+
+ Records are buffered in memory and written in batches, so logging never delays + a DNS response. Under sustained overload entries are dropped rather than queued. +
+
+ +
+
Retention
+
+ + Both limits are applied on every cleanup pass: rows older than the retention + period go first, then the table is trimmed to the row cap. Set a value to 0 to + disable that limit — but leaving both at 0 lets the database grow without bound. +
+
+
+ +
+ + days +
+
+
+ + +
Currently {{num .Data.QueryLogRows}} stored
+
+
+ +
+ + min +
+
+
+
+ +
+
Exclusions
+
+
+ + +
Queries from these clients are not logged.
+
+
+ + +
Subdomains are excluded too. Useful for chatty monitoring checks.
+
+
+
+ +
+ +
+
+
+
+ +
+
+
Application log
+
+
+ + +
+ Debug logs every request and resolution failure; useful when diagnosing, noisy + otherwise. +
+
+
+ + +
+
+ + +
Oldest entries are trimmed past this count.
+
+
+
+ +
+
Query log writer
+
+
+
State
+
+ {{if .Data.Stats.Enabled}}on + {{else}}off{{end}} +
+
Rows written
+
{{num .Data.Stats.Written}}
+
Buffered
+
{{num .Data.Stats.Buffered}}
+
Dropped
+
+ {{num .Data.Stats.Dropped}} +
+
Pruned
+
{{num .Data.Stats.Pruned}}
+
+
+
+
+
+
+{{end}} diff --git a/web/templates/pages/settings_resolver.html b/web/templates/pages/settings_resolver.html new file mode 100644 index 0000000..c155fce --- /dev/null +++ b/web/templates/pages/settings_resolver.html @@ -0,0 +1,160 @@ +{{define "content"}} +{{$s := .Data.S}} + +

Settings

+

Server configuration, stored in the database.

+ +{{template "settingsnav" .}} + +
+ + +
+
+
+
Recursive resolution
+
+ +
+
Upstream servers
+ + +
+ Literal IP addresses only — a host name here could not be resolved, because + this is the resolver that would have to resolve it. Port 53 is assumed if + omitted; bracket IPv6 addresses when specifying a port. +
+
+ +
+
Selection and timing
+
+
+ + +
+ A server that fails repeatedly is rested briefly and moved to the back of + the order, whichever strategy is chosen. +
+
+
+ + +
Milliseconds per attempt
+
+
+ + +
Extra servers to try
+
+
+ + +
+ Bounds how many upstream exchanges are in flight at once. +
+
+
+
+ +
+
Protocol preferences
+
+ + + +
+
+ Sets the DO bit so signatures are returned and passed through to clients that + ask for them. Validation itself is performed by the upstream resolver. +
+
+ + + +
+
+ +
+ +
+
+
+
+ +
+
+
+ Recursion access control +
+
+
+ An empty allow list denies recursion to everyone. A list that is too broad turns + this server into an open resolver, which will be abused for amplification + attacks. Keep it to networks you control. +
+ +
+ + +
One CIDR block or address per line.
+
+ +
+ + +
+ Evaluated before the allow list, so you can exclude a range from a broader + allowance. +
+
+
+
+ +
+
Current upstream health
+
+ + + {{range .Data.Upstreams}} + + + + + + {{else}} + + {{end}} + +
{{.Address}} + {{if .Healthy}}ok + {{else}}down{{end}} + {{ms .LatencyMS}}
No upstreams configured.
+
+
+
+
+
+{{end}} diff --git a/web/templates/pages/tools.html b/web/templates/pages/tools.html new file mode 100644 index 0000000..4ec8310 --- /dev/null +++ b/web/templates/pages/tools.html @@ -0,0 +1,130 @@ +{{define "content"}} +{{$form := .Data.Form}}{{$r := .Data.Result}} + +
+

Tools

+

+ Run a query through the full server pipeline exactly as a client on a given address + would experience it — policy, authoritative zones, cache and recursion included. +

+
+ +
+
+
+
Look up a name
+
+
+ +
+ + +
+
+ + +
+
+ + +
+ Policy and recursion ACLs are evaluated against this address, so you can + confirm what a guest device actually sees. +
+
+
+ + +
+
+ +
+
+
+
+
+ +
+ {{if .Data.Error}} +
+ {{.Data.Error}} +
+ {{end}} + + {{if $r}} +
+
+ {{$r.Question}} + + {{$r.Rcode}} + {{$r.Source}} + {{duration $r.Duration}} + +
+
+

Answer section

+ {{if $r.Answers}} +
{{range $r.Answers}}{{.}}
+{{end}}
+ {{else}} +

Empty — no records of that type exist for this name.

+ {{end}} + + {{if $r.Authority}} +

Authority section

+
{{range $r.Authority}}{{.}}
+{{end}}
+ {{end}} +
+
+ {{end}} + + {{if .Data.ListHits}} +
+
Policy list matches
+
+

+ Lists that cover this name. Whether it is actually blocked also depends on which + policies the client's network has assigned. +

+
+ + + + {{range .Data.ListHits}} + + + + + + {{end}} + +
ListKindMatched entry
{{.ListName}} + {{.Kind}} + {{.Matched}}
+
+
+
+ {{end}} + + {{if not $r}} +
+
+ {{template "empty" dict "Icon" "bi-tools" "Title" "No lookup run yet" + "Message" "Enter a name on the left and resolve it. The result shows which stage of the pipeline answered — a local zone, the cache, an upstream resolver, or a policy block."}} +
+
+ {{end}} +
+
+{{end}} diff --git a/web/templates/pages/zone_form.html b/web/templates/pages/zone_form.html new file mode 100644 index 0000000..c31ee06 --- /dev/null +++ b/web/templates/pages/zone_form.html @@ -0,0 +1,193 @@ +{{define "content"}} +{{$z := .Data.Zone}}{{$new := .Data.IsNew}}{{$kind := .Data.Kind}} +{{$isReverse := hasPrefix $kind "reverse"}} + + + +

{{if $new}}Create a zone{{else}}Edit {{trimDot $z.Name}}{{end}}

+ +
+ + +
+
+
+
Zone identity
+
+ + {{if $new}} +
+ + +
+ + {{if $isReverse}} +
+ + +
+
+ Enter the subnet and the matching in-addr.arpa or ip6.arpa + zone name is worked out for you. Leave the zone name below blank to use it. +
+
+ {{end}} + {{else}} + + {{end}} + +
+ + +
The apex of the zone. A trailing dot is added automatically.
+
+ +
+ + +
+ +
+ + + +
+
+
+ +
+
Start of authority
+
+

+ The SOA record is maintained for you. These values are what it will contain. +

+
+
+ + +
+
+ + +
Stored in the SOA in its DNS form.
+
+
+ + +
Seconds
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
SOA minimum
+
+
+
+
+
+ +
+
+
Serial number
+
+
+
Current serial
+
{{default 1 $z.Serial}}
+
+ +
+ + + +
+ The serial advances on every record change, which is what secondary servers + watch to know a zone was updated. +
+
+ +
+ + + +
+ +
+ Only needed when migrating a zone from another server that is already at a + higher serial. Tick the box above for this value to be used. +
+
+
+ + {{if not $new}} + + {{end}} + +
+
+ + Cancel +
+
+
+
+
+{{end}} diff --git a/web/templates/pages/zones.html b/web/templates/pages/zones.html new file mode 100644 index 0000000..9b333b2 --- /dev/null +++ b/web/templates/pages/zones.html @@ -0,0 +1,111 @@ +{{define "content"}} +{{$csrf := .CSRF}}{{$reverse := .Data.Reverse}} + +
+
+

{{.Title}}

+

+ {{if $reverse}}PTR zones for IPv4 and IPv6 address space. + {{else}}Zones this server answers authoritatively.{{end}} +

+
+ +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ + {{if .Data.Zones}} +
+ + + + + {{if $reverse}}{{end}} + + + + + + + + + + {{range .Data.Zones}} + + + {{if $reverse}}{{end}} + + + + + + + + {{end}} + +
ZoneKindRecordsSerialTTLStatusUpdatedActions
+ {{trimDot .Name}} + {{if .Description}}
{{truncate 80 .Description}}
{{end}} +
{{.Kind.Label}}{{num .RecordCount}}{{.Serial}}{{.DefaultTTL}} + {{statusWord .Enabled}} + {{timeAgo .UpdatedAt}} +
+ + + + + + + + + + {{template "postform" dict + "Action" (printf "/zones/%d/toggle" .ID) "CSRF" $csrf + "Fields" (dict "enabled" (boolstr (not .Enabled))) + "Icon" (toggleIcon .Enabled) + "Class" "btn btn-sm btn-outline-secondary" + "Title" (printf "%s this zone" (toggleVerb .Enabled))}} + {{template "confirmform" dict + "Action" (printf "/zones/%d/delete" .ID) "CSRF" $csrf + "Icon" "bi-trash" + "Message" (printf "Delete zone %s and all %d of its records? This cannot be undone." (trimDot .Name) .RecordCount)}} +
+
+
+ {{else if .Data.Search}} + {{template "empty" dict "Icon" "bi-search" "Title" "No zones match that search" + "Message" "Try a different term, or clear the search box to see every zone."}} + {{else}} + {{if $reverse}} + {{template "empty" dict "Icon" "bi-arrow-left-right" "Title" "No reverse zones yet" + "Message" "Reverse zones answer PTR lookups that turn an IP address back into a host name. Enter a subnet such as 192.168.1.0/24 and the correct in-addr.arpa zone name is worked out for you."}} + {{else}} + {{template "empty" dict "Icon" "bi-diagram-3" "Title" "No forward zones yet" + "Message" "A forward zone lets this server answer authoritatively for a domain such as internal.example.com, independently of the public DNS."}} + {{end}} + + {{end}} +
+
+{{end}} diff --git a/web/templates/partials.html b/web/templates/partials.html new file mode 100644 index 0000000..c11b36d --- /dev/null +++ b/web/templates/partials.html @@ -0,0 +1,184 @@ +{{/* Shared building blocks used across pages. */}} + +{{define "sidebar"}} + +{{end}} + + +{{/* Page header: title, optional subtitle and a slot for actions. */}} +{{define "pageheader"}} +
+
+

{{.Title}}

+ {{if .Subtitle}}

{{.Subtitle}}

{{end}} +
+ {{if .Actions}}
{{.Actions}}
{{end}} +
+{{end}} + + +{{/* A statistic tile. Pass: Label, Value, Icon, Sub, Tone. */}} +{{define "stat"}} +
+
+
+
+
{{.Label}}
+ {{if .Icon}}{{end}} +
+
{{.Value}}
+ {{if .Sub}}
{{.Sub}}
{{end}} +
+
+
+{{end}} + + +{{/* Empty state. Pass: Icon, Title, Message, optional Action (safe HTML). */}} +{{define "empty"}} +
+ +

{{.Title}}

+

{{.Message}}

+ {{if .Action}}
{{.Action}}
{{end}} +
+{{end}} + + +{{/* Pagination. Pass: P (pagination struct), Q (url.Values). */}} +{{define "pagination"}} +{{$p := .P}}{{$q := .Q}} +{{if gt $p.Pages 1}} + +{{else if gt $p.Total 0}} +
{{num $p.Total}} result{{if ne $p.Total 1}}s{{end}}
+{{end}} +{{end}} + + +{{/* A destructive submit button that opens the shared confirmation modal. + Pass: Action, CSRF, Label, Message, optional Icon, Class, ReturnTo. */}} +{{define "confirmform"}} +
+ + {{if .ReturnTo}}{{end}} + {{range $k, $v := .Fields}}{{end}} + +
+{{end}} + + +{{/* A plain post button with no confirmation. Same fields as confirmform. */}} +{{define "postform"}} +
+ + {{if .ReturnTo}}{{end}} + {{range $k, $v := .Fields}}{{end}} + +
+{{end}} + + +{{/* Settings sub-navigation. Pass the active key as .Subnav. */}} +{{define "settingsnav"}} + +{{end}} + + +{{/* Marks a field that needs a restart to take effect. */}} +{{define "restartbadge"}} +restart required +{{end}}