Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a01588f7f4 | ||
|
|
e46db71821 | ||
|
|
1218df9467 |
@@ -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,
|
||||||
@@ -460,7 +469,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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
- 2026-10-02: nameservers a referral names without addresses are looked up,
|
- 2026-10-02: nameservers a referral names without addresses are looked up,
|
||||||
three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221).
|
three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221).
|
||||||
|
- 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
|
||||||
|
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
|
||||||
a certificate check failed, which only the state file showed (closes #225).
|
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
|
- 2026-10-02: a name's CNAME is stored once per nameserver, not once per record
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,12 +43,16 @@ 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
|
||||||
Alerts []notify.AlertEntry
|
Hostnames map[string]*state.HostnameState
|
||||||
StateAge string
|
DomainRecords map[string]*state.HostnameState
|
||||||
GeneratedAt string
|
Alerts []notify.AlertEntry
|
||||||
|
StateAge string
|
||||||
|
GeneratedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleDashboard returns the dashboard page handler.
|
// HandleDashboard returns the dashboard page handler.
|
||||||
@@ -58,12 +65,15 @@ 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,
|
||||||
Alerts: alerts,
|
Hostnames: hostnames,
|
||||||
StateAge: relTime(snap.LastUpdated),
|
DomainRecords: domainRecords,
|
||||||
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
Alerts: alerts,
|
||||||
|
StateAge: relTime(snap.LastUpdated),
|
||||||
|
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.Header().Set(
|
writer.Header().Set(
|
||||||
@@ -122,16 +132,37 @@ func joinStrings(items []string, sep string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formatRecords formats a map of record type → values into a
|
// formatRecords formats a map of record type → values into a
|
||||||
// compact display string.
|
// compact display string. Record types are listed in the order the
|
||||||
|
// README lists them, any other type after them in alphabetical order,
|
||||||
|
// so rows of nameservers with the same records read the same.
|
||||||
func formatRecords(records map[string][]string) string {
|
func formatRecords(records map[string][]string) string {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
order := []string{"A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA", "NS"}
|
||||||
|
|
||||||
|
position := func(rtype string) int {
|
||||||
|
i := slices.Index(order, rtype)
|
||||||
|
if i < 0 {
|
||||||
|
return len(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
rtypes := slices.Collect(maps.Keys(records))
|
||||||
|
slices.SortFunc(rtypes, func(a, b string) int {
|
||||||
|
return cmp.Or(
|
||||||
|
cmp.Compare(position(a), position(b)),
|
||||||
|
strings.Compare(a, b),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
for rtype, values := range records {
|
for _, rtype := range rtypes {
|
||||||
for _, v := range values {
|
for _, v := range records[rtype] {
|
||||||
parts = append(parts, rtype+": "+v)
|
parts = append(parts, rtype+": "+v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -80,6 +81,45 @@ func TestFormatRecords(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFormatRecordsTypeOrder checks that record types are listed in
|
||||||
|
// the README's order (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS), with
|
||||||
|
// any other type after them in alphabetical order.
|
||||||
|
func TestFormatRecordsTypeOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := handlers.FormatRecords(map[string][]string{
|
||||||
|
"SOA": {"ns1.example.com. hostmaster.example.com. 1 2 3 4 5"},
|
||||||
|
"NS": {"ns1.example.com.", "ns2.example.com."},
|
||||||
|
"CAA": {`0 issue "letsencrypt.org"`},
|
||||||
|
"DNAME": {"example.net."},
|
||||||
|
"TXT": {"v=spf1 -all"},
|
||||||
|
"SRV": {"10 5 443 www.example.com."},
|
||||||
|
"MX": {"10 mail.example.com."},
|
||||||
|
"CNAME": {"www.example.com."},
|
||||||
|
"AAAA": {"2001:db8::1"},
|
||||||
|
"A": {"192.0.2.1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"A: 192.0.2.1",
|
||||||
|
"AAAA: 2001:db8::1",
|
||||||
|
"CNAME: www.example.com.",
|
||||||
|
"MX: 10 mail.example.com.",
|
||||||
|
"TXT: v=spf1 -all",
|
||||||
|
"SRV: 10 5 443 www.example.com.",
|
||||||
|
`CAA: 0 issue "letsencrypt.org"`,
|
||||||
|
"NS: ns1.example.com.",
|
||||||
|
"NS: ns2.example.com.",
|
||||||
|
"DNAME: example.net.",
|
||||||
|
"SOA: ns1.example.com. hostmaster.example.com. 1 2 3 4 5",
|
||||||
|
}, ", ")
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("FormatRecords lists types out of order:\n got %q\nwant %q",
|
||||||
|
got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dashboardRow returns the table row of page that contains name.
|
// dashboardRow returns the table row of page that contains name.
|
||||||
func dashboardRow(t *testing.T, page string, name string) string {
|
func dashboardRow(t *testing.T, page string, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -115,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+64
-24
@@ -9,9 +9,12 @@ 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"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
|
||||||
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusHostnameNSInfo holds per-nameserver status for a hostname.
|
// statusHostnameNSInfo holds per-nameserver status for a hostname.
|
||||||
@@ -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,41 +145,54 @@ 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,
|
||||||
LastChecked: ds.LastChecked,
|
RecordsByNameserver: records,
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for ns, nsState := range hs.RecordsByNameserver {
|
// nameserverInfo copies each nameserver's answer saved in hs.
|
||||||
recs := make(map[string][]string, len(nsState.Records))
|
func nameserverInfo(
|
||||||
for rtype, vals := range nsState.Records {
|
hs *state.HostnameState,
|
||||||
copied := make([]string, len(vals))
|
) map[string]*statusHostnameNSInfo {
|
||||||
copy(copied, vals)
|
info := make(map[string]*statusHostnameNSInfo)
|
||||||
recs[rtype] = copied
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Nameservers[ns] = &statusHostnameNSInfo{
|
for ns, nsState := range hs.RecordsByNameserver {
|
||||||
Records: recs,
|
recs := make(map[string][]string, len(nsState.Records))
|
||||||
Status: nsState.Status,
|
for rtype, vals := range nsState.Records {
|
||||||
Error: nsState.Error,
|
copied := make([]string, len(vals))
|
||||||
LastChecked: nsState.LastChecked,
|
copy(copied, vals)
|
||||||
}
|
recs[rtype] = copied
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.Hostnames[name] = info
|
info[ns] = &statusHostnameNSInfo{
|
||||||
|
Records: recs,
|
||||||
|
Status: nsState.Status,
|
||||||
|
Error: nsState.Error,
|
||||||
|
LastChecked: nsState.LastChecked,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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