package app import ( "context" "encoding/json" "fmt" "io" "time" "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/config" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/version" ) // ConfigExportVersion is the schema version of the export format. It is // checked on import so a future format change fails loudly rather than being // half-applied. const ConfigExportVersion = 1 // ConfigExport is a portable snapshot of the configuration. // // It deliberately excludes the administrator password hash and every API token // hash: an export is meant to be copied between machines and checked into a // configuration repository, so it must not carry credentials. type ConfigExport struct { FormatVersion int `json:"format_version"` ExportedAt time.Time `json:"exported_at"` AppVersion string `json:"app_version"` Settings map[string]string `json:"settings"` Zones []ExportedZone `json:"zones"` Networks []ExportedNetwork `json:"networks"` Policies []ExportedPolicy `json:"policies"` Lists []ExportedList `json:"lists"` } // ExportedZone is a zone with its records. type ExportedZone struct { models.Zone Records []models.Record `json:"records"` } // ExportedNetwork is a network with the names of its policies. type ExportedNetwork struct { models.Network PolicyNames []string `json:"policy_names"` } // ExportedPolicy is a policy with the names of its lists. type ExportedPolicy struct { models.Policy ListNames []string `json:"list_names"` } // ExportedList is a domain list with its domains. type ExportedList struct { models.DomainList Domains []ExportedDomain `json:"domains"` } // ExportedDomain is one entry in a domain list. type ExportedDomain struct { Domain string `json:"domain"` MatchSubdomains bool `json:"match_subdomains"` Comment string `json:"comment,omitempty"` } // ExportConfig builds a configuration export. // // includeDomains controls whether imported blocklists are included. A single // blocklist can hold hundreds of thousands of domains that are reproducible // from their source URL, so the default export omits them. func (a *App) ExportConfig(ctx context.Context, includeDomains bool) (*ConfigExport, error) { stored, err := a.DB.Settings(ctx) if err != nil { return nil, Internal(err, "The settings could not be exported.") } // The CSRF signing key is a secret and is regenerated per installation. delete(stored, keyCSRFSecret) out := &ConfigExport{ FormatVersion: ConfigExportVersion, ExportedAt: time.Now().UTC(), AppVersion: version.Version, Settings: stored, } zones, err := a.DB.Zones(ctx, database.ZoneFilter{}) if err != nil { return nil, Internal(err, "The zones could not be exported.") } for _, z := range zones { recs, err := a.DB.ZoneRecordsRaw(ctx, z.ID) if err != nil { return nil, Internal(err, "The zone records could not be exported.") } out.Zones = append(out.Zones, ExportedZone{Zone: z, Records: recs}) } nets, err := a.DB.Networks(ctx, "", true) if err != nil { return nil, Internal(err, "The networks could not be exported.") } for _, n := range nets { e := ExportedNetwork{Network: n} for _, p := range n.Policies { e.PolicyNames = append(e.PolicyNames, p.Name) } e.Network.Policies = nil // names carry the relationship instead of IDs out.Networks = append(out.Networks, e) } policies, err := a.DB.Policies(ctx) if err != nil { return nil, Internal(err, "The policies could not be exported.") } for _, p := range policies { e := ExportedPolicy{Policy: p} e.ListNames = append(append([]string{}, p.BlacklistName...), p.AllowlistName...) e.Policy.BlacklistIDs, e.Policy.AllowlistIDs = nil, nil out.Policies = append(out.Policies, e) } lists, err := a.DB.DomainLists(ctx, "", "") if err != nil { return nil, Internal(err, "The lists could not be exported.") } for _, l := range lists { e := ExportedList{DomainList: l} if includeDomains { err := a.DB.ExportDomains(ctx, l.ID, func(domain string, sub bool) { e.Domains = append(e.Domains, ExportedDomain{Domain: domain, MatchSubdomains: sub}) }) if err != nil { return nil, Internal(err, "The list domains could not be exported.") } } out.Lists = append(out.Lists, e) } return out, nil } // WriteConfigExport writes an export as indented JSON. func (a *App) WriteConfigExport(ctx context.Context, w io.Writer, includeDomains bool) error { export, err := a.ExportConfig(ctx, includeDomains) if err != nil { return err } enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(export); err != nil { return Internal(err, "The export could not be written.") } return nil } // ImportReport summarises what a configuration import created. type ImportReport struct { Zones int `json:"zones"` Records int `json:"records"` Networks int `json:"networks"` Policies int `json:"policies"` Lists int `json:"lists"` Domains int `json:"domains"` Settings int `json:"settings"` Skipped int `json:"skipped"` Conflicts []string `json:"conflicts,omitempty"` } // ImportConfig applies a configuration export. // // Objects that already exist are skipped rather than overwritten, and reported // in Conflicts, so an import can never silently destroy configuration that is // already in production. func (a *App) ImportConfig(ctx context.Context, actor auditlog.Actor, r io.Reader, applySettings bool) (*ImportReport, error) { var in ConfigExport dec := json.NewDecoder(r) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { return nil, Invalid("The import file could not be read as a VibeDNS configuration export: %v", err) } if in.FormatVersion != ConfigExportVersion { return nil, Invalid("This export uses format version %d, but this server understands version %d.", in.FormatVersion, ConfigExportVersion) } rep := &ImportReport{} // Lists first: policies reference them by name. listIDs := map[string]int64{} for _, l := range in.Lists { existing, err := a.DB.DomainLists(ctx, l.Kind, l.Name) if err != nil { return nil, Internal(err, "Existing lists could not be checked.") } var id int64 found := false for _, e := range existing { if e.Name == l.Name && e.Kind == l.Kind { id, found = e.ID, true break } } if !found { created, err := a.DB.CreateDomainList(ctx, l.DomainList) if err != nil { rep.Skipped++ rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("list %q could not be created", l.Name)) continue } id = created.ID rep.Lists++ } else { rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("list %q already exists and was left unchanged", l.Name)) } listIDs[l.Kind+"/"+l.Name] = id if len(l.Domains) > 0 { rows := make([]database.ImportDomain, 0, len(l.Domains)) for _, d := range l.Domains { rows = append(rows, database.ImportDomain{ Domain: d.Domain, MatchSubdomains: d.MatchSubdomains, Comment: d.Comment, }) } imported, _, err := a.DB.ImportDomains(ctx, id, rows) if err != nil { return nil, Internal(err, "The list domains could not be imported.") } rep.Domains += imported } } // Policies next: networks reference them by name. policyIDs := map[string]int64{} existingPolicies, err := a.DB.Policies(ctx) if err != nil { return nil, Internal(err, "Existing policies could not be checked.") } for _, p := range existingPolicies { policyIDs[p.Name] = p.ID } for _, p := range in.Policies { if _, exists := policyIDs[p.Name]; exists { rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("policy %q already exists and was left unchanged", p.Name)) continue } var ids []int64 for _, name := range p.ListNames { if id, ok := listIDs[models.KindBlacklist+"/"+name]; ok { ids = append(ids, id) } else if id, ok := listIDs[models.KindAllowlist+"/"+name]; ok { ids = append(ids, id) } } created, err := a.DB.CreatePolicy(ctx, p.Policy, ids) if err != nil { rep.Skipped++ continue } policyIDs[p.Name] = created.ID rep.Policies++ } // Networks. existingNets, err := a.DB.Networks(ctx, "", false) if err != nil { return nil, Internal(err, "Existing networks could not be checked.") } netNames := map[string]bool{} for _, n := range existingNets { netNames[n.Name] = true } for _, n := range in.Networks { if netNames[n.Name] { rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("network %q already exists and was left unchanged", n.Name)) continue } var ids []int64 for _, name := range n.PolicyNames { if id, ok := policyIDs[name]; ok { ids = append(ids, id) } } if _, err := a.DB.CreateNetwork(ctx, n.Network, ids); err != nil { rep.Skipped++ continue } rep.Networks++ } // Zones and their records. for _, z := range in.Zones { if _, err := a.DB.ZoneByName(ctx, z.Name); err == nil { rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("zone %s already exists and was left unchanged", z.Name)) continue } zone := z.Zone zone.ID = 0 created, err := a.DB.CreateZone(ctx, zone) if err != nil { rep.Skipped++ rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("zone %s could not be created", z.Name)) continue } rep.Zones++ if len(z.Records) > 0 { if err := a.DB.AppendZoneRecords(ctx, created.ID, z.Records); err != nil { return nil, Internal(err, "The zone records could not be imported.") } rep.Records += len(z.Records) } } if applySettings && len(in.Settings) > 0 { settings := config.LoadSettings(in.Settings) if err := settings.Validate(); err != nil { rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("settings were not applied because they are invalid: %v", err)) } else { delete(in.Settings, keyCSRFSecret) if err := a.DB.SetSettings(ctx, in.Settings); err != nil { return nil, Internal(err, "The settings could not be imported.") } rep.Settings = len(in.Settings) } } a.Audit.Record(ctx, actor, "config.import", auditlog.ObjectConfig, "", "configuration import", auditlog.Changes( "zones", fmt.Sprint(rep.Zones), "records", fmt.Sprint(rep.Records), "networks", fmt.Sprint(rep.Networks), "policies", fmt.Sprint(rep.Policies), "lists", fmt.Sprint(rep.Lists), "domains", fmt.Sprint(rep.Domains))) a.Runtime.RequestReload() return rep, nil }