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:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}}},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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.
|
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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"; };
|
||||
};
|
||||
@@ -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; };
|
||||
};
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
key "rndc-key" {
|
||||
algorithm hmac-sha256;
|
||||
secret "cm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQ=";
|
||||
};
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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), ",")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ".")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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." })));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user