From 24225a59b84fdad2691a00ed8537729e45ed13e4 Mon Sep 17 00:00:00 2001 From: Nicholas Penree Date: Thu, 8 Oct 2026 19:22:32 +0000 Subject: [PATCH] Add blocking rule sets for per-device policies Rule sets give some devices their own block lists and their own blocked and allowed domains. A device joins one through its [[clients]] entry's rule_set, and blocking.default_lists narrows the lists for everyone else. Each rule set gets an on/off table over the compiler's owner sets, so choosing lists costs nothing per blocked domain. The query path parses the client address once and looks it up in an exact-address map, then the networks most specific first, with no allocations. Bypass clients are now a rule set with blocking off, so there is one path for both. Devices now replicate with the rest of the runtime settings, since a device's rule set decides how every node blocks for it. Refs #240 --- config.example.toml | 7 + docs/architecture.md | 2 +- docs/clustering.md | 1 + docs/configuration.md | 49 +++++- internal/app/app.go | 34 ++++ internal/app/cluster_state.go | 22 +++ internal/app/insights_switch_test.go | 31 ++++ internal/app/rule_sets_test.go | 35 ++++ internal/config/clients.go | 10 +- internal/config/clients_test.go | 2 +- internal/config/config.go | 27 ++- internal/config/manager.go | 2 + internal/config/rule_sets.go | 124 +++++++++++++ internal/config/rule_sets_test.go | 66 +++++++ internal/dnsserver/handler.go | 45 +++-- internal/dnsserver/policy.go | 87 ++++------ internal/dnsserver/rule_sets.go | 240 ++++++++++++++++++++++++++ internal/dnsserver/rule_sets_test.go | 173 +++++++++++++++++++ internal/dnsserver/runtime_compile.go | 37 +--- 19 files changed, 878 insertions(+), 116 deletions(-) create mode 100644 internal/app/rule_sets_test.go create mode 100644 internal/config/rule_sets.go create mode 100644 internal/config/rule_sets_test.go create mode 100644 internal/dnsserver/rule_sets.go create mode 100644 internal/dnsserver/rule_sets_test.go diff --git a/config.example.toml b/config.example.toml index 328e6224..1a90876d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -98,6 +98,13 @@ allow_txt_report = true # path = "lists/block.txt" # format = "auto" +# A rule set gives some devices their own lists and domains. A device joins +# one through its [[clients]] entry's rule_set. +# [[blocking.rule_sets]] +# name = "Kids" +# lists = ["local"] +# domains = ["youtube.com"] + # Remote subscriptions are downloaded into Sable's data directory and updated # automatically. The path is optional for remote lists. # [[blocking.lists]] diff --git a/docs/architecture.md b/docs/architecture.md index ee207397..452b9363 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -258,7 +258,7 @@ stops answering. Sable uses a primary/replica control plane designed for one- and two-server deployments. One primary accepts configuration changes. Replicas pull signed, monotonically numbered generations containing authoritative zones, resolver and -cache policy, TSIG keys, blocking and query-log settings, Dynamic DNS, UniFi, +cache policy, TSIG keys, blocking and query-log settings, devices, Dynamic DNS, UniFi, and OpenID Connect configuration, users, roles, grants, federated identities, and API-token hashes. Replicated credentials travel over the authenticated enrollment and synchronization channel and land in each node's own encrypted vault, so no node diff --git a/docs/clustering.md b/docs/clustering.md index 118f8b7a..534a7517 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -113,6 +113,7 @@ Replicated state includes: - authoritative zones, records, DNSSEC policy, and encrypted signing keys; - resolver, cache, TSIG, blocking, and query-log runtime settings; +- devices (`[[clients]]`): their names, types, and blocking rule sets; - Dynamic DNS settings and external provider credentials; - UniFi settings and controller credentials; - OpenID Connect settings, client secret, linked identities, and role mappings; diff --git a/docs/configuration.md b/docs/configuration.md index 38b8deb5..919c81e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -719,6 +719,52 @@ any of them, recompiles the policy, and restores the previous files if activation fails. The console supports curated and custom subscriptions, manual refresh, and the configured automatic refresh interval. +### Rule sets + +A rule set gives some devices a different blocking policy: their own choice of +block lists, and blocked and allowed domains of their own. + +```toml +[blocking] +# Devices without a rule set use these lists. Leave it out to use every list. +default_lists = ["OISD Big"] + +[[blocking.rule_sets]] +name = "Kids" +lists = ["OISD Big", "filters"] +domains = ["youtube.com", "tiktok.com"] + +[[blocking.rule_sets]] +name = "Work" +lists = [] +allowed_domains = ["*.tracker.example"] + +[[clients]] +name = "Leo's Switch" +address = "192.0.2.20" +rule_set = "Kids" + +[[clients]] +name = "Guest Wi-Fi" +address = "10.20.40.0/24" +rule_set = "Work" +``` + +A device joins a rule set through its [`[[clients]]`](#devices-and-insights) entry. When +several entries match an address, an exact address wins over a network and a +smaller network over a larger one. A rule set's `domains` and `allowed_domains` +win over the global ones, and `blocking.domains` and `blocking.allowed_domains` +apply in every rule set. `lists` names entries in `[[blocking.lists]]`; a list a +rule set leaves out doesn't block for its devices, and neither do that list's +`@@` exceptions. `Default` is reserved for devices without a rule set. + +For now a rule set applies to devices named by `address`. A device named by +`mac` keeps its rule set in the configuration, and it takes effect in a later +release, once Sable ties hardware addresses to the addresses they use. + +`bypass_clients` is a rule set with blocking off: those clients skip blocking +entirely, and an address there wins over every rule set's. + ### Block-list health and retry backoff Sable tracks each remote subscription independently: last attempt, last @@ -819,7 +865,8 @@ be set alone. The Insights device drawer writes both, so most people never edit these by hand. Valid types are `phone`, `tablet`, `computer`, `server`, `tv`, `streaming-player`, `smart-speaker`, `speaker`, `camera`, `doorbell`, `game-console`, `printer`, `storage`, `network`, `thermostat`, `lighting`, -`smart-plug`, `smart-home`, `watch`, and `ups`. +`smart-plug`, `smart-home`, `watch`, and `ups`. `rule_set` puts the device in +a [blocking rule set](#rule-sets). Replicas follow the primary's devices. `[insights] enabled` turns Insights on, which is the default. Set it to `false` to stop Sable recording which devices it sees and what hardware they are, stop diff --git a/internal/app/app.go b/internal/app/app.go index ae6308d7..cb2b9eda 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -370,6 +371,8 @@ func compileRuntime(configuration config.Config, configuredZones []zone.Zone, ba BlockingTTL: configuration.Blocking.ResponseTTL, BlockingAddrs: configuration.Blocking.CustomAddresses, BypassClients: configuration.Blocking.BypassClients, + RuleSets: runtimeRuleSets(configuration), + DefaultLists: defaultLists(configuration.Blocking.DefaultLists), AllowTXTReport: configuration.Blocking.AllowTXTReport, Hosts: hosts, Zones: zones, @@ -450,6 +453,37 @@ func authoritativeZones(configuredZones []zone.Zone) []dnsserver.AuthoritativeZo return zones } +// runtimeRuleSets gives each rule set the devices that use it. The operator's +// own blocked domains apply to every rule set, whatever lists it picks. A device named +// by hardware address joins once Sable knows its addresses; until then only +// devices named by address or network are in a rule set. +func runtimeRuleSets(configuration config.Config) []dnsserver.RuleSetPolicy { + sets := make([]dnsserver.RuleSetPolicy, 0, len(configuration.Blocking.RuleSets)) + for _, set := range configuration.Blocking.RuleSets { + policy := dnsserver.RuleSetPolicy{ + Name: set.Name, Lists: append(slices.Clone(set.Lists), blockcompiler.CustomSourceName), + Domains: set.Domains, AllowedDomains: set.AllowedDomains, + } + for _, client := range configuration.Clients { + if client.RuleSet == set.Name && client.Address != "" { + policy.Clients = append(policy.Clients, client.Address) + } + } + sets = append(sets, policy) + } + return sets +} + +// defaultLists adds the operator's own blocked domains to the default policy's +// choice of block lists, since they apply whatever lists a policy uses. No +// choice stays nil, which applies every list. +func defaultLists(lists []string) []string { + if len(lists) == 0 { + return nil + } + return append(slices.Clone(lists), blockcompiler.CustomSourceName) +} + func runtimeTSIGKeys(keys []config.TSIGKey) []dnsserver.TSIGKey { tsigKeys := make([]dnsserver.TSIGKey, 0, len(keys)) for _, key := range keys { diff --git a/internal/app/cluster_state.go b/internal/app/cluster_state.go index e7f3ecae..3562b108 100644 --- a/internal/app/cluster_state.go +++ b/internal/app/cluster_state.go @@ -135,6 +135,10 @@ type clusterRuntimeConfiguration struct { // that predates the switch sends none, which leaves this node's own as it // is. InsightsEnabled *bool `toml:"insights_enabled,omitempty"` + // Devices follow the primary, since a device's rule set decides how every + // node blocks for it. A primary that predates them sends none, which + // leaves this node's own as they are. + Devices *clusterDevices `toml:"devices"` // InsightDataDeletedAt is when the primary last deleted what Insights had // collected. Every node keeps its own sightings, so a replica whose copy // predates it deletes its own too. It is not configuration, so it is left @@ -150,6 +154,12 @@ type insightDataStore interface { } // clusterAlerts is the alert state that follows the primary. +// clusterDevices wraps the [[clients]] list so a primary with no devices +// still sends an empty list rather than leaving a replica's own in place. +type clusterDevices struct { + Clients []config.Client `toml:"clients"` +} + type clusterAlerts struct { // Settings is the [alerts] section with every destination's URL, keys, // and header values filled in from the primary's vault. The receiving @@ -333,6 +343,9 @@ func (replicator *clusterStateReplicator) Apply(ctx context.Context, contents [] if runtimeConfiguration.InsightsEnabled == nil { activeConfiguration.InsightsEnabled = nil } + if runtimeConfiguration.Devices == nil { + activeConfiguration.Devices = nil + } activeZones := replicator.zones.Current().Zones activeAuthorization := store.AuthorizationState{} authorizationChanged := false @@ -462,6 +475,7 @@ func replicatedRuntimeConfiguration(source config.Config) clusterRuntimeConfigur InsightFindings: &findings, MCP: &mcp, InsightsEnabled: &insightsEnabled, + Devices: &clusterDevices{Clients: slices.Clone(source.Clients)}, } } @@ -497,6 +511,14 @@ func applyReplicatedRuntimeConfiguration(candidate *config.Config, source cluste if source.InsightsEnabled != nil { candidate.Insights.Enabled = *source.InsightsEnabled } + if source.Devices != nil { + // Through TOML no devices arrive as an empty list; keep nil so an + // unchanged snapshot leaves the configuration untouched. + candidate.Clients = nil + if len(source.Devices.Clients) > 0 { + candidate.Clients = slices.Clone(source.Devices.Clients) + } + } } // deleteReplicatedInsightData deletes this node's Insights data when the diff --git a/internal/app/insights_switch_test.go b/internal/app/insights_switch_test.go index 55133daf..721703e8 100644 --- a/internal/app/insights_switch_test.go +++ b/internal/app/insights_switch_test.go @@ -167,3 +167,34 @@ func TestReplicatedRuntimeConfigurationKeepsLocalInsightsWhenPrimaryOmitsIt(t *t t.Fatal("primary's Insights switch was ignored") } } + +func TestReplicatedRuntimeConfigurationCarriesDevices(t *testing.T) { + primary := config.Defaults() + primary.Clients = []config.Client{{Name: "Leo's Switch", Address: "192.0.2.20", RuleSet: "Kids"}} + candidate := config.Defaults() + candidate.Clients = []config.Client{{Name: "Old name", Address: "192.0.2.20"}} + source := replicatedRuntimeConfiguration(primary) + applyReplicatedRuntimeConfiguration(&candidate, source) + if len(candidate.Clients) != 1 || candidate.Clients[0].RuleSet != "Kids" { + t.Fatalf("replica devices = %+v, want the primary's", candidate.Clients) + } + // A primary whose last device was removed still says so through TOML. + encoded, err := toml.Marshal(replicatedRuntimeConfiguration(config.Defaults())) + if err != nil { + t.Fatal(err) + } + var decoded clusterRuntimeConfiguration + if err := toml.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + applyReplicatedRuntimeConfiguration(&candidate, decoded) + if len(candidate.Clients) != 0 { + t.Fatalf("replica kept devices the primary removed: %+v", candidate.Clients) + } + source.Devices = nil + candidate.Clients = []config.Client{{Name: "Kept", Address: "192.0.2.30"}} + applyReplicatedRuntimeConfiguration(&candidate, source) + if len(candidate.Clients) != 1 || candidate.Clients[0].Name != "Kept" { + t.Fatalf("a primary that predates devices replaced them: %+v", candidate.Clients) + } +} diff --git a/internal/app/rule_sets_test.go b/internal/app/rule_sets_test.go new file mode 100644 index 00000000..3743340f --- /dev/null +++ b/internal/app/rule_sets_test.go @@ -0,0 +1,35 @@ +package app + +import ( + "slices" + "testing" + + blockcompiler "github.com/drudge/sable/internal/blocking" + "github.com/drudge/sable/internal/config" +) + +func TestRuntimeRuleSetsJoinDevicesNamedByAddress(t *testing.T) { + t.Parallel() + configuration := config.Defaults() + configuration.Blocking.RuleSets = []config.RuleSet{{Name: "Kids", Lists: []string{"Strict"}}, {Name: "Work"}} + configuration.Clients = []config.Client{ + {Name: "Emma's iPad", MAC: "da:a1:19:00:00:01", RuleSet: "Kids"}, + {Name: "Leo's Switch", Address: "192.0.2.20", RuleSet: "Kids"}, + {Name: "Guest Wi-Fi", Address: "10.20.40.0/24", RuleSet: "Kids"}, + {Name: "Printer", Address: "192.0.2.9"}, + } + sets := runtimeRuleSets(configuration) + if len(sets) != 2 || sets[0].Name != "Kids" || !slices.Equal(sets[0].Clients, []string{"192.0.2.20", "10.20.40.0/24"}) { + t.Fatalf("runtime rule sets = %+v", sets) + } + if !slices.Equal(sets[0].Lists, []string{"Strict", blockcompiler.CustomSourceName}) || + !slices.Equal(sets[1].Lists, []string{blockcompiler.CustomSourceName}) || sets[1].Clients != nil { + t.Fatalf("rule set lists = %v and %v, want the operator's domains in each", sets[0].Lists, sets[1].Lists) + } + if got := defaultLists(nil); got != nil { + t.Fatalf("defaultLists(nil) = %v, want every list", got) + } + if got := defaultLists([]string{"Ads"}); !slices.Equal(got, []string{"Ads", blockcompiler.CustomSourceName}) { + t.Fatalf("defaultLists(Ads) = %v", got) + } +} diff --git a/internal/config/clients.go b/internal/config/clients.go index 3cf8f872..52f20f5b 100644 --- a/internal/config/clients.go +++ b/internal/config/clients.go @@ -26,6 +26,9 @@ type Client struct { Address string `toml:"address,omitempty"` // Type is one of ClientTypes, set when the operator corrected Sable's guess. Type string `toml:"type,omitempty"` + // RuleSet names the blocking rule set the device uses instead of the + // default policy. + RuleSet string `toml:"rule_set,omitempty"` } // ClientTypes are the kinds of device Sable recognizes, as stored in a @@ -46,8 +49,8 @@ func (client Client) Key() string { func validateClient(field string, client Client) error { name := strings.TrimSpace(client.Name) - if name == "" && client.Type == "" { - return fmt.Errorf("%s.name or %s.type is required", field, field) + if name == "" && client.Type == "" && client.RuleSet == "" { + return fmt.Errorf("%s.name, %s.type, or %s.rule_set is required", field, field, field) } if client.Type != "" && !slices.Contains(ClientTypes, client.Type) { return fmt.Errorf("%s.type must be one of %s", field, strings.Join(ClientTypes, ", ")) @@ -99,6 +102,7 @@ func validateClients(clients []Client) error { func normalizeClient(client Client) Client { client.Name = strings.TrimSpace(client.Name) client.Type = strings.TrimSpace(client.Type) + client.RuleSet = strings.TrimSpace(client.RuleSet) client.MAC = strings.TrimSpace(client.MAC) client.Address = strings.TrimSpace(client.Address) if mac, err := net.ParseMAC(client.MAC); err == nil { @@ -175,7 +179,7 @@ func changeClient(clients []Client, target Client, change func(*Client)) ([]Clie return true }) change(&entry) - if entry.Name == "" && entry.Type == "" { + if entry.Name == "" && entry.Type == "" && entry.RuleSet == "" { return updated, nil } if err := validateClient("client", entry); err != nil { diff --git a/internal/config/clients_test.go b/internal/config/clients_test.go index b29eb729..da926a91 100644 --- a/internal/config/clients_test.go +++ b/internal/config/clients_test.go @@ -30,7 +30,7 @@ func TestClientsValidateAndNormalize(t *testing.T) { client Client want string }{ - {Client{MAC: "da:a1:19:00:00:01"}, "name or clients[0].type is required"}, + {Client{MAC: "da:a1:19:00:00:01"}, "clients[0].type, or clients[0].rule_set is required"}, {Client{MAC: "da:a1:19:00:00:01", Type: "toaster"}, "type must be one of"}, {Client{Name: "Both", MAC: "da:a1:19:00:00:01", Address: "10.0.0.1"}, "not both"}, {Client{Name: "Neither"}, "must set mac or address"}, diff --git a/internal/config/config.go b/internal/config/config.go index 9073093a..dfe333ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -290,6 +290,10 @@ type Blocking struct { CustomAddresses []string `toml:"custom_addresses"` BypassClients []string `toml:"bypass_clients"` AllowTXTReport bool `toml:"allow_txt_report"` + // DefaultLists names the block lists for clients without a rule set. Empty + // means every list. + DefaultLists []string `toml:"default_lists,omitempty"` + RuleSets []RuleSet `toml:"rule_sets,omitempty"` } type BlockList struct { @@ -712,6 +716,7 @@ func (configuration Config) Validate() error { validationErrors = append(validationErrors, errors.New("security.secure_cookies must be true when server.http_listen is not loopback")) } validationErrors = append(validationErrors, configuration.Blocking.validate()...) + validationErrors = append(validationErrors, validateClientRuleSets(configuration.Clients, configuration.Blocking.RuleSets)...) validationErrors = append(validationErrors, configuration.DynamicDNS.validate()...) validationErrors = append(validationErrors, configuration.MCP.validate()...) validationErrors = append(validationErrors, configuration.UniFi.validate()...) @@ -1062,11 +1067,7 @@ func (settings Security) validate() []error { func (settings Blocking) validate() []error { var validationErrors []error - for index, domain := range settings.Domains { - if _, err := dnsname.Normalize(strings.TrimPrefix(domain, "*.")); err != nil { - validationErrors = append(validationErrors, fmt.Errorf("blocking.domains[%d]: %w", index, err)) - } - } + validationErrors = append(validationErrors, validatePolicyDomains("blocking.domains", settings.Domains)...) seenLists := make(map[string]struct{}, len(settings.Lists)) for index, list := range settings.Lists { field := fmt.Sprintf("blocking.lists[%d]", index) @@ -1089,12 +1090,21 @@ func (settings Blocking) validate() []error { } seenLists[list.Name] = struct{}{} } - for index, domain := range settings.AllowedDomains { + validationErrors = append(validationErrors, validatePolicyDomains("blocking.allowed_domains", settings.AllowedDomains)...) + validationErrors = append(validationErrors, settings.validateRuleSets()...) + return append(validationErrors, settings.validateResponse()...) +} + +// validatePolicyDomains checks blocked or allowed domains, each a name or a +// "*." wildcard. +func validatePolicyDomains(field string, domains []string) []error { + var validationErrors []error + for index, domain := range domains { if _, err := dnsname.Normalize(strings.TrimPrefix(domain, "*.")); err != nil { - validationErrors = append(validationErrors, fmt.Errorf("blocking.allowed_domains[%d]: %w", index, err)) + validationErrors = append(validationErrors, fmt.Errorf("%s[%d]: %w", field, index, err)) } } - return append(validationErrors, settings.validateResponse()...) + return validationErrors } // validateResponse checks how blocked queries are answered and who skips @@ -1495,6 +1505,7 @@ func (settings *Blocking) normalize() { } settings.CustomAddresses = uniqueAddresses(settings.CustomAddresses) settings.BypassClients = uniqueTrimmed(settings.BypassClients) + settings.normalizeRuleSets() for index := range settings.Lists { settings.Lists[index].normalize() } diff --git a/internal/config/manager.go b/internal/config/manager.go index 8a33553c..aabcf343 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -90,6 +90,8 @@ func cloneConfig(source Config) Config { cloned.Blocking.Lists = append([]BlockList(nil), source.Blocking.Lists...) cloned.Blocking.CustomAddresses = append([]string(nil), source.Blocking.CustomAddresses...) cloned.Blocking.BypassClients = append([]string(nil), source.Blocking.BypassClients...) + cloned.Blocking.DefaultLists = append([]string(nil), source.Blocking.DefaultLists...) + cloned.Blocking.RuleSets = cloneRuleSets(source.Blocking.RuleSets) cloned.EncryptedDNS.DoTListen = append([]string(nil), source.EncryptedDNS.DoTListen...) cloned.EncryptedDNS.DoHListen = append([]string(nil), source.EncryptedDNS.DoHListen...) cloned.EncryptedDNS.DoQListen = append([]string(nil), source.EncryptedDNS.DoQListen...) diff --git a/internal/config/rule_sets.go b/internal/config/rule_sets.go new file mode 100644 index 00000000..a4320074 --- /dev/null +++ b/internal/config/rule_sets.go @@ -0,0 +1,124 @@ +package config + +import ( + "fmt" + "slices" + "strings" + "unicode" +) + +// maximumRuleSetNameLength bounds a rule set's name so it fits the console's +// tables and drawers, as a device name does. +const maximumRuleSetNameLength = maximumClientNameLength + +// DefaultRuleSetName is what the console calls the policy for devices without +// a rule set. No rule set may take it. +const DefaultRuleSetName = "Default" + +// RuleSet is a blocking policy that devices join through their [[clients]] +// entry. The operator's own blocked and allowed domains apply to every rule +// set; a rule set picks its block lists and adds domains of its own, which +// win over the global ones. +type RuleSet struct { + Name string `toml:"name"` + Lists []string `toml:"lists"` + Domains []string `toml:"domains,omitempty"` + AllowedDomains []string `toml:"allowed_domains,omitempty"` +} + +func cloneRuleSets(sets []RuleSet) []RuleSet { + if sets == nil { + return nil + } + cloned := make([]RuleSet, len(sets)) + for index, set := range sets { + cloned[index] = RuleSet{ + Name: set.Name, + Lists: append([]string(nil), set.Lists...), + Domains: append([]string(nil), set.Domains...), + AllowedDomains: append([]string(nil), set.AllowedDomains...), + } + } + return cloned +} + +func (settings *Blocking) normalizeRuleSets() { + settings.DefaultLists = sortedUnique(settings.DefaultLists) + for index := range settings.RuleSets { + set := &settings.RuleSets[index] + set.Name = strings.TrimSpace(set.Name) + set.Lists = sortedUnique(set.Lists) + set.Domains = normalizePolicyDomains(set.Domains) + set.AllowedDomains = normalizePolicyDomains(set.AllowedDomains) + } + slices.SortFunc(settings.RuleSets, func(left, right RuleSet) int { + return strings.Compare(left.Name, right.Name) + }) +} + +func sortedUnique(values []string) []string { + unique := uniqueTrimmed(values) + slices.Sort(unique) + return unique +} + +// validateRuleSets checks each rule set's name, and that every list a rule +// set or the default policy names exists. +func (settings Blocking) validateRuleSets() []error { + var validationErrors []error + lists := make(map[string]struct{}, len(settings.Lists)) + for _, list := range settings.Lists { + lists[list.Name] = struct{}{} + } + checkLists := func(field string, names []string) { + for index, name := range names { + if _, found := lists[name]; !found { + validationErrors = append(validationErrors, fmt.Errorf("%s[%d] names no block list called %q", field, index, name)) + } + } + } + checkLists("blocking.default_lists", settings.DefaultLists) + seen := make(map[string]int, len(settings.RuleSets)) + for index, set := range settings.RuleSets { + field := fmt.Sprintf("blocking.rule_sets[%d]", index) + if err := validateRuleSetName(field, set.Name); err != nil { + validationErrors = append(validationErrors, err) + } else if previous, taken := seen[strings.ToLower(set.Name)]; taken { + validationErrors = append(validationErrors, fmt.Errorf("%s.name %q is already used by blocking.rule_sets[%d]", field, set.Name, previous)) + } else { + seen[strings.ToLower(set.Name)] = index + } + checkLists(field+".lists", set.Lists) + validationErrors = append(validationErrors, validatePolicyDomains(field+".domains", set.Domains)...) + validationErrors = append(validationErrors, validatePolicyDomains(field+".allowed_domains", set.AllowedDomains)...) + } + return validationErrors +} + +func validateRuleSetName(field, name string) error { + switch { + case name == "": + return fmt.Errorf("%s.name is required", field) + case strings.EqualFold(name, DefaultRuleSetName): + return fmt.Errorf("%s.name %q is reserved for devices without a rule set", field, name) + case len([]rune(name)) > maximumRuleSetNameLength: + return fmt.Errorf("%s.name must be at most %d characters", field, maximumRuleSetNameLength) + case strings.IndexFunc(name, unicode.IsControl) >= 0: + return fmt.Errorf("%s.name must not contain control characters", field) + } + return nil +} + +// validateClientRuleSets checks that every device's rule set exists. +func validateClientRuleSets(clients []Client, sets []RuleSet) []error { + var validationErrors []error + for index, client := range clients { + if client.RuleSet == "" { + continue + } + if !slices.ContainsFunc(sets, func(set RuleSet) bool { return set.Name == client.RuleSet }) { + validationErrors = append(validationErrors, fmt.Errorf("clients[%d].rule_set names no rule set called %q", index, client.RuleSet)) + } + } + return validationErrors +} diff --git a/internal/config/rule_sets_test.go b/internal/config/rule_sets_test.go new file mode 100644 index 00000000..fc5df028 --- /dev/null +++ b/internal/config/rule_sets_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "slices" + "strings" + "testing" +) + +func TestRuleSetsValidateAndNormalize(t *testing.T) { + t.Parallel() + configuration := Defaults() + configuration.Blocking.Lists = []BlockList{{Name: "Ads", Path: "ads.txt"}, {Name: "Strict", Path: "strict.txt"}} + configuration.Blocking.DefaultLists = []string{" Ads ", "Ads"} + configuration.Blocking.RuleSets = []RuleSet{ + {Name: " Work ", Domains: []string{"Tracker.Example."}}, + {Name: "Kids", Lists: []string{"Strict", "Ads"}, AllowedDomains: []string{"*.School.Example"}}, + } + configuration.Clients = []Client{{MAC: "da:a1:19:00:00:01", RuleSet: " Kids "}} + configuration.normalize() + if err := configuration.Validate(); err != nil { + t.Fatal(err) + } + blocking := configuration.Blocking + if !slices.Equal(blocking.DefaultLists, []string{"Ads"}) || blocking.RuleSets[0].Name != "Kids" || + !slices.Equal(blocking.RuleSets[0].Lists, []string{"Ads", "Strict"}) || + !slices.Equal(blocking.RuleSets[0].AllowedDomains, []string{"*.school.example"}) || + !slices.Equal(blocking.RuleSets[1].Domains, []string{"tracker.example"}) || configuration.Clients[0].RuleSet != "Kids" { + t.Fatalf("normalized rule sets = %+v, clients = %+v", blocking.RuleSets, configuration.Clients) + } + cloned := cloneConfig(configuration) + cloned.Blocking.RuleSets[0].Lists[0] = "changed" + if configuration.Blocking.RuleSets[0].Lists[0] != "Ads" { + t.Fatal("Clone shared a rule set's lists") + } + + for _, test := range []struct { + change func(*Config) + want string + }{ + {func(c *Config) { c.Blocking.RuleSets = append(c.Blocking.RuleSets, RuleSet{}) }, "rule_sets[2].name is required"}, + {func(c *Config) { c.Blocking.RuleSets[1].Name = "default" }, "reserved"}, + {func(c *Config) { c.Blocking.RuleSets[1].Name = "kids" }, `"kids" is already used`}, + {func(c *Config) { c.Blocking.RuleSets[0].Lists = []string{"Missing"} }, `names no block list called "Missing"`}, + {func(c *Config) { c.Blocking.DefaultLists = []string{"Missing"} }, "blocking.default_lists[0]"}, + {func(c *Config) { c.Blocking.RuleSets[0].Domains = []string{"bad..example"} }, "rule_sets[0].domains[0]"}, + {func(c *Config) { c.Clients[0].RuleSet = "Guests" }, `names no rule set called "Guests"`}, + } { + candidate := cloneConfig(configuration) + test.change(&candidate) + if err := candidate.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("Validate() error = %v, want %q", err, test.want) + } + } +} + +func TestClientWithOnlyRuleSetIsKept(t *testing.T) { + t.Parallel() + clients := []Client{{Address: "192.0.2.20", Name: "Switch", RuleSet: "Kids"}} + updated, err := SetClientName(clients, Client{Address: "192.0.2.20"}) + if err != nil { + t.Fatal(err) + } + if len(updated) != 1 || updated[0].RuleSet != "Kids" || updated[0].Name != "" { + t.Fatalf("clients after removing the name = %+v, want the rule set kept", updated) + } +} diff --git a/internal/dnsserver/handler.go b/internal/dnsserver/handler.go index dc852471..46e7c487 100644 --- a/internal/dnsserver/handler.go +++ b/internal/dnsserver/handler.go @@ -46,15 +46,20 @@ type Runtime struct { // subdomains, to the lists that carry the rule. firmBlocked holds the // blocks an exception doesn't lift: $important rules and the operator's // own blocked domains. Both are consulted only once a block matches. - exceptions map[string]uint32 - firmBlocked map[string]struct{} - allowedExact map[string]struct{} - allowedWildcard map[string]struct{} - blocking bool - blockType string - blockTTL uint32 - blockAddrs []netip.Addr - bypass []netip.Prefix + exceptions map[string]uint32 + firmBlocked map[string]struct{} + allowed allowList + blocking bool + blockType string + blockTTL uint32 + blockAddrs []netip.Addr + // ruleSets are the policies for particular clients, picked by address + // from clientExact, then by the most specific network in clientPrefixes. + // defaultSet narrows the block lists for everyone else; nil uses them all. + ruleSets []ruleSet + defaultSet *ruleSet + clientExact map[netip.Addr]int + clientPrefixes []clientPrefix blockTXT bool cache *ResponseCache blockLists []BlockListStats @@ -127,15 +132,19 @@ type RuntimeConfig struct { // ExceptionDomainOwners runs parallel, indexing BlockedDomainOwnerSets. // ImportantBlockedDomains are the blocks an exception doesn't lift: // $important rules and the operator's own blocked domains. - ExceptionDomains []string - ExceptionDomainOwners []uint32 - ImportantBlockedDomains []string - AllowedDomains []string - BlockLists []BlockListStats - BlockingType string - BlockingTTL uint32 - BlockingAddrs []string - BypassClients []string + ExceptionDomains []string + ExceptionDomainOwners []uint32 + ImportantBlockedDomains []string + AllowedDomains []string + BlockLists []BlockListStats + BlockingType string + BlockingTTL uint32 + BlockingAddrs []string + BypassClients []string + RuleSets []RuleSetPolicy + // DefaultLists names the block-list sources for clients without a rule + // set. Nil applies every source. + DefaultLists []string AllowTXTReport bool Hosts []HostOverride Zones []AuthoritativeZone diff --git a/internal/dnsserver/policy.go b/internal/dnsserver/policy.go index f9a670b5..c768fe93 100644 --- a/internal/dnsserver/policy.go +++ b/internal/dnsserver/policy.go @@ -2,7 +2,6 @@ package dnsserver import ( "net" - "net/netip" "strings" "time" @@ -56,35 +55,20 @@ func (handler *Handler) DomainPolicy(name string) DomainPolicy { } func (runtime *Runtime) matchesBlocked(name string) bool { - rule, _ := matchingDomainRule(runtime.blocked, name) + rule, _ := matchingDomainRule(runtime.blocked, name, nil) return rule != "" } -func (runtime *Runtime) matchesAllowed(name string) bool { - return runtime.matchingAllowedRule(name) != "" -} - -func (runtime *Runtime) matchingAllowedRule(name string) string { - name = normalizeName(name) - if _, found := runtime.allowedExact[name]; found { - return name - } - for { - separator := strings.IndexByte(name, '.') - if separator < 0 { - return "" - } - name = name[separator+1:] - if _, found := runtime.allowedWildcard[name]; found { - return "*." + name - } +// matchingDomainRule finds the most specific blocked name that covers name. +// applies, when set, skips entries whose owner set it marks false, so a rule +// set that doesn't use a list sees past that list's entries to a parent's. +func matchingDomainRule(domains map[string]uint32, name string, applies []bool) (string, uint32) { + if len(domains) == 0 { + return "", 0 } -} - -func matchingDomainRule(domains map[string]uint32, name string) (string, uint32) { name = normalizeName(name) for name != "" { - if owner, found := domains[name]; found { + if owner, found := domains[name]; found && (applies == nil || (int(owner) < len(applies) && applies[owner])) { return name, owner } separator := strings.IndexByte(name, '.') @@ -105,6 +89,9 @@ func (runtime *Runtime) blockedSources(owner uint32) []string { return runtime.blockedOwners[owner] } +// policyDecision decides how blocking treats name for a client. A client's +// rule set comes first: its own allowed and blocked domains win over the +// global ones, and only its block lists apply. func (runtime *Runtime) policyDecision(name, clientIP string, paused bool) (querylog.PolicyDecision, string, []string) { if !runtime.blocking { return querylog.PolicyDisabled, "", nil @@ -112,31 +99,42 @@ func (runtime *Runtime) policyDecision(name, clientIP string, paused bool) (quer if paused { return querylog.PolicyPaused, "", nil } - if runtime.clientBypasses(clientIP) { - // The fact that the client bypassed policy is useful; persisting the - // matching address or network would duplicate sensitive configuration. - return querylog.PolicyClientBypass, "", nil + set := runtime.ruleSetFor(clientIP) + if set != nil { + if set.off { + // The fact that the client bypassed policy is useful; persisting the + // matching address or network would duplicate sensitive configuration. + return querylog.PolicyClientBypass, "", nil + } + if rule := set.allowed.match(name); rule != "" { + return querylog.PolicyAllowed, rule, nil + } + if rule, _ := matchingDomainRule(set.blocked, name, nil); rule != "" { + return querylog.PolicyBlocked, rule, nil + } } - if rule := runtime.matchingAllowedRule(name); rule != "" { + if rule := runtime.allowed.match(name); rule != "" { return querylog.PolicyAllowed, rule, nil } - if rule, owner := matchingDomainRule(runtime.blocked, name); rule != "" { - if exception, exceptionOwner := runtime.matchingException(name); exception != "" { - return querylog.PolicyAllowed, exception, runtime.blockedSources(exceptionOwner) + applies := set.ownerMask() + if rule, owner := matchingDomainRule(runtime.blocked, name, applies); rule != "" { + if exception, exceptionOwner := runtime.matchingException(name, applies); exception != "" { + return querylog.PolicyAllowed, exception, set.ownerSources(runtime, exceptionOwner) } - return querylog.PolicyBlocked, rule, runtime.blockedSources(owner) + return querylog.PolicyBlocked, rule, set.ownerSources(runtime, owner) } return querylog.PolicyNoMatch, "", nil } // matchingException finds a block list's @@ exception for a blocked name. An // exception lifts blocks from every list, as in AdGuard Home and Technitium, -// but not a $important block or one the operator added. -func (runtime *Runtime) matchingException(name string) (string, uint32) { +// but not a $important block or one the operator added. Only exceptions from +// lists the client's rule set uses count. +func (runtime *Runtime) matchingException(name string, applies []bool) (string, uint32) { if len(runtime.exceptions) == 0 { return "", 0 } - exception, owner := matchingDomainRule(runtime.exceptions, name) + exception, owner := matchingDomainRule(runtime.exceptions, name, applies) if exception == "" { return "", 0 } @@ -155,23 +153,6 @@ func (runtime *Runtime) matchingException(name string) (string, uint32) { return exception, owner } -func (runtime *Runtime) clientBypasses(value string) bool { - if value == "" || len(runtime.bypass) == 0 { - return false - } - address, err := netip.ParseAddr(value) - if err != nil { - return false - } - address = address.Unmap() - for _, prefix := range runtime.bypass { - if prefix.Contains(address) { - return true - } - } - return false -} - func (runtime *Runtime) blockedResponse(request *dns.Msg) *dns.Msg { question := request.Question[0] if question.Qtype == dns.TypeTXT && runtime.blockTXT { diff --git a/internal/dnsserver/rule_sets.go b/internal/dnsserver/rule_sets.go new file mode 100644 index 00000000..77306b5a --- /dev/null +++ b/internal/dnsserver/rule_sets.go @@ -0,0 +1,240 @@ +package dnsserver + +import ( + "cmp" + "fmt" + "net/netip" + "slices" + "strings" + + "github.com/drudge/sable/internal/dnsname" +) + +// RuleSetPolicy is a blocking policy for some clients instead of everyone. A +// client none of them name gets the global policy. +type RuleSetPolicy struct { + Name string + // Off turns blocking off for the set's clients, as a bypass does. + Off bool + // Lists names the block-list sources that apply to the set's clients. + Lists []string + // Domains and AllowedDomains apply to the set's clients on top of the + // global blocked and allowed domains, and win over them. + Domains []string + AllowedDomains []string + // Clients are the IP addresses and CIDR networks the set applies to. + Clients []string +} + +// ruleSet is a RuleSetPolicy compiled against the runtime's block lists. +type ruleSet struct { + name string + off bool + // applies says, for each owner set, whether any of its sources is one the + // rule set uses. Nil means every owner set applies. sources holds, per + // owner set, the sources the rule set uses, for attribution. + applies []bool + sources [][]string + blocked map[string]uint32 + allowed allowList +} + +// clientPrefix maps a network to the rule set its clients use. +type clientPrefix struct { + prefix netip.Prefix + set int +} + +// allowList holds allowed names: exact ones, and wildcards that allow every +// subdomain of a name but not the name itself. +type allowList struct { + exact map[string]struct{} + wildcard map[string]struct{} +} + +func compileAllowList(domains []string) (allowList, error) { + list := allowList{exact: make(map[string]struct{}, len(domains)), wildcard: make(map[string]struct{}, len(domains))} + for _, domain := range domains { + domain = strings.TrimSpace(domain) + wildcard := strings.HasPrefix(domain, "*.") + normalized, err := dnsname.Normalize(strings.TrimPrefix(domain, "*.")) + if err != nil { + return allowList{}, fmt.Errorf("invalid allowed domain %q: %w", domain, err) + } + if wildcard { + list.wildcard[normalized] = struct{}{} + } else { + list.exact[normalized] = struct{}{} + } + } + return list, nil +} + +// match returns the rule that allows name, or "". +func (list allowList) match(name string) string { + if len(list.exact) == 0 && len(list.wildcard) == 0 { + return "" + } + name = normalizeName(name) + if _, found := list.exact[name]; found { + return name + } + for { + separator := strings.IndexByte(name, '.') + if separator < 0 { + return "" + } + name = name[separator+1:] + if _, found := list.wildcard[name]; found { + return "*." + name + } + } +} + +// compileRuleSets builds the rule sets and the table that picks one for a +// client. Bypass clients become a rule set with blocking off, so there is one +// path for both. Owner sets must already be in place. +func (runtime *Runtime) compileRuleSets(configuration RuntimeConfig) error { + policies := configuration.RuleSets + if len(configuration.BypassClients) > 0 { + policies = append([]RuleSetPolicy{{Off: true, Clients: configuration.BypassClients}}, policies...) + } + sets := make([]ruleSet, 0, len(policies)) + exact := make(map[netip.Addr]int) + var prefixes []clientPrefix + for index, policy := range policies { + set, err := runtime.compileRuleSet(policy) + if err != nil { + return err + } + sets = append(sets, set) + for _, value := range policy.Clients { + prefix, err := parseClientPrefix(value) + if err != nil { + if policy.Off { + return fmt.Errorf("invalid blocking bypass client %q", value) + } + return fmt.Errorf("rule set %q: invalid client %q", policy.Name, value) + } + if prefix.IsSingleIP() { + // The first set to name an address keeps it, so a bypass wins. + if _, taken := exact[prefix.Addr()]; !taken { + exact[prefix.Addr()] = index + } + continue + } + prefixes = append(prefixes, clientPrefix{prefix: prefix, set: index}) + } + } + // The most specific network wins; among equals, the earlier set. + slices.SortStableFunc(prefixes, func(left, right clientPrefix) int { + return cmp.Compare(right.prefix.Bits(), left.prefix.Bits()) + }) + runtime.ruleSets = sets + runtime.clientExact = exact + runtime.clientPrefixes = prefixes + runtime.defaultSet = nil + if configuration.DefaultLists != nil { + applies, sources := ownerSetsUsing(runtime.blockedOwners, configuration.DefaultLists) + runtime.defaultSet = &ruleSet{applies: applies, sources: sources} + } + return nil +} + +func (runtime *Runtime) compileRuleSet(policy RuleSetPolicy) (ruleSet, error) { + set := ruleSet{name: policy.Name, off: policy.Off} + if policy.Off { + return set, nil + } + blocked := make(map[string]uint32, len(policy.Domains)) + for _, domain := range policy.Domains { + normalized, err := dnsname.Normalize(strings.TrimPrefix(strings.TrimSpace(domain), "*.")) + if err != nil { + return ruleSet{}, fmt.Errorf("rule set %q: invalid blocked domain %q: %w", policy.Name, domain, err) + } + blocked[normalized] = 0 + } + allowed, err := compileAllowList(policy.AllowedDomains) + if err != nil { + return ruleSet{}, fmt.Errorf("rule set %q: %w", policy.Name, err) + } + set.blocked, set.allowed = blocked, allowed + set.applies, set.sources = ownerSetsUsing(runtime.blockedOwners, policy.Lists) + return set, nil +} + +// ownerSetsUsing works out which owner sets a rule set using lists applies, +// and which of each one's sources it uses. Owner set zero carries no +// attribution, so a domain without any still applies everywhere. +func ownerSetsUsing(owners [][]string, lists []string) ([]bool, [][]string) { + applies := make([]bool, max(len(owners), 1)) + sources := make([][]string, len(applies)) + applies[0] = true + for index := 1; index < len(owners); index++ { + var used []string + for _, source := range owners[index] { + if slices.Contains(lists, source) { + used = append(used, source) + } + } + if len(used) == len(owners[index]) { + used = owners[index] + } + applies[index], sources[index] = len(used) > 0, used + } + return applies, sources +} + +func parseClientPrefix(value string) (netip.Prefix, error) { + if prefix, err := netip.ParsePrefix(value); err == nil { + return prefix.Masked(), nil + } + address, err := netip.ParseAddr(value) + if err != nil || address.Zone() != "" { + return netip.Prefix{}, fmt.Errorf("invalid client %q", value) + } + address = address.Unmap() + return netip.PrefixFrom(address, address.BitLen()), nil +} + +// ruleSetFor picks the rule set for a client, falling back to the default +// one, which is nil when every list applies. It runs on every query that +// reaches policy, so it must not allocate. +func (runtime *Runtime) ruleSetFor(clientIP string) *ruleSet { + if len(runtime.ruleSets) == 0 || clientIP == "" { + return runtime.defaultSet + } + address, err := netip.ParseAddr(clientIP) + if err != nil { + return runtime.defaultSet + } + address = address.Unmap().WithZone("") + if index, found := runtime.clientExact[address]; found { + return &runtime.ruleSets[index] + } + for _, entry := range runtime.clientPrefixes { + if entry.prefix.Contains(address) { + return &runtime.ruleSets[entry.set] + } + } + return runtime.defaultSet +} + +// ownerSources names the sources behind an owner set that the rule set uses. +func (set *ruleSet) ownerSources(runtime *Runtime, owner uint32) []string { + if set == nil || set.applies == nil { + return runtime.blockedSources(owner) + } + if int(owner) >= len(set.sources) { + return nil + } + return set.sources[owner] +} + +// ownerMask is the owner sets a client's rule set applies, nil for all. +func (set *ruleSet) ownerMask() []bool { + if set == nil { + return nil + } + return set.applies +} diff --git a/internal/dnsserver/rule_sets_test.go b/internal/dnsserver/rule_sets_test.go new file mode 100644 index 00000000..c6598759 --- /dev/null +++ b/internal/dnsserver/rule_sets_test.go @@ -0,0 +1,173 @@ +package dnsserver + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/drudge/sable/internal/querylog" +) + +// ruleSetTestConfig blocks names from two lists and the operator's own +// domains, with an exception from the strict list. +func ruleSetTestConfig() RuntimeConfig { + configuration := testRuntimeConfig() + configuration.Blocking = true + configuration.BlockedDomains = []string{"ads.example", "tracker.example", "video.example", "operator.example", "shorts.video.example"} + configuration.BlockedDomainOwnerSets = [][]string{nil, {"Ads"}, {"Ads", "Strict"}, {"Strict"}, {"Custom"}} + configuration.BlockedDomainOwners = []uint32{1, 2, 3, 4, 1} + configuration.ExceptionDomains = []string{"cdn.tracker.example"} + configuration.ExceptionDomainOwners = []uint32{3} + configuration.AllowedDomains = []string{"allowed.example"} + configuration.RuleSets = []RuleSetPolicy{ + {Name: "Kids", Lists: []string{"Ads", "Strict", "Custom"}, Domains: []string{"games.example"}, AllowedDomains: []string{"school.ads.example"}, Clients: []string{"192.0.2.0/24"}}, + {Name: "Work", Lists: []string{"Custom"}, AllowedDomains: []string{"*.tracker.example"}, Clients: []string{"192.0.2.50", "2001:db8::/32"}}, + {Name: "Strict only", Lists: []string{"Strict", "Custom"}, Clients: []string{"198.51.100.7"}}, + {Name: "Ads only", Lists: []string{"Ads"}, Clients: []string{"198.51.100.8"}}, + } + configuration.BypassClients = []string{"192.0.2.99", "203.0.113.0/24"} + return configuration +} + +func TestRuleSetsDecidePolicyPerClient(t *testing.T) { + t.Parallel() + runtime, err := Compile(ruleSetTestConfig()) + if err != nil { + t.Fatalf("Compile() error = %v", err) + } + tests := []struct { + name, client, query string + decision querylog.PolicyDecision + rule string + sources []string + }{ + {"no set uses every list", "10.0.0.1", "ads.example", querylog.PolicyBlocked, "ads.example", []string{"Ads"}}, + {"no set ignores a set's blocks", "10.0.0.1", "games.example", querylog.PolicyNoMatch, "", nil}, + {"set adds its own block", "192.0.2.4", "www.games.example", querylog.PolicyBlocked, "games.example", nil}, + {"set allow beats a list", "192.0.2.4", "school.ads.example", querylog.PolicyAllowed, "school.ads.example", nil}, + {"global allow still applies", "192.0.2.4", "allowed.example", querylog.PolicyAllowed, "allowed.example", nil}, + {"exact address beats network", "192.0.2.50", "ads.example", querylog.PolicyNoMatch, "", nil}, + {"unused list doesn't block", "192.0.2.50", "video.example", querylog.PolicyNoMatch, "", nil}, + {"operator domains still block", "192.0.2.50", "operator.example", querylog.PolicyBlocked, "operator.example", []string{"Custom"}}, + {"set wildcard allow", "2001:db8::5", "cdn.tracker.example", querylog.PolicyAllowed, "*.tracker.example", nil}, + {"sources narrow to the set's lists", "198.51.100.7", "tracker.example", querylog.PolicyBlocked, "tracker.example", []string{"Strict"}}, + {"unused entry falls through to parent", "198.51.100.7", "shorts.video.example", querylog.PolicyBlocked, "video.example", []string{"Strict"}}, + {"used list's exception lifts", "198.51.100.7", "cdn.tracker.example", querylog.PolicyAllowed, "cdn.tracker.example", []string{"Strict"}}, + {"unused list's exception doesn't lift", "198.51.100.8", "cdn.tracker.example", querylog.PolicyBlocked, "tracker.example", []string{"Ads"}}, + {"bypass address beats network", "192.0.2.99", "ads.example", querylog.PolicyClientBypass, "", nil}, + {"bypass network", "203.0.113.8", "operator.example", querylog.PolicyClientBypass, "", nil}, + {"mapped IPv4 client", "::ffff:192.0.2.4", "games.example", querylog.PolicyBlocked, "games.example", nil}, + {"no client", "", "games.example", querylog.PolicyNoMatch, "", nil}, + } + for _, test := range tests { + decision, rule, sources := runtime.policyDecision(test.query, test.client, false) + if decision != test.decision || rule != test.rule || !slices.Equal(sources, test.sources) { + t.Errorf("%s: policyDecision(%q, %q) = %q %q %v, want %q %q %v", test.name, test.query, test.client, + decision, rule, sources, test.decision, test.rule, test.sources) + } + } +} + +func TestDefaultListsNarrowPolicyForClientsWithoutRuleSet(t *testing.T) { + t.Parallel() + configuration := ruleSetTestConfig() + configuration.DefaultLists = []string{"Ads", "Custom"} + runtime, err := Compile(configuration) + if err != nil { + t.Fatalf("Compile() error = %v", err) + } + handler := NewHandler(runtime) + if policy := handler.DomainPolicy("video.example"); policy.Decision != querylog.PolicyNoMatch { + t.Errorf("DomainPolicy(video.example) = %+v, want no match outside the default lists", policy) + } + if decision, rule, sources := runtime.policyDecision("tracker.example", "10.0.0.1", false); decision != querylog.PolicyBlocked || + rule != "tracker.example" || !slices.Equal(sources, []string{"Ads"}) { + t.Errorf("policyDecision(tracker.example) = %q %q %v, want blocked by Ads alone", decision, rule, sources) + } + if decision, _, _ := runtime.policyDecision("video.example", "198.51.100.7", false); decision != querylog.PolicyBlocked { + t.Errorf("policyDecision(video.example) for a rule set = %q, want its own lists to apply", decision) + } +} + +func TestRuleSetsRejectInvalidClientsAndDomains(t *testing.T) { + t.Parallel() + for _, change := range []struct { + name string + edit func(*RuntimeConfig) + error string + }{ + {"client", func(configuration *RuntimeConfig) { configuration.RuleSets[0].Clients = []string{"not-an-address"} }, `rule set "Kids": invalid client`}, + {"bypass", func(configuration *RuntimeConfig) { configuration.BypassClients = []string{"fe80::1%eth0"} }, "invalid blocking bypass client"}, + {"domain", func(configuration *RuntimeConfig) { configuration.RuleSets[1].Domains = []string{"bad..example"} }, `rule set "Work": invalid blocked domain`}, + {"allowed", func(configuration *RuntimeConfig) { + configuration.RuleSets[1].AllowedDomains = []string{"bad..example"} + }, `rule set "Work": invalid allowed domain`}, + } { + configuration := ruleSetTestConfig() + change.edit(&configuration) + if _, err := Compile(configuration); err == nil || !strings.Contains(err.Error(), change.error) { + t.Errorf("%s: Compile() error = %v, want %q", change.name, err, change.error) + } + } +} + +func TestPolicyDecisionDoesNotAllocateWithRuleSets(t *testing.T) { + runtime, err := Compile(ruleSetTestConfig()) + if err != nil { + t.Fatalf("Compile() error = %v", err) + } + for _, client := range []string{"10.0.0.1", "192.0.2.4", "192.0.2.50", "198.51.100.7", "192.0.2.99"} { + if allocations := testing.AllocsPerRun(100, func() { + _, _, _ = runtime.policyDecision("pixel.shorts.video.example.", client, false) + }); allocations != 0 { + t.Errorf("policyDecision for %s allocated %.0f times, want 0", client, allocations) + } + } +} + +// BenchmarkPolicyDecision measures the per-query policy check against a large +// block list, for a client with no rule set and for one in a set that uses +// only some of the lists. +func BenchmarkPolicyDecision(b *testing.B) { + const policySize = 100_000 + configuration := testRuntimeConfig() + configuration.Blocking = true + configuration.BlockedDomains = make([]string, 0, policySize) + configuration.BlockedDomainOwners = make([]uint32, 0, policySize) + configuration.BlockedDomainOwnerSets = [][]string{nil, {"Ads"}, {"Strict"}} + for index := range policySize { + configuration.BlockedDomains = append(configuration.BlockedDomains, fmt.Sprintf("host-%d.example", index)) + configuration.BlockedDomainOwners = append(configuration.BlockedDomainOwners, uint32(1+index%2)) + } + configuration.BypassClients = []string{"198.51.100.0/24"} + runtime, err := Compile(configuration) + if err != nil { + b.Fatal(err) + } + withSets := configuration + withSets.RuleSets = []RuleSetPolicy{ + {Name: "Kids", Lists: []string{"Strict"}, Domains: []string{"games.example"}, Clients: []string{"192.0.2.0/24"}}, + {Name: "Work", Lists: []string{"Ads"}, Clients: []string{"192.0.2.50", "2001:db8::/32"}}, + } + setRuntime, err := Compile(withSets) + if err != nil { + b.Fatal(err) + } + for _, run := range []struct { + name string + runtime *Runtime + client string + }{ + {"NoRuleSets", runtime, "192.0.2.4"}, + {"OutsideRuleSets", setRuntime, "10.0.0.1"}, + {"InRuleSet", setRuntime, "192.0.2.4"}, + } { + b.Run(run.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + _, _, _ = run.runtime.policyDecision("pixel.host-99999.example.", run.client, false) + } + }) + } +} diff --git a/internal/dnsserver/runtime_compile.go b/internal/dnsserver/runtime_compile.go index 476184c2..b87407e7 100644 --- a/internal/dnsserver/runtime_compile.go +++ b/internal/dnsserver/runtime_compile.go @@ -121,7 +121,7 @@ func compileResolver(configuration RuntimeConfig) (*Runtime, error) { } // compilePolicy fills in the block and allow lists, the blocking response and -// the clients that bypass blocking. +// the rule sets for particular clients, bypass clients among them. func (runtime *Runtime) compilePolicy(configuration RuntimeConfig) error { blocked := make(map[string]uint32, len(configuration.BlockedDomains)) attributed := len(configuration.BlockedDomainOwners) == len(configuration.BlockedDomains) @@ -148,20 +148,9 @@ func (runtime *Runtime) compilePolicy(configuration RuntimeConfig) error { if err != nil { return err } - allowedExact := make(map[string]struct{}, len(configuration.AllowedDomains)) - allowedWildcard := make(map[string]struct{}, len(configuration.AllowedDomains)) - for _, domain := range configuration.AllowedDomains { - domain = strings.TrimSpace(domain) - wildcard := strings.HasPrefix(domain, "*.") - normalized, err := dnsname.Normalize(strings.TrimPrefix(domain, "*.")) - if err != nil { - return fmt.Errorf("invalid allowed domain %q: %w", domain, err) - } - if wildcard { - allowedWildcard[normalized] = struct{}{} - } else { - allowedExact[normalized] = struct{}{} - } + allowed, err := compileAllowList(configuration.AllowedDomains) + if err != nil { + return err } blockingType := configuration.BlockingType if blockingType == "" { @@ -182,31 +171,17 @@ func (runtime *Runtime) compilePolicy(configuration RuntimeConfig) error { blockAddresses = append(blockAddresses, address.Unmap()) } } - bypass := make([]netip.Prefix, 0, len(configuration.BypassClients)) - for _, value := range configuration.BypassClients { - prefix, err := netip.ParsePrefix(value) - if err != nil { - address, addressErr := netip.ParseAddr(value) - if addressErr != nil || address.Zone() != "" { - return fmt.Errorf("invalid blocking bypass client %q", value) - } - prefix = netip.PrefixFrom(address.Unmap(), address.Unmap().BitLen()) - } - bypass = append(bypass, prefix.Masked()) - } runtime.blocked = blocked runtime.blockedOwners = configuration.BlockedDomainOwnerSets runtime.exceptions = exceptions runtime.firmBlocked = firmBlocked - runtime.allowedExact = allowedExact - runtime.allowedWildcard = allowedWildcard + runtime.allowed = allowed runtime.blocking = configuration.Blocking runtime.blockType = blockingType runtime.blockTTL = configuration.BlockingTTL runtime.blockAddrs = blockAddresses - runtime.bypass = bypass runtime.blockTXT = configuration.AllowTXTReport - return nil + return runtime.compileRuleSets(configuration) } // compileRoutes builds the conditional forwarding table, keyed by the