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
This commit is contained in:
clawbot
2026-02-19 20:08:11 -08:00
parent 144a2df665
commit 5916e32ff3
6 changed files with 240 additions and 14 deletions

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -89,6 +89,7 @@ func setupViper(name string) {
viper.SetDefault("PORT", defaultPort)
viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "./data")
viper.SetDefault("TARGETS", "")
viper.SetDefault("DOMAINS", "")
viper.SetDefault("HOSTNAMES", "")
viper.SetDefault("SLACK_WEBHOOK", "")
@@ -133,12 +134,17 @@ func buildConfig(
tlsInterval = defaultTLSInterval
}
domains, hostnames, err := resolveTargets(log)
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"),
@@ -181,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.
func (c *Config) StatePath() string {
return c.DataDir + "/state.json"