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
> ⚠️ 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
@@ -198,6 +196,8 @@ the following precedence (highest to lowest):
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
| `DNSWATCHER_DATA_DIR` | Directory for state file | `./data` |
| `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_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |

View File

@@ -90,6 +90,8 @@ func setupViper(name string) {
viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "./data")
viper.SetDefault("TARGETS", "")
viper.SetDefault("DOMAINS", "")
viper.SetDefault("HOSTNAMES", "")
viper.SetDefault("SLACK_WEBHOOK", "")
viper.SetDefault("MATTERMOST_WEBHOOK", "")
viper.SetDefault("NTFY_TOPIC", "")
@@ -132,9 +134,7 @@ func buildConfig(
tlsInterval = defaultTLSInterval
}
domains, hostnames, err := ClassifyTargets(
parseCSV(viper.GetString("TARGETS")),
)
domains, hostnames, err := resolveTargets(log)
if err != nil {
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.
func (c *Config) StatePath() string {
return c.DataDir + "/state.json"

View File

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