Compare commits

..

3 Commits

Author SHA1 Message Date
clawbot
628bba22fe docs: update README for TARGETS config and add pre-1.0 notice 2026-02-19 20:09:07 -08:00
clawbot
acae697aa2 feat: replace DOMAINS/HOSTNAMES with single TARGETS config
Single DNSWATCHER_TARGETS env var replaces the separate DOMAINS and
HOSTNAMES variables. Classification is automatic via the PSL.

Closes #10
2026-02-19 20:09:07 -08:00
clawbot
1db3056594 feat: add PSL-based target classification
Classify DNS names as apex domains or hostnames using the Public
Suffix List (golang.org/x/net/publicsuffix). Correctly handles
multi-level TLDs like .co.uk and .com.au.
2026-02-19 20:09:07 -08:00
5 changed files with 106 additions and 165 deletions

View File

@ -1,5 +1,8 @@
# 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
@ -16,10 +19,19 @@ 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)
- Accepts a list of DNS domain names (apex domains, identified via the - Apex domains are identified automatically via the PSL.
[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.
@ -195,9 +207,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 DNS names (auto-classified via PSL) | `""` | | `DNSWATCHER_TARGETS` | Comma-separated list of DNS names to monitor | `""` |
| `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,6 +9,7 @@ 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
) )
@ -16,10 +17,12 @@ 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
@ -37,4 +40,5 @@ 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,83 +1,59 @@
// 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"
) )
// DNSNameType indicates whether a DNS name is an apex domain or a hostname. // ClassifyTarget determines whether a DNS name is an apex domain
type DNSNameType int // (eTLD+1) or a hostname (subdomain of an eTLD+1). Returns
// "domain" or "hostname". Returns an error if the name is itself
const ( // a public suffix (e.g. "co.uk") or otherwise invalid.
// DNSNameTypeDomain indicates the name is an apex (eTLD+1) domain. func ClassifyTarget(name string) (string, error) {
DNSNameTypeDomain DNSNameType = iota // Normalize: lowercase, strip trailing dot.
// DNSNameTypeHostname indicates the name is a subdomain/hostname. name = strings.ToLower(strings.TrimSuffix(name, "."))
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 0, ErrEmptyDNSName return "", fmt.Errorf("empty target name")
} }
etld1, err := publicsuffix.EffectiveTLDPlusOne(name) apex, err := publicsuffix.EffectiveTLDPlusOne(name)
if err != nil { if err != nil {
return 0, fmt.Errorf("invalid DNS name %q: %w", name, err) return "", fmt.Errorf(
"invalid target %q: %w", name, err,
)
} }
if name == etld1 { if name == apex {
return DNSNameTypeDomain, nil return "domain", nil
} }
return DNSNameTypeHostname, nil return "hostname", nil
} }
// ClassifyTargets splits a list of DNS names into apex domains and // classifyTargets splits a list of DNS names into apex domains
// hostnames using the Public Suffix List. It returns an error if any // and hostnames using the Public Suffix List.
// name cannot be classified. func classifyTargets(
func ClassifyTargets(targets []string) ([]string, []string, error) { targets []string,
var domains, hostnames []string ) (domains, hostnames []string, err error) {
for _, target := range targets {
for _, t := range targets { kind, classifyErr := ClassifyTarget(target)
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(t), ".")) if classifyErr != nil {
return nil, nil, classifyErr
if normalized == "" {
continue
} }
typ, classErr := ClassifyDNSName(normalized) switch kind {
if classErr != nil { case "domain":
return nil, nil, classErr domains = append(domains, strings.ToLower(
} strings.TrimSuffix(target, "."),
))
switch typ { case "hostname":
case DNSNameTypeDomain: hostnames = append(hostnames, strings.ToLower(
domains = append(domains, normalized) strings.TrimSuffix(target, "."),
case DNSNameTypeHostname: ))
hostnames = append(hostnames, normalized)
} }
} }

View File

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

View File

@ -90,8 +90,6 @@ 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", "")
@ -134,9 +132,11 @@ func buildConfig(
tlsInterval = defaultTLSInterval tlsInterval = defaultTLSInterval
} }
domains, hostnames, err := resolveTargets(log) targets := parseCSV(viper.GetString("TARGETS"))
if err != nil {
return nil, fmt.Errorf("invalid targets configuration: %w", err) domains, hostnames, classifyErr := classifyTargets(targets)
if classifyErr != nil {
return nil, fmt.Errorf("classifying targets: %w", classifyErr)
} }
cfg := &Config{ cfg := &Config{
@ -187,56 +187,6 @@ 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"