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

332 lines
10 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"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"
)
// RecordInput is the editable surface of a resource record.
//
// Data may be supplied either as finished rdata (the advanced editor and the
// REST API) or as the individual fields of a type-specific editor, which the
// service assembles and quotes correctly.
type RecordInput struct {
Name string `json:"name"`
Type string `json:"type"`
Data string `json:"data"`
Fields map[string]string `json:"fields,omitempty"`
TTL *uint32 `json:"ttl"`
Enabled *bool `json:"enabled"`
Comment string `json:"comment"`
}
// Records lists records matching a filter, with the total match count.
func (a *App) Records(ctx context.Context, f database.RecordFilter) ([]models.Record, int, error) {
recs, total, err := a.DB.Records(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The record list could not be loaded.")
}
return recs, total, nil
}
// Record loads one record.
func (a *App) Record(ctx context.Context, id int64) (models.Record, error) {
r, err := a.DB.Record(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return r, NotFound("Record %d was not found.", id)
}
if err != nil {
return r, Internal(err, "The record could not be loaded.")
}
return r, nil
}
// CreateRecord validates and stores a record.
func (a *App) CreateRecord(ctx context.Context, actor auditlog.Actor, zoneID int64, in RecordInput) (models.Record, error) {
zone, err := a.Zone(ctx, zoneID)
if err != nil {
return models.Record{}, err
}
rec, err := a.prepareRecord(ctx, zone, in, 0)
if err != nil {
return models.Record{}, err
}
created, err := a.DB.CreateRecord(ctx, rec)
if err != nil {
return models.Record{}, Internal(err, "The record could not be saved.")
}
a.Audit.RecordID(ctx, actor, "record.create", auditlog.ObjectRecord, created.ID,
validate.AbsoluteName(created.Name, zone.Name),
auditlog.Changes("zone", zone.Name, "type", created.Type, "data", created.Data))
a.Runtime.RequestReload()
return created, nil
}
// UpdateRecord validates and saves an existing record.
func (a *App) UpdateRecord(ctx context.Context, actor auditlog.Actor, id int64, in RecordInput) (models.Record, error) {
existing, err := a.Record(ctx, id)
if err != nil {
return models.Record{}, err
}
zone, err := a.Zone(ctx, existing.ZoneID)
if err != nil {
return models.Record{}, err
}
// Carry forward anything the caller did not supply, so a PATCH-style
// update does not silently blank fields.
if strings.TrimSpace(in.Name) == "" {
in.Name = existing.Name
}
if strings.TrimSpace(in.Type) == "" {
in.Type = existing.Type
}
if in.Enabled == nil {
e := existing.Enabled
in.Enabled = &e
}
rec, err := a.prepareRecord(ctx, zone, in, id)
if err != nil {
return models.Record{}, err
}
rec.ID = id
rec.ZoneID = existing.ZoneID
updated, err := a.DB.UpdateRecord(ctx, rec)
if err != nil {
return models.Record{}, translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "record.update", auditlog.ObjectRecord, id,
validate.AbsoluteName(updated.Name, zone.Name),
auditlog.Changes("zone", zone.Name, "type", updated.Type, "data", updated.Data))
a.Runtime.RequestReload()
return updated, nil
}
// prepareRecord validates a record against its zone. excludeID lets an update
// ignore the record being edited when checking for conflicts.
func (a *App) prepareRecord(ctx context.Context, zone models.Zone, in RecordInput, excludeID int64) (models.Record, error) {
rtype, err := validate.NormaliseType(in.Type)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
name, err := validate.NormaliseRecordName(in.Name, zone.Name)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
data := strings.TrimSpace(in.Data)
if data == "" && len(in.Fields) > 0 {
data, err = validate.AssembleRData(rtype, in.Fields)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
}
if data == "" {
return models.Record{}, Invalid("Record data is required for a %s record.", rtype)
}
// TXT-style types are quoted for the caller when they clearly are not.
if (rtype == "TXT" || rtype == "SPF") && !strings.HasPrefix(data, `"`) {
data = validate.QuoteTXT(data)
}
ttl := zone.DefaultTTL
if in.TTL != nil {
if *in.TTL < 1 || *in.TTL > 604800 {
return models.Record{}, Invalid("The TTL must be between 1 and 604800 seconds.")
}
ttl = *in.TTL
}
if name == "@" {
if err := validate.ApexRestricted(rtype); err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
}
// Compile the record now so a malformed value is reported here, with a
// message about this record, rather than as a warning at index-build time.
if _, err := validate.BuildRR(zone.Name, name, rtype, data, ttl); err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
if err := a.checkCNAMEConflict(ctx, zone, name, rtype, excludeID); err != nil {
return models.Record{}, err
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
rec := models.Record{
ZoneID: zone.ID,
Name: name,
Type: rtype,
Data: data,
Enabled: enabled,
Comment: strings.TrimSpace(in.Comment),
}
if in.TTL != nil {
rec.TTL = in.TTL
}
return rec, nil
}
// checkCNAMEConflict enforces the RFC 1034 rule that a CNAME may not coexist
// with other data at the same name.
func (a *App) checkCNAMEConflict(ctx context.Context, zone models.Zone, name, rtype string, excludeID int64) error {
existing, _, err := a.DB.Records(ctx, database.RecordFilter{ZoneID: zone.ID})
if err != nil {
return Internal(err, "Existing records could not be checked.")
}
var types []string
for _, r := range existing {
if r.ID == excludeID || r.Name != name {
continue
}
types = append(types, r.Type)
}
if err := validate.CNAMEConflict(rtype, types); err != nil {
return Invalid("%s", err.Error())
}
return nil
}
// SetRecordEnabled toggles one record.
func (a *App) SetRecordEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
rec, err := a.Record(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetRecordEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
action := "record.disable"
if enabled {
action = "record.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectRecord, id, rec.Name,
auditlog.Changes("type", rec.Type))
a.Runtime.RequestReload()
return nil
}
// DeleteRecord removes one record.
func (a *App) DeleteRecord(ctx context.Context, actor auditlog.Actor, id int64) error {
rec, err := a.Record(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteRecord(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "record.delete", auditlog.ObjectRecord, id, rec.Name,
auditlog.Changes("type", rec.Type, "data", rec.Data))
a.Runtime.RequestReload()
return nil
}
// BulkAction names a bulk operation on selected records.
type BulkAction string
// Supported bulk operations.
const (
BulkEnable BulkAction = "enable"
BulkDisable BulkAction = "disable"
BulkDelete BulkAction = "delete"
)
// BulkRecords applies an action to several records of one zone.
func (a *App) BulkRecords(ctx context.Context, actor auditlog.Actor, zoneID int64, ids []int64, action BulkAction) (int, error) {
zone, err := a.Zone(ctx, zoneID)
if err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, Invalid("Select at least one record first.")
}
var n int
switch action {
case BulkDelete:
n, err = a.DB.DeleteRecords(ctx, zoneID, ids)
case BulkEnable:
n, err = a.DB.SetRecordsEnabled(ctx, zoneID, ids, true)
case BulkDisable:
n, err = a.DB.SetRecordsEnabled(ctx, zoneID, ids, false)
default:
return 0, Invalid("Unknown bulk action %q.", action)
}
if err != nil {
return 0, Internal(err, "The selected records could not be updated.")
}
a.Audit.RecordID(ctx, actor, "record.bulk_"+string(action), auditlog.ObjectRecord, zoneID, zone.Name,
auditlog.Changes("records", fmt.Sprint(n)))
a.Runtime.RequestReload()
return n, nil
}
// RecordTypes returns the record type catalogue for the editors.
func (a *App) RecordTypes() []validate.TypeInfo { return validate.TypeInfos() }
// RecordTypesInUse lists the distinct types present in a zone, for filters.
func (a *App) RecordTypesInUse(ctx context.Context, zoneID int64) ([]string, error) {
types, err := a.DB.RecordTypesInUse(ctx, zoneID)
if err != nil {
return nil, Internal(err, "Record types could not be loaded.")
}
return types, nil
}
// PTRSuggestion describes the reverse record the UI offers to create alongside
// an address record.
type PTRSuggestion struct {
ZoneID int64 `json:"zone_id"`
ZoneName string `json:"zone_name"`
Name string `json:"name"`
Data string `json:"data"`
}
// SuggestPTR finds the reverse zone covering an address and returns the PTR
// record that would point back at hostname.
func (a *App) SuggestPTR(ctx context.Context, ip, hostname string) (*PTRSuggestion, error) {
ptrName, err := validate.PTRName(ip)
if err != nil {
return nil, Invalid("%s", err.Error())
}
target, err := validate.NormaliseFQDN(hostname)
if err != nil {
return nil, Invalid("%s", err.Error())
}
zones, err := a.DB.Zones(ctx, database.ZoneFilter{Kind: "reverse"})
if err != nil {
return nil, Internal(err, "Reverse zones could not be loaded.")
}
var best models.Zone
for _, z := range zones {
if validate.IsSubdomain(ptrName, z.Name) && len(z.Name) > len(best.Name) {
best = z
}
}
if best.ID == 0 {
return nil, NotFound("No reverse zone covers %s. Create one first.", ip)
}
rel, err := validate.NormaliseRecordName(ptrName, best.Name)
if err != nil {
return nil, Invalid("%s", err.Error())
}
return &PTRSuggestion{ZoneID: best.ID, ZoneName: best.Name, Name: rel, Data: target}, nil
}