Compare commits
3
Commits
2a89d1dc7a
...
52877ffa3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52877ffa3f | ||
|
|
97c8138c85 | ||
|
|
d2f154b2cf |
@@ -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
|
## Features
|
||||||
|
|
||||||
### DNS Domain Monitoring (Apex Domains)
|
### DNS Domain Monitoring (Apex Domains)
|
||||||
@@ -110,7 +133,9 @@ Contributions that introduce mocked, faked, or stubbed DNS will be rejected.
|
|||||||
- Port transitioned from open to closed (or vice versa).
|
- Port transitioned from open to closed (or vice versa).
|
||||||
- New IP appeared (from DNS change) and its port state was recorded.
|
- New IP appeared (from DNS change) and its port state was recorded.
|
||||||
- IP disappeared (from DNS change) — noted in the DNS change notification;
|
- IP disappeared (from DNS change) — noted in the DNS change notification;
|
||||||
port state for that IP is removed.
|
port state for that IP is removed. When none of a name's nameservers
|
||||||
|
answered, its addresses are not known, so the port state saved for them is
|
||||||
|
kept.
|
||||||
|
|
||||||
### TLS Certificate Monitoring
|
### TLS Certificate Monitoring
|
||||||
|
|
||||||
@@ -270,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
|
||||||
|
|
||||||
Configuration is loaded via [Viper](https://github.com/spf13/viper) with the
|
Configuration is loaded via [Viper](https://github.com/spf13/viper) with the
|
||||||
@@ -488,8 +471,8 @@ reachability:
|
|||||||
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
|
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
|
||||||
|
|
||||||
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
|
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
|
||||||
`records`. A nameserver whose query failed has status `error`, empty `records`,
|
`records`. A nameserver whose query failed, or that only referred it to other
|
||||||
and the reason in `error`.
|
nameservers, has status `error`, empty `records`, and the reason in `error`.
|
||||||
|
|
||||||
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
|
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
|
||||||
resolves to. A state file without it loads, and the next check fills it in
|
resolves to. A state file without it loads, and the next check fills it in
|
||||||
@@ -665,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
|
## License
|
||||||
|
|
||||||
dnswatcher is released under the MIT License, Copyright (c) 2026
|
dnswatcher is released under the MIT License, Copyright (c) 2026
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# 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: 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
|
||||||
|
nameserver of the name's zone answered (closes #190).
|
||||||
- 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run
|
- 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run
|
||||||
in Docker at the version pinned by `yarn.lock` (closes #119).
|
in Docker at the version pinned by `yarn.lock` (closes #119).
|
||||||
- 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both
|
- 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both
|
||||||
@@ -116,7 +122,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:
|
- 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
|
|
||||||
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
|
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
|
||||||
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ var (
|
|||||||
"no authoritative nameservers found",
|
"no authoritative nameservers found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoNameserverAnswered is returned when every nameserver
|
||||||
|
// asked about a name timed out, failed or returned a referral,
|
||||||
|
// so whether the name has addresses is unknown.
|
||||||
|
ErrNoNameserverAnswered = errors.New("no nameserver answered")
|
||||||
|
|
||||||
// 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,6 +11,13 @@ func ExtractRecordValue(rr dns.RR) string {
|
|||||||
return extractRecordValue(rr)
|
return extractRecordValue(rr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CollectIPs exports collectIPs for testing.
|
||||||
|
func CollectIPs(
|
||||||
|
results map[string]*NameserverResponse,
|
||||||
|
) ([]string, string, error) {
|
||||||
|
return collectIPs(results)
|
||||||
|
}
|
||||||
|
|
||||||
// QueryEachNS exports queryEachNS for testing.
|
// QueryEachNS exports queryEachNS for testing.
|
||||||
func (r *Resolver) QueryEachNS(
|
func (r *Resolver) QueryEachNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ type queryState struct {
|
|||||||
gotSERVFAIL bool
|
gotSERVFAIL bool
|
||||||
gotRefused bool
|
gotRefused bool
|
||||||
gotTimeout bool
|
gotTimeout bool
|
||||||
|
gotReferral bool
|
||||||
netErr error
|
netErr error
|
||||||
hasRecords bool
|
hasRecords bool
|
||||||
}
|
}
|
||||||
@@ -578,6 +579,18 @@ func (r *Resolver) querySingleType(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// about the name's records. A parent zone's servers send one when
|
||||||
|
// every server of the name's own zone failed and
|
||||||
|
// FindAuthoritativeNameservers moved on to the parent name.
|
||||||
|
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
||||||
|
len(extractNSSet(msg.Ns)) > 0 {
|
||||||
|
state.gotReferral = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
collectAnswerRecords(msg, resp, state)
|
collectAnswerRecords(msg, resp, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +639,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
|||||||
case state.netErr != nil && !state.hasRecords:
|
case state.netErr != nil && !state.hasRecords:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "network error: " + state.netErr.Error()
|
resp.Error = "network error: " + state.netErr.Error()
|
||||||
|
case state.gotReferral && !state.hasRecords:
|
||||||
|
resp.Status = StatusError
|
||||||
|
resp.Error = "server returned a referral"
|
||||||
case !state.hasRecords && !state.gotNXDomain:
|
case !state.hasRecords && !state.gotNXDomain:
|
||||||
resp.Status = StatusNoData
|
resp.Status = StatusNoData
|
||||||
}
|
}
|
||||||
@@ -734,7 +750,9 @@ func (r *Resolver) LookupAllRecords(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
|
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
|
||||||
// addresses, following CNAME chains up to MaxCNAMEDepth.
|
// addresses, following CNAME chains up to MaxCNAMEDepth. When no
|
||||||
|
// nameserver of the name's zone answered, it returns an error rather
|
||||||
|
// than no addresses.
|
||||||
func (r *Resolver) ResolveIPAddresses(
|
func (r *Resolver) ResolveIPAddresses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
@@ -760,7 +778,10 @@ func (r *Resolver) resolveIPWithCNAME(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, cnameTarget := collectIPs(results)
|
ips, cnameTarget, err := collectIPs(results)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolving %s: %w", hostname, err)
|
||||||
|
}
|
||||||
|
|
||||||
if len(ips) == 0 && cnameTarget != "" {
|
if len(ips) == 0 && cnameTarget != "" {
|
||||||
return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1)
|
return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1)
|
||||||
@@ -771,16 +792,28 @@ func (r *Resolver) resolveIPWithCNAME(
|
|||||||
return ips, nil
|
return ips, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectIPs returns the addresses in the nameservers' answers and the
|
||||||
|
// first CNAME target among them. It returns ErrNoNameserverAnswered when
|
||||||
|
// every nameserver timed out, failed or returned a referral: that is not
|
||||||
|
// a name with no addresses.
|
||||||
func collectIPs(
|
func collectIPs(
|
||||||
results map[string]*NameserverResponse,
|
results map[string]*NameserverResponse,
|
||||||
) ([]string, string) {
|
) ([]string, string, error) {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
var ips []string
|
var ips []string
|
||||||
|
|
||||||
var cnameTarget string
|
var cnameTarget string
|
||||||
|
|
||||||
|
answered := false
|
||||||
|
|
||||||
for _, resp := range results {
|
for _, resp := range results {
|
||||||
|
if resp.Status == StatusTimeout || resp.Status == StatusError {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
answered = true
|
||||||
|
|
||||||
if resp.Status == StatusNXDomain {
|
if resp.Status == StatusNXDomain {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -804,5 +837,9 @@ func collectIPs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ips, cnameTarget
|
if !answered {
|
||||||
|
return nil, "", ErrNoNameserverAnswered
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, cnameTarget, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,42 @@ import (
|
|||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestCollectIPs_OneAnswerIsEnough checks that one nameserver answering
|
||||||
|
// NXDOMAIN says the name has no addresses, though the other timed out.
|
||||||
|
func TestCollectIPs_OneAnswerIsEnough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{
|
||||||
|
"ns1.example.": {Status: resolver.StatusTimeout},
|
||||||
|
"ns2.example.": {Status: resolver.StatusNXDomain},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollectIPs_FailedIsNoAnswer checks that nameservers that all have
|
||||||
|
// status error, from a refusal, a server failure, a network error or a
|
||||||
|
// referral, are no answer rather than a name with no addresses.
|
||||||
|
func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{
|
||||||
|
"ns1.example.": {Status: resolver.StatusError},
|
||||||
|
"ns2.example.": {Status: resolver.StatusError},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -633,6 +633,82 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
|
|||||||
assert.NotEmpty(t, resp.Error)
|
assert.NotEmpty(t, resp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCollectIPs_NoNameserverAnswered takes the response of a
|
||||||
|
// nameserver at 192.0.2.1, where nothing answers, as
|
||||||
|
// TestQueryNameserverIP_Timeout does. Addresses collected from
|
||||||
|
// nameservers that all failed to answer are an error, not none.
|
||||||
|
func TestCollectIPs_NoNameserverAnswered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
// The deadline outlasts the first try, as in
|
||||||
|
// TestQueryNameserverIP_Timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(
|
||||||
|
context.Background(), 3*time.Second,
|
||||||
|
)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserverIP(
|
||||||
|
ctx, "unreachable.test.", "192.0.2.1",
|
||||||
|
"example.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{resp.Nameserver: resp},
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollectIPs_ReferralIsNoAnswer asks a root server about
|
||||||
|
// example.com, which the root zone does not hold, so it only refers the
|
||||||
|
// query to the com servers. That reply is no answer, as is a parent
|
||||||
|
// zone's when every server of the name's own zone failed.
|
||||||
|
func TestCollectIPs_ReferralIsNoAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
var resp *resolver.NameserverResponse
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryNameserverIP(a.root-servers.net, example.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
resp, err = r.QueryNameserverIP(
|
||||||
|
ctx, "a.root-servers.net.", "198.41.0.4",
|
||||||
|
"example.com",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timeout or a network error is no reply at all.
|
||||||
|
if resp.Status == resolver.StatusTimeout ||
|
||||||
|
strings.HasPrefix(resp.Error, "network error") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", livednstest.ErrNoAnswer, resp.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, resolver.StatusError, resp.Status)
|
||||||
|
assert.Equal(t, "server returned a referral", resp.Error)
|
||||||
|
|
||||||
|
ips, _, err := resolver.CollectIPs(
|
||||||
|
map[string]*resolver.NameserverResponse{resp.Nameserver: resp},
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
||||||
|
assert.Empty(t, ips)
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
|
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ func (w *Watcher) DetectNSAddressChanges(
|
|||||||
w.detectNSAddressChanges(ctx, domain, prev, current)
|
w.detectNSAddressChanges(ctx, domain, prev, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckAllPorts exports checkAllPorts for testing.
|
||||||
|
func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
||||||
|
w.checkAllPorts(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,
|
||||||
|
|||||||
@@ -331,3 +331,118 @@ func TestNameserverThatRefuses(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPortStateWhenNoNameserverAnswered runs the port checks on
|
||||||
|
// hostname state built here, which gives the name no address. The port
|
||||||
|
// state saved for its old address is kept only when the name is a
|
||||||
|
// configured hostname or domain and none of its nameservers answered.
|
||||||
|
func TestPortStateWhenNoNameserverAnswered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
noneAnswered := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: failed(), nsB: failed(),
|
||||||
|
})
|
||||||
|
oneAnsweredNoAddress := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{}), nsB: failed(),
|
||||||
|
})
|
||||||
|
|
||||||
|
configured := []string{host}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname *state.HostnameState
|
||||||
|
hostnames []string
|
||||||
|
domains []string
|
||||||
|
wantKept bool
|
||||||
|
}{
|
||||||
|
{"no nameserver answered", noneAnswered, configured, nil, true},
|
||||||
|
{
|
||||||
|
"no nameserver answered, configured as a domain",
|
||||||
|
noneAnswered, nil, configured, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"one answered with no address",
|
||||||
|
oneAnsweredNoAddress, configured, nil, false,
|
||||||
|
},
|
||||||
|
{"no nameserver answered, not configured", noneAnswered, nil, nil, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = tt.hostnames
|
||||||
|
cfg.Domains = tt.domains
|
||||||
|
|
||||||
|
// The port checks read the saved hostname state and look
|
||||||
|
// nothing up, so the watcher has no resolver.
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
key := ip1 + ":443"
|
||||||
|
|
||||||
|
deps.state.SetHostnameState(host, tt.hostname)
|
||||||
|
deps.state.SetPortState(key, &state.PortState{
|
||||||
|
Open: true, Hostnames: []string{host},
|
||||||
|
})
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
_, kept := deps.state.GetPortState(key)
|
||||||
|
if kept != tt.wantKept {
|
||||||
|
t.Errorf("port state %s kept: %v, want %v", key, kept, tt.wantKept)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway saves the
|
||||||
|
// port state of an address two configured hostnames resolve to. While
|
||||||
|
// none of the first one's nameservers answer, the port checks run with
|
||||||
|
// the other one still at that address, then after it moved away; the
|
||||||
|
// port state is kept both times.
|
||||||
|
func TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const other = "mail.example.net"
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{host, other}
|
||||||
|
|
||||||
|
// The port checks read the saved hostname state and look nothing
|
||||||
|
// up, so the watcher has no resolver.
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
key := ip1 + ":443"
|
||||||
|
|
||||||
|
deps.state.SetPortState(key, &state.PortState{
|
||||||
|
Open: true, Hostnames: []string{host, other},
|
||||||
|
})
|
||||||
|
deps.state.SetHostnameState(host, saved(
|
||||||
|
map[string]*state.NameserverRecordState{nsA: failed(), nsB: failed()},
|
||||||
|
))
|
||||||
|
|
||||||
|
for _, otherIP := range []string{ip1, ip2} {
|
||||||
|
deps.state.SetHostnameState(other, saved(
|
||||||
|
map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {otherIP}}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
if _, kept := deps.state.GetPortState(key); !kept {
|
||||||
|
t.Fatalf("port state %s removed with %s at %s", key, other, otherIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -321,10 +322,9 @@ func (w *Watcher) detectNSChanges(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveNameserverAddresses returns the sorted addresses each
|
// resolveNameserverAddresses returns the sorted addresses each
|
||||||
// nameserver's name resolves to. A nameserver whose lookup fails or
|
// nameserver's name resolves to. A nameserver whose lookup fails, as it
|
||||||
// finds no address keeps its addresses from prev: the resolver finds no
|
// does when no nameserver of the name's zone answers, or finds no
|
||||||
// address, without an error, when every server it asks times out, and
|
// address keeps its addresses from prev and is not an address change.
|
||||||
// that is not an address change.
|
|
||||||
func (w *Watcher) resolveNameserverAddresses(
|
func (w *Watcher) resolveNameserverAddresses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nameservers []string,
|
nameservers []string,
|
||||||
@@ -709,15 +709,46 @@ func parsePortKey(key string) (string, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cleanupStalePorts removes port state entries that are no
|
// cleanupStalePorts removes port state entries that are no
|
||||||
// longer referenced by any hostname in the current DNS data.
|
// longer referenced by any hostname in the current DNS data. An
|
||||||
|
// entry saved for a configured name none of whose nameservers
|
||||||
|
// answered is kept: that name's addresses are not known, not gone.
|
||||||
func (w *Watcher) cleanupStalePorts(
|
func (w *Watcher) cleanupStalePorts(
|
||||||
currentAssociations map[string][]string,
|
currentAssociations map[string][]string,
|
||||||
) {
|
) {
|
||||||
for _, key := range w.state.GetAllPortKeys() {
|
for _, key := range w.state.GetAllPortKeys() {
|
||||||
if _, exists := currentAssociations[key]; !exists {
|
if _, exists := currentAssociations[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ps, ok := w.state.GetPortState(key)
|
||||||
|
if ok && slices.ContainsFunc(ps.Hostnames, w.noNameserverAnswered) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
w.state.DeletePortState(key)
|
w.state.DeletePortState(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noNameserverAnswered reports whether name is a configured domain or
|
||||||
|
// hostname and none of its nameservers answered on its last check.
|
||||||
|
func (w *Watcher) noNameserverAnswered(name string) bool {
|
||||||
|
if !slices.Contains(w.config.Hostnames, name) &&
|
||||||
|
!slices.Contains(w.config.Domains, name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hs, ok := w.state.GetHostnameState(name)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nsState := range hs.RecordsByNameserver {
|
||||||
|
if nsState.Status == statusOK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) collectIPs(hostname string) []string {
|
func (w *Watcher) collectIPs(hostname string) []string {
|
||||||
@@ -796,9 +827,24 @@ func (w *Watcher) checkSinglePort(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A configured name on the saved list none of whose nameservers
|
||||||
|
// answered stays on it, so the entry is kept when the other names
|
||||||
|
// stop resolving to this address.
|
||||||
|
savedHostnames := slices.Clone(hostnames)
|
||||||
|
|
||||||
|
if hasPrev {
|
||||||
|
for _, name := range prev.Hostnames {
|
||||||
|
if !slices.Contains(hostnames, name) && w.noNameserverAnswered(name) {
|
||||||
|
savedHostnames = append(savedHostnames, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(savedHostnames)
|
||||||
|
}
|
||||||
|
|
||||||
w.state.SetPortState(key, &state.PortState{
|
w.state.SetPortState(key, &state.PortState{
|
||||||
Open: result.Open,
|
Open: result.Open,
|
||||||
Hostnames: hostnames,
|
Hostnames: savedHostnames,
|
||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user