Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/clustering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 48 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions internal/app/cluster_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -462,6 +475,7 @@ func replicatedRuntimeConfiguration(source config.Config) clusterRuntimeConfigur
InsightFindings: &findings,
MCP: &mcp,
InsightsEnabled: &insightsEnabled,
Devices: &clusterDevices{Clients: slices.Clone(source.Clients)},
}
}

Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions internal/app/insights_switch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
35 changes: 35 additions & 0 deletions internal/app/rule_sets_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 7 additions & 3 deletions internal/config/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, ", "))
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/clients_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
27 changes: 19 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()...)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 2 additions & 0 deletions internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
Loading
Loading