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 }