378 lines
11 KiB
Go
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
|
|
}
|