1 Commits

Author SHA1 Message Date
clawbot
5916e32ff3 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
2026-02-19 20:08:11 -08:00
3 changed files with 74 additions and 61 deletions

View File

@@ -1,7 +1,5 @@
# 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
@@ -198,6 +196,8 @@ the following precedence (highest to lowest):
| `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 DNS names (auto-classified via PSL) | `""` |
| `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 | `""` |

View File

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

View File

@@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -49,9 +48,6 @@ type Service struct {
log *slog.Logger log *slog.Logger
client *http.Client client *http.Client
config *config.Config config *config.Config
ntfyURL *url.URL
slackWebhookURL *url.URL
mattermostWebhookURL *url.URL
} }
// New creates a new notify Service. // New creates a new notify Service.
@@ -59,44 +55,13 @@ func New(
_ fx.Lifecycle, _ fx.Lifecycle,
params Params, params Params,
) (*Service, error) { ) (*Service, error) {
svc := &Service{ return &Service{
log: params.Logger.Get(), log: params.Logger.Get(),
client: &http.Client{ client: &http.Client{
Timeout: httpClientTimeout, Timeout: httpClientTimeout,
}, },
config: params.Config, 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. // SendNotification sends a notification to all configured endpoints.
@@ -104,13 +69,13 @@ func (svc *Service) SendNotification(
ctx context.Context, ctx context.Context,
title, message, priority string, title, message, priority string,
) { ) {
if svc.ntfyURL != nil { if svc.config.NtfyTopic != "" {
go func() { go func() {
notifyCtx := context.WithoutCancel(ctx) notifyCtx := context.WithoutCancel(ctx)
err := svc.sendNtfy( err := svc.sendNtfy(
notifyCtx, notifyCtx,
svc.ntfyURL, svc.config.NtfyTopic,
title, message, priority, title, message, priority,
) )
if err != nil { if err != nil {
@@ -122,13 +87,13 @@ func (svc *Service) SendNotification(
}() }()
} }
if svc.slackWebhookURL != nil { if svc.config.SlackWebhook != "" {
go func() { go func() {
notifyCtx := context.WithoutCancel(ctx) notifyCtx := context.WithoutCancel(ctx)
err := svc.sendSlack( err := svc.sendSlack(
notifyCtx, notifyCtx,
svc.slackWebhookURL, svc.config.SlackWebhook,
title, message, priority, title, message, priority,
) )
if err != nil { if err != nil {
@@ -140,13 +105,13 @@ func (svc *Service) SendNotification(
}() }()
} }
if svc.mattermostWebhookURL != nil { if svc.config.MattermostWebhook != "" {
go func() { go func() {
notifyCtx := context.WithoutCancel(ctx) notifyCtx := context.WithoutCancel(ctx)
err := svc.sendSlack( err := svc.sendSlack(
notifyCtx, notifyCtx,
svc.mattermostWebhookURL, svc.config.MattermostWebhook,
title, message, priority, title, message, priority,
) )
if err != nil { if err != nil {
@@ -161,19 +126,18 @@ func (svc *Service) SendNotification(
func (svc *Service) sendNtfy( func (svc *Service) sendNtfy(
ctx context.Context, ctx context.Context,
topicURL *url.URL, topic, title, message, priority string,
title, message, priority string,
) error { ) error {
svc.log.Debug( svc.log.Debug(
"sending ntfy notification", "sending ntfy notification",
"topic", topicURL.String(), "topic", topic,
"title", title, "title", title,
) )
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
topicURL.String(), topic,
bytes.NewBufferString(message), bytes.NewBufferString(message),
) )
if err != nil { if err != nil {
@@ -183,7 +147,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title) request.Header.Set("Title", title)
request.Header.Set("Priority", ntfyPriority(priority)) request.Header.Set("Priority", ntfyPriority(priority))
resp, err := svc.client.Do(request) //nolint:gosec // URL validated at Service construction time resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("sending ntfy request: %w", err) return fmt.Errorf("sending ntfy request: %w", err)
} }
@@ -229,12 +193,11 @@ type SlackAttachment struct {
func (svc *Service) sendSlack( func (svc *Service) sendSlack(
ctx context.Context, ctx context.Context,
webhookURL *url.URL, webhookURL, title, message, priority string,
title, message, priority string,
) error { ) error {
svc.log.Debug( svc.log.Debug(
"sending webhook notification", "sending webhook notification",
"url", webhookURL.String(), "url", webhookURL,
"title", title, "title", title,
) )
@@ -256,7 +219,7 @@ func (svc *Service) sendSlack(
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
webhookURL.String(), webhookURL,
bytes.NewBuffer(body), bytes.NewBuffer(body),
) )
if err != nil { if err != nil {
@@ -265,7 +228,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request) //nolint:gosec // URL validated at Service construction time resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("sending webhook request: %w", err) return fmt.Errorf("sending webhook request: %w", err)
} }