Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0647eef26 | ||
|
|
250f3dd687 | ||
|
|
c07976a73a |
@@ -100,10 +100,11 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
nameserver by the previous check. On that check those records are not compared
|
||||
with the other nameservers', so no record change or inconsistency is reported
|
||||
for the type; on the next check they are compared with the nameserver's answer
|
||||
as usual. When there are none to keep, because the nameserver was new or
|
||||
failing on the previous check, the type is also listed in `unknownTypes` and
|
||||
left out of every comparison until it answers. A nameserver none of whose
|
||||
queries got a usable reply has failed (see NS query failure below).
|
||||
as usual. When the previous check did not know the type's records either,
|
||||
because the nameserver was new or failing then or the type was already listed
|
||||
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
|
||||
every comparison until it answers. A nameserver none of whose queries got a
|
||||
usable reply has failed (see NS query failure below).
|
||||
- Stores results **per nameserver**. The state for a hostname is not a merged
|
||||
view — it is a map from nameserver to record set.
|
||||
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
|
||||
@@ -244,9 +245,11 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
|
||||
|
||||
- **Summary counts** for monitored domains, hostnames, ports, and certificates.
|
||||
- **Domains** with their discovered nameservers.
|
||||
- **Hostnames** with per-nameserver DNS records and status.
|
||||
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
|
||||
whose query failed, the reason is shown in place of the records.
|
||||
- **Ports** with open/closed state and associated hostnames.
|
||||
- **TLS certificates** with CN, issuer, expiry, and status.
|
||||
- **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
|
||||
the reason is shown in place of CN, issuer and expiry.
|
||||
- **Recent alerts** (last 100 notifications sent since the process started),
|
||||
displayed in reverse chronological order.
|
||||
|
||||
@@ -273,6 +276,10 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||
| `GET /api/v1/status` | Current monitoring state |
|
||||
| `GET /metrics` | Prometheus metrics, see below |
|
||||
|
||||
In `/api/v1/status`, each nameserver entry and certificate entry whose `status`
|
||||
is `error` also has `error`, the reason, as in the state file (see State File
|
||||
Format).
|
||||
|
||||
`/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind
|
||||
Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
|
||||
process, and counts of `/metrics` requests); dnswatcher records no metrics of
|
||||
@@ -544,9 +551,10 @@ certificate entry whose TLS connection or handshake failed likewise has status
|
||||
|
||||
A nameserver with status `ok` whose query for one record type failed lists that
|
||||
type in `failedTypes` and holds its records from the previous check, which may
|
||||
not be current. When there were none to keep, the type is also listed in
|
||||
`unknownTypes`, and `records` holds nothing for it. Both lists are left out when
|
||||
empty.
|
||||
not be current. When the previous check did not know the type's records either,
|
||||
because the nameserver was new or failing then or the type was already listed in
|
||||
`unknownTypes`, the type is also listed in `unknownTypes`, and `records` holds
|
||||
nothing for it. Both lists are left out when empty.
|
||||
|
||||
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
|
||||
resolves to. A state file without it loads, and the next check fills it in
|
||||
|
||||
@@ -21,6 +21,10 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||
|
||||
- 2026-10-02: a record type whose query to a nameserver fails keeps its previous
|
||||
records and alerts nothing; the other types are still saved (closes #231).
|
||||
- 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or
|
||||
a certificate check failed, which only the state file showed (closes #225).
|
||||
- 2026-10-02: a name's CNAME is stored once per nameserver, not once per record
|
||||
type asked for; a state file with repeats loads each value once (closes #220).
|
||||
- 2026-10-02: a DNS lookup that shutdown cuts short logs no error; one that
|
||||
fails otherwise, or runs out of time, still does (closes #229).
|
||||
- 2026-10-02: Record Change and Inconsistency notifications list only the record
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -78,3 +79,39 @@ func TestFormatRecords(t *testing.T) {
|
||||
t.Errorf("unexpected format: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardRow returns the table row of page that contains name.
|
||||
func dashboardRow(t *testing.T, page string, name string) string {
|
||||
t.Helper()
|
||||
|
||||
for row := range strings.SplitSeq(page, "<tr") {
|
||||
if strings.Contains(row, name) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("dashboard has no row containing %q", name)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestDashboardShowsFailureReasons checks that the dashboard shows the
|
||||
// reason in the row of a failed nameserver and of a failed certificate,
|
||||
// and not in the row of a nameserver that answered.
|
||||
func TestDashboardShowsFailureReasons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
page := get(t, newHandlersWithFailures(t).HandleDashboard())
|
||||
|
||||
if !strings.Contains(dashboardRow(t, page, failedNS), nsFailureReason) {
|
||||
t.Errorf("row of %s does not show %q", failedNS, nsFailureReason)
|
||||
}
|
||||
|
||||
if strings.Contains(dashboardRow(t, page, answeringNS), nsFailureReason) {
|
||||
t.Errorf("row of %s shows %q", answeringNS, nsFailureReason)
|
||||
}
|
||||
|
||||
if !strings.Contains(dashboardRow(t, page, certKey), certFailedReason) {
|
||||
t.Errorf("row of %s does not show %q", certKey, certFailedReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type statusDomainInfo struct {
|
||||
type statusHostnameNSInfo struct {
|
||||
Records map[string][]string `json:"records"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ type statusCertificateInfo struct {
|
||||
NotAfter time.Time `json:"notAfter"`
|
||||
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
@@ -144,6 +146,7 @@ func buildHostnames(
|
||||
info.Nameservers[ns] = &statusHostnameNSInfo{
|
||||
Records: recs,
|
||||
Status: nsState.Status,
|
||||
Error: nsState.Error,
|
||||
LastChecked: nsState.LastChecked,
|
||||
}
|
||||
}
|
||||
@@ -183,6 +186,7 @@ func buildCertificates(
|
||||
NotAfter: cs.NotAfter,
|
||||
SubjectAlternativeNames: sans,
|
||||
Status: cs.Status,
|
||||
Error: cs.Error,
|
||||
LastChecked: cs.LastChecked,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// The state the handler tests serve: www.example.com has one nameserver
|
||||
// that answered and one whose query failed, and its certificate check
|
||||
// failed.
|
||||
const (
|
||||
testHostname = "www.example.com"
|
||||
answeringNS = "ns1.example.com."
|
||||
failedNS = "ns2.example.com."
|
||||
nsFailureReason = "server returned a referral"
|
||||
certKey = "192.0.2.1:443:www.example.com"
|
||||
certFailedReason = "x509: certificate has expired or is not yet valid"
|
||||
)
|
||||
|
||||
// newHandlersWithFailures builds real Handlers whose state holds the
|
||||
// entries described above.
|
||||
func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
|
||||
t.Helper()
|
||||
|
||||
glob, err := globals.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New: %v", err)
|
||||
}
|
||||
|
||||
log, err := logger.New(nil, logger.Params{Globals: glob})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New: %v", err)
|
||||
}
|
||||
|
||||
notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{
|
||||
Logger: log,
|
||||
Config: &config.Config{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("notify.New: %v", err)
|
||||
}
|
||||
|
||||
st, err := state.New(fxtest.NewLifecycle(t), state.Params{
|
||||
Logger: log,
|
||||
Config: &config.Config{DataDir: t.TempDir()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("state.New: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
st.SetHostnameState(testHostname, &state.HostnameState{
|
||||
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||
answeringNS: {
|
||||
Records: map[string][]string{"A": {"192.0.2.1"}},
|
||||
Status: "ok",
|
||||
LastChecked: now,
|
||||
},
|
||||
failedNS: {
|
||||
Records: map[string][]string{},
|
||||
Status: "error",
|
||||
Error: nsFailureReason,
|
||||
LastChecked: now,
|
||||
},
|
||||
},
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
st.SetCertificateState(certKey, &state.CertificateState{
|
||||
Status: "error",
|
||||
Error: certFailedReason,
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
hnd, err := handlers.New(nil, handlers.Params{
|
||||
Logger: log,
|
||||
Globals: glob,
|
||||
State: st,
|
||||
Notify: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handlers.New: %v", err)
|
||||
}
|
||||
|
||||
return hnd
|
||||
}
|
||||
|
||||
// get serves one GET request to handler and returns the response body.
|
||||
func get(t *testing.T, handler http.HandlerFunc) string {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// TestStatusGivesFailureReasons checks that /api/v1/status gives the
|
||||
// reason for a failed nameserver entry and a failed certificate entry,
|
||||
// and no error for a nameserver that answered.
|
||||
func TestStatusGivesFailureReasons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := get(t, newHandlersWithFailures(t).HandleStatus())
|
||||
|
||||
var resp struct {
|
||||
Hostnames map[string]struct {
|
||||
Nameservers map[string]map[string]any `json:"nameservers"`
|
||||
} `json:"hostnames"`
|
||||
Certificates map[string]map[string]any `json:"certificates"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
|
||||
nameservers := resp.Hostnames[testHostname].Nameservers
|
||||
|
||||
got := nameservers[failedNS]["error"]
|
||||
if got != nsFailureReason {
|
||||
t.Errorf("failed nameserver error = %v, want %q",
|
||||
got, nsFailureReason)
|
||||
}
|
||||
|
||||
_, has := nameservers[answeringNS]["error"]
|
||||
if has {
|
||||
t.Errorf("answering nameserver has an error field: %v",
|
||||
nameservers[answeringNS])
|
||||
}
|
||||
|
||||
got = resp.Certificates[certKey]["error"]
|
||||
if got != certFailedReason {
|
||||
t.Errorf("failed certificate error = %v, want %q",
|
||||
got, certFailedReason)
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,11 @@
|
||||
<td
|
||||
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
||||
>
|
||||
{{ if $nsr.Error }}
|
||||
<span class="text-red-400">{{ $nsr.Error }}</span>
|
||||
{{ else }}
|
||||
{{ formatRecords $nsr.Records }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $nsr.LastChecked }}
|
||||
@@ -258,6 +262,11 @@
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
{{ if $cs.Error }}
|
||||
<td colspan="3" class="py-2 px-3 text-red-400 break-all">
|
||||
<div class="max-w-xs">{{ $cs.Error }}</div>
|
||||
</td>
|
||||
{{ else }}
|
||||
<td class="py-2 px-3 text-slate-200">
|
||||
{{ $cs.CommonName }}
|
||||
</td>
|
||||
@@ -285,6 +294,7 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
{{ end }}
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $cs.LastChecked }}
|
||||
</td>
|
||||
|
||||
@@ -22,6 +22,13 @@ func ExtractRecordValue(rr dns.RR) string {
|
||||
return extractRecordValue(rr)
|
||||
}
|
||||
|
||||
// CollectAnswerRecords exports collectAnswerRecords for testing.
|
||||
func CollectAnswerRecords(msg *dns.Msg, resp *NameserverResponse) {
|
||||
var state queryState
|
||||
|
||||
collectAnswerRecords(msg, resp, &state)
|
||||
}
|
||||
|
||||
// UsableReply exports usableReply for testing.
|
||||
func UsableReply(resp *dns.Msg, zone string, name string) bool {
|
||||
return usableReply(resp, zone, name)
|
||||
|
||||
@@ -635,7 +635,7 @@ func (r *Resolver) queryTypes(
|
||||
|
||||
type queryState struct {
|
||||
gotNXDomain bool
|
||||
gotSERVFAIL bool
|
||||
errorReply string // code of an error reply, such as SERVFAIL
|
||||
gotRefused bool
|
||||
gotTimeout bool
|
||||
gotReferral bool
|
||||
@@ -724,16 +724,33 @@ func (r *Resolver) querySingleType(
|
||||
return err
|
||||
}
|
||||
|
||||
return readReply(msg, resp, state)
|
||||
}
|
||||
|
||||
// readReply adds to resp the records in msg, a nameserver's reply to a
|
||||
// query about one record type. It returns nil when the nameserver
|
||||
// answered: with records, with none, or with NXDOMAIN; otherwise it
|
||||
// returns why not.
|
||||
func readReply(
|
||||
msg *dns.Msg,
|
||||
resp *NameserverResponse,
|
||||
state *queryState,
|
||||
) error {
|
||||
if msg.Rcode == dns.RcodeNameError {
|
||||
state.gotNXDomain = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if msg.Rcode == dns.RcodeServerFailure {
|
||||
state.gotSERVFAIL = true
|
||||
// A reply with any other code but NOERROR, such as SERVFAIL, NOTIMP or
|
||||
// FORMERR, is an error reply and says nothing about the name's
|
||||
// records. usableReply takes the same codes as no usable reply.
|
||||
if msg.Rcode != dns.RcodeSuccess {
|
||||
state.errorReply = dns.RcodeToString[msg.Rcode]
|
||||
|
||||
return fmt.Errorf("server returned SERVFAIL: %w", ErrUnusableReply)
|
||||
return fmt.Errorf(
|
||||
"server returned %s: %w", state.errorReply, ErrUnusableReply,
|
||||
)
|
||||
}
|
||||
|
||||
// A reply with no answer that lists other nameservers, from a server
|
||||
@@ -762,6 +779,10 @@ func (r *Resolver) querySingleType(
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectAnswerRecords adds the records in msg's answer to resp, each
|
||||
// value once per record type. For a name with a CNAME, a nameserver
|
||||
// answers a query of any type with that CNAME, so the same value comes
|
||||
// in the answer to every type asked for.
|
||||
func collectAnswerRecords(
|
||||
msg *dns.Msg,
|
||||
resp *NameserverResponse,
|
||||
@@ -774,9 +795,12 @@ func collectAnswerRecords(
|
||||
}
|
||||
|
||||
typeName := dns.TypeToString[rr.Header().Rrtype]
|
||||
resp.Records[typeName] = append(
|
||||
resp.Records[typeName], val,
|
||||
)
|
||||
if !slices.Contains(resp.Records[typeName], val) {
|
||||
resp.Records[typeName] = append(
|
||||
resp.Records[typeName], val,
|
||||
)
|
||||
}
|
||||
|
||||
state.hasRecords = true
|
||||
}
|
||||
}
|
||||
@@ -801,9 +825,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
||||
case state.gotTimeout && !state.answered:
|
||||
resp.Status = StatusTimeout
|
||||
resp.Error = "all queries timed out"
|
||||
case state.gotSERVFAIL && !state.answered:
|
||||
case state.errorReply != "" && !state.answered:
|
||||
resp.Status = StatusError
|
||||
resp.Error = "server returned SERVFAIL"
|
||||
resp.Error = "server returned " + state.errorReply
|
||||
case state.gotRefused && !state.answered:
|
||||
resp.Status = StatusError
|
||||
resp.Error = "server returned REFUSED"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestClassifyResponse sets a nameserver's status from the results of
|
||||
// its queries, built here. One that answered some record types, even
|
||||
// with no records, has not failed when its query for another type timed
|
||||
// out; one whose every query timed out has.
|
||||
// with no records, has not failed when its query for another type got
|
||||
// no usable reply, whatever the reason; one whose every query got none
|
||||
// has.
|
||||
func TestClassifyResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -24,11 +27,42 @@ func TestClassifyResponse(t *testing.T) {
|
||||
queryState{answered: true, gotTimeout: true},
|
||||
StatusNoData, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another got SERVFAIL",
|
||||
queryState{answered: true, errorReply: "SERVFAIL"},
|
||||
StatusNoData, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another was refused",
|
||||
queryState{answered: true, gotRefused: true},
|
||||
StatusNoData, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another got a network error",
|
||||
queryState{answered: true, netErr: syscall.ECONNREFUSED},
|
||||
StatusNoData, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another's reply was " +
|
||||
"truncated and its retry over TCP failed",
|
||||
queryState{answered: true, netErr: ErrTruncated},
|
||||
StatusNoData, "",
|
||||
},
|
||||
{
|
||||
"some types answered with no records, another got a referral",
|
||||
queryState{answered: true, gotReferral: true},
|
||||
StatusNoData, "",
|
||||
},
|
||||
{
|
||||
"every query timed out",
|
||||
queryState{gotTimeout: true},
|
||||
StatusTimeout, "all queries timed out",
|
||||
},
|
||||
{
|
||||
"every query got NOTIMP",
|
||||
queryState{errorReply: "NOTIMP"},
|
||||
StatusError, "server returned NOTIMP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -43,3 +77,40 @@ func TestClassifyResponse(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadReply checks which replies to a query about one record type,
|
||||
// built here, are an answer: one with the code NOERROR or NXDOMAIN. A
|
||||
// reply with any other code is not, and the type's query has failed.
|
||||
func TestReadReply(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
rcode int
|
||||
answered bool
|
||||
}{
|
||||
{dns.RcodeSuccess, true},
|
||||
{dns.RcodeNameError, true},
|
||||
{dns.RcodeServerFailure, false},
|
||||
{dns.RcodeNotImplemented, false},
|
||||
{dns.RcodeFormatError, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(dns.RcodeToString[tt.rcode], func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := new(dns.Msg)
|
||||
msg.Authoritative = true
|
||||
msg.Rcode = tt.rcode
|
||||
|
||||
resp := &NameserverResponse{Records: map[string][]string{}}
|
||||
err := readReply(msg, resp, &queryState{})
|
||||
|
||||
if tt.answered {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.ErrorIs(t, err, ErrUnusableReply)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,38 @@ func TestExtractRecordValue_LetterCase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectAnswerRecords_CNAMEOnce collects the answers a nameserver
|
||||
// gives for a name with a CNAME, one for each record type a check asks
|
||||
// for. Each answer holds the CNAME, which must be stored once.
|
||||
func TestCollectAnswerRecords_CNAMEOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cname := &dns.CNAME{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: "git.eeqj.de.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET,
|
||||
},
|
||||
Target: "fsn1app1.datavi.be.",
|
||||
}
|
||||
|
||||
resp := &resolver.NameserverResponse{Records: map[string][]string{}}
|
||||
|
||||
for _, qtype := range []uint16{
|
||||
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX,
|
||||
dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeNS,
|
||||
} {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("git.eeqj.de.", qtype)
|
||||
msg.Answer = []dns.RR{cname}
|
||||
|
||||
resolver.CollectAnswerRecords(msg, resp)
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
map[string][]string{"CNAME": {"fsn1app1.datavi.be."}},
|
||||
resp.Records,
|
||||
)
|
||||
}
|
||||
|
||||
// TestShuffled shuffles the root servers with many seeds. Every order
|
||||
// must hold each root server once, so each is tried before a
|
||||
// resolution fails; each root server must come first for some seed, so
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -208,6 +209,19 @@ func (s *State) Load() error {
|
||||
return fmt.Errorf("parsing state file: %w", err)
|
||||
}
|
||||
|
||||
// A state file saved before each record value was stored once can
|
||||
// hold a hostname's CNAME once for every record type asked for.
|
||||
// Each value is kept once, so the first check does not see a
|
||||
// record change.
|
||||
for _, hs := range snapshot.Hostnames {
|
||||
for _, ns := range hs.RecordsByNameserver {
|
||||
for recordType, values := range ns.Records {
|
||||
slices.Sort(values)
|
||||
ns.Records[recordType] = slices.Compact(values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.snapshot = &snapshot
|
||||
s.log.Info("loaded state from disk", "path", path)
|
||||
|
||||
|
||||
@@ -236,6 +236,61 @@ func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveLoadRoundTrip_FailedTypes checks that a nameserver's
|
||||
// failedTypes and unknownTypes survive a save and load. Without
|
||||
// unknownTypes, a type whose records were not known would load as one
|
||||
// with no records.
|
||||
func TestSaveLoadRoundTrip_FailedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
s := state.NewForTestWithDataDir(dir)
|
||||
|
||||
failed := []string{"TXT", "CAA"}
|
||||
unknown := []string{"CAA"}
|
||||
|
||||
s.SetHostnameState(testHostname, &state.HostnameState{
|
||||
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||
testNS1: {
|
||||
Records: map[string][]string{"TXT": {"v=spf1 -all"}},
|
||||
FailedTypes: failed,
|
||||
UnknownTypes: unknown,
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := s.Save()
|
||||
if err != nil {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
}
|
||||
|
||||
loaded := state.NewForTestWithDataDir(dir)
|
||||
|
||||
err = loaded.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
hs, ok := loaded.GetHostnameState(testHostname)
|
||||
if !ok {
|
||||
t.Fatal("missing hostname " + testHostname)
|
||||
}
|
||||
|
||||
ns1 := hs.RecordsByNameserver[testNS1]
|
||||
if ns1 == nil {
|
||||
t.Fatal("missing nameserver " + testNS1)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(ns1.FailedTypes, failed) {
|
||||
t.Errorf("failedTypes: got %#v", ns1.FailedTypes)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(ns1.UnknownTypes, unknown) {
|
||||
t.Errorf("unknownTypes: got %#v", ns1.UnknownTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
|
||||
// before the addresses at the end of a hostname's CNAME chain were
|
||||
// saved. They load as not known (nil), not as none.
|
||||
@@ -277,6 +332,59 @@ func TestLoadStateFromBeforeCNAMEAddresses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadStateWithRepeatedValues loads a state file saved when a
|
||||
// hostname's CNAME was stored once for every record type asked for.
|
||||
// Each value must load once, and every different value must load.
|
||||
func TestLoadStateWithRepeatedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
data := []byte(`{
|
||||
"version": 1,
|
||||
"hostnames": {
|
||||
"www.example.com": {
|
||||
"recordsByNameserver": {
|
||||
"ns1.example.com.": {
|
||||
"records": {
|
||||
"A": ["192.0.2.2", "192.0.2.1", "192.0.2.2", "192.0.2.1"],
|
||||
"CNAME": ["a.example.net.", "a.example.net.", "a.example.net."]
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing state file: %v", err)
|
||||
}
|
||||
|
||||
s := state.NewForTestWithDataDir(dir)
|
||||
|
||||
err = s.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
hs, ok := s.GetHostnameState(testHostname)
|
||||
if !ok {
|
||||
t.Fatal("missing hostname " + testHostname)
|
||||
}
|
||||
|
||||
want := map[string][]string{
|
||||
"A": {"192.0.2.1", "192.0.2.2"},
|
||||
"CNAME": {"a.example.net."},
|
||||
}
|
||||
|
||||
got := hs.RecordsByNameserver[testNS1].Records
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("records: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
|
||||
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -183,3 +183,58 @@ func TestInconsistencyAlert(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstCheckAfterRepeatedValuesLoaded saves a state file holding a
|
||||
// hostname's CNAME once for every record type asked for, as checks did
|
||||
// before each value was stored once, and two addresses each repeated,
|
||||
// and loads it. A check that then finds each value once at each
|
||||
// nameserver must notify nothing.
|
||||
func TestFirstCheckAfterRepeatedValuesLoaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
cnameType = "CNAME"
|
||||
cname = "c.example.net."
|
||||
)
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
repeated := map[string][]string{
|
||||
"A": {ip2, ip1, ip2, ip1},
|
||||
cnameType: {cname, cname, cname, cname, cname, cname, cname, cname},
|
||||
}
|
||||
once := map[string][]string{"A": {ip1, ip2}, cnameType: {cname}}
|
||||
|
||||
saved := newTestDeps(t, cfg).state
|
||||
saved.SetHostnameState(host, hostnameState(map[string]map[string][]string{
|
||||
nsA: repeated, nsB: repeated,
|
||||
}))
|
||||
|
||||
err := saved.Save()
|
||||
if err != nil {
|
||||
t.Fatalf("saving the state file: %v", err)
|
||||
}
|
||||
|
||||
deps := newTestDeps(t, cfg)
|
||||
|
||||
err = deps.state.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("loading the state file: %v", err)
|
||||
}
|
||||
|
||||
prev, ok := deps.state.GetHostnameState(host)
|
||||
if !ok {
|
||||
t.Fatal("the state file has no state for " + host)
|
||||
}
|
||||
|
||||
current := hostnameState(map[string]map[string][]string{
|
||||
nsA: once, nsB: once,
|
||||
})
|
||||
|
||||
// The hostname change detection uses only the notifier.
|
||||
w := watcher.NewForTest(nil, nil, nil, nil, nil, deps.notifier)
|
||||
w.DetectHostnameChanges(t.Context(), host, prev, current)
|
||||
|
||||
if got := deps.notifier.getNotifications(); len(got) != 0 {
|
||||
t.Errorf("sent %v, want no notification", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user