Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
73e01c7664 feat: unify DOMAINS/HOSTNAMES into single TARGETS config
Replace DNSWATCHER_DOMAINS and DNSWATCHER_HOSTNAMES with a single
DNSWATCHER_TARGETS env var. Names are automatically classified as apex
domains or hostnames using the Public Suffix List
(golang.org/x/net/publicsuffix).

- ClassifyDNSName() uses EffectiveTLDPlusOne to determine type
- Public suffixes themselves (e.g. co.uk) are rejected with an error
- Old DOMAINS/HOSTNAMES vars removed entirely (pre-1.0, no compat needed)
- README updated with pre-1.0 warning

Closes #10
2026-02-19 20:09:39 -08:00
5 changed files with 121 additions and 112 deletions

View File

@ -1,7 +1,6 @@
# dnswatcher # dnswatcher
> ⚠️ **Pre-1.0 software.** APIs, configuration, and behavior may change > ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice.
> 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
@ -19,19 +18,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 +197,7 @@ 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_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. const (
func ClassifyTarget(name string) (string, error) { // DNSNameTypeDomain indicates the name is an apex (eTLD+1) domain.
// Normalize: lowercase, strip trailing dot. DNSNameTypeDomain DNSNameType = iota
name = strings.ToLower(strings.TrimSuffix(name, ".")) // 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 == "" { if name == "" {
return "", fmt.Errorf("empty target name") return 0, ErrEmptyDNSName
} }
apex, err := publicsuffix.EffectiveTLDPlusOne(name) etld1, err := publicsuffix.EffectiveTLDPlusOne(name)
if err != nil { if err != nil {
return "", fmt.Errorf( return 0, fmt.Errorf("invalid DNS name %q: %w", name, err)
"invalid target %q: %w", name, err,
)
} }
if name == apex { if name == etld1 {
return "domain", nil return DNSNameTypeDomain, nil
} }
return "hostname", nil return DNSNameTypeHostname, nil
} }
// classifyTargets splits a list of DNS names into apex domains // ClassifyTargets splits a list of DNS names into apex domains and
// and hostnames using the Public Suffix List. // hostnames using the Public Suffix List. It returns an error if any
func classifyTargets( // name cannot be classified.
targets []string, func ClassifyTargets(targets []string) ([]string, []string, error) {
) (domains, hostnames []string, err error) { var domains, hostnames []string
for _, target := range targets {
kind, classifyErr := ClassifyTarget(target) for _, t := range targets {
if classifyErr != nil { normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(t), "."))
return nil, nil, classifyErr
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(
t,
[]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) { if len(hostnames) != 2 {
t.Errorf("expected 2 hostnames, got %d: %v", len(hostnames), hostnames)
}
}
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

@ -132,11 +132,11 @@ func buildConfig(
tlsInterval = defaultTLSInterval tlsInterval = defaultTLSInterval
} }
targets := parseCSV(viper.GetString("TARGETS")) domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")),
domains, hostnames, classifyErr := classifyTargets(targets) )
if classifyErr != nil { if err != nil {
return nil, fmt.Errorf("classifying targets: %w", classifyErr) return nil, fmt.Errorf("invalid targets configuration: %w", err)
} }
cfg := &Config{ cfg := &Config{