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 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+203
View File
@@ -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"`
}
+144
View File
@@ -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)
}
+136
View File
@@ -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)
}
}
}
}
+382
View File
@@ -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"}}},
}
+316
View File
@@ -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
}
+417
View File
@@ -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 <string> [ <class> ] { ... };"))
}
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 <quoted_string>;", 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-]+ \{ <address_match_element>; \.\.\. \};$`)
)
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 "<boolean>":
return KindBool, nil
case "<integer>":
return KindInt, nil
case "<duration>":
return KindDuration, nil
case "<quoted_string>":
return KindString, nil
case "<string>":
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 == "<boolean>":
choices = append(choices, "yes", "no")
case strings.HasPrefix(alt, "<"):
// e.g. <integer> or <quoted_string> 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
}
+79
View File
@@ -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)
}
}
+839
View File
@@ -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 <string> { <address_match_element>; ... }; // may occur multiple times
controls {
inet ( <ipv4_address> | <ipv6_address> | * ) [ port ( <integer> | * ) ] allow { <address_match_element>; ... } [ keys { <string>; ... } ] [ read-only <boolean> ]; // may occur multiple times
unix <quoted_string> perm <integer> owner <integer> group <integer> [ keys { <string>; ... } ] [ read-only <boolean> ]; // may occur multiple times
}; // may occur multiple times
dlz <string> {
database <string>;
search <boolean>;
}; // may occur multiple times
dnssec-policy <string> {
cdnskey <boolean>;
cds-digest-types { <string>; ... };
dnskey-ttl <duration>;
inline-signing <boolean>;
keys { ( csk | ksk | zsk ) [ key-directory | key-store <string> ] lifetime <duration_or_unlimited> algorithm <string> [ tag-range <integer> <integer> ] [ <integer> ]; ... };
manual-mode <boolean>;
max-zone-ttl <duration>;
nsec3param [ iterations <integer> ] [ optout <boolean> ] [ salt-length <integer> ];
offline-ksk <boolean>;
parent-ds-ttl <duration>;
parent-propagation-delay <duration>;
publish-safety <duration>;
purge-keys <duration>;
retire-safety <duration>;
signatures-jitter <duration>;
signatures-refresh <duration>;
signatures-validity <duration>;
signatures-validity-dnskey <duration>;
zone-propagation-delay <duration>;
}; // may occur multiple times
dyndb <string> <quoted_string> { <unspecified-text> }; // may occur multiple times
http <string> {
endpoints { <quoted_string>; ... };
listener-clients <integer>;
streams-per-connection <integer>;
}; // may occur multiple times
key <string> {
algorithm <string>;
secret <string>;
}; // may occur multiple times
key-store <string> {
directory <string>;
pkcs11-uri <quoted_string>;
}; // may occur multiple times
logging {
category <string> { <string>; ... }; // may occur multiple times
channel <string> {
buffered <boolean>;
file <quoted_string> [ versions ( unlimited | <integer> ) ] [ size <size> ] [ suffix ( increment | timestamp ) ];
null;
print-category <boolean>;
print-severity <boolean>;
print-time ( iso8601 | iso8601-utc | local | <boolean> );
severity <log_severity>;
stderr;
syslog [ <syslog_facility> ];
}; // may occur multiple times
};
managed-keys { <string> ( static-key | initial-key | static-ds | initial-ds ) <integer> <integer> <integer> <quoted_string>; ... }; // may occur multiple times, deprecated
options {
allow-new-zones <boolean>;
allow-notify { <address_match_element>; ... };
allow-proxy { <address_match_element>; ... }; // experimental
allow-proxy-on { <address_match_element>; ... }; // experimental
allow-query { <address_match_element>; ... };
allow-query-cache { <address_match_element>; ... };
allow-query-cache-on { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
allow-recursion { <address_match_element>; ... };
allow-recursion-on { <address_match_element>; ... };
allow-transfer [ port <integer> ] [ transport <string> ] { <address_match_element>; ... };
allow-update { <address_match_element>; ... };
allow-update-forwarding { <address_match_element>; ... };
also-notify [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
answer-cookie <boolean>;
attach-cache <string>;
auth-nxdomain <boolean>;
automatic-interface-scan <boolean>;
avoid-v4-udp-ports { <portrange>; ... }; // deprecated
avoid-v6-udp-ports { <portrange>; ... }; // deprecated
bindkeys-file <quoted_string>; // test only
blackhole { <address_match_element>; ... };
catalog-zones { zone <string> [ default-primaries [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... } ] [ zone-directory <quoted_string> ] [ in-memory <boolean> ] [ min-update-interval <duration> ]; ... };
check-dup-records ( fail | warn | ignore );
check-integrity <boolean>;
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 <boolean>;
check-spf ( warn | ignore );
check-srv-cname ( fail | warn | ignore );
check-svcb <boolean>;
check-wildcard <boolean>;
clients-per-query <integer>;
cookie-algorithm ( siphash24 );
cookie-secret <string>; // may occur multiple times
deny-answer-addresses { <address_match_element>; ... } [ except-from { <string>; ... } ];
deny-answer-aliases { <string>; ... } [ except-from { <string>; ... } ];
dialup ( notify | notify-passive | passive | refresh | <boolean> ); // deprecated
directory <quoted_string>;
disable-algorithms <string> { <string>; ... }; // may occur multiple times
disable-ds-digests <string> { <string>; ... }; // may occur multiple times
disable-empty-zone <string>; // may occur multiple times
dns64 <netprefix> {
break-dnssec <boolean>;
clients { <address_match_element>; ... };
exclude { <address_match_element>; ... };
mapped { <address_match_element>; ... };
recursive-only <boolean>;
suffix <ipv6_address>;
}; // may occur multiple times
dns64-contact <string>;
dns64-server <string>;
dnskey-sig-validity <integer>; // obsolete
dnsrps-enable <boolean>; // not configured
dnsrps-library <quoted_string>; // not configured
dnsrps-options { <unspecified-text> }; // not configured
dnssec-accept-expired <boolean>;
dnssec-dnskey-kskonly <boolean>; // obsolete
dnssec-loadkeys-interval <integer>;
dnssec-must-be-secure <string> <boolean>; // may occur multiple times, deprecated
dnssec-policy <string>;
dnssec-secure-to-insecure <boolean>; // 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 ( <quoted_string> | none | hostname ); // not configured
dnstap-output ( file | unix ) <quoted_string> [ size ( unlimited | <size> ) ] [ versions ( unlimited | <integer> ) ] [ suffix ( increment | timestamp ) ]; // not configured
dnstap-version ( <quoted_string> | none ); // not configured
dual-stack-servers [ port <integer> ] { ( <quoted_string> [ port <integer> ] | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ); ... };
dump-file <quoted_string>;
edns-udp-size <integer>;
empty-contact <string>;
empty-server <string>;
empty-zones-enable <boolean>;
fetch-quota-params <integer> <fixedpoint> <fixedpoint> <fixedpoint>;
fetches-per-server <integer> [ ( drop | fail ) ];
fetches-per-zone <integer> [ ( drop | fail ) ];
flush-zones-on-shutdown <boolean>;
forward ( first | only );
forwarders [ port <integer> ] [ tls <string> ] { ( <ipv4_address> | <ipv6_address> ) [ port <integer> ] [ tls <string> ]; ... };
fstrm-set-buffer-hint <integer>; // not configured
fstrm-set-flush-timeout <integer>; // not configured
fstrm-set-input-queue-size <integer>; // not configured
fstrm-set-output-notify-threshold <integer>; // not configured
fstrm-set-output-queue-model ( mpsc | spsc ); // not configured
fstrm-set-output-queue-size <integer>; // not configured
fstrm-set-reopen-interval <duration>; // not configured
geoip-directory ( <quoted_string> | none );
heartbeat-interval <integer>; // deprecated
hostname ( <quoted_string> | none );
http-listener-clients <integer>;
http-port <integer>;
http-streams-per-connection <integer>;
https-port <integer>;
interface-interval <duration>;
ipv4only-contact <string>;
ipv4only-enable <boolean>;
ipv4only-server <string>;
ixfr-from-differences ( primary | master | secondary | slave | <boolean> );
keep-response-order { <address_match_element>; ... }; // obsolete
key-directory <quoted_string>;
lame-ttl <duration>;
listen-on [ port <integer> ] [ proxy <string> ] [ tls <string> ] [ http <string> ] { <address_match_element>; ... }; // may occur multiple times
listen-on-v6 [ port <integer> ] [ proxy <string> ] [ tls <string> ] [ http <string> ] { <address_match_element>; ... }; // may occur multiple times
lmdb-mapsize <sizeval>;
managed-keys-directory <quoted_string>;
masterfile-format ( raw | text );
masterfile-style ( full | relative );
match-mapped-addresses <boolean>;
max-cache-size ( default | unlimited | <sizeval> | <percentage> );
max-cache-ttl <duration>;
max-clients-per-query <integer>;
max-ixfr-ratio ( unlimited | <percentage> );
max-journal-size ( default | unlimited | <sizeval> );
max-ncache-ttl <duration>;
max-query-count <integer>;
max-query-restarts <integer>;
max-records <integer>;
max-records-per-type <integer>;
max-recursion-depth <integer>;
max-recursion-queries <integer>;
max-refresh-time <integer>;
max-retry-time <integer>;
max-rsa-exponent-size <integer>;
max-stale-ttl <duration>;
max-transfer-idle-in <integer>;
max-transfer-idle-out <integer>;
max-transfer-time-in <integer>;
max-transfer-time-out <integer>;
max-types-per-name <integer>;
max-udp-size <integer>;
max-validation-failures-per-fetch <integer>; // experimental
max-validations-per-fetch <integer>; // experimental
max-zone-ttl ( unlimited | <duration> ); // deprecated
memstatistics <boolean>;
memstatistics-file <quoted_string>;
message-compression <boolean>;
min-cache-ttl <duration>;
min-ncache-ttl <duration>;
min-refresh-time <integer>;
min-retry-time <integer>;
min-transfer-rate-in <integer> <integer>;
minimal-any <boolean>;
minimal-responses ( no-auth | no-auth-recursive | <boolean> );
multi-master <boolean>;
new-zones-directory <quoted_string>;
no-case-compress { <address_match_element>; ... };
nocookie-udp-size <integer>;
notify ( explicit | master-only | primary-only | <boolean> );
notify-defer <integer>;
notify-delay <integer>;
notify-rate <integer>;
notify-source ( <ipv4_address> | * );
notify-source-v6 ( <ipv6_address> | * );
notify-to-soa <boolean>;
nsec3-test-zone <boolean>; // test only
nta-lifetime <duration>;
nta-recheck <duration>;
nxdomain-redirect <string>;
parental-source ( <ipv4_address> | * );
parental-source-v6 ( <ipv6_address> | * );
pid-file ( <quoted_string> | none );
port <integer>;
preferred-glue <string>;
prefetch <integer> [ <integer> ];
provide-ixfr <boolean>;
qname-minimization ( strict | relaxed | disabled | off );
query-source [ address ] ( <ipv4_address> | * | none );
query-source-v6 [ address ] ( <ipv6_address> | * | none );
querylog <boolean>;
rate-limit {
all-per-second <integer>;
errors-per-second <integer>;
exempt-clients { <address_match_element>; ... };
ipv4-prefix-length <integer>;
ipv6-prefix-length <integer>;
log-only <boolean>;
max-table-size <integer>;
min-table-size <integer>;
nodata-per-second <integer>;
nxdomains-per-second <integer>;
qps-scale <integer>;
referrals-per-second <integer>;
responses-per-second <integer>;
slip <integer>;
window <integer>;
};
recursing-file <quoted_string>;
recursion <boolean>;
recursive-clients <integer>;
request-expire <boolean>;
request-ixfr <boolean>;
request-nsid <boolean>;
require-server-cookie <boolean>;
resolver-query-timeout <integer>;
resolver-use-dns64 <boolean>;
response-padding { <address_match_element>; ... } block-size <integer>;
response-policy { zone <string> [ add-soa <boolean> ] [ log <boolean> ] [ max-policy-ttl <duration> ] [ min-update-interval <duration> ] [ policy ( cname | disabled | drop | given | no-op | nodata | nxdomain | passthru | tcp-only <quoted_string> ) ] [ recursive-only <boolean> ] [ nsip-enable <boolean> ] [ nsdname-enable <boolean> ] [ ede <string> ]; ... } [ add-soa <boolean> ] [ break-dnssec <boolean> ] [ max-policy-ttl <duration> ] [ min-update-interval <duration> ] [ min-ns-dots <integer> ] [ nsip-wait-recurse <boolean> ] [ nsdname-wait-recurse <boolean> ] [ qname-wait-recurse <boolean> ] [ recursive-only <boolean> ] [ servfail-until-ready <boolean> ] [ nsip-enable <boolean> ] [ nsdname-enable <boolean> ] [ dnsrps-enable <boolean> ] [ dnsrps-options { <unspecified-text> } ];
responselog <boolean>;
reuseport <boolean>;
root-key-sentinel <boolean>;
rrset-order { [ class <string> ] [ type <string> ] [ name <quoted_string> ] <string> <string>; ... };
secroots-file <quoted_string>;
send-cookie <boolean>;
serial-query-rate <integer>;
serial-update-method ( date | increment | unixtime );
server-id ( <quoted_string> | none | hostname );
servfail-ttl <duration>;
session-keyalg <string>;
session-keyfile ( <quoted_string> | none );
session-keyname <string>;
sig-signing-nodes <integer>;
sig-signing-signatures <integer>;
sig-signing-type <integer>;
sig-validity-interval <integer> [ <integer> ]; // obsolete
sig0checks-quota <integer>; // experimental
sig0checks-quota-exempt { <address_match_element>; ... }; // experimental
sig0key-checks-limit <integer>;
sig0message-checks-limit <integer>;
sortlist { <address_match_element>; ... }; // deprecated
stale-answer-client-timeout ( disabled | off | <integer> );
stale-answer-enable <boolean>;
stale-answer-ttl <duration>;
stale-cache-enable <boolean>;
stale-refresh-time <duration>;
startup-notify-rate <integer>;
statistics-file <quoted_string>;
synth-from-dnssec <boolean>;
tcp-advertised-timeout <integer>;
tcp-clients <integer>;
tcp-idle-timeout <integer>;
tcp-initial-timeout <integer>;
tcp-keepalive-timeout <integer>;
tcp-listen-queue <integer>;
tcp-receive-buffer <integer>;
tcp-send-buffer <integer>;
tkey-domain <quoted_string>; // obsolete
tkey-gssapi-credential <quoted_string>; // deprecated
tkey-gssapi-keytab <quoted_string>;
tls-port <integer>;
transfer-format ( many-answers | one-answer );
transfer-message-size <integer>;
transfer-source ( <ipv4_address> | * );
transfer-source-v6 ( <ipv6_address> | * );
transfers-in <integer>;
transfers-out <integer>;
transfers-per-ns <integer>;
trust-anchor-telemetry <boolean>;
try-tcp-refresh <boolean>;
udp-receive-buffer <integer>;
udp-send-buffer <integer>;
update-check-ksk <boolean>; // obsolete
update-quota <integer>;
use-v4-udp-ports { <portrange>; ... }; // deprecated
use-v6-udp-ports { <portrange>; ... }; // deprecated
v6-bias <integer>;
validate-except { <string>; ... };
version ( <quoted_string> | none );
zero-no-soa-ttl <boolean>;
zero-no-soa-ttl-cache <boolean>;
zone-statistics ( full | terse | none | <boolean> );
};
plugin ( query ) <string> [ { <unspecified-text> } ]; // may occur multiple times
remote-servers <string> [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... }; // may occur multiple times
server <netprefix> {
bogus <boolean>;
edns <boolean>;
edns-udp-size <integer>;
edns-version <integer>;
keys <server_key>;
max-udp-size <integer>;
notify-source ( <ipv4_address> | * );
notify-source-v6 ( <ipv6_address> | * );
padding <integer>;
provide-ixfr <boolean>;
query-source [ address ] ( <ipv4_address> | * );
query-source-v6 [ address ] ( <ipv6_address> | * );
request-expire <boolean>;
request-ixfr <boolean>;
request-nsid <boolean>;
require-cookie <boolean>;
send-cookie <boolean>;
tcp-keepalive <boolean>;
tcp-only <boolean>;
transfer-format ( many-answers | one-answer );
transfer-source ( <ipv4_address> | * );
transfer-source-v6 ( <ipv6_address> | * );
transfers <integer>;
}; // may occur multiple times
statistics-channels {
inet ( <ipv4_address> | <ipv6_address> | * ) [ port ( <integer> | * ) ] [ allow { <address_match_element>; ... } ]; // may occur multiple times
}; // may occur multiple times
tls <string> {
ca-file <quoted_string>;
cert-file <quoted_string>;
cipher-suites <string>;
ciphers <string>;
dhparam-file <quoted_string>;
key-file <quoted_string>;
prefer-server-ciphers <boolean>;
protocols { <string>; ... };
remote-hostname <quoted_string>;
session-tickets <boolean>;
}; // may occur multiple times
trust-anchors { <string> ( static-key | initial-key | static-ds | initial-ds ) <integer> <integer> <integer> <quoted_string>; ... }; // may occur multiple times
trusted-keys { <string> <integer> <integer> <integer> <quoted_string>; ... }; // may occur multiple times, deprecated
view <string> [ <class> ] {
allow-new-zones <boolean>;
allow-notify { <address_match_element>; ... };
allow-proxy { <address_match_element>; ... }; // experimental
allow-proxy-on { <address_match_element>; ... }; // experimental
allow-query { <address_match_element>; ... };
allow-query-cache { <address_match_element>; ... };
allow-query-cache-on { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
allow-recursion { <address_match_element>; ... };
allow-recursion-on { <address_match_element>; ... };
allow-transfer [ port <integer> ] [ transport <string> ] { <address_match_element>; ... };
allow-update { <address_match_element>; ... };
allow-update-forwarding { <address_match_element>; ... };
also-notify [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
attach-cache <string>;
auth-nxdomain <boolean>;
catalog-zones { zone <string> [ default-primaries [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... } ] [ zone-directory <quoted_string> ] [ in-memory <boolean> ] [ min-update-interval <duration> ]; ... };
check-dup-records ( fail | warn | ignore );
check-integrity <boolean>;
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 <boolean>;
check-spf ( warn | ignore );
check-srv-cname ( fail | warn | ignore );
check-svcb <boolean>;
check-wildcard <boolean>;
clients-per-query <integer>;
deny-answer-addresses { <address_match_element>; ... } [ except-from { <string>; ... } ];
deny-answer-aliases { <string>; ... } [ except-from { <string>; ... } ];
dialup ( notify | notify-passive | passive | refresh | <boolean> ); // deprecated
disable-algorithms <string> { <string>; ... }; // may occur multiple times
disable-ds-digests <string> { <string>; ... }; // may occur multiple times
disable-empty-zone <string>; // may occur multiple times
dlz <string> {
database <string>;
search <boolean>;
}; // may occur multiple times
dns64 <netprefix> {
break-dnssec <boolean>;
clients { <address_match_element>; ... };
exclude { <address_match_element>; ... };
mapped { <address_match_element>; ... };
recursive-only <boolean>;
suffix <ipv6_address>;
}; // may occur multiple times
dns64-contact <string>;
dns64-server <string>;
dnskey-sig-validity <integer>; // obsolete
dnsrps-enable <boolean>; // not configured
dnsrps-options { <unspecified-text> }; // not configured
dnssec-accept-expired <boolean>;
dnssec-dnskey-kskonly <boolean>; // obsolete
dnssec-loadkeys-interval <integer>;
dnssec-must-be-secure <string> <boolean>; // may occur multiple times, deprecated
dnssec-policy <string>;
dnssec-secure-to-insecure <boolean>; // 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 <integer> ] { ( <quoted_string> [ port <integer> ] | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ); ... };
dyndb <string> <quoted_string> { <unspecified-text> }; // may occur multiple times
edns-udp-size <integer>;
empty-contact <string>;
empty-server <string>;
empty-zones-enable <boolean>;
fetch-quota-params <integer> <fixedpoint> <fixedpoint> <fixedpoint>;
fetches-per-server <integer> [ ( drop | fail ) ];
fetches-per-zone <integer> [ ( drop | fail ) ];
forward ( first | only );
forwarders [ port <integer> ] [ tls <string> ] { ( <ipv4_address> | <ipv6_address> ) [ port <integer> ] [ tls <string> ]; ... };
ipv4only-contact <string>;
ipv4only-enable <boolean>;
ipv4only-server <string>;
ixfr-from-differences ( primary | master | secondary | slave | <boolean> );
key <string> {
algorithm <string>;
secret <string>;
}; // may occur multiple times
key-directory <quoted_string>;
lame-ttl <duration>;
lmdb-mapsize <sizeval>;
managed-keys { <string> ( static-key | initial-key | static-ds | initial-ds ) <integer> <integer> <integer> <quoted_string>; ... }; // may occur multiple times, deprecated
masterfile-format ( raw | text );
masterfile-style ( full | relative );
match-clients { <address_match_element>; ... };
match-destinations { <address_match_element>; ... };
match-recursive-only <boolean>;
max-cache-size ( default | unlimited | <sizeval> | <percentage> );
max-cache-ttl <duration>;
max-clients-per-query <integer>;
max-ixfr-ratio ( unlimited | <percentage> );
max-journal-size ( default | unlimited | <sizeval> );
max-ncache-ttl <duration>;
max-query-count <integer>;
max-query-restarts <integer>;
max-records <integer>;
max-records-per-type <integer>;
max-recursion-depth <integer>;
max-recursion-queries <integer>;
max-refresh-time <integer>;
max-retry-time <integer>;
max-stale-ttl <duration>;
max-transfer-idle-in <integer>;
max-transfer-idle-out <integer>;
max-transfer-time-in <integer>;
max-transfer-time-out <integer>;
max-types-per-name <integer>;
max-udp-size <integer>;
max-validation-failures-per-fetch <integer>; // experimental
max-validations-per-fetch <integer>; // experimental
max-zone-ttl ( unlimited | <duration> ); // deprecated
message-compression <boolean>;
min-cache-ttl <duration>;
min-ncache-ttl <duration>;
min-refresh-time <integer>;
min-retry-time <integer>;
min-transfer-rate-in <integer> <integer>;
minimal-any <boolean>;
minimal-responses ( no-auth | no-auth-recursive | <boolean> );
multi-master <boolean>;
new-zones-directory <quoted_string>;
no-case-compress { <address_match_element>; ... };
nocookie-udp-size <integer>;
notify ( explicit | master-only | primary-only | <boolean> );
notify-defer <integer>;
notify-delay <integer>;
notify-source ( <ipv4_address> | * );
notify-source-v6 ( <ipv6_address> | * );
notify-to-soa <boolean>;
nsec3-test-zone <boolean>; // test only
nta-lifetime <duration>;
nta-recheck <duration>;
nxdomain-redirect <string>;
parental-source ( <ipv4_address> | * );
parental-source-v6 ( <ipv6_address> | * );
plugin ( query ) <string> [ { <unspecified-text> } ]; // may occur multiple times
preferred-glue <string>;
prefetch <integer> [ <integer> ];
provide-ixfr <boolean>;
qname-minimization ( strict | relaxed | disabled | off );
query-source [ address ] ( <ipv4_address> | * | none );
query-source-v6 [ address ] ( <ipv6_address> | * | none );
rate-limit {
all-per-second <integer>;
errors-per-second <integer>;
exempt-clients { <address_match_element>; ... };
ipv4-prefix-length <integer>;
ipv6-prefix-length <integer>;
log-only <boolean>;
max-table-size <integer>;
min-table-size <integer>;
nodata-per-second <integer>;
nxdomains-per-second <integer>;
qps-scale <integer>;
referrals-per-second <integer>;
responses-per-second <integer>;
slip <integer>;
window <integer>;
};
recursion <boolean>;
request-expire <boolean>;
request-ixfr <boolean>;
request-nsid <boolean>;
require-server-cookie <boolean>;
resolver-query-timeout <integer>;
resolver-use-dns64 <boolean>;
response-padding { <address_match_element>; ... } block-size <integer>;
response-policy { zone <string> [ add-soa <boolean> ] [ log <boolean> ] [ max-policy-ttl <duration> ] [ min-update-interval <duration> ] [ policy ( cname | disabled | drop | given | no-op | nodata | nxdomain | passthru | tcp-only <quoted_string> ) ] [ recursive-only <boolean> ] [ nsip-enable <boolean> ] [ nsdname-enable <boolean> ] [ ede <string> ]; ... } [ add-soa <boolean> ] [ break-dnssec <boolean> ] [ max-policy-ttl <duration> ] [ min-update-interval <duration> ] [ min-ns-dots <integer> ] [ nsip-wait-recurse <boolean> ] [ nsdname-wait-recurse <boolean> ] [ qname-wait-recurse <boolean> ] [ recursive-only <boolean> ] [ servfail-until-ready <boolean> ] [ nsip-enable <boolean> ] [ nsdname-enable <boolean> ] [ dnsrps-enable <boolean> ] [ dnsrps-options { <unspecified-text> } ];
root-key-sentinel <boolean>;
rrset-order { [ class <string> ] [ type <string> ] [ name <quoted_string> ] <string> <string>; ... };
send-cookie <boolean>;
serial-update-method ( date | increment | unixtime );
server <netprefix> {
bogus <boolean>;
edns <boolean>;
edns-udp-size <integer>;
edns-version <integer>;
keys <server_key>;
max-udp-size <integer>;
notify-source ( <ipv4_address> | * );
notify-source-v6 ( <ipv6_address> | * );
padding <integer>;
provide-ixfr <boolean>;
query-source [ address ] ( <ipv4_address> | * );
query-source-v6 [ address ] ( <ipv6_address> | * );
request-expire <boolean>;
request-ixfr <boolean>;
request-nsid <boolean>;
require-cookie <boolean>;
send-cookie <boolean>;
tcp-keepalive <boolean>;
tcp-only <boolean>;
transfer-format ( many-answers | one-answer );
transfer-source ( <ipv4_address> | * );
transfer-source-v6 ( <ipv6_address> | * );
transfers <integer>;
}; // may occur multiple times
servfail-ttl <duration>;
sig-signing-nodes <integer>;
sig-signing-signatures <integer>;
sig-signing-type <integer>;
sig-validity-interval <integer> [ <integer> ]; // obsolete
sig0key-checks-limit <integer>;
sig0message-checks-limit <integer>;
sortlist { <address_match_element>; ... }; // deprecated
stale-answer-client-timeout ( disabled | off | <integer> );
stale-answer-enable <boolean>;
stale-answer-ttl <duration>;
stale-cache-enable <boolean>;
stale-refresh-time <duration>;
synth-from-dnssec <boolean>;
transfer-format ( many-answers | one-answer );
transfer-source ( <ipv4_address> | * );
transfer-source-v6 ( <ipv6_address> | * );
trust-anchor-telemetry <boolean>;
trust-anchors { <string> ( static-key | initial-key | static-ds | initial-ds ) <integer> <integer> <integer> <quoted_string>; ... }; // may occur multiple times
trusted-keys { <string> <integer> <integer> <integer> <quoted_string>; ... }; // may occur multiple times, deprecated
try-tcp-refresh <boolean>;
update-check-ksk <boolean>; // obsolete
v6-bias <integer>;
validate-except { <string>; ... };
zero-no-soa-ttl <boolean>;
zero-no-soa-ttl-cache <boolean>;
zone-statistics ( full | terse | none | <boolean> );
}; // may occur multiple times
zone <string> [ <class> ] {
type primary;
allow-query { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
allow-transfer [ port <integer> ] [ transport <string> ] { <address_match_element>; ... };
allow-update { <address_match_element>; ... };
also-notify [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
check-dup-records ( fail | warn | ignore );
check-integrity <boolean>;
check-mx ( fail | warn | ignore );
check-mx-cname ( fail | warn | ignore );
check-names ( fail | warn | ignore );
check-sibling <boolean>;
check-spf ( warn | ignore );
check-srv-cname ( fail | warn | ignore );
check-svcb <boolean>;
check-wildcard <boolean>;
checkds ( explicit | <boolean> );
database <string>;
dialup ( notify | notify-passive | passive | refresh | <boolean> ); // deprecated
dlz <string>;
dnskey-sig-validity <integer>; // obsolete
dnssec-dnskey-kskonly <boolean>; // obsolete
dnssec-loadkeys-interval <integer>;
dnssec-policy <string>;
dnssec-secure-to-insecure <boolean>; // obsolete
dnssec-update-mode ( maintain | no-resign ); // obsolete
file <quoted_string>;
forward ( first | only );
forwarders [ port <integer> ] [ tls <string> ] { ( <ipv4_address> | <ipv6_address> ) [ port <integer> ] [ tls <string> ]; ... };
inline-signing <boolean>;
ixfr-from-differences <boolean>;
journal <quoted_string>;
key-directory <quoted_string>;
masterfile-format ( raw | text );
masterfile-style ( full | relative );
max-ixfr-ratio ( unlimited | <percentage> );
max-journal-size ( default | unlimited | <sizeval> );
max-records <integer>;
max-records-per-type <integer>;
max-transfer-idle-out <integer>;
max-transfer-time-out <integer>;
max-types-per-name <integer>;
max-zone-ttl ( unlimited | <duration> ); // deprecated
notify ( explicit | master-only | primary-only | <boolean> );
notify-defer <integer>;
notify-delay <integer>;
notify-source ( <ipv4_address> | * );
notify-source-v6 ( <ipv6_address> | * );
notify-to-soa <boolean>;
nsec3-test-zone <boolean>; // test only
parental-agents [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
parental-source ( <ipv4_address> | * );
parental-source-v6 ( <ipv6_address> | * );
serial-update-method ( date | increment | unixtime );
sig-signing-nodes <integer>;
sig-signing-signatures <integer>;
sig-signing-type <integer>;
sig-validity-interval <integer> [ <integer> ]; // obsolete
update-check-ksk <boolean>; // obsolete
update-policy ( local | { ( deny | grant ) <string> ( 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 ) [ <string> ] <rrtypelist>; ... } );
zero-no-soa-ttl <boolean>;
zone-statistics ( full | terse | none | <boolean> );
};
zone <string> [ <class> ] {
type secondary;
allow-notify { <address_match_element>; ... };
allow-query { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
allow-transfer [ port <integer> ] [ transport <string> ] { <address_match_element>; ... };
allow-update-forwarding { <address_match_element>; ... };
also-notify [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
check-names ( fail | warn | ignore );
checkds ( explicit | <boolean> );
database <string>;
dialup ( notify | notify-passive | passive | refresh | <boolean> ); // deprecated
dlz <string>;
dnskey-sig-validity <integer>; // obsolete
dnssec-dnskey-kskonly <boolean>; // obsolete
dnssec-loadkeys-interval <integer>;
dnssec-policy <string>;
dnssec-update-mode ( maintain | no-resign ); // obsolete
file <quoted_string>;
forward ( first | only );
forwarders [ port <integer> ] [ tls <string> ] { ( <ipv4_address> | <ipv6_address> ) [ port <integer> ] [ tls <string> ]; ... };
inline-signing <boolean>;
ixfr-from-differences <boolean>;
journal <quoted_string>;
key-directory <quoted_string>;
masterfile-format ( raw | text );
masterfile-style ( full | relative );
max-ixfr-ratio ( unlimited | <percentage> );
max-journal-size ( default | unlimited | <sizeval> );
max-records <integer>;
max-records-per-type <integer>;
max-refresh-time <integer>;
max-retry-time <integer>;
max-transfer-idle-in <integer>;
max-transfer-idle-out <integer>;
max-transfer-time-in <integer>;
max-transfer-time-out <integer>;
max-types-per-name <integer>;
min-refresh-time <integer>;
min-retry-time <integer>;
min-transfer-rate-in <integer> <integer>;
multi-master <boolean>;
notify ( explicit | master-only | primary-only | <boolean> );
notify-defer <integer>;
notify-delay <integer>;
notify-source ( <ipv4_address> | * );
notify-source-v6 ( <ipv6_address> | * );
notify-to-soa <boolean>;
nsec3-test-zone <boolean>; // test only
parental-agents [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
parental-source ( <ipv4_address> | * );
parental-source-v6 ( <ipv6_address> | * );
primaries [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
request-expire <boolean>;
request-ixfr <boolean>;
sig-signing-nodes <integer>;
sig-signing-signatures <integer>;
sig-signing-type <integer>;
sig-validity-interval <integer> [ <integer> ]; // obsolete
transfer-source ( <ipv4_address> | * );
transfer-source-v6 ( <ipv6_address> | * );
try-tcp-refresh <boolean>;
update-check-ksk <boolean>; // obsolete
zero-no-soa-ttl <boolean>;
zone-statistics ( full | terse | none | <boolean> );
};
zone <string> [ <class> ] {
type mirror;
allow-notify { <address_match_element>; ... };
allow-query { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
allow-transfer [ port <integer> ] [ transport <string> ] { <address_match_element>; ... };
allow-update-forwarding { <address_match_element>; ... };
also-notify [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
check-names ( fail | warn | ignore );
database <string>;
file <quoted_string>;
ixfr-from-differences <boolean>;
journal <quoted_string>;
masterfile-format ( raw | text );
masterfile-style ( full | relative );
max-ixfr-ratio ( unlimited | <percentage> );
max-journal-size ( default | unlimited | <sizeval> );
max-records <integer>;
max-records-per-type <integer>;
max-refresh-time <integer>;
max-retry-time <integer>;
max-transfer-idle-in <integer>;
max-transfer-idle-out <integer>;
max-transfer-time-in <integer>;
max-transfer-time-out <integer>;
max-types-per-name <integer>;
min-refresh-time <integer>;
min-retry-time <integer>;
min-transfer-rate-in <integer> <integer>;
multi-master <boolean>;
notify ( explicit | master-only | primary-only | <boolean> );
notify-defer <integer>;
notify-delay <integer>;
notify-source ( <ipv4_address> | * );
notify-source-v6 ( <ipv6_address> | * );
primaries [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
request-expire <boolean>;
request-ixfr <boolean>;
transfer-source ( <ipv4_address> | * );
transfer-source-v6 ( <ipv6_address> | * );
try-tcp-refresh <boolean>;
zero-no-soa-ttl <boolean>;
zone-statistics ( full | terse | none | <boolean> );
};
zone <string> [ <class> ] {
type forward;
forward ( first | only );
forwarders [ port <integer> ] [ tls <string> ] { ( <ipv4_address> | <ipv6_address> ) [ port <integer> ] [ tls <string> ]; ... };
};
zone <string> [ <class> ] {
type hint;
check-names ( fail | warn | ignore );
file <quoted_string>;
};
zone <string> [ <class> ] {
type redirect;
allow-query { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
dlz <string>;
file <quoted_string>;
masterfile-format ( raw | text );
masterfile-style ( full | relative );
max-records <integer>;
max-records-per-type <integer>;
max-types-per-name <integer>;
max-zone-ttl ( unlimited | <duration> ); // deprecated
primaries [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
zone-statistics ( full | terse | none | <boolean> );
};
zone <string> [ <class> ] {
type static-stub;
allow-query { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
forward ( first | only );
forwarders [ port <integer> ] [ tls <string> ] { ( <ipv4_address> | <ipv6_address> ) [ port <integer> ] [ tls <string> ]; ... };
max-records <integer>;
max-records-per-type <integer>;
max-types-per-name <integer>;
server-addresses { ( <ipv4_address> | <ipv6_address> ); ... };
server-names { <string>; ... };
zone-statistics ( full | terse | none | <boolean> );
};
zone <string> [ <class> ] {
type stub;
allow-query { <address_match_element>; ... };
allow-query-on { <address_match_element>; ... };
check-names ( fail | warn | ignore );
database <string>;
dialup ( notify | notify-passive | passive | refresh | <boolean> ); // deprecated
file <quoted_string>;
forward ( first | only );
forwarders [ port <integer> ] [ tls <string> ] { ( <ipv4_address> | <ipv6_address> ) [ port <integer> ] [ tls <string> ]; ... };
masterfile-format ( raw | text );
masterfile-style ( full | relative );
max-records <integer>;
max-records-per-type <integer>;
max-refresh-time <integer>;
max-retry-time <integer>;
max-transfer-idle-in <integer>;
max-transfer-time-in <integer>;
max-types-per-name <integer>;
min-refresh-time <integer>;
min-retry-time <integer>;
min-transfer-rate-in <integer> <integer>;
multi-master <boolean>;
primaries [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
transfer-source ( <ipv4_address> | * );
transfer-source-v6 ( <ipv6_address> | * );
zone-statistics ( full | terse | none | <boolean> );
};
zone <string> [ <class> ] {
in-view <string>;
};
+356
View File
@@ -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: name<TAB>description<TAB>tags (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
Can't render this file because it contains an unexpected character in line 1 and column 58.
+243
View File
@@ -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
}
+257
View File
@@ -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
`
+45
View File
@@ -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)
}
}
+420
View File
@@ -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
// ".<zone>", 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
}
+174
View File
@@ -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")
}
}
+249
View File
@@ -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
}
+169
View File
@@ -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)
}
}
+274
View File
@@ -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
}
+184
View File
@@ -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()))
}
}
File diff suppressed because it is too large. Load diff
+452
View File
@@ -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)
}
}
+61
View File
@@ -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"`
}
+852
View File
@@ -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
}
+203
View File
@@ -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"`
}
+234
View File
@@ -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)
}
}
File diff suppressed because it is too large. Load diff
+13
View File
@@ -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))
}
}
+213
View File
@@ -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, "<address_match_element>")
}
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) }
+75
View File
@@ -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
}
+737
View File
@@ -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
}
+148
View File
@@ -0,0 +1,148 @@
package importer
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
"jarl/internal/validate"
)
// copyTree copies testdata/<name> 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)
}
}
}
+7
View File
@@ -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.
+17
View File
@@ -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"
+7
View File
@@ -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"; };
};
+14
View File
@@ -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; };
};
+22
View File
@@ -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; };
};
+4
View File
@@ -0,0 +1,4 @@
key "rndc-key" {
algorithm hmac-sha256;
secret "cm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQ=";
};
+135
View File
@@ -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"`
}
+568
View File
@@ -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)
}
+357
View File
@@ -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 <name>
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
}
+157
View File
@@ -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
}
+189
View File
@@ -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
}
+195
View File
@@ -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)
}
}
+148
View File
@@ -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
}
+160
View File
@@ -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
}
}
}
+431
View File
@@ -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))
}
+205
View File
@@ -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")
}
}
+446
View File
@@ -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
}
+159
View File
@@ -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)
}
}
}
+85
View File
@@ -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: "<IPv4 address>", Example: "192.0.2.10"},
{Type: "AAAA", Common: true, Description: "IPv6 address", Format: "<IPv6 address>", Example: "2001:db8::10"},
{Type: "CNAME", Common: true, Description: "Alias for another name (no other records may exist at the same name)", Format: "<target name>", Example: "www.example.com."},
{Type: "MX", Common: true, Description: "Mail exchanger", Format: "<preference> <mail server host name>", 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: "<text> 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: "<name server host name>", Example: "ns1.example.com."},
{Type: "PTR", Common: true, Description: "Reverse mapping (in in-addr.arpa / ip6.arpa zones)", Format: "<host name>", Example: "host.example.com."},
{Type: "SRV", Common: true, Description: "Service location (owner _service._proto.name)", Format: "<priority> <weight> <port> <target>", Example: "0 5 5060 sip.example.com."},
{Type: "CAA", Common: true, Description: "Certification authority authorization", Format: "<flags> <tag> <value>", Example: "0 issue \"letsencrypt.org\""},
{Type: "HTTPS", Common: true, Description: "HTTPS service binding (RFC 9460)", Format: "<priority> <target> [params...]", Example: "1 . alpn=\"h2,h3\""},
{Type: "SVCB", Description: "General service binding (RFC 9460)", Format: "<priority> <target> [params...]", Example: "1 svc.example.com. alpn=\"h2\" port=8443"},
{Type: "SSHFP", Description: "SSH host key fingerprint", Format: "<algorithm> <fp type> <fingerprint hex>", Example: "4 2 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},
{Type: "TLSA", Description: "TLS certificate association, DANE (owner _port._proto.name)", Format: "<usage> <selector> <matching type> <data hex>", Example: "3 1 1 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
{Type: "DS", Description: "Delegation signer for a signed child zone (at a delegation point)", Format: "<key tag> <algorithm> <digest type> <digest hex>", Example: "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
{Type: "DNAME", Description: "Redirects a whole subtree to another name", Format: "<target name>", Example: "other.example.net."},
{Type: "NAPTR", Description: "Naming authority pointer", Format: "<order> <preference> \"<flags>\" \"<service>\" \"<regexp>\" <replacement>", Example: "100 10 \"u\" \"E2U+sip\" \"!^.*$!sip:info@example.com!\" ."},
{Type: "LOC", Description: "Geographical location", Format: "<lat> <lon> <alt>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: "\"<cpu>\" \"<os>\"", Example: "\"PC\" \"Linux\""},
{Type: "RP", Description: "Responsible person", Format: "<mailbox name> <TXT record name>", Example: "admin.example.com. ."},
{Type: "URI", Description: "Uniform resource identifier", Format: "<priority> <weight> \"<uri>\"", Example: "10 1 \"https://example.com/\""},
{Type: "OPENPGPKEY", Description: "OpenPGP public key (DANE)", Format: "<base64 key>"},
{Type: "SMIMEA", Description: "S/MIME certificate association", Format: "<usage> <selector> <matching type> <data hex>"},
{Type: "CERT", Description: "Certificate", Format: "<type> <key tag> <algorithm> <base64 certificate>"},
{Type: "DHCID", Description: "DHCP identifier", Format: "<base64 data>"},
{Type: "AFSDB", Description: "AFS database location", Format: "<subtype> <host name>", Example: "1 afs.example.com."},
{Type: "APL", Description: "Address prefix list", Format: "[!]<family>:<prefix>/<len> ...", Example: "1:192.168.32.0/21"},
{Type: "IPSECKEY", Description: "IPsec key", Format: "<precedence> <gateway type> <algorithm> <gateway> <base64 key>"},
{Type: "KX", Description: "Key exchanger", Format: "<preference> <host name>", Example: "10 kx.example.com."},
{Type: "KEY", Description: "Public key (SIG(0) and other legacy uses)", Format: "<flags> <protocol> <algorithm> <base64 key>"},
{Type: "SPF", Description: "Deprecated SPF record type; publish SPF policies as TXT instead", Format: "\"v=spf1 ...\""},
{Type: "CSYNC", Description: "Child-to-parent synchronization", Format: "<soa serial> <flags> <type list>", 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...)
}
+262
View File
@@ -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
}
+447
View File
@@ -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)
}
+344
View File
@@ -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
}
+549
View File
@@ -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
}
+541
View File
@@ -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}
})
}
+144
View File
@@ -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
}
+183
View File
@@ -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
}
+221
View File
@@ -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
}
+379
View File
@@ -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
}
+328
View File
@@ -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
}
+171
View File
@@ -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
}
+177
View File
@@ -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
}
+266
View File
@@ -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
}
+364
View File
@@ -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), ",")
}
+164
View File
@@ -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))
}
}
+289
View File
@@ -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
}
+78
View File
@@ -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}
}
+615
View File
@@ -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, "<address_match_element>") && !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
}
+472
View File
@@ -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.<domain>, 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
}
+184
View File
@@ -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, ".")
}
+346
View File
@@ -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")
}
}
+182
View File
@@ -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)
}
+7
View File
@@ -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"
+184
View File
@@ -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;
}
+187
View File
@@ -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");
});
@@ -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 <token>."),
tokensEl),
grantHelp(grants));
await drawTokens();
}
+54
View File
@@ -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); }
}
+34
View File
@@ -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);
}
+112
View File
@@ -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;
}
@@ -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);
}
@@ -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); }
}
+98
View File
@@ -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); }
}
+155
View File
@@ -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"))));
}
@@ -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." })));
}
+151
View File
@@ -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);
}
+90
View File
@@ -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); }
}
+290
View File
@@ -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));
}
+137
View File
@@ -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");
}
+234
View File
@@ -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; }
}
+242
View File
@@ -0,0 +1,242 @@
// Small DOM helpers and widgets used by every page.
// h creates an element. attrs may contain class, style, on<event>
// 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); };
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jarl</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 13 L3 6 L5.5 8.5 L8 3 L10.5 8.5 L13 6 L13 13 Z' fill='%232f5fb3'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/style.css">
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<noscript><p style="padding:16px">Jarl's web interface needs JavaScript. The REST API and the <code>jarl</code> CLI work without it.</p></noscript>
<div id="app"></div>
<div id="toasts"></div>
</body>
</html>
+20
View File
@@ -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
}