initial commit
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user