332 lines
10 KiB
Go
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
|
|
}
|