Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87aa5c2d04 |
@@ -40,29 +40,6 @@ Contributions that introduce mocked, faked, or stubbed DNS will be rejected.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
You need git and Docker. This builds the image and runs dnswatcher watching
|
|
||||||
`example.com` and `www.example.com`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://git.eeqj.de/sneak/dnswatcher.git
|
|
||||||
cd dnswatcher
|
|
||||||
docker build -t dnswatcher .
|
|
||||||
docker run -d --name dnswatcher \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-v dnswatcher-data:/var/lib/dnswatcher \
|
|
||||||
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
|
||||||
dnswatcher
|
|
||||||
```
|
|
||||||
|
|
||||||
The build also runs the linter and the test suite, which queries live DNS. Once
|
|
||||||
the container is running, the dashboard is at <http://localhost:8080/>. With no
|
|
||||||
notification endpoint set, changes show only on the dashboard; see
|
|
||||||
[Configuration](#configuration) to add one.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### DNS Domain Monitoring (Apex Domains)
|
### DNS Domain Monitoring (Apex Domains)
|
||||||
@@ -295,6 +272,48 @@ navigation needs, and its URL may name internal hosts.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
|
||||||
|
|
||||||
|
internal/
|
||||||
|
config/config.go Viper-based configuration
|
||||||
|
globals/globals.go Build-time variables (version)
|
||||||
|
logger/logger.go slog structured logging (TTY detection)
|
||||||
|
healthcheck/healthcheck.go Health check service
|
||||||
|
middleware/middleware.go HTTP middleware (logging, CORS, security
|
||||||
|
headers, metrics auth and rate limit)
|
||||||
|
handlers/handlers.go HTTP request handlers
|
||||||
|
server/
|
||||||
|
server.go HTTP server lifecycle
|
||||||
|
routes.go Route definitions
|
||||||
|
state/state.go JSON file state persistence
|
||||||
|
resolver/resolver.go Iterative DNS resolution engine
|
||||||
|
portcheck/portcheck.go TCP port connectivity checker
|
||||||
|
tlscheck/tlscheck.go TLS certificate inspector
|
||||||
|
notify/notify.go Notification service (Slack, Mattermost, ntfy)
|
||||||
|
watcher/watcher.go Main monitoring orchestrator and scheduler
|
||||||
|
livednstest/livednstest.go Retry and concurrency limit for tests
|
||||||
|
against live DNS (imported only by tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
- **No recursive resolvers**: All DNS resolution is performed iteratively,
|
||||||
|
tracing from root nameservers through the delegation chain to authoritative
|
||||||
|
servers.
|
||||||
|
- **No external database**: State is persisted as a single JSON file.
|
||||||
|
- **Dependency injection**: All components are wired via
|
||||||
|
[uber/fx](https://github.com/uber-go/fx).
|
||||||
|
- **Structured logging**: All logs use `log/slog` with JSON output in production
|
||||||
|
(TTY detection for development).
|
||||||
|
- **Graceful shutdown**: All background goroutines respect context cancellation
|
||||||
|
and the fx lifecycle. In-flight notification deliveries are drained on
|
||||||
|
shutdown, bounded by the shutdown timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is loaded via [Viper](https://github.com/spf13/viper) with the
|
Configuration is loaded via [Viper](https://github.com/spf13/viper) with the
|
||||||
@@ -384,10 +403,8 @@ performs full iterative resolution:
|
|||||||
|
|
||||||
In steps 2 and 3 the servers are asked one at a time in a random order, chosen
|
In steps 2 and 3 the servers are asked one at a time in a random order, chosen
|
||||||
anew each time, so no one root server gets every first query. A server that does
|
anew each time, so no one root server gets every first query. A server that does
|
||||||
not reply, refuses the query, or gives an error reply such as SERVFAIL or a
|
not reply, or refuses the query, is passed over for the next one; the first
|
||||||
referral that leads no closer to the name is passed over for the next one. When
|
other reply is used, even a SERVFAIL, and no further server is asked.
|
||||||
a referral names a zone's nameservers without their addresses, the addresses of
|
|
||||||
all of them are looked up, so that each can be asked.
|
|
||||||
|
|
||||||
This approach ensures:
|
This approach ensures:
|
||||||
|
|
||||||
@@ -655,68 +672,6 @@ configuration.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rationale
|
|
||||||
|
|
||||||
dnswatcher exists to report changes to the DNS records, TCP port availability
|
|
||||||
and TLS certificates of its configured domains and hostnames, failures and
|
|
||||||
recoveries included: it is designed as a real-time change feed. It queries the
|
|
||||||
authoritative nameservers directly, tracing from the root, instead of a
|
|
||||||
recursive resolver, so no resolver's cache or filtering hides a change and
|
|
||||||
nameservers that disagree with each other are seen. Its state is a single JSON
|
|
||||||
file, so it survives a restart without an external database.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
```
|
|
||||||
cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
|
|
||||||
|
|
||||||
internal/
|
|
||||||
config/config.go Viper-based configuration
|
|
||||||
globals/globals.go Build-time variables (version)
|
|
||||||
logger/logger.go slog structured logging (TTY detection)
|
|
||||||
healthcheck/healthcheck.go Health check service
|
|
||||||
middleware/middleware.go HTTP middleware (logging, CORS, security
|
|
||||||
headers, metrics auth and rate limit)
|
|
||||||
handlers/handlers.go HTTP request handlers
|
|
||||||
server/
|
|
||||||
server.go HTTP server lifecycle
|
|
||||||
routes.go Route definitions
|
|
||||||
state/state.go JSON file state persistence
|
|
||||||
resolver/resolver.go Iterative DNS resolution engine
|
|
||||||
portcheck/portcheck.go TCP port connectivity checker
|
|
||||||
tlscheck/tlscheck.go TLS certificate inspector
|
|
||||||
notify/notify.go Notification service (Slack, Mattermost, ntfy)
|
|
||||||
watcher/watcher.go Main monitoring orchestrator and scheduler
|
|
||||||
livednstest/livednstest.go Retry and concurrency limit for tests
|
|
||||||
against live DNS (imported only by tests)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
|
|
||||||
- **No recursive resolvers**: All DNS resolution is performed iteratively,
|
|
||||||
tracing from root nameservers through the delegation chain to authoritative
|
|
||||||
servers.
|
|
||||||
- **No external database**: State is persisted as a single JSON file.
|
|
||||||
- **Dependency injection**: All components are wired via
|
|
||||||
[uber/fx](https://github.com/uber-go/fx).
|
|
||||||
- **Structured logging**: All logs use `log/slog` with JSON output in production
|
|
||||||
(TTY detection for development).
|
|
||||||
- **Graceful shutdown**: All background goroutines respect context cancellation
|
|
||||||
and the fx lifecycle. In-flight notification deliveries are drained on
|
|
||||||
shutdown, bounded by the shutdown timeout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
[`TODO.md`](./TODO.md) names the next step and the steps planned after it. The
|
|
||||||
work for 1.0 is tracked as issues on the
|
|
||||||
[1.0 milestone](https://git.eeqj.de/sneak/dnswatcher/milestone/7).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
dnswatcher is released under the MIT License, Copyright (c) 2026
|
dnswatcher is released under the MIT License, Copyright (c) 2026
|
||||||
|
|||||||
@@ -19,16 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-02: the resolver tries root servers, and every other server list it
|
- 2026-10-01: the resolver tries root servers, and every other server list it
|
||||||
walks, in a random order each time, not always from the top (closes #138).
|
walks, in a random order each time, not always from the top (closes #138).
|
||||||
- 2026-10-01: a certificate within the expiry warning period is warned about on
|
|
||||||
every TLS check, where some checks used to skip it at random (closes #204).
|
|
||||||
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
|
||||||
servers, not whichever of its own servers answered first (closes #200).
|
|
||||||
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
|
|
||||||
Architecture section is now Design, in the order policy sets (closes #173).
|
|
||||||
- 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no
|
|
||||||
closer is passed over for the next, as one that times out is (closes #197).
|
|
||||||
- 2026-10-01: when none of a configured name's nameservers answered, the port
|
- 2026-10-01: when none of a configured name's nameservers answered, the port
|
||||||
state saved for its addresses is kept, not removed (closes #193).
|
state saved for its addresses is kept, not removed (closes #193).
|
||||||
- 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no
|
- 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no
|
||||||
@@ -130,4 +122,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
- 1.0 readiness: run it with a real config and read the logs:
|
- 1.0 readiness: run it with a real config and read the logs:
|
||||||
https://git.eeqj.de/sneak/dnswatcher/issues/66
|
https://git.eeqj.de/sneak/dnswatcher/issues/66
|
||||||
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
|
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
|
||||||
|
- README sections required by policy:
|
||||||
|
https://git.eeqj.de/sneak/dnswatcher/issues/173
|
||||||
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ var (
|
|||||||
// so whether the name has addresses is unknown.
|
// so whether the name has addresses is unknown.
|
||||||
ErrNoNameserverAnswered = errors.New("no nameserver answered")
|
ErrNoNameserverAnswered = errors.New("no nameserver answered")
|
||||||
|
|
||||||
// ErrUnusableReply is returned when a server replied with an
|
|
||||||
// error such as SERVFAIL, or with a referral that leads no
|
|
||||||
// closer to the name asked about.
|
|
||||||
ErrUnusableReply = errors.New(
|
|
||||||
"reply is an error or a referral that leads no closer",
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrCNAMEDepthExceeded is returned when a CNAME chain
|
// ErrCNAMEDepthExceeded is returned when a CNAME chain
|
||||||
// exceeds MaxCNAMEDepth.
|
// exceeds MaxCNAMEDepth.
|
||||||
ErrCNAMEDepthExceeded = errors.New(
|
ErrCNAMEDepthExceeded = errors.New(
|
||||||
|
|||||||
@@ -11,16 +11,6 @@ func ExtractRecordValue(rr dns.RR) string {
|
|||||||
return extractRecordValue(rr)
|
return extractRecordValue(rr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsableReply exports usableReply for testing.
|
|
||||||
func UsableReply(resp *dns.Msg, zone string, name string) bool {
|
|
||||||
return usableReply(resp, zone, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NSSetFrom exports nsSetFrom for testing.
|
|
||||||
func NSSetFrom(resp *dns.Msg, domain string) []string {
|
|
||||||
return nsSetFrom(resp, domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollectIPs exports collectIPs for testing.
|
// CollectIPs exports collectIPs for testing.
|
||||||
func CollectIPs(
|
func CollectIPs(
|
||||||
results map[string]*NameserverResponse,
|
results map[string]*NameserverResponse,
|
||||||
@@ -37,14 +27,6 @@ func (r *Resolver) QueryEachNS(
|
|||||||
return r.queryEachNS(ctx, nameservers, hostname)
|
return r.queryEachNS(ctx, nameservers, hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveNSIPs exports resolveNSIPs for testing.
|
|
||||||
func (r *Resolver) ResolveNSIPs(
|
|
||||||
ctx context.Context,
|
|
||||||
nsNames []string,
|
|
||||||
) []string {
|
|
||||||
return r.resolveNSIPs(ctx, nsNames)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RootServerList exports rootServerList for testing.
|
// RootServerList exports rootServerList for testing.
|
||||||
func RootServerList() []string {
|
func RootServerList() []string {
|
||||||
return rootServerList()
|
return rootServerList()
|
||||||
|
|||||||
@@ -209,24 +209,21 @@ func (r *Resolver) followDelegation(
|
|||||||
domain string,
|
domain string,
|
||||||
servers []string,
|
servers []string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
// servers are the root servers, the servers of zone ".".
|
|
||||||
zone := "."
|
|
||||||
|
|
||||||
for range maxDelegation {
|
for range maxDelegation {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.queryServers(
|
resp, err := r.queryServers(
|
||||||
ctx, servers, zone, domain, dns.TypeNS,
|
ctx, servers, domain, dns.TypeNS,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nsSet := nsSetFrom(resp, domain)
|
ansNS := extractNSSet(resp.Answer)
|
||||||
if len(nsSet) > 0 {
|
if len(ansNS) > 0 {
|
||||||
return nsSet, nil
|
return ansNS, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// An authoritative reply comes from the servers of the zone
|
// An authoritative reply comes from the servers of the zone
|
||||||
@@ -255,7 +252,6 @@ func (r *Resolver) followDelegation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
servers = nextServers
|
servers = nextServers
|
||||||
zone = referralZone(resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrNoNameservers
|
return nil, ErrNoNameservers
|
||||||
@@ -277,13 +273,11 @@ func shuffled(
|
|||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryServers asks servers, the servers of zone, about name in a random
|
// queryServers asks the servers in a random order and returns the
|
||||||
// order until one gives a usable reply. A server that times out, refuses
|
// first reply; it fails only when every server has failed.
|
||||||
// or gives a reply that is not usable is passed over for the next.
|
|
||||||
func (r *Resolver) queryServers(
|
func (r *Resolver) queryServers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
servers []string,
|
servers []string,
|
||||||
zone string,
|
|
||||||
name string,
|
name string,
|
||||||
qtype uint16,
|
qtype uint16,
|
||||||
) (*dns.Msg, error) {
|
) (*dns.Msg, error) {
|
||||||
@@ -295,12 +289,6 @@ func (r *Resolver) queryServers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.queryDNS(ctx, ip, name, qtype)
|
resp, err := r.queryDNS(ctx, ip, name, qtype)
|
||||||
if err == nil && !usableReply(resp, zone, name) {
|
|
||||||
err = fmt.Errorf(
|
|
||||||
"query %s @%s: %w", name, ip, ErrUnusableReply,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -311,68 +299,23 @@ func (r *Resolver) queryServers(
|
|||||||
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// usableReply reports whether resp, a reply from one of the servers of
|
// resolveNSIPs returns the addresses of one of the nameservers, trying
|
||||||
// zone to a query about name, is usable. An error reply such as SERVFAIL
|
// their names in a random order until one resolves.
|
||||||
// is not. Nor is a referral, unless it refers the query to a zone below
|
|
||||||
// zone that name is in: a server that refers it back to zone, up or
|
|
||||||
// sideways does not serve zone as it should.
|
|
||||||
func usableReply(resp *dns.Msg, zone string, name string) bool {
|
|
||||||
if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
child := referralZone(resp)
|
|
||||||
if resp.Authoritative || len(resp.Answer) > 0 || child == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return child != zone && dns.IsSubDomain(zone, child) &&
|
|
||||||
dns.IsSubDomain(child, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// referralZone returns the zone a referral refers the query to: the
|
|
||||||
// owner name of the NS records in resp's authority section, or "" when
|
|
||||||
// there are none.
|
|
||||||
func referralZone(resp *dns.Msg) string {
|
|
||||||
for _, rr := range resp.Ns {
|
|
||||||
if ns, ok := rr.(*dns.NS); ok {
|
|
||||||
return strings.ToLower(ns.Hdr.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// nsSetFrom returns the NS set of domain that resp, a reply to a query
|
|
||||||
// for domain's NS records, gives: the delegation in a referral to domain
|
|
||||||
// itself, or else the NS records in the answer; empty when it gives
|
|
||||||
// neither. A referral to domain comes from its parent zone's servers,
|
|
||||||
// which all hold the same delegation, so the set does not depend on
|
|
||||||
// which of them answered. domain's own servers, which can disagree about
|
|
||||||
// their NS records, are then not asked.
|
|
||||||
func nsSetFrom(resp *dns.Msg, domain string) []string {
|
|
||||||
if referralZone(resp) == domain {
|
|
||||||
return extractNSSet(resp.Ns)
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractNSSet(resp.Answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveNSIPs returns the addresses of every nameserver in nsNames
|
|
||||||
// whose name resolves, for a referral that carries none. The walk can
|
|
||||||
// then go on to the zone's other nameservers when one gives no usable
|
|
||||||
// reply.
|
|
||||||
func (r *Resolver) resolveNSIPs(
|
func (r *Resolver) resolveNSIPs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsNames []string,
|
nsNames []string,
|
||||||
) []string {
|
) []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
|
|
||||||
for _, ns := range nsNames {
|
for _, ns := range shuffled(nsNames, rand.Shuffle) {
|
||||||
resolved, err := r.resolveARecord(ctx, ns)
|
resolved, err := r.resolveARecord(ctx, ns)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ips = append(ips, resolved...)
|
ips = append(ips, resolved...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(ips) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ips
|
return ips
|
||||||
@@ -391,7 +334,6 @@ func (r *Resolver) resolveNSIterative(
|
|||||||
|
|
||||||
domain = dns.Fqdn(domain)
|
domain = dns.Fqdn(domain)
|
||||||
servers := rootServerList()
|
servers := rootServerList()
|
||||||
zone := "."
|
|
||||||
|
|
||||||
for range maxDelegation {
|
for range maxDelegation {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
@@ -399,13 +341,13 @@ func (r *Resolver) resolveNSIterative(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.queryServers(
|
resp, err := r.queryServers(
|
||||||
ctx, servers, zone, domain, dns.TypeNS,
|
ctx, servers, domain, dns.TypeNS,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nsNames := nsSetFrom(resp, domain)
|
nsNames := extractNSSet(resp.Answer)
|
||||||
if len(nsNames) > 0 {
|
if len(nsNames) > 0 {
|
||||||
return nsNames, nil
|
return nsNames, nil
|
||||||
}
|
}
|
||||||
@@ -424,7 +366,6 @@ func (r *Resolver) resolveNSIterative(
|
|||||||
}
|
}
|
||||||
|
|
||||||
servers = nextServers
|
servers = nextServers
|
||||||
zone = referralZone(resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrNoNameservers
|
return nil, ErrNoNameservers
|
||||||
@@ -442,7 +383,6 @@ func (r *Resolver) resolveARecord(
|
|||||||
|
|
||||||
hostname = dns.Fqdn(hostname)
|
hostname = dns.Fqdn(hostname)
|
||||||
servers := rootServerList()
|
servers := rootServerList()
|
||||||
zone := "."
|
|
||||||
|
|
||||||
for range maxDelegation {
|
for range maxDelegation {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
@@ -450,7 +390,7 @@ func (r *Resolver) resolveARecord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.queryServers(
|
resp, err := r.queryServers(
|
||||||
ctx, servers, zone, hostname, dns.TypeA,
|
ctx, servers, hostname, dns.TypeA,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -488,7 +428,6 @@ func (r *Resolver) resolveARecord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
servers = nextServers
|
servers = nextServers
|
||||||
zone = referralZone(resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -498,8 +437,7 @@ func (r *Resolver) resolveARecord(
|
|||||||
|
|
||||||
// FindAuthoritativeNameservers traces the delegation chain from
|
// FindAuthoritativeNameservers traces the delegation chain from
|
||||||
// root servers to discover all authoritative nameservers for the
|
// root servers to discover all authoritative nameservers for the
|
||||||
// given domain, as the delegation from its parent zone's servers lists
|
// given domain. For a name that is not a zone apex it tries each
|
||||||
// them. For a name that is not a zone apex it tries each
|
|
||||||
// parent name in turn, so it returns the nameservers of the zone the
|
// parent name in turn, so it returns the nameservers of the zone the
|
||||||
// name is in.
|
// name is in.
|
||||||
func (r *Resolver) FindAuthoritativeNameservers(
|
func (r *Resolver) FindAuthoritativeNameservers(
|
||||||
@@ -665,10 +603,9 @@ func (r *Resolver) querySingleType(
|
|||||||
|
|
||||||
// A reply with no answer that lists other nameservers, from a server
|
// A reply with no answer that lists other nameservers, from a server
|
||||||
// that does not hold the name's zone, is a referral and says nothing
|
// that does not hold the name's zone, is a referral and says nothing
|
||||||
// about the name's records. A server named in the delegation that
|
// about the name's records. A parent zone's servers send one when
|
||||||
// does not hold the zone may send one, as do a parent zone's servers
|
// every server of the name's own zone failed and
|
||||||
// when FindAuthoritativeNameservers found no delegation for the
|
// FindAuthoritativeNameservers moved on to the parent name.
|
||||||
// name's zone and moved on to a parent name.
|
|
||||||
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
||||||
len(extractNSSet(msg.Ns)) > 0 {
|
len(extractNSSet(msg.Ns)) > 0 {
|
||||||
state.gotReferral = true
|
state.gotReferral = true
|
||||||
|
|||||||
@@ -43,149 +43,6 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
|
|||||||
assert.Empty(t, ips)
|
assert.Empty(t, ips)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
// exampleCom is the zone most cases of TestUsableReply and
|
|
||||||
// TestNSSetFrom are about, and wwwExampleCom a name in it.
|
|
||||||
exampleCom = "example.com."
|
|
||||||
wwwExampleCom = "www.example.com."
|
|
||||||
|
|
||||||
// exampleNS is the server the NS records nsRecord builds name.
|
|
||||||
exampleNS = "ns1.example.net."
|
|
||||||
)
|
|
||||||
|
|
||||||
// nsRecord builds an NS record that names a server of zone.
|
|
||||||
func nsRecord(zone string) *dns.NS {
|
|
||||||
return &dns.NS{
|
|
||||||
Hdr: dns.RR_Header{
|
|
||||||
Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET,
|
|
||||||
},
|
|
||||||
Ns: exampleNS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// referralTo builds a reply that refers the query to the servers of
|
|
||||||
// zone.
|
|
||||||
func referralTo(zone string) *dns.Msg {
|
|
||||||
msg := new(dns.Msg)
|
|
||||||
msg.Ns = []dns.RR{nsRecord(zone)}
|
|
||||||
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUsableReply checks which replies from one of a zone's servers are
|
|
||||||
// used. A reply that is not usable moves the query on to the zone's
|
|
||||||
// next server.
|
|
||||||
func TestUsableReply(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
servfail := new(dns.Msg)
|
|
||||||
servfail.Rcode = dns.RcodeServerFailure
|
|
||||||
|
|
||||||
answer := new(dns.Msg)
|
|
||||||
answer.Authoritative = true
|
|
||||||
answer.Answer = []dns.RR{nsRecord(exampleCom)}
|
|
||||||
|
|
||||||
nxdomain := new(dns.Msg)
|
|
||||||
nxdomain.Authoritative = true
|
|
||||||
nxdomain.Rcode = dns.RcodeNameError
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
resp *dns.Msg
|
|
||||||
zone string
|
|
||||||
query string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "SERVFAIL", resp: servfail,
|
|
||||||
zone: exampleCom, query: exampleCom, want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "answer", resp: answer,
|
|
||||||
zone: exampleCom, query: exampleCom, want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "NXDOMAIN", resp: nxdomain,
|
|
||||||
zone: ".", query: exampleCom, want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "root refers to com", resp: referralTo("com."),
|
|
||||||
zone: ".", query: exampleCom, want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "com refers to example.com", resp: referralTo(exampleCom),
|
|
||||||
zone: "com.", query: wwwExampleCom, want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "referral back to the zone", resp: referralTo(exampleCom),
|
|
||||||
zone: exampleCom, query: exampleCom, want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "referral up to the root", resp: referralTo("."),
|
|
||||||
zone: exampleCom, query: exampleCom, want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "referral sideways", resp: referralTo("net."),
|
|
||||||
zone: ".", query: exampleCom, want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(t, tt.want,
|
|
||||||
resolver.UsableReply(tt.resp, tt.zone, tt.query),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNSSetFrom checks which NS set a reply gives for a domain; a set
|
|
||||||
// that is not empty ends the walk. The referral to example.com that
|
|
||||||
// com's servers all send alike gives its delegation, so the set is the
|
|
||||||
// same whichever of them answered, and example.com's own servers, which
|
|
||||||
// can disagree, are not asked.
|
|
||||||
func TestNSSetFrom(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
answer := new(dns.Msg)
|
|
||||||
answer.Authoritative = true
|
|
||||||
answer.Answer = []dns.RR{nsRecord(exampleCom)}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
resp *dns.Msg
|
|
||||||
domain string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "com refers to example.com", resp: referralTo(exampleCom),
|
|
||||||
domain: exampleCom, want: []string{exampleNS},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "com refers on, for www.example.com",
|
|
||||||
resp: referralTo(exampleCom), domain: wwwExampleCom,
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "answer from a server that holds example.com",
|
|
||||||
resp: answer, domain: exampleCom,
|
|
||||||
want: []string{exampleNS},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, tt.want,
|
|
||||||
resolver.NSSetFrom(tt.resp, tt.domain),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -383,37 +383,3 @@ func liveResolveIPsAllowingEmpty(
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveResolveNSIPs looks up the addresses of the nameservers named
|
|
||||||
// names, retrying until there are at least atLeast of them: a name
|
|
||||||
// whose lookup got no reply is left out of the result, not an error.
|
|
||||||
func liveResolveNSIPs(
|
|
||||||
t *testing.T,
|
|
||||||
r *resolver.Resolver,
|
|
||||||
names []string,
|
|
||||||
atLeast int,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out []string
|
|
||||||
|
|
||||||
livednstest.Retry(
|
|
||||||
t,
|
|
||||||
"ResolveNSIPs("+strings.Join(names, ", ")+")",
|
|
||||||
func(ctx context.Context) error {
|
|
||||||
ips := r.ResolveNSIPs(ctx, names)
|
|
||||||
if len(ips) < atLeast {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %d addresses, expected at least %d",
|
|
||||||
livednstest.ErrNoAnswer, len(ips), atLeast,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = ips
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -139,28 +139,6 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResolveNSIPs_EveryNameserver looks up the addresses of two of
|
|
||||||
// google.com's nameservers together, as the walk does when a referral
|
|
||||||
// names a zone's nameservers without their addresses, and compares them
|
|
||||||
// with each looked up alone. Together they must give the addresses of
|
|
||||||
// both, not only of the first that resolves, so that when one gives no
|
|
||||||
// usable reply the walk goes on to the other.
|
|
||||||
func TestResolveNSIPs_EveryNameserver(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := newTestResolver(t)
|
|
||||||
names := []string{"ns3.google.com.", "ns4.google.com."}
|
|
||||||
want := make([]string, 0, len(names))
|
|
||||||
|
|
||||||
for _, name := range names {
|
|
||||||
want = append(want, liveResolveNSIPs(t, r, []string{name}, 1)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := liveResolveNSIPs(t, r, names, len(want))
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// QueryNameserver tests
|
// QueryNameserver tests
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ func NewForTest(
|
|||||||
n Notifier,
|
n Notifier,
|
||||||
) *Watcher {
|
) *Watcher {
|
||||||
return &Watcher{
|
return &Watcher{
|
||||||
log: slog.Default(),
|
log: slog.Default(),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
state: st,
|
state: st,
|
||||||
resolver: res,
|
resolver: res,
|
||||||
portCheck: pc,
|
portCheck: pc,
|
||||||
tlsCheck: tc,
|
tlsCheck: tc,
|
||||||
notify: n,
|
notify: n,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
|
expiryNotified: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +72,6 @@ func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
|||||||
w.checkAllPorts(ctx)
|
w.checkAllPorts(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunTLSChecks exports runTLSChecks for testing.
|
|
||||||
func (w *Watcher) RunTLSChecks(ctx context.Context) {
|
|
||||||
w.runTLSChecks(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildHostnameState exports buildHostnameState for testing.
|
// BuildHostnameState exports buildHostnameState for testing.
|
||||||
func BuildHostnameState(
|
func BuildHostnameState(
|
||||||
results map[string]*resolver.NameserverResponse,
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
|||||||
+38
-18
@@ -7,6 +7,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -48,16 +49,18 @@ type Params struct {
|
|||||||
|
|
||||||
// Watcher orchestrates all monitoring checks on a schedule.
|
// Watcher orchestrates all monitoring checks on a schedule.
|
||||||
type Watcher struct {
|
type Watcher struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
config *config.Config
|
config *config.Config
|
||||||
state *state.State
|
state *state.State
|
||||||
resolver DNSResolver
|
resolver DNSResolver
|
||||||
portCheck PortChecker
|
portCheck PortChecker
|
||||||
tlsCheck TLSChecker
|
tlsCheck TLSChecker
|
||||||
notify Notifier
|
notify Notifier
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{} // closed when Run returns
|
done chan struct{} // closed when Run returns
|
||||||
firstRun bool
|
firstRun bool
|
||||||
|
expiryNotifiedMu sync.Mutex
|
||||||
|
expiryNotified map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Watcher instance wired into the fx lifecycle.
|
// New creates a new Watcher instance wired into the fx lifecycle.
|
||||||
@@ -66,14 +69,15 @@ func New(
|
|||||||
params Params,
|
params Params,
|
||||||
) (*Watcher, error) {
|
) (*Watcher, error) {
|
||||||
w := &Watcher{
|
w := &Watcher{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
state: params.State,
|
state: params.State,
|
||||||
resolver: params.Resolver,
|
resolver: params.Resolver,
|
||||||
portCheck: params.PortCheck,
|
portCheck: params.PortCheck,
|
||||||
tlsCheck: params.TLSCheck,
|
tlsCheck: params.TLSCheck,
|
||||||
notify: params.Notify,
|
notify: params.Notify,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
|
expiryNotified: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -1024,6 +1028,22 @@ func (w *Watcher) checkTLSExpiry(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate expiry warnings: don't re-notify for the same
|
||||||
|
// hostname within the TLS check interval.
|
||||||
|
dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip)
|
||||||
|
|
||||||
|
w.expiryNotifiedMu.Lock()
|
||||||
|
|
||||||
|
lastNotified, seen := w.expiryNotified[dedupKey]
|
||||||
|
if seen && time.Since(lastNotified) < w.config.TLSInterval {
|
||||||
|
w.expiryNotifiedMu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.expiryNotified[dedupKey] = time.Now()
|
||||||
|
w.expiryNotifiedMu.Unlock()
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Host: %s\nIP: %s\nCN: %s\n"+
|
"Host: %s\nIP: %s\nCN: %s\n"+
|
||||||
"Expires: %s (%.0f days)",
|
"Expires: %s (%.0f days)",
|
||||||
|
|||||||
@@ -615,54 +615,33 @@ func TestTLSExpiryWarning(t *testing.T) {
|
|||||||
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
|
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTLSExpiryWarningEachCheck runs the TLS checks three times in a
|
func TestTLSExpiryWarningDedup(t *testing.T) {
|
||||||
// row on hostname and port state built here, for a certificate that
|
|
||||||
// expires within the warning period. Each check warns once, whether the
|
|
||||||
// TLS interval is a nanosecond, shorter than the time between two
|
|
||||||
// checks, or a day, longer than it.
|
|
||||||
func TestTLSExpiryWarningEachCheck(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
title := "TLS Expiry Warning: " + host
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{testHost}
|
||||||
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
for _, interval := range []time.Duration{time.Nanosecond, 24 * time.Hour} {
|
title := "TLS Expiry Warning: " + testHost
|
||||||
t.Run(interval.String(), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
// The second check comes within the TLS interval of the first,
|
||||||
cfg.Hostnames = []string{host}
|
// so it must not warn again.
|
||||||
cfg.TLSInterval = interval
|
var warnings int
|
||||||
|
|
||||||
// The TLS checks read the saved hostname and port state and
|
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
|
||||||
// look nothing up, so the watcher has no resolver.
|
warnings = countNotifications(deps, title)
|
||||||
deps := newTestDeps(t, cfg)
|
})
|
||||||
w := watcher.NewForTest(
|
|
||||||
cfg, deps.state, nil,
|
|
||||||
deps.portChecker, deps.tlsChecker, deps.notifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
expiresInThreeDays(deps)
|
if warnings == 0 {
|
||||||
deps.state.SetHostnameState(host, saved(
|
t.Fatal("expected expiry warnings from the first check")
|
||||||
map[string]*state.NameserverRecordState{
|
}
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
deps.state.SetPortState(ip1+":443", &state.PortState{
|
|
||||||
Open: true, Hostnames: []string{host},
|
|
||||||
})
|
|
||||||
|
|
||||||
for check := 1; check <= 3; check++ {
|
got := countNotifications(deps, title)
|
||||||
w.RunTLSChecks(t.Context())
|
if got != warnings {
|
||||||
|
t.Errorf(
|
||||||
got := countNotifications(deps, title)
|
"expected %d expiry warnings (dedup), got %d",
|
||||||
if got != check {
|
warnings, got,
|
||||||
t.Fatalf(
|
)
|
||||||
"after check %d: %d expiry warnings, want %d",
|
|
||||||
check, got, check,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user