diff --git a/README.md b/README.md index 1460218..972a8ac 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # dnswatcher +> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice. + dnswatcher is a production DNS and infrastructure monitoring daemon written in Go. It watches configured DNS domains and hostnames for changes, monitors TCP port availability, tracks TLS certificate expiry, and delivers real-time @@ -195,8 +197,7 @@ the following precedence (highest to lowest): | `PORT` | HTTP listen port | `8080` | | `DNSWATCHER_DEBUG` | Enable debug logging | `false` | | `DNSWATCHER_DATA_DIR` | Directory for state file | `./data` | -| `DNSWATCHER_DOMAINS` | Comma-separated list of apex domains | `""` | -| `DNSWATCHER_HOSTNAMES` | Comma-separated list of hostnames | `""` | +| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | | `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | | `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | | `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | @@ -214,8 +215,7 @@ the following precedence (highest to lowest): PORT=8080 DNSWATCHER_DEBUG=false DNSWATCHER_DATA_DIR=./data -DNSWATCHER_DOMAINS=example.com,example.org -DNSWATCHER_HOSTNAMES=www.example.com,api.example.com,mail.example.org +DNSWATCHER_TARGETS=example.com,example.org,www.example.com,api.example.com,mail.example.org DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts @@ -352,8 +352,7 @@ docker build -t dnswatcher . docker run -d \ -p 8080:8080 \ -v dnswatcher-data:/var/lib/dnswatcher \ - -e DNSWATCHER_DOMAINS=example.com \ - -e DNSWATCHER_HOSTNAMES=www.example.com \ + -e DNSWATCHER_TARGETS=example.com,www.example.com \ -e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \ dnswatcher ``` diff --git a/go.mod b/go.mod index 1b21a1c..32ad532 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/spf13/viper v1.21.0 go.uber.org/fx v1.24.0 + golang.org/x/net v0.50.0 ) require ( @@ -33,7 +34,7 @@ require ( go.uber.org/zap v1.26.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 1b5631a..66cc528 100644 --- a/go.sum +++ b/go.sum @@ -74,10 +74,12 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/classify.go b/internal/config/classify.go new file mode 100644 index 0000000..1076215 --- /dev/null +++ b/internal/config/classify.go @@ -0,0 +1,85 @@ +package config + +import ( + "errors" + "fmt" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// DNSNameType indicates whether a DNS name is an apex domain or a hostname. +type DNSNameType int + +const ( + // DNSNameTypeDomain indicates the name is an apex (eTLD+1) domain. + DNSNameTypeDomain DNSNameType = iota + // DNSNameTypeHostname indicates the name is a subdomain/hostname. + DNSNameTypeHostname +) + +// ErrEmptyDNSName is returned when an empty string is passed to ClassifyDNSName. +var ErrEmptyDNSName = errors.New("empty DNS name") + +// String returns the string representation of a DNSNameType. +func (t DNSNameType) String() string { + switch t { + case DNSNameTypeDomain: + return "domain" + case DNSNameTypeHostname: + return "hostname" + default: + return "unknown" + } +} + +// ClassifyDNSName determines whether a DNS name is an apex domain or a +// hostname (subdomain) using the Public Suffix List. It returns an error +// if the input is empty or is itself a public suffix (e.g. "co.uk"). +func ClassifyDNSName(name string) (DNSNameType, error) { + name = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), ".")) + + if name == "" { + return 0, ErrEmptyDNSName + } + + etld1, err := publicsuffix.EffectiveTLDPlusOne(name) + if err != nil { + return 0, fmt.Errorf("invalid DNS name %q: %w", name, err) + } + + if name == etld1 { + return DNSNameTypeDomain, nil + } + + return DNSNameTypeHostname, nil +} + +// ClassifyTargets splits a list of DNS names into apex domains and +// hostnames using the Public Suffix List. It returns an error if any +// name cannot be classified. +func ClassifyTargets(targets []string) ([]string, []string, error) { + var domains, hostnames []string + + for _, t := range targets { + normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(t), ".")) + + if normalized == "" { + continue + } + + typ, classErr := ClassifyDNSName(normalized) + if classErr != nil { + return nil, nil, classErr + } + + switch typ { + case DNSNameTypeDomain: + domains = append(domains, normalized) + case DNSNameTypeHostname: + hostnames = append(hostnames, normalized) + } + } + + return domains, hostnames, nil +} diff --git a/internal/config/classify_test.go b/internal/config/classify_test.go new file mode 100644 index 0000000..fb21bbc --- /dev/null +++ b/internal/config/classify_test.go @@ -0,0 +1,83 @@ +package config_test + +import ( + "testing" + + "sneak.berlin/go/dnswatcher/internal/config" +) + +func TestClassifyDNSName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want config.DNSNameType + wantErr bool + }{ + {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain}, + {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname}, + {name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain}, + {name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname}, + {name: "public suffix itself", input: "co.uk", wantErr: true}, + {name: "empty string", input: "", wantErr: true}, + {name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname}, + {name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain}, + {name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := config.ClassifyDNSName(tt.input) + + if tt.wantErr { + if err == nil { + t.Errorf("ClassifyDNSName(%q) expected error, got %v", tt.input, got) + } + + return + } + + if err != nil { + t.Fatalf("ClassifyDNSName(%q) unexpected error: %v", tt.input, err) + } + + if got != tt.want { + t.Errorf("ClassifyDNSName(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestClassifyTargets(t *testing.T) { + t.Parallel() + + domains, hostnames, err := config.ClassifyTargets([]string{ + "example.com", + "www.example.com", + "example.co.uk", + "api.example.co.uk", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(domains) != 2 { + t.Errorf("expected 2 domains, got %d: %v", len(domains), domains) + } + + if len(hostnames) != 2 { + t.Errorf("expected 2 hostnames, got %d: %v", len(hostnames), hostnames) + } +} + +func TestClassifyTargetsRejectsPublicSuffix(t *testing.T) { + t.Parallel() + + _, _, err := config.ClassifyTargets([]string{"co.uk"}) + if err == nil { + t.Error("expected error for public suffix, got nil") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b43027d..0acf89e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,8 +89,7 @@ func setupViper(name string) { viper.SetDefault("PORT", defaultPort) viper.SetDefault("DEBUG", false) viper.SetDefault("DATA_DIR", "./data") - viper.SetDefault("DOMAINS", "") - viper.SetDefault("HOSTNAMES", "") + viper.SetDefault("TARGETS", "") viper.SetDefault("SLACK_WEBHOOK", "") viper.SetDefault("MATTERMOST_WEBHOOK", "") viper.SetDefault("NTFY_TOPIC", "") @@ -133,12 +132,19 @@ func buildConfig( tlsInterval = defaultTLSInterval } + domains, hostnames, err := ClassifyTargets( + parseCSV(viper.GetString("TARGETS")), + ) + if err != nil { + return nil, fmt.Errorf("invalid targets configuration: %w", err) + } + cfg := &Config{ Port: viper.GetInt("PORT"), Debug: viper.GetBool("DEBUG"), DataDir: viper.GetString("DATA_DIR"), - Domains: parseCSV(viper.GetString("DOMAINS")), - Hostnames: parseCSV(viper.GetString("HOSTNAMES")), + Domains: domains, + Hostnames: hostnames, SlackWebhook: viper.GetString("SLACK_WEBHOOK"), MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), NtfyTopic: viper.GetString("NTFY_TOPIC"),