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
|
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
|
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
|
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
|
as usual. When the previous check did not know the type's records either,
|
||||||
failing on the previous check, the type is also listed in `unknownTypes` and
|
because the nameserver was new or failing then or the type was already listed
|
||||||
left out of every comparison until it answers. A nameserver none of whose
|
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
|
||||||
queries got a usable reply has failed (see NS query failure below).
|
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
|
- Stores results **per nameserver**. The state for a hostname is not a merged
|
||||||
view — it is a map from nameserver to record set.
|
view — it is a map from nameserver to record set.
|
||||||
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
|
- 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.
|
- **Summary counts** for monitored domains, hostnames, ports, and certificates.
|
||||||
- **Domains** with their discovered nameservers.
|
- **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.
|
- **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),
|
- **Recent alerts** (last 100 notifications sent since the process started),
|
||||||
displayed in reverse chronological order.
|
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 /api/v1/status` | Current monitoring state |
|
||||||
| `GET /metrics` | Prometheus metrics, see below |
|
| `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
|
`/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,
|
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
|
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
|
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
|
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
|
not be current. When the previous check did not know the type's records either,
|
||||||
`unknownTypes`, and `records` holds nothing for it. Both lists are left out when
|
because the nameserver was new or failing then or the type was already listed in
|
||||||
empty.
|
`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
|
`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
|
||||||
|
|||||||
@@ -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
|
- 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).
|
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
|
- 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).
|
fails otherwise, or runs out of time, still does (closes #229).
|
||||||
- 2026-10-02: Record Change and Inconsistency notifications list only the record
|
- 2026-10-02: Record Change and Inconsistency notifications list only the record
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -78,3 +79,39 @@ func TestFormatRecords(t *testing.T) {
|
|||||||
t.Errorf("unexpected format: %q", got)
|
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 {
|
type statusHostnameNSInfo struct {
|
||||||
Records map[string][]string `json:"records"`
|
Records map[string][]string `json:"records"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ type statusCertificateInfo struct {
|
|||||||
NotAfter time.Time `json:"notAfter"`
|
NotAfter time.Time `json:"notAfter"`
|
||||||
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
|
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +146,7 @@ func buildHostnames(
|
|||||||
info.Nameservers[ns] = &statusHostnameNSInfo{
|
info.Nameservers[ns] = &statusHostnameNSInfo{
|
||||||
Records: recs,
|
Records: recs,
|
||||||
Status: nsState.Status,
|
Status: nsState.Status,
|
||||||
|
Error: nsState.Error,
|
||||||
LastChecked: nsState.LastChecked,
|
LastChecked: nsState.LastChecked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +186,7 @@ func buildCertificates(
|
|||||||
NotAfter: cs.NotAfter,
|
NotAfter: cs.NotAfter,
|
||||||
SubjectAlternativeNames: sans,
|
SubjectAlternativeNames: sans,
|
||||||
Status: cs.Status,
|
Status: cs.Status,
|
||||||
|
Error: cs.Error,
|
||||||
LastChecked: cs.LastChecked,
|
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
|
<td
|
||||||
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
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 }}
|
{{ formatRecords $nsr.Records }}
|
||||||
|
{{ end }}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||||
{{ relTime $nsr.LastChecked }}
|
{{ relTime $nsr.LastChecked }}
|
||||||
@@ -258,6 +262,11 @@
|
|||||||
>
|
>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</td>
|
</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">
|
<td class="py-2 px-3 text-slate-200">
|
||||||
{{ $cs.CommonName }}
|
{{ $cs.CommonName }}
|
||||||
</td>
|
</td>
|
||||||
@@ -285,6 +294,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</td>
|
</td>
|
||||||
|
{{ end }}
|
||||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||||
{{ relTime $cs.LastChecked }}
|
{{ relTime $cs.LastChecked }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ func ExtractRecordValue(rr dns.RR) string {
|
|||||||
return extractRecordValue(rr)
|
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.
|
// UsableReply exports usableReply for testing.
|
||||||
func UsableReply(resp *dns.Msg, zone string, name string) bool {
|
func UsableReply(resp *dns.Msg, zone string, name string) bool {
|
||||||
return usableReply(resp, zone, name)
|
return usableReply(resp, zone, name)
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ func (r *Resolver) queryTypes(
|
|||||||
|
|
||||||
type queryState struct {
|
type queryState struct {
|
||||||
gotNXDomain bool
|
gotNXDomain bool
|
||||||
gotSERVFAIL bool
|
errorReply string // code of an error reply, such as SERVFAIL
|
||||||
gotRefused bool
|
gotRefused bool
|
||||||
gotTimeout bool
|
gotTimeout bool
|
||||||
gotReferral bool
|
gotReferral bool
|
||||||
@@ -724,16 +724,33 @@ func (r *Resolver) querySingleType(
|
|||||||
return err
|
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 {
|
if msg.Rcode == dns.RcodeNameError {
|
||||||
state.gotNXDomain = true
|
state.gotNXDomain = true
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Rcode == dns.RcodeServerFailure {
|
// A reply with any other code but NOERROR, such as SERVFAIL, NOTIMP or
|
||||||
state.gotSERVFAIL = true
|
// 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
|
// A reply with no answer that lists other nameservers, from a server
|
||||||
@@ -762,6 +779,10 @@ func (r *Resolver) querySingleType(
|
|||||||
return nil
|
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(
|
func collectAnswerRecords(
|
||||||
msg *dns.Msg,
|
msg *dns.Msg,
|
||||||
resp *NameserverResponse,
|
resp *NameserverResponse,
|
||||||
@@ -774,9 +795,12 @@ func collectAnswerRecords(
|
|||||||
}
|
}
|
||||||
|
|
||||||
typeName := dns.TypeToString[rr.Header().Rrtype]
|
typeName := dns.TypeToString[rr.Header().Rrtype]
|
||||||
|
if !slices.Contains(resp.Records[typeName], val) {
|
||||||
resp.Records[typeName] = append(
|
resp.Records[typeName] = append(
|
||||||
resp.Records[typeName], val,
|
resp.Records[typeName], val,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
state.hasRecords = true
|
state.hasRecords = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -801,9 +825,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
|||||||
case state.gotTimeout && !state.answered:
|
case state.gotTimeout && !state.answered:
|
||||||
resp.Status = StatusTimeout
|
resp.Status = StatusTimeout
|
||||||
resp.Error = "all queries timed out"
|
resp.Error = "all queries timed out"
|
||||||
case state.gotSERVFAIL && !state.answered:
|
case state.errorReply != "" && !state.answered:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "server returned SERVFAIL"
|
resp.Error = "server returned " + state.errorReply
|
||||||
case state.gotRefused && !state.answered:
|
case state.gotRefused && !state.answered:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "server returned REFUSED"
|
resp.Error = "server returned REFUSED"
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package resolver
|
package resolver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestClassifyResponse sets a nameserver's status from the results of
|
// TestClassifyResponse sets a nameserver's status from the results of
|
||||||
// its queries, built here. One that answered some record types, even
|
// its queries, built here. One that answered some record types, even
|
||||||
// with no records, has not failed when its query for another type timed
|
// with no records, has not failed when its query for another type got
|
||||||
// out; one whose every query timed out has.
|
// no usable reply, whatever the reason; one whose every query got none
|
||||||
|
// has.
|
||||||
func TestClassifyResponse(t *testing.T) {
|
func TestClassifyResponse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -24,11 +27,42 @@ func TestClassifyResponse(t *testing.T) {
|
|||||||
queryState{answered: true, gotTimeout: true},
|
queryState{answered: true, gotTimeout: true},
|
||||||
StatusNoData, "",
|
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",
|
"every query timed out",
|
||||||
queryState{gotTimeout: true},
|
queryState{gotTimeout: true},
|
||||||
StatusTimeout, "all queries timed out",
|
StatusTimeout, "all queries timed out",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"every query got NOTIMP",
|
||||||
|
queryState{errorReply: "NOTIMP"},
|
||||||
|
StatusError, "server returned NOTIMP",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
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
|
// TestShuffled shuffles the root servers with many seeds. Every order
|
||||||
// must hold each root server once, so each is tried before a
|
// must hold each root server once, so each is tried before a
|
||||||
// resolution fails; each root server must come first for some seed, so
|
// resolution fails; each root server must come first for some seed, so
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -208,6 +209,19 @@ func (s *State) Load() error {
|
|||||||
return fmt.Errorf("parsing state file: %w", err)
|
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.snapshot = &snapshot
|
||||||
s.log.Info("loaded state from disk", "path", path)
|
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
|
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
|
||||||
// before the addresses at the end of a hostname's CNAME chain were
|
// before the addresses at the end of a hostname's CNAME chain were
|
||||||
// saved. They load as not known (nil), not as none.
|
// 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.
|
// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
|
||||||
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
|
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
|
||||||
t.Parallel()
|
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