Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6a5989f8e | ||
|
|
9b524e9d63 | ||
|
|
b43402a631 |
@@ -201,7 +201,9 @@ includes:
|
|||||||
- **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 or domain 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, and the domains and the
|
||||||
|
hostnames that resolve to it, on a `Domains:` line and a `Hostnames:` line. A
|
||||||
|
line that would name nothing is left out.
|
||||||
- **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.
|
||||||
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
|
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
|
||||||
@@ -228,10 +230,11 @@ clears them.
|
|||||||
false-positive change notifications.
|
false-positive change notifications.
|
||||||
- State is written atomically (write to temp file, then rename) to prevent
|
- State is written atomically (write to temp file, then rename) to prevent
|
||||||
corruption.
|
corruption.
|
||||||
- A name removed from `DNSWATCHER_TARGETS` is removed from the state at the
|
- A name removed from `DNSWATCHER_TARGETS` is removed from the state at startup,
|
||||||
first check after startup, without a notification: its domain, hostname and
|
before the first check, without a notification: its domain, hostname and
|
||||||
certificate entries go, as do the port entries of addresses no configured name
|
certificate entries go, so the dashboard and `/api/v1/status` no longer list
|
||||||
has. The dashboard and `/api/v1/status` then no longer list or count it.
|
or count it. The first check's port checks remove the port entries of
|
||||||
|
addresses no configured name has.
|
||||||
- Each port check also removes the certificate entries for an address their name
|
- Each port check also removes the certificate entries for an address their name
|
||||||
no longer resolves to, except while none of the name's nameservers answer.
|
no longer resolves to, except while none of the name's nameservers answer.
|
||||||
|
|
||||||
@@ -245,7 +248,8 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
|
|||||||
per nameserver and status, shown as a hostname's are.
|
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 the domains and hostnames that resolve to
|
||||||
|
each address, in separate columns.
|
||||||
- **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
|
- **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
|
||||||
the reason is shown in place of CN, issuer and expiry.
|
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),
|
||||||
@@ -278,7 +282,9 @@ 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). A domain's own records are in its entry in `domains`, under
|
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
|
`recordsByNameserver`, in the form a hostname's entry in `hostnames` has them
|
||||||
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain.
|
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A port
|
||||||
|
entry lists the domains that resolve to its address in `domains`, and the
|
||||||
|
hostnames in `hostnames`.
|
||||||
|
|
||||||
`/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,
|
||||||
@@ -567,8 +573,9 @@ previous check's list is kept, or `null` when no earlier check saved one. A
|
|||||||
state file without it loads, and the first check after that saves it without a
|
state file without it loads, and the first check after that saves it without a
|
||||||
notification.
|
notification.
|
||||||
|
|
||||||
A port entry in the older format, with one `hostname` instead of the `hostnames`
|
A port entry's `hostnames` lists every name that resolves to its address,
|
||||||
list, loads as a list of that one name.
|
domains included. A port entry in the older format, with one `hostname` instead
|
||||||
|
of the `hostnames` list, loads as a list of that one name.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -712,7 +719,8 @@ docker run -d \
|
|||||||
1. **Startup**: Check that the data directory can be written, and exit with an
|
1. **Startup**: Check that the data directory can be written, and exit with an
|
||||||
error naming it if not. Load state from disk. If no state file exists, start
|
error naming it if not. Load state from disk. If no state file exists, start
|
||||||
with empty state (first check will establish baseline without triggering
|
with empty state (first check will establish baseline without triggering
|
||||||
change notifications).
|
change notifications). Remove from the state the names no longer in
|
||||||
|
`DNSWATCHER_TARGETS` (see State Management).
|
||||||
2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
|
2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
|
||||||
startup.
|
startup.
|
||||||
3. **Periodic checks** (DNS always runs first):
|
3. **Periodic checks** (DNS always runs first):
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
|
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
|
||||||
the dashboard and API, at the first check after startup (closes #223).
|
the dashboard and API, at startup, before the first check (closes #223).
|
||||||
|
- 2026-10-02: a Port Change notification lists the port's domains on a
|
||||||
|
`Domains:` line and its hostnames on a `Hostnames:` line (closes #248).
|
||||||
|
- 2026-10-02: the dashboard's Ports table and `/api/v1/status` port entries list
|
||||||
|
a port's domains apart from its hostnames (closes #245).
|
||||||
- 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
|
- 2026-10-02: an apex domain is not counted or listed as a hostname; its records
|
||||||
|
|||||||
@@ -45,11 +45,14 @@ func newDashboardTemplate() *template.Template {
|
|||||||
|
|
||||||
// dashboardData is the data passed to the dashboard template. Hostnames
|
// dashboardData is the data passed to the dashboard template. Hostnames
|
||||||
// and DomainRecords split the records in Snapshot.Hostnames, which also
|
// and DomainRecords split the records in Snapshot.Hostnames, which also
|
||||||
// holds the apex domains' own (see splitHostnames).
|
// holds the apex domains' own (see splitHostnames). Ports holds
|
||||||
|
// Snapshot.Ports with each port's names split into domains and
|
||||||
|
// hostnames, as /api/v1/status gives them (see buildPorts).
|
||||||
type dashboardData struct {
|
type dashboardData struct {
|
||||||
Snapshot state.Snapshot
|
Snapshot state.Snapshot
|
||||||
Hostnames map[string]*state.HostnameState
|
Hostnames map[string]*state.HostnameState
|
||||||
DomainRecords map[string]*state.HostnameState
|
DomainRecords map[string]*state.HostnameState
|
||||||
|
Ports map[string]*statusPortInfo
|
||||||
Alerts []notify.AlertEntry
|
Alerts []notify.AlertEntry
|
||||||
StateAge string
|
StateAge string
|
||||||
GeneratedAt string
|
GeneratedAt string
|
||||||
@@ -71,6 +74,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
|||||||
Snapshot: snap,
|
Snapshot: snap,
|
||||||
Hostnames: hostnames,
|
Hostnames: hostnames,
|
||||||
DomainRecords: domainRecords,
|
DomainRecords: domainRecords,
|
||||||
|
Ports: buildPorts(snap),
|
||||||
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"),
|
||||||
|
|||||||
@@ -205,3 +205,51 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
|
|||||||
t.Errorf("summary bar does not say %q", summary)
|
t.Errorf("summary bar does not say %q", summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rowCells returns the text of each cell of a dashboard table row
|
||||||
|
// whose cells start with tag, "<th" or "<td".
|
||||||
|
func rowCells(row string, tag string) []string {
|
||||||
|
tags := regexp.MustCompile(`<[^>]*>`)
|
||||||
|
parts := strings.Split(row, tag)[1:]
|
||||||
|
cells := make([]string, 0, len(parts))
|
||||||
|
|
||||||
|
for _, cell := range parts {
|
||||||
|
text := tags.ReplaceAllString(tag+cell, " ")
|
||||||
|
cells = append(cells, strings.Join(strings.Fields(text), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDashboardPortsTellDomainsFromHostnames checks that the Ports
|
||||||
|
// table lists an apex domain under Domains and a hostname under
|
||||||
|
// Hostnames when both resolve to the port's address.
|
||||||
|
func TestDashboardPortsTellDomainsFromHostnames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
page := get(t, newHandlersWithFailures(t).HandleDashboard())
|
||||||
|
ports := dashboardSection(t, page, "Ports")
|
||||||
|
|
||||||
|
headings := rowCells(dashboardRow(t, ports, "Address</th>"), "<th")
|
||||||
|
cells := rowCells(dashboardRow(t, ports, sharedPort), "<td")
|
||||||
|
|
||||||
|
if len(cells) != len(headings) {
|
||||||
|
t.Fatalf("row of %s has cells %q under headings %q",
|
||||||
|
sharedPort, cells, headings)
|
||||||
|
}
|
||||||
|
|
||||||
|
under := make(map[string]string)
|
||||||
|
for i, heading := range headings {
|
||||||
|
under[heading] = cells[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if under["Domains"] != testDomain {
|
||||||
|
t.Errorf("row of %s lists %q under Domains, want %q",
|
||||||
|
sharedPort, under["Domains"], testDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if under["Hostnames"] != testHostname {
|
||||||
|
t.Errorf("row of %s lists %q under Hostnames, want %q",
|
||||||
|
sharedPort, under["Hostnames"], testHostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ type statusHostnameInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// statusPortInfo holds status information for a monitored port.
|
// statusPortInfo holds status information for a monitored port.
|
||||||
|
// Domains and Hostnames list the apex domains and the hostnames that
|
||||||
|
// resolve to its address.
|
||||||
type statusPortInfo struct {
|
type statusPortInfo struct {
|
||||||
Open bool `json:"open"`
|
Open bool `json:"open"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
Hostnames []string `json:"hostnames"`
|
Hostnames []string `json:"hostnames"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
@@ -99,7 +102,6 @@ func buildStatusResponse(
|
|||||||
LastUpdated: snap.LastUpdated,
|
LastUpdated: snap.LastUpdated,
|
||||||
Domains: make(map[string]*statusDomainInfo),
|
Domains: make(map[string]*statusDomainInfo),
|
||||||
Hostnames: make(map[string]*statusHostnameInfo),
|
Hostnames: make(map[string]*statusHostnameInfo),
|
||||||
Ports: make(map[string]*statusPortInfo),
|
|
||||||
Certificates: make(map[string]*statusCertificateInfo),
|
Certificates: make(map[string]*statusCertificateInfo),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ func buildStatusResponse(
|
|||||||
|
|
||||||
buildDomains(snap, domainRecords, resp)
|
buildDomains(snap, domainRecords, resp)
|
||||||
buildHostnames(hostnames, resp)
|
buildHostnames(hostnames, resp)
|
||||||
buildPorts(snap, resp)
|
resp.Ports = buildPorts(snap)
|
||||||
buildCertificates(snap, resp)
|
buildCertificates(snap, resp)
|
||||||
buildCounts(resp)
|
buildCounts(resp)
|
||||||
|
|
||||||
@@ -195,21 +197,36 @@ func nameserverInfo(
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPorts(
|
// buildPorts returns the port entries saved in snap. A port entry
|
||||||
snap state.Snapshot,
|
// saves apex domains with its hostnames; they are told apart as in
|
||||||
resp *statusResponse,
|
// splitHostnames, by a domain entry in snap.Domains.
|
||||||
) {
|
func buildPorts(snap state.Snapshot) map[string]*statusPortInfo {
|
||||||
|
ports := make(map[string]*statusPortInfo, len(snap.Ports))
|
||||||
|
|
||||||
for key, ps := range snap.Ports {
|
for key, ps := range snap.Ports {
|
||||||
hostnames := make([]string, len(ps.Hostnames))
|
domains := []string{}
|
||||||
copy(hostnames, ps.Hostnames)
|
hostnames := []string{}
|
||||||
|
|
||||||
|
for _, name := range ps.Hostnames {
|
||||||
|
if _, isDomain := snap.Domains[name]; isDomain {
|
||||||
|
domains = append(domains, name)
|
||||||
|
} else {
|
||||||
|
hostnames = append(hostnames, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(domains)
|
||||||
sort.Strings(hostnames)
|
sort.Strings(hostnames)
|
||||||
|
|
||||||
resp.Ports[key] = &statusPortInfo{
|
ports[key] = &statusPortInfo{
|
||||||
Open: ps.Open,
|
Open: ps.Open,
|
||||||
|
Domains: domains,
|
||||||
Hostnames: hostnames,
|
Hostnames: hostnames,
|
||||||
LastChecked: ps.LastChecked,
|
LastChecked: ps.LastChecked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ports
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCertificates(
|
func buildCertificates(
|
||||||
|
|||||||
@@ -21,7 +21,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. example.net is an apex domain, whose own records are saved
|
// failed. example.net is an apex domain, whose own records are saved
|
||||||
// with the hostnames' records, as the watcher saves them.
|
// with the hostnames' records, as the watcher saves them. Both names
|
||||||
|
// resolve to domainAddress, whose port 443 entry lists them.
|
||||||
const (
|
const (
|
||||||
testHostname = "www.example.com"
|
testHostname = "www.example.com"
|
||||||
answeringNS = "ns1.example.com."
|
answeringNS = "ns1.example.com."
|
||||||
@@ -32,6 +33,7 @@ const (
|
|||||||
testDomain = "example.net"
|
testDomain = "example.net"
|
||||||
domainNS = "a.iana-servers.net."
|
domainNS = "a.iana-servers.net."
|
||||||
domainAddress = "192.0.2.2"
|
domainAddress = "192.0.2.2"
|
||||||
|
sharedPort = domainAddress + ":443"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newHandlersWithFailures builds real Handlers whose state holds the
|
// newHandlersWithFailures builds real Handlers whose state holds the
|
||||||
@@ -65,12 +67,31 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
|
|||||||
t.Fatalf("state.New: %v", err)
|
t.Fatalf("state.New: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTestState(st)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTestState sets the entries described above in st.
|
||||||
|
func setTestState(st *state.State) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
st.SetHostnameState(testHostname, &state.HostnameState{
|
st.SetHostnameState(testHostname, &state.HostnameState{
|
||||||
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||||
answeringNS: {
|
answeringNS: {
|
||||||
Records: map[string][]string{"A": {"192.0.2.1"}},
|
Records: map[string][]string{
|
||||||
|
"A": {"192.0.2.1", domainAddress},
|
||||||
|
},
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
},
|
},
|
||||||
@@ -106,17 +127,11 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
|
|||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
hnd, err := handlers.New(nil, handlers.Params{
|
st.SetPortState(sharedPort, &state.PortState{
|
||||||
Logger: log,
|
Open: true,
|
||||||
Globals: glob,
|
Hostnames: []string{testDomain, testHostname},
|
||||||
State: st,
|
LastChecked: now,
|
||||||
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.
|
// get serves one GET request to handler and returns the response body.
|
||||||
@@ -217,3 +232,36 @@ func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
|
|||||||
testDomain, domainNS, records, domainAddress)
|
testDomain, domainNS, records, domainAddress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStatusPortsTellDomainsFromHostnames checks that a port entry in
|
||||||
|
// /api/v1/status lists an apex domain in domains and a hostname in
|
||||||
|
// hostnames when both resolve to its address.
|
||||||
|
func TestStatusPortsTellDomainsFromHostnames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := get(t, newHandlersWithFailures(t).HandleStatus())
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Ports map[string]struct {
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
} `json:"ports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(body), &resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := resp.Ports[sharedPort]
|
||||||
|
|
||||||
|
if !slices.Equal(port.Domains, []string{testDomain}) {
|
||||||
|
t.Errorf("port %s domains = %v, want [%s]",
|
||||||
|
sharedPort, port.Domains, testDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(port.Hostnames, []string{testHostname}) {
|
||||||
|
t.Errorf("port %s hostnames = %v, want [%s]",
|
||||||
|
sharedPort, port.Hostnames, testHostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,19 +157,20 @@
|
|||||||
>
|
>
|
||||||
Ports
|
Ports
|
||||||
</h2>
|
</h2>
|
||||||
{{ if .Snapshot.Ports }}
|
{{ if .Ports }}
|
||||||
<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>
|
||||||
<tr class="text-slate-500 uppercase tracking-wider">
|
<tr class="text-slate-500 uppercase tracking-wider">
|
||||||
<th class="py-2 px-3">Address</th>
|
<th class="py-2 px-3">Address</th>
|
||||||
<th class="py-2 px-3">State</th>
|
<th class="py-2 px-3">State</th>
|
||||||
|
<th class="py-2 px-3">Domains</th>
|
||||||
<th class="py-2 px-3">Hostnames</th>
|
<th class="py-2 px-3">Hostnames</th>
|
||||||
<th class="py-2 px-3">Checked</th>
|
<th class="py-2 px-3">Checked</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-800">
|
<tbody class="divide-y divide-slate-800">
|
||||||
{{ range $key, $ps := .Snapshot.Ports }}
|
{{ range $key, $ps := .Ports }}
|
||||||
<tr class="hover:bg-surface-800/50">
|
<tr class="hover:bg-surface-800/50">
|
||||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||||
{{ $key }}
|
{{ $key }}
|
||||||
@@ -187,6 +188,9 @@
|
|||||||
>
|
>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ joinStrings $ps.Domains ", " }}
|
||||||
|
</td>
|
||||||
<td class="py-2 px-3 text-slate-400 break-all">
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
{{ joinStrings $ps.Hostnames ", " }}
|
{{ joinStrings $ps.Hostnames ", " }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
|
|||||||
w.maybeSendTestNotification(ctx)
|
w.maybeSendTestNotification(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanupRemovedTargets exports cleanupRemovedTargets for testing.
|
||||||
|
func (w *Watcher) CleanupRemovedTargets() {
|
||||||
|
w.cleanupRemovedTargets()
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|||||||
@@ -165,3 +165,51 @@ func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
|
|||||||
t.Errorf("sent %v, want one message with %q", notifications, counts)
|
t.Errorf("sent %v, want one message with %q", notifications, counts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Port Change notification lists the configured apex domain and the
|
||||||
|
// hostname that resolve to the port's address on separate lines. The
|
||||||
|
// port checks read the saved hostname state and look nothing up, so the
|
||||||
|
// watcher has no resolver.
|
||||||
|
func TestPortChangeListsDomainsApartFromHostnames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{domain}
|
||||||
|
cfg.Hostnames = []string{host}
|
||||||
|
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
w.SetFirstRun(false)
|
||||||
|
|
||||||
|
// Both names resolve to ip1, whose port 443 the previous check
|
||||||
|
// found open. It is closed now.
|
||||||
|
for _, name := range []string{domain, host} {
|
||||||
|
deps.state.SetHostnameState(name, saved(
|
||||||
|
map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
key := ip1 + ":443"
|
||||||
|
deps.state.SetPortState(key, &state.PortState{
|
||||||
|
Open: true, Hostnames: []string{domain, host},
|
||||||
|
})
|
||||||
|
deps.portChecker.closed = true
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
title := "Port Change: " + key
|
||||||
|
want := `Domains: example.net
|
||||||
|
Hostnames: www.example.net
|
||||||
|
Address: 192.0.2.1:443
|
||||||
|
Port now closed`
|
||||||
|
|
||||||
|
got := deps.notifier.getNotifications()
|
||||||
|
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
|
||||||
|
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import (
|
|||||||
|
|
||||||
// TestRemovedTargetsLeaveTheState loads a state saved while a domain
|
// TestRemovedTargetsLeaveTheState loads a state saved while a domain
|
||||||
// and a hostname now removed from the configuration were still in it,
|
// and a hostname now removed from the configuration were still in it,
|
||||||
// and runs the port checks, which the first check after startup runs
|
// and runs the removal that Run does before the first check. The
|
||||||
// after its DNS checks. The removed names' domain, hostname and
|
// removed names' domain, hostname and certificate entries are gone,
|
||||||
// certificate entries are gone, the configured names' are kept, and
|
// the configured names' are kept, and nothing is notified. Nothing is
|
||||||
// nothing is notified. Nothing is looked up: the watcher has no
|
// looked up: the watcher has no resolver.
|
||||||
// resolver.
|
|
||||||
func TestRemovedTargetsLeaveTheState(t *testing.T) {
|
func TestRemovedTargetsLeaveTheState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -34,10 +33,6 @@ func TestRemovedTargetsLeaveTheState(t *testing.T) {
|
|||||||
deps.portChecker, deps.tlsChecker, deps.notifier,
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
// A state file is loaded, so changes are notified from the first
|
|
||||||
// check on.
|
|
||||||
w.SetFirstRun(false)
|
|
||||||
|
|
||||||
// The state a check of all four names saves, each name at ip1.
|
// The state a check of all four names saves, each name at ip1.
|
||||||
for _, name := range []string{domain, removedDomain} {
|
for _, name := range []string{domain, removedDomain} {
|
||||||
deps.state.SetDomainState(name, &state.DomainState{
|
deps.state.SetDomainState(name, &state.DomainState{
|
||||||
@@ -66,7 +61,7 @@ func TestRemovedTargetsLeaveTheState(t *testing.T) {
|
|||||||
t.Fatalf("loading the state: %v", err)
|
t.Fatalf("loading the state: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.CheckAllPorts(t.Context())
|
w.CleanupRemovedTargets()
|
||||||
|
|
||||||
snap := deps.state.GetSnapshot()
|
snap := deps.state.GetSnapshot()
|
||||||
|
|
||||||
|
|||||||
+73
-39
@@ -130,6 +130,7 @@ func (w *Watcher) Run(ctx context.Context) {
|
|||||||
"tlsInterval", w.config.TLSInterval.String(),
|
"tlsInterval", w.config.TLSInterval.String(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
w.cleanupRemovedTargets()
|
||||||
w.RunOnce(ctx)
|
w.RunOnce(ctx)
|
||||||
w.maybeSendTestNotification(ctx)
|
w.maybeSendTestNotification(ctx)
|
||||||
|
|
||||||
@@ -163,6 +164,31 @@ func (w *Watcher) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupRemovedTargets removes from the loaded state the domain,
|
||||||
|
// hostname and certificate entries of names no longer in the
|
||||||
|
// configuration, which changes only at a restart. Nothing is notified.
|
||||||
|
// A configured domain's own records are saved as a hostname entry under
|
||||||
|
// its name, which is kept.
|
||||||
|
func (w *Watcher) cleanupRemovedTargets() {
|
||||||
|
for _, name := range w.state.GetAllDomainNames() {
|
||||||
|
if !w.isDomain(name) {
|
||||||
|
w.state.DeleteDomainState(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range w.state.GetAllHostnames() {
|
||||||
|
if !w.isConfigured(name) {
|
||||||
|
w.state.DeleteHostnameState(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range w.state.GetAllCertificateKeys() {
|
||||||
|
if _, hostname := parseCertKey(key); !w.isConfigured(hostname) {
|
||||||
|
w.state.DeleteCertificateState(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RunOnce performs a single complete monitoring cycle.
|
// RunOnce performs a single complete monitoring cycle.
|
||||||
// DNS checks run first so that port and TLS checks use
|
// DNS checks run first so that port and TLS checks use
|
||||||
// freshly resolved IP addresses. Port checks run before
|
// freshly resolved IP addresses. Port checks run before
|
||||||
@@ -542,17 +568,50 @@ func (w *Watcher) detectHostnameChanges(
|
|||||||
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDomain reports whether name is a configured apex domain, whose own
|
||||||
|
// records are checked and saved as a hostname's are.
|
||||||
|
func (w *Watcher) isDomain(name string) bool {
|
||||||
|
return slices.Contains(w.config.Domains, name)
|
||||||
|
}
|
||||||
|
|
||||||
// nameLine is the line a notification about name's records starts with:
|
// nameLine is the line a notification about name's records starts with:
|
||||||
// "Domain: " and the name for a configured apex domain, whose own
|
// "Domain: " and the name for a configured apex domain, and
|
||||||
// records are checked as a hostname's are, and "Hostname: " otherwise.
|
// "Hostname: " otherwise.
|
||||||
func (w *Watcher) nameLine(name string) string {
|
func (w *Watcher) nameLine(name string) string {
|
||||||
if slices.Contains(w.config.Domains, name) {
|
if w.isDomain(name) {
|
||||||
return "Domain: " + name
|
return "Domain: " + name
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Hostname: " + name
|
return "Hostname: " + name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// portNameLines lists the names that resolve to a port's address, the
|
||||||
|
// configured apex domains on one line and the hostnames on the next,
|
||||||
|
// leaving out a line that would name nothing.
|
||||||
|
func (w *Watcher) portNameLines(names []string) string {
|
||||||
|
var domains, hostnames []string
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
if w.isDomain(name) {
|
||||||
|
domains = append(domains, name)
|
||||||
|
} else {
|
||||||
|
hostnames = append(hostnames, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
if len(domains) > 0 {
|
||||||
|
lines = append(lines, "Domains: "+strings.Join(domains, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hostnames) > 0 {
|
||||||
|
lines = append(lines, "Hostnames: "+strings.Join(hostnames, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -774,11 +833,9 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Remove port state entries that no longer have
|
// Phase 3: Remove port state entries that no longer have
|
||||||
// any hostname referencing them, the domain, hostname and
|
// any hostname referencing them, and certificate entries for
|
||||||
// certificate entries of names no longer configured, and
|
// an address their name no longer has.
|
||||||
// certificate entries for an address their name no longer has.
|
|
||||||
w.cleanupStalePorts(associations)
|
w.cleanupStalePorts(associations)
|
||||||
w.cleanupRemovedTargets()
|
|
||||||
w.cleanupStaleCertificates()
|
w.cleanupStaleCertificates()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,38 +920,16 @@ func (w *Watcher) cleanupStalePorts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupRemovedTargets removes the domain and hostname state entries
|
// cleanupStaleCertificates removes the certificate entries for an
|
||||||
// of names no longer in the configuration. A configured domain's own
|
// address their name no longer resolves to. An entry saved for a name
|
||||||
// records are saved as a hostname entry under its name, which is kept.
|
// none of whose nameservers answered is kept: that name's addresses are
|
||||||
func (w *Watcher) cleanupRemovedTargets() {
|
// not known, not gone.
|
||||||
for _, name := range w.state.GetAllDomainNames() {
|
|
||||||
if !slices.Contains(w.config.Domains, name) {
|
|
||||||
w.state.DeleteDomainState(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range w.state.GetAllHostnames() {
|
|
||||||
if !w.isConfigured(name) {
|
|
||||||
w.state.DeleteHostnameState(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupStaleCertificates removes the certificate entries of names no
|
|
||||||
// longer configured, and those for an address their name no longer
|
|
||||||
// resolves to. An entry saved for a configured name none of whose
|
|
||||||
// nameservers answered is kept: that name's addresses are not known,
|
|
||||||
// not gone.
|
|
||||||
func (w *Watcher) cleanupStaleCertificates() {
|
func (w *Watcher) cleanupStaleCertificates() {
|
||||||
for _, key := range w.state.GetAllCertificateKeys() {
|
for _, key := range w.state.GetAllCertificateKeys() {
|
||||||
ip, hostname := parseCertKey(key)
|
ip, hostname := parseCertKey(key)
|
||||||
|
|
||||||
if w.isConfigured(hostname) &&
|
if slices.Contains(w.collectIPs(hostname), ip) ||
|
||||||
slices.Contains(w.collectIPs(hostname), ip) {
|
w.noNameserverAnswered(hostname) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if w.noNameserverAnswered(hostname) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,8 +952,7 @@ func parseCertKey(key string) (string, string) {
|
|||||||
|
|
||||||
// isConfigured reports whether name is a configured domain or hostname.
|
// isConfigured reports whether name is a configured domain or hostname.
|
||||||
func (w *Watcher) isConfigured(name string) bool {
|
func (w *Watcher) isConfigured(name string) bool {
|
||||||
return slices.Contains(w.config.Domains, name) ||
|
return w.isDomain(name) || slices.Contains(w.config.Hostnames, name)
|
||||||
slices.Contains(w.config.Hostnames, name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// noNameserverAnswered reports whether name is a configured domain or
|
// noNameserverAnswered reports whether name is a configured domain or
|
||||||
@@ -1013,8 +1047,8 @@ func (w *Watcher) checkSinglePort(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hosts: %s\nAddress: %s\nPort now %s",
|
"%s\nAddress: %s\nPort now %s",
|
||||||
strings.Join(hostnames, ", "), key, stateStr,
|
w.portNameLines(hostnames), key, stateStr,
|
||||||
)
|
)
|
||||||
|
|
||||||
w.notify.SendNotification(
|
w.notify.SendNotification(
|
||||||
|
|||||||
Reference in New Issue
Block a user