Files
2026-08-16 21:18:45 -05:00

338 lines
10 KiB
Go

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
}