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 }