package app import ( "context" "errors" "fmt" "io" "strings" "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/blacklist" "github.com/owen/vibedns/internal/config" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/validate" ) // --- Client networks ---------------------------------------------------- // NetworkInput is the editable surface of a client network. type NetworkInput struct { Name string `json:"name"` CIDR string `json:"cidr"` Description string `json:"description"` Enabled *bool `json:"enabled"` PolicyIDs []int64 `json:"policy_ids"` } // Networks lists client networks with their policy assignments. func (a *App) Networks(ctx context.Context, search string) ([]models.Network, error) { nets, err := a.DB.Networks(ctx, search, true) if err != nil { return nil, Internal(err, "The network list could not be loaded.") } return nets, nil } // Network loads one client network. func (a *App) Network(ctx context.Context, id int64) (models.Network, error) { n, err := a.DB.Network(ctx, id) if errors.Is(err, database.ErrNotFound) { return n, NotFound("Network %d was not found.", id) } if err != nil { return n, Internal(err, "The network could not be loaded.") } return n, nil } func (a *App) normaliseNetwork(in NetworkInput, base models.Network) (models.Network, error) { n := base if name := strings.TrimSpace(in.Name); name != "" { n.Name = name } if n.Name == "" { return n, Invalid("A network name is required.") } if cidr := strings.TrimSpace(in.CIDR); cidr != "" { p, err := config.ParseCIDROrIP(cidr) if err != nil { return n, Invalid("Subnet %q: %s", cidr, err.Error()) } n.CIDR = p.String() } if n.CIDR == "" { return n, Invalid("A subnet in CIDR notation is required, for example 192.168.1.0/24.") } n.Description = strings.TrimSpace(in.Description) if in.Enabled != nil { n.Enabled = *in.Enabled } else if base.ID == 0 { n.Enabled = true } return n, nil } // CreateNetwork stores a client network. func (a *App) CreateNetwork(ctx context.Context, actor auditlog.Actor, in NetworkInput) (models.Network, error) { n, err := a.normaliseNetwork(in, models.Network{}) if err != nil { return models.Network{}, err } created, err := a.DB.CreateNetwork(ctx, n, in.PolicyIDs) if err != nil { return models.Network{}, translate(err, "Network not found.", fmt.Sprintf("A network named %q already exists.", n.Name)) } a.Audit.RecordID(ctx, actor, "network.create", auditlog.ObjectNetwork, created.ID, created.Name, auditlog.Changes("cidr", created.CIDR, "policies", fmt.Sprint(len(in.PolicyIDs)))) a.Runtime.RequestReload() return created, nil } // UpdateNetwork saves a client network and its policy assignments. func (a *App) UpdateNetwork(ctx context.Context, actor auditlog.Actor, id int64, in NetworkInput) (models.Network, error) { existing, err := a.Network(ctx, id) if err != nil { return models.Network{}, err } n, err := a.normaliseNetwork(in, existing) if err != nil { return models.Network{}, err } n.ID = id updated, err := a.DB.UpdateNetwork(ctx, n, in.PolicyIDs) if err != nil { return models.Network{}, translate(err, fmt.Sprintf("Network %d was not found.", id), fmt.Sprintf("A network named %q already exists.", n.Name)) } a.Audit.RecordID(ctx, actor, "network.update", auditlog.ObjectNetwork, id, updated.Name, auditlog.Changes("cidr", updated.CIDR, "policies", fmt.Sprint(len(in.PolicyIDs)))) a.Runtime.RequestReload() return updated, nil } // SetNetworkEnabled toggles a client network. func (a *App) SetNetworkEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error { n, err := a.Network(ctx, id) if err != nil { return err } if err := a.DB.SetNetworkEnabled(ctx, id, enabled); err != nil { return translate(err, fmt.Sprintf("Network %d was not found.", id), "") } action := "network.disable" if enabled { action = "network.enable" } a.Audit.RecordID(ctx, actor, action, auditlog.ObjectNetwork, id, n.Name, "") a.Runtime.RequestReload() return nil } // DeleteNetwork removes a client network. func (a *App) DeleteNetwork(ctx context.Context, actor auditlog.Actor, id int64) error { n, err := a.Network(ctx, id) if err != nil { return err } if err := a.DB.DeleteNetwork(ctx, id); err != nil { return translate(err, fmt.Sprintf("Network %d was not found.", id), "") } a.Audit.RecordID(ctx, actor, "network.delete", auditlog.ObjectNetwork, id, n.Name, auditlog.Changes("cidr", n.CIDR)) a.Runtime.RequestReload() return nil } // --- Policies ----------------------------------------------------------- // PolicyInput is the editable surface of a policy. type PolicyInput struct { Name string `json:"name"` Description string `json:"description"` Enabled *bool `json:"enabled"` BlockAction string `json:"block_action"` SinkholeIPv4 string `json:"sinkhole_ipv4"` SinkholeIPv6 string `json:"sinkhole_ipv6"` BlockTTL uint32 `json:"block_ttl"` ListIDs []int64 `json:"list_ids"` } // Policies lists every policy. func (a *App) Policies(ctx context.Context) ([]models.Policy, error) { p, err := a.DB.Policies(ctx) if err != nil { return nil, Internal(err, "The policy list could not be loaded.") } return p, nil } // Policy loads one policy. func (a *App) Policy(ctx context.Context, id int64) (models.Policy, error) { p, err := a.DB.Policy(ctx, id) if errors.Is(err, database.ErrNotFound) { return p, NotFound("Policy %d was not found.", id) } if err != nil { return p, Internal(err, "The policy could not be loaded.") } return p, nil } func (a *App) normalisePolicy(in PolicyInput, base models.Policy) (models.Policy, error) { p := base if name := strings.TrimSpace(in.Name); name != "" { p.Name = name } if p.Name == "" { return p, Invalid("A policy name is required.") } p.Description = strings.TrimSpace(in.Description) action := models.BlockAction(strings.ToLower(strings.TrimSpace(in.BlockAction))) if action == "" { action = base.BlockAction } if action == "" { action = models.BlockNXDOMAIN } if !action.Valid() { return p, Invalid("Block action %q must be nxdomain, refused or sinkhole.", in.BlockAction) } p.BlockAction = action p.SinkholeIPv4 = strings.TrimSpace(in.SinkholeIPv4) if p.SinkholeIPv4 == "" { p.SinkholeIPv4 = "0.0.0.0" } p.SinkholeIPv6 = strings.TrimSpace(in.SinkholeIPv6) if p.SinkholeIPv6 == "" { p.SinkholeIPv6 = "::" } if action == models.BlockSinkhole { if err := requireIP(p.SinkholeIPv4, true); err != nil { return p, Invalid("Sinkhole IPv4 address: %s", err.Error()) } if err := requireIP(p.SinkholeIPv6, false); err != nil { return p, Invalid("Sinkhole IPv6 address: %s", err.Error()) } } p.BlockTTL = in.BlockTTL if p.BlockTTL == 0 { p.BlockTTL = base.BlockTTL } if p.BlockTTL == 0 { p.BlockTTL = 60 } if p.BlockTTL > 86400 { return p, Invalid("The block TTL must be 86400 seconds or less.") } if in.Enabled != nil { p.Enabled = *in.Enabled } else if base.ID == 0 { p.Enabled = true } return p, nil } func requireIP(s string, wantV4 bool) error { p, err := config.ParseCIDROrIP(s) if err != nil { return errors.New("must be a valid IP address") } if p.Addr().Is4() != wantV4 { if wantV4 { return errors.New("must be an IPv4 address") } return errors.New("must be an IPv6 address") } return nil } // CreatePolicy stores a policy. func (a *App) CreatePolicy(ctx context.Context, actor auditlog.Actor, in PolicyInput) (models.Policy, error) { p, err := a.normalisePolicy(in, models.Policy{}) if err != nil { return models.Policy{}, err } created, err := a.DB.CreatePolicy(ctx, p, in.ListIDs) if err != nil { return models.Policy{}, translate(err, "Policy not found.", fmt.Sprintf("A policy named %q already exists.", p.Name)) } a.Audit.RecordID(ctx, actor, "policy.create", auditlog.ObjectPolicy, created.ID, created.Name, auditlog.Changes("action", string(created.BlockAction), "lists", fmt.Sprint(len(in.ListIDs)))) a.Runtime.RequestReload() return created, nil } // UpdatePolicy saves a policy. func (a *App) UpdatePolicy(ctx context.Context, actor auditlog.Actor, id int64, in PolicyInput) (models.Policy, error) { existing, err := a.Policy(ctx, id) if err != nil { return models.Policy{}, err } p, err := a.normalisePolicy(in, existing) if err != nil { return models.Policy{}, err } p.ID = id updated, err := a.DB.UpdatePolicy(ctx, p, in.ListIDs) if err != nil { return models.Policy{}, translate(err, fmt.Sprintf("Policy %d was not found.", id), fmt.Sprintf("A policy named %q already exists.", p.Name)) } a.Audit.RecordID(ctx, actor, "policy.update", auditlog.ObjectPolicy, id, updated.Name, auditlog.Changes("action", string(updated.BlockAction), "lists", fmt.Sprint(len(in.ListIDs)))) a.Runtime.RequestReload() return updated, nil } // SetPolicyEnabled toggles a policy. func (a *App) SetPolicyEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error { p, err := a.Policy(ctx, id) if err != nil { return err } if err := a.DB.SetPolicyEnabled(ctx, id, enabled); err != nil { return translate(err, fmt.Sprintf("Policy %d was not found.", id), "") } action := "policy.disable" if enabled { action = "policy.enable" } a.Audit.RecordID(ctx, actor, action, auditlog.ObjectPolicy, id, p.Name, "") a.Runtime.RequestReload() return nil } // DeletePolicy removes a policy. func (a *App) DeletePolicy(ctx context.Context, actor auditlog.Actor, id int64) error { p, err := a.Policy(ctx, id) if err != nil { return err } if err := a.DB.DeletePolicy(ctx, id); err != nil { return translate(err, fmt.Sprintf("Policy %d was not found.", id), "") } a.Audit.RecordID(ctx, actor, "policy.delete", auditlog.ObjectPolicy, id, p.Name, "") a.Runtime.RequestReload() return nil } // --- Domain lists ------------------------------------------------------- // ListInput is the editable surface of a blacklist or allowlist. type ListInput struct { Kind string `json:"kind"` Name string `json:"name"` Description string `json:"description"` Enabled *bool `json:"enabled"` SourceURL string `json:"source_url"` } // DomainLists returns blacklists, allowlists, or both when kind is empty. func (a *App) DomainLists(ctx context.Context, kind, search string) ([]models.DomainList, error) { lists, err := a.DB.DomainLists(ctx, kind, search) if err != nil { return nil, Internal(err, "The list could not be loaded.") } return lists, nil } // DomainList loads one list. func (a *App) DomainList(ctx context.Context, id int64) (models.DomainList, error) { l, err := a.DB.DomainList(ctx, id) if errors.Is(err, database.ErrNotFound) { return l, NotFound("List %d was not found.", id) } if err != nil { return l, Internal(err, "The list could not be loaded.") } return l, nil } // CreateDomainList stores a blacklist or allowlist. func (a *App) CreateDomainList(ctx context.Context, actor auditlog.Actor, in ListInput) (models.DomainList, error) { kind := strings.ToLower(strings.TrimSpace(in.Kind)) if kind != models.KindBlacklist && kind != models.KindAllowlist { return models.DomainList{}, Invalid("List kind must be blacklist or allowlist.") } name := strings.TrimSpace(in.Name) if name == "" { return models.DomainList{}, Invalid("A list name is required.") } enabled := true if in.Enabled != nil { enabled = *in.Enabled } l := models.DomainList{ Kind: kind, Name: name, Description: strings.TrimSpace(in.Description), Enabled: enabled, SourceURL: strings.TrimSpace(in.SourceURL), } created, err := a.DB.CreateDomainList(ctx, l) if err != nil { return models.DomainList{}, translate(err, "List not found.", fmt.Sprintf("A %s named %q already exists.", kind, name)) } a.Audit.RecordID(ctx, actor, "list.create", auditlog.ObjectList, created.ID, created.Name, auditlog.Changes("kind", created.Kind)) a.Runtime.RequestReload() return created, nil } // UpdateDomainList saves list metadata. func (a *App) UpdateDomainList(ctx context.Context, actor auditlog.Actor, id int64, in ListInput) (models.DomainList, error) { existing, err := a.DomainList(ctx, id) if err != nil { return models.DomainList{}, err } if name := strings.TrimSpace(in.Name); name != "" { existing.Name = name } existing.Description = strings.TrimSpace(in.Description) existing.SourceURL = strings.TrimSpace(in.SourceURL) if in.Enabled != nil { existing.Enabled = *in.Enabled } updated, err := a.DB.UpdateDomainList(ctx, existing) if err != nil { return models.DomainList{}, translate(err, fmt.Sprintf("List %d was not found.", id), fmt.Sprintf("A list named %q already exists.", existing.Name)) } a.Audit.RecordID(ctx, actor, "list.update", auditlog.ObjectList, id, updated.Name, "") a.Runtime.RequestReload() return updated, nil } // SetDomainListEnabled toggles a list. func (a *App) SetDomainListEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error { l, err := a.DomainList(ctx, id) if err != nil { return err } if err := a.DB.SetDomainListEnabled(ctx, id, enabled); err != nil { return translate(err, fmt.Sprintf("List %d was not found.", id), "") } action := "list.disable" if enabled { action = "list.enable" } a.Audit.RecordID(ctx, actor, action, auditlog.ObjectList, id, l.Name, "") a.Runtime.RequestReload() return nil } // DeleteDomainList removes a list and every domain in it. func (a *App) DeleteDomainList(ctx context.Context, actor auditlog.Actor, id int64) error { l, err := a.DomainList(ctx, id) if err != nil { return err } if err := a.DB.DeleteDomainList(ctx, id); err != nil { return translate(err, fmt.Sprintf("List %d was not found.", id), "") } a.Audit.RecordID(ctx, actor, "list.delete", auditlog.ObjectList, id, l.Name, auditlog.Changes("domains", fmt.Sprint(l.DomainCount))) a.Runtime.RequestReload() return nil } // --- Domain entries ----------------------------------------------------- // DomainEntries pages through a list's domains. func (a *App) DomainEntries(ctx context.Context, listID int64, search string, limit, offset int) ([]models.DomainEntry, int, error) { entries, total, err := a.DB.DomainEntries(ctx, listID, search, limit, offset) if err != nil { return nil, 0, Internal(err, "The domains could not be loaded.") } return entries, total, nil } // AddDomain adds one domain to a list. func (a *App) AddDomain(ctx context.Context, actor auditlog.Actor, listID int64, domain string, matchSubdomains bool, comment string) (models.DomainEntry, error) { l, err := a.DomainList(ctx, listID) if err != nil { return models.DomainEntry{}, err } d := strings.TrimSpace(domain) if strings.HasPrefix(d, "*.") { d = strings.TrimPrefix(d, "*.") matchSubdomains = true } normalised, err := validate.NormaliseDomain(d) if err != nil { return models.DomainEntry{}, Invalid("%s", err.Error()) } entry, err := a.DB.AddDomain(ctx, models.DomainEntry{ ListID: listID, Domain: normalised, MatchSubdomains: matchSubdomains, Enabled: true, Comment: strings.TrimSpace(comment), }) if err != nil { return models.DomainEntry{}, translate(err, "List not found.", fmt.Sprintf("%s is already in %s.", normalised, l.Name)) } a.Audit.RecordID(ctx, actor, "domain.add", auditlog.ObjectDomain, entry.ID, normalised, auditlog.Changes("list", l.Name)) a.Runtime.RequestReload() return entry, nil } // UpdateDomain saves an existing domain entry. func (a *App) UpdateDomain(ctx context.Context, actor auditlog.Actor, listID, id int64, domain string, matchSubdomains, enabled bool, comment string) error { normalised, err := validate.NormaliseDomain(strings.TrimPrefix(strings.TrimSpace(domain), "*.")) if err != nil { return Invalid("%s", err.Error()) } e := models.DomainEntry{ ID: id, ListID: listID, Domain: normalised, MatchSubdomains: matchSubdomains, Enabled: enabled, Comment: strings.TrimSpace(comment), } if err := a.DB.UpdateDomain(ctx, e); err != nil { return translate(err, fmt.Sprintf("Domain %d was not found.", id), fmt.Sprintf("%s is already in this list.", normalised)) } a.Audit.RecordID(ctx, actor, "domain.update", auditlog.ObjectDomain, id, normalised, "") a.Runtime.RequestReload() return nil } // DeleteDomain removes one domain from a list. func (a *App) DeleteDomain(ctx context.Context, actor auditlog.Actor, id int64) error { if err := a.DB.DeleteDomain(ctx, id); err != nil { return translate(err, fmt.Sprintf("Domain %d was not found.", id), "") } a.Audit.RecordID(ctx, actor, "domain.delete", auditlog.ObjectDomain, id, "", "") a.Runtime.RequestReload() return nil } // ClearDomains empties a list. func (a *App) ClearDomains(ctx context.Context, actor auditlog.Actor, listID int64) (int64, error) { l, err := a.DomainList(ctx, listID) if err != nil { return 0, err } n, err := a.DB.ClearDomains(ctx, listID) if err != nil { return 0, Internal(err, "The list could not be cleared.") } a.Audit.RecordID(ctx, actor, "list.clear", auditlog.ObjectList, listID, l.Name, auditlog.Changes("removed", fmt.Sprint(n))) a.Runtime.RequestReload() return n, nil } // ImportDomains parses a domain list and stores it. // // Parsing happens fully in memory and the insert runs as a single transaction // with one prepared statement, so a list of several hundred thousand domains // is one commit rather than one commit per domain. func (a *App) ImportDomains(ctx context.Context, actor auditlog.Actor, listID int64, r io.Reader, matchSubdomains bool) (models.ImportSummary, error) { l, err := a.DomainList(ctx, listID) if err != nil { return models.ImportSummary{}, err } parsed, summary := blacklist.Parse(r, blacklist.ParseOptions{DefaultMatchSubdomains: matchSubdomains}) if len(parsed) == 0 { if summary.LinesProcessed == 0 { return summary, Invalid("The import was empty.") } return summary, Invalid("No valid domains were found in %d lines. "+ "Supported formats are a plain domain list, a hosts file, or Adblock-style ||domain^ rules.", summary.LinesProcessed) } rows := make([]database.ImportDomain, 0, len(parsed)) for _, p := range parsed { rows = append(rows, database.ImportDomain{Domain: p.Domain, MatchSubdomains: p.MatchSubdomains}) } imported, duplicates, err := a.DB.ImportDomains(ctx, listID, rows) if err != nil { return summary, Internal(err, "The domains could not be imported.") } // The parser counts duplicates within the file; the database reports // domains that were already present. The summary shows the total. summary.Imported = imported summary.Duplicates += duplicates a.Audit.RecordID(ctx, actor, "list.import", auditlog.ObjectList, listID, l.Name, auditlog.Changes( "imported", fmt.Sprint(summary.Imported), "duplicates", fmt.Sprint(summary.Duplicates), "invalid", fmt.Sprint(summary.Invalid), "lines", fmt.Sprint(summary.LinesProcessed))) a.Runtime.RequestReload() return summary, nil } // ExportDomains writes a list as a plain domain list. func (a *App) ExportDomains(ctx context.Context, listID int64, w io.Writer) (models.DomainList, error) { l, err := a.DomainList(ctx, listID) if err != nil { return l, err } fmt.Fprintf(w, "# %s\n", l.Name) if l.Description != "" { fmt.Fprintf(w, "# %s\n", l.Description) } fmt.Fprintf(w, "# %d domains exported by VibeDNS\n", l.DomainCount) err = a.DB.ExportDomains(ctx, listID, func(domain string, matchSubdomains bool) { if matchSubdomains { fmt.Fprintln(w, domain) return } // A domain that must match exactly is written in a form the importer // will not silently widen. fmt.Fprintf(w, "%s # exact\n", domain) }) if err != nil { return l, Internal(err, "The domains could not be exported.") } return l, nil } // LookupDomain reports which lists cover a name, for the "why was this // blocked?" tool. type LookupHit struct { ListID int64 `json:"list_id"` ListName string `json:"list_name"` Kind string `json:"kind"` Matched string `json:"matched_domain"` } // LookupDomain checks a name against every compiled list. func (a *App) LookupDomain(ctx context.Context, name string) ([]LookupHit, error) { domain, err := validate.NormaliseDomain(name) if err != nil { return nil, Invalid("%s", err.Error()) } lists, err := a.DB.DomainLists(ctx, "", "") if err != nil { return nil, Internal(err, "The lists could not be loaded.") } sets := a.Snapshot().Policy.Sets() var hits []LookupHit for _, l := range lists { set, ok := sets[l.ID] if !ok { continue // list is disabled, so it was not compiled } if matched, found := set.Match(domain); found { hits = append(hits, LookupHit{ ListID: l.ID, ListName: l.Name, Kind: l.Kind, Matched: matched, }) } } return hits, nil }