initial commit
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user