Remove the ConfigMap, RPZ and managed-directory editors the cluster objects replace

Zones, address redirects, inline rules, lists and groups are cluster objects now
(see the previous commits and deploy/MIGRATION.md), so the mechanisms that held
them are gone:

* internal/kubezones and the resolver's service-account access to a ConfigMap,
  the address-overrides read/write path and its poller, the zone-file poller and
  the Local zones editor with its /api/local-zones routes.
* localzone.Managed, the directory of console-written master files, and the
  blocklist.managed_zones key. The resolver now writes no zone data at all; its
  zone hooks only list. The shared half of that file (the record validator, the
  renderer and the round-trip check) stays as zonestate.go, because DNSZone
  rendering is built on it. Its tests are ported to RenderZone, which keeps the
  coverage of every editable record type, wildcards, relative owners, the
  SOA/NS rule and the delegation rule.
* Tests of the removed write paths: the contract test now builds its zone from a
  DNSZone, and a new test pins that every zone write route on the resolver is
  refused and changes nothing.

Standalone installs keep editing master files on disk and SIGHUP, and the
YAML-backed domain, client, upstream and settings editors, which have not moved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:17:17 +02:00
1 parent 2f25463347
commit 467733a7f9
21 files changed
+505 -2312

No files matched your search

+7 -6
View File
@@ -59,7 +59,7 @@ Cache is a single shard-local lookup; only a miss touches the network.
|---|---|---|
| Scope | Full production | clustering, metrics, ACLs, access log, hot reload, Docker, k8s, CI |
| Config store | YAML + DNS master files | reviewable/diffable in git |
| Console-written zone data | Its own directory, `blocklist.managed_zones`, one file per apex | the console is a real operator surface, so it needs somewhere to write; a separate directory keeps the operator's master files read-only to it, and an unset key keeps the console read-only by default |
| Editable data | Cluster objects (`DNSZone`, `ClientGroup`, `BlockList`, `AddressOverride`, `DomainRule`) written only by `fancy-dns-console`; **no ConfigMap-backed editors, no managed zone directory** | the resolver only reads them (a watch), so it runs anywhere with a kubeconfig, in or out of the cluster. YAML stays for deployment config (listeners, upstreams, cache). The old ConfigMap/RPZ/managed-directory writers are gone |
| Cluster sync | Valkey (Redis protocol) | simple, mature Go client, easy local dev |
| Secure DNS | DoT + DoH, **no DNSSEC validation** | deliberately out of scope; AD/CD/extended-RCODE are passed through only |
| Observability | Prometheus metrics + a JSON access log on stdout; **no in-app dashboard, log store or live tail** | the resolver is DNS only. Charts belong in Grafana, search in the log collector, and per-pod state in a multi-node deployment only disagrees with itself. The access log is blocked/errors by default, goes through a bounded buffer that drops (and counts) rather than ever blocking a query, and nothing may be added to the query path to feed a UI |
@@ -140,11 +140,12 @@ Break these and you break the design, not just the test.
- **The Go heap does not account for goroutine stacks, runtime metadata, or the
bbolt mmap.** `memory.reserve` exists to cover them; the cache snapshot is
mmapped and lives outside the heap entirely.
- **The zone writer proves its own output.** Every record is validated by the
project's own master-file parser, the file is rendered, and the rendering is
re-parsed and compared with the records it meant to write *before* the file
is renamed into place. A writer bug that lost a record would otherwise show
up as a zone that silently stopped answering.
- **A zone proves its own rendering.** Every record is validated by the
project's own master-file parser, the zone is rendered, and the rendering is
re-parsed and compared with the records it meant to write *before* it is
served (`localzone.RenderZone`, used by both the runtime and the console's
`kubesource.CheckZone`). A renderer bug that lost a record would otherwise
show up as a zone that silently stopped answering.
- **A zone the console creates is a real zone.** It is authoritative, so it
answers NXDOMAIN for the names it does not hold, and `DELETE /api/zones` is
the only thing that hands a name back to upstream. Deleting a record is not,
+41 -69
View File
@@ -1,7 +1,8 @@
# fancy-dns
A recursive DNS server with blocklists, per-client ACLs, a query log, an
embedded admin console and optional clustering over Valkey/Redis.
A recursive DNS server with blocklists, per-client ACLs, Prometheus metrics, a
JSON access log, a separate admin console and optional clustering over
Valkey/Redis.
* **What it is** — a single Go binary that answers DNS over UDP, TCP,
DNS-over-TLS (RFC 7858) and DNS-over-HTTPS (RFC 8484), resolves everything
@@ -24,7 +25,8 @@ Each line points at the package that implements it.
| Blocklists from hosts, Adblock Plus, plain and RPZ sources, compiled into one immutable set that is swapped atomically | `internal/blocklist` |
| Authoritative local data and RPZ policy zones from RFC 1035 master files | `internal/zonefile`, `internal/localzone` |
| Per-client ACL groups matched on CIDR, PTR suffix or reverse-name tag, with per-group domain policy, rate limit, concurrency cap, cache and blocklist toggles | `internal/acl` |
| Query log in a bbolt ring plus an in-memory live ring, sampling, per-domain counters and an SSE stream | `internal/qlog` |
| JSON access log on stdout (blocked and failed queries by default) through a buffer that drops rather than blocks | `internal/accesslog` |
| Cluster objects (`DNSZone`, `ClientGroup`, `BlockList`, `AddressOverride`, `DomainRule`) read with a watch, and written by the console | `internal/kubesource`, `internal/kubeedit`, `internal/console` |
| Prometheus metrics (`fancydns_*`) | `internal/metrics` |
| Admin REST API with API-key / basic / no auth, read-only keys, CORS, per-IP rate limiting and login lockout | `internal/api` |
| Admin console embedded in the binary with `go:embed` | `internal/web`, `web/` |
@@ -85,9 +87,8 @@ blocklist:
enabled: true # lists are OFF unless you say enabled: true
refresh: 0s # 0 disables the scheduled refresh
qlog:
enabled: true
path: data/queries.db
access_log:
enabled: true # one JSON line on stdout per blocked or failed query
api:
enabled: true
@@ -249,7 +250,6 @@ file.
| `blocklist.response_ttl` | duration | `60s` | TTL of synthesised answers |
| `blocklist.redirect_name` | name | — | required when `default_action: redirect` |
| `blocklist.local_zones` | list of globs | empty | RPZ policy zones and/or local authoritative data, read from master files |
| `blocklist.managed_zones` | path | empty | the only directory the admin console may **write** zone data into, one `<apex>.zone` per zone; empty keeps the console's Zones page read-only |
| `blocklist.block_entries` / `allow_entries` | list of lines | empty | inline rules, compiled as `inline:block` / `inline:allow` and applied before every list |
| `blocklist.parallelism` | int | `4` | **unused** — compilation is single-threaded |
| `blocklist.lists[].name` | string | — | required, unique |
@@ -284,25 +284,25 @@ file.
| `acl.groups[].skip_cache` | bool | `false` | the group only gets fresh lookups |
| `acl.groups[].rate_limit` | int | inherit | queries/second for this group |
| `acl.groups[].max_concurrent` | int | `0` (off) | in-flight cap for this client |
| `acl.groups[].log_queries` | bool | `false` | log every query from this group regardless of `qlog.sample_rate` |
| `acl.groups[].log_queries` | bool | `false` | log every query from this group regardless of `access_log` selection |
| `acl.groups[].upstreams` | list of addrs | empty | restrict this group to named upstreams; a list that matches nothing falls back to the whole pool |
### qlog
### access_log
One JSON line on stdout per query whose outcome is selected. The resolver keeps
no query log of its own: a log collector indexes these lines, and the Prometheus
metrics count everything. Lines go through a bounded buffer and are dropped, and
counted in `fancydns_access_log_dropped_total`, if the stream falls behind, so
logging can never stall a query.
| Key | Type | Default | What it does |
|---|---|---|---|
| `qlog.enabled` | bool | `true` | when off the bbolt file is not opened (the live ring and counters stay) |
| `qlog.path` | path | `data/queries.db` | bbolt ring file |
| `qlog.max_entries` | int | `200000` | ring bound; the oldest entries are dropped |
| `qlog.flush_interval` | duration | `1s` | batches writes onto a background goroutine |
| `qlog.sample_rate` | 0–1 | `1.0` | sampling for ordinary queries; blocked/error/cached logging is governed by the flags below |
| `qlog.log_blocked` | bool | `true` | always log blocks |
| `qlog.log_cached` | bool | `true` | log cache hits (set `false` on a busy instance) |
| `qlog.log_errors` | bool | `true` | always log SERVFAIL and friends |
| `qlog.log_client_names` | bool | `false` | **unused** |
| `qlog.live_buffer` | int | `2000` | in-memory ring that feeds the UI and the SSE stream |
| `qlog.top_domains` | int | `5000` | number of per-domain counters kept |
| `qlog.top_ttl` | duration | `5m` | how long those counters are aggregated |
| `access_log.enabled` | bool | `true` | |
| `access_log.log_blocked` | bool | `true` | blocklist hits and redirects |
| `access_log.log_errors` | bool | `true` | SERVFAIL, drops and ACL refusals |
| `access_log.log_cached` | bool | `false` | also cache hits |
| `access_log.sample_rate` | 0–1 | `0` | fraction of ordinary answers to log as well; for a debugging session |
| `access_log.buffer` | int | `4096` | lines that may wait for the stream before dropping |
### api
@@ -432,51 +432,27 @@ records mean and where they are matched:
one-label and deeper wildcards, SRV, TXT). `deploy/zones/local.rpz.zone.example`
is a policy zone.
**Two zone pages, two ownership models.** **Local zones** edits the RFC 1035
master files themselves (in Kubernetes, one file per zone in the
`fancy-dns-zones` ConfigMap; standalone, the files named by
`blocklist.local_zones`). **Zones** is the record-level view and the only one
that writes a directory of its own, `blocklist.managed_zones`. They are
separate on purpose: the first edits what you wrote, the second writes what it
owns. Neither rewrites the other's files.
**Edit zones in the console.** Open **Local zones** to create a zone or edit its
RFC 1035 master-file records. In Kubernetes, the editor stores each file in the
`fancy-dns-zones` ConfigMap; both replicas watch the projected files and reload
them automatically. The API validates every file and its record owners before
saving. The pod's service account can read and patch only this ConfigMap. The
ConfigMap's `data` is intentionally left out of the GitOps manifest so console
edits persist across reconciliation. For a standalone install, manage zone
files on disk and reload the configuration.
**Zones the console writes.** `blocklist.managed_zones` names a directory the
admin console owns: one `<apex>.zone` per zone, holding the SOA, the apex NS
set and whatever records were added there. It is a zone source like any other —
`localzone.Load` compiles it alongside `local_zones` — so a record added from
the console is served on the next query, with no reload and no restart. The
master files in `local_zones` are never rewritten: they are listed in the
console's Zones page and shown as read-only. Without `managed_zones` the page
is a browser and nothing else.
Four rules the writer holds to, all of them because the alternative is a zone
**Zones in Kubernetes are `DNSZone` objects.** With `-kube` the resolver watches
`DNSZone` objects and serves them next to the master files in `local_zones`; the
console (`fancy-dns-console`) is what writes them. The resolver itself writes no
zone data. A `DNSZone` is rendered into a master file and re-parsed before it is
served, and one that does not render is rejected without touching the zones that
already work. Rules the renderer holds to, all because the alternative is a zone
that silently stops answering:
* A record line is validated by the project's own master-file parser, so an RDATA
name written without a trailing dot means the same thing here as in a zone
file, and the value the API echoes is the value the file holds.
* What the writer renders is re-parsed and compared with what it meant to write
before anything touches the disk. A round-trip that loses a record is a
writer bug, caught there rather than by an operator noticing an empty zone.
* A record is validated by the project's own master-file parser, so an RDATA name
written without a trailing dot means the same thing here as in a zone file.
* What is rendered is re-parsed and compared with what it meant to write. A
round-trip that loses a record is a renderer bug, caught there.
* A zone needs an SOA (or it cannot answer NXDOMAIN/NODATA) and an NS record at
the apex (or it delegates nothing and has no server to name), so a brand new
zone is started with one of the two. The SOA's primary follows the apex NS
set, and the serial moves on every write.
* A record at or below a delegation is refused: the store answers those names as
delegated and never looks at the zone file, so it would be data that looks
alive and never answers.
the apex. The SOA's primary follows the apex NS set, and its serial is the
object's generation, so every replica serves identical bytes.
* A record at or below a delegation is refused: those names are answered as
delegated and the record would look alive and never answer.
A record is written by rewriting the whole file, so a file under
`managed_zones` is the console's to own; hand-maintained data belongs in
`local_zones`. Each file says so in its header.
To move an existing deployment onto these objects, see
[deploy/MIGRATION.md](deploy/MIGRATION.md). A standalone install keeps its zones
as master files on disk and reloads with SIGHUP.
## Blocklists
@@ -536,7 +512,6 @@ is on.
| POST | `/api/auth/login` | — | exchange an API key or user/password for a bearer token (public) |
| POST | `/api/auth/logout` | yes | revoke the caller's bearer token |
| GET | `/api/whoami` | — | caller's name, mode, `read_only`, `can_write` |
| GET | `/api/dashboard?range=…` | — | aggregated counters and the rolling series |
| GET | `/api/status` | — | version, uptime, config generation/last error, per-component status, listeners |
| GET | `/api/blocklists` | — | compiled state, per-source metadata, errors |
| POST | `/api/blocklists/refresh` | yes | recompile now; body `{"names":[…]}` or empty for everything |
@@ -546,22 +521,19 @@ is on.
| POST | `/api/domains` | yes | add an inline rule |
| POST | `/api/dns/test` | — | run one query through the live pipeline (`{"name","type"}`) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request |
| DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule |
| GET | `/api/zones` | — | every local zone with its records, delegations, source files, the managed directory, whether it is writable, the writable record types, and why the store is stale (`error`) |
| GET | `/api/zones` | — | every local zone with its records, delegations, source files, whether each is an editable cluster object, the record types the console writes, and why the store is stale (`error`) |
| POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) |
| PUT | `/api/zones/records` | yes | replace the whole RRset of one name and type |
| DELETE | `/api/zones/records?zone=&name=&type=&value=` | yes | remove one record, or the RRset when `value` is empty |
| POST | `/api/zones/delegations` | yes | delegate a name to other servers (`{"zone","name","targets":[…],"ttl"}`); the delegation's targets become the apex NS set of a zone that has none |
| DELETE | `/api/zones/delegations?zone=&name=` | yes | remove a cut |
| DELETE | `/api/zones?zone=` | yes | delete a zone the console owns, so its names go back to whatever answered for them before; refused for a zone with records outside the managed directory |
| DELETE | `/api/zones?zone=` | yes | delete a `DNSZone`, so its names go back to whatever answered for them before (console only) |
| GET | `/api/clients` | — | ACL groups and the default policy |
| PUT | `/api/clients` | yes | replace the ACL (`{"groups":[…],"default_policy":…}`); validates, writes the file, swaps, broadcasts |
| GET | `/api/upstreams` | — | pool state, health, latency, breaker |
| PUT | `/api/upstreams` | yes | replace policy/servers/bootstraps/attempts/threshold (cooldown is preserved) |
| GET | `/api/logs?q=&action=&client=&rcode=&blocked=&since=&limit=&offset=` | — | page the query log |
| GET | `/api/logs/stream` | — | Server-Sent Events: `ready`, `entry`, `stats` and `: ping` keep-alives |
| POST | `/api/logs/clear` | yes | empty the query log |
| GET | `/api/cache` | — | cache statistics |
| GET | `/api/cache/top?limit=` | — | hottest cached names |
| GET | `/api/links` | — | the configured `api.links` (Grafana, log view, metrics) |
| POST | `/api/cache/flush` | yes | drop the cache (also done after a rule change) |
| GET | `/api/settings` | — | file YAML, effective YAML, defaults YAML, writability |
| PUT | `/api/settings` | yes | validate, write (atomic, mode 0600), bump `version`, swap, broadcast; needs `api.allow_config_write` |
+89
View File
@@ -0,0 +1,89 @@
# Moving a ConfigMap deployment to cluster objects
Until now the console edited zones and address redirects by writing two
ConfigMaps (`fancy-dns-zones`, `fancy-dns-rpz`) through the resolver's service
account, and the domains page rewrote the config file. Those mechanisms are gone.
Everything the console edits is now an object:
| Was | Is |
|---|---|
| `zone--<apex>.zone` keys in the `fancy-dns-zones` ConfigMap | `DNSZone` |
| `address-overrides.rpz` in the `fancy-dns-rpz` ConfigMap | `AddressOverride` (one per name) |
| `blocklist.allow_entries` / `block_entries` | `DomainRule` (`inline-allow`, `inline-block`) |
| `blocklist.lists` with a URL | `BlockList` |
| `acl.groups` | `ClientGroup` |
| listeners, upstreams, cache, `default_policy` | still the resolver's YAML |
The old resolver does not read the objects and the new one does not read the
ConfigMaps, so the order below matters: **create the objects while the old
resolver is still serving, switch, then delete the ConfigMaps.** Nothing here
deletes anything until the last step.
## 1. Install the definitions and permissions
```sh
kubectl apply -f deploy/k8s/crds/crds.yaml
kubectl apply -k deploy/k8s/crds # lets the resolver's service account read the objects
kubectl apply -f deploy/k8s/console/console.yaml # the console, which writes them (create its two Secrets first)
```
## 2. Convert what you have
The command reads the two ConfigMaps from the cluster and, if you give it the
resolver's `config.yaml`, its URL lists, inline entries and ACL groups. By
default it only **prints** manifests.
```sh
kubectl -n dns get cm fancy-dns-config -o jsonpath='{.data.config\.yaml}' > /tmp/config.yaml
FANCYDNS_API_KEY=unused \
fancy-dns-console migrate -kube-namespace dns -config /tmp/config.yaml > migrated.yaml
```
(`FANCYDNS_API_KEY=unused`: the config expands `${VAR}` references and refuses an
unset one; the value is not used.) Read `migrated.yaml`, and read what the
command printed on stderr:
* `converted:` lines say what became an object and what to remove from the config.
* `NOT converted:` lines are things it could not represent faithfully (a zone
with a record type the console does not write, a list that is a file on the
pod, a name that is not a legal object name). Those stay where they are.
Commit `migrated.yaml` if you manage the cluster with GitOps, or apply it:
```sh
fancy-dns-console migrate -kube-namespace dns -config /tmp/config.yaml -apply
```
It is safe to run again: objects are created or updated, never duplicated.
## 3. Switch the resolver
* Start it with `-kube` (the StatefulSet in `deploy/k8s/10-deployment.yaml` does).
* In `config.yaml`, remove what moved: the `edge-router-x-address-overrides` list
and any other list you converted, `acl.groups`, the inline entries,
`blocklist.managed_zones` and the `fancy-dns-zones` glob in `local_zones`.
* Roll it out.
## 4. Check before you delete anything
```sh
kubectl -n dns get dnszones,addressoverrides,clientgroups,blocklists,domainrules
curl -s https://<console>/api/status # components.degraded must not mention kubernetes
dig @<resolver> <a name from each zone and override>
```
A rejected object is not a silent failure: it is logged by the resolver, the
node reports "kubernetes: objects rejected" as degraded, and an object that was
valid before keeps its last good version.
## 5. Delete the old ConfigMaps
Only now:
```sh
kubectl -n dns delete cm fancy-dns-zones fancy-dns-rpz
```
If your GitOps tool prunes, remove them from the repository **after** step 4, not
before: they hold the only copy of the data until the objects exist.
-6
View File
@@ -113,12 +113,6 @@ blocklist:
response_ttl: 60s
# RPZ-style policy zones and/or local authoritative data, as master files.
local_zones: []
# The one directory the admin console may WRITE zone data into: one
# <apex>.zone per zone, holding the SOA, the apex NS set and whatever the
# console created. Files in local_zones are read-only to the console, and
# are never rewritten by it. Empty keeps the console's Zones page
# read-only, so nothing is written behind your back.
managed_zones: ""
# Inline rules. "*.example.com" covers subdomains, "**.example.com" covers
# the apex too, a bare name matches only itself.
block_entries: []
-1
View File
@@ -86,7 +86,6 @@ data:
blocklist:
enabled: true
default_action: nxdomain
managed_zones: /var/lib/fancy-dns/managed-zones
local_zones: [/etc/fancy-dns/zones/*.zone]
lists:
- name: edge-router-x-address-overrides
+1 -14
View File
@@ -72,11 +72,7 @@ type Hooks struct {
// AddDomain adds an inline rule and persists it.
AddDomain func(ctx context.Context, in DomainInput) (any, error)
// DeleteDomain removes a rule and persists the change.
DeleteDomain func(ctx context.Context, name, action, list string) error
// LocalZones lists, saves and deletes UI-managed DNS master files.
LocalZones func(ctx context.Context) ([]ZoneFile, error)
SaveLocalZone func(ctx context.Context, name, content string) error
DeleteLocalZone func(ctx context.Context, name string) error
DeleteDomain func(ctx context.Context, name, action, list string) error
AddressOverrides func(ctx context.Context) (any, error)
SaveAddressOverride func(ctx context.Context, domain, address string) error
DeleteAddressOverride func(ctx context.Context, domain string) error
@@ -135,12 +131,6 @@ type DomainInput struct {
Content string `json:"content,omitempty"`
}
// ZoneFile is a UI-managed authoritative master file.
type ZoneFile struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Server is the admin HTTP server.
type Server struct {
hooks Hooks
@@ -247,9 +237,6 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux.Handle("GET "+base+"address-overrides", s.authed(s.handleAddressOverrides, false))
mux.Handle("PUT "+base+"address-overrides", s.authed(s.handleAddressOverridePut, true))
mux.Handle("DELETE "+base+"address-overrides/{domain}", s.authed(s.handleAddressOverrideDelete, true))
mux.Handle("GET "+base+"local-zones", s.authed(s.handleLocalZones, false))
mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true))
mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true))
mux.Handle("GET "+base+"zones", s.authed(s.handleZones, false))
mux.Handle("DELETE "+base+"zones", s.authed(s.handleZoneDelete, true))
mux.Handle("POST "+base+"zones/records", s.authed(s.handleZoneRecordAdd, true))
-46
View File
@@ -253,52 +253,6 @@ func (s *Server) handleAddressOverrideDelete(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleLocalZones(w http.ResponseWriter, r *http.Request) {
if s.hooks.LocalZones == nil {
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
return
}
zones, err := s.hooks.LocalZones(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
}
func (s *Server) handleLocalZonePut(w http.ResponseWriter, r *http.Request) {
if s.hooks.SaveLocalZone == nil {
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
return
}
var in struct {
Content string `json:"content"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
name := r.PathValue("name")
if err := s.hooks.SaveLocalZone(r.Context(), name, in.Content); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
func (s *Server) handleLocalZoneDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteLocalZone == nil {
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
return
}
name := r.PathValue("name")
if err := s.hooks.DeleteLocalZone(r.Context(), name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
// handleDomainAdd adds an inline rule.
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddDomain == nil {
+3 -14
View File
@@ -1,7 +1,6 @@
package api
import (
"errors"
"net/http"
"strings"
"time"
@@ -109,22 +108,12 @@ func (s *Server) handleZoneDelete(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.hooks.DeleteZone(r.Context(), zone); err != nil {
writeError(w, zoneWriteStatus(err), err.Error())
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone})
}
// zoneWriteStatus tells a configuration problem from a missing record: a
// console that is not allowed to write zone data is a 400 with something the
// operator can fix, while a name that is not there is a 404.
func zoneWriteStatus(err error) int {
if errors.Is(err, localzone.ErrNotManaged) {
return http.StatusBadRequest
}
return http.StatusNotFound
}
// handleZoneRecordAdd adds one record to a zone.
func (s *Server) handleZoneRecordAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddZoneRecord == nil {
@@ -186,7 +175,7 @@ func (s *Server) handleZoneRecordDelete(w http.ResponseWriter, r *http.Request)
return
}
if err := s.hooks.DeleteZoneRecord(r.Context(), zone, name, rrtype, r.URL.Query().Get("value")); err != nil {
writeError(w, zoneWriteStatus(err), err.Error())
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name, "type": rrtype})
@@ -236,7 +225,7 @@ func (s *Server) handleZoneDelegationDelete(w http.ResponseWriter, r *http.Reque
return
}
if err := s.hooks.DeleteZoneDelegation(r.Context(), zone, name); err != nil {
writeError(w, zoneWriteStatus(err), err.Error())
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name})
+18 -55
View File
@@ -29,7 +29,6 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubezones"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
@@ -63,29 +62,22 @@ type App struct {
cfg *config.Store
ver string
blocks *blocklist.Manager
overrideMu sync.Mutex
cache *cache.Manager
cstore *cache.Store
logger *accesslog.Logger
aclH *acl.Holder
local *localzone.Holder
// zones is the Kubernetes-sourced zone client.
zones *kubezones.Client
blocks *blocklist.Manager
cache *cache.Manager
cstore *cache.Store
logger *accesslog.Logger
aclH *acl.Holder
local *localzone.Holder
// kube watches the cluster's fancy-dns objects when enabled; kubeSnap is
// what the config overlay merges in.
kube *kubesource.Source
kubeSnap atomic.Pointer[kubesource.Snapshot]
// managedZones is the console-managed zone directory: the only zone data
// the admin API may rewrite. An empty directory is what makes the
// console's Zones page read-only.
managedZones *localzone.Managed
pool *resolver.Pool
reverse *engine.ReverseCache
engine *engine.Engine
dns *server.Server
api *api.Server
cl *cluster.Cluster
pool *resolver.Pool
reverse *engine.ReverseCache
engine *engine.Engine
dns *server.Server
api *api.Server
cl *cluster.Cluster
apiServers []*http.Server
apiLn []net.Listener
@@ -116,11 +108,7 @@ func New(opt Options) (*App, error) {
return nil, err
}
store := config.NewStore(cfg, opt.ConfigPath)
zones, err := kubezones.InCluster()
if err != nil {
return nil, err
}
return &App{opt: opt, log: log, cfg: store, ver: opt.Version, zones: zones}, nil
return &App{opt: opt, log: log, cfg: store, ver: opt.Version}, nil
}
// newLogger builds the structured logger from config plus CLI overrides.
@@ -322,22 +310,9 @@ func (a *App) startBlocklist() {
Config: a.cfg,
Logger: a.log,
})
if a.zones != nil {
ctx := context.Background()
content, exists, err := a.zones.Data(ctx, "address-overrides.rpz")
if err == nil && !exists {
if seed, readErr := os.ReadFile("/etc/fancy-dns/rpz/address-overrides.rpz"); readErr == nil {
content = string(seed)
if saveErr := a.zones.SaveData(ctx, "address-overrides.rpz", content); saveErr != nil {
a.log.Warn("seed address overrides failed", "error", saveErr)
}
}
}
if err == nil {
a.blocks.SetManagedRPZ(content)
} else {
a.log.Warn("load address overrides failed", "error", err)
}
// The cluster's address overrides, if the Kubernetes source has any yet.
if snap := a.kubeSnap.Load(); snap != nil {
a.blocks.SetManagedRPZ(kubesource.RenderRPZ(snap.Overrides))
}
// Compile once before serving so the first queries are not unprotected.
if _, err := a.blocks.RefreshAll(context.Background()); err != nil {
@@ -349,14 +324,9 @@ func (a *App) startBlocklist() {
// startLocalZones compiles the local master files.
func (a *App) startLocalZones(cfg *config.Config) {
a.local = localzone.NewHolder(nil)
a.managedZones = localzone.NewManaged(cfg.AbsPath(cfg.Block.ManagedZones))
a.reloadLocalZones()
}
// managedZoneDir is the directory the console may write zone files into, or
// "" when the console is read-only.
func (a *App) managedZoneDir() string { return a.managedZones.Dir() }
// reloadLocalZones recompiles the local zone store. A failure keeps the
// previous store: serving stale local data beats serving nothing.
func (a *App) reloadLocalZones() {
@@ -367,13 +337,8 @@ func (a *App) reloadLocalZones() {
for _, p := range cfg.Block.LocalZones {
paths = append(paths, cfg.AbsPath(p))
}
// The managed directory is a zone source like any other, so a zone the
// console created is served by the same store. It is only added when it
// exists: the loader stats every path it is given, and a directory the
// console has not written to yet is not a broken configuration.
if a.managedZones.Exists() {
paths = append(paths, a.managedZones.Dir())
}
// DNSZone objects are rendered into a directory of their own, which is a
// zone source like any other.
if dir := a.kubeZoneDir(); dir != "" {
paths = append(paths, dir)
}
@@ -600,8 +565,6 @@ func (a *App) startCluster(ctx context.Context, cfg *config.Config) {
// startBackground runs the periodic jobs: cache snapshots, upstream health
// probes and the metrics gauge.
func (a *App) startBackground(ctx context.Context, cfg *config.Config) {
go a.watchLocalZones(ctx)
go a.watchAddressOverrides(ctx)
// Cache snapshots.
if a.cstore != nil {
interval := cfg.Cache.Persist.Interval.D()
+23 -16
View File
@@ -2,16 +2,19 @@ package app
import (
"bufio"
"context"
"encoding/json"
"io"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
)
// The contract tests in this file exist because the console's TypeScript types
@@ -469,21 +472,25 @@ func TestRemainingEndpointKeySets(t *testing.T) {
// record is the thing the console renders row by row. So this builds a zone
// through the write endpoint, delegates a name in it, and pins every level.
func TestZoneEndpointKeySets(t *testing.T) {
managed := filepath.Join(t.TempDir(), "zones")
h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed })
write := func(t *testing.T, method, path, body string) {
t.Helper()
code, out := h.do(method, path, body)
if code != http.StatusOK {
t.Fatalf("%s %s = %d: %s", method, path, code, out)
}
client := kubeClient()
h := newHarness(t, nil, func(o *Options) {
o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: client, SyncTimeout: 5 * time.Second}
})
// A zone with a record and a delegation, from the cluster.
if _, err := client.Resource(kubesource.ZoneGVR).Namespace("dns").Create(context.Background(), kubeObj("DNSZone", "console", map[string]any{
"zone": "console.test",
"records": []any{
map[string]any{"name": "@", "type": "NS", "value": "ns1.console.test."},
map[string]any{"name": "www", "type": "A", "ttl": int64(60), "value": "10.9.9.9"},
map[string]any{"name": "sub", "type": "NS", "value": "ns1.sub.console.test."},
},
}), metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
// A zone needs a server list before it can hold anything, and a
// delegation is the other way to start one.
write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`)
write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`)
write(t, http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`)
eventually(t, "the zone to be listed", func() bool {
_, body := h.get("/api/zones")
return strings.Contains(string(body), "console.test.")
})
t.Run("zones", func(t *testing.T) {
code, body := h.get("/api/zones")
+24 -187
View File
@@ -17,49 +17,36 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// hooks builds the api.Hooks that connect the admin API to the live
// components. Everything the UI can read or change goes through here.
func (a *App) hooks() api.Hooks {
return api.Hooks{
Store: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Status: a.status,
Health: a.health,
BlocklistState: a.blocklistState,
BlocklistStats: a.blocklistStats,
RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck,
Resolve: a.resolveTest,
ResolveUpstream: a.resolveTestUpstream,
AddressOverrides: a.listAddressOverrides,
SaveAddressOverride: a.saveAddressOverride,
DeleteAddressOverride: a.deleteAddressOverride,
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
ListZones: a.listZones,
AddZoneRecord: a.addZoneRecord,
PutZoneRecord: a.putZoneRecord,
DeleteZoneRecord: a.deleteZoneRecord,
DeleteZone: a.deleteZone,
AddZoneDelegation: a.addZoneDelegation,
DeleteZoneDelegation: a.deleteZoneDelegation,
LocalZones: a.listLocalZones,
SaveLocalZone: a.saveLocalZone,
DeleteLocalZone: a.deleteLocalZone,
GetACL: a.getACL,
PutACL: a.putACL,
GetUpstream: a.getUpstreams,
PutUpstream: a.putUpstream,
CacheStats: a.cacheStats,
FlushCache: a.flushCache,
Flush: a.flush,
Store: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Status: a.status,
Health: a.health,
BlocklistState: a.blocklistState,
BlocklistStats: a.blocklistStats,
RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck,
Resolve: a.resolveTest,
ResolveUpstream: a.resolveTestUpstream,
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
ListZones: a.listZones,
GetACL: a.getACL,
PutACL: a.putACL,
GetUpstream: a.getUpstreams,
PutUpstream: a.putUpstream,
CacheStats: a.cacheStats,
FlushCache: a.flushCache,
Flush: a.flush,
}
}
@@ -99,156 +86,6 @@ func (a *App) resolveTestUpstream(ctx context.Context, name, qtype, upstream str
return out, nil
}
type addressOverride struct {
Domain string `json:"domain"`
Address string `json:"address"`
}
func (a *App) listAddressOverrides(ctx context.Context) (any, error) {
if a.zones == nil {
return map[string]any{"items": []addressOverride{}, "writable": false}, nil
}
raw, ok, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil {
return nil, err
}
items := parseAddressOverrides(raw)
return map[string]any{"items": items, "writable": true, "initialized": ok}, nil
}
func parseAddressOverrides(raw string) []addressOverride {
out := []addressOverride{}
for _, line := range strings.Split(raw, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && !strings.HasPrefix(f[0], "$") && (strings.EqualFold(f[1], "A") || strings.EqualFold(f[1], "AAAA")) {
out = append(out, addressOverride{Domain: strings.TrimSuffix(f[0], "."), Address: f[2]})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
return out
}
func (a *App) saveAddressOverride(ctx context.Context, domain, address string) error {
return a.mutateAddressOverride(ctx, domain, address, false)
}
func (a *App) deleteAddressOverride(ctx context.Context, domain string) error {
return a.mutateAddressOverride(ctx, domain, "", true)
}
func (a *App) mutateAddressOverride(ctx context.Context, domain, address string, remove bool) error {
a.overrideMu.Lock()
defer a.overrideMu.Unlock()
if a.zones == nil {
return errors.New("address overrides are writable only in Kubernetes")
}
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
if _, ok := dns.IsDomainName(dns.Fqdn(domain)); !ok || domain == "" {
return errors.New("enter a valid domain name")
}
ip, err := netip.ParseAddr(strings.TrimSpace(address))
if !remove && err != nil {
return errors.New("enter a valid IPv4 or IPv6 address")
}
raw, _, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil {
return err
}
items := parseAddressOverrides(raw)
next := make([]addressOverride, 0, len(items)+1)
for _, it := range items {
if it.Domain != domain {
next = append(next, it)
}
}
if !remove {
next = append(next, addressOverride{Domain: domain, Address: ip.String()})
}
sort.Slice(next, func(i, j int) bool { return next[i].Domain < next[j].Domain })
var b strings.Builder
b.WriteString("$TTL 60\n")
for _, it := range next {
typ := "A"
if strings.Contains(it.Address, ":") {
typ = "AAAA"
}
fmt.Fprintf(&b, "%s %s %s\n", it.Domain, typ, it.Address)
}
if err := a.zones.SaveData(ctx, "address-overrides.rpz", b.String()); err != nil {
return err
}
if a.blocks != nil {
a.blocks.SetManagedRPZ(b.String())
if _, err := a.blocks.RefreshAll(ctx); err != nil {
return err
}
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
func (a *App) listLocalZones(ctx context.Context) ([]api.ZoneFile, error) {
if a.zones == nil {
return nil, fmt.Errorf("local zone editor is available only in Kubernetes")
}
zones, err := a.zones.List(ctx)
if err != nil {
return nil, err
}
out := make([]api.ZoneFile, 0, len(zones))
for _, z := range zones {
out = append(out, api.ZoneFile{Name: z.Name, Content: z.Content})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (a *App) saveLocalZone(ctx context.Context, name, content string) error {
if a.zones == nil {
return fmt.Errorf("local zone editor is available only in Kubernetes")
}
apex := strings.ToLower(dns.Fqdn(strings.TrimSuffix(strings.TrimSpace(name), ".")))
_, valid := dns.IsDomainName(apex)
if !valid || apex == "." || len(apex) > 243 {
return fmt.Errorf("invalid zone name %q", name)
}
if strings.TrimSpace(content) == "" {
return fmt.Errorf("zone file cannot be empty")
}
parsed, err := zonefile.Parse(strings.NewReader(content), zonefile.Options{Origin: apex, File: name + ".zone"})
if err != nil {
return fmt.Errorf("invalid zone file: %w", err)
}
if len(parsed.Records) == 0 {
return fmt.Errorf("zone file must contain at least one record")
}
for _, record := range parsed.Records {
owner := strings.ToLower(dns.Fqdn(record.Name))
if owner != strings.ToLower(apex) && !strings.HasSuffix(owner, "."+strings.ToLower(apex)) {
return fmt.Errorf("record %q is outside zone %s", owner, apex)
}
}
if err := a.zones.Save(ctx, strings.TrimSuffix(apex, "."), content); err != nil {
return err
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
func (a *App) deleteLocalZone(ctx context.Context, name string) error {
if a.zones == nil {
return fmt.Errorf("local zone editor is available only in Kubernetes")
}
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
if err := a.zones.Delete(ctx, name); err != nil {
return err
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
// health is the unauthenticated probe payload.
func (a *App) health() any {
status := "ok"
+20 -199
View File
@@ -4,8 +4,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -15,170 +13,6 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// TestZoneConsoleRoundTrip is the console's zone page, end to end: a record
// added through the admin API is answered by this resolver on the next query,
// with no reload and no restart, because a zone the console created is
// compiled into the same store the operator's master files go into.
func TestZoneConsoleRoundTrip(t *testing.T) {
managed := filepath.Join(t.TempDir(), "zones")
h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed })
do := func(method, path, body string) (int, string) {
t.Helper()
code, out := h.do(method, path, body)
return code, string(out)
}
mustWrite := func(method, path, body string) string {
t.Helper()
code, out := do(method, path, body)
if code != http.StatusOK {
t.Fatalf("%s %s = %d: %s", method, path, code, out)
}
return out
}
// Before anything is written the page is empty but honest: no zones, the
// managed directory named, and the types the API accepts so the console
// builds its picker from the server.
_, body := h.get("/api/zones")
var empty zonesPayload
if err := json.Unmarshal(body, &empty); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
// The operator's own zone is listed before the console has written
// anything, and it is not the console's to edit.
if len(empty.Zones) != 1 || empty.Zones[0].Origin != "local." {
t.Fatalf("zones = %+v, want just the operator's local. zone", empty.Zones)
}
if empty.Zones[0].Managed {
t.Error("the operator's zone is reported as managed")
}
if empty.ManagedDir != managed {
t.Errorf("managed_dir = %q, want %q", empty.ManagedDir, managed)
}
if !empty.Writable {
t.Error("writable = false, but the harness's temp dir is writable")
}
if len(empty.Types) == 0 {
t.Error("types is empty, so the console has no record types to offer")
}
// A zone starts with a server list: without one it cannot say NXDOMAIN,
// and the writer refuses to invent it.
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`)
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`)
m := h.query("www.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query www.console.test = %v, want one answer", m)
}
a, ok := m.Answer[0].(*dns.A)
if !ok || a.A.String() != "10.9.9.9" {
t.Errorf("www.console.test A = %v, want the console's record, not the upstream's", m.Answer[0])
}
if m.Authoritative != true {
t.Error("the local answer is not authoritative")
}
// The file is on disk where the operator can read it, and it is a zone
// file rather than a JSON dump.
raw, err := os.ReadFile(filepath.Join(managed, "console.test.zone"))
if err != nil {
t.Fatalf("read the managed zone file: %v", err)
}
for _, want := range []string{"$ORIGIN console.test.", "IN SOA", "IN NS", "10.9.9.9"} {
if !strings.Contains(string(raw), want) {
t.Errorf("the zone file is missing %q:\n%s", want, raw)
}
}
// A delegation is a cut: the parent stops answering below it and the
// query goes upstream, which is the whole point of making one.
mustWrite(http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`)
m = h.query("www.sub.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query below the cut = %v, want the upstream answer", m)
}
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
t.Errorf("below the cut = %v, want the upstream's 203.0.113.7", m.Answer[0])
}
// And the cut itself is answerable, or no resolver could ever learn it.
m = h.query("sub.console.test", dns.TypeNS)
if len(m.Answer) != 1 {
t.Errorf("NS at the cut = %v, want the delegation itself", m)
}
// A record below a cut is dead data, so it is refused rather than
// written and silently never answered.
if code, _ := do(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"host.sub.console.test.","type":"A","value":"10.9.9.10"}`); code != http.StatusBadRequest {
t.Errorf("a record below a delegation = %d, want 400", code)
}
// Deleting the record leaves the zone authoritative, and a zone that
// exists says NXDOMAIN for the names it does not hold. That is what
// creating a zone means, and it is why deleting a zone is a separate
// action below rather than a side effect of deleting a record.
mustWrite(http.MethodDelete, "/api/zones/records?zone=console.test.&name=www.console.test.&type=A", "")
m = h.query("www.console.test", dns.TypeA)
if m.Rcode != dns.RcodeNameError || len(m.Answer) != 0 {
t.Errorf("query after the record was deleted = %v, want NXDOMAIN from the zone", m)
}
if m.Authoritative != true {
t.Error("the NXDOMAIN is not authoritative, so the zone is not serving")
}
// Deleting something that is not there is a 404, not a 500.
if code, _ := do(http.MethodDelete, "/api/zones/records?zone=console.test.&name=nope.console.test.&type=A", ""); code != http.StatusNotFound {
t.Errorf("deleting an absent record = %d, want 404", code)
}
// Removing the zone is what hands the name back to upstream, and the
// cached NXDOMAIN goes with it.
mustWrite(http.MethodDelete, "/api/zones?zone=console.test.", "")
if _, err := os.Stat(filepath.Join(managed, "console.test.zone")); !os.IsNotExist(err) {
t.Errorf("the managed zone file is still there (stat err = %v)", err)
}
m = h.query("www.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query after the zone was deleted = %v, want the upstream answer", m)
}
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
t.Errorf("after the zone was deleted = %v, want the upstream's 203.0.113.7", m.Answer[0])
}
// And the zone is gone from the page.
_, body = h.get("/api/zones")
var gone zonesPayload
if err := json.Unmarshal(body, &gone); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
for _, zone := range gone.Zones {
if zone.Origin == "console.test." {
t.Errorf("the deleted zone is still listed: %+v", zone)
}
}
// An operator's zone is not the console's to delete.
if code, _ := do(http.MethodDelete, "/api/zones?zone=local.", ""); code != http.StatusNotFound {
t.Errorf("deleting the operator's zone = %d, want 404", code)
}
// The operator's master file is still theirs: it is listed, and it is not
// reported as console-managed.
var sawLocal bool
for _, zone := range gone.Zones {
if zone.Origin != "local." {
continue
}
sawLocal = true
if zone.Managed {
t.Error("the operator's zone is reported as managed, so the console would offer to rewrite it")
}
}
if !sawLocal {
t.Errorf("the operator's zone is missing from the list: %+v", gone.Zones)
}
}
// zonesPayload is the slice of GET /api/zones this test reads. It is declared
// here rather than imported so the assertions name the fields the console
// depends on; the contract test in this package pins the whole key set.
@@ -202,10 +36,11 @@ type zonesPayload struct {
Records int `json:"records"`
}
// TestZoneWritesNeedAManagedDirectory pins the read-only default: a
// deployment that never sets blocklist.managed_zones can browse its local zones
// and cannot have one rewritten behind its back.
func TestZoneWritesNeedAManagedDirectory(t *testing.T) {
// TestZonesAreReadOnlyOnTheResolver: the resolver lists the zones it serves and
// writes none of them. Editing a zone is the console's job and happens on the
// DNSZone objects (see console_e2e_test.go); every write route on the
// resolver answers "not available" and changes nothing.
func TestZonesAreReadOnlyOnTheResolver(t *testing.T) {
h := newHarness(t, nil)
code, body := h.get("/api/zones")
if code != http.StatusOK {
@@ -215,43 +50,29 @@ func TestZoneWritesNeedAManagedDirectory(t *testing.T) {
if err := json.Unmarshal(body, &zones); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
if zones.Writable {
t.Error("writable = true with no managed zone directory configured")
if zones.Writable || zones.ManagedDir != "" {
t.Errorf("writable=%v managed_dir=%q on a resolver with no Kubernetes source", zones.Writable, zones.ManagedDir)
}
if zones.ManagedDir != "" {
t.Errorf("managed_dir = %q, want empty", zones.ManagedDir)
if len(zones.Zones) != 1 || zones.Zones[0].Origin != "local." || zones.Zones[0].Managed {
t.Errorf("zones = %+v, want only the operator's master file, not managed", zones.Zones)
}
if len(zones.Zones) == 0 {
t.Error("the operator's local zone is missing, so the page would be blank rather than read-only")
}
for _, zone := range zones.Zones {
if zone.Managed {
t.Errorf("zone %s is reported as managed without a managed directory", zone.Origin)
}
}
// Every write route refuses, and says what to configure.
writes := []struct {
method string
path string
body string
}{
for _, w := range []struct{ method, path, body string }{
{http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`},
{http.MethodPut, "/api/zones/records", `{"zone":"console.test.","name":"www","type":"A","value":"10.0.0.1"}`},
{http.MethodDelete, "/api/zones/records?zone=console.test.&name=www&type=A", ""},
{http.MethodDelete, "/api/zones/records?zone=local.&name=ns.local.&type=A", ""},
{http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub","targets":["ns1.sub.console.test."]}`},
{http.MethodDelete, "/api/zones/delegations?zone=console.test.&name=sub.console.test.", ""},
{http.MethodDelete, "/api/zones?zone=console.test.", ""},
}
for _, w := range writes {
{http.MethodDelete, "/api/zones/delegations?zone=local.&name=sub.local.", ""},
{http.MethodDelete, "/api/zones?zone=local.", ""},
{http.MethodGet, "/api/local-zones", ""},
} {
code, out := h.do(w.method, w.path, w.body)
if code != http.StatusBadRequest {
t.Errorf("%s %s = %d, want 400: %s", w.method, w.path, code, out)
continue
}
if !strings.Contains(string(out), "managed_zones") {
t.Errorf("%s %s did not say what to configure: %s", w.method, w.path, out)
if code != http.StatusNotImplemented && code != http.StatusNotFound && code != http.StatusMethodNotAllowed {
t.Errorf("%s %s = %d, want it refused: %s", w.method, w.path, code, out)
}
}
if m := h.query("ns.local", dns.TypeA); len(m.Answer) != 1 {
t.Errorf("the operator's zone stopped answering after the refused writes: %v", m)
}
}
// TestResolveUpstream covers the happy path: a name nobody blocks is forwarded
-76
View File
@@ -1,76 +0,0 @@
package app
import (
"context"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// watchLocalZones reloads projected ConfigMap files on every replica. A
// Kubernetes ConfigMap volume swaps its ..data symlink atomically when the UI
// edits a zone; polling the contents keeps both pods in sync without routing
// DNS changes through a single pod.
func (a *App) watchLocalZones(ctx context.Context) {
if a.zones == nil {
return
}
last, _ := a.localZoneFingerprint()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
next, err := a.localZoneFingerprint()
if err != nil {
a.log.Warn("local zone change check failed", "error", err)
continue
}
if next == last {
continue
}
a.reloadLocalZones()
if a.cache != nil {
a.cache.Flush()
}
last = next
}
}
}
func (a *App) localZoneFingerprint() (string, error) {
paths := make([]string, 0)
for _, configured := range a.cfg.Get().Block.LocalZones {
path := a.cfg.Get().AbsPath(configured)
if matches, err := filepath.Glob(path); err == nil && len(matches) > 0 {
paths = append(paths, matches...)
continue
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
matches, err := filepath.Glob(filepath.Join(path, "*.zone"))
if err != nil {
return "", err
}
paths = append(paths, matches...)
continue
}
}
sort.Strings(paths)
h := sha256.New()
for _, path := range paths {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
_, _ = h.Write([]byte(path))
_, _ = h.Write([]byte{0})
_, _ = h.Write(b)
_, _ = h.Write([]byte{0})
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
-35
View File
@@ -1,35 +0,0 @@
package app
import (
"context"
"time"
)
func (a *App) watchAddressOverrides(ctx context.Context) {
if a.zones == nil || a.blocks == nil {
return
}
t := time.NewTicker(2 * time.Second)
defer t.Stop()
var last string
for {
select {
case <-ctx.Done():
return
case <-t.C:
content, ok, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil || !ok || content == last {
continue
}
a.overrideMu.Lock()
a.blocks.SetManagedRPZ(content)
if _, err := a.blocks.RefreshAll(ctx); err == nil {
if a.cache != nil {
a.cache.Flush()
}
last = content
}
a.overrideMu.Unlock()
}
}
}
+8 -211
View File
@@ -1,38 +1,22 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
)
// The local-zone write path.
//
// Everything the console changes lives in the managed zone directory
// (blocklist.managed_zones), one <apex>.zone per zone. The operator's own master
// files in blocklist.local_zones are read and reported, never rewritten: a
// deployment that never configures a managed directory gets a read-only zone
// browser, and one that does gets a console whose edits land in a file it
// names and owns.
//
// Every change follows the same four steps: write the file, recompile the
// whole store, drop the cached answers, report. A change that does not
// recompile is a change the query path is not serving, so the store is only
// swapped after the new file parsed, and a failure keeps the previous store.
// The resolver only reads zones. Master files from blocklist.local_zones and the
// rendered DNSZone objects are compiled into one store; editing is the
// console's job and happens on the objects.
// listZones renders the compiled store for the console.
func (a *App) listZones() (any, error) {
dir, writable := a.managedZoneDir(), a.managedZones.Writable()
if a.kube != nil {
// With the Kubernetes source the zones that can be edited are the
// DNSZone objects, so those are the "managed" ones. The runtime itself
// cannot write them; the console, which can, reports writable.
dir, writable = a.kubeZoneDir(), false
}
// The zones that can be edited are the DNSZone objects, which the Kubernetes
// source renders into a directory of its own. The resolver itself writes
// nothing; the console, which can, reports the zones writable.
dir := a.kubeZoneDir()
store := a.local.Get()
// The slices are never nil: the console reads lengths and maps without
// a null check, and an empty list is a list.
@@ -40,7 +24,7 @@ func (a *App) listZones() (any, error) {
Zones: []api.Zone{},
Files: []string{},
ManagedDir: dir,
Writable: writable,
Writable: false,
Types: localzone.EditableTypes(),
}
if store != nil {
@@ -55,193 +39,6 @@ func (a *App) listZones() (any, error) {
return res, nil
}
// addZoneRecord adds one record to a zone.
func (a *App) addZoneRecord(_ context.Context, in api.ZoneRecordInput) (any, error) {
origin, err := a.zoneOrigin(in.Zone)
if err != nil {
return nil, err
}
serial, err := a.managedZones.Add(origin, localzone.Record{
Name: in.Name,
Type: in.Type,
TTL: in.TTL,
Value: in.Value,
}, a.zoneDefaults(origin))
if err != nil {
return nil, err
}
a.afterZoneWrite(origin, "add", serial)
return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil
}
// putZoneRecord replaces the whole RRset of one name and type.
func (a *App) putZoneRecord(_ context.Context, in api.ZoneRecordInput) (any, error) {
origin, err := a.zoneOrigin(in.Zone)
if err != nil {
return nil, err
}
serial, err := a.managedZones.Put(origin, localzone.Record{
Name: in.Name,
Type: in.Type,
TTL: in.TTL,
Value: in.Value,
}, a.zoneDefaults(origin))
if err != nil {
return nil, err
}
a.afterZoneWrite(origin, "replace", serial)
return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil
}
// deleteZoneRecord removes a record, or its whole RRset when value is empty.
func (a *App) deleteZoneRecord(_ context.Context, zone, name, rrtype, value string) error {
origin, err := a.zoneOrigin(zone)
if err != nil {
return err
}
serial, removed, err := a.managedZones.Delete(origin, name, rrtype, value)
if err != nil {
return err
}
a.afterZoneWrite(origin, "delete", serial)
if !removed {
return fmt.Errorf("no managed record matches %s %s in %s", name, rrtype, origin)
}
return nil
}
// addZoneDelegation hands a name below an apex to other servers.
func (a *App) addZoneDelegation(_ context.Context, in api.ZoneDelegationInput) (any, error) {
origin, err := a.zoneOrigin(in.Zone)
if err != nil {
return nil, err
}
targets := make([]string, 0, len(in.Targets))
for _, t := range in.Targets {
if t = strings.TrimSpace(t); t != "" {
targets = append(targets, t)
}
}
if len(targets) == 0 {
return nil, errors.New("a delegation needs at least one NS target")
}
// A brand new zone has no NS set of its own, so the delegation's targets
// are also the zone's. An existing file ignores this.
def := a.zoneDefaults(origin)
def.NS = targets
if def.TTL == 0 {
def.TTL = in.TTL
}
serial, err := a.managedZones.SetDelegation(origin, in.Name, targets, in.TTL, def)
if err != nil {
return nil, err
}
a.afterZoneWrite(origin, "delegate", serial)
return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil
}
// deleteZoneDelegation removes a cut.
func (a *App) deleteZoneDelegation(_ context.Context, zone, name string) error {
origin, err := a.zoneOrigin(zone)
if err != nil {
return err
}
serial, removed, err := a.managedZones.RemoveDelegation(origin, name)
if err != nil {
return err
}
a.afterZoneWrite(origin, "undelegate", serial)
if !removed {
return fmt.Errorf("no delegation for %s in %s", name, origin)
}
return nil
}
// zoneOrigin canonicalises the apex a write names and refuses to go near the
// disk when the console has no managed directory.
func (a *App) zoneOrigin(zone string) (string, error) {
origin, err := localzone.NormalizeOrigin(zone)
if err != nil {
return "", err
}
if err := a.managedZones.Check(); err != nil {
return "", err
}
return origin, nil
}
// deleteZone removes a zone the console created, which is the only way a name
// goes back to upstream once the console has made a zone authoritative for
// it: a zone that exists answers NXDOMAIN for everything it does not hold.
func (a *App) deleteZone(_ context.Context, zone string) error {
origin, err := a.zoneOrigin(zone)
if err != nil {
return err
}
// The zone has to be wholly the console's: deleting a managed file that
// shares its apex with an operator's master file would remove none of
// the zone, only half of it.
whole := false
if store := a.local.Get(); store != nil {
if view := store.Snapshot(origin, a.managedZoneDir()); view != nil {
whole = view.Managed
} else {
return fmt.Errorf("no zone %s is loaded", origin)
}
} else {
return fmt.Errorf("no zone %s is loaded", origin)
}
removed, err := a.managedZones.Remove(origin, whole)
if err != nil {
return err
}
if !removed {
return fmt.Errorf("no managed zone file for %s", origin)
}
a.afterZoneWrite(origin, "delete-zone", 0)
return nil
}
// zoneDefaults seeds a managed file for an apex that has none from the zone
// as it is compiled today, so the console never has to restate a zone's
// server list and never invents one.
func (a *App) zoneDefaults(origin string) localzone.ZoneDefaults {
store := a.local.Get()
if store == nil {
return localzone.ZoneDefaults{}
}
zone := store.Snapshot(origin, a.managedZoneDir())
if zone == nil {
return localzone.ZoneDefaults{}
}
def := localzone.ZoneDefaults{TTL: zone.DefaultTTL, Soa: zone.Soa}
// The apex NS set follows the zone's existing one, from the operator's
// file if that is where it lives. A managed file that already has its own
// is not asked for defaults at all, so this only ever happens once.
for _, rec := range zone.Records {
if rec.Name == origin && rec.Type == "NS" {
def.NS = append(def.NS, rec.Value)
}
}
return def
}
// afterZoneWrite is the tail every change shares: recompile, drop the cached
// answers, log. The flush is the blunt half and it is deliberate — a name the
// zone now answers authoritatively may have an upstream answer cached, and
// which names changed is not knowable from here.
func (a *App) afterZoneWrite(origin, action string, serial uint32) {
a.reloadLocalZones()
if a.cache != nil {
a.cache.Flush()
}
records := 0
if store := a.local.Get(); store != nil {
records = store.Count()
}
a.log.Info("local zone changed", "zone", origin, "action", action, "serial", serial, "records", records)
}
// localZoneError is the reason the store is stale, or "" when it is current.
// It comes from the degraded list, so the console and /healthz agree.
func (a *App) localZoneError() string {
-9
View File
@@ -316,15 +316,6 @@ type Block struct {
// LocalZones are DNS master files (RFC 1035) holding RPZ-style policy
// records and/or locally defined zones.
LocalZones []string `yaml:"local_zones"`
// ManagedZones is the directory the admin API may write local zone
// files into: one <apex>.zone per zone, holding the SOA, the apex NS
// set and whatever records the console created. It is the only zone
// data the console owns — the files in LocalZones are the operator's and
// are reported read-only.
//
// Empty means the console's zone pages are read-only, so a deployment
// that never sets it cannot have zone data rewritten behind its back.
ManagedZones string `yaml:"managed_zones"`
// Lists are remote or on-disk sources of block data.
Lists []ListSource `yaml:"lists"`
// AllowEntries and BlockEntries are inline rules (a list of DNS names or
-278
View File
@@ -1,278 +0,0 @@
// Package kubezones stores UI-managed zone files in a Kubernetes ConfigMap.
// The ConfigMap volume is shared by every fancy-dns replica, so UI edits do
// not depend on which pod receives the request.
package kubezones
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"
defaultName = "fancy-dns-zones"
)
// Zone is a ConfigMap key and its master-file contents.
type Zone struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Client is a narrow client for the one ConfigMap the console may edit.
type Client struct {
baseURL string
ns string
name string
http *http.Client
}
// InCluster returns nil outside Kubernetes. The projected service account
// token must be available; RBAC limits it to the fancy-dns-zones ConfigMap.
func InCluster() (*Client, error) {
name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP")
if name == "" {
name = defaultName
}
return InClusterConfigMap(name)
}
// InClusterConfigMap returns a client for a named ConfigMap. RBAC on the
// service account still limits which ConfigMaps it can read or patch.
func InClusterConfigMap(name string) (*Client, error) {
host := os.Getenv("KUBERNETES_SERVICE_HOST")
if host == "" {
return nil, nil
}
port := os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")
if port == "" {
port = "443"
}
ns := strings.TrimSpace(os.Getenv("POD_NAMESPACE"))
if ns == "" {
b, err := os.ReadFile(serviceAccountDir + "/namespace")
if err != nil {
return nil, fmt.Errorf("kube zones: read service account namespace: %w", err)
}
ns = strings.TrimSpace(string(b))
}
ca, err := os.ReadFile(serviceAccountDir + "/ca.crt")
if err != nil {
return nil, fmt.Errorf("kube zones: read service account CA: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(ca) {
return nil, errors.New("kube zones: invalid service account CA")
}
if strings.TrimSpace(name) == "" {
return nil, errors.New("kube zones: ConfigMap name is required")
}
return &Client{
baseURL: "https://" + net.JoinHostPort(host, port),
ns: ns,
name: name,
http: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: nil,
TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "kubernetes.default.svc"},
},
},
}, nil
}
func (c *Client) resourceURL() string {
return c.baseURL + "/api/v1/namespaces/" + url.PathEscape(c.ns) + "/configmaps/" + url.PathEscape(c.name)
}
// List returns all zone files stored in the managed ConfigMap.
func (c *Client) List(ctx context.Context) ([]Zone, error) {
cm, _, err := c.get(ctx)
if err != nil {
return nil, err
}
out := make([]Zone, 0, len(cm.Data))
for name, content := range cm.Data {
if strings.HasPrefix(name, "zone--") && strings.HasSuffix(name, ".zone") {
out = append(out, Zone{Name: strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone"), Content: content})
}
}
return out, nil
}
// Save updates or adds one zone file. Kubernetes resourceVersion makes the
// update conditional and retrying resource-version conflicts. Separate zone
// edits merge safely; when two editors change the same zone, the last save
// wins.
func (c *Client) Save(ctx context.Context, name, content string) error {
return c.change(ctx, filename(name), &content)
}
// Delete removes one zone file.
func (c *Client) Delete(ctx context.Context, name string) error {
return c.change(ctx, filename(name), nil)
}
// Data reads one arbitrary ConfigMap data key. It is used for console-owned
// shared files that are not zone master files.
func (c *Client) Data(ctx context.Context, key string) (string, bool, error) {
if c == nil {
return "", false, errors.New("kube zones: Kubernetes ConfigMap is unavailable")
}
cm, _, err := c.get(ctx)
if err != nil {
return "", false, err
}
value, ok := cm.Data[key]
return value, ok, nil
}
// SaveData creates or replaces one arbitrary ConfigMap data key, retrying
// resource-version conflicts so independent edits to different keys merge.
func (c *Client) SaveData(ctx context.Context, key, content string) error {
if c == nil {
return errors.New("kube zones: Kubernetes ConfigMap is unavailable")
}
if key == "" || len(key) > 253 || strings.ContainsAny(key, `/\\`) {
return errors.New("kube zones: invalid ConfigMap data key")
}
if len(content) > 512*1024 {
return errors.New("kube zones: ConfigMap data exceeds 512 KiB")
}
return c.changeData(ctx, key, &content)
}
func filename(name string) string { return "zone--" + strings.TrimSuffix(name, ".") + ".zone" }
func (c *Client) change(ctx context.Context, name string, content *string) error {
if !validKey(name) {
return errors.New("zone filename must be a DNS name ending in .zone")
}
return c.changeData(ctx, name, content)
}
func (c *Client) changeData(ctx context.Context, name string, content *string) error {
if content != nil && len(*content) > 512*1024 {
return errors.New("ConfigMap data exceeds 512 KiB")
}
for attempt := 0; attempt < 3; attempt++ {
_, rv, err := c.get(ctx)
if err != nil {
return err
}
var value any
if content == nil {
value = nil
} else {
value = *content
}
body, err := json.Marshal(map[string]any{
"metadata": map[string]string{"resourceVersion": rv},
"data": map[string]any{name: value},
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.resourceURL(), bytes.NewReader(body))
if err != nil {
return err
}
if err := c.authorize(req); err != nil {
return err
}
req.Header.Set("Content-Type", "application/merge-patch+json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("kube zones: update ConfigMap: %w", err)
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("kube zones: update ConfigMap: Kubernetes returned %s", resp.Status)
}
return nil
}
return errors.New("kube zones: concurrent edits; reload and try again")
}
type configMap struct {
Metadata struct {
ResourceVersion string `json:"resourceVersion"`
} `json:"metadata"`
Data map[string]string `json:"data"`
}
func (c *Client) get(ctx context.Context) (configMap, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.resourceURL(), nil)
if err != nil {
return configMap{}, "", err
}
if err := c.authorize(req); err != nil {
return configMap{}, "", err
}
resp, err := c.http.Do(req)
if err != nil {
return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: Kubernetes returned %s", resp.Status)
}
var cm configMap
if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&cm); err != nil {
return configMap{}, "", fmt.Errorf("kube zones: decode ConfigMap: %w", err)
}
if cm.Data == nil {
cm.Data = map[string]string{}
}
return cm, cm.Metadata.ResourceVersion, nil
}
func (c *Client) authorize(req *http.Request) error {
token, err := os.ReadFile(serviceAccountDir + "/token")
if err != nil {
return fmt.Errorf("kube zones: read service account token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
return nil
}
func validKey(name string) bool {
if !strings.HasPrefix(name, "zone--") || !strings.HasSuffix(name, ".zone") ||
strings.ContainsAny(name, `/\\`) || len(name) > 253 {
return false
}
name = strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone")
if name == "" || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
return false
}
for _, label := range strings.Split(name, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, r := range label {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
}
return true
}
-704
View File
@@ -1,704 +0,0 @@
package localzone
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/miekg/dns"
)
// loadManaged compiles a managed directory the way the app does, so a test
// asserts on what the query path would actually serve rather than on the
// writer's own idea of it.
func loadManaged(t *testing.T, dir string) *Store {
t.Helper()
store, err := Load([]string{dir}, Options{})
if err != nil {
t.Fatalf("load managed dir: %v", err)
}
return store
}
func mustAdd(t *testing.T, m *Managed, origin string, rec Record, def ZoneDefaults) uint32 {
t.Helper()
serial, err := m.Add(origin, rec, def)
if err != nil {
t.Fatalf("add %s %s: %v", rec.Name, rec.Type, err)
}
return serial
}
func zoneDef() ZoneDefaults {
return ZoneDefaults{NS: []string{"ns1.example.com."}}
}
func TestManagedAddWritesAParsableZoneFile(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
first := mustAdd(t, m, "example.com", Record{Name: "www.example.com", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{})
raw, err := os.ReadFile(filepath.Join(dir, "example.com.zone"))
if err != nil {
t.Fatalf("read zone file: %v", err)
}
text := string(raw)
for _, want := range []string{
"$ORIGIN example.com.",
"$TTL 5m",
"IN SOA",
"IN NS",
"IN A",
"10.0.0.1",
} {
if !strings.Contains(text, want) {
t.Errorf("zone file is missing %q:\n%s", want, text)
}
}
// The second write bumps the serial: a zone file that does not move its
// serial tells nobody the data changed.
second := mustAdd(t, m, "example.com", Record{Name: "db.example.com", Type: "A", TTL: 60, Value: "10.0.0.2"}, ZoneDefaults{})
if second != first+1 {
t.Errorf("serial after second write = %d, want %d", second, first+1)
}
store := loadManaged(t, dir)
if got := store.Zones(); len(got) != 1 || got[0] != "example.com." {
t.Fatalf("zones = %v, want [example.com.]", got)
}
res := store.Lookup(dns.Question{Name: "www.example.com.", Qtype: dns.TypeA})
if !res.Found || len(res.Records) != 1 {
t.Fatalf("lookup www = %+v, want one A record", res)
}
if a, ok := res.Records[0].(*dns.A); !ok || a.A.String() != "10.0.0.1" {
t.Errorf("www A = %v, want 10.0.0.1", res.Records[0])
}
// A record written without a TTL inherits the file's $TTL, not the
// library default of 3600.
if ttl := res.Records[0].Header().Ttl; ttl != 60 {
t.Errorf("www TTL = %d, want 60", ttl)
}
}
func TestManagedSnapshotMarksTheConsoleAsTheSource(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "www.example.com", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if zone == nil {
t.Fatal("snapshot is nil")
}
if !zone.Managed {
t.Error("zone is not marked managed, so the console would refuse to edit it")
}
if len(zone.Records) != 2 || zone.RecordCount != 2 {
t.Fatalf("records = %d, want 2", zone.RecordCount)
}
if zone.Serial == 0 {
t.Error("snapshot has no SOA serial")
}
for _, rec := range zone.Records {
if !rec.Managed {
t.Errorf("record %s %s is not marked managed", rec.Name, rec.Type)
}
if !strings.HasPrefix(rec.Source, dir) {
t.Errorf("record %s %s source = %q, want a path under %s", rec.Name, rec.Type, rec.Source, dir)
}
if rec.Delegation {
t.Errorf("the apex NS set is reported as a delegation: %+v", rec)
}
}
if len(zone.Delegations) != 0 {
t.Errorf("delegations = %+v, want none", zone.Delegations)
}
}
// An operator's own master file is not the console's to rewrite, so its
// records have to be visible with Managed false.
func TestManagedSnapshotOfAnOperatorFileIsNotManaged(t *testing.T) {
dir := t.TempDir()
managed := NewManaged(dir)
mustAdd(t, managed, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
op := filepath.Join(t.TempDir(), "example.com")
if err := os.WriteFile(op, []byte("$ORIGIN example.com.\n$TTL 300\n@ 300 IN SOA ns1 hostmaster 1 3600 600 604800 300\n@ 300 IN NS ns1\nhost 300 IN A 192.0.2.7\n"), 0o644); err != nil {
t.Fatal(err)
}
store, err := Load([]string{dir, op}, Options{})
if err != nil {
t.Fatalf("load: %v", err)
}
zone := store.Snapshot("example.com.", dir)
if zone.Managed {
t.Error("a zone with an operator-managed record is reported as wholly managed")
}
var found bool
for _, rec := range zone.Records {
if rec.Name == "host.example.com." {
found = true
if rec.Managed {
t.Error("the operator's record is marked managed")
}
if rec.Value != "192.0.2.7" {
t.Errorf("operator record value = %q", rec.Value)
}
}
}
if !found {
t.Errorf("the operator's record is missing from the snapshot: %+v", zone.Records)
}
}
func TestManagedDelegationIsACutBelowTheApex(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com.", "ns2.sub.example.com."}, 300, ZoneDefaults{}); err != nil {
t.Fatalf("set delegation: %v", err)
}
store := loadManaged(t, dir)
zone := store.Snapshot("example.com.", dir)
if len(zone.Delegations) != 1 {
t.Fatalf("delegations = %+v, want one", zone.Delegations)
}
cut := zone.Delegations[0]
if cut.Name != "sub.example.com." {
t.Errorf("delegated name = %q", cut.Name)
}
if strings.Join(cut.Targets, ",") != "ns1.sub.example.com.,ns2.sub.example.com." {
t.Errorf("delegation targets = %v", cut.Targets)
}
if !cut.Managed {
t.Error("delegation is not marked managed")
}
// A cut means the parent answers nothing below it, and the store is what
// decides that.
res := store.Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA})
if !res.Delegated {
t.Errorf("lookup below the cut = %+v, want Delegated", res)
}
// The NS RRset at the cut itself stays answerable, or a resolver can
// never learn the delegation (RFC 1034 §4.3.3).
res = store.Lookup(dns.Question{Name: "sub.example.com.", Qtype: dns.TypeNS})
if !res.Found || len(res.Records) != 2 {
t.Errorf("NS at the cut = %+v, want two records", res)
}
// Above the cut the parent still answers for itself.
if res := store.Lookup(dns.Question{Name: "example.com.", Qtype: dns.TypeNS}); !res.Found {
t.Error("apex NS lookup stopped working after a delegation was added")
}
}
func TestManagedDelegationRejectsTheApexAndEmptyTargets(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "example.com", []string{"ns1.example.com."}, 300, ZoneDefaults{}); err == nil {
t.Error("delegating the apex was accepted")
}
if _, err := m.SetDelegation("example.com", "sub.example.com", nil, 300, ZoneDefaults{}); err == nil {
t.Error("a delegation with no targets was accepted")
}
if _, err := m.SetDelegation("example.com", "elsewhere.test", []string{"ns1.test."}, 300, ZoneDefaults{}); err == nil {
t.Error("a delegation outside the zone was accepted")
}
}
func TestManagedRemoveDelegationDropsTheWholeCut(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com.", "ns2.sub.example.com."}, 300, ZoneDefaults{}); err != nil {
t.Fatal(err)
}
if _, removed, err := m.RemoveDelegation("example.com", "sub.example.com."); err != nil || !removed {
t.Fatalf("remove delegation: removed=%v err=%v", removed, err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if len(zone.Delegations) != 0 {
t.Errorf("delegations = %+v, want none left", zone.Delegations)
}
// The parent answers for the name again once the cut is gone.
if res := loadManaged(t, dir).Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA}); res.Delegated {
t.Error("the name is still delegated after the cut was removed")
}
// A second removal is a stale console, not a server error.
if _, _, err := m.RemoveDelegation("example.com", "sub.example.com."); err == nil {
t.Error("removing a delegation twice was accepted")
}
}
func TestManagedPutReplacesTheRRset(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
for _, target := range []string{"10 mx1.example.com.", "20 mx2.example.com."} {
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "MX", TTL: 300, Value: target}, ZoneDefaults{})
}
if _, err := m.Put("example.com", Record{Name: "example.com", Type: "MX", TTL: 300, Value: "10 mx1.example.com."}, ZoneDefaults{}); err != nil {
t.Fatalf("put: %v", err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
var mx int
for _, rec := range zone.Records {
if rec.Type == "MX" {
mx++
}
}
if mx != 1 {
t.Errorf("MX records = %d, want the whole RRset replaced by one", mx)
}
}
func TestManagedDeleteIsScopedToTheRRsetOrTheRecord(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "TXT", TTL: 300, Value: `"one"`}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "TXT", TTL: 300, Value: `"two"`}, ZoneDefaults{})
if _, removed, err := m.Delete("example.com", "example.com", "TXT", `"two"`); err != nil || !removed {
t.Fatalf("delete one TXT: removed=%v err=%v", removed, err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
var txt int
for _, rec := range zone.Records {
if rec.Type == "TXT" {
txt++
}
}
if txt != 1 {
t.Errorf("TXT records = %d, want the one that was not deleted", txt)
}
if _, _, err := m.Delete("example.com", "example.com", "TXT", ""); err != nil {
t.Fatalf("delete the RRset: %v", err)
}
if _, removed, err := m.Delete("example.com", "example.com", "TXT", ""); err == nil || removed {
t.Error("deleting an absent record succeeded")
}
}
func TestManagedRefusesWhatItCannotRoundTrip(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
seed := Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}
mustAdd(t, m, "example.com", seed, ZoneDefaults{})
cases := []struct {
what string
rec Record
}{
{"out of zone", Record{Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.1"}},
{"unknown type", Record{Name: "example.com", Type: "BOGUS", Value: "x"}},
{"SOA", Record{Name: "example.com", Type: "SOA", Value: "a. b. 1 2 3 4 5"}},
{"empty value", Record{Name: "example.com", Type: "A", Value: ""}},
{"malformed rdata", Record{Name: "example.com", Type: "A", Value: "not-an-address"}},
{"empty name", Record{Name: "", Type: "A", Value: "10.0.0.1"}},
}
for _, tc := range cases {
if _, err := m.Add("example.com", tc.rec, ZoneDefaults{}); err == nil {
t.Errorf("%s was accepted", tc.what)
}
}
// A duplicate is refused rather than written twice, and neither attempt
// touched the file.
if _, err := m.Add("example.com", seed, ZoneDefaults{}); err == nil {
t.Error("a duplicate record was accepted")
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if len(zone.Records) != 1 {
t.Errorf("records = %d, want only the seed record", len(zone.Records))
}
}
func TestManagedRefusesABadZoneApex(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
for _, origin := range []string{"", ".", "example", "example..com."} {
if _, err := m.Add(origin, Record{Name: "example.com.", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{}); err == nil {
t.Errorf("origin %q was accepted", origin)
}
}
// The one shape that is a zone: an apex with at least two labels.
if _, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{NS: []string{"ns1.example.com."}}); err != nil {
t.Fatalf("valid origin refused: %v", err)
}
}
// A zone with no SOA cannot say NXDOMAIN, and one with no apex NS has no
// server to name, so the writer refuses to create either.
func TestManagedRefusesAZoneWithNoServerList(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
_, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{})
if err == nil {
t.Fatal("a brand new zone with no NS record was created")
}
if !strings.Contains(err.Error(), "NS") {
t.Errorf("error = %v, want it to say what is missing", err)
}
if _, statErr := os.Stat(filepath.Join(dir, "example.com.zone")); statErr == nil {
t.Error("a refused write left a zone file behind")
}
}
func TestManagedWildcardAndRelativeTargetsSurviveTheRoundTrip(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
// A wildcard owner and a relative RDATA name: both are the kind of thing
// an operator types and a writer has to preserve.
mustAdd(t, m, "example.com", Record{Name: "*.example.com", Type: "A", TTL: 60, Value: "10.0.0.9"}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "alias.example.com", Type: "CNAME", TTL: 60, Value: "target"}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
byKey := map[string]Record{}
for _, rec := range zone.Records {
byKey[rec.Key()] = rec
}
wild, ok := byKey["*.example.com.|A"]
if !ok {
t.Fatalf("the wildcard record was lost: %+v", zone.Records)
}
if !wild.Wildcard {
t.Error("the wildcard record is not flagged as one")
}
if wild.Value != "10.0.0.9" {
t.Errorf("wildcard value = %q", wild.Value)
}
cname, ok := byKey["alias.example.com.|CNAME"]
if !ok {
t.Fatalf("the CNAME was lost: %+v", zone.Records)
}
if cname.Value != "target.example.com." {
t.Errorf("a relative CNAME target became %q, want it qualified by the origin", cname.Value)
}
// And the store still answers the wildcard, which is the point of it.
res := loadManaged(t, dir).Lookup(dns.Question{Name: "anything.example.com.", Qtype: dns.TypeA})
if !res.Found || len(res.Records) != 1 {
t.Errorf("wildcard lookup = %+v, want an answer", res)
}
}
func TestManagedSeedsFromAnExistingZone(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
// The apex and SOA come from a zone that already exists elsewhere, so
// the console can add a record to it without the caller restating them.
_, err := m.Add("example.com", Record{Name: "new.example.com", Type: "A", Value: "10.0.0.5"}, ZoneDefaults{
TTL: 120,
Soa: "ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300",
NS: []string{"ns1.example.com.", "ns2.example.com."},
})
if err != nil {
t.Fatalf("add with seeded zone: %v", err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if zone.Serial != 2024010102 {
t.Errorf("serial = %d, want the seeded SOA's serial bumped once by the write", zone.Serial)
}
if zone.DefaultTTL != 120 {
t.Errorf("default TTL = %d, want the seeded 120", zone.DefaultTTL)
}
var ns int
for _, rec := range zone.Records {
if rec.Name == "example.com." && rec.Type == "NS" {
ns++
}
}
if ns != 2 {
t.Errorf("apex NS records = %d, want the two seeded", ns)
}
}
// Every type the API accepts has to survive being written and read back. A
// type in the whitelist that the master-file assembler does not recognise
// would render a line no parse accepts, and the writer's round-trip check
// would refuse the write for every record of that type.
func TestEveryEditableTypeSurvivesTheFile(t *testing.T) {
samples := map[string]string{
"A": "10.0.0.1", "AAAA": "2001:db8::1", "CAA": `0 issue "letsencrypt.org"`,
"CNAME": "target.example.com.", "DHCID": "AAIBY2/AuCccgoJbsaxcQc9TUapptP69lOjxfNuVAA2kjEA=",
"HINFO": `"cpu" "os"`, "HTTPS": "1 . alpn=h2", "LOC": "42 21 54 N 71 06 18 W -24m 30m 0m 10m",
// NAPTR's flags, service and regexp are all character-strings in
// miekg's presentation format, so all three are quoted; a bare
// regexp (as in RFC 3403 §4.1) does not parse.
"MX": "10 mail.example.com.",
"NAPTR": `100 50 "S" "SIP+D2U" "!^.*$!sip:info@example.com!" _sip._udp.example.com.`,
"NS": "ns1.example.com.", "PTR": "host.example.com.",
"SRV": "0 1 443 target.example.com.", "SPF": `"v=spf1 -all"`,
"SSHFP": "1 1 aa11223344556666778899aabbccddeeff00112233445566778899aabbccddeeff",
"SVCB": "1 target.example.com. alpn=h2", "TXT": `"hello" "world"`,
// miekg presents URI's target as a character-string, so it has to be
// quoted; the samples above pin that.
"URI": `10 1 "https://example.com/"`,
}
for _, rrtype := range EditableTypes() {
sample, ok := samples[rrtype]
if !ok {
t.Errorf("editable type %s has no sample in this test; the writer is untested for it", rrtype)
continue
}
dir := t.TempDir()
m := NewManaged(dir)
if _, err := m.Add("example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}); err != nil {
t.Fatalf("%s: seed zone: %v", rrtype, err)
}
if _, err := m.Add("example.com", Record{Name: "rec.example.com", Type: rrtype, TTL: 300, Value: sample}, ZoneDefaults{}); err != nil {
t.Errorf("%s: add: %v", rrtype, err)
continue
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
var found *Record
for i := range zone.Records {
if zone.Records[i].Name == "rec.example.com." && zone.Records[i].Type == rrtype {
found = &zone.Records[i]
}
}
if found == nil {
t.Errorf("%s: the record did not survive the file: %+v", rrtype, zone.Records)
continue
}
// The canonical value is what the API echoed, so the console shows
// the same text the file holds.
if found.TTL != 300 {
t.Errorf("%s: TTL = %d, want 300", rrtype, found.TTL)
}
}
}
func TestManagedWritableAndPath(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested", "zones")
m := NewManaged(dir)
if !m.Writable() {
t.Error("a directory under a writable parent is not writable")
}
// Asking must not touch the disk: the console calls this on every read.
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("checking writability created the directory (stat err = %v)", err)
}
if _, err := m.Add("example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}); err != nil {
t.Fatalf("add: %v", err)
}
if !m.Writable() {
t.Error("a directory the process just wrote into is not writable")
}
if got := m.Path("example.com."); got != filepath.Join(dir, "example.com.zone") {
t.Errorf("Path = %q", got)
}
if got := NewManaged("").Writable(); got {
t.Error("an unconfigured managed directory is writable")
}
// The probe must not leave files behind.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != "example.com.zone" {
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
t.Errorf("managed directory holds %v, want only the zone file", names)
}
}
// The console's form asks for a short name, because a zone is already chosen.
// A name without a trailing dot therefore means "in this zone", the same way
// it does in a zone file.
func TestManagedQualifiesARelativeOwnerName(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "www", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "*.cdn", Type: "A", TTL: 60, Value: "10.0.0.2"}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
got := map[string]string{}
for _, rec := range zone.Records {
got[rec.Name] = rec.Value
}
if got["www.example.com."] != "10.0.0.1" {
t.Errorf("relative owner became %v, want www.example.com.", got)
}
if got["*.cdn.example.com."] != "10.0.0.2" {
t.Errorf("relative wildcard owner became %v, want *.cdn.example.com.", got)
}
// And the store serves them under the qualified names.
if res := loadManaged(t, dir).Lookup(dns.Question{Name: "cdn.example.com.", Qtype: dns.TypeA}); !res.Found {
t.Error("the wildcard does not answer under its qualified name")
}
// Deleting by the same short name finds the same record.
if _, removed, err := m.Delete("example.com", "www", "A", ""); err != nil || !removed {
t.Errorf("delete by relative name: removed=%v err=%v", removed, err)
}
// An absolute name outside the zone is still refused.
if _, err := m.Add("example.com", Record{Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.9"}, ZoneDefaults{}); err == nil {
t.Error("an absolute name outside the zone was accepted")
}
}
// A zone whose SOA names a server that is not in its own NS set hands out a
// referral no resolver can follow, and the console is what changes that set.
func TestManagedSOAPrimaryFollowsTheApexNSSet(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if !strings.HasPrefix(zone.Soa, "ns1.example.com. ") {
t.Fatalf("soa = %q, want ns1.example.com. as the primary", zone.Soa)
}
if _, err := m.Put("example.com", Record{Name: "example.com", Type: "NS", TTL: 300, Value: "ns2.example.com."}, ZoneDefaults{}); err != nil {
t.Fatal(err)
}
zone = loadManaged(t, dir).Snapshot("example.com.", dir)
if !strings.HasPrefix(zone.Soa, "ns2.example.com. ") {
t.Errorf("soa = %q after replacing the apex NS set, want ns2.example.com.", zone.Soa)
}
}
// A zone file that is not a zone file must be reported, not silently
// rewritten.
func TestManagedRefusesAFileWhoseOriginMoved(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if err := os.Rename(m.Path("example.com."), m.Path("other.com.")); err != nil {
t.Fatal(err)
}
if _, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{}); err == nil {
t.Error("a file holding another zone was overwritten")
}
}
// A cut means the names under it belong to another server, so a record there
// is dead data: the store answers them as delegated without ever looking at
// what the zone file says.
func TestManagedRefusesDataBelowADelegation(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com."}, 300, ZoneDefaults{}); err != nil {
t.Fatal(err)
}
below := Record{Name: "host.sub.example.com", Type: "A", TTL: 60, Value: "10.0.0.3"}
if _, err := m.Add("example.com", below, ZoneDefaults{}); err == nil {
t.Error("a record below a delegation was accepted")
}
for _, rec := range loadManaged(t, dir).Snapshot("example.com.", dir).Records {
if rec.Name == below.Name {
t.Errorf("the refused record was written anyway: %+v", rec)
}
}
// The delegation itself is still there and still a cut.
if res := loadManaged(t, dir).Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA}); !res.Delegated {
t.Error("the cut stopped being a cut")
}
// And the rule follows the cut, not the record: removing the delegation
// makes the same record legal again.
if _, _, err := m.RemoveDelegation("example.com", "sub.example.com."); err != nil {
t.Fatal(err)
}
if _, err := m.Add("example.com", below, ZoneDefaults{}); err != nil {
t.Errorf("a record under a name that is no longer delegated was refused: %v", err)
}
}
func TestZoneFileName(t *testing.T) {
for origin, want := range map[string]string{
"example.com.": "example.com.zone",
"EXAMPLE.com.": "example.com.zone",
"example.com": "example.com.zone",
"a.b.example.": "a.b.example.zone",
"example.com...": "example.com...zone",
} {
if got := zoneFileName(origin); got != want {
t.Errorf("zoneFileName(%q) = %q, want %q", origin, got, want)
}
}
}
func TestNormalizeOriginAndName(t *testing.T) {
if got, err := NormalizeOrigin(" Example.COM "); err != nil || got != "example.com." {
t.Errorf("NormalizeOrigin = %q, %v", got, err)
}
if got, err := NormalizeName("*.WWW.Example.com"); err != nil || got != "*.www.example.com." {
t.Errorf("NormalizeName = %q, %v", got, err)
}
if _, err := NormalizeName("."); err == nil {
t.Error("the root was accepted as an owner name")
}
// A name that is only a wildcard is not a zone the store can hold.
if _, err := NormalizeOrigin("*."); err == nil {
t.Error("a wildcard was accepted as a zone apex")
}
}
func TestZoneFileIsHumanReadable(t *testing.T) {
f := &fileState{
origin: "example.com.",
ttl: 300,
soa: mustSOA(t, "example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300"),
recs: []Record{
{Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"},
{Name: "example.com.", Type: "NS", TTL: 300, Value: "ns1.example.com."},
},
}
text := f.render()
if !strings.Contains(text, "@") || !strings.Contains(text, "IN SOA") {
t.Errorf("the apex SOA is not written with @:\n%s", text)
}
// One record per line, with the owner relative to the origin: that is the
// form this project's master-file parser accepts, so what the writer
// emits is what the loader can read. A bare owner line would parse as a
// record with no type.
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "$") {
continue
}
if !strings.Contains(line, " IN ") {
t.Errorf("record line without an owner, TTL, class and type: %q\n%s", line, text)
}
}
if !strings.Contains(text, "1m IN A") || !strings.Contains(text, "10.0.0.1") {
t.Errorf("the A record is missing:\n%s", text)
}
// And it is a zone file: it parses, and it parses to the same records.
parsed, err := parseFileState("example.com.", text)
if err != nil {
t.Fatalf("rendered file does not parse: %v\n%s", err, text)
}
if err := sameRecords(parsed.recs, sortCopy(f.recs)); err != nil {
t.Errorf("rendered file differs from the records: %v\n%s", err, text)
}
}
func sortCopy(in []Record) []Record {
out := append([]Record(nil), in...)
sortRecords(out)
return out
}
func mustSOA(t *testing.T, line string) *dns.SOA {
t.Helper()
rr, err := dns.NewRR(line)
if err != nil {
t.Fatal(err)
}
soa, ok := rr.(*dns.SOA)
if !ok {
t.Fatalf("%q is not an SOA", line)
}
return soa
}
+1 -1
View File
@@ -53,7 +53,7 @@ func RenderZone(origin string, def ZoneDefaults, recs []Record, serial uint32) (
if err := sameRecords(check.recs, f.recs); err != nil {
return "", fmt.Errorf("the zone file for %s did not render correctly: %w", origin, err)
}
return strings.Replace(text, FileHeader, ZoneSourceHeader, 1), nil
return text, nil
}
// ZoneSourceHeader tops a rendered zone that an external object owns.
@@ -3,8 +3,6 @@ package localzone
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
@@ -16,22 +14,11 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// DefaultZoneTTL is the $TTL a console-written zone file gets when the request
// does not name one. It is also the SOA minimum, so a record written without a
// DefaultZoneTTL is the $TTL a rendered zone gets when its spec does not name
// one. It is also the SOA minimum, so a record written without a
// TTL of its own inherits it.
const DefaultZoneTTL uint32 = 300
// FileHeader tops every console-written zone file. The file is rewritten
// whole on each edit, so hand-written commentary below it does not survive;
// operator-owned data belongs in blocklist.local_zones, which the API never
// touches.
const FileHeader = `; fancy-dns console-managed zone data.
;
; Rewritten by the admin API on every change: edits made here by hand are lost
; the next time a record in this zone is added, changed or deleted. Keep
; hand-maintained data in blocklist.local_zones instead.
`
// editableTypes are the RR types the API accepts. It is a subset of what the
// master-file assembler recognises, and of the types a zone browser can put
// in a form: SOA is zone metadata the writer owns, and AXFR/IXFR/ANY are not
@@ -68,124 +55,6 @@ func isEditableType(rrtype string) bool {
return false
}
// Managed is the console-managed zone directory: the only zone data the admin
// API is allowed to rewrite. It is one file per apex, named after it, holding
// the SOA, the apex NS set and whatever records the console has written. A
// zone whose bulk lives in an operator's master file can still gain a managed
// file, because Load merges every file it is given under one apex.
type Managed struct {
dir string
}
// NewManaged wraps a directory. The directory is created on the first write,
// never here: a read of a zone directory that does not exist is a legitimate
// state, not an error.
func NewManaged(dir string) *Managed { return &Managed{dir: dir} }
// Dir is the managed directory.
func (m *Managed) Dir() string {
if m == nil {
return ""
}
return m.dir
}
// Path is the file holding one apex's managed data.
func (m *Managed) Path(origin string) string {
if m == nil || m.dir == "" {
return ""
}
return filepath.Join(m.dir, zoneFileName(origin))
}
// ErrNotManaged is why a write did not happen because the console is not
// allowed to write zone data: no managed directory is configured, or the one
// that is cannot be written to. It is not "not found", so the API answers a
// delete with 400 for it and 404 for a record that simply is not there.
var ErrNotManaged = errors.New("local zone writes are disabled")
// Check reports whether a write can proceed, naming which of the two reasons
// stopped it.
func (m *Managed) Check() error {
if m == nil || m.dir == "" {
return fmt.Errorf("%w: set blocklist.managed_zones to let the console write zone data", ErrNotManaged)
}
if !m.Writable() {
return fmt.Errorf("%w: the managed zone directory %s is not writable", ErrNotManaged, m.dir)
}
return nil
}
// Remove deletes a zone's managed file, which is how a zone the console
// created stops being authoritative. It refuses a zone that also exists in an
// operator's master file: deleting that file would not remove the zone, and
// reporting success would be a lie.
func (m *Managed) Remove(origin string, wholeZoneIsManaged bool) (bool, error) {
canonical, err := NormalizeOrigin(origin)
if err != nil {
return false, err
}
if err := m.Check(); err != nil {
return false, err
}
if !wholeZoneIsManaged {
return false, fmt.Errorf("%s also has records outside %s; remove those first", canonical, m.dir)
}
path := m.Path(canonical)
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
// Exists reports whether the managed directory is there. It is not an error
// when it is not: the directory holds no zones until the console writes one.
func (m *Managed) Exists() bool {
if m == nil || m.dir == "" {
return false
}
info, err := os.Stat(m.dir)
return err == nil && info.IsDir()
}
// Writable reports whether a write can succeed: a directory that is (or whose
// nearest existing parent is) one the process may write to. It creates
// nothing, because the API calls it on every read to tell the console whether
// its edit controls are live.
func (m *Managed) Writable() bool {
if m == nil || m.dir == "" {
return false
}
dir := m.dir
for {
info, err := os.Stat(dir)
if err == nil {
if !info.IsDir() {
return false
}
probe, err := os.CreateTemp(dir, ".fancy-dns-probe")
if err != nil {
return false
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
return true
}
if !os.IsNotExist(err) {
return false
}
parent := filepath.Dir(dir)
if parent == dir {
return false
}
dir = parent
}
}
// ZoneDefaults seeds a managed file for an apex that has none yet: the SOA and
// apex NS set of a zone that already exists elsewhere, or the NS targets the
// request itself supplies.
@@ -199,172 +68,6 @@ type ZoneDefaults struct {
NS []string
}
// Add writes one record into a zone, creating the zone's managed file when it
// has none. The record is canonicalised first, so what the API echoes back is
// what a zone-file parse will produce.
func (m *Managed) Add(origin string, rec Record, def ZoneDefaults) (uint32, error) {
var serial uint32
err := m.mutate(origin, def, func(f *fileState) error {
rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value)
if err != nil {
return err
}
for _, have := range f.recs {
if have.Key() == rec.Key() && have.Value == rec.Value {
return fmt.Errorf("%s %s already exists in %s", rec.Name, rec.Type, origin)
}
}
f.recs = append(f.recs, rec)
return nil
}, &serial)
return serial, err
}
// Put replaces the whole RRset of one name and type. This is the console's
// edit: a name and type hold one logical record (an A, a set of MXs, a TXT
// RRset), and replacing it wholesale is what an operator means by "change
// this row".
func (m *Managed) Put(origin string, rec Record, def ZoneDefaults) (uint32, error) {
var serial uint32
err := m.mutate(origin, def, func(f *fileState) error {
rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value)
if err != nil {
return err
}
kept := f.recs[:0]
for _, have := range f.recs {
if have.Key() == rec.Key() {
continue
}
kept = append(kept, have)
}
f.recs = append(kept, rec)
return nil
}, &serial)
return serial, err
}
// Delete removes one record, or the whole RRset when value is empty. It
// reports whether anything was removed: "delete" on a name that is not there
// is a stale console, not a server error.
func (m *Managed) Delete(origin, name, rrtype, value string) (uint32, bool, error) {
var serial uint32
var removed bool
err := m.mutate(origin, ZoneDefaults{}, func(f *fileState) error {
owner, err := ownerInZone(f.origin, name)
if err != nil {
return err
}
up := strings.ToUpper(strings.TrimSpace(rrtype))
if !isEditableType(up) {
return fmt.Errorf("type %s cannot be written through the API", up)
}
// An empty value means "the whole RRset", so there is nothing to
// parse.
want := ""
if value != "" {
rec, err := newRecord(f.origin, owner, up, f.ttl, value)
if err != nil {
return err
}
want = rec.Value
}
key := owner + "|" + up
kept := f.recs[:0]
for _, have := range f.recs {
hit := have.Key() == key && (want == "" || have.Value == want)
if hit {
removed = true
continue
}
kept = append(kept, have)
}
f.recs = append([]Record(nil), kept...)
if !removed {
return fmt.Errorf("no managed record matches %s %s in %s", name, up, f.origin)
}
return nil
}, &serial)
if err != nil {
return 0, false, err
}
return serial, removed, nil
}
// SetDelegation hands a name below the apex, and everything under it, to other
// servers: it writes the NS RRset of that name.
func (m *Managed) SetDelegation(origin, name string, targets []string, ttl uint32, def ZoneDefaults) (uint32, error) {
if len(targets) == 0 {
return 0, errors.New("a delegation needs at least one NS target")
}
cut, err := NormalizeName(name)
if err != nil {
return 0, fmt.Errorf("delegated name: %w", err)
}
if cut == strings.ToLower(dns.Fqdn(origin)) {
return 0, errors.New("a delegation must be below the zone apex; the apex NS set is the zone's own server list")
}
if def.TTL == 0 {
def.TTL = ttl
}
var serial uint32
err = m.mutate(origin, def, func(f *fileState) error {
for _, target := range targets {
t, err := newRecord(f.origin, cut, "NS", f.ttl, target)
if err != nil {
return fmt.Errorf("delegation target %q: %w", target, err)
}
// Replace one target at a time, so re-submitting the form does
// not accumulate duplicates and a second target does not drop
// the first.
kept := make([]Record, 0, len(f.recs)+1)
for _, have := range f.recs {
if have.Key() == t.Key() && have.Value == t.Value {
continue
}
kept = append(kept, have)
}
f.recs = append(kept, t)
}
return nil
}, &serial)
return serial, err
}
// RemoveDelegation deletes the NS RRset of a name, which is the whole cut.
func (m *Managed) RemoveDelegation(origin, name string) (uint32, bool, error) {
return m.Delete(origin, name, "NS", "")
}
// mutate is the whole write path: read, change, bump the serial, render, prove
// the rendering parses back to the same records, then write atomically.
func (m *Managed) mutate(origin string, def ZoneDefaults, fn func(*fileState) error, serial *uint32) error {
if err := m.Check(); err != nil {
return err
}
canonical, err := NormalizeOrigin(origin)
if err != nil {
return err
}
f, err := m.load(canonical)
if err != nil {
return err
}
if f == nil {
f, err = newFileState(canonical, def)
if err != nil {
return err
}
}
if err := fn(f); err != nil {
return err
}
if err := f.finish(); err != nil {
return err
}
return m.save(f, serial)
}
// fileState is one managed file's content in presentation form.
type fileState struct {
origin string
@@ -472,7 +175,7 @@ func (f *fileState) checkCuts() error {
// indented under it.
func (f *fileState) render() string {
var sb strings.Builder
sb.WriteString(FileHeader)
sb.WriteString(ZoneSourceHeader)
fmt.Fprintf(&sb, "$ORIGIN %s\n", f.origin)
fmt.Fprintf(&sb, "$TTL %s\n", zonefile.FormatTTL(f.ttl))
soa := f.soa
@@ -509,86 +212,6 @@ func sortedNames(byName map[string][]Record) []string {
return out
}
// save writes the file atomically, and only after proving that what it
// rendered is what a parse of that text produces. A writer bug would otherwise
// show up as a zone that silently stopped answering.
func (m *Managed) save(f *fileState, serial *uint32) error {
text := f.render()
check, err := parseFileState(f.origin, text)
if err != nil {
return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err)
}
if err := sameRecords(check.recs, f.recs); err != nil {
return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err)
}
if err := os.MkdirAll(m.dir, 0o755); err != nil {
return fmt.Errorf("managed zone directory %s: %w", m.dir, err)
}
path := m.Path(f.origin)
tmp, err := os.CreateTemp(m.dir, ".fancy-dns-zone-*")
if err != nil {
return fmt.Errorf("managed zone directory %s: %w", m.dir, err)
}
name := tmp.Name()
defer func() {
// A rename that never happened must not leave a stray temp file.
_ = os.Remove(name)
}()
if _, err := tmp.WriteString(text); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(name, 0o644); err != nil {
return err
}
if err := os.Rename(name, path); err != nil {
return err
}
if serial != nil && f.soa != nil {
*serial = f.soa.Serial
}
return nil
}
// load reads one apex's managed file. A missing file is (nil, nil): the zone
// has no console-managed data yet, which is the normal state.
func (m *Managed) load(origin string) (*fileState, error) {
path := m.Path(origin)
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()
z, err := zonefile.Parse(f, zonefile.Options{Origin: origin, File: path, BaseDir: m.dir})
if err != nil {
return nil, err
}
if strings.ToLower(z.Origin) != origin {
return nil, fmt.Errorf("%s holds zone %s, not %s", path, z.Origin, origin)
}
return fileStateFromZone(z, origin), nil
}
// ManagedRecords reads the records the console owns for one apex, which is the
// subset the API may rewrite.
func (m *Managed) ManagedRecords(origin string) ([]Record, error) {
f, err := m.load(origin)
if err != nil || f == nil {
return nil, err
}
return append([]Record(nil), f.recs...), nil
}
// fileStateFromZone converts a parsed master file into presentation form.
func fileStateFromZone(z *zonefile.Zone, origin string) *fileState {
ttl := z.DefaultTTL
@@ -786,11 +409,6 @@ func relativeOwner(name, origin string) string {
return name
}
// zoneFileName maps an apex to its managed file.
func zoneFileName(origin string) string {
return strings.TrimSuffix(strings.ToLower(origin), ".") + ".zone"
}
// synthSOA builds a zone's start of authority.
func synthSOA(origin, primary string, serial, ttl uint32) (*dns.SOA, error) {
if primary == "" {
+267
View File
@@ -0,0 +1,267 @@
package localzone
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/miekg/dns"
)
// rendered renders a zone and loads it the way the resolver does, so a test
// asserts on what the query path would actually serve.
func rendered(t *testing.T, origin string, def ZoneDefaults, recs []Record) (*Store, string) {
t.Helper()
text, err := RenderZone(origin, def, recs, 7)
if err != nil {
t.Fatalf("render %s: %v", origin, err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "z.zone"), []byte(text), 0o600); err != nil {
t.Fatal(err)
}
store, err := Load([]string{dir}, Options{})
if err != nil {
t.Fatalf("load rendered zone: %v\n%s", err, text)
}
return store, text
}
func ns(origin string) Record { return Record{Name: origin, Type: "NS", Value: "ns1." + origin} }
func TestRenderedZoneIsAParsableZoneFile(t *testing.T) {
store, text := rendered(t, "example.com", ZoneDefaults{}, []Record{
ns("example.com."), {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"},
})
for _, want := range []string{"$ORIGIN example.com.", "$TTL 5m", "IN SOA", "IN NS", "IN A", "10.0.0.1", "Kubernetes DNSZone"} {
if !strings.Contains(text, want) {
t.Errorf("zone file is missing %q:\n%s", want, text)
}
}
zone := store.Snapshot("example.com.", "")
if zone == nil || zone.Serial != 7 {
t.Fatalf("zone = %+v, want the serial that was asked for", zone)
}
// The same input renders the same bytes: replicas must agree.
again, err := RenderZone("example.com", ZoneDefaults{}, []Record{
ns("example.com."), {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"},
}, 7)
if err != nil || again != text {
t.Fatalf("rendering is not deterministic (%v)", err)
}
}
func TestWildcardAndRelativeTargetsSurviveTheRoundTrip(t *testing.T) {
store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{
ns("example.com."),
{Name: "*.example.com", Type: "A", TTL: 60, Value: "10.0.0.9"},
{Name: "alias.example.com", Type: "CNAME", TTL: 60, Value: "target"},
})
zone := store.Snapshot("example.com.", "")
byKey := map[string]Record{}
for _, rec := range zone.Records {
byKey[rec.Key()] = rec
}
wild, ok := byKey["*.example.com.|A"]
if !ok || !wild.Wildcard || wild.Value != "10.0.0.9" {
t.Fatalf("the wildcard record = %+v (%v)", wild, ok)
}
if cname := byKey["alias.example.com.|CNAME"]; cname.Value != "target.example.com." {
t.Errorf("a relative CNAME target became %q, want it qualified by the origin", cname.Value)
}
if res := store.Lookup(dns.Question{Name: "anything.example.com.", Qtype: dns.TypeA}); !res.Found || len(res.Records) != 1 {
t.Errorf("wildcard lookup = %+v, want an answer", res)
}
}
func TestEveryEditableTypeSurvivesTheFile(t *testing.T) {
samples := map[string]string{
"A": "10.0.0.1", "AAAA": "2001:db8::1", "CAA": `0 issue "letsencrypt.org"`,
"CNAME": "target.example.com.", "DHCID": "AAIBY2/AuCccgoJbsaxcQc9TUapptP69lOjxfNuVAA2kjEA=",
"HINFO": `"cpu" "os"`, "HTTPS": "1 . alpn=h2", "LOC": "42 21 54 N 71 06 18 W -24m 30m 0m 10m",
"MX": "10 mail.example.com.",
"NAPTR": `100 50 "S" "SIP+D2U" "!^.*$!sip:info@example.com!" _sip._udp.example.com.`,
"NS": "ns1.example.com.", "PTR": "host.example.com.",
"SRV": "0 1 443 target.example.com.", "SPF": `"v=spf1 -all"`,
"SSHFP": "1 1 aa11223344556666778899aabbccddeeff00112233445566778899aabbccddeeff",
"SVCB": "1 target.example.com. alpn=h2", "TXT": `"hello" "world"`,
"URI": `10 1 "https://example.com/"`,
}
for _, rrtype := range EditableTypes() {
sample, ok := samples[rrtype]
if !ok {
t.Errorf("editable type %s has no sample in this test; the renderer is untested for it", rrtype)
continue
}
text, err := RenderZone("example.com", ZoneDefaults{}, []Record{
ns("example.com."), {Name: "rec.example.com", Type: rrtype, TTL: 300, Value: sample},
}, 1)
if err != nil {
t.Errorf("%s: render: %v", rrtype, err)
continue
}
zf, err := ParseZoneFile("example.com", text)
if err != nil {
t.Errorf("%s: the rendered file does not parse: %v", rrtype, err)
continue
}
found := false
for _, r := range zf.Records {
if r.Name == "rec.example.com." && r.Type == rrtype && r.TTL == 300 {
found = true
}
}
if !found {
t.Errorf("%s: the record did not survive the file: %+v", rrtype, zf.Records)
}
}
}
func TestRelativeOwnersAreQualified(t *testing.T) {
store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{
ns("example.com."),
{Name: "www", Type: "A", TTL: 60, Value: "10.0.0.1"},
{Name: "*.cdn", Type: "A", TTL: 60, Value: "10.0.0.2"},
})
got := map[string]string{}
for _, rec := range store.Snapshot("example.com.", "").Records {
got[rec.Name] = rec.Value
}
if got["www.example.com."] != "10.0.0.1" || got["*.cdn.example.com."] != "10.0.0.2" {
t.Errorf("owners = %v", got)
}
if res := store.Lookup(dns.Question{Name: "cdn.example.com.", Qtype: dns.TypeA}); !res.Found {
t.Error("the wildcard does not answer under its qualified name")
}
if _, err := RenderZone("example.com", ZoneDefaults{}, []Record{ns("example.com."), {Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.9"}}, 1); err == nil {
t.Error("an absolute name outside the zone was accepted")
}
}
// A zone whose SOA names a server that is not in its own NS set hands out a
// referral no resolver can follow.
func TestSOAPrimaryFollowsTheApexNSSet(t *testing.T) {
store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{ns("example.com.")})
if soa := store.Snapshot("example.com.", "").Soa; !strings.HasPrefix(soa, "ns1.example.com. ") {
t.Fatalf("soa = %q, want ns1 as the primary", soa)
}
store, _ = rendered(t, "example.com", ZoneDefaults{Soa: "ns1.example.com. hostmaster.example.com. 1 3600 600 604800 300"},
[]Record{{Name: "example.com.", Type: "NS", Value: "ns2.example.com."}})
if soa := store.Snapshot("example.com.", "").Soa; !strings.HasPrefix(soa, "ns2.example.com. ") {
t.Errorf("soa = %q: a seed SOA must not name a server outside the NS set", soa)
}
}
func TestRenderRefusesWhatItCannotServe(t *testing.T) {
for name, tc := range map[string]struct {
origin string
recs []Record
}{
"no server list": {"example.com", []Record{{Name: "www.example.com.", Type: "A", Value: "10.0.0.1"}}},
"bad apex": {"com", []Record{ns("com.")}},
"root": {".", nil},
"duplicate": {"example.com", []Record{ns("example.com."), ns("example.com.")}},
"soa as record": {"example.com", []Record{ns("example.com."), {Name: "example.com.", Type: "SOA", Value: "a. b. 1 2 3 4 5"}}},
"below a cut": {"example.com", []Record{
ns("example.com."), {Name: "sub.example.com.", Type: "NS", Value: "ns.sub.example.com."},
{Name: "host.sub.example.com.", Type: "A", Value: "10.0.0.3"},
}},
} {
if _, err := RenderZone(tc.origin, ZoneDefaults{}, tc.recs, 1); err == nil {
t.Errorf("%s: accepted", name)
}
}
// The rule follows the cut, not the record.
if _, err := RenderZone("example.com", ZoneDefaults{}, []Record{
ns("example.com."), {Name: "host.sub.example.com.", Type: "A", Value: "10.0.0.3"},
}, 1); err != nil {
t.Errorf("a record under a name that is not delegated was refused: %v", err)
}
}
func TestNormalizeOriginAndName(t *testing.T) {
if got, err := NormalizeOrigin(" Example.COM "); err != nil || got != "example.com." {
t.Errorf("NormalizeOrigin = %q, %v", got, err)
}
if got, err := NormalizeName("*.WWW.Example.com"); err != nil || got != "*.www.example.com." {
t.Errorf("NormalizeName = %q, %v", got, err)
}
if _, err := NormalizeName("."); err == nil {
t.Error("the root was accepted as an owner name")
}
if _, err := NormalizeOrigin("*."); err == nil {
t.Error("a wildcard was accepted as a zone apex")
}
}
func TestZoneFileIsHumanReadable(t *testing.T) {
f := &fileState{
origin: "example.com.",
ttl: 300,
soa: mustSOA(t, "example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300"),
recs: []Record{
{Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"},
{Name: "example.com.", Type: "NS", TTL: 300, Value: "ns1.example.com."},
},
}
text := f.render()
if !strings.Contains(text, "@") || !strings.Contains(text, "IN SOA") {
t.Errorf("the apex SOA is not written with @:\n%s", text)
}
// One record per line: this project's master-file parser takes a bare owner
// line as a record with no type.
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "$") {
continue
}
if !strings.Contains(line, " IN ") {
t.Errorf("record line without an owner, TTL, class and type: %q\n%s", line, text)
}
}
parsed, err := parseFileState("example.com.", text)
if err != nil {
t.Fatalf("rendered file does not parse: %v\n%s", err, text)
}
if err := sameRecords(parsed.recs, sortCopy(f.recs)); err != nil {
t.Errorf("rendered file differs from the records: %v\n%s", err, text)
}
}
func TestParseZoneFile(t *testing.T) {
zf, err := ParseZoneFile("Home.Example.com", "$TTL 120\n@ IN SOA ns1 host 5 3600 600 604800 120\n@ IN NS ns1\nns1 IN A 10.0.0.1\nweb 30 IN A 10.0.0.2\n")
if err != nil {
t.Fatal(err)
}
if zf.TTL != 120 || !strings.HasPrefix(zf.Soa, "ns1.home.example.com. host.home.example.com. 5 ") || len(zf.Records) != 3 {
t.Fatalf("zone file = %+v", zf)
}
for _, r := range zf.Records {
if r.Name == "web.home.example.com." && r.TTL != 30 {
t.Errorf("web TTL = %d, want its own 30", r.TTL)
}
}
if _, err := ParseZoneFile("home.example.com", "@@@ ("); err == nil {
t.Error("garbage parsed")
}
}
func sortCopy(in []Record) []Record {
out := append([]Record(nil), in...)
sortRecords(out)
return out
}
func mustSOA(t *testing.T, line string) *dns.SOA {
t.Helper()
rr, err := dns.NewRR(line)
if err != nil {
t.Fatal(err)
}
soa, ok := rr.(*dns.SOA)
if !ok {
t.Fatalf("%q is not an SOA", line)
}
return soa
}