Files
vibedns/internal/app/zones.go
T
2026-08-16 21:18:45 -05:00

378 lines
11 KiB
Go

package app
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/zonefile"
)
// ZoneInput is the editable surface of a zone.
type ZoneInput struct {
Name string `json:"name"`
Kind string `json:"kind"`
CIDR string `json:"cidr"` // reverse zones may be created from a subnet instead
Description string `json:"description"`
Enabled *bool `json:"enabled"`
DefaultTTL uint32 `json:"default_ttl"`
PrimaryNS string `json:"primary_ns"`
AdminEmail string `json:"admin_email"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
Minimum uint32 `json:"minimum"`
AutoSerial *bool `json:"auto_serial"`
Serial *uint32 `json:"serial"`
}
// Zones lists zones matching a filter.
func (a *App) Zones(ctx context.Context, f database.ZoneFilter) ([]models.Zone, error) {
zones, err := a.DB.Zones(ctx, f)
if err != nil {
return nil, Internal(err, "The zone list could not be loaded.")
}
return zones, nil
}
// Zone loads one zone.
func (a *App) Zone(ctx context.Context, id int64) (models.Zone, error) {
z, err := a.DB.Zone(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return z, NotFound("Zone %d was not found.", id)
}
if err != nil {
return z, Internal(err, "The zone could not be loaded.")
}
return z, nil
}
// CreateZone validates and stores a new zone.
//
// A reverse zone may be given either as an explicit apex name or as the subnet
// it covers, which is what the UI sends: administrators should not have to
// reverse octets by hand.
func (a *App) CreateZone(ctx context.Context, actor auditlog.Actor, in ZoneInput) (models.Zone, error) {
z, note, err := a.normaliseZoneInput(in, models.Zone{})
if err != nil {
return models.Zone{}, err
}
created, err := a.DB.CreateZone(ctx, z)
if err != nil {
return models.Zone{}, translate(err,
"Zone not found.",
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(z.Name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.create", auditlog.ObjectZone, created.ID, created.Name,
auditlog.Changes("kind", string(created.Kind), "ttl", fmt.Sprint(created.DefaultTTL)))
a.Runtime.RequestReload()
if note != "" {
a.Log.Info("reverse zone name derived from subnet", "zone", created.Name, "note", note)
}
return created, nil
}
// ReverseZoneName previews the zone apex a subnet maps to, for the UI's live
// hint under the CIDR field.
func (a *App) ReverseZoneName(cidr string) (name, note string, err error) {
name, note, err = validate.ReverseZone(cidr)
if err != nil {
return "", "", Invalid("%s", err.Error())
}
return name, note, nil
}
// UpdateZone saves zone metadata.
func (a *App) UpdateZone(ctx context.Context, actor auditlog.Actor, id int64, in ZoneInput) (models.Zone, error) {
existing, err := a.Zone(ctx, id)
if err != nil {
return models.Zone{}, err
}
z, _, err := a.normaliseZoneInput(in, existing)
if err != nil {
return models.Zone{}, err
}
z.ID = id
z.CreatedAt = existing.CreatedAt
updated, err := a.DB.UpdateZone(ctx, z)
if err != nil {
return models.Zone{}, translate(err,
fmt.Sprintf("Zone %d was not found.", id),
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(z.Name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.update", auditlog.ObjectZone, id, updated.Name,
auditlog.Changes("serial", fmt.Sprint(updated.Serial), "ttl", fmt.Sprint(updated.DefaultTTL)))
a.Runtime.RequestReload()
return updated, nil
}
// normaliseZoneInput validates input and merges it over an existing zone.
func (a *App) normaliseZoneInput(in ZoneInput, base models.Zone) (models.Zone, string, error) {
z := base
var note string
name := strings.TrimSpace(in.Name)
if cidr := strings.TrimSpace(in.CIDR); cidr != "" && name == "" {
derived, n, err := validate.ReverseZone(cidr)
if err != nil {
return z, "", Invalid("%s", err.Error())
}
name = derived
note = n
kind, err := validate.ReverseZoneKindForCIDR(cidr)
if err == nil {
in.Kind = kind
}
}
if name == "" && base.Name == "" {
return z, "", Invalid("A zone name is required.")
}
if name != "" {
normalised, err := validate.NormaliseZoneName(name)
if err != nil {
return z, "", Invalid("%s", err.Error())
}
z.Name = normalised
}
kind := models.ZoneKind(strings.TrimSpace(in.Kind))
if kind == "" {
kind = models.ZoneKind(validate.ZoneKindForName(z.Name))
}
if !kind.Valid() {
return z, "", Invalid("Zone kind %q must be forward, reverse4 or reverse6.", in.Kind)
}
z.Kind = kind
z.Description = strings.TrimSpace(in.Description)
if in.Enabled != nil {
z.Enabled = *in.Enabled
} else if base.ID == 0 {
z.Enabled = true
}
z.DefaultTTL = in.DefaultTTL
if z.DefaultTTL == 0 {
z.DefaultTTL = base.DefaultTTL
}
if z.DefaultTTL == 0 {
z.DefaultTTL = a.Settings().DNS.DefaultTTL
}
if z.DefaultTTL < 1 || z.DefaultTTL > 604800 {
return z, "", Invalid("The default TTL must be between 1 and 604800 seconds.")
}
z.PrimaryNS = strings.TrimSpace(in.PrimaryNS)
if z.PrimaryNS == "" {
z.PrimaryNS = base.PrimaryNS
}
if z.PrimaryNS == "" {
z.PrimaryNS = "ns1." + z.Name
}
ns, err := validate.NormaliseFQDN(z.PrimaryNS)
if err != nil {
return z, "", Invalid("Primary name server: %s", err.Error())
}
z.PrimaryNS = ns
z.AdminEmail = strings.TrimSpace(in.AdminEmail)
if z.AdminEmail == "" {
z.AdminEmail = base.AdminEmail
}
if z.AdminEmail == "" {
z.AdminEmail = "hostmaster@" + strings.TrimSuffix(z.Name, ".")
}
z.Refresh = orDefault(in.Refresh, base.Refresh, 7200)
z.Retry = orDefault(in.Retry, base.Retry, 3600)
z.Expire = orDefault(in.Expire, base.Expire, 1209600)
z.Minimum = orDefault(in.Minimum, base.Minimum, 3600)
if in.AutoSerial != nil {
z.AutoSerial = *in.AutoSerial
} else if base.ID == 0 {
z.AutoSerial = true
}
if in.Serial != nil {
// A manual serial override is allowed, which matters when migrating a
// zone from another server that is already at a higher serial.
if *in.Serial == 0 {
return z, "", Invalid("The serial must be at least 1.")
}
z.Serial = *in.Serial
} else if z.Serial == 0 {
z.Serial = 1
}
return z, note, nil
}
func orDefault(v, fallback, def uint32) uint32 {
if v != 0 {
return v
}
if fallback != 0 {
return fallback
}
return def
}
// SetZoneEnabled toggles a zone.
func (a *App) SetZoneEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
z, err := a.Zone(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetZoneEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Zone %d was not found.", id), "")
}
action := "zone.disable"
if enabled {
action = "zone.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectZone, id, z.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeleteZone removes a zone and all of its records.
func (a *App) DeleteZone(ctx context.Context, actor auditlog.Actor, id int64) error {
z, err := a.Zone(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteZone(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Zone %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "zone.delete", auditlog.ObjectZone, id, z.Name,
auditlog.Changes("records", fmt.Sprint(z.RecordCount)))
a.Runtime.RequestReload()
return nil
}
// CloneZone copies a zone under a new name.
func (a *App) CloneZone(ctx context.Context, actor auditlog.Actor, id int64, newName, description string) (models.Zone, error) {
src, err := a.Zone(ctx, id)
if err != nil {
return models.Zone{}, err
}
name, err := validate.NormaliseZoneName(newName)
if err != nil {
return models.Zone{}, Invalid("%s", err.Error())
}
if name == src.Name {
return models.Zone{}, Invalid("The new zone name must differ from the zone being cloned.")
}
clone, err := a.DB.CloneZone(ctx, id, name, strings.TrimSpace(description))
if err != nil {
return models.Zone{}, translate(err,
fmt.Sprintf("Zone %d was not found.", id),
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.clone", auditlog.ObjectZone, clone.ID, clone.Name,
auditlog.Changes("source", src.Name))
a.Runtime.RequestReload()
return clone, nil
}
// --- Zone file import and export ---------------------------------------
// ImportMode selects how an imported zone file is applied.
type ImportMode string
const (
// ImportReplace discards the zone's existing records.
ImportReplace ImportMode = "replace"
// ImportMerge adds the imported records to what is already there.
ImportMerge ImportMode = "merge"
)
// ImportResult reports the outcome of a zone file import.
type ImportResult struct {
Zone models.Zone `json:"zone"`
Summary zonefile.ParseSummary `json:"summary"`
Created bool `json:"zone_created"`
}
// ImportZoneFile parses a BIND zone file and stores its records.
//
// The whole file is validated before anything is written, so a syntax error
// halfway through never leaves a zone half-imported.
func (a *App) ImportZoneFile(ctx context.Context, actor auditlog.Actor, zoneID int64,
r io.Reader, mode ImportMode) (*ImportResult, error) {
z, err := a.Zone(ctx, zoneID)
if err != nil {
return nil, err
}
parsed, err := zonefile.Parse(r, z.Name, z.DefaultTTL)
if err != nil {
return nil, Invalid("%s", err.Error())
}
if problems := zonefile.ValidateRecords(z.Name, parsed.Records, z.DefaultTTL); len(problems) > 0 {
return nil, Invalid("The zone file contains records this server cannot store:\n%s",
strings.Join(problems, "\n"))
}
switch mode {
case ImportMerge:
err = a.DB.AppendZoneRecords(ctx, zoneID, parsed.Records)
default:
mode = ImportReplace
err = a.DB.ReplaceZoneRecords(ctx, zoneID, parsed.Records)
}
if err != nil {
return nil, Internal(err, "The imported records could not be saved.")
}
// Adopt the SOA timers from the file, but keep our own serial management
// unless the file's serial is higher.
if parsed.SOA != nil {
updated := z
zonefile.ZoneMetadataFromSOA(&updated, parsed.SOA)
if updated.Serial < z.Serial {
updated.Serial = z.Serial
}
if _, err := a.DB.UpdateZone(ctx, updated); err != nil {
a.Log.Warn("could not apply imported SOA values", "zone", z.Name, "error", err)
} else {
z = updated
}
}
a.Audit.RecordID(ctx, actor, "zone.import", auditlog.ObjectZone, zoneID, z.Name,
auditlog.Changes("mode", string(mode), "records", fmt.Sprint(parsed.Summary.RecordsParsed)))
a.Runtime.RequestReload()
return &ImportResult{Zone: z, Summary: parsed.Summary}, nil
}
// ExportZoneFile renders a zone as a BIND zone file.
func (a *App) ExportZoneFile(ctx context.Context, zoneID int64) (models.Zone, []byte, error) {
z, err := a.Zone(ctx, zoneID)
if err != nil {
return z, nil, err
}
recs, err := a.DB.ZoneRecordsRaw(ctx, zoneID)
if err != nil {
return z, nil, Internal(err, "The zone records could not be loaded.")
}
var buf bytes.Buffer
if err := zonefile.Export(&buf, z, recs); err != nil {
return z, nil, Internal(err, "The zone file could not be generated.")
}
return z, buf.Bytes(), nil
}