From 1e05a01bcf3d1755ce112bacba4e0d72064710e1 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 16 Aug 2026 21:18:45 -0500 Subject: [PATCH] initial commit --- .dockerignore | 27 + .gitignore | 36 + Dockerfile | 69 ++ LICENSE | 21 + Makefile | 86 ++ README.md | 540 +++++++++++ SECURITY.md | 9 + cmd/vibedns/main.go | 18 + deploy/vibedns.service | 71 ++ docker-compose.yml | 64 ++ go.mod | 25 + go.sum | 58 ++ internal/api/api.go | 289 ++++++ internal/api/handlers.go | 788 ++++++++++++++++ internal/api/routes.go | 125 +++ internal/app/admin.go | 237 +++++ internal/app/app.go | 320 +++++++ internal/app/backups.go | 96 ++ internal/app/cache.go | 65 ++ internal/app/configio.go | 337 +++++++ internal/app/errors.go | 79 ++ internal/app/integration_test.go | 442 +++++++++ internal/app/policy.go | 647 +++++++++++++ internal/app/records.go | 331 +++++++ internal/app/settings.go | 122 +++ internal/app/stats.go | 305 ++++++ internal/app/zones.go | 377 ++++++++ internal/auditlog/auditlog.go | 120 +++ internal/auth/auth_test.go | 277 ++++++ internal/auth/middleware.go | 504 ++++++++++ internal/auth/password.go | 176 ++++ internal/auth/token.go | 82 ++ internal/authoritative/answer.go | 322 +++++++ internal/authoritative/answer_test.go | 266 ++++++ internal/authoritative/index.go | 354 +++++++ internal/backup/backup.go | 463 +++++++++ internal/blacklist/blacklist_test.go | 262 +++++ internal/blacklist/matcher.go | 145 +++ internal/blacklist/parse.go | 228 +++++ internal/cache/cache.go | 690 ++++++++++++++ internal/cache/cache_test.go | 331 +++++++ internal/cli/cli.go | 210 +++++ internal/cli/commands.go | 596 ++++++++++++ internal/cli/serve.go | 258 +++++ internal/config/bootstrap.go | 193 ++++ internal/config/settings.go | 661 +++++++++++++ internal/database/database.go | 205 ++++ internal/database/database_test.go | 443 +++++++++ internal/database/migrate.go | 202 ++++ .../migrations/0001_initial_schema.sql | 182 ++++ .../migrations/0002_seed_defaults.sql | 31 + internal/database/store_admin.go | 207 ++++ internal/database/store_logs.go | 409 ++++++++ internal/database/store_policy.go | 891 ++++++++++++++++++ internal/database/store_settings.go | 79 ++ internal/database/store_zones.go | 654 +++++++++++++ internal/dnsengine/handler.go | 335 +++++++ internal/dnsengine/server.go | 315 +++++++ internal/metrics/metrics.go | 336 +++++++ internal/models/models.go | 268 ++++++ internal/netutil/prefix.go | 120 +++ internal/policy/policy.go | 298 ++++++ internal/policy/policy_test.go | 229 +++++ internal/querylog/querylog.go | 240 +++++ internal/ratelimit/ratelimit.go | 204 ++++ internal/resolver/acl.go | 94 ++ internal/resolver/resolver.go | 436 +++++++++ internal/runtimecfg/manager.go | 308 ++++++ internal/validate/dnsname.go | 272 ++++++ internal/validate/record.go | 541 +++++++++++ internal/validate/validate_test.go | 357 +++++++ internal/version/version.go | 62 ++ internal/web/flash.go | 105 +++ internal/web/handlers_core.go | 321 +++++++ internal/web/handlers_policy.go | 583 ++++++++++++ internal/web/handlers_records.go | 165 ++++ internal/web/handlers_settings.go | 398 ++++++++ internal/web/handlers_zones.go | 336 +++++++ internal/web/pages_test.go | 365 +++++++ internal/web/render.go | 592 ++++++++++++ internal/web/server.go | 512 ++++++++++ internal/web/support.go | 264 ++++++ internal/web/support_test.go | 33 + internal/web/templates_test.go | 120 +++ internal/zonefile/zonefile.go | 371 ++++++++ internal/zonefile/zonefile_test.go | 243 +++++ web/embed.go | 42 + web/static/css/app.css | 361 +++++++ web/static/css/bootstrap-icons.min.css | 5 + web/static/css/bootstrap.min.css | 6 + web/static/fonts/bootstrap-icons.woff2 | Bin 0 -> 130396 bytes web/static/img/favicon.svg | 15 + web/static/js/app.js | 540 +++++++++++ web/static/js/bootstrap.bundle.min.js | 7 + web/static/js/chart.umd.js | 14 + web/templates/layout.html | 125 +++ web/templates/pages/account.html | 127 +++ web/templates/pages/audit.html | 86 ++ web/templates/pages/cache.html | 116 +++ web/templates/pages/dashboard.html | 270 ++++++ web/templates/pages/error.html | 19 + web/templates/pages/list_detail.html | 281 ++++++ web/templates/pages/lists.html | 156 +++ web/templates/pages/network_form.html | 119 +++ web/templates/pages/networks.html | 113 +++ web/templates/pages/policies.html | 95 ++ web/templates/pages/policy_form.html | 167 ++++ web/templates/pages/querylog.html | 151 +++ web/templates/pages/records.html | 368 ++++++++ web/templates/pages/records_all.html | 107 +++ web/templates/pages/resolver.html | 148 +++ web/templates/pages/settings_api.html | 162 ++++ web/templates/pages/settings_cache.html | 168 ++++ web/templates/pages/settings_database.html | 275 ++++++ web/templates/pages/settings_dns.html | 157 +++ web/templates/pages/settings_http.html | 203 ++++ web/templates/pages/settings_logging.html | 149 +++ web/templates/pages/settings_resolver.html | 160 ++++ web/templates/pages/tools.html | 130 +++ web/templates/pages/zone_form.html | 193 ++++ web/templates/pages/zones.html | 111 +++ web/templates/partials.html | 184 ++++ 122 files changed, 29178 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 cmd/vibedns/main.go create mode 100644 deploy/vibedns.service create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api.go create mode 100644 internal/api/handlers.go create mode 100644 internal/api/routes.go create mode 100644 internal/app/admin.go create mode 100644 internal/app/app.go create mode 100644 internal/app/backups.go create mode 100644 internal/app/cache.go create mode 100644 internal/app/configio.go create mode 100644 internal/app/errors.go create mode 100644 internal/app/integration_test.go create mode 100644 internal/app/policy.go create mode 100644 internal/app/records.go create mode 100644 internal/app/settings.go create mode 100644 internal/app/stats.go create mode 100644 internal/app/zones.go create mode 100644 internal/auditlog/auditlog.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/token.go create mode 100644 internal/authoritative/answer.go create mode 100644 internal/authoritative/answer_test.go create mode 100644 internal/authoritative/index.go create mode 100644 internal/backup/backup.go create mode 100644 internal/blacklist/blacklist_test.go create mode 100644 internal/blacklist/matcher.go create mode 100644 internal/blacklist/parse.go create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/commands.go create mode 100644 internal/cli/serve.go create mode 100644 internal/config/bootstrap.go create mode 100644 internal/config/settings.go create mode 100644 internal/database/database.go create mode 100644 internal/database/database_test.go create mode 100644 internal/database/migrate.go create mode 100644 internal/database/migrations/0001_initial_schema.sql create mode 100644 internal/database/migrations/0002_seed_defaults.sql create mode 100644 internal/database/store_admin.go create mode 100644 internal/database/store_logs.go create mode 100644 internal/database/store_policy.go create mode 100644 internal/database/store_settings.go create mode 100644 internal/database/store_zones.go create mode 100644 internal/dnsengine/handler.go create mode 100644 internal/dnsengine/server.go create mode 100644 internal/metrics/metrics.go create mode 100644 internal/models/models.go create mode 100644 internal/netutil/prefix.go create mode 100644 internal/policy/policy.go create mode 100644 internal/policy/policy_test.go create mode 100644 internal/querylog/querylog.go create mode 100644 internal/ratelimit/ratelimit.go create mode 100644 internal/resolver/acl.go create mode 100644 internal/resolver/resolver.go create mode 100644 internal/runtimecfg/manager.go create mode 100644 internal/validate/dnsname.go create mode 100644 internal/validate/record.go create mode 100644 internal/validate/validate_test.go create mode 100644 internal/version/version.go create mode 100644 internal/web/flash.go create mode 100644 internal/web/handlers_core.go create mode 100644 internal/web/handlers_policy.go create mode 100644 internal/web/handlers_records.go create mode 100644 internal/web/handlers_settings.go create mode 100644 internal/web/handlers_zones.go create mode 100644 internal/web/pages_test.go create mode 100644 internal/web/render.go create mode 100644 internal/web/server.go create mode 100644 internal/web/support.go create mode 100644 internal/web/support_test.go create mode 100644 internal/web/templates_test.go create mode 100644 internal/zonefile/zonefile.go create mode 100644 internal/zonefile/zonefile_test.go create mode 100644 web/embed.go create mode 100644 web/static/css/app.css create mode 100644 web/static/css/bootstrap-icons.min.css create mode 100644 web/static/css/bootstrap.min.css create mode 100644 web/static/fonts/bootstrap-icons.woff2 create mode 100644 web/static/img/favicon.svg create mode 100644 web/static/js/app.js create mode 100644 web/static/js/bootstrap.bundle.min.js create mode 100644 web/static/js/chart.umd.js create mode 100644 web/templates/layout.html create mode 100644 web/templates/pages/account.html create mode 100644 web/templates/pages/audit.html create mode 100644 web/templates/pages/cache.html create mode 100644 web/templates/pages/dashboard.html create mode 100644 web/templates/pages/error.html create mode 100644 web/templates/pages/list_detail.html create mode 100644 web/templates/pages/lists.html create mode 100644 web/templates/pages/network_form.html create mode 100644 web/templates/pages/networks.html create mode 100644 web/templates/pages/policies.html create mode 100644 web/templates/pages/policy_form.html create mode 100644 web/templates/pages/querylog.html create mode 100644 web/templates/pages/records.html create mode 100644 web/templates/pages/records_all.html create mode 100644 web/templates/pages/resolver.html create mode 100644 web/templates/pages/settings_api.html create mode 100644 web/templates/pages/settings_cache.html create mode 100644 web/templates/pages/settings_database.html create mode 100644 web/templates/pages/settings_dns.html create mode 100644 web/templates/pages/settings_http.html create mode 100644 web/templates/pages/settings_logging.html create mode 100644 web/templates/pages/settings_resolver.html create mode 100644 web/templates/pages/tools.html create mode 100644 web/templates/pages/zone_form.html create mode 100644 web/templates/pages/zones.html create mode 100644 web/templates/partials.html 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}}