2 Commits
Author SHA1 Message Date
sneak 288877fe88 docs: add the README sections policy requires (closes #173)
check / check (push) Successful in 1m42s
REPO_POLICIES.md requires Getting Started, Rationale, Design and TODO
sections in the README, and it had none of them. Getting Started clones
the repository, builds the image and runs it watching example.com and
www.example.com; DNSWATCHER_TARGETS is the only setting it requires.
Rationale is drawn from what the README already says. The Architecture
section moves below Entrypoints and is renamed Design, its text
unchanged, so the required sections come in policy order. TODO points
to TODO.md and the 1.0 milestone instead of copying the list.

Model: opus-5-5
2026-10-01 23:22:49 +00:00
clawbot d09822562d resolver: pass over a server that answers SERVFAIL or refers no closer (closes #197)
check / check (push) Failing after 2m8s
When the resolver walks from the root servers towards a name, a server
that answered SERVFAIL, or referred the query back to its own zone, up
or sideways, ended the step, so finding a zone's servers gave up on the
zone though its other servers would answer. Such a reply is now passed
over for the zone's next server, as a timeout or a refusal already was.
To tell a referral that leads closer to the name from one that does
not, each walk keeps the zone of the servers it is asking. Other error
replies, such as FORMERR, are passed over too. The walk that finds a
nameserver's address shares the same server loop, so it changes too.

Model: opus-5-5
2026-10-02 01:17:37 +02:00
6 changed files with 245 additions and 47 deletions
+85 -42
View File
@@ -40,6 +40,29 @@ 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
### DNS Domain Monitoring (Apex Domains)
@@ -272,48 +295,6 @@ 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 is loaded via [Viper](https://github.com/spf13/viper) with the
@@ -667,6 +648,68 @@ 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
dnswatcher is released under the MIT License, Copyright (c) 2026
+4 -2
View File
@@ -19,6 +19,10 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps
- 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
state saved for its addresses is kept, not removed (closes #193).
- 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no
@@ -120,7 +124,5 @@ 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:
https://git.eeqj.de/sneak/dnswatcher/issues/66
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- README sections required by policy:
https://git.eeqj.de/sneak/dnswatcher/issues/173
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
+7
View File
@@ -15,6 +15,13 @@ var (
// so whether the name has addresses is unknown.
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
// exceeds MaxCNAMEDepth.
ErrCNAMEDepthExceeded = errors.New(
+5
View File
@@ -11,6 +11,11 @@ func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr)
}
// UsableReply exports usableReply for testing.
func UsableReply(resp *dns.Msg, zone string, name string) bool {
return usableReply(resp, zone, name)
}
// CollectIPs exports collectIPs for testing.
func CollectIPs(
results map[string]*NameserverResponse,
+53 -3
View File
@@ -207,13 +207,16 @@ func (r *Resolver) followDelegation(
domain string,
servers []string,
) ([]string, error) {
// servers are the root servers, the servers of zone ".".
zone := "."
for range maxDelegation {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
resp, err := r.queryServers(
ctx, servers, domain, dns.TypeNS,
ctx, servers, zone, domain, dns.TypeNS,
)
if err != nil {
return nil, err
@@ -250,14 +253,19 @@ func (r *Resolver) followDelegation(
}
servers = nextServers
zone = referralZone(resp)
}
return nil, ErrNoNameservers
}
// queryServers asks servers, the servers of zone, about name until one
// gives a usable reply. A server that times out, refuses or gives a
// reply that is not usable is passed over for the next.
func (r *Resolver) queryServers(
ctx context.Context,
servers []string,
zone string,
name string,
qtype uint16,
) (*dns.Msg, error) {
@@ -269,6 +277,12 @@ func (r *Resolver) queryServers(
}
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 {
return resp, nil
}
@@ -279,6 +293,38 @@ func (r *Resolver) queryServers(
return nil, fmt.Errorf("all servers failed: %w", lastErr)
}
// usableReply reports whether resp, a reply from one of the servers of
// zone to a query about name, is usable. An error reply such as SERVFAIL
// 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 ""
}
func (r *Resolver) resolveNSIPs(
ctx context.Context,
nsNames []string,
@@ -312,6 +358,7 @@ func (r *Resolver) resolveNSIterative(
domain = dns.Fqdn(domain)
servers := rootServerList()
zone := "."
for range maxDelegation {
if checkCtx(ctx) != nil {
@@ -319,7 +366,7 @@ func (r *Resolver) resolveNSIterative(
}
resp, err := r.queryServers(
ctx, servers, domain, dns.TypeNS,
ctx, servers, zone, domain, dns.TypeNS,
)
if err != nil {
return nil, err
@@ -344,6 +391,7 @@ func (r *Resolver) resolveNSIterative(
}
servers = nextServers
zone = referralZone(resp)
}
return nil, ErrNoNameservers
@@ -361,6 +409,7 @@ func (r *Resolver) resolveARecord(
hostname = dns.Fqdn(hostname)
servers := rootServerList()
zone := "."
for range maxDelegation {
if checkCtx(ctx) != nil {
@@ -368,7 +417,7 @@ func (r *Resolver) resolveARecord(
}
resp, err := r.queryServers(
ctx, servers, hostname, dns.TypeA,
ctx, servers, zone, hostname, dns.TypeA,
)
if err != nil {
return nil, fmt.Errorf(
@@ -406,6 +455,7 @@ func (r *Resolver) resolveARecord(
}
servers = nextServers
zone = referralZone(resp)
}
return nil, fmt.Errorf(
+91
View File
@@ -41,6 +41,97 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
assert.Empty(t, ips)
}
// exampleCom is the zone most cases of TestUsableReply are about.
const exampleCom = "example.com."
// 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: "ns1.example.net.",
}
}
// 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: "www.example.com.", 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),
)
})
}
}
func TestExtractRecordValue_LetterCase(t *testing.T) {
t.Parallel()