dashboard, status API and notifications tell apex domains from hostnames (closes #224)
check / check (push) Successful in 1m15s
check / check (push) Successful in 1m15s
An apex domain's own records are still saved with the hostnames' records, under the domain's name, so the port and TLS checks find its addresses. Notifications about them now start `Domain:`, decided by the configured domains. The dashboard and /api/v1/status, which read only the saved state, take a hostname entry whose name also has a domain entry as that domain's own records: the dashboard shows them in a second table under Domains, the API in the domain's `recordsByNameserver`, and neither lists or counts them as hostnames. The startup notification counts domains and hostnames from the configuration. README says which of a domain's own records are watched and how their changes are notified. Model: opus-5-5
This commit is contained in:
@@ -81,6 +81,12 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
removed gets only the NS change notification. When the lookup of a
|
removed gets only the NS change notification. When the lookup of a
|
||||||
nameserver's addresses fails or finds none, its previous addresses are
|
nameserver's addresses fails or finds none, its previous addresses are
|
||||||
kept and nothing is sent.
|
kept and nothing is sent.
|
||||||
|
- Also watches the domain's own records as a hostname's are watched (see DNS
|
||||||
|
Hostname Monitoring below): its A, AAAA, CNAME, MX, TXT, SRV, CAA and NS
|
||||||
|
records, stored per nameserver. Their changes are notified as a hostname's
|
||||||
|
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
|
||||||
|
address change, in a message that starts `Domain:` where a hostname's starts
|
||||||
|
`Hostname:`.
|
||||||
|
|
||||||
### DNS Hostname Monitoring (Subdomains)
|
### DNS Hostname Monitoring (Subdomains)
|
||||||
|
|
||||||
@@ -183,18 +189,18 @@ Supported notification backends:
|
|||||||
All configured endpoints receive every notification. Notification content
|
All configured endpoints receive every notification. Notification content
|
||||||
includes:
|
includes:
|
||||||
|
|
||||||
- **DNS record changes**: Which hostname, which nameserver, what record type,
|
- **DNS record changes**: Which hostname or domain, which nameserver, what
|
||||||
old values, new values.
|
record type, old values, new values.
|
||||||
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
||||||
- **NS address changes**: Which domain, which nameserver, its old and new
|
- **NS address changes**: Which domain, which nameserver, its old and new
|
||||||
addresses.
|
addresses.
|
||||||
- **CNAME address changes**: Which hostname, the old and new addresses at the
|
- **CNAME address changes**: Which hostname or domain, the old and new addresses
|
||||||
end of its CNAME chain.
|
at the end of its CNAME chain.
|
||||||
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
||||||
REFUSED, network error), which hostname/domain affected.
|
REFUSED, network error), which hostname/domain affected.
|
||||||
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
||||||
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
|
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
|
||||||
which hostname affected.
|
which hostname or domain affected.
|
||||||
- **Port changes**: Which IP:port, its new state, all associated hostnames.
|
- **Port changes**: Which IP:port, its new state, all associated hostnames.
|
||||||
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
|
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
|
||||||
hostname and IP.
|
hostname and IP.
|
||||||
@@ -229,7 +235,8 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
|
|||||||
(`/`). It displays:
|
(`/`). It displays:
|
||||||
|
|
||||||
- **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, and each domain's own records
|
||||||
|
per nameserver and status, shown as a hostname's are.
|
||||||
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
|
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
|
||||||
whose query failed, the reason is shown in place of the records.
|
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.
|
||||||
@@ -263,7 +270,9 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
|||||||
|
|
||||||
In `/api/v1/status`, each nameserver entry and certificate entry whose `status`
|
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
|
is `error` also has `error`, the reason, as in the state file (see State File
|
||||||
Format).
|
Format). A domain's own records are in its entry in `domains`, under
|
||||||
|
`recordsByNameserver`, in the form a hostname's entry in `hostnames` has them
|
||||||
|
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain.
|
||||||
|
|
||||||
`/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,
|
||||||
@@ -455,7 +464,8 @@ resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
|
|||||||
|
|
||||||
The state file (`DATA_DIR/state.json`) contains the complete monitoring
|
The state file (`DATA_DIR/state.json`) contains the complete monitoring
|
||||||
snapshot. Hostname records are stored **per authoritative nameserver**, not as a
|
snapshot. Hostname records are stored **per authoritative nameserver**, not as a
|
||||||
merged view, to enable inconsistency detection.
|
merged view, to enable inconsistency detection. `hostnames` also holds each
|
||||||
|
domain's own records, under the domain's name.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-02: an apex domain is not counted or listed as a hostname; its records
|
||||||
|
show under Domains, and notifications about them say `Domain:` (closes #224).
|
||||||
- 2026-10-02: the dashboard lists each nameserver's record types in one fixed
|
- 2026-10-02: the dashboard lists each nameserver's record types in one fixed
|
||||||
order, the README's, then any other type, not a random one (closes #226).
|
order, the README's, then any other type, not a random one (closes #226).
|
||||||
- 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or
|
- 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or
|
||||||
|
|||||||
@@ -43,9 +43,13 @@ func newDashboardTemplate() *template.Template {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dashboardData is the data passed to the dashboard template.
|
// dashboardData is the data passed to the dashboard template. Hostnames
|
||||||
|
// and DomainRecords split the records in Snapshot.Hostnames, which also
|
||||||
|
// holds the apex domains' own (see splitHostnames).
|
||||||
type dashboardData struct {
|
type dashboardData struct {
|
||||||
Snapshot state.Snapshot
|
Snapshot state.Snapshot
|
||||||
|
Hostnames map[string]*state.HostnameState
|
||||||
|
DomainRecords map[string]*state.HostnameState
|
||||||
Alerts []notify.AlertEntry
|
Alerts []notify.AlertEntry
|
||||||
StateAge string
|
StateAge string
|
||||||
GeneratedAt string
|
GeneratedAt string
|
||||||
@@ -61,9 +65,12 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
|||||||
) {
|
) {
|
||||||
snap := h.state.GetSnapshot()
|
snap := h.state.GetSnapshot()
|
||||||
alerts := h.notifyHistory.Recent()
|
alerts := h.notifyHistory.Recent()
|
||||||
|
hostnames, domainRecords := splitHostnames(snap)
|
||||||
|
|
||||||
data := dashboardData{
|
data := dashboardData{
|
||||||
Snapshot: snap,
|
Snapshot: snap,
|
||||||
|
Hostnames: hostnames,
|
||||||
|
DomainRecords: domainRecords,
|
||||||
Alerts: alerts,
|
Alerts: alerts,
|
||||||
StateAge: relTime(snap.LastUpdated),
|
StateAge: relTime(snap.LastUpdated),
|
||||||
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -154,3 +155,53 @@ func TestDashboardShowsFailureReasons(t *testing.T) {
|
|||||||
t.Errorf("row of %s does not show %q", certKey, certFailedReason)
|
t.Errorf("row of %s does not show %q", certKey, certFailedReason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dashboardSection returns the section of page under heading.
|
||||||
|
func dashboardSection(t *testing.T, page string, heading string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for section := range strings.SplitSeq(page, "<section") {
|
||||||
|
words := strings.Join(strings.Fields(section), " ")
|
||||||
|
if strings.Contains(words, "> "+heading+" </h2>") {
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("dashboard has no section headed %q", heading)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDashboardShowsDomainRecordsUnderDomains checks that the dashboard
|
||||||
|
// shows an apex domain's own records in the Domains section, and
|
||||||
|
// neither lists nor counts the domain as a hostname.
|
||||||
|
func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
page := get(t, newHandlersWithFailures(t).HandleDashboard())
|
||||||
|
|
||||||
|
domains := dashboardSection(t, page, "Domains")
|
||||||
|
if !strings.Contains(dashboardRow(t, domains, domainAddress), testDomain) {
|
||||||
|
t.Errorf("row of %s does not name %s", domainAddress, testDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(dashboardSection(t, page, "Hostnames"), testDomain) {
|
||||||
|
t.Errorf("Hostnames section lists the domain %s", testDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
words := strings.Join(strings.Fields(page), " ")
|
||||||
|
|
||||||
|
footer := "monitoring 1 domains + 1 hostnames"
|
||||||
|
if !strings.Contains(words, footer) {
|
||||||
|
t.Errorf("dashboard does not say %q", footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the tags taken out, the summary bar starts "Domains 1
|
||||||
|
// Hostnames 1".
|
||||||
|
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
|
||||||
|
summary := "Domains 1 Hostnames 1"
|
||||||
|
|
||||||
|
if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) {
|
||||||
|
t.Errorf("summary bar does not say %q", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// statusDomainInfo holds status information for a monitored domain.
|
// statusDomainInfo holds status information for a monitored domain.
|
||||||
|
// RecordsByNameserver holds the domain's own records, in the form a
|
||||||
|
// hostname's Nameservers holds the hostname's.
|
||||||
type statusDomainInfo struct {
|
type statusDomainInfo struct {
|
||||||
Nameservers []string `json:"nameservers"`
|
Nameservers []string `json:"nameservers"`
|
||||||
|
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +103,10 @@ func buildStatusResponse(
|
|||||||
Certificates: make(map[string]*statusCertificateInfo),
|
Certificates: make(map[string]*statusCertificateInfo),
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDomains(snap, resp)
|
hostnames, domainRecords := splitHostnames(snap)
|
||||||
buildHostnames(snap, resp)
|
|
||||||
|
buildDomains(snap, domainRecords, resp)
|
||||||
|
buildHostnames(hostnames, resp)
|
||||||
buildPorts(snap, resp)
|
buildPorts(snap, resp)
|
||||||
buildCertificates(snap, resp)
|
buildCertificates(snap, resp)
|
||||||
buildCounts(resp)
|
buildCounts(resp)
|
||||||
@@ -109,8 +114,30 @@ func buildStatusResponse(
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitHostnames returns the records saved in snap.Hostnames in two
|
||||||
|
// maps: the hostnames' and the apex domains' own. The watcher saves a
|
||||||
|
// domain's own records there under the domain's name, which has an
|
||||||
|
// entry in snap.Domains too.
|
||||||
|
func splitHostnames(
|
||||||
|
snap state.Snapshot,
|
||||||
|
) (map[string]*state.HostnameState, map[string]*state.HostnameState) {
|
||||||
|
hostnames := make(map[string]*state.HostnameState)
|
||||||
|
domainRecords := make(map[string]*state.HostnameState)
|
||||||
|
|
||||||
|
for name, hs := range snap.Hostnames {
|
||||||
|
if _, isDomain := snap.Domains[name]; isDomain {
|
||||||
|
domainRecords[name] = hs
|
||||||
|
} else {
|
||||||
|
hostnames[name] = hs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostnames, domainRecords
|
||||||
|
}
|
||||||
|
|
||||||
func buildDomains(
|
func buildDomains(
|
||||||
snap state.Snapshot,
|
snap state.Snapshot,
|
||||||
|
domainRecords map[string]*state.HostnameState,
|
||||||
resp *statusResponse,
|
resp *statusResponse,
|
||||||
) {
|
) {
|
||||||
for name, ds := range snap.Domains {
|
for name, ds := range snap.Domains {
|
||||||
@@ -118,22 +145,36 @@ func buildDomains(
|
|||||||
copy(ns, ds.Nameservers)
|
copy(ns, ds.Nameservers)
|
||||||
sort.Strings(ns)
|
sort.Strings(ns)
|
||||||
|
|
||||||
|
records := make(map[string]*statusHostnameNSInfo)
|
||||||
|
if hs, ok := domainRecords[name]; ok {
|
||||||
|
records = nameserverInfo(hs)
|
||||||
|
}
|
||||||
|
|
||||||
resp.Domains[name] = &statusDomainInfo{
|
resp.Domains[name] = &statusDomainInfo{
|
||||||
Nameservers: ns,
|
Nameservers: ns,
|
||||||
|
RecordsByNameserver: records,
|
||||||
LastChecked: ds.LastChecked,
|
LastChecked: ds.LastChecked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildHostnames(
|
func buildHostnames(
|
||||||
snap state.Snapshot,
|
hostnames map[string]*state.HostnameState,
|
||||||
resp *statusResponse,
|
resp *statusResponse,
|
||||||
) {
|
) {
|
||||||
for name, hs := range snap.Hostnames {
|
for name, hs := range hostnames {
|
||||||
info := &statusHostnameInfo{
|
resp.Hostnames[name] = &statusHostnameInfo{
|
||||||
Nameservers: make(map[string]*statusHostnameNSInfo),
|
Nameservers: nameserverInfo(hs),
|
||||||
LastChecked: hs.LastChecked,
|
LastChecked: hs.LastChecked,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameserverInfo copies each nameserver's answer saved in hs.
|
||||||
|
func nameserverInfo(
|
||||||
|
hs *state.HostnameState,
|
||||||
|
) map[string]*statusHostnameNSInfo {
|
||||||
|
info := make(map[string]*statusHostnameNSInfo)
|
||||||
|
|
||||||
for ns, nsState := range hs.RecordsByNameserver {
|
for ns, nsState := range hs.RecordsByNameserver {
|
||||||
recs := make(map[string][]string, len(nsState.Records))
|
recs := make(map[string][]string, len(nsState.Records))
|
||||||
@@ -143,7 +184,7 @@ func buildHostnames(
|
|||||||
recs[rtype] = copied
|
recs[rtype] = copied
|
||||||
}
|
}
|
||||||
|
|
||||||
info.Nameservers[ns] = &statusHostnameNSInfo{
|
info[ns] = &statusHostnameNSInfo{
|
||||||
Records: recs,
|
Records: recs,
|
||||||
Status: nsState.Status,
|
Status: nsState.Status,
|
||||||
Error: nsState.Error,
|
Error: nsState.Error,
|
||||||
@@ -151,8 +192,7 @@ func buildHostnames(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.Hostnames[name] = info
|
return info
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPorts(
|
func buildPorts(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ import (
|
|||||||
|
|
||||||
// The state the handler tests serve: www.example.com has one nameserver
|
// The state the handler tests serve: www.example.com has one nameserver
|
||||||
// that answered and one whose query failed, and its certificate check
|
// that answered and one whose query failed, and its certificate check
|
||||||
// failed.
|
// failed. example.net is an apex domain, whose own records are saved
|
||||||
|
// with the hostnames' records, as the watcher saves them.
|
||||||
const (
|
const (
|
||||||
testHostname = "www.example.com"
|
testHostname = "www.example.com"
|
||||||
answeringNS = "ns1.example.com."
|
answeringNS = "ns1.example.com."
|
||||||
@@ -27,6 +29,9 @@ const (
|
|||||||
nsFailureReason = "server returned a referral"
|
nsFailureReason = "server returned a referral"
|
||||||
certKey = "192.0.2.1:443:www.example.com"
|
certKey = "192.0.2.1:443:www.example.com"
|
||||||
certFailedReason = "x509: certificate has expired or is not yet valid"
|
certFailedReason = "x509: certificate has expired or is not yet valid"
|
||||||
|
testDomain = "example.net"
|
||||||
|
domainNS = "a.iana-servers.net."
|
||||||
|
domainAddress = "192.0.2.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newHandlersWithFailures builds real Handlers whose state holds the
|
// newHandlersWithFailures builds real Handlers whose state holds the
|
||||||
@@ -85,6 +90,22 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
|
|||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
st.SetDomainState(testDomain, &state.DomainState{
|
||||||
|
Nameservers: []string{domainNS},
|
||||||
|
LastChecked: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
st.SetHostnameState(testDomain, &state.HostnameState{
|
||||||
|
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||||
|
domainNS: {
|
||||||
|
Records: map[string][]string{"A": {domainAddress}},
|
||||||
|
Status: "ok",
|
||||||
|
LastChecked: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LastChecked: now,
|
||||||
|
})
|
||||||
|
|
||||||
hnd, err := handlers.New(nil, handlers.Params{
|
hnd, err := handlers.New(nil, handlers.Params{
|
||||||
Logger: log,
|
Logger: log,
|
||||||
Globals: glob,
|
Globals: glob,
|
||||||
@@ -156,3 +177,43 @@ func TestStatusGivesFailureReasons(t *testing.T) {
|
|||||||
got, certFailedReason)
|
got, certFailedReason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStatusGivesDomainRecordsUnderTheDomain checks that /api/v1/status
|
||||||
|
// gives an apex domain's own records in its domain entry, and neither
|
||||||
|
// lists nor counts the domain as a hostname.
|
||||||
|
func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := get(t, newHandlersWithFailures(t).HandleStatus())
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Counts struct {
|
||||||
|
Hostnames int `json:"hostnames"`
|
||||||
|
} `json:"counts"`
|
||||||
|
Domains map[string]struct {
|
||||||
|
RecordsByNameserver map[string]struct {
|
||||||
|
Records map[string][]string `json:"records"`
|
||||||
|
} `json:"recordsByNameserver"`
|
||||||
|
} `json:"domains"`
|
||||||
|
Hostnames map[string]any `json:"hostnames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(body), &resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Counts.Hostnames != 1 {
|
||||||
|
t.Errorf("counts.hostnames = %d, want 1", resp.Counts.Hostnames)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := resp.Hostnames[testDomain]; listed {
|
||||||
|
t.Errorf("hostnames lists the domain %s", testDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := resp.Domains[testDomain].RecordsByNameserver[domainNS].Records
|
||||||
|
if !slices.Equal(records["A"], []string{domainAddress}) {
|
||||||
|
t.Errorf("domain %s records at %s = %v, want A %s",
|
||||||
|
testDomain, domainNS, records, domainAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
Hostnames
|
Hostnames
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||||
{{ len .Snapshot.Hostnames }}
|
{{ len .Hostnames }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||||
@@ -94,6 +94,24 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{ if .DomainRecords }}
|
||||||
|
<div class="overflow-x-auto mt-4">
|
||||||
|
<table class="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-slate-500 uppercase tracking-wider">
|
||||||
|
<th class="py-2 px-3">Domain</th>
|
||||||
|
<th class="py-2 px-3">NS</th>
|
||||||
|
<th class="py-2 px-3">Status</th>
|
||||||
|
<th class="py-2 px-3">Records</th>
|
||||||
|
<th class="py-2 px-3">Checked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{{ template "records" .DomainRecords }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<p class="text-slate-600 italic text-xs">
|
<p class="text-slate-600 italic text-xs">
|
||||||
No domains configured.
|
No domains configured.
|
||||||
@@ -108,7 +126,7 @@
|
|||||||
>
|
>
|
||||||
Hostnames
|
Hostnames
|
||||||
</h2>
|
</h2>
|
||||||
{{ if .Snapshot.Hostnames }}
|
{{ if .Hostnames }}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-left text-xs">
|
<table class="w-full text-left text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -121,43 +139,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-800">
|
<tbody class="divide-y divide-slate-800">
|
||||||
{{ range $name, $hs := .Snapshot.Hostnames }}
|
{{ template "records" .Hostnames }}
|
||||||
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
|
||||||
<tr class="hover:bg-surface-800/50">
|
|
||||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
|
||||||
{{ $name }}
|
|
||||||
</td>
|
|
||||||
<td class="py-2 px-3 text-slate-400 break-all">
|
|
||||||
{{ $ns }}
|
|
||||||
</td>
|
|
||||||
<td class="py-2 px-3">
|
|
||||||
{{ if eq $nsr.Status "ok" }}
|
|
||||||
<span
|
|
||||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
|
||||||
>ok</span
|
|
||||||
>
|
|
||||||
{{ else }}
|
|
||||||
<span
|
|
||||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
|
||||||
>{{ $nsr.Status }}</span
|
|
||||||
>
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
<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 }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,8 +355,48 @@
|
|||||||
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
|
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
|
||||||
>
|
>
|
||||||
dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
|
dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
|
||||||
{{ len .Snapshot.Hostnames }} hostnames
|
{{ len .Hostnames }} hostnames
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
{{/* ---- One row per nameserver of each name in the map it is given ---- */}}
|
||||||
|
{{ define "records" }}
|
||||||
|
{{ range $name, $hs := . }}
|
||||||
|
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
||||||
|
<tr class="hover:bg-surface-800/50">
|
||||||
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||||
|
{{ $name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ $ns }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
{{ if eq $nsr.Status "ok" }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||||
|
>ok</span
|
||||||
|
>
|
||||||
|
{{ else }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||||
|
>{{ $nsr.Status }}</span
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<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 }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ type CertificateState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot is the complete monitoring state persisted to disk.
|
// Snapshot is the complete monitoring state persisted to disk.
|
||||||
|
// Hostnames also holds each apex domain's own records, under the
|
||||||
|
// domain's name, which has an entry in Domains too.
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
LastUpdated time.Time `json:"lastUpdated"`
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewForTest creates a Watcher without fx for unit testing.
|
// NewForTest creates a Watcher without fx for unit testing. A nil cfg
|
||||||
|
// is an empty configuration.
|
||||||
func NewForTest(
|
func NewForTest(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
st *state.State,
|
st *state.State,
|
||||||
@@ -19,6 +20,10 @@ func NewForTest(
|
|||||||
tc TLSChecker,
|
tc TLSChecker,
|
||||||
n Notifier,
|
n Notifier,
|
||||||
) *Watcher {
|
) *Watcher {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &config.Config{}
|
||||||
|
}
|
||||||
|
|
||||||
return &Watcher{
|
return &Watcher{
|
||||||
log: slog.Default(),
|
log: slog.Default(),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@@ -96,6 +101,12 @@ func (w *Watcher) DetectNSAddressChanges(
|
|||||||
w.detectNSAddressChanges(ctx, domain, prev, current)
|
w.detectNSAddressChanges(ctx, domain, prev, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaybeSendTestNotification exports maybeSendTestNotification for
|
||||||
|
// testing.
|
||||||
|
func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
|
||||||
|
w.maybeSendTestNotification(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// CheckAllPorts exports checkAllPorts for testing.
|
// CheckAllPorts exports checkAllPorts for testing.
|
||||||
func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
||||||
w.checkAllPorts(ctx)
|
w.checkAllPorts(ctx)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package watcher_test
|
package watcher_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"maps"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,3 +68,100 @@ b.ns.example.net.: 192.0.2.2`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every kind of notification about a configured apex domain's own
|
||||||
|
// records names it as a domain.
|
||||||
|
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
notifier := &mockNotifier{}
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
&config.Config{Domains: []string{domain}},
|
||||||
|
nil, nil, nil, nil, notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
// nsA's address changes, which also makes it differ from nsC; nsB
|
||||||
|
// fails; nsC answers again; nsD is gone.
|
||||||
|
nsD := "d.ns.example.net."
|
||||||
|
w.DetectHostnameChanges(t.Context(), domain,
|
||||||
|
saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
nsB: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
nsC: failed(),
|
||||||
|
nsD: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
}),
|
||||||
|
saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip2}}),
|
||||||
|
nsB: failed(),
|
||||||
|
nsC: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The address at the end of its CNAME chain changes.
|
||||||
|
w.DetectHostnameChanges(
|
||||||
|
t.Context(), domain, cnameState(ip1), cnameState(ip2),
|
||||||
|
)
|
||||||
|
|
||||||
|
// NS Failure is sent for nsB failing and for nsD being gone.
|
||||||
|
want := map[string]int{
|
||||||
|
"Record Change": 1,
|
||||||
|
"Inconsistency": 1,
|
||||||
|
"NS Failure": 2,
|
||||||
|
"NS Recovery": 1,
|
||||||
|
"CNAME Address Change": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := make(map[string]int)
|
||||||
|
|
||||||
|
for _, n := range notifier.getNotifications() {
|
||||||
|
kind, _, _ := strings.Cut(n.Title, ":")
|
||||||
|
sent[kind]++
|
||||||
|
|
||||||
|
if !strings.HasPrefix(n.Message, "Domain: "+domain+"\n") {
|
||||||
|
t.Errorf("%s message does not name the domain:\n%s",
|
||||||
|
n.Title, n.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !maps.Equal(sent, want) {
|
||||||
|
t.Errorf("sent %v, want %v", sent, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup notification counts the configured domains and hostnames,
|
||||||
|
// although the state's hostnames also hold the apex domain's own
|
||||||
|
// records. Nothing is looked up: the watcher has no resolver.
|
||||||
|
func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.SendTestNotification = true
|
||||||
|
cfg.Domains = []string{domain}
|
||||||
|
cfg.Hostnames = []string{host}
|
||||||
|
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier)
|
||||||
|
|
||||||
|
// The state a check of both names saves.
|
||||||
|
deps.state.SetDomainState(domain, &state.DomainState{
|
||||||
|
Nameservers: []string{nsA},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, name := range []string{domain, host} {
|
||||||
|
deps.state.SetHostnameState(name, saved(
|
||||||
|
map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.MaybeSendTestNotification(t.Context())
|
||||||
|
|
||||||
|
notifications := deps.notifier.getNotifications()
|
||||||
|
|
||||||
|
counts := "\nMonitoring 1 domain(s) and 1 hostname(s).\n"
|
||||||
|
if len(notifications) != 1 ||
|
||||||
|
!strings.Contains(notifications[0].Message, counts) {
|
||||||
|
t.Errorf("sent %v, want one message with %q", notifications, counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+31
-17
@@ -271,8 +271,9 @@ func (w *Watcher) checkDomain(
|
|||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The apex domain's records are also checked as a hostname's, so
|
// The apex domain's records are also checked and saved as a
|
||||||
// that the port and TLS checks find its addresses.
|
// hostname's, so that the port and TLS checks find its addresses.
|
||||||
|
// Notifications about them name it as a domain (see nameLine).
|
||||||
w.checkHostname(ctx, domain)
|
w.checkHostname(ctx, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +542,17 @@ func (w *Watcher) detectHostnameChanges(
|
|||||||
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nameLine is the line a notification about name's records starts with:
|
||||||
|
// "Domain: " and the name for a configured apex domain, whose own
|
||||||
|
// records are checked as a hostname's are, and "Hostname: " otherwise.
|
||||||
|
func (w *Watcher) nameLine(name string) string {
|
||||||
|
if slices.Contains(w.config.Domains, name) {
|
||||||
|
return "Domain: " + name
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Hostname: " + name
|
||||||
|
}
|
||||||
|
|
||||||
// detectCNAMEAddressChanges notifies when the addresses at the end of
|
// detectCNAMEAddressChanges notifies when the addresses at the end of
|
||||||
// hostname's CNAME chain differ from those the previous check saved,
|
// hostname's CNAME chain differ from those the previous check saved,
|
||||||
// including a change from or to none. When the previous addresses are
|
// including a change from or to none. When the previous addresses are
|
||||||
@@ -557,8 +569,8 @@ func (w *Watcher) detectCNAMEAddressChanges(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\nOld: %s\nNew: %s",
|
"%s\nOld: %s\nNew: %s",
|
||||||
hostname,
|
w.nameLine(hostname),
|
||||||
strings.Join(old, ", "),
|
strings.Join(old, ", "),
|
||||||
strings.Join(cur, ", "),
|
strings.Join(cur, ", "),
|
||||||
)
|
)
|
||||||
@@ -590,8 +602,8 @@ func (w *Watcher) detectRecordChanges(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\nNameserver: %s\n%s",
|
"%s\nNameserver: %s\n%s",
|
||||||
hostname, ns,
|
w.nameLine(hostname), ns,
|
||||||
recordDifferences(
|
recordDifferences(
|
||||||
"Old", prevNS.Records,
|
"Old", prevNS.Records,
|
||||||
"New", cur.Records,
|
"New", cur.Records,
|
||||||
@@ -618,8 +630,8 @@ func (w *Watcher) detectNSDisappearances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\nNameserver: %s disappeared",
|
"%s\nNameserver: %s disappeared",
|
||||||
hostname, ns,
|
w.nameLine(hostname), ns,
|
||||||
)
|
)
|
||||||
|
|
||||||
w.notify.SendNotification(
|
w.notify.SendNotification(
|
||||||
@@ -648,8 +660,8 @@ func (w *Watcher) detectNSFailures(
|
|||||||
switch {
|
switch {
|
||||||
case prevNS.Status == statusOK && cur.Status == statusError:
|
case prevNS.Status == statusOK && cur.Status == statusError:
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\nNameserver: %s\nError: %s",
|
"%s\nNameserver: %s\nError: %s",
|
||||||
hostname, ns, cur.Error,
|
w.nameLine(hostname), ns, cur.Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
w.notify.SendNotification(
|
w.notify.SendNotification(
|
||||||
@@ -660,8 +672,8 @@ func (w *Watcher) detectNSFailures(
|
|||||||
)
|
)
|
||||||
case prevNS.Status == statusError && cur.Status == statusOK:
|
case prevNS.Status == statusError && cur.Status == statusOK:
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\nNameserver: %s recovered",
|
"%s\nNameserver: %s recovered",
|
||||||
hostname, ns,
|
w.nameLine(hostname), ns,
|
||||||
)
|
)
|
||||||
|
|
||||||
w.notify.SendNotification(
|
w.notify.SendNotification(
|
||||||
@@ -683,8 +695,8 @@ func (w *Watcher) detectInconsistencies(
|
|||||||
ns1, ns2 := pair[0], pair[1]
|
ns1, ns2 := pair[0], pair[1]
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\n%s",
|
"%s\n%s",
|
||||||
hostname,
|
w.nameLine(hostname),
|
||||||
recordDifferences(
|
recordDifferences(
|
||||||
ns1, current.RecordsByNameserver[ns1].Records,
|
ns1, current.RecordsByNameserver[ns1].Records,
|
||||||
ns2, current.RecordsByNameserver[ns2].Records,
|
ns2, current.RecordsByNameserver[ns2].Records,
|
||||||
@@ -1180,7 +1192,9 @@ func (w *Watcher) saveState() {
|
|||||||
// after the first full scan completes, if SEND_TEST_NOTIFICATION
|
// after the first full scan completes, if SEND_TEST_NOTIFICATION
|
||||||
// is enabled. The message is informational, not an error or anomaly
|
// is enabled. The message is informational, not an error or anomaly
|
||||||
// alert. It is written before it reaches any endpoint, so it claims
|
// alert. It is written before it reaches any endpoint, so it claims
|
||||||
// nothing about whether the endpoints work.
|
// nothing about whether the endpoints work. Domains and hostnames are
|
||||||
|
// counted from the configuration: the state's hostnames also hold each
|
||||||
|
// apex domain's own records.
|
||||||
func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
|
func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
|
||||||
if !w.config.SendTestNotification {
|
if !w.config.SendTestNotification {
|
||||||
return
|
return
|
||||||
@@ -1194,8 +1208,8 @@ func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
|
|||||||
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
|
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
|
||||||
"This is a test notification, sent to every configured "+
|
"This is a test notification, sent to every configured "+
|
||||||
"notification endpoint.",
|
"notification endpoint.",
|
||||||
len(snap.Domains),
|
len(w.config.Domains),
|
||||||
len(snap.Hostnames),
|
len(w.config.Hostnames),
|
||||||
len(snap.Ports),
|
len(snap.Ports),
|
||||||
len(snap.Certificates),
|
len(snap.Certificates),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user