Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
5916e32ff3 feat: unify DOMAINS/HOSTNAMES into single TARGETS config
Add DNSWATCHER_TARGETS env var that accepts a comma-separated list of
DNS names and automatically classifies them as apex domains or hostnames
using the Public Suffix List (golang.org/x/net/publicsuffix).

- ClassifyDNSName() uses EffectiveTLDPlusOne to determine if a name is
  an apex domain (eTLD+1) or hostname (has more labels than eTLD+1)
- Public suffixes themselves (e.g. co.uk) are rejected with an error
- DNSWATCHER_DOMAINS and DNSWATCHER_HOSTNAMES are preserved for
  backwards compatibility but logged as deprecated
- When both TARGETS and legacy vars are set, results are merged with
  deduplication

Closes #10
2026-02-19 20:08:11 -08:00
5 changed files with 172 additions and 113 deletions

View File

@ -1,8 +1,5 @@
# dnswatcher # dnswatcher
> ⚠️ **Pre-1.0 software.** APIs, configuration, and behavior may change
> without notice.
dnswatcher is a production DNS and infrastructure monitoring daemon written in dnswatcher is a production DNS and infrastructure monitoring daemon written in
Go. It watches configured DNS domains and hostnames for changes, monitors TCP Go. It watches configured DNS domains and hostnames for changes, monitors TCP
port availability, tracks TLS certificate expiry, and delivers real-time port availability, tracks TLS certificate expiry, and delivers real-time
@ -19,19 +16,10 @@ without requiring an external database.
## Features ## Features
### Target Classification
All monitored DNS names are provided via a single `DNSWATCHER_TARGETS`
list. dnswatcher uses the [Public Suffix List](https://publicsuffix.org/)
to automatically classify each entry as an apex domain (eTLD+1, e.g.
`example.com`, `example.co.uk`) or a hostname (subdomain, e.g.
`www.example.com`). Apex domains receive NS delegation monitoring;
hostnames receive per-nameserver record monitoring. Both receive port
and TLS checks.
### DNS Domain Monitoring (Apex Domains) ### DNS Domain Monitoring (Apex Domains)
- Apex domains are identified automatically via the PSL. - Accepts a list of DNS domain names (apex domains, identified via the
[Public Suffix List](https://publicsuffix.org/)).
- Every **1 hour**, performs a full iterative trace from root servers to - Every **1 hour**, performs a full iterative trace from root servers to
discover all authoritative nameservers (NS records) for each domain. discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently. - Queries **every** discovered authoritative nameserver independently.
@ -207,7 +195,9 @@ the following precedence (highest to lowest):
| `PORT` | HTTP listen port | `8080` | | `PORT` | HTTP listen port | `8080` |
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` | | `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
| `DNSWATCHER_DATA_DIR` | Directory for state file | `./data` | | `DNSWATCHER_DATA_DIR` | Directory for state file | `./data` |
| `DNSWATCHER_TARGETS` | Comma-separated list of DNS names to monitor | `""` | | `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
| `DNSWATCHER_DOMAINS` | *(deprecated)* Comma-separated apex domains | `""` |
| `DNSWATCHER_HOSTNAMES` | *(deprecated)* Comma-separated hostnames | `""` |
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | | `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | | `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | | `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |

4
go.mod
View File

@ -9,7 +9,6 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/net v0.50.0 golang.org/x/net v0.50.0
) )
@ -17,12 +16,10 @@ require (
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
@ -40,5 +37,4 @@ require (
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@ -1,59 +1,83 @@
// Package config provides application configuration via Viper.
package config package config
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"golang.org/x/net/publicsuffix" "golang.org/x/net/publicsuffix"
) )
// ClassifyTarget determines whether a DNS name is an apex domain // DNSNameType indicates whether a DNS name is an apex domain or a hostname.
// (eTLD+1) or a hostname (subdomain of an eTLD+1). Returns type DNSNameType int
// "domain" or "hostname". Returns an error if the name is itself
// a public suffix (e.g. "co.uk") or otherwise invalid.
func ClassifyTarget(name string) (string, error) {
// Normalize: lowercase, strip trailing dot.
name = strings.ToLower(strings.TrimSuffix(name, "."))
if name == "" { const (
return "", fmt.Errorf("empty target name") // 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"
} }
apex, err := publicsuffix.EffectiveTLDPlusOne(name)
if err != nil {
return "", fmt.Errorf(
"invalid target %q: %w", name, err,
)
}
if name == apex {
return "domain", nil
}
return "hostname", nil
} }
// classifyTargets splits a list of DNS names into apex domains // ClassifyDNSName determines whether a DNS name is an apex domain or a
// and hostnames using the Public Suffix List. // hostname (subdomain) using the Public Suffix List. It returns an error
func classifyTargets( // if the input is empty or is itself a public suffix (e.g. "co.uk").
targets []string, func ClassifyDNSName(name string) (DNSNameType, error) {
) (domains, hostnames []string, err error) { name = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), "."))
for _, target := range targets {
kind, classifyErr := ClassifyTarget(target) if name == "" {
if classifyErr != nil { return 0, ErrEmptyDNSName
return nil, nil, classifyErr }
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
} }
switch kind { typ, classErr := ClassifyDNSName(normalized)
case "domain": if classErr != nil {
domains = append(domains, strings.ToLower( return nil, nil, classErr
strings.TrimSuffix(target, "."), }
))
case "hostname": switch typ {
hostnames = append(hostnames, strings.ToLower( case DNSNameTypeDomain:
strings.TrimSuffix(target, "."), domains = append(domains, normalized)
)) case DNSNameTypeHostname:
hostnames = append(hostnames, normalized)
} }
} }

