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
> ⚠️ **Pre-1.0 software.** APIs, configuration, and behavior may change
> without notice.
> ⚠️ 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
@ -19,19 +18,10 @@ without requiring an external database.
## 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)
- 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
discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently.
@ -207,7 +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_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_MATTERMOST_WEBHOOK` | Mattermost incoming webhook 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/prometheus/client_golang v1.23.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0
golang.org/x/net v0.50.0
)
@ -17,12 +16,10 @@ require (
require (
github.com/beorn7/perks v1.0.1 // 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/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/common v0.66.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/text v0.34.0 // 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
import (
"errors"
"fmt"
"strings"
"golang.org/x/net/publicsuffix"
)
// ClassifyTarget determines whether a DNS name is an apex domain
// (eTLD+1) or a hostname (subdomain of an eTLD+1). Returns
// "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, "."))
// DNSNameType indicates whether a DNS name is an apex domain or a hostname.
type DNSNameType int
if name == "" {
return "", fmt.Errorf("empty target name")
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"
}
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
// and hostnames using the Public Suffix List.
func classifyTargets(
targets []string,
) (domains, hostnames []string, err error) {
for _, target := range targets {
kind, classifyErr := ClassifyTarget(target)
if classifyErr != nil {
return nil, nil, classifyErr
// 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
}
switch kind {
case "domain":
domains = append(domains, strings.ToLower(
strings.TrimSuffix(target, "."),
))
case "hostname":
hostnames = append(hostnames, strings.ToLower(
strings.TrimSuffix(target, "."),
))
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)
}
}

View File

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

View File

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