feat: unify DOMAINS/HOSTNAMES into single TARGETS config (closes #10) #11
11
README.md
11
README.md
@ -1,5 +1,7 @@
|
||||
# dnswatcher
|
||||
|
||||
> ⚠️ 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
|
||||
port availability, tracks TLS certificate expiry, and delivers real-time
|
||||
@ -195,8 +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_DOMAINS` | Comma-separated list of apex domains | `""` |
|
||||
| `DNSWATCHER_HOSTNAMES` | Comma-separated list of hostnames | `""` |
|
||||
| `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 | `""` |
|
||||
@ -214,8 +215,7 @@ the following precedence (highest to lowest):
|
||||
PORT=8080
|
||||
DNSWATCHER_DEBUG=false
|
||||
DNSWATCHER_DATA_DIR=./data
|
||||
DNSWATCHER_DOMAINS=example.com,example.org
|
||||
DNSWATCHER_HOSTNAMES=www.example.com,api.example.com,mail.example.org
|
||||
DNSWATCHER_TARGETS=example.com,example.org,www.example.com,api.example.com,mail.example.org
|
||||
DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx
|
||||
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
||||
DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts
|
||||
@ -352,8 +352,7 @@ docker build -t dnswatcher .
|
||||
docker run -d \
|
||||
-p 8080:8080 \
|
||||
-v dnswatcher-data:/var/lib/dnswatcher \
|
||||
-e DNSWATCHER_DOMAINS=example.com \
|
||||
-e DNSWATCHER_HOSTNAMES=www.example.com \
|
||||
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
||||
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
||||
dnswatcher
|
||||
```
|
||||
|
||||
5
go.mod
5
go.mod
@ -10,6 +10,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/net v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -33,7 +34,7 @@ require (
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
10
go.sum
10
go.sum
@ -74,10 +74,12 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
85
internal/config/classify.go
Normal file
85
internal/config/classify.go
Normal 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
|
||||
}
|
||||
83
internal/config/classify_test.go
Normal file
83
internal/config/classify_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -89,8 +89,7 @@ func setupViper(name string) {
|
||||
viper.SetDefault("PORT", defaultPort)
|
||||
viper.SetDefault("DEBUG", false)
|
||||
viper.SetDefault("DATA_DIR", "./data")
|
||||
viper.SetDefault("DOMAINS", "")
|
||||
viper.SetDefault("HOSTNAMES", "")
|
||||
viper.SetDefault("TARGETS", "")
|
||||
viper.SetDefault("SLACK_WEBHOOK", "")
|
||||
viper.SetDefault("MATTERMOST_WEBHOOK", "")
|
||||
viper.SetDefault("NTFY_TOPIC", "")
|
||||
@ -133,12 +132,19 @@ func buildConfig(
|
||||
tlsInterval = defaultTLSInterval
|
||||
}
|
||||
|
||||
domains, hostnames, err := ClassifyTargets(
|
||||
parseCSV(viper.GetString("TARGETS")),
|
||||
)
|
||||
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"),
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@ -48,6 +49,9 @@ type Service struct {
|
||||
log *slog.Logger
|
||||
client *http.Client
|
||||
config *config.Config
|
||||
ntfyURL *url.URL
|
||||
slackWebhookURL *url.URL
|
||||
mattermostWebhookURL *url.URL
|
||||
}
|
||||
|
||||
// New creates a new notify Service.
|
||||
@ -55,13 +59,44 @@ func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
svc := &Service{
|
||||
log: params.Logger.Get(),
|
||||
client: &http.Client{
|
||||
Timeout: httpClientTimeout,
|
||||
},
|
||||
config: params.Config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if params.Config.NtfyTopic != "" {
|
||||
u, err := url.ParseRequestURI(params.Config.NtfyTopic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ntfy topic URL: %w", err)
|
||||
}
|
||||
|
||||
svc.ntfyURL = u
|
||||
}
|
||||
|
||||
if params.Config.SlackWebhook != "" {
|
||||
u, err := url.ParseRequestURI(params.Config.SlackWebhook)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid slack webhook URL: %w", err)
|
||||
}
|
||||
|
||||
svc.slackWebhookURL = u
|
||||
}
|
||||
|
||||
if params.Config.MattermostWebhook != "" {
|
||||
u, err := url.ParseRequestURI(params.Config.MattermostWebhook)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid mattermost webhook URL: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
svc.mattermostWebhookURL = u
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// SendNotification sends a notification to all configured endpoints.
|
||||
@ -69,13 +104,13 @@ func (svc *Service) SendNotification(
|
||||
ctx context.Context,
|
||||
title, message, priority string,
|
||||
) {
|
||||
if svc.config.NtfyTopic != "" {
|
||||
if svc.ntfyURL != nil {
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
err := svc.sendNtfy(
|
||||
notifyCtx,
|
||||
svc.config.NtfyTopic,
|
||||
svc.ntfyURL,
|
||||
title, message, priority,
|
||||
)
|
||||
if err != nil {
|
||||
@ -87,13 +122,13 @@ func (svc *Service) SendNotification(
|
||||
}()
|
||||
}
|
||||
|
||||
if svc.config.SlackWebhook != "" {
|
||||
if svc.slackWebhookURL != nil {
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
err := svc.sendSlack(
|
||||
notifyCtx,
|
||||
svc.config.SlackWebhook,
|
||||
svc.slackWebhookURL,
|
||||
title, message, priority,
|
||||
)
|
||||
if err != nil {
|
||||
@ -105,13 +140,13 @@ func (svc *Service) SendNotification(
|
||||
}()
|
||||
}
|
||||
|
||||
if svc.config.MattermostWebhook != "" {
|
||||
if svc.mattermostWebhookURL != nil {
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
err := svc.sendSlack(
|
||||
notifyCtx,
|
||||
svc.config.MattermostWebhook,
|
||||
svc.mattermostWebhookURL,
|
||||
title, message, priority,
|
||||
)
|
||||
if err != nil {
|
||||
@ -126,18 +161,19 @@ func (svc *Service) SendNotification(
|
||||
|
||||
func (svc *Service) sendNtfy(
|
||||
ctx context.Context,
|
||||
topic, title, message, priority string,
|
||||
topicURL *url.URL,
|
||||
title, message, priority string,
|
||||
) error {
|
||||
svc.log.Debug(
|
||||
"sending ntfy notification",
|
||||
"topic", topic,
|
||||
"topic", topicURL.String(),
|
||||
"title", title,
|
||||
)
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
topic,
|
||||
topicURL.String(),
|
||||
bytes.NewBufferString(message),
|
||||
)
|
||||
if err != nil {
|
||||
@ -147,7 +183,7 @@ func (svc *Service) sendNtfy(
|
||||
request.Header.Set("Title", title)
|
||||
request.Header.Set("Priority", ntfyPriority(priority))
|
||||
|
||||
resp, err := svc.client.Do(request)
|
||||
resp, err := svc.client.Do(request) //nolint:gosec // URL validated at Service construction time
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending ntfy request: %w", err)
|
||||
}
|
||||
@ -193,11 +229,12 @@ type SlackAttachment struct {
|
||||
|
||||
func (svc *Service) sendSlack(
|
||||
ctx context.Context,
|
||||
webhookURL, title, message, priority string,
|
||||
webhookURL *url.URL,
|
||||
title, message, priority string,
|
||||
) error {
|
||||
svc.log.Debug(
|
||||
"sending webhook notification",
|
||||
"url", webhookURL,
|
||||
"url", webhookURL.String(),
|
||||
"title", title,
|
||||
)
|
||||
|
||||
@ -219,7 +256,7 @@ func (svc *Service) sendSlack(
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
webhookURL.String(),
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
@ -228,7 +265,7 @@ func (svc *Service) sendSlack(
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := svc.client.Do(request)
|
||||
resp, err := svc.client.Do(request) //nolint:gosec // URL validated at Service construction time
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending webhook request: %w", err)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user