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