commit bd6b8778e41b3a03295a49fc83182bda85a1e853 Author: abrown38 Date: Wed Oct 7 15:03:13 2026 +0000 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3ce933 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/bin/ +/dist/ +*.db +*.db-wal +*.db-shm diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a63b3eb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +All notable changes to Jarl are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +The first version, to be released as 0.1.0. + +### Added + +- **jarld daemon**: REST API (`/api/v1`) and embedded web UI, with SQLite as + the source of truth for the whole BIND configuration. Runs under systemd + (`Type=notify`, sandboxed, unprivileged `jarl` user in group `bind`). + SIGHUP and certificate file changes reload TLS. +- **Configuration model**: views (ordered, match-clients/destinations, + recursive-only), ACLs (nested, negation, keys, reference tracking with + rename and delete protection), TSIG keys (kept in a separate `keys.conf`, + never in diffs), zones of every type (primary, secondary, stub, + static-stub, forward, mirror, hint, redirect, in-view), reverse zones + from a CIDR, records of every type, options at server, view, zone and + top-level scope, and defaults for new zones. +- **BIND 9.20 option catalog**: all 353 statements with grammar, allowed + scopes, value kinds, deprecation status and ARM descriptions, used for + validation and by the UI's option editor. +- **Changesets**: immediate one-operation changes by default; staging into + named changesets (`?changeset=`, `X-Jarl-Changeset`, `--changeset`), + batches (`POST /apply`, `jarl apply -f`), dry runs, previews with diffs, + and revert of applied changesets. Optimistic locking with object versions. +- **Apply pipeline**: whole-configuration validation, rendering, `named-checkconf -z` + and `named-checkzone` in a staging directory, atomic install, `rndc reconfig` + and per-zone reload, verification with `rndc zonestatus`, and automatic + rollback when named doesn't load the change. Automatic serials (date, + increment or unixtime policy, RFC 1982), bumped only when DNS data + changes. Drift detection for files changed outside Jarl. +- **Validation**: CNAME/DNAME rules, NS/MX/SRV targets, glue and + delegations, RRset TTL consistency, BIND 9.20 RRset/type limits, SPF/DMARC + sanity, ACL/key references and cycles, option syntax and scope, and + security lints (open resolver, open zone transfers, dynamic updates on + managed zones). +- **Records**: TXT entered as plain text (automatic quoting and 255-byte + splitting), relative and absolute names with detection of doubled + origins, PTR records kept in step with A/AAAA (`ptr`), RRset replace, + delete by match, zone file import (merge or replace). +- **Import and adoption**: `jarld import` reads an existing named.conf tree + and zone files (comments, `$INCLUDE`, `$GENERATE`, legacy + `master`/`slave` spellings); `jarld adopt` switches named to Jarl's + configuration with a backup and automatic fallback. +- **Authentication**: web logins with argon2id passwords, sessions + (HttpOnly, SameSite=Strict) and CSRF tokens, rate limiting per user and + IP; hashed API tokens (`jarl_...`) with optional expiry. +- **Permissions**: granular grants (resource × read/write, filtered by zone, + view, record name pattern and type), roles (`readonly`, `dns-editor`, + `operator`, `acme`), tokens restricted to a subset of their user's + rights, administrators. +- **Audit log** of logins, changes, users, tokens and secret reveals, with + user, token, IP and outcome, searchable and written to the journal. +- **Web UI**: dashboard, zones and records with inline editing and pending + markers, views, ACLs, TSIG keys, the option editor, review and apply, + history with revert, audit log, users, account and tokens, search. + Review mode by default, with an "apply immediately" switch. +- **jarl CLI**: zones, records, views, ACLs, TSIG keys, options, settings, + changesets, batches, history, users, tokens, audit, search, status, check + and resync. Table, JSON and YAML output, and documented exit codes. +- **ACME DNS-01**: `jarl acme set|unset`, certbot hooks + (`jarl acme certbot-auth|certbot-cleanup`), an acme.sh DNS API hook + (`contrib/acme.sh/dns_jarl.sh`), automatic zone and view discovery, + idempotent retries, and `--follow-cname` / `--resolver` for a dedicated + ACME zone. +- **Operations**: `jarld backup` (online), `jarld resync`, `jarld check`, + `jarld render`, local user recovery commands, audit retention. +- **Packaging**: Makefile (build, test, e2e, install, dist), systemd unit, + sysusers.d and tmpfiles.d files, example configuration + (`jarld config --example`). +- **Tests**: unit tests for every package, integration tests against the + real named 9.20 (checkers, reload and rollback, import and adopt), and an + end-to-end script (`scripts/e2e.sh`) that drives every CLI command + against a running named. +- **Documentation**: concepts, install and adoption, configuration, CLI, + API, permissions, ACME, validation, operations and development guides. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5d27cd5 --- /dev/null +++ b/Makefile @@ -0,0 +1,80 @@ +# Jarl build and install. +# +# make build bin/jarld and bin/jarl +# make test unit tests (integration tests run when BIND is found; +# set JARL_BIND_ROOT, see docs/development.md) +# make e2e end-to-end test against a real named (scripts/e2e.sh) +# sudo make install install binaries, systemd unit, sysusers/tmpfiles +# files and an example /etc/jarl/jarl.yaml +# make dist static binaries for linux/amd64 and linux/arm64 in dist/ + +VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev) +LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION) +GOFLAGS := -trimpath +export CGO_ENABLED := 0 + +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +DOCDIR ?= $(PREFIX)/share/doc/jarl +UNITDIR ?= /etc/systemd/system +SYSUSERSDIR ?= /usr/lib/sysusers.d +TMPFILESDIR ?= /usr/lib/tmpfiles.d +DESTDIR ?= + +.PHONY: all build test vet e2e install uninstall dist clean + +all: build + +build: + go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/jarld ./cmd/jarld + go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/jarl ./cmd/jarl + +vet: + go vet ./... + @test -z "$$(gofmt -l cmd internal)" || { echo "gofmt needed:"; gofmt -l cmd internal; exit 1; } + +test: vet + go test ./... + +e2e: build + scripts/e2e.sh + +install: build + install -D -m 0755 bin/jarld $(DESTDIR)$(BINDIR)/jarld + install -D -m 0755 bin/jarl $(DESTDIR)$(BINDIR)/jarl + install -D -m 0644 deploy/systemd/jarl.service $(DESTDIR)$(UNITDIR)/jarl.service + sed -i 's#/usr/local/bin/jarld#$(BINDIR)/jarld#; s#/usr/local/share/doc/jarl#$(DOCDIR)#' $(DESTDIR)$(UNITDIR)/jarl.service + install -D -m 0644 deploy/sysusers.d/jarl.conf $(DESTDIR)$(SYSUSERSDIR)/jarl.conf + install -D -m 0644 deploy/tmpfiles.d/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf + install -D -m 0644 README.md $(DESTDIR)$(DOCDIR)/README.md + install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/CHANGELOG.md + install -d $(DESTDIR)$(DOCDIR)/docs $(DESTDIR)$(DOCDIR)/contrib/acme.sh + install -m 0644 docs/*.md $(DESTDIR)$(DOCDIR)/docs/ + install -m 0644 contrib/acme.sh/dns_jarl.sh $(DESTDIR)$(DOCDIR)/contrib/acme.sh/ + @if [ ! -e $(DESTDIR)/etc/jarl/jarl.yaml ]; then \ + install -d -m 0750 $(DESTDIR)/etc/jarl; \ + bin/jarld config --example > $(DESTDIR)/etc/jarl/jarl.yaml; \ + chmod 0640 $(DESTDIR)/etc/jarl/jarl.yaml; \ + echo "wrote $(DESTDIR)/etc/jarl/jarl.yaml"; \ + fi + @echo + @echo "Next (as root): systemd-sysusers jarl.conf && systemd-tmpfiles --create jarl.conf" + @echo " chgrp jarl /etc/jarl /etc/jarl/jarl.yaml && systemctl daemon-reload" + @echo "Then follow docs/install.md (init, import, adopt, start)." + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \ + $(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf + rm -rf $(DESTDIR)$(DOCDIR) + @echo "Kept /etc/jarl, /var/lib/jarl and the files in /etc/bind/jarl and /var/lib/bind/jarl." + +dist: + for arch in amd64 arm64; do \ + GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/jarl-$(VERSION)-linux-$$arch/jarld ./cmd/jarld && \ + GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/jarl-$(VERSION)-linux-$$arch/jarl ./cmd/jarl && \ + cp -r README.md CHANGELOG.md docs deploy contrib dist/jarl-$(VERSION)-linux-$$arch/ && \ + tar -C dist -czf dist/jarl-$(VERSION)-linux-$$arch.tar.gz jarl-$(VERSION)-linux-$$arch || exit 1; \ + done + +clean: + rm -rf bin dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..76369b8 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# Jarl + +*A Jarl rules a Hold; this one rules your zones.* + +Jarl manages a BIND 9 (named) server: zones, records of every type, views, +ACLs, TSIG keys and any named.conf option. It has a web UI, a REST API and +a command line client. All three go through the same validated, audited, +all-or-nothing change pipeline, and nothing reaches named before BIND's own +checkers have accepted it. + +``` +$ jarl record add adbraun.lol nas A 192.168.1.20 --ptr +add nas.adbraun.lol. IN A 192.168.1.20 +add 20.1.168.192.in-addr.arpa. IN PTR nas.adbraun.lol. +zone 1.168.192.in-addr.arpa serial 2026100700 -> 2026100701 +zone adbraun.lol serial 2026100701 -> 2026100702 +applied (changeset 12) + +$ jarl record add adbraun.lol www A 192.168.1.99 +error: www.adbraun.lol has a CNAME, so it can't have any other records (found A); a CNAME must be the only record at its name (RFC 1034) [zone adbraun.lol] +``` + +## Features + +* **Everything in one place**: primary, secondary, forward, stub, mirror, + hint, redirect and in-view zones, reverse zones from a CIDR, split DNS with + views, ACLs, TSIG keys, and every BIND 9.20 option at server, view, zone + and top level, with the ARM's description and grammar built in. +* **Safe changes.** Every change is validated as a whole (CNAME rules, + glue, delegations, RRset TTLs, references, option syntax, open-resolver + and open-transfer lints), checked with `named-checkconf -z`, installed + atomically, reloaded, and verified with `rndc zonestatus`. If named + doesn't load it, the previous files are put back. +* **Changesets**: apply edits one at a time, or bundle them, review the + diff, and apply them together. Every applied changeset can be reverted. + Serials are bumped automatically. +* **Users and API tokens with granular permissions**, e.g. a token that may + only write `_acme-challenge` TXT records in one zone. Passwords use + argon2id, sessions have CSRF protection, logins are rate limited. +* **Audit log** of every login, change and token, with user, token, IP and + outcome. +* **Let's Encrypt DNS-01** built in: `jarl acme` plus certbot and acme.sh + hooks, including CNAME delegation to a dedicated ACME zone. +* **Adopts an existing server**: imports named.conf and zone files + (comments included), then switches named over with an automatic + fallback. +* One static Go binary for the daemon (web UI embedded, SQLite built in) and + one for the CLI, run by a sandboxed systemd service as an unprivileged + user. + +## Quick start + +On a Debian/Ubuntu BIND 9.20 server ([install.md](docs/install.md) has the +details): + +```sh +make && sudo make install +sudo systemd-sysusers jarl.conf && sudo systemd-tmpfiles --create jarl.conf +sudo chgrp jarl /etc/jarl/jarl.yaml +sudo -u jarl jarld init --token # first admin + a CLI token +sudo -u jarl jarld import # read the current named.conf and zones +sudo jarld adopt # switch named over (keeps a backup, rolls back on failure) +sudo systemctl enable --now jarl + +jarl config set server http://127.0.0.1:8053 +jarl config set token # paste the token from 'jarld init' +jarl status +``` + +Then open the web UI at `http://127.0.0.1:8053/`, or set `listen` and `tls` +in `/etc/jarl/jarl.yaml` to reach it from your management network. + +## Documentation + +| | | +|-|-| +| [docs/concepts.md](docs/concepts.md) | How Jarl works: source of truth, changesets, the apply pipeline, authentication. Read this first | +| [docs/install.md](docs/install.md) | Installing, importing and adopting an existing server, TLS, upgrading | +| [docs/configuration.md](docs/configuration.md) | `/etc/jarl/jarl.yaml` reference | +| [docs/cli.md](docs/cli.md) | The `jarl` command line client | +| [docs/api.md](docs/api.md) | REST API reference | +| [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles | +| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot and acme.sh | +| [docs/validation.md](docs/validation.md) | Every check Jarl makes | +| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring | +| [docs/development.md](docs/development.md) | Code layout, tests against real BIND, conventions | +| [CHANGELOG.md](CHANGELOG.md) | Changes per release | + +## The web UI + +The UI is built for getting things done rather than for looks. It has +these pages: + +* **Dashboard**: named status, adoption, drift, recent changes, open + problems. +* **Zones**: list, create (fields adapt to the zone type), per-zone records + with inline editing and type-specific hints, settings (SOA, TTL, serial + policy, view), options, the generated zone file, and pasting a zone file + to merge or replace records. +* **Views, ACLs, TSIG keys, Server options**: the options editor knows every + BIND 9.20 statement for the scope and shows its description, grammar and + a value field that fits it. +* **Changes**: pending edits with the diff, named-checkconf output and + problems, applied as one changeset. History lists applied changesets with + their diffs and a revert button. +* **Audit, Users, Account** (password, API tokens) and **Search**. + +By default, edits are collected as pending changes. Pending rows are shown +inline (green = new, yellow = changed, struck out = deleted) until you +review and apply them. Switch on "Apply edits immediately" to skip the +review step. + +## Requirements + +* BIND 9.20, with `named-checkconf`, `named-checkzone` and `rndc`. The + option catalog follows 9.20. 9.18 will probably work, because + named-checkconf still checks every change, but it is untested. +* Linux with systemd for the packaged service. Defaults match + Debian/Ubuntu, and other layouts are configurable. +* Go 1.27+ to build. + +## Status + +Version 0.1.0, the first release. It is feature-complete for one server's +configuration. DNSSEC signing and managing several servers are not handled +yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/jarl/acme.go b/cmd/jarl/acme.go new file mode 100644 index 0000000..d637438 --- /dev/null +++ b/cmd/jarl/acme.go @@ -0,0 +1,256 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "jarl/internal/api" + "jarl/internal/client" +) + +const acmePrefix = "_acme-challenge." + +// acmeOptions are the flags shared by the acme subcommands. +type acmeOptions struct { + view string + ttl uint32 + exact bool + followCNAME bool + resolver string +} + +func (o *acmeOptions) flags(cmd *cobra.Command) { + cmd.Flags().StringVar(&o.view, "view", "", "only the zone in this view (default: every view that has the zone)") + cmd.Flags().BoolVar(&o.exact, "exact", false, "use DOMAIN as the record name as it is, without adding _acme-challenge.") + cmd.Flags().BoolVar(&o.followCNAME, "follow-cname", false, "if the challenge name is a CNAME (e.g. into a dedicated ACME zone), write the record at its target") + cmd.Flags().StringVar(&o.resolver, "resolver", "", "DNS server for --follow-cname, HOST[:PORT] (default: the system's resolvers)") +} + +// dnsResolver returns the resolver used to follow CNAMEs. +func (o *acmeOptions) dnsResolver() *net.Resolver { + if o.resolver == "" { + return net.DefaultResolver + } + addr := o.resolver + if _, _, err := net.SplitHostPort(addr); err != nil { + addr = net.JoinHostPort(addr, "53") + } + return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, addr) + }} +} + +// challengeName returns the FQDN of the TXT record for a domain. +func (o *acmeOptions) challengeName(domain string) (string, error) { + d := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(domain), ".")) + if d == "" { + return "", fmt.Errorf("no domain given") + } + if !o.exact { + d = strings.TrimPrefix(d, "*.") + if !strings.HasPrefix(d, acmePrefix) { + d = acmePrefix + d + } + } + name := d + "." + if o.followCNAME { + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // A lookup error usually just means there is no CNAME (or no + // record yet), so keep the name. + if target, err := o.dnsResolver().LookupCNAME(c, name); err == nil && !strings.EqualFold(target, name) { + if !flagQuiet { + fmt.Fprintf(os.Stderr, "%s is a CNAME for %s\n", dot(name), dot(target)) + } + name = strings.ToLower(target) + } + } + return name, nil +} + +// acmeZones finds the primary zones (one per view) that contain name. +func acmeZones(c *client.Client, name, view string) ([]api.Zone, error) { + var zones []api.Zone + if err := c.Get(ctx(), "/zones", q("type", "primary"), &zones); err != nil { + return nil, err + } + best := "" + var out []api.Zone + for _, z := range zones { + if view != "" && !strings.EqualFold(z.View, view) { + continue + } + zn := strings.ToLower(z.Name) + if !(name == zn || zn == "." || strings.HasSuffix(name, "."+zn)) { + continue + } + switch { + case len(zn) > len(best): + best, out = zn, []api.Zone{z} + case zn == best: + out = append(out, z) + } + } + if len(out) == 0 { + where := "" + if view != "" { + where = " in view " + view + } + return nil, fmt.Errorf("no primary zone%s that you can see contains %s", where, dot(name)) + } + return out, nil +} + +// acmeChange adds (set) or removes the challenge values for domain. +func acmeChange(o *acmeOptions, domain string, values []string, set bool) error { + name, err := o.challengeName(domain) + if err != nil { + return err + } + c, err := newClient() + if err != nil { + return err + } + zones, err := acmeZones(c, name, o.view) + if err != nil { + return err + } + var reqs []api.ChangeRequest + for _, z := range zones { + base := map[string]any{"zone": z.Name, "name": name, "type": "TXT"} + if z.View != "" { + base["view"] = z.View + } + with := func(extra map[string]any) map[string]any { + m := map[string]any{} + for k, v := range base { + m[k] = v + } + for k, v := range extra { + m[k] = v + } + return m + } + if !set && len(values) == 0 { + reqs = append(reqs, api.ChangeRequest{Op: "record.delete", Data: rawJSON(with(map[string]any{"missing_ok": true}))}) + continue + } + for _, v := range values { + // Deleting first makes a repeated 'set' (a retried hook) + // succeed instead of failing on a duplicate record. + reqs = append(reqs, api.ChangeRequest{Op: "record.delete", Data: rawJSON(with(map[string]any{"data": v, "missing_ok": true}))}) + if set { + reqs = append(reqs, api.ChangeRequest{Op: "record.create", Data: rawJSON(with(map[string]any{"data": v, "ttl": o.ttl, "comment": "ACME DNS-01 challenge"}))}) + } + } + } + verb := "remove" + if set { + verb = "set" + } + return submitBatch(c, fmt.Sprintf("ACME: %s challenge for %s", verb, dot(name)), reqs) +} + +// envOrError returns an environment variable that a hook needs. +func envOrError(name string) (string, error) { + v := os.Getenv(name) + if v == "" { + return "", fmt.Errorf("%s is not set (run this command as a certbot hook)", name) + } + return v, nil +} + +func acmeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "acme", + Short: "Publish and remove ACME DNS-01 challenge records (Let's Encrypt)", + Long: `Publishes and removes the _acme-challenge TXT records that ACME certificate +authorities such as Let's Encrypt check for DNS-01 challenges. When the +command returns, named is already serving the record: Jarl reloads the zone +and checks its serial before it reports success. + +The zone is found automatically (the longest matching primary zone). If the +zone exists in several views, the record goes into each of them, so it is +seen both from inside and from the internet. + +Give the hook its own token that can only touch challenge records: + + jarl user create certbot --no-password --role acme + jarl token create certbot --user certbot --role acme + +certbot: + + certbot certonly --manual --preferred-challenges dns \ + --manual-auth-hook 'jarl acme certbot-auth' \ + --manual-cleanup-hook 'jarl acme certbot-cleanup' \ + -d adbraun.lol -d '*.adbraun.lol' + +acme.sh: copy contrib/acme.sh/dns_jarl.sh into ~/.acme.sh/dnsapi/ and use +'--dns dns_jarl' (see docs/acme.md).`, + } + o := &acmeOptions{} + set := &cobra.Command{ + Use: "set DOMAIN VALUE...", + Short: "Publish challenge values for DOMAIN (at _acme-challenge.DOMAIN)", + Example: ` jarl acme set www.adbraun.lol gfj9Xq...Rg85nM + jarl acme set '*.adbraun.lol' value-for-wildcard value-for-apex`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return acmeChange(o, args[0], args[1:], true) + }, + } + o.flags(set) + set.Flags().Uint32Var(&o.ttl, "ttl", 60, "TTL of the challenge record") + unset := &cobra.Command{ + Use: "unset DOMAIN [VALUE...]", + Short: "Remove challenge values for DOMAIN (all of them when no VALUE is given)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return acmeChange(o, args[0], args[1:], false) + }, + } + o.flags(unset) + auth := &cobra.Command{ + Use: "certbot-auth", + Short: "certbot --manual-auth-hook: publish $CERTBOT_VALIDATION for $CERTBOT_DOMAIN", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + domain, err := envOrError("CERTBOT_DOMAIN") + if err != nil { + return err + } + value, err := envOrError("CERTBOT_VALIDATION") + if err != nil { + return err + } + return acmeChange(o, domain, []string{value}, true) + }, + } + o.flags(auth) + auth.Flags().Uint32Var(&o.ttl, "ttl", 60, "TTL of the challenge record") + cleanup := &cobra.Command{ + Use: "certbot-cleanup", + Short: "certbot --manual-cleanup-hook: remove $CERTBOT_VALIDATION for $CERTBOT_DOMAIN", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + domain, err := envOrError("CERTBOT_DOMAIN") + if err != nil { + return err + } + value, err := envOrError("CERTBOT_VALIDATION") + if err != nil { + return err + } + return acmeChange(o, domain, []string{value}, false) + }, + } + o.flags(cleanup) + cmd.AddCommand(set, unset, auth, cleanup) + return cmd +} diff --git a/cmd/jarl/admin_cmds.go b/cmd/jarl/admin_cmds.go new file mode 100644 index 0000000..a25514d --- /dev/null +++ b/cmd/jarl/admin_cmds.go @@ -0,0 +1,853 @@ +package main + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "jarl/internal/api" + "jarl/internal/client" +) + +// randomSecret returns a base64 TSIG secret as long as the algorithm's +// hash output. +func randomSecret(alg string) string { + n := map[string]int{"hmac-sha512": 64, "hmac-sha384": 48, "hmac-sha224": 28, "hmac-sha1": 20, "hmac-md5": 16}[strings.ToLower(alg)] + if n == 0 { + n = 32 + } + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.StdEncoding.EncodeToString(b) +} + +// readSecret reads a password or token from the terminal without echo, or +// a line from standard input when it is not a terminal. +func readSecret(prompt string) (string, error) { + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + fmt.Fprint(os.Stderr, prompt) + b, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + return strings.TrimSpace(string(b)), err + } + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimSpace(line), nil +} + +// readNewPassword asks for a password twice on a terminal. +func readNewPassword() (string, error) { + pw, err := readSecret("New password: ") + if err != nil { + return "", err + } + if term.IsTerminal(int(os.Stdin.Fd())) { + again, err := readSecret("Repeat it: ") + if err != nil { + return "", err + } + if again != pw { + return "", fmt.Errorf("the passwords don't match") + } + } + if pw == "" { + return "", fmt.Errorf("empty password") + } + return pw, nil +} + +func fmtTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "-" + } + return t.Local().Format("2006-01-02 15:04") +} + +// ---- status, whoami, check, resync + +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the state of Jarl and named", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var resp api.StatusResponse + if err := c.Get(ctx(), "/status", nil, &resp); err != nil { + return err + } + var st struct { + Adopted bool `json:"adopted"` + AdoptError string `json:"adopt_error"` + NamedError string `json:"named_error"` + BINDVersion string `json:"bind_version"` + Named *struct { + Version string `json:"version"` + BootTime string `json:"boot_time"` + LastConfigured string `json:"last_configured"` + Zones string `json:"zones"` + Running bool `json:"running"` + } `json:"named"` + Drift []struct { + Path string `json:"path"` + Reason string `json:"reason"` + } `json:"drift"` + Deployed bool `json:"deployed"` + LastApplied *api.Changeset `json:"last_applied"` + OpenChangesets int `json:"open_changesets"` + Problems []client.Problem `json:"problems"` + Counts map[string]int `json:"counts"` + MainConfig string `json:"main_config"` + } + if err := json.Unmarshal(resp.Engine, &st); err != nil { + return err + } + return output(resp, func(w io.Writer) { + fmt.Fprintf(w, "Jarl:\t%s\n", resp.Version) + switch { + case st.Named != nil && st.Named.Running: + fmt.Fprintf(w, "named:\trunning (%s), up since %s, configured %s\n", firstNonEmpty(st.Named.Version, st.BINDVersion), st.Named.BootTime, st.Named.LastConfigured) + case st.NamedError != "": + fmt.Fprintf(w, "named:\tNOT REACHABLE: %s\n", st.NamedError) + default: + fmt.Fprintf(w, "named:\tnot checked (reloads disabled)\n") + } + adopted := "yes" + if !st.Adopted { + adopted = "NO: named does not load Jarl's configuration yet (run 'jarld adopt')" + if st.AdoptError != "" { + adopted += ": " + st.AdoptError + } + } + if st.MainConfig != "" { + fmt.Fprintf(w, "Adopted:\t%s\n", adopted) + } + if st.Counts != nil { + fmt.Fprintf(w, "Objects:\t%d zones, %d records, %d views, %d ACLs, %d TSIG keys\n", st.Counts["zones"], st.Counts["records"], st.Counts["views"], st.Counts["acls"], st.Counts["tsig_keys"]) + } + if st.LastApplied != nil { + fmt.Fprintf(w, "Last change:\t#%d %s by %s (%s)\n", st.LastApplied.ID, fmtTime(st.LastApplied.AppliedAt), st.LastApplied.AppliedBy, st.LastApplied.Description) + } + fmt.Fprintf(w, "Open changesets:\t%d\n", st.OpenChangesets) + if len(st.Drift) == 0 { + fmt.Fprintf(w, "Drift:\tnone\n") + } + for _, d := range st.Drift { + fmt.Fprintf(w, "Drift:\t%s %s (fix with 'jarl resync --force' or apply with --force)\n", d.Path, d.Reason) + } + if len(st.Problems) > 0 { + w.(interface{ Flush() error }).Flush() + fmt.Println("Problems:") + printProblems(os.Stdout, st.Problems) + } + }) + }, + } +} + +func whoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show who the token belongs to and what it may do", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var me api.Whoami + if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil { + return err + } + return output(me, func(w io.Writer) { + fmt.Fprintf(w, "User:\t%s\n", me.User.Username) + if me.Token != nil { + fmt.Fprintf(w, "Token:\t%s (%s), expires %s\n", me.Token.Name, me.Token.PublicID, fmtTime(me.Token.ExpiresAt)) + } + fmt.Fprintf(w, "Server:\t%s\n", me.Version) + for i, p := range me.Permissions { + label := "" + if i == 0 { + label = "Permissions:" + } + fmt.Fprintf(w, "%s\t%s\n", label, p) + } + }) + }, + } +} + +// printResult prints the outcome of a check or resync. +func printResult(r *client.Result, ok string) error { + if r == nil { + return fmt.Errorf("empty response") + } + if flagOutput != "table" { + return output(r, nil) + } + if r.Error != nil { + fmt.Println("error:", r.Error.Message) + } + printProblems(os.Stdout, r.Problems) + for _, m := range r.Check { + obj := "" + if m.Object != "" { + obj = " [" + m.Object + "]" + } + fmt.Printf(" named-checkconf: %s%s\n", m.Text, obj) + } + for _, w := range r.Warnings { + fmt.Println("warning:", w) + } + if r.Diff != "" { + fmt.Print(r.Diff) + } + if !r.Valid || r.Error != nil { + return fmt.Errorf("the configuration has errors") + } + if !flagQuiet { + fmt.Println(ok) + } + return nil +} + +func checkCmd() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Validate the current configuration and run named-checkconf on it", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var raw json.RawMessage + if err := c.Do(ctx(), "POST", "/system/check", nil, nil, &raw); err != nil { + return err + } + return printResult(client.ParseResult(raw), "ok: the configuration is valid") + }, + } +} + +func resyncCmd() *cobra.Command { + return &cobra.Command{ + Use: "resync", + Short: "Rewrite all BIND files from Jarl's database and reload named (admin)", + Long: `Rewrites every file Jarl manages from the database and reloads named. Use +it after files were changed by hand (drift) or restored from a backup. +Files changed outside Jarl are only overwritten with --force; --dry-run +shows what would change.`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var raw json.RawMessage + err = c.Do(ctx(), "POST", "/system/resync", nil, map[string]bool{"dry_run": flagDryRun, "force": flagForce}, &raw) + if err != nil { + return err + } + msg := "resynced" + if flagDryRun { + msg = "dry run: nothing was changed" + } + return printResult(client.ParseResult(raw), msg) + }, + } +} + +// ---- users and tokens + +// userID resolves a username (or numeric ID) to a user ID. +func userID(c *client.Client, name string) (int64, error) { + if id, err := strconv.ParseInt(name, 10, 64); err == nil { + return id, nil + } + var users []api.User + if err := c.Get(ctx(), "/users", nil, &users); err != nil { + return 0, err + } + for _, u := range users { + if strings.EqualFold(u.Username, name) { + return u.ID, nil + } + } + return 0, fmt.Errorf("no user %q", name) +} + +func printUser(w io.Writer, u api.User) { + fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID) + role := "user" + if u.Admin { + role = "administrator" + } + if u.Disabled { + role += ", DISABLED" + } + if !u.HasPassword { + role += ", no password (tokens only)" + } + fmt.Fprintf(w, "Role:\t%s\n", role) + for i, g := range u.GrantStrings { + label := "" + if i == 0 { + label = "Grants:" + } + fmt.Fprintf(w, "%s\t%s\n", label, g) + } + if u.Comment != "" { + fmt.Fprintf(w, "Comment:\t%s\n", u.Comment) + } + fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt)) +} + +const grantHelp = `Grants use the form RESOURCE:ACCESS [zones=..] [views=..] [names=..] [types=..] +where RESOURCE is zones, records, views, acls, tsig, options, audit or *, +ACCESS is read or write, and filters take comma separated glob patterns: + + records:write zones=adbraun.lol + records:write zones=adbraun.lol names=_acme-challenge.* types=TXT + *:read + +Roles are ready-made grant sets: readonly, dns-editor, operator, acme.` + +func userCmd() *cobra.Command { + cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp} + list := &cobra.Command{ + Use: "list", + Short: "List users", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var users []api.User + if err := c.Get(ctx(), "/users", nil, &users); err != nil { + return err + } + return output(users, func(w io.Writer) { + fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\tGRANTS\tLAST LOGIN") + for _, u := range users { + fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt)) + } + }) + }, + } + show := &cobra.Command{ + Use: "show USER", + Short: "Show a user", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id, err := userID(c, args[0]) + if err != nil { + return err + } + var u api.User + if err := c.Get(ctx(), fmt.Sprintf("/users/%d", id), nil, &u); err != nil { + return err + } + return output(u, func(w io.Writer) { printUser(w, u) }) + }, + } + var admin, noPassword, disabled bool + var role, comment, rename string + var grants []string + userBody := func(cmd *cobra.Command) (api.UserInput, error) { + var in api.UserInput + f := cmd.Flags() + if f.Changed("admin") { + in.Admin = &admin + } + if f.Changed("role") { + in.Role = &role + } + if f.Changed("grant") { + in.GrantStrings = &grants + } + if f.Changed("comment") { + in.Comment = &comment + } + if f.Changed("disabled") { + in.Disabled = &disabled + } + if f.Changed("rename") { + in.Username = &rename + } + return in, nil + } + addUserFlags := func(c *cobra.Command) { + c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)") + c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme") + c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`) + c.Flags().StringVar(&comment, "comment", "", "comment") + } + create := &cobra.Command{ + Use: "create NAME", + Short: "Create a user (asks for a password unless --no-password)", + Example: ` jarl user create alice --role dns-editor + jarl user create certbot --no-password --role acme --comment "Let's Encrypt" + jarl user create bob --grant "records:write zones=lab.adbraun.lol" --grant "zones:read"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + in, err := userBody(cmd) + if err != nil { + return err + } + name := args[0] + in.Username = &name + pw := "" + if !noPassword { + if pw, err = readNewPassword(); err != nil { + return err + } + } + in.Password = &pw + var u api.User + if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil { + return err + } + return output(u, func(w io.Writer) { printUser(w, u) }) + }, + } + addUserFlags(create) + create.Flags().BoolVar(&noPassword, "no-password", false, "a service account that can only use API tokens") + var setPassword, clearPassword bool + update := &cobra.Command{ + Use: "update USER", + Short: "Change a user", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id, err := userID(c, args[0]) + if err != nil { + return err + } + in, err := userBody(cmd) + if err != nil { + return err + } + switch { + case setPassword: + pw, err := readNewPassword() + if err != nil { + return err + } + in.Password = &pw + case clearPassword: + empty := "" + in.Password = &empty + } + if in == (api.UserInput{}) { + return fmt.Errorf("nothing to change (see --help)") + } + var u api.User + if err := c.Do(ctx(), "PATCH", fmt.Sprintf("/users/%d", id), nil, in, &u); err != nil { + return err + } + return output(u, func(w io.Writer) { printUser(w, u) }) + }, + } + addUserFlags(update) + update.Flags().BoolVar(&disabled, "disabled", false, "disable the account (--disabled=false to enable it)") + update.Flags().StringVar(&rename, "rename", "", "new username") + update.Flags().BoolVar(&setPassword, "password", false, "set a new password (asks for it)") + update.Flags().BoolVar(&clearPassword, "no-password", false, "remove the password (tokens only)") + var yes bool + del := &cobra.Command{ + Use: "delete USER", + Short: "Delete a user and revoke their tokens", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !confirm("Delete user "+args[0]+"?", yes) { + return fmt.Errorf("canceled") + } + c, err := newClient() + if err != nil { + return err + } + id, err := userID(c, args[0]) + if err != nil { + return err + } + if err := c.Do(ctx(), "DELETE", fmt.Sprintf("/users/%d", id), nil, nil, nil); err != nil { + return err + } + if !flagQuiet { + fmt.Println("deleted user", args[0]) + } + return nil + }, + } + del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") + cmd.AddCommand(list, show, create, update, del) + return cmd +} + +func tokenCmd() *cobra.Command { + cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "Manage API tokens", Long: "Manage API tokens. A token has its user's permissions, or fewer when it has\nits own grants.\n\n" + grantHelp} + var user string + var all, revoked bool + list := &cobra.Command{ + Use: "list", + Short: "List your tokens (--user or --all for others; admin)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path := "/me/tokens" + switch { + case all: + path = "/tokens" + case user != "": + id, err := userID(c, user) + if err != nil { + return err + } + path = fmt.Sprintf("/users/%d/tokens", id) + } + qv := q() + if revoked { + qv.Set("all", "1") + } + var tokens []api.Token + if err := c.Get(ctx(), path, qv, &tokens); err != nil { + return err + } + return output(tokens, func(w io.Writer) { + fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tLAST USED\tREVOKED") + for _, t := range tokens { + g := "(user's)" + if t.Grants != nil { + g = strings.Join(t.GrantStrings, "; ") + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtTime(t.ExpiresAt), fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt)) + } + }) + }, + } + list.Flags().StringVar(&user, "user", "", "tokens of this user") + list.Flags().BoolVar(&all, "all", false, "tokens of every user") + list.Flags().BoolVar(&revoked, "revoked", false, "include revoked tokens") + var role, expires string + var grants []string + var save bool + create := &cobra.Command{ + Use: "create NAME", + Short: "Create a token; the secret is printed once", + Example: ` jarl token create laptop + jarl token create certbot --user certbot --role acme --expires 365d + jarl token create lab-ci --grant "records:write zones=lab.adbraun.lol"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + in := api.TokenInput{Name: args[0], Role: role, ExpiresIn: expires} + if cmd.Flags().Changed("grant") { + in.GrantStrings = &grants + } + path := "/me/tokens" + if user != "" { + id, err := userID(c, user) + if err != nil { + return err + } + path = fmt.Sprintf("/users/%d/tokens", id) + } + var t api.Token + if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil { + return err + } + if save { + cfg, err := loadCLIConfig() + if err != nil { + return err + } + cfg.Token = t.Secret + if err := saveCLIConfig(cfg); err != nil { + return err + } + } + if flagQuiet { + fmt.Println(t.Secret) + return nil + } + return output(t, func(w io.Writer) { + fmt.Fprintf(w, "Token:\t%s\n", t.Secret) + fmt.Fprintf(w, "Name:\t%s (id %d, user %s)\n", t.Name, t.ID, t.Username) + if t.Grants != nil { + fmt.Fprintf(w, "Grants:\t%s\n", strings.Join(t.GrantStrings, "; ")) + } + fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt)) + if save { + fmt.Fprintf(w, "Saved to:\t%s\n", flagConfig) + } else { + fmt.Fprintln(w, "\nThe token is shown only once; store it now.") + } + }) + }, + } + create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)") + create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme") + create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)") + create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)") + create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file") + revoke := &cobra.Command{ + Use: "revoke ID", + Short: "Revoke a token (see 'token list' for IDs)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + if _, err := strconv.ParseInt(args[0], 10, 64); err != nil { + return fmt.Errorf("give the token's numeric ID (see 'jarl token list')") + } + if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil { + return err + } + if !flagQuiet { + fmt.Println("revoked token", args[0]) + } + return nil + }, + } + cmd.AddCommand(list, create, revoke) + return cmd +} + +// ---- audit and search + +func auditCmd() *cobra.Command { + var user, action, outcome, objType, since, until, search string + var changeset int64 + var limit int + cmd := &cobra.Command{ + Use: "audit", + Short: "Show the audit log (logins, changes, users and tokens)", + Long: `Shows the audit log, newest first. Without the audit permission you only +see your own activity. + + jarl audit --since 2026-10-01 --outcome failure + jarl audit --user certbot --limit 20 + jarl audit --action login`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + qv := q("user", user, "action", action, "outcome", outcome, "object_type", objType, "since", since, "until", until, "q", search, "limit", strconv.Itoa(limit)) + if changeset > 0 { + qv.Set("changeset", strconv.FormatInt(changeset, 10)) + } + var events []struct { + ID int64 `json:"id"` + Time time.Time `json:"time"` + Username string `json:"username"` + Auth string `json:"auth"` + TokenName string `json:"token_name"` + IP string `json:"ip"` + Action string `json:"action"` + ObjectType string `json:"object_type"` + ObjectName string `json:"object_name"` + ChangesetID int64 `json:"changeset_id"` + Outcome string `json:"outcome"` + Message string `json:"message"` + } + if err := c.Get(ctx(), "/audit", qv, &events); err != nil { + return err + } + return output(events, func(w io.Writer) { + fmt.Fprintln(w, "TIME\tUSER\tVIA\tIP\tACTION\tOBJECT\tOUTCOME\tMESSAGE") + for _, e := range events { + via := e.Auth + if e.TokenName != "" { + via += ":" + e.TokenName + } + obj := e.ObjectName + if e.ChangesetID > 0 { + obj = strings.TrimSpace(fmt.Sprintf("%s (changeset %d)", obj, e.ChangesetID)) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), e.Username, via, e.IP, e.Action, obj, e.Outcome, e.Message) + } + }) + }, + } + f := cmd.Flags() + f.StringVar(&user, "user", "", "only this user") + f.StringVar(&action, "action", "", "only this action (e.g. login, changeset.apply, token.create)") + f.StringVar(&outcome, "outcome", "", "success, failure or denied") + f.StringVar(&objType, "object-type", "", "only events about this kind of object (zone, user, token, ...)") + f.StringVar(&since, "since", "", "from this time (YYYY-MM-DD or RFC 3339)") + f.StringVar(&until, "until", "", "up to this time") + f.StringVar(&search, "search", "", "only events whose text contains this") + f.Int64Var(&changeset, "changeset", 0, "only events of this changeset") + f.IntVar(&limit, "limit", 50, "how many") + return cmd +} + +func searchCmd() *cobra.Command { + var limit int + cmd := &cobra.Command{ + Use: "search TEXT", + Short: "Find zones, records and ACLs by name, data or comment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var hits []api.SearchResult + if err := c.Get(ctx(), "/search", q("q", args[0], "limit", strconv.Itoa(limit)), &hits); err != nil { + return err + } + return output(hits, func(w io.Writer) { + fmt.Fprintln(w, "KIND\tID\tMATCH\tWHERE") + for _, h := range hits { + fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", h.Kind, h.ID, h.Label, h.Detail) + } + }) + }, + } + cmd.Flags().IntVar(&limit, "limit", 100, "maximum number of results") + return cmd +} + +// ---- CLI configuration + +func configCmd() *cobra.Command { + cmd := &cobra.Command{Use: "config", Short: "Show or change the CLI's own configuration file"} + keys := "server, token, insecure, ca-cert" + set := &cobra.Command{ + Use: "set KEY [VALUE]", + Short: "Set " + keys + " (the token is read from the terminal when VALUE is left out)", + Example: ` jarl config set server https://ns1.adbraun.lol:8053 + jarl config set token # paste it at the prompt, keeping it out of shell history`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCLIConfig() + if err != nil { + return err + } + value := "" + if len(args) == 2 { + value = args[1] + } else if args[0] == "token" { + if value, err = readSecret("API token: "); err != nil { + return err + } + } else { + return fmt.Errorf("give a value for %s", args[0]) + } + switch args[0] { + case "server": + u, err := url.Parse(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("invalid server URL %q (use e.g. https://ns1.example.com:8053)", value) + } + cfg.Server = strings.TrimSuffix(value, "/") + case "token": + if !strings.HasPrefix(value, "jarl_") && !strings.HasPrefix(value, "@") { + return fmt.Errorf("that doesn't look like a Jarl token (they start with jarl_)") + } + cfg.Token = value + case "insecure": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("insecure must be true or false") + } + cfg.Insecure = b + case "ca-cert", "ca_cert": + cfg.CACert = value + default: + return fmt.Errorf("unknown key %q (use %s)", args[0], keys) + } + if err := saveCLIConfig(cfg); err != nil { + return err + } + if !flagQuiet { + fmt.Printf("saved %s in %s\n", args[0], flagConfig) + } + return nil + }, + } + show := &cobra.Command{ + Use: "show", + Short: "Show the configuration (the token is masked)", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCLIConfig() + if err != nil { + return err + } + tok := cfg.Token + if len(tok) > 14 && !strings.HasPrefix(tok, "@") { + tok = tok[:14] + "..." + } + fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca-cert: %s\n", flagConfig, cfg.Server, tok, cfg.Insecure, cfg.CACert) + if v := os.Getenv("JARL_SERVER"); v != "" { + fmt.Printf("(JARL_SERVER overrides the server: %s)\n", v) + } + if os.Getenv("JARL_TOKEN") != "" { + fmt.Println("(JARL_TOKEN overrides the token)") + } + return nil + }, + } + unset := &cobra.Command{ + Use: "unset KEY", + Short: "Remove a setting", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCLIConfig() + if err != nil { + return err + } + switch args[0] { + case "server": + cfg.Server = "" + case "token": + cfg.Token = "" + case "insecure": + cfg.Insecure = false + case "ca-cert", "ca_cert": + cfg.CACert = "" + default: + return fmt.Errorf("unknown key %q (use %s)", args[0], keys) + } + return saveCLIConfig(cfg) + }, + } + cmd.AddCommand(set, show, unset) + return cmd +} diff --git a/cmd/jarl/changes.go b/cmd/jarl/changes.go new file mode 100644 index 0000000..8f30321 --- /dev/null +++ b/cmd/jarl/changes.go @@ -0,0 +1,408 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + "go.yaml.in/yaml/v3" + + "jarl/internal/api" + "jarl/internal/client" +) + +// batchFile is the format of 'jarl apply -f'. +type batchFile struct { + Description string `yaml:"description" json:"description"` + Changes []batchEntry `yaml:"changes" json:"changes"` +} + +type batchEntry struct { + Add map[string]any `yaml:"add,omitempty" json:"add,omitempty"` + Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"` + Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"` + Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"` + // Any API operation: op, id, data. + Op string `yaml:"op,omitempty" json:"op,omitempty"` + ID int64 `yaml:"id,omitempty" json:"id,omitempty"` + Data map[string]any `yaml:"data,omitempty" json:"data,omitempty"` +} + +func toInt64(v any) (int64, bool) { + switch n := v.(type) { + case int: + return int64(n), true + case int64: + return n, true + case float64: + return int64(n), true + case string: + i, err := strconv.ParseInt(n, 10, 64) + return i, err == nil + } + return 0, false +} + +func rawJSON(v any) json.RawMessage { + b, _ := json.Marshal(v) + return b +} + +// requests converts the file's entries into API change requests. +func (b *batchFile) requests() ([]api.ChangeRequest, error) { + var out []api.ChangeRequest + for i, e := range b.Changes { + n := 0 + for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Op != ""} { + if set { + n++ + } + } + if n != 1 { + return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update or op", i+1) + } + switch { + case e.Add != nil: + out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)}) + case e.Delete != nil: + d := e.Delete + if id, ok := toInt64(d["id"]); ok { + delete(d, "id") + out = append(out, api.ChangeRequest{Op: "record.delete", ID: id, Data: rawJSON(d)}) + } else { + out = append(out, api.ChangeRequest{Op: "record.delete", Data: rawJSON(d)}) + } + case e.Set != nil: + s := e.Set + switch d := s["data"].(type) { + case string: + s["data"] = []string{d} + case nil: + s["data"] = []string{} + } + out = append(out, api.ChangeRequest{Op: "rrset.replace", Data: rawJSON(s)}) + case e.Update != nil: + u := e.Update + id, ok := toInt64(u["id"]) + if !ok { + return nil, fmt.Errorf("change %d: update needs the record id", i+1) + } + delete(u, "id") + out = append(out, api.ChangeRequest{Op: "record.update", ID: id, Data: rawJSON(u)}) + default: + var data json.RawMessage + if e.Data != nil { + data = rawJSON(e.Data) + } + out = append(out, api.ChangeRequest{Op: e.Op, ID: e.ID, Data: data}) + } + } + return out, nil +} + +// submitBatch applies requests as one changeset, or stages them into the +// changeset given with --changeset. +func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error { + var resp api.MutationResponse + if c.Changeset > 0 { + if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil { + return err + } + return report(&resp) + } + if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil { + return err + } + return report(&resp) +} + +func applyCmd() *cobra.Command { + var file, desc string + cmd := &cobra.Command{ + Use: "apply -f FILE", + Short: "Apply a batch of changes from a YAML or JSON file, all or nothing", + Long: `Applies the changes in FILE ('-' for standard input) as one changeset: they +are validated together and either all are applied or none. + + description: move the web server + changes: + - add: {zone: example.com, name: www2, type: A, data: 192.0.2.20, ttl: 300, ptr: true} + - delete: {zone: example.com, name: www, type: A} # every A record of www + - delete: {zone: example.com, name: old, type: CNAME, data: www} # just this record + - delete: {id: 1234} + - set: {zone: example.com, name: api, type: A, data: [192.0.2.30, 192.0.2.31], ttl: 60} + - update: {id: 1235, data: 192.0.2.40} + - op: zone.create # any API operation + data: {name: lab.example.com, nameservers: [ns1.example.com.]} + +Records name zones by name (add "view: NAME" when the zone exists in several +views). With --changeset the changes are staged instead; with --dry-run they +are only validated.`, + RunE: func(cmd *cobra.Command, args []string) error { + if file == "" { + return fmt.Errorf("-f FILE is required") + } + var data []byte + var err error + if file == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(file) + } + if err != nil { + return err + } + var b batchFile + if err := yaml.Unmarshal(data, &b); err != nil { + return fmt.Errorf("%s: %w", file, err) + } + if desc != "" { + b.Description = desc + } + reqs, err := b.requests() + if err != nil { + return err + } + if len(reqs) == 0 { + return fmt.Errorf("%s has no changes", file) + } + c, err := newClient() + if err != nil { + return err + } + return submitBatch(c, b.Description, reqs) + }, + } + cmd.Flags().StringVarP(&file, "file", "f", "", "file with the changes ('-' for standard input)") + cmd.Flags().StringVar(&desc, "description", "", "description for the history") + return cmd +} + +func changesetCmd() *cobra.Command { + cmd := &cobra.Command{Use: "changeset", Aliases: []string{"cs"}, Short: "Stage changes and apply them together"} + var status string + var mine bool + list := &cobra.Command{ + Use: "list", + Short: "List changesets (open ones by default)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + qv := q("status", status) + if mine { + qv.Set("mine", "1") + } + var list []api.Changeset + if err := c.Get(ctx(), "/changesets", qv, &list); err != nil { + return err + } + return output(list, func(w io.Writer) { + fmt.Fprintln(w, "ID\tSTATUS\tUSER\tVIA\tOPS\tDESCRIPTION") + for _, cs := range list { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.Description) + } + }) + }, + } + list.Flags().StringVar(&status, "status", "open", "open, applied or discarded (empty for all)") + list.Flags().BoolVar(&mine, "mine", false, "only my changesets") + create := &cobra.Command{ + Use: "create [DESCRIPTION]", + Short: "Start a changeset; prints its ID", + Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q) + jarl record add example.com www2 A 192.0.2.20 + jarl record delete example.com www A + jarl changeset diff $JARL_CHANGESET + jarl changeset apply $JARL_CHANGESET`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var cs api.Changeset + if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": strings.Join(args, " ")}, &cs); err != nil { + return err + } + if flagQuiet { + fmt.Println(cs.ID) + return nil + } + fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d)\n", cs.ID, cs.ID, cs.ID) + return nil + }, + } + show := &cobra.Command{ + Use: "show ID", + Short: "Show a changeset's operations", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var cs api.Changeset + if err := c.Get(ctx(), "/changesets/"+args[0], nil, &cs); err != nil { + return err + } + return output(cs, func(w io.Writer) { + fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via) + if cs.AppliedAt != nil { + fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy) + } + fmt.Fprintln(w, "\n#\tOPERATION") + for _, op := range cs.Ops { + fmt.Fprintf(w, "%d\t%s\n", op.Seq, op.Summary) + } + if cs.Diff != "" { + w.(interface{ Flush() error }).Flush() + fmt.Println() + fmt.Print(cs.Diff) + } + }) + }, + } + diff := &cobra.Command{ + Use: "diff ID", + Short: "Validate an open changeset and show what applying it would change", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var raw json.RawMessage + if err := c.Get(ctx(), "/changesets/"+args[0]+"/preview", nil, &raw); err != nil { + return err + } + if flagOutput != "table" { + var v any + json.Unmarshal(raw, &v) + return output(v, nil) + } + r := client.ParseResult(raw) + for _, op := range r.Ops { + fmt.Printf("#%d %s\n", op.Seq, op.Summary) + } + if r.Error != nil { + fmt.Println("error:", r.Error.Message) + } + printProblems(os.Stdout, r.Problems) + printCheck(os.Stdout, r.Check) + for _, w := range r.Warnings { + fmt.Println("warning:", w) + } + fmt.Println() + fmt.Print(r.Diff) + if r.Valid { + fmt.Println("\nvalid: apply with 'jarl changeset apply " + args[0] + "'") + return nil + } + return fmt.Errorf("the changeset can't be applied as it is") + }, + } + apply := &cobra.Command{ + Use: "apply ID", + Short: "Apply an open changeset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var resp api.MutationResponse + if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil { + return err + } + return report(&resp) + }, + } + discard := &cobra.Command{ + Use: "discard ID", + Short: "Throw away an open changeset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/discard", nil, nil, nil); err != nil { + return err + } + if !flagQuiet { + fmt.Println("discarded changeset", args[0]) + } + return nil + }, + } + remove := &cobra.Command{ + Use: "remove ID SEQ", + Short: "Remove one operation from an open changeset", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + return c.Do(ctx(), "DELETE", "/changesets/"+args[0]+"/ops/"+args[1], nil, nil, nil) + }, + } + revert := &cobra.Command{ + Use: "revert ID", + Short: "Create a changeset that undoes an applied changeset (review it, then apply)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var cs api.Changeset + if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/revert", nil, nil, &cs); err != nil { + return err + } + if flagQuiet { + fmt.Println(cs.ID) + return nil + } + fmt.Printf("created changeset %d with %d operations; review with 'jarl changeset diff %d', apply with 'jarl changeset apply %d'\n", cs.ID, len(cs.Ops), cs.ID, cs.ID) + return nil + }, + } + cmd.AddCommand(list, create, show, diff, apply, discard, remove, revert) + return cmd +} + +func historyCmd() *cobra.Command { + var limit int + cmd := &cobra.Command{ + Use: "history", + Short: "List applied changesets", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var list []api.Changeset + if err := c.Get(ctx(), "/changesets", q("status", "applied", "limit", fmt.Sprint(limit)), &list); err != nil { + return err + } + return output(list, func(w io.Writer) { + fmt.Fprintln(w, "ID\tAPPLIED\tBY\tVIA\tOPS\tDESCRIPTION") + for _, cs := range list { + at := "" + if cs.AppliedAt != nil { + at = cs.AppliedAt.Local().Format("2006-01-02 15:04") + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, at, cs.AppliedBy, cs.Via, len(cs.Ops), cs.Description) + } + }) + }, + } + cmd.Flags().IntVar(&limit, "limit", 30, "how many") + return cmd +} diff --git a/cmd/jarl/config_cmds.go b/cmd/jarl/config_cmds.go new file mode 100644 index 0000000..cf9bc2c --- /dev/null +++ b/cmd/jarl/config_cmds.go @@ -0,0 +1,735 @@ +package main + +import ( + "fmt" + "io" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "jarl/internal/api" + "jarl/internal/model" +) + +func viewCmd() *cobra.Command { + cmd := &cobra.Command{Use: "view", Aliases: []string{"views"}, Short: "Manage views"} + list := &cobra.Command{ + Use: "list", + Short: "List views in match order", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var views []api.View + if err := c.Get(ctx(), "/views", nil, &views); err != nil { + return err + } + return output(views, func(w io.Writer) { + fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES") + for i, v := range views { + fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones) + } + }) + }, + } + var clients, dests []string + var recursiveOnly, comment string + var adopt, cascade, yes bool + viewBody := func(cmd *cobra.Command) map[string]any { + body := map[string]any{} + f := cmd.Flags() + if f.Changed("match-clients") { + body["match_clients"] = clients + } + if f.Changed("match-destinations") { + body["match_destinations"] = dests + } + if f.Changed("recursive-only") { + switch recursiveOnly { + case "yes", "true": + body["match_recursive_only"] = true + case "no", "false": + body["match_recursive_only"] = false + default: + body["match_recursive_only"] = nil + } + } + if f.Changed("comment") { + body["comment"] = comment + } + return body + } + addViewFlags := func(c *cobra.Command) { + c.Flags().StringSliceVar(&clients, "match-clients", nil, "match-clients elements (comma separated or repeated)") + c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements") + c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default") + c.Flags().StringVar(&comment, "comment", "", "comment") + } + create := &cobra.Command{ + Use: "create NAME", + Short: "Create a view (added after the existing views)", + Example: ` jarl view create internal --match-clients 192.168.0.0/16,localhost --adopt-zones + jarl view create external --match-clients any`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := viewBody(cmd) + body["name"] = args[0] + if adopt { + body["adopt_zones"] = true + } + resp, err := c.Mutate(ctx(), "POST", "/views", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + addViewFlags(create) + create.Flags().BoolVar(&adopt, "adopt-zones", false, "move zones that are not in a view into this one (required for the first view if zones exist)") + var rename string + update := &cobra.Command{ + Use: "update VIEW", + Short: "Change a view", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := viewBody(cmd) + if rename != "" { + body["name"] = rename + } + resp, err := c.Mutate(ctx(), "PATCH", "/views/"+url.PathEscape(args[0]), nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + addViewFlags(update) + update.Flags().StringVar(&rename, "rename", "", "new name") + del := &cobra.Command{ + Use: "delete VIEW", + Short: "Delete a view", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if cascade && !confirm("Delete view "+args[0]+" with all its zones?", yes) { + return fmt.Errorf("canceled") + } + c, err := newClient() + if err != nil { + return err + } + qv := q() + if cascade { + qv.Set("cascade", "1") + } + resp, err := c.Mutate(ctx(), "DELETE", "/views/"+url.PathEscape(args[0]), qv, nil) + if err != nil { + return err + } + return report(resp) + }, + } + del.Flags().BoolVar(&cascade, "cascade", false, "also delete the view's zones") + del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") + order := &cobra.Command{ + Use: "order VIEW...", + Short: "Set the order in which views are matched (list every view)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var views []api.View + if err := c.Get(ctx(), "/views", nil, &views); err != nil { + return err + } + byName := map[string]int64{} + for _, v := range views { + byName[strings.ToLower(v.Name)] = v.ID + } + var ids []int64 + for _, a := range args { + id, ok := byName[strings.ToLower(a)] + if !ok { + return fmt.Errorf("no view %q", a) + } + ids = append(ids, id) + } + resp, err := c.Mutate(ctx(), "POST", "/views/order", nil, map[string]any{"ids": ids}) + if err != nil { + return err + } + return report(resp) + }, + } + cmd.AddCommand(list, create, update, del, order) + return cmd +} + +func orAny(list []string) string { + if len(list) == 0 { + return "any" + } + return strings.Join(list, "; ") +} + +func aclCmd() *cobra.Command { + cmd := &cobra.Command{Use: "acl", Aliases: []string{"acls"}, Short: "Manage ACLs (named address match lists)"} + list := &cobra.Command{ + Use: "list", + Short: "List ACLs", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var acls []api.ACL + if err := c.Get(ctx(), "/acls", nil, &acls); err != nil { + return err + } + return output(acls, func(w io.Writer) { + fmt.Fprintln(w, "ID\tACL\tELEMENTS\tUSED BY") + for _, a := range acls { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", a.ID, a.Name, strings.Join(a.Elements, "; "), strings.Join(a.UsedBy, ", ")) + } + }) + }, + } + var comment, rename string + var add, remove []string + create := &cobra.Command{ + Use: "create NAME ELEMENT...", + Short: "Create an ACL", + Example: ` jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{"name": args[0], "elements": args[1:], "comment": comment} + resp, err := c.Mutate(ctx(), "POST", "/acls", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + create.Flags().StringVar(&comment, "comment", "", "comment") + var set []string + update := &cobra.Command{ + Use: "update ACL", + Short: "Change an ACL (--add/--remove elements, --set all elements, --rename)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var cur api.ACL + if err := c.Get(ctx(), "/acls/"+url.PathEscape(args[0]), nil, &cur); err != nil { + return err + } + body := map[string]any{} + elems := cur.Elements + if cmd.Flags().Changed("set") { + elems = set + } + for _, a := range add { + elems = append(elems, a) + } + if len(remove) > 0 { + drop := map[string]bool{} + for _, r := range remove { + drop[r] = true + } + var kept []string + for _, e := range elems { + if !drop[e] { + kept = append(kept, e) + } + } + elems = kept + } + if elems == nil { + elems = []string{} + } + if cmd.Flags().Changed("set") || len(add) > 0 || len(remove) > 0 { + body["elements"] = elems + } + if rename != "" { + body["name"] = rename + } + if cmd.Flags().Changed("comment") { + body["comment"] = comment + } + if len(body) == 0 { + return fmt.Errorf("nothing to change (see --help)") + } + resp, err := c.Mutate(ctx(), "PATCH", "/acls/"+url.PathEscape(args[0]), nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + update.Flags().StringSliceVar(&add, "add", nil, "add elements") + update.Flags().StringSliceVar(&remove, "remove", nil, "remove elements (exact text)") + update.Flags().StringSliceVar(&set, "set", nil, "replace all elements") + update.Flags().StringVar(&rename, "rename", "", "new name (references are updated)") + update.Flags().StringVar(&comment, "comment", "", "comment") + del := &cobra.Command{ + Use: "delete ACL", + Short: "Delete an ACL (refused while it is in use)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + resp, err := c.Mutate(ctx(), "DELETE", "/acls/"+url.PathEscape(args[0]), nil, nil) + if err != nil { + return err + } + return report(resp) + }, + } + cmd.AddCommand(list, create, update, del) + return cmd +} + +func tsigCmd() *cobra.Command { + cmd := &cobra.Command{Use: "tsig", Aliases: []string{"key", "keys"}, Short: "Manage TSIG keys"} + list := &cobra.Command{ + Use: "list", + Short: "List TSIG keys", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var keys []api.TSIGKey + if err := c.Get(ctx(), "/tsig-keys", nil, &keys); err != nil { + return err + } + return output(keys, func(w io.Writer) { + fmt.Fprintln(w, "ID\tKEY\tALGORITHM\tUSED BY") + for _, k := range keys { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", k.ID, k.Name, k.Algorithm, strings.Join(k.UsedBy, ", ")) + } + }) + }, + } + var secret, algorithm, comment string + var named bool + show := &cobra.Command{ + Use: "show KEY", + Short: "Show a key and its secret", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var k api.TSIGKey + if err := c.Get(ctx(), "/tsig-keys/"+url.PathEscape(args[0]), q("secret", "1"), &k); err != nil { + return err + } + if named { + fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret) + return nil + } + return output(k, func(w io.Writer) { + fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\nUsed by:\t%s\n", k.Name, k.Algorithm, k.Secret, strings.Join(k.UsedBy, ", ")) + }) + }, + } + show.Flags().BoolVar(&named, "named-conf", false, "print a key statement for another server's named.conf") + create := &cobra.Command{ + Use: "create NAME", + Short: "Create a TSIG key (the secret is generated unless given)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{"name": args[0], "algorithm": algorithm, "comment": comment} + if secret != "" { + body["secret"] = secret + } + resp, err := c.Mutate(ctx(), "POST", "/tsig-keys", nil, body) + if err != nil { + return err + } + if err := report(resp); err != nil { + return err + } + if resp.Applied && flagOutput == "table" { + var k api.TSIGKey + if jsonUnmarshal(resp.Object, &k) == nil && k.Secret != "" { + fmt.Printf("secret: %s\n", k.Secret) + } + } + return nil + }, + } + create.Flags().StringVar(&algorithm, "algorithm", "hmac-sha256", "algorithm") + create.Flags().StringVar(&secret, "secret", "", "base64 secret (default: generated)") + create.Flags().StringVar(&comment, "comment", "", "comment") + rotate := &cobra.Command{ + Use: "rotate KEY", + Short: "Give a key a new random secret (or --secret)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + if secret == "" { + var k api.TSIGKey + if err := c.Get(ctx(), "/tsig-keys/"+url.PathEscape(args[0]), nil, &k); err != nil { + return err + } + secret = randomSecret(k.Algorithm) + } + resp, err := c.Mutate(ctx(), "PATCH", "/tsig-keys/"+url.PathEscape(args[0]), nil, map[string]any{"secret": secret}) + if err != nil { + return err + } + if err := report(resp); err != nil { + return err + } + fmt.Printf("new secret: %s\n", secret) + return nil + }, + } + rotate.Flags().StringVar(&secret, "secret", "", "the new secret (default: generated)") + del := &cobra.Command{ + Use: "delete KEY", + Short: "Delete a key (refused while it is in use)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + resp, err := c.Mutate(ctx(), "DELETE", "/tsig-keys/"+url.PathEscape(args[0]), nil, nil) + if err != nil { + return err + } + return report(resp) + }, + } + cmd.AddCommand(list, show, create, rotate, del) + return cmd +} + +func optionCmd() *cobra.Command { + cmd := &cobra.Command{Use: "option", Aliases: []string{"options", "opt"}, Short: "Manage named options (server, view, zone, top-level)"} + var view, zone, zoneView string + var toplevel bool + scopeArgs := func(c *cobra.Command) { + c.Flags().StringVar(&view, "view", "", "options of this view") + c.Flags().StringVar(&zone, "zone", "", "options of this zone") + c.Flags().StringVar(&zoneView, "zone-view", "", "view of --zone") + c.Flags().BoolVar(&toplevel, "toplevel", false, "top-level statements (logging, controls, ...)") + } + scope := func() (string, int64, error) { + c, err := newClient() + if err != nil { + return "", 0, err + } + switch { + case zone != "": + id, err := resolveZoneID(c, zone, zoneView) + return model.ScopeZone, id, err + case view != "": + var v api.View + if err := c.Get(ctx(), "/views/"+url.PathEscape(view), nil, &v); err != nil { + return "", 0, err + } + return model.ScopeView, v.ID, nil + case toplevel: + return model.ScopeTopLevel, 0, nil + } + return model.ScopeOptions, 0, nil + } + list := &cobra.Command{ + Use: "list", + Short: "List options (server options by default)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + qv := q() + if view != "" || zone != "" || toplevel || !cmd.Flags().Changed("all") { + sc, id, err := scope() + if err != nil { + return err + } + qv = q("scope", sc, "scope_id", fmt.Sprint(id)) + } + if all { + qv = q() + } + var opts []api.Option + if err := c.Get(ctx(), "/options", qv, &opts); err != nil { + return err + } + return output(opts, func(w io.Writer) { + fmt.Fprintln(w, "ID\tWHERE\tOPTION\tVALUE") + for _, o := range opts { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", o.ID, o.Where, o.Name, o.Value) + } + }) + }, + } + scopeArgs(list) + list.Flags().BoolVar(&all, "all", false, "options of every scope") + var comment string + set := &cobra.Command{ + Use: "set NAME [VALUE...]", + Short: "Set an option (replaces an existing option of the same name)", + Long: `Set an option; VALUE is named.conf syntax without the trailing semicolon. + + jarl option set recursion no + jarl option set allow-recursion '{ homelab; localhost; }' + jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }' + jarl option set allow-transfer '{ key xfer; }' --zone example.com + jarl option set logging '{ channel q { file "/var/log/named/q.log"; }; category queries { q; }; }' --toplevel`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + sc, id, err := scope() + if err != nil { + return err + } + value := strings.Join(args[1:], " ") + var opts []api.Option + if err := c.Get(ctx(), "/options", q("scope", sc, "scope_id", fmt.Sprint(id)), &opts); err != nil { + return err + } + body := map[string]any{"value": value} + if cmd.Flags().Changed("comment") { + body["comment"] = comment + } + for _, o := range opts { + if o.Name == strings.ToLower(args[0]) && !multiple { + resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body) + if err != nil { + return err + } + return report(resp) + } + } + body["scope"], body["scope_id"], body["name"] = sc, id, args[0] + resp, err := c.Mutate(ctx(), "POST", "/options", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + scopeArgs(set) + set.Flags().StringVar(&comment, "comment", "", "comment") + set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)") + unset := &cobra.Command{ + Use: "unset NAME | unset --id ID", + Short: "Remove an option (BIND's default applies again)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + if optID > 0 { + resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", optID), nil, nil) + if err != nil { + return err + } + return report(resp) + } + if len(args) != 1 { + return fmt.Errorf("give the option name or --id") + } + sc, id, err := scope() + if err != nil { + return err + } + var opts []api.Option + if err := c.Get(ctx(), "/options", q("scope", sc, "scope_id", fmt.Sprint(id)), &opts); err != nil { + return err + } + var matches []api.Option + for _, o := range opts { + if o.Name == strings.ToLower(args[0]) { + matches = append(matches, o) + } + } + switch len(matches) { + case 0: + return fmt.Errorf("option %s is not set here", args[0]) + case 1: + default: + return fmt.Errorf("option %s is set %d times; remove one with --id", args[0], len(matches)) + } + resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil) + if err != nil { + return err + } + return report(resp) + }, + } + scopeArgs(unset) + unset.Flags().Int64Var(&optID, "id", 0, "remove the option with this ID") + describe := &cobra.Command{ + Use: "describe [NAME]", + Short: "Describe options from the BIND 9.20 catalog", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cs := "options" + switch { + case toplevel: + cs = "toplevel" + case view != "": + cs = "view" + case zone != "": + cs = "zone:" + zoneType + } + var stmts []struct { + Name string `json:"name"` + Grammar string `json:"grammar"` + Kind string `json:"kind"` + Status string `json:"status"` + Description string `json:"description"` + } + if err := c.Get(ctx(), "/catalog/options", q("scope", cs), &stmts); err != nil { + return err + } + for _, s := range stmts { + if len(args) == 1 && s.Name != args[0] { + continue + } + if len(args) == 0 { + fmt.Printf("%-32s %s\n", s.Name, s.Description) + continue + } + fmt.Printf("%s (%s)%s\n %s\n\n%s\n", s.Name, s.Kind, statusText(s.Status), s.Description, s.Grammar) + return nil + } + if len(args) == 1 { + return fmt.Errorf("%s is not an option in scope %s", args[0], cs) + } + return nil + }, + } + scopeArgs(describe) + describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type") + cmd.AddCommand(list, set, unset, describe) + return cmd +} + +var ( + all bool + multiple bool + optID int64 + zoneType string +) + +func statusText(s string) string { + if s == "" { + return "" + } + return " [" + s + "]" +} + +func settingsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "settings", Short: "Show or change Jarl's defaults for new zones"} + show := &cobra.Command{ + Use: "show", + Short: "Show settings", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var s model.Settings + if err := c.Get(ctx(), "/settings", nil, &s); err != nil { + return err + } + return output(s, func(w io.Writer) { + fmt.Fprintf(w, "Name servers:\t%s\nContact:\t%s\nDefault TTL:\t%d\nRefresh/retry/expire:\t%d/%d/%d\nNegative TTL:\t%d\nSerial policy:\t%s\n", + strings.Join(s.Nameservers, ", "), s.RName, s.DefaultTTL, s.Refresh, s.Retry, s.Expire, s.NegativeTTL, s.SerialPolicy) + }) + }, + } + var ns []string + var contact, policy string + var ttl, refresh, retry, expire, neg uint32 + set := &cobra.Command{ + Use: "set", + Short: "Change settings", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{} + f := cmd.Flags() + if f.Changed("ns") { + body["nameservers"] = ns + } + if f.Changed("contact") { + body["rname"] = contact + } + if f.Changed("serial-policy") { + body["serial_policy"] = policy + } + for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} { + if f.Changed(name) { + key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name] + if key == "" { + key = name + } + body[key] = v + } + } + if len(body) == 0 { + return fmt.Errorf("nothing to change (see --help)") + } + resp, err := c.Mutate(ctx(), "PATCH", "/settings", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers") + set.Flags().StringVar(&contact, "contact", "", "default SOA contact") + set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime") + set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL") + set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh") + set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry") + set.Flags().Uint32Var(&expire, "expire", 0, "SOA expire") + set.Flags().Uint32Var(&neg, "negative-ttl", 0, "SOA minimum") + cmd.AddCommand(show, set) + return cmd +} diff --git a/cmd/jarl/main.go b/cmd/jarl/main.go new file mode 100644 index 0000000..1b30892 --- /dev/null +++ b/cmd/jarl/main.go @@ -0,0 +1,382 @@ +// Command jarl is the command line client for Jarl. It talks to jarld's +// REST API with an API token. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "go.yaml.in/yaml/v3" + + "jarl/internal/api" + "jarl/internal/client" + "jarl/internal/version" +) + +func main() { + root := rootCmd() + if err := root.Execute(); err != nil { + printError(err) + os.Exit(exitCode(err)) + } +} + +// Global flags. +var ( + flagServer string + flagToken string + flagConfig string + flagChangeset int64 + flagDryRun bool + flagForce bool + flagOutput string + flagInsecure bool + flagCACert string + flagQuiet bool +) + +// cliConfig is ~/.config/jarl/config.yaml. +type cliConfig struct { + Server string `yaml:"server"` + Token string `yaml:"token"` + Insecure bool `yaml:"insecure,omitempty"` + CACert string `yaml:"ca_cert,omitempty"` +} + +func defaultConfigPath() string { + if p := os.Getenv("JARL_CLI_CONFIG"); p != "" { + return p + } + dir, err := os.UserConfigDir() + if err != nil { + dir = filepath.Join(os.Getenv("HOME"), ".config") + } + return filepath.Join(dir, "jarl", "config.yaml") +} + +func loadCLIConfig() (*cliConfig, error) { + c := &cliConfig{} + b, err := os.ReadFile(flagConfig) + if errors.Is(err, os.ErrNotExist) { + return c, nil + } + if err != nil { + return nil, err + } + return c, yaml.Unmarshal(b, c) +} + +func saveCLIConfig(c *cliConfig) error { + if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil { + return err + } + b, err := yaml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(flagConfig, b, 0o600) +} + +func newClient() (*client.Client, error) { + cfg, err := loadCLIConfig() + if err != nil { + return nil, fmt.Errorf("reading %s: %w", flagConfig, err) + } + server := firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Server) + token := firstNonEmpty(flagToken, os.Getenv("JARL_TOKEN"), cfg.Token) + if token == "" { + return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'") + } + if strings.HasPrefix(token, "@") { + b, err := os.ReadFile(token[1:]) + if err != nil { + return nil, fmt.Errorf("reading token file: %w", err) + } + token = strings.TrimSpace(string(b)) + } + c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)}) + if err != nil { + return nil, err + } + c.Changeset = flagChangeset + if c.Changeset == 0 { + if v := os.Getenv("JARL_CHANGESET"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v) + } + c.Changeset = n + } + } + c.DryRun, c.Force = flagDryRun, flagForce + return c, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func rootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "jarl", + Short: "Command line client for Jarl, the BIND manager", + Long: `jarl manages a BIND server through Jarl's API. + +Every command that changes the configuration is applied at once, as a +single changeset that is validated, checked with named-checkconf and loaded +into named. To batch several changes, create a changeset and pass +--changeset ID (or set JARL_CHANGESET), then apply it; or describe the +changes in a file and run 'jarl apply -f FILE'. + +Configure the server and token once: + + jarl config set server https://dns.example.com:8053 + jarl config set token (paste the token at the prompt) + +Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, +5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`, + SilenceUsage: true, + SilenceErrors: true, + Version: version.Version, + } + pf := root.PersistentFlags() + pf.StringVar(&flagServer, "server", "", "Jarl server URL (env JARL_SERVER)") + pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env JARL_TOKEN)") + pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file") + pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)") + pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them") + pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl") + pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json or yaml") + pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate") + pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate") + pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors (and IDs where useful)") + root.AddGroup(&cobra.Group{ID: "dns", Title: "DNS commands:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"}) + add := func(group string, cmds ...*cobra.Command) { + for _, c := range cmds { + c.GroupID = group + root.AddCommand(c) + } + } + add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), acmeCmd(), searchCmd()) + add("changes", applyCmd(), changesetCmd(), historyCmd()) + add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd()) + return root +} + +// ctx returns the context for API calls. The HTTP client's timeout bounds +// each request. +func ctx() context.Context { return context.Background() } + +// ---- output + +func output(v any, table func(w io.Writer)) error { + switch flagOutput { + case "json": + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) + case "yaml": + // Round-trip through JSON so field names match the API. + b, err := json.Marshal(v) + if err != nil { + return err + } + var x any + if err := json.Unmarshal(b, &x); err != nil { + return err + } + return yaml.NewEncoder(os.Stdout).Encode(x) + case "table", "": + if table == nil { + return output(v, nil) + } + tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) + table(tw) + return tw.Flush() + } + return fmt.Errorf("unknown output format %q (use table, json or yaml)", flagOutput) +} + +func dot(name string) string { + if name == "." || name == "" { + return "." + } + return strings.TrimSuffix(name, ".") +} + +// where describes the zone and view a problem is about. +func where(p client.Problem) string { + if p.Zone == "" { + return "" + } + s := " [zone " + dot(p.Zone) + if p.View != "" { + s += ", view " + p.View + } + return s + "]" +} + +func printProblems(w io.Writer, ps []client.Problem) { + for _, p := range ps { + fmt.Fprintf(w, " %s: %s%s\n", p.Severity, p.Message, where(p)) + } +} + +func printCheck(w io.Writer, ms []client.CheckMessage) { + for _, m := range ms { + if m.Error { + obj := "" + if m.Object != "" { + obj = " [" + m.Object + "]" + } + fmt.Fprintf(w, " named-checkconf: %s%s\n", m.Text, obj) + } + } +} + +func printError(err error) { + var ce *client.Error + if errors.As(err, &ce) { + // The message often repeats the first problem; print it once. + head, ps := ce.Info.Message, ce.Problems() + for i, p := range ps { + if p.Message == head { + head += where(p) + ps = append(ps[:i:i], ps[i+1:]...) + break + } + if strings.HasPrefix(head, p.Message+" (and ") { + head = fmt.Sprintf("the change was rejected (%d problems):", len(ps)) + break + } + } + fmt.Fprintln(os.Stderr, "error:", head) + printProblems(os.Stderr, ps) + printCheck(os.Stderr, ce.Check()) + if r := client.ParseResult(ce.Info.Result); r != nil && r.Diff != "" && flagDryRun { + fmt.Fprint(os.Stderr, r.Diff) + } + return + } + fmt.Fprintln(os.Stderr, "error:", err) +} + +// exitCode maps errors to exit codes for scripts: 1 other errors, 3 not +// authenticated or not allowed, 4 not found, 5 conflict, 6 invalid +// request or rejected by validation, 7 server error. +func exitCode(err error) int { + var ce *client.Error + if errors.As(err, &ce) { + switch { + case ce.Status == 401 || ce.Status == 403: + return 3 + case ce.Status == 404: + return 4 + case ce.Status == 409: + return 5 + case ce.Status == 400 || ce.Status == 422: + return 6 + case ce.Status >= 500: + return 7 + } + } + return 1 +} + +// report prints the outcome of a change. +func report(resp *api.MutationResponse) error { + if flagOutput == "json" || flagOutput == "yaml" { + return output(resp, nil) + } + w := os.Stdout + r := client.ParseResult(resp.Result) + switch { + case resp.Staged: + if !flagQuiet { + for _, op := range resp.Added { + fmt.Fprintf(w, "staged #%d: %s\n", op.Seq, op.Summary) + } + fmt.Fprintf(w, "(changeset %d: review with 'jarl changeset diff %d', apply with 'jarl changeset apply %d')\n", resp.ChangesetID, resp.ChangesetID, resp.ChangesetID) + } + case resp.DryRun && r != nil: + for _, op := range r.Ops { + fmt.Fprintf(w, "would %s\n", op.Summary) + } + if len(r.Problems) > 0 { + fmt.Fprintln(w, "problems:") + printProblems(w, r.Problems) + } + if r.Diff != "" { + fmt.Fprint(w, r.Diff) + } else { + fmt.Fprintln(w, "(no file changes)") + } + fmt.Fprintln(w, "dry run: nothing was changed") + case resp.Applied && r != nil: + if flagQuiet { + break + } + for _, op := range r.Ops { + fmt.Fprintf(w, "%s\n", op.Summary) + } + for _, s := range r.Serials { + v := "" + if s.View != "" { + v = " [" + s.View + "]" + } + fmt.Fprintf(w, "zone %s%s serial %d -> %d\n", dot(s.Zone), v, s.Old, s.New) + } + fmt.Fprintf(w, "applied (changeset %d)\n", r.ChangesetID) + } + for _, warn := range resp.Warnings { + fmt.Fprintln(os.Stderr, "warning:", warn) + } + if r != nil && !resp.Applied && !resp.Staged && !resp.DryRun { + return fmt.Errorf("not applied") + } + return nil +} + +func q(kv ...string) url.Values { + v := url.Values{} + for i := 0; i+1 < len(kv); i += 2 { + if kv[i+1] != "" { + v.Set(kv[i], kv[i+1]) + } + } + return v +} + +// zonePath returns the API path for a zone given by name or ID. +func zonePath(zone, view string) (string, url.Values) { + z := strings.TrimSuffix(zone, ".") + if zone == "." { + z = "root" + } + return "/zones/" + url.PathEscape(z), q("view", view) +} + +func confirm(prompt string, yes bool) bool { + if yes || flagDryRun || flagChangeset > 0 { + return true + } + fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt) + var answer string + fmt.Scanln(&answer) + return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes") +} diff --git a/cmd/jarl/main_test.go b/cmd/jarl/main_test.go new file mode 100644 index 0000000..7677f3e --- /dev/null +++ b/cmd/jarl/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "testing" + + "go.yaml.in/yaml/v3" +) + +func TestBatchFile(t *testing.T) { + src := ` +description: test +changes: + - add: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true} + - delete: {zone: example.com, name: old, type: A} + - delete: {id: 12} + - set: {zone: example.com, name: api, type: A, data: 192.0.2.5} + - set: {zone: example.com, name: gone, type: TXT} + - update: {id: 13, data: 192.0.2.9} + - op: zone.create + data: {name: lab.example.com} +` + var b batchFile + if err := yaml.Unmarshal([]byte(src), &b); err != nil { + t.Fatal(err) + } + reqs, err := b.requests() + if err != nil { + t.Fatal(err) + } + want := []struct { + op string + id int64 + data string + }{ + {"record.create", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`}, + {"record.delete", 0, `{"name":"old","type":"A","zone":"example.com"}`}, + {"record.delete", 12, `{}`}, + {"rrset.replace", 0, `{"data":["192.0.2.5"],"name":"api","type":"A","zone":"example.com"}`}, + {"rrset.replace", 0, `{"data":[],"name":"gone","type":"TXT","zone":"example.com"}`}, + {"record.update", 13, `{"data":"192.0.2.9"}`}, + {"zone.create", 0, `{"name":"lab.example.com"}`}, + } + if len(reqs) != len(want) { + t.Fatalf("got %d requests, want %d", len(reqs), len(want)) + } + for i, w := range want { + r := reqs[i] + // Compare data as parsed JSON so key order doesn't matter. + var got, exp any + json.Unmarshal(r.Data, &got) + json.Unmarshal([]byte(w.data), &exp) + gb, _ := json.Marshal(got) + eb, _ := json.Marshal(exp) + if r.Op != w.op || r.ID != w.id || string(gb) != string(eb) { + t.Errorf("request %d = %s %d %s, want %s %d %s", i, r.Op, r.ID, gb, w.op, w.id, eb) + } + } +} + +func TestBatchFileErrors(t *testing.T) { + for _, src := range []string{ + "changes: [{add: {name: a}, delete: {name: b}}]", // two actions in one entry + "changes: [{}]", // no action + "changes: [{update: {data: x}}]", // update without id + } { + var b batchFile + if err := yaml.Unmarshal([]byte(src), &b); err != nil { + t.Fatal(err) + } + if _, err := b.requests(); err == nil { + t.Errorf("%s: expected an error", src) + } + } +} + +func TestChallengeName(t *testing.T) { + for _, tc := range []struct { + domain string + exact bool + want string + }{ + {"example.com", false, "_acme-challenge.example.com."}, + {"*.example.com", false, "_acme-challenge.example.com."}, + {"WWW.Example.com.", false, "_acme-challenge.www.example.com."}, + {"_acme-challenge.www.example.com", false, "_acme-challenge.www.example.com."}, // acme.sh passes the full name + {"www.acme.example.com", true, "www.acme.example.com."}, + } { + o := &acmeOptions{exact: tc.exact} + got, err := o.challengeName(tc.domain) + if err != nil || got != tc.want { + t.Errorf("challengeName(%q, exact=%v) = %q, %v; want %q", tc.domain, tc.exact, got, err, tc.want) + } + } + if _, err := (&acmeOptions{}).challengeName(" "); err == nil { + t.Error("empty domain accepted") + } +} + +func TestListValue(t *testing.T) { + if got := listValue([]string{"192.0.2.1", " 192.0.2.2; ", "key xfer"}); got != "{ 192.0.2.1; 192.0.2.2; key xfer; }" { + t.Errorf("listValue = %q", got) + } +} diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go new file mode 100644 index 0000000..a3327f4 --- /dev/null +++ b/cmd/jarl/zones.go @@ -0,0 +1,614 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "jarl/internal/api" + "jarl/internal/client" +) + +func zoneCmd() *cobra.Command { + cmd := &cobra.Command{Use: "zone", Aliases: []string{"zones"}, Short: "Manage zones"} + var view, typ string + + list := &cobra.Command{ + Use: "list", + Short: "List zones", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var zones []api.Zone + if err := c.Get(ctx(), "/zones", q("view", view, "type", typ), &zones); err != nil { + return err + } + return output(zones, func(w io.Writer) { + fmt.Fprintln(w, "ID\tZONE\tVIEW\tTYPE\tRECORDS\tSERIAL") + for _, z := range zones { + serial, recs := "", "" + if z.Type == "primary" { + serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records) + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, z.Type, recs, serial) + } + }) + }, + } + list.Flags().StringVar(&view, "view", "", "only zones in this view") + list.Flags().StringVar(&typ, "type", "", "only zones of this type") + + show := &cobra.Command{ + Use: "show ZONE", + Short: "Show a zone", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + var z api.Zone + if err := c.Get(ctx(), path, qv, &z); err != nil { + return err + } + return output(z, func(w io.Writer) { + fmt.Fprintf(w, "Zone:\t%s (id %d)\n", z.Display, z.ID) + if z.View != "" { + fmt.Fprintf(w, "View:\t%s\n", z.View) + } + fmt.Fprintf(w, "Type:\t%s\n", z.Type) + if z.Type == "primary" { + fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial) + fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum) + fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL) + fmt.Fprintf(w, "Records:\t%d\n", z.Records) + } + if z.InView != "" { + fmt.Fprintf(w, "In view:\t%s\n", z.InView) + } + if z.ZoneFile != "" { + fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile) + } + if z.Comment != "" { + fmt.Fprintf(w, "Comment:\t%s\n", z.Comment) + } + }) + }, + } + show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)") + + var ns, records, primaries, forwarders, opts []string + var contact, comment, cloneFrom, file, inView, forward string + var ttl uint32 + create := &cobra.Command{ + Use: "create NAME", + Short: "Create a zone", + Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to +create its reverse zone. + + jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1" + jarl zone create 192.168.1.0/24 + jarl zone create partner.example --type secondary --primary 192.0.2.53 + jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600" + jarl zone create example.com --view external --clone-from example.com --clone-view internal`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{"name": args[0], "type": typ} + if view != "" { + body["view"] = view + } + if len(ns) > 0 { + body["nameservers"] = ns + } + if contact != "" { + body["soa"] = map[string]any{"rname": contact} + } + if ttl > 0 { + body["default_ttl"] = ttl + } + if comment != "" { + body["comment"] = comment + } + if file != "" { + body["file"] = file + } + if inView != "" { + body["in_view"] = inView + } + var recs []map[string]any + for _, r := range records { + f := strings.Fields(r) + if len(f) < 3 { + return fmt.Errorf("--record %q: use \"NAME TYPE DATA\"", r) + } + recs = append(recs, map[string]any{"name": f[0], "type": f[1], "data": strings.Join(f[2:], " ")}) + } + if len(recs) > 0 { + body["records"] = recs + } + var zopts []map[string]string + if len(primaries) > 0 { + zopts = append(zopts, map[string]string{"name": "primaries", "value": listValue(primaries)}) + } + if len(forwarders) > 0 || typ == "forward" { + zopts = append(zopts, map[string]string{"name": "forwarders", "value": listValue(forwarders)}) + zopts = append(zopts, map[string]string{"name": "forward", "value": forward}) + } + for _, o := range opts { + n, v, _ := strings.Cut(strings.TrimSpace(o), " ") + zopts = append(zopts, map[string]string{"name": n, "value": v}) + } + if len(zopts) > 0 { + body["options"] = zopts + } + if cloneFrom != "" { + id, err := resolveZoneID(c, cloneFrom, cloneView) + if err != nil { + return err + } + body["clone_from"] = id + } + resp, err := c.Mutate(ctx(), "POST", "/zones", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + create.Flags().StringVar(&view, "view", "", "create the zone in this view") + create.Flags().StringVar(&typ, "type", "primary", "zone type: primary, secondary, forward, stub, static-stub, mirror, hint, redirect, in-view") + create.Flags().StringSliceVar(&ns, "ns", nil, "name server (repeatable; default: the configured defaults)") + create.Flags().StringArrayVar(&records, "record", nil, `initial record "NAME TYPE DATA" (repeatable), e.g. glue for in-zone name servers`) + create.Flags().StringVar(&contact, "contact", "", "SOA contact email (default: the configured default)") + create.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL") + create.Flags().StringArrayVar(&primaries, "primary", nil, `primary server for secondary/stub zones, e.g. "192.0.2.53 key xfer" (repeatable)`) + create.Flags().StringArrayVar(&forwarders, "forwarder", nil, `forwarder for forward zones, e.g. "192.0.2.1 port 5353" (repeatable)`) + create.Flags().StringVar(&forward, "forward", "only", "forward mode for forward zones: only or first") + create.Flags().StringArrayVar(&opts, "option", nil, `zone option "NAME VALUE" (repeatable), e.g. "allow-transfer { key xfer; }"`) + create.Flags().StringVar(&cloneFrom, "clone-from", "", "copy records from this zone (same name, other view)") + create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from") + create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)") + create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from") + create.Flags().StringVar(&comment, "comment", "", "comment") + + var refresh, retry, expire, minimum, serial uint32 + var mname, policy, moveView string + update := &cobra.Command{ + Use: "update ZONE", + Short: "Change a zone's settings (SOA, TTL, view, comment)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{} + soa := map[string]any{} + f := cmd.Flags() + if f.Changed("mname") { + soa["mname"] = mname + } + if f.Changed("contact") { + soa["rname"] = contact + } + for name, v := range map[string]uint32{"refresh": refresh, "retry": retry, "expire": expire, "minimum": minimum, "serial": serial} { + if f.Changed(name) { + soa[name] = v + } + } + if len(soa) > 0 { + body["soa"] = soa + } + if f.Changed("ttl") { + body["default_ttl"] = ttl + } + if f.Changed("serial-policy") { + body["serial_policy"] = policy + } + if f.Changed("comment") { + body["comment"] = comment + } + if f.Changed("move-to-view") { + body["view"] = moveView + } + if f.Changed("file") { + body["file"] = file + } + if len(body) == 0 { + return fmt.Errorf("nothing to change (see --help)") + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "PATCH", path, qv, body) + if err != nil { + return err + } + return report(resp) + }, + } + update.Flags().StringVar(&view, "view", "", "view of the zone") + update.Flags().StringVar(&mname, "mname", "", "SOA primary name server") + update.Flags().StringVar(&contact, "contact", "", "SOA contact email") + update.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh") + update.Flags().Uint32Var(&retry, "retry", 0, "SOA retry") + update.Flags().Uint32Var(&expire, "expire", 0, "SOA expire") + update.Flags().Uint32Var(&minimum, "minimum", 0, "SOA minimum (negative caching TTL)") + update.Flags().Uint32Var(&serial, "serial", 0, "set the serial explicitly") + update.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL") + update.Flags().StringVar(&policy, "serial-policy", "", "serial policy: date, increment or unixtime") + update.Flags().StringVar(&comment, "comment", "", "comment") + update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view") + update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)") + + var yes bool + del := &cobra.Command{ + Use: "delete ZONE", + Short: "Delete a zone and its records", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !confirm("Delete zone "+args[0]+" and all its records?", yes) { + return fmt.Errorf("canceled") + } + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "DELETE", path, qv, nil) + if err != nil { + return err + } + return report(resp) + }, + } + del.Flags().StringVar(&view, "view", "", "view of the zone") + del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") + + fileCmd := &cobra.Command{ + Use: "file ZONE", + Short: "Print the zone file Jarl generates", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + var text string + if err := c.Get(ctx(), path+"/file", qv, &text); err != nil { + return err + } + fmt.Print(text) + return nil + }, + } + fileCmd.Flags().StringVar(&view, "view", "", "view of the zone") + + var replace, useSOA bool + importCmd := &cobra.Command{ + Use: "import ZONE FILE", + Short: "Import records from a zone file ('-' reads standard input)", + Long: `Adds the records of a zone file to a zone. With --replace the zone is made +equal to the file: records not in the file are deleted.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var data []byte + if args[1] == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(args[1]) + } + if err != nil { + return err + } + mode := "merge" + if replace { + mode = "replace" + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "POST", path+"/import", qv, map[string]any{"text": string(data), "mode": mode, "use_soa": useSOA, "dry_run": flagDryRun}) + if err != nil { + return err + } + if len(resp.Object) > 0 && !flagQuiet { + var o struct { + Skipped []string `json:"skipped"` + } + if jsonUnmarshal(resp.Object, &o) == nil { + for _, s := range o.Skipped { + fmt.Fprintln(os.Stderr, "skipped:", s) + } + } + } + return report(resp) + }, + } + importCmd.Flags().StringVar(&view, "view", "", "view of the zone") + importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file") + importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file") + + cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd) + return cmd +} + +var cloneView string + +func listValue(items []string) string { + var parts []string + for _, it := range items { + it = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(it), ";")) + if it != "" { + parts = append(parts, it+";") + } + } + return "{ " + strings.Join(parts, " ") + " }" +} + +// resolveZoneID looks up a zone's ID by name (or ID) and view. +func resolveZoneID(c *client.Client, zone, view string) (int64, error) { + path, qv := zonePath(zone, view) + var z api.Zone + if err := c.Get(ctx(), path, qv, &z); err != nil { + return 0, err + } + return z.ID, nil +} + +func urlPathEscape(s string) string { return url.PathEscape(s) } + +func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) } + +// ---- records + +func recordCmd() *cobra.Command { + cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"} + var view, name, typ, search, comment, ttlArg string + var ptr, missingOK, yes bool + var id int64 + + list := &cobra.Command{ + Use: "list ZONE", + Short: "List records", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + for k, v := range q("name", name, "type", strings.ToUpper(typ), "q", search) { + qv[k] = v + } + var recs []api.Record + if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil { + return err + } + return output(recs, func(w io.Writer) { + fmt.Fprintln(w, "ID\tNAME\tTTL\tTYPE\tDATA\tCOMMENT") + for _, r := range recs { + ttl := fmt.Sprint(r.EffectiveTTL) + if r.TTL == nil { + ttl += "*" + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.RelativeName, ttl, r.Type, r.Data, r.Comment) + } + }) + }, + } + list.Flags().StringVar(&view, "view", "", "view of the zone") + list.Flags().StringVar(&name, "name", "", "only this name") + list.Flags().StringVarP(&typ, "type", "t", "", "only this type") + list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this") + + add := &cobra.Command{ + Use: "add ZONE NAME TYPE DATA...", + Short: "Add a record", + Long: `Add a record. NAME is relative to the zone ("@" for the apex) unless it ends +with a dot. DATA is in zone file format; TXT data is plain text (quoting +and splitting are automatic). + + jarl record add example.com www A 192.0.2.10 --ttl 300 --ptr + jarl record add example.com @ MX 10 mail + jarl record add example.com @ TXT "v=spf1 mx -all" + jarl record add example.com _sip._tcp SRV 0 5 5060 sip`, + Args: cobra.MinimumNArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{"name": args[1], "type": args[2], "data": strings.Join(args[3:], " ")} + if err := setTTL(body, ttlArg); err != nil { + return err + } + if comment != "" { + body["comment"] = comment + } + if ptr { + body["ptr"] = true + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "POST", path+"/records", qv, body) + if err != nil { + return err + } + return report(resp) + }, + } + add.Flags().StringVar(&view, "view", "", "view of the zone") + add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)") + add.Flags().StringVar(&comment, "comment", "", "comment") + add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)") + + var data string + update := &cobra.Command{ + Use: "update ID", + Short: "Change a record (see 'record list' for IDs)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{} + f := cmd.Flags() + if f.Changed("name") { + body["name"] = name + } + if f.Changed("type") { + body["type"] = typ + } + if f.Changed("data") { + body["data"] = data + } + if f.Changed("comment") { + body["comment"] = comment + } + if f.Changed("ttl") { + if err := setTTL(body, ttlArg); err != nil { + return err + } + } + if ptr { + body["ptr"] = true + } + if len(body) == 0 { + return fmt.Errorf("nothing to change (see --help)") + } + resp, err := c.Mutate(ctx(), "PATCH", "/records/"+args[0], nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + update.Flags().StringVar(&name, "name", "", "new name") + update.Flags().StringVarP(&typ, "type", "t", "", "new type (needs --data)") + update.Flags().StringVar(&data, "data", "", "new data") + update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`) + update.Flags().StringVar(&comment, "comment", "", "new comment") + update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)") + + del := &cobra.Command{ + Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID", + Short: "Delete records", + Long: `Delete records by name and type (all of them, or only the one with DATA), +or one record by ID. + + jarl record delete example.com www A # all A records of www + jarl record delete example.com www A 192.0.2.10 # just this one + jarl record delete --id 1234`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + if id > 0 { + qv := q() + if ptr { + qv.Set("ptr", "1") + } + resp, err := c.Mutate(ctx(), "DELETE", "/records/"+strconv.FormatInt(id, 10), qv, nil) + if err != nil { + return err + } + return report(resp) + } + if len(args) < 3 { + return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id") + } + if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) { + return fmt.Errorf("canceled") + } + path, qv := zonePath(args[0], view) + qv.Set("name", args[1]) + qv.Set("type", args[2]) + if len(args) > 3 { + qv.Set("data", strings.Join(args[3:], " ")) + } + if missingOK { + qv.Set("missing_ok", "1") + } + if ptr { + qv.Set("ptr", "1") + } + resp, err := c.Mutate(ctx(), "DELETE", path+"/records", qv, nil) + if err != nil { + return err + } + return report(resp) + }, + } + del.Flags().StringVar(&view, "view", "", "view of the zone") + del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID") + del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches") + del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)") + del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask before deleting a whole RRset") + + set := &cobra.Command{ + Use: "set ZONE NAME TYPE DATA [DATA...]", + Short: "Make NAME/TYPE have exactly these records (each DATA argument is one record)", + Long: `Replaces the whole RRset. Each DATA argument is one record; quote data with +spaces. + + jarl record set example.com api A 192.0.2.30 192.0.2.31 --ttl 60 + jarl record set example.com @ MX "10 mail1" "20 mail2"`, + Args: cobra.MinimumNArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := map[string]any{"data": args[3:]} + if err := setTTL(body, ttlArg); err != nil { + return err + } + if comment != "" { + body["comment"] = comment + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body) + if err != nil { + return err + } + return report(resp) + }, + } + set.Flags().StringVar(&view, "view", "", "view of the zone") + set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset") + set.Flags().StringVar(&comment, "comment", "", "comment for new records") + + cmd.AddCommand(list, add, update, del, set) + return cmd +} + +func setTTL(body map[string]any, ttl string) error { + switch ttl { + case "": + case "default", "zone", "inherit": + body["ttl"] = nil + default: + n, err := strconv.ParseUint(ttl, 10, 32) + if err != nil { + return fmt.Errorf("invalid TTL %q", ttl) + } + body["ttl"] = n + } + return nil +} diff --git a/cmd/jarld/admin.go b/cmd/jarld/admin.go new file mode 100644 index 0000000..ee3103e --- /dev/null +++ b/cmd/jarld/admin.go @@ -0,0 +1,408 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/spf13/cobra" + + "jarl/internal/config" + "jarl/internal/engine" + "jarl/internal/render" + "jarl/internal/validate" + "jarl/internal/version" +) + +// dirOwner restores file ownership after commands run as root. +type dirOwner struct { + uid, gid int + active bool +} + +func newDirOwner(dir string) *dirOwner { + o := &dirOwner{} + if os.Geteuid() != 0 { + return o + } + fi, err := os.Stat(dir) + if err != nil { + return o + } + if st, ok := fi.Sys().(*syscall.Stat_t); ok && st.Uid != 0 { + o.uid, o.gid, o.active = int(st.Uid), int(st.Gid), true + } + return o +} + +// fix gives root-owned files under dirs to the daemon's user (keeping +// their group, which matters for named). +func (o *dirOwner) fix(dirs ...string) { + if !o.active { + return + } + for _, d := range dirs { + filepath.WalkDir(d, func(path string, de fs.DirEntry, err error) error { + if err != nil { + return nil + } + info, err := de.Info() + if err != nil { + return nil + } + if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid == 0 { + gid := int(st.Gid) + if path == d || strings.HasSuffix(path, ".db") || strings.Contains(path, ".db-") { + gid = o.gid + } + os.Lchown(path, o.uid, gid) + } + return nil + }) + } +} + +func printJSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(v) +} + +func printProblems(ps []validate.Problem) { + for _, p := range ps { + fmt.Println(" " + p.String()) + } +} + +func printResult(res *engine.Result) { + if res == nil { + return + } + if len(res.Problems) > 0 { + fmt.Println("Problems:") + printProblems(res.Problems) + } + for _, m := range res.Check { + if m.Error { + obj := "" + if m.Object != "" { + obj = " [" + m.Object + "]" + } + fmt.Printf(" named-checkconf: %s%s\n", m.Text, obj) + } + } + for _, w := range res.Warnings { + fmt.Println("warning:", w) + } + for _, d := range res.Drift { + fmt.Printf("drift: %s (%s)\n", d.Path, d.Reason) + } +} + +func importCmd() *cobra.Command { + var from string + var dryRun, asJSON bool + cmd := &cobra.Command{ + Use: "import", + Short: "Import the existing BIND configuration into Jarl", + Long: `Reads named.conf (following includes) and all primary zone files into +Jarl. Key files used by rndc (rndc.key) stay as include statements. + +If the imported configuration is valid it is applied right away: Jarl +writes its own files (bind.config_dir, bind.zone_dir), but named keeps +running on the old configuration until you run 'jarld adopt'. If there +are problems, the import is kept as an open changeset that you can fix in +the web UI or with the CLI.`, + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx := context.Background() + if from == "" { + from = a.cfg.BIND.MainConfig + } + p, err := a.localPrincipal(ctx, "") + if err != nil { + return err + } + res, err := a.eng.Import(ctx, from, p, dryRun) + if err != nil { + return err + } + if asJSON { + printJSON(res) + return nil + } + rep := res.Report + fmt.Printf("Read %s:\n", from) + fmt.Printf(" %d views, %d zones, %d records, %d ACLs, %d TSIG keys, %d options\n", + rep.Counts["views"], rep.Counts["zones"], rep.Counts["records"], rep.Counts["acls"], rep.Counts["tsig_keys"], rep.Counts["options"]) + for _, z := range rep.Zones { + view := "" + if z.View != "" { + view = " [view " + z.View + "]" + } + extra := "" + if z.Skipped > 0 { + extra = fmt.Sprintf(", %d skipped", z.Skipped) + } + fmt.Printf(" zone %-35s %-10s %4d records%s\n", z.Name+view, z.Type, z.Records, extra) + } + for _, k := range rep.Kept { + fmt.Printf(" kept as include: %s\n", k) + } + for _, w := range rep.Warnings { + fmt.Println("warning:", w) + } + if len(rep.Skipped) > 0 { + fmt.Println("Not imported:") + for _, s := range rep.Skipped { + fmt.Println(" " + s) + } + } + if dryRun { + fmt.Printf("\nDry run: %d operations would be applied; nothing was changed.\n", len(res.Changeset.Ops)) + return nil + } + if !res.Applied { + fmt.Printf("\nThe import is saved as changeset #%d but was NOT applied:\n", res.Changeset.ID) + if res.Result != nil && res.Result.Error != nil { + fmt.Println(" " + res.Result.Error.Message) + } + printResult(res.Result) + fmt.Println("\nFix the problems (web UI: Changes, or 'jarl changeset show " + fmt.Sprint(res.Changeset.ID) + "'), apply the changeset, then run 'jarld adopt'.") + return nil + } + printResult(res.Result) + fmt.Printf("\nImported and applied as changeset #%d. Jarl's files are in %s and %s.\n", res.Result.ChangesetID, a.cfg.BIND.ConfigDir, a.cfg.BIND.ZoneDir) + fmt.Println("named still uses the old configuration. Review Jarl's files, then switch named over with:") + fmt.Println("\n sudo jarld adopt") + return nil + }, + } + cmd.Flags().StringVar(&from, "from", "", "named.conf to import (default: bind.main_config)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "only show what would be imported") + cmd.Flags().BoolVar(&asJSON, "json", false, "print the result as JSON") + return cmd +} + +func adoptCmd() *cobra.Command { + return &cobra.Command{ + Use: "adopt", + Short: "Switch named over to Jarl's configuration (run as root)", + Long: `Makes named load Jarl's configuration: + + 1. bumps the serial of every primary zone, + 2. saves bind.main_config (e.g. /etc/bind/named.conf) as + named.conf.pre-jarl- and replaces it with a single include of + Jarl's named.conf, + 3. runs named-checkconf, rndc reconfig and reloads every zone, + 4. checks that named serves every zone from Jarl's files. + +If anything fails the previous named.conf is restored. Writing +named.conf needs root.`, + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx := context.Background() + p, err := a.localPrincipal(ctx, "") + if err != nil { + return err + } + res, err := a.eng.Adopt(ctx, p) + if err != nil { + if ee, ok := engine.AsError(err); ok && len(ee.Problems) > 0 { + fmt.Println("Problems:") + printProblems(ee.Problems) + } + return err + } + if res.Already { + fmt.Printf("named already loads Jarl's configuration (%s includes %s).\n", res.MainConfig, filepath.Join(a.cfg.BIND.ConfigDir, "named.conf")) + return nil + } + fmt.Printf("named now loads Jarl's configuration.\n %s includes %s\n previous configuration saved as %s\n", res.MainConfig, filepath.Join(a.cfg.BIND.ConfigDir, "named.conf"), res.Backup) + for _, s := range res.Serials { + fmt.Printf(" zone %s serial %d -> %d\n", strings.TrimSuffix(s.Zone, "."), s.Old, s.New) + } + fmt.Println("\nFrom now on, change DNS only through Jarl. To go back: restore the saved file and run 'rndc reconfig'.") + return nil + }, + } +} + +func checkCmd() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "check", + Short: "Validate the current configuration and run named-checkconf on it", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + res, err := a.eng.CheckCurrent(context.Background()) + if err != nil { + return err + } + if asJSON { + printJSON(res) + } else { + printResult(res) + if res.Diff != "" { + fmt.Println("Files on disk differ from the configuration; 'jarld resync' rewrites them:") + fmt.Print(res.Diff) + } + } + if !res.Valid { + return fmt.Errorf("the configuration has errors") + } + if !asJSON { + fmt.Println("OK: the configuration is valid and named-checkconf accepts it.") + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "print the result as JSON") + return cmd +} + +func renderCmd() *cobra.Command { + var out string + cmd := &cobra.Command{ + Use: "render", + Short: "Write the generated configuration to a directory (for inspection)", + RunE: func(cmd *cobra.Command, args []string) error { + if out == "" { + return fmt.Errorf("--out is required") + } + a, err := openApp(true) + if err != nil { + return err + } + defer a.store.Close() + st, err := a.store.LoadState(context.Background()) + if err != nil { + return err + } + paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")} + o := render.Render(st, paths) + for _, f := range o.Files { + if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil { + return err + } + if err := os.WriteFile(f.Path, f.Content, 0o640); err != nil { + return err + } + fmt.Println(f.Path) + } + return nil + }, + } + cmd.Flags().StringVarP(&out, "out", "o", "", "output directory") + return cmd +} + +func resyncCmd() *cobra.Command { + var force, dryRun bool + cmd := &cobra.Command{ + Use: "resync", + Short: "Rewrite all generated files from the database and reload named", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx := context.Background() + p, err := a.localPrincipal(ctx, "") + if err != nil { + return err + } + res, err := a.eng.Resync(ctx, p, engine.ApplyOptions{Force: force, DryRun: dryRun}) + printResult(res) + if err != nil { + return err + } + if res.Diff != "" { + fmt.Print(res.Diff) + } + if dryRun { + fmt.Printf("Dry run: %d files would be written.\n", len(res.Files)) + } else { + fmt.Printf("%d files written; named reloaded: %v\n", len(res.Files), res.Reloaded) + } + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "overwrite files changed outside Jarl") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "only show what would change") + return cmd +} + +func backupCmd() *cobra.Command { + return &cobra.Command{ + Use: "backup FILE", + Short: "Write a consistent copy of the database to FILE (safe while jarld runs)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + if _, err := os.Stat(args[0]); err == nil { + return fmt.Errorf("%s already exists", args[0]) + } + if err := a.store.Backup(context.Background(), args[0]); err != nil { + return err + } + os.Chmod(args[0], 0o600) + fmt.Println("Database copied to", args[0]) + return nil + }, + } +} + +func configCmd() *cobra.Command { + var example bool + cmd := &cobra.Command{ + Use: "config", + Short: "Show the effective configuration (or an example file)", + RunE: func(cmd *cobra.Command, args []string) error { + if example { + fmt.Print(config.Example) + return nil + } + cfg, err := config.Load(configPath, true) + if err != nil { + return err + } + printJSON(cfg) + return nil + }, + } + cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration file") + return cmd +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("jarld", version.Version) + }, + } +} diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go new file mode 100644 index 0000000..684b5e7 --- /dev/null +++ b/cmd/jarld/main.go @@ -0,0 +1,502 @@ +// Command jarld is the Jarl daemon: it serves the web UI and the REST API +// and writes BIND's configuration. It also has administrative subcommands +// for setting up and migrating a server. +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "jarl/internal/auth" + "jarl/internal/bind" + "jarl/internal/config" + "jarl/internal/engine" + "jarl/internal/model" + "jarl/internal/render" + "jarl/internal/server" + "jarl/internal/store" + "jarl/internal/version" + "jarl/internal/web" +) + +func main() { + if err := rootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +var configPath string + +func rootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "jarld", + Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI", + SilenceUsage: true, + SilenceErrors: true, + Version: version.Version, + } + def := os.Getenv("JARL_CONFIG") + if def == "" { + def = config.DefaultPath + } + root.PersistentFlags().StringVarP(&configPath, "config", "c", def, "configuration file") + root.AddCommand(serveCmd(), initCmd(), userCmd(), importCmd(), adoptCmd(), checkCmd(), renderCmd(), resyncCmd(), backupCmd(), configCmd(), versionCmd()) + return root +} + +// app holds what most commands need. +type app struct { + cfg *config.Config + log *slog.Logger + store *store.Store + eng *engine.Engine + owner *dirOwner +} + +func newLogger(cfg *config.Config, w io.Writer) *slog.Logger { + var level slog.Level + switch strings.ToLower(cfg.Log.Level) { + case "debug": + level = slog.LevelDebug + case "warn", "warning": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + opts := &slog.HandlerOptions{Level: level} + if cfg.Log.Format == "json" { + return slog.New(slog.NewJSONHandler(w, opts)) + } + // systemd's journal adds timestamps. + if os.Getenv("JOURNAL_STREAM") != "" { + opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + return a + } + } + return slog.New(slog.NewTextHandler(w, opts)) +} + +func openApp(allowMissingConfig bool) (*app, error) { + cfg, err := config.Load(configPath, allowMissingConfig) + if err != nil { + return nil, fmt.Errorf("loading configuration: %w", err) + } + log := newLogger(cfg, os.Stderr) + owner := newDirOwner(filepath.Dir(cfg.Database)) + if err := os.MkdirAll(filepath.Dir(cfg.Database), 0o750); err != nil { + return nil, err + } + st, err := store.Open(cfg.Database) + if err != nil { + return nil, fmt.Errorf("opening database %s: %w", cfg.Database, err) + } + tools := &bind.Tools{NamedCheckConf: cfg.BIND.NamedCheckConf, NamedCheckZone: cfg.BIND.NamedCheckZone, RNDC: cfg.BIND.RNDC, RNDCArgs: cfg.BIND.RNDCArgs} + eng := engine.New(st, tools, engine.Config{ + Paths: render.Paths{ConfigDir: cfg.BIND.ConfigDir, ZoneDir: cfg.BIND.ZoneDir, CacheDir: cfg.BIND.CacheDir}, + MainConfig: cfg.BIND.MainConfig, + StagingDir: cfg.StagingDir, + FileGroup: cfg.BIND.FileGroup, + ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout), + NoReload: cfg.BIND.NoReload, + }, log) + return &app{cfg: cfg, log: log, store: st, eng: eng, owner: owner}, nil +} + +func (a *app) close() { + a.store.Close() + // Commands run as root must not leave root-owned files where the + // daemon (running as its own user) needs to write. + a.owner.fix(filepath.Dir(a.cfg.Database), a.cfg.StagingDir, a.cfg.BIND.ConfigDir, a.cfg.BIND.ZoneDir, a.cfg.BIND.CacheDir) +} + +// localPrincipal is used for administrative commands run on the server. +func (a *app) localPrincipal(ctx context.Context, username string) (*auth.Principal, error) { + if username == "" { + username = os.Getenv("SUDO_USER") + } + if username != "" { + if u, err := a.store.GetUserByName(ctx, username); err == nil { + if !u.Admin { + return nil, fmt.Errorf("user %s is not an administrator", username) + } + return &auth.Principal{User: u, Method: "local"}, nil + } + } + // Fall back to the first administrator, or a synthetic one. + users, err := a.store.ListUsers(ctx) + if err != nil { + return nil, err + } + for _, u := range users { + if u.Admin && !u.Disabled { + return &auth.Principal{User: &model.User{ID: u.ID, Username: u.Username + " (local)", Admin: true}, Method: "local"}, nil + } + } + return &auth.Principal{User: &model.User{Username: "local", Admin: true}, Method: "local"}, nil +} + +// ---- serve + +func serveCmd() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Run the daemon (web UI and API)", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if users, err := a.store.ListUsers(ctx); err == nil && len(users) == 0 { + a.log.Warn("there are no users yet; create an administrator with 'jarld init' or 'jarld user add --admin NAME'") + } + if ok, err := a.eng.Adopted(); err == nil && !ok { + a.log.Warn("named does not load Jarl's configuration yet; changes are written but named is not reloaded until 'jarld adopt' runs", "main_config", a.cfg.BIND.MainConfig) + } + srv := server.New(server.Config{ + Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, + TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), + SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, + Version: version.Version, + }, a.eng, a.log, web.FS()) + hup := make(chan os.Signal, 1) + signal.Notify(hup, syscall.SIGHUP) + go func() { + for range hup { + sdNotify("RELOADING=1") + srv.ReloadTLS() + sdNotify("READY=1") + } + }() + go auditRetention(ctx, a) + a.log.Info("jarld starting", "version", version.Version, "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir) + err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") }) + sdNotify("STOPPING=1") + a.log.Info("jarld stopped") + return err + }, + } +} + +func auditRetention(ctx context.Context, a *app) { + if a.cfg.Audit.RetentionDays <= 0 { + return + } + t := time.NewTicker(6 * time.Hour) + defer t.Stop() + for { + before := time.Now().Add(-time.Duration(a.cfg.Audit.RetentionDays) * 24 * time.Hour) + if n, err := a.store.PruneAudit(ctx, before); err != nil { + a.log.Warn("pruning audit log failed", "err", err) + } else if n > 0 { + a.log.Info("pruned audit log", "events", n) + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// sdNotify tells systemd about state changes (Type=notify). +func sdNotify(state string) { + sock := os.Getenv("NOTIFY_SOCKET") + if sock == "" { + return + } + addr := &net.UnixAddr{Name: sock, Net: "unixgram"} + if strings.HasPrefix(sock, "@") { + addr.Name = "\x00" + sock[1:] + } + c, err := net.DialUnix("unixgram", nil, addr) + if err != nil { + return + } + defer c.Close() + c.Write([]byte(state)) +} + +// ---- init and users + +func readPassword(prompt string, fromStdin bool) (string, error) { + if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) { + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil + } + fmt.Fprint(os.Stderr, prompt) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + fmt.Fprint(os.Stderr, "Repeat: ") + b2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + if string(b) != string(b2) { + return "", errors.New("passwords don't match") + } + return string(b), nil +} + +func createToken(ctx context.Context, a *app, u *model.User, name string) (string, error) { + secret, pid, hash := auth.NewToken() + if err := a.store.CreateToken(ctx, &model.Token{UserID: u.ID, Name: name, PublicID: pid, SecretHash: hash}); err != nil { + return "", err + } + return secret, nil +} + +func initCmd() *cobra.Command { + var username string + var stdin, withToken bool + var nameservers []string + var rname string + cmd := &cobra.Command{ + Use: "init", + Short: "Create the database, the first administrator and default server options", + Long: `Creates Jarl's database with a first administrator account. + +Jarl starts empty. Either import your existing BIND configuration with +'jarld import', or build one from scratch in the web UI.`, + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx := context.Background() + users, err := a.store.ListUsers(ctx) + if err != nil { + return err + } + if len(users) > 0 { + return fmt.Errorf("Jarl is already initialized (%d users); use 'jarld user add' to add users", len(users)) + } + pw, err := readPassword("Password for "+username+": ", stdin) + if err != nil { + return err + } + hash, err := auth.HashPassword(pw) + if err != nil { + return err + } + u := &model.User{Username: username, PasswordHash: hash, Admin: true, Comment: "created by jarld init"} + if err := a.store.CreateUser(ctx, u); err != nil { + return err + } + p := &auth.Principal{User: u, Method: "local"} + set := engine.SettingsInput{} + if len(nameservers) > 0 { + set.Nameservers = &nameservers + } + if rname != "" { + set.RName = &rname + } + if set.Nameservers != nil || set.RName != nil { + if _, err := a.eng.Submit(ctx, []engine.Request{engine.NewRequest(engine.OpSettingsUpdate, 0, set)}, p, "cli", "initial settings", engine.ApplyOptions{NoReload: true}); err != nil { + return err + } + } + a.eng.Audit(ctx, p, "system.init", "user", u.ID, u.Username, "success", "") + fmt.Printf("Created administrator %q in %s.\n", u.Username, a.cfg.Database) + if withToken { + tok, err := createToken(ctx, a, u, "cli") + if err != nil { + return err + } + fmt.Printf("\nAPI token for the CLI (shown only once):\n\n %s\n\n", tok) + } + fmt.Println("\nNext steps:") + fmt.Println(" 1. Import your current BIND configuration: sudo -u jarl jarld import") + fmt.Println(" 2. Switch named over to Jarl: sudo jarld adopt") + fmt.Println(" 3. Start the daemon: sudo systemctl enable --now jarl") + return nil + }, + } + cmd.Flags().StringVarP(&username, "user", "u", "admin", "administrator username") + cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") + cmd.Flags().BoolVar(&withToken, "token", false, "also create an API token for the CLI") + cmd.Flags().StringSliceVar(&nameservers, "nameservers", nil, "default name servers for new zones (e.g. ns1.example.com.)") + cmd.Flags().StringVar(&rname, "contact", "", "default SOA contact for new zones (e.g. hostmaster@example.com)") + return cmd +} + +func userCmd() *cobra.Command { + cmd := &cobra.Command{Use: "user", Short: "Manage users locally (for bootstrapping and recovery)"} + var admin, stdin, withToken, service bool + var grants []string + add := &cobra.Command{ + Use: "add NAME", + Short: "Create a user", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx := context.Background() + u := &model.User{Username: args[0], Admin: admin} + for _, g := range grants { + gr, err := auth.ParseGrant(g) + if err != nil { + return fmt.Errorf("grant %q: %w", g, err) + } + u.Grants = append(u.Grants, gr) + } + if !service { + pw, err := readPassword("Password for "+u.Username+": ", stdin) + if err != nil { + return err + } + if u.PasswordHash, err = auth.HashPassword(pw); err != nil { + return err + } + } + if err := a.store.CreateUser(ctx, u); err != nil { + return err + } + a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.create", "user", u.ID, u.Username, "success", "jarld user add") + fmt.Printf("Created user %s.\n", u.Username) + if withToken { + tok, err := createToken(ctx, a, u, "cli") + if err != nil { + return err + } + fmt.Printf("API token (shown only once): %s\n", tok) + } + return nil + }, + } + add.Flags().BoolVar(&admin, "admin", false, "make the user an administrator") + add.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") + add.Flags().BoolVar(&withToken, "token", false, "also create an API token") + add.Flags().BoolVar(&service, "service", false, "service account without password (API tokens only)") + add.Flags().StringArrayVar(&grants, "grant", nil, `permission, e.g. "records:write zones=example.com" (repeatable)`) + + passwd := &cobra.Command{ + Use: "passwd NAME", + Short: "Set a user's password (and enable the account)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx := context.Background() + u, err := a.store.GetUserByName(ctx, args[0]) + if err != nil { + return fmt.Errorf("user %s: %w", args[0], err) + } + pw, err := readPassword("New password for "+u.Username+": ", stdin) + if err != nil { + return err + } + if u.PasswordHash, err = auth.HashPassword(pw); err != nil { + return err + } + u.Disabled = false + if err := a.store.UpdateUser(ctx, u); err != nil { + return err + } + a.store.DeleteUserSessions(ctx, u.ID, "") + a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.password", "user", u.ID, u.Username, "success", "jarld user passwd") + fmt.Printf("Password of %s changed.\n", u.Username) + return nil + }, + } + passwd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") + + token := &cobra.Command{ + Use: "token NAME [TOKEN-NAME]", + Short: "Create an API token for a user", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + ctx := context.Background() + u, err := a.store.GetUserByName(ctx, args[0]) + if err != nil { + return fmt.Errorf("user %s: %w", args[0], err) + } + name := "cli" + if len(args) > 1 { + name = args[1] + } + tok, err := createToken(ctx, a, u, name) + if err != nil { + return err + } + fmt.Println(tok) + return nil + }, + } + list := &cobra.Command{ + Use: "list", + Short: "List users", + RunE: func(cmd *cobra.Command, args []string) error { + a, err := openApp(true) + if err != nil { + return err + } + defer a.close() + users, err := a.store.ListUsers(context.Background()) + if err != nil { + return err + } + for _, u := range users { + flags := []string{} + if u.Admin { + flags = append(flags, "admin") + } + if u.Disabled { + flags = append(flags, "disabled") + } + if !u.HasPassword { + flags = append(flags, "no password") + } + var gs []string + for _, g := range u.Grants { + gs = append(gs, auth.FormatGrant(g)) + } + fmt.Printf("%-20s %-25s %s\n", u.Username, strings.Join(flags, ","), strings.Join(gs, "; ")) + } + return nil + }, + } + cmd.AddCommand(add, passwd, token, list) + return cmd +} diff --git a/contrib/acme.sh/dns_jarl.sh b/contrib/acme.sh/dns_jarl.sh new file mode 100755 index 0000000..76279a4 --- /dev/null +++ b/contrib/acme.sh/dns_jarl.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_jarl_info='Jarl (BIND manager) + Publishes DNS-01 challenge records through the jarl CLI. + Install: copy this file to ~/.acme.sh/dnsapi/dns_jarl.sh, then + acme.sh --issue --dns dns_jarl -d example.com -d "*.example.com" +Options: + JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured) + JARL_TOKEN API token, ideally one with the "acme" role (optional if the jarl CLI is configured) + JARL_VIEW Only write into this view (optional; default every view that has the zone) + JARL_BIN Path of the jarl binary (optional; default "jarl") +' + +dns_jarl_add() { + fulldomain=$1 + txtvalue=$2 + _jarl_setup || return 1 + _info "Jarl: adding TXT record for $fulldomain" + if ! _jarl acme set --exact "$fulldomain" "$txtvalue"; then + _err "Jarl: could not add the TXT record for $fulldomain" + return 1 + fi + return 0 +} + +dns_jarl_rm() { + fulldomain=$1 + txtvalue=$2 + _jarl_setup || return 1 + _info "Jarl: removing TXT record for $fulldomain" + if ! _jarl acme unset --exact "$fulldomain" "$txtvalue"; then + _err "Jarl: could not remove the TXT record for $fulldomain" + return 1 + fi + return 0 +} + +#################### private functions #################### + +_jarl_setup() { + JARL_SERVER="${JARL_SERVER:-$(_readaccountconf_mutable JARL_SERVER)}" + JARL_TOKEN="${JARL_TOKEN:-$(_readaccountconf_mutable JARL_TOKEN)}" + JARL_VIEW="${JARL_VIEW:-$(_readaccountconf_mutable JARL_VIEW)}" + JARL_BIN="${JARL_BIN:-$(_readaccountconf_mutable JARL_BIN)}" + JARL_BIN="${JARL_BIN:-jarl}" + if ! command -v "$JARL_BIN" >/dev/null 2>&1; then + _err "Jarl: the jarl CLI ($JARL_BIN) was not found; set JARL_BIN" + return 1 + fi + # Remember the settings for renewals. + [ -n "$JARL_SERVER" ] && _saveaccountconf_mutable JARL_SERVER "$JARL_SERVER" + [ -n "$JARL_TOKEN" ] && _saveaccountconf_mutable JARL_TOKEN "$JARL_TOKEN" + [ -n "$JARL_VIEW" ] && _saveaccountconf_mutable JARL_VIEW "$JARL_VIEW" + [ "$JARL_BIN" != "jarl" ] && _saveaccountconf_mutable JARL_BIN "$JARL_BIN" + return 0 +} + +_jarl() { + # jarl reads JARL_SERVER and JARL_TOKEN from the environment, and falls + # back to its own configuration file when they are empty. + if [ -n "$JARL_VIEW" ]; then + JARL_SERVER="$JARL_SERVER" JARL_TOKEN="$JARL_TOKEN" "$JARL_BIN" -q "$@" --view "$JARL_VIEW" + else + JARL_SERVER="$JARL_SERVER" JARL_TOKEN="$JARL_TOKEN" "$JARL_BIN" -q "$@" + fi +} diff --git a/deploy/systemd/jarl.service b/deploy/systemd/jarl.service new file mode 100644 index 0000000..3525c4b --- /dev/null +++ b/deploy/systemd/jarl.service @@ -0,0 +1,60 @@ +# Jarl daemon: web UI and REST API for managing BIND. +# Installed as /etc/systemd/system/jarl.service (see docs/install.md). +[Unit] +Description=Jarl BIND manager (web UI and API) +Documentation=file:///usr/local/share/doc/jarl/README.md +After=network-online.target named.service +Wants=network-online.target + +[Service] +Type=notify +NotifyAccess=main +ExecStart=/usr/local/bin/jarld --config /etc/jarl/jarl.yaml serve +# SIGHUP rereads the TLS certificate. +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=5s + +User=jarl +Group=jarl +# Membership of 'bind' lets jarld read /etc/bind/rndc.key and the rest of +# named's configuration (for rndc and named-checkconf) and give generated +# files to group bind so named can read them. +SupplementaryGroups=bind +UMask=0027 + +# /var/lib/jarl: database and staging area. +StateDirectory=jarl +StateDirectoryMode=0750 +ConfigurationDirectory=jarl +ConfigurationDirectoryMode=0750 + +# Sandboxing. jarld may only write its own state and the directories it +# manages for named (created by tmpfiles.d/jarl.conf). +ProtectSystem=strict +ReadWritePaths=/etc/bind/jarl /var/lib/bind/jarl /var/cache/bind/jarl +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +# @system-service includes @chown, which jarld needs to give files to group bind. +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +[Install] +WantedBy=multi-user.target diff --git a/deploy/sysusers.d/jarl.conf b/deploy/sysusers.d/jarl.conf new file mode 100644 index 0000000..dd504af --- /dev/null +++ b/deploy/sysusers.d/jarl.conf @@ -0,0 +1,4 @@ +# Jarl's system user (installed as /usr/lib/sysusers.d/jarl.conf). +# It joins group 'bind' to read named's configuration and rndc.key. +u jarl - "Jarl BIND manager" /var/lib/jarl +m jarl bind diff --git a/deploy/tmpfiles.d/jarl.conf b/deploy/tmpfiles.d/jarl.conf new file mode 100644 index 0000000..6dcf587 --- /dev/null +++ b/deploy/tmpfiles.d/jarl.conf @@ -0,0 +1,12 @@ +# Directories Jarl needs (installed as /usr/lib/tmpfiles.d/jarl.conf). +# Apply with: systemd-tmpfiles --create jarl.conf +# +# Type Path Mode User Group +d /etc/jarl 0750 root jarl +d /var/lib/jarl 0750 jarl jarl +# Generated named configuration: named reads it (AppArmor allows /etc/bind). +d /etc/bind/jarl 2750 jarl bind +# Primary zone files: named may write journals here (/var/lib/bind is writable for named). +d /var/lib/bind/jarl 2770 jarl bind +# Files named writes for secondary, stub and mirror zones. +d /var/cache/bind/jarl 2770 jarl bind diff --git a/docs/acme.md b/docs/acme.md new file mode 100644 index 0000000..0196008 --- /dev/null +++ b/docs/acme.md @@ -0,0 +1,130 @@ +# Let's Encrypt (ACME DNS-01) with Jarl + +The DNS-01 challenge proves that you control a domain by publishing a TXT +record at `_acme-challenge.`. Jarl does this through its API, so no +dynamic updates (`allow-update`, nsupdate) are needed. That matters because +Jarl regenerates zone files and doesn't allow dynamic zones. + +**When a `jarl acme` command returns, named is already serving the record.** +Jarl reloads the zone and checks the new serial with `rndc zonestatus` +before it reports success, so hooks need no "sleep and hope" delay. If you +also run secondary servers, they pick the change up through NOTIFY within +seconds. Add a short `--dns-propagation-seconds`/`--dnssleep` only if your +CA checks them too. + +## 1. A token that can only publish challenges + +```sh +jarl user create certbot --no-password --role acme --comment "Let's Encrypt" +jarl token create certbot --user certbot --role acme -q > certbot.token +sudo install -m 0600 certbot.token /etc/jarl/certbot.token && rm certbot.token +``` + +The `acme` role is `records:write names=_acme-challenge.* types=TXT`. The +token can add and remove challenge TXT records in any zone and nothing +else. To restrict it to your zones, use an explicit grant instead: + +```sh +jarl user create certbot --no-password --grant "records:write zones=adbraun.lol names=_acme-challenge.* types=TXT" +``` + +## 2. What `jarl acme` does + +```sh +jarl acme set DOMAIN VALUE... # publish +jarl acme unset DOMAIN [VALUE...] # remove these values, or all of them +``` + +* `DOMAIN` is the name on the certificate. `_acme-challenge.` is added for + you, and a leading `*.` is dropped, since a wildcard and its apex share one + challenge name. A name that already starts with `_acme-challenge.` is used + as it is. +* The zone is found automatically: the longest matching primary zone. +* If the zone exists in several **views** (split DNS), the record is written + into every view, so the CA (on the internet, in your external view) and + your internal resolvers see the same thing. `--view NAME` limits it to one. +* All values and views go into one changeset. Setting a value that is + already there succeeds, so a retried hook doesn't fail, and removing a + value that isn't there succeeds as well. +* The record TTL is 60 seconds (`--ttl`). + +## 3. certbot + +```sh +sudo certbot certonly --manual --preferred-challenges dns \ + --manual-auth-hook 'jarl --token @/etc/jarl/certbot.token acme certbot-auth' \ + --manual-cleanup-hook 'jarl --token @/etc/jarl/certbot.token acme certbot-cleanup' \ + -d adbraun.lol -d '*.adbraun.lol' +``` + +The hooks read `CERTBOT_DOMAIN` and `CERTBOT_VALIDATION` from certbot. Add +`--server https://ns1.adbraun.lol:8053` when certbot runs on another host, +or `--server http://127.0.0.1:8053` on the DNS server itself (root's jarl +CLI has no configuration file). certbot stores the hooks in the renewal +configuration, so `certbot renew` works unattended. + +## 4. acme.sh + +Install the DNS API hook that comes with Jarl: + +```sh +cp contrib/acme.sh/dns_jarl.sh ~/.acme.sh/dnsapi/ # or from /usr/local/share/doc/jarl/contrib/ +export JARL_SERVER=https://ns1.adbraun.lol:8053 +export JARL_TOKEN=$(cat /etc/jarl/certbot.token) +acme.sh --issue --dns dns_jarl -d adbraun.lol -d '*.adbraun.lol' +``` + +The hook calls the `jarl` CLI, which must be installed on the same host. It +remembers `JARL_SERVER`, `JARL_TOKEN`, `JARL_VIEW` (optional) and `JARL_BIN` +(path to jarl, optional) in acme.sh's account configuration for renewals. +When they are unset, jarl's own configuration file is used. + +## 5. A dedicated ACME zone (CNAME delegation) + +Instead of letting a token write into your main zone, point each challenge +name at a small zone that exists only for ACME. The token may then write +only there: + +```sh +jarl zone create acme.adbraun.lol --ns ns1.adbraun.lol. +jarl record add adbraun.lol acme NS ns1.adbraun.lol. # delegate it +jarl record add adbraun.lol _acme-challenge CNAME apex.acme.adbraun.lol. +jarl record add adbraun.lol _acme-challenge.www CNAME www.acme.adbraun.lol. + +jarl user create acme --no-password --grant "records:write zones=acme.adbraun.lol types=TXT" +jarl token create acme --user acme -q > acme.token +``` + +The CA follows the CNAME, so the TXT record has to be written at its +target: + +* **certbot**: add `--follow-cname` to both hooks, e.g. + `jarl --token @/etc/jarl/acme.token acme certbot-auth --follow-cname`. + jarl looks up `_acme-challenge.` and writes the TXT record at the + target (`www.acme.adbraun.lol`). The lookup uses the host's resolvers. + Add `--resolver 127.0.0.1` to ask the Jarl server directly, for example + when the host's resolver sees a different view. +* **acme.sh** does the lookup itself: + * `--challenge-alias acme.adbraun.lol` puts every TXT record at + `_acme-challenge.acme.adbraun.lol`. Point all the CNAMEs there + (`_acme-challenge.www CNAME _acme-challenge.acme.adbraun.lol.`). + * `--domain-alias www.acme.adbraun.lol` writes at exactly that name. + + The hook passes acme.sh's full record name to `jarl acme --exact`, so both + modes work. + +A leaked ACME token can then only touch TXT records in the ACME zone, and +the main zone is never modified by automation. + +## Troubleshooting + +* `no primary zone that you can see contains ...`: the token's grants + don't cover the zone, or the zone isn't a primary zone in Jarl. +* `permission denied: this needs records:write ...` (exit code 3): the + record name or type isn't allowed for the token. Compare with + `jarl --token @FILE whoami`. +* The CA says the TXT record is missing: check from outside with + `dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make + sure the record is in the view that matches the CA (it is by default) + and that `allow-query` lets the internet query the zone. +* `jarl audit --user certbot` shows every attempt. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..d2f891e --- /dev/null +++ b/docs/api.md @@ -0,0 +1,310 @@ +# REST API reference + +Everything the web UI and the CLI do goes through this API. The UI only uses +what is documented here. + +* Base URL: `https:///api/v1` +* JSON in, JSON out (`Content-Type: application/json`). Request bodies are + limited to 16 MiB (1 MiB for login). +* Times are RFC 3339. Names in responses are FQDNs with the final dot + (`www.adbraun.lol.`), and display helpers drop it (`display_name`, + `relative_name`). +* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`. + +## Authentication + +**API tokens**, for scripts and the CLI: + +```sh +curl -H "Authorization: Bearer jarl_xxxxxxxx_yyyyyyyy" https://ns1.adbraun.lol:8053/api/v1/zones +``` + +**Sessions**, as used by the web UI: `POST /auth/login` with +`{"username": "...", "password": "..."}` sets the `jarl_session` cookie and +returns the `whoami` object, which includes `csrf_token`. Requests that +change something (anything but GET/HEAD) must send it as `X-CSRF-Token`. +`POST /auth/logout` ends the session. + +`GET /auth/whoami` returns the user, the token (if any), the effective +permissions and whether the caller is an administrator. + +## Errors + +```http +HTTP/1.1 422 Unprocessable Entity + +{ + "error": { + "code": "validation_failed", + "message": "www.adbraun.lol has a CNAME, so it can't have any other records (found A); ...", + "problems": [ + {"severity": "error", "code": "cname_conflict", "message": "...", "object_type": "record", + "object_id": 15, "zone": "adbraun.lol.", "view": "internal", "name": "www.adbraun.lol."} + ], + "check": [ ... named-checkconf messages, when it ran ... ], + "op": 2, + "result": { ... the full result, as for a dry run ... } + } +} +``` + +| Status | `code` | Meaning | +|--------|--------|---------| +| 400 | `bad_request` | Malformed JSON, unknown field value, record data that doesn't parse | +| 401 | `unauthenticated` | No or invalid credentials | +| 403 | `forbidden`, `csrf` | Not allowed, or missing CSRF token | +| 404 | `not_found` | No such object (or not visible to you) | +| 409 | `conflict`, `drift`, `not_adopted` | Object changed since it was staged, object in use, name taken, files changed outside Jarl | +| 422 | `validation_failed` | The resulting configuration would be invalid: see `problems` (Jarl's checks, [validation.md](validation.md)) and `check` (named-checkconf / named-checkzone messages) | +| 429 | `rate_limited` | Too many failed logins | +| 502 | `bind_failed` | A BIND tool couldn't run, writing the files failed, or named did not load the change (the previous files were restored) | + +`op` is the 1-based index of the operation that failed in a batch. + +## Changing things: apply, stage or dry-run + +Every endpoint that changes the configuration (POST, PATCH, PUT and DELETE +on the resources below, except users and tokens) works the same way: + +| Request | Effect | Status | +|---------|--------|--------| +| plain | Applied immediately as a one-operation changeset | 201 (create) / 200 | +| `?changeset=ID` or header `X-Jarl-Changeset: ID` | Staged into the open changeset `ID` (validated against the changeset's working state, not applied) | 202 | +| `?dry_run=1` | Validated, rendered and checked with named-checkconf, nothing installed | 200 | +| `?force=1` | Overwrite files that were changed outside Jarl | | + +Reads accept `?changeset=ID` too. They then show the configuration as it +will be after the changeset, with `"pending": "add" | "change" | "delete"` +on affected zones and records. + +The response of an applied change: + +```json +{ + "applied": true, + "changeset_id": 37, + "result": { + "valid": true, + "problems": [], + "existing_problems": [ ... warnings that were already there ... ], + "check_ok": true, + "diff": "--- /var/lib/bind/jarl/internal/adbraun.lol.zone\n+++ ...", + "files_changed": ["/var/lib/bind/jarl/internal/adbraun.lol.zone"], + "serials": [{"zone": "adbraun.lol.", "view": "internal", "old": 2026100715, "new": 2026100716}], + "reloaded": ["reload adbraun.lol [internal]"], + "ops": [{"seq": 1, "op": "record.create", "id": 295, "summary": "add doc.adbraun.lol. 300 IN A 192.168.1.77"}] + }, + "object": { ... the created or updated object ... }, + "warnings": [] +} +``` + +A staged change returns `{"staged": true, "changeset_id": ..., "added": [ops], "problems": [...]}`. +Staging checks right away that the request doesn't introduce validation +errors into the changeset's working state (a request with several +operations is checked at its end state) and returns 422 if it does. +named-checkconf runs on preview and apply, and the whole changeset is +validated again when it is applied. + +**Optimistic locking.** Every object has a `version`. `PATCH /records/{id}` +honours `If-Match: `, and batch operations take `base_version`. If +the object changed in the meantime the request fails with 409. Staged +operations record the version they were staged against automatically. + +## Resources + +`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME` +when the name exists in several views. `{id}` for views, ACLs and TSIG keys +may be the name. + +### Zones + +| Method | Path | Body / query | +|--------|------|--------------| +| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) | +| POST | `/zones` | ZoneInput (below) | +| GET | `/zones/{zone}` | | +| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) | +| DELETE | `/zones/{zone}` | deletes the zone with its records and options | +| GET | `/zones/{zone}/file` | the generated zone file (text/plain) | +| POST | `/zones/{zone}/import` | `{"text": "", "mode": "merge"\|"replace", "use_soa": false, "dry_run": false}` | + +ZoneInput: + +```json +{ + "name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone + "view": "internal", // or "view_id"; moving a zone = PATCH with another view + "type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view + "in_view": "internal", // type in-view only + "file": "...", // hint/redirect zones, or a custom file for secondaries + "soa": {"mname": "ns1.adbraun.lol.", "rname": "hostmaster@adbraun.lol", "serial": 0, + "refresh": 3600, "retry": 900, "expire": 1209600, "minimum": 300}, + "default_ttl": 3600, + "serial_policy": "date", // date, increment, unixtime + "comment": "", + // create only: + "nameservers": ["ns1.adbraun.lol."], // default: settings + "records": [{"name": "ns1", "type": "A", "data": "192.0.2.1"}], + "options": [{"name": "allow-transfer", "value": "{ key xfer; }"}], + "clone_from": 12 // copy records from zone 12 +} +``` + +### Records + +| Method | Path | Body / query | +|--------|------|--------------| +| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=` | +| POST | `/zones/{zone}/records` | a RecordInput, or an array of them (one changeset) | +| DELETE | `/zones/{zone}/records` | `?name=&type=[&data=][&missing_ok=1][&ptr=1]`: delete by match | +| PUT | `/zones/{zone}/rrsets/{name}/{type}` | `{"data": ["192.0.2.1", "192.0.2.2"], "ttl": 60, "comment": "..."}`: make the RRset exactly this | +| DELETE | `/zones/{zone}/rrsets/{name}/{type}` | delete the whole RRset | +| GET | `/records/{id}` | | +| PATCH | `/records/{id}` | RecordInput fields to change (`If-Match` supported) | +| DELETE | `/records/{id}` | `?ptr=1` also deletes the matching PTR | + +RecordInput: + +```json +{ + "name": "www", // relative to the zone, "@" for the apex, or an FQDN with a final dot + "type": "A", + "data": "192.0.2.10", // zone file syntax; TXT/SPF take plain text + "ttl": 300, // null or absent: the zone's default TTL + "comment": "web server", + "ptr": true // A/AAAA: also create/move/delete the PTR in the reverse zone +} +``` + +Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text` +(the decoded value of TXT records). + +### Views + +| Method | Path | Body | +|--------|------|------| +| GET | `/views` | in match order | +| POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` | +| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones | +| POST | `/views/order` | `{"ids": [3, 1, 2]}` | + +`adopt_zones` moves zones that are not in a view into the new view. named +requires every zone to be in a view as soon as one view exists. + +### ACLs and TSIG keys + +| Method | Path | Body | +|--------|------|------| +| GET | `/acls` | each with `used_by` | +| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` | +| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 | +| GET | `/tsig-keys` | without secrets | +| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": ""}` (secret generated when absent; the response `object` shows it once) | +| GET | `/tsig-keys/{id}` | `?secret=1` includes the secret (needs `tsig:write`, audited) | +| PATCH / DELETE | `/tsig-keys/{id}` | PATCH `{"secret": "..."}` rotates | + +### Options and settings + +| Method | Path | Body / query | +|--------|------|--------------| +| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see) | +| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` | +| GET / PATCH / DELETE | `/options/{id}` | | +| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` | + +Option values are named.conf syntax without the final semicolon. They are +checked against the BIND 9.20 grammar for that scope. + +### Catalogs and search + +| Method | Path | Returns | +|--------|------|---------| +| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself | +| GET | `/catalog/rrtypes` | record types with field hints and examples | +| GET | `/catalog/grants` | resources, roles and the grant syntax | +| GET | `/search?q=text` | zones, records and ACLs matching the text | + +### Changesets + +| Method | Path | Body / query | +|--------|------|--------------| +| POST | `/apply` | `{"description": "...", "ops": [Operation...], "dry_run": false, "force": false}`: apply several operations as one changeset | +| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` | +| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) | +| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` | +| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations | +| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation | +| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff | +| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` | +| POST | `/changesets/{id}/discard` | | +| POST | `/changesets/{id}/revert` | creates a new open changeset that undoes an applied one | + +An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`: + +| `op` | `id` | `data` | +|------|------|--------| +| `zone.create` / `zone.update` / `zone.delete` | zone (update/delete) | ZoneInput | +| `record.create` | | RecordInput + `"zone": "adbraun.lol"` (or `zone_id`) and `"view"` | +| `record.update` | record | RecordInput | +| `record.delete` | record, or 0 with `zone`, `name`, `type`, optional `data`, `missing_ok`, `ptr` | | +| `rrset.replace` | | `{"zone", "view", "name", "type", "ttl", "data": [...]}` (empty data deletes the RRset) | +| `view.create` / `view.update` / `view.delete` / `view.reorder` | view (update/delete) | ViewInput / `{"ids": [...]}` | +| `acl.create` / `acl.update` / `acl.delete` | ACL | ACL fields | +| `tsig.create` / `tsig.update` / `tsig.delete` | key | key fields | +| `option.create` / `option.update` / `option.delete` | option | option fields | +| `settings.update` | | settings fields | + +Operations later in a batch can refer to objects created earlier in it by +name (zone and view names). One request can therefore create a view, a zone +in it and its records. + +### Status, system, audit + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts | +| POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) | +| POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) | +| GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` | + +### Users and tokens + +| Method | Path | Body | +|--------|------|------| +| GET | `/users` | admin | +| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) | +| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled` | +| GET / POST | `/users/{id}/tokens` | admin (or yourself) | +| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once | +| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens | +| DELETE | `/tokens/{id}` | revoke | +| POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) | + +User and token changes take effect immediately. They are not changesets, +because they don't touch named, but they are audited. + +## Examples + +```sh +API=https://ns1.adbraun.lol:8053/api/v1 +AUTH="Authorization: Bearer $JARL_TOKEN" + +# add a record and its PTR +curl -sf -H "$AUTH" -H 'Content-Type: application/json' -X POST "$API/zones/adbraun.lol/records" \ + -d '{"name": "nas", "type": "A", "data": "192.168.1.20", "ptr": true}' + +# swap an A record for a CNAME in one changeset +curl -sf -H "$AUTH" -H 'Content-Type: application/json' -X POST "$API/apply" -d '{ + "description": "www is now an alias", + "ops": [ + {"op": "record.delete", "data": {"zone": "adbraun.lol", "name": "www", "type": "A"}}, + {"op": "record.create", "data": {"zone": "adbraun.lol", "name": "www", "type": "CNAME", "data": "web1"}} + ]}' + +# stage, preview, apply +CS=$(curl -sf -H "$AUTH" -X POST "$API/changesets" -d '{"description": "lab"}' | jq .id) +curl -sf -H "$AUTH" -H "X-Jarl-Changeset: $CS" -X PUT "$API/zones/lab.adbraun.lol/rrsets/gw/A" -d '{"data": ["10.20.0.1"]}' +curl -sf -H "$AUTH" "$API/changesets/$CS/preview" | jq -r .diff +curl -sf -H "$AUTH" -X POST "$API/changesets/$CS/apply" +``` diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..6d48325 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,238 @@ +# The `jarl` command line client + +`jarl` talks to jarld's REST API with an API token. It runs on the DNS server +or on any machine that can reach jarld. Every command has `--help` with +examples, and `jarl completion bash|zsh|fish` prints shell completion. + +## Setup + +```sh +jarl config set server https://ns1.adbraun.lol:8053 +jarl config set token # paste the token at the prompt +jarl whoami +``` + +| Setting | Flag | Environment | Config file key | +|---------|------|-------------|-----------------| +| Server URL | `--server` | `JARL_SERVER` | `server` | +| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` | +| Skip TLS verification | `--insecure` | | `insecure` | +| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` | +| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | | + +Flags win over the environment, and the environment wins over the file. The +file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` / +`JARL_CLI_CONFIG`. `jarl config show` prints it with the token masked. + +Get tokens from the web UI (Account → API tokens), from +`jarl token create`, or from `jarld init --token` on the server. + +## Global flags + +| Flag | Meaning | +|------|---------| +| `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. | +| `-n, --dry-run` | Validate and show the diff without changing anything. | +| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. | +| `--force` | Overwrite BIND files that were changed outside Jarl (drift). | +| `-q, --quiet` | Print only errors, or just the ID where a command creates something. | + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Other error (bad arguments, connection failure, ...) | +| 3 | Not authenticated, or not allowed (token lacks the permission) | +| 4 | Not found | +| 5 | Conflict: concurrent change, object in use, drift | +| 6 | Invalid request, or rejected by validation (e.g. a CNAME conflict, or named-checkconf found an error) | +| 7 | Server error, or BIND failed: a tool couldn't run, or named didn't load the change (it was rolled back) | + +Validation errors are printed with the offending zone and view: + +``` +$ jarl record add adbraun.lol www A 192.168.1.99 +error: www.adbraun.lol has a CNAME, so it can't have any other records (found A); a CNAME must be the only record at its name (RFC 1034) [zone adbraun.lol] +$ echo $? +6 +``` + +## Zones + +```sh +jarl zone list [--view V] [--type primary] +jarl zone show adbraun.lol +jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol. +jarl zone create 192.168.20.0/24 # reverse zone from a network +jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal +jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer" +jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600" +jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200 +jarl zone update adbraun.lol --move-to-view internal +jarl zone delete lab.adbraun.lol [-y] +jarl zone file adbraun.lol # the generated zone file +jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa] +``` + +`zone import` adds the records of a zone file (`-` reads stdin). With +`--replace` the zone is made equal to the file, so records that aren't in it +are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on +`zone create` sets any zone option in the same step. + +When a zone exists in several views, add `--view NAME` to commands that +name it. + +## Records + +```sh +jarl record list adbraun.lol [--name www] [-t A] [--search nas] +jarl record add adbraun.lol nas A 192.168.1.20 --ptr --comment "synology" +jarl record add adbraun.lol @ MX 10 mail +jarl record add adbraun.lol @ TXT "v=spf1 mx -all" # plain text, quoting is automatic +jarl record add adbraun.lol _sip._tcp SRV 0 5 5060 sip +jarl record add adbraun.lol @ CAA 0 issue "letsencrypt.org" +jarl record set adbraun.lol api A 192.168.1.30 192.168.1.31 --ttl 60 # replace the RRset +jarl record update 1234 --data 192.168.1.21 --ptr # IDs from 'record list' +jarl record update 1234 --ttl default +jarl record delete adbraun.lol www A # every A record of www +jarl record delete adbraun.lol www A 192.168.1.10 # just this one +jarl record delete --id 1234 [--ptr] +``` + +Names are relative to the zone (`@` is the apex) unless they end with a +dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl +adds the quotes and splits long values into 255-byte strings. `--ptr` +keeps the PTR record in the reverse zone in step (when that zone is managed +by Jarl). Every record type BIND supports can be used. Jarl knows the +syntax of the common ones and checks them strictly, and the rest are +validated by parsing. + +## Batches: `jarl apply -f` + +Several changes that must succeed or fail together: + +```yaml +# move-web.yaml +description: move the web server to the new VM +changes: + - add: {zone: adbraun.lol, name: web2, type: A, data: 192.168.1.40, ttl: 300, ptr: true} + - delete: {zone: adbraun.lol, name: www, type: CNAME} + - add: {zone: adbraun.lol, name: www, type: CNAME, data: web2} + - set: {zone: adbraun.lol, name: api, type: A, data: [192.168.1.41, 192.168.1.42], ttl: 60} + - update: {id: 1235, comment: "old web server, remove in November"} + - delete: {id: 1240} + - op: acl.update # any API operation (see api.md) + id: 3 + data: {elements: [192.168.1.0/24, 10.10.0.0/16]} +``` + +```sh +jarl apply -f move-web.yaml --dry-run # check and show the diff +jarl apply -f move-web.yaml +``` + +The whole file is validated as one changeset, at its end state, so the +order of the entries doesn't matter (the CNAME swap above works either way +round). Add `view: NAME` to entries +when a zone exists in several views. JSON works as well as YAML, and `-f -` +reads standard input. + +## Changesets: stage, review, apply + +```sh +export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q) +jarl record set lab.adbraun.lol gw A 10.20.0.1 +jarl record set lab.adbraun.lol nas A 10.20.0.10 +jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff +jarl changeset remove $JARL_CHANGESET 2 # drop operation #2 +jarl changeset apply $JARL_CHANGESET +unset JARL_CHANGESET + +jarl changeset list [--status open|applied|discarded] [--mine] +jarl changeset show 42 +jarl changeset discard 43 +jarl history [--limit 50] +jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset +``` + +Changesets staged in the web UI show up here too, and the other way round. + +## Views, ACLs, TSIG keys, options, settings + +```sh +jarl view create internal --match-clients homelab,localhost --adopt-zones +jarl view create external --match-clients any +jarl view order internal external +jarl view update external --match-clients "!homelab,any" +jarl view delete old [--cascade] + +jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost +jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16 +jarl acl update homelab --rename lan # references are updated +jarl acl delete lan # refused while in use + +jarl tsig create xfer [--algorithm hmac-sha512] +jarl tsig show xfer --named-conf # paste into the secondary's named.conf +jarl tsig rotate xfer +jarl tsig delete xfer + +jarl option list [--view V | --zone Z | --toplevel | --all] +jarl option set recursion yes +jarl option set allow-recursion '{ homelab; localhost; }' +jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }' +jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol +jarl option set response-policy '{ zone "rpz.local"; }' --view internal +jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel +jarl option unset forwarders +jarl option describe # every option allowed at this scope +jarl option describe allow-query-cache # grammar and ARM description + +jarl settings show +jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600 +``` + +## Users, tokens, audit (administration) + +```sh +jarl user list +jarl user create alice --role dns-editor # asks for a password +jarl user create ci --no-password --grant "records:write zones=lab.adbraun.lol" +jarl user update alice --admin +jarl user update alice --password # set a new password +jarl user update bob --disabled # logs bob out, blocks his tokens +jarl user delete bob -y + +jarl token list [--user alice | --all] [--revoked] +jarl token create laptop --expires 365d +jarl token create backup-script --grant "*:read" +jarl token create acme --user certbot --role acme +jarl token revoke 7 + +jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100] +jarl search 192.168.1.20 +jarl status # named, adoption, drift, open changesets, problems +jarl check # validation + named-checkconf of the current configuration +jarl resync [--force] [--dry-run] # rewrite every file from the database +``` + +Grant syntax and roles are in [permissions.md](permissions.md). + +## ACME DNS-01 + +```sh +jarl acme set www.adbraun.lol # TXT at _acme-challenge.www.adbraun.lol +jarl acme set '*.adbraun.lol' # wildcard + apex use the same name +jarl acme unset www.adbraun.lol [] +jarl acme certbot-auth / certbot-cleanup # certbot manual hooks +``` + +See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone. + +## Scripting tips + +* `-o json` gives full API objects. Pipe them to `jq`. +* `-q` with `create` commands prints just the ID or token. +* Check `$?` against the exit code table. +* `jarl record delete ... --missing-ok` succeeds when nothing matches, which + keeps cleanup scripts idempotent. `jarl record set` is idempotent by nature. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..46b7f3c --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,221 @@ +# How Jarl works + +This page explains Jarl's design: where the data lives, how a change reaches +named, and why it works that way. Read it once before you adopt your server. + +## The pieces + +``` + browser ──(session + CSRF)──┐ + ├──> jarld ──> SQLite DB (source of truth) + jarl CLI ──(API token)──────┤ │ + scripts / certbot ──(token)─┘ ├──> /etc/bind/jarl/named.conf, keys.conf + ├──> /var/lib/bind/jarl/*.zone + └──> named-checkconf / named-checkzone / rndc ──> named +``` + +* **jarld** is one Go binary. It serves the REST API and the web UI (embedded + in the binary; the UI uses only the public API), and it is the only thing + that writes BIND's files. It runs as the unprivileged user `jarl` under + systemd. +* **jarl** is the command line client. It talks to jarld's API with an API + token, so it works from any machine that can reach jarld. +* **The database** (SQLite, `/var/lib/jarl/jarl.db`) holds the whole BIND + configuration as structured data: views, ACLs, TSIG keys, zones, records, + options, and the defaults for new zones. It also holds users, tokens, + sessions, changesets and the audit log. + +## The database is the source of truth + +Jarl never edits BIND's files in place. Every time a change is applied it +renders the complete configuration from the database: + +| File | Contents | +|------|----------| +| `/etc/bind/jarl/named.conf` | ACLs, options, views, zone statements, top-level statements such as `logging` and `controls` | +| `/etc/bind/jarl/keys.conf` | TSIG keys. They are kept apart so secrets never show up in diffs, history or the audit log | +| `/var/lib/bind/jarl//.zone` | One file per primary zone (`_default` when views aren't used) | +| `/var/cache/bind/jarl/...` | Written by named itself for secondary, stub and mirror zones | + +`jarld adopt` turns `/etc/bind/named.conf` into a single +`include "/etc/bind/jarl/named.conf";`, so named loads only what Jarl +generates. Zone files go under `/var/lib/bind` because Debian's AppArmor +profile lets named write there. That keeps journals and future DNSSEC +signing working. + +Because files are always regenerated, anything you edit by hand is +overwritten. Jarl records a checksum of every file it writes and reports +**drift** when a file changes behind its back. It then refuses to overwrite +the file until you say so (`--force`, or `jarl resync --force`). + +Comments are part of the data model: zones, records, ACLs, views, keys and +options can all carry a comment, and Jarl writes it into the generated file. +Comments in imported zone files are kept. + +## Changesets: one change or a bundle, it's the same pipeline + +Your brief asked whether each change should be checked and applied on its +own, or whether changes should be bundled and applied together. Jarl does +both, using a single mechanism: + +* A **changeset** is an ordered list of operations (create a zone, add a + record, rename an ACL, ...). Applying one is all-or-nothing. +* Every API call that changes something is applied at once **as a one-operation + changeset** by default. Nothing extra is needed for small edits. +* To bundle changes, create a changeset and *stage* operations into it + (API: `?changeset=ID` or the `X-Jarl-Changeset` header; CLI: `--changeset ID` + or `JARL_CHANGESET`). Review the diff, then apply or discard it. +* `POST /api/v1/apply` (CLI: `jarl apply -f changes.yaml`) applies a list of + operations as one changeset in a single request. +* `?dry_run=1` (CLI: `--dry-run`) runs the whole pipeline except installing + files and reloading named, and shows the problems and the diff. + +**Why bundling matters.** Some changes only make sense together. Turning +`www` from an A record into a CNAME means deleting the A record and adding +the CNAME, and named must never see the zone with neither, or with a +half-finished renumbering. A changeset reaches named in one step or not at +all. + +Operations sent **in one request** (`jarl apply -f`, `POST /apply`, or one +staging request with several operations) are validated together, at their +end state, so their order doesn't matter. When operations are staged **one +at a time** (the web UI's review mode, `--changeset` on single commands), +each one is checked as it is added. You get immediate feedback, so delete +the A record before adding the CNAME. At apply time the whole changeset is +validated again against the configuration as it is then. + +**Interfaces use different defaults**, because they are used differently: + +| Interface | Default | Why | +|-----------|---------|-----| +| Web UI | *Review mode*: edits are collected in a draft changeset, the zone pages show pending rows (green = new, yellow = changed, struck out = deleted), and a banner leads to the review page, which has a diff and an Apply button. Users can switch to "Apply edits immediately". | People click through several related edits and want to see the result before it goes live. | +| CLI | *Immediate*: every command is applied (and validated) on its own. Bundle with `jarl apply -f` or `--changeset`. | Scripts want a clear success or failure per step, and ACME hooks need the record live when the command returns. | +| API | Immediate unless a changeset is named. | Same as the CLI. | + +### What happens when a changeset is applied + +1. **Replay.** The operations are replayed on a copy of the current + configuration, checking permissions and (for staged operations) that the + objects haven't changed since they were staged (optimistic locking via + object versions). Concurrent edits produce a `409 conflict` instead of + silently overwriting each other. +2. **Validate.** The resulting configuration is checked as a whole (see + [validation.md](validation.md)): DNS rules such as CNAME exclusivity, + glue, delegations and RRset TTLs; references to ACLs and keys; option + syntax and scope against the BIND 9.20 grammar; and security lints such + as open resolvers. Errors block the change, while warnings are shown and + allowed. Problems that already existed are reported separately and don't + block unrelated changes. +3. **Render and check.** The files are rendered into a staging directory and + checked with the real `named-checkconf -z` and `named-checkzone`. Their + messages are mapped back to the object that caused them. +4. **Install.** Changed files are written atomically (temp file + rename), + keeping the previous contents in memory. +5. **Reload.** `rndc reconfig` if `named.conf` changed, then `rndc reload ZONE` + for each changed zone. +6. **Verify.** named loads zones asynchronously and an `rndc reload` that + fails only shows up in named's log. So Jarl polls `rndc zonestatus` until + every changed zone reports its new serial. If one doesn't within + `reload_timeout` (15s), Jarl restores the previous files, reloads again + and reports the failure. +7. **Record.** The changeset is stored with its result, its diff and the + *undo operations*, so it can be reverted later (`jarl changeset revert ID`, + or the Revert button in History). A revert is itself a changeset you + review before applying. + +Serials are bumped automatically, and only when a zone's DNS data actually +changes (a comment-only edit doesn't bump it). Each zone follows its serial +policy: `date` (YYYYMMDDnn, the default), `increment` or `unixtime`, using +RFC 1982 arithmetic. + +## Model: views, zones, records, options + +* **Views** are ordered; named uses the first view whose `match-clients` and + `match-destinations` match. Once one view exists, named requires every + zone to be in a view, so creating the first view offers to move existing + zones into it (`adopt_zones`). +* **Zones** have a type: `primary`, `secondary`, `stub`, `static-stub`, + `forward`, `mirror`, `hint`, `redirect`, or `in-view` (share a zone from + another view). Only primary zones have records in Jarl. The same zone name + can exist in several views (split DNS); copy one with `clone_from`. + A CIDR such as `192.168.1.0/24` or `2001:db8::/48` creates the matching + reverse zone. +* **Records** are stored canonically (owner FQDN, type, TTL or "zone + default", data in presentation format). TXT data is entered as plain text; + Jarl quotes it and splits it into 255-byte strings. Relative names are + resolved against the zone, and `www.example.com` without a final dot in a + zone `example.com` is caught as the classic `www.example.com.example.com.` + mistake. Adding an A/AAAA record with `ptr` also maintains the PTR record in + the matching reverse zone. +* **Options** are named.conf statements attached to a scope: server + `options { }`, a view, a zone, or the top level (`logging`, `controls`, + `statistics-channels`, `include`, ...). The UI and CLI know every BIND 9.20 + statement (353 of them, from the ARM): where it may appear, its grammar, + and whether it is deprecated. Values are written in named.conf syntax, and + Jarl checks them before named does. Statements that Jarl manages itself + (`zone`, `view`, `acl`, `key`, a zone's `type`/`file`, a view's + `match-clients`, ...) can't be set as raw options. +* **Settings** are Jarl's defaults for new zones: name servers, SOA contact + and timers, default TTL, serial policy. + +ACLs and TSIG keys are referenced by name. Jarl tracks every reference +(other ACLs, `match-clients`, `allow-*` options, `primaries ... key`, ...): +deleting something that is still in use is refused with the list of users, +and renaming updates every reference. + +## Authentication and permissions + +Your brief suggested HTTP basic auth for the web UI, with the web app +holding an API key. Jarl keeps the idea (each person has a username and +password, and every request is tied to a user and checked against that +user's permissions) but uses a login form and a session instead of basic +auth: + +* Basic auth sends the password with every request and can't log out. + Browsers cache it until they restart, and it can't be rate limited + per user in a friendly way. +* A login form creates a session: an HttpOnly, SameSite=Strict cookie that + expires after 12 hours (2 hours idle), plus a CSRF token the UI sends with + every change. Failed logins are rate limited (8 per username and 20 per IP + per 15 minutes). Passwords are hashed with argon2id. +* The web UI has no key of its own. It calls the same API as the CLI, + authenticated as the logged-in user, so the audit log always names the + person rather than "the web app". + +The CLI and scripts use **API tokens** (`jarl__`). Only a hash +of each token is stored, and the full token is shown once. + +* Each token belongs to a user. By default it has the user's permissions, + or it can be **restricted** to a subset with its own grants and given an + expiry. A token can never do more than its user. +* **Grants** are granular: resource (`zones`, `records`, `views`, `acls`, + `tsig`, `options`, `audit`, `*`) × access (`read`/`write`), optionally + narrowed by zone, view, record name pattern and record type. For example, + `records:write zones=adbraun.lol names=_acme-challenge.* types=TXT` is + what the ready-made `acme` role contains. +* Administrators can do everything, including managing users and server + internals. Admin rights are never available through a restricted token. + +Details: [permissions.md](permissions.md). + +## Audit log + +Jarl records every login (successful, failed or blocked by the rate +limit), every failed token, every changeset (created, staged, applied or +failed, discarded, reverted), token creation and revocation, user change, +password change, TSIG secret reveal and adoption. Each event has the user, +how they authenticated (session, which token, or local), the client IP and +the outcome. Tokens also remember when and from where they were last used. It is in the database +(searchable in the UI and with `jarl audit`) and is also written to the +journal through jarld's log. + +## What Jarl deliberately doesn't do (yet) + +* **Dynamic updates** (`allow-update`, `update-policy`): named would write + to zone files that Jarl regenerates, so the validator rejects them on Jarl + zones. Use the API (for example `jarl acme`) instead of nsupdate. +* **DNSSEC signing**: not managed yet. The zone directory is + named-writable, so `dnssec-policy` can be added later. +* **Several servers**: Jarl manages the named on its own host. Secondaries + elsewhere get zones by normal AXFR/IXFR (`allow-transfer`, `also-notify`, + TSIG keys). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..61c5749 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,103 @@ +# jarld configuration (`/etc/jarl/jarl.yaml`) + +jarld reads `/etc/jarl/jarl.yaml` (or the file given with `-c/--config`, +which every jarld subcommand accepts). Every setting is optional. The +defaults suit Debian/Ubuntu with BIND 9.20. Unknown keys are an error, so +typos don't go unnoticed. + +```sh +jarld config --example # a commented example file with every setting +jarld config # the effective configuration (file + defaults) +``` + +The CLI has its own small configuration file, described in [cli.md](cli.md). + +## Settings + +### Web UI and API + +| Key | Default | Meaning | +|-----|---------|---------| +| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. | +| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. | +| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy. | +| `session.lifetime` | `12h` | A web session ends this long after login... | +| `session.idle_timeout` | `2h` | ...or after this long without requests. | +| `session.cookie_secure` | `auto` | `auto` sets the cookie's Secure flag when the request came over HTTPS (directly or via a trusted proxy); `always` / `never` force it. | + +Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`). + +### Storage + +| Key | Default | Meaning | +|-----|---------|---------| +| `database` | `/var/lib/jarl/jarl.db` | SQLite database: configuration, users, tokens, history, audit log. | +| `staging_dir` | `/var/lib/jarl/staging` | Scratch space where candidate configurations are rendered and checked with named-checkconf before they are installed. | +| `audit.retention_days` | `0` | Delete audit events older than this many days (0 keeps them forever). Changeset history is kept regardless. | + +### Logging + +| Key | Default | Meaning | +|-----|---------|---------| +| `log.level` | `info` | `debug`, `info`, `warn` or `error`. | +| `log.format` | `text` | `text` or `json`. Under systemd both go to the journal (`journalctl -u jarl`). | + +Audit events are logged as `msg=audit` lines as well as stored in the +database. + +### BIND + +| Key | Default | Meaning | +|-----|---------|---------| +| `bind.main_config` | `/etc/bind/named.conf` | The file named loads. `jarld import` reads it, and `jarld adopt` replaces it with an include of Jarl's configuration. | +| `bind.config_dir` | `/etc/bind/jarl` | Where Jarl writes `named.conf` and `keys.conf`. Must be readable by named. | +| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`/.zone`). named may write journals here. | +| `bind.cache_dir` | `/var/cache/bind/jarl` | Where named stores secondary, stub and mirror zones. | +| `bind.file_group` | `bind` | Group given to generated files and directories so named can read them. Empty leaves the group alone. | +| `bind.named_checkconf` | `/usr/bin/named-checkconf` | Used to check every candidate configuration (`-z` also loads the zones). | +| `bind.named_checkzone` | `/usr/bin/named-checkzone` | Used to check zone files. | +| `bind.rndc` | `/usr/sbin/rndc` | Used to reconfigure and reload named and to read zone serials. | +| `bind.rndc_args` | `[]` | Extra rndc arguments, e.g. `["-k", "/etc/bind/rndc.key", "-s", "127.0.0.1", "-p", "953"]`. Debian's rndc finds `/etc/bind/rndc.key` by itself. | +| `bind.reload_timeout` | `15s` | How long to wait for named to load changed zones (checked with `rndc zonestatus`) before the change is rolled back. | +| `bind.no_reload` | `false` | Write files but never run rndc. Use it when named runs on another host or for testing. Changes are then not verified against a running named. | + +The three directories must exist and be writable by the `jarl` user. The +tmpfiles.d entry creates them, and the systemd unit only allows writes +there (`ReadWritePaths`). If you change them, update both +`/usr/lib/tmpfiles.d/jarl.conf` and the unit (`systemctl edit jarl`): + +```ini +[Service] +ReadWritePaths=/srv/bind/jarl /srv/bind/zones +``` + +## Example: management network with TLS + +```yaml +listen: + - "127.0.0.1:8053" + - "192.168.10.2:8053" +tls: + cert_file: /etc/jarl/tls/fullchain.pem + key_file: /etc/jarl/tls/privkey.pem +log: + level: info +audit: + retention_days: 730 +``` + +## Example: behind nginx + +```yaml +listen: ["127.0.0.1:8053"] +trusted_proxies: ["127.0.0.1"] +``` + +```nginx +location / { + proxy_pass http://127.0.0.1:8053; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; # applying a large changeset can take a while +} +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..5f4909d --- /dev/null +++ b/docs/development.md @@ -0,0 +1,102 @@ +# Developing Jarl + +## Layout + +``` +cmd/jarld/ daemon: serve, init, import, adopt, check, render, resync, backup, user +cmd/jarl/ CLI +internal/ + model/ configuration objects (State: views, ACLs, keys, zones, records, options) and changesets + dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones + rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog + namedconf/ named.conf lexer, parser and printer; address match lists + catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded + validate/ whole-configuration validation and security lints + render/ State → named.conf, keys.conf and zone files, with a source map for error messages + bind/ named-checkconf, named-checkzone and rndc wrappers + engine/ changesets: expand requests into ops, replay, validate, render, check, deploy, reload, verify, revert; + import and adopt + importer/ reads an existing named.conf tree and its zone files + store/ SQLite (modernc.org/sqlite, no cgo): state, changesets, users, tokens, sessions, audit + auth/ argon2id passwords, tokens, sessions, grants and roles + server/ HTTP API, authentication, the web UI + web/static/ the web UI: plain ES modules, no build step, embedded with go:embed + api/, client/ JSON types and the Go client shared by server and CLI + config/ jarld's YAML configuration +deploy/ systemd unit, sysusers.d and tmpfiles.d files +contrib/acme.sh/ acme.sh DNS API hook +scripts/e2e.sh end-to-end test against a real named +``` + +The main flow is in `internal/engine/engine.go` (`applyOps`): +expand → replay → validate → render → check → install → reload → verify → +record. [concepts.md](concepts.md) describes it from the outside. + +## Building and testing + +```sh +make # bin/jarld, bin/jarl (CGO_ENABLED=0, version from ./VERSION) +make test # go vet, gofmt check, go test ./... +make e2e # scripts/e2e.sh +``` + +### Tests against real BIND + +The render, engine and adopt tests run the real `named-checkconf`, +`named-checkzone`, `named`, `rndc` and `dig` when they can find them, and +skip otherwise. They are found through `JARL_BIND_ROOT` (an extracted +package tree) or on the system. To get them without root or AppArmor in the +way, extract the packages: + +```sh +mkdir -p ~/bind-root/debs && cd ~/bind-root/debs +apt-get download bind9 bind9-utils bind9-libs bind9-dnsutils \ + liblmdb0 libmaxminddb0 liburcu8t64 libuv1t64 libjemalloc2 libjson-c5 libnghttp2-14 +for f in *.deb; do dpkg -x "$f" ~/bind-root; done +cd - +JARL_BIND_ROOT=~/bind-root make test +JARL_BIND_ROOT=~/bind-root make e2e +``` + +The tests start named unprivileged on random high ports in temporary +directories. On Debian/Ubuntu, AppArmor confines the installed +`/usr/sbin/named` to BIND's own directories, so the tests that *start* +named (engine integration tests, `make e2e`) need the extracted copy. The +checker tests work with the installed `bind9-utils` too. + +`scripts/e2e.sh` builds a legacy BIND setup, starts named, runs +`jarld init`, `import`, `adopt` and `serve`, then drives every CLI command +(records, batches, changesets, views, ACLs, TSIG, options, users, tokens, +ACME hooks, audit) and checks the answers with `dig`. `KEEP=1` leaves named +and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG` +to that directory's `cli.yaml` to keep using the CLI against it. + +### The web UI + +The UI is plain JavaScript modules under `internal/web/static/`, with no +framework and no build step. All DOM is built with the `h()` helper in +`ui.js`, which never uses `innerHTML`, so record data and comments can't +inject markup. The Content-Security-Policy only allows the app's own +scripts and styles. To work on it, run jarld and reload the browser: the +files are embedded at build time, so rebuild jarld after changes. + +## Conventions + +* **Every user-visible change goes into `CHANGELOG.md`** (under + "Unreleased"), and the docs in `docs/` are updated in the same change. +* Errors that reach users say what is wrong and how to fix it, naming the + object. +* New validation rules get a stable `code`, a test in + `internal/validate/validate_test.go`, and a row in + [validation.md](validation.md). +* New API operations go through the engine (`internal/engine/expand.go`, + `ops.go`) so they get permissions, validation, history and revert for + free, and are documented in [api.md](api.md). +* `gofmt` and `go vet` clean (`make vet`). + +## Releasing + +1. Move the "Unreleased" section of `CHANGELOG.md` under the new version + and date. +2. Put the version in `VERSION`. +3. `make test && JARL_BIND_ROOT=... make e2e && make dist`. diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..a21b0e7 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,262 @@ +# Installing Jarl and adopting an existing BIND server + +This guide targets Debian/Ubuntu with BIND 9.20 (`bind9` package, +`/etc/bind`, user and group `bind`, AppArmor). Other layouts work too: every +path is configurable in `/etc/jarl/jarl.yaml` (see +[configuration.md](configuration.md)). + +The migration is designed to be safe. Your current configuration is +imported into Jarl and checked with `named-checkconf` before named is +touched. Adopting keeps a copy of the old `named.conf`, and if named fails +to load Jarl's configuration the old one is put back automatically. + +## 1. Build + +Jarl is two static binaries with no runtime dependencies (SQLite is +compiled in). Building needs Go 1.27 or newer, on any machine: + +```sh +make # bin/jarld and bin/jarl +make test # optional: unit and integration tests +make dist # optional: dist/jarl--linux-{amd64,arm64}.tar.gz +``` + +If you build on another machine, copy the binaries plus the `deploy/`, +`docs/` and `contrib/` directories to the DNS server. + +## 2. Install the files + +On the DNS server, as root: + +```sh +make install # or: make install PREFIX=/usr +``` + +This installs: + +| File | Purpose | +|------|---------| +| `/usr/local/bin/jarld`, `/usr/local/bin/jarl` | daemon and CLI | +| `/etc/systemd/system/jarl.service` | systemd unit (`Type=notify`, sandboxed) | +| `/usr/lib/sysusers.d/jarl.conf` | creates the `jarl` system user and adds it to group `bind` | +| `/usr/lib/tmpfiles.d/jarl.conf` | creates `/etc/jarl`, `/var/lib/jarl`, `/etc/bind/jarl`, `/var/lib/bind/jarl`, `/var/cache/bind/jarl` | +| `/etc/jarl/jarl.yaml` | example configuration (only if none exists) | +| `/usr/local/share/doc/jarl/` | these docs and the acme.sh hook | + +Then create the user and directories: + +```sh +systemd-sysusers jarl.conf +systemd-tmpfiles --create jarl.conf +chgrp jarl /etc/jarl/jarl.yaml +systemctl daemon-reload +``` + +Without `make` (from a release tarball), the same steps are: + +```sh +install -m 0755 jarld jarl /usr/local/bin/ +install -m 0644 deploy/systemd/jarl.service /etc/systemd/system/ +install -m 0644 deploy/sysusers.d/jarl.conf /usr/lib/sysusers.d/ +install -m 0644 deploy/tmpfiles.d/jarl.conf /usr/lib/tmpfiles.d/ +systemd-sysusers jarl.conf && systemd-tmpfiles --create jarl.conf +jarld config --example > /etc/jarl/jarl.yaml +chgrp jarl /etc/jarl/jarl.yaml && chmod 0640 /etc/jarl/jarl.yaml +systemctl daemon-reload +``` + +### Why the jarl user is in group bind + +jarld runs as `jarl`, not root. Membership of `bind` lets it read named's +configuration and `/etc/bind/rndc.key` (for `named-checkconf` and `rndc`), +and give the files it generates to group `bind` so named can read them. +named in turn can't write Jarl's configuration (`/etc/bind/jarl` is 2750), +but it can write journals in `/var/lib/bind/jarl` (2770). + +## 3. Configure + +Edit `/etc/jarl/jarl.yaml`. The defaults match Debian's BIND 9.20 layout, +so usually only `listen` and `tls` need changes: + +```yaml +listen: + - "127.0.0.1:8053" # always keep localhost for the local CLI and hooks + - "192.168.1.2:8053" # a management address for the web UI +tls: + cert_file: /etc/jarl/tls/fullchain.pem + key_file: /etc/jarl/tls/privkey.pem +``` + +Keep the web UI and API **off the internet**: listen on localhost and a +management network only, and restrict the port in your firewall. When the +server becomes internet-facing for DNS, that changes nothing for Jarl. + +A plain-HTTP listener is fine on localhost. Anything reachable over the +network should use TLS, either with `tls:` above or behind a reverse proxy +that terminates TLS (set `trusted_proxies` so client IPs in the audit log +are right). See "TLS certificate from Let's Encrypt" below. + +Check the result with `jarld config` (prints the effective configuration). + +## 4. Back up BIND + +Jarl keeps a copy of `named.conf`, but take your own backup anyway: + +```sh +tar czf /root/bind-before-jarl-$(date +%F).tgz /etc/bind /var/lib/bind /var/cache/bind +``` + +## 5. Create the database and the first administrator + +```sh +sudo -u jarl jarld init --token \ + --nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol +``` + +It asks for the administrator's password (user `admin`, change it with +`-u NAME`). `--token` also prints an API token for the CLI, shown only +once. `--nameservers` and `--contact` are the defaults for zones you create +later, and you can change them in the UI or with `jarl settings set`. + +## 6. Import the current configuration + +```sh +sudo -u jarl jarld import --dry-run # see what would be imported +sudo -u jarl jarld import +``` + +The importer reads `/etc/bind/named.conf` and everything it includes: ACLs, +keys, options, logging, views, zones, and every primary zone file (with +comments, `$TTL`, `$ORIGIN` and `$INCLUDE`; `$GENERATE` ranges are expanded +into individual records). It converts legacy +spellings (`master`/`slave`/`masters`) and keeps `rndc.key` as an include. + +If everything validates, the import is applied as changeset #2. Jarl writes +its files into `/etc/bind/jarl` and `/var/lib/bind/jarl`, but **named keeps +running on the old configuration**. If there are problems, the import is +left as an open changeset to fix in the web UI or CLI first, and the +command tells you what is wrong. + +Look at what Jarl generated before going further: + +```sh +less /etc/bind/jarl/named.conf +ls -R /var/lib/bind/jarl +sudo -u jarl jarld check # validation + named-checkconf -z +``` + +## 7. Adopt: switch named over to Jarl + +```sh +sudo jarld adopt +``` + +This needs root because it rewrites `/etc/bind/named.conf`: + +1. bumps the serial of every primary zone, so secondaries and caches notice + the change, +2. saves `named.conf` as `named.conf.pre-jarl-` and replaces it with a + single `include "/etc/bind/jarl/named.conf";`, +3. runs `named-checkconf`, `rndc reconfig` and reloads every zone, +4. checks that named serves every zone with the new serial. + +If any step fails, the previous `named.conf` is restored and named is +reloaded. Afterwards, check that the answers match: + +```sh +dig @127.0.0.1 adbraun.lol SOA +short +dig @127.0.0.1 -x 192.168.1.1 +short +``` + +From now on, change DNS only through Jarl. Hand edits to the generated +files are detected as drift and are never silently overwritten (see +[operations.md](operations.md)). + +**Going back.** `cp /etc/bind/named.conf.pre-jarl- /etc/bind/named.conf && rndc reconfig` +restores the old setup. Your old zone files were never modified. + +## 8. Start the daemon + +```sh +systemctl enable --now jarl +systemctl status jarl +journalctl -u jarl -f +``` + +Open `https://:8053/` and log in as `admin`. + +## 9. Set up the CLI + +The CLI can run on the server or on any machine that can reach jarld. + +```sh +jarl config set server https://ns1.adbraun.lol:8053 # or http://127.0.0.1:8053 on the server +jarl config set token # paste the token from 'jarld init' at the prompt +jarl status +jarl zone list +``` + +The CLI's settings are stored in `~/.config/jarl/config.yaml` (mode 0600). +`JARL_SERVER` and `JARL_TOKEN` override them, which is handy in scripts. +Create more tokens in the web UI (Account → API tokens) or with +`jarl token create NAME`. See [cli.md](cli.md). + +## TLS certificate from Let's Encrypt + +Jarl can publish the DNS-01 challenge for its own certificate. On the DNS +server, run certbot with the hooks against the local listener: + +```sh +# As yourself (with an admin token configured): a service account and a +# token that can only touch _acme-challenge TXT records. +jarl user create certbot --no-password --role acme +jarl token create certbot --user certbot --role acme -q > certbot.token +sudo install -m 0600 certbot.token /etc/jarl/certbot.token && rm certbot.token + +# certbot runs as root and reads the token from the file. +sudo certbot certonly --manual --preferred-challenges dns \ + --manual-auth-hook 'jarl --server http://127.0.0.1:8053 --token @/etc/jarl/certbot.token acme certbot-auth' \ + --manual-cleanup-hook 'jarl --server http://127.0.0.1:8053 --token @/etc/jarl/certbot.token acme certbot-cleanup' \ + -d ns1.adbraun.lol +``` + +certbot keeps its keys readable only by root, so give jarld a copy after +each renewal with a deploy hook, `/etc/letsencrypt/renewal-hooks/deploy/jarl`: + +```sh +#!/bin/sh +set -e +install -d -m 0750 -g jarl /etc/jarl/tls +install -m 0640 -g jarl "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" /etc/jarl/tls/ +systemctl reload jarl +``` + +`systemctl reload jarl` sends SIGHUP, and jarld rereads the certificate +without dropping connections. More in [acme.md](acme.md). + +## Upgrading + +```sh +make && sudo make install +sudo systemctl restart jarl +``` + +Database migrations run automatically at startup. Before an upgrade, back +up the database (it is safe while jarld runs): + +```sh +sudo -u jarl jarld backup /var/lib/jarl/jarl-$(date +%F).db +``` + +## Uninstalling + +```sh +cp /etc/bind/named.conf.pre-jarl- /etc/bind/named.conf # or write a named.conf that includes Jarl's files +rndc reconfig +systemctl disable --now jarl +make uninstall +``` + +`/etc/bind/jarl` and `/var/lib/bind/jarl` contain plain named.conf and zone +files that keep working without Jarl. `make uninstall` leaves them and +Jarl's database in place. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..6bbdea8 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,124 @@ +# Running Jarl + +## Day to day + +| Task | How | +|------|-----| +| Is everything healthy? | `jarl status`, or the web UI dashboard: named reachable, adoption, drift, open changesets, configuration problems | +| What changed and who did it? | `jarl history`, `jarl changeset show ID` (with diff), `jarl audit` | +| Undo a change | `jarl changeset revert ID`, review it, then `jarl changeset apply NEW_ID` (or History → Revert in the UI) | +| Check the configuration | `jarl check`: full validation plus `named-checkconf -z` | +| Logs | `journalctl -u jarl` (jarld, including `msg=audit` lines), `journalctl -u named` | +| Restart / reload TLS | `systemctl restart jarl` / `systemctl reload jarl` | + +Restarting jarld doesn't affect DNS: named keeps serving the files it +loaded. jarld is only needed to make changes. + +## Backups + +Everything Jarl knows is in its database. The BIND files can be generated +from it again at any time. + +```sh +sudo -u jarl jarld backup /var/backups/jarl/jarl-$(date +%F).db # consistent copy, safe while jarld runs +``` + +For example, as a daily timer or cron job: + +```sh +# /etc/cron.daily/jarl-backup +#!/bin/sh +set -e +install -d -o jarl -g jarl -m 0700 /var/backups/jarl +sudo -u jarl jarld backup /var/backups/jarl/jarl-$(date +%F).db +find /var/backups/jarl -name 'jarl-*.db' -mtime +30 -delete +``` + +The database contains password and token *hashes* and the TSIG secrets in +the clear, so keep backups private. + +### Restoring + +```sh +systemctl stop jarl +install -o jarl -g jarl -m 0600 /var/backups/jarl/jarl-2026-10-01.db /var/lib/jarl/jarl.db +rm -f /var/lib/jarl/jarl.db-wal /var/lib/jarl/jarl.db-shm +systemctl start jarl +jarl resync --dry-run # what differs between the restored database and the files +jarl resync --force # write everything and reload named +``` + +An older backup can carry lower zone serials than named last served. Your +own named doesn't mind, but secondaries and caches ignore a serial that went +backwards. If you have secondaries, raise the serials after a restore: +`jarl zone update ZONE --serial `. + +## Drift: files changed outside Jarl + +Jarl keeps a checksum of every file it writes. If someone edits +`/etc/bind/jarl/named.conf` or a zone file by hand (or a restore puts old +files back), `jarl status` and the dashboard show **drift**, and changes +that would overwrite those files are refused with `409 drift`: + +``` +error: files managed by Jarl were changed outside Jarl: /var/lib/bind/jarl/internal/adbraun.lol.zone; review them and apply with force to overwrite +``` + +Decide which version is right: + +* **Jarl's** (the usual case): `jarl resync --force` rewrites every file from + the database and reloads named. Any single change with `--force` also + overwrites the drifted files. +* **The hand edit**: put the same change into Jarl (for example + `jarl zone import ZONE /var/lib/bind/jarl/internal/adbraun.lol.zone` for + records), then `jarl resync --force`. + +## When an apply fails + +Jarl checks everything it can before it touches named, so failures are +rare. When one happens, nothing is left half-done: + +| Stage | What you see | State afterwards | +|-------|--------------|------------------| +| validation | 422 with `problems` | Nothing written | +| named-checkconf / named-checkzone | 422 `validation_failed` with the checker's messages in `check`, mapped to the zone/option that caused them | Nothing written | +| `rndc reconfig` / `reload` fails, or a zone doesn't load within `reload_timeout` | 502 `bind_failed` | Previous files restored and named reloaded | + +A failed change is never recorded as applied. A staged changeset stays open, +so you can fix it and apply again, and the failure is in the audit log +(`jarl audit --outcome failure`). named's own log usually explains a load +failure: `journalctl -u named`. + +If named isn't running, changes fail at the reload step and are rolled back. +Start named, then apply again. + +## Recovering access + +| Problem | Fix (on the server) | +|---------|---------------------| +| Forgot the admin password | `sudo -u jarl jarld user passwd admin` | +| Locked out by the login rate limit | wait 15 minutes, or `systemctl restart jarl` (the limiter is in memory) | +| Lost every admin account | `sudo -u jarl jarld user add --admin rescue` | +| Need a token without the web UI | `sudo -u jarl jarld user token admin cli` | + +## Taking Jarl out of the picture + +The generated files are plain BIND configuration. To stop using Jarl, +disable the service. named keeps working with `/etc/bind/jarl/` exactly as +it is. To go back to the configuration you had before adopting: + +```sh +cp /etc/bind/named.conf.pre-jarl- /etc/bind/named.conf +rndc reconfig +``` + +## Monitoring + +* `GET /healthz` (no authentication) reports that jarld is up. +* `GET /api/v1/status` with a read-only token reports named reachability, + drift and configuration problems. It is suitable for a monitoring check: + ```sh + jarl --token @/etc/jarl/monitor.token status -o json | jq -e '.engine.drift == [] and .engine.adopted' + ``` +* The audit log records failed logins as `auth.login_failed` / + `auth.login_blocked`, and rejected tokens as `auth.token_failed`. diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..231379c --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,117 @@ +# Users, tokens and permissions + +## Ways to authenticate + +| Who | How | Notes | +|-----|-----|-------| +| People in the web UI | Username + password → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. | +| CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. | +| Root on the server | `jarld` subcommands (`init`, `user`, `import`, `adopt`, `check`, `resync`, `backup`) | Work directly on the database. They are for bootstrapping and recovery, and are audited as "local". | + +Failed logins are rate limited: 8 per username and 20 per client IP within +15 minutes. Failed token attempts count against the IP too. Every attempt +is in the audit log. + +A user **without a password** (`--no-password`) is a service account. It +can't log in to the web UI and is used only through its tokens, which suits +certbot, CI jobs and backups. + +## Grants + +A user's permissions are a list of grants. Administrators (`admin: true`) +may do everything, and only they can manage users, see everyone's tokens +and run `resync`. + +A grant is written as: + +``` +RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES] +``` + +| Resource | Covers | +|----------|--------| +| `zones` | zone definitions (create, delete, SOA, TTL, view) and zone options | +| `records` | records in primary zones | +| `views` | views and view options | +| `acls` | ACLs | +| `tsig` | TSIG keys (`write` is needed to see a secret) | +| `options` | server options, top-level statements, the defaults for new zones, `jarl check` | +| `audit` | everyone's audit events and changesets (without it, users see only their own) | +| `*` | all of the above | + +`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant. +Each takes comma separated patterns where `*` and `?` are wildcards, and a +missing filter matches everything: + +* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot +* `views=internal`: view names (`_default` means zones outside any view) +* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot +* `types=TXT,CNAME`: record types + +Examples: + +``` +*:read read everything +records:write zones=lab.adbraun.lol edit records in one zone +zones:write zones=*.lab.adbraun.lol create/delete zones below lab +records:write zones=adbraun.lol views=internal only the internal copy of a split zone +records:write names=_acme-challenge.* types=TXT ACME challenges anywhere +records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone +``` + +A user who may see some records of a zone may also see that the zone exists +(its name and type), but not its other records. + +### Roles + +Roles are ready-made grant lists. Choosing a role copies its grants, which +you can then edit. + +| Role | Grants | +|------|--------| +| `readonly` | `*:read` | +| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read` | +| `operator` | `*:write` (everything except user management) | +| `acme` | `records:write names=_acme-challenge.* types=TXT` | + +```sh +jarl user create alice --role dns-editor +jarl user update alice --grant "records:write zones=adbraun.lol" --grant "zones:read" # replaces all grants +jarl user update alice --role readonly +``` + +## Tokens + +Every token belongs to a user, and every token request acts as that user. +The audit log shows the user and the token name. + +* A token **without grants** has all of its user's permissions, and they + follow the user: if the user loses a permission, so does the token. +* A token **with grants** is restricted to them. Effective permission is + *user grants ∩ token grants*: a token can never do more than its user. + Admin rights don't pass through a restricted token. +* A restricted token can create further tokens, but only ones no wider than + itself. +* Disabling or deleting a user stops all their tokens at once. + +```sh +jarl token create laptop # full rights of the user, no expiry +jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d +jarl token create certbot --user certbot --role acme # admin: a token for another user +jarl token list --all +jarl token revoke 12 +``` + +In the web UI: Account → API tokens (your own) and Users → *user* → +Tokens (admin). + +## Recovering access + +On the server, as root or the `jarl` user: + +```sh +sudo -u jarl jarld user list +sudo -u jarl jarld user passwd admin # set a new password and re-enable the account +sudo -u jarl jarld user add --admin rescue # a new administrator +sudo -u jarl jarld user token admin cli # a new API token for a user +``` diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..9705f5e --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,97 @@ +# Validation + +Every change is checked twice before named sees it: + +1. **Jarl's validator** checks the whole resulting configuration: the DNS + rules, references between objects, and option syntax against the BIND + 9.20 grammar. Its messages name the record, zone or option and explain + the rule. +2. **BIND's own checkers**: `named-checkconf -z` on the complete rendered + configuration (which also loads every zone) and `named-checkzone`. Their + messages are mapped back to the object that caused them. + +Problems have a severity: + +* **error** blocks the change (HTTP 422 `validation_failed`, CLI exit code 6). +* **warning** is shown but allowed: probably a mistake, or a security risk. +* **info** is a hint. + +Only problems that a change *introduces* block it. Problems that already +existed are reported separately (`existing_problems`) and don't stop +unrelated changes, though `jarl status`, `jarl check` and the dashboard keep +showing them. + +## Records and zones + +| Code | Severity | Rule | +|------|----------|------| +| `record_invalid` | error | Record data must parse for its type (addresses, numbers, names, base64/hex fields, ...). TXT/SPF strings are split into 255-byte pieces automatically | +| `record_name_invalid` | error | Owner names must be valid. Host names in A/AAAA/MX owners follow `check-names` rules (letters, digits, hyphens; `_` only where it is normal, e.g. SRV/TXT owners) | +| `record_out_of_zone` | error | The name must be inside the zone (catches `www.example.com` without the final dot) | +| `record_duplicate` | error | The same record can't exist twice | +| `cname_conflict` | error | A CNAME must be the only record at its name (RFC 1034), so no CNAME next to A, MX, TXT... | +| `cname_multiple` | error | A name can have only one CNAME | +| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) | +| `cname_loop` | error | CNAMEs that point back to themselves | +| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution | +| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone | +| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) | +| `apex_ns_missing` | error | A zone needs NS records at its apex | +| `ns_no_address` | error | An in-zone name server needs an A or AAAA record (named refuses to load the zone otherwise) | +| `ns_cname`, `mx_cname`, `srv_cname` | error | NS, MX and SRV targets must not be CNAMEs (RFC 2181 / RFC 2782) | +| `glue_missing` | error | A delegation to a name server inside the delegated zone needs glue records | +| `target_no_address` | warning | A delegation's name server has no address in the zone | +| `occluded` | warning | Records at or below a delegation point are not served (named answers with a referral) | +| `shadowed_by_zone` | warning | Records hidden because a more specific zone exists in the same view | +| `child_not_delegated` | info | A child zone on this server isn't delegated from its parent, so other resolvers can't find it | +| `ds_not_at_delegation` | error | DS records belong at a delegation point | +| `null_mx_not_alone` | error | A null MX (`0 .`) must be the only MX | +| `rrset_ttl_mismatch` | error | All records of an RRset need the same TTL (BIND would silently use the first one) | +| `rrset_too_large`, `too_many_types` | error | BIND 9.20's limits: 100 records per RRset (`max-records-per-type`), 100 types per name (`max-types-per-name`) | +| `spf_multiple`, `dmarc_multiple`, `dmarc_name` | warning | More than one SPF or DMARC policy (receivers then ignore them), or DMARC outside `_dmarc.` | +| `wildcard_unusual` | warning | Wildcard NS records | +| `soa_invalid`, `soa_warning`, `ttl_invalid` | error / warning | SOA fields and TTLs in range and sensible | +| `zone_name_invalid`, `zone_type_invalid`, `zone_duplicate` | error | Valid zone names and types. A zone exists once per view | +| `zone_outside_view` | error | Once views exist, every zone must be in one (named requires this) | +| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` | +| `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's | +| `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` | +| `zone_records_unmanaged` | error | Only primary zones can have records in Jarl | +| `zone_serial_policy` | error | `date`, `increment` or `unixtime` | +| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups | + +## ACLs, keys, views, options + +| Code | Severity | Rule | +|------|----------|------| +| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` | +| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist | +| `acl_cycle` | error | ACLs that include each other | +| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match | +| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak | +| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists | +| `view_unreachable` | warning | A view that comes after one matching every client is never used | +| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) | +| `option_syntax`, `option_value_invalid` | error | The value must parse (balanced braces, strings, the grammar's value type: yes/no, enums, numbers, durations, sizes, address match lists) | +| `option_duplicate` | error | Options that may appear once, appear once | +| `option_managed` | error | Statements Jarl manages itself can't be raw options: `zone`, `view`, `acl`, `key`, `options`, a zone's `type`/`file`/`in-view`, a view's `match-*` | +| `option_deprecated`, `option_obsolete`, `option_unavailable` | warning | Flags from the BIND 9.20 reference | +| `option_experimental` | info | | +| `settings_invalid` | error | Defaults for new zones are valid | + +## Security lints + +| Code | Severity | What it catches | +|------|----------|-----------------| +| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks | +| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key | +| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead | + +## What only BIND checks + +named-checkconf and named-checkzone run on every change, so anything Jarl's +validator doesn't model is still caught before named loads it. Option +values Jarl accepts as raw named.conf syntax, such as `logging` channels +and `rrset-order`, are the main example. Their errors come back as +`validation_failed` (HTTP 422) with the checker's messages in `check`, each +tied to the object it belongs to. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3cd9b81 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module jarl + +go 1.27.1 + +require ( + github.com/aymanbagabas/go-udiff v0.4.1 + github.com/miekg/dns v1.1.73 + github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/crypto v0.57.0 + golang.org/x/net v0.58.0 + golang.org/x/term v0.46.0 + modernc.org/sqlite v1.60.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..54b87fb --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..47d0372 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,203 @@ +// Package api defines the JSON types of Jarl's HTTP API, shared by the +// server and the Go client used by the CLI. +package api + +import ( + "encoding/json" + "time" + + "jarl/internal/model" +) + +// Version is the API path prefix. +const Prefix = "/api/v1" + +// Zone is a zone as returned by the API. +type Zone struct { + model.Zone + Display string `json:"display_name"` + View string `json:"view,omitempty"` + Records int `json:"record_count"` + ZoneFile string `json:"zone_file,omitempty"` // the file named loads + RName string `json:"rname_email,omitempty"` + // Pending is set when listing with a changeset: add, change or delete. + Pending string `json:"pending,omitempty"` +} + +// Record is a record as returned by the API. +type Record struct { + model.Record + Zone string `json:"zone"` + View string `json:"view,omitempty"` + RelativeName string `json:"relative_name"` + EffectiveTTL uint32 `json:"effective_ttl"` + // Text is the decoded value of TXT and SPF records. + Text *string `json:"text,omitempty"` + // Pending is set when listing with a changeset: add, change or delete. + Pending string `json:"pending,omitempty"` +} + +// View is a view with its zone count. +type View struct { + model.View + Zones int `json:"zone_count"` +} + +// ACL is an ACL with the objects that use it. +type ACL struct { + model.ACL + UsedBy []string `json:"used_by"` +} + +// TSIGKey is a key; Secret is only filled when asked for and allowed. +type TSIGKey struct { + model.TSIGKey + UsedBy []string `json:"used_by"` +} + +// Option is an option with display helpers. +type Option struct { + model.Option + Where string `json:"where"` // e.g. "view internal" + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` +} + +// User is a user with formatted grants. +type User struct { + model.User + GrantStrings []string `json:"grant_strings"` +} + +// Token is an API token. Secret is only set when the token is created. +type Token struct { + model.Token + GrantStrings []string `json:"grant_strings,omitempty"` + Secret string `json:"token,omitempty"` +} + +// Whoami describes the authenticated principal. +type Whoami struct { + User User `json:"user"` + Method string `json:"method"` + Token *Token `json:"token,omitempty"` + CSRFToken string `json:"csrf_token,omitempty"` + Permissions []string `json:"permissions"` + Admin bool `json:"admin"` + Version string `json:"version"` +} + +// LoginRequest logs in. +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// ErrorBody is the error response. +type ErrorBody struct { + Error ErrorInfo `json:"error"` +} + +// ErrorInfo describes an error. +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` + Op int `json:"op,omitempty"` + Problems json.RawMessage `json:"problems,omitempty"` + Check json.RawMessage `json:"check,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +// ChangeRequest is a request in a batch (POST /apply or changeset ops). +type ChangeRequest struct { + Op string `json:"op"` + ID int64 `json:"id,omitempty"` + BaseVersion int `json:"base_version,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// BatchRequest applies (or stages) several requests at once. +type BatchRequest struct { + Description string `json:"description,omitempty"` + Ops []ChangeRequest `json:"ops"` + DryRun bool `json:"dry_run,omitempty"` + Force bool `json:"force,omitempty"` + // Apply (for POST /changesets) applies the new changeset at once. + Apply bool `json:"apply,omitempty"` +} + +// MutationResponse is returned by endpoints that change the configuration. +type MutationResponse struct { + Applied bool `json:"applied"` + Staged bool `json:"staged,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + ChangesetID int64 `json:"changeset_id,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Changeset json.RawMessage `json:"changeset,omitempty"` + Added []model.Op `json:"added,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Problems json.RawMessage `json:"problems,omitempty"` + Object json.RawMessage `json:"object,omitempty"` +} + +// Changeset is a changeset as returned by the API. +type Changeset struct { + model.Changeset + Undoable bool `json:"undoable"` +} + +// StatusResponse is GET /status. +type StatusResponse struct { + Version string `json:"version"` + Engine json.RawMessage `json:"engine"` + Time time.Time `json:"time"` +} + +// SearchResult is one search hit. +type SearchResult struct { + Kind string `json:"kind"` // zone, record, acl, view, key + ID int64 `json:"id"` + ZoneID int64 `json:"zone_id,omitempty"` + Label string `json:"label"` + Detail string `json:"detail,omitempty"` +} + +// PasswordChange changes a password. +type PasswordChange struct { + Current string `json:"current_password,omitempty"` + Password string `json:"password"` +} + +// UserInput creates or updates a user. +type UserInput struct { + Username *string `json:"username,omitempty"` + Password *string `json:"password,omitempty"` + Admin *bool `json:"admin,omitempty"` + Grants *[]model.Grant `json:"grants,omitempty"` + // GrantStrings are grants in compact form ("records:write zones=x"). + GrantStrings *[]string `json:"grant_strings,omitempty"` + Role *string `json:"role,omitempty"` // a role template name + Disabled *bool `json:"disabled,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +// TokenInput creates a token. +type TokenInput struct { + Name string `json:"name"` + Grants *[]model.Grant `json:"grants,omitempty"` + GrantStrings *[]string `json:"grant_strings,omitempty"` + Role string `json:"role,omitempty"` + ExpiresIn string `json:"expires_in,omitempty"` // e.g. "90d", "720h" + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// ZoneImport imports zone file text into a zone. +type ZoneImport struct { + Text string `json:"text"` + // Mode "merge" adds missing records; "replace" makes the zone equal + // to the file (deleting other records). + Mode string `json:"mode"` + DryRun bool `json:"dry_run,omitempty"` + // UseSOA also takes the SOA timers and default TTL from the file. + UseSOA bool `json:"use_soa,omitempty"` +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b41e048 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,144 @@ +// Package auth handles passwords, API tokens, sessions and permissions. +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters (OWASP recommendation: m=19 MiB, t=2, p=1). +const ( + argonTime = 2 + argonMemory = 19 * 1024 + argonThreads = 1 + argonKeyLen = 32 + saltLen = 16 +) + +// MinPasswordLength is the shortest accepted password. +const MinPasswordLength = 8 + +// HashPassword returns an argon2id hash in PHC string format. +func HashPassword(password string) (string, error) { + if len(password) < MinPasswordLength { + return "", fmt.Errorf("password must be at least %d characters", MinPasswordLength) + } + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil +} + +// dummyHash is verified against when a user doesn't exist, so a login +// attempt takes the same time whether or not the username is valid. +var dummyHash, _ = HashPassword("not-a-real-password") + +// CheckPassword reports whether password matches hash. An empty hash +// never matches (the time spent is the same). +func CheckPassword(hash, password string) bool { + if hash == "" { + hash = dummyHash + password = "\x00" + password + } + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m, t uint32 + var p uint8 + for _, kv := range strings.Split(parts[3], ",") { + k, v, _ := strings.Cut(kv, "=") + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + return false + } + switch k { + case "m": + m = uint32(n) + case "t": + t = uint32(n) + case "p": + p = uint8(n) + } + } + salt, err1 := base64.RawStdEncoding.DecodeString(parts[4]) + want, err2 := base64.RawStdEncoding.DecodeString(parts[5]) + if err1 != nil || err2 != nil || m == 0 || t == 0 || p == 0 { + return false + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +var b32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) + +func randomString(nbytes int) string { + b := make([]byte, nbytes) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return b32.EncodeToString(b) +} + +// TokenPrefix starts every Jarl API token, which makes leaked tokens easy +// to recognize (and to find with secret scanners). +const TokenPrefix = "jarl_" + +// NewToken generates a token: the full secret string to give to the user, +// its public ID (stored in clear) and the hash of the secret part. +func NewToken() (token, publicID, secretHash string) { + publicID = randomString(10) // 16 chars + secret := randomString(20) // 32 chars + return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret) +} + +// ParseToken splits a token into its public ID and secret. +func ParseToken(token string) (publicID, secret string, err error) { + rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix) + if !ok { + return "", "", errors.New("not a Jarl token") + } + publicID, secret, ok = strings.Cut(rest, "_") + if !ok || len(publicID) != 16 || len(secret) != 32 { + return "", "", errors.New("malformed token") + } + return publicID, secret, nil +} + +// HashSecret hashes a high-entropy secret (tokens, session IDs). +func HashSecret(secret string) string { + h := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(h[:]) +} + +// CheckSecret compares a secret with a stored hash in constant time. +func CheckSecret(secret, hash string) bool { + return subtle.ConstantTimeCompare([]byte(HashSecret(secret)), []byte(hash)) == 1 +} + +// NewSessionID returns a new session ID and CSRF token. +func NewSessionID() (id, csrf string) { + return randomString(32), randomString(32) +} + +// GenerateTSIGSecret returns a random base64 secret of n bytes. +func GenerateTSIGSecret(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.StdEncoding.EncodeToString(b) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..3436c17 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,136 @@ +package auth + +import ( + "strings" + "testing" + + "jarl/internal/model" +) + +func TestPasswords(t *testing.T) { + h, err := HashPassword("correct horse battery") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(h, "$argon2id$v=19$m=19456,t=2,p=1$") { + t.Errorf("hash format %q", h) + } + if !CheckPassword(h, "correct horse battery") || CheckPassword(h, "wrong horse battery") { + t.Error("CheckPassword wrong") + } + if CheckPassword("", "anything") || CheckPassword("garbage", "x") { + t.Error("bad hash accepted") + } + if _, err := HashPassword("short"); err == nil { + t.Error("short password accepted") + } +} + +func TestTokens(t *testing.T) { + tok, pid, hash := NewToken() + if !strings.HasPrefix(tok, "jarl_") || len(tok) != len("jarl_")+16+1+32 { + t.Fatalf("token %q", tok) + } + p, secret, err := ParseToken(tok) + if err != nil || p != pid || !CheckSecret(secret, hash) { + t.Fatalf("ParseToken: %v %v", p, err) + } + if CheckSecret(secret+"x", hash) { + t.Error("wrong secret accepted") + } + for _, bad := range []string{"", "abc", "jarl_short_x", "Bearer " + tok} { + if _, _, err := ParseToken(bad); err == nil { + t.Errorf("ParseToken(%q) accepted", bad) + } + } +} + +func TestParseGrant(t *testing.T) { + g, err := ParseGrant("records:write zones=Example.COM. names=_acme-challenge.* types=txt") + if err != nil { + t.Fatal(err) + } + if g.Zones[0] != "example.com" || g.Types[0] != "TXT" || g.Names[0] != "_acme-challenge.*" { + t.Errorf("grant = %+v", g) + } + if FormatGrant(g) != "records:write zones=example.com names=_acme-challenge.* types=TXT" { + t.Errorf("FormatGrant = %q", FormatGrant(g)) + } + for _, bad := range []string{"", "records", "records:admin", "bogus:read", "acls:write names=x", "records:read color=red", "options:read zones=x"} { + if _, err := ParseGrant(bad); err == nil { + t.Errorf("ParseGrant(%q) accepted", bad) + } + } +} + +func TestPermissions(t *testing.T) { + acme, _ := ParseGrant("records:write zones=example.com names=_acme-challenge.* types=TXT") + reader, _ := ParseGrant("*:read") + u := &model.User{Username: "bot", Grants: []model.Grant{acme, reader}} + p := &Principal{User: u} + allow := []Action{ + {Resource: ResRecords, Write: true, Zone: "example.com.", Name: "_acme-challenge.www.example.com.", Type: "TXT"}, + {Resource: ResRecords, Write: true, Zone: "example.com.", Name: "_acme-challenge.example.com.", Type: "txt"}, + {Resource: ResRecords, Zone: "other.org.", Name: "www.other.org.", Type: "A"}, + {Resource: ResACLs}, + } + deny := []Action{ + {Resource: ResRecords, Write: true, Zone: "example.com.", Name: "www.example.com.", Type: "TXT"}, + {Resource: ResRecords, Write: true, Zone: "example.com.", Name: "_acme-challenge.example.com.", Type: "A"}, + {Resource: ResRecords, Write: true, Zone: "other.org.", Name: "_acme-challenge.other.org.", Type: "TXT"}, + {Resource: ResZones, Write: true, Zone: "example.com."}, + {Resource: ResACLs, Write: true}, + } + for _, a := range allow { + if !p.Can(a) { + t.Errorf("denied %s", a) + } + } + for _, a := range deny { + if p.Can(a) { + t.Errorf("allowed %s", a) + } + } + // A token narrows its user's permissions; it can't widen them. + tp := &Principal{User: u, Token: &model.Token{Grants: []model.Grant{acme}}} + if tp.Can(Action{Resource: ResACLs}) { + t.Error("token restriction ignored") + } + if !tp.Can(allow[0]) { + t.Error("token denied allowed action") + } + wide := &Principal{User: u, Token: &model.Token{Grants: []model.Grant{{Resource: ResAll, Access: Write}}}} + if wide.Can(Action{Resource: ResACLs, Write: true}) { + t.Error("token widened user's permissions") + } + // Admins can do everything, but not through restricted tokens. + admin := &model.User{Username: "root", Admin: true} + if !(&Principal{User: admin}).IsAdmin() || (&Principal{User: admin, Token: &model.Token{Grants: []model.Grant{reader}}}).IsAdmin() { + t.Error("admin via restricted token") + } + if (&Principal{User: &model.User{Admin: true, Disabled: true}}).Can(Action{Resource: ResACLs}) { + t.Error("disabled user allowed") + } + // View filters, with "_default" for zones outside views. + vg, _ := ParseGrant("zones:write views=external") + vp := &Principal{User: &model.User{Grants: []model.Grant{vg}}} + if !vp.Can(Action{Resource: ResZones, Write: true, Zone: "example.com.", View: "external"}) || vp.Can(Action{Resource: ResZones, Write: true, Zone: "example.com.", View: "internal"}) || vp.Can(Action{Resource: ResZones, Write: true, Zone: "example.com."}) { + t.Error("view filter wrong") + } + // CanSeeZone with a filtered records grant. + sp := &Principal{User: &model.User{Grants: []model.Grant{acme}}} + if !sp.CanSeeZone("example.com.", "") || sp.CanSeeZone("other.org.", "") { + t.Error("CanSeeZone wrong") + } +} + +func TestRoleTemplatesValid(t *testing.T) { + for name, gs := range RoleTemplates { + for _, g := range gs { + gg := g + if err := ValidateGrant(&gg); err != nil { + t.Errorf("template %s: %v", name, err) + } + } + } +} diff --git a/internal/auth/grants.go b/internal/auth/grants.go new file mode 100644 index 0000000..ec4036c --- /dev/null +++ b/internal/auth/grants.go @@ -0,0 +1,382 @@ +package auth + +import ( + "fmt" + "regexp" + "sort" + "strings" + "sync" + + "jarl/internal/model" +) + +// Resources that grants apply to. +const ( + ResZones = "zones" // zone definitions and zone options + ResRecords = "records" // records in zones + ResViews = "views" // views and view options + ResACLs = "acls" + ResTSIG = "tsig" // TSIG keys + ResOptions = "options" // server options, top-level statements, settings + ResAudit = "audit" // audit log and everyone's changesets + ResAll = "*" +) + +// Resources lists the resources in display order. +var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit} + +// Access levels. +const ( + Read = "read" + Write = "write" +) + +// DefaultViewName is used for matching view filters against zones that +// are not in a view. +const DefaultViewName = "_default" + +// ValidateGrant checks a grant and normalizes it. +func ValidateGrant(g *model.Grant) error { + g.Resource = strings.ToLower(strings.TrimSpace(g.Resource)) + g.Access = strings.ToLower(strings.TrimSpace(g.Access)) + ok := g.Resource == ResAll + for _, r := range Resources { + ok = ok || g.Resource == r + } + if !ok { + return fmt.Errorf("unknown resource %q (use one of %s or *)", g.Resource, strings.Join(Resources, ", ")) + } + if g.Access != Read && g.Access != Write { + return fmt.Errorf("access must be read or write, not %q", g.Access) + } + norm := func(list []string, lower bool, trimDot bool) []string { + var out []string + for _, s := range list { + s = strings.TrimSpace(s) + if lower { + s = strings.ToLower(s) + } + if trimDot && s != "." { + s = strings.TrimSuffix(s, ".") + } + if s != "" { + out = append(out, s) + } + } + return out + } + g.Zones = norm(g.Zones, true, true) + g.Views = norm(g.Views, false, false) + g.Names = norm(g.Names, true, true) + g.Types = norm(g.Types, false, false) + for i, t := range g.Types { + g.Types[i] = strings.ToUpper(t) + } + if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords { + return fmt.Errorf("names and types filters only apply to the records resource") + } + if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews { + return fmt.Errorf("zone and view filters only apply to the zones, records and views resources") + } + for _, p := range append(append(append([]string{}, g.Zones...), g.Names...), g.Views...) { + if strings.ContainsAny(p, " \t\n") { + return fmt.Errorf("invalid pattern %q", p) + } + } + return nil +} + +// ParseGrant parses the compact grant syntax used by the CLI and docs: +// +// resource:access [zones=a,b] [views=x] [names=pattern] [types=TXT,CNAME] +// +// For example "records:write zones=example.com names=_acme-challenge.* types=TXT". +func ParseGrant(s string) (model.Grant, error) { + var g model.Grant + fields := strings.Fields(s) + if len(fields) == 0 { + return g, fmt.Errorf("empty grant") + } + res, acc, ok := strings.Cut(fields[0], ":") + if !ok { + return g, fmt.Errorf("grant %q must start with resource:access, e.g. records:write", s) + } + g.Resource, g.Access = res, acc + for _, f := range fields[1:] { + k, v, ok := strings.Cut(f, "=") + if !ok || v == "" { + return g, fmt.Errorf("invalid filter %q (use key=value)", f) + } + vals := strings.Split(v, ",") + switch strings.ToLower(k) { + case "zone", "zones": + g.Zones = append(g.Zones, vals...) + case "view", "views": + g.Views = append(g.Views, vals...) + case "name", "names": + g.Names = append(g.Names, vals...) + case "type", "types": + g.Types = append(g.Types, vals...) + default: + return g, fmt.Errorf("unknown filter %q (use zones, views, names or types)", k) + } + } + return g, ValidateGrant(&g) +} + +// FormatGrant returns the compact form of a grant. +func FormatGrant(g model.Grant) string { + parts := []string{g.Resource + ":" + g.Access} + add := func(k string, vals []string) { + if len(vals) > 0 { + parts = append(parts, k+"="+strings.Join(vals, ",")) + } + } + add("zones", g.Zones) + add("views", g.Views) + add("names", g.Names) + add("types", g.Types) + return strings.Join(parts, " ") +} + +// Action is something a principal wants to do. +type Action struct { + Resource string + Write bool + Zone string // zone FQDN (zones, records) + View string // view name ("" for zones without a view) + Name string // record owner FQDN (records) + Type string // record type (records) +} + +func (a Action) String() string { + acc := Read + if a.Write { + acc = Write + } + s := a.Resource + ":" + acc + if a.Zone != "" { + s += " zone " + strings.TrimSuffix(a.Zone, ".") + } + if a.View != "" { + s += " view " + a.View + } + if a.Name != "" { + s += " name " + strings.TrimSuffix(a.Name, ".") + } + if a.Type != "" { + s += " type " + a.Type + } + return s +} + +var globCache sync.Map // pattern -> *regexp.Regexp + +func globMatch(pattern, s string) bool { + if pattern == "*" { + return true + } + if !strings.ContainsAny(pattern, "*?") { + return pattern == s + } + v, ok := globCache.Load(pattern) + if !ok { + var sb strings.Builder + sb.WriteString("^") + for _, c := range pattern { + switch c { + case '*': + sb.WriteString(".*") + case '?': + sb.WriteString(".") + default: + sb.WriteString(regexp.QuoteMeta(string(c))) + } + } + sb.WriteString("$") + v, _ = globCache.LoadOrStore(pattern, regexp.MustCompile(sb.String())) + } + return v.(*regexp.Regexp).MatchString(s) +} + +func matchAny(patterns []string, s string, fold bool) bool { + if len(patterns) == 0 { + return true + } + if fold { + s = strings.ToLower(s) + } + for _, p := range patterns { + if globMatch(p, s) { + return true + } + } + return false +} + +func trimName(n string) string { + if n == "." { + return n + } + return strings.TrimSuffix(strings.ToLower(n), ".") +} + +// GrantAllows reports whether g allows action a. +func GrantAllows(g model.Grant, a Action) bool { + if g.Resource != ResAll && g.Resource != a.Resource { + return false + } + if a.Write && g.Access != Write { + return false + } + if a.Zone != "" && !matchAny(g.Zones, trimName(a.Zone), true) { + return false + } + view := a.View + if view == "" { + view = DefaultViewName + } + if (a.Zone != "" || a.Resource == ResViews) && !matchAny(g.Views, view, false) { + return false + } + if a.Name != "" && !matchAny(g.Names, trimName(a.Name), true) { + return false + } + if a.Type != "" && len(g.Types) > 0 { + found := false + for _, t := range g.Types { + if strings.EqualFold(t, a.Type) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// GrantsAllow reports whether any grant allows a. +func GrantsAllow(gs []model.Grant, a Action) bool { + for _, g := range gs { + if GrantAllows(g, a) { + return true + } + } + return false +} + +// Principal is an authenticated identity. +type Principal struct { + User *model.User + Token *model.Token // nil for sessions and local CLI use + Method string // session, token, local + IP string + Agent string +} + +// System is the principal used for internal operations. +var System = &Principal{User: &model.User{ID: 0, Username: "system", Admin: true}, Method: "local"} + +// Name returns a display name for the principal. +func (p *Principal) Name() string { + if p == nil || p.User == nil { + return "" + } + return p.User.Username +} + +// IsAdmin reports whether the principal has admin rights. Admin rights +// are never available through a restricted token. +func (p *Principal) IsAdmin() bool { + if p == nil || p.User == nil || !p.User.Admin { + return false + } + return p.Token == nil || p.Token.Grants == nil +} + +// Can reports whether the principal may perform a. +func (p *Principal) Can(a Action) bool { + if p == nil || p.User == nil || p.User.Disabled { + return false + } + userOK := p.User.Admin || GrantsAllow(p.User.Grants, a) + if !userOK { + return false + } + if p.Token != nil && p.Token.Grants != nil { + return GrantsAllow(p.Token.Grants, a) + } + return true +} + +// CanSeeZone reports whether the principal may see that a zone exists: +// any read access to the zone definition or to some of its records. +func (p *Principal) CanSeeZone(zone, view string) bool { + if p.Can(Action{Resource: ResZones, Zone: zone, View: view}) { + return true + } + // Records access with name/type filters still reveals the zone. + check := func(gs []model.Grant) bool { + for _, g := range gs { + if g.Resource != ResAll && g.Resource != ResRecords && g.Resource != ResZones { + continue + } + ng := g + ng.Names, ng.Types = nil, nil + if GrantAllows(ng, Action{Resource: g.Resource, Zone: zone, View: view}) { + return true + } + } + return false + } + if p.User == nil || p.User.Disabled { + return false + } + if !p.User.Admin && !check(p.User.Grants) { + return false + } + if p.Token != nil && p.Token.Grants != nil { + return check(p.Token.Grants) + } + return true +} + +// Summary describes effective permissions for display. +func (p *Principal) Summary() []string { + if p.IsAdmin() { + return []string{"admin (everything)"} + } + var out []string + gs := p.User.Grants + if p.User.Admin { + gs = []model.Grant{{Resource: ResAll, Access: Write}} + } + for _, g := range gs { + out = append(out, FormatGrant(g)) + } + if p.Token != nil && p.Token.Grants != nil { + var tg []string + for _, g := range p.Token.Grants { + tg = append(tg, FormatGrant(g)) + } + out = append(out, "restricted by token to: "+strings.Join(tg, "; ")) + } + sort.Strings(out) + return out +} + +// RoleTemplates are ready-made grant sets offered in the UI and CLI. +var RoleTemplates = map[string][]model.Grant{ + "readonly": {{Resource: ResAll, Access: Read}}, + "dns-editor": { + {Resource: ResZones, Access: Write}, + {Resource: ResRecords, Access: Write}, + {Resource: ResViews, Access: Read}, + {Resource: ResACLs, Access: Read}, + {Resource: ResOptions, Access: Read}, + }, + "operator": {{Resource: ResAll, Access: Write}}, + "acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}, +} diff --git a/internal/bind/bind.go b/internal/bind/bind.go new file mode 100644 index 0000000..17a11ef --- /dev/null +++ b/internal/bind/bind.go @@ -0,0 +1,316 @@ +// Package bind runs BIND's own tools: named-checkconf and named-checkzone +// to validate generated configuration, and rndc to reload named and query +// its state. +package bind + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// Tools locates and runs the BIND utilities. +type Tools struct { + NamedCheckConf string // path to named-checkconf + NamedCheckZone string // path to named-checkzone + RNDC string // path to rndc + RNDCArgs []string // extra rndc arguments, e.g. -k /etc/bind/rndc.key -s 127.0.0.1 -p 953 + Env []string // extra environment variables + Timeout time.Duration +} + +// Message is one line of tool output. +type Message struct { + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Zone string `json:"zone,omitempty"` // zone name, when the message is about a zone + View string `json:"view,omitempty"` + Text string `json:"text"` + Error bool `json:"error"` +} + +// Result is the outcome of a check. +type Result struct { + OK bool `json:"ok"` + Messages []Message `json:"messages"` + Output string `json:"output"` +} + +func (t *Tools) timeout() time.Duration { + if t.Timeout > 0 { + return t.Timeout + } + return 60 * time.Second +} + +func (t *Tools) run(ctx context.Context, bin string, args ...string) (string, int, error) { + ctx, cancel := context.WithTimeout(ctx, t.timeout()) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + if len(t.Env) > 0 { + cmd.Env = append(cmd.Environ(), t.Env...) + } + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + code := 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + code = ee.ExitCode() + err = nil + } else { + return out.String(), -1, fmt.Errorf("running %s: %w", bin, err) + } + } + if ctx.Err() != nil { + return out.String(), -1, fmt.Errorf("%s timed out after %s", bin, t.timeout()) + } + return out.String(), code, nil +} + +var ( + // /path/file:12: message + reFileLine = regexp.MustCompile(`^(?:dns_master_load: |dns_rdata_fromtext: )?(/[^:]+|[^:\s]+\.[A-Za-z0-9]+):(\d+): (.*)$`) + // zone example.com/IN: message or zone example.com/IN/internal: message + reZoneMsg = regexp.MustCompile(`^zone ([^/\s]+)/([A-Z]+)(?:/(\S+))?: (.*)$`) + // _default/example.com/IN: message (checkconf summary) + reViewZone = regexp.MustCompile(`^([^/\s]+)/([^/\s]+)/([A-Z]+): (.*)$`) +) + +func isErrorText(s string) bool { + l := strings.ToLower(s) + switch { + case strings.HasPrefix(l, "warning"), strings.Contains(l, ": warning:"): + return false + case strings.HasPrefix(l, "loaded serial"), l == "ok": + return false + } + return true +} + +// ParseOutput splits tool output into messages. +func ParseOutput(out string) []Message { + var msgs []Message + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + m := Message{Text: line} + if sm := reFileLine.FindStringSubmatch(line); sm != nil { + m.File = sm[1] + m.Line, _ = strconv.Atoi(sm[2]) + m.Text = sm[3] + m.Error = isErrorText(sm[3]) + } else if sm := reZoneMsg.FindStringSubmatch(line); sm != nil { + m.Zone, m.View, m.Text = sm[1], sm[3], sm[4] + m.Error = isErrorText(sm[4]) && !strings.HasPrefix(sm[4], "loaded serial") + if strings.Contains(sm[4], "is a CNAME (illegal)") || strings.Contains(sm[4], "has no address records") || strings.Contains(sm[4], "has no REQUIRED GLUE") { + // named loads the zone anyway; Jarl's own checks + // report these with more context. + m.Error = strings.Contains(sm[4], "NS '") && !strings.Contains(sm[4], "/NS '") + } + } else if sm := reViewZone.FindStringSubmatch(line); sm != nil { + m.View, m.Zone, m.Text = sm[1], sm[2], sm[4] + m.Error = true + } else { + m.Error = isErrorText(line) && line != "OK" + } + msgs = append(msgs, m) + } + return msgs +} + +// CheckConfig runs named-checkconf on path. With loadZones, primary zones +// are test-loaded too (-z). +func (t *Tools) CheckConfig(ctx context.Context, path string, loadZones bool) (*Result, error) { + args := []string{} + if loadZones { + args = append(args, "-z") + } + args = append(args, path) + out, code, err := t.run(ctx, t.NamedCheckConf, args...) + if err != nil { + return nil, err + } + return &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, nil +} + +// CheckZone runs named-checkzone on a zone file with the same strictness +// named applies to primary zones (check-names fail, local integrity +// checks). +func (t *Tools) CheckZone(ctx context.Context, zone, file string) (*Result, error) { + out, code, err := t.run(ctx, t.NamedCheckZone, "-k", "fail", "-i", "local", zone, file) + if err != nil { + return nil, err + } + return &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, nil +} + +// DumpZone loads a zone file and prints it in canonical form (used by the +// importer for files with $INCLUDE/$GENERATE). +func (t *Tools) DumpZone(ctx context.Context, zone, file string) (string, error) { + out, code, err := t.run(ctx, t.NamedCheckZone, "-q", "-D", "-s", "full", "-o", "-", zone, file) + if err != nil { + return "", err + } + if code != 0 { + return "", fmt.Errorf("named-checkzone failed for %s", zone) + } + return out, nil +} + +// Version returns the version printed by named-checkconf -v. +func (t *Tools) Version(ctx context.Context) (string, error) { + out, _, err := t.run(ctx, t.NamedCheckConf, "-v") + return strings.TrimSpace(out), err +} + +// RNDCError is returned when rndc reports a failure. +type RNDCError struct { + Command string + Output string +} + +func (e *RNDCError) Error() string { + return fmt.Sprintf("rndc %s failed: %s", e.Command, strings.TrimSpace(e.Output)) +} + +// Run runs an rndc command. +func (t *Tools) RNDCRun(ctx context.Context, args ...string) (string, error) { + full := append(append([]string{}, t.RNDCArgs...), args...) + out, code, err := t.run(ctx, t.RNDC, full...) + if err != nil { + return out, err + } + if code != 0 { + return out, &RNDCError{Command: strings.Join(args, " "), Output: out} + } + return out, nil +} + +// Reconfig makes named reread its configuration and load new zones. +func (t *Tools) Reconfig(ctx context.Context) error { + _, err := t.RNDCRun(ctx, "reconfig") + return err +} + +// ReloadZone asks named to reload one zone. named queues the reload and +// does not report load errors; check the result with ZoneStatus. +func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error { + _, err := t.RNDCRun(ctx, zoneArgs("reload", zone, view)...) + return err +} + +func zoneArgs(cmd, zone, view string) []string { + z := strings.TrimSuffix(zone, ".") + if z == "" { + z = "." + } + if view == "" { + return []string{cmd, z} + } + return []string{cmd, z, "IN", view} +} + +// ZoneStatus is the result of rndc zonestatus. +type ZoneStatus struct { + Name string `json:"name"` + Type string `json:"type"` + Files string `json:"files"` + Serial uint32 `json:"serial"` + HasSerial bool `json:"-"` + Nodes int `json:"nodes"` + LastLoaded string `json:"last_loaded"` + Secure bool `json:"secure"` + Dynamic bool `json:"dynamic"` + Raw string `json:"raw"` +} + +// ZoneStatus returns named's view of a zone. +func (t *Tools) ZoneStatus(ctx context.Context, zone, view string) (*ZoneStatus, error) { + out, err := t.RNDCRun(ctx, zoneArgs("zonestatus", zone, view)...) + if err != nil { + return nil, err + } + return ParseZoneStatus(out), nil +} + +// ParseZoneStatus parses rndc zonestatus output. +func ParseZoneStatus(out string) *ZoneStatus { + zs := &ZoneStatus{Raw: out} + for _, line := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(line, ": ") + if !ok { + continue + } + v = strings.TrimSpace(v) + switch strings.TrimSpace(k) { + case "name": + zs.Name = v + case "type": + zs.Type = v + case "files": + zs.Files = v + case "serial": + if n, err := strconv.ParseUint(v, 10, 32); err == nil { + zs.Serial, zs.HasSerial = uint32(n), true + } + case "nodes": + zs.Nodes, _ = strconv.Atoi(v) + case "last loaded": + zs.LastLoaded = v + case "secure": + zs.Secure = v == "yes" + case "dynamic": + zs.Dynamic = v == "yes" + } + } + return zs +} + +// ServerStatus is the parsed output of rndc status. +type ServerStatus struct { + Version string `json:"version"` + BootTime string `json:"boot_time"` + LastConfigured string `json:"last_configured"` + ConfigFile string `json:"config_file"` + Zones string `json:"zones"` + Running bool `json:"running"` + Raw string `json:"raw"` +} + +// Status returns named's status. +func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) { + out, err := t.RNDCRun(ctx, "status") + if err != nil { + return nil, err + } + st := &ServerStatus{Raw: out} + for _, line := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(line, ": ") + switch { + case ok && k == "version": + st.Version = v + case ok && k == "boot time": + st.BootTime = v + case ok && k == "last configured": + st.LastConfigured = v + case ok && k == "configuration file": + st.ConfigFile = v + case ok && k == "number of zones": + st.Zones = v + case strings.Contains(line, "server is up and running"): + st.Running = true + } + } + return st, nil +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go new file mode 100644 index 0000000..a3a4f0a --- /dev/null +++ b/internal/catalog/catalog.go @@ -0,0 +1,417 @@ +// Package catalog knows which named.conf statements are valid where. +// +// It is built at start-up from two files shipped with Jarl: the grammar +// summary printed in named.conf(5) and the statement index from the BIND +// Administrator Reference Manual (both for BIND 9.20). Jarl uses it to +// reject options that are not valid in a scope, to offer the right inputs in +// the web UI, and to show a one-line description of every option. +// named-checkconf remains the final authority on option values. +package catalog + +import ( + _ "embed" + "fmt" + "regexp" + "sort" + "strings" + "sync" +) + +//go:embed data/grammar-9.20.txt +var grammarText string + +//go:embed data/statements-9.20.tsv +var statementsTSV string + +// Scopes for configuration statements. +const ( + ScopeTop = "toplevel" // top-level statements (logging, controls, ...) + ScopeOptions = "options" // inside options { } + ScopeView = "view" // inside view { } +) + +// ZoneScope returns the scope name for statements inside a zone of the +// given type, e.g. "zone:primary". +func ZoneScope(zoneType string) string { return "zone:" + zoneType } + +// Kinds of values, used for input widgets and quick validation. +const ( + KindBool = "bool" + KindInt = "integer" + KindDuration = "duration" + KindString = "string" // a quoted string + KindWord = "word" // a single unquoted word + KindEnum = "enum" + KindACL = "address_match_list" + KindRaw = "raw" +) + +// Statement describes one configuration statement in one scope. +type Statement struct { + Name string `json:"name"` + Scope string `json:"scope"` + Grammar string `json:"grammar"` + Kind string `json:"kind"` + Choices []string `json:"choices,omitempty"` + Multiple bool `json:"multiple"` + Status string `json:"status,omitempty"` // deprecated, obsolete, experimental, test only, not configured + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + // Managed statements are represented by first-class Jarl objects or + // fields (zones, views, ACLs, keys, zone type/file, ...) and cannot be + // set as free-form options. + Managed bool `json:"managed,omitempty"` + ManagedBy string `json:"managed_by,omitempty"` +} + +// Catalog holds the statements for every scope. +type Catalog struct { + scopes map[string]map[string]*Statement + order map[string][]string +} + +var ( + once sync.Once + def *Catalog + initErr error +) + +// Default returns the catalog for BIND 9.20. +func Default() *Catalog { + once.Do(func() { + def, initErr = build(grammarText, statementsTSV) + if initErr != nil { + panic("catalog: " + initErr.Error()) + } + }) + return def +} + +// managed lists statements that Jarl models itself, per scope. +var managed = map[string]map[string]string{ + ScopeTop: { + "options": "server options", + "view": "views", + "zone": "zones", + "acl": "ACLs", + "key": "TSIG keys", + }, + ScopeView: { + "match-clients": "the view's match-clients field", + "match-destinations": "the view's match-destinations field", + "match-recursive-only": "the view's match-recursive-only field", + "key": "TSIG keys (define keys at the top level)", + "zone": "zones", + }, +} + +var zoneManaged = map[string]string{ + "type": "the zone's type field", + "file": "the zone's file (generated by Jarl)", + "in-view": "the zone's in-view field", +} + +// synonyms are older spellings that named 9.20 still accepts. +var synonyms = map[string]map[string]string{ + ScopeTop: {"primaries": "remote-servers", "masters": "remote-servers", "parental-agents": "remote-servers"}, + "zone": {"masters": "primaries"}, +} + +// ZoneTypeSynonyms maps old zone type names to their current names. +var ZoneTypeSynonyms = map[string]string{"master": "primary", "slave": "secondary"} + +// Canonical returns the current name of a statement, translating older +// synonyms (for example "masters" to "primaries" in zones). The second +// result reports whether name was a synonym. +func Canonical(scope, name string) (string, bool) { + name = strings.ToLower(name) + key := scope + if strings.HasPrefix(scope, "zone:") { + key = "zone" + } + if c, ok := synonyms[key][name]; ok { + return c, true + } + return name, false +} + +// Lookup returns the statement name in scope, if it exists. Synonyms are +// resolved to the current statement. +func (c *Catalog) Lookup(scope, name string) (*Statement, bool) { + n, _ := Canonical(scope, name) + s, ok := c.scopes[scope][n] + return s, ok +} + +// List returns the statements of a scope in grammar order. +func (c *Catalog) List(scope string) []*Statement { + var out []*Statement + for _, n := range c.order[scope] { + out = append(out, c.scopes[scope][n]) + } + return out +} + +// Scopes returns all known scope names. +func (c *Catalog) Scopes() []string { + var out []string + for s := range c.scopes { + out = append(out, s) + } + sort.Strings(out) + return out +} + +// ZoneTypes returns the zone types in the grammar (including "in-view"). +func (c *Catalog) ZoneTypes() []string { + var out []string + for s := range c.scopes { + if t, ok := strings.CutPrefix(s, "zone:"); ok { + out = append(out, t) + } + } + sort.Strings(out) + return out +} + +func build(grammar, tsv string) (*Catalog, error) { + descs := map[string][2]string{} + for _, line := range strings.Split(tsv, "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + f := strings.Split(line, "\t") + if len(f) != 3 { + return nil, fmt.Errorf("bad statements line %q", line) + } + descs[f[0]] = [2]string{f[1], f[2]} + } + c := &Catalog{scopes: map[string]map[string]*Statement{}, order: map[string][]string{}} + add := func(scope string, st *Statement) { + if c.scopes[scope] == nil { + c.scopes[scope] = map[string]*Statement{} + } + if _, dup := c.scopes[scope][st.Name]; dup { + // The grammar lists some statements twice (e.g. alternative + // forms); keep the first and merge the grammar text. + c.scopes[scope][st.Name].Grammar += "\n" + st.Grammar + return + } + st.Scope = scope + if d, ok := descs[st.Name]; ok { + st.Description = d[0] + for _, t := range strings.Split(d[1], ",") { + if t = strings.TrimSpace(t); t != "" { + st.Tags = append(st.Tags, t) + } + } + } + if m, ok := managed[scope][st.Name]; ok { + st.Managed, st.ManagedBy = true, m + } + if strings.HasPrefix(scope, "zone:") { + if m, ok := zoneManaged[st.Name]; ok { + st.Managed, st.ManagedBy = true, m + } + } + st.Kind, st.Choices = classify(st.Name, st.Grammar) + c.scopes[scope][st.Name] = st + c.order[scope] = append(c.order[scope], st.Name) + } + + var lines []string + for _, l := range strings.Split(grammar, "\n") { + if l == "" || strings.HasPrefix(l, "#") { + continue + } + lines = append(lines, l) + } + level := func(l string) int { return (len(l) - len(strings.TrimLeft(l, " "))) / 5 } + + for i := 0; i < len(lines); i++ { + l := lines[i] + if level(l) != 0 { + return nil, fmt.Errorf("unexpected indentation at %q", l) + } + name := firstWord(l) + if !strings.HasSuffix(strings.TrimSpace(stripComment(l)), "{") { + add(ScopeTop, newStatement(name, l)) + continue + } + // A block: collect its body. + j := i + 1 + for j < len(lines) && level(lines[j]) > 0 { + j++ + } + body := lines[i+1 : j] + closing := "" + if j < len(lines) { + closing = lines[j] // "};" possibly with flags + } + i = j + switch name { + case "options", "view": + scope := name + parseScope(body, func(st *Statement) { add(scope, st) }) + st := newStatement(name, l+"\n ...\n"+closing) + st.Multiple, st.Status = flags(closing) + add(ScopeTop, st) + case "zone": + if len(body) == 0 { + return nil, fmt.Errorf("empty zone grammar") + } + first := strings.TrimSpace(body[0]) + var zt string + if t, ok := strings.CutPrefix(first, "type "); ok { + zt = strings.TrimSuffix(t, ";") + body = body[1:] + } else if strings.HasPrefix(first, "in-view ") { + zt = "in-view" + } else { + return nil, fmt.Errorf("zone grammar without type: %q", first) + } + scope := ZoneScope(zt) + if c.scopes[scope] == nil { + c.scopes[scope] = map[string]*Statement{} + } + parseScope(body, func(st *Statement) { add(scope, st) }) + if _, ok := c.scopes[ScopeTop]["zone"]; !ok { + add(ScopeTop, newStatement("zone", "zone [ ] { ... };")) + } + default: + // Other top-level blocks (logging, controls, ...) are + // free-form statements; keep their full grammar. + text := l + "\n" + strings.Join(body, "\n") + "\n" + closing + st := newStatement(name, text) + st.Multiple, st.Status = flags(closing) + add(ScopeTop, st) + } + } + // include is a directive rather than a statement but is valid at the + // top level (and Jarl uses it for things like rndc.key). + add(ScopeTop, &Statement{Name: "include", Grammar: "include ;", Multiple: true, + Description: "Includes another configuration file at this point."}) + return c, nil +} + +// parseScope parses the body of options/view/zone into statements; nested +// blocks become part of their statement's grammar. +func parseScope(body []string, emit func(*Statement)) { + for i := 0; i < len(body); i++ { + l := strings.TrimPrefix(body[i], " ") + name := firstWord(l) + if strings.HasSuffix(strings.TrimSpace(stripComment(l)), "{") { + j := i + 1 + for j < len(body) && strings.HasPrefix(body[j], " ") { + j++ + } + text := l + for _, sub := range body[i+1 : j] { + text += "\n" + strings.TrimPrefix(sub, " ") + } + closing := "" + if j < len(body) { + closing = strings.TrimPrefix(body[j], " ") + text += "\n" + closing + } + st := &Statement{Name: name, Grammar: strings.TrimSpace(text)} + st.Multiple, st.Status = flags(closing) + emit(st) + i = j + continue + } + emit(newStatement(name, l)) + } +} + +func newStatement(name, line string) *Statement { + st := &Statement{Name: name, Grammar: strings.TrimSpace(stripComment(line))} + if !strings.Contains(line, "\n") { + st.Multiple, st.Status = flags(line) + } + return st +} + +func firstWord(l string) string { + f := strings.Fields(l) + if len(f) == 0 { + return "" + } + return strings.TrimSuffix(f[0], ";") +} + +func stripComment(l string) string { + if i := strings.Index(l, " // "); i >= 0 { + return l[:i] + } + return l +} + +// flags extracts "may occur multiple times" and the status from a trailing +// grammar comment. +func flags(l string) (multiple bool, status string) { + i := strings.Index(l, "// ") + if i < 0 { + return false, "" + } + for _, part := range strings.Split(l[i+3:], ",") { + switch p := strings.TrimSpace(part); p { + case "may occur multiple times": + multiple = true + case "deprecated", "obsolete", "experimental", "test only", "not configured", "not implemented": + status = p + } + } + return multiple, status +} + +var ( + reSimple = regexp.MustCompile(`^[a-z0-9-]+ (<[a-z_]+>);$`) + reEnum = regexp.MustCompile(`^[a-z0-9-]+ \( ([^()]+) \);$`) + reACL = regexp.MustCompile(`^[a-z0-9-]+ \{ ; \.\.\. \};$`) +) + +func classify(name, grammar string) (string, []string) { + g := strings.TrimSpace(grammar) + if strings.Contains(g, "\n") { + return KindRaw, nil + } + if m := reSimple.FindStringSubmatch(g); m != nil { + switch m[1] { + case "": + return KindBool, nil + case "": + return KindInt, nil + case "": + return KindDuration, nil + case "": + return KindString, nil + case "": + return KindWord, nil + } + return KindRaw, nil + } + if reACL.MatchString(g) { + return KindACL, nil + } + if m := reEnum.FindStringSubmatch(g); m != nil { + var choices []string + for _, alt := range strings.Split(m[1], "|") { + alt = strings.TrimSpace(alt) + switch { + case alt == "": + choices = append(choices, "yes", "no") + case strings.HasPrefix(alt, "<"): + // e.g. or among the choices: + // the value is not a pure enum. + return KindRaw, nil + case strings.ContainsAny(alt, " []"): + return KindRaw, nil + default: + choices = append(choices, alt) + } + } + return KindEnum, choices + } + return KindRaw, nil +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go new file mode 100644 index 0000000..47df13f --- /dev/null +++ b/internal/catalog/catalog_test.go @@ -0,0 +1,79 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestCatalog(t *testing.T) { + c := Default() + for _, scope := range []string{ScopeTop, ScopeOptions, ScopeView, "zone:primary", "zone:secondary", "zone:forward", "zone:hint", "zone:stub", "zone:static-stub", "zone:mirror", "zone:redirect", "zone:in-view"} { + if len(c.List(scope)) == 0 { + t.Errorf("scope %s is empty", scope) + } + } + type want struct { + scope, name, kind string + multiple bool + status string + managed bool + } + cases := []want{ + {ScopeOptions, "recursion", KindBool, false, "", false}, + {ScopeOptions, "allow-recursion", KindACL, false, "", false}, + {ScopeOptions, "dnssec-validation", KindEnum, false, "", false}, + {ScopeOptions, "check-names", KindRaw, true, "", false}, + {ScopeOptions, "directory", KindString, false, "", false}, + {ScopeOptions, "max-cache-ttl", KindDuration, false, "", false}, + {ScopeOptions, "sortlist", KindACL, false, "deprecated", false}, + {ScopeOptions, "dns64", KindRaw, true, "", false}, + {ScopeOptions, "listen-on", KindRaw, true, "", false}, + {ScopeView, "match-clients", KindACL, false, "", true}, + {ScopeView, "recursion", KindBool, false, "", false}, + {ScopeView, "server", KindRaw, true, "", false}, + {"zone:primary", "allow-update", KindACL, false, "", false}, + {"zone:primary", "file", KindString, false, "", true}, + {"zone:primary", "notify", KindEnum, false, "", false}, + {"zone:secondary", "primaries", KindRaw, false, "", false}, + {"zone:forward", "forward", KindEnum, false, "", false}, + {"zone:in-view", "in-view", KindWord, false, "", true}, + {ScopeTop, "logging", KindRaw, false, "", false}, + {ScopeTop, "controls", KindRaw, true, "", false}, + {ScopeTop, "acl", KindRaw, true, "", true}, + {ScopeTop, "view", KindRaw, true, "", true}, + {ScopeTop, "include", KindString, true, "", false}, + {ScopeTop, "remote-servers", KindRaw, true, "", false}, + } + for _, w := range cases { + st, ok := c.Lookup(w.scope, w.name) + if !ok { + t.Errorf("%s/%s missing", w.scope, w.name) + continue + } + if st.Kind != w.kind || st.Multiple != w.multiple || st.Status != w.status || st.Managed != w.managed { + t.Errorf("%s/%s = kind %s multiple %v status %q managed %v; want %+v (grammar %q)", w.scope, w.name, st.Kind, st.Multiple, st.Status, st.Managed, w, st.Grammar) + } + } + if _, ok := c.Lookup("zone:forward", "allow-update"); ok { + t.Error("allow-update is not valid in forward zones") + } + if _, ok := c.Lookup(ScopeOptions, "frobnicate"); ok { + t.Error("unknown option found") + } + st, _ := c.Lookup(ScopeOptions, "dnssec-validation") + if strings.Join(st.Choices, ",") != "yes,no,auto" { + t.Errorf("dnssec-validation choices = %v", st.Choices) + } + st, _ = c.Lookup(ScopeOptions, "allow-recursion") + if st.Description == "" || len(st.Tags) == 0 { + t.Error("description missing") + } + st, _ = c.Lookup(ScopeOptions, "dns64") + if !strings.Contains(st.Grammar, "break-dnssec") { + t.Errorf("nested grammar missing: %q", st.Grammar) + } + types := strings.Join(c.ZoneTypes(), ",") + if types != "forward,hint,in-view,mirror,primary,redirect,secondary,static-stub,stub" { + t.Errorf("zone types = %s", types) + } +} diff --git a/internal/catalog/data/grammar-9.20.txt b/internal/catalog/data/grammar-9.20.txt new file mode 100644 index 0000000..52da3a4 --- /dev/null +++ b/internal/catalog/data/grammar-9.20.txt @@ -0,0 +1,839 @@ +# named.conf grammar from named.conf(5), BIND 9.20.24 (Ubuntu 1:9.20.24-1ubuntu0.3). +# Indentation: one level = 5 spaces. Used by internal/catalog to build the options catalog. +acl { ; ... }; // may occur multiple times +controls { + inet ( | | * ) [ port ( | * ) ] allow { ; ... } [ keys { ; ... } ] [ read-only ]; // may occur multiple times + unix perm owner group [ keys { ; ... } ] [ read-only ]; // may occur multiple times +}; // may occur multiple times +dlz { + database ; + search ; +}; // may occur multiple times +dnssec-policy { + cdnskey ; + cds-digest-types { ; ... }; + dnskey-ttl ; + inline-signing ; + keys { ( csk | ksk | zsk ) [ key-directory | key-store ] lifetime algorithm [ tag-range ] [ ]; ... }; + manual-mode ; + max-zone-ttl ; + nsec3param [ iterations ] [ optout ] [ salt-length ]; + offline-ksk ; + parent-ds-ttl ; + parent-propagation-delay ; + publish-safety ; + purge-keys ; + retire-safety ; + signatures-jitter ; + signatures-refresh ; + signatures-validity ; + signatures-validity-dnskey ; + zone-propagation-delay ; +}; // may occur multiple times +dyndb { }; // may occur multiple times +http { + endpoints { ; ... }; + listener-clients ; + streams-per-connection ; +}; // may occur multiple times +key { + algorithm ; + secret ; +}; // may occur multiple times +key-store { + directory ; + pkcs11-uri ; +}; // may occur multiple times +logging { + category { ; ... }; // may occur multiple times + channel { + buffered ; + file [ versions ( unlimited | ) ] [ size ] [ suffix ( increment | timestamp ) ]; + null; + print-category ; + print-severity ; + print-time ( iso8601 | iso8601-utc | local | ); + severity ; + stderr; + syslog [ ]; + }; // may occur multiple times +}; +managed-keys { ( static-key | initial-key | static-ds | initial-ds ) ; ... }; // may occur multiple times, deprecated +options { + allow-new-zones ; + allow-notify { ; ... }; + allow-proxy { ; ... }; // experimental + allow-proxy-on { ; ... }; // experimental + allow-query { ; ... }; + allow-query-cache { ; ... }; + allow-query-cache-on { ; ... }; + allow-query-on { ; ... }; + allow-recursion { ; ... }; + allow-recursion-on { ; ... }; + allow-transfer [ port ] [ transport ] { ; ... }; + allow-update { ; ... }; + allow-update-forwarding { ; ... }; + also-notify [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + answer-cookie ; + attach-cache ; + auth-nxdomain ; + automatic-interface-scan ; + avoid-v4-udp-ports { ; ... }; // deprecated + avoid-v6-udp-ports { ; ... }; // deprecated + bindkeys-file ; // test only + blackhole { ; ... }; + catalog-zones { zone [ default-primaries [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... } ] [ zone-directory ] [ in-memory ] [ min-update-interval ]; ... }; + check-dup-records ( fail | warn | ignore ); + check-integrity ; + check-mx ( fail | warn | ignore ); + check-mx-cname ( fail | warn | ignore ); + check-names ( primary | master | secondary | slave | response ) ( fail | warn | ignore ); // may occur multiple times + check-sibling ; + check-spf ( warn | ignore ); + check-srv-cname ( fail | warn | ignore ); + check-svcb ; + check-wildcard ; + clients-per-query ; + cookie-algorithm ( siphash24 ); + cookie-secret ; // may occur multiple times + deny-answer-addresses { ; ... } [ except-from { ; ... } ]; + deny-answer-aliases { ; ... } [ except-from { ; ... } ]; + dialup ( notify | notify-passive | passive | refresh | ); // deprecated + directory ; + disable-algorithms { ; ... }; // may occur multiple times + disable-ds-digests { ; ... }; // may occur multiple times + disable-empty-zone ; // may occur multiple times + dns64 { + break-dnssec ; + clients { ; ... }; + exclude { ; ... }; + mapped { ; ... }; + recursive-only ; + suffix ; + }; // may occur multiple times + dns64-contact ; + dns64-server ; + dnskey-sig-validity ; // obsolete + dnsrps-enable ; // not configured + dnsrps-library ; // not configured + dnsrps-options { }; // not configured + dnssec-accept-expired ; + dnssec-dnskey-kskonly ; // obsolete + dnssec-loadkeys-interval ; + dnssec-must-be-secure ; // may occur multiple times, deprecated + dnssec-policy ; + dnssec-secure-to-insecure ; // obsolete + dnssec-update-mode ( maintain | no-resign ); // obsolete + dnssec-validation ( yes | no | auto ); + dnstap { ( all | auth | client | forwarder | resolver | update ) [ ( query | response ) ]; ... }; // not configured + dnstap-identity ( | none | hostname ); // not configured + dnstap-output ( file | unix ) [ size ( unlimited | ) ] [ versions ( unlimited | ) ] [ suffix ( increment | timestamp ) ]; // not configured + dnstap-version ( | none ); // not configured + dual-stack-servers [ port ] { ( [ port ] | [ port ] | [ port ] ); ... }; + dump-file ; + edns-udp-size ; + empty-contact ; + empty-server ; + empty-zones-enable ; + fetch-quota-params ; + fetches-per-server [ ( drop | fail ) ]; + fetches-per-zone [ ( drop | fail ) ]; + flush-zones-on-shutdown ; + forward ( first | only ); + forwarders [ port ] [ tls ] { ( | ) [ port ] [ tls ]; ... }; + fstrm-set-buffer-hint ; // not configured + fstrm-set-flush-timeout ; // not configured + fstrm-set-input-queue-size ; // not configured + fstrm-set-output-notify-threshold ; // not configured + fstrm-set-output-queue-model ( mpsc | spsc ); // not configured + fstrm-set-output-queue-size ; // not configured + fstrm-set-reopen-interval ; // not configured + geoip-directory ( | none ); + heartbeat-interval ; // deprecated + hostname ( | none ); + http-listener-clients ; + http-port ; + http-streams-per-connection ; + https-port ; + interface-interval ; + ipv4only-contact ; + ipv4only-enable ; + ipv4only-server ; + ixfr-from-differences ( primary | master | secondary | slave | ); + keep-response-order { ; ... }; // obsolete + key-directory ; + lame-ttl ; + listen-on [ port ] [ proxy ] [ tls ] [ http ] { ; ... }; // may occur multiple times + listen-on-v6 [ port ] [ proxy ] [ tls ] [ http ] { ; ... }; // may occur multiple times + lmdb-mapsize ; + managed-keys-directory ; + masterfile-format ( raw | text ); + masterfile-style ( full | relative ); + match-mapped-addresses ; + max-cache-size ( default | unlimited | | ); + max-cache-ttl ; + max-clients-per-query ; + max-ixfr-ratio ( unlimited | ); + max-journal-size ( default | unlimited | ); + max-ncache-ttl ; + max-query-count ; + max-query-restarts ; + max-records ; + max-records-per-type ; + max-recursion-depth ; + max-recursion-queries ; + max-refresh-time ; + max-retry-time ; + max-rsa-exponent-size ; + max-stale-ttl ; + max-transfer-idle-in ; + max-transfer-idle-out ; + max-transfer-time-in ; + max-transfer-time-out ; + max-types-per-name ; + max-udp-size ; + max-validation-failures-per-fetch ; // experimental + max-validations-per-fetch ; // experimental + max-zone-ttl ( unlimited | ); // deprecated + memstatistics ; + memstatistics-file ; + message-compression ; + min-cache-ttl ; + min-ncache-ttl ; + min-refresh-time ; + min-retry-time ; + min-transfer-rate-in ; + minimal-any ; + minimal-responses ( no-auth | no-auth-recursive | ); + multi-master ; + new-zones-directory ; + no-case-compress { ; ... }; + nocookie-udp-size ; + notify ( explicit | master-only | primary-only | ); + notify-defer ; + notify-delay ; + notify-rate ; + notify-source ( | * ); + notify-source-v6 ( | * ); + notify-to-soa ; + nsec3-test-zone ; // test only + nta-lifetime ; + nta-recheck ; + nxdomain-redirect ; + parental-source ( | * ); + parental-source-v6 ( | * ); + pid-file ( | none ); + port ; + preferred-glue ; + prefetch [ ]; + provide-ixfr ; + qname-minimization ( strict | relaxed | disabled | off ); + query-source [ address ] ( | * | none ); + query-source-v6 [ address ] ( | * | none ); + querylog ; + rate-limit { + all-per-second ; + errors-per-second ; + exempt-clients { ; ... }; + ipv4-prefix-length ; + ipv6-prefix-length ; + log-only ; + max-table-size ; + min-table-size ; + nodata-per-second ; + nxdomains-per-second ; + qps-scale ; + referrals-per-second ; + responses-per-second ; + slip ; + window ; + }; + recursing-file ; + recursion ; + recursive-clients ; + request-expire ; + request-ixfr ; + request-nsid ; + require-server-cookie ; + resolver-query-timeout ; + resolver-use-dns64 ; + response-padding { ; ... } block-size ; + response-policy { zone [ add-soa ] [ log ] [ max-policy-ttl ] [ min-update-interval ] [ policy ( cname | disabled | drop | given | no-op | nodata | nxdomain | passthru | tcp-only ) ] [ recursive-only ] [ nsip-enable ] [ nsdname-enable ] [ ede ]; ... } [ add-soa ] [ break-dnssec ] [ max-policy-ttl ] [ min-update-interval ] [ min-ns-dots ] [ nsip-wait-recurse ] [ nsdname-wait-recurse ] [ qname-wait-recurse ] [ recursive-only ] [ servfail-until-ready ] [ nsip-enable ] [ nsdname-enable ] [ dnsrps-enable ] [ dnsrps-options { } ]; + responselog ; + reuseport ; + root-key-sentinel ; + rrset-order { [ class ] [ type ] [ name ] ; ... }; + secroots-file ; + send-cookie ; + serial-query-rate ; + serial-update-method ( date | increment | unixtime ); + server-id ( | none | hostname ); + servfail-ttl ; + session-keyalg ; + session-keyfile ( | none ); + session-keyname ; + sig-signing-nodes ; + sig-signing-signatures ; + sig-signing-type ; + sig-validity-interval [ ]; // obsolete + sig0checks-quota ; // experimental + sig0checks-quota-exempt { ; ... }; // experimental + sig0key-checks-limit ; + sig0message-checks-limit ; + sortlist { ; ... }; // deprecated + stale-answer-client-timeout ( disabled | off | ); + stale-answer-enable ; + stale-answer-ttl ; + stale-cache-enable ; + stale-refresh-time ; + startup-notify-rate ; + statistics-file ; + synth-from-dnssec ; + tcp-advertised-timeout ; + tcp-clients ; + tcp-idle-timeout ; + tcp-initial-timeout ; + tcp-keepalive-timeout ; + tcp-listen-queue ; + tcp-receive-buffer ; + tcp-send-buffer ; + tkey-domain ; // obsolete + tkey-gssapi-credential ; // deprecated + tkey-gssapi-keytab ; + tls-port ; + transfer-format ( many-answers | one-answer ); + transfer-message-size ; + transfer-source ( | * ); + transfer-source-v6 ( | * ); + transfers-in ; + transfers-out ; + transfers-per-ns ; + trust-anchor-telemetry ; + try-tcp-refresh ; + udp-receive-buffer ; + udp-send-buffer ; + update-check-ksk ; // obsolete + update-quota ; + use-v4-udp-ports { ; ... }; // deprecated + use-v6-udp-ports { ; ... }; // deprecated + v6-bias ; + validate-except { ; ... }; + version ( | none ); + zero-no-soa-ttl ; + zero-no-soa-ttl-cache ; + zone-statistics ( full | terse | none | ); +}; +plugin ( query ) [ { } ]; // may occur multiple times +remote-servers [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; // may occur multiple times +server { + bogus ; + edns ; + edns-udp-size ; + edns-version ; + keys ; + max-udp-size ; + notify-source ( | * ); + notify-source-v6 ( | * ); + padding ; + provide-ixfr ; + query-source [ address ] ( | * ); + query-source-v6 [ address ] ( | * ); + request-expire ; + request-ixfr ; + request-nsid ; + require-cookie ; + send-cookie ; + tcp-keepalive ; + tcp-only ; + transfer-format ( many-answers | one-answer ); + transfer-source ( | * ); + transfer-source-v6 ( | * ); + transfers ; +}; // may occur multiple times +statistics-channels { + inet ( | | * ) [ port ( | * ) ] [ allow { ; ... } ]; // may occur multiple times +}; // may occur multiple times +tls { + ca-file ; + cert-file ; + cipher-suites ; + ciphers ; + dhparam-file ; + key-file ; + prefer-server-ciphers ; + protocols { ; ... }; + remote-hostname ; + session-tickets ; +}; // may occur multiple times +trust-anchors { ( static-key | initial-key | static-ds | initial-ds ) ; ... }; // may occur multiple times +trusted-keys { ; ... }; // may occur multiple times, deprecated +view [ ] { + allow-new-zones ; + allow-notify { ; ... }; + allow-proxy { ; ... }; // experimental + allow-proxy-on { ; ... }; // experimental + allow-query { ; ... }; + allow-query-cache { ; ... }; + allow-query-cache-on { ; ... }; + allow-query-on { ; ... }; + allow-recursion { ; ... }; + allow-recursion-on { ; ... }; + allow-transfer [ port ] [ transport ] { ; ... }; + allow-update { ; ... }; + allow-update-forwarding { ; ... }; + also-notify [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + attach-cache ; + auth-nxdomain ; + catalog-zones { zone [ default-primaries [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... } ] [ zone-directory ] [ in-memory ] [ min-update-interval ]; ... }; + check-dup-records ( fail | warn | ignore ); + check-integrity ; + check-mx ( fail | warn | ignore ); + check-mx-cname ( fail | warn | ignore ); + check-names ( primary | master | secondary | slave | response ) ( fail | warn | ignore ); // may occur multiple times + check-sibling ; + check-spf ( warn | ignore ); + check-srv-cname ( fail | warn | ignore ); + check-svcb ; + check-wildcard ; + clients-per-query ; + deny-answer-addresses { ; ... } [ except-from { ; ... } ]; + deny-answer-aliases { ; ... } [ except-from { ; ... } ]; + dialup ( notify | notify-passive | passive | refresh | ); // deprecated + disable-algorithms { ; ... }; // may occur multiple times + disable-ds-digests { ; ... }; // may occur multiple times + disable-empty-zone ; // may occur multiple times + dlz { + database ; + search ; + }; // may occur multiple times + dns64 { + break-dnssec ; + clients { ; ... }; + exclude { ; ... }; + mapped { ; ... }; + recursive-only ; + suffix ; + }; // may occur multiple times + dns64-contact ; + dns64-server ; + dnskey-sig-validity ; // obsolete + dnsrps-enable ; // not configured + dnsrps-options { }; // not configured + dnssec-accept-expired ; + dnssec-dnskey-kskonly ; // obsolete + dnssec-loadkeys-interval ; + dnssec-must-be-secure ; // may occur multiple times, deprecated + dnssec-policy ; + dnssec-secure-to-insecure ; // obsolete + dnssec-update-mode ( maintain | no-resign ); // obsolete + dnssec-validation ( yes | no | auto ); + dnstap { ( all | auth | client | forwarder | resolver | update ) [ ( query | response ) ]; ... }; // not configured + dual-stack-servers [ port ] { ( [ port ] | [ port ] | [ port ] ); ... }; + dyndb { }; // may occur multiple times + edns-udp-size ; + empty-contact ; + empty-server ; + empty-zones-enable ; + fetch-quota-params ; + fetches-per-server [ ( drop | fail ) ]; + fetches-per-zone [ ( drop | fail ) ]; + forward ( first | only ); + forwarders [ port ] [ tls ] { ( | ) [ port ] [ tls ]; ... }; + ipv4only-contact ; + ipv4only-enable ; + ipv4only-server ; + ixfr-from-differences ( primary | master | secondary | slave | ); + key { + algorithm ; + secret ; + }; // may occur multiple times + key-directory ; + lame-ttl ; + lmdb-mapsize ; + managed-keys { ( static-key | initial-key | static-ds | initial-ds ) ; ... }; // may occur multiple times, deprecated + masterfile-format ( raw | text ); + masterfile-style ( full | relative ); + match-clients { ; ... }; + match-destinations { ; ... }; + match-recursive-only ; + max-cache-size ( default | unlimited | | ); + max-cache-ttl ; + max-clients-per-query ; + max-ixfr-ratio ( unlimited | ); + max-journal-size ( default | unlimited | ); + max-ncache-ttl ; + max-query-count ; + max-query-restarts ; + max-records ; + max-records-per-type ; + max-recursion-depth ; + max-recursion-queries ; + max-refresh-time ; + max-retry-time ; + max-stale-ttl ; + max-transfer-idle-in ; + max-transfer-idle-out ; + max-transfer-time-in ; + max-transfer-time-out ; + max-types-per-name ; + max-udp-size ; + max-validation-failures-per-fetch ; // experimental + max-validations-per-fetch ; // experimental + max-zone-ttl ( unlimited | ); // deprecated + message-compression ; + min-cache-ttl ; + min-ncache-ttl ; + min-refresh-time ; + min-retry-time ; + min-transfer-rate-in ; + minimal-any ; + minimal-responses ( no-auth | no-auth-recursive | ); + multi-master ; + new-zones-directory ; + no-case-compress { ; ... }; + nocookie-udp-size ; + notify ( explicit | master-only | primary-only | ); + notify-defer ; + notify-delay ; + notify-source ( | * ); + notify-source-v6 ( | * ); + notify-to-soa ; + nsec3-test-zone ; // test only + nta-lifetime ; + nta-recheck ; + nxdomain-redirect ; + parental-source ( | * ); + parental-source-v6 ( | * ); + plugin ( query ) [ { } ]; // may occur multiple times + preferred-glue ; + prefetch [ ]; + provide-ixfr ; + qname-minimization ( strict | relaxed | disabled | off ); + query-source [ address ] ( | * | none ); + query-source-v6 [ address ] ( | * | none ); + rate-limit { + all-per-second ; + errors-per-second ; + exempt-clients { ; ... }; + ipv4-prefix-length ; + ipv6-prefix-length ; + log-only ; + max-table-size ; + min-table-size ; + nodata-per-second ; + nxdomains-per-second ; + qps-scale ; + referrals-per-second ; + responses-per-second ; + slip ; + window ; + }; + recursion ; + request-expire ; + request-ixfr ; + request-nsid ; + require-server-cookie ; + resolver-query-timeout ; + resolver-use-dns64 ; + response-padding { ; ... } block-size ; + response-policy { zone [ add-soa ] [ log ] [ max-policy-ttl ] [ min-update-interval ] [ policy ( cname | disabled | drop | given | no-op | nodata | nxdomain | passthru | tcp-only ) ] [ recursive-only ] [ nsip-enable ] [ nsdname-enable ] [ ede ]; ... } [ add-soa ] [ break-dnssec ] [ max-policy-ttl ] [ min-update-interval ] [ min-ns-dots ] [ nsip-wait-recurse ] [ nsdname-wait-recurse ] [ qname-wait-recurse ] [ recursive-only ] [ servfail-until-ready ] [ nsip-enable ] [ nsdname-enable ] [ dnsrps-enable ] [ dnsrps-options { } ]; + root-key-sentinel ; + rrset-order { [ class ] [ type ] [ name ] ; ... }; + send-cookie ; + serial-update-method ( date | increment | unixtime ); + server { + bogus ; + edns ; + edns-udp-size ; + edns-version ; + keys ; + max-udp-size ; + notify-source ( | * ); + notify-source-v6 ( | * ); + padding ; + provide-ixfr ; + query-source [ address ] ( | * ); + query-source-v6 [ address ] ( | * ); + request-expire ; + request-ixfr ; + request-nsid ; + require-cookie ; + send-cookie ; + tcp-keepalive ; + tcp-only ; + transfer-format ( many-answers | one-answer ); + transfer-source ( | * ); + transfer-source-v6 ( | * ); + transfers ; + }; // may occur multiple times + servfail-ttl ; + sig-signing-nodes ; + sig-signing-signatures ; + sig-signing-type ; + sig-validity-interval [ ]; // obsolete + sig0key-checks-limit ; + sig0message-checks-limit ; + sortlist { ; ... }; // deprecated + stale-answer-client-timeout ( disabled | off | ); + stale-answer-enable ; + stale-answer-ttl ; + stale-cache-enable ; + stale-refresh-time ; + synth-from-dnssec ; + transfer-format ( many-answers | one-answer ); + transfer-source ( | * ); + transfer-source-v6 ( | * ); + trust-anchor-telemetry ; + trust-anchors { ( static-key | initial-key | static-ds | initial-ds ) ; ... }; // may occur multiple times + trusted-keys { ; ... }; // may occur multiple times, deprecated + try-tcp-refresh ; + update-check-ksk ; // obsolete + v6-bias ; + validate-except { ; ... }; + zero-no-soa-ttl ; + zero-no-soa-ttl-cache ; + zone-statistics ( full | terse | none | ); +}; // may occur multiple times +zone [ ] { + type primary; + allow-query { ; ... }; + allow-query-on { ; ... }; + allow-transfer [ port ] [ transport ] { ; ... }; + allow-update { ; ... }; + also-notify [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + check-dup-records ( fail | warn | ignore ); + check-integrity ; + check-mx ( fail | warn | ignore ); + check-mx-cname ( fail | warn | ignore ); + check-names ( fail | warn | ignore ); + check-sibling ; + check-spf ( warn | ignore ); + check-srv-cname ( fail | warn | ignore ); + check-svcb ; + check-wildcard ; + checkds ( explicit | ); + database ; + dialup ( notify | notify-passive | passive | refresh | ); // deprecated + dlz ; + dnskey-sig-validity ; // obsolete + dnssec-dnskey-kskonly ; // obsolete + dnssec-loadkeys-interval ; + dnssec-policy ; + dnssec-secure-to-insecure ; // obsolete + dnssec-update-mode ( maintain | no-resign ); // obsolete + file ; + forward ( first | only ); + forwarders [ port ] [ tls ] { ( | ) [ port ] [ tls ]; ... }; + inline-signing ; + ixfr-from-differences ; + journal ; + key-directory ; + masterfile-format ( raw | text ); + masterfile-style ( full | relative ); + max-ixfr-ratio ( unlimited | ); + max-journal-size ( default | unlimited | ); + max-records ; + max-records-per-type ; + max-transfer-idle-out ; + max-transfer-time-out ; + max-types-per-name ; + max-zone-ttl ( unlimited | ); // deprecated + notify ( explicit | master-only | primary-only | ); + notify-defer ; + notify-delay ; + notify-source ( | * ); + notify-source-v6 ( | * ); + notify-to-soa ; + nsec3-test-zone ; // test only + parental-agents [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + parental-source ( | * ); + parental-source-v6 ( | * ); + serial-update-method ( date | increment | unixtime ); + sig-signing-nodes ; + sig-signing-signatures ; + sig-signing-type ; + sig-validity-interval [ ]; // obsolete + update-check-ksk ; // obsolete + update-policy ( local | { ( deny | grant ) ( 6to4-self | external | krb5-self | krb5-selfsub | krb5-subdomain | krb5-subdomain-self-rhs | ms-self | ms-selfsub | ms-subdomain | ms-subdomain-self-rhs | name | self | selfsub | selfwild | subdomain | tcp-self | wildcard | zonesub ) [ ] ; ... } ); + zero-no-soa-ttl ; + zone-statistics ( full | terse | none | ); +}; +zone [ ] { + type secondary; + allow-notify { ; ... }; + allow-query { ; ... }; + allow-query-on { ; ... }; + allow-transfer [ port ] [ transport ] { ; ... }; + allow-update-forwarding { ; ... }; + also-notify [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + check-names ( fail | warn | ignore ); + checkds ( explicit | ); + database ; + dialup ( notify | notify-passive | passive | refresh | ); // deprecated + dlz ; + dnskey-sig-validity ; // obsolete + dnssec-dnskey-kskonly ; // obsolete + dnssec-loadkeys-interval ; + dnssec-policy ; + dnssec-update-mode ( maintain | no-resign ); // obsolete + file ; + forward ( first | only ); + forwarders [ port ] [ tls ] { ( | ) [ port ] [ tls ]; ... }; + inline-signing ; + ixfr-from-differences ; + journal ; + key-directory ; + masterfile-format ( raw | text ); + masterfile-style ( full | relative ); + max-ixfr-ratio ( unlimited | ); + max-journal-size ( default | unlimited | ); + max-records ; + max-records-per-type ; + max-refresh-time ; + max-retry-time ; + max-transfer-idle-in ; + max-transfer-idle-out ; + max-transfer-time-in ; + max-transfer-time-out ; + max-types-per-name ; + min-refresh-time ; + min-retry-time ; + min-transfer-rate-in ; + multi-master ; + notify ( explicit | master-only | primary-only | ); + notify-defer ; + notify-delay ; + notify-source ( | * ); + notify-source-v6 ( | * ); + notify-to-soa ; + nsec3-test-zone ; // test only + parental-agents [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + parental-source ( | * ); + parental-source-v6 ( | * ); + primaries [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + request-expire ; + request-ixfr ; + sig-signing-nodes ; + sig-signing-signatures ; + sig-signing-type ; + sig-validity-interval [ ]; // obsolete + transfer-source ( | * ); + transfer-source-v6 ( | * ); + try-tcp-refresh ; + update-check-ksk ; // obsolete + zero-no-soa-ttl ; + zone-statistics ( full | terse | none | ); +}; +zone [ ] { + type mirror; + allow-notify { ; ... }; + allow-query { ; ... }; + allow-query-on { ; ... }; + allow-transfer [ port ] [ transport ] { ; ... }; + allow-update-forwarding { ; ... }; + also-notify [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + check-names ( fail | warn | ignore ); + database ; + file ; + ixfr-from-differences ; + journal ; + masterfile-format ( raw | text ); + masterfile-style ( full | relative ); + max-ixfr-ratio ( unlimited | ); + max-journal-size ( default | unlimited | ); + max-records ; + max-records-per-type ; + max-refresh-time ; + max-retry-time ; + max-transfer-idle-in ; + max-transfer-idle-out ; + max-transfer-time-in ; + max-transfer-time-out ; + max-types-per-name ; + min-refresh-time ; + min-retry-time ; + min-transfer-rate-in ; + multi-master ; + notify ( explicit | master-only | primary-only | ); + notify-defer ; + notify-delay ; + notify-source ( | * ); + notify-source-v6 ( | * ); + primaries [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + request-expire ; + request-ixfr ; + transfer-source ( | * ); + transfer-source-v6 ( | * ); + try-tcp-refresh ; + zero-no-soa-ttl ; + zone-statistics ( full | terse | none | ); +}; +zone [ ] { + type forward; + forward ( first | only ); + forwarders [ port ] [ tls ] { ( | ) [ port ] [ tls ]; ... }; +}; +zone [ ] { + type hint; + check-names ( fail | warn | ignore ); + file ; +}; +zone [ ] { + type redirect; + allow-query { ; ... }; + allow-query-on { ; ... }; + dlz ; + file ; + masterfile-format ( raw | text ); + masterfile-style ( full | relative ); + max-records ; + max-records-per-type ; + max-types-per-name ; + max-zone-ttl ( unlimited | ); // deprecated + primaries [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + zone-statistics ( full | terse | none | ); +}; +zone [ ] { + type static-stub; + allow-query { ; ... }; + allow-query-on { ; ... }; + forward ( first | only ); + forwarders [ port ] [ tls ] { ( | ) [ port ] [ tls ]; ... }; + max-records ; + max-records-per-type ; + max-types-per-name ; + server-addresses { ( | ); ... }; + server-names { ; ... }; + zone-statistics ( full | terse | none | ); +}; +zone [ ] { + type stub; + allow-query { ; ... }; + allow-query-on { ; ... }; + check-names ( fail | warn | ignore ); + database ; + dialup ( notify | notify-passive | passive | refresh | ); // deprecated + file ; + forward ( first | only ); + forwarders [ port ] [ tls ] { ( | ) [ port ] [ tls ]; ... }; + masterfile-format ( raw | text ); + masterfile-style ( full | relative ); + max-records ; + max-records-per-type ; + max-refresh-time ; + max-retry-time ; + max-transfer-idle-in ; + max-transfer-time-in ; + max-types-per-name ; + min-refresh-time ; + min-retry-time ; + min-transfer-rate-in ; + multi-master ; + primaries [ port ] [ source ( | * ) ] [ source-v6 ( | * ) ] { ( | [ port ] | [ port ] ) [ key ] [ tls ]; ... }; + transfer-source ( | * ); + transfer-source-v6 ( | * ); + zone-statistics ( full | terse | none | ); +}; +zone [ ] { + in-view ; +}; diff --git a/internal/catalog/data/statements-9.20.tsv b/internal/catalog/data/statements-9.20.tsv new file mode 100644 index 0000000..c996078 --- /dev/null +++ b/internal/catalog/data/statements-9.20.tsv @@ -0,0 +1,356 @@ +# Statement index from the BIND 9.20.24 ARM, section 8.3 "Statements" +# (https://bind9.readthedocs.io/en/v9.20.24/reference.html#statements). +# Format: namedescriptiontags (comma separated) +acl Assigns a symbolic name to an address match list. server +algorithm Defines the algorithm to be used in a key clause. security +all-per-second Limits UDP responses of all kinds. query +allow-new-zones Controls the ability to add zones at runtime via rndc addzone. server, zone +allow-notify Defines an address_match_list that is allowed to send NOTIFY messages for the zone, in addition to addresses defined in the primaries option for the zone. transfer +allow-proxy Defines an address_match_list for the client addresses allowed to send PROXYv2 headers. server +allow-proxy-on Defines an address_match_list for the interface addresses allowed to accept PROXYv2 headers. The option is mostly intended for multi-homed configurations. server +allow-query Specifies which hosts (an IP address list) are allowed to send queries to this resolver. query +allow-query-cache Specifies which hosts (an IP address list) can access this server's cache and thus effectively controls recursion. query +allow-query-cache-on Specifies which hosts (from an IP address list) can access this server's cache. It is used on servers with multiple interfaces. query +allow-query-on Specifies which local addresses (an IP address list) are allowed to send queries to this resolver. This option is used in multi-homed configurations. query +allow-recursion Defines an address_match_list of clients that are allowed to perform recursive queries. query +allow-recursion-on Specifies which local addresses can accept recursive queries. query, server +allow-transfer Defines an address_match_list of hosts that are allowed to transfer the zone information from this server. transfer +allow-update Defines an address_match_list of hosts that are allowed to submit dynamic updates for primary zones. transfer +allow-update-forwarding Defines an address_match_list of hosts that are allowed to submit dynamic updates to a secondary server for transmission to a primary. transfer +also-notify Defines one or more hosts that are sent NOTIFY messages when zone changes occur. transfer +answer-cookie Controls whether COOKIE EDNS replies are sent in response to client queries. query +attach-cache Allows multiple views to share a single cache database. view +auth-nxdomain Controls whether BIND, acting as a resolver, provides authoritative NXDOMAIN (domain does not exist) answers. query +automatic-interface-scan Controls the automatic rescanning of network interfaces when addresses are added or removed. server +avoid-v4-udp-ports Specifies the range(s) of ports to be excluded from use as sources for UDP/IPv4 messages. deprecated +avoid-v6-udp-ports Specifies the range(s) of ports to be excluded from use as sources for UDP/IPv6 messages. deprecated +bindkeys-file Specifies the pathname of a file to override the built-in trusted keys provided by named. dnssec +blackhole Defines an address_match_list of hosts to ignore. The server will neither respond to queries from nor send queries to these addresses. query +bogus Allows a remote server to be ignored. server +break-dnssec Enables dns64 synthesis even if the validated result would cause a DNSSEC validation failure. query +buffered Controls flushing of log messages. logging +ca-file Specifies the path to a file containing TLS certificates for trusted CA authorities, used to verify remote peer certificates. server, security +catalog-zones Configures catalog zones in named.conf. zone +category Specifies the type of data logged to a particular channel. logging +cdnskey Specifies whether a CDNSKEY record should be published during KSK rollover. dnssec +cds-digest-types Specifies the digest types to use for CDS resource records. dnssec +cert-file Specifies the path to a file containing the TLS certificate for a connection. server, security +channel Defines a stream of data that can be independently logged. logging +check-dup-records Checks primary zones for records that are treated as different by DNSSEC but are semantically equal in plain DNS. dnssec, query +check-integrity Performs post-load zone integrity checks on primary zones. zone +check-mx Checks whether an MX record appears to refer to an IP address. zone +check-mx-cname Sets the response to MX records that refer to CNAMEs. zone +check-names Restricts the character set and syntax of certain domain names in primary files and/or DNS responses received from the network. query, server +check-sibling Specifies whether to check for sibling glue when performing integrity checks. zone +check-spf Specifies whether to check for a TXT Sender Policy Framework record, if an SPF record is present. zone +check-srv-cname Sets the response to SRV records that refer to CNAMEs. zone +check-svcb Specifies whether to perform additional checks on SVCB records. zone +check-wildcard Checks for non-terminal wildcards. zone +checkds Controls whether DS queries are sent to parental agents. dnssec +cipher-suites Specifies a list of allowed cipher suites in the order of preference for TLSv1.3 only. security +ciphers Specifies a list of allowed ciphers in the order of preference for TLSv1.2 only. security +clients Specifies an access control list (ACL) of clients that are affected by a given dns64 directive. query +clients-per-query Sets the initial minimum number of simultaneous recursive clients accepted by the server for any given query before the server drops additional clients. server +controls Specifies control channels to be used to manage the name server. server +cookie-algorithm Sets the algorithm to be used when generating a server cookie. server +cookie-secret Specifies a shared secret used for generating and verifying EDNS COOKIE options within an anycast cluster. server +database Specifies the type of database to be used to store zone data. zone +deny-answer-addresses Rejects A or AAAA records if the corresponding IPv4 or IPv6 addresses match a given address_match_list. query +deny-answer-aliases Rejects CNAME or DNAME records if the "alias" name matches a given list of domain_name elements. query +dhparam-file Specifies the path to a file containing Diffie-Hellman parameters, for enabling cipher suites. server, security +dialup Concentrates zone maintenance so that all transfers take place once every heartbeat-interval, ideally during a single call. deprecated +directory Sets the server's working directory. server +disable-algorithms Disables DNSSEC algorithms from a specified zone. dnssec +disable-ds-digests Disables DS digest types from a specified zone. dnssec, zone +disable-empty-zone Disables individual empty zones. server, zone +dlz Configures a Dynamically Loadable Zone (DLZ) database in named.conf. zone +dns64 Instructs named to return mapped IPv4 addresses to AAAA queries when there are no AAAA records. query +dns64-contact Specifies the name of the contact for dns64 zones. server +dns64-server Specifies the name of the server for dns64 zones. server +dnskey-sig-validity obsolete +dnskey-ttl Specifies the time-to-live (TTL) for DNSKEY resource records. dnssec +dnsrps-enable Turns on the DNS Response Policy Service (DNSRPS) interface. server, security +dnsrps-library Specifies the path to the DNS Response Policy Service (DNSRPS) provider library. server, security +dnsrps-options Provides additional RPZ configuration settings, which are passed to the DNS Response Policy Service (DNSRPS) provider library. server, security +dnssec-accept-expired Instructs BIND 9 to accept expired DNSSEC signatures when validating. dnssec +dnssec-dnskey-kskonly obsolete +dnssec-loadkeys-interval Sets the frequency of automatic checks of the DNSSEC key repository. dnssec +dnssec-must-be-secure Defines hierarchies that must or may not be secure (signed and validated). deprecated +dnssec-policy Defines a key and signing policy (KASP) for zones. dnssec +dnssec-secure-to-insecure obsolete +dnssec-update-mode obsolete +dnssec-validation Enables DNSSEC validation in named. dnssec +dnstap Enables logging of dnstap messages. logging +dnstap-identity Specifies an identity string to send in dnstap messages. logging +dnstap-output Configures the path to which the dnstap frame stream is sent. logging +dnstap-version Specifies a version string to send in dnstap messages. logging +dual-stack-servers Specifies host names or addresses of machines with access to both IPv4 and IPv6 transports. server +dump-file Indicates the pathname of the file where the server dumps the database after rndc dumpdb. logging +dyndb Configures a DynDB database in named.conf. zone +edns Controls the use of the EDNS0 (RFC 2671) feature. server +edns-udp-size Sets the maximum advertised EDNS UDP buffer size to control the size of packets received from authoritative servers in response to recursive queries. query +edns-version Sets the maximum EDNS VERSION that is sent to the server(s) by the resolver. server +empty-contact Specifies the contact name in the returned SOA record for empty zones. server, zone +empty-server Specifies the server name in the returned SOA record for empty zones. server, zone +empty-zones-enable Enables or disables all empty zones. server, zone +endpoints Specifies a list of HTTP query paths on which to listen. server, query +errors-per-second Limits the number of errors for a valid domain name and record type. server +exclude Allows a list of IPv6 addresses to be ignored if they appear in a domain name's AAAA records in dns64. query +exempt-clients Exempts specific clients or client groups from rate limiting. query +fetch-quota-params Sets the parameters for dynamic resizing of the fetches-per-server quota in response to detected congestion. server, query +fetches-per-server Sets the maximum number of simultaneous iterative queries allowed to be sent by a server to an upstream name server before the server blocks additional queries. server, query +fetches-per-zone Sets the maximum number of simultaneous iterative queries allowed to any one domain before the server blocks new queries for data in or beneath that zone. server, query +file Specifies the zone's filename. zone +flush-zones-on-shutdown Controls whether pending zone writes are flushed when the name server exits. zone +forward Allows or disallows fallback to recursion if forwarding has failed; it is always used in conjunction with the forwarders statement. query +forwarders Defines one or more resolvers to which queries are forwarded. query +fstrm-set-buffer-hint Sets the number of accumulated bytes in the output buffer before forcing a buffer flush. logging +fstrm-set-flush-timeout Sets the number of seconds that unflushed data remains in the output buffer. logging +fstrm-set-input-queue-size Sets the number of queue entries to allocate for each input queue. logging +fstrm-set-output-notify-threshold Sets the number of outstanding queue entries allowed on an input queue before waking the I/O thread. logging +fstrm-set-output-queue-model Sets the queuing semantics to use for queue objects. logging +fstrm-set-output-queue-size Sets the number of queue entries allocated for each output queue. logging +fstrm-set-reopen-interval Sets the number of seconds to wait between attempts to reopen a closed output stream. logging +geoip-directory Specifies the directory containing GeoIP database files. server +heartbeat-interval Sets the interval at which the server performs zone maintenance tasks for all zones marked as dialup. deprecated +hostname Specifies the hostname of the server to return in response to a hostname.bind query. server +http Configures HTTP endpoints on which to listen for DNS-over-HTTPS (DoH) queries. server, query +http-listener-clients Limits the number of active concurrent connections on a per-listener basis. server +http-port Specifies the TCP port number the server uses to receive and send unencrypted DNS traffic via HTTP. server, query +http-streams-per-connection Limits the number of active concurrent HTTP/2 streams on a per-connection basis. server +https-port Specifies the TCP port number the server uses to receive and send DNS-over-HTTPS protocol traffic. server, query +in-view Specifies the view in which a given zone is defined. view, zone +inet Specifies a TCP socket as a control channel. server +inline-signing Specifies whether BIND 9 maintains a separate signed version of a zone. dnssec, zone +interface-interval Sets the interval at which the server scans the network interface list. server +ipv4-prefix-length Specifies the prefix lengths of IPv4 address blocks. server +ipv4only-contact Specifies the contact for the IPV4ONLY.ARPA zone created by dns64. server +ipv4only-enable Enables automatic IPv4 zones if a dns64 block is configured. query +ipv4only-server Specifies the name of the server for the IPV4ONLY.ARPA zone created by dns64. server, query +ipv6-prefix-length Specifies the prefix lengths of IPv6 address blocks. server +ixfr-from-differences Controls how IXFR transfers are calculated. transfer +journal Allows the default journal's filename to be overridden. zone +key Defines a shared secret key for use with TSIG or the command channel. security +key-directory Indicates the directory where public and private DNSSEC key files are found. dnssec +key-file Specifies the path to a file containing the private TLS key for a connection. server, security +key-store Configures a DNSSEC key store. dnssec +keys Specifies one or more server_key s to be used with a remote server. server, security +lame-ttl Sets the resolver's lame cache. server +listen-on Specifies the IPv4 addresses on which a server listens for DNS queries. server +listen-on-v6 Specifies the IPv6 addresses on which a server listens for DNS queries. server +listener-clients Specifies a per-listener quota for active connections. server, query +lmdb-mapsize Sets a maximum size for the memory map of the new-zone database in LMDB database format. server +log-only Tests rate-limiting parameters without actually dropping any requests. logging, query +logging Configures logging options for the name server. logging +managed-keys deprecated +managed-keys-directory Specifies the directory in which to store the files that track managed DNSSEC keys. dnssec +manual-mode Run key management in a manual mode. dnssec +mapped Specifies an access control list (ACL) of IPv4 addresses that are to be mapped to the corresponding A RRset in dns64. query +masterfile-format Specifies the file format of zone files. zone, server +masterfile-style Specifies the format of zone files during a dump, when the masterfile-format is text. server +match-clients Specifies a view of DNS namespace for a given subset of client IP addresses. view +match-destinations Specifies a view of DNS namespace for a given subset of destination IP addresses. view +match-mapped-addresses Allows IPv4-mapped IPv6 addresses to match address-match list entries for corresponding IPv4 addresses. server +match-recursive-only Specifies that only recursive requests can match this view of the DNS namespace. view +max-cache-size Sets the maximum amount of memory to use for an individual cache database and its associated metadata. server +max-cache-ttl Specifies the maximum time (in seconds) that the server caches ordinary (positive) answers. server +max-clients-per-query Sets the maximum number of simultaneous recursive clients accepted by the server for any given query before the server drops additional clients. server +max-ixfr-ratio Sets the maximum size for IXFR responses to zone transfer requests. transfer +max-journal-size Controls the size of journal files. transfer +max-ncache-ttl Specifies the maximum retention time (in seconds) for storage of negative answers in the server's cache. server +max-query-count Sets the maximum number of iterative queries while servicing a recursive query. server, query +max-query-restarts Sets the maximum number of chained CNAMEs to follow server, query +max-records Sets the maximum number of records permitted in a zone. zone, server +max-records-per-type Sets the maximum number of records that can be stored in an RRset. server +max-recursion-depth Sets the maximum number of levels of recursion permitted at any one time while servicing a recursive query. server +max-recursion-queries Sets the maximum number of iterative queries while servicing a recursive query. server, query +max-refresh-time Limits the zone refresh interval to no less often than the specified value, in seconds. transfer +max-retry-time Limits the zone refresh retry interval to no less often than the specified value, in seconds. transfer +max-rsa-exponent-size Sets the maximum RSA exponent size (in bits) when validating. dnssec, query +max-stale-ttl Specifies the maximum time that the server retains records past their normal expiry, to return them as stale records. server +max-table-size Sets the maximum size of the table used to track requests and rate-limit responses. server +max-transfer-idle-in Specifies the number of minutes after which inbound zone transfers making no progress are terminated. transfer +max-transfer-idle-out Specifies the number of minutes after which outbound zone transfers making no progress are terminated. transfer +max-transfer-time-in Specifies the number of minutes after which inbound zone transfers are terminated. transfer +max-transfer-time-out Specifies the number of minutes after which outbound zone transfers are terminated. transfer +max-types-per-name Sets the maximum number of RR types that can be stored for an owner name. server +max-udp-size Sets the maximum EDNS UDP message size sent by named. query +max-validation-failures-per-fetch Sets the maximum number of DNSSEC validation failures that can happen in a single fetch. server +max-validations-per-fetch Sets the maximum number of DNSSEC validations that can happen in a single fetch. server +max-zone-ttl Specifies a maximum permissible time-to-live (TTL) value, in seconds. deprecated +memstatistics Controls whether memory statistics are written to the file specified by memstatistics-file at exit. server, logging +memstatistics-file Sets the pathname of the file where the server writes memory usage statistics on exit. logging +message-compression Controls whether DNS name compression is used in responses to regular queries. query +min-cache-ttl Specifies the minimum time (in seconds) that the server caches ordinary (positive) answers. server +min-ncache-ttl Specifies the minimum retention time (in seconds) for storage of negative answers in the server's cache. server +min-refresh-time Limits the zone refresh interval to no more often than the specified value, in seconds. transfer +min-retry-time Limits the zone refresh retry interval to no more often than the specified value, in seconds. transfer +min-table-size Sets the minimum size of the table used to track requests and rate-limit responses. query +min-transfer-rate-in Specifies the minimum traffic rate below which inbound zone transfers are terminated. transfer +minimal-any Controls whether the server replies with only one of the RRsets for a query name, when generating a positive response to a query of type ANY over UDP. query +minimal-responses Controls whether the server only adds records to the authority and additional data sections when they are required (e.g. delegations, negative responses). This improves server performance. query +multi-master Controls whether serial number mismatch errors are logged. transfer +new-zones-directory Specifies the directory where configuration parameters are stored for zones added by rndc addzone. zone +no-case-compress Specifies a list of addresses that require case-insensitive compression in responses. server +nocookie-udp-size Sets the maximum size of UDP responses that are sent to queries without a valid server COOKIE. query +nodata-per-second Limits the number of empty (NODATA) responses for a valid domain name. query +notify Controls whether NOTIFY messages are sent on zone changes. transfer +notify-defer Sets the defer time (in seconds) before sending NOTIFY messages for a zone. transfer, zone +notify-delay Sets the delay (in seconds) between sending sets of NOTIFY messages for a zone. transfer, zone +notify-rate Specifies the rate at which NOTIFY requests are sent during normal zone maintenance operations. transfer, zone +notify-source Defines the IPv4 address (and optional port) to be used for outgoing NOTIFY messages. transfer +notify-source-v6 Defines the IPv6 address (and optional port) to be used for outgoing NOTIFY messages. transfer +notify-to-soa Controls whether the name servers in the NS RRset are checked against the SOA MNAME. transfer +nsec3param Specifies the use of NSEC3 instead of NSEC, and sets NSEC3 parameters. dnssec +nta-lifetime Specifies the lifetime, in seconds, for negative trust anchors added via rndc nta. dnssec +nta-recheck Specifies the time interval for checking whether negative trust anchors added via rndc nta are still necessary. dnssec +null Causes all messages sent to the logging channel to be discarded. logging +nxdomain-redirect Appends the specified suffix to the original query name, when replacing an NXDOMAIN with a redirect namespace. query +nxdomains-per-second Limits the number of undefined subdomains for a valid domain name. query +offline-ksk Specifies whether the DNSKEY, CDS, and CDNSKEY RRsets are being signed offline. dnssec +options Defines global options to be used by BIND 9. server +padding Adds EDNS Padding options to outgoing messages to increase the packet size. server +parent-ds-ttl Sets the time to live (TTL) of the DS RRset used by the parent zone. dnssec +parent-propagation-delay Sets the propagation delay from the time the parent zone is updated to when the new version is served by all of the parent zone's name servers. dnssec, zone +parental-agents dnssec +parental-source Specifies which local IPv4 source address is used to send parental DS queries. dnssec +parental-source-v6 Specifies which local IPv6 source address is used to send parental DS queries. dnssec +pid-file Specifies the pathname of the file where the server writes its process ID. server +pkcs11-uri dnssec, pkcs11 +plugin Configures plugins in named.conf. server +port Specifies the UDP/TCP port number the server uses to receive and send DNS protocol traffic. server, query +prefer-server-ciphers Specifies that server ciphers should be preferred over client ones. server, security +preferred-glue Controls the order of glue records in an A or AAAA response. query +prefetch Specifies the "trigger" time-to-live (TTL) value at which prefetch of the current query takes place. query +primaries Defines one or more servers that zone transfer can be requested from. transfer, zone +print-category Includes the category in log messages. logging +print-severity Includes the severity in log messages. logging +print-time Specifies the time format for log messages. logging +protocols Specifies the allowed versions of the TLS protocol. security +provide-ixfr Controls whether a primary responds to an incremental zone request (IXFR) or only responds with a full zone transfer (AXFR). transfer +publish-safety Increases the amount of time between when keys are published and when they become active, to allow for unforeseen events. dnssec +purge-keys Specifies the amount of time after which DNSSEC keys that have been deleted from the zone can be removed from disk. dnssec +qname-minimization Controls QNAME minimization behavior in the BIND 9 resolver. query +qps-scale Tightens defenses during DNS attacks by scaling back the ratio of the current query-per-second rate. query +query-source Controls the IPv4 address from which queries are issued. If none, then no IPv4 address would be used to issue the query and therefore only IPv6 servers are queried. query +query-source-v6 Controls the IPv6 address from which queries are issued. If none, then no IPv6 address would be used to issue the query and therefore only IPv4 servers are quried. query +querylog Specifies whether query logging should be active when named first starts. logging, server +rate-limit Controls excessive UDP responses, to prevent BIND 9 from being used to amplify reflection denial-of-service (DoS) attacks. query +recursing-file Specifies the pathname of the file where the server dumps queries that are currently recursing via rndc recursing. server +recursion Defines whether recursion and caching are allowed. query +recursive-clients Specifies the maximum number of concurrent recursive queries the server can perform. query +recursive-only Toggles whether dns64 synthesis occurs only for recursive queries. query +referrals-per-second Limits the number of referrals or delegations to a server for a given domain. query +remote-hostname Specifies the expected hostname in the TLS certificate of the remote server. security +remote-servers Defines a list of servers to be used by primary and secondary zones. server +request-expire Specifies whether the local server requests the EDNS EXPIRE value, when acting as a secondary. transfer, query +request-ixfr Controls whether a secondary requests an incremental zone transfer (IXFR) or a full zone transfer (AXFR). transfer +request-nsid Controls whether an empty EDNS(0) NSID (Name Server Identifier) option is sent with all queries to authoritative name servers during iterative resolution. query +require-cookie Controls whether responses without a server cookie are accepted. query +require-server-cookie Controls whether a valid server cookie is required before sending a full response to a UDP request. query +resolver-query-timeout Specifies the length of time, in milliseconds, that a resolver attempts to resolve a recursive query before failing. query +resolver-use-dns64 Specifies whether to apply DNS64 mappings when sending queries. server +response-padding Adds an EDNS Padding option to encrypted messages, to reduce the chance of guessing the contents based on size. query +response-policy Specifies response policy zones for the view or among global options. server, query, zone, security +responselog Specifies whether response logging should be active when named first starts. logging, server +responses-per-second Limits the number of non-empty responses for a valid domain name and record type. query +retire-safety Increases the amount of time a key remains published after it is no longer active, to allow for unforeseen events. dnssec +reuseport Enables kernel load-balancing of sockets. server +root-key-sentinel Controls whether BIND 9 responds to root key sentinel probes. server +rrset-order Defines the order in which equal RRs (RRsets) are returned. query +search Specifies whether a Dynamically Loadable Zone (DLZ) module is queried for an answer to a query name. query +secret Defines a Base64-encoded string to be used as the secret by the algorithm. security +secroots-file Specifies the pathname of the file where the server dumps security roots, when using rndc secroots. dnssec +send-cookie Controls whether a COOKIE EDNS option is sent along with a query. query +serial-query-rate Defines an upper limit on the number of queries per second issued by the server, when querying the SOA RRs used for zone transfers. transfer +serial-update-method Specifies the update method to be used for the zone serial number in the SOA record. zone +server Defines characteristics to be associated with a remote name server. server +server-addresses Specifies a list of IP addresses to which queries should be sent in recursive resolution for a static-stub zone. query, zone +server-id Specifies the ID of the server to return in response to a ID.SERVER query. server +server-names Specifies a list of domain names of name servers that act as authoritative servers of a static-stub zone. zone +servfail-ttl Sets the length of time (in seconds) that a SERVFAIL response is cached. server +session-keyalg Specifies the algorithm to use for the TSIG session key. security +session-keyfile Specifies the pathname of the file where a TSIG session key is written, when generated by named for use by nsupdate -l. security +session-keyname Specifies the key name for the TSIG session key. security +session-tickets Enables or disables session resumption through TLS session tickets. security +severity Defines the priority level of log messages. logging +sig-signing-nodes Specifies the maximum number of nodes to be examined in each quantum, when signing a zone with a new DNSKEY. dnssec +sig-signing-signatures Specifies the threshold for the number of signatures that terminates processing a quantum, when signing a zone with a new DNSKEY. dnssec +sig-signing-type Specifies a private RDATA type to use when generating signing-state records. dnssec +sig-validity-interval obsolete +sig0checks-quota Specifies the maximum number of concurrent SIG(0) signature checks that can be processed by the server. server +sig0checks-quota-exempt Exempts specific clients or client groups from SIG(0) signature checking quota. server +sig0key-checks-limit Specifies the maximum number of SIG(0) keys to consider when trying to verify a message. server +sig0message-checks-limit Specifies the maximum number of matching SIG(0) keys to try to verify a message. server +signatures-jitter Specifies a range for signature expirations. dnssec +signatures-refresh Specifies how frequently an RRSIG record is refreshed. dnssec +signatures-validity Indicates the validity period of an RRSIG record. dnssec +signatures-validity-dnskey Indicates the validity period of DNSKEY records. dnssec +slip Sets the number of "slipped" responses to minimize the use of forged source addresses for an attack. query +sortlist Controls the ordering of RRs returned to the client, based on the client's IP address. query, deprecated +stale-answer-client-timeout Defines the amount of time (in milliseconds) that named waits before attempting to answer a query with a stale RRset from cache. server, query +stale-answer-enable Enables the returning of "stale" cached answers when the name servers for a zone are not answering. server, query +stale-answer-ttl Specifies the time to live (TTL) to be returned on stale answers, in seconds. query +stale-cache-enable Enables the retention of "stale" cached answers. server, query +stale-refresh-time Sets the time window for the return of "stale" cached answers before the next attempt to contact, if the name servers for a given zone are not responding. server, query +startup-notify-rate Specifies the rate at which NOTIFY requests are sent when the name server is first starting, or when new zones have been added. transfer, zone +statistics-channels Specifies the communication channels to be used by system administrators to access statistics information on the name server. logging +statistics-file Specifies the pathname of the file where the server appends statistics, when using rndc stats. logging, server +stderr Directs the logging channel output to the server's standard error stream. logging +streams-per-connection Specifies the maximum number of concurrent HTTP/2 streams over an HTTP/2 connection. server, query +suffix Defines trailing bits for mapped IPv4 address bits in dns64. query +synth-from-dnssec Enables support for RFC 8198, Aggressive Use of DNSSEC-Validated Cache. dnssec +syslog Directs the logging channel to the system log. logging +tcp-advertised-timeout Sets the timeout value (in milliseconds) that the server sends in responses containing the EDNS TCP keepalive option. query +tcp-clients Specifies the maximum number of simultaneous client TCP connections accepted by the server. server +tcp-idle-timeout Sets the amount of time (in milliseconds) that the server waits on an idle TCP connection before closing it, if the EDNS TCP keepalive option is not in use. query +tcp-initial-timeout Sets the amount of time (in milliseconds) that the server waits on a new TCP connection for the first message from the client. server, query +tcp-keepalive Adds EDNS TCP keepalive to messages sent over TCP. server +tcp-keepalive-timeout Sets the amount of time (in milliseconds) that the server waits on an idle TCP connection before closing it, if the EDNS TCP keepalive option is in use. query +tcp-listen-queue Sets the listen-queue depth. server +tcp-only Sets the transport protocol to TCP. server +tcp-receive-buffer Sets the operating system's receive buffer size for TCP sockets. server +tcp-send-buffer Sets the operating system's send buffer size for TCP sockets. server +tkey-gssapi-credential Sets the security credential for authentication keys requested by the GSS-TSIG protocol. security +tkey-gssapi-keytab Sets the KRB5 keytab file to use for GSS-TSIG updates. security +tls Configures a TLS connection. security +tls-port Specifies the TCP port number the server uses to receive and send DNS-over-TLS protocol traffic. server, query +transfer-format Controls whether multiple records can be packed into a message during zone transfers. transfer +transfer-message-size Limits the uncompressed size of DNS messages used in zone transfers over TCP. transfer +transfer-source Defines which local IPv4 address(es) are bound to TCP connections used to fetch zones transferred inbound by the server. transfer +transfer-source-v6 Defines which local IPv6 address(es) are bound to TCP connections used to fetch zones transferred inbound by the server. transfer +transfers Limits the number of concurrent inbound zone transfers from a server. server +transfers-in Limits the number of concurrent inbound zone transfers. transfer +transfers-out Limits the number of concurrent outbound zone transfers. transfer +transfers-per-ns Limits the number of concurrent inbound zone transfers from a remote server. transfer +trust-anchor-telemetry Instructs named to send specially formed queries once per day to domains for which trust anchors have been configured. dnssec +trust-anchors Defines DNSSEC trust anchors. dnssec +trusted-keys deprecated +try-tcp-refresh Specifies that BIND 9 should attempt to refresh a zone using TCP if UDP queries fail. transfer +type Specifies the kind of zone in a given configuration. zone +type forward Contains forwarding statements that apply to queries within a given domain. zone +type hint Contains the initial set of root name servers to be used at BIND 9 startup. zone +type mirror Contains a DNSSEC-validated duplicate of the main data for a zone. zone +type primary Contains the main copy of the data for a zone. zone +type redirect Contains information to answer queries when normal resolution would return NXDOMAIN. zone +type secondary Contains a duplicate of the data for a zone that has been transferred from a primary server. zone +type static-stub Contains statically configured NS records for a zone. zone +type stub Contains a duplicate of the NS records of a zone. zone +udp-receive-buffer Sets the operating system's receive buffer size for UDP sockets. server +udp-send-buffer Sets the operating system's send buffer size for UDP sockets. server +unix Specifies a Unix domain socket as a control channel. obsolete +update-check-ksk obsolete +update-policy Sets fine-grained rules to allow or deny dynamic updates (DDNS), based on requester identity, updated content, etc. transfer +update-quota Specifies the maximum number of concurrent DNS UPDATE messages that can be processed by the server. server +use-v4-udp-ports Specifies a list of ports that are valid sources for UDP/IPv4 messages. deprecated +use-v6-udp-ports Specifies a list of ports that are valid sources for UDP/IPv6 messages. deprecated +v6-bias Indicates the number of milliseconds of preference to give to IPv6 name servers. server, query +validate-except Specifies a list of domain names at and beneath which DNSSEC validation should not be performed. dnssec +version Specifies the version number of the server to return in response to a version.bind query. server +view Allows a name server to answer a DNS query differently depending on who is asking. view +window Specifies the length of time during which responses are tracked. query +zero-no-soa-ttl Specifies whether to set the time to live (TTL) of the SOA record to zero, when returning authoritative negative responses to SOA queries. zone, query, server +zero-no-soa-ttl-cache Sets the time to live (TTL) to zero when caching a negative response to an SOA query. zone, query, server +zone Specifies the zone in a BIND 9 configuration. zone +zone-propagation-delay Sets the propagation delay from the time a zone is first updated to when the new version of the zone is served by all secondary servers. dnssec, zone +zone-statistics Controls the level of statistics gathered for all zones. zone, logging diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..abe6375 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,243 @@ +// Package client is a small Go client for Jarl's REST API, used by the +// jarl CLI. +package client + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "jarl/internal/api" + "jarl/internal/version" +) + +// Client talks to a Jarl server. +type Client struct { + BaseURL string + Token string + HTTP *http.Client + // Changeset, if set, stages changes into this changeset instead of + // applying them. + Changeset int64 + DryRun bool + Force bool +} + +// Options configure New. +type Options struct { + Server string + Token string + Insecure bool // skip TLS certificate verification + CACert string // PEM file with an extra trusted CA + Timeout time.Duration +} + +// New creates a client. +func New(o Options) (*Client, error) { + if o.Server == "" { + return nil, fmt.Errorf("no server configured: use --server, JARL_SERVER or 'jarl config set server https://...'") + } + u, err := url.Parse(o.Server) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dns.example.com:8053)", o.Server) + } + tr := http.DefaultTransport.(*http.Transport).Clone() + tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // explicit user choice + if o.CACert != "" { + pem, err := os.ReadFile(o.CACert) + if err != nil { + return nil, err + } + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("no certificates in %s", o.CACert) + } + tc.RootCAs = pool + } + tr.TLSClientConfig = tc + timeout := o.Timeout + if timeout == 0 { + timeout = 2 * time.Minute + } + return &Client{BaseURL: strings.TrimSuffix(o.Server, "/"), Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil +} + +// Error is an API error. +type Error struct { + Status int + Info api.ErrorInfo +} + +func (e *Error) Error() string { + if e.Info.Message != "" { + return e.Info.Message + } + return fmt.Sprintf("HTTP %d", e.Status) +} + +// Problem is a validation problem (a subset of the server's fields). +type Problem struct { + Severity string `json:"severity"` + Code string `json:"code"` + Message string `json:"message"` + Zone string `json:"zone"` + View string `json:"view"` +} + +// CheckMessage is a named-checkconf message. +type CheckMessage struct { + Text string `json:"text"` + Error bool `json:"error"` + Object string `json:"object"` +} + +// Problems returns the validation problems attached to the error. +func (e *Error) Problems() []Problem { + var ps []Problem + if len(e.Info.Problems) > 0 { + json.Unmarshal(e.Info.Problems, &ps) + } + return ps +} + +// Check returns named-checkconf messages attached to the error. +func (e *Error) Check() []CheckMessage { + var ms []CheckMessage + if len(e.Info.Check) > 0 { + json.Unmarshal(e.Info.Check, &ms) + } + return ms +} + +// Do sends a request. body is marshaled as JSON; out (if not nil) receives +// the JSON response. +func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error { + u := c.BaseURL + api.Prefix + path + if len(query) > 0 { + u += "?" + query.Encode() + } + var rd io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rd = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, u, rd) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "jarl-cli/"+version.Version) + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + var eb api.ErrorBody + if json.Unmarshal(data, &eb) != nil || eb.Error.Message == "" { + eb.Error.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + return &Error{Status: resp.StatusCode, Info: eb.Error} + } + if out == nil || len(data) == 0 { + return nil + } + if s, ok := out.(*string); ok { + *s = string(data) + return nil + } + return json.Unmarshal(data, out) +} + +// Get is Do with GET. +func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { + return c.Do(ctx, http.MethodGet, path, query, nil, out) +} + +// Mutate sends a configuration change, honoring Changeset, DryRun and +// Force. +func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) { + q := url.Values{} + for k, v := range query { + q[k] = v + } + if c.Changeset > 0 { + q.Set("changeset", fmt.Sprint(c.Changeset)) + } + if c.DryRun { + q.Set("dry_run", "1") + } + if c.Force { + q.Set("force", "1") + } + var out api.MutationResponse + err := c.Do(ctx, method, path, q, body, &out) + return &out, err +} + +// Result is the part of an engine result the CLI shows. +type Result struct { + ChangesetID int64 `json:"changeset_id"` + Applied bool `json:"applied"` + Valid bool `json:"valid"` + Problems []Problem `json:"problems"` + Existing []Problem `json:"existing_problems"` + Check []CheckMessage `json:"check"` + Diff string `json:"diff"` + Files []string `json:"files_changed"` + Serials []struct { + Zone string `json:"zone"` + View string `json:"view"` + Old uint32 `json:"old"` + New uint32 `json:"new"` + } `json:"serials"` + Reloaded []string `json:"reloaded"` + Warnings []string `json:"warnings"` + Ops []struct { + Seq int `json:"seq"` + Op string `json:"op"` + ID int64 `json:"id"` + Summary string `json:"summary"` + } `json:"ops"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// ParseResult decodes the result embedded in a mutation response or error. +func ParseResult(raw json.RawMessage) *Result { + if len(raw) == 0 { + return nil + } + var r Result + if json.Unmarshal(raw, &r) != nil { + return nil + } + return &r +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f913204 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,257 @@ +// Package config loads jarld's configuration file. +package config + +import ( + "bytes" + "fmt" + "net/netip" + "os" + "path/filepath" + "strings" + "time" + + "go.yaml.in/yaml/v3" +) + +// DefaultPath is where jarld looks for its configuration. +const DefaultPath = "/etc/jarl/jarl.yaml" + +// Config is the daemon configuration. +type Config struct { + Listen []string `yaml:"listen"` + TLS TLS `yaml:"tls"` + TrustedProxies []string `yaml:"trusted_proxies"` + Database string `yaml:"database"` + StagingDir string `yaml:"staging_dir"` + Log Log `yaml:"log"` + Session Session `yaml:"session"` + BIND BIND `yaml:"bind"` + Audit Audit `yaml:"audit"` +} + +// TLS configures HTTPS. +type TLS struct { + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +// Log configures logging. +type Log struct { + Level string `yaml:"level"` // debug, info, warn, error + Format string `yaml:"format"` // text, json +} + +// Session configures web sessions. +type Session struct { + Lifetime Duration `yaml:"lifetime"` + IdleTimeout Duration `yaml:"idle_timeout"` + CookieSecure string `yaml:"cookie_secure"` // auto, always, never +} + +// BIND says where BIND's files and tools are. +type BIND struct { + MainConfig string `yaml:"main_config"` + ConfigDir string `yaml:"config_dir"` + ZoneDir string `yaml:"zone_dir"` + CacheDir string `yaml:"cache_dir"` + FileGroup string `yaml:"file_group"` + NamedCheckConf string `yaml:"named_checkconf"` + NamedCheckZone string `yaml:"named_checkzone"` + RNDC string `yaml:"rndc"` + RNDCArgs []string `yaml:"rndc_args"` + ReloadTimeout Duration `yaml:"reload_timeout"` + NoReload bool `yaml:"no_reload"` +} + +// Audit configures the audit log. +type Audit struct { + // RetentionDays deletes older audit events (0 keeps everything). + RetentionDays int `yaml:"retention_days"` +} + +// Duration is a time.Duration written as "15s" or "12h" in YAML. Days +// ("30d") are accepted too. +type Duration time.Duration + +// UnmarshalYAML implements yaml.Unmarshaler. +func (d *Duration) UnmarshalYAML(n *yaml.Node) error { + var s string + if err := n.Decode(&s); err != nil { + return err + } + v, err := ParseDuration(s) + if err != nil { + return err + } + *d = Duration(v) + return nil +} + +// MarshalYAML implements yaml.Marshaler. +func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil } + +// ParseDuration parses a Go duration, also accepting a "d" suffix. +func ParseDuration(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if n, ok := strings.CutSuffix(s, "d"); ok { + var days int + if _, err := fmt.Sscanf(n, "%d", &days); err != nil || fmt.Sprint(days) != n { + return 0, fmt.Errorf("invalid duration %q", s) + } + return time.Duration(days) * 24 * time.Hour, nil + } + return time.ParseDuration(s) +} + +// Default returns the defaults for a Debian/Ubuntu BIND 9.20 server. +func Default() *Config { + return &Config{ + Listen: []string{"127.0.0.1:8053"}, + Database: "/var/lib/jarl/jarl.db", + StagingDir: "/var/lib/jarl/staging", + Log: Log{Level: "info", Format: "text"}, + Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"}, + BIND: BIND{ + MainConfig: "/etc/bind/named.conf", + ConfigDir: "/etc/bind/jarl", + ZoneDir: "/var/lib/bind/jarl", + CacheDir: "/var/cache/bind/jarl", + FileGroup: "bind", + NamedCheckConf: "/usr/bin/named-checkconf", + NamedCheckZone: "/usr/bin/named-checkzone", + RNDC: "/usr/sbin/rndc", + ReloadTimeout: Duration(15 * time.Second), + }, + } +} + +// Load reads the configuration file at path over the defaults. A missing +// file is not an error when allowMissing is set. +func Load(path string, allowMissing bool) (*Config, error) { + c := Default() + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) && allowMissing { + return c, c.check() + } + return nil, err + } + dec := yaml.NewDecoder(bytes.NewReader(b)) + dec.KnownFields(true) + if err := dec.Decode(c); err != nil && err.Error() != "EOF" { + return nil, fmt.Errorf("%s: %w", path, err) + } + return c, c.check() +} + +func (c *Config) check() error { + if len(c.Listen) == 0 { + return fmt.Errorf("listen: at least one address is needed") + } + if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") { + return fmt.Errorf("tls: set both cert_file and key_file") + } + for _, p := range c.TrustedProxies { + if _, err := ParsePrefix(p); err != nil { + return fmt.Errorf("trusted_proxies: %v", err) + } + } + for name, p := range map[string]string{"database": c.Database, "bind.config_dir": c.BIND.ConfigDir, "bind.zone_dir": c.BIND.ZoneDir, "bind.cache_dir": c.BIND.CacheDir} { + if p == "" || !filepath.IsAbs(p) { + return fmt.Errorf("%s must be an absolute path", name) + } + } + switch c.Session.CookieSecure { + case "auto", "always", "never": + default: + return fmt.Errorf("session.cookie_secure must be auto, always or never") + } + switch c.Log.Format { + case "text", "json": + default: + return fmt.Errorf("log.format must be text or json") + } + return nil +} + +// ParsePrefix parses an address or prefix. +func ParsePrefix(s string) (netip.Prefix, error) { + if strings.Contains(s, "/") { + return netip.ParsePrefix(s) + } + a, err := netip.ParseAddr(s) + if err != nil { + return netip.Prefix{}, err + } + return netip.PrefixFrom(a, a.BitLen()), nil +} + +// Proxies returns the trusted proxy prefixes. +func (c *Config) Proxies() []netip.Prefix { + var out []netip.Prefix + for _, p := range c.TrustedProxies { + if pf, err := ParsePrefix(p); err == nil { + out = append(out, pf.Masked()) + } + } + return out +} + +// Example is a commented example configuration. +const Example = `# Jarl daemon configuration (/etc/jarl/jarl.yaml). +# Every setting is optional; the values shown are the defaults for +# Debian/Ubuntu with BIND 9.20. + +# Addresses the web UI and API listen on. Keep this off the internet: +# bind to localhost or a management network, and use TLS (below) or a +# TLS-terminating reverse proxy. +listen: + - "127.0.0.1:8053" + +# HTTPS. The certificate is reread when the file changes (or on SIGHUP), +# so renewed Let's Encrypt certificates are picked up automatically. +tls: + cert_file: "" + key_file: "" + +# Reverse proxies whose X-Forwarded-For / X-Forwarded-Proto headers are +# trusted (addresses or prefixes). +trusted_proxies: [] + +database: /var/lib/jarl/jarl.db +staging_dir: /var/lib/jarl/staging + +log: + level: info # debug, info, warn or error + format: text # text or json (both go to the journal under systemd) + +session: + lifetime: 12h # web sessions end after this long... + idle_timeout: 2h # ...or after this long without activity + cookie_secure: auto # auto, always or never + +bind: + # The file named loads. 'jarld adopt' makes it include Jarl's config. + main_config: /etc/bind/named.conf + # Where Jarl writes named.conf/keys.conf, zone files, and where named + # keeps files of secondary zones. named (AppArmor) may read /etc/bind + # and write /var/lib/bind and /var/cache/bind. + config_dir: /etc/bind/jarl + zone_dir: /var/lib/bind/jarl + cache_dir: /var/cache/bind/jarl + # Group of generated files; named must be able to read them. + file_group: bind + named_checkconf: /usr/bin/named-checkconf + named_checkzone: /usr/bin/named-checkzone + rndc: /usr/sbin/rndc + # Extra rndc arguments, e.g. ["-k", "/etc/bind/rndc.key", "-s", "127.0.0.1"]. + rndc_args: [] + # How long to wait for named to load changed zones before rolling back. + reload_timeout: 15s + # Write files but never run rndc (no named on this host). + no_reload: false + +audit: + # Delete audit events older than this many days (0 keeps them forever). + retention_days: 0 +` diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..160dfd8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestExampleParses(t *testing.T) { + path := filepath.Join(t.TempDir(), "jarl.yaml") + os.WriteFile(path, []byte(Example), 0o644) + c, err := Load(path, false) + if err != nil { + t.Fatal(err) + } + d := Default() + if c.BIND.ZoneDir != d.BIND.ZoneDir || time.Duration(c.Session.IdleTimeout) != 2*time.Hour || c.Listen[0] != "127.0.0.1:8053" { + t.Errorf("example differs from defaults: %+v", c) + } +} + +func TestLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "jarl.yaml") + os.WriteFile(path, []byte("listen: [\"0.0.0.0:8443\"]\ntls: {cert_file: /a, key_file: /b}\ntrusted_proxies: [10.0.0.1, 192.168.0.0/16]\nsession: {lifetime: 1d}\n"), 0o644) + c, err := Load(path, false) + if err != nil { + t.Fatal(err) + } + if time.Duration(c.Session.Lifetime) != 24*time.Hour || len(c.Proxies()) != 2 || c.BIND.FileGroup != "bind" { + t.Errorf("config = %+v", c) + } + os.WriteFile(path, []byte("lisen: []\n"), 0o644) + if _, err := Load(path, false); err == nil { + t.Error("unknown field accepted") + } + os.WriteFile(path, []byte("tls: {cert_file: /a}\n"), 0o644) + if _, err := Load(path, false); err == nil { + t.Error("half TLS config accepted") + } + if _, err := Load(filepath.Join(dir, "missing.yaml"), true); err != nil { + t.Errorf("missing file with allowMissing: %v", err) + } +} diff --git a/internal/dnsname/dnsname.go b/internal/dnsname/dnsname.go new file mode 100644 index 0000000..ce60331 --- /dev/null +++ b/internal/dnsname/dnsname.go @@ -0,0 +1,420 @@ +// Package dnsname contains helpers for handling DNS names the way Jarl stores +// them: lowercase, fully qualified, with a trailing dot ("www.example.com."). +package dnsname + +import ( + "errors" + "fmt" + "net/netip" + "strings" + + "github.com/miekg/dns" + "golang.org/x/net/idna" +) + +// Root is the root domain name. +const Root = "." + +// Fqdn lowercases name and makes sure it ends with a dot. An empty name +// becomes the root name. +func Fqdn(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" || name == "." { + return Root + } + if !strings.HasSuffix(name, ".") || strings.HasSuffix(name, `\.`) && !strings.HasSuffix(name, `\\.`) { + name += "." + } + return name +} + +// Display returns name without its trailing dot, which is how names are +// shown to people. The root name is shown as ".". +func Display(name string) string { + if name == Root || name == "" { + return Root + } + return strings.TrimSuffix(name, ".") +} + +// IsSubdomain reports whether child is equal to, or below, parent. Both names +// must be canonical (see Fqdn). +func IsSubdomain(child, parent string) bool { + if parent == Root { + return true + } + return child == parent || strings.HasSuffix(child, "."+parent) +} + +// IsStrictSubdomain reports whether child is below parent (and not equal). +func IsStrictSubdomain(child, parent string) bool { + return child != parent && IsSubdomain(child, parent) +} + +// Parent returns the name with its leftmost label removed. The parent of a +// top-level name is the root, and the root has no parent (""). +func Parent(name string) string { + if name == Root { + return "" + } + i := strings.IndexByte(name, '.') + if i < 0 || i == len(name)-1 { + return Root + } + return name[i+1:] +} + +// FirstLabel returns the leftmost label of name. +func FirstLabel(name string) string { + if name == Root { + return "" + } + if i := strings.IndexByte(name, '.'); i >= 0 { + return name[:i] + } + return name +} + +// CountLabels returns the number of labels in name (the root has 0). +func CountLabels(name string) int { + if name == Root { + return 0 + } + return strings.Count(strings.TrimSuffix(name, "."), ".") + 1 +} + +// ToASCII converts an internationalized name to its ASCII (punycode) form. +// Names that are already ASCII are returned lowercased and unchanged. +func ToASCII(name string) (string, error) { + for i := 0; i < len(name); i++ { + if name[i] >= 0x80 { + trailing := strings.HasSuffix(name, ".") + a, err := idna.Lookup.ToASCII(strings.TrimSuffix(name, ".")) + if err != nil { + return "", fmt.Errorf("invalid internationalized name %q: %v", name, err) + } + if trailing { + a += "." + } + return strings.ToLower(a), nil + } + } + return strings.ToLower(name), nil +} + +// Owner resolves a user supplied owner name against zone (canonical FQDN) +// and returns the canonical FQDN. +// +// - "" and "@" mean the zone apex. +// - A name ending in "." is absolute. +// - A name without a trailing dot that equals the zone name, or ends with +// ".", is treated as absolute. Nobody wants +// "www.example.com.example.com.", and this is how most DNS APIs behave. +// - Anything else is relative to the zone. +func Owner(input, zone string) (string, error) { + in := strings.TrimSpace(input) + if in == "" || in == "@" { + return zone, nil + } + a, err := ToASCII(in) + if err != nil { + return "", err + } + in = a + if strings.HasSuffix(in, ".") { + return Fqdn(in), nil + } + zoneNoDot := strings.TrimSuffix(zone, ".") + if zone != Root && (in == zoneNoDot || strings.HasSuffix(in, "."+zoneNoDot)) { + return Fqdn(in), nil + } + if zone == Root { + return Fqdn(in), nil + } + return Fqdn(in + "." + zone), nil +} + +// Relative returns name relative to zone: "@" for the apex, the remaining +// labels for names inside the zone, and the FQDN for names outside it. +func Relative(name, zone string) string { + if name == zone { + return "@" + } + if zone == Root { + return name + } + if strings.HasSuffix(name, "."+zone) { + return strings.TrimSuffix(name, "."+zone) + } + return name +} + +// Syntax errors returned by CheckOwner and CheckZoneName. +var ( + ErrEmptyLabel = errors.New("empty label (two dots in a row?)") + ErrTooLong = errors.New("name is longer than 255 octets") +) + +// CheckOwner validates the syntax of a canonical owner name. Labels may +// contain letters, digits, hyphens, underscores and slashes (RFC 2317 style +// reverse names); a "*" label is allowed only as the leftmost label. +func CheckOwner(name string) error { + return checkName(name, true) +} + +// CheckZoneName validates the syntax of a canonical zone name. It is the same +// as CheckOwner except that wildcards are not allowed. +func CheckZoneName(name string) error { + return checkName(name, false) +} + +func checkName(name string, wildcardOK bool) error { + if name == Root { + return nil + } + if !strings.HasSuffix(name, ".") { + return fmt.Errorf("name %q is not fully qualified", name) + } + // Wire length: one length octet per label plus the label bytes, plus + // the terminating zero octet. For a dotted name that's len(name)+1. + if len(name)+1 > 255 { + return ErrTooLong + } + labels := strings.Split(strings.TrimSuffix(name, "."), ".") + for i, l := range labels { + if l == "" { + return ErrEmptyLabel + } + if len(l) > 63 { + return fmt.Errorf("label %q is longer than 63 octets", l) + } + if l == "*" { + if !wildcardOK { + return errors.New("wildcard labels are not allowed here") + } + if i != 0 { + return errors.New("a wildcard (*) must be the leftmost label") + } + continue + } + for j := 0; j < len(l); j++ { + c := l[j] + switch { + case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '_', c == '/': + case c >= 'A' && c <= 'Z': + return fmt.Errorf("label %q must be lowercase", l) + case c == '*': + return fmt.Errorf("label %q: '*' is only allowed as a whole leftmost label", l) + default: + return fmt.Errorf("label %q contains an invalid character %q", l, c) + } + } + } + return nil +} + +// IsHostname reports whether name follows the RFC 952/1123 host name rules +// that BIND's check-names option enforces: every label starts and ends with +// a letter or digit and contains only letters, digits and hyphens. If +// wildcardOK is set, a leftmost "*" label is accepted. The root name is not +// a host name, except "." used as a "no service" target where callers allow it. +func IsHostname(name string, wildcardOK bool) bool { + if name == Root || name == "" { + return false + } + labels := dns.SplitDomainName(name) + for i, l := range labels { + if i == 0 && wildcardOK && l == "*" { + continue + } + if !isHostLabel(l) { + return false + } + } + return true +} + +func isHostLabel(l string) bool { + if l == "" || len(l) > 63 { + return false + } + for j := 0; j < len(l); j++ { + c := l[j] + alnum := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' + if j == 0 || j == len(l)-1 { + if !alnum { + return false + } + } else if !alnum && c != '-' { + return false + } + } + return true +} + +// IsMailbox reports whether name is a valid mailbox domain name, as BIND's +// check-names applies to the SOA RNAME: the first label may contain any +// printable ASCII character (it is the local part), the rest must be a host +// name. +func IsMailbox(name string) bool { + labels := dns.SplitDomainName(name) + if len(labels) < 2 { + return false + } + for _, c := range []byte(labels[0]) { + if c <= 0x20 || c >= 0x7f { + return false + } + } + return IsHostname(dns.Fqdn(strings.Join(labels[1:], ".")), false) +} + +// MailboxFromEmail converts an email address ("first.last@example.com") to +// the domain-name form used in the SOA RNAME field +// ("first\.last.example.com."). Strings that are already in domain-name form +// (no "@") are returned as an FQDN. +func MailboxFromEmail(s string) (string, error) { + s = strings.TrimSpace(s) + at := strings.LastIndexByte(s, '@') + if at < 0 { + return Fqdn(s), nil + } + local, domain := s[:at], s[at+1:] + if local == "" || domain == "" { + return "", fmt.Errorf("invalid email address %q", s) + } + local = strings.ReplaceAll(local, `\`, `\\`) + local = strings.ReplaceAll(local, ".", `\.`) + return Fqdn(local + "." + domain), nil +} + +// EmailFromMailbox converts an SOA RNAME back into email address form. +func EmailFromMailbox(rname string) string { + labels := dns.SplitDomainName(rname) + if len(labels) < 2 { + return Display(rname) + } + local := strings.ReplaceAll(labels[0], `\.`, ".") + return local + "@" + strings.Join(labels[1:], ".") +} + +// IsReverse reports whether name is in in-addr.arpa or ip6.arpa. +func IsReverse(name string) bool { + return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.") +} + +// ReverseName returns the PTR owner name for addr, for example +// "10.2.0.192.in-addr.arpa.". +func ReverseName(addr netip.Addr) string { + addr = addr.Unmap() + if addr.Is4() { + b := addr.As4() + return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa.", b[3], b[2], b[1], b[0]) + } + b := addr.As16() + var sb strings.Builder + const hex = "0123456789abcdef" + for i := 15; i >= 0; i-- { + sb.WriteByte(hex[b[i]&0xf]) + sb.WriteByte('.') + sb.WriteByte(hex[b[i]>>4]) + sb.WriteByte('.') + } + sb.WriteString("ip6.arpa.") + return sb.String() +} + +// ReverseZone returns the name of the reverse zone covering prefix. IPv4 +// prefixes must be /8, /16 or /24 and IPv6 prefixes a multiple of /4; other +// lengths need RFC 2317 style delegation, which must be set up by hand. +func ReverseZone(p netip.Prefix) (string, error) { + p = p.Masked() + addr := p.Addr() + bits := p.Bits() + if addr.Is4() { + if bits != 8 && bits != 16 && bits != 24 { + return "", fmt.Errorf("IPv4 reverse zones must be /8, /16 or /24 (got /%d); use the enclosing /24 zone instead", bits) + } + b := addr.As4() + parts := []string{} + for i := bits/8 - 1; i >= 0; i-- { + parts = append(parts, fmt.Sprint(b[i])) + } + return strings.Join(parts, ".") + ".in-addr.arpa.", nil + } + if bits%4 != 0 || bits == 0 { + return "", fmt.Errorf("IPv6 reverse zones must be a multiple of /4 (got /%d)", bits) + } + full := ReverseName(addr) // 32 nibbles + nibbles := strings.Split(strings.TrimSuffix(full, ".ip6.arpa."), ".") + keep := nibbles[len(nibbles)-bits/4:] + return strings.Join(keep, ".") + ".ip6.arpa.", nil +} + +// AddrFromReverse parses a full reverse-lookup owner name back into an +// address. It returns false for names that don't name a single address. +func AddrFromReverse(name string) (netip.Addr, bool) { + switch { + case strings.HasSuffix(name, ".in-addr.arpa."): + parts := strings.Split(strings.TrimSuffix(name, ".in-addr.arpa."), ".") + if len(parts) != 4 { + return netip.Addr{}, false + } + s := parts[3] + "." + parts[2] + "." + parts[1] + "." + parts[0] + a, err := netip.ParseAddr(s) + return a, err == nil + case strings.HasSuffix(name, ".ip6.arpa."): + parts := strings.Split(strings.TrimSuffix(name, ".ip6.arpa."), ".") + if len(parts) != 32 { + return netip.Addr{}, false + } + var b [16]byte + for i, n := range parts { + if len(n) != 1 { + return netip.Addr{}, false + } + v := strings.IndexByte("0123456789abcdef", n[0]) + if v < 0 { + return netip.Addr{}, false + } + pos := 31 - i + if pos%2 == 0 { + b[pos/2] |= byte(v) << 4 + } else { + b[pos/2] |= byte(v) + } + } + return netip.AddrFrom16(b), true + } + return netip.Addr{}, false +} + +// FileSafe turns a zone name into something safe to use as a file name. +func FileSafe(name string) string { + if name == Root { + return "root" + } + s := strings.TrimSuffix(name, ".") + var sb strings.Builder + for _, c := range []byte(s) { + switch { + case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '.', c == '_': + sb.WriteByte(c) + default: + fmt.Fprintf(&sb, "%%%02x", c) + } + } + return sb.String() +} + +// BestZone returns the longest name in zones that name falls within, or "" +// if none. +func BestZone(name string, zones []string) string { + best := "" + for _, z := range zones { + if IsSubdomain(name, z) && (best == "" || len(z) > len(best)) { + best = z + } + } + return best +} diff --git a/internal/dnsname/dnsname_test.go b/internal/dnsname/dnsname_test.go new file mode 100644 index 0000000..71d5af6 --- /dev/null +++ b/internal/dnsname/dnsname_test.go @@ -0,0 +1,174 @@ +package dnsname + +import ( + "net/netip" + "testing" +) + +func TestOwner(t *testing.T) { + zone := "example.com." + cases := []struct{ in, want string }{ + {"", "example.com."}, + {"@", "example.com."}, + {"www", "www.example.com."}, + {"WWW", "www.example.com."}, + {"www.example.com", "www.example.com."}, + {"www.example.com.", "www.example.com."}, + {"example.com", "example.com."}, + {"a.b", "a.b.example.com."}, + {"other.org.", "other.org."}, + {"_acme-challenge.www", "_acme-challenge.www.example.com."}, + {"bücher", "xn--bcher-kva.example.com."}, + } + for _, c := range cases { + got, err := Owner(c.in, zone) + if err != nil || got != c.want { + t.Errorf("Owner(%q) = %q, %v; want %q", c.in, got, err, c.want) + } + } + // Not fooled by a zone name that's a suffix of a label. + got, _ := Owner("myexample.com", zone) + if got != "myexample.com.example.com." { + t.Errorf("Owner(myexample.com) = %q", got) + } +} + +func TestRelative(t *testing.T) { + cases := []struct{ name, zone, want string }{ + {"example.com.", "example.com.", "@"}, + {"www.example.com.", "example.com.", "www"}, + {"a.b.example.com.", "example.com.", "a.b"}, + {"other.org.", "example.com.", "other.org."}, + {"x.", ".", "x."}, + } + for _, c := range cases { + if got := Relative(c.name, c.zone); got != c.want { + t.Errorf("Relative(%q,%q) = %q want %q", c.name, c.zone, got, c.want) + } + } +} + +func TestCheckOwner(t *testing.T) { + ok := []string{".", "example.com.", "*.example.com.", "_dmarc.example.com.", "0/26.2.0.192.in-addr.arpa.", "a-b.c."} + for _, n := range ok { + if err := CheckOwner(n); err != nil { + t.Errorf("CheckOwner(%q) = %v", n, err) + } + } + bad := []string{"a..b.", "x.*.example.com.", "foo*.example.com.", "Upper.example.com.", "sp ace.com.", "example.com"} + for _, n := range bad { + if err := CheckOwner(n); err == nil { + t.Errorf("CheckOwner(%q) succeeded, want error", n) + } + } + long := "" + for i := 0; i < 64; i++ { + long += "a" + } + if CheckOwner(long+".com.") == nil { + t.Error("64 octet label accepted") + } + if CheckZoneName("*.example.com.") == nil { + t.Error("wildcard zone accepted") + } +} + +func TestIsHostname(t *testing.T) { + cases := []struct { + name string + wildcard bool + want bool + }{ + {"www.example.com.", false, true}, + {"1host.example.com.", false, true}, + {"foo_bar.example.com.", false, false}, + {"-x.example.com.", false, false}, + {"x-.example.com.", false, false}, + {"*.example.com.", true, true}, + {"*.example.com.", false, false}, + {".", false, false}, + {"a.b.", false, true}, + } + for _, c := range cases { + if got := IsHostname(c.name, c.wildcard); got != c.want { + t.Errorf("IsHostname(%q,%v) = %v", c.name, c.wildcard, got) + } + } +} + +func TestMailbox(t *testing.T) { + mb, err := MailboxFromEmail("first.last@example.com") + if err != nil || mb != `first\.last.example.com.` { + t.Fatalf("MailboxFromEmail = %q %v", mb, err) + } + if !IsMailbox(mb) { + t.Error("IsMailbox false for escaped local part") + } + if got := EmailFromMailbox(mb); got != "first.last@example.com" { + t.Errorf("EmailFromMailbox = %q", got) + } + if mb, _ := MailboxFromEmail("hostmaster.example.com."); mb != "hostmaster.example.com." { + t.Errorf("domain form changed: %q", mb) + } + if IsMailbox("hostmaster.bad_domain.com.") { + t.Error("bad domain part accepted") + } +} + +func TestReverse(t *testing.T) { + a := netip.MustParseAddr("192.0.2.10") + if got := ReverseName(a); got != "10.2.0.192.in-addr.arpa." { + t.Errorf("ReverseName v4 = %q", got) + } + a6 := netip.MustParseAddr("2001:db8::1") + r6 := ReverseName(a6) + if r6 != "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa." { + t.Errorf("ReverseName v6 = %q", r6) + } + for _, name := range []string{"10.2.0.192.in-addr.arpa.", r6} { + back, ok := AddrFromReverse(name) + if !ok { + t.Errorf("AddrFromReverse(%q) failed", name) + continue + } + if ReverseName(back) != name { + t.Errorf("round trip %q -> %v", name, back) + } + } + z, err := ReverseZone(netip.MustParsePrefix("192.168.1.0/24")) + if err != nil || z != "1.168.192.in-addr.arpa." { + t.Errorf("ReverseZone /24 = %q %v", z, err) + } + z, err = ReverseZone(netip.MustParsePrefix("10.0.0.0/8")) + if err != nil || z != "10.in-addr.arpa." { + t.Errorf("ReverseZone /8 = %q %v", z, err) + } + if _, err := ReverseZone(netip.MustParsePrefix("192.168.1.0/26")); err == nil { + t.Error("ReverseZone /26 accepted") + } + z, err = ReverseZone(netip.MustParsePrefix("2001:db8::/32")) + if err != nil || z != "8.b.d.0.1.0.0.2.ip6.arpa." { + t.Errorf("ReverseZone v6 = %q %v", z, err) + } + if !IsReverse("1.168.192.in-addr.arpa.") || IsReverse("example.com.") { + t.Error("IsReverse") + } +} + +func TestMisc(t *testing.T) { + if Parent("www.example.com.") != "example.com." || Parent("com.") != "." || Parent(".") != "" { + t.Error("Parent") + } + if CountLabels(".") != 0 || CountLabels("a.b.c.") != 3 { + t.Error("CountLabels") + } + if BestZone("a.lab.example.com.", []string{"example.com.", "lab.example.com.", "other.com."}) != "lab.example.com." { + t.Error("BestZone") + } + if FileSafe("0/26.2.0.192.in-addr.arpa.") != "0%2f26.2.0.192.in-addr.arpa" || FileSafe(".") != "root" { + t.Errorf("FileSafe = %q", FileSafe("0/26.2.0.192.in-addr.arpa.")) + } + if !IsSubdomain("a.example.com.", "example.com.") || IsSubdomain("aexample.com.", "example.com.") || !IsSubdomain("x.", ".") { + t.Error("IsSubdomain") + } +} diff --git a/internal/engine/adopt.go b/internal/engine/adopt.go new file mode 100644 index 0000000..51bc052 --- /dev/null +++ b/internal/engine/adopt.go @@ -0,0 +1,249 @@ +package engine + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "jarl/internal/auth" + "jarl/internal/importer" + "jarl/internal/model" + "jarl/internal/validate" +) + +// ImportResult is returned by Import. +type ImportResult struct { + Report *importer.Report `json:"report"` + Changeset *model.Changeset `json:"changeset"` + Result *Result `json:"result,omitempty"` + Applied bool `json:"applied"` +} + +// Import loads an existing BIND configuration into Jarl. The import becomes +// a changeset; if the configuration is valid it is applied right away +// (writing Jarl's files), otherwise it stays open so problems can be fixed +// before applying. +func (e *Engine) Import(ctx context.Context, mainPath string, p *auth.Principal, dryRun bool) (*ImportResult, error) { + cur, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + if len(cur.Zones)+len(cur.Views)+len(cur.ACLs)+len(cur.Keys)+len(cur.Options) > 0 { + return nil, errf(KindConflict, "Jarl already has a configuration; import only works into an empty Jarl") + } + st, rep, err := importer.Import(ctx, mainPath, importer.Options{Tools: e.tools, Alloc: e.store.AllocateIDs, User: p.Name()}) + if err != nil { + return nil, errf(KindBadRequest, "reading %s: %v", mainPath, err) + } + // Keep Jarl's own settings (defaults for new zones), but take the + // name servers and contact from the imported zones if unset. + st.Settings = cur.Settings + if len(st.Settings.Nameservers) == 0 || st.Settings.RName == "" { + for _, z := range st.SortedZones() { + if z.Type != model.ZonePrimary { + continue + } + if len(st.Settings.Nameservers) == 0 { + for _, r := range st.ZoneRecords(z.ID) { + if r.Name == z.Name && r.Type == "NS" { + st.Settings.Nameservers = append(st.Settings.Nameservers, r.Data) + } + } + } + if st.Settings.RName == "" { + st.Settings.RName = z.SOA.RName + } + break + } + } + ops := DiffOps(cur, st) + now := e.Now() + for i := range ops { + ops[i].AddedAt = now + } + out := &ImportResult{Report: rep} + cs := &model.Changeset{Description: "import from " + mainPath, UserID: p.User.ID, Username: p.Name(), Via: "import", Status: model.ChangesetOpen, Ops: ops} + if dryRun { + out.Changeset = cs + return out, nil + } + if err := e.store.CreateChangeset(ctx, cs); err != nil { + return nil, err + } + out.Changeset = cs + e.audit(ctx, p, "import", "changeset", cs.ID, mainPath, cs.ID, "success", fmt.Sprintf("%d zones, %d records", len(st.Zones), len(st.Records)), nil) + res, err := e.Apply(ctx, cs.ID, p, ApplyOptions{NoReload: true}) + out.Result = res + if err != nil { + // Leave the changeset open for fixing. + if _, ok := AsError(err); ok { + return out, nil + } + return out, err + } + out.Applied = true + return out, nil +} + +// AdoptResult describes an adoption. +type AdoptResult struct { + MainConfig string `json:"main_config"` + Backup string `json:"backup"` + Serials []SerialChange `json:"serials"` + Already bool `json:"already_adopted"` +} + +// mainConfigText is what Jarl writes to named's main configuration file. +func mainConfigText(jarlConf, backup string) string { + return fmt.Sprintf(`// %s +// +// This server's DNS configuration is managed by Jarl. Jarl generates the +// file included below; make changes through the Jarl web UI, API or CLI. +// The configuration from before Jarl took over was saved as: +// %s + +include "%s"; +`, "named.conf", backup, jarlConf) +} + +// Adopt switches named over to Jarl's configuration: it bumps every +// primary zone's serial, makes named's main configuration file include +// Jarl's named.conf (saving the old file), reloads named and checks that +// every zone is served from Jarl's files. On failure the old main +// configuration is restored. +func (e *Engine) Adopt(ctx context.Context, p *auth.Principal) (*AdoptResult, error) { + main := e.cfg.MainConfig + jarlConf := e.cfg.Paths.MainConfig() + if main == "" || filepath.Clean(main) == filepath.Clean(jarlConf) { + return nil, errf(KindBadRequest, "bind.main_config is not set (or is Jarl's own named.conf); nothing to adopt") + } + res := &AdoptResult{MainConfig: main} + if ok, _ := e.Adopted(); ok { + res.Already = true + return res, nil + } + st, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + if len(st.Zones) == 0 { + return nil, errf(KindConflict, "Jarl has no zones yet; import your configuration (jarld import) or create zones first") + } + if ps := e.validate(st); validate.HasErrors(ps) { + return nil, &Error{Kind: KindValidation, Message: "the configuration has errors; fix them before adopting: " + describeErrors(ps), Problems: validate.Errors(ps)} + } + // Bump every primary zone's serial so named (and secondaries) load + // the zones from Jarl's files. + var reqs []Request + for _, z := range st.SortedZones() { + if z.Type == model.ZonePrimary { + policy := z.SerialPolicy + if policy == "" { + policy = st.Settings.SerialPolicy + } + next := NextSerial(z.SOA.Serial, policy, e.Now()) + reqs = append(reqs, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{Serial: &next}})) + res.Serials = append(res.Serials, SerialChange{ZoneID: z.ID, Zone: z.Name, View: st.ViewName(z), Old: z.SOA.Serial, New: next}) + } + } + if len(reqs) > 0 { + if _, err := e.Submit(ctx, reqs, p, "adopt", "bump serials for adoption", ApplyOptions{NoReload: true, Force: true}); err != nil { + return nil, fmt.Errorf("preparing zones: %w", err) + } + } + // Make sure the files on disk are exactly Jarl's. + if _, err := e.Resync(ctx, p, ApplyOptions{NoReload: true, Force: true}); err != nil { + return nil, fmt.Errorf("writing Jarl's files: %w", err) + } + + e.mu.Lock() + defer e.mu.Unlock() + old, err := os.ReadFile(main) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", main, err) + } + fi, err := os.Stat(main) + if err != nil { + return nil, err + } + backup := main + ".pre-jarl-" + e.Now().Format("20060102-150405") + res.Backup = backup + text := mainConfigText(jarlConf, backup) + // Check the new main configuration before touching anything. + dir, err := os.MkdirTemp(e.cfg.StagingDir, "adopt-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + staged := filepath.Join(dir, "named.conf") + if err := os.WriteFile(staged, []byte(text), 0o640); err != nil { + return nil, err + } + check, err := e.tools.CheckConfig(ctx, staged, true) + if err != nil { + return nil, err + } + if !check.OK { + return nil, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration:\n" + check.Output} + } + if err := os.WriteFile(backup, old, fi.Mode().Perm()); err != nil { + return nil, fmt.Errorf("saving %s: %w", backup, err) + } + restore := func() { + if err := os.WriteFile(main, old, fi.Mode().Perm()); err != nil { + e.log.Error("restoring main configuration failed", "path", main, "err", err) + } + if err := e.tools.Reconfig(ctx); err != nil { + e.log.Error("rndc reconfig after restoring failed", "err", err) + } + } + tmp := main + ".jarl-new" + if err := os.WriteFile(tmp, []byte(text), fi.Mode().Perm()); err != nil { + return nil, err + } + copyOwner(fi, tmp) + if err := os.Rename(tmp, main); err != nil { + os.Remove(tmp) + return nil, err + } + if err := e.tools.Reconfig(ctx); err != nil { + restore() + return nil, &Error{Kind: KindBIND, Message: "named rejected Jarl's configuration (the old configuration was restored): " + err.Error()} + } + st, err = e.store.LoadState(ctx) + if err != nil { + restore() + return nil, err + } + deadline := time.Now().Add(e.cfg.ReloadTimeout) + for _, z := range st.SortedZones() { + if z.Type != model.ZonePrimary { + continue + } + view := e.namedView(st, z) + _ = e.tools.ReloadZone(ctx, z.Name, view) + want := z.SOA.Serial + for { + zs, err := e.tools.ZoneStatus(ctx, z.Name, view) + if err == nil && zs.HasSerial && zs.Serial == want && strings.Contains(zs.Files, e.cfg.Paths.ZoneDir) { + break + } + if time.Now().After(deadline) { + restore() + detail := "" + if err != nil { + detail = err.Error() + } else { + detail = fmt.Sprintf("named serves serial %d from %s", zs.Serial, zs.Files) + } + return nil, &Error{Kind: KindBIND, Message: fmt.Sprintf("zone %s was not loaded from Jarl's file with serial %d (%s); the old configuration was restored", zoneDesc(st, z), want, detail)} + } + time.Sleep(100 * time.Millisecond) + } + } + e.audit(ctx, p, "system.adopt", "", 0, main, 0, "success", "named now loads "+jarlConf+"; previous configuration saved as "+backup, nil) + return res, nil +} diff --git a/internal/engine/adopt_test.go b/internal/engine/adopt_test.go new file mode 100644 index 0000000..c66a85a --- /dev/null +++ b/internal/engine/adopt_test.go @@ -0,0 +1,169 @@ +package engine + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "jarl/internal/auth" + "jarl/internal/bind" + "jarl/internal/model" + "jarl/internal/render" + "jarl/internal/store" + "jarl/internal/testutil" +) + +func TestImportAndAdopt(t *testing.T) { + bins := testutil.FindBind() + if bins == nil || bins.Named == "" || bins.RNDC == "" || bins.Dig == "" { + t.Skip("named, rndc and dig are needed") + } + dir := t.TempDir() + legacy := filepath.Join(dir, "legacy") + run := filepath.Join(dir, "run") + os.MkdirAll(legacy, 0o755) + os.MkdirAll(run, 0o755) + keyOut, err := runEnv(bins.Env, bins.TSIGKeygen, "-a", "hmac-sha256", "rndc-key") + if err != nil { + t.Fatal(err) + } + keyFile := filepath.Join(legacy, "rndc.key") + os.WriteFile(keyFile, []byte(keyOut), 0o600) + dnsPort, ctlPort := freePort(t), freePort(t) + files := map[string]string{ + "named.conf": fmt.Sprintf(`include "%[1]s/named.conf.options"; +include "%[1]s/named.conf.local"; +include "%[1]s/rndc.key"; +controls { inet 127.0.0.1 port %[2]d allow { 127.0.0.1; } keys { "rndc-key"; }; }; +`, legacy, ctlPort), + "named.conf.options": fmt.Sprintf(`acl homelab { 127.0.0.0/8; 192.168.1.0/24; }; +options { + directory "%[1]s"; + pid-file "%[1]s/named.pid"; + session-keyfile "%[1]s/session.key"; + listen-on port %[2]d { 127.0.0.1; }; + listen-on-v6 { none; }; + recursion no; + allow-query { any; }; +}; +`, run, dnsPort), + "named.conf.local": fmt.Sprintf(`zone "adbraun.test" { type master; file "%[1]s/db.adbraun.test"; }; +zone "1.168.192.in-addr.arpa" { type master; file "%[1]s/db.192.168.1"; }; +`, legacy), + "db.adbraun.test": `$TTL 3600 +@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 ) + IN NS ns1 +ns1 IN A 127.0.0.1 +nas IN A 192.168.1.20 ; synology +www 300 IN CNAME nas +`, + "db.192.168.1": `$TTL 86400 +@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 ) +@ IN NS ns1.adbraun.test. +20 IN PTR nas.adbraun.test. +`, + } + for name, content := range files { + os.WriteFile(filepath.Join(legacy, name), []byte(content), 0o644) + } + mainConf := filepath.Join(legacy, "named.conf") + var namedLog bytes.Buffer + cmd := exec.Command(bins.Named, "-c", mainConf, "-g", "-n", "1") + cmd.Env = append(os.Environ(), bins.Env...) + cmd.Stdout, cmd.Stderr = &namedLog, &namedLog + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + cmd.Process.Kill() + cmd.Wait() + if t.Failed() { + t.Logf("named log:\n%s", namedLog.String()) + } + }() + tools := &bind.Tools{NamedCheckConf: bins.CheckConf, NamedCheckZone: bins.CheckZone, RNDC: bins.RNDC, Env: bins.Env, + RNDCArgs: []string{"-k", keyFile, "-s", "127.0.0.1", "-p", fmt.Sprint(ctlPort)}} + for i := 0; ; i++ { + if _, err := tools.Status(context.Background()); err == nil { + break + } + if i > 100 { + t.Fatalf("named did not start:\n%s", namedLog.String()) + } + time.Sleep(100 * time.Millisecond) + } + dig := func(name, typ string) string { + out, _ := runEnv(bins.Env, bins.Dig, "+short", "+norec", "-p", fmt.Sprint(dnsPort), "@127.0.0.1", name, typ) + return strings.TrimSpace(out) + } + if got := dig("nas.adbraun.test", "A"); got != "192.168.1.20" { + t.Fatalf("legacy setup broken: %q", got) + } + + st, err := store.Open(filepath.Join(dir, "jarl.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + cfg := Config{ + Paths: render.Paths{ConfigDir: filepath.Join(dir, "jarl/etc"), ZoneDir: filepath.Join(dir, "jarl/zones"), CacheDir: filepath.Join(dir, "jarl/cache")}, + MainConfig: mainConf, + StagingDir: filepath.Join(dir, "staging"), + ReloadTimeout: 10 * time.Second, + } + e := New(st, tools, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + admin := &model.User{Username: "admin", Admin: true} + st.CreateUser(context.Background(), admin) + p := &auth.Principal{User: admin, Method: "local"} + ctx := context.Background() + + imp, err := e.Import(ctx, mainConf, p, false) + if err != nil { + t.Fatal(err) + } + if !imp.Applied { + t.Fatalf("import not applied: %+v %+v", imp.Result.Error, imp.Result.Problems) + } + if ok, _ := e.Adopted(); ok { + t.Fatal("adopted before adopt") + } + ad, err := e.Adopt(ctx, p) + if err != nil { + t.Fatalf("adopt: %v", err) + } + if len(ad.Serials) != 2 || ad.Serials[0].New <= ad.Serials[0].Old { + t.Errorf("serials = %+v", ad.Serials) + } + if b, _ := os.ReadFile(ad.Backup); !strings.Contains(string(b), "named.conf.options") { + t.Error("backup missing") + } + if ok, _ := e.Adopted(); !ok { + t.Error("not adopted after adopt") + } + // Same answers, now from Jarl's files. + if got := dig("www.adbraun.test", "A"); !strings.Contains(got, "192.168.1.20") { + t.Errorf("www after adopt = %q", got) + } + zs, err := tools.ZoneStatus(ctx, "adbraun.test", "") + if err != nil || !strings.Contains(zs.Files, cfg.Paths.ZoneDir) { + t.Errorf("zone not loaded from Jarl's file: %+v %v", zs, err) + } + // And Jarl manages the server from now on. + if _, err := e.Submit(ctx, []Request{rec("adbraun.test", "printer", "A", "192.168.1.30")}, p, "test", "", ApplyOptions{}); err != nil { + t.Fatal(err) + } + if got := dig("printer.adbraun.test", "A"); got != "192.168.1.30" { + t.Errorf("printer = %q", got) + } + if got := dig("20.1.168.192.in-addr.arpa", "PTR"); got != "nas.adbraun.test." { + t.Errorf("PTR = %q", got) + } +} diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go new file mode 100644 index 0000000..11bea21 --- /dev/null +++ b/internal/engine/deploy.go @@ -0,0 +1,274 @@ +package engine + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + + udiff "github.com/aymanbagabas/go-udiff" + + "jarl/internal/model" + "jarl/internal/render" +) + +// Deployer writes rendered files to disk. +type Deployer struct { + // Group, if set, is the group generated files and directories belong + // to (named must be able to read them; on Debian this is "bind"). + Group string + // NamedWritable lists directories (and their subdirectories) named + // must be able to write to: zone files (for journals and DNSSEC + // signing) and secondary zone files. Other directories are only + // readable by the group. + NamedWritable []string + gid int +} + +func (d *Deployer) dirMode(dir string) fs.FileMode { + for _, w := range d.NamedWritable { + if w != "" && (dir == w || strings.HasPrefix(dir, w+string(filepath.Separator))) { + return 0o770 | fs.ModeSetgid + } + } + return 0o750 | fs.ModeSetgid +} + +func (d *Deployer) groupID() (int, error) { + if d.Group == "" { + return -1, nil + } + if d.gid != 0 { + return d.gid, nil + } + if n, err := strconv.Atoi(d.Group); err == nil { + d.gid = n + return n, nil + } + g, err := user.LookupGroup(d.Group) + if err != nil { + return -1, fmt.Errorf("group %q: %w", d.Group, err) + } + n, _ := strconv.Atoi(g.Gid) + d.gid = n + return n, nil +} + +type fileBackup struct { + path string + existed bool + content []byte + mode fs.FileMode +} + +// Backup holds the previous contents of files changed by Install. +type Backup struct { + d *Deployer + files []fileBackup +} + +func readFile(path string) ([]byte, bool, error) { + b, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return b, true, nil +} + +// EnsureDir creates dir (and missing parents) with mode and the deploy +// group. Existing directories are left alone. +func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error { + if _, err := os.Stat(dir); err == nil { + return nil + } + if parent := filepath.Dir(dir); parent != dir { + if err := d.EnsureDir(parent, d.dirMode(parent)); err != nil { + return err + } + } + if err := os.Mkdir(dir, mode.Perm()); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + gid, err := d.groupID() + if err != nil { + return err + } + if gid >= 0 { + if err := os.Chown(dir, -1, gid); err != nil { + return fmt.Errorf("chgrp %s: %w", dir, err) + } + } + return os.Chmod(dir, mode) +} + +func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) error { + dir := filepath.Dir(path) + if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".jarl-*") + if err != nil { + return err + } + name := tmp.Name() + ok := false + defer func() { + if !ok { + tmp.Close() + os.Remove(name) + } + }() + if _, err := tmp.Write(content); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Chmod(mode); err != nil { + return err + } + gid, err := d.groupID() + if err != nil { + return err + } + if gid >= 0 { + if err := tmp.Chown(-1, gid); err != nil { + return fmt.Errorf("chgrp %s: %w", path, err) + } + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + return err + } + ok = true + if df, err := os.Open(dir); err == nil { + df.Sync() + df.Close() + } + return nil +} + +// Install writes files and removes paths, returning a backup that undoes +// the changes. On error the files already written are restored. +func (d *Deployer) Install(files []*render.File, remove []string, dirs []string) (*Backup, error) { + b := &Backup{d: d} + for _, dir := range dirs { + // Directories named writes into (secondary zone files). + if err := d.EnsureDir(dir, 0o770|fs.ModeSetgid); err != nil { + return b, err + } + } + save := func(path string) error { + old, existed, err := readFile(path) + if err != nil { + return err + } + mode := fs.FileMode(0o640) + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode().Perm() + } + b.files = append(b.files, fileBackup{path: path, existed: existed, content: old, mode: mode}) + return nil + } + for _, f := range files { + if err := save(f.Path); err != nil { + b.Restore() + return nil, err + } + if err := d.writeAtomic(f.Path, f.Content, f.Mode); err != nil { + b.Restore() + return nil, fmt.Errorf("writing %s: %w", f.Path, err) + } + } + for _, p := range remove { + if err := save(p); err != nil { + b.Restore() + return nil, err + } + if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) { + b.Restore() + return nil, fmt.Errorf("removing %s: %w", p, err) + } + } + return b, nil +} + +// Restore puts back the previous file contents. +func (b *Backup) Restore() error { + if b == nil { + return nil + } + var errs []error + for i := len(b.files) - 1; i >= 0; i-- { + f := b.files[i] + if f.existed { + if err := b.d.writeAtomic(f.path, f.content, f.mode); err != nil { + errs = append(errs, err) + } + } else if err := os.Remove(f.path); err != nil && !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func sha(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +// DriftEntry is a managed file that was changed outside Jarl. +type DriftEntry struct { + Path string `json:"path"` + Reason string `json:"reason"` // modified, missing +} + +// CheckDrift compares managed files on disk with what Jarl last wrote. +func CheckDrift(deployed map[string]*model.DeployedFile) []DriftEntry { + var out []DriftEntry + for path, f := range deployed { + b, exists, err := readFile(path) + switch { + case err != nil: + out = append(out, DriftEntry{Path: path, Reason: "unreadable: " + err.Error()}) + case !exists: + out = append(out, DriftEntry{Path: path, Reason: "missing"}) + case sha(b) != f.SHA256: + out = append(out, DriftEntry{Path: path, Reason: "modified"}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// fileDiff returns a unified diff from old to new for display. Secret +// files only show that they changed. +func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret bool) string { + from, to := path, path + if !existed { + from = "/dev/null" + } + if removed { + to = "/dev/null" + } + if secret { + return fmt.Sprintf("--- %s\n+++ %s\n@@ contents hidden: this file holds TSIG secrets @@\n", from, to) + } + d := udiff.Unified(from, to, string(old), string(new)) + if d != "" && !strings.HasSuffix(d, "\n") { + d += "\n" + } + return d +} diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go new file mode 100644 index 0000000..3e7ac4f --- /dev/null +++ b/internal/engine/diffops.go @@ -0,0 +1,184 @@ +package engine + +import ( + "fmt" + "reflect" + "sort" + + "jarl/internal/dnsname" + "jarl/internal/model" +) + +// DiffOps returns operations that turn from into to. It is used to record +// how to undo an applied changeset and to turn an imported configuration +// into a changeset. Create operations keep the object IDs of to. +func DiffOps(from, to *model.State) []model.Op { + var ops []model.Op + add := func(kind string, id int64, base int, data any, summary string) { + ops = append(ops, newOp(kind, id, base, data, summary)) + } + if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) { + s := to.Settings + add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName, + Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings") + } + + for _, k := range sortedKeys(to.Keys) { + n := to.Keys[k] + in := TSIGInput{Name: &n.Name, Algorithm: &n.Algorithm, Secret: &n.Secret, Comment: &n.Comment} + if o, ok := from.Keys[k]; !ok { + add(OpTSIGCreate, k, 0, in, "create TSIG key "+n.Name) + } else if !sameObject(o, n) { + add(OpTSIGUpdate, k, o.Version, in, "restore TSIG key "+n.Name) + } + } + for _, k := range sortedKeys(to.ACLs) { + n := to.ACLs[k] + in := ACLInput{Name: &n.Name, Elements: model.Ptr(append([]string{}, n.Elements...)), Comment: &n.Comment} + if o, ok := from.ACLs[k]; !ok { + add(OpACLCreate, k, 0, in, "create ACL "+n.Name) + } else if !sameObject(o, n) { + add(OpACLUpdate, k, o.Version, in, "restore ACL "+n.Name) + } + } + for _, k := range sortedKeys(to.Views) { + n := to.Views[k] + in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)), + MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)} + if n.MatchRecursiveOnly != nil { + in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly) + } else { + in.MatchRecursiveOnly = model.Opt[bool]{Set: true, Null: true} + } + if o, ok := from.Views[k]; !ok { + add(OpViewCreate, k, 0, in, "create view "+n.Name) + } else if !sameObject(o, n) { + add(OpViewUpdate, k, o.Version, in, "restore view "+n.Name) + } + } + for _, k := range sortedKeys(to.Zones) { + n := to.Zones[k] + s := n.SOA + in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File, + DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, Comment: &n.Comment} + if n.Type == model.ZonePrimary { + in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum} + } + if o, ok := from.Zones[k]; !ok { + add(OpZoneCreate, k, 0, in, "create zone "+zoneDesc(to, n)) + } else if !sameObject(o, n) { + in.Name = nil // zones can't be renamed + if n.Type == model.ZonePrimary && o.SOA.Serial != s.Serial { + // Never move a serial backwards: let the normal bump + // happen instead. + in.SOA.Serial = nil + } + add(OpZoneUpdate, k, o.Version, in, "restore zone "+zoneDesc(to, n)) + } + } + for _, k := range sortedKeys(to.Options) { + n := to.Options[k] + in := OptionInput{Scope: &n.Scope, ScopeID: &n.ScopeID, Name: &n.Name, Value: &n.Value, Comment: &n.Comment, Position: &n.Position} + if o, ok := from.Options[k]; !ok { + add(OpOptionCreate, k, 0, in, "set "+optionLabel(to, n)+" "+n.Value) + } else if !sameObject(o, n) { + in.Scope, in.ScopeID = nil, nil + add(OpOptionUpdate, k, o.Version, in, "restore "+optionLabel(to, n)) + } + } + for _, k := range sortedKeys(to.Records) { + n := to.Records[k] + in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment} + if n.TTL != nil { + in.TTL = model.Some(*n.TTL) + } else { + in.TTL = model.Opt[uint32]{Set: true, Null: true} + } + if o, ok := from.Records[k]; !ok { + add(OpRecordCreate, k, 0, in, fmt.Sprintf("add %s %s %s", n.Name, n.Type, n.Data)) + } else if !sameObject(o, n) { + in.ZoneID = 0 + add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data)) + } + } + // Deletions, most dependent objects first. + for _, k := range sortedKeys(from.Records) { + if _, ok := to.Records[k]; !ok { + o := from.Records[k] + // Records of zones that are deleted go with the zone. + if _, zoneStays := to.Zones[o.ZoneID]; zoneStays { + add(OpRecordDelete, k, o.Version, nil, fmt.Sprintf("delete %s %s %s", o.Name, o.Type, o.Data)) + } + } + } + for _, k := range sortedKeys(from.Options) { + if _, ok := to.Options[k]; !ok { + o := from.Options[k] + stays := true + switch o.Scope { + case model.ScopeZone: + _, stays = to.Zones[o.ScopeID] + case model.ScopeView: + _, stays = to.Views[o.ScopeID] + } + if stays { + add(OpOptionDelete, k, o.Version, nil, "delete "+optionLabel(from, o)) + } + } + } + for _, k := range sortedKeys(from.Zones) { + if _, ok := to.Zones[k]; !ok { + o := from.Zones[k] + add(OpZoneDelete, k, o.Version, nil, "delete zone "+dnsname.Display(o.Name)) + } + } + for _, k := range sortedKeys(from.Views) { + if _, ok := to.Views[k]; !ok { + o := from.Views[k] + add(OpViewDelete, k, o.Version, DeleteInput{Cascade: true}, "delete view "+o.Name) + } + } + for _, k := range sortedKeys(from.ACLs) { + if _, ok := to.ACLs[k]; !ok { + add(OpACLDelete, k, from.ACLs[k].Version, nil, "delete ACL "+from.ACLs[k].Name) + } + } + for _, k := range sortedKeys(from.Keys) { + if _, ok := to.Keys[k]; !ok { + add(OpTSIGDelete, k, from.Keys[k].Version, nil, "delete TSIG key "+from.Keys[k].Name) + } + } + for i := range ops { + ops[i].Seq = i + 1 + } + return ops +} + +func stripVersion(s model.Settings) model.Settings { + s.Version = 0 + return s +} + +func sortedKeys[T any](m map[int64]*T) []int64 { + keys := make([]int64, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + return keys +} + +// sameObject compares two objects ignoring their meta data. +func sameObject[T any](a, b *T) bool { + ca, cb := *a, *b + clearMeta(&ca) + clearMeta(&cb) + return reflect.DeepEqual(ca, cb) +} + +func clearMeta(v any) { + rv := reflect.ValueOf(v).Elem() + if f := rv.FieldByName("Meta"); f.IsValid() { + f.Set(reflect.Zero(f.Type())) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..b3b9cfe --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,1135 @@ +// Package engine applies changes to the configuration. +// +// Every change goes through a changeset: a list of operations that is +// validated as a whole, rendered to BIND configuration, checked with +// named-checkconf, written to disk and loaded into named. Only when named +// has loaded the new configuration is the database updated. If anything +// fails, the previous files are restored and nothing is recorded. +package engine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "jarl/internal/auth" + "jarl/internal/bind" + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/namedconf" + "jarl/internal/render" + "jarl/internal/store" + "jarl/internal/validate" +) + +// Config configures the engine. +type Config struct { + Paths render.Paths + // MainConfig is the configuration file named loads (on Debian + // /etc/bind/named.conf). Jarl is "adopted" once it includes Jarl's + // named.conf. If it is Jarl's named.conf itself, Jarl is always + // adopted. + MainConfig string + // StagingDir is where configurations are rendered for named-checkconf. + StagingDir string + // FileGroup is the group of generated files (e.g. "bind"). + FileGroup string + // ReloadTimeout bounds how long Jarl waits for named to load zones. + ReloadTimeout time.Duration + // NoReload writes files but never runs rndc (for setups without a + // running named, and tests). + NoReload bool + // SkipCheck disables named-checkconf (only for tests without BIND). + SkipCheck bool +} + +// Engine applies changesets. +type Engine struct { + store *store.Store + tools *bind.Tools + cfg Config + log *slog.Logger + deploy *Deployer + mu sync.Mutex // serializes applies + Now func() time.Time +} + +// New creates an engine. +func New(st *store.Store, tools *bind.Tools, cfg Config, log *slog.Logger) *Engine { + if cfg.ReloadTimeout == 0 { + cfg.ReloadTimeout = 15 * time.Second + } + if log == nil { + log = slog.Default() + } + return &Engine{ + store: st, tools: tools, cfg: cfg, log: log, + deploy: &Deployer{Group: cfg.FileGroup, NamedWritable: []string{cfg.Paths.ZoneDir, cfg.Paths.CacheDir}}, + Now: time.Now, + } +} + +// Config returns the engine configuration. +func (e *Engine) Config() Config { return e.cfg } + +// Store returns the engine's store. +func (e *Engine) Store() *store.Store { return e.store } + +// Result describes a preview or an applied changeset. +type Result struct { + ChangesetID int64 `json:"changeset_id,omitempty"` + Applied bool `json:"applied"` + DryRun bool `json:"dry_run,omitempty"` + Valid bool `json:"valid"` + Problems []validate.Problem `json:"problems"` // problems this change introduces + Existing []validate.Problem `json:"existing_problems,omitempty"` + Check []CheckMessage `json:"check,omitempty"` + CheckOK bool `json:"check_ok"` + Diff string `json:"diff"` + Files []string `json:"files_changed"` + Serials []SerialChange `json:"serials,omitempty"` + Reloaded []string `json:"reloaded,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Drift []DriftEntry `json:"drift,omitempty"` + Ops []model.Op `json:"ops,omitempty"` + Error *Error `json:"error,omitempty"` +} + +// stored is what is kept in a changeset's result column. +type stored struct { + Serials []SerialChange `json:"serials,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Files []string `json:"files_changed,omitempty"` + Undo []model.Op `json:"undo,omitempty"` +} + +// ApplyOptions modify Apply and Submit. +type ApplyOptions struct { + DryRun bool + // Force overwrites files that were changed outside Jarl. + Force bool + // NoReload writes the files without reloading named. + NoReload bool +} + +func (e *Engine) replay(base *model.State, ops []model.Op, p *auth.Principal) (*model.State, []string, error) { + work := base.Clone() + c := &applyCtx{p: p, now: e.Now()} + for _, op := range ops { + if err := applyOp(work, op, c); err != nil { + return nil, c.warnings, err + } + } + return work, c.warnings, nil +} + +// ValidateOptions returns validation options for st, finding TSIG keys +// defined in files that top-level include statements pull in. +func ValidateOptions(st *model.State) validate.Options { + var opts validate.Options + for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) { + if o.Name != "include" { + continue + } + path := strings.Trim(strings.TrimSpace(o.Value), `"`) + b, err := os.ReadFile(path) + if err != nil { + opts.ExternalKeysUnknown = true + continue + } + stmts, err := namedconf.Parse(path, string(b)) + if err != nil { + opts.ExternalKeysUnknown = true + continue + } + for _, s := range namedconf.Find(stmts, "key") { + opts.ExternalKeys = append(opts.ExternalKeys, s.Arg(0)) + } + } + return opts +} + +func (e *Engine) validate(st *model.State) []validate.Problem { + return validate.State(st, ValidateOptions(st)) +} + +// ---- changeset management + +func (e *Engine) loadChangeset(ctx context.Context, id int64, p *auth.Principal, write bool) (*model.Changeset, error) { + cs, err := e.store.GetChangeset(ctx, id) + if errors.Is(err, store.ErrNotFound) { + return nil, errf(KindNotFound, "changeset %d does not exist", id) + } + if err != nil { + return nil, err + } + if !CanAccessChangeset(p, cs, write) { + return nil, errf(KindForbidden, "changeset %d belongs to %s", id, cs.Username) + } + if write && cs.Status != model.ChangesetOpen { + return nil, errf(KindConflict, "changeset %d is %s and can't be changed", id, cs.Status) + } + return cs, nil +} + +// CanAccessChangeset reports whether p may see (or modify) a changeset. +func CanAccessChangeset(p *auth.Principal, cs *model.Changeset, write bool) bool { + if p.IsAdmin() || (p.User != nil && cs.UserID == p.User.ID) { + return true + } + return !write && p.Can(auth.Action{Resource: auth.ResAudit}) +} + +// CreateChangeset starts a new open changeset. +func (e *Engine) CreateChangeset(ctx context.Context, p *auth.Principal, via, description string) (*model.Changeset, error) { + cs := &model.Changeset{Description: description, UserID: p.User.ID, Username: p.Name(), Via: via, Status: model.ChangesetOpen} + if err := e.store.CreateChangeset(ctx, cs); err != nil { + return nil, err + } + e.audit(ctx, p, "changeset.create", "changeset", cs.ID, cs.Description, cs.ID, "success", "", nil) + return cs, nil +} + +// StageResult is returned when operations are added to a changeset. +type StageResult struct { + Changeset *model.Changeset `json:"changeset"` + Added []model.Op `json:"added"` + Warnings []string `json:"warnings,omitempty"` + Problems []validate.Problem `json:"problems,omitempty"` // warnings introduced +} + +// expandAll expands and applies requests to work, returning the new +// operations. +func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Request, p *auth.Principal) ([]model.Op, []string, error) { + var ops []model.Op + var warnings []string + c := &applyCtx{p: p, now: e.Now()} + for i, req := range reqs { + x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now()} + more, err := x.expand(req) + if err != nil { + if ee, ok := AsError(err); ok && len(reqs) > 1 { + ee.Message = fmt.Sprintf("request %d (%s): %s", i+1, req.Op, ee.Message) + } + return nil, nil, err + } + warnings = append(warnings, x.warnings...) + for _, op := range more { + if err := applyOp(work, op, c); err != nil { + if ee, ok := AsError(err); ok { + ee.OpSeq = 0 + if len(reqs) > 1 { + ee.Message = fmt.Sprintf("request %d (%s): %s", i+1, req.Op, ee.Message) + } + } + return nil, nil, err + } + } + ops = append(ops, more...) + } + return ops, append(warnings, c.warnings...), nil +} + +// Stage adds requests to an open changeset. Requests that would introduce +// errors (for example an A record next to a CNAME) are rejected. +func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.Principal) (*StageResult, error) { + cs, err := e.loadChangeset(ctx, csID, p, true) + if err != nil { + return nil, err + } + base, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + work, _, err := e.replay(base, cs.Ops, p) + if err != nil { + return nil, errf(KindConflict, "the changeset no longer applies to the current configuration (%v); remove the failing operation first", err) + } + before := e.validate(work) + ops, warnings, err := e.expandAll(ctx, work, reqs, p) + if err != nil { + return nil, err + } + intro := validate.Introduced(before, e.validate(work)) + if validate.HasErrors(intro) { + return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)} + } + now := e.Now() + for i := range ops { + ops[i].Seq = nextSeq(cs.Ops) + i + ops[i].AddedAt = now + } + cs.Ops = append(cs.Ops, ops...) + if err := e.store.UpdateChangeset(ctx, cs); err != nil { + return nil, err + } + for _, op := range ops { + e.audit(ctx, p, "changeset.stage", "changeset", cs.ID, op.Summary, cs.ID, "success", "", nil) + } + var warnProblems []validate.Problem + for _, pr := range intro { + if pr.Severity != validate.Error { + warnProblems = append(warnProblems, pr) + } + } + return &StageResult{Changeset: cs, Added: ops, Warnings: warnings, Problems: warnProblems}, nil +} + +func nextSeq(ops []model.Op) int { + m := 0 + for _, op := range ops { + if op.Seq > m { + m = op.Seq + } + } + return m + 1 +} + +func describeErrors(ps []validate.Problem) string { + errs := validate.Errors(ps) + if len(errs) == 0 { + return "validation failed" + } + if len(errs) == 1 { + return errs[0].Message + } + return fmt.Sprintf("%s (and %d more problems)", errs[0].Message, len(errs)-1) +} + +// RemoveOps removes operations (by sequence number) from an open changeset. +func (e *Engine) RemoveOps(ctx context.Context, csID int64, seqs []int, p *auth.Principal) (*model.Changeset, error) { + cs, err := e.loadChangeset(ctx, csID, p, true) + if err != nil { + return nil, err + } + drop := map[int]bool{} + for _, s := range seqs { + drop[s] = true + } + var kept []model.Op + found := 0 + for _, op := range cs.Ops { + if drop[op.Seq] { + found++ + e.audit(ctx, p, "changeset.unstage", "changeset", cs.ID, op.Summary, cs.ID, "success", "", nil) + continue + } + kept = append(kept, op) + } + if found != len(drop) { + return nil, errf(KindNotFound, "operation not found in changeset %d", csID) + } + if kept == nil { + kept = []model.Op{} + } + cs.Ops = kept + if err := e.store.UpdateChangeset(ctx, cs); err != nil { + return nil, err + } + return cs, nil +} + +// UpdateDescription changes an open changeset's description. +func (e *Engine) UpdateDescription(ctx context.Context, csID int64, desc string, p *auth.Principal) (*model.Changeset, error) { + cs, err := e.loadChangeset(ctx, csID, p, true) + if err != nil { + return nil, err + } + cs.Description = desc + return cs, e.store.UpdateChangeset(ctx, cs) +} + +// Discard abandons an open changeset. +func (e *Engine) Discard(ctx context.Context, csID int64, p *auth.Principal) error { + cs, err := e.loadChangeset(ctx, csID, p, true) + if err != nil { + return err + } + cs.Status = model.ChangesetDiscarded + if err := e.store.UpdateChangeset(ctx, cs); err != nil { + return err + } + e.audit(ctx, p, "changeset.discard", "changeset", cs.ID, cs.Description, cs.ID, "success", "", nil) + return nil +} + +// ---- planning + +type plan struct { + base, work *model.State + introduced []validate.Problem + existing []validate.Problem + serials []SerialChange + out *render.Output + changed []*render.File + removed []string + diff string + check *bind.Result + checkMsgs []CheckMessage + drift []DriftEntry + configChanged bool + reloadZones []*model.Zone + verify map[int64]uint32 + newZones bool + backup *Backup + reloaded bool +} + +func (pl *plan) files() []string { + var out []string + for _, f := range pl.changed { + out = append(out, f.Path) + } + out = append(out, pl.removed...) + sort.Strings(out) + return out +} + +func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string) (*plan, error) { + pl := &plan{base: base, work: work, verify: map[int64]uint32{}} + before := e.validate(base) + after := e.validate(work) + pl.introduced = validate.Introduced(before, after) + introducedKeys := map[string]bool{} + for _, p := range pl.introduced { + introducedKeys[p.Key()] = true + } + for _, p := range after { + if !introducedKeys[p.Key()] { + pl.existing = append(pl.existing, p) + } + } + if validate.HasErrors(pl.introduced) { + return pl, nil + } + pl.serials = bumpSerials(base, work, e.Now(), user) + pl.out = render.Render(work, e.cfg.Paths) + deployed, err := e.store.DeployedFiles(ctx) + if err != nil { + return nil, err + } + pl.drift = CheckDrift(deployed) + + var diff strings.Builder + for _, f := range pl.out.Files { + old, existed, err := readFile(f.Path) + if err != nil { + return nil, err + } + if existed && string(old) == string(f.Content) { + continue + } + pl.changed = append(pl.changed, f) + diff.WriteString(fileDiff(f.Path, old, existed, f.Content, false, f.Secret)) + if f.Kind == render.KindConfig || f.Kind == render.KindKeys { + pl.configChanged = true + } + } + for path, df := range deployed { + if pl.out.File(path) != nil || df.Kind != render.KindZone { + continue + } + old, existed, _ := readFile(path) + if existed { + pl.removed = append(pl.removed, path) + diff.WriteString(fileDiff(path, old, true, nil, true, false)) + } + } + sort.Strings(pl.removed) + pl.diff = diff.String() + + // Which zones named must reload, and which serials to verify. + baseOut := render.Render(base, e.cfg.Paths) + changedPaths := map[string]bool{} + for _, f := range pl.changed { + changedPaths[f.Path] = true + } + for _, z := range work.SortedZones() { + if z.Type != model.ZonePrimary { + continue + } + path := pl.out.ZoneFiles[z.ID] + old := base.Zones[z.ID] + existedInNamed := old != nil && old.Type == model.ZonePrimary && baseOut.ZoneFiles[old.ID] == path + if !existedInNamed { + pl.newZones = true + pl.verify[z.ID] = z.SOA.Serial + continue + } + if changedPaths[path] && render.ZoneContentHash(base, old) != render.ZoneContentHash(work, z) || old.SOA.Serial != z.SOA.Serial { + pl.reloadZones = append(pl.reloadZones, z) + pl.verify[z.ID] = z.SOA.Serial + } + } + if len(work.Zones) != len(base.Zones) { + pl.configChanged = true + } + + if !e.cfg.SkipCheck { + if err := e.stagingCheck(ctx, pl); err != nil { + return nil, err + } + } + return pl, nil +} + +// stagingCheck renders work into a staging directory and runs +// named-checkconf -z on it. +func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error { + if err := os.MkdirAll(e.cfg.StagingDir, 0o750); err != nil { + return fmt.Errorf("creating staging directory: %w", err) + } + dir, err := os.MkdirTemp(e.cfg.StagingDir, "check-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} + sout := render.Render(pl.work, sp) + for _, f := range sout.Files { + if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil { + return err + } + if err := os.WriteFile(f.Path, f.Content, 0o640); err != nil { + return err + } + } + main, err := e.stagingMain(dir, sp.MainConfig()) + if err != nil { + return err + } + res, err := e.tools.CheckConfig(ctx, main, true) + if err != nil { + return &Error{Kind: KindBIND, Message: "running named-checkconf failed: " + err.Error()} + } + pl.check = res + // Map staging paths back to objects and production paths. + zoneByFile := map[string]int64{} + for id, path := range sout.ZoneFiles { + zoneByFile[path] = id + } + replacer := strings.NewReplacer(sp.ConfigDir, e.cfg.Paths.ConfigDir, sp.ZoneDir, e.cfg.Paths.ZoneDir, sp.CacheDir, e.cfg.Paths.CacheDir, dir, "(staging)") + for _, m := range res.Messages { + cm := CheckMessage{Message: m} + if span, ok := sout.Lookup(m.File, m.Line); ok { + cm.ObjectType, cm.ObjectID, cm.Object = span.ObjectType, span.ObjectID, span.Label + } else if id, ok := zoneByFile[m.File]; ok { + z := pl.work.Zones[id] + cm.ObjectType, cm.ObjectID, cm.Object = "zone", id, "zone "+zoneDesc(pl.work, z) + } + cm.File = replacer.Replace(cm.File) + cm.Text = replacer.Replace(cm.Text) + pl.checkMsgs = append(pl.checkMsgs, cm) + } + return nil +} + +// includesPath reports whether config text includes path. +func includesPath(text, path string) bool { + stmts, err := namedconf.Parse("", text) + if err != nil { + return strings.Contains(text, `"`+path+`"`) + } + for _, s := range namedconf.Find(stmts, "include") { + if filepath.Clean(s.Arg(0)) == filepath.Clean(path) { + return true + } + } + return false +} + +// stagingMain returns the main configuration file to check: named's own +// main file with Jarl's include pointed at the staging copy, so anything +// else in it is checked too. +func (e *Engine) stagingMain(dir, stagingJarl string) (string, error) { + main := e.cfg.MainConfig + if main == "" || filepath.Clean(main) == filepath.Clean(e.cfg.Paths.MainConfig()) { + return stagingJarl, nil + } + b, err := os.ReadFile(main) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return stagingJarl, nil + } + // Not readable by Jarl: check Jarl's configuration alone. + e.log.Warn("cannot read main named configuration; checking Jarl's configuration alone", "path", main, "err", err) + return stagingJarl, nil + } + text := string(b) + if !includesPath(text, e.cfg.Paths.MainConfig()) { + return stagingJarl, nil + } + text = strings.ReplaceAll(text, `"`+e.cfg.Paths.MainConfig()+`"`, `"`+stagingJarl+`"`) + path := filepath.Join(dir, "main.conf") + return path, os.WriteFile(path, []byte(text), 0o640) +} + +// Adopted reports whether named loads Jarl's configuration. +func (e *Engine) Adopted() (bool, error) { + main := e.cfg.MainConfig + if main == "" || filepath.Clean(main) == filepath.Clean(e.cfg.Paths.MainConfig()) { + return true, nil + } + b, err := os.ReadFile(main) + if err != nil { + return false, err + } + return includesPath(string(b), e.cfg.Paths.MainConfig()), nil +} + +func (pl *plan) result() *Result { + r := &Result{ + Valid: !validate.HasErrors(pl.introduced), + Problems: nonNilProblems(pl.introduced), + Existing: pl.existing, + Check: pl.checkMsgs, + CheckOK: pl.check == nil || pl.check.OK, + Diff: pl.diff, + Files: pl.files(), + Serials: pl.serials, + Drift: pl.drift, + } + r.Valid = r.Valid && r.CheckOK + if r.Files == nil { + r.Files = []string{} + } + return r +} + +func nonNilProblems(ps []validate.Problem) []validate.Problem { + if ps == nil { + return []validate.Problem{} + } + return ps +} + +// Preview validates a changeset and shows what applying it would do. +func (e *Engine) Preview(ctx context.Context, csID int64, p *auth.Principal) (*Result, error) { + cs, err := e.loadChangeset(ctx, csID, p, false) + if err != nil { + return nil, err + } + base, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + res := &Result{ChangesetID: cs.ID, DryRun: true, Ops: RedactOps(cs.Ops), Problems: []validate.Problem{}, Files: []string{}} + work, warnings, err := e.replay(base, cs.Ops, p) + if err != nil { + ee, ok := AsError(err) + if !ok { + return nil, err + } + res.Error = ee + return res, nil + } + pl, err := e.makePlan(ctx, base, work, p.Name()) + if err != nil { + return nil, err + } + r := pl.result() + r.ChangesetID, r.DryRun, r.Ops, r.Warnings = cs.ID, true, RedactOps(cs.Ops), warnings + return r, nil +} + +// Apply applies an open changeset. +func (e *Engine) Apply(ctx context.Context, csID int64, p *auth.Principal, opts ApplyOptions) (*Result, error) { + cs, err := e.loadChangeset(ctx, csID, p, true) + if err != nil { + return nil, err + } + if len(cs.Ops) == 0 { + return nil, errf(KindBadRequest, "changeset %d is empty", csID) + } + return e.applyOps(ctx, cs, nil, p, opts) +} + +// Submit expands requests into a new changeset and applies it at once. +// Nothing is stored if it fails. +func (e *Engine) Submit(ctx context.Context, reqs []Request, p *auth.Principal, via, description string, opts ApplyOptions) (*Result, error) { + cs := &model.Changeset{Description: description, UserID: p.User.ID, Username: p.Name(), Via: via, CreatedAt: e.Now()} + return e.applyOps(ctx, cs, reqs, p, opts) +} + +func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Request, p *auth.Principal, opts ApplyOptions) (res *Result, err error) { + e.mu.Lock() + defer e.mu.Unlock() + defer func() { + if err != nil { + outcome := "failure" + if ee, ok := AsError(err); ok && ee.Kind == KindForbidden { + outcome = "denied" + } + summary := cs.Description + if summary == "" && len(cs.Ops) > 0 { + summary = cs.Ops[0].Summary + } + if !opts.DryRun { + e.audit(ctx, p, "changeset.apply", "changeset", cs.ID, summary, cs.ID, outcome, err.Error(), nil) + } + } + }() + base, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + var work *model.State + var warnings []string + if reqs != nil { + work = base.Clone() + var ops []model.Op + ops, warnings, err = e.expandAll(ctx, work, reqs, p) + if err != nil { + return nil, err + } + now := e.Now() + for i := range ops { + ops[i].Seq, ops[i].AddedAt = i+1, now + } + cs.Ops = ops + if len(ops) == 0 { + return nil, errf(KindBadRequest, "nothing to do") + } + } else { + work, warnings, err = e.replay(base, cs.Ops, p) + if err != nil { + return nil, err + } + } + if cs.Description == "" && len(cs.Ops) > 0 { + cs.Description = cs.Ops[0].Summary + if len(cs.Ops) > 1 { + cs.Description += fmt.Sprintf(" (+%d more)", len(cs.Ops)-1) + } + } + pl, err := e.makePlan(ctx, base, work, p.Name()) + if err != nil { + return nil, err + } + res = pl.result() + res.ChangesetID, res.Warnings, res.Ops, res.DryRun = cs.ID, warnings, RedactOps(cs.Ops), opts.DryRun + if validate.HasErrors(pl.introduced) { + return res, &Error{Kind: KindValidation, Message: describeErrors(pl.introduced), Problems: validate.Errors(pl.introduced)} + } + if pl.check != nil && !pl.check.OK { + return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the new configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs} + } + if opts.DryRun { + return res, nil + } + if len(pl.drift) > 0 && !opts.Force { + var paths []string + for _, d := range pl.drift { + paths = append(paths, d.Path+" ("+d.Reason+")") + } + return res, &Error{Kind: KindDrift, Message: "files managed by Jarl were changed outside Jarl: " + strings.Join(paths, ", ") + "; review them and apply with force to overwrite"} + } + reload := !e.cfg.NoReload && !opts.NoReload + if reload { + if adopted, aerr := e.Adopted(); aerr != nil || !adopted { + // named doesn't read Jarl's files yet, so there is nothing + // to reload; the files are ready for 'jarld adopt'. + reload = false + res.Warnings = append(res.Warnings, fmt.Sprintf("named does not load Jarl's configuration yet (%s does not include %s): the files were written but named was not reloaded; run 'jarld adopt' to switch named over", e.cfg.MainConfig, e.cfg.Paths.MainConfig())) + } + } + if err := e.deployAndReload(ctx, pl, res, reload); err != nil { + return res, err + } + // Record everything. + undo := DiffOps(work, base) + st := stored{Serials: pl.serials, Warnings: warnings, Files: res.Files, Undo: undo} + cs.Result = mustJSON(st) + cs.Diff = pl.diff + cs.AppliedBy = p.Name() + deployed := deployedList(pl.out, cs.ID, e.Now()) + err = e.store.InTx(ctx, func(tx *store.Tx) error { + if err := tx.SaveState(base, work); err != nil { + return err + } + if cs.ID == 0 { + if err := tx.InsertAppliedChangeset(cs); err != nil { + return err + } + } else if err := tx.MarkApplied(cs); err != nil { + return err + } + for _, d := range deployed { + d.ChangesetID = cs.ID + } + if err := tx.SetDeployedFiles(deployed); err != nil { + return err + } + details, _ := json.Marshal(map[string]any{"ops": RedactOps(cs.Ops), "serials": pl.serials, "files": res.Files}) + return tx.AddAudit(e.auditEvent(p, "changeset.apply", "changeset", cs.ID, cs.Description, cs.ID, "success", "", details)) + }) + if err != nil { + e.log.Error("recording applied changeset failed; restoring previous configuration", "err", err) + e.rollback(ctx, pl) + return res, fmt.Errorf("recording the change failed (the previous configuration was restored): %w", err) + } + res.ChangesetID = cs.ID + res.Applied = true + e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description) + return res, nil +} + +func firstCheckError(msgs []CheckMessage) string { + for _, m := range msgs { + if m.Error { + s := m.Text + if m.Object != "" { + s = m.Object + ": " + s + } + return s + } + } + return "see the check output" +} + +func deployedList(out *render.Output, csID int64, now time.Time) []*model.DeployedFile { + var list []*model.DeployedFile + for _, f := range out.Files { + list = append(list, &model.DeployedFile{Path: f.Path, SHA256: sha(f.Content), Size: int64(len(f.Content)), Kind: f.Kind, WrittenAt: now, ChangesetID: csID}) + } + return list +} + +// deployAndReload writes the files and (if reload) makes named load them. +// On failure the previous files are restored. +func (e *Engine) deployAndReload(ctx context.Context, pl *plan, res *Result, reload bool) error { + backup, err := e.deploy.Install(pl.changed, pl.removed, pl.out.Dirs) + if err != nil { + return &Error{Kind: KindBIND, Message: "writing configuration files failed (nothing was changed): " + err.Error()} + } + pl.backup = backup + pl.reloaded = reload + if !reload { + return nil + } + reloaded, err := e.reload(ctx, pl) + res.Reloaded = reloaded + if err != nil { + e.log.Error("reloading named failed; restoring previous configuration", "err", err) + e.rollback(ctx, pl) + return &Error{Kind: KindBIND, Message: err.Error() + " (the previous configuration was restored)"} + } + return nil +} + +func (e *Engine) rollback(ctx context.Context, pl *plan) { + if err := pl.backup.Restore(); err != nil { + e.log.Error("restoring files failed", "err", err) + } + if !pl.reloaded { + return + } + if pl.configChanged || pl.newZones { + if err := e.tools.Reconfig(ctx); err != nil { + e.log.Error("rndc reconfig after restore failed", "err", err) + } + } + for _, z := range pl.reloadZones { + if err := e.tools.ReloadZone(ctx, z.Name, e.namedView(pl.base, pl.base.Zones[z.ID])); err != nil { + e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err) + } + } +} + +// namedView returns the view name to pass to rndc for a zone ("" without +// views). +func (e *Engine) namedView(st *model.State, z *model.Zone) string { + if z == nil { + return "" + } + return st.ViewName(z) +} + +func (e *Engine) reload(ctx context.Context, pl *plan) ([]string, error) { + var done []string + if pl.configChanged || pl.newZones { + if err := e.tools.Reconfig(ctx); err != nil { + return done, fmt.Errorf("named rejected the new configuration: %v", err) + } + done = append(done, "reconfig") + } + for _, z := range pl.reloadZones { + if err := e.tools.ReloadZone(ctx, z.Name, e.namedView(pl.work, z)); err != nil { + return done, fmt.Errorf("reloading zone %s failed: %v", zoneDesc(pl.work, z), err) + } + done = append(done, "reload "+zoneDesc(pl.work, z)) + } + // named reloads zones asynchronously and reports load errors only in + // its log, so wait until it serves the new serials. + deadline := time.Now().Add(e.cfg.ReloadTimeout) + pending := map[int64]uint32{} + for id, s := range pl.verify { + pending[id] = s + } + var lastErr error + for len(pending) > 0 { + for id, want := range pending { + z := pl.work.Zones[id] + st, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(pl.work, z)) + if err == nil && st.HasSerial && st.Serial == want { + delete(pending, id) + continue + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("named serves serial %d", st.Serial) + } + } + if len(pending) == 0 { + break + } + if time.Now().After(deadline) { + var names []string + for id, want := range pending { + names = append(names, fmt.Sprintf("%s (expected serial %d)", zoneDesc(pl.work, pl.work.Zones[id]), want)) + } + sort.Strings(names) + return done, fmt.Errorf("named did not load %s within %s: %v; check named's log (journalctl -u named)", strings.Join(names, ", "), e.cfg.ReloadTimeout, lastErr) + } + select { + case <-ctx.Done(): + return done, ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + return done, nil +} + +// ---- revert, resync, check + +// Revert creates an open changeset that undoes an applied changeset. +func (e *Engine) Revert(ctx context.Context, csID int64, p *auth.Principal) (*model.Changeset, error) { + cs, err := e.loadChangeset(ctx, csID, p, false) + if err != nil { + return nil, err + } + if cs.Status != model.ChangesetApplied { + return nil, errf(KindConflict, "only applied changesets can be reverted") + } + var st stored + if len(cs.Result) > 0 { + if err := json.Unmarshal(cs.Result, &st); err != nil { + return nil, err + } + } + if len(st.Undo) == 0 { + return nil, errf(KindConflict, "changeset %d has nothing to undo", csID) + } + rev := &model.Changeset{Description: fmt.Sprintf("revert changeset #%d: %s", cs.ID, cs.Description), UserID: p.User.ID, Username: p.Name(), Via: "revert", Status: model.ChangesetOpen, Ops: st.Undo} + now := e.Now() + for i := range rev.Ops { + rev.Ops[i].Seq, rev.Ops[i].AddedAt = i+1, now + } + if err := e.store.CreateChangeset(ctx, rev); err != nil { + return nil, err + } + e.audit(ctx, p, "changeset.revert", "changeset", rev.ID, rev.Description, cs.ID, "success", "", nil) + return rev, nil +} + +// Resync renders the current configuration and deploys it, overwriting +// whatever is on disk (used for the first deployment and to repair +// drift). +func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOptions) (*Result, error) { + e.mu.Lock() + defer e.mu.Unlock() + base, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + work := base.Clone() + pl, err := e.makePlan(ctx, base, work, p.Name()) + if err != nil { + return nil, err + } + res := pl.result() + res.DryRun = opts.DryRun + if validate.HasErrors(pl.existing) { + // Resync deploys the stored state as is; it must be valid. + return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(pl.existing), Problems: validate.Errors(pl.existing)} + } + if pl.check != nil && !pl.check.OK { + return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs} + } + if opts.DryRun { + return res, nil + } + // Verify every primary zone and reload them all. + pl.configChanged = true + pl.reloadZones = nil + for _, z := range work.SortedZones() { + if z.Type == model.ZonePrimary { + pl.reloadZones = append(pl.reloadZones, z) + pl.verify[z.ID] = z.SOA.Serial + } + } + reload := !e.cfg.NoReload && !opts.NoReload + if adopted, aerr := e.Adopted(); reload && (aerr != nil || !adopted) { + reload = false + res.Warnings = append(res.Warnings, "named does not load Jarl's configuration yet; files written without reloading named") + } + if err := e.deployAndReload(ctx, pl, res, reload); err != nil { + return res, err + } + deployed := deployedList(pl.out, 0, e.Now()) + err = e.store.InTx(ctx, func(tx *store.Tx) error { + if err := tx.SetDeployedFiles(deployed); err != nil { + return err + } + return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), nil)) + }) + if err != nil { + return res, err + } + res.Applied = true + return res, nil +} + +// CheckCurrent validates the stored configuration and runs named-checkconf +// on it without changing anything. +func (e *Engine) CheckCurrent(ctx context.Context) (*Result, error) { + base, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + pl, err := e.makePlan(ctx, base, base.Clone(), "") + if err != nil { + return nil, err + } + res := pl.result() + res.DryRun = true + res.Problems = nonNilProblems(pl.existing) + res.Existing = nil + res.Valid = !validate.HasErrors(pl.existing) && res.CheckOK + return res, nil +} + +// Status summarizes the state of Jarl and named. +type Status struct { + Adopted bool `json:"adopted"` + AdoptError string `json:"adopt_error,omitempty"` + Named *bind.ServerStatus `json:"named,omitempty"` + NamedError string `json:"named_error,omitempty"` + BINDVersion string `json:"bind_version,omitempty"` + Drift []DriftEntry `json:"drift"` + Deployed bool `json:"deployed"` + LastApplied *model.Changeset `json:"last_applied,omitempty"` + OpenChangesets int `json:"open_changesets"` + Problems []validate.Problem `json:"problems"` + Counts map[string]int `json:"counts"` + Paths render.Paths `json:"paths"` + MainConfig string `json:"main_config"` +} + +// Status returns the current status. +func (e *Engine) Status(ctx context.Context) (*Status, error) { + st, err := e.store.LoadState(ctx) + if err != nil { + return nil, err + } + s := &Status{Paths: e.cfg.Paths, MainConfig: e.cfg.MainConfig, Drift: []DriftEntry{}} + s.Adopted, err = e.Adopted() + if err != nil { + s.AdoptError = err.Error() + } + if !e.cfg.NoReload && e.tools != nil { + ns, err := e.tools.Status(ctx) + if err != nil { + s.NamedError = err.Error() + } else { + s.Named = ns + } + } + if e.tools != nil { + if v, err := e.tools.Version(ctx); err == nil { + s.BINDVersion = v + } + } + deployed, err := e.store.DeployedFiles(ctx) + if err != nil { + return nil, err + } + s.Deployed = len(deployed) > 0 + s.Drift = append(s.Drift, CheckDrift(deployed)...) + if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetApplied, Limit: 1}); err == nil && len(list) > 0 { + s.LastApplied = list[0] + s.LastApplied.Ops = RedactOps(s.LastApplied.Ops) + } + if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetOpen, Limit: 1000}); err == nil { + s.OpenChangesets = len(list) + } + s.Problems = nonNilProblems(e.validate(st)) + s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)} + return s, nil +} + +// ---- audit + +func (e *Engine) auditEvent(p *auth.Principal, action, objType string, objID int64, objName string, csID int64, outcome, msg string, details json.RawMessage) *model.AuditEvent { + ev := &model.AuditEvent{Time: e.Now(), Action: action, ObjectType: objType, ObjectID: objID, ObjectName: objName, ChangesetID: csID, Outcome: outcome, Message: msg, Details: details} + if p != nil { + if p.User != nil { + ev.UserID, ev.Username = p.User.ID, p.User.Username + } + ev.Auth, ev.IP, ev.UserAgent = p.Method, p.IP, p.Agent + if p.Token != nil { + ev.TokenID, ev.TokenName = p.Token.ID, p.Token.Name + } + } + return ev +} + +func (e *Engine) audit(ctx context.Context, p *auth.Principal, action, objType string, objID int64, objName string, csID int64, outcome, msg string, details json.RawMessage) { + ev := e.auditEvent(p, action, objType, objID, objName, csID, outcome, msg, details) + if err := e.store.AddAudit(ctx, ev); err != nil { + e.log.Error("writing audit event failed", "err", err, "action", action) + } + e.log.Info("audit", "action", action, "user", ev.Username, "auth", ev.Auth, "token", ev.TokenName, "ip", ev.IP, "object", objName, "changeset", csID, "outcome", outcome, "message", msg) +} + +// Audit records an audit event (used by other packages). +func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, objType string, objID int64, objName, outcome, msg string) { + e.audit(ctx, p, action, objType, objID, objName, 0, outcome, msg, nil) +} + +// ZoneLabel is exported for other packages. +func ZoneLabel(st *model.State, z *model.Zone) string { return zoneDesc(st, z) } + +// Display is a helper for names. +func Display(n string) string { return dnsname.Display(n) } + +// WorkingState returns the configuration as it would be after applying an +// open changeset (work), and the current configuration (base). +func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal) (work, base *model.State, err error) { + cs, err := e.loadChangeset(ctx, csID, p, false) + if err != nil { + return nil, nil, err + } + base, err = e.store.LoadState(ctx) + if err != nil { + return nil, nil, err + } + if cs.Status != model.ChangesetOpen { + return base, base, nil + } + work, _, err = e.replay(base, cs.Ops, p) + if err != nil { + return nil, nil, err + } + return work, base, nil +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..a25dd40 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,452 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "jarl/internal/auth" + "jarl/internal/model" + "jarl/internal/render" + "jarl/internal/store" +) + +type testEnv struct { + t *testing.T + ctx context.Context + e *Engine + st *store.Store + admin *auth.Principal + dir string +} + +func newEnv(t *testing.T) *testEnv { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "jarl.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + cfg := Config{ + Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}, + StagingDir: filepath.Join(dir, "staging"), + NoReload: true, + SkipCheck: true, + } + e := New(st, nil, cfg, nil) + e.Now = func() time.Time { return time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) } + admin := &model.User{Username: "admin", Admin: true} + if err := st.CreateUser(context.Background(), admin); err != nil { + t.Fatal(err) + } + env := &testEnv{t: t, ctx: context.Background(), e: e, st: st, admin: &auth.Principal{User: admin, Method: "local"}, dir: dir} + // Defaults for new zones. + env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.com."}, RName: model.Ptr("hostmaster@example.com")})) + return env +} + +func (env *testEnv) submit(p *auth.Principal, reqs ...Request) *Result { + env.t.Helper() + res, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{}) + if err != nil { + env.t.Fatalf("submit %v: %v (%+v)", reqs[0].Op, err, res) + } + return res +} + +func (env *testEnv) submitErr(p *auth.Principal, reqs ...Request) *Error { + env.t.Helper() + _, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{}) + if err == nil { + env.t.Fatalf("submit %v succeeded, want error", reqs[0].Op) + } + ee, ok := AsError(err) + if !ok { + env.t.Fatalf("unexpected error type %T: %v", err, err) + } + return ee +} + +func (env *testEnv) state() *model.State { + s, err := env.st.LoadState(env.ctx) + if err != nil { + env.t.Fatal(err) + } + return s +} + +func rec(zone, name, typ, data string) Request { + return NewRequest(OpRecordCreate, 0, RecordInput{Zone: zone, Name: &name, Type: &typ, Data: &data}) +} + +func (env *testEnv) records(zone string) []string { + s := env.state() + zs := s.ZonesNamed(zone) + var out []string + for _, r := range s.ZoneRecords(zs[0].ID) { + out = append(out, r.Name+" "+r.Type+" "+r.Data) + } + return out +} + +func TestZoneLifecycle(t *testing.T) { + env := newEnv(t) + res := env.submit(env.admin, + NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}), + rec("example.com", "ns1", "A", "192.0.2.1"), + rec("example.com", "www", "A", "192.0.2.10"), + ) + if !res.Applied || len(res.Files) != 2 || !strings.HasSuffix(res.Files[0], "etc/named.conf") || !strings.HasSuffix(res.Files[1], "example.com.zone") { + t.Fatalf("result = %+v", res) + } + s := env.state() + z := s.ZonesNamed("example.com")[0] + if z.SOA.Serial != 2026100700 || z.SOA.MName != "ns1.example.com." || z.SOA.RName != "hostmaster.example.com." || z.DefaultTTL != 3600 { + t.Errorf("zone defaults = %+v", z.SOA) + } + got := strings.Join(env.records("example.com"), "|") + if got != "example.com. NS ns1.example.com.|ns1.example.com. A 192.0.2.1|www.example.com. A 192.0.2.10" { + t.Errorf("records = %s", got) + } + zf, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/example.com.zone")) + if err != nil || !strings.Contains(string(zf), "www\t\tIN\tA\t192.0.2.10") { + t.Errorf("zone file: %v\n%s", err, zf) + } + // A change bumps the serial once. + env.submit(env.admin, rec("example.com", "mail", "A", "192.0.2.25"), rec("example.com", "@", "MX", "10 mail")) + if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 { + t.Errorf("serial after change = %d", s) + } + // Comment-only changes don't. + r := findRec(env.state(), "www.example.com.", "A") + env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Comment: model.Ptr("web server")})) + if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 { + t.Errorf("serial after comment change = %d", s) + } +} + +func findRec(s *model.State, name, typ string) *model.Record { + for _, r := range s.Records { + if r.Name == name && r.Type == typ { + return r + } + } + return nil +} + +func setupZone(env *testEnv) { + env.submit(env.admin, + NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}), + rec("example.com", "ns1", "A", "192.0.2.1"), + ) +} + +func TestConflictsRejected(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1")) + // The headline rule: no A record next to a CNAME. + e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10")) + if e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") { + t.Errorf("error = %+v", e) + } + // Bad data is rejected before validation. + e = env.submitErr(env.admin, rec("example.com", "bad", "A", "300.1.1.1")) + if e.Kind != KindBadRequest { + t.Errorf("bad data error = %+v", e) + } + // Swapping CNAME for A works when both happen in one changeset. + r := findRec(env.state(), "www.example.com.", "CNAME") + env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "www", "A", "192.0.2.10")) + if findRec(env.state(), "www.example.com.", "A") == nil { + t.Error("swap failed") + } + // Nothing is stored when a submit fails. + cs, _ := env.st.ListChangesets(env.ctx, store.ChangesetFilter{}) + for _, c := range cs { + if c.Status != model.ChangesetApplied { + t.Errorf("unexpected changeset %+v", c) + } + } +} + +func TestStagedChangeset(t *testing.T) { + env := newEnv(t) + setupZone(env) + cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "add web") + if err != nil { + t.Fatal(err) + } + if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "CNAME", "ns1")}, env.admin); err != nil { + t.Fatal(err) + } + // Staging a conflicting record is rejected right away. + if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "TXT", "hello")}, env.admin); err == nil { + t.Error("conflicting record staged") + } + prev, err := env.e.Preview(env.ctx, cs.ID, env.admin) + if err != nil || !prev.Valid || !strings.Contains(prev.Diff, "+www\t\tIN\tCNAME\tns1.example.com.") { + t.Fatalf("preview = %+v %v", prev, err) + } + if findRec(env.state(), "www.example.com.", "CNAME") != nil { + t.Error("preview changed the state") + } + // Someone else changes the configuration meanwhile. + env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10")) + if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err == nil { + t.Fatal("stale changeset applied") + } + // Staging the delete of the conflicting A record fixes the + // changeset (it removes an error rather than adding one). + a := findRec(env.state(), "www.example.com.", "A") + if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordDelete, a.ID, nil)}, env.admin); err != nil { + t.Fatal(err) + } + res, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}) + if err != nil || !res.Applied { + t.Fatalf("apply: %v %+v", err, res) + } + got, _ := env.st.GetChangeset(env.ctx, cs.ID) + if got.Status != model.ChangesetApplied || got.Diff == "" { + t.Errorf("changeset = %+v", got) + } + if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "x", "A", "192.0.2.3")}, env.admin); err == nil { + t.Error("staged into applied changeset") + } +} + +func TestVersionConflict(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10")) + r := findRec(env.state(), "www.example.com.", "A") + cs, _ := env.e.CreateChangeset(env.ctx, env.admin, "web", "") + if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.11")})}, env.admin); err != nil { + t.Fatal(err) + } + env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")})) + _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}) + if ee, ok := AsError(err); !ok || ee.Kind != KindConflict { + t.Errorf("expected version conflict, got %v", err) + } +} + +func TestPermissions(t *testing.T) { + env := newEnv(t) + setupZone(env) + g, _ := auth.ParseGrant("records:write zones=example.com names=_acme-challenge.* types=TXT") + u := &model.User{Username: "acme", Grants: []model.Grant{g}} + env.st.CreateUser(env.ctx, u) + p := &auth.Principal{User: u, Method: "token"} + env.submit(p, rec("example.com", "_acme-challenge.www", "TXT", "token123")) + if e := env.submitErr(p, rec("example.com", "www", "A", "192.0.2.66")); e.Kind != KindForbidden { + t.Errorf("A record allowed: %+v", e) + } + if e := env.submitErr(p, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("evil.com")})); e.Kind != KindForbidden { + t.Errorf("zone create allowed: %+v", e) + } + // ACME cleanup: delete by match, idempotent. + del := NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123")}) + env.submit(p, del) + if findRec(env.state(), "_acme-challenge.www.example.com.", "TXT") != nil { + t.Error("challenge not deleted") + } + e := env.submitErr(p, del) + if e.Kind != KindNotFound { + t.Errorf("second delete = %+v", e) + } + env.submit(p, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123"), MissingOK: true})) +} + +func TestPTRAndRRSet(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")})) + in := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true} + res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, in)) + if len(res.Ops) != 2 || res.Ops[1].Summary != "add 20.2.0.192.in-addr.arpa. IN PTR nas.example.com." { + t.Fatalf("ops = %+v", res.Ops) + } + ptr := findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR") + if ptr == nil || ptr.Data != "nas.example.com." { + t.Fatalf("PTR = %+v", ptr) + } + // Changing the address moves the PTR. + a := findRec(env.state(), "nas.example.com.", "A") + env.submit(env.admin, NewRequest(OpRecordUpdate, a.ID, RecordInput{Data: model.Ptr("192.0.2.21"), PTR: true})) + if findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR") != nil || findRec(env.state(), "21.2.0.192.in-addr.arpa.", "PTR") == nil { + t.Error("PTR not moved") + } + // RRset replace keeps matching records and sets the TTL on all. + env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", TTL: model.Some(uint32(60)), Data: []string{"192.0.2.30", "192.0.2.31"}})) + keep := findRecData(env.state(), "api.example.com.", "192.0.2.30") + env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.32"}})) + s := env.state() + if findRecData(s, "api.example.com.", "192.0.2.31") != nil || findRecData(s, "api.example.com.", "192.0.2.32") == nil { + t.Error("rrset replace wrong") + } + if k := findRecData(s, "api.example.com.", "192.0.2.30"); k == nil || k.ID != keep.ID || *k.TTL != 60 || *findRecData(s, "api.example.com.", "192.0.2.32").TTL != 60 { + t.Error("rrset replace did not keep record or TTL") + } + // A new record joining an RRset inherits its TTL. + env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.33")) + if r := findRecData(env.state(), "api.example.com.", "192.0.2.33"); r.TTL == nil || *r.TTL != 60 { + t.Error("TTL not inherited") + } + // A conflicting TTL is rejected. + ttl := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.34"), TTL: model.Some(uint32(300))} + if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, ttl)); !strings.Contains(e.Message, "same TTL") { + t.Errorf("TTL mismatch error = %+v", e) + } +} + +func findRecData(s *model.State, name, data string) *model.Record { + for _, r := range s.Records { + if r.Name == name && r.Data == data { + return r + } + } + return nil +} + +func TestRevert(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10")) + before := env.records("example.com") + r := findRec(env.state(), "www.example.com.", "A") + res := env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "web", "A", "192.0.2.11")) + rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin) + if err != nil { + t.Fatal(err) + } + if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil { + t.Fatal(err) + } + if got := strings.Join(env.records("example.com"), "|"); got != strings.Join(before, "|") { + t.Errorf("after revert %s, want %s", got, strings.Join(before, "|")) + } + // Reverting a zone deletion restores records and options. + z := env.state().ZonesNamed("example.com")[0] + env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: &z.ID, Name: model.Ptr("notify"), Value: model.Ptr("no")})) + del := env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil)) + if len(env.state().Zones) != 0 { + t.Fatal("zone not deleted") + } + rev, _ = env.e.Revert(env.ctx, del.ChangesetID, env.admin) + if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil { + t.Fatal(err) + } + s := env.state() + if len(s.Zones) != 1 || len(s.Records) != 3 || s.FindOption(model.ScopeZone, z.ID, "notify") == nil { + t.Errorf("zone not restored: %d zones, %d records", len(s.Zones), len(s.Records)) + } +} + +func TestViewsAndACLs(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("lan"), Elements: &[]string{"192.168.0.0/16", "10/8"}})) + a := env.state().ACLByName("lan") + if strings.Join(a.Elements, ",") != "192.168.0.0/16,10.0.0.0/8" { + t.Errorf("elements = %v", a.Elements) + } + // Creating a view moves existing zones into it. + env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"lan", "localhost"}, AdoptZones: true})) + s := env.state() + v := s.ViewByName("internal") + if s.ZonesNamed("example.com")[0].ViewID != v.ID { + t.Error("zone not adopted into view") + } + // A view without adopt_zones and zones outside views is rejected. + env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}})) + // Clone the zone into the external view. + z := env.state().ZonesNamed("example.com")[0] + env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), CloneFrom: z.ID})) + if zs := env.state().ZonesNamed("example.com"); len(zs) != 2 { + t.Fatalf("clone failed: %d zones", len(zs)) + } + // Records now need a view to say which zone. + if e := env.submitErr(env.admin, rec("example.com", "x", "A", "192.0.2.5")); !strings.Contains(e.Message, "several views") { + t.Errorf("ambiguous zone error = %+v", e) + } + // Renaming an ACL rewrites references. + env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &v.ID, Name: model.Ptr("allow-recursion"), Value: model.Ptr("{ lan; }")})) + env.submit(env.admin, NewRequest(OpACLUpdate, a.ID, ACLInput{Name: model.Ptr("home")})) + s = env.state() + if strings.Join(s.ViewByName("internal").MatchClients, ",") != "home,localhost" || s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value != "{ home; }" { + t.Errorf("references not renamed: %v %q", s.ViewByName("internal").MatchClients, s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value) + } + // Deleting an ACL in use is refused with the users listed. + e := env.submitErr(env.admin, NewRequest(OpACLDelete, a.ID, nil)) + if !strings.Contains(e.Message, "view internal") || !strings.Contains(e.Message, "allow-recursion") { + t.Errorf("delete in-use ACL error = %+v", e) + } + // Invalid options are rejected with the catalog's help. + e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("recursion"), Value: model.Ptr("maybe")})) + if !strings.Contains(e.Message, "boolean") { + t.Errorf("bad option error = %+v", e) + } + e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("match-clients"), Value: model.Ptr("{ any; }")})) + if e.Kind != KindBadRequest { + t.Errorf("option in wrong scope = %+v", e) + } + // String options get quoted automatically. + env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("directory"), Value: model.Ptr("/var/cache/bind")})) + if o := env.state().FindOption(model.ScopeOptions, 0, "directory"); o.Value != `"/var/cache/bind"` { + t.Errorf("directory = %q", o.Value) + } +} + +func TestTSIGAndDrift(t *testing.T) { + env := newEnv(t) + setupZone(env) + res := env.submit(env.admin, NewRequest(OpTSIGCreate, 0, TSIGInput{Name: model.Ptr("xfer")})) + if strings.Contains(string(res.Ops[0].Data), `"secret":"`) && !strings.Contains(string(res.Ops[0].Data), "(hidden)") { + t.Errorf("secret not redacted: %s", res.Ops[0].Data) + } + k := env.state().KeyByName("xfer") + if k == nil || len(k.Secret) != 44 { + t.Fatalf("key = %+v", k) + } + if !strings.Contains(res.Diff, "contents hidden") || strings.Contains(res.Diff, k.Secret) { + t.Errorf("secret in diff:\n%s", res.Diff) + } + // Edit a managed file behind Jarl's back. + zf := filepath.Join(env.dir, "zones/_default/example.com.zone") + os.WriteFile(zf, []byte("hand edited\n"), 0o640) + e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10")) + if e.Kind != KindDrift { + t.Errorf("drift not detected: %+v", e) + } + if _, err := env.e.Submit(env.ctx, []Request{rec("example.com", "www", "A", "192.0.2.10")}, env.admin, "test", "", ApplyOptions{Force: true}); err != nil { + t.Errorf("forced apply: %v", err) + } + if b, _ := os.ReadFile(zf); strings.Contains(string(b), "hand edited") { + t.Error("drifted file not overwritten") + } +} + +func TestZoneCreateWithOptions(t *testing.T) { + env := newEnv(t) + // A secondary zone is only valid together with its primaries option. + e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave")})) + if e.Kind != KindValidation || !strings.Contains(e.Message, "primaries") { + t.Errorf("secondary without primaries = %+v", e) + } + env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave"), + Options: []ZoneOption{{Name: "masters", Value: "{ 192.0.2.53; }"}}})) + s := env.state() + z := s.ZonesNamed("partner.example")[0] + if z.Type != model.ZoneSecondary { + t.Errorf("type = %s", z.Type) + } + o := s.FindOption(model.ScopeZone, z.ID, "primaries") + if o == nil || o.Value != "{ 192.0.2.53; }" { + t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o) + } +} diff --git a/internal/engine/errors.go b/internal/engine/errors.go new file mode 100644 index 0000000..7cf395e --- /dev/null +++ b/internal/engine/errors.go @@ -0,0 +1,61 @@ +package engine + +import ( + "errors" + "fmt" + + "jarl/internal/bind" + "jarl/internal/validate" +) + +// ErrorKind classifies engine errors (the API maps them to HTTP status +// codes). +type ErrorKind string + +const ( + KindBadRequest ErrorKind = "bad_request" // malformed or invalid input + KindForbidden ErrorKind = "forbidden" // permission denied + KindNotFound ErrorKind = "not_found" // object does not exist + KindConflict ErrorKind = "conflict" // concurrent change, wrong state + KindValidation ErrorKind = "validation_failed" // the resulting config has errors + KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed + KindDrift ErrorKind = "drift" // files were changed outside Jarl + KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet +) + +// Error is an engine error with details for the caller. +type Error struct { + Kind ErrorKind `json:"code"` + Message string `json:"message"` + OpSeq int `json:"op,omitempty"` // the operation that failed + Problems []validate.Problem `json:"problems,omitempty"` + Check []CheckMessage `json:"check,omitempty"` + Diff string `json:"diff,omitempty"` +} + +func (e *Error) Error() string { + if e.OpSeq > 0 { + return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message) + } + return e.Message +} + +func errf(kind ErrorKind, format string, args ...any) *Error { + return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)} +} + +// AsError returns err as *Error, if it is one. +func AsError(err error) (*Error, bool) { + var e *Error + ok := errors.As(err, &e) + return e, ok +} + +// CheckMessage is a named-checkconf message mapped back to the Jarl object +// that produced the offending line. +type CheckMessage struct { + bind.Message + ObjectType string `json:"object_type,omitempty"` + ObjectID int64 `json:"object_id,omitempty"` + Object string `json:"object,omitempty"` +} diff --git a/internal/engine/expand.go b/internal/engine/expand.go new file mode 100644 index 0000000..382ec3d --- /dev/null +++ b/internal/engine/expand.go @@ -0,0 +1,852 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "net/netip" + "strings" + "time" + + "github.com/miekg/dns" + + "jarl/internal/auth" + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/rr" + "jarl/internal/validate" +) + +// IDAllocator reserves object IDs. +type IDAllocator func(ctx context.Context, n int) ([]int64, error) + +type expander struct { + ctx context.Context + st *model.State // working state: base plus the changeset's ops so far + alloc IDAllocator + p *auth.Principal + now time.Time + warnings []string +} + +func (x *expander) ids(n int) ([]int64, error) { + ids, err := x.alloc(x.ctx, n) + if err != nil { + return nil, fmt.Errorf("allocating IDs: %w", err) + } + return ids, nil +} + +func mustJSON(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +func newOp(kind string, id int64, base int, data any, summary string) model.Op { + op := model.Op{Kind: kind, ID: id, BaseVersion: base, Summary: summary} + if data != nil { + op.Data = mustJSON(data) + } + return op +} + +// resolveZone finds the zone a record request refers to. +func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, error) { + if zoneID != 0 { + z := x.st.Zones[zoneID] + if z == nil { + return nil, errf(KindNotFound, "zone %d does not exist", zoneID) + } + return z, nil + } + if zone == "" { + return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)") + } + return FindZoneRef(x.st, zone, view) +} + +// FindZoneRef finds a zone by name and optional view name. +func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) { + name, err := ParseZoneName(zone) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + if view != "" { + v := st.ViewByName(view) + if v == nil { + return nil, errf(KindNotFound, "view %q does not exist", view) + } + z := st.FindZone(name, v.ID) + if z == nil { + return nil, errf(KindNotFound, "zone %s does not exist in view %s", dnsname.Display(name), v.Name) + } + return z, nil + } + zs := st.ZonesNamed(name) + switch len(zs) { + case 0: + return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name)) + case 1: + return zs[0], nil + } + var views []string + for _, z := range zs { + views = append(views, st.ViewName(z)) + } + return nil, errf(KindBadRequest, "zone %s exists in several views (%s); say which view", dnsname.Display(name), strings.Join(views, ", ")) +} + +func zoneDesc(st *model.State, z *model.Zone) string { + s := dnsname.Display(z.Name) + if v := st.ViewName(z); v != "" { + s += " [" + v + "]" + } + return s +} + +func ttlText(o model.Opt[uint32]) string { + if o.Set && !o.Null { + return fmt.Sprintf(" %d", o.Value) + } + return "" +} + +// InitialSerial returns the first serial for a new zone. +func InitialSerial(policy string, now time.Time) uint32 { + switch policy { + case model.SerialIncrement: + return 1 + case model.SerialUnixtime: + return uint32(now.Unix()) + } + return dateSerial(now) +} + +func dateSerial(now time.Time) uint32 { + y, m, d := now.UTC().Date() + return uint32(y*1000000 + int(m)*10000 + d*100) +} + +func (x *expander) expand(req Request) ([]model.Op, error) { + switch req.Op { + case OpZoneCreate: + return x.zoneCreate(req) + case OpZoneUpdate, OpZoneDelete: + z := x.st.Zones[req.ID] + if z == nil { + return nil, errf(KindNotFound, "zone %d does not exist", req.ID) + } + verb := "update" + if req.Op == OpZoneDelete { + verb = "delete" + if n := len(x.st.ZoneRecords(z.ID)); n > 0 { + verb += fmt.Sprintf(" (with its %d records)", n) + } + } + return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil + case OpRecordCreate: + return x.recordCreate(req) + case OpRecordUpdate: + return x.recordUpdate(req) + case OpRecordDelete: + return x.recordDelete(req) + case OpRRSetReplace: + return x.rrsetReplace(req) + case OpViewCreate: + return x.viewCreate(req) + case OpViewUpdate, OpViewDelete: + v := x.st.Views[req.ID] + if v == nil { + return nil, errf(KindNotFound, "view %d does not exist", req.ID) + } + return []model.Op{x.versioned(req, v.Version, strings.TrimPrefix(req.Op, "view.")+" view "+v.Name)}, nil + case OpViewReorder: + var in ReorderInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + var names []string + for _, id := range in.IDs { + if v := x.st.Views[id]; v != nil { + names = append(names, v.Name) + } + } + return []model.Op{newOp(OpViewReorder, 0, 0, in, "reorder views: "+strings.Join(names, ", "))}, nil + case OpACLCreate: + return x.simpleCreate(req, OpACLCreate, func(data json.RawMessage) (string, error) { + var in ACLInput + if err := decode(data, &in); err != nil { + return "", err + } + if in.Name == nil { + return "", errf(KindBadRequest, "an ACL needs a name") + } + return "create ACL " + *in.Name, nil + }) + case OpACLUpdate, OpACLDelete: + a := x.st.ACLs[req.ID] + if a == nil { + return nil, errf(KindNotFound, "ACL %d does not exist", req.ID) + } + return []model.Op{x.versioned(req, a.Version, strings.TrimPrefix(req.Op, "acl.")+" ACL "+a.Name)}, nil + case OpTSIGCreate: + return x.tsigCreate(req) + case OpTSIGUpdate, OpTSIGDelete: + k := x.st.Keys[req.ID] + if k == nil { + return nil, errf(KindNotFound, "TSIG key %d does not exist", req.ID) + } + return []model.Op{x.versioned(req, k.Version, strings.TrimPrefix(req.Op, "tsig.")+" TSIG key "+k.Name)}, nil + case OpOptionCreate: + return x.optionCreate(req) + case OpOptionUpdate, OpOptionDelete: + o := x.st.Options[req.ID] + if o == nil { + return nil, errf(KindNotFound, "option %d does not exist", req.ID) + } + summary := strings.TrimPrefix(req.Op, "option.") + " " + optionLabel(x.st, o) + if req.Op == OpOptionUpdate { + var in OptionInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + if in.Value != nil { + summary += ": " + o.Value + " → " + *in.Value + } + } + return []model.Op{x.versioned(req, o.Version, summary)}, nil + case OpSettingsUpdate: + return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil + } + return nil, errf(KindBadRequest, "unknown operation %q", req.Op) +} + +func newOpRaw(kind string, id int64, base int, data json.RawMessage, summary string) model.Op { + return model.Op{Kind: kind, ID: id, BaseVersion: base, Data: data, Summary: summary} +} + +// versioned builds an update/delete operation, recording the object's +// version for conflict detection. +func (x *expander) versioned(req Request, current int, summary string) model.Op { + base := req.BaseVersion + if base == 0 { + base = current + } + return newOpRaw(req.Op, req.ID, base, req.Data, summary) +} + +func (x *expander) simpleCreate(req Request, kind string, describe func(json.RawMessage) (string, error)) ([]model.Op, error) { + summary, err := describe(req.Data) + if err != nil { + return nil, err + } + ids, err := x.ids(1) + if err != nil { + return nil, err + } + return []model.Op{newOpRaw(kind, ids[0], 0, req.Data, summary)}, nil +} + +func (x *expander) zoneCreate(req Request) ([]model.Op, error) { + var in ZoneInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + if in.Name == nil || strings.TrimSpace(*in.Name) == "" { + return nil, errf(KindBadRequest, "a zone needs a name") + } + name, err := ParseZoneName(*in.Name) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + in.Name = &name + if in.Type == nil { + in.Type = model.Ptr(model.ZonePrimary) + } + zt, err := normalizeZoneType(*in.Type) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + in.Type = &zt + if in.View != nil { + if *in.View != "" { + v := x.st.ViewByName(*in.View) + if v == nil { + return nil, errf(KindNotFound, "view %q does not exist", *in.View) + } + in.ViewID = &v.ID + } + in.View = nil + } + nameservers := in.Nameservers + records := in.Records + cloneFrom := in.CloneFrom + zoneOpts := in.Options + in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil + + set := x.st.Settings + var src *model.Zone + if cloneFrom != 0 { + src = x.st.Zones[cloneFrom] + if src == nil { + return nil, errf(KindNotFound, "zone %d (clone_from) does not exist", cloneFrom) + } + if src.Name != name { + return nil, errf(KindBadRequest, "clone_from must be a zone with the same name in another view (it is %s)", dnsname.Display(src.Name)) + } + if !x.p.Can(zoneAction(x.st, src, false)) || !x.p.Can(recordAction(x.st, src, "", "", false)) { + return nil, errf(KindForbidden, "permission denied: you can't read zone %s", zoneDesc(x.st, src)) + } + } + if zt == model.ZonePrimary { + if len(nameservers) == 0 && src == nil { + nameservers = set.Nameservers + } + if len(nameservers) == 0 && src == nil { + return nil, errf(KindBadRequest, "a primary zone needs at least one name server: give nameservers, or set default name servers in the settings") + } + if in.SOA == nil { + in.SOA = &SOAInput{} + } + s := in.SOA + if src != nil { + // Copy the source zone's SOA and TTL unless overridden. + if s.MName == nil { + s.MName = model.Ptr(src.SOA.MName) + } + if s.RName == nil { + s.RName = model.Ptr(src.SOA.RName) + } + if s.Refresh == nil { + s.Refresh = model.Ptr(src.SOA.Refresh) + } + if s.Retry == nil { + s.Retry = model.Ptr(src.SOA.Retry) + } + if s.Expire == nil { + s.Expire = model.Ptr(src.SOA.Expire) + } + if s.Minimum == nil { + s.Minimum = model.Ptr(src.SOA.Minimum) + } + if in.DefaultTTL == nil { + in.DefaultTTL = model.Ptr(src.DefaultTTL) + } + } + if s.MName == nil { + first, err := dnsname.Owner(nameservers[0], name) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + s.MName = &first + } + if s.RName == nil { + if set.RName != "" { + s.RName = model.Ptr(set.RName) + } else { + s.RName = model.Ptr("hostmaster." + name) + } + } + if s.Refresh == nil { + s.Refresh = model.Ptr(set.Refresh) + } + if s.Retry == nil { + s.Retry = model.Ptr(set.Retry) + } + if s.Expire == nil { + s.Expire = model.Ptr(set.Expire) + } + if s.Minimum == nil { + s.Minimum = model.Ptr(set.NegativeTTL) + } + if in.DefaultTTL == nil { + in.DefaultTTL = model.Ptr(set.DefaultTTL) + } + if s.Serial == nil { + policy := set.SerialPolicy + if in.SerialPolicy != nil && *in.SerialPolicy != "" { + policy = *in.SerialPolicy + } + serial := InitialSerial(policy, x.now) + if src != nil && serialGreater(src.SOA.Serial, serial) { + serial = src.SOA.Serial + } + s.Serial = &serial + } + } else if len(nameservers) > 0 || len(records) > 0 || src != nil { + return nil, errf(KindBadRequest, "nameservers, records and clone_from only apply to primary zones") + } + + type recSpec struct { + in RecordInput + summary string + } + var recs []recSpec + seen := map[string]bool{} + addRec := func(ri RecordInput) error { + tmp := &model.Zone{Name: name, Type: zt} + owner, p, err := parseRecord(tmp, deref(ri.Name), deref(ri.Type), deref(ri.Data)) + if err != nil { + return err + } + k := owner + "|" + p.Type + "|" + strings.ToLower(p.Data) + if seen[k] { + return nil + } + seen[k] = true + ri.Name, ri.Type, ri.Data = &owner, &p.Type, &p.Data + recs = append(recs, recSpec{ri, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(ri.TTL), p.Type, p.Data)}) + return nil + } + for _, ns := range nameservers { + target, err := dnsname.Owner(ns, name) + if err != nil { + return nil, errf(KindBadRequest, "name server %q: %v", ns, err) + } + if err := addRec(RecordInput{Name: model.Ptr("@"), Type: model.Ptr("NS"), Data: model.Ptr(target)}); err != nil { + return nil, err + } + } + if src != nil { + for _, r := range x.st.ZoneRecords(src.ID) { + ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)} + if r.TTL != nil { + ri.TTL = model.Some(*r.TTL) + } else { + ri.TTL = model.Opt[uint32]{Set: true, Null: true} + } + if err := addRec(ri); err != nil { + return nil, err + } + } + } + for _, ri := range records { + ri.ZoneID, ri.Zone, ri.View = 0, "", "" + if err := addRec(ri); err != nil { + return nil, err + } + } + ids, err := x.ids(1 + len(recs) + len(zoneOpts)) + if err != nil { + return nil, err + } + summary := fmt.Sprintf("create %s zone %s", zt, dnsname.Display(name)) + if in.ViewID != nil && *in.ViewID != 0 { + if v := x.st.Views[*in.ViewID]; v != nil { + summary += " in view " + v.Name + } + } + if src != nil { + summary += " (copy of " + zoneDesc(x.st, src) + ")" + } + ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)} + for i, zo := range zoneOpts { + oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)} + if zo.Comment != "" { + oi.Comment = model.Ptr(zo.Comment) + } + ops = append(ops, newOp(OpOptionCreate, ids[1+len(recs)+i], 0, oi, fmt.Sprintf("set zone %s option %s %s", dnsname.Display(name), zo.Name, zo.Value))) + } + for i, r := range recs { + r.in.ZoneID = ids[0] + ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary)) + } + return ops, nil +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +// reverseZoneFor finds the primary reverse zone for addr in a view. +func reverseZoneFor(st *model.State, viewID int64, addr netip.Addr) (*model.Zone, string) { + name := dnsname.ReverseName(addr) + var best *model.Zone + for _, z := range st.ZonesInView(viewID) { + if z.Type == model.ZonePrimary && dnsname.IsSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) { + best = z + } + } + return best, name +} + +func addrOf(p *rr.Parsed) (netip.Addr, bool) { + switch v := p.RR.(type) { + case *dns.A: + a, ok := netip.AddrFromSlice(v.A.To4()) + return a, ok + case *dns.AAAA: + a, ok := netip.AddrFromSlice(v.AAAA) + return a, ok + } + return netip.Addr{}, false +} + +// ptrOps returns operations creating the PTR record for an address record. +func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) { + addr, ok := addrOf(p) + if !ok { + return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records") + } + rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr) + if rz == nil { + x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr)) + return nil, nil + } + for _, r := range x.st.ZoneRecords(rz.ID) { + if r.Name == ptrName && r.Type == "PTR" { + if strings.EqualFold(r.Data, owner) { + return nil, nil // already there + } + x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data)) + } + } + ids, err := x.ids(1) + if err != nil { + return nil, err + } + in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)} + return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil +} + +func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op { + addr, ok := addrOf(p) + if !ok { + return nil + } + rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr) + if rz == nil { + return nil + } + in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true} + return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))} +} + +func (x *expander) recordCreate(req Request) ([]model.Op, error) { + var in RecordInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + z, err := x.resolveZone(in.ZoneID, in.Zone, in.View) + if err != nil { + return nil, err + } + if in.Name == nil || in.Type == nil || in.Data == nil { + return nil, errf(KindBadRequest, "a record needs a name, a type and data") + } + owner, p, err := parseRecord(z, *in.Name, *in.Type, *in.Data) + if err != nil { + return nil, err + } + ptr := in.PTR + in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false + in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data + ids, err := x.ids(1) + if err != nil { + return nil, err + } + ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))} + if ptr { + more, err := x.ptrCreate(z, owner, p) + if err != nil { + return nil, err + } + ops = append(ops, more...) + } + return ops, nil +} + +func (x *expander) recordUpdate(req Request) ([]model.Op, error) { + r := x.st.Records[req.ID] + if r == nil { + return nil, errf(KindNotFound, "record %d does not exist", req.ID) + } + z := x.st.Zones[r.ZoneID] + var in RecordInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + ptr := in.PTR + in.PTR = false + in.Zone, in.View = "", "" + name, typ, data := r.Name, r.Type, r.Data + if in.Name != nil { + name = *in.Name + } + if in.Type != nil { + typ = *in.Type + } + if in.Data != nil { + data = *in.Data + } + owner, p, err := parseRecord(z, name, typ, data) + if err != nil { + return nil, err + } + if in.Name != nil { + in.Name = &owner + } + if in.Type != nil { + in.Type = &p.Type + } + if in.Data != nil { + in.Data = &p.Data + } + var changes []string + if owner != r.Name { + changes = append(changes, "name "+r.Name+" → "+owner) + } + if p.Type != r.Type { + changes = append(changes, "type "+r.Type+" → "+p.Type) + } + if p.Data != r.Data { + changes = append(changes, "data "+r.Data+" → "+p.Data) + } + if in.TTL.Set { + old := "default" + if r.TTL != nil { + old = fmt.Sprint(*r.TTL) + } + nw := "default" + if !in.TTL.Null { + nw = fmt.Sprint(in.TTL.Value) + } + if old != nw { + changes = append(changes, "ttl "+old+" → "+nw) + } + } + if in.Comment != nil && *in.Comment != r.Comment { + changes = append(changes, "comment") + } + summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data) + if len(changes) > 0 { + summary += ": " + strings.Join(changes, ", ") + } + base := req.BaseVersion + if base == 0 { + base = r.Version + } + ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)} + if ptr && (p.Data != r.Data || owner != r.Name) { + if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { + ops = append(ops, x.ptrDelete(z, r.Name, old)...) + } + more, err := x.ptrCreate(z, owner, p) + if err != nil { + return nil, err + } + ops = append(ops, more...) + } + return ops, nil +} + +func (x *expander) recordDelete(req Request) ([]model.Op, error) { + var in RecordInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + if req.ID != 0 { + r := x.st.Records[req.ID] + if r == nil { + if in.MissingOK { + return nil, nil + } + return nil, errf(KindNotFound, "record %d does not exist", req.ID) + } + base := req.BaseVersion + if base == 0 { + base = r.Version + } + ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))} + if in.PTR { + z := x.st.Zones[r.ZoneID] + if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { + ops = append(ops, x.ptrDelete(z, r.Name, p)...) + } + } + return ops, nil + } + z, err := x.resolveZone(in.ZoneID, in.Zone, in.View) + if err != nil { + return nil, err + } + if in.Name == nil || in.Type == nil { + return nil, errf(KindBadRequest, "deleting by match needs a name and a type (or give the record id)") + } + owner, err := dnsname.Owner(*in.Name, z.Name) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + typ, _, err := rr.NormalizeType(*in.Type) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + what := owner + " " + typ + var parsed *rr.Parsed + if in.Data != nil && strings.TrimSpace(*in.Data) != "" { + _, p, err := parseRecord(z, owner, typ, *in.Data) + if err != nil { + return nil, err + } + in.Data = &p.Data + what += " " + p.Data + parsed = p + } else { + in.Data = nil + what += " (all)" + } + if in.MissingOK { + what += " (if present)" + } + ptr := in.PTR + in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false + in.Name, in.Type = &owner, &typ + ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)} + if ptr && parsed != nil { + ops = append(ops, x.ptrDelete(z, owner, parsed)...) + } + return ops, nil +} + +func (x *expander) rrsetReplace(req Request) ([]model.Op, error) { + var in RRSetInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + z, err := x.resolveZone(in.ZoneID, in.Zone, in.View) + if err != nil { + return nil, err + } + owner, err := dnsname.Owner(in.Name, z.Name) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + typ, _, err := rr.NormalizeType(in.Type) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + var canon []string + for _, d := range in.Data { + _, p, err := parseRecord(z, owner, typ, d) + if err != nil { + return nil, err + } + canon = append(canon, p.Data) + } + in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon + if len(canon) > 0 { + in.NewIDs, err = x.ids(len(canon)) + if err != nil { + return nil, err + } + } + summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", ")) + if len(canon) == 0 { + summary = fmt.Sprintf("delete all %s records of %s", typ, owner) + } + return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil +} + +func (x *expander) viewCreate(req Request) ([]model.Op, error) { + var in ViewInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + if in.Name == nil || strings.TrimSpace(*in.Name) == "" { + return nil, errf(KindBadRequest, "a view needs a name") + } + adopt := in.AdoptZones + in.AdoptZones = false + ids, err := x.ids(1) + if err != nil { + return nil, err + } + ops := []model.Op{newOp(OpViewCreate, ids[0], 0, in, "create view "+*in.Name)} + if adopt { + for _, z := range x.st.ZonesInView(0) { + ops = append(ops, newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{ViewID: model.Ptr(ids[0])}, "move zone "+dnsname.Display(z.Name)+" into view "+*in.Name)) + } + } + return ops, nil +} + +func (x *expander) tsigCreate(req Request) ([]model.Op, error) { + var in TSIGInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + if in.Name == nil { + return nil, errf(KindBadRequest, "a TSIG key needs a name") + } + alg := "hmac-sha256" + if in.Algorithm != nil { + alg = strings.ToLower(*in.Algorithm) + } + in.Algorithm = &alg + if in.Secret == nil || *in.Secret == "" { + n := validate.TSIGAlgorithms[alg] + if n == 0 { + return nil, errf(KindBadRequest, "unsupported TSIG algorithm %q", alg) + } + in.Secret = model.Ptr(auth.GenerateTSIGSecret(n)) + } + ids, err := x.ids(1) + if err != nil { + return nil, err + } + return []model.Op{newOp(OpTSIGCreate, ids[0], 0, in, "create TSIG key "+model.NormalizeKeyName(*in.Name)+" ("+alg+")")}, nil +} + +func (x *expander) optionCreate(req Request) ([]model.Op, error) { + var in OptionInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + if in.Scope == nil || in.Name == nil { + return nil, errf(KindBadRequest, "an option needs a scope and a name") + } + scopeID := int64(0) + if in.ScopeID != nil { + scopeID = *in.ScopeID + } + val := "" + if in.Value != nil { + val = *in.Value + } + n, v, err := NormalizeOption(x.st, *in.Scope, scopeID, *in.Name, val) + if err != nil { + return nil, err + } + in.Name, in.Value = &n, &v + ids, err := x.ids(1) + if err != nil { + return nil, err + } + o := &model.Option{Scope: *in.Scope, ScopeID: scopeID, Name: n} + return []model.Op{newOp(OpOptionCreate, ids[0], 0, in, "set "+optionLabel(x.st, o)+" "+v)}, nil +} + +// RedactOps hides TSIG secrets in operation data. +func RedactOps(ops []model.Op) []model.Op { + out := make([]model.Op, len(ops)) + for i, op := range ops { + out[i] = op + if op.Kind != OpTSIGCreate && op.Kind != OpTSIGUpdate || len(op.Data) == 0 { + continue + } + var m map[string]any + if json.Unmarshal(op.Data, &m) == nil { + if _, ok := m["secret"]; ok { + m["secret"] = "(hidden)" + out[i].Data = mustJSON(m) + } + } + } + return out +} diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go new file mode 100644 index 0000000..7e96c96 --- /dev/null +++ b/internal/engine/inputs.go @@ -0,0 +1,203 @@ +package engine + +import ( + "encoding/json" + + "jarl/internal/model" +) + +// Operation kinds. +const ( + OpZoneCreate = "zone.create" + OpZoneUpdate = "zone.update" + OpZoneDelete = "zone.delete" + OpRecordCreate = "record.create" + OpRecordUpdate = "record.update" + OpRecordDelete = "record.delete" + OpRRSetReplace = "rrset.replace" + OpViewCreate = "view.create" + OpViewUpdate = "view.update" + OpViewDelete = "view.delete" + OpViewReorder = "view.reorder" + OpACLCreate = "acl.create" + OpACLUpdate = "acl.update" + OpACLDelete = "acl.delete" + OpTSIGCreate = "tsig.create" + OpTSIGUpdate = "tsig.update" + OpTSIGDelete = "tsig.delete" + OpOptionCreate = "option.create" + OpOptionUpdate = "option.update" + OpOptionDelete = "option.delete" + OpSettingsUpdate = "settings.update" +) + +// OpKinds lists all operation kinds. +var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, + OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete, + OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate} + +// Request is an operation as submitted by a client. Requests are expanded +// into one or more stored operations (for example an A record with "ptr" +// also creates the matching PTR record). +type Request struct { + Op string `json:"op"` + // ID is the object to update or delete. + ID int64 `json:"id,omitempty"` + // BaseVersion, if set, makes the request fail when the object's + // version differs (optimistic locking). Otherwise the version at + // staging time is recorded. + BaseVersion int `json:"base_version,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// NewRequest builds a request with data marshaled from an input struct. +func NewRequest(op string, id int64, data any) Request { + r := Request{Op: op, ID: id} + if data != nil { + b, err := json.Marshal(data) + if err != nil { + panic(err) + } + r.Data = b + } + return r +} + +// SOAInput sets SOA fields of a primary zone. +type SOAInput struct { + MName *string `json:"mname,omitempty"` + RName *string `json:"rname,omitempty"` // mailbox name or email address + Serial *uint32 `json:"serial,omitempty"` + Refresh *uint32 `json:"refresh,omitempty"` + Retry *uint32 `json:"retry,omitempty"` + Expire *uint32 `json:"expire,omitempty"` + Minimum *uint32 `json:"minimum,omitempty"` +} + +// ZoneInput creates or updates a zone. +type ZoneInput struct { + // Name is the zone name. For create, a CIDR prefix such as + // "192.168.1.0/24" creates the matching reverse zone. + Name *string `json:"name,omitempty"` + ViewID *int64 `json:"view_id,omitempty"` + View *string `json:"view,omitempty"` // view name, alternative to view_id + Type *string `json:"type,omitempty"` + InView *string `json:"in_view,omitempty"` + File *string `json:"file,omitempty"` + SOA *SOAInput `json:"soa,omitempty"` + DefaultTTL *uint32 `json:"default_ttl,omitempty"` + SerialPolicy *string `json:"serial_policy,omitempty"` + Comment *string `json:"comment,omitempty"` + + // Create only: name servers for the apex NS records (default: the + // configured default name servers), additional records, and a zone to + // copy records from. + Nameservers []string `json:"nameservers,omitempty"` + Records []RecordInput `json:"records,omitempty"` + CloneFrom int64 `json:"clone_from,omitempty"` + // Options (create only) are zone options to set, e.g. primaries for a + // secondary zone or forwarders for a forward zone. + Options []ZoneOption `json:"options,omitempty"` +} + +// ZoneOption is an option given when creating a zone. +type ZoneOption struct { + Name string `json:"name"` + Value string `json:"value"` + Comment string `json:"comment,omitempty"` +} + +// RecordInput creates, updates or (by match) deletes a record. +type RecordInput struct { + ZoneID int64 `json:"zone_id,omitempty"` + Zone string `json:"zone,omitempty"` // zone name, alternative to zone_id + View string `json:"view,omitempty"` // view of Zone + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + TTL model.Opt[uint32] `json:"ttl,omitzero"` // null: use the zone default + Data *string `json:"data,omitempty"` + Comment *string `json:"comment,omitempty"` + // PTR also creates (or deletes) the matching PTR record in the + // reverse zone, for A and AAAA records. + PTR bool `json:"ptr,omitempty"` + // MissingOK makes a delete-by-match succeed when nothing matches. + MissingOK bool `json:"missing_ok,omitempty"` +} + +// RRSetInput replaces all records of one name and type. +type RRSetInput struct { + ZoneID int64 `json:"zone_id,omitempty"` + Zone string `json:"zone,omitempty"` + View string `json:"view,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + TTL model.Opt[uint32] `json:"ttl,omitzero"` + Data []string `json:"data"` + Comment *string `json:"comment,omitempty"` + // NewIDs are allocated for records that need to be created. + NewIDs []int64 `json:"new_ids,omitempty"` +} + +// ViewInput creates or updates a view. +type ViewInput struct { + Name *string `json:"name,omitempty"` + MatchClients *[]string `json:"match_clients,omitempty"` + MatchDestinations *[]string `json:"match_destinations,omitempty"` + MatchRecursiveOnly model.Opt[bool] `json:"match_recursive_only,omitzero"` + Comment *string `json:"comment,omitempty"` + Position *int `json:"position,omitempty"` + // AdoptZones (create) moves all zones that are not in a view into the + // new view; named requires every zone to be in a view once views + // are used. + AdoptZones bool `json:"adopt_zones,omitempty"` + // Cascade (delete) also deletes the view's zones. + Cascade bool `json:"cascade,omitempty"` +} + +// ReorderInput sets the order of views. +type ReorderInput struct { + IDs []int64 `json:"ids"` +} + +// ACLInput creates or updates an ACL. +type ACLInput struct { + Name *string `json:"name,omitempty"` + Elements *[]string `json:"elements,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +// TSIGInput creates or updates a TSIG key. A missing secret on create is +// generated. +type TSIGInput struct { + Name *string `json:"name,omitempty"` + Algorithm *string `json:"algorithm,omitempty"` + Secret *string `json:"secret,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +// OptionInput creates or updates an option. +type OptionInput struct { + Scope *string `json:"scope,omitempty"` // options, view, zone, toplevel + ScopeID *int64 `json:"scope_id,omitempty"` // view or zone ID + Name *string `json:"name,omitempty"` + Value *string `json:"value,omitempty"` + Comment *string `json:"comment,omitempty"` + Position *int `json:"position,omitempty"` +} + +// SettingsInput updates Jarl's settings. +type SettingsInput struct { + DefaultTTL *uint32 `json:"default_ttl,omitempty"` + Nameservers *[]string `json:"nameservers,omitempty"` + RName *string `json:"rname,omitempty"` + Refresh *uint32 `json:"refresh,omitempty"` + Retry *uint32 `json:"retry,omitempty"` + Expire *uint32 `json:"expire,omitempty"` + NegativeTTL *uint32 `json:"negative_ttl,omitempty"` + SerialPolicy *string `json:"serial_policy,omitempty"` +} + +// DeleteInput carries options for delete operations. +type DeleteInput struct { + Cascade bool `json:"cascade,omitempty"` +} diff --git a/internal/engine/integration_test.go b/internal/engine/integration_test.go new file mode 100644 index 0000000..0689dbf --- /dev/null +++ b/internal/engine/integration_test.go @@ -0,0 +1,234 @@ +package engine + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "jarl/internal/auth" + "jarl/internal/bind" + "jarl/internal/model" + "jarl/internal/render" + "jarl/internal/store" + "jarl/internal/testutil" +) + +func freePort(t *testing.T) int { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// namedEnv runs a real named on high ports, loading Jarl's configuration. +type namedEnv struct { + *testEnv + bins *testutil.BindBinaries + dnsPort int + cmd *exec.Cmd + log *bytes.Buffer +} + +func newNamedEnv(t *testing.T) *namedEnv { + bins := testutil.FindBind() + if bins == nil || bins.Named == "" || bins.RNDC == "" || bins.Dig == "" { + t.Skip("named, rndc and dig are needed (install bind9 or set JARL_BIND_ROOT)") + } + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "jarl.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + keyFile := filepath.Join(dir, "rndc.key") + out, err := runEnv(bins.Env, bins.TSIGKeygen, "-a", "hmac-sha256", "rndc-key") + if err != nil { + t.Fatal(err) + } + os.WriteFile(keyFile, []byte(out), 0o600) + dnsPort, ctlPort := freePort(t), freePort(t) + paths := render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} + tools := &bind.Tools{NamedCheckConf: bins.CheckConf, NamedCheckZone: bins.CheckZone, RNDC: bins.RNDC, Env: bins.Env, + RNDCArgs: []string{"-k", keyFile, "-s", "127.0.0.1", "-p", fmt.Sprint(ctlPort)}} + cfg := Config{Paths: paths, StagingDir: filepath.Join(dir, "staging"), ReloadTimeout: 10 * time.Second, NoReload: true} + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + e := New(st, tools, cfg, quiet) + admin := &model.User{Username: "admin", Admin: true} + st.CreateUser(context.Background(), admin) + env := &namedEnv{testEnv: &testEnv{t: t, ctx: context.Background(), e: e, st: st, admin: &auth.Principal{User: admin, Method: "local"}, dir: dir}, bins: bins, dnsPort: dnsPort} + run := filepath.Join(dir, "run") + os.MkdirAll(run, 0o755) + opt := func(scope, name, value string) Request { + return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, Name: &name, Value: &value}) + } + // Bootstrap the configuration without named running. + env.submit(env.admin, + NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.test."}}), + opt(model.ScopeTopLevel, "include", `"`+keyFile+`"`), + opt(model.ScopeTopLevel, "controls", fmt.Sprintf("{ inet 127.0.0.1 port %d allow { 127.0.0.1; } keys { rndc-key; }; }", ctlPort)), + opt(model.ScopeOptions, "directory", `"`+run+`"`), + opt(model.ScopeOptions, "pid-file", `"`+filepath.Join(run, "named.pid")+`"`), + opt(model.ScopeOptions, "session-keyfile", `"`+filepath.Join(run, "session.key")+`"`), + opt(model.ScopeOptions, "listen-on", fmt.Sprintf("port %d { 127.0.0.1; }", dnsPort)), + opt(model.ScopeOptions, "listen-on-v6", "{ none; }"), + opt(model.ScopeOptions, "recursion", "no"), + NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.test")}), + rec("example.test", "ns1", "A", "127.0.0.1"), + rec("example.test", "www", "A", "192.0.2.10"), + ) + env.log = &bytes.Buffer{} + env.cmd = exec.Command(bins.Named, "-c", paths.MainConfig(), "-g", "-n", "1") + env.cmd.Env = append(os.Environ(), bins.Env...) + env.cmd.Stdout, env.cmd.Stderr = env.log, env.log + if err := env.cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + env.cmd.Process.Kill() + env.cmd.Wait() + if t.Failed() { + t.Logf("named log:\n%s", env.log.String()) + } + }) + deadline := time.Now().Add(10 * time.Second) + for { + if _, err := tools.Status(context.Background()); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("named did not start:\n%s", env.log.String()) + } + time.Sleep(100 * time.Millisecond) + } + // From now on, reload named on every change. + e.cfg.NoReload = false + return env +} + +func runEnv(env []string, bin string, args ...string) (string, error) { + cmd := exec.Command(bin, args...) + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (n *namedEnv) dig(name, typ string) string { + out, err := runEnv(n.bins.Env, n.bins.Dig, "+short", "+norec", "-p", fmt.Sprint(n.dnsPort), "@127.0.0.1", name, typ) + if err != nil { + n.t.Fatalf("dig %s %s: %v %s", name, typ, err, out) + } + return strings.TrimSpace(out) +} + +func TestWithRealNamed(t *testing.T) { + n := newNamedEnv(t) + if got := n.dig("www.example.test", "A"); got != "192.0.2.10" { + t.Fatalf("initial www = %q", got) + } + // Change a record: named serves it as soon as Apply returns. + r := findRec(n.state(), "www.example.test.", "A") + res := n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.11")})) + if got := n.dig("www.example.test", "A"); got != "192.0.2.11" { + t.Errorf("www after update = %q (reloaded %v)", got, res.Reloaded) + } + if len(res.Serials) != 1 || res.Serials[0].New != res.Serials[0].Old+1 { + t.Errorf("serials = %+v", res.Serials) + } + // A new zone is loaded via reconfig. + n.submit(n.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("second.test"), Nameservers: []string{"ns1.example.test."}}), + rec("second.test", "@", "TXT", "hello from jarl")) + if got := n.dig("second.test", "TXT"); got != `"hello from jarl"` { + t.Errorf("second.test TXT = %q", got) + } + // ACME-style challenge add and cleanup. + n.submit(n.admin, rec("example.test", "_acme-challenge", "TXT", "abc-123")) + if got := n.dig("_acme-challenge.example.test", "TXT"); got != `"abc-123"` { + t.Errorf("challenge = %q", got) + } + n.submit(n.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.test", Name: model.Ptr("_acme-challenge"), Type: model.Ptr("TXT")})) + if got := n.dig("_acme-challenge.example.test", "TXT"); got != "" { + t.Errorf("challenge after cleanup = %q", got) + } + // Deleting a zone removes it from named and its file from disk. + z := n.state().ZonesNamed("second.test")[0] + n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil)) + if got := n.dig("second.test", "TXT"); got != "" { + t.Errorf("deleted zone still served: %q", got) + } + if _, err := os.Stat(filepath.Join(n.dir, "zones/_default/second.test.zone")); !os.IsNotExist(err) { + t.Errorf("zone file of deleted zone still exists: %v", err) + } + st, err := n.e.Status(n.ctx) + if err != nil || st.Named == nil || !st.Named.Running || len(st.Drift) != 0 { + t.Errorf("status = %+v %v", st, err) + } +} + +func TestRollbackOnReloadFailure(t *testing.T) { + n := newNamedEnv(t) + // Make rndc fail for "reconfig" to simulate named rejecting the change. + wrapper := filepath.Join(n.dir, "rndc-wrapper") + os.WriteFile(wrapper, []byte(fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = reconfig ] && { echo 'rndc: reconfig failed: simulated' >&2; exit 1; }; done\nexec %s \"$@\"\n", n.bins.RNDC)), 0o755) + n.e.tools.RNDC = wrapper + confBefore, _ := os.ReadFile(n.e.cfg.Paths.MainConfig()) + _, err := n.e.Submit(n.ctx, []Request{NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("broken.test"), Nameservers: []string{"ns1.example.test."}})}, n.admin, "test", "", ApplyOptions{}) + ee, ok := AsError(err) + if !ok || ee.Kind != KindBIND || !strings.Contains(ee.Message, "restored") { + t.Fatalf("error = %v", err) + } + confAfter, _ := os.ReadFile(n.e.cfg.Paths.MainConfig()) + if !bytes.Equal(confBefore, confAfter) { + t.Error("named.conf was not restored") + } + if _, err := os.Stat(filepath.Join(n.dir, "zones/_default/broken.test.zone")); !os.IsNotExist(err) { + t.Error("zone file of failed change left behind") + } + if len(n.state().ZonesNamed("broken.test")) != 0 { + t.Error("failed change recorded in the database") + } + // named keeps serving the old data and Jarl keeps working. + n.e.tools.RNDC = n.bins.RNDC + if got := n.dig("www.example.test", "A"); got != "192.0.2.10" { + t.Errorf("www = %q", got) + } + n.submit(n.admin, rec("example.test", "after", "A", "192.0.2.99")) + if got := n.dig("after.example.test", "A"); got != "192.0.2.99" { + t.Errorf("after = %q", got) + } + evs, _ := n.st.ListAudit(n.ctx, store.AuditFilter{Outcome: "failure"}) + if len(evs) == 0 || !strings.Contains(evs[0].Message, "simulated") { + t.Errorf("failure not audited: %+v", evs) + } +} + +func TestNamedCheckconfRejects(t *testing.T) { + n := newNamedEnv(t) + // An option value Jarl's quick checks accept but named rejects. + name, value := "max-cache-size", "lots" + scope := model.ScopeOptions + _, err := n.e.Submit(n.ctx, []Request{NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, Name: &name, Value: &value})}, n.admin, "test", "", ApplyOptions{}) + ee, ok := AsError(err) + if !ok || ee.Kind != KindValidation || !strings.Contains(ee.Message, "named-checkconf") { + t.Fatalf("error = %v", err) + } + found := false + for _, m := range ee.Check { + if m.Error && strings.Contains(m.Object, "server option max-cache-size") { + found = true + } + } + if !found { + t.Errorf("checkconf message not mapped to the option: %+v", ee.Check) + } +} diff --git a/internal/engine/ops.go b/internal/engine/ops.go new file mode 100644 index 0000000..6cbb5be --- /dev/null +++ b/internal/engine/ops.go @@ -0,0 +1,1241 @@ +package engine + +import ( + "bytes" + "encoding/json" + "fmt" + "net/netip" + "strings" + "time" + + "jarl/internal/auth" + "jarl/internal/catalog" + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/namedconf" + "jarl/internal/rr" + "jarl/internal/validate" +) + +const maxTTL = 2147483647 + +// applyCtx carries what applying an operation needs. +type applyCtx struct { + p *auth.Principal + now time.Time + warnings []string +} + +func (c *applyCtx) user() string { return c.p.Name() } + +func (c *applyCtx) require(a auth.Action) error { + if c.p.Can(a) { + return nil + } + return errf(KindForbidden, "permission denied: this needs %s", a) +} + +func (c *applyCtx) warn(format string, args ...any) { + c.warnings = append(c.warnings, fmt.Sprintf(format, args...)) +} + +func decode(data json.RawMessage, v any) error { + if len(data) == 0 { + return nil + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return errf(KindBadRequest, "invalid operation data: %v", err) + } + return nil +} + +func checkVersion(op model.Op, current int, what string) error { + if op.BaseVersion != 0 && op.BaseVersion != current { + return errf(KindConflict, "%s was changed by someone else after this change was staged (version %d, now %d); review it and stage the change again", what, op.BaseVersion, current) + } + return nil +} + +// applyOp applies one stored operation to st. +func applyOp(st *model.State, op model.Op, c *applyCtx) error { + var err error + switch op.Kind { + case OpZoneCreate: + err = applyZoneCreate(st, op, c) + case OpZoneUpdate: + err = applyZoneUpdate(st, op, c) + case OpZoneDelete: + err = applyZoneDelete(st, op, c) + case OpRecordCreate: + err = applyRecordCreate(st, op, c) + case OpRecordUpdate: + err = applyRecordUpdate(st, op, c) + case OpRecordDelete: + err = applyRecordDelete(st, op, c) + case OpRRSetReplace: + err = applyRRSetReplace(st, op, c) + case OpViewCreate: + err = applyViewCreate(st, op, c) + case OpViewUpdate: + err = applyViewUpdate(st, op, c) + case OpViewDelete: + err = applyViewDelete(st, op, c) + case OpViewReorder: + err = applyViewReorder(st, op, c) + case OpACLCreate, OpACLUpdate: + err = applyACLPut(st, op, c) + case OpACLDelete: + err = applyACLDelete(st, op, c) + case OpTSIGCreate, OpTSIGUpdate: + err = applyTSIGPut(st, op, c) + case OpTSIGDelete: + err = applyTSIGDelete(st, op, c) + case OpOptionCreate, OpOptionUpdate: + err = applyOptionPut(st, op, c) + case OpOptionDelete: + err = applyOptionDelete(st, op, c) + case OpSettingsUpdate: + err = applySettings(st, op, c) + default: + err = errf(KindBadRequest, "unknown operation %q", op.Kind) + } + if e, ok := AsError(err); ok && e.OpSeq == 0 { + e.OpSeq = op.Seq + } + return err +} + +// ---- zones + +func zoneAction(st *model.State, z *model.Zone, write bool) auth.Action { + return auth.Action{Resource: auth.ResZones, Write: write, Zone: z.Name, View: st.ViewName(z)} +} + +// ParseZoneName accepts a zone name or a CIDR prefix (for reverse zones). +func ParseZoneName(s string) (string, error) { + s = strings.TrimSpace(s) + if strings.Contains(s, "/") { + if p, err := netip.ParsePrefix(s); err == nil { + return dnsname.ReverseZone(p) + } + } + a, err := dnsname.ToASCII(s) + if err != nil { + return "", err + } + name := dnsname.Fqdn(a) + if err := dnsname.CheckZoneName(name); err != nil { + return "", fmt.Errorf("invalid zone name %q: %v", s, err) + } + return name, nil +} + +func normalizeZoneType(t string) (string, error) { + t = strings.ToLower(strings.TrimSpace(t)) + if s, ok := catalog.ZoneTypeSynonyms[t]; ok { + t = s + } + for _, zt := range model.ZoneTypes { + if t == zt { + return t, nil + } + } + return "", fmt.Errorf("unknown zone type %q (use one of %s)", t, strings.Join(model.ZoneTypes, ", ")) +} + +func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool) error { + if in.Name != nil { + name, err := ParseZoneName(*in.Name) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + if !creating && name != z.Name { + return errf(KindBadRequest, "zones can't be renamed; create a new zone (it can copy the records with clone_from) and delete the old one") + } + z.Name = name + } + if in.ViewID != nil { + if *in.ViewID != 0 && st.Views[*in.ViewID] == nil { + return errf(KindNotFound, "view %d does not exist", *in.ViewID) + } + z.ViewID = *in.ViewID + } else if in.View != nil { + if *in.View == "" { + z.ViewID = 0 + } else if v := st.ViewByName(*in.View); v != nil { + z.ViewID = v.ID + } else { + return errf(KindNotFound, "view %q does not exist", *in.View) + } + } + if in.Type != nil { + t, err := normalizeZoneType(*in.Type) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + z.Type = t + } + if in.InView != nil { + z.InView = strings.TrimSpace(*in.InView) + } + if in.File != nil { + z.File = strings.TrimSpace(*in.File) + } + if s := in.SOA; s != nil { + if s.MName != nil { + n, err := dnsname.Owner(*s.MName, z.Name) + if err != nil { + return errf(KindBadRequest, "SOA mname: %v", err) + } + z.SOA.MName = n + } + if s.RName != nil { + mb, err := dnsname.MailboxFromEmail(*s.RName) + if err != nil { + return errf(KindBadRequest, "SOA rname: %v", err) + } + z.SOA.RName = mb + } + if s.Serial != nil { + z.SOA.Serial = *s.Serial + } + set := func(dst *uint32, v *uint32, name string) error { + if v == nil { + return nil + } + if *v > maxTTL { + return errf(KindBadRequest, "SOA %s must be at most 2147483647", name) + } + *dst = *v + return nil + } + for _, f := range []struct { + dst *uint32 + v *uint32 + name string + }{{&z.SOA.Refresh, s.Refresh, "refresh"}, {&z.SOA.Retry, s.Retry, "retry"}, {&z.SOA.Expire, s.Expire, "expire"}, {&z.SOA.Minimum, s.Minimum, "minimum"}} { + if err := set(f.dst, f.v, f.name); err != nil { + return err + } + } + } + if in.DefaultTTL != nil { + if *in.DefaultTTL > maxTTL { + return errf(KindBadRequest, "default TTL must be at most 2147483647") + } + z.DefaultTTL = *in.DefaultTTL + } + if in.SerialPolicy != nil { + z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy)) + } + if in.Comment != nil { + z.Comment = *in.Comment + } + return nil +} + +func applyZoneCreate(st *model.State, op model.Op, c *applyCtx) error { + var in ZoneInput + if err := decode(op.Data, &in); err != nil { + return err + } + if op.ID == 0 || st.Zones[op.ID] != nil { + return errf(KindConflict, "zone ID %d is already in use", op.ID) + } + if in.Name == nil || in.Type == nil { + return errf(KindBadRequest, "a new zone needs a name and a type") + } + z := &model.Zone{ID: op.ID} + if err := setZoneFields(st, z, &in, true); err != nil { + return err + } + if err := c.require(zoneAction(st, z, true)); err != nil { + return err + } + if existing := st.FindZone(z.Name, z.ViewID); existing != nil { + where := "" + if v := st.ViewName(z); v != "" { + where = " in view " + v + } + return errf(KindConflict, "zone %s already exists%s", dnsname.Display(z.Name), where) + } + z.Touch(c.user(), c.now) + st.Zones[z.ID] = z + return nil +} + +func applyZoneUpdate(st *model.State, op model.Op, c *applyCtx) error { + z := st.Zones[op.ID] + if z == nil { + return errf(KindNotFound, "zone %d does not exist", op.ID) + } + if err := checkVersion(op, z.Version, "zone "+dnsname.Display(z.Name)); err != nil { + return err + } + if err := c.require(zoneAction(st, z, true)); err != nil { + return err + } + var in ZoneInput + if err := decode(op.Data, &in); err != nil { + return err + } + if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 { + return errf(KindBadRequest, "nameservers, records, options and clone_from can only be used when creating a zone") + } + oldView := z.ViewID + if err := setZoneFields(st, z, &in, false); err != nil { + return err + } + if z.ViewID != oldView { + if other := st.FindZone(z.Name, z.ViewID); other != nil && other.ID != z.ID { + return errf(KindConflict, "the target view already has a zone %s", dnsname.Display(z.Name)) + } + if err := c.require(zoneAction(st, z, true)); err != nil { + return err + } + } + z.Touch(c.user(), c.now) + return nil +} + +func applyZoneDelete(st *model.State, op model.Op, c *applyCtx) error { + z := st.Zones[op.ID] + if z == nil { + return errf(KindNotFound, "zone %d does not exist", op.ID) + } + if err := checkVersion(op, z.Version, "zone "+dnsname.Display(z.Name)); err != nil { + return err + } + if err := c.require(zoneAction(st, z, true)); err != nil { + return err + } + deleteZone(st, z.ID) + return nil +} + +func deleteZone(st *model.State, id int64) { + for rid, r := range st.Records { + if r.ZoneID == id { + delete(st.Records, rid) + } + } + for oid, o := range st.Options { + if o.Scope == model.ScopeZone && o.ScopeID == id { + delete(st.Options, oid) + } + } + delete(st.Zones, id) +} + +// ---- records + +func recordAction(st *model.State, z *model.Zone, name, typ string, write bool) auth.Action { + return auth.Action{Resource: auth.ResRecords, Write: write, Zone: z.Name, View: st.ViewName(z), Name: name, Type: typ} +} + +func recordLabel(r *model.Record) string { + return fmt.Sprintf("record %s %s %s", r.Name, r.Type, r.Data) +} + +// parseRecord resolves and validates name, type and data for zone z. +func parseRecord(z *model.Zone, name, typ, data string) (string, *rr.Parsed, error) { + if !z.HasRecords() { + return "", nil, errf(KindBadRequest, "zone %s is a %s zone; records can only be managed in primary zones", dnsname.Display(z.Name), z.Type) + } + owner, err := dnsname.Owner(name, z.Name) + if err != nil { + return "", nil, errf(KindBadRequest, "%v", err) + } + if err := dnsname.CheckOwner(owner); err != nil { + return "", nil, errf(KindBadRequest, "invalid name %q: %v", name, err) + } + if !dnsname.IsSubdomain(owner, z.Name) { + return "", nil, errf(KindBadRequest, "%s is not inside zone %s", dnsname.Display(owner), dnsname.Display(z.Name)) + } + p, err := rr.Parse(z.Name, owner, typ, data) + if err != nil { + return "", nil, errf(KindBadRequest, "%s %s: %v", dnsname.Display(owner), strings.ToUpper(typ), err) + } + return owner, p, nil +} + +func rrsetTTL(st *model.State, zoneID int64, name, typ string, exceptID int64) (*uint32, bool) { + for _, r := range st.Records { + if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID { + if r.TTL == nil { + return nil, true + } + t := *r.TTL + return &t, true + } + } + return nil, false +} + +func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error { + var in RecordInput + if err := decode(op.Data, &in); err != nil { + return err + } + if op.ID == 0 || st.Records[op.ID] != nil { + return errf(KindConflict, "record ID %d is already in use", op.ID) + } + z := st.Zones[in.ZoneID] + if z == nil { + return errf(KindNotFound, "zone %d does not exist", in.ZoneID) + } + if in.Name == nil || in.Type == nil || in.Data == nil { + return errf(KindBadRequest, "a record needs a name, a type and data") + } + owner, p, err := parseRecord(z, *in.Name, *in.Type, *in.Data) + if err != nil { + return err + } + if err := c.require(recordAction(st, z, owner, p.Type, true)); err != nil { + return err + } + r := &model.Record{ID: op.ID, ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data} + switch { + case in.TTL.Set && !in.TTL.Null: + if in.TTL.Value > maxTTL { + return errf(KindBadRequest, "TTL must be at most 2147483647") + } + r.TTL = model.Ptr(in.TTL.Value) + case !in.TTL.Set: + // Join an existing RRset with its TTL. + if ttl, ok := rrsetTTL(st, z.ID, owner, p.Type, 0); ok { + r.TTL = ttl + } + } + if in.Comment != nil { + r.Comment = *in.Comment + } + for _, w := range p.Warnings { + c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w) + } + r.Touch(c.user(), c.now) + st.Records[r.ID] = r + return nil +} + +func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error { + r := st.Records[op.ID] + if r == nil { + return errf(KindNotFound, "record %d does not exist", op.ID) + } + if err := checkVersion(op, r.Version, recordLabel(r)); err != nil { + return err + } + z := st.Zones[r.ZoneID] + if z == nil { + return errf(KindNotFound, "the record's zone does not exist") + } + if err := c.require(recordAction(st, z, r.Name, r.Type, true)); err != nil { + return err + } + var in RecordInput + if err := decode(op.Data, &in); err != nil { + return err + } + if in.ZoneID != 0 && in.ZoneID != r.ZoneID { + return errf(KindBadRequest, "records can't be moved to another zone; delete and re-create them") + } + name, typ, data := r.Name, r.Type, r.Data + if in.Name != nil { + name = *in.Name + } + if in.Type != nil { + typ = *in.Type + } + if in.Data != nil { + data = *in.Data + } else if in.Type != nil && !strings.EqualFold(*in.Type, r.Type) { + return errf(KindBadRequest, "changing the type of a record needs new data too") + } + if in.Name != nil || in.Type != nil || in.Data != nil { + owner, p, err := parseRecord(z, name, typ, data) + if err != nil { + return err + } + if owner != r.Name || p.Type != r.Type { + if err := c.require(recordAction(st, z, owner, p.Type, true)); err != nil { + return err + } + } + for _, w := range p.Warnings { + c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w) + } + moved := owner != r.Name || p.Type != r.Type + r.Name, r.Type, r.Data = owner, p.Type, p.Data + if moved && !in.TTL.Set { + if ttl, ok := rrsetTTL(st, z.ID, owner, p.Type, r.ID); ok { + r.TTL = ttl + } + } + } + if in.TTL.Set { + if in.TTL.Null { + r.TTL = nil + } else { + if in.TTL.Value > maxTTL { + return errf(KindBadRequest, "TTL must be at most 2147483647") + } + r.TTL = model.Ptr(in.TTL.Value) + } + } + if in.Comment != nil { + r.Comment = *in.Comment + } + r.Touch(c.user(), c.now) + return nil +} + +func applyRecordDelete(st *model.State, op model.Op, c *applyCtx) error { + if op.ID != 0 { + r := st.Records[op.ID] + if r == nil { + return errf(KindNotFound, "record %d does not exist", op.ID) + } + if err := checkVersion(op, r.Version, recordLabel(r)); err != nil { + return err + } + z := st.Zones[r.ZoneID] + if z != nil { + if err := c.require(recordAction(st, z, r.Name, r.Type, true)); err != nil { + return err + } + } + delete(st.Records, r.ID) + return nil + } + // Delete by match. + var in RecordInput + if err := decode(op.Data, &in); err != nil { + return err + } + z := st.Zones[in.ZoneID] + if z == nil { + return errf(KindNotFound, "zone %d does not exist", in.ZoneID) + } + if in.Name == nil || in.Type == nil { + return errf(KindBadRequest, "deleting records by match needs a name and a type") + } + owner, err := dnsname.Owner(*in.Name, z.Name) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + typ, _, err := rr.NormalizeType(*in.Type) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + if err := c.require(recordAction(st, z, owner, typ, true)); err != nil { + return err + } + data := "" + if in.Data != nil && strings.TrimSpace(*in.Data) != "" { + _, p, err := parseRecord(z, owner, typ, *in.Data) + if err != nil { + return err + } + data = p.Data + } + n := 0 + for id, r := range st.Records { + if r.ZoneID == z.ID && r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) { + delete(st.Records, id) + n++ + } + } + if n == 0 && !in.MissingOK { + what := dnsname.Display(owner) + " " + typ + if data != "" { + what += " " + data + } + return errf(KindNotFound, "no record %s in zone %s", what, dnsname.Display(z.Name)) + } + return nil +} + +func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error { + var in RRSetInput + if err := decode(op.Data, &in); err != nil { + return err + } + z := st.Zones[in.ZoneID] + if z == nil { + return errf(KindNotFound, "zone %d does not exist", in.ZoneID) + } + owner, err := dnsname.Owner(in.Name, z.Name) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + typ, _, err := rr.NormalizeType(in.Type) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + if err := c.require(recordAction(st, z, owner, typ, true)); err != nil { + return err + } + var desired []string + seen := map[string]bool{} + for _, d := range in.Data { + _, p, err := parseRecord(z, owner, typ, d) + if err != nil { + return err + } + for _, w := range p.Warnings { + c.warn("%s %s: %s", dnsname.Display(owner), typ, w) + } + k := strings.ToLower(p.Data) + if !seen[k] { + seen[k] = true + desired = append(desired, p.Data) + } + } + var ttl *uint32 + ttlKnown := false + if in.TTL.Set { + ttlKnown = true + if !in.TTL.Null { + if in.TTL.Value > maxTTL { + return errf(KindBadRequest, "TTL must be at most 2147483647") + } + ttl = model.Ptr(in.TTL.Value) + } + } else if t, ok := rrsetTTL(st, z.ID, owner, typ, 0); ok { + ttl, ttlKnown = t, true + } + keep := map[string]bool{} + for id, r := range st.Records { + if r.ZoneID != z.ID || r.Name != owner || r.Type != typ { + continue + } + k := strings.ToLower(r.Data) + if seen[k] && !keep[k] { + keep[k] = true + if ttlKnown && !sameTTL(r.TTL, ttl) { + r.TTL = copyTTL(ttl) + r.Touch(c.user(), c.now) + } + continue + } + delete(st.Records, id) + } + ids := in.NewIDs + for _, d := range desired { + if keep[strings.ToLower(d)] { + continue + } + if len(ids) == 0 { + return errf(KindBadRequest, "internal error: no IDs allocated for new records") + } + r := &model.Record{ID: ids[0], ZoneID: z.ID, Name: owner, Type: typ, Data: d, TTL: copyTTL(ttl)} + ids = ids[1:] + if st.Records[r.ID] != nil { + return errf(KindConflict, "record ID %d is already in use", r.ID) + } + if in.Comment != nil { + r.Comment = *in.Comment + } + r.Touch(c.user(), c.now) + st.Records[r.ID] = r + } + return nil +} + +func sameTTL(a, b *uint32) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func copyTTL(t *uint32) *uint32 { + if t == nil { + return nil + } + v := *t + return &v +} + +// ---- views + +func viewAction(name string) auth.Action { + return auth.Action{Resource: auth.ResViews, Write: true, View: name} +} + +func normalizeElements(list []string, field string) ([]string, error) { + out := []string{} + for _, s := range list { + if strings.TrimSpace(s) == "" { + continue + } + e, err := namedconf.ParseAMElement(s) + if err != nil { + return nil, errf(KindBadRequest, "%s element %q: %v", field, s, err) + } + out = append(out, e.String()) + } + return out, nil +} + +func setViewFields(v *model.View, in *ViewInput) error { + if in.Name != nil { + v.Name = strings.TrimSpace(*in.Name) + } + if in.MatchClients != nil { + l, err := normalizeElements(*in.MatchClients, "match-clients") + if err != nil { + return err + } + v.MatchClients = l + } + if in.MatchDestinations != nil { + l, err := normalizeElements(*in.MatchDestinations, "match-destinations") + if err != nil { + return err + } + v.MatchDestinations = l + } + if in.MatchRecursiveOnly.Set { + if in.MatchRecursiveOnly.Null { + v.MatchRecursiveOnly = nil + } else { + v.MatchRecursiveOnly = model.Ptr(in.MatchRecursiveOnly.Value) + } + } + if in.Comment != nil { + v.Comment = *in.Comment + } + if in.Position != nil { + v.Position = *in.Position + } + return nil +} + +func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error { + var in ViewInput + if err := decode(op.Data, &in); err != nil { + return err + } + if op.ID == 0 || st.Views[op.ID] != nil { + return errf(KindConflict, "view ID %d is already in use", op.ID) + } + if in.Name == nil || strings.TrimSpace(*in.Name) == "" { + return errf(KindBadRequest, "a view needs a name") + } + v := &model.View{ID: op.ID, Position: len(st.Views), MatchClients: []string{}, MatchDestinations: []string{}} + if err := setViewFields(v, &in); err != nil { + return err + } + if err := c.require(viewAction(v.Name)); err != nil { + return err + } + if st.ViewByName(v.Name) != nil { + return errf(KindConflict, "view %s already exists", v.Name) + } + v.Touch(c.user(), c.now) + st.Views[v.ID] = v + renumberViews(st) + return nil +} + +// renumberViews makes positions 0..n-1 in the current order. +func renumberViews(st *model.State) { + for i, v := range st.OrderedViews() { + v.Position = i + } +} + +func applyViewUpdate(st *model.State, op model.Op, c *applyCtx) error { + v := st.Views[op.ID] + if v == nil { + return errf(KindNotFound, "view %d does not exist", op.ID) + } + if err := checkVersion(op, v.Version, "view "+v.Name); err != nil { + return err + } + if err := c.require(viewAction(v.Name)); err != nil { + return err + } + var in ViewInput + if err := decode(op.Data, &in); err != nil { + return err + } + oldName := v.Name + if err := setViewFields(v, &in); err != nil { + return err + } + if v.Name != oldName { + if other := st.ViewByName(v.Name); other != nil && other.ID != v.ID { + return errf(KindConflict, "view %s already exists", v.Name) + } + if err := c.require(viewAction(v.Name)); err != nil { + return err + } + // Keep in-view references working. + for _, z := range st.Zones { + if z.Type == model.ZoneInView && strings.EqualFold(z.InView, oldName) { + z.InView = v.Name + z.Touch(c.user(), c.now) + } + } + } + v.Touch(c.user(), c.now) + renumberViews(st) + return nil +} + +func applyViewDelete(st *model.State, op model.Op, c *applyCtx) error { + v := st.Views[op.ID] + if v == nil { + return errf(KindNotFound, "view %d does not exist", op.ID) + } + if err := checkVersion(op, v.Version, "view "+v.Name); err != nil { + return err + } + if err := c.require(viewAction(v.Name)); err != nil { + return err + } + var in DeleteInput + if err := decode(op.Data, &in); err != nil { + return err + } + zones := st.ZonesInView(v.ID) + if len(zones) > 0 && !in.Cascade { + var names []string + for _, z := range zones { + names = append(names, dnsname.Display(z.Name)) + } + return errf(KindConflict, "view %s still has %d zone(s) (%s); move or delete them first, or delete the view with cascade", v.Name, len(zones), strings.Join(names, ", ")) + } + for _, z := range zones { + if err := c.require(zoneAction(st, z, true)); err != nil { + return err + } + deleteZone(st, z.ID) + } + for oid, o := range st.Options { + if o.Scope == model.ScopeView && o.ScopeID == v.ID { + delete(st.Options, oid) + } + } + delete(st.Views, v.ID) + renumberViews(st) + return nil +} + +func applyViewReorder(st *model.State, op model.Op, c *applyCtx) error { + var in ReorderInput + if err := decode(op.Data, &in); err != nil { + return err + } + if len(in.IDs) != len(st.Views) { + return errf(KindConflict, "the new order lists %d views, but there are %d", len(in.IDs), len(st.Views)) + } + seen := map[int64]bool{} + for _, id := range in.IDs { + v := st.Views[id] + if v == nil || seen[id] { + return errf(KindBadRequest, "the new order must list every view exactly once") + } + seen[id] = true + if err := c.require(viewAction(v.Name)); err != nil { + return err + } + } + for i, id := range in.IDs { + v := st.Views[id] + if v.Position != i { + v.Position = i + v.Touch(c.user(), c.now) + } + } + return nil +} + +// ---- ACLs + +func applyACLPut(st *model.State, op model.Op, c *applyCtx) error { + if err := c.require(auth.Action{Resource: auth.ResACLs, Write: true}); err != nil { + return err + } + var in ACLInput + if err := decode(op.Data, &in); err != nil { + return err + } + var a *model.ACL + if op.Kind == OpACLCreate { + if op.ID == 0 || st.ACLs[op.ID] != nil { + return errf(KindConflict, "ACL ID %d is already in use", op.ID) + } + if in.Name == nil { + return errf(KindBadRequest, "an ACL needs a name") + } + a = &model.ACL{ID: op.ID, Elements: []string{}} + } else { + a = st.ACLs[op.ID] + if a == nil { + return errf(KindNotFound, "ACL %d does not exist", op.ID) + } + if err := checkVersion(op, a.Version, "ACL "+a.Name); err != nil { + return err + } + } + oldName := a.Name + if in.Name != nil { + name := strings.TrimSpace(*in.Name) + if namedconf.BuiltinACLs[strings.ToLower(name)] { + return errf(KindBadRequest, "%q is a built-in ACL name", name) + } + if err := namedconf.CheckACLName(name); err != nil { + return errf(KindBadRequest, "%v", err) + } + if other := st.ACLByName(name); other != nil && other.ID != a.ID { + return errf(KindConflict, "ACL %s already exists", name) + } + a.Name = name + } + if in.Elements != nil { + l, err := normalizeElements(*in.Elements, "ACL") + if err != nil { + return err + } + a.Elements = l + } + if in.Comment != nil { + a.Comment = *in.Comment + } + a.Touch(c.user(), c.now) + st.ACLs[a.ID] = a + if op.Kind == OpACLUpdate && oldName != a.Name { + renameRefs(st, refACL, oldName, a.Name, c) + } + return nil +} + +func applyACLDelete(st *model.State, op model.Op, c *applyCtx) error { + if err := c.require(auth.Action{Resource: auth.ResACLs, Write: true}); err != nil { + return err + } + a := st.ACLs[op.ID] + if a == nil { + return errf(KindNotFound, "ACL %d does not exist", op.ID) + } + if err := checkVersion(op, a.Version, "ACL "+a.Name); err != nil { + return err + } + if users := FindRefs(st, refACL, a.Name); len(users) > 0 { + return errf(KindConflict, "ACL %s is still used by %s", a.Name, strings.Join(users, ", ")) + } + delete(st.ACLs, a.ID) + return nil +} + +// ---- TSIG keys + +func applyTSIGPut(st *model.State, op model.Op, c *applyCtx) error { + if err := c.require(auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil { + return err + } + var in TSIGInput + if err := decode(op.Data, &in); err != nil { + return err + } + var k *model.TSIGKey + if op.Kind == OpTSIGCreate { + if op.ID == 0 || st.Keys[op.ID] != nil { + return errf(KindConflict, "key ID %d is already in use", op.ID) + } + if in.Name == nil || in.Secret == nil { + return errf(KindBadRequest, "a TSIG key needs a name and a secret") + } + k = &model.TSIGKey{ID: op.ID, Algorithm: "hmac-sha256"} + } else { + k = st.Keys[op.ID] + if k == nil { + return errf(KindNotFound, "TSIG key %d does not exist", op.ID) + } + if err := checkVersion(op, k.Version, "TSIG key "+k.Name); err != nil { + return err + } + } + oldName := k.Name + if in.Name != nil { + name := model.NormalizeKeyName(*in.Name) + if err := dnsname.CheckZoneName(dnsname.Fqdn(name)); err != nil || name == "" || name == "." { + return errf(KindBadRequest, "invalid key name %q (key names are domain names, e.g. xfer-key or acme.example.com)", *in.Name) + } + if other := st.KeyByName(name); other != nil && other.ID != k.ID { + return errf(KindConflict, "TSIG key %s already exists", name) + } + k.Name = name + } + if in.Algorithm != nil { + alg := strings.ToLower(strings.TrimSpace(*in.Algorithm)) + if _, ok := validate.TSIGAlgorithms[alg]; !ok { + return errf(KindBadRequest, "unsupported TSIG algorithm %q", alg) + } + k.Algorithm = alg + } + if in.Secret != nil { + k.Secret = strings.TrimSpace(*in.Secret) + } + if in.Comment != nil { + k.Comment = *in.Comment + } + k.Touch(c.user(), c.now) + st.Keys[k.ID] = k + if op.Kind == OpTSIGUpdate && oldName != k.Name { + renameRefs(st, refKey, oldName, k.Name, c) + } + return nil +} + +func applyTSIGDelete(st *model.State, op model.Op, c *applyCtx) error { + if err := c.require(auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil { + return err + } + k := st.Keys[op.ID] + if k == nil { + return errf(KindNotFound, "TSIG key %d does not exist", op.ID) + } + if err := checkVersion(op, k.Version, "TSIG key "+k.Name); err != nil { + return err + } + if users := FindRefs(st, refKey, k.Name); len(users) > 0 { + return errf(KindConflict, "TSIG key %s is still used by %s", k.Name, strings.Join(users, ", ")) + } + delete(st.Keys, k.ID) + return nil +} + +// ---- options + +func optionAction(st *model.State, scope string, scopeID int64) (auth.Action, error) { + switch scope { + case model.ScopeOptions, model.ScopeTopLevel: + return auth.Action{Resource: auth.ResOptions, Write: true}, nil + case model.ScopeView: + v := st.Views[scopeID] + if v == nil { + return auth.Action{}, errf(KindNotFound, "view %d does not exist", scopeID) + } + return viewAction(v.Name), nil + case model.ScopeZone: + z := st.Zones[scopeID] + if z == nil { + return auth.Action{}, errf(KindNotFound, "zone %d does not exist", scopeID) + } + return zoneAction(st, z, true), nil + } + return auth.Action{}, errf(KindBadRequest, "unknown option scope %q (use options, view, zone or toplevel)", scope) +} + +// catalogScope returns the catalog scope for an option scope. +func catalogScope(st *model.State, scope string, scopeID int64) string { + switch scope { + case model.ScopeOptions: + return catalog.ScopeOptions + case model.ScopeTopLevel: + return catalog.ScopeTop + case model.ScopeView: + return catalog.ScopeView + case model.ScopeZone: + if z := st.Zones[scopeID]; z != nil { + return catalog.ZoneScope(z.Type) + } + } + return "" +} + +// NormalizeOption checks an option name for its scope and returns the +// canonical name and value. +func NormalizeOption(st *model.State, scope string, scopeID int64, name, value string) (string, string, error) { + cs := catalogScope(st, scope, scopeID) + n := strings.ToLower(strings.TrimSpace(name)) + if canon, syn := catalog.Canonical(cs, n); syn { + n = canon + } + stmt, ok := catalog.Default().Lookup(cs, n) + if !ok { + hint := "" + if scope == model.ScopeZone { + if z := st.Zones[scopeID]; z != nil { + hint = " for " + z.Type + " zones" + } + } + return "", "", errf(KindBadRequest, "%q is not a valid option%s in this scope", name, hint) + } + if stmt.Managed { + return "", "", errf(KindBadRequest, "%q can't be set as an option; it is managed through %s", n, stmt.ManagedBy) + } + v := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), ";")) + if v == "" { + if n == "include" { + return "", "", errf(KindBadRequest, "include needs a file name") + } + return n, "", nil + } + // Quote plain strings for options that take a quoted string. + if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") { + v = namedconf.QuoteString(v) + } + parsed, err := namedconf.ParseOptionValue(n, v) + if err != nil { + return "", "", errf(KindBadRequest, "option %s: %v", n, err) + } + return n, namedconf.Compact(parsed.Items[1:]), nil +} + +func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error { + var in OptionInput + if err := decode(op.Data, &in); err != nil { + return err + } + var o *model.Option + if op.Kind == OpOptionCreate { + if op.ID == 0 || st.Options[op.ID] != nil { + return errf(KindConflict, "option ID %d is already in use", op.ID) + } + if in.Scope == nil || in.Name == nil { + return errf(KindBadRequest, "an option needs a scope and a name") + } + o = &model.Option{ID: op.ID, Scope: *in.Scope} + if in.ScopeID != nil { + o.ScopeID = *in.ScopeID + } + if o.Scope == model.ScopeOptions || o.Scope == model.ScopeTopLevel { + o.ScopeID = 0 + } + o.Position = maxPosition(st, o.Scope, o.ScopeID) + 1 + } else { + o = st.Options[op.ID] + if o == nil { + return errf(KindNotFound, "option %d does not exist", op.ID) + } + if err := checkVersion(op, o.Version, "option "+o.Name); err != nil { + return err + } + if in.Scope != nil && *in.Scope != o.Scope || in.ScopeID != nil && *in.ScopeID != o.ScopeID { + return errf(KindBadRequest, "options can't be moved to another scope; delete and re-create them") + } + } + act, err := optionAction(st, o.Scope, o.ScopeID) + if err != nil { + return err + } + if err := c.require(act); err != nil { + return err + } + name, value := o.Name, o.Value + if in.Name != nil { + name = *in.Name + } + if in.Value != nil { + value = *in.Value + } + n, v, err := NormalizeOption(st, o.Scope, o.ScopeID, name, value) + if err != nil { + return err + } + o.Name, o.Value = n, v + if in.Comment != nil { + o.Comment = *in.Comment + } + if in.Position != nil { + o.Position = *in.Position + } + o.Touch(c.user(), c.now) + st.Options[o.ID] = o + return nil +} + +func maxPosition(st *model.State, scope string, scopeID int64) int { + m := 0 + for _, o := range st.Options { + if o.Scope == scope && o.ScopeID == scopeID && o.Position > m { + m = o.Position + } + } + return m +} + +func applyOptionDelete(st *model.State, op model.Op, c *applyCtx) error { + o := st.Options[op.ID] + if o == nil { + return errf(KindNotFound, "option %d does not exist", op.ID) + } + if err := checkVersion(op, o.Version, "option "+o.Name); err != nil { + return err + } + act, err := optionAction(st, o.Scope, o.ScopeID) + if err != nil { + return err + } + if err := c.require(act); err != nil { + return err + } + delete(st.Options, o.ID) + return nil +} + +// ---- settings + +func applySettings(st *model.State, op model.Op, c *applyCtx) error { + if err := c.require(auth.Action{Resource: auth.ResOptions, Write: true}); err != nil { + return err + } + var in SettingsInput + if err := decode(op.Data, &in); err != nil { + return err + } + s := &st.Settings + if in.DefaultTTL != nil { + s.DefaultTTL = *in.DefaultTTL + } + if in.Nameservers != nil { + var ns []string + for _, n := range *in.Nameservers { + if strings.TrimSpace(n) == "" { + continue + } + a, err := dnsname.ToASCII(strings.TrimSpace(n)) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + ns = append(ns, dnsname.Fqdn(a)) + } + s.Nameservers = ns + } + if in.RName != nil { + if strings.TrimSpace(*in.RName) == "" { + s.RName = "" + } else { + mb, err := dnsname.MailboxFromEmail(*in.RName) + if err != nil { + return errf(KindBadRequest, "%v", err) + } + s.RName = mb + } + } + if in.Refresh != nil { + s.Refresh = *in.Refresh + } + if in.Retry != nil { + s.Retry = *in.Retry + } + if in.Expire != nil { + s.Expire = *in.Expire + } + if in.NegativeTTL != nil { + s.NegativeTTL = *in.NegativeTTL + } + if in.SerialPolicy != nil { + s.SerialPolicy = strings.ToLower(*in.SerialPolicy) + } + s.Version++ + return nil +} diff --git a/internal/engine/owner_unix.go b/internal/engine/owner_unix.go new file mode 100644 index 0000000..03ae49b --- /dev/null +++ b/internal/engine/owner_unix.go @@ -0,0 +1,13 @@ +package engine + +import ( + "os" + "syscall" +) + +// copyOwner gives path the owner and group of fi (when running as root). +func copyOwner(fi os.FileInfo, path string) { + if st, ok := fi.Sys().(*syscall.Stat_t); ok && os.Geteuid() == 0 { + os.Chown(path, int(st.Uid), int(st.Gid)) + } +} diff --git a/internal/engine/refs.go b/internal/engine/refs.go new file mode 100644 index 0000000..a610a62 --- /dev/null +++ b/internal/engine/refs.go @@ -0,0 +1,213 @@ +package engine + +import ( + "fmt" + "sort" + "strings" + + "jarl/internal/catalog" + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/namedconf" +) + +type refKind int + +const ( + refACL refKind = iota + refKey +) + +func sameRef(kind refKind, a, b string) bool { + if kind == refKey { + return model.NormalizeKeyName(a) == model.NormalizeKeyName(b) + } + return strings.EqualFold(a, b) +} + +// rewriteElements renames references in ACL-style element strings. It +// returns the new list and whether anything matched. +func rewriteElements(list []string, kind refKind, old, new string) ([]string, bool) { + changed := false + var fix func(es []namedconf.AMElement) []namedconf.AMElement + fix = func(es []namedconf.AMElement) []namedconf.AMElement { + for i := range es { + e := &es[i] + switch { + case kind == refACL && e.Kind == namedconf.AMName && !e.IsBuiltin() && sameRef(kind, e.Name, old): + e.Name, changed = new, true + case kind == refKey && e.Kind == namedconf.AMKey && sameRef(kind, e.Name, old): + e.Name, changed = new, true + case e.Kind == namedconf.AMNested: + e.Nested = fix(e.Nested) + } + } + return es + } + out := make([]string, len(list)) + for i, s := range list { + e, err := namedconf.ParseAMElement(s) + if err != nil { + out[i] = s + continue + } + res := fix([]namedconf.AMElement{e}) + out[i] = res[0].String() + } + return out, changed +} + +// walkRefs calls fn for every reference of the given kind in items. fn may +// change the value's text. +func walkRefs(items []namedconf.Item, kind refKind, aml bool, fn func(v *namedconf.Value)) { + for i := 0; i < len(items); i++ { + it := items[i] + if it.Value != nil && !it.Value.Quoted && i+1 < len(items) { + switch strings.ToLower(it.Value.Text) { + case "key": + if kind == refKey && items[i+1].Value != nil { + fn(items[i+1].Value) + } + i++ + continue + case "keys": + next := items[i+1] + if kind == refKey { + if next.Value != nil { + fn(next.Value) + } else if next.Block != nil { + for _, s := range next.Block.Stmts { + if len(s.Items) == 1 && s.Items[0].Value != nil { + fn(s.Items[0].Value) + } + } + } + } + i++ + continue + } + } + if it.Block == nil { + continue + } + for _, s := range it.Block.Stmts { + if kind == refACL && aml { + var v *namedconf.Value + switch { + case len(s.Items) == 1 && s.Items[0].Value != nil: + v = s.Items[0].Value + case len(s.Items) == 2 && s.Items[0].Bang && s.Items[1].Value != nil: + v = s.Items[1].Value + } + if v != nil && !namedconf.BuiltinACLs[strings.ToLower(v.Text)] { + fn(v) + continue + } + } + walkRefs(s.Items, kind, aml, fn) + } + } +} + +// optionHasAML reports whether the option's grammar contains address match +// lists (so single words in its blocks are ACL names). +func optionHasAML(st *model.State, o *model.Option) bool { + cs := catalogScope(st, o.Scope, o.ScopeID) + stmt, ok := catalog.Default().Lookup(cs, o.Name) + return ok && strings.Contains(stmt.Grammar, "") +} + +func optionLabel(st *model.State, o *model.Option) string { + switch o.Scope { + case model.ScopeView: + if v := st.Views[o.ScopeID]; v != nil { + return fmt.Sprintf("view %s option %s", v.Name, o.Name) + } + case model.ScopeZone: + if z := st.Zones[o.ScopeID]; z != nil { + label := "zone " + dnsname.Display(z.Name) + if v := st.ViewName(z); v != "" { + label += " [view " + v + "]" + } + return label + " option " + o.Name + } + case model.ScopeTopLevel: + return "top-level statement " + o.Name + } + return "server option " + o.Name +} + +// FindRefs lists the objects that refer to an ACL or key by name. +func FindRefs(st *model.State, kind refKind, name string) []string { + var out []string + for _, a := range st.SortedACLs() { + if _, hit := rewriteElements(a.Elements, kind, name, name); hit { + out = append(out, "ACL "+a.Name) + } + } + for _, v := range st.OrderedViews() { + _, h1 := rewriteElements(v.MatchClients, kind, name, name) + _, h2 := rewriteElements(v.MatchDestinations, kind, name, name) + if h1 || h2 { + out = append(out, "view "+v.Name) + } + } + var opts []string + for _, o := range st.Options { + stmt, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil { + continue + } + hit := false + walkRefs(stmt.Items, kind, optionHasAML(st, o), func(v *namedconf.Value) { + if sameRef(kind, v.Text, name) { + hit = true + } + }) + if hit { + opts = append(opts, optionLabel(st, o)) + } + } + sort.Strings(opts) + return append(out, opts...) +} + +// renameRefs rewrites references to an ACL or key after a rename. +func renameRefs(st *model.State, kind refKind, old, new string, c *applyCtx) { + for _, a := range st.ACLs { + if l, hit := rewriteElements(a.Elements, kind, old, new); hit { + a.Elements = l + a.Touch(c.user(), c.now) + } + } + for _, v := range st.Views { + l1, h1 := rewriteElements(v.MatchClients, kind, old, new) + l2, h2 := rewriteElements(v.MatchDestinations, kind, old, new) + if h1 || h2 { + v.MatchClients, v.MatchDestinations = l1, l2 + v.Touch(c.user(), c.now) + } + } + for _, o := range st.Options { + stmt, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil { + continue + } + hit := false + walkRefs(stmt.Items, kind, optionHasAML(st, o), func(v *namedconf.Value) { + if sameRef(kind, v.Text, old) { + v.Text, hit = new, true + } + }) + if hit { + o.Value = namedconf.Compact(stmt.Items[1:]) + o.Touch(c.user(), c.now) + } + } +} + +// FindACLRefs lists the objects that use the ACL called name. +func FindACLRefs(st *model.State, name string) []string { return FindRefs(st, refACL, name) } + +// FindKeyRefs lists the objects that use the TSIG key called name. +func FindKeyRefs(st *model.State, name string) []string { return FindRefs(st, refKey, name) } diff --git a/internal/engine/serial.go b/internal/engine/serial.go new file mode 100644 index 0000000..79f4a4f --- /dev/null +++ b/internal/engine/serial.go @@ -0,0 +1,75 @@ +package engine + +import ( + "time" + + "jarl/internal/model" + "jarl/internal/render" +) + +// serialGreater reports whether a > b in RFC 1982 serial number +// arithmetic. +func serialGreater(a, b uint32) bool { return int32(a-b) > 0 } + +// NextSerial returns the serial after old under policy, with the same +// semantics as BIND's serial-update-method. +func NextSerial(old uint32, policy string, now time.Time) uint32 { + switch policy { + case model.SerialUnixtime: + t := uint32(now.Unix()) + if serialGreater(t, old) { + return t + } + return old + 1 + case model.SerialIncrement: + return old + 1 + default: // date: YYYYMMDDnn + d := dateSerial(now) + if serialGreater(d, old) { + return d + } + return old + 1 + } +} + +// SerialChange records a serial bump. +type SerialChange struct { + ZoneID int64 `json:"zone_id"` + Zone string `json:"zone"` + View string `json:"view,omitempty"` + Old uint32 `json:"old"` + New uint32 `json:"new"` +} + +// bumpSerials increments the serial of every primary zone in work whose +// DNS data differs from base, unless the serial was set explicitly. +func bumpSerials(base, work *model.State, now time.Time, user string) []SerialChange { + var out []SerialChange + for _, z := range work.SortedZones() { + if z.Type != model.ZonePrimary { + continue + } + policy := z.SerialPolicy + if policy == "" { + policy = work.Settings.SerialPolicy + } + old := base.Zones[z.ID] + if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID { + // New to named (or moved): keep the serial it was created + // with, which InitialSerial or the source zone provided. + continue + } + if z.SOA.Serial != old.SOA.Serial { + // Set explicitly by the user. + continue + } + if render.ZoneContentHash(base, old) == render.ZoneContentHash(work, z) { + continue + } + next := NextSerial(old.SOA.Serial, policy, now) + out = append(out, SerialChange{ZoneID: z.ID, Zone: z.Name, View: work.ViewName(z), Old: old.SOA.Serial, New: next}) + z.SOA.Serial = next + z.Touch(user, now) + } + return out +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go new file mode 100644 index 0000000..5c9d554 --- /dev/null +++ b/internal/importer/importer.go @@ -0,0 +1,737 @@ +// Package importer reads an existing BIND configuration (named.conf, its +// include files and zone files) into a Jarl State. +package importer + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/miekg/dns" + + "jarl/internal/bind" + "jarl/internal/catalog" + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/namedconf" + "jarl/internal/rr" +) + +// Options control the import. +type Options struct { + // Tools, if set, is used to read zone files miekg/dns can't parse + // (named-checkzone -D understands everything named does). + Tools *bind.Tools + // Alloc allocates object IDs. + Alloc func(ctx context.Context, n int) ([]int64, error) + // User is recorded as the creator of imported objects. + User string +} + +// ZoneReport describes one imported zone. +type ZoneReport struct { + Name string `json:"name"` + View string `json:"view,omitempty"` + Type string `json:"type"` + File string `json:"file,omitempty"` + Records int `json:"records"` + Skipped int `json:"skipped"` +} + +// Report summarizes an import. +type Report struct { + Zones []ZoneReport `json:"zones"` + Kept []string `json:"kept_includes"` + Warnings []string `json:"warnings"` + Skipped []string `json:"skipped"` // records or statements that were not imported + Counts map[string]int +} + +type importer struct { + ctx context.Context + opts Options + st *model.State + rep *Report + ids []int64 + dir string // the directory option, for relative zone file names + seen map[string]bool + ctrl map[string]bool // key names used by controls +} + +func (im *importer) warn(format string, args ...any) { + im.rep.Warnings = append(im.rep.Warnings, fmt.Sprintf(format, args...)) +} + +func (im *importer) skip(format string, args ...any) { + im.rep.Skipped = append(im.rep.Skipped, fmt.Sprintf(format, args...)) +} + +func (im *importer) id() (int64, error) { + if len(im.ids) == 0 { + ids, err := im.opts.Alloc(im.ctx, 256) + if err != nil { + return 0, err + } + im.ids = ids + } + id := im.ids[0] + im.ids = im.ids[1:] + return id, nil +} + +func (im *importer) meta() model.Meta { + return model.Meta{Version: 1, CreatedBy: im.opts.User, UpdatedBy: im.opts.User} +} + +// Import reads the configuration starting at mainPath. +func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *Report, error) { + im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}} + stmts, err := im.readFile(mainPath) + if err != nil { + return nil, nil, err + } + // Learn which keys the control channel uses (they stay outside Jarl). + for _, s := range namedconf.Find(stmts, "controls") { + for _, k := range keyNamesInStmt(s) { + im.ctrl[model.NormalizeKeyName(k)] = true + } + } + stmts, err = im.expand(stmts, filepath.Dir(mainPath), true) + if err != nil { + return nil, nil, err + } + // The directory option is needed before zones are read. + if o := namedconf.FindOne(stmts, "options"); o != nil && o.FirstBlock() != nil { + if d := namedconf.FindOne(o.FirstBlock().Stmts, "directory"); d != nil { + im.dir = d.Arg(0) + } + } + for _, s := range stmts { + if err := im.topLevel(s); err != nil { + return nil, nil, err + } + } + im.rep.Counts = map[string]int{"views": len(im.st.Views), "zones": len(im.st.Zones), "records": len(im.st.Records), "acls": len(im.st.ACLs), "tsig_keys": len(im.st.Keys), "options": len(im.st.Options)} + sort.Slice(im.rep.Zones, func(i, j int) bool { + a, b := im.rep.Zones[i], im.rep.Zones[j] + if a.View != b.View { + return a.View < b.View + } + return a.Name < b.Name + }) + return im.st, im.rep, nil +} + +func (im *importer) readFile(path string) ([]*namedconf.Stmt, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + stmts, err := namedconf.Parse(path, string(b)) + if err != nil { + return nil, err + } + return stmts, nil +} + +func keyNamesInStmt(s *namedconf.Stmt) []string { + var out []string + var walk func(items []namedconf.Item) + walk = func(items []namedconf.Item) { + for i, it := range items { + if it.Value != nil && strings.EqualFold(it.Value.Text, "keys") && i+1 < len(items) && items[i+1].Block != nil { + for _, k := range items[i+1].Block.Stmts { + if len(k.Items) > 0 && k.Items[0].Value != nil { + out = append(out, k.Items[0].Value.Text) + } + } + } + if it.Block != nil { + for _, st := range it.Block.Stmts { + walk(st.Items) + } + } + } + } + walk(s.Items) + return out +} + +// expand inlines include directives. At the top level, files holding only +// keys used by the control channel (rndc.key) stay as include statements so +// the rndc secret never ends up in Jarl's database. +func (im *importer) expand(stmts []*namedconf.Stmt, base string, top bool) ([]*namedconf.Stmt, error) { + var out []*namedconf.Stmt + for _, s := range stmts { + if s.Keyword() != "include" { + for _, it := range s.Items { + if it.Block != nil { + inner, err := im.expand(it.Block.Stmts, base, false) + if err != nil { + return nil, err + } + it.Block.Stmts = inner + } + } + out = append(out, s) + continue + } + pattern := s.Arg(0) + if !filepath.IsAbs(pattern) { + pattern = filepath.Join(base, pattern) + } + files, _ := filepath.Glob(pattern) + if len(files) == 0 { + if _, err := os.Stat(pattern); err != nil { + return nil, fmt.Errorf("%s: include %q: %w", s.Pos, s.Arg(0), err) + } + files = []string{pattern} + } + for _, f := range files { + if im.seen[f] { + return nil, fmt.Errorf("%s: include loop through %s", s.Pos, f) + } + inner, err := im.readFile(f) + if err != nil { + if top && errors.Is(err, fs.ErrPermission) { + // e.g. rndc.key readable by named only: keep it. + im.keepInclude(f) + continue + } + return nil, err + } + if top && im.keyOnly(inner) { + im.keepInclude(f) + continue + } + im.seen[f] = true + inner, err = im.expand(inner, filepath.Dir(f), top) + delete(im.seen, f) + if err != nil { + return nil, err + } + out = append(out, inner...) + } + } + return out, nil +} + +// keyOnly reports whether stmts only define keys, at least one of which is +// used by the control channel (or the file is the conventional rndc.key). +func (im *importer) keyOnly(stmts []*namedconf.Stmt) bool { + if len(stmts) == 0 { + return false + } + ctrl := false + for _, s := range stmts { + if s.Keyword() != "key" { + return false + } + n := model.NormalizeKeyName(s.Arg(0)) + if im.ctrl[n] || n == "rndc-key" { + ctrl = true + } + } + return ctrl +} + +func (im *importer) keepInclude(path string) { + im.rep.Kept = append(im.rep.Kept, path) + im.addOption(model.ScopeTopLevel, 0, "include", namedconf.QuoteString(path), "") +} + +func (im *importer) addOption(scope string, scopeID int64, name, value, comment string) { + id, err := im.id() + if err != nil { + return + } + pos := 0 + for _, o := range im.st.Options { + if o.Scope == scope && o.ScopeID == scopeID && o.Position >= pos { + pos = o.Position + 1 + } + } + im.st.Options[id] = &model.Option{ID: id, Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: pos, Comment: comment, Meta: im.meta()} +} + +func restText(s *namedconf.Stmt) string { + return namedconf.Compact(s.Items[1:]) +} + +func (im *importer) topLevel(s *namedconf.Stmt) error { + switch kw := s.Keyword(); kw { + case "options": + b := s.FirstBlock() + if b == nil { + return fmt.Errorf("%s: options without a block", s.Pos) + } + for _, o := range b.Stmts { + im.addOption(model.ScopeOptions, 0, canonicalName(catalog.ScopeOptions, o.Keyword()), restText(o), "") + } + case "acl": + return im.acl(s) + case "key": + return im.key(s) + case "view": + return im.view(s) + case "zone": + return im.zone(s, nil) + case "": + im.skip("%s: statement without a keyword", s.Pos) + default: + name := canonicalName(catalog.ScopeTop, kw) + if _, ok := catalog.Default().Lookup(catalog.ScopeTop, name); !ok { + im.warn("%s: unknown top-level statement %q imported as is", s.Pos, kw) + } + im.addOption(model.ScopeTopLevel, 0, name, restText(s), "") + } + return nil +} + +func canonicalName(scope, name string) string { + n, _ := catalog.Canonical(scope, name) + return n +} + +func elementStrings(b *namedconf.Block) []string { + var out []string + for _, st := range b.Stmts { + if e, err := namedconf.ParseAMList(&namedconf.Block{Stmts: []*namedconf.Stmt{st}}); err == nil && len(e) == 1 { + out = append(out, e[0].String()) + } else { + out = append(out, namedconf.Compact(st.Items)) + } + } + return out +} + +func (im *importer) acl(s *namedconf.Stmt) error { + b := s.FirstBlock() + if b == nil { + return fmt.Errorf("%s: acl without a block", s.Pos) + } + id, err := im.id() + if err != nil { + return err + } + im.st.ACLs[id] = &model.ACL{ID: id, Name: s.Arg(0), Elements: nonNil(elementStrings(b)), Meta: im.meta()} + return nil +} + +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +func (im *importer) key(s *namedconf.Stmt) error { + b := s.FirstBlock() + if b == nil { + return fmt.Errorf("%s: key without a block", s.Pos) + } + name := model.NormalizeKeyName(s.Arg(0)) + alg, secret := "", "" + if a := namedconf.FindOne(b.Stmts, "algorithm"); a != nil { + alg = strings.ToLower(a.Arg(0)) + } + if sc := namedconf.FindOne(b.Stmts, "secret"); sc != nil { + secret = sc.Arg(0) + } + if existing := im.st.KeyByName(name); existing != nil { + if existing.Secret != secret || existing.Algorithm != alg { + im.warn("%s: key %s is defined more than once with different secrets; keeping the first", s.Pos, name) + } + return nil + } + id, err := im.id() + if err != nil { + return err + } + im.st.Keys[id] = &model.TSIGKey{ID: id, Name: name, Algorithm: alg, Secret: secret, Meta: im.meta()} + return nil +} + +func (im *importer) view(s *namedconf.Stmt) error { + b := s.FirstBlock() + if b == nil { + return fmt.Errorf("%s: view without a block", s.Pos) + } + if class := s.Arg(1); class != "" && !strings.EqualFold(class, "IN") { + im.skip("%s: view %s of class %s (only class IN views are supported)", s.Pos, s.Arg(0), class) + return nil + } + id, err := im.id() + if err != nil { + return err + } + v := &model.View{ID: id, Name: s.Arg(0), Position: len(im.st.Views), MatchClients: []string{}, MatchDestinations: []string{}, Meta: im.meta()} + im.st.Views[id] = v + for _, st := range b.Stmts { + switch kw := st.Keyword(); kw { + case "match-clients": + if mb := st.FirstBlock(); mb != nil { + v.MatchClients = nonNil(elementStrings(mb)) + } + case "match-destinations": + if mb := st.FirstBlock(); mb != nil { + v.MatchDestinations = nonNil(elementStrings(mb)) + } + case "match-recursive-only": + v.MatchRecursiveOnly = model.Ptr(isYes(st.Arg(0))) + case "zone": + if err := im.zone(st, v); err != nil { + return err + } + case "key": + im.warn("%s: key %s defined inside view %s was moved to the top level", st.Pos, st.Arg(0), v.Name) + if err := im.key(st); err != nil { + return err + } + default: + im.addOption(model.ScopeView, v.ID, canonicalName(catalog.ScopeView, kw), restText(st), "") + } + } + return nil +} + +func isYes(s string) bool { + switch strings.ToLower(s) { + case "yes", "true", "1": + return true + } + return false +} + +func (im *importer) zone(s *namedconf.Stmt, v *model.View) error { + b := s.FirstBlock() + if b == nil { + return fmt.Errorf("%s: zone without a block", s.Pos) + } + if class := s.Arg(1); class != "" && !strings.EqualFold(class, "IN") { + im.skip("%s: zone %s of class %s (only class IN is supported)", s.Pos, s.Arg(0), class) + return nil + } + name, err := dnsname.ToASCII(s.Arg(0)) + if err != nil { + return fmt.Errorf("%s: %v", s.Pos, err) + } + id, err := im.id() + if err != nil { + return err + } + z := &model.Zone{ID: id, Name: dnsname.Fqdn(name), Meta: im.meta()} + if v != nil { + z.ViewID = v.ID + } + file := "" + var opts []*namedconf.Stmt + for _, st := range b.Stmts { + switch kw := st.Keyword(); kw { + case "type": + t := strings.ToLower(st.Arg(0)) + if syn, ok := catalog.ZoneTypeSynonyms[t]; ok { + t = syn + } + z.Type = t + case "file": + file = st.Arg(0) + case "in-view": + z.Type = model.ZoneInView + z.InView = st.Arg(0) + default: + opts = append(opts, st) + } + } + if z.Type == "" { + im.skip("%s: zone %s has no type", s.Pos, name) + return nil + } + if z.Type == "delegation-only" { + im.skip("%s: zone %s: delegation-only zones were removed in BIND 9.20", s.Pos, name) + return nil + } + im.st.Zones[z.ID] = z + for _, o := range opts { + im.addOption(model.ScopeZone, z.ID, canonicalName(catalog.ZoneScope(z.Type), o.Keyword()), restText(o), "") + } + report := ZoneReport{Name: dnsname.Display(z.Name), Type: z.Type} + if v != nil { + report.View = v.Name + } + abs := file + if file != "" && !filepath.IsAbs(file) { + abs = filepath.Join(im.dir, file) + } + report.File = abs + switch z.Type { + case model.ZonePrimary: + n, skipped, err := im.loadZoneFile(z, abs) + if err != nil { + return fmt.Errorf("zone %s (%s): %w", dnsname.Display(z.Name), abs, err) + } + report.Records, report.Skipped = n, skipped + for _, o := range opts { + if kw := o.Keyword(); kw == "allow-update" || kw == "update-policy" { + im.warn("zone %s uses %s (dynamic updates); Jarl rewrites zone files and won't apply such zones until %s is removed", dnsname.Display(z.Name), kw, kw) + } + } + case model.ZoneSecondary, model.ZoneMirror, model.ZoneStub, model.ZoneHint, model.ZoneRedirect: + // Keep using the existing file (named maintains it). + z.File = abs + } + im.rep.Zones = append(im.rep.Zones, report) + return nil +} + +// loadZoneFile parses a primary zone's file into records. +func (im *importer) loadZoneFile(z *model.Zone, path string) (int, int, error) { + if path == "" { + return 0, 0, errors.New("primary zone without a file") + } + type parsedRR struct { + rr dns.RR + comment string + } + var rrs []parsedRR + var defaultTTL uint32 + hasTTL := false + parse := func(src string, file string, withComments bool) error { + rrs = rrs[:0] + // Find $TTL ourselves: miekg/dns doesn't expose it. + hasTTL = false + for _, line := range strings.Split(src, "\n") { + f := strings.Fields(line) + if len(f) >= 2 && strings.EqualFold(f[0], "$TTL") { + if t, ok := ParseTTL(f[1]); ok { + defaultTTL, hasTTL = t, true + } + break + } + } + zp := dns.NewZoneParser(strings.NewReader(src), z.Name, file) + zp.SetIncludeAllowed(true) + for r, ok := zp.Next(); ok; r, ok = zp.Next() { + c := "" + if withComments { + c = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(zp.Comment()), ";")) + } + rrs = append(rrs, parsedRR{r, c}) + } + return zp.Err() + } + b, err := os.ReadFile(path) + if err != nil { + return 0, 0, err + } + if err := parse(string(b), path, true); err != nil { + if im.opts.Tools == nil { + return 0, 0, err + } + // Let named-checkzone produce a canonical dump instead. + dump, derr := im.opts.Tools.DumpZone(im.ctx, z.Name, path) + if derr != nil { + return 0, 0, fmt.Errorf("%v (named-checkzone: %v)", err, derr) + } + im.warn("zone %s: read through named-checkzone (%v); comments were not kept", dnsname.Display(z.Name), err) + if err := parse(dump, path, false); err != nil { + return 0, 0, err + } + } + if len(rrs) == 0 { + return 0, 0, errors.New("the zone file has no records") + } + soa, ok := rrs[0].rr.(*dns.SOA) + if !ok { + return 0, 0, errors.New("the first record is not the SOA") + } + z.SOA = model.SOA{MName: strings.ToLower(soa.Ns), RName: strings.ToLower(soa.Mbox), Serial: soa.Serial, Refresh: soa.Refresh, Retry: soa.Retry, Expire: soa.Expire, Minimum: soa.Minttl} + if !hasTTL { + // RFC 1035: without $TTL, the SOA minimum was the default. + defaultTTL = soa.Minttl + im.warn("zone %s has no $TTL; using %d (the SOA minimum) as the default TTL", dnsname.Display(z.Name), defaultTTL) + } + z.DefaultTTL = defaultTTL + n, skipped := 0, 0 + seen := map[string]bool{} + rrsetTTL := map[string]uint32{} + for _, p := range rrs[1:] { + h := p.rr.Header() + owner := strings.ToLower(h.Name) + typ := dns.TypeToString[h.Rrtype] + label := fmt.Sprintf("zone %s: %s %s", dnsname.Display(z.Name), owner, typ) + switch { + case h.Class != dns.ClassINET: + im.skip("%s: class %s is not supported", label, dns.ClassToString[h.Class]) + skipped++ + continue + case !dnsname.IsSubdomain(owner, z.Name): + im.skip("%s: outside the zone (named ignores it too)", label) + skipped++ + continue + case h.Rrtype == dns.TypeSOA: + im.skip("%s: extra SOA record", label) + skipped++ + continue + } + fields := strings.SplitN(p.rr.String(), "\t", 5) + data := "" + if len(fields) == 5 { + data = fields[4] + } + if typ == "" { + typ = fmt.Sprintf("TYPE%d", h.Rrtype) + } + parsed, err := rr.Parse(z.Name, owner, typ, data) + if err != nil { + im.skip("%s %s: %v", label, data, err) + skipped++ + continue + } + k := owner + "|" + parsed.Type + "|" + strings.ToLower(parsed.Data) + if seen[k] { + continue // duplicates are dropped silently, as named does + } + seen[k] = true + ttl := h.Ttl + setKey := owner + "|" + parsed.Type + if first, ok := rrsetTTL[setKey]; ok && first != ttl { + im.warn("%s: records of this RRset had different TTLs; using %d like named does", label, first) + ttl = first + } else { + rrsetTTL[setKey] = ttl + } + id, err := im.id() + if err != nil { + return 0, 0, err + } + r := &model.Record{ID: id, ZoneID: z.ID, Name: owner, Type: parsed.Type, Data: parsed.Data, Comment: p.comment, Meta: im.meta()} + if ttl != defaultTTL { + r.TTL = model.Ptr(ttl) + } + im.st.Records[id] = r + n++ + } + return n, skipped, nil +} + +// ParseTTL parses a TTL as BIND writes them: seconds, or a combination of +// units such as 1w2d3h4m5s. +func ParseTTL(s string) (uint32, bool) { + if s == "" { + return 0, false + } + var total, cur uint64 + digits := false + for _, c := range strings.ToLower(s) { + switch { + case c >= '0' && c <= '9': + cur = cur*10 + uint64(c-'0') + digits = true + case strings.ContainsRune("wdhms", c) && digits: + mult := map[rune]uint64{'w': 604800, 'd': 86400, 'h': 3600, 'm': 60, 's': 1}[c] + total += cur * mult + cur, digits = 0, false + default: + return 0, false + } + if total+cur > 0xffffffff { + return 0, false + } + } + total += cur + return uint32(total), true +} + +// ParsedRecord is a record read from zone file text. +type ParsedRecord struct { + Name string + Type string + TTL uint32 + Data string + Comment string +} + +// ParsedZone is the content of a zone file. +type ParsedZone struct { + SOA *dns.SOA + DefaultTTL uint32 + HasTTL bool + Records []ParsedRecord + Skipped []string +} + +// ParseZoneText parses zone file text for zone (an FQDN). $INCLUDE is not +// allowed. Records that are invalid, outside the zone or duplicated are +// reported in Skipped. +func ParseZoneText(zone, text string) (*ParsedZone, error) { + pz := &ParsedZone{} + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) >= 2 && strings.EqualFold(f[0], "$TTL") { + if t, ok := ParseTTL(f[1]); ok { + pz.DefaultTTL, pz.HasTTL = t, true + } + break + } + } + zp := dns.NewZoneParser(strings.NewReader(text), zone, "") + zp.SetIncludeAllowed(false) + if pz.HasTTL { + zp.SetDefaultTTL(pz.DefaultTTL) + } + seen := map[string]bool{} + for r, ok := zp.Next(); ok; r, ok = zp.Next() { + h := r.Header() + owner := strings.ToLower(h.Name) + typ := dns.TypeToString[h.Rrtype] + if typ == "" { + typ = fmt.Sprintf("TYPE%d", h.Rrtype) + } + if soa, isSOA := r.(*dns.SOA); isSOA { + if pz.SOA == nil && owner == zone { + pz.SOA = soa + } else { + pz.Skipped = append(pz.Skipped, owner+" SOA: only the zone's own SOA is used") + } + continue + } + if h.Class != dns.ClassINET { + pz.Skipped = append(pz.Skipped, fmt.Sprintf("%s %s: class %s is not supported", owner, typ, dns.ClassToString[h.Class])) + continue + } + if !dnsname.IsSubdomain(owner, zone) { + pz.Skipped = append(pz.Skipped, fmt.Sprintf("%s %s: outside the zone", owner, typ)) + continue + } + fields := strings.SplitN(r.String(), "\t", 5) + data := "" + if len(fields) == 5 { + data = fields[4] + } + p, err := rr.Parse(zone, owner, typ, data) + if err != nil { + pz.Skipped = append(pz.Skipped, fmt.Sprintf("%s %s %s: %v", owner, typ, data, err)) + continue + } + k := owner + "|" + p.Type + "|" + strings.ToLower(p.Data) + if seen[k] { + continue + } + seen[k] = true + c := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(zp.Comment()), ";")) + pz.Records = append(pz.Records, ParsedRecord{Name: owner, Type: p.Type, TTL: h.Ttl, Data: p.Data, Comment: c}) + } + if err := zp.Err(); err != nil { + return nil, err + } + if !pz.HasTTL && pz.SOA != nil { + pz.DefaultTTL = pz.SOA.Minttl + } + return pz, nil +} diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go new file mode 100644 index 0000000..cabdf9d --- /dev/null +++ b/internal/importer/importer_test.go @@ -0,0 +1,148 @@ +package importer + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "jarl/internal/model" + "jarl/internal/validate" +) + +// copyTree copies testdata/ to a temp dir, replacing @DIR@. +func copyTree(t *testing.T, name string) string { + t.Helper() + dst := t.TempDir() + src := filepath.Join("testdata", name) + entries, err := os.ReadDir(src) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + b, err := os.ReadFile(filepath.Join(src, e.Name())) + if err != nil { + t.Fatal(err) + } + b = []byte(strings.ReplaceAll(string(b), "@DIR@", dst)) + if err := os.WriteFile(filepath.Join(dst, e.Name()), b, 0o644); err != nil { + t.Fatal(err) + } + } + return dst +} + +func seqAlloc() func(context.Context, int) ([]int64, error) { + next := int64(0) + return func(_ context.Context, n int) ([]int64, error) { + out := make([]int64, n) + for i := range out { + next++ + out[i] = next + } + return out, nil + } +} + +func TestImportDebian(t *testing.T) { + dir := copyTree(t, "debian") + st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc(), User: "import"}) + if err != nil { + t.Fatal(err) + } + // rndc.key stays an include; its secret is not imported. + if len(rep.Kept) != 1 || !strings.HasSuffix(rep.Kept[0], "rndc.key") { + t.Errorf("kept = %v", rep.Kept) + } + if st.KeyByName("rndc-key") != nil { + t.Error("rndc key imported") + } + if k := st.KeyByName("xfer-key"); k == nil || k.Algorithm != "hmac-sha256" { + t.Errorf("xfer-key = %+v", k) + } + if a := st.ACLByName("homelab"); a == nil || strings.Join(a.Elements, ",") != "192.168.1.0/24,10.10.0.0/16,localhost" { + t.Errorf("acl = %+v", a) + } + if o := st.FindOption(model.ScopeOptions, 0, "allow-recursion"); o == nil || o.Value != "{ homelab; }" { + t.Errorf("allow-recursion = %+v", o) + } + if st.FindOption(model.ScopeTopLevel, 0, "controls") == nil || st.FindOption(model.ScopeTopLevel, 0, "logging") == nil { + t.Error("controls/logging not kept") + } + z := st.FindZone("adbraun.lol", 0) + if z == nil || z.Type != model.ZonePrimary || z.SOA.Serial != 2024051201 || z.SOA.RName != "admin.adbraun.lol." || z.DefaultTTL != 3600 { + t.Fatalf("zone = %+v", z) + } + if st.FindOption(model.ScopeZone, z.ID, "allow-transfer") == nil { + t.Error("zone option lost") + } + recs := map[string]*model.Record{} + for _, r := range st.ZoneRecords(z.ID) { + recs[r.Name+" "+r.Type+" "+r.Data] = r + } + if r := recs["nas.adbraun.lol. A 192.168.1.20"]; r == nil || r.Comment != "synology" || r.TTL != nil { + t.Errorf("nas = %+v", r) + } + if r := recs["www.adbraun.lol. A 192.168.1.11"]; r == nil || r.TTL == nil || *r.TTL != 300 { + t.Errorf("www = %+v", r) + } + if recs["adbraun.lol. MX 10 mail.adbraun.lol."] == nil || recs["media.adbraun.lol. CNAME nas.adbraun.lol."] == nil { + t.Errorf("records = %v", recs) + } + rz := st.FindZone("1.168.192.in-addr.arpa", 0) + if rz == nil { + t.Fatal("reverse zone missing") + } + if n := len(st.ZoneRecords(rz.ID)); n != 6 { // NS, 2 PTR, 3 generated PTR + t.Errorf("reverse zone has %d records", n) + } + if fz := st.FindZone("consul", 0); fz == nil || fz.Type != model.ZoneForward { + t.Error("forward zone missing") + } + // The imported configuration is valid (the open allow-query is fine + // because allow-recursion is restricted). + ps := validate.State(st, validate.Options{ExternalKeys: []string{"rndc-key"}}) + if validate.HasErrors(ps) { + t.Errorf("imported state has errors: %v", validate.Errors(ps)) + } + for _, p := range ps { + if p.Code == "security_open_resolver" { + t.Error("false open resolver warning") + } + } + if rep.Counts["zones"] != 3 || len(rep.Zones) != 3 { + t.Errorf("report = %+v", rep) + } +} + +func TestImportErrors(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`include "`+dir+`/missing.conf";`), 0o644) + if _, _, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}); err == nil { + t.Error("missing include accepted") + } + os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; };`), 0o644) + os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\nbad_name IN A 192.0.2.2\nother.test. IN A 192.0.2.3\n"), 0o644) + st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}) + if err != nil { + t.Fatal(err) + } + if len(rep.Skipped) != 2 || len(st.Records) != 2 { + t.Errorf("skipped = %v, records = %d", rep.Skipped, len(st.Records)) + } +} + +func TestParseTTL(t *testing.T) { + cases := map[string]uint32{"3600": 3600, "1h": 3600, "1h30m": 5400, "1W": 604800, "2d3s": 172803} + for in, want := range cases { + if got, ok := ParseTTL(in); !ok || got != want { + t.Errorf("ParseTTL(%q) = %d %v", in, got, ok) + } + } + for _, bad := range []string{"", "h", "1x", "-5"} { + if _, ok := ParseTTL(bad); ok { + t.Errorf("ParseTTL(%q) accepted", bad) + } + } +} diff --git a/internal/importer/testdata/debian/db.192.168.1 b/internal/importer/testdata/debian/db.192.168.1 new file mode 100644 index 0000000..712d8d3 --- /dev/null +++ b/internal/importer/testdata/debian/db.192.168.1 @@ -0,0 +1,7 @@ +$TTL 86400 +$ORIGIN 1.168.192.in-addr.arpa. +@ IN SOA ns1.adbraun.lol. admin.adbraun.lol. ( 2024051201 3600 900 1209600 300 ) +@ IN NS ns1.adbraun.lol. +2 IN PTR ns1.adbraun.lol. +20 IN PTR nas.adbraun.lol. +$GENERATE 100-102 $ IN PTR dhcp-$.adbraun.lol. diff --git a/internal/importer/testdata/debian/db.adbraun.lol b/internal/importer/testdata/debian/db.adbraun.lol new file mode 100644 index 0000000..13d77ed --- /dev/null +++ b/internal/importer/testdata/debian/db.adbraun.lol @@ -0,0 +1,17 @@ +$TTL 3600 +@ IN SOA ns1.adbraun.lol. admin.adbraun.lol. ( + 2024051201 ; serial + 3600 ; refresh + 900 ; retry + 1209600 ; expire + 300 ) ; negative cache TTL + IN NS ns1 + IN MX 10 mail +@ IN TXT "v=spf1 mx -all" +ns1 IN A 192.168.1.2 ; the DNS server itself +mail IN A 192.168.1.25 +www 300 IN A 192.168.1.10 +www 300 IN A 192.168.1.11 +nas IN A 192.168.1.20 ; synology +media IN CNAME nas +_acme-challenge 60 IN TXT "old-token" diff --git a/internal/importer/testdata/debian/named.conf b/internal/importer/testdata/debian/named.conf new file mode 100644 index 0000000..5061e71 --- /dev/null +++ b/internal/importer/testdata/debian/named.conf @@ -0,0 +1,7 @@ +// This is the primary configuration file for the BIND DNS server named. +include "@DIR@/named.conf.options"; +include "@DIR@/named.conf.local"; +include "@DIR@/rndc.key"; +controls { + inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; }; +}; diff --git a/internal/importer/testdata/debian/named.conf.local b/internal/importer/testdata/debian/named.conf.local new file mode 100644 index 0000000..6949a24 --- /dev/null +++ b/internal/importer/testdata/debian/named.conf.local @@ -0,0 +1,14 @@ +zone "adbraun.lol" { + type master; + file "@DIR@/db.adbraun.lol"; + allow-transfer { key xfer-key; }; +}; +zone "1.168.192.in-addr.arpa" { + type master; + file "@DIR@/db.192.168.1"; +}; +zone "consul" { + type forward; + forward only; + forwarders { 127.0.0.1 port 8600; }; +}; diff --git a/internal/importer/testdata/debian/named.conf.options b/internal/importer/testdata/debian/named.conf.options new file mode 100644 index 0000000..593d232 --- /dev/null +++ b/internal/importer/testdata/debian/named.conf.options @@ -0,0 +1,22 @@ +acl "homelab" { + 192.168.1.0/24; + 10.10.0.0/16; + localhost; +}; +key "xfer-key" { + algorithm hmac-sha256; + secret "Y2hhbmdlLW1lLWNoYW5nZS1tZS1jaGFuZ2UtbWUtMTIzNA=="; +}; +options { + directory "/var/cache/bind"; + recursion yes; + allow-recursion { homelab; }; + allow-query { any; }; + forwarders { 1.1.1.1; 9.9.9.9; }; + dnssec-validation auto; + listen-on-v6 { any; }; +}; +logging { + channel default_log { file "/var/log/named/default.log" versions 3 size 5m; severity info; print-time yes; }; + category default { default_log; }; +}; diff --git a/internal/importer/testdata/debian/rndc.key b/internal/importer/testdata/debian/rndc.key new file mode 100644 index 0000000..896fe36 --- /dev/null +++ b/internal/importer/testdata/debian/rndc.key @@ -0,0 +1,4 @@ +key "rndc-key" { + algorithm hmac-sha256; + secret "cm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQ="; +}; diff --git a/internal/model/changeset.go b/internal/model/changeset.go new file mode 100644 index 0000000..77694b3 --- /dev/null +++ b/internal/model/changeset.go @@ -0,0 +1,135 @@ +package model + +import ( + "encoding/json" + "time" +) + +// Changeset statuses. +const ( + ChangesetOpen = "open" + ChangesetApplied = "applied" + ChangesetDiscarded = "discarded" +) + +// Op is one operation in a changeset, for example "record.create". +type Op struct { + Seq int `json:"seq"` + Kind string `json:"op"` + // ID is the object the operation acts on. For create operations it is + // allocated when the operation is added, so later operations in the + // same changeset can refer to the new object. + ID int64 `json:"id,omitempty"` + // BaseVersion is the version of the object when the operation was + // staged. Applying fails with a conflict if the object has changed + // since (0 skips the check). + BaseVersion int `json:"base_version,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Summary string `json:"summary,omitempty"` + AddedAt time.Time `json:"added_at"` +} + +// Changeset is a group of operations applied together. +type Changeset struct { + ID int64 `json:"id"` + Description string `json:"description"` + Status string `json:"status"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Via string `json:"via"` // web, api, cli, import, system + Ops []Op `json:"ops"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + AppliedAt *time.Time `json:"applied_at,omitempty"` + AppliedBy string `json:"applied_by,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Diff string `json:"diff,omitempty"` +} + +// Grant gives access to a kind of resource. Filters narrow it: empty +// filters match everything. +type Grant struct { + Resource string `json:"resource"` // zones, records, views, acls, tsig, options, audit, * + Access string `json:"access"` // read or write (write implies read) + Zones []string `json:"zones,omitempty"` + Views []string `json:"views,omitempty"` + Names []string `json:"names,omitempty"` + Types []string `json:"types,omitempty"` +} + +// User is a person or service account. +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + HasPassword bool `json:"has_password"` + Admin bool `json:"admin"` + Grants []Grant `json:"grants"` + Disabled bool `json:"disabled"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` +} + +// Token is an API key. The secret is shown once at creation and only its +// hash is stored. +type Token struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Username string `json:"username,omitempty"` + Name string `json:"name"` + PublicID string `json:"public_id"` + SecretHash string `json:"-"` + // Grants restrict the token to a subset of its user's permissions; + // nil means the token has all of the user's permissions. + Grants []Grant `json:"grants"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + LastUsedIP string `json:"last_used_ip,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` +} + +// Session is a logged-in web session. +type Session struct { + IDHash string `json:"-"` + UserID int64 `json:"user_id"` + CSRFToken string `json:"-"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + LastSeenAt time.Time `json:"last_seen_at"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` +} + +// AuditEvent records an action. +type AuditEvent struct { + ID int64 `json:"id"` + Time time.Time `json:"time"` + UserID int64 `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + Auth string `json:"auth,omitempty"` // session, token, local + TokenID int64 `json:"token_id,omitempty"` + TokenName string `json:"token_name,omitempty"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Action string `json:"action"` + ObjectType string `json:"object_type,omitempty"` + ObjectID int64 `json:"object_id,omitempty"` + ObjectName string `json:"object_name,omitempty"` + ChangesetID int64 `json:"changeset_id,omitempty"` + Outcome string `json:"outcome"` // success, failure, denied + Message string `json:"message,omitempty"` + Details json.RawMessage `json:"details,omitempty"` +} + +// DeployedFile records a file Jarl wrote, for drift detection. +type DeployedFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Kind string `json:"kind"` + WrittenAt time.Time `json:"written_at"` + ChangesetID int64 `json:"changeset_id"` +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..e66bb27 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,568 @@ +// Package model defines Jarl's configuration objects and the in-memory +// State they live in. +// +// The State is the complete desired configuration of the name server. It is +// loaded from the database, modified by applying changeset operations to a +// copy, validated and rendered to BIND configuration files, and only then +// written back. +package model + +import ( + "encoding/json" + "sort" + "strings" + "time" + + "jarl/internal/dnsname" +) + +// Meta holds bookkeeping fields shared by all objects. +type Meta struct { + // Version is incremented on every change and used for optimistic + // concurrency control. + Version int `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy string `json:"created_by,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` +} + +// Touch updates the meta data for a modification by user at time now. +func (m *Meta) Touch(user string, now time.Time) { + if m.Version == 0 { + m.CreatedAt, m.CreatedBy = now, user + } + m.Version++ + m.UpdatedAt, m.UpdatedBy = now, user +} + +// View is a BIND view. +type View struct { + ID int64 `json:"id"` + Name string `json:"name"` + Position int `json:"position"` + MatchClients []string `json:"match_clients"` + MatchDestinations []string `json:"match_destinations"` + MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"` + Comment string `json:"comment"` + Meta +} + +// ACL is a named address match list. +type ACL struct { + ID int64 `json:"id"` + Name string `json:"name"` + Elements []string `json:"elements"` + Comment string `json:"comment"` + Meta +} + +// TSIGKey is a shared secret key for TSIG. +type TSIGKey struct { + ID int64 `json:"id"` + Name string `json:"name"` + Algorithm string `json:"algorithm"` + Secret string `json:"secret,omitempty"` + Comment string `json:"comment"` + Meta +} + +// Zone types. +const ( + ZonePrimary = "primary" + ZoneSecondary = "secondary" + ZoneMirror = "mirror" + ZoneStub = "stub" + ZoneStaticStub = "static-stub" + ZoneForward = "forward" + ZoneHint = "hint" + ZoneRedirect = "redirect" + ZoneInView = "in-view" +) + +// ZoneTypes lists the supported zone types. +var ZoneTypes = []string{ZonePrimary, ZoneSecondary, ZoneMirror, ZoneStub, ZoneStaticStub, ZoneForward, ZoneHint, ZoneRedirect, ZoneInView} + +// Serial policies. +const ( + SerialDate = "date" + SerialIncrement = "increment" + SerialUnixtime = "unixtime" +) + +// SOA holds the SOA fields of a primary zone. The serial is maintained by +// Jarl and bumped automatically when the zone's data changes. +type SOA struct { + MName string `json:"mname"` + RName string `json:"rname"` + Serial uint32 `json:"serial"` + Refresh uint32 `json:"refresh"` + Retry uint32 `json:"retry"` + Expire uint32 `json:"expire"` + Minimum uint32 `json:"minimum"` +} + +// Zone is a BIND zone. +type Zone struct { + ID int64 `json:"id"` + Name string `json:"name"` + ViewID int64 `json:"view_id,omitempty"` // 0 when views are not used + Type string `json:"type"` + // InView names the view whose zone of the same name this zone shares + // (type in-view only). + InView string `json:"in_view,omitempty"` + // File is the zone file. It is generated for primary zones and for + // zones named loads from transfers (secondary, mirror, stub), unless + // set explicitly; hint and redirect zones must set it. + File string `json:"file,omitempty"` + SOA SOA `json:"soa"` + DefaultTTL uint32 `json:"default_ttl"` + SerialPolicy string `json:"serial_policy,omitempty"` + Comment string `json:"comment"` + Meta +} + +// HasRecords reports whether Jarl manages the zone's records. +func (z *Zone) HasRecords() bool { return z.Type == ZonePrimary } + +// Record is a resource record in a primary zone. +type Record struct { + ID int64 `json:"id"` + ZoneID int64 `json:"zone_id"` + Name string `json:"name"` // canonical FQDN + Type string `json:"type"` + // TTL is nil when the record uses the zone's default TTL. + TTL *uint32 `json:"ttl,omitempty"` + Data string `json:"data"` // canonical presentation format + Comment string `json:"comment"` + Meta +} + +// EffectiveTTL returns the record's TTL, falling back to the zone default. +func (r *Record) EffectiveTTL(z *Zone) uint32 { + if r.TTL != nil { + return *r.TTL + } + return z.DefaultTTL +} + +// Option scopes. +const ( + ScopeOptions = "options" // options { } block + ScopeView = "view" // a view + ScopeZone = "zone" // a zone + ScopeTopLevel = "toplevel" // a free-form top-level statement +) + +// Option is a configuration statement: a server option, a view or zone +// option, or a free-form top-level statement such as logging or controls. +// Value holds everything after the name, in named.conf syntax. +type Option struct { + ID int64 `json:"id"` + Scope string `json:"scope"` + ScopeID int64 `json:"scope_id,omitempty"` + Name string `json:"name"` + Value string `json:"value"` + Position int `json:"position"` + Comment string `json:"comment"` + Meta +} + +// Text returns the option as a single named.conf statement. +func (o *Option) Text() string { + if o.Value == "" { + return o.Name + ";" + } + return o.Name + " " + o.Value + ";" +} + +// Settings are server-wide Jarl settings, mostly defaults for new zones. +type Settings struct { + DefaultTTL uint32 `json:"default_ttl"` + Nameservers []string `json:"nameservers"` + RName string `json:"rname"` + Refresh uint32 `json:"refresh"` + Retry uint32 `json:"retry"` + Expire uint32 `json:"expire"` + NegativeTTL uint32 `json:"negative_ttl"` + SerialPolicy string `json:"serial_policy"` + Version int `json:"version"` +} + +// DefaultSettings returns the settings of a fresh installation. +func DefaultSettings() Settings { + return Settings{ + DefaultTTL: 3600, + Refresh: 3600, + Retry: 900, + Expire: 1209600, + NegativeTTL: 300, + SerialPolicy: SerialDate, + } +} + +// State is the complete desired configuration. +type State struct { + Settings Settings + Views map[int64]*View + ACLs map[int64]*ACL + Keys map[int64]*TSIGKey + Zones map[int64]*Zone + Records map[int64]*Record + Options map[int64]*Option +} + +// NewState returns an empty state with default settings. +func NewState() *State { + return &State{ + Settings: DefaultSettings(), + Views: map[int64]*View{}, + ACLs: map[int64]*ACL{}, + Keys: map[int64]*TSIGKey{}, + Zones: map[int64]*Zone{}, + Records: map[int64]*Record{}, + Options: map[int64]*Option{}, + } +} + +// Clone returns a deep copy of the state. +func (s *State) Clone() *State { + c := &State{ + Settings: s.Settings, + Views: make(map[int64]*View, len(s.Views)), + ACLs: make(map[int64]*ACL, len(s.ACLs)), + Keys: make(map[int64]*TSIGKey, len(s.Keys)), + Zones: make(map[int64]*Zone, len(s.Zones)), + Records: make(map[int64]*Record, len(s.Records)), + Options: make(map[int64]*Option, len(s.Options)), + } + c.Settings.Nameservers = append([]string(nil), s.Settings.Nameservers...) + for id, v := range s.Views { + n := *v + n.MatchClients = append([]string(nil), v.MatchClients...) + n.MatchDestinations = append([]string(nil), v.MatchDestinations...) + if v.MatchRecursiveOnly != nil { + b := *v.MatchRecursiveOnly + n.MatchRecursiveOnly = &b + } + c.Views[id] = &n + } + for id, a := range s.ACLs { + n := *a + n.Elements = append([]string(nil), a.Elements...) + c.ACLs[id] = &n + } + for id, k := range s.Keys { + n := *k + c.Keys[id] = &n + } + for id, z := range s.Zones { + n := *z + c.Zones[id] = &n + } + for id, r := range s.Records { + n := *r + if r.TTL != nil { + t := *r.TTL + n.TTL = &t + } + c.Records[id] = &n + } + for id, o := range s.Options { + n := *o + c.Options[id] = &n + } + return c +} + +// OrderedViews returns the views in match order. +func (s *State) OrderedViews() []*View { + out := make([]*View, 0, len(s.Views)) + for _, v := range s.Views { + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Position != out[j].Position { + return out[i].Position < out[j].Position + } + return out[i].ID < out[j].ID + }) + return out +} + +// ViewByName finds a view by name (case-insensitive). +func (s *State) ViewByName(name string) *View { + for _, v := range s.Views { + if strings.EqualFold(v.Name, name) { + return v + } + } + return nil +} + +// ViewName returns the name of the zone's view, or "" without views. +func (s *State) ViewName(z *Zone) string { + if v := s.Views[z.ViewID]; v != nil { + return v.Name + } + return "" +} + +// ACLByName finds an ACL by name (case-insensitive, as BIND does). +func (s *State) ACLByName(name string) *ACL { + for _, a := range s.ACLs { + if strings.EqualFold(a.Name, name) { + return a + } + } + return nil +} + +// KeyByName finds a TSIG key by name. +func (s *State) KeyByName(name string) *TSIGKey { + n := NormalizeKeyName(name) + for _, k := range s.Keys { + if k.Name == n { + return k + } + } + return nil +} + +// NormalizeKeyName returns the canonical form of a key name: lowercase, +// without a trailing dot. +func NormalizeKeyName(name string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") +} + +// SortedACLs returns ACLs ordered by name. +func (s *State) SortedACLs() []*ACL { + out := make([]*ACL, 0, len(s.ACLs)) + for _, a := range s.ACLs { + out = append(out, a) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) + return out +} + +// SortedKeys returns TSIG keys ordered by name. +func (s *State) SortedKeys() []*TSIGKey { + out := make([]*TSIGKey, 0, len(s.Keys)) + for _, k := range s.Keys { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// ZonesInView returns the zones of a view (viewID 0 for "no view"), sorted +// by name. +func (s *State) ZonesInView(viewID int64) []*Zone { + var out []*Zone + for _, z := range s.Zones { + if z.ViewID == viewID { + out = append(out, z) + } + } + SortZones(out) + return out +} + +// SortedZones returns all zones ordered by view position and name. +func (s *State) SortedZones() []*Zone { + out := make([]*Zone, 0, len(s.Zones)) + for _, z := range s.Zones { + out = append(out, z) + } + pos := map[int64]int{} + for i, v := range s.OrderedViews() { + pos[v.ID] = i + 1 + } + sort.Slice(out, func(i, j int) bool { + if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj { + return pi < pj + } + return CanonicalLess(out[i].Name, out[j].Name) + }) + return out +} + +// SortZones sorts zones by name in DNS canonical order. +func SortZones(zs []*Zone) { + sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) }) +} + +// FindZone finds a zone by name within a view (viewID 0 for no view). +func (s *State) FindZone(name string, viewID int64) *Zone { + name = dnsname.Fqdn(name) + for _, z := range s.Zones { + if z.Name == name && z.ViewID == viewID { + return z + } + } + return nil +} + +// ZonesNamed returns all zones with the given name across views. +func (s *State) ZonesNamed(name string) []*Zone { + name = dnsname.Fqdn(name) + var out []*Zone + for _, z := range s.Zones { + if z.Name == name { + out = append(out, z) + } + } + return out +} + +// ZoneRecords returns the records of a zone in display order. +func (s *State) ZoneRecords(zoneID int64) []*Record { + var out []*Record + for _, r := range s.Records { + if r.ZoneID == zoneID { + out = append(out, r) + } + } + SortRecords(out, s.Zones[zoneID]) + return out +} + +// RecordsByZone groups all records by zone ID (unsorted). +func (s *State) RecordsByZone() map[int64][]*Record { + out := map[int64][]*Record{} + for _, r := range s.Records { + out[r.ZoneID] = append(out[r.ZoneID], r) + } + return out +} + +// ScopedOptions returns the options of a scope, in position order. +func (s *State) ScopedOptions(scope string, scopeID int64) []*Option { + var out []*Option + for _, o := range s.Options { + if o.Scope == scope && o.ScopeID == scopeID { + out = append(out, o) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Position != out[j].Position { + return out[i].Position < out[j].Position + } + return out[i].ID < out[j].ID + }) + return out +} + +// FindOption returns the first option with the given name in a scope. +func (s *State) FindOption(scope string, scopeID int64, name string) *Option { + for _, o := range s.ScopedOptions(scope, scopeID) { + if o.Name == name { + return o + } + } + return nil +} + +// typeOrder puts SOA and NS first when sorting records. +func typeOrder(t string) string { + switch t { + case "SOA": + return "0" + case "NS": + return "1" + } + return "2" + t +} + +// SortRecords sorts records in zone-file order: apex first, then names in +// DNS canonical order (so subdomains group together), then by type and +// data. +func SortRecords(rs []*Record, z *Zone) { + apex := "" + if z != nil { + apex = z.Name + } + sort.SliceStable(rs, func(i, j int) bool { + a, b := rs[i], rs[j] + if a.Name != b.Name { + if a.Name == apex { + return true + } + if b.Name == apex { + return false + } + return CanonicalLess(a.Name, b.Name) + } + if a.Type != b.Type { + return typeOrder(a.Type) < typeOrder(b.Type) + } + if a.Data != b.Data { + return a.Data < b.Data + } + return a.ID < b.ID + }) +} + +// CanonicalLess compares names in DNS canonical order (RFC 4034 section +// 6.1): by label from the right. +func CanonicalLess(a, b string) bool { + la := strings.Split(strings.TrimSuffix(a, "."), ".") + lb := strings.Split(strings.TrimSuffix(b, "."), ".") + for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 { + if la[i] != lb[j] { + return la[i] < lb[j] + } + } + return len(la) < len(lb) +} + +// RRSetKey identifies an RRset. +type RRSetKey struct { + Name string + Type string +} + +// RRSets groups records into RRsets. +func RRSets(rs []*Record) map[RRSetKey][]*Record { + out := map[RRSetKey][]*Record{} + for _, r := range rs { + k := RRSetKey{r.Name, r.Type} + out[k] = append(out[k], r) + } + return out +} + +// Ptr returns a pointer to v. +func Ptr[T any](v T) *T { return &v } + +// Opt is an optional value for partial updates that distinguishes "not +// set" from "set to null". +type Opt[T any] struct { + Set bool + Null bool + Value T +} + +// Some returns an Opt holding v. +func Some[T any](v T) Opt[T] { return Opt[T]{Set: true, Value: v} } + +// IsZero reports whether the option was not set (used by omitzero). +func (o Opt[T]) IsZero() bool { return !o.Set } + +// UnmarshalJSON implements json.Unmarshaler. +func (o *Opt[T]) UnmarshalJSON(b []byte) error { + o.Set = true + if string(b) == "null" { + o.Null = true + return nil + } + return json.Unmarshal(b, &o.Value) +} + +// MarshalJSON implements json.Marshaler. +func (o Opt[T]) MarshalJSON() ([]byte, error) { + if !o.Set || o.Null { + return []byte("null"), nil + } + return json.Marshal(o.Value) +} diff --git a/internal/namedconf/acl.go b/internal/namedconf/acl.go new file mode 100644 index 0000000..9f292f3 --- /dev/null +++ b/internal/namedconf/acl.go @@ -0,0 +1,357 @@ +package namedconf + +import ( + "errors" + "fmt" + "net/netip" + "strconv" + "strings" +) + +// AMKind is the kind of an address match list element. +type AMKind int + +const ( + AMPrefix AMKind = iota // an address or prefix + AMKey // key + AMName // an ACL name (or built-in ACL) + AMNested // { ... } + AMOther // anything else (e.g. geoip), passed through +) + +// Builtin ACL names. +var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true, "localnets": true} + +// AMElement is one element of an address match list. +type AMElement struct { + Negated bool + Kind AMKind + Prefix netip.Prefix // AMPrefix + Name string // AMKey, AMName + Nested []AMElement // AMNested + Raw string // AMOther +} + +// String returns the element in canonical named.conf syntax, without the +// trailing semicolon. +func (e AMElement) String() string { + var s string + switch e.Kind { + case AMPrefix: + if e.Prefix.IsSingleIP() { + s = e.Prefix.Addr().String() + } else { + s = e.Prefix.String() + } + case AMKey: + s = "key " + quoteIfNeeded(e.Name) + case AMName: + s = quoteIfNeeded(e.Name) + case AMNested: + parts := make([]string, len(e.Nested)) + for i, n := range e.Nested { + parts[i] = n.String() + ";" + } + s = "{ " + strings.Join(parts, " ") + " }" + case AMOther: + s = e.Raw + } + if e.Negated { + return "!" + s + } + return s +} + +// IsBuiltin reports whether the element is one of the built-in ACL names. +func (e AMElement) IsBuiltin() bool { return e.Kind == AMName && BuiltinACLs[e.Name] } + +func quoteIfNeeded(s string) string { + for i := 0; i < len(s); i++ { + c := s[i] + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.') { + return QuoteString(s) + } + } + if s == "" { + return `""` + } + return s +} + +// ParseAMList parses the statements of an address match list block. +func ParseAMList(b *Block) ([]AMElement, error) { + var out []AMElement + for _, st := range b.Stmts { + e, err := parseAMItems(st.Items) + if err != nil { + return nil, &Error{Pos: st.Pos, Msg: err.Error()} + } + out = append(out, e) + } + return out, nil +} + +// ParseAMElement parses a single element given as text, such as +// "!192.168.1.0/24", "key xfer" or "trusted". +func ParseAMElement(s string) (AMElement, error) { + s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), ";")) + if s == "" { + return AMElement{}, errors.New("empty element") + } + st, err := ParseStatement(s) + if err != nil { + var pe *Error + if errors.As(err, &pe) { + return AMElement{}, errors.New(pe.Msg) + } + return AMElement{}, err + } + return parseAMItems(st.Items) +} + +// ParseAMListString parses a whole list given as "{ a; b; }" or "a; b". +func ParseAMListString(s string) ([]AMElement, error) { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, ";") + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "{") { + s = "{ " + s + "; }" + } + st, err := ParseStatement("x " + s) + if err != nil { + return nil, err + } + b := st.FirstBlock() + if b == nil || len(st.Items) != 2 { + return nil, errors.New("malformed address match list") + } + return ParseAMList(b) +} + +func parseAMItems(items []Item) (AMElement, error) { + var e AMElement + if len(items) > 0 && items[0].Bang { + e.Negated = true + items = items[1:] + } + if len(items) == 0 { + return e, errors.New("empty address match element") + } + first := items[0] + if first.Block != nil { + if len(items) != 1 { + return e, errors.New("unexpected text after nested address match list") + } + nested, err := ParseAMList(first.Block) + if err != nil { + return e, err + } + e.Kind = AMNested + e.Nested = nested + return e, nil + } + if first.Bang { + return e, errors.New("double negation") + } + word := first.Value.Text + lw := strings.ToLower(word) + if !first.Value.Quoted && lw == "key" { + if len(items) != 2 || items[1].Value == nil { + return e, errors.New("'key' must be followed by a key name") + } + e.Kind = AMKey + e.Name = strings.ToLower(strings.TrimSuffix(items[1].Value.Text, ".")) + return e, nil + } + if len(items) > 1 { + // geoip and other multi-word elements are passed through. + if lw == "geoip" { + e.Kind = AMOther + e.Raw = Compact(items) + return e, nil + } + return e, fmt.Errorf("unexpected text after %q", word) + } + if !first.Value.Quoted || BuiltinACLs[lw] { + if p, ok, err := parsePrefix(word); ok { + if err != nil { + return e, err + } + e.Kind = AMPrefix + e.Prefix = p + return e, nil + } + } + if BuiltinACLs[lw] { + e.Kind = AMName + e.Name = lw + return e, nil + } + if err := CheckACLName(word); err != nil { + return e, err + } + e.Kind = AMName + e.Name = word + return e, nil +} + +// CheckACLName validates the name of a user-defined ACL. +func CheckACLName(name string) error { + if name == "" { + return errors.New("ACL name is empty") + } + if len(name) > 64 { + return errors.New("ACL name is longer than 64 characters") + } + for i := 0; i < len(name); i++ { + c := name[i] + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.') { + return fmt.Errorf("ACL name %q may only contain letters, digits, '-', '_' and '.'", name) + } + } + if c := name[0]; c >= '0' && c <= '9' { + // Avoid names that look like addresses. + if _, ok, _ := parsePrefix(name); ok { + return fmt.Errorf("ACL name %q looks like an address", name) + } + } + return nil +} + +// parsePrefix parses an address or prefix as BIND does, including the IPv4 +// shorthand where trailing zero octets are omitted ("10/8", "192.168/16"). +// ok is false if s doesn't look like an address at all. Prefixes with host +// bits set are rejected, matching named's "address/prefix length mismatch". +func parsePrefix(s string) (netip.Prefix, bool, error) { + addrPart, lenPart, hasLen := strings.Cut(s, "/") + if strings.Contains(addrPart, ":") { + a, err := netip.ParseAddr(addrPart) + if err != nil { + return netip.Prefix{}, true, fmt.Errorf("invalid IPv6 address %q", addrPart) + } + if a.Zone() != "" { + return netip.Prefix{}, true, fmt.Errorf("zoned IPv6 address %q is not allowed", addrPart) + } + return finishPrefix(s, a, lenPart, hasLen, 128) + } + // IPv4 (possibly shorthand). Must be digits and dots only. + if addrPart == "" { + return netip.Prefix{}, false, nil + } + for i := 0; i < len(addrPart); i++ { + if c := addrPart[i]; !(c >= '0' && c <= '9' || c == '.') { + return netip.Prefix{}, false, nil + } + } + octets := strings.Split(addrPart, ".") + if len(octets) > 4 { + return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q", addrPart) + } + if len(octets) < 4 && !hasLen { + return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q (abbreviated addresses need a prefix length)", addrPart) + } + var b [4]byte + for i, o := range octets { + if o == "" || len(o) > 3 { + return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q", addrPart) + } + n, err := strconv.Atoi(o) + if err != nil || n > 255 { + return netip.Prefix{}, true, fmt.Errorf("invalid IPv4 address %q", addrPart) + } + b[i] = byte(n) + } + return finishPrefix(s, netip.AddrFrom4(b), lenPart, hasLen, 32) +} + +func finishPrefix(orig string, a netip.Addr, lenPart string, hasLen bool, max int) (netip.Prefix, bool, error) { + bits := max + if hasLen { + n, err := strconv.Atoi(lenPart) + if err != nil || n < 0 || n > max { + return netip.Prefix{}, true, fmt.Errorf("invalid prefix length in %q", orig) + } + bits = n + } + p := netip.PrefixFrom(a, bits) + if p.Masked() != p { + return netip.Prefix{}, true, fmt.Errorf("%q: address/prefix length mismatch (host bits are set; did you mean %s?)", orig, p.Masked()) + } + return p, true, nil +} + +// ParseAddr parses a plain IP address (no prefix) as used by forwarders, +// primaries and similar options. +func ParseAddr(s string) (netip.Addr, error) { + a, err := netip.ParseAddr(s) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IP address %q", s) + } + return a, nil +} + +// AMRefs returns the ACL names and key names referenced by elements, +// including nested lists. Built-in ACLs are not included. +func AMRefs(elems []AMElement) (acls, keys []string) { + var walk func([]AMElement) + walk = func(es []AMElement) { + for _, e := range es { + switch e.Kind { + case AMName: + if !BuiltinACLs[e.Name] { + acls = append(acls, e.Name) + } + case AMKey: + keys = append(keys, e.Name) + case AMNested: + walk(e.Nested) + } + } + } + walk(elems) + return acls, keys +} + +// Shadowed returns human-readable warnings for elements that can never +// match because an earlier element always matches first (BIND uses first +// match). Only address/prefix elements and "any" are considered. +func Shadowed(elems []AMElement) []string { + var warns []string + for i, e := range elems { + if e.Kind != AMPrefix && e.Kind != AMName { + continue + } + for j := 0; j < i; j++ { + prev := elems[j] + if prev.Kind == AMName && prev.Name == "any" { + warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String())) + break + } + if prev.Kind == AMPrefix && e.Kind == AMPrefix && + prev.Prefix.Addr().Is4() == e.Prefix.Addr().Is4() && + prev.Prefix.Bits() <= e.Prefix.Bits() && prev.Prefix.Contains(e.Prefix.Addr()) { + warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String())) + break + } + } + } + return warns +} + +// ContainsAny reports whether elems contain a non-negated "any" before any +// negated element would restrict it, which makes the list match everyone. +// It is used for security lints (open resolvers, open transfers). +func ContainsAny(elems []AMElement) bool { + for _, e := range elems { + if e.Negated { + return false + } + if e.Kind == AMName && e.Name == "any" { + return true + } + if e.Kind == AMPrefix && e.Prefix.Bits() == 0 { + return true + } + } + return false +} diff --git a/internal/namedconf/ast.go b/internal/namedconf/ast.go new file mode 100644 index 0000000..411bfd6 --- /dev/null +++ b/internal/namedconf/ast.go @@ -0,0 +1,157 @@ +package namedconf + +import ( + "fmt" + "strings" +) + +// Value is a single word or quoted string. +type Value struct { + Text string // unescaped text + Quoted bool + Pos Pos +} + +// Block is a brace-enclosed list of statements. +type Block struct { + Stmts []*Stmt + Pos Pos +} + +// Item is one element of a statement: either a value, a "!" negation +// marker, or a block. +type Item struct { + Value *Value + Block *Block + Bang bool +} + +// Stmt is a statement: a sequence of items terminated by a semicolon. +type Stmt struct { + Items []Item + Pos Pos +} + +// Keyword returns the statement's first word, lowercased, or "" if the +// statement doesn't start with a value. +func (s *Stmt) Keyword() string { + if len(s.Items) == 0 || s.Items[0].Value == nil { + return "" + } + return strings.ToLower(s.Items[0].Value.Text) +} + +// Arg returns the text of the n-th value after the keyword (0-based), or "". +func (s *Stmt) Arg(n int) string { + idx := 0 + for _, it := range s.Items[1:] { + if it.Value == nil { + break + } + if idx == n { + return it.Value.Text + } + idx++ + } + return "" +} + +// Values returns the texts of the leading values after the keyword, up to +// the first block. +func (s *Stmt) Values() []string { + var out []string + for _, it := range s.Items[1:] { + if it.Value == nil { + break + } + out = append(out, it.Value.Text) + } + return out +} + +// FirstBlock returns the statement's first block, or nil. +func (s *Stmt) FirstBlock() *Block { + for _, it := range s.Items { + if it.Block != nil { + return it.Block + } + } + return nil +} + +// Rest returns a copy of the statement without its keyword. +func (s *Stmt) Rest() *Stmt { + if len(s.Items) == 0 { + return &Stmt{Pos: s.Pos} + } + return &Stmt{Items: append([]Item(nil), s.Items[1:]...), Pos: s.Pos} +} + +// Find returns the statements in stmts with the given keyword. +func Find(stmts []*Stmt, keyword string) []*Stmt { + var out []*Stmt + for _, s := range stmts { + if s.Keyword() == keyword { + out = append(out, s) + } + } + return out +} + +// FindOne returns the first statement with the given keyword, or nil. +func FindOne(stmts []*Stmt, keyword string) *Stmt { + for _, s := range stmts { + if s.Keyword() == keyword { + return s + } + } + return nil +} + +// NewStmt builds a statement from values: strings become unquoted words and +// *Block become blocks. Use Q to make a quoted string. +func NewStmt(items ...any) *Stmt { + st := &Stmt{} + for _, it := range items { + switch v := it.(type) { + case string: + st.Items = append(st.Items, Item{Value: &Value{Text: v}}) + case Quoted: + st.Items = append(st.Items, Item{Value: &Value{Text: string(v), Quoted: true}}) + case *Block: + st.Items = append(st.Items, Item{Block: v}) + case Item: + st.Items = append(st.Items, v) + default: + panic(fmt.Sprintf("namedconf.NewStmt: unsupported item %T", it)) + } + } + return st +} + +// Quoted marks a string for NewStmt as a quoted string. +type Quoted string + +// NewBlock builds a block from statements. +func NewBlock(stmts ...*Stmt) *Block { return &Block{Stmts: stmts} } + +// Clone returns a deep copy of the statement. +func (s *Stmt) Clone() *Stmt { + out := &Stmt{Pos: s.Pos, Items: make([]Item, len(s.Items))} + for i, it := range s.Items { + switch { + case it.Value != nil: + v := *it.Value + out.Items[i] = Item{Value: &v} + case it.Block != nil: + b := &Block{Pos: it.Block.Pos} + for _, st := range it.Block.Stmts { + b.Stmts = append(b.Stmts, st.Clone()) + } + out.Items[i] = Item{Block: b} + default: + out.Items[i] = it + } + } + return out +} diff --git a/internal/namedconf/lexer.go b/internal/namedconf/lexer.go new file mode 100644 index 0000000..ecf2fb5 --- /dev/null +++ b/internal/namedconf/lexer.go @@ -0,0 +1,189 @@ +// Package namedconf parses and prints the named.conf configuration syntax. +// +// The parser is deliberately generic: a configuration is a list of +// statements, and each statement is a sequence of values (words or quoted +// strings) and blocks, terminated by a semicolon. This is enough to +// represent every BIND 9 statement without knowing its grammar, which is +// checked separately by the options catalog and, ultimately, by +// named-checkconf. +package namedconf + +import ( + "fmt" + "strings" +) + +// Pos is a position in a configuration file. +type Pos struct { + File string + Line int +} + +func (p Pos) String() string { + if p.File == "" { + return fmt.Sprintf("line %d", p.Line) + } + return fmt.Sprintf("%s:%d", p.File, p.Line) +} + +type tokenKind int + +const ( + tokEOF tokenKind = iota + tokWord + tokString + tokLBrace + tokRBrace + tokSemi + tokBang +) + +func (k tokenKind) String() string { + switch k { + case tokEOF: + return "end of input" + case tokWord: + return "word" + case tokString: + return "quoted string" + case tokLBrace: + return "'{'" + case tokRBrace: + return "'}'" + case tokSemi: + return "';'" + case tokBang: + return "'!'" + } + return "?" +} + +type token struct { + kind tokenKind + text string + pos Pos +} + +// Error is a syntax error with a position. +type Error struct { + Pos Pos + Msg string +} + +func (e *Error) Error() string { return e.Pos.String() + ": " + e.Msg } + +type lexer struct { + src string + i int + line int + file string +} + +func (l *lexer) errorf(format string, args ...any) error { + return &Error{Pos: Pos{File: l.file, Line: l.line}, Msg: fmt.Sprintf(format, args...)} +} + +// next returns the next token, skipping whitespace and all three comment +// styles (/* */, // and #). +func (l *lexer) next() (token, error) { + for { + if l.i >= len(l.src) { + return token{kind: tokEOF, pos: Pos{l.file, l.line}}, nil + } + c := l.src[l.i] + switch { + case c == '\n': + l.line++ + l.i++ + case c == ' ' || c == '\t' || c == '\r' || c == '\f' || c == '\v': + l.i++ + case c == '#': + l.skipLine() + case c == '/' && l.peek(1) == '/': + l.skipLine() + case c == '/' && l.peek(1) == '*': + start := l.line + end := strings.Index(l.src[l.i+2:], "*/") + if end < 0 { + l.line = start + return token{}, l.errorf("unterminated /* comment") + } + l.line += strings.Count(l.src[l.i:l.i+2+end], "\n") + l.i += 2 + end + 2 + default: + return l.lexToken() + } + } +} + +func (l *lexer) peek(n int) byte { + if l.i+n < len(l.src) { + return l.src[l.i+n] + } + return 0 +} + +func (l *lexer) skipLine() { + for l.i < len(l.src) && l.src[l.i] != '\n' { + l.i++ + } +} + +func (l *lexer) lexToken() (token, error) { + pos := Pos{l.file, l.line} + c := l.src[l.i] + switch c { + case '{': + l.i++ + return token{tokLBrace, "{", pos}, nil + case '}': + l.i++ + return token{tokRBrace, "}", pos}, nil + case ';': + l.i++ + return token{tokSemi, ";", pos}, nil + case '!': + l.i++ + return token{tokBang, "!", pos}, nil + case '"': + var sb strings.Builder + l.i++ + for { + if l.i >= len(l.src) { + return token{}, &Error{Pos: pos, Msg: "unterminated quoted string"} + } + c := l.src[l.i] + if c == '\\' && l.i+1 < len(l.src) { + sb.WriteByte(l.src[l.i+1]) + if l.src[l.i+1] == '\n' { + l.line++ + } + l.i += 2 + continue + } + if c == '"' { + l.i++ + break + } + if c == '\n' { + l.line++ + } + sb.WriteByte(c) + l.i++ + } + return token{tokString, sb.String(), pos}, nil + } + start := l.i + for l.i < len(l.src) { + c := l.src[l.i] + if c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v' || + c == '{' || c == '}' || c == ';' || c == '"' || c == '!' || c == '#' { + break + } + if c == '/' && (l.peek(1) == '/' || l.peek(1) == '*') { + break + } + l.i++ + } + return token{tokWord, l.src[start:l.i], pos}, nil +} diff --git a/internal/namedconf/namedconf_test.go b/internal/namedconf/namedconf_test.go new file mode 100644 index 0000000..30aa3c5 --- /dev/null +++ b/internal/namedconf/namedconf_test.go @@ -0,0 +1,195 @@ +package namedconf + +import ( + "strings" + "testing" +) + +const sample = ` +// Debian style config +include "/etc/bind/rndc.key"; # shell comment +/* block + comment */ +acl "trusted" { 192.168.1.0/24; !192.168.1.66; key "xfer-key"; { 10/8; }; }; +controls { + inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; }; +}; +options { + directory "/var/cache/bind"; + allow-query { any; }; + listen-on-v6 { "none"; }; + forwarders port 53 { 1.1.1.1; 2606:4700:4700::1111 port 53; }; + version "not \"telling\""; +}; +zone "example.com" IN { + type primary; + file "/etc/bind/db.example.com"; +}; +` + +func TestParseAndFormat(t *testing.T) { + stmts, err := Parse("named.conf", sample) + if err != nil { + t.Fatal(err) + } + if len(stmts) != 5 { + t.Fatalf("got %d statements", len(stmts)) + } + if stmts[0].Keyword() != "include" || stmts[0].Arg(0) != "/etc/bind/rndc.key" { + t.Errorf("include parsed wrong: %+v", stmts[0]) + } + acl := stmts[1] + if acl.Keyword() != "acl" || acl.Arg(0) != "trusted" { + t.Errorf("acl parsed wrong") + } + elems, err := ParseAMList(acl.FirstBlock()) + if err != nil { + t.Fatal(err) + } + var got []string + for _, e := range elems { + got = append(got, e.String()) + } + want := "192.168.1.0/24|!192.168.1.66|key xfer-key|{ 10.0.0.0/8; }" + if strings.Join(got, "|") != want { + t.Errorf("acl elements = %q want %q", strings.Join(got, "|"), want) + } + opts := FindOne(stmts, "options") + ver := FindOne(opts.FirstBlock().Stmts, "version") + if ver.Arg(0) != `not "telling"` { + t.Errorf("escaped string = %q", ver.Arg(0)) + } + out := Format(stmts) + // Re-parse the output and format again: must be stable. + stmts2, err := Parse("out", out) + if err != nil { + t.Fatalf("re-parse: %v\n%s", err, out) + } + if out2 := Format(stmts2); out2 != out { + t.Errorf("format not stable:\n%s\n---\n%s", out, out2) + } + if !strings.Contains(out, `version "not \"telling\"";`) { + t.Errorf("quoted escape lost:\n%s", out) + } + z := FindOne(stmts, "zone") + if z.Arg(0) != "example.com" || z.Arg(1) != "IN" || FindOne(z.FirstBlock().Stmts, "type").Arg(0) != "primary" { + t.Errorf("zone parsed wrong") + } + if line := z.Pos.Line; line != 17 { + t.Errorf("zone line = %d", line) + } +} + +func TestParseErrors(t *testing.T) { + bad := map[string]string{ + `options { directory "x" };`: "missing ';' before '}'", + `options { directory "x"; `: "missing '}'", + `zone "x" { type primary; }`: "missing ';' at end of statement", + `};`: "unexpected '}'", + `options { version "abc; };`: "unterminated quoted string", + `/* never closed`: "unterminated /* comment", + } + for src, want := range bad { + _, err := Parse("t", src) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("Parse(%q) error = %v, want %q", src, err, want) + } + } +} + +func TestAMElements(t *testing.T) { + good := map[string]string{ + "192.168.1.0/24": "192.168.1.0/24", + "!10.0.0.1": "!10.0.0.1", + "! 10.0.0.1": "!10.0.0.1", + "10/8": "10.0.0.0/8", + "192.168/16": "192.168.0.0/16", + "2001:db8::/32": "2001:db8::/32", + "key xfer": "key xfer", + `key "rndc-key"`: "key rndc-key", + "any": "any", + `"none"`: "none", + "LocalHost": "localhost", + "trusted": "trusted", + "{ 1.2.3.4; any; }": "{ 1.2.3.4; any; }", + "10.0.0.5/32": "10.0.0.5", + } + for in, want := range good { + e, err := ParseAMElement(in) + if err != nil { + t.Errorf("ParseAMElement(%q): %v", in, err) + continue + } + if e.String() != want { + t.Errorf("ParseAMElement(%q) = %q want %q", in, e.String(), want) + } + } + bad := []string{"10.0.0.5/8", "300.1.1.1", "10.1", "key", "1.2.3.4 5.6.7.8", "2001:db8::1/200", "fe80::1%eth0", "bad name!"} + for _, in := range bad { + if e, err := ParseAMElement(in); err == nil { + t.Errorf("ParseAMElement(%q) = %q, want error", in, e.String()) + } + } + e, err := ParseAMElement("10.0.0.5/8") + if err == nil || !strings.Contains(err.Error(), "did you mean 10.0.0.0/8") { + t.Errorf("host bits error = %v (%v)", err, e) + } +} + +func TestShadowedAndRefs(t *testing.T) { + elems, err := ParseAMListString("192.168.1.0/24; !192.168.1.13; trusted; key k1; { key k2; other; }") + if err != nil { + t.Fatal(err) + } + w := Shadowed(elems) + if len(w) != 1 || !strings.Contains(w[0], "192.168.1.13") { + t.Errorf("Shadowed = %v", w) + } + acls, keys := AMRefs(elems) + if strings.Join(acls, ",") != "trusted,other" || strings.Join(keys, ",") != "k1,k2" { + t.Errorf("AMRefs = %v %v", acls, keys) + } + anyList, _ := ParseAMListString("{ any; }") + if !ContainsAny(anyList) { + t.Error("ContainsAny(any) false") + } + neg, _ := ParseAMListString("!10.0.0.1; any") + if ContainsAny(neg) { + t.Error("ContainsAny with leading negation true") + } + if w := Shadowed(anyList); len(w) != 0 { + t.Errorf("unexpected %v", w) + } + w2, _ := ParseAMListString("any; 10.0.0.0/8") + if len(Shadowed(w2)) != 1 { + t.Error("element after any not reported") + } +} + +func TestParseOptionValue(t *testing.T) { + st, err := ParseOptionValue("allow-query", "{ 10.0.0.0/8; localhost; };") + if err != nil { + t.Fatal(err) + } + if got := CompactStmt(st); got != "allow-query { 10.0.0.0/8; localhost; };" { + t.Errorf("compact = %q", got) + } + if _, err := ParseOptionValue("allow-query", "{ 10.0.0.0/8; "); err == nil { + t.Error("unbalanced accepted") + } + if _, err := ParseOptionValue("recursion", "yes; recursion no"); err == nil { + t.Error("two statements accepted") + } + if _, err := ParseOptionValue("recursion", ""); err == nil { + t.Error("empty accepted") + } +} + +func TestFormatLayout(t *testing.T) { + stmts, _ := Parse("", `options { allow-recursion { 10.0.0.0/8; 172.16.0.0/12; 192.168.0.0/16; 100.64.0.0/10; fd00::/8; localhost; }; recursion yes; };`) + out := Format(stmts) + want := "options {\n\tallow-recursion {\n\t\t10.0.0.0/8;\n\t\t172.16.0.0/12;\n\t\t192.168.0.0/16;\n\t\t100.64.0.0/10;\n\t\tfd00::/8;\n\t\tlocalhost;\n\t};\n\trecursion yes;\n};\n" + if out != want { + t.Errorf("layout:\n%s\nwant:\n%s", out, want) + } +} diff --git a/internal/namedconf/parser.go b/internal/namedconf/parser.go new file mode 100644 index 0000000..57a7b3e --- /dev/null +++ b/internal/namedconf/parser.go @@ -0,0 +1,148 @@ +package namedconf + +import ( + "errors" + "fmt" + "strings" +) + +// Parse parses a complete configuration (or configuration fragment). The +// file name is only used in error messages and positions. Include +// directives are returned as ordinary statements; callers that want to +// follow them must do so themselves. +func Parse(file, src string) ([]*Stmt, error) { + p := &parser{lex: &lexer{src: src, line: 1, file: file}} + if err := p.advance(); err != nil { + return nil, err + } + stmts, err := p.stmts(false) + if err != nil { + return nil, err + } + return stmts, nil +} + +type parser struct { + lex *lexer + tok token +} + +func (p *parser) advance() error { + t, err := p.lex.next() + if err != nil { + return err + } + p.tok = t + return nil +} + +func (p *parser) errorf(format string, args ...any) error { + return &Error{Pos: p.tok.pos, Msg: fmt.Sprintf(format, args...)} +} + +// stmts parses statements until EOF (top level) or a closing brace. +func (p *parser) stmts(inBlock bool) ([]*Stmt, error) { + var out []*Stmt + for { + switch p.tok.kind { + case tokEOF: + if inBlock { + return nil, p.errorf("missing '}' (unexpected end of input)") + } + return out, nil + case tokRBrace: + if !inBlock { + return nil, p.errorf("unexpected '}'") + } + return out, nil + case tokSemi: + // Empty statement; BIND tolerates stray semicolons. + if err := p.advance(); err != nil { + return nil, err + } + continue + } + st, err := p.stmt() + if err != nil { + return nil, err + } + out = append(out, st) + } +} + +func (p *parser) stmt() (*Stmt, error) { + st := &Stmt{Pos: p.tok.pos} + for { + switch p.tok.kind { + case tokSemi: + if err := p.advance(); err != nil { + return nil, err + } + return st, nil + case tokEOF: + return nil, &Error{Pos: st.Pos, Msg: "missing ';' at end of statement"} + case tokRBrace: + return nil, p.errorf("missing ';' before '}'") + case tokWord: + st.Items = append(st.Items, Item{Value: &Value{Text: p.tok.text, Pos: p.tok.pos}}) + case tokString: + st.Items = append(st.Items, Item{Value: &Value{Text: p.tok.text, Quoted: true, Pos: p.tok.pos}}) + case tokBang: + st.Items = append(st.Items, Item{Bang: true}) + case tokLBrace: + pos := p.tok.pos + if err := p.advance(); err != nil { + return nil, err + } + inner, err := p.stmts(true) + if err != nil { + return nil, err + } + st.Items = append(st.Items, Item{Block: &Block{Stmts: inner, Pos: pos}}) + // p.tok is now '}'. + } + if err := p.advance(); err != nil { + return nil, err + } + } +} + +// ParseStatement parses exactly one statement. A missing trailing +// semicolon is added. +func ParseStatement(src string) (*Stmt, error) { + s := strings.TrimSpace(src) + if !strings.HasSuffix(s, ";") { + s += ";" + } + stmts, err := Parse("", s) + if err != nil { + return nil, err + } + if len(stmts) != 1 { + return nil, fmt.Errorf("expected exactly one statement, found %d", len(stmts)) + } + return stmts[0], nil +} + +// ParseOptionValue parses the value part of an option (everything after +// the option name), for example `{ 10.0.0.0/8; localhost; }` for +// allow-query. It returns the statement "name value;". +func ParseOptionValue(name, value string) (*Stmt, error) { + v := strings.TrimSpace(value) + v = strings.TrimSuffix(v, ";") + if strings.TrimSpace(v) == "" { + return nil, fmt.Errorf("option %s needs a value", name) + } + st, err := ParseStatement(name + " " + v) + if err != nil { + var pe *Error + if errors.As(err, &pe) { + return nil, errors.New(pe.Msg) + } + return nil, err + } + if st.Keyword() != strings.ToLower(name) { + return nil, fmt.Errorf("value for %s is malformed", name) + } + return st, nil +} diff --git a/internal/namedconf/printer.go b/internal/namedconf/printer.go new file mode 100644 index 0000000..6154803 --- /dev/null +++ b/internal/namedconf/printer.go @@ -0,0 +1,160 @@ +package namedconf + +import ( + "strings" +) + +// maxInline is the widest a block may be to be printed on one line. +const maxInline = 72 + +// Format returns stmts in canonical form, one statement per line, with +// nested blocks indented by tabs. +func Format(stmts []*Stmt) string { + var sb strings.Builder + for _, s := range stmts { + writeStmt(&sb, s, 0) + } + return sb.String() +} + +// FormatStmt returns a single statement in canonical form, indented by +// indent tabs, including the trailing newline. +func FormatStmt(s *Stmt, indent int) string { + var sb strings.Builder + writeStmt(&sb, s, indent) + return sb.String() +} + +// Compact returns the statement's items on a single line, without the +// trailing semicolon: `allow-query { 10.0.0.0/8; localhost; }`. +func Compact(items []Item) string { + var sb strings.Builder + writeItemsInline(&sb, items) + return sb.String() +} + +// CompactStmt returns the statement on a single line with its semicolon. +func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" } + +// QuoteString returns s as a named.conf quoted string. +func QuoteString(s string) string { + var sb strings.Builder + sb.WriteByte('"') + for i := 0; i < len(s); i++ { + if s[i] == '"' || s[i] == '\\' { + sb.WriteByte('\\') + } + sb.WriteByte(s[i]) + } + sb.WriteByte('"') + return sb.String() +} + +func valueText(v *Value) string { + if v.Quoted { + return QuoteString(v.Text) + } + return v.Text +} + +func writeStmt(sb *strings.Builder, s *Stmt, indent int) { + tabs := strings.Repeat("\t", indent) + sb.WriteString(tabs) + inline := Compact(s.Items) + if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 { + sb.WriteString(inline) + sb.WriteString(";\n") + return + } + needSpace := false + for _, it := range s.Items { + switch { + case it.Bang: + if needSpace { + sb.WriteByte(' ') + } + sb.WriteByte('!') + needSpace = false + case it.Value != nil: + if needSpace { + sb.WriteByte(' ') + } + sb.WriteString(valueText(it.Value)) + needSpace = true + case it.Block != nil: + if needSpace { + sb.WriteByte(' ') + } + blockInline := Compact([]Item{it}) + if !blockHasBlocks(it.Block) && len(blockInline) <= 40 { + sb.WriteString(blockInline) + } else if len(it.Block.Stmts) == 0 { + sb.WriteString("{ }") + } else { + sb.WriteString("{\n") + for _, inner := range it.Block.Stmts { + writeStmt(sb, inner, indent+1) + } + sb.WriteString(tabs) + sb.WriteString("}") + } + needSpace = true + } + } + sb.WriteString(";\n") +} + +func hasNestedBlocks(items []Item) bool { + for _, it := range items { + if it.Block != nil && blockHasBlocks(it.Block) { + return true + } + } + return false +} + +func blockHasBlocks(b *Block) bool { + for _, s := range b.Stmts { + for _, it := range s.Items { + if it.Block != nil { + return true + } + } + } + return false +} + +func writeItemsInline(sb *strings.Builder, items []Item) { + needSpace := false + for _, it := range items { + switch { + case it.Bang: + if needSpace { + sb.WriteByte(' ') + } + sb.WriteByte('!') + needSpace = false + case it.Value != nil: + if needSpace { + sb.WriteByte(' ') + } + sb.WriteString(valueText(it.Value)) + needSpace = true + case it.Block != nil: + if needSpace { + sb.WriteByte(' ') + } + if len(it.Block.Stmts) == 0 { + sb.WriteString("{ }") + } else { + sb.WriteString("{ ") + for _, s := range it.Block.Stmts { + writeItemsInline(sb, s.Items) + sb.WriteString("; ") + } + sb.WriteString("}") + } + needSpace = true + } + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..b0a0193 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,431 @@ +// Package render turns a configuration State into BIND configuration +// files: Jarl's named.conf, a separate keys.conf holding TSIG secrets, and +// one zone file per primary zone. +// +// Rendering is deterministic: the same State always produces byte-for-byte +// identical files, so unchanged files are not rewritten and diffs only show +// real changes. +package render + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/namedconf" +) + +// Paths says where generated files go. +type Paths struct { + ConfigDir string `json:"config_dir"` // Jarl's named.conf and keys.conf, e.g. /etc/bind/jarl + ZoneDir string `json:"zone_dir"` // primary zone files, e.g. /var/lib/bind/jarl + CacheDir string `json:"cache_dir"` // files named writes for secondary/mirror/stub zones, e.g. /var/cache/bind/jarl +} + +// MainConfig returns the path of Jarl's named.conf. +func (p Paths) MainConfig() string { return filepath.Join(p.ConfigDir, "named.conf") } + +// KeysConfig returns the path of the TSIG key file. +func (p Paths) KeysConfig() string { return filepath.Join(p.ConfigDir, "keys.conf") } + +// File kinds. +const ( + KindConfig = "config" + KindKeys = "keys" + KindZone = "zone" +) + +// File is a rendered file. +type File struct { + Path string + Content []byte + Mode os.FileMode + Kind string + Secret bool // contains secrets; never show contents in diffs or logs + ZoneID int64 // for zone files +} + +// Span maps lines of a rendered file to the object they came from. +type Span struct { + Start, End int // 1-based, inclusive + ObjectType string + ObjectID int64 + Label string +} + +// Output is the result of rendering. +type Output struct { + Files []*File + // Dirs are directories named needs to exist (for secondary zone files). + Dirs []string + // Spans maps file paths to source spans. + Spans map[string][]Span + // ZoneFiles maps zone IDs to the file named loads for the zone. + ZoneFiles map[int64]string +} + +// File returns the rendered file at path, or nil. +func (o *Output) File(path string) *File { + for _, f := range o.Files { + if f.Path == path { + return f + } + } + return nil +} + +// Lookup returns the span covering line in path, if any. When spans nest, +// the innermost wins. +func (o *Output) Lookup(path string, line int) (Span, bool) { + var best Span + found := false + for _, s := range o.Spans[path] { + if line >= s.Start && line <= s.End { + if !found || s.End-s.Start < best.End-best.Start { + best, found = s, true + } + } + } + return best, found +} + +// ViewDir returns the directory component used for a zone's view. +func ViewDir(viewName string) string { + if viewName == "" { + return "_default" + } + return dnsname.FileSafe(dnsname.Fqdn(strings.ToLower(viewName))) +} + +// ZoneFilePath returns the file named loads for zone z, or "" for zone +// types without files. +func ZoneFilePath(s *model.State, z *model.Zone, p Paths) string { + switch z.Type { + case model.ZonePrimary: + return filepath.Join(p.ZoneDir, ViewDir(s.ViewName(z)), dnsname.FileSafe(z.Name)+".zone") + case model.ZoneSecondary, model.ZoneMirror, model.ZoneStub: + if z.File != "" { + return z.File + } + return filepath.Join(p.CacheDir, ViewDir(s.ViewName(z)), dnsname.FileSafe(z.Name)+".zone") + case model.ZoneHint, model.ZoneRedirect: + return z.File + } + return "" +} + +// Header is placed at the top of every generated file. +const headerText = "Generated by Jarl. DO NOT EDIT: changes made here are overwritten the next time\nchanges are applied. Manage this server through the Jarl web UI, API or CLI." + +// Render renders the complete configuration. +func Render(s *model.State, p Paths) *Output { + out := &Output{Spans: map[string][]Span{}, ZoneFiles: map[int64]string{}} + keys := renderKeys(s) + out.Files = append(out.Files, &File{Path: p.KeysConfig(), Content: []byte(keys), Mode: 0o640, Kind: KindKeys, Secret: true}) + conf, spans := renderConfig(s, p, out) + out.Files = append(out.Files, &File{Path: p.MainConfig(), Content: []byte(conf), Mode: 0o640, Kind: KindConfig}) + out.Spans[p.MainConfig()] = spans + for _, z := range s.SortedZones() { + if z.Type != model.ZonePrimary { + continue + } + path := ZoneFilePath(s, z, p) + out.Files = append(out.Files, &File{Path: path, Content: []byte(ZoneFile(s, z)), Mode: 0o640, Kind: KindZone, ZoneID: z.ID}) + } + sort.Strings(out.Dirs) + return out +} + +func renderKeys(s *model.State) string { + var sb strings.Builder + sb.WriteString(commentBlock("//", headerText+"\nThis file holds TSIG secrets: keep it readable only by named and Jarl.")) + for _, k := range s.SortedKeys() { + sb.WriteString("\n") + if k.Comment != "" { + sb.WriteString(commentBlock("//", k.Comment)) + } + fmt.Fprintf(&sb, "key %s {\n\talgorithm %s;\n\tsecret %s;\n};\n", namedconf.QuoteString(k.Name), k.Algorithm, namedconf.QuoteString(k.Secret)) + } + return sb.String() +} + +func commentBlock(prefix, text string) string { + var sb strings.Builder + for _, l := range strings.Split(strings.TrimRight(text, "\n"), "\n") { + l = strings.TrimRight(l, " \t\r") + if l == "" { + sb.WriteString(prefix + "\n") + } else { + sb.WriteString(prefix + " " + l + "\n") + } + } + return sb.String() +} + +// confWriter tracks line numbers for the source map. +type confWriter struct { + sb strings.Builder + line int // number of the next line to be written + spans []Span + open []int +} + +func newConfWriter() *confWriter { return &confWriter{line: 1} } + +func (w *confWriter) write(s string) { + w.sb.WriteString(s) + w.line += strings.Count(s, "\n") +} + +func (w *confWriter) begin(objType string, id int64, label string) { + w.spans = append(w.spans, Span{Start: w.line, ObjectType: objType, ObjectID: id, Label: label}) + w.open = append(w.open, len(w.spans)-1) +} + +func (w *confWriter) end() { + i := w.open[len(w.open)-1] + w.open = w.open[:len(w.open)-1] + w.spans[i].End = w.line - 1 +} + +// optionText formats an option as a statement at the given indentation. +func optionText(o *model.Option, indent int) string { + var prefix string + if o.Comment != "" { + prefix = indentLines(commentBlock("//", o.Comment), indent) + } + st, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil { + // Validation rejects unparsable options; emit as-is so + // named-checkconf reports the problem. + return prefix + strings.Repeat("\t", indent) + o.Text() + "\n" + } + return prefix + namedconf.FormatStmt(st, indent) +} + +func indentLines(s string, indent int) string { + tabs := strings.Repeat("\t", indent) + var sb strings.Builder + for _, l := range strings.SplitAfter(s, "\n") { + if l != "" { + sb.WriteString(tabs + l) + } + } + return sb.String() +} + +func amlBlock(elems []string) string { + if len(elems) == 0 { + return "{ }" + } + var parts []string + for _, e := range elems { + parts = append(parts, normalizeElement(e)+";") + } + return "{ " + strings.Join(parts, " ") + " }" +} + +func normalizeElement(e string) string { + if el, err := namedconf.ParseAMElement(e); err == nil { + return el.String() + } + return strings.TrimSuffix(strings.TrimSpace(e), ";") +} + +func renderConfig(s *model.State, p Paths, out *Output) (string, []Span) { + w := newConfWriter() + w.write(commentBlock("//", headerText)) + w.write("\n") + w.write(fmt.Sprintf("include %s;\n", namedconf.QuoteString(p.KeysConfig()))) + + if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 { + w.write("\n") + for _, o := range top { + w.begin("option", o.ID, "top-level statement "+o.Name) + w.write(optionText(o, 0)) + w.end() + } + } + + if acls := s.SortedACLs(); len(acls) > 0 { + w.write("\n") + for _, a := range acls { + w.begin("acl", a.ID, "ACL "+a.Name) + if a.Comment != "" { + w.write(commentBlock("//", a.Comment)) + } + st, err := namedconf.ParseStatement("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements)) + if err != nil { + w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n") + } else { + w.write(namedconf.FormatStmt(st, 0)) + } + w.end() + } + } + + w.write("\noptions {\n") + for _, o := range s.ScopedOptions(model.ScopeOptions, 0) { + w.begin("option", o.ID, "server option "+o.Name) + w.write(optionText(o, 1)) + w.end() + } + w.write("};\n") + + views := s.OrderedViews() + if len(views) == 0 { + for _, z := range s.ZonesInView(0) { + w.write("\n") + renderZone(w, s, z, p, out, 0) + } + } + for _, v := range views { + w.write("\n") + w.begin("view", v.ID, "view "+v.Name) + if v.Comment != "" { + w.write(commentBlock("//", v.Comment)) + } + w.write(fmt.Sprintf("view %s {\n", namedconf.QuoteString(v.Name))) + if len(v.MatchClients) > 0 { + writeAML(w, "match-clients", v.MatchClients) + } + if len(v.MatchDestinations) > 0 { + writeAML(w, "match-destinations", v.MatchDestinations) + } + if v.MatchRecursiveOnly != nil { + w.write(fmt.Sprintf("\tmatch-recursive-only %s;\n", yesNo(*v.MatchRecursiveOnly))) + } + for _, o := range s.ScopedOptions(model.ScopeView, v.ID) { + w.begin("option", o.ID, "view "+v.Name+" option "+o.Name) + w.write(optionText(o, 1)) + w.end() + } + for _, z := range s.ZonesInView(v.ID) { + w.write("\n") + renderZone(w, s, z, p, out, 1) + } + w.write("};\n") + w.end() + } + return w.sb.String(), w.spans +} + +func writeAML(w *confWriter, name string, elems []string) { + st, err := namedconf.ParseStatement(name + " " + amlBlock(elems)) + if err != nil { + w.write("\t" + name + " " + amlBlock(elems) + ";\n") + return + } + w.write(namedconf.FormatStmt(st, 1)) +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} + +func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Output, indent int) { + tabs := strings.Repeat("\t", indent) + label := "zone " + dnsname.Display(z.Name) + if v := s.ViewName(z); v != "" { + label += " [view " + v + "]" + } + w.begin("zone", z.ID, label) + if z.Comment != "" { + w.write(indentLines(commentBlock("//", z.Comment), indent)) + } + w.write(fmt.Sprintf("%szone %s {\n", tabs, namedconf.QuoteString(dnsname.Display(z.Name)))) + if z.Type == model.ZoneInView { + w.write(fmt.Sprintf("%s\tin-view %s;\n", tabs, namedconf.QuoteString(z.InView))) + } else { + w.write(fmt.Sprintf("%s\ttype %s;\n", tabs, z.Type)) + } + if path := ZoneFilePath(s, z, p); path != "" { + w.write(fmt.Sprintf("%s\tfile %s;\n", tabs, namedconf.QuoteString(path))) + out.ZoneFiles[z.ID] = path + switch z.Type { + case model.ZoneSecondary, model.ZoneMirror, model.ZoneStub: + if z.File == "" { + out.Dirs = appendUnique(out.Dirs, filepath.Dir(path)) + } + } + } + for _, o := range s.ScopedOptions(model.ScopeZone, z.ID) { + w.begin("option", o.ID, label+" option "+o.Name) + w.write(optionText(o, indent+1)) + w.end() + } + w.write(tabs + "};\n") + w.end() +} + +func appendUnique(list []string, s string) []string { + for _, x := range list { + if x == s { + return list + } + } + return append(list, s) +} + +// ZoneFile renders the zone file of a primary zone. +func ZoneFile(s *model.State, z *model.Zone) string { + var sb strings.Builder + title := "Zone " + dnsname.Display(z.Name) + if v := s.ViewName(z); v != "" { + title += " (view " + v + ")" + } + sb.WriteString(commentBlock(";", title+"\n"+headerText)) + if z.Comment != "" { + sb.WriteString(commentBlock(";", z.Comment)) + } + fmt.Fprintf(&sb, "\n$ORIGIN %s\n$TTL %d\n", z.Name, z.DefaultTTL) + soa := z.SOA + fmt.Fprintf(&sb, "@\tIN\tSOA\t%s %s (\n\t\t\t%d\t; serial\n\t\t\t%d\t; refresh\n\t\t\t%d\t; retry\n\t\t\t%d\t; expire\n\t\t\t%d )\t; minimum (negative caching TTL)\n", + soa.MName, soa.RName, soa.Serial, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum) + recs := s.ZoneRecords(z.ID) + prev := "" + for _, r := range recs { + owner := dnsname.Relative(r.Name, z.Name) + if r.Name != prev { + sb.WriteString("\n") + prev = r.Name + } + ttl := "" + if r.TTL != nil { + ttl = fmt.Sprint(*r.TTL) + } + line := fmt.Sprintf("%s\t%s\tIN\t%s\t%s", owner, ttl, r.Type, r.Data) + if r.Comment != "" { + line += "\t; " + oneLine(r.Comment) + } + sb.WriteString(line + "\n") + } + return sb.String() +} + +func oneLine(s string) string { + s = strings.ReplaceAll(s, "\r", " ") + return strings.ReplaceAll(s, "\n", " ") +} + +// ZoneContentHash returns a hash of the DNS data of a primary zone: its +// records and SOA fields, excluding the serial and comments. It changes +// exactly when the zone's serial must be bumped. +func ZoneContentHash(s *model.State, z *model.Zone) string { + h := sha256.New() + soa := z.SOA + fmt.Fprintf(h, "%s|%d|%s|%s|%d|%d|%d|%d\n", z.Name, z.DefaultTTL, soa.MName, soa.RName, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum) + for _, r := range s.ZoneRecords(z.ID) { + fmt.Fprintf(h, "%s|%d|%s|%s\n", r.Name, r.EffectiveTTL(z), r.Type, r.Data) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..9eec789 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,205 @@ +package render + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/rr" + "jarl/internal/testutil" + "jarl/internal/validate" +) + +// sampleState builds a split-horizon configuration exercising most +// features. +func sampleState(t *testing.T) *model.State { + t.Helper() + s := model.NewState() + id := int64(0) + next := func() int64 { id++; return id } + s.ACLs[next()] = &model.ACL{ID: id, Name: "lan", Elements: []string{"192.168.1.0/24", "10/8"}, Comment: "home networks"} + s.Keys[next()] = &model.TSIGKey{ID: id, Name: "xfer-key", Algorithm: "hmac-sha256", Secret: "s0cvDUATzPDPntCVDfO3S9JjT8+51cRZSuWTNM9Eosc="} + internal := &model.View{ID: next(), Name: "internal", Position: 0, MatchClients: []string{"lan", "localhost"}} + external := &model.View{ID: next(), Name: "external", Position: 1, MatchClients: []string{"any"}} + s.Views[internal.ID], s.Views[external.ID] = internal, external + opt := func(scope string, scopeID int64, name, value string) { + o := &model.Option{ID: next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: int(id)} + s.Options[o.ID] = o + } + opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`) + opt(model.ScopeOptions, 0, "dnssec-validation", "auto") + opt(model.ScopeOptions, 0, "listen-on-v6", "{ any; }") + opt(model.ScopeView, internal.ID, "recursion", "yes") + opt(model.ScopeView, internal.ID, "allow-recursion", "{ lan; localhost; }") + opt(model.ScopeView, external.ID, "recursion", "no") + opt(model.ScopeTopLevel, 0, "logging", `{ channel default_log { stderr; severity info; }; category default { default_log; }; }`) + + zone := func(name string, v *model.View, typ string) *model.Zone { + z := &model.Zone{ID: next(), Name: dnsname.Fqdn(name), ViewID: v.ID, Type: typ, DefaultTTL: 3600, + SOA: model.SOA{MName: "ns1.example.com.", RName: "hostmaster.example.com.", Serial: 2026100700, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}} + s.Zones[z.ID] = z + return z + } + rec := func(z *model.Zone, name, typ, data string) *model.Record { + owner, _ := dnsname.Owner(name, z.Name) + p, err := rr.Parse(z.Name, owner, typ, data) + if err != nil { + t.Fatalf("%s %s %s: %v", name, typ, data, err) + } + r := &model.Record{ID: next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data} + s.Records[r.ID] = r + return r + } + in := zone("example.com", internal, model.ZonePrimary) + in.Comment = "internal version" + rec(in, "@", "NS", "ns1") + rec(in, "ns1", "A", "192.168.1.2") + r := rec(in, "nas", "A", "192.168.1.20") + r.Comment = "storage box" + r.TTL = model.Ptr(uint32(300)) + rec(in, "www", "CNAME", "nas") + rec(in, "@", "TXT", "v=spf1 -all") + rec(in, "@", "CAA", `0 issue "letsencrypt.org"`) + rec(in, "_acme-challenge", "TXT", "abc123") + ext := zone("example.com", external, model.ZonePrimary) + rec(ext, "@", "NS", "ns1") + rec(ext, "ns1", "A", "203.0.113.2") + rec(ext, "www", "A", "203.0.113.10") + opt(model.ScopeZone, ext.ID, "allow-transfer", "{ key xfer-key; }") + rev := zone("1.168.192.in-addr.arpa", internal, model.ZonePrimary) + rec(rev, "@", "NS", "ns1.example.com.") + rec(rev, "20", "PTR", "nas.example.com.") + fwd := zone("consul", internal, model.ZoneForward) + opt(model.ScopeZone, fwd.ID, "forwarders", "{ 127.0.0.1 port 8600; }") + opt(model.ScopeZone, fwd.ID, "forward", "only") + sec := zone("partner.example", external, model.ZoneSecondary) + opt(model.ScopeZone, sec.ID, "primaries", "{ 198.51.100.1 key xfer-key; }") + hint := zone(".", internal, model.ZoneHint) + hint.File = "/usr/share/dns/root.hints" + return s +} + +func TestRenderDeterministicAndValid(t *testing.T) { + s := sampleState(t) + if ps := validate.Errors(validate.State(s, validate.Options{})); len(ps) > 0 { + t.Fatalf("sample state has errors: %v", ps) + } + dir := t.TempDir() + p := Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} + out1 := Render(s, p) + out2 := Render(s.Clone(), p) + if len(out1.Files) != len(out2.Files) { + t.Fatal("file count differs") + } + for i := range out1.Files { + if string(out1.Files[i].Content) != string(out2.Files[i].Content) { + t.Errorf("rendering of %s is not deterministic", out1.Files[i].Path) + } + } + conf := string(out1.File(p.MainConfig()).Content) + for _, want := range []string{ + `include "` + p.KeysConfig() + `";`, + "acl \"lan\" { 192.168.1.0/24; 10.0.0.0/8; };", + "view \"internal\" {\n\tmatch-clients { lan; localhost; };", + "\tzone \"example.com\" {\n\t\ttype primary;", + "zone \"consul\" {\n\t\ttype forward;", + "\t\tforwarders { 127.0.0.1 port 8600; };", + `file "/usr/share/dns/root.hints";`, + } { + if !strings.Contains(conf, want) { + t.Errorf("named.conf missing %q:\n%s", want, conf) + } + } + if strings.Contains(conf, "s0cvDUAT") { + t.Error("secret leaked into named.conf") + } + keys := out1.File(p.KeysConfig()) + if !keys.Secret || !strings.Contains(string(keys.Content), `secret "s0cvDUAT`) { + t.Error("keys.conf wrong") + } + if len(out1.Dirs) != 1 || !strings.HasSuffix(out1.Dirs[0], "cache/external") { + t.Errorf("dirs = %v", out1.Dirs) + } + // Source map: the line with the internal zone maps to that zone. + lines := strings.Split(conf, "\n") + for i, l := range lines { + if strings.Contains(l, "forwarders { 127.0.0.1 port 8600; }") { + span, ok := out1.Lookup(p.MainConfig(), i+1) + if !ok || span.ObjectType != "option" || !strings.Contains(span.Label, "consul") { + t.Errorf("span for line %d = %+v %v", i+1, span, ok) + } + } + } + zf := ZoneFile(s, s.FindZone("example.com", s.ViewByName("internal").ID)) + for _, want := range []string{"$ORIGIN example.com.", "$TTL 3600", "nas\t300\tIN\tA\t192.168.1.20\t; storage box", "www\t\tIN\tCNAME\tnas.example.com.", "@\t\tIN\tNS\tns1.example.com.", "2026100700\t; serial"} { + if !strings.Contains(zf, want) { + t.Errorf("zone file missing %q:\n%s", want, zf) + } + } +} + +func TestRenderWithNamedCheckconf(t *testing.T) { + tools := testutil.BindTools(t) + s := sampleState(t) + dir := t.TempDir() + p := Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} + // named-checkconf changes into the directory option, so it must exist. + s.FindOption(model.ScopeOptions, 0, "directory").Value = `"` + dir + `"` + // The hint zone's file must exist too. + hints := filepath.Join(dir, "root.hints") + if err := os.WriteFile(hints, []byte(".\t3600000\tNS\tA.ROOT-SERVERS.NET.\nA.ROOT-SERVERS.NET.\t3600000\tA\t198.41.0.4\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, z := range s.Zones { + if z.Type == model.ZoneHint { + z.File = hints + } + } + out := Render(s, p) + for _, f := range out.Files { + if err := os.MkdirAll(filepath.Dir(f.Path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f.Path, f.Content, 0o644); err != nil { + t.Fatal(err) + } + } + res, err := tools.CheckConfig(context.Background(), p.MainConfig(), true) + if err != nil { + t.Fatal(err) + } + if !res.OK { + t.Fatalf("named-checkconf failed:\n%s\n--- named.conf:\n%s", res.Output, out.File(p.MainConfig()).Content) + } + for _, f := range out.Files { + if f.Kind != KindZone { + continue + } + z := s.Zones[f.ZoneID] + r, err := tools.CheckZone(context.Background(), z.Name, f.Path) + if err != nil || !r.OK { + t.Errorf("named-checkzone %s: %v\n%s", z.Name, err, r.Output) + } + } +} + +func TestZoneContentHash(t *testing.T) { + s := sampleState(t) + z := s.FindZone("example.com", s.ViewByName("internal").ID) + h1 := ZoneContentHash(s, z) + z.SOA.Serial++ + for _, r := range s.ZoneRecords(z.ID) { + r.Comment = "changed comment" + } + if ZoneContentHash(s, z) != h1 { + t.Error("serial or comments changed the content hash") + } + z.DefaultTTL = 7200 + if ZoneContentHash(s, z) == h1 { + t.Error("default TTL change not reflected") + } +} diff --git a/internal/rr/rr.go b/internal/rr/rr.go new file mode 100644 index 0000000..5720b06 --- /dev/null +++ b/internal/rr/rr.go @@ -0,0 +1,446 @@ +// Package rr parses, validates and canonicalizes resource record data. +// +// Record data is given in zone file (presentation) format, exactly as it +// would appear in a BIND zone file after the type, for example +// "10 mail.example.com." for MX. Names without a trailing dot are relative +// to the zone, as in zone files. TXT data that does not start with a quote +// is treated as plain text and quoted (and split into 255-byte strings) +// automatically. +package rr + +import ( + "encoding/hex" + "errors" + "fmt" + "reflect" + "regexp" + "strings" + "unicode/utf8" + + "github.com/miekg/dns" + + "jarl/internal/dnsname" +) + +// Parsed is a successfully parsed record. +type Parsed struct { + RR dns.RR + Type string // canonical type mnemonic, e.g. "A" or "TYPE65280" + Data string // canonical presentation-format RDATA + Warnings []string + // Targets are the domain names in the RDATA (CNAME target, MX + // exchange, NS name, ...), canonical. + Targets []string +} + +// Errors. +var ( + ErrEmptyData = errors.New("record data is empty") +) + +// forbidden lists types that cannot be created by users, with the reason. +var forbidden = map[uint16]string{ + dns.TypeSOA: "the SOA record is managed by Jarl from the zone's settings", + dns.TypeOPT: "OPT is a pseudo-record", + dns.TypeTSIG: "TSIG is a pseudo-record", + dns.TypeTKEY: "TKEY is a pseudo-record", + dns.TypeANY: "ANY is a query type", + dns.TypeAXFR: "AXFR is a query type", + dns.TypeIXFR: "IXFR is a query type", + dns.TypeRRSIG: "DNSSEC signatures are maintained by named (dnssec-policy)", + dns.TypeNSEC: "NSEC records are maintained by named (dnssec-policy)", + dns.TypeNSEC3: "NSEC3 records are maintained by named (dnssec-policy)", + dns.TypeNSEC3PARAM: "NSEC3PARAM is maintained by named (dnssec-policy)", + dns.TypeDNSKEY: "DNSKEY records are maintained by named (dnssec-policy)", + dns.TypeCDS: "CDS records are maintained by named (dnssec-policy)", + dns.TypeCDNSKEY: "CDNSKEY records are maintained by named (dnssec-policy)", + dns.TypeSIG: "SIG is obsolete", + dns.TypeNXT: "NXT is obsolete", + dns.TypeDLV: "DLV is obsolete", + dns.TypeMD: "MD is obsolete (use MX)", + dns.TypeMF: "MF is obsolete (use MX)", + dns.TypeZONEMD: "ZONEMD would be wrong after any change; it is not supported", + dns.TypeNXNAME: "NXNAME is a meta type", + dns.TypeMAILA: "MAILA is a query type", + dns.TypeMAILB: "MAILB is a query type", +} + +// NormalizeType returns the canonical mnemonic for a type given in any case +// (or as TYPEnnn), and its code. +func NormalizeType(t string) (string, uint16, error) { + u := strings.ToUpper(strings.TrimSpace(t)) + if u == "" { + return "", 0, errors.New("record type is empty") + } + code, ok := dns.StringToType[u] + if !ok { + if n, ok2 := strings.CutPrefix(u, "TYPE"); ok2 { + var v uint64 + if _, err := fmt.Sscanf(n, "%d", &v); err == nil && v > 0 && v < 65536 && fmt.Sprint(v) == n { + code = uint16(v) + if s, known := dns.TypeToString[code]; known { + u = s + } + ok = true + } + } + } + if !ok { + return "", 0, fmt.Errorf("unknown record type %q", t) + } + if why, bad := forbidden[code]; bad { + return "", 0, fmt.Errorf("records of type %s can't be managed: %s", u, why) + } + if code == 0 || (code >= 128 && code <= 255) || code == 65535 { + return "", 0, fmt.Errorf("type %s is a meta or reserved type", u) + } + return u, code, nil +} + +// IsTextType reports whether data for the type is character strings that +// may be given as plain text. +func IsTextType(t string) bool { return t == "TXT" || t == "SPF" } + +// QuoteText converts plain text into one or more quoted character +// strings of at most 255 bytes each, escaping quotes and backslashes. +// Multi-byte UTF-8 sequences are not split between strings. +func QuoteText(text string) string { + var parts []string + var cur strings.Builder + n := 0 // raw bytes in current chunk + flush := func() { + parts = append(parts, `"`+cur.String()+`"`) + cur.Reset() + n = 0 + } + for i := 0; i < len(text); { + r, size := utf8.DecodeRuneInString(text[i:]) + if n+size > 255 { + flush() + } + chunk := text[i : i+size] + switch { + case r == '"' || r == '\\': + cur.WriteByte('\\') + cur.WriteString(chunk) + case size == 1 && (chunk[0] < 0x20 || chunk[0] == 0x7f): + fmt.Fprintf(&cur, "\\%03d", chunk[0]) + default: + cur.WriteString(chunk) + } + n += size + i += size + } + if n > 0 || len(parts) == 0 { + flush() + } + return strings.Join(parts, " ") +} + +// TextValue returns the concatenated, unescaped text of TXT-style data. +func TextValue(r dns.RR) (string, bool) { + switch v := r.(type) { + case *dns.TXT: + return unescapeAll(v.Txt), true + case *dns.SPF: + return unescapeAll(v.Txt), true + } + return "", false +} + +func unescapeAll(ss []string) string { + var sb strings.Builder + for _, s := range ss { + sb.WriteString(unescapeTxt(s)) + } + return sb.String() +} + +// unescapeTxt undoes miekg/dns' presentation escaping of a TXT string. +func unescapeTxt(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var out []byte + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + if i+3 < len(s) && isDigit(s[i+1]) && isDigit(s[i+2]) && isDigit(s[i+3]) { + out = append(out, byte((s[i+1]-'0')*100+(s[i+2]-'0')*10+(s[i+3]-'0'))) + i += 3 + continue + } + out = append(out, s[i+1]) + i++ + continue + } + out = append(out, s[i]) + } + return string(out) +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +// Parse parses and validates record data. zone and owner must be canonical +// FQDNs; typ is the record type in any case. +func Parse(zone, owner, typ, data string) (*Parsed, error) { + t, code, err := NormalizeType(typ) + if err != nil { + return nil, err + } + d := strings.TrimSpace(data) + if d == "" { + return nil, ErrEmptyData + } + for i := 0; i < len(d); i++ { + if c := d[i]; c < 0x20 && c != '\t' || c == 0x7f { + return nil, errors.New("record data must not contain line breaks or control characters") + } + } + if strings.HasPrefix(d, "$") { + return nil, errors.New("record data must not start with '$' (zone file directives are not allowed)") + } + if IsTextType(t) && !strings.HasPrefix(d, `"`) { + d = QuoteText(d) + } + typeText := t + if _, known := dns.TypeToString[code]; !known { + typeText = fmt.Sprintf("TYPE%d", code) + } + line := fmt.Sprintf("%s 3600 IN %s %s\n", owner, typeText, d) + zp := dns.NewZoneParser(strings.NewReader(line), zone, "") + zp.SetIncludeAllowed(false) + parsed, ok := zp.Next() + if perr := zp.Err(); perr != nil { + return nil, cleanParseError(perr, t) + } + if !ok || parsed == nil { + return nil, fmt.Errorf("could not parse %s data %q", t, data) + } + if c := zp.Comment(); c != "" { + return nil, errors.New("';' starts a comment in zone file syntax; put comments in the record's comment field (or quote the text)") + } + if extra, more := zp.Next(); more || extra != nil { + return nil, errors.New("record data contains more than one record") + } + if parsed.Header().Rrtype != code { + return nil, fmt.Errorf("data does not form a %s record", t) + } + p := &Parsed{RR: parsed, Type: t} + if err := p.fixNames(zone, data); err != nil { + return nil, err + } + if err := p.check(owner); err != nil { + return nil, err + } + // Ensure the record fits in a DNS message. + buf := make([]byte, 65535+512) + if _, err := dns.PackRR(p.RR, buf, 0, nil, false); err != nil { + return nil, fmt.Errorf("record data is too large or invalid: %v", err) + } + // RR.String() is "owner\tttl\tclass\ttype\tdata". + fields := strings.SplitN(p.RR.String(), "\t", 5) + if len(fields) == 5 { + p.Data = strings.TrimSpace(fields[4]) + } + if p.Data == "" { + return nil, ErrEmptyData + } + return p, nil +} + +var badFieldRe = regexp.MustCompile(`^bad (\S+) (\S+): (.*)$`) + +func cleanParseError(err error, t string) error { + msg := err.Error() + // miekg/dns errors look like: dns: bad A A: "1.2.3" at line: 1:20 + msg = strings.TrimPrefix(msg, "dns: ") + if i := strings.Index(msg, " at line: "); i >= 0 { + msg = msg[:i] + } + // "bad A A: "1.2.3"" names the type and then the field; say it once. + if m := badFieldRe.FindStringSubmatch(msg); m != nil && strings.EqualFold(m[1], t) { + if strings.EqualFold(m[2], t) { + msg = "bad value " + m[3] + } else { + msg = "bad " + m[2] + " " + m[3] + } + } + return fmt.Errorf("invalid %s data: %s", t, msg) +} + +// nameField is a domain-name field of an RR struct. +type nameField struct { + v reflect.Value + name string +} + +func domainFields(rr dns.RR) []nameField { + var out []nameField + var walk func(v reflect.Value) + walk = func(v reflect.Value) { + t := v.Type() + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + fv := v.Field(i) + if f.Anonymous && f.Type.Kind() == reflect.Struct && f.Type != reflect.TypeOf(dns.RR_Header{}) { + walk(fv) + continue + } + tag := f.Tag.Get("dns") + if strings.Contains(tag, "domain-name") && fv.Kind() == reflect.String { + out = append(out, nameField{fv, f.Name}) + } + } + } + walk(reflect.ValueOf(rr).Elem()) + return out +} + +// fixNames lowercases RDATA domain names and repairs the classic mistake +// of writing an absolute name without the trailing dot when it already +// ends in the zone name ("www.example.com" in zone example.com would +// otherwise become "www.example.com.example.com."). +func (p *Parsed) fixNames(zone, input string) error { + _ = input + zoneNoDot := strings.TrimSuffix(zone, ".") + dup := zoneNoDot + "." + zone // "example.com.example.com." + for _, f := range domainFields(p.RR) { + n := strings.ToLower(f.v.String()) + if zone != dnsname.Root && (n == dup || strings.HasSuffix(n, "."+dup)) { + fixed := strings.TrimSuffix(n, zone) + p.Warnings = append(p.Warnings, fmt.Sprintf("%q was read as the absolute name %q (add a trailing dot to be explicit)", strings.TrimSuffix(fixed, "."), fixed)) + n = fixed + } + f.v.SetString(n) + p.Targets = append(p.Targets, n) + } + return nil +} + +// check applies type-specific rules, most of them the same ones BIND's +// check-names and integrity checks apply when it loads a primary zone. +func (p *Parsed) check(owner string) error { + hostTarget := func(field, name string, allowRoot bool) error { + if name == "." { + if allowRoot { + return nil + } + return fmt.Errorf("%s must not be the root name", field) + } + if looksLikeIPv4Prefix(name) { + return fmt.Errorf("%s must be a host name, not an IP address (%q)", field, dnsname.Display(name)) + } + if !dnsname.IsHostname(name, false) { + return fmt.Errorf("%s %q is not a valid host name (letters, digits and hyphens only; check-names would reject it)", field, dnsname.Display(name)) + } + return nil + } + switch v := p.RR.(type) { + case *dns.A: + if !dnsname.IsHostname(owner, true) { + return fmt.Errorf("the owner of an A record must be a valid host name (no underscores); %q would be rejected by check-names", dnsname.Display(owner)) + } + case *dns.AAAA: + if !dnsname.IsHostname(owner, true) { + return fmt.Errorf("the owner of an AAAA record must be a valid host name (no underscores); %q would be rejected by check-names", dnsname.Display(owner)) + } + if v.AAAA.To4() != nil && strings.Contains(v.AAAA.String(), ".") { + p.Warnings = append(p.Warnings, "AAAA record contains an IPv4-mapped address") + } + case *dns.MX: + if !dnsname.IsHostname(owner, true) { + return fmt.Errorf("the owner of an MX record must be a valid host name; %q would be rejected by check-names", dnsname.Display(owner)) + } + if v.Mx == "." { + if v.Preference != 0 { + return errors.New(`a "null MX" (exchange ".") must have preference 0 (RFC 7505)`) + } + return nil + } + return hostTarget("MX exchange", v.Mx, false) + case *dns.NS: + return hostTarget("NS target", v.Ns, false) + case *dns.SRV: + if err := hostTarget("SRV target", v.Target, true); err != nil { + return err + } + labels := dns.SplitDomainName(owner) + if len(labels) < 3 || !strings.HasPrefix(labels[0], "_") || !strings.HasPrefix(labels[1], "_") { + p.Warnings = append(p.Warnings, "SRV owner names should look like _service._proto.name (RFC 2782)") + } + case *dns.PTR: + if dnsname.IsReverse(owner) { + return hostTarget("PTR target", v.Ptr, false) + } + case *dns.CNAME: + if v.Target == "." { + return errors.New("CNAME target must not be the root name") + } + case *dns.DNAME: + if v.Target == "." { + return errors.New("DNAME target must not be the root name") + } + case *dns.CAA: + switch v.Tag { + case "issue", "issuewild", "iodef", "issuemail", "issuevmc", "contactemail", "contactphone": + default: + p.Warnings = append(p.Warnings, fmt.Sprintf("unknown CAA property tag %q", v.Tag)) + } + if v.Flag != 0 && v.Flag != 128 { + p.Warnings = append(p.Warnings, "CAA flags should be 0 or 128 (issuer critical)") + } + case *dns.DS: + if err := checkDigest("DS", v.DigestType, v.Digest, map[uint8]int{1: 20, 2: 32, 4: 48}); err != nil { + return err + } + case *dns.SSHFP: + if err := checkDigest("SSHFP", v.Type, v.FingerPrint, map[uint8]int{1: 20, 2: 32}); err != nil { + return err + } + case *dns.TLSA: + if err := checkDigest("TLSA", v.MatchingType, v.Certificate, map[uint8]int{1: 32, 2: 64}); err != nil { + return err + } + labels := dns.SplitDomainName(owner) + if len(labels) < 3 || !strings.HasPrefix(labels[0], "_") || !strings.HasPrefix(labels[1], "_") { + p.Warnings = append(p.Warnings, "TLSA owner names should look like _port._proto.name (RFC 6698)") + } + case *dns.SPF: + p.Warnings = append(p.Warnings, "the SPF record type is deprecated (RFC 7208); publish SPF policies as TXT records") + } + return nil +} + +func looksLikeIPv4Prefix(name string) bool { + // "10.0.0.1.example.com." from "MX 10 10.0.0.1": four numeric labels. + labels := dns.SplitDomainName(name) + if len(labels) < 4 { + return false + } + for _, l := range labels[:4] { + if l == "" || len(l) > 3 { + return false + } + for i := 0; i < len(l); i++ { + if !isDigit(l[i]) { + return false + } + } + } + return true +} + +func checkDigest(t string, digestType uint8, digest string, sizes map[uint8]int) error { + want, known := sizes[digestType] + if !known { + return nil + } + b, err := hex.DecodeString(digest) + if err != nil { + return fmt.Errorf("%s digest is not valid hex", t) + } + if len(b) != want { + return fmt.Errorf("%s digest type %d needs %d bytes (%d hex digits), got %d bytes", t, digestType, want, want*2, len(b)) + } + return nil +} diff --git a/internal/rr/rr_test.go b/internal/rr/rr_test.go new file mode 100644 index 0000000..a4a0078 --- /dev/null +++ b/internal/rr/rr_test.go @@ -0,0 +1,159 @@ +package rr + +import ( + "strings" + "testing" +) + +const zone = "example.com." + +func TestParseCanonical(t *testing.T) { + cases := []struct { + owner, typ, data, want string + }{ + {"www.example.com.", "a", "192.0.2.1", "192.0.2.1"}, + {"www.example.com.", "AAAA", "2001:DB8:0:0::1", "2001:db8::1"}, + {"www.example.com.", "CNAME", "web", "web.example.com."}, + {"www.example.com.", "CNAME", "Web.Example.Org.", "web.example.org."}, + {"www.example.com.", "CNAME", "host.example.com", "host.example.com."}, + {"example.com.", "MX", "10 mail", "10 mail.example.com."}, + {"example.com.", "MX", "0 .", "0 ."}, + {"example.com.", "TXT", "v=spf1 mx -all", `"v=spf1 mx -all"`}, + {"example.com.", "TXT", `"a b" "c"`, `"a b" "c"`}, + {"example.com.", "TXT", `say "hi" \o/`, `"say \"hi\" \\o/"`}, + {"example.com.", "TXT", "semi; colon", `"semi; colon"`}, + {"example.com.", "CAA", "0 issue letsencrypt.org", `0 issue "letsencrypt.org"`}, + {"_sip._tcp.example.com.", "SRV", "0 5 5060 sip", "0 5 5060 sip.example.com."}, + {"svc.example.com.", "HTTPS", `1 . alpn="h2,h3"`, `1 . alpn="h2,h3"`}, + {"x.example.com.", "type65280", `\# 4 deadbeef`, `\# 4 deadbeef`}, + {"10.2.0.192.in-addr.arpa.", "PTR", "host.example.com.", "host.example.com."}, + {"_dmarc.example.com.", "TXT", "v=DMARC1; p=none", `"v=DMARC1; p=none"`}, + {"*.example.com.", "A", "192.0.2.9", "192.0.2.9"}, + } + for _, c := range cases { + p, err := Parse(zone, c.owner, c.typ, c.data) + if err != nil { + t.Errorf("Parse(%s %s %q): %v", c.owner, c.typ, c.data, err) + continue + } + if p.Data != c.want { + t.Errorf("Parse(%s %s %q) = %q, want %q", c.owner, c.typ, c.data, p.Data, c.want) + } + } +} + +func TestParseWarnings(t *testing.T) { + p, err := Parse(zone, "www.example.com.", "CNAME", "host.example.com") + if err != nil || len(p.Warnings) != 1 || !strings.Contains(p.Warnings[0], "absolute name") { + t.Errorf("double origin warning missing: %+v %v", p, err) + } + p, err = Parse(zone, "sip.example.com.", "SRV", "0 5 5060 sip") + if err != nil || len(p.Warnings) != 1 { + t.Errorf("SRV owner warning missing: %+v %v", p, err) + } +} + +func TestParseErrors(t *testing.T) { + cases := []struct { + owner, typ, data, want string + }{ + {"www.example.com.", "A", "01.2.3.4", "invalid A data"}, + {"www.example.com.", "A", "", "empty"}, + {"www.example.com.", "SOA", "x", "managed by Jarl"}, + {"www.example.com.", "RRSIG", "x", "maintained by named"}, + {"www.example.com.", "BOGUS", "x", "unknown record type"}, + {"foo_bar.example.com.", "A", "1.2.3.4", "valid host name"}, + {"example.com.", "MX", "10 1.2.3.4", "not an IP address"}, + {"example.com.", "MX", "10 bad_host.example.org.", "not a valid host name"}, + {"example.com.", "MX", "5 .", "null MX"}, + {"www.example.com.", "A", "1.2.3.4\n$INCLUDE /etc/passwd", "line breaks"}, + {"www.example.com.", "A", "1.2.3.4 ; comment", "comment"}, + {"www.example.com.", "A", "1.2.3.4 5.6.7.8", "invalid A data"}, + {"www.example.com.", "DS", "12345 13 2 abcd", "needs 32 bytes"}, + {"www.example.com.", "NS", "1.2.3.4.", "not an IP address"}, + {"www.example.com.", "CNAME", ".", "root"}, + {"www.example.com.", "A", "$ORIGIN x", "'$'"}, + } + for _, c := range cases { + _, err := Parse(zone, c.owner, c.typ, c.data) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("Parse(%s %s %q) error = %v, want %q", c.owner, c.typ, c.data, err, c.want) + } + } +} + +func TestQuoteText(t *testing.T) { + long := strings.Repeat("a", 600) + q := QuoteText(long) + if strings.Count(q, `"`) != 6 { + t.Errorf("600 bytes should be 3 strings: %q", q) + } + p, err := Parse(zone, "k._domainkey.example.com.", "TXT", "v=DKIM1; k=rsa; p="+long) + if err != nil { + t.Fatal(err) + } + v, _ := TextValue(p.RR) + if v != "v=DKIM1; k=rsa; p="+long { + t.Errorf("TextValue mismatch") + } + // Multi-byte characters are not split across strings. + s := strings.Repeat("é", 200) // 400 bytes + for _, part := range strings.Split(QuoteText(s), `" "`) { + if strings.ContainsRune(strings.Trim(part, `"`), '\uFFFD') { + t.Error("split inside a UTF-8 sequence") + } + } + p, err = Parse(zone, "example.com.", "TXT", `say "hi" \o/`) + if err != nil { + t.Fatal(err) + } + if v, _ := TextValue(p.RR); v != `say "hi" \o/` { + t.Errorf("TextValue = %q", v) + } +} + +func TestTypes(t *testing.T) { + types := Types() + if types[0].Type != "A" || !types[0].Common { + t.Errorf("first type = %+v", types[0]) + } + seen := map[string]bool{} + for _, ti := range types { + if seen[ti.Type] { + t.Errorf("duplicate %s", ti.Type) + } + seen[ti.Type] = true + if _, _, err := NormalizeType(ti.Type); err != nil { + t.Errorf("listed type %s not accepted: %v", ti.Type, err) + } + if ti.Example != "" { + owner := "x.example.com." + switch ti.Type { + case "SRV": + owner = "_sip._tcp.example.com." + case "TLSA": + owner = "_443._tcp.example.com." + } + if _, err := Parse(zone, owner, ti.Type, ti.Example); err != nil { + t.Errorf("example for %s fails: %v", ti.Type, err) + } + } + } + for _, bad := range []string{"SOA", "RRSIG", "OPT", "DNSKEY"} { + if seen[bad] { + t.Errorf("forbidden type %s listed", bad) + } + } +} + +func TestParseErrorText(t *testing.T) { + for _, tc := range []struct{ typ, data, want string }{ + {"A", "192.168.1.300", `invalid A data: bad value "192.168.1.300"`}, + {"MX", "x mail", `invalid MX data: bad Pref "x"`}, + } { + _, err := Parse("example.com.", "www.example.com.", tc.typ, tc.data) + if err == nil || err.Error() != tc.want { + t.Errorf("Parse(%s %q) error = %v, want %q", tc.typ, tc.data, err, tc.want) + } + } +} diff --git a/internal/rr/types.go b/internal/rr/types.go new file mode 100644 index 0000000..e8a58af --- /dev/null +++ b/internal/rr/types.go @@ -0,0 +1,85 @@ +package rr + +import ( + "sort" + + "github.com/miekg/dns" +) + +// TypeInfo describes a record type for the web UI and CLI. +type TypeInfo struct { + Type string `json:"type"` + Code uint16 `json:"code"` + Common bool `json:"common"` + Description string `json:"description"` + Format string `json:"format,omitempty"` + Example string `json:"example,omitempty"` +} + +var described = []TypeInfo{ + {Type: "A", Common: true, Description: "IPv4 address", Format: "", Example: "192.0.2.10"}, + {Type: "AAAA", Common: true, Description: "IPv6 address", Format: "", Example: "2001:db8::10"}, + {Type: "CNAME", Common: true, Description: "Alias for another name (no other records may exist at the same name)", Format: "", Example: "www.example.com."}, + {Type: "MX", Common: true, Description: "Mail exchanger", Format: " ", Example: "10 mail.example.com."}, + {Type: "TXT", Common: true, Description: "Text. Plain text is quoted and split into 255-byte strings automatically; start with a quote to give presentation format.", Format: " or \"string\" \"string\"", Example: "v=spf1 mx -all"}, + {Type: "NS", Common: true, Description: "Name server for the zone (at the apex) or a delegation (below it)", Format: "", Example: "ns1.example.com."}, + {Type: "PTR", Common: true, Description: "Reverse mapping (in in-addr.arpa / ip6.arpa zones)", Format: "", Example: "host.example.com."}, + {Type: "SRV", Common: true, Description: "Service location (owner _service._proto.name)", Format: " ", Example: "0 5 5060 sip.example.com."}, + {Type: "CAA", Common: true, Description: "Certification authority authorization", Format: " ", Example: "0 issue \"letsencrypt.org\""}, + {Type: "HTTPS", Common: true, Description: "HTTPS service binding (RFC 9460)", Format: " [params...]", Example: "1 . alpn=\"h2,h3\""}, + {Type: "SVCB", Description: "General service binding (RFC 9460)", Format: " [params...]", Example: "1 svc.example.com. alpn=\"h2\" port=8443"}, + {Type: "SSHFP", Description: "SSH host key fingerprint", Format: " ", Example: "4 2 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}, + {Type: "TLSA", Description: "TLS certificate association, DANE (owner _port._proto.name)", Format: " ", Example: "3 1 1 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + {Type: "DS", Description: "Delegation signer for a signed child zone (at a delegation point)", Format: " ", Example: "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + {Type: "DNAME", Description: "Redirects a whole subtree to another name", Format: "", Example: "other.example.net."}, + {Type: "NAPTR", Description: "Naming authority pointer", Format: " \"\" \"\" \"\" ", Example: "100 10 \"u\" \"E2U+sip\" \"!^.*$!sip:info@example.com!\" ."}, + {Type: "LOC", Description: "Geographical location", Format: " m [size [hp [vp]]]", Example: "52 22 23.000 N 4 53 32.000 E -2.00m 0.00m 10000m 10m"}, + {Type: "HINFO", Description: "Host information", Format: "\"\" \"\"", Example: "\"PC\" \"Linux\""}, + {Type: "RP", Description: "Responsible person", Format: " ", Example: "admin.example.com. ."}, + {Type: "URI", Description: "Uniform resource identifier", Format: " \"\"", Example: "10 1 \"https://example.com/\""}, + {Type: "OPENPGPKEY", Description: "OpenPGP public key (DANE)", Format: ""}, + {Type: "SMIMEA", Description: "S/MIME certificate association", Format: " "}, + {Type: "CERT", Description: "Certificate", Format: " "}, + {Type: "DHCID", Description: "DHCP identifier", Format: ""}, + {Type: "AFSDB", Description: "AFS database location", Format: " ", Example: "1 afs.example.com."}, + {Type: "APL", Description: "Address prefix list", Format: "[!]:/ ...", Example: "1:192.168.32.0/21"}, + {Type: "IPSECKEY", Description: "IPsec key", Format: " "}, + {Type: "KX", Description: "Key exchanger", Format: " ", Example: "10 kx.example.com."}, + {Type: "KEY", Description: "Public key (SIG(0) and other legacy uses)", Format: " "}, + {Type: "SPF", Description: "Deprecated SPF record type; publish SPF policies as TXT instead", Format: "\"v=spf1 ...\""}, + {Type: "CSYNC", Description: "Child-to-parent synchronization", Format: " ", Example: "66 3 A NS AAAA"}, + {Type: "EUI48", Description: "48-bit MAC address", Format: "xx-xx-xx-xx-xx-xx", Example: "00-00-5e-00-53-2a"}, + {Type: "EUI64", Description: "64-bit EUI address", Format: "xx-xx-xx-xx-xx-xx-xx-xx", Example: "00-00-5e-ef-10-00-00-2a"}, + {Type: "RESINFO", Description: "Resolver information (RFC 9606)", Format: "key=value ...", Example: "qnamemin exterr=15,16,17"}, +} + +// Types returns information about every record type that can be managed, +// common types first. +func Types() []TypeInfo { + seen := map[string]bool{} + var out []TypeInfo + for _, t := range described { + code, ok := dns.StringToType[t.Type] + if !ok { + continue + } + t.Code = code + out = append(out, t) + seen[t.Type] = true + } + var rest []TypeInfo + for code, name := range dns.TypeToString { + if seen[name] { + continue + } + if _, ok := dns.TypeToRR[code]; !ok { + continue + } + if _, bad := forbidden[code]; bad { + continue + } + rest = append(rest, TypeInfo{Type: name, Code: code, Description: name + " record (zone file format)"}) + } + sort.Slice(rest, func(i, j int) bool { return rest[i].Type < rest[j].Type }) + return append(out, rest...) +} diff --git a/internal/server/authn.go b/internal/server/authn.go new file mode 100644 index 0000000..96ae573 --- /dev/null +++ b/internal/server/authn.go @@ -0,0 +1,262 @@ +package server + +import ( + "context" + "crypto/subtle" + "errors" + "net/http" + "strings" + "sync" + "time" + + "jarl/internal/auth" + "jarl/internal/model" + "jarl/internal/store" +) + +const ( + sessionCookie = "jarl_session" + csrfHeader = "X-CSRF-Token" +) + +// rateLimiter limits failed authentication attempts per key (IP or +// username) with a sliding window. +type rateLimiter struct { + mu sync.Mutex + hits map[string][]time.Time +} + +func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} } + +const ( + failWindow = 15 * time.Minute + failsPerIP = 20 + failsPerKey = 8 +) + +func (l *rateLimiter) recent(key string, now time.Time) []time.Time { + var keep []time.Time + for _, t := range l.hits[key] { + if now.Sub(t) < failWindow { + keep = append(keep, t) + } + } + l.hits[key] = keep + return keep +} + +// blocked reports whether key has failed too often. +func (l *rateLimiter) blocked(key string, max int) bool { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.recent(key, time.Now())) >= max +} + +func (l *rateLimiter) fail(keys ...string) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + for _, k := range keys { + l.hits[k] = append(l.recent(k, now), now) + } +} + +func (l *rateLimiter) reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.hits, key) +} + +func (l *rateLimiter) cleanup() { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + for k := range l.hits { + if len(l.recent(k, now)) == 0 { + delete(l.hits, k) + } + } +} + +var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"} + +// authenticate identifies the caller from a bearer token or a session +// cookie. For sessions, state-changing requests must carry the CSRF token. +func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) { + ip := s.clientIP(r) + if h := r.Header.Get("Authorization"); h != "" { + scheme, tok, _ := strings.Cut(h, " ") + if !strings.EqualFold(scheme, "Bearer") { + return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"} + } + if s.limiter.blocked("ip:"+ip, failsPerIP) { + return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"} + } + p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent()) + if err != nil { + s.limiter.fail("ip:" + ip) + s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error()) + return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()} + } + return p, nil, nil + } + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + return nil, nil, errUnauthenticated + } + hash := auth.HashSecret(c.Value) + se, err := s.store.GetSession(r.Context(), hash) + if err != nil { + return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"} + } + now := time.Now() + if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle { + s.store.DeleteSession(r.Context(), hash) + return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"} + } + u, err := s.store.GetUser(r.Context(), se.UserID) + if err != nil || u.Disabled { + s.store.DeleteSession(r.Context(), hash) + return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"} + } + if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { + got := r.Header.Get(csrfHeader) + if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 { + return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"} + } + } + if now.Sub(se.LastSeenAt) > time.Minute { + s.store.TouchSession(r.Context(), hash, now) + } + return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil +} + +func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) { + pid, secret, err := auth.ParseToken(tok) + if err != nil { + return nil, err + } + t, err := s.store.GetTokenByPublicID(ctx, pid) + if errors.Is(err, store.ErrNotFound) { + return nil, errors.New("unknown token") + } + if err != nil { + return nil, err + } + if !auth.CheckSecret(secret, t.SecretHash) { + return nil, errors.New("wrong token secret") + } + now := time.Now() + if t.RevokedAt != nil { + return nil, errors.New("token was revoked") + } + if t.ExpiresAt != nil && now.After(*t.ExpiresAt) { + return nil, errors.New("token has expired") + } + u, err := s.store.GetUser(ctx, t.UserID) + if err != nil { + return nil, err + } + if u.Disabled { + return nil, errors.New("the token's user is disabled") + } + if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip { + s.store.TouchToken(ctx, t.ID, ip) + } + return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil +} + +// authed wraps a handler with authentication and error handling. +func (s *Server) authed(h handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes) + p, se, err := s.authenticate(r) + if err != nil { + s.writeErr(w, r, err, nil) + return + } + ctx := context.WithValue(r.Context(), keyPrincipal, p) + if se != nil { + ctx = context.WithValue(ctx, keySession, se) + } + r = r.WithContext(ctx) + if err := h(w, r, p); err != nil { + s.writeErr(w, r, err, nil) + } + } +} + +// public wraps an unauthenticated handler. +func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := h(w, r); err != nil { + s.writeErr(w, r, err, nil) + } + } +} + +func (s *Server) cookieSecure(r *http.Request) bool { + switch s.cfg.CookieSecure { + case "always": + return true + case "never": + return false + } + return s.isHTTPS(r) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) error { + var in struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &in); err != nil { + return err + } + ip := s.clientIP(r) + name := strings.TrimSpace(in.Username) + userKey := "user:" + strings.ToLower(name) + if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) { + s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_blocked", "user", 0, name, "denied", "too many failed attempts") + return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"} + } + u, err := s.store.GetUserByName(r.Context(), name) + hash := "" + if err == nil { + hash = u.PasswordHash + } + ok := auth.CheckPassword(hash, in.Password) // constant time even for unknown users + if err != nil || !ok || u.Disabled || u.PasswordHash == "" { + s.limiter.fail("ip:"+ip, userKey) + reason := "wrong username or password" + if err == nil && u.Disabled && ok { + reason = "account disabled" + } + s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason) + return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} + } + s.limiter.reset(userKey) + id, csrf := auth.NewSessionID() + now := time.Now() + se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()} + if err := s.store.CreateSession(r.Context(), se); err != nil { + return err + } + s.store.TouchLogin(r.Context(), u.ID) + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt}) + p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()} + s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", "") + writeJSON(w, http.StatusOK, s.whoamiOf(p, se)) + return nil +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if se, ok := r.Context().Value(keySession).(*model.Session); ok { + s.store.DeleteSession(r.Context(), se.IDHash) + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1}) + s.eng.Audit(r.Context(), p, "auth.logout", "user", p.User.ID, p.Name(), "success", "") + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/internal/server/h_auth.go b/internal/server/h_auth.go new file mode 100644 index 0000000..a804391 --- /dev/null +++ b/internal/server/h_auth.go @@ -0,0 +1,447 @@ +package server + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/model" + "jarl/internal/store" +) + +func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + se, _ := r.Context().Value(keySession).(*model.Session) + writeJSON(w, http.StatusOK, s.whoamiOf(p, se)) + return nil +} + +func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if p.Token != nil { + return forbidden("passwords can't be changed with an API token; log in to the web UI") + } + var in api.PasswordChange + if err := decodeJSON(r, &in); err != nil { + return err + } + u, err := s.store.GetUser(r.Context(), p.User.ID) + if err != nil { + return err + } + if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) { + s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "failure", "wrong current password") + return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"} + } + hash, err := auth.HashPassword(in.Password) + if err != nil { + return badRequest("%v", err) + } + u.PasswordHash = hash + if err := s.store.UpdateUser(r.Context(), u); err != nil { + return err + } + // Log out other sessions. + keep := "" + if se, ok := r.Context().Value(keySession).(*model.Session); ok { + keep = se.IDHash + } + s.store.DeleteUserSessions(r.Context(), u.ID, keep) + s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "success", "") + w.WriteHeader(http.StatusNoContent) + return nil +} + +// grantsFrom builds the grant list from structured grants, compact +// strings or a role template. +func grantsFrom(grants *[]model.Grant, strs *[]string, role string) ([]model.Grant, bool, error) { + var out []model.Grant + set := false + if role != "" { + t, ok := auth.RoleTemplates[role] + if !ok { + var names []string + for n := range auth.RoleTemplates { + names = append(names, n) + } + return nil, false, badRequest("unknown role %q (known: %s)", role, strings.Join(names, ", ")) + } + out = append(out, t...) + set = true + } + if grants != nil { + for _, g := range *grants { + if err := auth.ValidateGrant(&g); err != nil { + return nil, false, badRequest("%v", err) + } + out = append(out, g) + } + set = true + } + if strs != nil { + for _, gs := range *strs { + g, err := auth.ParseGrant(gs) + if err != nil { + return nil, false, badRequest("grant %q: %v", gs, err) + } + out = append(out, g) + } + set = true + } + if set && out == nil { + out = []model.Grant{} + } + return out, set, nil +} + +func parseExpiry(in api.TokenInput) (*time.Time, error) { + if in.ExpiresAt != nil { + if in.ExpiresAt.Before(time.Now()) { + return nil, badRequest("expires_at is in the past") + } + return in.ExpiresAt, nil + } + if in.ExpiresIn == "" { + return nil, nil + } + v := strings.TrimSpace(in.ExpiresIn) + var d time.Duration + if n, ok := strings.CutSuffix(v, "d"); ok { + days, err := strconv.Atoi(n) + if err != nil || days <= 0 { + return nil, badRequest("invalid expires_in %q", v) + } + d = time.Duration(days) * 24 * time.Hour + } else { + var err error + d, err = time.ParseDuration(v) + if err != nil || d <= 0 { + return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", v) + } + } + t := time.Now().Add(d) + return &t, nil +} + +func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error { + var in api.TokenInput + if err := decodeJSON(r, &in); err != nil { + return err + } + if strings.TrimSpace(in.Name) == "" { + return badRequest("a token needs a name") + } + grants, set, err := grantsFrom(in.Grants, in.GrantStrings, in.Role) + if err != nil { + return err + } + exp, err := parseExpiry(in) + if err != nil { + return err + } + // A restricted token can only create tokens that are no wider than + // itself. + if p.Token != nil && p.Token.Grants != nil { + if !set { + grants = p.Token.Grants + } else if !narrower(grants, p.Token.Grants) { + return forbidden("a restricted token can only create tokens with a subset of its own grants") + } + } + secret, pid, hash := auth.NewToken() + t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp} + if set { + t.Grants = grants + } + if err := s.store.CreateToken(r.Context(), t); err != nil { + return err + } + t.Username = u.Username + s.eng.Audit(r.Context(), p, "token.create", "token", t.ID, u.Username+"/"+t.Name, "success", "") + out := presentToken(t) + out.Secret = secret + writeJSON(w, http.StatusCreated, out) + return nil +} + +// narrower reports whether every grant in a is covered by a grant in b +// (a conservative check: identical or b is unfiltered for the resource). +func narrower(a, b []model.Grant) bool { + for _, g := range a { + ok := false + for _, h := range b { + if (h.Resource == auth.ResAll || h.Resource == g.Resource) && (h.Access == auth.Write || g.Access == auth.Read) && + len(h.Zones) == 0 && len(h.Views) == 0 && len(h.Names) == 0 && len(h.Types) == 0 { + ok = true + break + } + if auth.FormatGrant(g) == auth.FormatGrant(h) { + ok = true + break + } + } + if !ok { + return false + } + } + return true +} + +func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + return s.createTokenFor(w, r, p, p.User) +} + +func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error { + ts, err := s.store.ListTokens(r.Context(), userID) + if err != nil { + return err + } + out := []api.Token{} + for _, t := range ts { + if t.RevokedAt != nil && !queryBool(r, "all") { + continue + } + out = append(out, presentToken(t)) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + return s.listTokens(w, r, p.User.ID) +} + +func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + return s.listTokens(w, r, 0) +} + +func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + t, err := s.store.GetToken(r.Context(), id) + if err != nil { + return err + } + if t.UserID != p.User.ID && !p.IsAdmin() { + return notFound("token %d not found", id) + } + if err := s.store.RevokeToken(r.Context(), id); err != nil { + if errors.Is(err, store.ErrNotFound) { + return conflict("token %d is already revoked", id) + } + return err + } + s.eng.Audit(r.Context(), p, "token.revoke", "token", t.ID, t.Username+"/"+t.Name, "success", "") + w.WriteHeader(http.StatusNoContent) + return nil +} + +func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + us, err := s.store.ListUsers(r.Context()) + if err != nil { + return err + } + out := []api.User{} + for _, u := range us { + out = append(out, presentUser(u)) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func validUsername(n string) error { + if len(n) < 1 || len(n) > 64 { + return badRequest("usernames must be 1 to 64 characters") + } + for _, c := range n { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) { + return badRequest("usernames may contain letters, digits, '.', '_', '-' and '@'") + } + } + return nil +} + +func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + var in api.UserInput + if err := decodeJSON(r, &in); err != nil { + return err + } + if in.Username == nil { + return badRequest("a user needs a username") + } + u := &model.User{Username: strings.TrimSpace(*in.Username)} + if err := validUsername(u.Username); err != nil { + return err + } + if err := applyUserInput(u, &in); err != nil { + return err + } + if err := s.store.CreateUser(r.Context(), u); err != nil { + if errors.Is(err, store.ErrConflict) { + return conflict("user %s already exists", u.Username) + } + return err + } + s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; ")) + writeJSON(w, http.StatusCreated, presentUser(u)) + return nil +} + +func applyUserInput(u *model.User, in *api.UserInput) error { + role := "" + if in.Role != nil { + role = *in.Role + } + grants, set, err := grantsFrom(in.Grants, in.GrantStrings, role) + if err != nil { + return err + } + if set { + u.Grants = grants + } + if in.Admin != nil { + u.Admin = *in.Admin + } + if in.Disabled != nil { + u.Disabled = *in.Disabled + } + if in.Comment != nil { + u.Comment = *in.Comment + } + if in.Password != nil { + if *in.Password == "" { + u.PasswordHash = "" // service account: tokens only + } else { + h, err := auth.HashPassword(*in.Password) + if err != nil { + return badRequest("%v", err) + } + u.PasswordHash = h + } + } + return nil +} + +func (s *Server) userByPath(r *http.Request) (*model.User, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + u, err := s.store.GetUser(r.Context(), id) + if errors.Is(err, store.ErrNotFound) { + return nil, notFound("user %d not found", id) + } + return u, err +} + +func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + u, err := s.userByPath(r) + if err != nil { + return err + } + if u.ID != p.User.ID && !p.IsAdmin() { + return forbidden("only administrators can see other users") + } + writeJSON(w, http.StatusOK, presentUser(u)) + return nil +} + +func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + u, err := s.userByPath(r) + if err != nil { + return err + } + var in api.UserInput + if err := decodeJSON(r, &in); err != nil { + return err + } + wasAdmin := u.Admin && !u.Disabled + if in.Username != nil { + u.Username = strings.TrimSpace(*in.Username) + if err := validUsername(u.Username); err != nil { + return err + } + } + if err := applyUserInput(u, &in); err != nil { + return err + } + if wasAdmin && (!u.Admin || u.Disabled) { + if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 { + return conflict("%s is the last administrator; make someone else an administrator first", u.Username) + } + } + if err := s.store.UpdateUser(r.Context(), u); err != nil { + if errors.Is(err, store.ErrConflict) { + return conflict("user %s already exists", u.Username) + } + return err + } + if u.Disabled || in.Password != nil { + s.store.DeleteUserSessions(r.Context(), u.ID, "") + } + s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; ")) + writeJSON(w, http.StatusOK, presentUser(u)) + return nil +} + +func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + u, err := s.userByPath(r) + if err != nil { + return err + } + if u.ID == p.User.ID { + return conflict("you can't delete yourself") + } + if u.Admin && !u.Disabled { + if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 { + return conflict("%s is the last administrator", u.Username) + } + } + if err := s.store.DeleteUser(r.Context(), u.ID); err != nil { + return err + } + s.eng.Audit(r.Context(), p, "user.delete", "user", u.ID, u.Username, "success", "") + w.WriteHeader(http.StatusNoContent) + return nil +} + +func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + u, err := s.userByPath(r) + if err != nil { + return err + } + if u.ID != p.User.ID && !p.IsAdmin() { + return forbidden("only administrators can see other users' tokens") + } + return s.listTokens(w, r, u.ID) +} + +func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + u, err := s.userByPath(r) + if err != nil { + return err + } + if u.ID != p.User.ID && !p.IsAdmin() { + return forbidden("only administrators can create tokens for other users") + } + return s.createTokenFor(w, r, p, u) +} diff --git a/internal/server/h_changes.go b/internal/server/h_changes.go new file mode 100644 index 0000000..e002001 --- /dev/null +++ b/internal/server/h_changes.go @@ -0,0 +1,344 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/engine" + "jarl/internal/model" + "jarl/internal/store" +) + +func toRequests(in []api.ChangeRequest) []engine.Request { + out := make([]engine.Request, len(in)) + for i, c := range in { + out[i] = engine.Request{Op: c.Op, ID: c.ID, BaseVersion: c.BaseVersion, Data: c.Data} + } + return out +} + +func presentChangeset(cs *model.Changeset) api.Changeset { + c := *cs + c.Ops = engine.RedactOps(cs.Ops) + undoable := false + if cs.Status == model.ChangesetApplied && len(cs.Result) > 0 { + var r struct { + Undo []json.RawMessage `json:"undo"` + } + if json.Unmarshal(cs.Result, &r) == nil { + undoable = len(r.Undo) > 0 + } + // The undo operations hold old TSIG secrets; don't send them. + var m map[string]json.RawMessage + if json.Unmarshal(cs.Result, &m) == nil { + delete(m, "undo") + c.Result, _ = json.Marshal(m) + } + } + return api.Changeset{Changeset: c, Undoable: undoable} +} + +// applyBatch: POST /apply applies several requests atomically. +func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + var in api.BatchRequest + if err := decodeJSON(r, &in); err != nil { + return err + } + if len(in.Ops) == 0 { + return badRequest("no operations given") + } + q := r.URL.Query() + if in.DryRun { + q.Set("dry_run", "1") + } + if in.Force { + q.Set("force", "1") + } + r.URL.RawQuery = q.Encode() + return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil) +} + +func (s *Server) listChangesets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + f := store.ChangesetFilter{Status: r.URL.Query().Get("status"), Limit: queryInt(r, "limit", 100), Before: int64(queryInt(r, "before", 0))} + all := p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResAudit}) + if !all || queryBool(r, "mine") { + f.UserID = p.User.ID + } + list, err := s.store.ListChangesets(r.Context(), f) + if err != nil { + return err + } + out := []api.Changeset{} + for _, cs := range list { + out = append(out, presentChangeset(cs)) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + var in api.BatchRequest + body, err := readBody(r) + if err != nil { + return err + } + if len(body) > 0 { + if err := json.Unmarshal(body, &in); err != nil { + return badRequest("invalid JSON: %v", err) + } + } + cs, err := s.eng.CreateChangeset(r.Context(), p, via(r, p), in.Description) + if err != nil { + return err + } + if len(in.Ops) > 0 { + res, err := s.eng.Stage(r.Context(), cs.ID, toRequests(in.Ops), p) + if err != nil { + s.eng.Discard(r.Context(), cs.ID, p) + return err + } + cs = res.Changeset + if in.Apply { + out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force}) + if err != nil { + s.writeErr(w, r, err, out) + return nil + } + writeJSON(w, http.StatusCreated, api.MutationResponse{Applied: out.Applied, DryRun: out.DryRun, ChangesetID: cs.ID, Result: raw(out), Warnings: out.Warnings}) + return nil + } + writeJSON(w, http.StatusCreated, api.MutationResponse{Staged: true, ChangesetID: cs.ID, Changeset: raw(presentChangeset(cs)), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)}) + return nil + } + writeJSON(w, http.StatusCreated, presentChangeset(cs)) + return nil +} + +func (s *Server) changesetByPath(r *http.Request, p *auth.Principal) (*model.Changeset, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + cs, err := s.store.GetChangeset(r.Context(), id) + if errors.Is(err, store.ErrNotFound) || err == nil && !engine.CanAccessChangeset(p, cs, false) { + return nil, notFound("changeset %d not found", id) + } + return cs, err +} + +func (s *Server) getChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + cs, err := s.changesetByPath(r, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentChangeset(cs)) + return nil +} + +func (s *Server) updateChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + var in struct { + Description string `json:"description"` + } + if err := decodeJSON(r, &in); err != nil { + return err + } + cs, err := s.eng.UpdateDescription(r.Context(), id, in.Description, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentChangeset(cs)) + return nil +} + +func (s *Server) stageOps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + var in api.BatchRequest + if err := decodeJSON(r, &in); err != nil { + return err + } + res, err := s.eng.Stage(r.Context(), id, toRequests(in.Ops), p) + if err != nil { + return err + } + writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: id, Changeset: raw(presentChangeset(res.Changeset)), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)}) + return nil +} + +func (s *Server) unstageOp(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + seq, err := strconv.Atoi(r.PathValue("seq")) + if err != nil { + return badRequest("invalid operation number") + } + cs, err := s.eng.RemoveOps(r.Context(), id, []int{seq}, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentChangeset(cs)) + return nil +} + +func (s *Server) previewChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + res, err := s.eng.Preview(r.Context(), id, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, res) + return nil +} + +func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + var in struct { + DryRun bool `json:"dry_run"` + Force bool `json:"force"` + } + if body, err := readBody(r); err != nil { + return err + } else if len(body) > 0 { + if err := json.Unmarshal(body, &in); err != nil { + return badRequest("invalid JSON: %v", err) + } + } + res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")}) + if err != nil { + s.writeErr(w, r, err, res) + return nil + } + writeJSON(w, http.StatusOK, api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings}) + return nil +} + +func (s *Server) discardChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + if err := s.eng.Discard(r.Context(), id, p); err != nil { + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +func (s *Server) revertChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + cs, err := s.eng.Revert(r.Context(), id, p) + if err != nil { + return err + } + writeJSON(w, http.StatusCreated, presentChangeset(cs)) + return nil +} + +// ---- status, system, audit + +func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.eng.Status(r.Context()) + if err != nil { + return err + } + if !p.IsAdmin() && !p.Can(auth.Action{Resource: auth.ResOptions}) { + // Keep it to the essentials for restricted principals. + st.Problems = nil + st.Drift = nil + if st.Named != nil { + st.Named.Raw = "" + } + } + writeJSON(w, http.StatusOK, api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: time.Now()}) + return nil +} + +func (s *Server) systemCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil { + return err + } + res, err := s.eng.CheckCurrent(r.Context()) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, res) + return nil +} + +func (s *Server) systemResync(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := requireAdmin(p); err != nil { + return err + } + var in struct { + DryRun bool `json:"dry_run"` + Force bool `json:"force"` + } + if body, err := readBody(r); err != nil { + return err + } else if len(body) > 0 { + if err := json.Unmarshal(body, &in); err != nil { + return badRequest("invalid JSON: %v", err) + } + } + res, err := s.eng.Resync(r.Context(), p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force}) + if err != nil { + s.writeErr(w, r, err, res) + return nil + } + writeJSON(w, http.StatusOK, res) + return nil +} + +func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + q := r.URL.Query() + f := store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Outcome: q.Get("outcome"), ObjectType: q.Get("object_type"), + ChangesetID: int64(queryInt(r, "changeset", 0)), Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)} + for name, dst := range map[string]*time.Time{"since": &f.Since, "until": &f.Until} { + if v := q.Get(name); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + if d, derr := time.Parse("2006-01-02", v); derr == nil { + t = d + } else { + return badRequest("invalid %s %q (use RFC 3339 or YYYY-MM-DD)", name, v) + } + } + *dst = t + } + } + if !p.IsAdmin() && !p.Can(auth.Action{Resource: auth.ResAudit}) { + // Everyone may see their own activity. + f.Username = p.User.Username + } + evs, err := s.store.ListAudit(r.Context(), f) + if err != nil { + return err + } + if evs == nil { + evs = []*model.AuditEvent{} + } + writeJSON(w, http.StatusOK, evs) + return nil +} diff --git a/internal/server/h_config.go b/internal/server/h_config.go new file mode 100644 index 0000000..fc779f4 --- /dev/null +++ b/internal/server/h_config.go @@ -0,0 +1,549 @@ +package server + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/catalog" + "jarl/internal/dnsname" + "jarl/internal/engine" + "jarl/internal/model" + "jarl/internal/rr" +) + +// state returns the configuration a request works on: the current one, +// or, when the request names an open changeset (?changeset= or the +// X-Jarl-Changeset header), the configuration as it will be after that +// changeset, so staged objects can be referred to. +func (s *Server) state(r *http.Request) (*model.State, error) { + st, _, err := s.states(r) + return st, err +} + +// states is like state but also returns the current configuration when a +// changeset is involved (base is nil otherwise). +func (s *Server) states(r *http.Request) (work, base *model.State, err error) { + if csID, _ := changesetTarget(r); csID > 0 { + if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok { + if w, b, err := s.eng.WorkingState(r.Context(), csID, p); err == nil { + return w, b, nil + } + } + } + st, err := s.store.LoadState(r.Context()) + return st, nil, err +} + +// ---- views + +func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + out := []api.View{} + for _, v := range st.OrderedViews() { + if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) { + out = append(out, presentView(st, v)) + } + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) viewByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.View, error) { + ref := r.PathValue("id") + var v *model.View + if id, err := strconv.ParseInt(ref, 10, 64); err == nil { + v = st.Views[id] + } else { + v = st.ViewByName(ref) + } + if v == nil || !p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) { + return nil, notFound("view %s not found", ref) + } + return v, nil +} + +func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + v, err := s.viewByPath(r, st, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentView(st, v)) + return nil +} + +func viewObject(st *model.State, id int64) any { + if v := st.Views[id]; v != nil { + return presentView(st, v) + } + return nil +} + +func (s *Server) createView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + body, err := readBody(r) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, viewObject) +} + +func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + v, err := s.viewByPath(r, st, p) + if err != nil { + return err + } + body, err := readBody(r) + if err != nil { + return err + } + id := v.ID + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return viewObject(st, id) }) +} + +func (s *Server) deleteView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + v, err := s.viewByPath(r, st, p) + if err != nil { + return err + } + data, _ := json.Marshal(engine.DeleteInput{Cascade: queryBool(r, "cascade")}) + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewDelete, v.ID, data)}, nil) +} + +func (s *Server) reorderViews(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + body, err := readBody(r) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewReorder, 0, body)}, nil) +} + +// ---- ACLs + +func (s *Server) listACLs(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.Action{Resource: auth.ResACLs}); err != nil { + return err + } + st, err := s.state(r) + if err != nil { + return err + } + out := []api.ACL{} + for _, a := range st.SortedACLs() { + out = append(out, presentACL(st, a)) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) aclByPath(r *http.Request, st *model.State) (*model.ACL, error) { + ref := r.PathValue("id") + var a *model.ACL + if id, err := strconv.ParseInt(ref, 10, 64); err == nil { + a = st.ACLs[id] + } else { + a = st.ACLByName(ref) + } + if a == nil { + return nil, notFound("ACL %s not found", ref) + } + return a, nil +} + +func (s *Server) getACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.Action{Resource: auth.ResACLs}); err != nil { + return err + } + st, err := s.state(r) + if err != nil { + return err + } + a, err := s.aclByPath(r, st) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentACL(st, a)) + return nil +} + +func aclObject(st *model.State, id int64) any { + if a := st.ACLs[id]; a != nil { + return presentACL(st, a) + } + return nil +} + +func (s *Server) createACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + body, err := readBody(r) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLCreate, 0, body)}, aclObject) +} + +func (s *Server) updateACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + a, err := s.aclByPath(r, st) + if err != nil { + return err + } + body, err := readBody(r) + if err != nil { + return err + } + id := a.ID + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLUpdate, id, body)}, func(st *model.State, _ int64) any { return aclObject(st, id) }) +} + +func (s *Server) deleteACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + a, err := s.aclByPath(r, st) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLDelete, a.ID, nil)}, nil) +} + +// ---- TSIG keys + +func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.Action{Resource: auth.ResTSIG}); err != nil { + return err + } + st, err := s.state(r) + if err != nil { + return err + } + out := []api.TSIGKey{} + for _, k := range st.SortedKeys() { + out = append(out, presentKey(st, k, false)) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) keyByPath(r *http.Request, st *model.State) (*model.TSIGKey, error) { + ref := r.PathValue("id") + var k *model.TSIGKey + if id, err := strconv.ParseInt(ref, 10, 64); err == nil { + k = st.Keys[id] + } else { + k = st.KeyByName(ref) + } + if k == nil { + return nil, notFound("TSIG key %s not found", ref) + } + return k, nil +} + +func (s *Server) getKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.Action{Resource: auth.ResTSIG}); err != nil { + return err + } + st, err := s.state(r) + if err != nil { + return err + } + k, err := s.keyByPath(r, st) + if err != nil { + return err + } + withSecret := queryBool(r, "secret") + if withSecret { + if err := require(p, auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil { + return forbidden("showing a TSIG secret needs tsig:write") + } + s.eng.Audit(r.Context(), p, "tsig.reveal", "tsig", k.ID, k.Name, "success", "secret shown") + } + writeJSON(w, http.StatusOK, presentKey(st, k, withSecret)) + return nil +} + +func keyObject(withSecret bool) func(st *model.State, id int64) any { + return func(st *model.State, id int64) any { + if k := st.Keys[id]; k != nil { + return presentKey(st, k, withSecret) + } + return nil + } +} + +func (s *Server) createKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + body, err := readBody(r) + if err != nil { + return err + } + // The creator gets to see a generated secret once. + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGCreate, 0, body)}, keyObject(true)) +} + +func (s *Server) updateKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + k, err := s.keyByPath(r, st) + if err != nil { + return err + } + body, err := readBody(r) + if err != nil { + return err + } + id := k.ID + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGUpdate, id, body)}, func(st *model.State, _ int64) any { return keyObject(false)(st, id) }) +} + +func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + k, err := s.keyByPath(r, st) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGDelete, k.ID, nil)}, nil) +} + +// ---- options + +func (s *Server) listOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + scope := r.URL.Query().Get("scope") + scopeID := int64(queryInt(r, "scope_id", 0)) + out := []api.Option{} + var list []*model.Option + if scope != "" { + list = st.ScopedOptions(scope, scopeID) + } else { + for _, sc := range []string{model.ScopeTopLevel, model.ScopeOptions} { + list = append(list, st.ScopedOptions(sc, 0)...) + } + for _, v := range st.OrderedViews() { + list = append(list, st.ScopedOptions(model.ScopeView, v.ID)...) + } + for _, z := range st.SortedZones() { + list = append(list, st.ScopedOptions(model.ScopeZone, z.ID)...) + } + } + for _, o := range list { + if canReadOption(p, st, o) { + out = append(out, presentOption(st, o)) + } + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) optionByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Option, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + o := st.Options[id] + if o == nil || !canReadOption(p, st, o) { + return nil, notFound("option %d not found", id) + } + return o, nil +} + +func (s *Server) getOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + o, err := s.optionByPath(r, st, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentOption(st, o)) + return nil +} + +func optionObject(st *model.State, id int64) any { + if o := st.Options[id]; o != nil { + return presentOption(st, o) + } + return nil +} + +func (s *Server) createOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + body, err := readBody(r) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionCreate, 0, body)}, optionObject) +} + +func (s *Server) updateOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + o, err := s.optionByPath(r, st, p) + if err != nil { + return err + } + body, err := readBody(r) + if err != nil { + return err + } + id := o.ID + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionUpdate, id, body)}, func(st *model.State, _ int64) any { return optionObject(st, id) }) +} + +func (s *Server) deleteOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + o, err := s.optionByPath(r, st, p) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionDelete, o.ID, nil)}, nil) +} + +// ---- settings + +func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, st.Settings) + return nil +} + +func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + body, err := readBody(r) + if err != nil { + return err + } + return s.mutate(w, r, p, "update settings", []engine.Request{request(engine.OpSettingsUpdate, 0, body)}, func(st *model.State, _ int64) any { return st.Settings }) +} + +// ---- catalogs + +func (s *Server) catalogOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + cat := catalog.Default() + scope := r.URL.Query().Get("scope") + if scope == "" { + writeJSON(w, http.StatusOK, map[string]any{"scopes": cat.Scopes(), "zone_types": cat.ZoneTypes()}) + return nil + } + list := cat.List(scope) + if list == nil { + return notFound("unknown scope %q (use one of %s)", scope, strings.Join(cat.Scopes(), ", ")) + } + out := []*catalog.Statement{} + for _, st := range list { + if !st.Managed || queryBool(r, "all") { + out = append(out, st) + } + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + writeJSON(w, http.StatusOK, rr.Types()) + return nil +} + +func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + roles := map[string][]string{} + for name, gs := range auth.RoleTemplates { + for _, g := range gs { + roles[name] = append(roles[name], auth.FormatGrant(g)) + } + } + writeJSON(w, http.StatusOK, map[string]any{"resources": auth.Resources, "access": []string{auth.Read, auth.Write}, "roles": roles, + "syntax": "resource:access [zones=a,b] [views=x] [names=pattern] [types=TXT,CNAME]"}) + return nil +} + +// ---- search + +func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) + if len(q) < 2 { + return badRequest("search for at least 2 characters") + } + st, err := s.state(r) + if err != nil { + return err + } + limit := queryInt(r, "limit", 200) + out := []api.SearchResult{} + add := func(res api.SearchResult) bool { + out = append(out, res) + return len(out) >= limit + } + for _, z := range st.SortedZones() { + if strings.Contains(z.Name, q) && p.CanSeeZone(z.Name, st.ViewName(z)) { + if add(api.SearchResult{Kind: "zone", ID: z.ID, ZoneID: z.ID, Label: engine.ZoneLabel(st, z), Detail: z.Type}) { + break + } + } + } + var recs []*model.Record + for _, r := range st.Records { + if strings.Contains(r.Name, q) || strings.Contains(strings.ToLower(r.Data), q) || strings.Contains(strings.ToLower(r.Comment), q) { + recs = append(recs, r) + } + } + sort.Slice(recs, func(i, j int) bool { + if recs[i].Name != recs[j].Name { + return model.CanonicalLess(recs[i].Name, recs[j].Name) + } + return recs[i].ID < recs[j].ID + }) + for _, rec := range recs { + if len(out) >= limit { + break + } + if !canReadRecord(p, st, rec) { + continue + } + z := st.Zones[rec.ZoneID] + add(api.SearchResult{Kind: "record", ID: rec.ID, ZoneID: rec.ZoneID, Label: dnsname.Display(rec.Name) + " " + rec.Type + " " + rec.Data, Detail: engine.ZoneLabel(st, z)}) + } + if p.Can(auth.Action{Resource: auth.ResACLs}) { + for _, a := range st.SortedACLs() { + if strings.Contains(strings.ToLower(a.Name), q) || strings.Contains(strings.ToLower(strings.Join(a.Elements, " ")), q) { + add(api.SearchResult{Kind: "acl", ID: a.ID, Label: "ACL " + a.Name, Detail: strings.Join(a.Elements, "; ")}) + } + } + } + writeJSON(w, http.StatusOK, out) + return nil +} diff --git a/internal/server/h_zones.go b/internal/server/h_zones.go new file mode 100644 index 0000000..b6d7af3 --- /dev/null +++ b/internal/server/h_zones.go @@ -0,0 +1,541 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/dnsname" + "jarl/internal/engine" + "jarl/internal/importer" + "jarl/internal/model" + "jarl/internal/render" +) + +// zoneByPath resolves {zone}: an ID, a zone name (with ?view= when the +// name exists in several views), or "root" for ".". +func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Zone, error) { + ref := r.PathValue("zone") + var z *model.Zone + if id, err := strconv.ParseInt(ref, 10, 64); err == nil { + z = st.Zones[id] + if z == nil { + return nil, notFound("zone %d not found", id) + } + } else { + name := ref + if name == "root" { + name = "." + } + var err error + z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view")) + if err != nil { + return nil, err + } + } + if !p.CanSeeZone(z.Name, st.ViewName(z)) { + return nil, notFound("zone %s not found", ref) + } + return z, nil +} + +func recordCounts(st *model.State) map[int64]int { + counts := map[int64]int{} + for _, r := range st.Records { + counts[r.ZoneID]++ + } + return counts +} + +func (s *Server) listZones(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, base, err := s.states(r) + if err != nil { + return err + } + q := r.URL.Query() + view, typ, search := q.Get("view"), q.Get("type"), strings.ToLower(q.Get("q")) + counts := recordCounts(st) + out := []api.Zone{} + for _, z := range st.SortedZones() { + vn := st.ViewName(z) + if !p.CanSeeZone(z.Name, vn) { + continue + } + if view != "" && !strings.EqualFold(view, vn) || typ != "" && typ != z.Type || search != "" && !strings.Contains(z.Name, search) { + continue + } + zo := s.presentZone(st, z, counts) + if base != nil { + if old := base.Zones[z.ID]; old == nil { + zo.Pending = "add" + } else if old.Version != z.Version { + zo.Pending = "change" + } + } + out = append(out, zo) + } + if base != nil { + for _, z := range base.SortedZones() { + if st.Zones[z.ID] == nil && p.CanSeeZone(z.Name, base.ViewName(z)) { + zo := s.presentZone(base, z, recordCounts(base)) + zo.Pending = "delete" + out = append(out, zo) + } + } + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func (s *Server) getZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, s.presentZone(st, z, recordCounts(st))) + return nil +} + +func (s *Server) zoneObject(st *model.State, id int64) any { + if z := st.Zones[id]; z != nil { + return s.presentZone(st, z, recordCounts(st)) + } + return nil +} + +func (s *Server) createZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + body, err := readBody(r) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneCreate, 0, body)}, s.zoneObject) +} + +func (s *Server) updateZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + body, err := readBody(r) + if err != nil { + return err + } + id := z.ID + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneUpdate, id, body)}, func(st *model.State, _ int64) any { return s.zoneObject(st, id) }) +} + +func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil) +} + +func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + if z.Type != model.ZonePrimary { + return badRequest("only primary zones have a zone file generated by Jarl") + } + if err := require(p, auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z)}); err != nil { + return err + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s.zone"`, dnsname.FileSafe(z.Name))) + w.Write([]byte(render.ZoneFile(st, z))) + return nil +} + +// ---- records + +func (s *Server) listRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, base, err := s.states(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + q := r.URL.Query() + name := q.Get("name") + if name != "" { + if n, err := dnsname.Owner(name, z.Name); err == nil { + name = n + } + } + typ := strings.ToUpper(q.Get("type")) + search := strings.ToLower(q.Get("q")) + match := func(rec *model.Record) bool { + if name != "" && rec.Name != name || typ != "" && rec.Type != typ { + return false + } + return search == "" || strings.Contains(rec.Name, search) || strings.Contains(strings.ToLower(rec.Data), search) || strings.Contains(strings.ToLower(rec.Comment), search) + } + recs := st.ZoneRecords(z.ID) + var deleted []*model.Record + if base != nil { + // Show records the changeset deletes too, marked as such. + for _, rec := range base.ZoneRecords(z.ID) { + if st.Records[rec.ID] == nil { + deleted = append(deleted, rec) + } + } + recs = append(recs, deleted...) + model.SortRecords(recs, z) + } + out := []api.Record{} + for _, rec := range recs { + if !match(rec) { + continue + } + src := st + if st.Records[rec.ID] == nil { + src = base + } + if !canReadRecord(p, src, rec) { + continue + } + ro := presentRecord(src, rec) + if base != nil { + switch old := base.Records[rec.ID]; { + case src == base: + ro.Pending = "delete" + case old == nil: + ro.Pending = "add" + case old.Version != rec.Version: + ro.Pending = "change" + } + } + out = append(out, ro) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +func recordObject(st *model.State, id int64) any { + if r := st.Records[id]; r != nil { + return presentRecord(st, r) + } + return nil +} + +// withZone puts the zone ID into a record request body. +func withZone(body json.RawMessage, zoneID int64) (json.RawMessage, error) { + m := map[string]any{} + if len(body) > 0 { + if err := json.Unmarshal(body, &m); err != nil { + return nil, badRequest("invalid JSON: %v", err) + } + } + delete(m, "zone") + delete(m, "view") + m["zone_id"] = zoneID + return json.Marshal(m) +} + +func (s *Server) createRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + body, err := readBody(r) + if err != nil { + return err + } + // Several records may be posted as a JSON array. + var reqs []engine.Request + if len(body) > 0 && strings.HasPrefix(strings.TrimSpace(string(body)), "[") { + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + return badRequest("invalid JSON: %v", err) + } + for _, it := range items { + b, err := withZone(it, z.ID) + if err != nil { + return err + } + reqs = append(reqs, request(engine.OpRecordCreate, 0, b)) + } + } else { + b, err := withZone(body, z.ID) + if err != nil { + return err + } + reqs = []engine.Request{request(engine.OpRecordCreate, 0, b)} + } + return s.mutate(w, r, p, "", reqs, recordObject) +} + +func (s *Server) recordByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Record, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + rec := st.Records[id] + if rec == nil || !canReadRecord(p, st, rec) { + return nil, notFound("record %d not found", id) + } + return rec, nil +} + +func (s *Server) getRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + rec, err := s.recordByPath(r, st, p) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentRecord(st, rec)) + return nil +} + +func (s *Server) updateRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + rec, err := s.recordByPath(r, st, p) + if err != nil { + return err + } + body, err := readBody(r) + if err != nil { + return err + } + req := request(engine.OpRecordUpdate, rec.ID, body) + if v := r.Header.Get("If-Match"); v != "" { + if n, err := strconv.Atoi(strings.Trim(v, `"W/`)); err == nil { + req.BaseVersion = n + } + } + id := rec.ID + return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any { return recordObject(st, id) }) +} + +func (s *Server) deleteRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + rec, err := s.recordByPath(r, st, p) + if err != nil { + return err + } + var data json.RawMessage + if queryBool(r, "ptr") { + data, _ = json.Marshal(map[string]any{"ptr": true}) + } + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpRecordDelete, rec.ID, data)}, nil) +} + +// deleteRecordsByMatch: DELETE /zones/{zone}/records?name=&type=[&data=][&missing_ok=1][&ptr=1] +func (s *Server) deleteRecordsByMatch(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + q := r.URL.Query() + if q.Get("name") == "" || q.Get("type") == "" { + return badRequest("give name and type (and optionally data) of the records to delete") + } + in := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(q.Get("name")), Type: model.Ptr(q.Get("type")), MissingOK: queryBool(r, "missing_ok"), PTR: queryBool(r, "ptr")} + if d := q.Get("data"); d != "" { + in.Data = &d + } + return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpRecordDelete, 0, in)}, nil) +} + +func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + var in struct { + TTL model.Opt[uint32] `json:"ttl,omitzero"` + Data []string `json:"data"` + Comment *string `json:"comment,omitempty"` + } + if err := decodeJSON(r, &in); err != nil { + return err + } + req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment}) + zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type")) + return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any { + z := st.Zones[zid] + owner, _ := dnsname.Owner(name, z.Name) + out := []api.Record{} + for _, rec := range st.ZoneRecords(zid) { + if rec.Name == owner && rec.Type == typ { + out = append(out, presentRecord(st, rec)) + } + } + return out + }) +} + +func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), Data: []string{}}) + return s.mutate(w, r, p, "", []engine.Request{req}, nil) +} + +// importZoneFile turns zone file text into record operations. +func (s *Server) importZoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + if z.Type != model.ZonePrimary { + return badRequest("records can only be imported into primary zones") + } + var in api.ZoneImport + if err := decodeJSON(r, &in); err != nil { + return err + } + if in.Mode == "" { + in.Mode = "merge" + } + if in.Mode != "merge" && in.Mode != "replace" { + return badRequest(`mode must be "merge" or "replace"`) + } + pz, err := importer.ParseZoneText(z.Name, in.Text) + if err != nil { + return badRequest("could not parse the zone file: %v", err) + } + defTTL := z.DefaultTTL + var reqs []engine.Request + if in.UseSOA && pz.SOA != nil { + soa := pz.SOA + zi := engine.ZoneInput{SOA: &engine.SOAInput{Refresh: &soa.Refresh, Retry: &soa.Retry, Expire: &soa.Expire, Minimum: &soa.Minttl}} + if pz.HasTTL { + zi.DefaultTTL = &pz.DefaultTTL + defTTL = pz.DefaultTTL + } + reqs = append(reqs, engine.NewRequest(engine.OpZoneUpdate, z.ID, zi)) + } + ttlOf := func(t uint32) model.Opt[uint32] { + if t == defTTL { + return model.Opt[uint32]{Set: true, Null: true} + } + return model.Some(t) + } + existing := map[string]bool{} + sets := map[model.RRSetKey]bool{} + for _, rec := range st.ZoneRecords(z.ID) { + existing[rec.Name+"|"+rec.Type+"|"+strings.ToLower(rec.Data)] = true + sets[model.RRSetKey{Name: rec.Name, Type: rec.Type}] = true + } + switch in.Mode { + case "merge": + for _, pr := range pz.Records { + if existing[pr.Name+"|"+pr.Type+"|"+strings.ToLower(pr.Data)] { + continue + } + ri := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(pr.Name), Type: model.Ptr(pr.Type), Data: model.Ptr(pr.Data)} + if pr.TTL != defTTL { + ri.TTL = model.Some(pr.TTL) + } + if pr.Comment != "" { + ri.Comment = model.Ptr(pr.Comment) + } + reqs = append(reqs, engine.NewRequest(engine.OpRecordCreate, 0, ri)) + } + case "replace": + type set struct { + ttl uint32 + data []string + } + want := map[model.RRSetKey]*set{} + var order []model.RRSetKey + for _, pr := range pz.Records { + k := model.RRSetKey{Name: pr.Name, Type: pr.Type} + if want[k] == nil { + want[k] = &set{ttl: pr.TTL} + order = append(order, k) + } + want[k].data = append(want[k].data, pr.Data) + } + // Delete RRsets that are not in the file first, so CNAMEs can + // replace other data. + var gone []model.RRSetKey + for k := range sets { + if want[k] == nil { + gone = append(gone, k) + } + } + sort.Slice(gone, func(i, j int) bool { return gone[i].Name+gone[i].Type < gone[j].Name+gone[j].Type }) + for _, k := range gone { + reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: k.Name, Type: k.Type, Data: []string{}})) + } + for _, k := range order { + reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: k.Name, Type: k.Type, TTL: ttlOf(want[k].ttl), Data: want[k].data})) + } + } + if len(reqs) == 0 { + writeJSON(w, http.StatusOK, api.MutationResponse{Warnings: append([]string{"nothing to import: all records already exist"}, pz.Skipped...)}) + return nil + } + if in.DryRun { + q := r.URL.Query() + q.Set("dry_run", "1") + r.URL.RawQuery = q.Encode() + } + desc := fmt.Sprintf("import %d records into %s (%s)", len(pz.Records), engine.ZoneLabel(st, z), in.Mode) + // Report skipped records as warnings by wrapping the writer. + if len(pz.Skipped) > 0 { + w.Header().Set("X-Jarl-Skipped", strconv.Itoa(len(pz.Skipped))) + } + return s.mutate(w, r, p, desc, reqs, func(st *model.State, _ int64) any { + return map[string]any{"records": len(pz.Records), "skipped": pz.Skipped} + }) +} diff --git a/internal/server/present.go b/internal/server/present.go new file mode 100644 index 0000000..760c491 --- /dev/null +++ b/internal/server/present.go @@ -0,0 +1,144 @@ +package server + +import ( + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/catalog" + "jarl/internal/dnsname" + "jarl/internal/engine" + "jarl/internal/model" + "jarl/internal/render" + "jarl/internal/rr" +) + +func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone { + out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID]} + out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths) + if z.Type == model.ZonePrimary { + out.RName = dnsname.EmailFromMailbox(z.SOA.RName) + } + return out +} + +func presentRecord(st *model.State, r *model.Record) api.Record { + z := st.Zones[r.ZoneID] + out := api.Record{Record: *r} + if z != nil { + out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z) + if rr.IsTextType(r.Type) { + if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { + if t, ok := rr.TextValue(p.RR); ok { + out.Text = &t + } + } + } + } + return out +} + +func canReadRecord(p *auth.Principal, st *model.State, r *model.Record) bool { + z := st.Zones[r.ZoneID] + return z != nil && p.Can(auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z), Name: r.Name, Type: r.Type}) +} + +func presentView(st *model.State, v *model.View) api.View { + return api.View{View: *v, Zones: len(st.ZonesInView(v.ID))} +} + +func presentACL(st *model.State, a *model.ACL) api.ACL { + used := engine.FindACLRefs(st, a.Name) + if used == nil { + used = []string{} + } + return api.ACL{ACL: *a, UsedBy: used} +} + +func presentKey(st *model.State, k *model.TSIGKey, withSecret bool) api.TSIGKey { + out := api.TSIGKey{TSIGKey: *k, UsedBy: engine.FindKeyRefs(st, k.Name)} + if out.UsedBy == nil { + out.UsedBy = []string{} + } + if !withSecret { + out.Secret = "" + } + return out +} + +func optionWhere(st *model.State, o *model.Option) string { + switch o.Scope { + case model.ScopeView: + if v := st.Views[o.ScopeID]; v != nil { + return "view " + v.Name + } + case model.ScopeZone: + if z := st.Zones[o.ScopeID]; z != nil { + return "zone " + engine.ZoneLabel(st, z) + } + case model.ScopeTopLevel: + return "top level" + } + return "server options" +} + +func presentOption(st *model.State, o *model.Option) api.Option { + out := api.Option{Option: *o, Where: optionWhere(st, o)} + scope := catalog.ScopeOptions + switch o.Scope { + case model.ScopeView: + scope = catalog.ScopeView + case model.ScopeTopLevel: + scope = catalog.ScopeTop + case model.ScopeZone: + if z := st.Zones[o.ScopeID]; z != nil { + scope = catalog.ZoneScope(z.Type) + } + } + if c, ok := catalog.Default().Lookup(scope, o.Name); ok { + out.Description, out.Status = c.Description, c.Status + } + return out +} + +// canReadOption reports whether p may see an option. +func canReadOption(p *auth.Principal, st *model.State, o *model.Option) bool { + switch o.Scope { + case model.ScopeView: + v := st.Views[o.ScopeID] + return v != nil && p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) + case model.ScopeZone: + z := st.Zones[o.ScopeID] + return z != nil && p.Can(auth.Action{Resource: auth.ResZones, Zone: z.Name, View: st.ViewName(z)}) + } + return p.Can(auth.Action{Resource: auth.ResOptions}) +} + +func presentUser(u *model.User) api.User { + out := api.User{User: *u} + for _, g := range u.Grants { + out.GrantStrings = append(out.GrantStrings, auth.FormatGrant(g)) + } + if out.GrantStrings == nil { + out.GrantStrings = []string{} + } + return out +} + +func presentToken(t *model.Token) api.Token { + out := api.Token{Token: *t} + for _, g := range t.Grants { + out.GrantStrings = append(out.GrantStrings, auth.FormatGrant(g)) + } + return out +} + +func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami { + out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version} + if p.Token != nil { + t := presentToken(p.Token) + out.Token = &t + } + if se != nil { + out.CSRFToken = se.CSRFToken + } + return out +} diff --git a/internal/server/respond.go b/internal/server/respond.go new file mode 100644 index 0000000..886067a --- /dev/null +++ b/internal/server/respond.go @@ -0,0 +1,183 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/engine" + "jarl/internal/store" +) + +// httpError is an error with an HTTP status. +type httpError struct { + status int + code string + msg string +} + +func (e *httpError) Error() string { return e.msg } + +func badRequest(format string, args ...any) error { + return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)} +} + +func notFound(format string, args ...any) error { + return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)} +} + +func forbidden(format string, args ...any) error { + return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)} +} + +func conflict(format string, args ...any) error { + return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)} +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + enc.Encode(v) +} + +func writeError(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}}) +} + +func engineStatus(k engine.ErrorKind) int { + switch k { + case engine.KindBadRequest: + return http.StatusBadRequest + case engine.KindForbidden: + return http.StatusForbidden + case engine.KindNotFound: + return http.StatusNotFound + case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted: + return http.StatusConflict + case engine.KindValidation: + return http.StatusUnprocessableEntity + case engine.KindBIND: + return http.StatusBadGateway + } + return http.StatusInternalServerError +} + +func raw(v any) json.RawMessage { + if v == nil { + return nil + } + b, err := json.Marshal(v) + if err != nil { + return nil + } + if string(b) == "null" { + return nil + } + return b +} + +// writeErr writes any error as a JSON error response. result, if given, is +// included (e.g. the preview of a change that failed validation). +func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) { + var he *httpError + if errors.As(err, &he) { + writeError(w, he.status, he.code, he.msg) + return + } + if ee, ok := engine.AsError(err); ok { + body := api.ErrorBody{Error: api.ErrorInfo{Code: string(ee.Kind), Message: ee.Error(), Op: ee.OpSeq, Problems: raw(ee.Problems), Check: raw(ee.Check)}} + if result != nil { + body.Error.Result = raw(result) + } + writeJSON(w, engineStatus(ee.Kind), body) + return + } + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "not found") + return + } + if errors.Is(err, store.ErrConflict) { + writeError(w, http.StatusConflict, "conflict", "already exists") + return + } + s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err) + writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error()) +} + +// handler is an authenticated API handler. +type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error + +// decodeJSON reads a JSON request body into v, rejecting unknown fields. +func decodeJSON(r *http.Request, v any) error { + ct := r.Header.Get("Content-Type") + if ct != "" && !strings.HasPrefix(ct, "application/json") { + return badRequest("expected a JSON request body (Content-Type: application/json)") + } + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + if errors.Is(err, io.EOF) { + return badRequest("request body is empty") + } + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + return &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"} + } + return badRequest("invalid JSON: %v", err) + } + return nil +} + +// readBody returns the raw JSON body (may be empty). +func readBody(r *http.Request) (json.RawMessage, error) { + b, err := io.ReadAll(r.Body) + if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + return nil, &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"} + } + return nil, err + } + if len(strings.TrimSpace(string(b))) == 0 { + return nil, nil + } + if !json.Valid(b) { + return nil, badRequest("invalid JSON body") + } + return b, nil +} + +func pathID(r *http.Request, name string) (int64, error) { + v := r.PathValue(name) + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + return 0, badRequest("invalid %s %q", name, v) + } + return id, nil +} + +func queryBool(r *http.Request, name string) bool { + switch strings.ToLower(r.URL.Query().Get(name)) { + case "1", "true", "yes", "on": + return true + } + return false +} + +func queryInt(r *http.Request, name string, def int) int { + if v := r.URL.Query().Get(name); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..e6d24ac --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,221 @@ +package server + +import ( + "io/fs" + "net/http" + "strconv" + "strings" + + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/engine" + "jarl/internal/model" +) + +func (s *Server) routes() { + m := s.mux + p := api.Prefix + m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version}) + }) + // Authentication + m.HandleFunc("POST "+p+"/auth/login", s.public(s.login)) + m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout)) + m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami)) + m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword)) + m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens)) + m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken)) + m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken)) + m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens)) + // Users (admin) + m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers)) + m.HandleFunc("POST "+p+"/users", s.authed(s.createUser)) + m.HandleFunc("GET "+p+"/users/{id}", s.authed(s.getUser)) + m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser)) + m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser)) + m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens)) + m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken)) + // Views + m.HandleFunc("GET "+p+"/views", s.authed(s.listViews)) + m.HandleFunc("POST "+p+"/views", s.authed(s.createView)) + m.HandleFunc("POST "+p+"/views/order", s.authed(s.reorderViews)) + m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView)) + m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView)) + m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView)) + // ACLs + m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs)) + m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL)) + m.HandleFunc("GET "+p+"/acls/{id}", s.authed(s.getACL)) + m.HandleFunc("PATCH "+p+"/acls/{id}", s.authed(s.updateACL)) + m.HandleFunc("DELETE "+p+"/acls/{id}", s.authed(s.deleteACL)) + // TSIG keys + m.HandleFunc("GET "+p+"/tsig-keys", s.authed(s.listKeys)) + m.HandleFunc("POST "+p+"/tsig-keys", s.authed(s.createKey)) + m.HandleFunc("GET "+p+"/tsig-keys/{id}", s.authed(s.getKey)) + m.HandleFunc("PATCH "+p+"/tsig-keys/{id}", s.authed(s.updateKey)) + m.HandleFunc("DELETE "+p+"/tsig-keys/{id}", s.authed(s.deleteKey)) + // Zones and records + m.HandleFunc("GET "+p+"/zones", s.authed(s.listZones)) + m.HandleFunc("POST "+p+"/zones", s.authed(s.createZone)) + m.HandleFunc("GET "+p+"/zones/{zone}", s.authed(s.getZone)) + m.HandleFunc("PATCH "+p+"/zones/{zone}", s.authed(s.updateZone)) + m.HandleFunc("DELETE "+p+"/zones/{zone}", s.authed(s.deleteZone)) + m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile)) + m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile)) + m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords)) + m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord)) + m.HandleFunc("DELETE "+p+"/zones/{zone}/records", s.authed(s.deleteRecordsByMatch)) + m.HandleFunc("PUT "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.putRRSet)) + m.HandleFunc("DELETE "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.deleteRRSet)) + m.HandleFunc("GET "+p+"/records/{id}", s.authed(s.getRecord)) + m.HandleFunc("PATCH "+p+"/records/{id}", s.authed(s.updateRecord)) + m.HandleFunc("DELETE "+p+"/records/{id}", s.authed(s.deleteRecord)) + // Options and settings + m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions)) + m.HandleFunc("POST "+p+"/options", s.authed(s.createOption)) + m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption)) + m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption)) + m.HandleFunc("DELETE "+p+"/options/{id}", s.authed(s.deleteOption)) + m.HandleFunc("GET "+p+"/settings", s.authed(s.getSettings)) + m.HandleFunc("PATCH "+p+"/settings", s.authed(s.updateSettings)) + // Catalogs + m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions)) + m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes)) + m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants)) + m.HandleFunc("GET "+p+"/search", s.authed(s.search)) + // Changesets + m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch)) + m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets)) + m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset)) + m.HandleFunc("GET "+p+"/changesets/{id}", s.authed(s.getChangeset)) + m.HandleFunc("PATCH "+p+"/changesets/{id}", s.authed(s.updateChangeset)) + m.HandleFunc("POST "+p+"/changesets/{id}/ops", s.authed(s.stageOps)) + m.HandleFunc("DELETE "+p+"/changesets/{id}/ops/{seq}", s.authed(s.unstageOp)) + m.HandleFunc("GET "+p+"/changesets/{id}/preview", s.authed(s.previewChangeset)) + m.HandleFunc("POST "+p+"/changesets/{id}/apply", s.authed(s.applyChangeset)) + m.HandleFunc("POST "+p+"/changesets/{id}/discard", s.authed(s.discardChangeset)) + m.HandleFunc("POST "+p+"/changesets/{id}/revert", s.authed(s.revertChangeset)) + // Status, system and audit + m.HandleFunc("GET "+p+"/status", s.authed(s.status)) + m.HandleFunc("POST "+p+"/system/check", s.authed(s.systemCheck)) + m.HandleFunc("POST "+p+"/system/resync", s.authed(s.systemResync)) + m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit)) + m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path) + }) + // Web UI + if s.web != nil { + files := http.FileServer(http.FS(s.web)) + m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + b, err := fs.ReadFile(s.web, "index.html") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write(b) + return + } + if strings.HasPrefix(r.URL.Path, "/assets/") { + w.Header().Set("Cache-Control", "no-cache") + files.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }) + } +} + +// via names the interface a request came through. +func via(r *http.Request, p *auth.Principal) string { + if strings.Contains(r.UserAgent(), "jarl-cli") { + return "cli" + } + if p.Method == "session" { + return "web" + } + return "api" +} + +// changesetTarget returns the changeset to stage into, if any. +func changesetTarget(r *http.Request) (int64, error) { + v := r.URL.Query().Get("changeset") + if v == "" { + v = r.Header.Get("X-Jarl-Changeset") + } + if v == "" { + return 0, nil + } + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + return 0, badRequest("invalid changeset %q", v) + } + return id, nil +} + +// mutate applies (or stages, or previews) requests and writes the +// response. object, if set, returns the resulting object to include. +func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error { + csID, err := changesetTarget(r) + if err != nil { + return err + } + if csID != 0 { + res, err := s.eng.Stage(r.Context(), csID, reqs, p) + if err != nil { + return err + } + res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops) + writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)}) + return nil + } + opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")} + res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts) + if err != nil { + s.writeErr(w, r, err, res) + return nil + } + out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings} + if object != nil && res.Applied { + st, err := s.store.LoadState(r.Context()) + if err == nil { + var created int64 + if len(res.Ops) > 0 { + created = res.Ops[0].ID + } + out.Object = raw(object(st, created)) + } + } + status := http.StatusOK + if res.Applied && r.Method == http.MethodPost { + status = http.StatusCreated + } + writeJSON(w, status, out) + return nil +} + +// request builds an engine request from a raw JSON body. +func request(op string, id int64, body []byte) engine.Request { + return engine.Request{Op: op, ID: id, Data: body} +} + +func requireAdmin(p *auth.Principal) error { + if !p.IsAdmin() { + return forbidden("this needs an administrator (and not a restricted token)") + } + return nil +} + +func require(p *auth.Principal, a auth.Action) error { + if !p.Can(a) { + return forbidden("permission denied: this needs %s", a) + } + return nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..53cc9ed --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,379 @@ +// Package server implements Jarl's HTTP API and serves the web UI. +package server + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "net/http" + "net/netip" + "os" + "strings" + "sync" + "time" + + "jarl/internal/auth" + "jarl/internal/engine" + "jarl/internal/store" +) + +// Config configures the HTTP server. +type Config struct { + Listen []string + TLSCertFile string + TLSKeyFile string + TrustedProxies []netip.Prefix + SessionLifetime time.Duration + SessionIdle time.Duration + // CookieSecure is "auto" (secure when TLS is used or the request came + // through a trusted HTTPS proxy), "always" or "never". + CookieSecure string + MaxBodyBytes int64 + Version string +} + +// Server is the HTTP server. +type Server struct { + cfg Config + eng *engine.Engine + store *store.Store + log *slog.Logger + mux *http.ServeMux + limiter *rateLimiter + web fs.FS + certs *certReloader + + mu sync.Mutex + srvs []*http.Server +} + +// New creates a server. web holds the static web UI (may be nil). +func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server { + if cfg.SessionLifetime == 0 { + cfg.SessionLifetime = 12 * time.Hour + } + if cfg.SessionIdle == 0 { + cfg.SessionIdle = 2 * time.Hour + } + if cfg.MaxBodyBytes == 0 { + cfg.MaxBodyBytes = 16 << 20 + } + if cfg.CookieSecure == "" { + cfg.CookieSecure = "auto" + } + if log == nil { + log = slog.Default() + } + s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web} + s.routes() + return s +} + +// Handler returns the server's HTTP handler with all middleware. +func (s *Server) Handler() http.Handler { + return s.recoverer(s.securityHeaders(s.logRequests(s.mux))) +} + +// Serve listens on all configured addresses until ctx is canceled. +func (s *Server) Serve(ctx context.Context, ready func()) error { + if len(s.cfg.Listen) == 0 { + return errors.New("no listen addresses configured") + } + var tlsCfg *tls.Config + if s.cfg.TLSCertFile != "" { + cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log) + if err != nil { + return err + } + s.certs = cr + tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get} + } + errc := make(chan error, len(s.cfg.Listen)) + var listeners []net.Listener + for _, addr := range s.cfg.Listen { + ln, err := net.Listen("tcp", addr) + if err != nil { + for _, l := range listeners { + l.Close() + } + return fmt.Errorf("listening on %s: %w", addr, err) + } + listeners = append(listeners, ln) + } + for i, ln := range listeners { + srv := &http.Server{ + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 2 * time.Minute, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 2 * time.Minute, + TLSConfig: tlsCfg, + ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn), + } + s.mu.Lock() + s.srvs = append(s.srvs, srv) + s.mu.Unlock() + scheme := "http" + if tlsCfg != nil { + scheme = "https" + } + s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String()) + go func(srv *http.Server, ln net.Listener) { + var err error + if tlsCfg != nil { + err = srv.ServeTLS(ln, "", "") + } else { + err = srv.Serve(ln) + } + if !errors.Is(err, http.ErrServerClosed) { + errc <- err + } + }(srv, ln) + } + if tlsCfg == nil { + for _, a := range s.cfg.Listen { + if host, _, err := net.SplitHostPort(a); err == nil { + if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() { + s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", a) + } + } + } + } + if ready != nil { + ready() + } + go s.housekeeping(ctx) + select { + case <-ctx.Done(): + case err := <-errc: + s.shutdown() + return err + } + s.shutdown() + return nil +} + +func (s *Server) shutdown() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + s.mu.Lock() + defer s.mu.Unlock() + for _, srv := range s.srvs { + srv.Shutdown(ctx) + } +} + +// ReloadTLS rereads the TLS certificate (on SIGHUP). +func (s *Server) ReloadTLS() { + if s.certs != nil { + s.certs.reload() + } +} + +func (s *Server) housekeeping(ctx context.Context) { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := s.store.PurgeSessions(ctx, time.Now()); err != nil { + s.log.Warn("purging sessions failed", "err", err) + } + s.limiter.cleanup() + if s.certs != nil { + s.certs.reloadIfChanged() + } + } + } +} + +// certReloader serves a certificate that is reread when the files change, +// so renewed certificates (e.g. from Let's Encrypt) are picked up. +type certReloader struct { + certFile, keyFile string + log *slog.Logger + mu sync.RWMutex + cert *tls.Certificate + modTime time.Time +} + +func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error) { + cr := &certReloader{certFile: certFile, keyFile: keyFile, log: log} + if err := cr.load(); err != nil { + return nil, err + } + return cr, nil +} + +func (cr *certReloader) load() error { + cert, err := tls.LoadX509KeyPair(cr.certFile, cr.keyFile) + if err != nil { + return fmt.Errorf("loading TLS certificate: %w", err) + } + fi, _ := os.Stat(cr.certFile) + cr.mu.Lock() + cr.cert = &cert + if fi != nil { + cr.modTime = fi.ModTime() + } + cr.mu.Unlock() + return nil +} + +func (cr *certReloader) reload() { + if err := cr.load(); err != nil { + cr.log.Error("reloading TLS certificate failed", "err", err) + return + } + cr.log.Info("TLS certificate reloaded", "file", cr.certFile) +} + +func (cr *certReloader) reloadIfChanged() { + fi, err := os.Stat(cr.certFile) + if err != nil { + return + } + cr.mu.RLock() + changed := fi.ModTime().After(cr.modTime) + cr.mu.RUnlock() + if changed { + cr.reload() + } +} + +func (cr *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) { + cr.mu.RLock() + defer cr.mu.RUnlock() + return cr.cert, nil +} + +// ---- middleware + +type ctxKey int + +const ( + keyPrincipal ctxKey = iota + keySession +) + +func (s *Server) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if v := recover(); v != nil { + if v == http.ErrAbortHandler { + panic(v) + } + s.log.Error("panic serving request", "path", r.URL.Path, "panic", v) + writeError(w, http.StatusInternalServerError, "internal", "internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + h.Set("Cross-Origin-Opener-Policy", "same-origin") + if s.isHTTPS(r) { + h.Set("Strict-Transport-Security", "max-age=31536000") + } + next.ServeHTTP(w, r) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(b) + w.bytes += n + return n, err +} + +func (s *Server) logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w} + next.ServeHTTP(sw, r) + if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/assets/") { + return + } + user := "" + if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok { + user = p.Name() + } + s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "bytes", sw.bytes, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user) + }) +} + +// clientIP returns the client's address, honoring X-Forwarded-For from +// trusted proxies. +func (s *Server) clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + peer, err := netip.ParseAddr(host) + if err != nil || !s.trusted(peer) { + return host + } + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return host + } + parts := strings.Split(xff, ",") + // Walk from the right, skipping trusted proxies. + for i := len(parts) - 1; i >= 0; i-- { + a, err := netip.ParseAddr(strings.TrimSpace(parts[i])) + if err != nil { + return host + } + if !s.trusted(a) { + return a.String() + } + } + return strings.TrimSpace(parts[0]) +} + +func (s *Server) trusted(a netip.Addr) bool { + a = a.Unmap() + for _, p := range s.cfg.TrustedProxies { + if p.Contains(a) { + return true + } + } + return false +} + +func (s *Server) isHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + host, _, _ := net.SplitHostPort(r.RemoteAddr) + if a, err := netip.ParseAddr(host); err == nil && s.trusted(a) { + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") + } + return false +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..b1dce7d --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,328 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "jarl/internal/api" + "jarl/internal/auth" + "jarl/internal/engine" + "jarl/internal/model" + "jarl/internal/render" + "jarl/internal/store" +) + +type apiEnv struct { + t *testing.T + ts *httptest.Server + st *store.Store + token string // admin token +} + +func newAPIEnv(t *testing.T) *apiEnv { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "jarl.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + eng := engine.New(st, nil, engine.Config{ + Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}, + StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true, + }, quiet) + srv := New(Config{Version: "test"}, eng, quiet, nil) + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + hash, _ := auth.HashPassword("correct horse battery") + admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true} + st.CreateUser(context.Background(), admin) + tok, pid, sh := auth.NewToken() + st.CreateToken(context.Background(), &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh}) + env := &apiEnv{t: t, ts: ts, st: st, token: tok} + env.do("PATCH", "/settings", env.token, map[string]any{"nameservers": []string{"ns1.example.com."}}, 200) + return env +} + +// do sends a request with a bearer token and checks the status. +func (e *apiEnv) do(method, path, token string, body any, want int) map[string]any { + e.t.Helper() + out, status := e.req(method, path, token, body) + if status != want { + e.t.Fatalf("%s %s = %d, want %d: %s", method, path, status, want, out) + } + if len(out) == 0 { + return nil + } + var m map[string]any + if json.Unmarshal(out, &m) != nil { + return map[string]any{"_raw": string(out)} + } + return m +} + +func (e *apiEnv) req(method, path, token string, body any) ([]byte, int) { + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + r, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + r.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(r) + if err != nil { + e.t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return b, resp.StatusCode +} + +func (e *apiEnv) list(path, token string) []map[string]any { + e.t.Helper() + b, status := e.req("GET", path, token, nil) + if status != 200 { + e.t.Fatalf("GET %s = %d: %s", path, status, b) + } + var out []map[string]any + json.Unmarshal(b, &out) + return out +} + +func TestRecordsAPI(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) + res := e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10", "ttl": 300}, 201) + obj := res["object"].(map[string]any) + if obj["name"] != "www.example.com." || obj["relative_name"] != "www" || obj["effective_ttl"].(float64) != 300 { + t.Errorf("object = %v", obj) + } + id := int64(obj["id"].(float64)) + // The CNAME rule, over HTTP. + b, status := e.req("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "CNAME", "data": "ns1"}) + if status != 422 || !strings.Contains(string(b), "cname_conflict") { + t.Errorf("CNAME next to A: %d %s", status, b) + } + // Bad data is a 400 with a clear message. + b, status = e.req("POST", "/zones/example.com/records", e.token, map[string]any{"name": "x", "type": "A", "data": "1.2.3"}) + if status != 400 || !strings.Contains(string(b), "invalid A data") { + t.Errorf("bad data: %d %s", status, b) + } + // TXT as plain text. + e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "@", "type": "TXT", "data": "v=spf1 mx -all"}, 201) + recs := e.list("/zones/example.com/records?type=TXT", e.token) + if len(recs) != 1 || recs[0]["data"] != `"v=spf1 mx -all"` || recs[0]["text"] != "v=spf1 mx -all" { + t.Errorf("TXT = %v", recs) + } + // Update, dry run first. + res = e.do("PATCH", "/records/"+itoa(id)+"?dry_run=1", e.token, map[string]any{"data": "192.0.2.11"}, 200) + if res["applied"] == true || !strings.Contains(res["result"].(map[string]any)["diff"].(string), "192.0.2.11") { + t.Errorf("dry run = %v", res) + } + e.do("PATCH", "/records/"+itoa(id), e.token, map[string]any{"data": "192.0.2.11"}, 200) + got := e.do("GET", "/records/"+itoa(id), e.token, nil, 200) + if got["data"] != "192.0.2.11" { + t.Errorf("after update = %v", got) + } + // RRset replace and delete by match. + e.do("PUT", "/zones/example.com/rrsets/api/A", e.token, map[string]any{"ttl": 60, "data": []string{"192.0.2.30", "192.0.2.31"}}, 200) + if n := len(e.list("/zones/example.com/records?name=api", e.token)); n != 2 { + t.Errorf("rrset has %d records", n) + } + e.do("DELETE", "/zones/example.com/records?name=api&type=A&data=192.0.2.30", e.token, nil, 200) + e.do("DELETE", "/zones/example.com/rrsets/api/A", e.token, nil, 200) + if n := len(e.list("/zones/example.com/records?name=api", e.token)); n != 0 { + t.Errorf("rrset not deleted: %d", n) + } + // Zone file. + b, status = e.req("GET", "/zones/example.com/file", e.token, nil) + if status != 200 || !strings.Contains(string(b), "www\t\tIN\tA\t192.0.2.11") && !strings.Contains(string(b), "www\t300\tIN\tA\t192.0.2.11") { + t.Errorf("zone file %d:\n%s", status, b) + } + // History and revert. + cs := e.list("/changesets?status=applied", e.token) + if len(cs) < 5 { + t.Errorf("changesets = %d", len(cs)) + } + if strings.Contains(string(mustJSON(cs)), `"undo"`) { + t.Error("undo ops leaked in changeset list") + } +} + +func itoa(n int64) string { return strings.TrimSpace(strings.TrimSuffix(string(mustJSON(n)), "\n")) } + +func mustJSON(v any) []byte { + b, _ := json.Marshal(v) + return b +} + +func TestStagingAPI(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) + cs := e.do("POST", "/changesets", e.token, map[string]any{"description": "web refresh"}, 201) + id := itoa(int64(cs["id"].(float64))) + res := e.do("POST", "/zones/example.com/records?changeset="+id, e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10"}, 202) + if res["staged"] != true { + t.Errorf("not staged: %v", res) + } + if n := len(e.list("/zones/example.com/records?name=www", e.token)); n != 0 { + t.Error("staged record visible before apply") + } + prev := e.do("GET", "/changesets/"+id+"/preview", e.token, nil, 200) + if prev["valid"] != true || !strings.Contains(prev["diff"].(string), "192.0.2.10") { + t.Errorf("preview = %v", prev) + } + e.do("POST", "/changesets/"+id+"/apply", e.token, nil, 200) + if n := len(e.list("/zones/example.com/records?name=www", e.token)); n != 1 { + t.Error("record missing after apply") + } + b, status := e.req("POST", "/zones/example.com/records?changeset="+id, e.token, map[string]any{"name": "x", "type": "A", "data": "192.0.2.2"}) + if status != 409 { + t.Errorf("staging into applied changeset: %d %s", status, b) + } + // Batch apply is atomic. + b, status = e.req("POST", "/apply", e.token, map[string]any{"ops": []map[string]any{ + {"op": "record.create", "data": map[string]any{"zone": "example.com", "name": "a", "type": "A", "data": "192.0.2.3"}}, + {"op": "record.create", "data": map[string]any{"zone": "example.com", "name": "a", "type": "CNAME", "data": "www"}}, + }}) + if status != 422 { + t.Errorf("bad batch: %d %s", status, b) + } + if n := len(e.list("/zones/example.com/records?name=a", e.token)); n != 0 { + t.Error("part of a failed batch was applied") + } +} + +func TestSessionsAndPermissions(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) + e.do("POST", "/zones", e.token, map[string]any{"name": "secret.org", "nameservers": []string{"ns1.example.com."}}, 201) + // Unauthenticated. + if _, status := e.req("GET", "/zones", "", nil); status != 401 { + t.Errorf("unauthenticated = %d", status) + } + if _, status := e.req("GET", "/zones", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil); status != 401 { + t.Errorf("bad token = %d", status) + } + // An ACME user with a restricted token. + e.do("POST", "/users", e.token, map[string]any{"username": "acme", "password": "acme-password-123", "grant_strings": []string{"records:write zones=example.com names=_acme-challenge.* types=TXT"}}, 201) + users := e.list("/users", e.token) + var acmeID int64 + for _, u := range users { + if u["username"] == "acme" { + acmeID = int64(u["id"].(float64)) + } + } + tok := e.do("POST", "/users/"+itoa(acmeID)+"/tokens", e.token, map[string]any{"name": "certbot", "expires_in": "90d"}, 201) + acme := tok["token"].(string) + e.do("POST", "/zones/example.com/records", acme, map[string]any{"name": "_acme-challenge.www", "type": "TXT", "data": "token"}, 201) + if _, status := e.req("POST", "/zones/example.com/records", acme, map[string]any{"name": "www", "type": "A", "data": "192.0.2.66"}); status != 403 { + t.Errorf("acme wrote an A record: %d", status) + } + zones := e.list("/zones", acme) + if len(zones) != 1 || zones[0]["name"] != "example.com." { + t.Errorf("acme sees zones %v", zones) + } + if recs := e.list("/zones/example.com/records", acme); len(recs) != 1 { + t.Errorf("acme sees %d records", len(recs)) + } + if _, status := e.req("GET", "/zones/secret.org", acme, nil); status != 404 { + t.Errorf("acme can see secret.org: %d", status) + } + if _, status := e.req("GET", "/users", acme, nil); status != 403 { + t.Errorf("acme lists users: %d", status) + } + // Session login with CSRF protection. + jar, _ := cookiejar.New(nil) + c := &http.Client{Jar: jar} + post := func(path, csrf string, body any) *http.Response { + b, _ := json.Marshal(body) + r, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + if csrf != "" { + r.Header.Set(csrfHeader, csrf) + } + resp, err := c.Do(r) + if err != nil { + t.Fatal(err) + } + return resp + } + resp := post("/auth/login", "", map[string]string{"username": "admin", "password": "wrong"}) + if resp.StatusCode != 401 { + t.Errorf("wrong password = %d", resp.StatusCode) + } + resp = post("/auth/login", "", map[string]string{"username": "ADMIN", "password": "correct horse battery"}) + var who api.Whoami + json.NewDecoder(resp.Body).Decode(&who) + resp.Body.Close() + if resp.StatusCode != 200 || who.CSRFToken == "" || !who.Admin { + t.Fatalf("login = %d %+v", resp.StatusCode, who) + } + if r := post("/zones/example.com/records", "", map[string]any{"name": "web", "type": "A", "data": "192.0.2.80"}); r.StatusCode != 403 { + t.Errorf("missing CSRF = %d", r.StatusCode) + } + if r := post("/zones/example.com/records", who.CSRFToken, map[string]any{"name": "web", "type": "A", "data": "192.0.2.80"}); r.StatusCode != 201 { + b, _ := io.ReadAll(r.Body) + t.Errorf("with CSRF = %d %s", r.StatusCode, b) + } + cs := e.list("/changesets?status=applied", e.token) + if cs[0]["via"] != "web" || cs[0]["username"] != "admin" { + t.Errorf("last changeset = %v", cs[0]) + } + if r := post("/auth/logout", who.CSRFToken, nil); r.StatusCode != 204 { + t.Errorf("logout = %d", r.StatusCode) + } + resp2, _ := c.Get(e.ts.URL + api.Prefix + "/auth/whoami") + if resp2.StatusCode != 401 { + t.Errorf("after logout = %d", resp2.StatusCode) + } + // Audit trail. + evs := e.list("/audit?action=auth.", e.token) + if len(evs) < 3 { + t.Errorf("auth audit events = %d", len(evs)) + } + // Login rate limiting. + for i := 0; i < 10; i++ { + post("/auth/login", "", map[string]string{"username": "acme", "password": "nope"}) + } + if r := post("/auth/login", "", map[string]string{"username": "acme", "password": "acme-password-123"}); r.StatusCode != 429 { + t.Errorf("rate limit = %d", r.StatusCode) + } +} + +func TestTSIGSecrets(t *testing.T) { + e := newAPIEnv(t) + res := e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201) + obj := res["object"].(map[string]any) + if s, _ := obj["secret"].(string); len(s) != 44 { + t.Errorf("generated secret not returned once: %v", obj) + } + list := e.list("/tsig-keys", e.token) + if _, has := list[0]["secret"]; has { + t.Error("secret in list") + } + k := e.do("GET", "/tsig-keys/xfer?secret=1", e.token, nil, 200) + if len(k["secret"].(string)) != 44 { + t.Error("secret not revealed") + } + ops := e.list("/changesets?status=applied", e.token)[0]["ops"].([]any) + if !strings.Contains(string(mustJSON(ops)), "(hidden)") { + t.Errorf("secret not redacted in changeset: %s", mustJSON(ops)) + } + _ = time.Now +} diff --git a/internal/store/audit.go b/internal/store/audit.go new file mode 100644 index 0000000..bf9f2aa --- /dev/null +++ b/internal/store/audit.go @@ -0,0 +1,171 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "time" + + "jarl/internal/model" +) + +// AddAudit stores an audit event. +func (s *Store) AddAudit(ctx context.Context, ev *model.AuditEvent) error { + return addAudit(ctx, s.db, ev) +} + +// AddAudit stores an audit event inside the transaction. +func (t *Tx) AddAudit(ev *model.AuditEvent) error { return addAudit(t.ctx, t.tx, ev) } + +func addAudit(ctx context.Context, e execer, ev *model.AuditEvent) error { + if ev.Time.IsZero() { + ev.Time = time.Now() + } + var details any + if len(ev.Details) > 0 { + details = string(ev.Details) + } + res, err := e.ExecContext(ctx, `INSERT INTO audit (time, user_id, username, auth, token_id, token_name, ip, user_agent, action, object_type, object_id, object_name, changeset_id, outcome, message, details) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ts(ev.Time), ev.UserID, ev.Username, ev.Auth, ev.TokenID, ev.TokenName, ev.IP, ev.UserAgent, ev.Action, ev.ObjectType, ev.ObjectID, ev.ObjectName, ev.ChangesetID, ev.Outcome, ev.Message, details) + if err != nil { + return err + } + ev.ID, _ = res.LastInsertId() + return nil +} + +// AuditFilter selects audit events. +type AuditFilter struct { + Username string + Action string // prefix match, e.g. "auth." or "changeset.apply" + Outcome string + ObjectType string + ChangesetID int64 + Since time.Time + Until time.Time + Search string // substring of object name or message + Before int64 // IDs below this (paging) + Limit int +} + +// ListAudit returns audit events, newest first. +func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]*model.AuditEvent, error) { + var where []string + var args []any + if f.Username != "" { + where = append(where, "username = ? COLLATE NOCASE") + args = append(args, f.Username) + } + if f.Action != "" { + where = append(where, "action LIKE ? ESCAPE '\\'") + args = append(args, likeEscape(f.Action)+"%") + } + if f.Outcome != "" { + where = append(where, "outcome = ?") + args = append(args, f.Outcome) + } + if f.ObjectType != "" { + where = append(where, "object_type = ?") + args = append(args, f.ObjectType) + } + if f.ChangesetID > 0 { + where = append(where, "changeset_id = ?") + args = append(args, f.ChangesetID) + } + if !f.Since.IsZero() { + where = append(where, "time >= ?") + args = append(args, ts(f.Since)) + } + if !f.Until.IsZero() { + where = append(where, "time < ?") + args = append(args, ts(f.Until)) + } + if f.Search != "" { + where = append(where, "(object_name LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\')") + p := "%" + likeEscape(f.Search) + "%" + args = append(args, p, p) + } + if f.Before > 0 { + where = append(where, "id < ?") + args = append(args, f.Before) + } + q := `SELECT id, time, user_id, username, auth, token_id, token_name, ip, user_agent, action, object_type, object_id, object_name, changeset_id, outcome, message, details FROM audit` + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + limit := f.Limit + if limit <= 0 || limit > 1000 { + limit = 200 + } + q += " ORDER BY id DESC LIMIT ?" + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*model.AuditEvent + for rows.Next() { + ev := &model.AuditEvent{} + var t string + var details sql.NullString + if err := rows.Scan(&ev.ID, &t, &ev.UserID, &ev.Username, &ev.Auth, &ev.TokenID, &ev.TokenName, &ev.IP, &ev.UserAgent, &ev.Action, &ev.ObjectType, &ev.ObjectID, &ev.ObjectName, &ev.ChangesetID, &ev.Outcome, &ev.Message, &details); err != nil { + return nil, err + } + ev.Time = parseTS(t) + if details.Valid && details.String != "" { + ev.Details = json.RawMessage(details.String) + } + out = append(out, ev) + } + return out, rows.Err() +} + +// PruneAudit deletes audit events older than before. +func (s *Store) PruneAudit(ctx context.Context, before time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, `DELETE FROM audit WHERE time < ?`, ts(before)) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func likeEscape(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + +// DeployedFiles returns the files Jarl wrote last time it applied changes. +func (s *Store) DeployedFiles(ctx context.Context) (map[string]*model.DeployedFile, error) { + rows, err := s.db.QueryContext(ctx, `SELECT path, sha256, size, kind, written_at, changeset_id FROM deployed_files`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]*model.DeployedFile{} + for rows.Next() { + f := &model.DeployedFile{} + var wa string + if err := rows.Scan(&f.Path, &f.SHA256, &f.Size, &f.Kind, &wa, &f.ChangesetID); err != nil { + return nil, err + } + f.WrittenAt = parseTS(wa) + out[f.Path] = f + } + return out, rows.Err() +} + +// SetDeployedFiles replaces the record of deployed files. +func (t *Tx) SetDeployedFiles(files []*model.DeployedFile) error { + if _, err := t.tx.ExecContext(t.ctx, `DELETE FROM deployed_files`); err != nil { + return err + } + for _, f := range files { + if _, err := t.tx.ExecContext(t.ctx, `INSERT INTO deployed_files (path, sha256, size, kind, written_at, changeset_id) VALUES (?,?,?,?,?,?)`, + f.Path, f.SHA256, f.Size, f.Kind, ts(f.WrittenAt), f.ChangesetID); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/changesets.go b/internal/store/changesets.go new file mode 100644 index 0000000..83f5905 --- /dev/null +++ b/internal/store/changesets.go @@ -0,0 +1,177 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + "jarl/internal/model" +) + +const changesetCols = `id, description, status, user_id, username, via, ops, created_at, updated_at, applied_at, applied_by, result, diff` + +func scanChangeset(sc interface{ Scan(...any) error }, withDiff bool) (*model.Changeset, error) { + cs := &model.Changeset{} + var ops, ca, ua, diff string + var aa, result sql.NullString + if err := sc.Scan(&cs.ID, &cs.Description, &cs.Status, &cs.UserID, &cs.Username, &cs.Via, &ops, &ca, &ua, &aa, &cs.AppliedBy, &result, &diff); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(ops), &cs.Ops); err != nil { + return nil, err + } + if cs.Ops == nil { + cs.Ops = []model.Op{} + } + cs.CreatedAt, cs.UpdatedAt, cs.AppliedAt = parseTS(ca), parseTS(ua), parseTSPtr(aa) + if result.Valid && result.String != "" { + cs.Result = json.RawMessage(result.String) + } + if withDiff { + cs.Diff = diff + } + return cs, nil +} + +// CreateChangeset inserts a new changeset and sets its ID. +func (s *Store) CreateChangeset(ctx context.Context, cs *model.Changeset) error { + now := time.Now() + if cs.CreatedAt.IsZero() { + cs.CreatedAt = now + } + cs.UpdatedAt = now + if cs.Status == "" { + cs.Status = model.ChangesetOpen + } + if cs.Ops == nil { + cs.Ops = []model.Op{} + } + res, err := s.db.ExecContext(ctx, `INSERT INTO changesets (description, status, user_id, username, via, ops, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`, + cs.Description, cs.Status, cs.UserID, cs.Username, cs.Via, jsonText(cs.Ops), ts(cs.CreatedAt), ts(cs.UpdatedAt)) + if err != nil { + return err + } + cs.ID, err = res.LastInsertId() + return err +} + +// GetChangeset loads a changeset including its diff. +func (s *Store) GetChangeset(ctx context.Context, id int64) (*model.Changeset, error) { + cs, err := scanChangeset(s.db.QueryRowContext(ctx, `SELECT `+changesetCols+` FROM changesets WHERE id = ?`, id), true) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return cs, err +} + +// ChangesetFilter selects changesets. +type ChangesetFilter struct { + Status string + UserID int64 + Limit int + Before int64 // only IDs below this (paging) +} + +// ListChangesets lists changesets, newest first, without diffs. +func (s *Store) ListChangesets(ctx context.Context, f ChangesetFilter) ([]*model.Changeset, error) { + var where []string + var args []any + if f.Status != "" { + where = append(where, "status = ?") + args = append(args, f.Status) + } + if f.UserID != 0 { + where = append(where, "user_id = ?") + args = append(args, f.UserID) + } + if f.Before > 0 { + where = append(where, "id < ?") + args = append(args, f.Before) + } + q := `SELECT ` + changesetCols + ` FROM changesets` + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + limit := f.Limit + if limit <= 0 || limit > 1000 { + limit = 100 + } + q += " ORDER BY id DESC LIMIT ?" + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*model.Changeset + for rows.Next() { + cs, err := scanChangeset(rows, false) + if err != nil { + return nil, err + } + out = append(out, cs) + } + return out, rows.Err() +} + +// UpdateChangeset saves the description, status and operations of an open +// changeset. +func (s *Store) UpdateChangeset(ctx context.Context, cs *model.Changeset) error { + cs.UpdatedAt = time.Now() + res, err := s.db.ExecContext(ctx, `UPDATE changesets SET description = ?, status = ?, ops = ?, updated_at = ? WHERE id = ? AND status = 'open'`, + cs.Description, cs.Status, jsonText(cs.Ops), ts(cs.UpdatedAt), cs.ID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// MarkApplied records that a changeset was applied. +func (t *Tx) MarkApplied(cs *model.Changeset) error { + now := time.Now() + cs.Status = model.ChangesetApplied + cs.AppliedAt = &now + cs.UpdatedAt = now + _, err := t.tx.ExecContext(t.ctx, `UPDATE changesets SET status = ?, ops = ?, applied_at = ?, applied_by = ?, result = ?, diff = ?, updated_at = ?, description = ? WHERE id = ?`, + cs.Status, jsonText(cs.Ops), ts(now), cs.AppliedBy, nullJSON(cs.Result), cs.Diff, ts(now), cs.Description, cs.ID) + return err +} + +// InsertAppliedChangeset stores a changeset that was created and applied in +// one step. +func (t *Tx) InsertAppliedChangeset(cs *model.Changeset) error { + now := time.Now() + if cs.CreatedAt.IsZero() { + cs.CreatedAt = now + } + cs.Status = model.ChangesetApplied + cs.AppliedAt = &now + cs.UpdatedAt = now + res, err := t.tx.ExecContext(t.ctx, `INSERT INTO changesets (description, status, user_id, username, via, ops, created_at, updated_at, applied_at, applied_by, result, diff) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + cs.Description, cs.Status, cs.UserID, cs.Username, cs.Via, jsonText(cs.Ops), ts(cs.CreatedAt), ts(now), ts(now), cs.AppliedBy, nullJSON(cs.Result), cs.Diff) + if err != nil { + return err + } + cs.ID, err = res.LastInsertId() + return err +} + +func nullJSON(r json.RawMessage) any { + if len(r) == 0 { + return nil + } + return string(r) +} + +// DeleteChangeset removes a changeset (used for discarded one-shot +// changesets that never became visible). +func (s *Store) DeleteChangeset(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM changesets WHERE id = ?`, id) + return err +} diff --git a/internal/store/state.go b/internal/store/state.go new file mode 100644 index 0000000..74a21db --- /dev/null +++ b/internal/store/state.go @@ -0,0 +1,266 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "reflect" + + "jarl/internal/model" +) + +const settingsKey = "settings" + +// LoadState loads the complete configuration. +func (s *Store) LoadState(ctx context.Context) (*model.State, error) { + return loadState(ctx, s.db) +} + +func loadState(ctx context.Context, e execer) (*model.State, error) { + st := model.NewState() + var raw string + err := e.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, settingsKey).Scan(&raw) + switch { + case errors.Is(err, sql.ErrNoRows): + case err != nil: + return nil, err + default: + if err := json.Unmarshal([]byte(raw), &st.Settings); err != nil { + return nil, err + } + } + + rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`) + if err != nil { + return nil, err + } + for rows.Next() { + v := &model.View{} + var mc, md, ca, ua string + var mro sql.NullInt64 + if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil { + rows.Close() + return nil, err + } + json.Unmarshal([]byte(mc), &v.MatchClients) + json.Unmarshal([]byte(md), &v.MatchDestinations) + if mro.Valid { + v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0) + } + v.CreatedAt, v.UpdatedAt = parseTS(ca), parseTS(ua) + st.Views[v.ID] = v + } + if err := closeRows(rows); err != nil { + return nil, err + } + + rows, err = e.QueryContext(ctx, `SELECT id, name, elements, comment, version, created_at, updated_at, created_by, updated_by FROM acls`) + if err != nil { + return nil, err + } + for rows.Next() { + a := &model.ACL{} + var el, ca, ua string + if err := rows.Scan(&a.ID, &a.Name, &el, &a.Comment, &a.Version, &ca, &ua, &a.CreatedBy, &a.UpdatedBy); err != nil { + rows.Close() + return nil, err + } + json.Unmarshal([]byte(el), &a.Elements) + a.CreatedAt, a.UpdatedAt = parseTS(ca), parseTS(ua) + st.ACLs[a.ID] = a + } + if err := closeRows(rows); err != nil { + return nil, err + } + + rows, err = e.QueryContext(ctx, `SELECT id, name, algorithm, secret, comment, version, created_at, updated_at, created_by, updated_by FROM tsig_keys`) + if err != nil { + return nil, err + } + for rows.Next() { + k := &model.TSIGKey{} + var ca, ua string + if err := rows.Scan(&k.ID, &k.Name, &k.Algorithm, &k.Secret, &k.Comment, &k.Version, &ca, &ua, &k.CreatedBy, &k.UpdatedBy); err != nil { + rows.Close() + return nil, err + } + k.CreatedAt, k.UpdatedAt = parseTS(ca), parseTS(ua) + st.Keys[k.ID] = k + } + if err := closeRows(rows); err != nil { + return nil, err + } + + rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`) + if err != nil { + return nil, err + } + for rows.Next() { + z := &model.Zone{} + var ca, ua string + if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil { + rows.Close() + return nil, err + } + z.CreatedAt, z.UpdatedAt = parseTS(ca), parseTS(ua) + st.Zones[z.ID] = z + } + if err := closeRows(rows); err != nil { + return nil, err + } + + rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by FROM records`) + if err != nil { + return nil, err + } + for rows.Next() { + r := &model.Record{} + var ttl sql.NullInt64 + var ca, ua string + if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil { + rows.Close() + return nil, err + } + if ttl.Valid { + r.TTL = model.Ptr(uint32(ttl.Int64)) + } + r.CreatedAt, r.UpdatedAt = parseTS(ca), parseTS(ua) + st.Records[r.ID] = r + } + if err := closeRows(rows); err != nil { + return nil, err + } + + rows, err = e.QueryContext(ctx, `SELECT id, scope, scope_id, name, value, position, comment, version, created_at, updated_at, created_by, updated_by FROM options`) + if err != nil { + return nil, err + } + for rows.Next() { + o := &model.Option{} + var ca, ua string + if err := rows.Scan(&o.ID, &o.Scope, &o.ScopeID, &o.Name, &o.Value, &o.Position, &o.Comment, &o.Version, &ca, &ua, &o.CreatedBy, &o.UpdatedBy); err != nil { + rows.Close() + return nil, err + } + o.CreatedAt, o.UpdatedAt = parseTS(ca), parseTS(ua) + st.Options[o.ID] = o + } + if err := closeRows(rows); err != nil { + return nil, err + } + return st, nil +} + +func closeRows(rows *sql.Rows) error { + err := rows.Err() + rows.Close() + return err +} + +// SaveState writes the differences between old and new. +func (t *Tx) SaveState(old, new *model.State) error { + return saveState(t.ctx, t.tx, old, new) +} + +// ReplaceState replaces the stored configuration with st (used by import +// and tests). +func (t *Tx) ReplaceState(st *model.State) error { + for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options"} { + if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil { + return err + } + } + return saveState(t.ctx, t.tx, model.NewState(), st) +} + +// LoadState loads the configuration inside the transaction. +func (t *Tx) LoadState() (*model.State, error) { return loadState(t.ctx, t.tx) } + +func saveState(ctx context.Context, e execer, old, new *model.State) error { + if !reflect.DeepEqual(old.Settings, new.Settings) { + if err := setMeta(ctx, e, settingsKey, jsonText(new.Settings)); err != nil { + return err + } + } + if err := diffMap(old.Views, new.Views, func(v *model.View) error { + var mro any + if v.MatchRecursiveOnly != nil { + mro = boolInt(*v.MatchRecursiveOnly) + } + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) + return err + }, func(id int64) error { return del(ctx, e, "views", id) }); err != nil { + return err + } + if err := diffMap(old.ACLs, new.ACLs, func(a *model.ACL) error { + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO acls (id, name, elements, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`, + a.ID, a.Name, jsonText(nonNil(a.Elements)), a.Comment, a.Version, ts(a.CreatedAt), ts(a.UpdatedAt), a.CreatedBy, a.UpdatedBy) + return err + }, func(id int64) error { return del(ctx, e, "acls", id) }); err != nil { + return err + } + if err := diffMap(old.Keys, new.Keys, func(k *model.TSIGKey) error { + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO tsig_keys (id, name, algorithm, secret, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?)`, + k.ID, k.Name, k.Algorithm, k.Secret, k.Comment, k.Version, ts(k.CreatedAt), ts(k.UpdatedAt), k.CreatedBy, k.UpdatedBy) + return err + }, func(id int64) error { return del(ctx, e, "tsig_keys", id) }); err != nil { + return err + } + if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error { + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy) + return err + }, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil { + return err + } + if err := diffMap(old.Records, new.Records, func(r *model.Record) error { + var ttl any + if r.TTL != nil { + ttl = int64(*r.TTL) + } + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy) + return err + }, func(id int64) error { return del(ctx, e, "records", id) }); err != nil { + return err + } + return diffMap(old.Options, new.Options, func(o *model.Option) error { + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO options (id, scope, scope_id, name, value, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + o.ID, o.Scope, o.ScopeID, o.Name, o.Value, o.Position, o.Comment, o.Version, ts(o.CreatedAt), ts(o.UpdatedAt), o.CreatedBy, o.UpdatedBy) + return err + }, func(id int64) error { return del(ctx, e, "options", id) }) +} + +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +func del(ctx context.Context, e execer, table string, id int64) error { + _, err := e.ExecContext(ctx, "DELETE FROM "+table+" WHERE id = ?", id) + return err +} + +// diffMap calls put for new or changed objects and remove for deleted ones. +func diffMap[T any](old, new map[int64]*T, put func(*T) error, remove func(int64) error) error { + for id, n := range new { + if o, ok := old[id]; ok && reflect.DeepEqual(o, n) { + continue + } + if err := put(n); err != nil { + return err + } + } + for id := range old { + if _, ok := new[id]; !ok { + if err := remove(id); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..b3fac7a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,364 @@ +// Package store persists Jarl's data in SQLite. +// +// The configuration (views, zones, records, ...) is loaded into a +// model.State as a whole and saved back by diffing two states, so the rest +// of Jarl works on plain Go values. Users, tokens, sessions, changesets and +// the audit log are accessed directly. +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// ErrNotFound is returned when a row does not exist. +var ErrNotFound = errors.New("not found") + +// Store is a Jarl database. +type Store struct { + db *sql.DB +} + +// DB exposes the underlying database (for backups and tests). +func (s *Store) DB() *sql.DB { return s.db } + +// Open opens (creating if needed) the database at path and applies +// migrations. Use ":memory:" for a throwaway in-memory database. +func Open(path string) (*Store, error) { + dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate" + if path == ":memory:" { + dsn = "file::memory:?_pragma=foreign_keys(1)&_txlock=immediate" + } else { + dsn += "&_pragma=journal_mode(WAL)" + } + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + if path == ":memory:" { + // Every connection would get its own in-memory database. + db.SetMaxOpenConns(1) + } + s := &Store{db: db} + if err := s.migrate(context.Background()); err != nil { + db.Close() + return nil, fmt.Errorf("migrating database: %w", err) + } + return s, nil +} + +// Close closes the database. +func (s *Store) Close() error { return s.db.Close() } + +var migrations = []string{ + // 1: initial schema + ` +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE seq (name TEXT PRIMARY KEY, value INTEGER NOT NULL); +INSERT INTO seq (name, value) VALUES ('object', 0); + +CREATE TABLE views ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + position INTEGER NOT NULL, + match_clients TEXT NOT NULL DEFAULT '[]', + match_destinations TEXT NOT NULL DEFAULT '[]', + match_recursive_only INTEGER, + comment TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' +); +CREATE TABLE acls ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + elements TEXT NOT NULL DEFAULT '[]', + comment TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' +); +CREATE TABLE tsig_keys ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + algorithm TEXT NOT NULL, + secret TEXT NOT NULL, + comment TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' +); +CREATE TABLE zones ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + view_id INTEGER NOT NULL DEFAULT 0, + type TEXT NOT NULL, + in_view TEXT NOT NULL DEFAULT '', + file TEXT NOT NULL DEFAULT '', + soa_mname TEXT NOT NULL DEFAULT '', soa_rname TEXT NOT NULL DEFAULT '', + soa_serial INTEGER NOT NULL DEFAULT 0, soa_refresh INTEGER NOT NULL DEFAULT 0, + soa_retry INTEGER NOT NULL DEFAULT 0, soa_expire INTEGER NOT NULL DEFAULT 0, + soa_minimum INTEGER NOT NULL DEFAULT 0, + default_ttl INTEGER NOT NULL DEFAULT 3600, + serial_policy TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' +); +CREATE INDEX zones_name ON zones(name); +CREATE TABLE records ( + id INTEGER PRIMARY KEY, + zone_id INTEGER NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + ttl INTEGER, + data TEXT NOT NULL, + comment TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' +); +CREATE INDEX records_zone ON records(zone_id, name); +CREATE TABLE options ( + id INTEGER PRIMARY KEY, + scope TEXT NOT NULL, + scope_id INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + comment TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL DEFAULT '', + admin INTEGER NOT NULL DEFAULT 0, + grants TEXT NOT NULL DEFAULT '[]', + disabled INTEGER NOT NULL DEFAULT 0, + comment TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + last_login_at TEXT +); +CREATE TABLE tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + public_id TEXT NOT NULL UNIQUE, + secret_hash TEXT NOT NULL, + grants TEXT, + expires_at TEXT, + created_at TEXT NOT NULL, + last_used_at TEXT, + last_used_ip TEXT NOT NULL DEFAULT '', + revoked_at TEXT +); +CREATE TABLE sessions ( + id_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '' +); +CREATE TABLE changesets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + user_id INTEGER NOT NULL DEFAULT 0, + username TEXT NOT NULL DEFAULT '', + via TEXT NOT NULL DEFAULT '', + ops TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + applied_at TEXT, + applied_by TEXT NOT NULL DEFAULT '', + result TEXT, + diff TEXT NOT NULL DEFAULT '' +); +CREATE INDEX changesets_status ON changesets(status, user_id); +CREATE TABLE audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time TEXT NOT NULL, + user_id INTEGER NOT NULL DEFAULT 0, + username TEXT NOT NULL DEFAULT '', + auth TEXT NOT NULL DEFAULT '', + token_id INTEGER NOT NULL DEFAULT 0, + token_name TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + object_type TEXT NOT NULL DEFAULT '', + object_id INTEGER NOT NULL DEFAULT 0, + object_name TEXT NOT NULL DEFAULT '', + changeset_id INTEGER NOT NULL DEFAULT 0, + outcome TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + details TEXT +); +CREATE INDEX audit_time ON audit(time); +CREATE INDEX audit_user ON audit(username, time); +CREATE TABLE deployed_files ( + path TEXT PRIMARY KEY, + sha256 TEXT NOT NULL, + size INTEGER NOT NULL, + kind TEXT NOT NULL, + written_at TEXT NOT NULL, + changeset_id INTEGER NOT NULL DEFAULT 0 +); +`, +} + +func (s *Store) migrate(ctx context.Context) error { + if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil { + return err + } + var v int + err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + if _, err := s.db.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (0)`); err != nil { + return err + } + } else if err != nil { + return err + } + if v > len(migrations) { + return fmt.Errorf("database schema version %d is newer than this Jarl (%d); upgrade Jarl", v, len(migrations)) + } + for i := v; i < len(migrations); i++ { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, migrations[i]); err != nil { + tx.Rollback() + return fmt.Errorf("migration %d: %w", i+1, err) + } + if _, err := tx.ExecContext(ctx, `UPDATE schema_version SET version = ?`, i+1); err != nil { + tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} + +// Tx is a database transaction. +type Tx struct { + tx *sql.Tx + ctx context.Context +} + +// InTx runs fn in a transaction, committing if it returns nil. +func (s *Store) InTx(ctx context.Context, fn func(tx *Tx) error) error { + t, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := fn(&Tx{tx: t, ctx: ctx}); err != nil { + t.Rollback() + return err + } + return t.Commit() +} + +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// AllocateIDs reserves n object IDs. +func (s *Store) AllocateIDs(ctx context.Context, n int) ([]int64, error) { + if n <= 0 { + return nil, nil + } + var last int64 + err := s.db.QueryRowContext(ctx, `UPDATE seq SET value = value + ? WHERE name = 'object' RETURNING value`, n).Scan(&last) + if err != nil { + return nil, err + } + ids := make([]int64, n) + for i := range ids { + ids[i] = last - int64(n) + 1 + int64(i) + } + return ids, nil +} + +// GetMeta reads a meta value. +func (s *Store) GetMeta(ctx context.Context, key string) (string, error) { + var v string + err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return v, err +} + +// SetMeta writes a meta value. +func (s *Store) SetMeta(ctx context.Context, key, value string) error { + return setMeta(ctx, s.db, key, value) +} + +func setMeta(ctx context.Context, e execer, key, value string) error { + _, err := e.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) + return err +} + +// Backup writes a consistent copy of the database to path. +func (s *Store) Backup(ctx context.Context, path string) error { + _, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, path) + return err +} + +// time helpers + +func ts(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +func tsPtr(t *time.Time) any { + if t == nil { + return nil + } + return ts(*t) +} + +func parseTS(s string) time.Time { + t, _ := time.Parse(time.RFC3339Nano, s) + return t +} + +func parseTSPtr(s sql.NullString) *time.Time { + if !s.Valid || s.String == "" { + return nil + } + t := parseTS(s.String) + return &t +} + +func jsonText(v any) string { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(b) +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func placeholders(n int) string { + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..b324e82 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,164 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" + + "jarl/internal/model" +) + +func TestStateRoundTrip(t *testing.T) { + ctx := context.Background() + s, err := Open(filepath.Join(t.TempDir(), "jarl.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ids, err := s.AllocateIDs(ctx, 5) + if err != nil || len(ids) != 5 || ids[0] != 1 || ids[4] != 5 { + t.Fatalf("AllocateIDs = %v %v", ids, err) + } + ids2, _ := s.AllocateIDs(ctx, 2) + if ids2[0] != 6 { + t.Fatalf("second allocation = %v", ids2) + } + now := time.Now().UTC().Truncate(time.Millisecond) + st := model.NewState() + st.Settings.Nameservers = []string{"ns1.example.com."} + meta := model.Meta{Version: 1, CreatedAt: now, UpdatedAt: now, CreatedBy: "adam", UpdatedBy: "adam"} + st.Views[1] = &model.View{ID: 1, Name: "internal", MatchClients: []string{"lan"}, MatchRecursiveOnly: model.Ptr(true), Meta: meta} + st.ACLs[2] = &model.ACL{ID: 2, Name: "lan", Elements: []string{"192.168.0.0/16"}, Meta: meta} + st.Keys[3] = &model.TSIGKey{ID: 3, Name: "k", Algorithm: "hmac-sha256", Secret: "c2VjcmV0", Meta: meta} + st.Zones[4] = &model.Zone{ID: 4, Name: "example.com.", ViewID: 1, Type: "primary", SOA: model.SOA{MName: "ns1.example.com.", Serial: 7}, DefaultTTL: 300, Meta: meta} + st.Records[5] = &model.Record{ID: 5, ZoneID: 4, Name: "www.example.com.", Type: "A", TTL: model.Ptr(uint32(60)), Data: "192.0.2.1", Meta: meta} + st.Records[6] = &model.Record{ID: 6, ZoneID: 4, Name: "x.example.com.", Type: "A", Data: "192.0.2.2", Meta: meta} + st.Options[7] = &model.Option{ID: 7, Scope: model.ScopeOptions, Name: "recursion", Value: "no", Meta: meta} + if err := s.InTx(ctx, func(tx *Tx) error { return tx.SaveState(model.NewState(), st) }); err != nil { + t.Fatal(err) + } + got, err := s.LoadState(ctx) + if err != nil { + t.Fatal(err) + } + if got.Views[1].Name != "internal" || !*got.Views[1].MatchRecursiveOnly || got.Views[1].MatchClients[0] != "lan" { + t.Errorf("view = %+v", got.Views[1]) + } + if *got.Records[5].TTL != 60 || got.Records[6].TTL != nil { + t.Errorf("ttl round trip failed") + } + if got.Zones[4].SOA.Serial != 7 || got.Settings.Nameservers[0] != "ns1.example.com." { + t.Errorf("zone/settings = %+v %+v", got.Zones[4], got.Settings) + } + if !got.Records[5].CreatedAt.Equal(now) { + t.Errorf("time round trip: %v vs %v", got.Records[5].CreatedAt, now) + } + // Diff save: delete one record, change another. + next := got.Clone() + delete(next.Records, 6) + next.Records[5].Data = "192.0.2.9" + if err := s.InTx(ctx, func(tx *Tx) error { return tx.SaveState(got, next) }); err != nil { + t.Fatal(err) + } + again, _ := s.LoadState(ctx) + if len(again.Records) != 1 || again.Records[5].Data != "192.0.2.9" { + t.Errorf("after diff save: %+v", again.Records) + } +} + +func TestUsersTokensSessions(t *testing.T) { + ctx := context.Background() + s, err := Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + u := &model.User{Username: "Adam", PasswordHash: "x", Admin: true} + if err := s.CreateUser(ctx, u); err != nil { + t.Fatal(err) + } + if err := s.CreateUser(ctx, &model.User{Username: "adam"}); err != ErrConflict { + t.Errorf("duplicate username (case-insensitive) = %v", err) + } + got, err := s.GetUserByName(ctx, "ADAM") + if err != nil || got.ID != u.ID || !got.Admin { + t.Fatalf("GetUserByName = %+v %v", got, err) + } + tok := &model.Token{UserID: u.ID, Name: "cli", PublicID: "abc", SecretHash: "h", Grants: []model.Grant{{Resource: "records", Access: "write", Types: []string{"TXT"}}}} + if err := s.CreateToken(ctx, tok); err != nil { + t.Fatal(err) + } + gt, err := s.GetTokenByPublicID(ctx, "abc") + if err != nil || gt.Username != "Adam" || len(gt.Grants) != 1 || gt.Grants[0].Types[0] != "TXT" { + t.Fatalf("token = %+v %v", gt, err) + } + tok2 := &model.Token{UserID: u.ID, Name: "web", PublicID: "def", SecretHash: "h"} + s.CreateToken(ctx, tok2) + gt2, _ := s.GetTokenByPublicID(ctx, "def") + if gt2.Grants != nil { + t.Error("nil grants should stay nil (inherit)") + } + if err := s.RevokeToken(ctx, tok.ID); err != nil { + t.Fatal(err) + } + gt, _ = s.GetTokenByPublicID(ctx, "abc") + if gt.RevokedAt == nil { + t.Error("not revoked") + } + now := time.Now() + se := &model.Session{IDHash: "hash", UserID: u.ID, CSRFToken: "c", CreatedAt: now, ExpiresAt: now.Add(time.Hour), LastSeenAt: now} + if err := s.CreateSession(ctx, se); err != nil { + t.Fatal(err) + } + gs, err := s.GetSession(ctx, "hash") + if err != nil || gs.CSRFToken != "c" { + t.Fatalf("session = %+v %v", gs, err) + } + if err := s.DeleteUser(ctx, u.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetSession(ctx, "hash"); err != ErrNotFound { + t.Error("session not deleted with user") + } + if _, err := s.GetTokenByPublicID(ctx, "def"); err != ErrNotFound { + t.Error("token not deleted with user") + } +} + +func TestChangesetsAndAudit(t *testing.T) { + ctx := context.Background() + s, _ := Open(":memory:") + defer s.Close() + cs := &model.Changeset{Description: "test", UserID: 1, Username: "adam", Via: "cli", Ops: []model.Op{{Seq: 1, Kind: "record.create", ID: 9}}} + if err := s.CreateChangeset(ctx, cs); err != nil { + t.Fatal(err) + } + got, err := s.GetChangeset(ctx, cs.ID) + if err != nil || got.Ops[0].Kind != "record.create" || got.Status != model.ChangesetOpen { + t.Fatalf("changeset = %+v %v", got, err) + } + got.Diff = "--- a\n+++ b\n" + got.AppliedBy = "adam" + if err := s.InTx(ctx, func(tx *Tx) error { return tx.MarkApplied(got) }); err != nil { + t.Fatal(err) + } + if err := s.UpdateChangeset(ctx, got); err != ErrNotFound { + t.Errorf("updating an applied changeset = %v", err) + } + list, _ := s.ListChangesets(ctx, ChangesetFilter{Status: model.ChangesetApplied}) + if len(list) != 1 || list[0].Diff != "" { + t.Errorf("list = %+v", list) + } + for _, a := range []string{"auth.login", "auth.login_failed", "changeset.apply"} { + s.AddAudit(ctx, &model.AuditEvent{Action: a, Username: "adam", Outcome: "success", ObjectName: "www_example"}) + } + evs, err := s.ListAudit(ctx, AuditFilter{Action: "auth."}) + if err != nil || len(evs) != 2 || evs[0].Action != "auth.login_failed" { + t.Errorf("audit = %+v %v", evs, err) + } + evs, _ = s.ListAudit(ctx, AuditFilter{Search: "www_"}) + if len(evs) != 3 { + t.Errorf("search found %d", len(evs)) + } +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..f9a8738 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,289 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + "jarl/internal/model" +) + +// ErrConflict is returned for unique constraint violations. +var ErrConflict = errors.New("already exists") + +func isUnique(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} + +const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at` + +func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) { + u := &model.User{} + var grants, ca, ua string + var admin, disabled int + var ll sql.NullString + if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil { + return nil, err + } + u.Admin, u.Disabled = admin != 0, disabled != 0 + u.HasPassword = u.PasswordHash != "" + if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil { + return nil, err + } + if u.Grants == nil { + u.Grants = []model.Grant{} + } + u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll) + return u, nil +} + +// CreateUser inserts a user. +func (s *Store) CreateUser(ctx context.Context, u *model.User) error { + now := time.Now() + u.CreatedAt, u.UpdatedAt = now, now + if u.Grants == nil { + u.Grants = []model.Grant{} + } + res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`, + u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now)) + if isUnique(err) { + return ErrConflict + } + if err != nil { + return err + } + u.ID, err = res.LastInsertId() + u.HasPassword = u.PasswordHash != "" + return err +} + +// UpdateUser saves a user. +func (s *Store) UpdateUser(ctx context.Context, u *model.User) error { + u.UpdatedAt = time.Now() + res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`, + u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID) + if isUnique(err) { + return ErrConflict + } + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + u.HasPassword = u.PasswordHash != "" + return nil +} + +// TouchLogin records a successful login. +func (s *Store) TouchLogin(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, ts(time.Now()), id) + return err +} + +// GetUser loads a user by ID. +func (s *Store) GetUser(ctx context.Context, id int64) (*model.User, error) { + u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return u, err +} + +// GetUserByName loads a user by username (case-insensitive). +func (s *Store) GetUserByName(ctx context.Context, name string) (*model.User, error) { + u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return u, err +} + +// ListUsers returns all users ordered by name. +func (s *Store) ListUsers(ctx context.Context) ([]*model.User, error) { + rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*model.User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// CountAdmins returns the number of enabled admin users. +func (s *Store) CountAdmins(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE admin = 1 AND disabled = 0`).Scan(&n) + return n, err +} + +// DeleteUser removes a user with their tokens and sessions. +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at` + +func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) { + t := &model.Token{} + var grants, exp, lu, rev sql.NullString + var ca string + if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil { + return nil, err + } + if grants.Valid { + if err := json.Unmarshal([]byte(grants.String), &t.Grants); err != nil { + return nil, err + } + if t.Grants == nil { + t.Grants = []model.Grant{} + } + } + t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev) + t.CreatedAt = parseTS(ca) + return t, nil +} + +// CreateToken inserts a token. +func (s *Store) CreateToken(ctx context.Context, t *model.Token) error { + t.CreatedAt = time.Now() + var grants any + if t.Grants != nil { + grants = jsonText(t.Grants) + } + res, err := s.db.ExecContext(ctx, `INSERT INTO tokens (user_id, name, public_id, secret_hash, grants, expires_at, created_at) VALUES (?,?,?,?,?,?,?)`, + t.UserID, t.Name, t.PublicID, t.SecretHash, grants, tsPtr(t.ExpiresAt), ts(t.CreatedAt)) + if isUnique(err) { + return ErrConflict + } + if err != nil { + return err + } + t.ID, err = res.LastInsertId() + return err +} + +// GetTokenByPublicID loads a token by its public ID. +func (s *Store) GetTokenByPublicID(ctx context.Context, pid string) (*model.Token, error) { + t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.public_id = ?`, pid)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return t, err +} + +// GetToken loads a token by ID. +func (s *Store) GetToken(ctx context.Context, id int64) (*model.Token, error) { + t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return t, err +} + +// ListTokens lists tokens, of one user (userID > 0) or all. +func (s *Store) ListTokens(ctx context.Context, userID int64) ([]*model.Token, error) { + q := `SELECT ` + tokenCols + ` FROM tokens t JOIN users u ON u.id = t.user_id` + var args []any + if userID > 0 { + q += ` WHERE t.user_id = ?` + args = append(args, userID) + } + q += ` ORDER BY t.id` + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*model.Token + for rows.Next() { + t, err := scanToken(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// RevokeToken marks a token revoked. +func (s *Store) RevokeToken(ctx context.Context, id int64) error { + res, err := s.db.ExecContext(ctx, `UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, ts(time.Now()), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// TouchToken records a token's use. It is best-effort and cheap. +func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error { + _, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id) + return err +} + +// CreateSession stores a session. +func (s *Store) CreateSession(ctx context.Context, se *model.Session) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`, + se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent) + return err +} + +// GetSession loads a session by the hash of its ID. +func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) { + se := &model.Session{} + var ca, ea, ls string + err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash). + Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls) + return se, nil +} + +// TouchSession updates a session's last-seen time. +func (s *Store) TouchSession(ctx context.Context, idHash string, at time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?`, ts(at), idHash) + return err +} + +// DeleteSession removes a session. +func (s *Store) DeleteSession(ctx context.Context, idHash string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id_hash = ?`, idHash) + return err +} + +// DeleteUserSessions removes all sessions of a user, except keep (if set). +func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keep string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id_hash != ?`, userID, keep) + return err +} + +// PurgeSessions removes expired sessions. +func (s *Store) PurgeSessions(ctx context.Context, now time.Time) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, ts(now)) + return err +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..175618d --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,78 @@ +// Package testutil has helpers shared by tests, chiefly locating BIND's +// tools so integration tests can run against the real named-checkconf, +// named-checkzone, named and rndc. +// +// Set JARL_BIND_ROOT to a directory containing an extracted BIND +// installation (usr/sbin/named, usr/bin/named-checkconf, ... and the +// libraries under usr/lib/x86_64-linux-gnu) to use binaries that are not +// installed system-wide. See docs/development.md. +package testutil + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "jarl/internal/bind" +) + +// BindBinaries holds paths to the BIND binaries. +type BindBinaries struct { + Named, CheckConf, CheckZone, RNDC, TSIGKeygen, Dig string + Env []string +} + +// FindBind locates the BIND binaries, or returns nil if they are missing. +func FindBind() *BindBinaries { + b := &BindBinaries{} + if root := os.Getenv("JARL_BIND_ROOT"); root != "" { + b.Named = filepath.Join(root, "usr/sbin/named") + b.RNDC = filepath.Join(root, "usr/sbin/rndc") + b.TSIGKeygen = filepath.Join(root, "usr/sbin/tsig-keygen") + b.CheckConf = filepath.Join(root, "usr/bin/named-checkconf") + b.CheckZone = filepath.Join(root, "usr/bin/named-checkzone") + b.Dig = filepath.Join(root, "usr/bin/dig") + libs, _ := filepath.Glob(filepath.Join(root, "usr/lib/*-linux-gnu")) + if len(libs) > 0 { + b.Env = []string{"LD_LIBRARY_PATH=" + libs[0]} + } + if _, err := os.Stat(b.CheckConf); err != nil { + return nil + } + return b + } + look := func(name string) string { + if p, err := exec.LookPath(name); err == nil { + return p + } + for _, dir := range []string{"/usr/sbin", "/usr/bin", "/sbin"} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" + } + b.CheckConf = look("named-checkconf") + b.CheckZone = look("named-checkzone") + b.Named = look("named") + b.RNDC = look("rndc") + b.TSIGKeygen = look("tsig-keygen") + b.Dig = look("dig") + if b.CheckConf == "" || b.CheckZone == "" { + return nil + } + return b +} + +// BindTools returns bind.Tools using the located binaries, skipping the +// test when they are not available. +func BindTools(t testing.TB) *bind.Tools { + t.Helper() + b := FindBind() + if b == nil { + t.Skip("BIND tools not found (install bind9-utils or set JARL_BIND_ROOT)") + } + return &bind.Tools{NamedCheckConf: b.CheckConf, NamedCheckZone: b.CheckZone, RNDC: b.RNDC, Env: b.Env} +} diff --git a/internal/validate/config.go b/internal/validate/config.go new file mode 100644 index 0000000..c8ba22f --- /dev/null +++ b/internal/validate/config.go @@ -0,0 +1,615 @@ +package validate + +import ( + "encoding/base64" + "fmt" + "regexp" + "strings" + + "jarl/internal/catalog" + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/namedconf" +) + +// TSIGAlgorithms are the algorithms named supports, with the minimum +// recommended secret length in bytes (the hash output size). +var TSIGAlgorithms = map[string]int{ + "hmac-md5": 16, + "hmac-sha1": 20, + "hmac-sha224": 28, + "hmac-sha256": 32, + "hmac-sha384": 48, + "hmac-sha512": 64, +} + +var reViewName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) + +func (c *checker) checkSettings() { + st := c.s.Settings + for _, ns := range st.Nameservers { + if !dnsname.IsHostname(ns, false) { + c.errf("settings_invalid", "settings", 0, "default name server %q is not a valid host name", dnsname.Display(ns)) + } + } + if st.RName != "" && !dnsname.IsMailbox(st.RName) { + c.errf("settings_invalid", "settings", 0, "default SOA contact %q is not a valid mailbox name", st.RName) + } + if st.DefaultTTL > maxTTL { + c.errf("settings_invalid", "settings", 0, "default TTL %d is larger than 2147483647", st.DefaultTTL) + } + switch st.SerialPolicy { + case model.SerialDate, model.SerialIncrement, model.SerialUnixtime: + default: + c.errf("settings_invalid", "settings", 0, "unknown serial policy %q", st.SerialPolicy) + } +} + +func (c *checker) checkKeys() { + seen := map[string]bool{} + for _, k := range c.s.SortedKeys() { + label := "TSIG key " + k.Name + if seen[k.Name] { + c.errf("key_duplicate", "key", k.ID, "%s is defined more than once", label) + } + seen[k.Name] = true + if k.Name == "" { + c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name") + } else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil { + c.errf("key_name_invalid", "key", k.ID, "%s: invalid name: %v", label, err) + } + minLen, ok := TSIGAlgorithms[k.Algorithm] + if !ok { + c.errf("key_algorithm_invalid", "key", k.ID, "%s: unsupported algorithm %q (use one of hmac-sha256, hmac-sha512, ...)", label, k.Algorithm) + } + b, err := base64.StdEncoding.DecodeString(k.Secret) + if err != nil || len(b) == 0 { + c.errf("key_secret_invalid", "key", k.ID, "%s: secret is not valid base64", label) + } else if ok && len(b) < minLen { + c.warnf("key_secret_weak", "key", k.ID, "%s: secret is only %d bytes; %d or more are recommended for %s", label, len(b), minLen, k.Algorithm) + } + if k.Algorithm == "hmac-md5" || k.Algorithm == "hmac-sha1" { + c.warnf("key_algorithm_weak", "key", k.ID, "%s: %s is outdated; prefer hmac-sha256", label, k.Algorithm) + } + } +} + +// parseElements parses ACL-style element strings, reporting errors. +func (c *checker) parseElements(elems []string, report func(err error, elem string)) []namedconf.AMElement { + var out []namedconf.AMElement + for _, s := range elems { + e, err := namedconf.ParseAMElement(s) + if err != nil { + report(err, s) + continue + } + out = append(out, e) + } + return out +} + +// checkRefs reports references to ACLs and keys that don't exist. +func (c *checker) checkRefs(elems []namedconf.AMElement, where string, objType string, id int64) { + acls, keys := namedconf.AMRefs(elems) + for _, a := range acls { + if c.s.ACLByName(a) == nil { + c.errf("acl_ref_missing", objType, id, "%s refers to ACL %q, which does not exist", where, a) + } + } + for _, k := range keys { + if !c.keyNames[model.NormalizeKeyName(k)] && !c.opts.ExternalKeysUnknown { + c.errf("key_ref_missing", objType, id, "%s refers to TSIG key %q, which does not exist", where, k) + } + } +} + +func (c *checker) checkACLs() { + seen := map[string]bool{} + for _, a := range c.s.SortedACLs() { + label := "ACL " + a.Name + lname := strings.ToLower(a.Name) + if seen[lname] { + c.errf("acl_duplicate", "acl", a.ID, "%s is defined more than once (ACL names are case-insensitive)", label) + } + seen[lname] = true + if namedconf.BuiltinACLs[lname] { + c.errf("acl_name_invalid", "acl", a.ID, "%q is a built-in ACL name and can't be redefined", a.Name) + } else if err := namedconf.CheckACLName(a.Name); err != nil { + c.errf("acl_name_invalid", "acl", a.ID, "%v", err) + } + elems := c.parseElements(a.Elements, func(err error, s string) { + c.errf("acl_element_invalid", "acl", a.ID, "%s: element %q: %v", label, s, err) + }) + if len(a.Elements) == 0 { + c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label) + } + c.checkRefs(elems, label, "acl", a.ID) + for _, w := range namedconf.Shadowed(elems) { + c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w) + } + } + // Cycles between ACLs. + state := map[string]int{} // 0 unvisited, 1 visiting, 2 done + var visit func(a *model.ACL, path []string) + visit = func(a *model.ACL, path []string) { + n := strings.ToLower(a.Name) + switch state[n] { + case 1: + c.errf("acl_cycle", "acl", a.ID, "ACLs refer to each other in a loop: %s", strings.Join(append(path, a.Name), " -> ")) + return + case 2: + return + } + state[n] = 1 + var elems []namedconf.AMElement + for _, s := range a.Elements { + if e, err := namedconf.ParseAMElement(s); err == nil { + elems = append(elems, e) + } + } + refs, _ := namedconf.AMRefs(elems) + for _, r := range refs { + if next := c.s.ACLByName(r); next != nil { + visit(next, append(path, a.Name)) + } + } + state[n] = 2 + } + for _, a := range c.s.SortedACLs() { + visit(a, nil) + } +} + +func (c *checker) checkViews() { + seen := map[string]bool{} + for _, v := range c.s.OrderedViews() { + label := "view " + v.Name + ln := strings.ToLower(v.Name) + if seen[ln] { + c.errf("view_duplicate", "view", v.ID, "%s is defined more than once", label) + } + seen[ln] = true + if !reViewName.MatchString(v.Name) { + c.errf("view_name_invalid", "view", v.ID, "view name %q must start with a letter or digit and contain only letters, digits, '_', '-' and '.'", v.Name) + } + for field, list := range map[string][]string{"match-clients": v.MatchClients, "match-destinations": v.MatchDestinations} { + elems := c.parseElements(list, func(err error, s string) { + c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err) + }) + c.checkRefs(elems, label+" "+field, "view", v.ID) + for _, w := range namedconf.Shadowed(elems) { + c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w) + } + } + } + // Views that can never match because an earlier view matches every + // client. + views := c.s.OrderedViews() + for i, v := range views { + if !c.viewMatchesEveryone(v) { + continue + } + for _, later := range views[i+1:] { + c.warnf("view_unreachable", "view", later.ID, "view %s is never used: view %s before it matches every client", later.Name, v.Name) + } + break + } +} + +// viewMatchesEveryone reports whether a view matches all clients and +// destinations (and not just recursive queries). +func (c *checker) viewMatchesEveryone(v *model.View) bool { + if v.MatchRecursiveOnly != nil && *v.MatchRecursiveOnly { + return false + } + return c.listMatchesEveryone(v.MatchClients, true) && c.listMatchesEveryone(v.MatchDestinations, true) +} + +// listMatchesEveryone reports whether an element list matches every +// address. emptyIsAny is true for lists that default to "any". +func (c *checker) listMatchesEveryone(list []string, emptyIsAny bool) bool { + if len(list) == 0 { + return emptyIsAny + } + return namedconf.ContainsAny(c.flatten(list, 0)) +} + +// flatten parses elements and expands references to user ACLs. +func (c *checker) flatten(list []string, depth int) []namedconf.AMElement { + var out []namedconf.AMElement + for _, s := range list { + e, err := namedconf.ParseAMElement(s) + if err != nil { + continue + } + if e.Kind == namedconf.AMName && !e.IsBuiltin() && depth < 10 { + if a := c.s.ACLByName(e.Name); a != nil { + inner := c.flatten(a.Elements, depth+1) + if e.Negated { + // A negated ACL reference: treat as a single + // negated element (restrictive). + out = append(out, namedconf.AMElement{Negated: true, Kind: namedconf.AMOther, Raw: "!" + e.Name}) + continue + } + out = append(out, inner...) + continue + } + } + out = append(out, e) + } + return out +} + +// optionScope maps an option to its catalog scope. +func (c *checker) optionScope(o *model.Option) (string, string, bool) { + switch o.Scope { + case model.ScopeOptions: + return catalog.ScopeOptions, "server options", true + case model.ScopeTopLevel: + return catalog.ScopeTop, "top-level statements", true + case model.ScopeView: + v := c.s.Views[o.ScopeID] + if v == nil { + return "", "", false + } + return catalog.ScopeView, "view " + v.Name, true + case model.ScopeZone: + z := c.s.Zones[o.ScopeID] + if z == nil { + return "", "", false + } + return catalog.ZoneScope(z.Type), "zone " + c.zoneLabel(z), true + } + return "", "", false +} + +func (c *checker) checkOptions() { + cat := catalog.Default() + type key struct { + scope string + id int64 + name string + } + counts := map[key]int{} + var opts []*model.Option + for _, o := range c.s.Options { + opts = append(opts, o) + } + sortOptions(opts) + for _, o := range opts { + scope, where, ok := c.optionScope(o) + if !ok { + c.errf("option_scope_invalid", "option", o.ID, "option %s belongs to a %s that does not exist", o.Name, o.Scope) + continue + } + name := strings.ToLower(o.Name) + st, known := cat.Lookup(scope, name) + if !known { + if o.Scope == model.ScopeZone { + z := c.s.Zones[o.ScopeID] + c.errf("option_unknown", "option", o.ID, "%s: %q is not a valid option for %s zones", where, o.Name, z.Type) + } else { + c.errf("option_unknown", "option", o.ID, "%s: unknown option %q", where, o.Name) + } + continue + } + if st.Managed { + c.errf("option_managed", "option", o.ID, "%s: %q can't be set as an option; it is managed through %s", where, o.Name, st.ManagedBy) + continue + } + switch st.Status { + case "deprecated": + c.warnf("option_deprecated", "option", o.ID, "%s: %q is deprecated in BIND 9.20", where, o.Name) + case "obsolete": + c.warnf("option_obsolete", "option", o.ID, "%s: %q is obsolete and has no effect (or is rejected) in BIND 9.20", where, o.Name) + case "experimental": + c.infof("option_experimental", "option", o.ID, "%s: %q is experimental", where, o.Name) + case "test only", "not configured": + c.warnf("option_unavailable", "option", o.ID, "%s: %q is %s in this BIND build", where, o.Name, st.Status) + } + k := key{o.Scope, o.ScopeID, st.Name} + counts[k]++ + if counts[k] == 2 && !st.Multiple { + c.errf("option_duplicate", "option", o.ID, "%s: %q may only be set once", where, o.Name) + } + stmt, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil { + if strings.TrimSpace(o.Value) == "" && (name == "null" || name == "stderr") { + continue + } + c.errf("option_syntax", "option", o.ID, "%s: %s: %v", where, o.Name, err) + continue + } + if err := checkKind(st, stmt); err != nil { + c.errf("option_value_invalid", "option", o.ID, "%s: %s: %v (expected: %s)", where, o.Name, err, st.Grammar) + continue + } + label := where + " " + o.Name + if strings.Contains(st.Grammar, "") && !strings.Contains(st.Grammar, "\n") { + if b := stmt.FirstBlock(); b != nil { + elems, err := namedconf.ParseAMList(b) + if err != nil { + c.errf("option_value_invalid", "option", o.ID, "%s: %v", label, err) + } else { + c.checkRefs(elems, label, "option", o.ID) + for _, w := range namedconf.Shadowed(elems) { + c.warnf("acl_shadowed", "option", o.ID, "%s: %s", label, w) + } + } + } + } + for _, kn := range keyRefs(stmt) { + if !c.keyNames[model.NormalizeKeyName(kn)] && !c.opts.ExternalKeysUnknown { + c.errf("key_ref_missing", "option", o.ID, "%s refers to TSIG key %q, which does not exist", label, kn) + } + } + } +} + +func sortOptions(opts []*model.Option) { + for i := 1; i < len(opts); i++ { + for j := i; j > 0 && optLess(opts[j], opts[j-1]); j-- { + opts[j], opts[j-1] = opts[j-1], opts[j] + } + } +} + +func optLess(a, b *model.Option) bool { + if a.Scope != b.Scope { + return a.Scope < b.Scope + } + if a.ScopeID != b.ScopeID { + return a.ScopeID < b.ScopeID + } + if a.Position != b.Position { + return a.Position < b.Position + } + return a.ID < b.ID +} + +var ( + reDuration = regexp.MustCompile(`(?i)^(\d+|(\d+[wdhms])+|P(\d+[YMWD])*(T(\d+[HMS])+)?)$`) + reInteger = regexp.MustCompile(`^\d+$`) +) + +// checkKind does quick checks on simple option values. Anything it does +// not understand is left to named-checkconf. +func checkKind(st *catalog.Statement, stmt *namedconf.Stmt) error { + args := stmt.Items[1:] + single := func() (*namedconf.Value, error) { + if len(args) != 1 || args[0].Value == nil { + return nil, fmt.Errorf("expected a single value") + } + return args[0].Value, nil + } + switch st.Kind { + case catalog.KindBool: + v, err := single() + if err != nil { + return err + } + switch strings.ToLower(v.Text) { + case "yes", "no", "true", "false", "1", "0": + return nil + } + return fmt.Errorf("%q is not a boolean (use yes or no)", v.Text) + case catalog.KindInt: + v, err := single() + if err != nil { + return err + } + if !reInteger.MatchString(v.Text) { + return fmt.Errorf("%q is not a number", v.Text) + } + case catalog.KindDuration: + v, err := single() + if err != nil { + return err + } + if !reDuration.MatchString(v.Text) { + return fmt.Errorf("%q is not a duration (e.g. 3600, 1h30m or PT1H)", v.Text) + } + case catalog.KindString, catalog.KindWord: + if _, err := single(); err != nil { + return err + } + case catalog.KindEnum: + v, err := single() + if err != nil { + return err + } + for _, ch := range st.Choices { + if strings.EqualFold(ch, v.Text) { + return nil + } + } + if lv := strings.ToLower(v.Text); contains(st.Choices, "yes") && (lv == "true" || lv == "false" || lv == "1" || lv == "0") { + return nil + } + return fmt.Errorf("%q is not one of %s", v.Text, strings.Join(st.Choices, ", ")) + case catalog.KindACL: + if len(args) != 1 || args[0].Block == nil { + return fmt.Errorf("expected an address match list in braces, e.g. { 192.168.0.0/16; localhost; }") + } + } + return nil +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// keyRefs finds TSIG key references in a statement: "key NAME" and +// "keys { NAME; ... }" / "keys NAME" anywhere in it. +func keyRefs(st *namedconf.Stmt) []string { + var out []string + var walk func(items []namedconf.Item) + walk = func(items []namedconf.Item) { + for i, it := range items { + if it.Value != nil && !it.Value.Quoted && i+1 < len(items) { + switch strings.ToLower(it.Value.Text) { + case "key": + if n := items[i+1].Value; n != nil { + out = append(out, n.Text) + } + case "keys": + if n := items[i+1].Value; n != nil { + out = append(out, n.Text) + } else if b := items[i+1].Block; b != nil { + for _, s := range b.Stmts { + if len(s.Items) > 0 && s.Items[0].Value != nil { + out = append(out, s.Items[0].Value.Text) + } + } + } + } + } + if it.Block != nil { + for _, s := range it.Block.Stmts { + walk(s.Items) + } + } + } + } + if st.Keyword() == "key" { // a key definition, not a reference + return nil + } + walk(st.Items) + return out +} + +// effectiveAML finds the first of names set in the view (if any) or in the +// global options, returning its elements (with ACL references expanded). +func (c *checker) effectiveAML(v *model.View, names ...string) ([]namedconf.AMElement, string, bool) { + for _, n := range names { + if v != nil { + if o := c.s.FindOption(model.ScopeView, v.ID, n); o != nil { + return c.amlOf(o), n, true + } + } + if o := c.s.FindOption(model.ScopeOptions, 0, n); o != nil { + return c.amlOf(o), n, true + } + } + return nil, "", false +} + +func (c *checker) amlOf(o *model.Option) []namedconf.AMElement { + st, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil || st.FirstBlock() == nil { + return nil + } + var list []string + for _, s := range st.FirstBlock().Stmts { + list = append(list, namedconf.Compact(s.Items)) + } + return c.flatten(list, 0) +} + +func (c *checker) effectiveBool(v *model.View, name string, def bool) bool { + var o *model.Option + if v != nil { + o = c.s.FindOption(model.ScopeView, v.ID, name) + } + if o == nil { + o = c.s.FindOption(model.ScopeOptions, 0, name) + } + if o == nil { + return def + } + switch strings.ToLower(strings.TrimSpace(o.Value)) { + case "yes", "true", "1": + return true + case "no", "false", "0": + return false + } + return def +} + +// checkSecurity looks for configurations that are dangerous on an +// internet-facing server. +func (c *checker) checkSecurity() { + views := c.s.OrderedViews() + type ctx struct { + v *model.View + label string + } + var ctxs []ctx + if len(views) == 0 { + ctxs = append(ctxs, ctx{nil, "the server"}) + } + for _, v := range views { + ctxs = append(ctxs, ctx{v, "view " + v.Name}) + } + for _, x := range ctxs { + // A view only reachable from some clients (e.g. "internal") can + // recurse for everyone inside it without being an open resolver. + if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) { + continue + } + if c.effectiveBool(x.v, "recursion", true) { + aml, from, set := c.effectiveAML(x.v, "allow-recursion", "allow-query-cache", "allow-query") + if set && namedconf.ContainsAny(aml) { + p := c.warnf("security_open_resolver", "view", viewID(x.v), + "%s offers recursion to any client (via %s), making it an open resolver that can be abused for DDoS amplification; set allow-recursion to your own networks", x.label, from) + if x.v != nil { + p.View = x.v.Name + } + } + } + if aml, _, set := c.effectiveAML(x.v, "allow-transfer"); set && namedconf.ContainsAny(aml) { + p := c.warnf("security_open_transfer", "view", viewID(x.v), "%s allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key", x.label) + if x.v != nil { + p.View = x.v.Name + } + } + } + // Dynamic updates would be lost when Jarl rewrites zone files. + for _, z := range c.s.SortedZones() { + if z.Type != model.ZonePrimary { + continue + } + v := c.s.Views[z.ViewID] + if o := c.s.FindOption(model.ScopeZone, z.ID, "update-policy"); o != nil { + p := c.errf("dynamic_update_unsupported", "zone", z.ID, "update-policy makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; manage records through Jarl instead") + p.Zone, p.View = z.Name, c.s.ViewName(z) + continue + } + var aml []namedconf.AMElement + set := false + if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-update"); o != nil { + aml, set = c.amlOf(o), true + } else { + aml, _, set = c.effectiveAML(v, "allow-update") + } + if set && !onlyNone(aml) { + p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl") + p.Zone, p.View = z.Name, c.s.ViewName(z) + } + if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) { + p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key") + p.Zone, p.View = z.Name, c.s.ViewName(z) + } + } +} + +func onlyNone(elems []namedconf.AMElement) bool { + for _, e := range elems { + if !(e.Kind == namedconf.AMName && e.Name == "none" && !e.Negated) { + return false + } + } + return true +} + +func viewID(v *model.View) int64 { + if v == nil { + return 0 + } + return v.ID +} diff --git a/internal/validate/records.go b/internal/validate/records.go new file mode 100644 index 0000000..cfa9db4 --- /dev/null +++ b/internal/validate/records.go @@ -0,0 +1,472 @@ +package validate + +import ( + "sort" + "strconv" + "strings" + + "github.com/miekg/dns" + + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/rr" +) + +// BIND 9.20 defaults for max-records-per-type and max-types-per-name. +const ( + defaultMaxRecordsPerType = 100 + defaultMaxTypesPerName = 100 +) + +type zoneIndex struct { + z *model.Zone + recs []*model.Record + parsed map[int64]*rr.Parsed + byName map[string][]*model.Record + types map[string]map[string][]*model.Record // name -> type -> records + cuts map[string]bool // delegation points (NS below apex) + dnames map[string]bool +} + +func (ix *zoneIndex) has(name, typ string) bool { return len(ix.types[name][typ]) > 0 } + +func (ix *zoneIndex) hasAddress(name string) bool { + if ix.has(name, "A") || ix.has(name, "AAAA") { + return true + } + // A wildcard one level up may supply the address. + if p := dnsname.Parent(name); p != "" && dnsname.IsSubdomain(p, ix.z.Name) { + w := "*." + p + if p == dnsname.Root { + w = "*." + } + if len(ix.byName[name]) == 0 && (ix.has(w, "A") || ix.has(w, "AAAA")) { + return true + } + } + return false +} + +// cutAbove returns the delegation point at or above name (excluding the +// apex), or "". +func (ix *zoneIndex) cutAbove(name string, includeSelf bool) string { + n := name + if !includeSelf { + n = dnsname.Parent(name) + } + for n != "" && n != ix.z.Name && dnsname.IsSubdomain(n, ix.z.Name) { + if ix.cuts[n] { + return n + } + n = dnsname.Parent(n) + } + return "" +} + +func (c *checker) recProblem(z *model.Zone, r *model.Record, sev Severity, code, format string, args ...any) *Problem { + var id int64 + name := "" + if r != nil { + id, name = r.ID, r.Name + } + var p *Problem + switch sev { + case Error: + p = c.errf(code, "record", id, format, args...) + case Warning: + p = c.warnf(code, "record", id, format, args...) + default: + p = c.infof(code, "record", id, format, args...) + } + p.Zone, p.View, p.Name = z.Name, c.s.ViewName(z), name + return p +} + +func disp(n string) string { return dnsname.Display(n) } + +func (c *checker) zoneLimit(z *model.Zone, name string, def int) int { + var o *model.Option + o = c.s.FindOption(model.ScopeZone, z.ID, name) + if o == nil && z.ViewID != 0 { + o = c.s.FindOption(model.ScopeView, z.ViewID, name) + } + if o == nil { + o = c.s.FindOption(model.ScopeOptions, 0, name) + } + if o == nil { + return def + } + n, err := strconv.Atoi(strings.TrimSpace(o.Value)) + if err != nil { + return def + } + return n +} + +func (c *checker) checkZoneRecords(z *model.Zone) { + ix := &zoneIndex{ + z: z, + recs: c.zoneRecords[z.ID], + parsed: map[int64]*rr.Parsed{}, + byName: map[string][]*model.Record{}, + types: map[string]map[string][]*model.Record{}, + cuts: map[string]bool{}, + dnames: map[string]bool{}, + } + model.SortRecords(ix.recs, z) + + // Per-record checks and indexing. + dups := map[string]*model.Record{} + for _, r := range ix.recs { + if !dnsname.IsSubdomain(r.Name, z.Name) { + c.recProblem(z, r, Error, "record_out_of_zone", "%s is not inside the zone %s", disp(r.Name), disp(z.Name)) + continue + } + if err := dnsname.CheckOwner(r.Name); err != nil { + c.recProblem(z, r, Error, "record_name_invalid", "invalid name %s: %v", disp(r.Name), err) + continue + } + if r.TTL != nil && *r.TTL > maxTTL { + c.recProblem(z, r, Error, "ttl_invalid", "%s %s: TTL %d is larger than 2147483647", disp(r.Name), r.Type, *r.TTL) + } + p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data) + if err != nil { + c.recProblem(z, r, Error, "record_invalid", "%s %s %s: %v", disp(r.Name), r.Type, r.Data, err) + continue + } + ix.parsed[r.ID] = p + dk := r.Name + "|" + r.Type + "|" + strings.ToLower(p.Data) + if prev, ok := dups[dk]; ok { + c.recProblem(z, r, Error, "record_duplicate", "%s %s %s exists twice (records %d and %d)", disp(r.Name), r.Type, r.Data, prev.ID, r.ID) + continue + } + dups[dk] = r + ix.byName[r.Name] = append(ix.byName[r.Name], r) + if ix.types[r.Name] == nil { + ix.types[r.Name] = map[string][]*model.Record{} + } + ix.types[r.Name][r.Type] = append(ix.types[r.Name][r.Type], r) + if r.Type == "NS" && r.Name != z.Name { + ix.cuts[r.Name] = true + } + if r.Type == "DNAME" { + ix.dnames[r.Name] = true + } + } + + maxPerType := c.zoneLimit(z, "max-records-per-type", defaultMaxRecordsPerType) + maxTypes := c.zoneLimit(z, "max-types-per-name", defaultMaxTypesPerName) + + names := make([]string, 0, len(ix.types)) + for n := range ix.types { + names = append(names, n) + } + sort.Slice(names, func(i, j int) bool { return model.CanonicalLess(names[i], names[j]) }) + + for _, name := range names { + types := ix.types[name] + first := ix.byName[name][0] + // RRset TTL consistency. + for t, rs := range types { + ttl := rs[0].EffectiveTTL(z) + for _, r := range rs[1:] { + if r.EffectiveTTL(z) != ttl { + c.recProblem(z, r, Error, "rrset_ttl_mismatch", "%s %s: all records of an RRset must have the same TTL (found %d and %d); BIND would silently use the first one", disp(name), t, ttl, r.EffectiveTTL(z)) + break + } + } + if maxPerType > 0 && len(rs) > maxPerType { + c.recProblem(z, rs[0], Error, "rrset_too_large", "%s %s has %d records; named refuses to load RRsets larger than max-records-per-type (%d)", disp(name), t, len(rs), maxPerType) + } + } + if maxTypes > 0 && len(types) > maxTypes { + c.recProblem(z, first, Error, "too_many_types", "%s has %d record types; named refuses more than max-types-per-name (%d)", disp(name), len(types), maxTypes) + } + // CNAME exclusivity. + if cn := types["CNAME"]; len(cn) > 0 { + if name == z.Name { + c.recProblem(z, cn[0], Error, "cname_apex", "a CNAME can't be at the zone apex, which must have SOA and NS records (use an A/AAAA record, or an ALIAS-style HTTPS record)") + } + if len(cn) > 1 { + c.recProblem(z, cn[1], Error, "cname_multiple", "%s has %d CNAME records; a name can have only one CNAME", disp(name), len(cn)) + } + var others []string + for t := range types { + if t != "CNAME" { + others = append(others, t) + } + } + if len(others) > 0 { + sort.Strings(others) + c.recProblem(z, cn[0], Error, "cname_conflict", "%s has a CNAME, so it can't have any other records (found %s); a CNAME must be the only record at its name (RFC 1034)", disp(name), strings.Join(others, ", ")) + } + } + // DNAME rules. + if dn := types["DNAME"]; len(dn) > 1 { + c.recProblem(z, dn[1], Error, "dname_multiple", "%s has %d DNAME records; only one is allowed", disp(name), len(dn)) + } + if strings.HasPrefix(name, "*.") { + if ix.has(name, "NS") { + c.recProblem(z, first, Warning, "wildcard_unusual", "wildcard NS records (%s) are almost never what you want", disp(name)) + } + if ix.has(name, "DNAME") { + c.recProblem(z, first, Warning, "wildcard_unusual", "DNAME at a wildcard name (%s) should not be used (RFC 4592)", disp(name)) + } + } + // Null MX must be alone. + if mx := types["MX"]; len(mx) > 1 { + for _, r := range mx { + if p := ix.parsed[r.ID]; p != nil && p.RR.(*dns.MX).Mx == "." { + c.recProblem(z, r, Error, "null_mx_not_alone", "%s has a null MX (\"0 .\") and other MX records; a null MX must be the only MX (RFC 7505)", disp(name)) + break + } + } + } + c.checkTXTPolicies(z, ix, name) + } + + // Apex. + if !ix.has(z.Name, "NS") { + c.recProblem(z, nil, Error, "apex_ns_missing", "the zone has no NS records at its apex; named refuses to load a zone without NS records") + } + + // Records below DNAMEs. + for _, r := range ix.recs { + for n := dnsname.Parent(r.Name); n != "" && dnsname.IsSubdomain(n, z.Name); n = dnsname.Parent(n) { + if ix.dnames[n] { + c.recProblem(z, r, Error, "below_dname", "%s is below the DNAME at %s; names below a DNAME are never reached (RFC 6672)", disp(r.Name), disp(n)) + break + } + if n == z.Name { + break + } + } + } + + // Delegations: occluded data and DS placement. + glueNames := map[string]bool{} + for cut := range ix.cuts { + for _, r := range ix.types[cut]["NS"] { + if p := ix.parsed[r.ID]; p != nil { + glueNames[p.RR.(*dns.NS).Ns] = true + } + } + } + for _, r := range ix.recs { + if ix.parsed[r.ID] == nil { + continue + } + if r.Type == "DS" && !ix.cuts[r.Name] { + c.recProblem(z, r, Error, "ds_not_at_delegation", "DS records belong at a delegation point (a name with NS records below the apex); %s is not one", disp(r.Name)) + } + cutSelf := ix.cuts[r.Name] + cut := ix.cutAbove(r.Name, false) + switch { + case cutSelf && cut == "": + // At the delegation point: NS and DS are what belongs here; + // address records are fine if they are glue. + switch r.Type { + case "NS", "DS": + case "A", "AAAA": + if !glueNames[r.Name] { + c.recProblem(z, r, Warning, "occluded", "%s %s is at the delegation point %s and won't be served (the server answers with a referral)", disp(r.Name), r.Type, disp(r.Name)) + } + case "CNAME": + // reported as cname_conflict + default: + c.recProblem(z, r, Warning, "occluded", "%s %s is at the delegation point %s and won't be served (the server answers with a referral)", disp(r.Name), r.Type, disp(r.Name)) + } + case cut != "": + if (r.Type == "A" || r.Type == "AAAA") && glueNames[r.Name] { + continue + } + c.recProblem(z, r, Warning, "occluded", "%s %s is below the delegation point %s and won't be served; only glue address records for the delegated name servers belong there", disp(r.Name), r.Type, disp(cut)) + } + } + + // Targets of NS, MX, SRV and CNAME records. + for _, r := range ix.recs { + p := ix.parsed[r.ID] + if p == nil { + continue + } + switch v := p.RR.(type) { + case *dns.NS: + c.checkNSTarget(z, ix, r, v.Ns) + case *dns.MX: + if v.Mx != "." { + c.checkHostTarget(z, ix, r, "MX", v.Mx, "mx_cname") + } + case *dns.SRV: + if v.Target != "." { + c.checkHostTarget(z, ix, r, "SRV", v.Target, "srv_cname") + } + case *dns.CNAME: + c.checkCNAMETarget(z, ix, r, v.Target) + } + } + + c.checkChildZones(z, ix) +} + +// inZoneTarget reports whether target is served from this zone's data +// (inside the zone and not below a delegation). +func (ix *zoneIndex) inZoneTarget(target string) bool { + return dnsname.IsSubdomain(target, ix.z.Name) && ix.cutAbove(target, true) == "" +} + +func (c *checker) checkNSTarget(z *model.Zone, ix *zoneIndex, r *model.Record, target string) { + if !dnsname.IsSubdomain(target, z.Name) { + return + } + if ix.has(target, "CNAME") { + c.recProblem(z, r, Error, "ns_cname", "NS target %s is a CNAME; NS records must point to a name with address records (RFC 2181)", disp(target)) + return + } + if ix.hasAddress(target) { + return + } + if r.Name == z.Name { + c.recProblem(z, r, Error, "ns_no_address", "name server %s is inside the zone but has no A or AAAA record; named refuses to load the zone", disp(target)) + return + } + if dnsname.IsSubdomain(target, r.Name) { + c.recProblem(z, r, Error, "glue_missing", "the delegation of %s uses name server %s, which is inside the delegated zone, so glue A/AAAA records are required at %s", disp(r.Name), disp(target), disp(target)) + return + } + if ix.inZoneTarget(target) { + c.recProblem(z, r, Warning, "target_no_address", "name server %s (for the delegation of %s) has no A or AAAA record in this zone", disp(target), disp(r.Name)) + } +} + +func (c *checker) checkHostTarget(z *model.Zone, ix *zoneIndex, r *model.Record, t, target, cnameCode string) { + if !ix.inZoneTarget(target) { + return + } + if ix.has(target, "CNAME") { + c.recProblem(z, r, Error, cnameCode, "%s target %s is a CNAME; %s records must point directly to a name with address records (RFC 2181 / RFC 2782)", t, disp(target), t) + return + } + if !ix.hasAddress(target) { + if len(ix.byName[target]) == 0 { + c.recProblem(z, r, Warning, "target_no_address", "%s %s points to %s, which does not exist in the zone", disp(r.Name), t, disp(target)) + } else { + c.recProblem(z, r, Warning, "target_no_address", "%s %s points to %s, which has no A or AAAA record", disp(r.Name), t, disp(target)) + } + } +} + +func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record, target string) { + if !ix.inZoneTarget(target) { + return + } + // Follow the chain inside the zone to detect loops. + seen := map[string]bool{r.Name: true} + cur := target + for hops := 0; hops < 20; hops++ { + if seen[cur] { + c.recProblem(z, r, Error, "cname_loop", "CNAME loop: %s points (via other CNAMEs) back to itself", disp(r.Name)) + return + } + seen[cur] = true + next := ix.types[cur]["CNAME"] + if len(next) == 0 { + break + } + if hops == 0 { + c.recProblem(z, r, Warning, "cname_chain", "%s is a CNAME to %s, which is itself a CNAME; chains slow down resolution", disp(r.Name), disp(target)) + } + p := ix.parsed[next[0].ID] + if p == nil { + break + } + cur = p.RR.(*dns.CNAME).Target + if !ix.inZoneTarget(cur) { + return + } + } + if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) { + c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target)) + } +} + +func hasDescendant(ix *zoneIndex, name string) bool { + for n := range ix.byName { + if dnsname.IsStrictSubdomain(n, name) { + return true + } + } + // A wildcard sibling may cover the name. + if p := dnsname.Parent(name); p != "" { + if len(ix.byName["*."+p]) > 0 { + return true + } + } + return false +} + +func (c *checker) checkTXTPolicies(z *model.Zone, ix *zoneIndex, name string) { + var spf, dmarc []*model.Record + for _, r := range ix.types[name]["TXT"] { + p := ix.parsed[r.ID] + if p == nil { + continue + } + v, _ := rr.TextValue(p.RR) + lv := strings.ToLower(v) + if strings.HasPrefix(lv, "v=spf1 ") || lv == "v=spf1" { + spf = append(spf, r) + } + if strings.HasPrefix(lv, "v=dmarc1") { + dmarc = append(dmarc, r) + } + } + if len(spf) > 1 { + c.recProblem(z, spf[1], Warning, "spf_multiple", "%s has %d SPF policies (TXT \"v=spf1 ...\"); receivers treat multiple SPF records as a permanent error (RFC 7208), merge them into one", disp(name), len(spf)) + } + if len(dmarc) > 1 { + c.recProblem(z, dmarc[1], Warning, "dmarc_multiple", "%s has %d DMARC policies; receivers ignore DMARC when there is more than one", disp(name), len(dmarc)) + } + if len(dmarc) > 0 && !strings.HasPrefix(name, "_dmarc.") { + c.recProblem(z, dmarc[0], Warning, "dmarc_name", "DMARC policies must be published at _dmarc., not at %s", disp(name)) + } +} + +// checkChildZones reports records hidden by more specific zones in the +// same view, and child zones that are not delegated. +func (c *checker) checkChildZones(z *model.Zone, ix *zoneIndex) { + for _, other := range c.s.ZonesInView(z.ViewID) { + if other.ID == z.ID || !dnsname.IsStrictSubdomain(other.Name, z.Name) { + continue + } + hidden := 0 + var firstHidden *model.Record + for _, r := range ix.recs { + if dnsname.IsSubdomain(r.Name, other.Name) && !(r.Name == other.Name && (r.Type == "NS" || r.Type == "DS")) && !glueFor(ix, other.Name, r) { + hidden++ + if firstHidden == nil { + firstHidden = r + } + } + } + if hidden > 0 { + c.recProblem(z, firstHidden, Warning, "shadowed_by_zone", "%d record(s) under %s (e.g. %s %s) are hidden by the zone %s in the same view; move them into that zone", hidden, disp(other.Name), disp(firstHidden.Name), firstHidden.Type, disp(other.Name)) + } + if (other.Type == model.ZonePrimary || other.Type == model.ZoneSecondary) && !ix.has(other.Name, "NS") { + p := c.recProblem(z, nil, Info, "child_not_delegated", "zone %s exists on this server but %s has no NS records delegating it, so other resolvers can't find it", disp(other.Name), disp(z.Name)) + p.Name = other.Name + } + } +} + +func glueFor(ix *zoneIndex, cut string, r *model.Record) bool { + if r.Type != "A" && r.Type != "AAAA" { + return false + } + for _, ns := range ix.types[cut]["NS"] { + if p := ix.parsed[ns.ID]; p != nil && p.RR.(*dns.NS).Ns == r.Name { + return true + } + } + return false +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000..987dd5b --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,184 @@ +// Package validate checks a complete configuration State for problems +// before it is rendered and handed to BIND. +// +// Rules come from the DNS RFCs (CNAME exclusivity, glue, DNAME, ...), from +// the checks named itself performs when loading a zone (check-names, +// check-integrity, record limits), and from operational experience (open +// resolvers, open zone transfers). named-checkconf and named-checkzone are +// run as well before anything is applied; this package exists so that +// problems are found early and explained in terms of Jarl objects. +package validate + +import ( + "fmt" + "sort" + "strings" + + "jarl/internal/model" +) + +// Severity of a problem. +type Severity string + +const ( + Error Severity = "error" // blocks applying + Warning Severity = "warning" // shown, does not block + Info Severity = "info" +) + +// Problem is a single validation finding. +type Problem struct { + Severity Severity `json:"severity"` + Code string `json:"code"` + Message string `json:"message"` + ObjectType string `json:"object_type,omitempty"` // view, zone, record, acl, key, option, settings + ObjectID int64 `json:"object_id,omitempty"` + Zone string `json:"zone,omitempty"` + View string `json:"view,omitempty"` + Name string `json:"name,omitempty"` +} + +// Key identifies a problem independently of object IDs, so problems can be +// compared between two states. +func (p Problem) Key() string { + return string(p.Severity) + "|" + p.Code + "|" + p.View + "|" + p.Zone + "|" + p.Name + "|" + p.Message +} + +func (p Problem) String() string { + loc := "" + if p.Zone != "" { + loc = "zone " + p.Zone + if p.View != "" { + loc += " [view " + p.View + "]" + } + loc += ": " + } else if p.View != "" { + loc = "view " + p.View + ": " + } + return string(p.Severity) + ": " + loc + p.Message +} + +// Options adjust validation. +type Options struct { + // ExternalKeys are TSIG key names defined outside Jarl, in files + // pulled in by top-level include statements (e.g. rndc-key). + ExternalKeys []string + // ExternalKeysUnknown is set when an included file could not be read, + // so references to unknown keys are not reported. + ExternalKeysUnknown bool +} + +// Result holds the problems found. +type Result struct { + Problems []Problem `json:"problems"` +} + +// HasErrors reports whether any problem is an error. +func HasErrors(ps []Problem) bool { + for _, p := range ps { + if p.Severity == Error { + return true + } + } + return false +} + +// Errors returns only the errors. +func Errors(ps []Problem) []Problem { + var out []Problem + for _, p := range ps { + if p.Severity == Error { + out = append(out, p) + } + } + return out +} + +// Introduced returns the problems in after that are not in before. +func Introduced(before, after []Problem) []Problem { + seen := map[string]bool{} + for _, p := range before { + seen[p.Key()] = true + } + var out []Problem + for _, p := range after { + if !seen[p.Key()] { + out = append(out, p) + } + } + return out +} + +type checker struct { + s *model.State + opts Options + problems []Problem + // indexes + zoneRecords map[int64][]*model.Record + keyNames map[string]bool +} + +// State validates the complete state. +func State(s *model.State, opts Options) []Problem { + c := &checker{s: s, opts: opts, zoneRecords: s.RecordsByZone(), keyNames: map[string]bool{}} + for _, k := range s.Keys { + c.keyNames[k.Name] = true + } + for _, k := range opts.ExternalKeys { + c.keyNames[model.NormalizeKeyName(k)] = true + } + c.checkSettings() + c.checkKeys() + c.checkACLs() + c.checkViews() + c.checkZones() + c.checkOptions() + c.checkSecurity() + sortProblems(c.problems) + return c.problems +} + +func (c *checker) add(p Problem) { c.problems = append(c.problems, p) } + +func (c *checker) errf(code, objType string, id int64, format string, args ...any) *Problem { + c.add(Problem{Severity: Error, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)}) + return &c.problems[len(c.problems)-1] +} + +func (c *checker) warnf(code, objType string, id int64, format string, args ...any) *Problem { + c.add(Problem{Severity: Warning, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)}) + return &c.problems[len(c.problems)-1] +} + +func (c *checker) infof(code, objType string, id int64, format string, args ...any) *Problem { + c.add(Problem{Severity: Info, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)}) + return &c.problems[len(c.problems)-1] +} + +func sortProblems(ps []Problem) { + rank := map[Severity]int{Error: 0, Warning: 1, Info: 2} + sort.SliceStable(ps, func(i, j int) bool { + a, b := ps[i], ps[j] + if rank[a.Severity] != rank[b.Severity] { + return rank[a.Severity] < rank[b.Severity] + } + if a.View != b.View { + return a.View < b.View + } + if a.Zone != b.Zone { + return a.Zone < b.Zone + } + if a.Name != b.Name { + return model.CanonicalLess(a.Name, b.Name) + } + return a.Message < b.Message + }) +} + +// zoneLabel returns "name [view]" for messages. +func (c *checker) zoneLabel(z *model.Zone) string { + if v := c.s.ViewName(z); v != "" { + return strings.TrimSuffix(z.Name, ".") + " [view " + v + "]" + } + return strings.TrimSuffix(z.Name, ".") +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go new file mode 100644 index 0000000..673ccfc --- /dev/null +++ b/internal/validate/validate_test.go @@ -0,0 +1,346 @@ +package validate + +import ( + "strings" + "testing" + + "jarl/internal/dnsname" + "jarl/internal/model" + "jarl/internal/rr" +) + +type builder struct { + s *model.State + id int64 +} + +func newBuilder() *builder { return &builder{s: model.NewState()} } + +func (b *builder) next() int64 { b.id++; return b.id } + +func (b *builder) view(name string, clients ...string) *model.View { + v := &model.View{ID: b.next(), Name: name, Position: len(b.s.Views), MatchClients: clients} + b.s.Views[v.ID] = v + return v +} + +func (b *builder) zone(name string, view *model.View, typ string) *model.Zone { + z := &model.Zone{ID: b.next(), Name: dnsname.Fqdn(name), Type: typ, DefaultTTL: 3600, + SOA: model.SOA{MName: "ns1." + dnsname.Fqdn(name), RName: "hostmaster." + dnsname.Fqdn(name), Serial: 1, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}} + if view != nil { + z.ViewID = view.ID + } + b.s.Zones[z.ID] = z + return z +} + +// primary creates a valid primary zone with NS and glue. +func (b *builder) primary(name string, view *model.View) *model.Zone { + z := b.zone(name, view, model.ZonePrimary) + b.rec(z, "@", "NS", "ns1") + b.rec(z, "ns1", "A", "192.0.2.1") + return z +} + +func (b *builder) rec(z *model.Zone, name, typ, data string) *model.Record { + owner, err := dnsname.Owner(name, z.Name) + if err != nil { + panic(err) + } + p, err := rr.Parse(z.Name, owner, typ, data) + if err != nil { + panic(name + " " + typ + " " + data + ": " + err.Error()) + } + r := &model.Record{ID: b.next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data} + b.s.Records[r.ID] = r + return r +} + +func (b *builder) opt(scope string, scopeID int64, name, value string) *model.Option { + o := &model.Option{ID: b.next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value} + b.s.Options[o.ID] = o + return o +} + +func codes(ps []Problem, sev Severity) []string { + var out []string + for _, p := range ps { + if p.Severity == sev { + out = append(out, p.Code) + } + } + return out +} + +func hasCode(ps []Problem, code string) bool { + for _, p := range ps { + if p.Code == code { + return true + } + } + return false +} + +func TestValidZoneIsClean(t *testing.T) { + b := newBuilder() + z := b.primary("example.com", nil) + b.rec(z, "www", "A", "192.0.2.10") + b.rec(z, "www", "AAAA", "2001:db8::10") + b.rec(z, "@", "MX", "10 mail") + b.rec(z, "mail", "A", "192.0.2.25") + b.rec(z, "@", "TXT", "v=spf1 mx -all") + b.rec(z, "_dmarc", "TXT", "v=DMARC1; p=none") + b.rec(z, "ftp", "CNAME", "www") + b.rec(z, "_sip._tcp", "SRV", "0 5 5060 www") + b.rec(z, "*", "A", "192.0.2.99") + b.rec(z, "sub", "NS", "ns.sub") + b.rec(z, "ns.sub", "A", "192.0.2.53") + b.rec(z, "sub", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + rz := b.primary("2.0.192.in-addr.arpa", nil) + rz.SOA.MName = "ns1.example.com." + b.rec(rz, "10", "PTR", "www.example.com.") + b.opt(model.ScopeOptions, 0, "recursion", "yes") + b.opt(model.ScopeOptions, 0, "allow-recursion", "{ 192.168.0.0/16; localhost; }") + ps := State(b.s, Options{}) + for _, p := range ps { + if p.Severity != Info { + t.Errorf("unexpected problem: %s (%s)", p, p.Code) + } + } +} + +func TestRecordConflicts(t *testing.T) { + cases := []struct { + name string + build func(b *builder, z *model.Zone) + code string + sev Severity + }{ + {"cname and A", func(b *builder, z *model.Zone) { + b.rec(z, "www", "CNAME", "ns1") + b.rec(z, "www", "A", "192.0.2.2") + }, "cname_conflict", Error}, + {"two cnames", func(b *builder, z *model.Zone) { + b.rec(z, "www", "CNAME", "ns1") + b.rec(z, "www", "CNAME", "other.example.org.") + }, "cname_multiple", Error}, + {"cname at apex", func(b *builder, z *model.Zone) { b.rec(z, "@", "CNAME", "other.example.org.") }, "cname_apex", Error}, + {"duplicate", func(b *builder, z *model.Zone) { + b.rec(z, "x", "A", "192.0.2.2") + b.rec(z, "x", "A", "192.0.2.2") + }, "record_duplicate", Error}, + {"ttl mismatch", func(b *builder, z *model.Zone) { + r := b.rec(z, "x", "A", "192.0.2.2") + r.TTL = model.Ptr(uint32(60)) + b.rec(z, "x", "A", "192.0.2.3") + }, "rrset_ttl_mismatch", Error}, + {"mx to cname", func(b *builder, z *model.Zone) { + b.rec(z, "mail", "CNAME", "ns1") + b.rec(z, "@", "MX", "10 mail") + }, "mx_cname", Error}, + {"srv to cname", func(b *builder, z *model.Zone) { + b.rec(z, "alias", "CNAME", "ns1") + b.rec(z, "_sip._tcp", "SRV", "0 0 5060 alias") + }, "srv_cname", Error}, + {"missing glue", func(b *builder, z *model.Zone) { b.rec(z, "sub", "NS", "ns.sub") }, "glue_missing", Error}, + {"apex ns without address", func(b *builder, z *model.Zone) { b.rec(z, "@", "NS", "ns2") }, "ns_no_address", Error}, + {"ds not at delegation", func(b *builder, z *model.Zone) { + b.rec(z, "x", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + }, "ds_not_at_delegation", Error}, + {"below dname", func(b *builder, z *model.Zone) { + b.rec(z, "d", "DNAME", "other.example.org.") + b.rec(z, "x.d", "A", "192.0.2.2") + }, "below_dname", Error}, + {"occluded", func(b *builder, z *model.Zone) { + b.rec(z, "sub", "NS", "ns.other.org.") + b.rec(z, "www.sub", "A", "192.0.2.2") + }, "occluded", Warning}, + {"cname loop", func(b *builder, z *model.Zone) { + b.rec(z, "a", "CNAME", "b") + b.rec(z, "b", "CNAME", "a") + }, "cname_loop", Error}, + {"cname chain", func(b *builder, z *model.Zone) { + b.rec(z, "a", "CNAME", "b") + b.rec(z, "b", "CNAME", "ns1") + }, "cname_chain", Warning}, + {"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning}, + {"null mx not alone", func(b *builder, z *model.Zone) { + b.rec(z, "@", "MX", "0 .") + b.rec(z, "@", "MX", "10 ns1") + }, "null_mx_not_alone", Error}, + {"two spf", func(b *builder, z *model.Zone) { + b.rec(z, "@", "TXT", "v=spf1 mx -all") + b.rec(z, "@", "TXT", "v=spf1 a -all") + }, "spf_multiple", Warning}, + {"mx target missing", func(b *builder, z *model.Zone) { b.rec(z, "@", "MX", "10 nomail") }, "target_no_address", Warning}, + {"ns to cname", func(b *builder, z *model.Zone) { + b.rec(z, "nsalias", "CNAME", "ns1") + b.rec(z, "@", "NS", "nsalias") + }, "ns_cname", Error}, + } + for _, c := range cases { + b := newBuilder() + z := b.primary("example.com", nil) + c.build(b, z) + ps := State(b.s, Options{}) + found := false + for _, p := range ps { + if p.Code == c.code && p.Severity == c.sev { + found = true + } + } + if !found { + t.Errorf("%s: expected %s %s, got errors %v warnings %v", c.name, c.sev, c.code, codes(ps, Error), codes(ps, Warning)) + } + } +} + +func TestZoneRules(t *testing.T) { + b := newBuilder() + b.zone("empty.com", nil, model.ZonePrimary) // no NS + b.zone("sec.com", nil, model.ZoneSecondary) // no primaries + ps := State(b.s, Options{}) + if !hasCode(ps, "apex_ns_missing") || !hasCode(ps, "zone_missing_option") { + t.Errorf("got %v", codes(ps, Error)) + } + + // Views: zones outside views are not allowed once views exist. + b = newBuilder() + b.primary("example.com", nil) + b.view("internal", "192.168.0.0/16") + ps = State(b.s, Options{}) + if !hasCode(ps, "zone_outside_view") { + t.Errorf("zone outside view not reported: %v", codes(ps, Error)) + } + + // in-view must reference an earlier view. + b = newBuilder() + ext := b.view("external") + in := b.view("internal", "10/8") + b.primary("example.com", in) + iv := b.zone("example.com", ext, model.ZoneInView) + iv.InView = "internal" + ps = State(b.s, Options{}) + if !hasCode(ps, "zone_inview_invalid") { + t.Errorf("in-view order not checked: %v", codes(ps, Error)) + } + if !hasCode(ps, "view_unreachable") { + t.Errorf("unreachable view not reported: %v", codes(ps, Warning)) + } + in.Position, ext.Position = 0, 1 + ps = State(b.s, Options{}) + if HasErrors(ps) { + t.Errorf("valid in-view setup has errors: %v", Errors(ps)) + } +} + +func TestACLsAndOptions(t *testing.T) { + b := newBuilder() + b.s.ACLs[1] = &model.ACL{ID: 1, Name: "lan", Elements: []string{"192.168.1.0/24", "!192.168.1.5", "missing", "key nokey"}} + b.s.ACLs[2] = &model.ACL{ID: 2, Name: "a", Elements: []string{"b"}} + b.s.ACLs[3] = &model.ACL{ID: 3, Name: "b", Elements: []string{"a"}} + b.s.ACLs[4] = &model.ACL{ID: 4, Name: "any", Elements: []string{"10.0.0.0/8"}} + b.s.ACLs[5] = &model.ACL{ID: 5, Name: "bad", Elements: []string{"10.0.0.1/8"}} + b.id = 10 + b.opt(model.ScopeOptions, 0, "recursion", "maybe") + b.opt(model.ScopeOptions, 0, "frobnicate", "yes") + b.opt(model.ScopeOptions, 0, "allow-query", "{ lan; ghost; }") + b.opt(model.ScopeOptions, 0, "allow-transfer", "{ key nope; }") + b.opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`) + b.opt(model.ScopeOptions, 0, "directory", `"/tmp"`) + b.opt(model.ScopeOptions, 0, "dnssec-validation", "sometimes") + b.opt(model.ScopeTopLevel, 0, "acl", "x { any; }") + ps := State(b.s, Options{}) + for _, want := range []string{"acl_ref_missing", "key_ref_missing", "acl_cycle", "acl_name_invalid", "acl_element_invalid", "option_value_invalid", "option_unknown", "option_duplicate", "option_managed"} { + if !hasCode(ps, want) { + t.Errorf("missing %s; got %v", want, codes(ps, Error)) + } + } + if !hasCode(ps, "acl_shadowed") { + t.Errorf("shadowed element not reported: %v", codes(ps, Warning)) + } + ps = State(b.s, Options{ExternalKeysUnknown: true}) + for _, p := range ps { + if p.Code == "key_ref_missing" { + t.Error("key refs reported despite ExternalKeysUnknown") + } + } +} + +func TestSecurityLints(t *testing.T) { + // allow-query any without allow-recursion: open resolver. + b := newBuilder() + b.primary("example.com", nil) + b.opt(model.ScopeOptions, 0, "allow-query", "{ any; }") + ps := State(b.s, Options{}) + if !hasCode(ps, "security_open_resolver") { + t.Errorf("open resolver not detected: %v", codes(ps, Warning)) + } + // Fixed by recursion no. + b.opt(model.ScopeOptions, 0, "recursion", "no") + if hasCode(State(b.s, Options{}), "security_open_resolver") { + t.Error("open resolver reported with recursion no") + } + // Through an ACL that contains any. + b = newBuilder() + b.s.ACLs[100] = &model.ACL{ID: 100, Name: "everyone", Elements: []string{"any"}} + b.opt(model.ScopeOptions, 0, "allow-recursion", "{ everyone; }") + if !hasCode(State(b.s, Options{}), "security_open_resolver") { + t.Error("open resolver via ACL not detected") + } + // Inside a view that only matches internal clients it's fine. + b = newBuilder() + v := b.view("internal", "192.168.0.0/16") + b.opt(model.ScopeView, v.ID, "allow-recursion", "{ any; }") + if hasCode(State(b.s, Options{}), "security_open_resolver") { + t.Error("open resolver reported for internal-only view") + } + // Dynamic updates are rejected. + b = newBuilder() + z := b.primary("example.com", nil) + b.opt(model.ScopeZone, z.ID, "allow-update", "{ key ddns; }") + ps = State(b.s, Options{ExternalKeys: []string{"ddns"}}) + if !hasCode(ps, "dynamic_update_unsupported") { + t.Errorf("allow-update not rejected: %v", codes(ps, Error)) + } + b.s.Options[b.id].Value = "{ none; }" + if hasCode(State(b.s, Options{}), "dynamic_update_unsupported") { + t.Error("allow-update { none; } rejected") + } + // Open transfers. + b.opt(model.ScopeZone, z.ID, "allow-transfer", "{ any; }") + if !hasCode(State(b.s, Options{}), "security_open_transfer") { + t.Error("open transfer not detected") + } +} + +func TestIntroduced(t *testing.T) { + b := newBuilder() + z := b.primary("example.com", nil) + before := State(b.s, Options{}) + b.rec(z, "www", "CNAME", "ns1") + b.rec(z, "www", "TXT", "x") + after := State(b.s, Options{}) + intro := Introduced(before, after) + if len(intro) == 0 || intro[0].Code != "cname_conflict" { + t.Errorf("Introduced = %v", intro) + } + if len(Introduced(after, after)) != 0 { + t.Error("same state introduced problems") + } +} + +func TestRecordLimit(t *testing.T) { + b := newBuilder() + z := b.primary("example.com", nil) + for i := 0; i < 101; i++ { + b.rec(z, "many", "TXT", strings.Repeat("x", i+1)) + } + if !hasCode(State(b.s, Options{}), "rrset_too_large") { + t.Error("RRset over 100 records not reported") + } + b.opt(model.ScopeOptions, 0, "max-records-per-type", "0") + if hasCode(State(b.s, Options{}), "rrset_too_large") { + t.Error("max-records-per-type 0 (unlimited) not honoured") + } +} diff --git a/internal/validate/zones.go b/internal/validate/zones.go new file mode 100644 index 0000000..87acb0d --- /dev/null +++ b/internal/validate/zones.go @@ -0,0 +1,182 @@ +package validate + +import ( + "path/filepath" + "strings" + + "jarl/internal/dnsname" + "jarl/internal/model" +) + +const maxTTL = 2147483647 + +func (c *checker) zoneProblem(z *model.Zone, sev Severity, code, format string, args ...any) *Problem { + var p *Problem + switch sev { + case Error: + p = c.errf(code, "zone", z.ID, format, args...) + case Warning: + p = c.warnf(code, "zone", z.ID, format, args...) + default: + p = c.infof(code, "zone", z.ID, format, args...) + } + p.Zone = z.Name + p.View = c.s.ViewName(z) + return p +} + +func (c *checker) checkZones() { + hasViews := len(c.s.Views) > 0 + type zkey struct { + view int64 + name string + } + seen := map[zkey]bool{} + viewPos := map[int64]int{} + for i, v := range c.s.OrderedViews() { + viewPos[v.ID] = i + } + for _, z := range c.s.SortedZones() { + if z.ViewID != 0 && c.s.Views[z.ViewID] == nil { + c.zoneProblem(z, Error, "zone_view_missing", "the zone's view (id %d) does not exist", z.ViewID) + continue + } + if hasViews && z.ViewID == 0 { + c.zoneProblem(z, Error, "zone_outside_view", "when views are used, every zone must be inside a view (named requires this); move the zone into a view") + } + if err := dnsname.CheckZoneName(z.Name); err != nil { + c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err) + } + k := zkey{z.ViewID, z.Name} + if seen[k] { + c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view") + } + seen[k] = true + + switch z.Type { + case model.ZonePrimary: + c.checkPrimary(z) + case model.ZoneSecondary, model.ZoneStub: + if c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil { + c.zoneProblem(z, Error, "zone_missing_option", "%s zones need a primaries option listing the primary servers", z.Type) + } + case model.ZoneMirror: + if z.Name != dnsname.Root && c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil { + c.zoneProblem(z, Error, "zone_missing_option", "mirror zones other than the root zone need a primaries option") + } + case model.ZoneForward: + if c.s.FindOption(model.ScopeZone, z.ID, "forwarders") == nil { + c.zoneProblem(z, Info, "zone_no_forwarders", "the forward zone has no forwarders option, so names in it are resolved normally (forwarding is disabled for this domain)") + } + case model.ZoneHint: + if z.File == "" { + c.zoneProblem(z, Error, "zone_file_required", "hint zones need a file (for example /usr/share/dns/root.hints)") + } + if z.Name != dnsname.Root { + c.zoneProblem(z, Warning, "zone_hint_name", "hint zones are normally the root zone \".\"") + } + case model.ZoneRedirect: + if z.Name != dnsname.Root { + c.zoneProblem(z, Error, "zone_name_invalid", "redirect zones must be named \".\"") + } + if z.File == "" && c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil { + c.zoneProblem(z, Error, "zone_file_required", "redirect zones need a file or a primaries option") + } + case model.ZoneStaticStub: + if c.s.FindOption(model.ScopeZone, z.ID, "server-addresses") == nil && c.s.FindOption(model.ScopeZone, z.ID, "server-names") == nil { + c.zoneProblem(z, Error, "zone_missing_option", "static-stub zones need server-addresses or server-names") + } + case model.ZoneInView: + c.checkInView(z, viewPos) + default: + c.zoneProblem(z, Error, "zone_type_invalid", "unknown zone type %q", z.Type) + } + + if z.File != "" { + switch z.Type { + case model.ZonePrimary: + c.zoneProblem(z, Error, "zone_file_managed", "the file of a primary zone is generated by Jarl and can't be set") + case model.ZoneForward, model.ZoneStaticStub, model.ZoneInView: + c.zoneProblem(z, Error, "zone_file_invalid", "%s zones have no file", z.Type) + default: + if !filepath.IsAbs(z.File) { + c.zoneProblem(z, Error, "zone_file_invalid", "zone file %q must be an absolute path", z.File) + } + } + } + if !z.HasRecords() && len(c.zoneRecords[z.ID]) > 0 { + c.zoneProblem(z, Error, "zone_records_unmanaged", "%s zones can't have records managed by Jarl (they get their data from elsewhere); delete the %d records or change the type to primary", z.Type, len(c.zoneRecords[z.ID])) + } + } +} + +func (c *checker) checkInView(z *model.Zone, viewPos map[int64]int) { + if z.ViewID == 0 { + c.zoneProblem(z, Error, "zone_inview_invalid", "in-view zones can only be used inside views") + return + } + target := c.s.ViewByName(z.InView) + if target == nil { + c.zoneProblem(z, Error, "zone_inview_invalid", "in-view refers to view %q, which does not exist", z.InView) + return + } + if target.ID == z.ViewID { + c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can't refer to the zone's own view") + return + } + if viewPos[target.ID] > viewPos[z.ViewID] { + c.zoneProblem(z, Error, "zone_inview_invalid", "in-view refers to view %q, which comes after this view; named only allows references to earlier views (reorder the views)", target.Name) + } + src := c.s.FindZone(z.Name, target.ID) + if src == nil { + c.zoneProblem(z, Error, "zone_inview_invalid", "view %q has no zone %s to share", target.Name, dnsname.Display(z.Name)) + } else if src.Type != model.ZonePrimary && src.Type != model.ZoneSecondary { + c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can only share primary or secondary zones, but %s in view %q is %s", dnsname.Display(z.Name), target.Name, src.Type) + } + for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) { + if o.Name != "forward" && o.Name != "forwarders" { + c.zoneProblem(z, Error, "zone_inview_option", "in-view zones may only set forward and forwarders, not %s (set it on the zone in view %q)", o.Name, z.InView) + } + } +} + +func (c *checker) checkPrimary(z *model.Zone) { + soa := z.SOA + if soa.MName == "" || !dnsname.IsHostname(soa.MName, false) { + c.zoneProblem(z, Error, "soa_invalid", "SOA primary name server (mname) %q is not a valid host name", dnsname.Display(soa.MName)) + } + if soa.RName == "" || !dnsname.IsMailbox(soa.RName) { + c.zoneProblem(z, Error, "soa_invalid", "SOA contact (rname) %q is not a valid mailbox (use hostmaster.example.com. or an email address)", soa.RName) + } + for name, v := range map[string]uint32{"refresh": soa.Refresh, "retry": soa.Retry, "expire": soa.Expire, "minimum": soa.Minimum} { + if v > maxTTL { + c.zoneProblem(z, Error, "soa_invalid", "SOA %s %d is larger than 2147483647", name, v) + } + } + if soa.Refresh < 60 { + c.zoneProblem(z, Warning, "soa_warning", "SOA refresh %ds is very short; secondaries will poll constantly", soa.Refresh) + } + if soa.Retry >= soa.Refresh && soa.Refresh > 0 { + c.zoneProblem(z, Warning, "soa_warning", "SOA retry (%d) should be shorter than refresh (%d)", soa.Retry, soa.Refresh) + } + if soa.Expire < soa.Refresh+soa.Retry { + c.zoneProblem(z, Warning, "soa_warning", "SOA expire (%d) should be longer than refresh + retry", soa.Expire) + } else if soa.Expire < 604800 { + c.zoneProblem(z, Warning, "soa_warning", "SOA expire is under a week (%d); 2-4 weeks is recommended so secondaries survive a primary outage", soa.Expire) + } + if soa.Minimum > 10800 { + c.zoneProblem(z, Warning, "soa_warning", "SOA minimum (negative caching TTL) %d is more than 3 hours; BIND caps negative caching at 3 hours", soa.Minimum) + } + if z.DefaultTTL > maxTTL { + c.zoneProblem(z, Error, "ttl_invalid", "default TTL %d is larger than 2147483647", z.DefaultTTL) + } + switch z.SerialPolicy { + case "", model.SerialDate, model.SerialIncrement, model.SerialUnixtime: + default: + c.zoneProblem(z, Error, "zone_serial_policy", "unknown serial policy %q (use date, increment or unixtime)", z.SerialPolicy) + } + if strings.Contains(z.Name, "/") { + c.zoneProblem(z, Info, "zone_name_slash", "zone names with '/' (RFC 2317) are supported, but the file name is escaped") + } + c.checkZoneRecords(z) +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..7675729 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,7 @@ +// Package version holds Jarl's version, set at build time with +// +// go build -ldflags "-X jarl/internal/version.Version=1.2.3" +package version + +// Version is the Jarl version. +var Version = "0.1.0-dev" diff --git a/internal/web/static/assets/api.js b/internal/web/static/assets/api.js new file mode 100644 index 0000000..68e1ba1 --- /dev/null +++ b/internal/web/static/assets/api.js @@ -0,0 +1,184 @@ +// API client and shared application state. + +export const state = { + me: null, // whoami response + csrf: "", + draft: null, // the open changeset edits are staged into + immediate: false, // apply edits immediately instead of staging them + rrtypes: null, + listeners: new Set(), +}; + +export class APIError extends Error { + constructor(status, body) { + const e = (body && body.error) || {}; + super(e.message || ("HTTP " + status)); + this.status = status; + this.code = e.code || ""; + this.details = e; + this.result = e.result || null; + } +} + +let onUnauthorized = () => {}; +export function setUnauthorizedHandler(fn) { onUnauthorized = fn; } + +// api calls the REST API. opts: {body, stage (bool), query}. +export async function api(method, path, opts = {}) { + const headers = { "Accept": "application/json" }; + let body; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.body); + } + if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf; + let url = "/api/v1" + path; + if (opts.query) { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v); + const s = q.toString(); + if (s) url += (url.includes("?") ? "&" : "?") + s; + } + if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage); + const resp = await fetch(url, { method, headers, body, credentials: "same-origin" }); + if (resp.status === 204) return null; + const ct = resp.headers.get("Content-Type") || ""; + const data = ct.includes("application/json") ? await resp.json() : await resp.text(); + if (!resp.ok) { + if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized(); + throw new APIError(resp.status, data); + } + return data; +} + +export const get = (path, query) => api("GET", path, { query }); + +// draftQuery adds the draft changeset to a query, so reads show the +// configuration as it will be once the pending changes are applied. +export function draftQuery(query = {}) { + if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id }; + return query; +} + +// ---- drafts + +const draftKey = () => "jarl.draft." + (state.me ? state.me.user.id : ""); +const immediateKey = () => "jarl.immediate." + (state.me ? state.me.user.id : ""); + +export function onChange(fn) { state.listeners.add(fn); return () => state.listeners.delete(fn); } +export function notify() { for (const fn of state.listeners) fn(); } + +// loadDraft restores the user's draft changeset if it is still open. +export async function loadDraft() { + state.immediate = localStorage.getItem(immediateKey()) === "1"; + const id = localStorage.getItem(draftKey()); + state.draft = null; + if (id) { + try { + const cs = await get("/changesets/" + id); + if (cs.status === "open") state.draft = cs; + } catch { /* gone */ } + } + if (!state.draft) { + // Pick up the newest open changeset made in the web UI. + try { + const list = await get("/changesets", { status: "open", mine: 1, limit: 20 }); + const web = list.find((c) => c.via === "web"); + if (web) state.draft = web; + } catch { /* ignore */ } + } + if (state.draft) localStorage.setItem(draftKey(), state.draft.id); + notify(); +} + +export function setImmediate(on) { + state.immediate = on; + localStorage.setItem(immediateKey(), on ? "1" : "0"); + notify(); +} + +export async function refreshDraft() { + if (!state.draft) return; + try { + const cs = await get("/changesets/" + state.draft.id); + state.draft = cs.status === "open" ? cs : null; + } catch { state.draft = null; } + if (!state.draft) localStorage.removeItem(draftKey()); + notify(); +} + +export function useDraft(cs) { + state.draft = cs; + if (cs) localStorage.setItem(draftKey(), cs.id); else localStorage.removeItem(draftKey()); + notify(); +} + +async function ensureDraft() { + if (state.draft) return state.draft; + const cs = await api("POST", "/changesets", { body: { description: "" } }); + useDraft(cs); + return cs; +} + +// change sends a configuration change: staged into the draft changeset, +// or applied at once when immediate mode is on (or opts.immediate). +// It returns the API response. +export async function change(method, path, body, opts = {}) { + if (state.immediate || opts.immediate) { + const r = await api(method, path, { body, query: opts.query }); + notify(); + return r; + } + const cs = await ensureDraft(); + try { + const r = await api(method, path, { body, stage: cs.id, query: opts.query }); + if (r && r.changeset) state.draft = r.changeset; + notify(); + return r; + } catch (e) { + if (e.status === 409 && /is (applied|discarded)/.test(e.message)) { + useDraft(null); + return change(method, path, body, opts); + } + throw e; + } +} + +// changeMany submits several requests ({op, id, data}) together. +export async function changeMany(ops, description, opts = {}) { + if (state.immediate || opts.immediate) { + const r = await api("POST", "/apply", { body: { ops, description } }); + notify(); + return r; + } + const cs = await ensureDraft(); + const r = await api("POST", "/changesets/" + cs.id + "/ops", { body: { ops } }); + if (r && r.changeset) state.draft = r.changeset; + notify(); + return r; +} + +// describeResult turns a mutation response into a short message. +export function describeResult(r) { + if (!r) return "Done"; + if (r.staged) { + const n = (r.added || []).length; + return "Staged " + (n === 1 ? "1 change" : n + " changes") + " — review and apply under Changes"; + } + if (r.applied) return "Applied (changeset #" + r.changeset_id + ")"; + if (r.dry_run) return "Dry run OK"; + return "Done"; +} + +export function can(resource, write) { + const me = state.me; + if (!me) return false; + if (me.admin) return true; + return (me.user.grants || []).some((g) => + (g.resource === "*" || g.resource === resource) && (!write || g.access === "write")); +} + +export async function rrtypes() { + if (!state.rrtypes) state.rrtypes = await get("/catalog/rrtypes"); + return state.rrtypes; +} diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js new file mode 100644 index 0000000..0aec259 --- /dev/null +++ b/internal/web/static/assets/app.js @@ -0,0 +1,187 @@ +// Jarl web UI: router, layout and login. +import { h, mount, toast, errorBox, plural } from "./ui.js"; +import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js"; +import * as dashboard from "./pages/dashboard.js"; +import * as zones from "./pages/zones.js"; +import * as zone from "./pages/zone.js"; +import * as views from "./pages/views.js"; +import * as acls from "./pages/acls.js"; +import * as keys from "./pages/keys.js"; +import * as options from "./pages/options.js"; +import * as changes from "./pages/changes.js"; +import * as history from "./pages/history.js"; +import * as audit from "./pages/audit.js"; +import * as users from "./pages/users.js"; +import * as account from "./pages/account.js"; +import * as search from "./pages/search.js"; + +const routes = [ + { pattern: /^$/, page: dashboard, nav: "dashboard" }, + { pattern: /^zones$/, page: zones, nav: "zones" }, + { pattern: /^zones\/(\d+)(?:\/(\w+))?$/, page: zone, nav: "zones", params: ["id", "tab"] }, + { pattern: /^views$/, page: views, nav: "views" }, + { pattern: /^acls$/, page: acls, nav: "acls" }, + { pattern: /^keys$/, page: keys, nav: "keys" }, + { pattern: /^options$/, page: options, nav: "options" }, + { pattern: /^changes$/, page: changes, nav: "changes" }, + { pattern: /^history$/, page: history, nav: "history" }, + { pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] }, + { pattern: /^audit$/, page: audit, nav: "audit" }, + { pattern: /^users$/, page: users, nav: "users" }, + { pattern: /^account$/, page: account, nav: "account" }, + { pattern: /^search$/, page: search, nav: "" }, +]; + +const navItems = [ + ["dashboard", "#/", "Dashboard"], + ["zones", "#/zones", "Zones"], + ["views", "#/views", "Views"], + ["acls", "#/acls", "ACLs"], + ["keys", "#/keys", "TSIG keys"], + ["options", "#/options", "Server options"], + ["changes", "#/changes", "Changes"], + ["history", "#/history", "History"], + ["audit", "#/audit", "Audit log"], +]; + +const app = document.getElementById("app"); +let mainEl, navEl, barEl, current = { key: "" }; + +function parseHash() { + const raw = location.hash.replace(/^#\/?/, ""); + const [path, qs] = raw.split("?"); + return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") }; +} + +function renderLogin(message) { + const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true }); + const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true }); + const err = h("div"); + const form = h("form", { onsubmit: async (e) => { + e.preventDefault(); + mount(err); + try { + const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } }); + state.me = me; + state.csrf = me.csrf_token; + await start(); + } catch (ex) { + mount(err, errorBox(ex)); + pass.select(); + } + } }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err); + mount(app, h("div", { class: "login panel" }, + h("h1", null, "Jarl"), + h("div", { class: "tag" }, "Rule your DNS holds"), + message ? h("div", { class: "msg warn" }, message) : null, + form)); + user.focus(); +} + +function layout() { + navEl = h("nav", { class: "main" }); + barEl = h("div"); + mainEl = h("main"); + const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" }); + const me = state.me; + const header = h("header", { class: "top" }, + h("a", { class: "brand", href: "#/" }, "Jarl", h("span", null, me.version)), + navEl, + h("div", { class: "spacer" }), + h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q), + h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username), + me.admin ? h("a", { href: "#/users" }, "Users") : null, + h("button", { class: "small", onclick: logout }, "Log out")); + mount(app, header, barEl, mainEl); + renderNav(); + renderBar(); +} + +function renderNav() { + mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label))); +} + +function renderBar() { + if (!barEl) return; + const d = state.draft; + const n = d ? (d.ops || []).length : 0; + const mode = h("label", { class: "check small", title: "When on, every edit is applied to named right away instead of being collected for review." }, + h("input", { type: "checkbox", checked: state.immediate, onchange: (e) => setImmediate(e.target.checked) }), "Apply edits immediately"); + if (n > 0) { + mount(barEl, h("div", { class: "pending-bar" }, + h("strong", null, plural(n, "pending change")), + h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."), + h("a", { class: "button primary small", href: "#/changes" }, "Review & apply"), + h("div", { class: "spacer", style: { flex: 1 } }), + mode)); + } else { + mount(barEl, h("div", { class: "pending-bar", style: { background: "var(--panel)" } }, + h("span", { class: "muted small" }, state.immediate ? "Edits are applied to named immediately." : "Edits are collected as pending changes; review and apply them under Changes."), + h("div", { style: { flex: 1 } }), + mode)); + } +} + +async function logout() { + try { await api("POST", "/auth/logout"); } catch { /* ignore */ } + state.me = null; + state.csrf = ""; + renderLogin("You have logged out."); +} + +async function route() { + if (!state.me) return; + const { path, query } = parseHash(); + for (const r of routes) { + const m = path.match(r.pattern); + if (!m) continue; + const params = {}; + (r.params || []).forEach((p, i) => { params[p] = m[i + 1]; }); + current = { key: path, nav: r.nav, page: r.page, params, query }; + renderNav(); + mount(mainEl, h("div", { class: "muted" }, "Loading…")); + try { + await r.page.render(mainEl, params, query); + } catch (e) { + mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e)); + } + return; + } + mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard"))); +} + +// rerender redraws the current page (after changes). +export function rerender() { + if (current.page) route(); +} + +async function start() { + layout(); + await loadDraft(); + onChange(renderBar); + window.addEventListener("hashchange", route); + await route(); +} + +setUnauthorizedHandler(() => { + if (state.me) { + state.me = null; + renderLogin("Your session has expired. Please log in again."); + } +}); + +(async function boot() { + try { + const me = await get("/auth/whoami"); + state.me = me; + state.csrf = me.csrf_token || ""; + await start(); + } catch (e) { + if (e.status === 401) renderLogin(); + else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e))); + } +})(); + +window.addEventListener("unhandledrejection", (e) => { + toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err"); +}); diff --git a/internal/web/static/assets/pages/account.js b/internal/web/static/assets/pages/account.js new file mode 100644 index 0000000..1bc2bbd --- /dev/null +++ b/internal/web/static/assets/pages/account.js @@ -0,0 +1,38 @@ +import { h, mount, field, input, toast, errorBox } from "../ui.js"; +import { api, get } from "../api.js"; +import { tokenTable, newTokenForm, grantHelp } from "./users.js"; + +export async function render(el) { + const me = await get("/auth/whoami"); + const grants = await get("/catalog/grants"); + const tokensEl = h("div"); + const drawTokens = async () => { + const tokens = await get("/me/tokens"); + mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens)); + }; + const cur = input({ type: "password", autocomplete: "current-password" }); + const pw1 = input({ type: "password", autocomplete: "new-password" }); + const pw2 = input({ type: "password", autocomplete: "new-password" }); + const err = h("div"); + mount(el, + h("h1", null, "Account: " + me.user.username), + h("div", { class: "panel" }, h("h2", null, "Permissions"), + h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))), + h("div", { class: "panel" }, h("h2", null, "Change password"), + h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)), + err, + h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => { + mount(err); + if (pw1.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; } + try { + await api("POST", "/me/password", { body: { current_password: cur.value, password: pw1.value } }); + toast("Password changed; other sessions were logged out"); + cur.value = pw1.value = pw2.value = ""; + } catch (e) { mount(err, errorBox(e)); } + } }, "Change password"))), + h("div", { class: "panel" }, h("h2", null, "My API tokens"), + h("p", { class: "muted small" }, "Use a token with the jarl CLI (jarl config set token …) or any HTTP client: Authorization: Bearer ."), + tokensEl), + grantHelp(grants)); + await drawTokens(); +} diff --git a/internal/web/static/assets/pages/acls.js b/internal/web/static/assets/pages/acls.js new file mode 100644 index 0000000..2b46ca9 --- /dev/null +++ b/internal/web/static/assets/pages/acls.js @@ -0,0 +1,54 @@ +import { h, mount, table, field, input, textarea, toast, errorBox, confirmDialog, modal, lines } from "../ui.js"; +import { get, change, describeResult } from "../api.js"; +import { rerender } from "../app.js"; + +export async function render(el) { + const acls = await get("/acls"); + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "ACLs"), + h("div", { class: "muted" }, "Named address match lists, used in options such as allow-query, allow-recursion, allow-transfer and in views' match-clients.")), + h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => aclDialog(null) }, "New ACL"))), + h("div", { class: "msg info small" }, "Built-in ACLs: any, none, localhost (this server's addresses), localnets (networks this server is directly attached to). Elements are checked in order and the first match wins, so put exceptions (!addr) before broader prefixes."), + h("div", { class: "panel" }, table([ + { label: "Name", render: (a) => h("strong", { class: "mono" }, a.name) }, + { label: "Elements", render: (a) => a.elements.join("; ") || h("span", { class: "muted" }, "(empty: matches nothing)"), tdClass: "data" }, + { label: "Used by", render: (a) => a.used_by.length ? a.used_by.join(", ") : h("span", { class: "muted" }, "unused"), tdClass: "small" }, + { label: "Comment", render: (a) => h("span", { class: "muted" }, a.comment || "") }, + { label: "", render: (a) => h("span", null, + h("button", { class: "small", onclick: () => aclDialog(a) }, "Edit"), " ", + h("button", { class: "small danger", disabled: a.used_by.length > 0, title: a.used_by.length ? "Still in use" : "", onclick: () => del(a) }, "Delete")), tdClass: "actions-cell" }, + ], acls, { empty: "No ACLs yet." }))); +} + +async function aclDialog(a) { + const err = h("div"); + const name = input({ value: a ? a.name : "", placeholder: "homelab", class: "mono" }); + const elems = textarea(a ? a.elements.join("\n") : "", { class: "mono", rows: 8, placeholder: "192.168.1.0/24\n10.0.0.0/8\n!10.0.0.66\nkey xfer-key\nlocalhost" }); + const comment = input({ value: a ? a.comment : "" }); + await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" }, + field("Name", name, a ? "Renaming updates every reference automatically." : ""), + field("Elements", elems, "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."), + field("Comment", comment)), err), [ + { label: "Cancel", value: null }, + { label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => { + const data = { name: name.value.trim(), elements: lines(elems.value), comment: comment.value }; + try { + const r = a ? await change("PATCH", "/acls/" + a.id, data) : await change("POST", "/acls", data); + toast(describeResult(r)); + (r.warnings || []).forEach((w) => toast(w, "warn", 8000)); + rerender(); + return true; + } catch (e) { mount(err, errorBox(e)); return false; } + } }, + ], { sticky: true }); +} + +async function del(a) { + if (!(await confirmDialog("Delete ACL", "Delete ACL " + a.name + "?", "Delete", "danger"))) return; + try { + const r = await change("DELETE", "/acls/" + a.id); + toast(describeResult(r)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} diff --git a/internal/web/static/assets/pages/audit.js b/internal/web/static/assets/pages/audit.js new file mode 100644 index 0000000..834a673 --- /dev/null +++ b/internal/web/static/assets/pages/audit.js @@ -0,0 +1,34 @@ +import { h, mount, table, input, select, fmtTime, badge } from "../ui.js"; +import { get } from "../api.js"; + +export async function render(el, params, query) { + const user = input({ placeholder: "user", value: query.get("user") || "", style: { width: "130px" } }); + const action = select([["", "All actions"], ["auth.", "Logins (auth.*)"], ["changeset.apply", "Applied changes"], ["changeset.", "All changeset activity"], ["user.", "User changes"], ["token.", "Tokens"], ["tsig.", "TSIG keys"], ["system.", "System"], ["import", "Imports"]], query.get("action") || ""); + const outcome = select([["", "Any outcome"], "success", "failure", "denied"], query.get("outcome") || ""); + const since = input({ type: "date", value: query.get("since") || "" }); + const q = input({ type: "search", placeholder: "text in object or message", value: query.get("q") || "" }); + const list = h("div"); + let rows = []; + const load = async (append) => { + const before = append && rows.length ? rows[rows.length - 1].id : undefined; + const page = await get("/audit", { user: user.value.trim(), action: action.value, outcome: outcome.value, since: since.value, q: q.value.trim(), limit: 100, before }); + rows = append ? rows.concat(page) : page; + mount(list, table([ + { label: "Time", render: (e) => h("span", { class: "nowrap" }, fmtTime(e.time)) }, + { label: "User", render: (e) => e.username || h("span", { class: "muted" }, "–") }, + { label: "Via", render: (e) => [e.auth, e.token_name ? " (" + e.token_name + ")" : ""].join(""), tdClass: "small" }, + { label: "IP", render: (e) => e.ip || "", tdClass: "mono small" }, + { label: "Action", render: (e) => h("span", { class: "mono" }, e.action) }, + { label: "Object", render: (e) => e.changeset_id ? h("a", { href: "#/history/" + e.changeset_id }, e.object_name || "#" + e.changeset_id) : (e.object_name || ""), tdClass: "small" }, + { label: "Outcome", render: (e) => badge(e.outcome, e.outcome === "success" ? "ok" : e.outcome === "denied" ? "warn" : "err") }, + { label: "Message", render: (e) => h("span", { class: "small" }, e.message || "") }, + ], rows, { empty: "No events match." }), + page.length === 100 ? h("div", { style: { marginTop: "8px" } }, h("button", { onclick: () => load(true) }, "Load older")) : null); + }; + const form = h("form", { class: "actions", onsubmit: (e) => { e.preventDefault(); load(false); } }, + user, action, outcome, since, q, h("button", { class: "primary", type: "submit" }, "Filter")); + mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"), + h("div", { class: "muted" }, "Logins, API token use, every staged and applied change, user and token administration. Also written to the system journal."))), + h("div", { class: "panel" }, form, h("div", { style: { marginTop: "10px" } }, list))); + await load(false); +} diff --git a/internal/web/static/assets/pages/changes.js b/internal/web/static/assets/pages/changes.js new file mode 100644 index 0000000..71778d5 --- /dev/null +++ b/internal/web/static/assets/pages/changes.js @@ -0,0 +1,112 @@ +import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, checkList, time, plural, badge } from "../ui.js"; +import { api, get, state, useDraft, refreshDraft } from "../api.js"; +import { rerender } from "../app.js"; + +export async function render(el) { + await refreshDraft(); + const open = await get("/changesets", { status: "open", mine: 1, limit: 50 }); + const d = state.draft; + const parts = [h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "Pending changes"), + h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches named until you apply it; then the whole changeset is checked by Jarl and named-checkconf, written, and loaded together.")))]; + if (!d || !(d.ops || []).length) { + parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit zones, records or options and they will show up here."))); + } else { + parts.push(await draftPanel(d)); + } + const others = open.filter((c) => !d || c.id !== d.id); + if (others.length) { + parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"), + h("p", { class: "muted small" }, "Created through the API or CLI, or earlier in the web UI. Switch to one to review and apply it here."), + table([ + { label: "#", render: (c) => String(c.id) }, + { label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") }, + { label: "Operations", render: (c) => String((c.ops || []).length) }, + { label: "Via", render: (c) => c.via }, + { label: "Created", render: (c) => time(c.created_at) }, + { label: "", render: (c) => h("span", null, + h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ", + h("button", { class: "small danger", onclick: async () => { + if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return; + await api("POST", "/changesets/" + c.id + "/discard"); + rerender(); + } }, "Discard")), tdClass: "actions-cell" }, + ], others))); + } + mount(el, parts); +} + +async function draftPanel(d) { + const desc = input({ value: d.description || "", placeholder: "Describe this change (shown in the history)", style: { width: "100%" } }); + const preview = h("div", { class: "muted" }, "Checking…"); + const result = h("div"); + let valid = false; + const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes"); + const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl"); + const ops = d.ops || []; + const opsTable = table([ + { label: "#", render: (o) => String(o.seq) }, + { label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" }, + { label: "Added", render: (o) => time(o.added_at) }, + { label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: async () => { + try { + const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + o.seq); + useDraft(cs); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } + } }, "Remove"), tdClass: "actions-cell" }, + ], ops); + const runPreview = async () => { + try { + const p = await get("/changesets/" + d.id + "/preview"); + valid = p.valid && !p.error; + applyBtn.disabled = !valid; + const drift = (p.drift || []); + forceBox.classList.toggle("hidden", drift.length === 0); + mount(preview, + p.error ? errorBox({ message: p.error.message, details: p.error }) : null, + valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null, + !p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null, + problemsList(p.problems), + checkList(p.check), + drift.length ? h("div", { class: "msg warn" }, "Files changed outside Jarl: ", drift.map((x) => x.path + " (" + x.reason + ")").join(", ")) : null, + (p.warnings || []).length ? h("div", { class: "msg warn" }, h("ul", null, p.warnings.map((w) => h("li", null, w)))) : null, + (p.existing_problems || []).filter((x) => x.severity !== "info").length ? h("details", null, h("summary", { class: "muted" }, "Existing problems (not caused by this changeset)"), problemsList(p.existing_problems.filter((x) => x.severity !== "info"))) : null, + h("h3", null, "Changes to BIND's files" + ((p.files_changed || []).length ? " (" + plural(p.files_changed.length, "file") + ")" : "")), + diffView(p.diff)); + } catch (e) { mount(preview, errorBox(e)); } + }; + applyBtn.addEventListener("click", async () => { + applyBtn.disabled = true; + mount(result, h("div", { class: "muted" }, "Applying…")); + try { + if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); + const force = forceBox.querySelector("input").checked; + const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } }); + const res = r.result || {}; + toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : "")); + (r.warnings || []).forEach((w) => toast(w, "warn", 10000)); + useDraft(null); + location.hash = "#/history/" + d.id; + } catch (e) { + mount(result, errorBox(e)); + applyBtn.disabled = false; + } + }); + const discard = h("button", { class: "danger", onclick: async () => { + if (!(await confirmDialog("Discard changes", "Throw away all " + ops.length + " pending changes?", "Discard", "danger"))) return; + await api("POST", "/changesets/" + d.id + "/discard"); + useDraft(null); + rerender(); + } }, "Discard all"); + const panel = h("div", null, + h("div", { class: "panel" }, + h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")), + desc, + h("div", { style: { marginTop: "10px" } }, opsTable), + h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox), + result), + h("div", { class: "panel" }, h("h2", null, "Review"), preview)); + runPreview(); + return panel; +} diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js new file mode 100644 index 0000000..f687e2d --- /dev/null +++ b/internal/web/static/assets/pages/dashboard.js @@ -0,0 +1,53 @@ +import { h, mount, time, problemsList, badge, plural } from "../ui.js"; +import { get, state } from "../api.js"; + +export async function render(el) { + const st = await get("/status"); + const s = st.engine || {}; + const n = s.named; + const cards = h("div", { class: "cards" }, + card("named", n ? (n.running ? badge("running", "ok") : badge("unknown", "warn")) : badge(s.named_error ? "unreachable" : "not checked", s.named_error ? "err" : ""), + n ? [n.version, h("br"), "last configured " + (n.last_configured || "?")] : (s.named_error || "")), + card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"), + s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"), + card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")), + card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key") + " · " + plural((s.counts || {}).options || 0, "option")), + ); + const last = s.last_applied; + const parts = [h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "Dashboard"), h("div", { class: "muted" }, "Signed in as " + state.me.user.username + " · " + (state.me.permissions || []).join(", "))), + h("div", { class: "actions" }, + h("a", { class: "button primary", href: "#/zones?new=1" }, "New zone"), + h("a", { class: "button", href: "#/changes" }, "Pending changes" + (s.open_changesets ? " (" + s.open_changesets + " open)" : "")))), + cards]; + if (s.drift && s.drift.length) { + parts.push(h("div", { class: "msg warn" }, + h("strong", null, "Files changed outside Jarl: "), + s.drift.map((d) => d.path + " (" + d.reason + ")").join(", "), + h("div", { class: "small" }, "The next apply refuses to overwrite them unless forced. An administrator can rewrite them with 'jarld resync' (or System → Resync)."))); + } + parts.push(h("div", { class: "panel" }, + h("h2", null, "Last change"), + last ? h("div", null, + h("a", { href: "#/history/" + last.id }, "#" + last.id + " " + (last.description || "")), " ", + h("span", { class: "muted" }, "by " + last.username + " via " + last.via + ", "), time(last.applied_at)) + : h("div", { class: "muted" }, "Nothing applied yet."))); + const problems = s.problems || []; + const real = problems.filter((p) => p.severity !== "info"); + parts.push(h("div", { class: "panel" }, + h("h2", null, "Configuration checks"), + real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."), + problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null)); + if (s.paths) { + parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" }, + h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"), + h("dt", null, "Jarl's named.conf"), h("dd", { class: "mono" }, s.paths.config_dir + "/named.conf"), + h("dt", null, "Zone files"), h("dd", { class: "mono" }, s.paths.zone_dir), + h("dt", null, "BIND tools"), h("dd", null, s.bind_version || "–")))); + } + mount(el, parts); +} + +function card(label, value, sub) { + return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null); +} diff --git a/internal/web/static/assets/pages/history.js b/internal/web/static/assets/pages/history.js new file mode 100644 index 0000000..61c3b6d --- /dev/null +++ b/internal/web/static/assets/pages/history.js @@ -0,0 +1,59 @@ +import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural } from "../ui.js"; +import { api, get, useDraft } from "../api.js"; + +export async function render(el, params) { + if (params.id) return detail(el, params.id); + const list = h("div"); + let rows = []; + const load = async (before) => { + const page = await get("/changesets", { status: "applied", limit: 50, before }); + rows = rows.concat(page); + mount(list, table([ + { label: "#", render: (c) => h("a", { href: "#/history/" + c.id }, String(c.id)) }, + { label: "Applied", render: (c) => time(c.applied_at) }, + { label: "By", render: (c) => c.applied_by || c.username }, + { label: "Via", render: (c) => badge(c.via) }, + { label: "Description", render: (c) => h("a", { href: "#/history/" + c.id }, c.description || "(no description)") }, + { label: "Operations", render: (c) => String((c.ops || []).length), tdClass: "right" }, + ], rows, { empty: "Nothing has been applied yet." }), + page.length === 50 ? h("div", { style: { marginTop: "8px" } }, h("button", { onclick: () => load(rows[rows.length - 1].id) }, "Load older")) : null); + }; + mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"), + h("div", { class: "muted" }, "Every applied changeset, with the exact changes made to BIND's files. Any of them can be reverted."))), + h("div", { class: "panel" }, list)); + await load(); +} + +async function detail(el, id) { + const c = await get("/changesets/" + id); + const res = c.result || {}; + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, + h("h1", null, "Changeset #" + c.id), + h("div", { class: "muted" }, c.description || "(no description)")), + h("div", { class: "actions" }, + h("a", { class: "button", href: "#/history" }, "All changes"), + c.status === "applied" && c.undoable ? h("button", { onclick: () => revert(c) }, "Revert…") : null)), + h("div", { class: "panel" }, h("dl", { class: "kv" }, + h("dt", null, "Status"), h("dd", null, badge(c.status, c.status === "applied" ? "ok" : "")), + h("dt", null, "Created"), h("dd", null, fmtTime(c.created_at) + " by " + c.username + " via " + c.via), + c.applied_at ? [h("dt", null, "Applied"), h("dd", null, fmtTime(c.applied_at) + " by " + c.applied_by)] : null, + (res.serials || []).length ? [h("dt", null, "Serials"), h("dd", { class: "mono" }, res.serials.map((s) => s.zone.replace(/\.$/, "") + (s.view ? " [" + s.view + "]" : "") + ": " + s.old + " → " + s.new).join(", "))] : null, + (res.warnings || []).length ? [h("dt", null, "Warnings"), h("dd", null, res.warnings.join("; "))] : null)), + h("div", { class: "panel" }, h("h2", null, plural((c.ops || []).length, "operation")), table([ + { label: "#", render: (o) => String(o.seq) }, + { label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" }, + ], c.ops || [])), + h("div", { class: "panel" }, h("h2", null, "File changes"), diffView(c.diff))); +} + +async function revert(c) { + if (!(await confirmDialog("Revert changeset #" + c.id, "This creates a new changeset that undoes these changes. You can review it before applying.", "Create revert", "primary"))) return; + try { + const rev = await api("POST", "/changesets/" + c.id + "/revert"); + useDraft(rev); + toast("Created changeset #" + rev.id + "; review and apply it"); + location.hash = "#/changes"; + } catch (e) { toast(e.message, "err", 8000); } +} diff --git a/internal/web/static/assets/pages/keys.js b/internal/web/static/assets/pages/keys.js new file mode 100644 index 0000000..e15afdb --- /dev/null +++ b/internal/web/static/assets/pages/keys.js @@ -0,0 +1,98 @@ +import { h, mount, table, field, input, select, toast, errorBox, confirmDialog, modal, copyBox } from "../ui.js"; +import { get, change, describeResult } from "../api.js"; +import { rerender } from "../app.js"; + +const algorithms = ["hmac-sha256", "hmac-sha512", "hmac-sha384", "hmac-sha224", "hmac-sha1", "hmac-md5"]; + +// randomSecret returns a base64 secret as long as the algorithm's hash. +function randomSecret(alg) { + const bytes = { "hmac-sha512": 64, "hmac-sha384": 48, "hmac-sha224": 28, "hmac-sha1": 20, "hmac-md5": 16 }[alg] || 32; + const raw = new Uint8Array(bytes); + crypto.getRandomValues(raw); + return btoa(String.fromCharCode(...raw)); +} + +export async function render(el) { + const keys = await get("/tsig-keys"); + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "TSIG keys"), + h("div", { class: "muted" }, "Shared secrets that authenticate zone transfers, NOTIFY and other server-to-server traffic. Use them in ACLs and options as 'key NAME'.")), + h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => keyDialog() }, "New key"))), + h("div", { class: "panel" }, table([ + { label: "Name", render: (k) => h("strong", { class: "mono" }, k.name) }, + { label: "Algorithm", render: (k) => k.algorithm, tdClass: "mono" }, + { label: "Used by", render: (k) => k.used_by.length ? k.used_by.join(", ") : h("span", { class: "muted" }, "unused"), tdClass: "small" }, + { label: "Comment", render: (k) => h("span", { class: "muted" }, k.comment || "") }, + { label: "", render: (k) => h("span", null, + h("button", { class: "small", onclick: () => reveal(k) }, "Show secret"), " ", + h("button", { class: "small", onclick: () => rotate(k) }, "New secret"), " ", + h("button", { class: "small danger", disabled: k.used_by.length > 0, onclick: () => del(k) }, "Delete")), tdClass: "actions-cell" }, + ], keys, { empty: "No TSIG keys. The rndc control key normally stays in /etc/bind/rndc.key and is not managed here." }))); +} + +function snippet(k) { + return 'key "' + k.name + '" {\n\talgorithm ' + k.algorithm + ';\n\tsecret "' + k.secret + '";\n};'; +} + +function showSecret(k, title) { + return modal(title, h("div", null, + h("p", null, "Secret of ", h("strong", { class: "mono" }, k.name), ":"), + copyBox(k.secret), + h("h3", null, "named.conf for the other server"), + h("pre", null, snippet(k)), + h("h3", null, "Usage"), + h("pre", null, "dig @this-server example.com AXFR -y " + k.algorithm + ":" + k.name + ":" + k.secret))); +} + +async function keyDialog() { + const err = h("div"); + const name = input({ placeholder: "xfer-key", class: "mono" }); + const alg = select(algorithms, "hmac-sha256"); + const secret = input({ placeholder: "leave empty to generate a random secret", class: "mono" }); + const comment = input(); + await modal("New TSIG key", h("div", null, h("div", { class: "form-grid" }, + field("Name", name, "Key names are domain names, e.g. xfer-key or secondary.example.com."), + field("Algorithm", alg), + field("Secret", secret, "Base64. Must be the same on both servers."), + field("Comment", comment)), err), [ + { label: "Cancel", value: null }, + { label: "Create key", kind: "primary", onclick: async () => { + const data = { name: name.value.trim(), algorithm: alg.value, comment: comment.value, secret: secret.value.trim() || randomSecret(alg.value) }; + try { + const r = await change("POST", "/tsig-keys", data); + toast(describeResult(r)); + await showSecret({ name: data.name, algorithm: data.algorithm, secret: data.secret }, r.staged ? "Key staged" : "Key created"); + rerender(); + return true; + } catch (e) { mount(err, errorBox(e)); return false; } + } }, + ], { sticky: true }); +} + +async function reveal(k) { + try { + const full = await get("/tsig-keys/" + k.id + "?secret=1"); + await showSecret(full, "TSIG key " + k.name); + } catch (e) { toast(e.message, "err", 8000); } +} + +async function rotate(k) { + if (!(await confirmDialog("New secret", "Generate a new secret for " + k.name + "? Every server using the key must be updated with the new secret.", "Generate", "danger"))) return; + const secret = randomSecret(k.algorithm); + try { + const r = await change("PATCH", "/tsig-keys/" + k.id, { secret }); + toast(describeResult(r)); + await showSecret({ ...k, secret }, "New secret for " + k.name); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} + +async function del(k) { + if (!(await confirmDialog("Delete key", "Delete TSIG key " + k.name + "?", "Delete", "danger"))) return; + try { + const r = await change("DELETE", "/tsig-keys/" + k.id); + toast(describeResult(r)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} diff --git a/internal/web/static/assets/pages/options.js b/internal/web/static/assets/pages/options.js new file mode 100644 index 0000000..589fcb1 --- /dev/null +++ b/internal/web/static/assets/pages/options.js @@ -0,0 +1,155 @@ +import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js"; +import { get, change, describeResult } from "../api.js"; +import { rerender } from "../app.js"; +import { emailOf } from "./zones.js"; + +const catalogCache = {}; +async function catalog(scope) { + if (!catalogCache[scope]) catalogCache[scope] = await get("/catalog/options", { scope }); + return catalogCache[scope]; +} + +// valueWidget returns {el, get()} for editing a value of the given kind. +function valueWidget(stmt, value) { + const kind = stmt ? stmt.kind : "raw"; + if (kind === "bool") { + const s = select(["yes", "no"], value || "yes"); + return { el: s, get: () => s.value }; + } + if (kind === "enum") { + const s = select(stmt.choices, value || stmt.choices[0]); + return { el: s, get: () => s.value }; + } + if (kind === "address_match_list") { + const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, ""); + const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" }); + return { el: t, get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." }; + } + if (kind === "raw") { + const t = textarea(value || "", { class: "mono", rows: 3 }); + return { el: t, get: () => t.value.trim() }; + } + const i = input({ value: value || "", class: "mono" }); + return { el: i, get: () => i.value.trim() }; +} + +// optionsEditor renders the options of one scope. +export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) { + const [opts, cat] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope)]); + const byName = Object.fromEntries(cat.map((s) => [s.name, s])); + const err = h("div"); + const listEl = h("div"); + let editing = null; + const draw = () => { + mount(listEl, table([ + { label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" }, + { label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.value || "(no value)") }, + { label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" }, + { label: "", render: (o) => editing === o.id ? "" : h("span", null, + h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ", + h("button", { class: "small danger", onclick: () => del(o) }, "Delete")), tdClass: "actions-cell" }, + ], opts, { empty: "No options set; BIND's defaults apply." })); + }; + const editRow = (o) => { + const w = valueWidget(byName[o.name], o.value); + const e2 = h("div"); + return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null, + h("div", { class: "actions", style: { marginTop: "6px" } }, + h("button", { class: "primary small", onclick: async () => { + try { + const r = await change("PATCH", "/options/" + o.id, { value: w.get() }); + toast(describeResult(r)); + editing = null; + rerender(); + } catch (ex) { mount(e2, errorBox(ex)); } + } }, "Save"), + h("button", { class: "small", onclick: () => { editing = null; draw(); } }, "Cancel")), e2); + }; + const del = async (o) => { + if (!(await confirmDialog("Remove option", "Remove " + o.name + " (" + o.where + ")? BIND's default applies afterwards.", "Remove", "danger"))) return; + try { + const r = await change("DELETE", "/options/" + o.id); + toast(describeResult(r)); + rerender(); + } catch (ex) { toast(ex.message, "err", 8000); } + }; + // Add form + const listId = "opt-" + Math.random().toString(36).slice(2); + const nameIn = input({ list: listId, placeholder: "Start typing an option name…", class: "mono", style: { width: "280px" } }); + const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || ""))); + const info = h("div", { class: "hint" }); + const valueBox = h("div"); + let widget = valueWidget(null, ""); + const showInfo = () => { + const st = byName[nameIn.value.trim()]; + widget = valueWidget(st, ""); + mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null); + if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; } + mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar)); + }; + nameIn.addEventListener("input", showInfo); + showInfo(); + const add = async () => { + mount(err); + try { + const r = await change("POST", "/options", { scope, scope_id: scope_id || undefined, name: nameIn.value.trim(), value: widget.get() }); + toast(describeResult(r)); + rerender(); + } catch (ex) { mount(err, errorBox(ex)); } + }; + draw(); + mount(el, h("div", { class: "panel" }, + h("h2", null, title), + intro ? h("p", { class: "muted small" }, intro) : null, + listEl, + h("h3", null, "Add an option"), + h("div", { class: "form-grid" }, + h("label", null, "Name"), h("div", null, nameIn, dl, info), + h("label", null, "Value"), valueBox), + err, + h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: add }, "Add option")))); +} + +export async function render(el) { + const settings = await get("/settings"); + const settingsEl = h("div"); + const serverEl = h("div"); + const topEl = h("div"); + mount(el, + h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"), + h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))), + settingsEl, serverEl, topEl); + settingsForm(settingsEl, settings); + await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." }); + await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements", + intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." }); +} + +function settingsForm(el, s) { + const ns = textarea((s.nameservers || []).join("\n"), { class: "mono", rows: 2 }); + const rname = input({ value: s.rname ? emailOf(s.rname) : "", placeholder: "hostmaster@example.com" }); + const ttl = input({ type: "number", value: s.default_ttl }); + const refresh = input({ type: "number", value: s.refresh }); + const retry = input({ type: "number", value: s.retry }); + const expire = input({ type: "number", value: s.expire }); + const neg = input({ type: "number", value: s.negative_ttl }); + const policy = select(["date", "increment", "unixtime"], s.serial_policy); + const err = h("div"); + mount(el, h("div", { class: "panel" }, + h("h2", null, "Defaults for new zones"), + h("div", { class: "form-grid" }, + field("Name servers", ns, "One per line; used as NS records for new primary zones."), + field("SOA contact", rname, "Email address (or mailbox name) for new zones."), + field("Default TTL", ttl), + field("Refresh", refresh), field("Retry", retry), field("Expire", expire), + field("Negative TTL", neg, "SOA minimum."), + field("Serial policy", policy, "date: YYYYMMDDnn, increment: +1, unixtime: seconds since 1970.")), + err, + h("div", { class: "actions", style: { marginTop: "10px" } }, h("button", { class: "primary", onclick: async () => { + mount(err); + try { + const r = await change("PATCH", "/settings", { nameservers: lines(ns.value), rname: rname.value.trim(), default_ttl: +ttl.value, refresh: +refresh.value, retry: +retry.value, expire: +expire.value, negative_ttl: +neg.value, serial_policy: policy.value }); + toast(describeResult(r)); + } catch (e) { mount(err, errorBox(e)); } + } }, "Save defaults")))); +} diff --git a/internal/web/static/assets/pages/search.js b/internal/web/static/assets/pages/search.js new file mode 100644 index 0000000..e44b7e4 --- /dev/null +++ b/internal/web/static/assets/pages/search.js @@ -0,0 +1,19 @@ +import { h, mount, table, badge } from "../ui.js"; +import { get } from "../api.js"; + +export async function render(el, params, query) { + const q = query.get("q") || ""; + const results = q.length >= 2 ? await get("/search", { q }) : []; + const link = (r) => { + if (r.kind === "zone") return "#/zones/" + r.id; + if (r.kind === "record") return "#/zones/" + r.zone_id + "/records?q=" + encodeURIComponent(r.label.split(" ")[0]); + if (r.kind === "acl") return "#/acls"; + return "#/"; + }; + mount(el, h("h1", null, "Search: ", h("span", { class: "mono" }, q)), + h("div", { class: "panel" }, table([ + { label: "Kind", render: (r) => badge(r.kind) }, + { label: "Match", render: (r) => h("a", { href: link(r) }, r.label), tdClass: "data" }, + { label: "Where", render: (r) => h("span", { class: "muted" }, r.detail || "") }, + ], results, { empty: q.length < 2 ? "Type at least two characters." : "Nothing found." }))); +} diff --git a/internal/web/static/assets/pages/users.js b/internal/web/static/assets/pages/users.js new file mode 100644 index 0000000..3cd18c9 --- /dev/null +++ b/internal/web/static/assets/pages/users.js @@ -0,0 +1,151 @@ +import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, copyBox, badge } from "../ui.js"; +import { api, get, state } from "../api.js"; +import { rerender } from "../app.js"; + +export async function render(el) { + if (!state.me.admin) { + mount(el, h("h1", null, "Users"), h("div", { class: "msg warn" }, "Only administrators can manage users.")); + return; + } + const [users, grants] = await Promise.all([get("/users"), get("/catalog/grants")]); + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "Users"), + h("div", { class: "muted" }, "People log in to the web UI with a password; scripts and the CLI use API tokens. Each token belongs to a user and can be restricted to a subset of that user's permissions.")), + h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, grants) }, "New user"))), + h("div", { class: "panel" }, table([ + { label: "User", render: (u) => h("strong", null, u.username) }, + { label: "Role", render: (u) => [u.admin ? badge("admin", "accent") : null, u.disabled ? badge("disabled", "err") : null, !u.has_password ? badge("tokens only") : null] }, + { label: "Permissions", render: (u) => u.admin ? h("span", { class: "muted" }, "everything") : h("div", { class: "grants" }, (u.grant_strings || []).map((g) => h("div", null, g))), tdClass: "small" }, + { label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") }, + { label: "", render: (u) => h("span", null, + h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ", + h("button", { class: "small", onclick: () => userDialog(u, grants) }, "Edit"), " ", + h("button", { class: "small danger", disabled: u.id === state.me.user.id, onclick: () => del(u) }, "Delete")), tdClass: "actions-cell" }, + ], users)), + grantHelp(grants)); +} + +export function grantHelp(grants) { + return h("div", { class: "panel" }, + h("h2", null, "Permissions"), + h("p", { class: "small" }, "A grant is ", h("code", null, grants.syntax), ". Write includes read. Filters are patterns where * matches anything, e.g. ", h("code", null, "names=_acme-challenge.*"), ". Zone names without the trailing dot; zones outside views are in view _default."), + h("p", { class: "small" }, "Resources: ", grants.resources.join(", "), ", or * for all. Users management is for administrators only."), + h("h3", null, "Templates"), + h("div", { class: "grants" }, Object.entries(grants.roles).map(([name, gs]) => h("div", null, h("strong", null, name + ": "), gs.join("; ")))), + h("h3", null, "Examples"), + h("pre", null, "records:write zones=adbraun.lol names=_acme-challenge.* types=TXT # ACME DNS-01 only\nzones:write zones=*.lab.adbraun.lol # lab subzones\nrecords:write views=internal # internal view only\n*:read # read-only")); +} + +async function userDialog(u, grants) { + const err = h("div"); + const name = input({ value: u ? u.username : "", autocomplete: "off" }); + const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "leave empty to keep" : "at least 8 characters" }); + const admin = checkbox("Administrator (can do everything, including managing users)", u ? u.admin : false); + const disabled = checkbox("Disabled (can't log in, tokens stop working)", u ? u.disabled : false); + const service = checkbox("Service account: no password, API tokens only", u ? !u.has_password : false); + const gr = textarea(u ? (u.grant_strings || []).join("\n") : "", { class: "mono", rows: 5, placeholder: "records:write zones=example.com\n*:read" }); + const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], ""); + tmpl.addEventListener("change", () => { + if (!tmpl.value) return; + gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n"); + tmpl.value = ""; + }); + const comment = input({ value: u ? u.comment : "" }); + await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" }, + field("Username", name), + field("Password", pw), + h("span"), service, + h("span"), admin, + u ? h("span") : null, u ? disabled : null, + field("Permissions", gr, "One grant per line (ignored for administrators)."), + h("span"), tmpl, + field("Comment", comment)), err), [ + { label: "Cancel", value: null }, + { label: u ? "Save" : "Create user", kind: "primary", onclick: async () => { + const data = { username: name.value.trim(), admin: admin.input.checked, grant_strings: lines(gr.value), comment: comment.value }; + if (service.input.checked) data.password = ""; + else if (pw.value) data.password = pw.value; + if (u) data.disabled = disabled.input.checked; + try { + if (u) await api("PATCH", "/users/" + u.id, { body: data }); + else await api("POST", "/users", { body: data }); + toast(u ? "User updated" : "User created"); + rerender(); + return true; + } catch (e) { mount(err, errorBox(e)); return false; } + } }, + ], { wide: true, sticky: true }); +} + +async function del(u) { + if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return; + try { + await api("DELETE", "/users/" + u.id); + toast("User deleted"); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} + +// tokensDialog manages a user's tokens (also used by the account page). +export async function tokensDialog(u, grants, own) { + const box = h("div"); + const base = own ? "/me/tokens" : "/users/" + u.id + "/tokens"; + const draw = async () => { + const tokens = await get(base); + mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw)); + }; + await draw(); + await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true }); +} + +export function tokenTable(tokens, redraw) { + return table([ + { label: "Name", render: (t) => h("strong", null, t.name) }, + { label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" }, + { label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" }, + { label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") }, + { label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") }, + { label: "", render: (t) => h("button", { class: "small danger", onclick: async () => { + if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return; + await api("DELETE", "/tokens/" + t.id); + toast("Token revoked"); + redraw(); + } }, "Revoke"), tdClass: "actions-cell" }, + ], tokens, { empty: "No tokens." }); +} + +export function newTokenForm(base, grants, redraw) { + const err = h("div"); + const out = h("div"); + const name = input({ placeholder: "e.g. certbot on web01" }); + const exp = select([["", "never expires"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "1 year"]], "90d"); + const gr = textarea("", { class: "mono", rows: 3, placeholder: "optional restriction, e.g.\nrecords:write zones=example.com names=_acme-challenge.* types=TXT" }); + const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], ""); + tmpl.addEventListener("change", () => { + if (!tmpl.value) return; + gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n"); + tmpl.value = ""; + }); + return h("div", { class: "panel", style: { marginTop: "12px" } }, + h("h3", null, "New token"), + h("div", { class: "form-grid" }, + field("Name", name), + field("Expires", exp), + field("Restrict to", gr, "Leave empty for all of the user's permissions. A token can never do more than its user."), + h("span"), tmpl), + err, + h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => { + mount(err); mount(out); + const body = { name: name.value.trim(), expires_in: exp.value || undefined }; + const g = lines(gr.value); + if (g.length) body.grant_strings = g; + try { + const t = await api("POST", base, { body }); + mount(out, h("div", { class: "msg ok" }, h("p", null, "Token created. Copy it now: it is not shown again."), copyBox(t.token))); + name.value = ""; gr.value = ""; + if (redraw) setTimeout(redraw, 15000); + } catch (e) { mount(err, errorBox(e)); } + } }, "Create token")), + out); +} diff --git a/internal/web/static/assets/pages/views.js b/internal/web/static/assets/pages/views.js new file mode 100644 index 0000000..c55d734 --- /dev/null +++ b/internal/web/static/assets/pages/views.js @@ -0,0 +1,90 @@ +import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js"; +import { get, change, describeResult } from "../api.js"; +import { rerender } from "../app.js"; +import { optionsEditor } from "./options.js"; + +export async function render(el, params, query) { + const [views, zones] = await Promise.all([get("/views"), get("/zones")]); + const viewless = zones.filter((z) => !z.view_id); + const optsEl = h("div"); + const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null; + const move = async (i, d) => { + const ids = views.map((v) => v.id); + [ids[i], ids[i + d]] = [ids[i + d], ids[i]]; + try { + const r = await change("POST", "/views/order", { ids }); + toast(describeResult(r)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } + }; + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "Views"), + h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")), + h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(null, viewless) }, "New view"))), + views.length === 0 ? h("div", { class: "msg info" }, + "No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null, + h("div", { class: "panel" }, table([ + { label: "#", render: (v, i) => String(i + 1) }, + { label: "View", render: (v) => h("a", { href: "#/views?view=" + v.id }, h("strong", null, v.name)) }, + { label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" }, + { label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" }, + { label: "Zones", render: (v) => h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, String(v.zone_count)) }, + { label: "Comment", render: (v) => h("span", { class: "muted" }, v.comment || "") }, + { label: "", render: (v, i) => h("span", null, + h("button", { class: "small", disabled: i === 0, title: "Move up", onclick: () => move(i, -1) }, "↑"), " ", + h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓"), " ", + h("button", { class: "small", onclick: () => viewDialog(v, viewless) }, "Edit"), " ", + h("button", { class: "small danger", onclick: () => deleteView(v) }, "Delete")), tdClass: "actions-cell" }, + ], views, { empty: "No views." })), + selected ? optsEl : (views.length ? h("p", { class: "muted" }, "Select a view to see its options.") : null)); + if (selected) await optionsEditor(optsEl, { scope: "view", scope_id: selected.id, catalogScope: "view", title: "Options of view " + selected.name, + intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." }); +} + +async function viewDialog(v, viewless) { + const err = h("div"); + const name = input({ value: v ? v.name : "", placeholder: "internal" }); + const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" }); + const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" }); + const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : ""); + const comment = input({ value: v ? v.comment : "" }); + const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true); + const body = h("div", null, h("div", { class: "form-grid" }, + field("Name", name), + field("Match clients", mc, "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."), + field("Match destinations", md, "Server addresses this view answers on (optional)."), + field("Recursive only", mro, "If yes, only recursive queries match this view."), + field("Comment", comment)), + !v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null, + err); + await modal(v ? "Edit view " + v.name : "New view", body, [ + { label: "Cancel", value: null }, + { label: v ? "Save" : "Create view", kind: "primary", onclick: async () => { + const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value }; + data.match_recursive_only = mro.value === "" ? null : mro.value === "true"; + if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true; + try { + const r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data); + toast(describeResult(r)); + rerender(); + return true; + } catch (e) { mount(err, errorBox(e)); return false; } + } }, + ], { wide: true, sticky: true }); +} + +async function deleteView(v) { + let cascade = false; + if (v.zone_count > 0) { + const c = checkbox("Also delete its " + v.zone_count + " zone(s) and their records", false); + const ok = await modal("Delete view " + v.name, h("div", null, h("p", null, "The view contains zones."), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]); + if (ok !== true) return; + cascade = c.input.checked; + } else if (!(await confirmDialog("Delete view", "Delete view " + v.name + "?", "Delete", "danger"))) return; + try { + const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined }); + toast(describeResult(r)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js new file mode 100644 index 0000000..b1e5d35 --- /dev/null +++ b/internal/web/static/assets/pages/zone.js @@ -0,0 +1,290 @@ +import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView } from "../ui.js"; +import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js"; +import { rerender } from "../app.js"; +import { optionsEditor } from "./options.js"; +import { emailOf, pendingBadge } from "./zones.js"; + +export async function render(el, params, query) { + const id = params.id; + const zone = await get("/zones/" + id, draftQuery()); + const tab = params.tab || (zone.type === "primary" ? "records" : "settings"); + const tabs = [ + zone.type === "primary" ? ["records", "Records (" + zone.record_count + ")"] : null, + ["settings", "Settings"], + ["options", "Options"], + zone.type === "primary" ? ["file", "Zone file"] : null, + ].filter(Boolean); + const body = h("div"); + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, + h("h1", { class: "mono" }, zone.display_name), + h("div", { class: "muted" }, + badge(zone.type, "accent"), " ", + zone.pending ? [pendingBadge(zone.pending), " "] : null, + zone.view ? ["view ", h("a", { href: "#/views" }, zone.view), " · "] : null, + zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null, + zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)), + h("div", { class: "actions" }, + h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))), + h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))), + body); + if (tab === "records") await recordsTab(body, zone, query); + else if (tab === "settings") await settingsTab(body, zone); + else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" }); + else if (tab === "file") await fileTab(body, zone); +} + +async function deleteZone(zone) { + const msg = zone.type === "primary" + ? "Delete zone " + zone.display_name + " and its " + zone.record_count + " records? named stops serving it once applied." + : "Delete zone " + zone.display_name + "?"; + if (!(await confirmDialog("Delete zone", msg, "Delete zone", "danger"))) return; + try { + const r = await change("DELETE", "/zones/" + zone.id); + toast(describeResult(r)); + location.hash = "#/zones"; + } catch (e) { toast(e.message, "err", 8000); } +} + +// ---- records + +async function recordsTab(el, zone, query) { + const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]); + const typeInfo = Object.fromEntries(types.map((t) => [t.type, t])); + const err = h("div"); + // Add form + const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" }); + const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } }); + const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } }); + const fData = input({ class: "mono", placeholder: typeInfo.A.example }); + const fComment = input({ placeholder: "optional", style: { width: "160px" } }); + const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" }); + const hint = h("div", { class: "hint" }); + const showHint = () => { + const t = typeInfo[fType.value] || {}; + fData.placeholder = t.example || t.format || ""; + mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null); + fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA")); + }; + fType.addEventListener("change", showHint); + showHint(); + const add = async (e) => { + e.preventDefault(); + mount(err); + const body = { name: fName.value.trim() || "@", type: fType.value, data: fData.value.trim() }; + if (fTTL.value !== "") body.ttl = Number(fTTL.value); + if (fComment.value.trim()) body.comment = fComment.value.trim(); + if (fPTR.input.checked) body.ptr = true; + try { + const r = await change("POST", "/zones/" + zone.id + "/records", body); + toast(describeResult(r)); + (r.warnings || []).forEach((w) => toast(w, "warn", 8000)); + fData.value = ""; + fComment.value = ""; + await redraw(); + fData.focus(); + } catch (ex) { + mount(err, errorBox(ex)); + } + }; + const form = h("form", { class: "inline-form", onsubmit: add }, + h("div", { class: "f" }, h("label", null, "Name"), fName), + h("div", { class: "f" }, h("label", null, "Type"), fType), + h("div", { class: "f" }, h("label", null, "TTL"), fTTL), + h("div", { class: "f grow" }, h("label", null, "Data"), fData), + h("div", { class: "f" }, h("label", null, "Comment"), fComment), + h("div", { class: "f" }, h("label", null, " "), fPTR), + h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record"))); + + // Filters and table + const filter = input({ type: "search", placeholder: "Filter by name or data…", value: query.get("q") || "" }); + const typeFilter = select([["", "All types"], ...[...new Set(records.map((r) => r.type))].sort()], ""); + const list = h("div"); + let editing = null; + let current = records; + const redraw = async () => { + current = await get("/zones/" + zone.id + "/records", draftQuery()); + draw(); + }; + const draw = () => { + const q = filter.value.trim().toLowerCase(); + const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value)); + let prevName = null; + mount(list, table([ + { label: "Name", render: (r) => r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } }, + { label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" }, + { label: "Type", render: (r) => h("strong", null, r.type) }, + { label: "Data", render: (r) => r.data, tdClass: "data" }, + { label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] }, + { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, + h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ", + h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" }, + ], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" })); + if (editing) mountEdit(); + }; + const del = async (r) => { + const isAddr = r.type === "A" || r.type === "AAAA"; + let ptr = false; + if (isAddr) { + const c = checkbox("Also delete the matching PTR record", true); + const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]); + if (ok !== true) return; + ptr = c.input.checked; + } else if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data, "Delete", "danger"))) return; + try { + const res = await change("DELETE", "/records/" + r.id, undefined, { query: ptr ? { ptr: 1 } : undefined }); + toast(describeResult(res)); + await redraw(); + } catch (e) { toast(e.message, "err", 8000); } + }; + const edit = (r) => { editing = r; draw(); }; + const mountEdit = () => { + const r = editing; + const eName = input({ value: r.relative_name, class: "mono", style: { width: "100%" } }); + const eType = select(types.map((t) => t.type), r.type); + const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } }); + const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } }); + const eComment = input({ value: r.comment || "", style: { width: "100%" } }); + const ePTR = checkbox("update PTR", false); + const eErr = h("div"); + const save = async () => { + const body = {}; + const name = eName.value.trim() || "@"; + if (name !== r.relative_name) body.name = name; + if (eType.value !== r.type) body.type = eType.value; + const shown = r.type === "TXT" && r.text !== undefined ? r.text : r.data; + if (eData.value.trim() !== shown || body.type) body.data = eData.value.trim(); + if (eTTL.value === "" && r.ttl !== undefined && r.ttl !== null) body.ttl = null; + else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value); + if (eComment.value !== (r.comment || "")) body.comment = eComment.value; + if (ePTR.input.checked) body.ptr = true; + if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.ptr)) { editing = null; draw(); return; } + try { + const res = await change("PATCH", "/records/" + r.id, body); + toast(describeResult(res)); + (res.warnings || []).forEach((w) => toast(w, "warn", 8000)); + editing = null; + await redraw(); + } catch (e) { mount(eErr, errorBox(e)); } + }; + const row = h("div", { class: "panel", style: { background: "var(--accent-soft)" } }, + h("h3", null, "Edit record #" + r.id), + h("div", { class: "inline-form" }, + h("div", { class: "f" }, h("label", null, "Name"), eName), + h("div", { class: "f" }, h("label", null, "Type"), eType), + h("div", { class: "f" }, h("label", null, "TTL"), eTTL), + h("div", { class: "f grow" }, h("label", null, "Data"), eData), + h("div", { class: "f" }, h("label", null, "Comment"), eComment), + (r.type === "A" || r.type === "AAAA") ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null, + h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))), + h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."), + eErr); + list.prepend(row); + eData.focus(); + }; + filter.addEventListener("input", draw); + typeFilter.addEventListener("change", draw); + mount(el, + h("div", { class: "panel" }, form, hint, err), + h("div", { class: "panel" }, + h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter, + h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute.")), + list)); + draw(); + fName.focus(); +} + +// ---- settings + +async function settingsTab(el, zone) { + const views = await get("/views"); + const err = h("div"); + const rows = []; + const s = zone.soa || {}; + const mname = input({ value: display(s.mname), class: "mono" }); + const rname = input({ value: zone.rname_email || emailOf(s.rname) }); + const refresh = input({ type: "number", value: s.refresh }); + const retry = input({ type: "number", value: s.retry }); + const expire = input({ type: "number", value: s.expire }); + const minimum = input({ type: "number", value: s.minimum }); + const ttl = input({ type: "number", value: zone.default_ttl }); + const policy = select([["", "(server default)"], "date", "increment", "unixtime"], zone.serial_policy || ""); + const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || "")); + const file = input({ value: zone.file || "", class: "mono" }); + const comment = textarea(zone.comment, { rows: 2 }); + if (zone.type === "primary") { + rows.push( + field("Primary name server", mname, "SOA MNAME."), + field("Contact", rname, "SOA RNAME, as an email address."), + field("Refresh", refresh, "Seconds between secondaries checking for updates."), + field("Retry", retry, "Seconds before a failed refresh is retried."), + field("Expire", expire, "Seconds after which secondaries stop answering without a successful refresh."), + field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."), + field("Default TTL", ttl, "TTL for records that don't set their own."), + field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes.")); + } + if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones).")); + if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it.")); + rows.push(field("Comment", comment)); + const save = async () => { + mount(err); + const body = { comment: comment.value }; + if (zone.type === "primary") { + body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value }; + body.default_ttl = +ttl.value; + body.serial_policy = policy.value; + } + if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim(); + if (views.length) body.view_id = view.value ? Number(view.value) : 0; + try { + const r = await change("PATCH", "/zones/" + zone.id, body); + toast(describeResult(r)); + rerender(); + } catch (e) { mount(err, errorBox(e)); } + }; + mount(el, h("div", { class: "panel" }, h("div", { class: "form-grid" }, rows), err, + h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings")))); +} + +// ---- zone file + +async function fileTab(el, zone) { + const text = await get("/zones/" + zone.id + "/file", draftQuery()); + const imp = textarea("", { class: "mono", rows: 10, placeholder: "$TTL 3600\nwww IN A 192.0.2.10\n..." }); + const mode = select([["merge", "merge: add records that are missing"], ["replace", "replace: make the zone equal to this text"]], "merge"); + const useSOA = checkbox("Also take SOA timers and $TTL from the text", false); + const out = h("div"); + const run = async (dry) => { + mount(out); + try { + const body = { text: imp.value, mode: mode.value, use_soa: useSOA.input.checked, dry_run: dry }; + const r = dry ? await get_dry(zone, body) : await change("POST", "/zones/" + zone.id + "/import", body); + if (dry) { + mount(out, h("h3", null, "Preview"), diffViewOf(r)); + } else { + toast(describeResult(r)); + (r.warnings || []).forEach((w) => toast(w, "warn", 8000)); + rerender(); + } + } catch (e) { mount(out, errorBox(e)); } + }; + mount(el, + h("div", { class: "panel" }, h("h2", null, "Generated zone file"), h("p", { class: "muted small" }, state.draft && !state.immediate ? "What Jarl will write for named once the pending changes are applied." : "This is exactly what Jarl writes for named."), h("pre", null, text)), + h("div", { class: "panel" }, h("h2", null, "Import records from zone file text"), + h("p", { class: "muted small" }, "Paste records in zone file format. Names are relative to " + zone.display_name + ". The SOA record is ignored unless you tick the box."), + imp, h("div", { class: "actions", style: { marginTop: "8px" } }, mode, useSOA, + h("button", { onclick: () => run(true) }, "Preview"), + h("button", { class: "primary", onclick: () => run(false) }, "Import")), + out)); +} + +async function get_dry(zone, body) { + return api("POST", "/zones/" + zone.id + "/import", { body: { ...body, dry_run: true } }); +} + +function diffViewOf(r) { + const res = r && r.result; + if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import."); + return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff)); +} diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js new file mode 100644 index 0000000..b929bc3 --- /dev/null +++ b/internal/web/static/assets/pages/zones.js @@ -0,0 +1,137 @@ +import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js"; +import { get, change, describeResult, can, draftQuery } from "../api.js"; +import { rerender } from "../app.js"; + +export async function render(el, params, query) { + const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]); + const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" }); + const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || ""); + const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || ""); + const list = h("div"); + const draw = () => { + const q = filter.value.trim().toLowerCase(); + const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value)); + mount(list, table([ + { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" }, + views.length ? { label: "View", render: (z) => z.view || h("span", { class: "muted" }, "–") } : null, + { label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") }, + { label: "Records", render: (z) => z.type === "primary" ? String(z.record_count) : "", class: "right", tdClass: "right" }, + { label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" }, + { label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") }, + ].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" })); + }; + filter.addEventListener("input", draw); + viewSel.addEventListener("change", draw); + typeSel.addEventListener("change", draw); + mount(el, + h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")), + h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel, + can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)), + h("div", { class: "panel" }, list)); + draw(); + if (query.get("new")) newZoneDialog(views, zones, settings); +} + +export async function newZoneDialog(views, zones, settings) { + const err = h("div"); + const name = input({ placeholder: "example.com or 192.168.1.0/24" }); + const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary"); + const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : ""); + const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." }); + const nsAddr = textarea("", { class: "mono", rows: 2, placeholder: "ns1 192.0.2.1" }); + const contact = input({ value: settings.rname ? emailOf(settings.rname) : "", placeholder: "hostmaster@example.com" }); + const ttl = input({ type: "number", value: settings.default_ttl || 3600, min: 0 }); + const primaries = textarea("", { class: "mono", rows: 2, placeholder: "192.0.2.53\n2001:db8::53 key xfer-key" }); + const forwarders = textarea("", { class: "mono", rows: 2, placeholder: "192.168.1.1\n127.0.0.1 port 8600" }); + const forwardMode = select(["only", "first"], "only"); + const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" }); + const inView = select(views.map((v) => [v.name, v.name]), ""); + const cloneSel = select([["", "– don't copy –"]], ""); + const comment = input(); + const updateClones = () => { + const n = name.value.trim().toLowerCase().replace(/\.$/, "") + "."; + mount(cloneSel, h("option", { value: "" }, "– don't copy –"), + zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]"))); + }; + name.addEventListener("input", updateClones); + const rows = { + primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."), + field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."), + field("SOA contact", contact, "Email address of the zone's administrator."), + field("Default TTL", ttl, "Seconds, used by records without their own TTL."), + field("Copy records from", cloneSel, "For split-horizon setups: start with the records of this zone in another view.")], + secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")], + stub: [field("Primary servers", primaries, "One per line.")], + mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")], + forward: [field("Forwarders", forwarders, "One per line: address [port N]. Leave empty to disable forwarding for this domain."), field("Forward", forwardMode, "only: never resolve these names directly; first: try the forwarders, then resolve normally.")], + "static-stub": [field("Server addresses", primaries, "One per line.")], + hint: [field("File", file, "Root hints file, e.g. /usr/share/dns/root.hints.")], + "in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")], + }; + const typeRows = h("div", { class: "form-grid" }); + const drawType = () => mount(typeRows, rows[type.value] || []); + type.addEventListener("change", drawType); + drawType(); + const body = h("div", null, + h("div", { class: "form-grid" }, + field("Name", name, "A domain name, or a network such as 192.168.1.0/24 to create its reverse zone."), + views.length ? field("View", view) : null, + field("Type", type), + field("Comment", comment)), + h("div", { style: { marginTop: "8px" } }, typeRows), + err); + await modal("New zone", body, [ + { label: "Cancel", value: null }, + { label: "Create zone", kind: "primary", onclick: async () => { + const t = type.value; + const data = { name: name.value.trim(), type: t, comment: comment.value || undefined }; + if (view.value) data.view = view.value; + const opts = []; + const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" "); + if (t === "primary") { + data.nameservers = lines(ns.value); + if (contact.value.trim()) data.soa = { rname: contact.value.trim() }; + data.default_ttl = Number(ttl.value) || undefined; + if (cloneSel.value) { data.clone_from = Number(cloneSel.value); delete data.nameservers; } + data.records = lines(nsAddr.value).map((l) => { + const [n, a] = l.split(/\s+/); + return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a }; + }); + } else if (t === "secondary" || t === "stub" || t === "mirror") { + if (lines(primaries.value).length) opts.push({ name: "primaries", value: "{ " + list(primaries) + " }" }); + } else if (t === "static-stub") { + opts.push({ name: "server-addresses", value: "{ " + list(primaries) + " }" }); + } else if (t === "forward") { + opts.push({ name: "forwarders", value: "{ " + list(forwarders) + " }" }); + opts.push({ name: "forward", value: forwardMode.value }); + } else if (t === "hint") { + data.file = file.value.trim(); + } else if (t === "in-view") { + data.in_view = inView.value; + } + if (opts.length) data.options = opts; + try { + const r = await change("POST", "/zones", data); + toast(describeResult(r)); + rerender(); + return true; + } catch (e) { + mount(err, errorBox(e)); + return false; + } + } }, + ], { wide: true, sticky: true }); +} + +export function emailOf(rname) { + if (!rname) return ""; + const s = rname.replace(/\.$/, ""); + const m = s.match(/^((?:[^.\\]|\\.)+)\.(.+)$/); + if (!m) return s; + return m[1].replace(/\\\./g, ".") + "@" + m[2]; +} + +export function pendingBadge(kind) { + return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete" }[kind] || kind, kind === "delete" ? "err" : "warn"); +} diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css new file mode 100644 index 0000000..1cf9587 --- /dev/null +++ b/internal/web/static/assets/style.css @@ -0,0 +1,234 @@ +/* Jarl web UI. Plain CSS, light and dark themes. */ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --text: #1d2330; + --muted: #5f6b7d; + --border: #d9dee6; + --accent: #2f5fb3; + --accent-text: #ffffff; + --accent-soft: #e5edfa; + --ok: #1e7a46; + --ok-soft: #e3f4ea; + --warn: #8a5a00; + --warn-soft: #fdf2d9; + --err: #b3261e; + --err-soft: #fbe7e5; + --info-soft: #eef1f6; + --code-bg: #f0f2f5; + --add: #e6f6ea; + --add-text: #135c2c; + --del: #fde8e7; + --del-text: #8c1d18; + --hunk: #e8eefb; + --shadow: 0 1px 2px rgba(20, 30, 50, 0.08); + --radius: 6px; + --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #14171c; + --panel: #1c2027; + --text: #e3e7ee; + --muted: #9aa5b5; + --border: #2f3540; + --accent: #6d9be8; + --accent-text: #0f1520; + --accent-soft: #23314a; + --ok: #59c287; + --ok-soft: #173326; + --warn: #e5b450; + --warn-soft: #3a2f15; + --err: #f08077; + --err-soft: #3c1f1d; + --info-soft: #232833; + --code-bg: #242933; + --add: #183a25; + --add-text: #9fe3b5; + --del: #43201e; + --del-text: #f7b2ac; + --hunk: #23314a; + --shadow: none; + color-scheme: dark; + } +} +:root[data-theme="dark"] { + --bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540; + --accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326; + --warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833; + --code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a; + --shadow: none; color-scheme: dark; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre, .mono, textarea.mono, input.mono { font-family: var(--mono); font-size: 13px; } +pre { background: var(--code-bg); padding: 10px 12px; border-radius: var(--radius); overflow: auto; margin: 0; white-space: pre; } +h1 { font-size: 20px; margin: 0 0 4px; } +h2 { font-size: 16px; margin: 18px 0 8px; } +h3 { font-size: 14px; margin: 14px 0 6px; } +.muted { color: var(--muted); } +.small { font-size: 12px; } +.nowrap { white-space: nowrap; } +.right { text-align: right; } +.hidden { display: none !important; } + +/* Layout */ +header.top { + display: flex; align-items: center; gap: 16px; flex-wrap: wrap; + background: var(--panel); border-bottom: 1px solid var(--border); + padding: 8px 16px; position: sticky; top: 0; z-index: 10; +} +header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); } +header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; } +nav.main { display: flex; gap: 2px; flex-wrap: wrap; } +nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); } +nav.main a:hover { background: var(--info-soft); text-decoration: none; } +nav.main a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +header.top .spacer { flex: 1; } +header.top form.search input { width: 220px; } +header.top .user { color: var(--muted); } +main { padding: 16px; max-width: 1400px; margin: 0 auto; } +.pending-bar { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--border); + padding: 8px 16px; +} +.pending-bar strong { color: var(--warn); } +.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; } +.page-head .grow { flex: 1; min-width: 200px; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +/* Panels and cards */ +.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; } +.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; } +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-bottom: 14px; } +.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); } +.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; } +.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; } +.card .sub { color: var(--muted); font-size: 12px; margin-top: 4px; } + +/* Tables */ +.table-wrap { overflow-x: auto; } +table { border-collapse: collapse; width: 100%; background: var(--panel); } +th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } +th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; } +tr:hover td { background: var(--info-soft); } +td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; } +td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; } +td.name.repeat { color: var(--muted); } +td.actions-cell { white-space: nowrap; text-align: right; width: 1%; } +tr.editing td { background: var(--accent-soft); } +tr.pending-add td { background: var(--add); } +tr.pending-change td { background: var(--warn-soft); } +tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); } +.empty { color: var(--muted); padding: 18px; text-align: center; } + +/* Forms */ +input, select, textarea, button { + font: inherit; color: var(--text); +} +input[type=text], input[type=password], input[type=number], input[type=search], input:not([type]), select, textarea { + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; min-width: 0; +} +input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); } +textarea { width: 100%; min-height: 80px; resize: vertical; } +.form-grid { display: grid; grid-template-columns: minmax(140px, 200px) 1fr; gap: 8px 14px; align-items: start; } +.form-grid > label { padding-top: 6px; color: var(--muted); } +.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; } +.form-grid input, .form-grid select { width: 100%; max-width: 560px; } +.inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; } +.inline-form .f { display: flex; flex-direction: column; gap: 2px; } +.inline-form .f label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; } +.inline-form .grow { flex: 1; min-width: 220px; } +.inline-form .grow input { width: 100%; } +.hint { color: var(--muted); font-size: 12px; margin-top: 4px; } +.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; } + +button, .button { + display: inline-flex; align-items: center; gap: 6px; + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + padding: 5px 12px; cursor: pointer; white-space: nowrap; color: var(--text); +} +button:hover, .button:hover { background: var(--info-soft); text-decoration: none; } +button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; } +button.primary:hover { filter: brightness(1.08); background: var(--accent); } +button.danger { color: var(--err); border-color: var(--err); } +button.danger:hover { background: var(--err-soft); } +button.small, .button.small { padding: 2px 8px; font-size: 12px; } +button.link { border: none; background: none; color: var(--accent); padding: 0 4px; } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Badges and messages */ +.badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; background: var(--info-soft); color: var(--muted); white-space: nowrap; } +.badge.ok { background: var(--ok-soft); color: var(--ok); } +.badge.warn { background: var(--warn-soft); color: var(--warn); } +.badge.err { background: var(--err-soft); color: var(--err); } +.badge.accent { background: var(--accent-soft); color: var(--accent); } +.msg { border-radius: var(--radius); padding: 8px 12px; margin: 8px 0; border: 1px solid transparent; } +.msg.err { background: var(--err-soft); color: var(--err); border-color: var(--err); } +.msg.warn { background: var(--warn-soft); color: var(--warn); } +.msg.ok { background: var(--ok-soft); color: var(--ok); } +.msg.info { background: var(--info-soft); } +.msg ul { margin: 4px 0 0; padding-left: 18px; } +.problems { list-style: none; padding: 0; margin: 6px 0; } +.problems li { padding: 4px 8px; border-left: 3px solid var(--border); margin-bottom: 3px; background: var(--info-soft); border-radius: 0 var(--radius) var(--radius) 0; } +.problems li.error { border-color: var(--err); background: var(--err-soft); } +.problems li.warning { border-color: var(--warn); background: var(--warn-soft); } +.problems li .where { color: var(--muted); font-size: 12px; margin-left: 6px; } + +/* Diff */ +.diff { background: var(--code-bg); border-radius: var(--radius); overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.4; } +.diff div { white-space: pre; padding: 0 10px; } +.diff .add { background: var(--add); color: var(--add-text); } +.diff .del { background: var(--del); color: var(--del-text); } +.diff .hunk { background: var(--hunk); color: var(--accent); } +.diff .file { font-weight: 700; padding-top: 6px; } + +/* Tabs */ +.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 12px; flex-wrap: wrap; } +.tabs a { padding: 7px 12px; border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; color: var(--muted); } +.tabs a.active { background: var(--panel); border-color: var(--border); color: var(--text); font-weight: 600; margin-bottom: -1px; } + +/* Modal */ +.modal-back { position: fixed; inset: 0; background: rgba(10, 15, 25, 0.45); display: flex; align-items: flex-start; justify-content: center; padding: 60px 16px; z-index: 50; overflow: auto; } +.modal { background: var(--panel); border-radius: 8px; border: 1px solid var(--border); width: min(760px, 100%); padding: 18px 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.25); } +.modal.wide { width: min(1100px, 100%); } +.modal h2 { margin-top: 0; } +.modal .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } + +/* Toasts */ +#toasts { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; max-width: min(480px, calc(100% - 32px)); } +.toast { background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent); padding: 10px 14px; border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.18); } +.toast.ok { border-left-color: var(--ok); } +.toast.err { border-left-color: var(--err); } +.toast.warn { border-left-color: var(--warn); } + +/* Login */ +.login { max-width: 360px; margin: 10vh auto; } +.login h1 { font-size: 26px; text-align: center; margin-bottom: 4px; } +.login .tag { text-align: center; color: var(--muted); margin-bottom: 18px; } +.login form { display: flex; flex-direction: column; gap: 10px; } +.login input { width: 100%; padding: 8px 10px; } +.login button { justify-content: center; padding: 8px; } + +.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; } +.grants { font-family: var(--mono); font-size: 12px; } +.copy-box { display: flex; gap: 6px; align-items: center; } +.copy-box input { flex: 1; font-family: var(--mono); } +.sortable-row { display: flex; align-items: center; gap: 8px; } +@media (max-width: 700px) { + .form-grid { grid-template-columns: 1fr; } + .form-grid .help { grid-column: 1; } + header.top form.search input { width: 140px; } +} diff --git a/internal/web/static/assets/ui.js b/internal/web/static/assets/ui.js new file mode 100644 index 0000000..858477d --- /dev/null +++ b/internal/web/static/assets/ui.js @@ -0,0 +1,242 @@ +// Small DOM helpers and widgets used by every page. + +// h creates an element. attrs may contain class, style, on +// handlers, dataset, value/checked/disabled and any other attribute. +export function h(tag, attrs, ...children) { + const el = document.createElement(tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + if (v === undefined || v === null || v === false) continue; + if (k === "class") el.className = v; + else if (k === "style" && typeof v === "object") Object.assign(el.style, v); + else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v); + else if (k === "dataset") Object.assign(el.dataset, v); + else if (k === "value") el.value = v; + else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v; + else el.setAttribute(k, v === true ? "" : v); + } + } + append(el, children); + return el; +} + +function append(el, children) { + for (const c of children.flat(Infinity)) { + if (c === null || c === undefined || c === false) continue; + el.append(c instanceof Node ? c : document.createTextNode(String(c))); + } +} + +export function clear(el) { + while (el.firstChild) el.removeChild(el.firstChild); + return el; +} + +export function mount(el, ...children) { + clear(el); + append(el, children); + return el; +} + +// ---- formatting + +export function fmtTime(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d)) return iso; + return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" }); +} + +export function relTime(iso) { + if (!iso) return ""; + const s = (Date.now() - new Date(iso).getTime()) / 1000; + if (s < 60) return "just now"; + if (s < 3600) return Math.floor(s / 60) + " min ago"; + if (s < 86400) return Math.floor(s / 3600) + " h ago"; + if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago"; + return fmtTime(iso); +} + +export function time(iso) { + return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso)); +} + +export function display(name) { + if (!name || name === ".") return "."; + return name.endsWith(".") ? name.slice(0, -1) : name; +} + +export function plural(n, word, words) { + return n + " " + (n === 1 ? word : (words || word + "s")); +} + +export function badge(text, kind) { + return h("span", { class: "badge" + (kind ? " " + kind : "") }, text); +} + +// ---- toasts + +export function toast(message, kind = "ok", ms = 3500) { + const box = document.getElementById("toasts"); + const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message); + // A repeated message replaces the earlier one instead of stacking. + for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove(); + box.append(t); + while (box.children.length > 3) box.firstChild.remove(); + setTimeout(() => t.remove(), ms); +} + +// ---- modal dialogs + +// modal shows a dialog. buttons: [{label, kind, value, onclick}]. +// onclick may return false to keep the dialog open, or a promise. +export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) { + return new Promise((resolve) => { + const back = h("div", { class: "modal-back" }); + const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); }; + const onKey = (e) => { if (e.key === "Escape") close(null); }; + document.addEventListener("keydown", onKey); + const btns = buttons.map((b) => + h("button", { + class: b.kind || "", type: "button", + onclick: async (e) => { + if (b.onclick) { + e.target.disabled = true; + try { + const r = await b.onclick(); + if (r === false) return; + } finally { e.target.disabled = false; } + } + close(b.value === undefined ? b.label : b.value); + }, + }, b.label)); + const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns)); + back.append(dialog); + back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); }); + document.body.append(back); + const first = dialog.querySelector("input, select, textarea"); + if (first) first.focus(); + }); +} + +export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") { + const r = await modal(title, h("p", null, message), [ + { label: "Cancel", value: false }, + { label: okLabel, kind, value: true }, + ]); + return r === true; +} + +// ---- forms + +// field returns a label + control pair for .form-grid layouts. +export function field(label, control, help) { + const id = control.id || ("f" + Math.random().toString(36).slice(2, 9)); + control.id = id; + return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null]; +} + +export function input(attrs = {}) { + return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs }); +} + +export function select(options, value, attrs = {}) { + const s = h("select", attrs); + for (const o of options) { + const [v, label] = Array.isArray(o) ? o : [o, o]; + s.append(h("option", { value: v, selected: String(v) === String(value) }, label)); + } + return s; +} + +export function textarea(value, attrs = {}) { + return h("textarea", { spellcheck: "false", ...attrs, value: value || "" }); +} + +export function checkbox(label, checked, attrs = {}) { + const c = h("input", { type: "checkbox", checked, ...attrs }); + const l = h("label", { class: "check" }, c, label); + l.input = c; + return l; +} + +// lines splits a textarea's value into non-empty trimmed lines. +export function lines(text) { + return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== ""); +} + +// ---- tables + +// table renders rows with columns [{label, render(row) => node|string, class}]. +export function table(columns, rows, opts = {}) { + if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet."); + const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label)))); + const tbody = h("tbody"); + rows.forEach((row, i) => { + const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" }); + for (const c of columns) { + const v = c.render(row, i); + tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v)); + } + tbody.append(tr); + }); + return h("div", { class: "table-wrap" }, h("table", null, thead, tbody)); +} + +// ---- problems, diffs, errors + +export function problemsList(problems) { + if (!problems || !problems.length) return null; + return h("ul", { class: "problems" }, problems.map((p) => { + const where = [p.view && "view " + p.view, p.zone && "zone " + display(p.zone)].filter(Boolean).join(", "); + return h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, where ? h("span", { class: "where" }, where) : null); + })); +} + +export function checkList(msgs) { + const errs = (msgs || []).filter((m) => m.error); + if (!errs.length) return null; + return h("ul", { class: "problems" }, errs.map((m) => + h("li", { class: "error" }, h("strong", null, "named-checkconf: "), m.text, m.object ? h("span", { class: "where" }, m.object) : null))); +} + +export function diffView(text) { + if (!text) return h("div", { class: "muted" }, "No file changes."); + const box = h("div", { class: "diff" }); + for (const line of text.replace(/\n$/, "").split("\n")) { + let cls = ""; + if (line.startsWith("+++") || line.startsWith("---")) cls = "file"; + else if (line.startsWith("+")) cls = "add"; + else if (line.startsWith("-")) cls = "del"; + else if (line.startsWith("@@")) cls = "hunk"; + box.append(h("div", { class: cls }, line || " ")); + } + return box; +} + +// errorBox shows an API error with its problems and named-checkconf output. +export function errorBox(err) { + if (!err) return null; + const d = err.details || {}; + let problems = d.problems || []; + // Don't repeat a single problem that is already the headline. + if (problems.length === 1 && err.message.includes(problems[0].message)) problems = []; + return h("div", { class: "msg err" }, + h("div", null, err.message), + problemsList(problems), + checkList(d.check)); +} + +export function copyBox(value) { + const inp = input({ value, readOnly: true }); + const btn = h("button", { type: "button", class: "small", onclick: async () => { + try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); } + } }, "Copy"); + return h("div", { class: "copy-box" }, inp, btn); +} + +// debounce delays fn until calls stop for ms. +export function debounce(fn, ms = 250) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html new file mode 100644 index 0000000..2f721c4 --- /dev/null +++ b/internal/web/static/index.html @@ -0,0 +1,16 @@ + + + + + +Jarl + + + + + + +
+
+ + diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..58505df --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,20 @@ +// Package web embeds the web UI: a small single-page application that +// uses Jarl's REST API for everything it does. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed static +var static embed.FS + +// FS returns the web UI files (index.html and assets/). +func FS() fs.FS { + sub, err := fs.Sub(static, "static") + if err != nil { + panic(err) + } + return sub +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..ef65f82 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,270 @@ +#!/bin/bash +# End-to-end test: builds a legacy BIND setup, starts an unprivileged named, +# imports and adopts it with jarld, runs jarld, and drives every jarl CLI +# command, checking the answers with dig. Needs no root. +# +# BIND tools come from $JARL_BIND_ROOT (an extracted bind9 package tree, see +# docs/development.md) or from PATH. Run from the repository root: +# +# make e2e or JARL_BIND_ROOT=... scripts/e2e.sh +# +# Set KEEP=1 to leave named and jarld running afterwards (in $WORK). +set -u +ROOT=$(cd "$(dirname "$0")/.." && pwd) +WORK=${WORK:-$(mktemp -d /tmp/jarl-e2e.XXXXXX)} +E=$WORK +DNS=${DNS_PORT:-25353}; CTL=${CTL_PORT:-25953}; HTTP=${HTTP_PORT:-28053} +if [ -n "${JARL_BIND_ROOT:-}" ]; then + B=$JARL_BIND_ROOT + export LD_LIBRARY_PATH=$(echo $B/usr/lib/*-linux-gnu | cut -d' ' -f1) + NAMED=$B/usr/sbin/named RNDC=$B/usr/sbin/rndc TSIGKEYGEN=$B/usr/sbin/tsig-keygen + CHECKCONF=$B/usr/bin/named-checkconf CHECKZONE=$B/usr/bin/named-checkzone DIG=$B/usr/bin/dig +else + NAMED=$(command -v named || echo /usr/sbin/named) RNDC=$(command -v rndc || echo /usr/sbin/rndc) + TSIGKEYGEN=$(command -v tsig-keygen || echo /usr/sbin/tsig-keygen) + CHECKCONF=$(command -v named-checkconf) CHECKZONE=$(command -v named-checkzone) DIG=$(command -v dig) +fi +for t in "$NAMED" "$RNDC" "$TSIGKEYGEN" "$CHECKCONF" "$CHECKZONE" "$DIG"; do + [ -x "$t" ] || { echo "missing BIND tool: $t (set JARL_BIND_ROOT)"; exit 2; } +done +mkdir -p $E/legacy $E/cache $E/data +JARLD=$E/jarld J=$E/jarl +(cd "$ROOT" && go build -o $JARLD ./cmd/jarld && go build -o $J ./cmd/jarl) || exit 1 +NAMED_PID= JARLD_PID= +cleanup() { + if [ "${KEEP:-}" = 1 ]; then echo "left running in $WORK (named $NAMED_PID, jarld $JARLD_PID)"; return; fi + [ -n "$JARLD_PID" ] && kill $JARLD_PID 2>/dev/null + [ -n "$NAMED_PID" ] && kill $NAMED_PID 2>/dev/null + wait 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT + +# ---- legacy setup, import, adopt, serve + +$TSIGKEYGEN -a hmac-sha256 rndc-key > $E/legacy/rndc.key +cat > $E/legacy/named.conf < $E/legacy/named.conf.options < $E/legacy/named.conf.local < $E/legacy/db.adbraun.test <<'EOF' +$TTL 3600 +@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 ) + IN NS ns1 +ns1 IN A 127.0.0.1 +nas IN A 192.168.1.20 ; synology +www 300 IN CNAME nas +EOF +cat > $E/legacy/db.192.168.1 <<'EOF' +$TTL 86400 +@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 ) +@ IN NS ns1.adbraun.test. +20 IN PTR nas.adbraun.test. +EOF +$NAMED -c $E/legacy/named.conf -g -n 1 > $E/named.log 2>&1 & +NAMED_PID=$! +for i in $(seq 50); do $RNDC -k $E/legacy/rndc.key -s 127.0.0.1 -p $CTL status >/dev/null 2>&1 && break; sleep 0.2; done + +cat > $E/jarl.yaml < init.out 2>&1 || { cat init.out; exit 1; } +grep -o 'jarl_[A-Za-z0-9_]*' init.out | head -1 > admin.token +$JARLD -c jarl.yaml import > import.out 2>&1 || { cat import.out; exit 1; } +$JARLD -c jarl.yaml adopt > adopt.out 2>&1 || { cat adopt.out; exit 1; } +$JARLD -c jarl.yaml serve > jarld.log 2>&1 & +JARLD_PID=$! +for i in $(seq 50); do curl -s -o /dev/null http://127.0.0.1:$HTTP/healthz && break; sleep 0.2; done +echo "setup done in $WORK" + +# ---- CLI walkthrough +export JARL_CLI_CONFIG=$E/cli.yaml +unset JARL_SERVER JARL_TOKEN JARL_CHANGESET +PASS=0; FAIL=0 +dig() { $DIG +short +norec -p $DNS @127.0.0.1 "$@" | sort | tr '\n' ' ' | sed 's/ $//'; } +ok() { echo "PASS: $1"; PASS=$((PASS+1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } +# run DESC EXPECTED_RC CMD...: runs a command and checks its exit code; +# the output is kept in $OUT. +run() { + local desc=$1 want=$2; shift 2 + OUT=$("$@" 2>&1); local rc=$? + if [ "$rc" = "$want" ]; then ok "$desc (rc=$rc)"; else bad "$desc: rc=$rc want $want"; echo "$OUT" | sed 's/^/ /'; fi +} +expect_dig() { # DESC WANT DIG-ARGS... + local desc=$1 want=$2; shift 2 + local got; got=$(dig "$@") + if [ "$got" = "$want" ]; then ok "dig $desc = '$want'"; else bad "dig $desc: got '$got' want '$want'"; fi +} +contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; } + +run "config set server" 0 $J config set server http://127.0.0.1:$HTTP +run "config set token (from stdin)" 0 sh -c "$J config set token < $E/admin.token" +run "config show masks token" 0 $J config show +contains "jarl_" +run "status" 0 $J status +contains "Adopted:.*yes" +run "whoami" 0 $J whoami +contains "admin (everything)" +run "zone list" 0 $J zone list +contains "adbraun.test" + +# Records +run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300 +expect_dig "web A" "192.168.1.30" web.adbraun.test A +expect_dig "PTR 30" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR +run "CNAME conflict rejected (exit 6)" 6 $J record add adbraun.test www A 192.168.1.99 +contains "CNAME" +run "record add TXT with spaces" 0 $J record add adbraun.test @ TXT "v=spf1 mx -all" +expect_dig "apex TXT" '"v=spf1 mx -all"' adbraun.test TXT +run "record set RRset" 0 $J record set adbraun.test api A 192.168.1.40 192.168.1.41 --ttl 60 +expect_dig "api A" "192.168.1.40 192.168.1.41" api.adbraun.test A +run "dry run changes nothing" 0 $J record add adbraun.test dry A 192.168.1.50 --dry-run +contains "dry run" +expect_dig "dry A" "" dry.adbraun.test A +run "invalid IPv4 rejected" 6 $J record add adbraun.test bad A 192.168.1.300 +run "record list json" 0 $J record list adbraun.test -o json +contains '"relative_name": "api"' + +# Batch file +cat > $E/batch.yaml <<'EOF' +description: e2e batch +changes: + - add: {zone: adbraun.test, name: printer, type: A, data: 192.168.1.60, ptr: true} + - delete: {zone: adbraun.test, name: api, type: A, data: 192.168.1.41} + - set: {zone: adbraun.test, name: mail, type: MX, data: ["10 nas"]} +EOF +run "apply -f batch" 0 $J apply -f $E/batch.yaml +expect_dig "printer A" "192.168.1.60" printer.adbraun.test A +expect_dig "api A after batch" "192.168.1.40" api.adbraun.test A +expect_dig "mail MX" "10 nas.adbraun.test." mail.adbraun.test MX +cat > $E/badbatch.yaml <<'EOF' +changes: + - add: {zone: adbraun.test, name: ok1, type: A, data: 192.168.1.70} + - add: {zone: adbraun.test, name: www, type: TXT, data: "conflicts with CNAME"} +EOF +run "bad batch is all-or-nothing" 6 $J apply -f $E/badbatch.yaml +expect_dig "ok1 not created" "" ok1.adbraun.test A + +# Changesets +CS=$($J changeset create "e2e staged" -q) +run "stage into changeset" 0 $J record add adbraun.test staged A 192.168.1.80 --changeset $CS +expect_dig "staged not live yet" "" staged.adbraun.test A +run "changeset diff" 0 $J changeset diff $CS +contains "staged" +run "changeset apply" 0 $J changeset apply $CS +expect_dig "staged now live" "192.168.1.80" staged.adbraun.test A +REV=$($J changeset revert $CS -q) +run "apply revert changeset" 0 $J changeset apply $REV +expect_dig "staged reverted" "" staged.adbraun.test A +run "history" 0 $J history +contains "e2e batch" + +# ACLs, views, options, TSIG +run "acl create" 0 $J acl create trusted 10.0.0.0/8 192.168.1.0/24 +run "acl update add" 0 $J acl update trusted --add 172.16.0.0/12 +run "acl list" 0 $J acl list +contains "172.16.0.0/12" +run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted" +run "option unset" 0 $J option unset allow-transfer --zone adbraun.test +run "acl delete" 0 $J acl delete trusted +run "tsig create" 0 $J tsig create xfer-test +contains "secret:" +run "tsig rotate" 0 $J tsig rotate xfer-test +run "tsig show named-conf" 0 $J tsig show xfer-test --named-conf +contains 'algorithm hmac-sha256' +run "tsig delete" 0 $J tsig delete xfer-test +run "option describe" 0 $J option describe allow-recursion +run "option list" 0 $J option list +contains "allow-recursion" +run "check" 0 $J check +contains "valid" +run "search" 0 $J search printer +contains "printer" + +# Users, tokens, ACME +run "user create acme service account" 0 $J user create certbot --no-password --role acme --comment "e2e" +run "token create for certbot" 0 sh -c "$J token create certbot-hook --user certbot --role acme --expires 30d -q > $E/acme.token" +ACME="--token @$E/acme.token" +run "acme whoami" 0 $J $ACME whoami +contains "_acme-challenge" +run "acme set" 0 $J $ACME acme set adbraun.test value-one +expect_dig "acme TXT" '"value-one"' _acme-challenge.adbraun.test TXT +run "acme set again (idempotent)" 0 $J $ACME acme set adbraun.test value-one +run "acme set wildcard second value" 0 $J $ACME acme set '*.adbraun.test' value-two +expect_dig "acme TXT both" '"value-one" "value-two"' _acme-challenge.adbraun.test TXT +run "acme token cannot touch A records (exit 3)" 3 $J $ACME record add adbraun.test evil A 192.168.1.66 +run "certbot hooks" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-auth +expect_dig "certbot TXT" '"cb-value"' _acme-challenge.nas.adbraun.test TXT +run "certbot cleanup" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-cleanup +expect_dig "certbot TXT gone" "" _acme-challenge.nas.adbraun.test TXT +run "acme unset one value" 0 $J $ACME acme unset adbraun.test value-one +expect_dig "acme TXT one left" '"value-two"' _acme-challenge.adbraun.test TXT +run "acme unset all" 0 $J $ACME acme unset adbraun.test +expect_dig "acme TXT none" "" _acme-challenge.adbraun.test TXT +run "acme unset when nothing there" 0 $J $ACME acme unset adbraun.test +run "acme in unknown zone" 1 $J $ACME acme set example.org v +run "token list --all" 0 $J token list --all +contains "certbot-hook" +TID=$($J token list --user certbot -o json | grep -o '"id": [0-9]*' | head -1 | grep -o '[0-9]*') +run "token revoke" 0 $J token revoke $TID +run "revoked token refused (exit 3)" 3 $J $ACME whoami +run "user update disable" 0 $J user update certbot --disabled +run "user list" 0 $J user list +run "user delete" 0 $J user delete certbot -y +run "audit" 0 $J audit --limit 10 +contains "token.revoke" + +# Views +run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones +run "view create external" 0 $J view create external --match-clients any +run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1" +run "zone list shows both views" 0 $J zone list +contains "external" +expect_dig "internal view nas" "192.168.1.20" nas.adbraun.test A +run "record add needs a view now" 6 $J record add adbraun.test x A 192.168.1.90 +run "acme set goes into both views" 0 $J acme set adbraun.test both-views +expect_dig "acme TXT (internal, from 127.0.0.1)" '"both-views"' _acme-challenge.adbraun.test TXT +run "acme set --view external only" 0 $J acme unset adbraun.test --view internal +expect_dig "acme TXT removed from internal" "" _acme-challenge.adbraun.test TXT +run "zone file" 0 $J zone file adbraun.test --view external +contains "_acme-challenge" +run "status at the end" 0 $J status +contains "Drift:.*none" + +echo +echo "RESULT: $PASS passed, $FAIL failed" +[ $FAIL = 0 ]