View File

@ -1,51 +1,52 @@
package config package config_test
import ( import (
"testing" "testing"
"github.com/stretchr/testify/assert" "sneak.berlin/go/dnswatcher/internal/config"
"github.com/stretchr/testify/require"
) )
func TestClassifyTarget(t *testing.T) { func TestClassifyDNSName(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
input string input string
expected string want config.DNSNameType
wantErr bool wantErr bool
}{ }{
{"apex .com", "example.com", "domain", false}, {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain},
{"apex .org", "example.org", "domain", false}, {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname},
{"apex .co.uk", "example.co.uk", "domain", false}, {name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain},
{"apex .com.au", "example.com.au", "domain", false}, {name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname},
{"subdomain www", "www.example.com", "hostname", false}, {name: "public suffix itself", input: "co.uk", wantErr: true},
{"subdomain api", "api.example.com", "hostname", false}, {name: "empty string", input: "", wantErr: true},
{"deep subdomain", "a.b.c.example.com", "hostname", false}, {name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname},
{"subdomain .co.uk", "www.example.co.uk", "hostname", false}, {name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain},
{"trailing dot", "example.com.", "domain", false}, {name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname},
{"trailing dot sub", "www.example.com.", "hostname", false},
{"uppercase", "EXAMPLE.COM", "domain", false},
{"mixed case", "Www.Example.Com", "hostname", false},
{"public suffix", "co.uk", "", true},
{"tld only", "com", "", true},
{"empty", "", "", true},
} }
for _, tc := range tests { for _, tt := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
result, err := ClassifyTarget(tc.input) got, err := config.ClassifyDNSName(tt.input)
if tc.wantErr {
assert.Error(t, err) if tt.wantErr {
if err == nil {
t.Errorf("ClassifyDNSName(%q) expected error, got %v", tt.input, got)
}
return return
} }
require.NoError(t, err) if err != nil {
assert.Equal(t, tc.expected, result) 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)
}
}) })
} }
} }
@ -53,32 +54,30 @@ func TestClassifyTarget(t *testing.T) {
func TestClassifyTargets(t *testing.T) { func TestClassifyTargets(t *testing.T) {
t.Parallel() t.Parallel()
targets := []string{ domains, hostnames, err := config.ClassifyTargets([]string{
"example.com", "example.com",
"www.example.com", "www.example.com",
"api.example.com",
"example.co.uk", "example.co.uk",
"blog.example.co.uk", "api.example.co.uk",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
} }
domains, hostnames, err := classifyTargets(targets) if len(domains) != 2 {
require.NoError(t, err) t.Errorf("expected 2 domains, got %d: %v", len(domains), domains)
}
assert.Equal( if len(hostnames) != 2 {
t, t.Errorf("expected 2 hostnames, got %d: %v", len(hostnames), hostnames)
[]string{"example.com", "example.co.uk"}, }
domains,
)
assert.Equal(
t,
[]string{"www.example.com", "api.example.com", "blog.example.co.uk"},
hostnames,
)
} }
func TestClassifyTargets_RejectsPublicSuffix(t *testing.T) { func TestClassifyTargetsRejectsPublicSuffix(t *testing.T) {
t.Parallel() t.Parallel()
_, _, err := classifyTargets([]string{"example.com", "co.uk"}) _, _, err := config.ClassifyTargets([]string{"co.uk"})
assert.Error(t, err) if err == nil {
t.Error("expected error for public suffix, got nil")
}
} }

View File

@ -90,6 +90,8 @@ func setupViper(name string) {
viper.SetDefault("DEBUG", false) viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "./data") viper.SetDefault("DATA_DIR", "./data")
viper.SetDefault("TARGETS", "") viper.SetDefault("TARGETS", "")
viper.SetDefault("DOMAINS", "")
viper.SetDefault("HOSTNAMES", "")
viper.SetDefault("SLACK_WEBHOOK", "") viper.SetDefault("SLACK_WEBHOOK", "")
viper.SetDefault("MATTERMOST_WEBHOOK", "") viper.SetDefault("MATTERMOST_WEBHOOK", "")
viper.SetDefault("NTFY_TOPIC", "") viper.SetDefault("NTFY_TOPIC", "")
@ -132,11 +134,9 @@ func buildConfig(
tlsInterval = defaultTLSInterval tlsInterval = defaultTLSInterval
} }
targets := parseCSV(viper.GetString("TARGETS")) domains, hostnames, err := resolveTargets(log)
if err != nil {
domains, hostnames, classifyErr := classifyTargets(targets) return nil, fmt.Errorf("invalid targets configuration: %w", err)
if classifyErr != nil {
return nil, fmt.Errorf("classifying targets: %w", classifyErr)
} }
cfg := &Config{ cfg := &Config{
@ -187,6 +187,56 @@ func configureDebugLogging(cfg *Config, params Params) {
} }
} }
// resolveTargets merges DNSWATCHER_TARGETS with the deprecated
// DNSWATCHER_DOMAINS and DNSWATCHER_HOSTNAMES variables. When TARGETS
// is set, names are automatically classified using the Public Suffix
// List. Legacy variables are merged in and a deprecation warning is
// logged when they are used.
func resolveTargets(log *slog.Logger) ([]string, []string, error) {
targets := parseCSV(viper.GetString("TARGETS"))
legacyDomains := parseCSV(viper.GetString("DOMAINS"))
legacyHostnames := parseCSV(viper.GetString("HOSTNAMES"))
if len(legacyDomains) > 0 || len(legacyHostnames) > 0 {
log.Warn(
"DNSWATCHER_DOMAINS and DNSWATCHER_HOSTNAMES are deprecated; use DNSWATCHER_TARGETS instead",
)
}
var domains, hostnames []string
if len(targets) > 0 {
var err error
domains, hostnames, err = ClassifyTargets(targets)
if err != nil {
return nil, nil, err
}
}
domains = mergeUnique(domains, legacyDomains)
hostnames = mergeUnique(hostnames, legacyHostnames)
return domains, hostnames, nil
}
// mergeUnique appends items from b into a, skipping duplicates.
func mergeUnique(a, b []string) []string {
seen := make(map[string]bool, len(a))
for _, v := range a {
seen[v] = true
}
for _, v := range b {
if !seen[v] {
a = append(a, v)
seen[v] = true
}
}
return a
}
// StatePath returns the full path to the state JSON file. // StatePath returns the full path to the state JSON file.
func (c *Config) StatePath() string { func (c *Config) StatePath() string {
return c.DataDir + "/state.json" return c.DataDir + "/state.json"