Compare commits
3 Commits
73e01c7664
...
628bba22fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
628bba22fe | ||
|
|
acae697aa2 | ||
|
|
1db3056594 |
25
README.md
25
README.md
@ -1,5 +1,8 @@
|
|||||||
# 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
|
||||||
@ -16,10 +19,19 @@ without requiring an external database.
|
|||||||
|
|
||||||
## Features
|
## 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)
|
### DNS Domain Monitoring (Apex Domains)
|
||||||
|
|
||||||
- Accepts a list of DNS domain names (apex domains, identified via the
|
- Apex domains are identified automatically via the PSL.
|
||||||
[Public Suffix List](https://publicsuffix.org/)).
|
|
||||||
- Every **1 hour**, performs a full iterative trace from root servers to
|
- Every **1 hour**, performs a full iterative trace from root servers to
|
||||||
discover all authoritative nameservers (NS records) for each domain.
|
discover all authoritative nameservers (NS records) for each domain.
|
||||||
- Queries **every** discovered authoritative nameserver independently.
|
- Queries **every** discovered authoritative nameserver independently.
|
||||||
@ -195,8 +207,7 @@ the following precedence (highest to lowest):
|
|||||||
| `PORT` | HTTP listen port | `8080` |
|
| `PORT` | HTTP listen port | `8080` |
|
||||||
| `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_DOMAINS` | Comma-separated list of apex domains | `""` |
|
| `DNSWATCHER_TARGETS` | Comma-separated list of DNS names to monitor | `""` |
|
||||||
| `DNSWATCHER_HOSTNAMES` | Comma-separated list of 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 | `""` |
|
||||||
@ -214,8 +225,7 @@ the following precedence (highest to lowest):
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
DNSWATCHER_DEBUG=false
|
DNSWATCHER_DEBUG=false
|
||||||
DNSWATCHER_DATA_DIR=./data
|
DNSWATCHER_DATA_DIR=./data
|
||||||
DNSWATCHER_DOMAINS=example.com,example.org
|
DNSWATCHER_TARGETS=example.com,example.org,www.example.com,api.example.com,mail.example.org
|
||||||
DNSWATCHER_HOSTNAMES=www.example.com,api.example.com,mail.example.org
|
|
||||||
DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx
|
DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx
|
||||||
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
||||||
DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts
|
DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts
|
||||||
@ -352,8 +362,7 @@ docker build -t dnswatcher .
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-v dnswatcher-data:/var/lib/dnswatcher \
|
-v dnswatcher-data:/var/lib/dnswatcher \
|
||||||
-e DNSWATCHER_DOMAINS=example.com \
|
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
||||||
-e DNSWATCHER_HOSTNAMES=www.example.com \
|
|
||||||
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
||||||
dnswatcher
|
dnswatcher
|
||||||
```
|
```
|
||||||
|
|||||||
9
go.mod
9
go.mod
@ -9,16 +9,20 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
|
golang.org/x/net v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
@ -33,7 +37,8 @@ require (
|
|||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // 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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
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 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
61
internal/config/classify.go
Normal file
61
internal/config/classify.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// Package config provides application configuration via Viper.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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, "."))
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("empty target name")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case "domain":
|
||||||
|
domains = append(domains, strings.ToLower(
|
||||||
|
strings.TrimSuffix(target, "."),
|
||||||
|
))
|
||||||
|
case "hostname":
|
||||||
|
hostnames = append(hostnames, strings.ToLower(
|
||||||
|
strings.TrimSuffix(target, "."),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return domains, hostnames, nil
|
||||||
|
}
|
||||||
84
internal/config/classify_test.go
Normal file
84
internal/config/classify_test.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifyTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result, err := ClassifyTarget(tc.input)
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyTargets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targets := []string{
|
||||||
|
"example.com",
|
||||||
|
"www.example.com",
|
||||||
|
"api.example.com",
|
||||||
|
"example.co.uk",
|
||||||
|
"blog.example.co.uk",
|
||||||
|
}
|
||||||
|
|
||||||
|
domains, hostnames, err := classifyTargets(targets)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyTargets_RejectsPublicSuffix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, _, err := classifyTargets([]string{"example.com", "co.uk"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@ -89,8 +89,7 @@ func setupViper(name string) {
|
|||||||
viper.SetDefault("PORT", defaultPort)
|
viper.SetDefault("PORT", defaultPort)
|
||||||
viper.SetDefault("DEBUG", false)
|
viper.SetDefault("DEBUG", false)
|
||||||
viper.SetDefault("DATA_DIR", "./data")
|
viper.SetDefault("DATA_DIR", "./data")
|
||||||
viper.SetDefault("DOMAINS", "")
|
viper.SetDefault("TARGETS", "")
|
||||||
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", "")
|
||||||
@ -133,12 +132,19 @@ func buildConfig(
|
|||||||
tlsInterval = defaultTLSInterval
|
tlsInterval = defaultTLSInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targets := parseCSV(viper.GetString("TARGETS"))
|
||||||
|
|
||||||
|
domains, hostnames, classifyErr := classifyTargets(targets)
|
||||||
|
if classifyErr != nil {
|
||||||
|
return nil, fmt.Errorf("classifying targets: %w", classifyErr)
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: viper.GetBool("DEBUG"),
|
||||||
DataDir: viper.GetString("DATA_DIR"),
|
DataDir: viper.GetString("DATA_DIR"),
|
||||||
Domains: parseCSV(viper.GetString("DOMAINS")),
|
Domains: domains,
|
||||||
Hostnames: parseCSV(viper.GetString("HOSTNAMES")),
|
Hostnames: hostnames,
|
||||||
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
||||||
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
||||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user