Compare commits
1
Commits
next
...
1b617847eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b617847eb |
@@ -125,8 +125,11 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
### TCP Port Monitoring
|
### TCP Port Monitoring
|
||||||
|
|
||||||
- For every configured domain and hostname, constructs a deduplicated list of
|
- For every configured domain and hostname, constructs a deduplicated list of
|
||||||
all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution
|
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
|
||||||
across all authoritative nameservers.
|
nameservers returned. When they returned a CNAME and no address, the CNAME
|
||||||
|
chain is followed and the addresses at its end are used; a change in those
|
||||||
|
sends no notification of its own. When the chain cannot be followed, the
|
||||||
|
addresses the last check found at its end are used.
|
||||||
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
||||||
- Every **1 hour**, re-checks all ports.
|
- Every **1 hour**, re-checks all ports.
|
||||||
- Any change in port availability triggers a notification:
|
- Any change in port availability triggers a notification:
|
||||||
@@ -389,8 +392,11 @@ This approach ensures:
|
|||||||
servers.
|
servers.
|
||||||
- Visibility into the full delegation chain.
|
- Visibility into the full delegation chain.
|
||||||
|
|
||||||
For hostname monitoring, the resolver follows CNAME chains (with a depth limit
|
A watched name's records are stored as its nameservers return them, CNAME
|
||||||
to prevent loops) before collecting terminal A/AAAA records.
|
included. When they return a CNAME and no address, the CNAME chain is followed
|
||||||
|
(with a depth limit to prevent loops) to the A and AAAA records at its end, and
|
||||||
|
the port and TLS checks use those addresses. Nameservers' addresses are found
|
||||||
|
the same way.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -478,6 +484,10 @@ nameservers, has status `error`, empty `records`, and the reason in `error`.
|
|||||||
resolves to. A state file without it loads, and the next check fills it in
|
resolves to. A state file without it loads, and the next check fills it in
|
||||||
without a notification.
|
without a notification.
|
||||||
|
|
||||||
|
`cnameAddresses` lists the sorted addresses at the end of a hostname's CNAME
|
||||||
|
chain, found when its nameservers answered with a CNAME and no address. It is
|
||||||
|
left out otherwise.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-01: a watched name whose nameservers answer with a CNAME and no
|
||||||
|
address gets port and TLS checks at the end of its CNAME chain (closes #203).
|
||||||
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
||||||
servers, not whichever of its own servers answered first (closes #200).
|
servers, not whichever of its own servers answered first (closes #200).
|
||||||
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
|
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ type NameserverRecordState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HostnameState holds per-nameserver monitoring state for a hostname.
|
// HostnameState holds per-nameserver monitoring state for a hostname.
|
||||||
|
// CNAMEAddresses holds the sorted addresses at the end of the name's
|
||||||
|
// CNAME chain, found when its nameservers answered with a CNAME and no
|
||||||
|
// address; it is empty otherwise.
|
||||||
type HostnameState struct {
|
type HostnameState struct {
|
||||||
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
|
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
|
||||||
|
CNAMEAddresses []string `json:"cnameAddresses,omitempty"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package watcher_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/livednstest"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cnameHost is a CNAME into another zone: its nameservers answer with
|
||||||
|
// the CNAME and no address.
|
||||||
|
const cnameHost = "www.python.org"
|
||||||
|
|
||||||
|
// TestCNAMEIntoAnotherZonePortAndTLSChecks checks cnameHost against
|
||||||
|
// live DNS. Its port and TLS checks must use the addresses at the end
|
||||||
|
// of its CNAME chain.
|
||||||
|
func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{cnameHost}
|
||||||
|
|
||||||
|
deps := runChecks(t, cfg, nil, nil)
|
||||||
|
|
||||||
|
snap := deps.state.GetSnapshot()
|
||||||
|
hs := snap.Hostnames[cnameHost]
|
||||||
|
|
||||||
|
if len(hs.CNAMEAddresses) == 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"%s: no addresses saved from following its CNAME; if it "+
|
||||||
|
"is no longer a CNAME into another zone, this test "+
|
||||||
|
"needs another name",
|
||||||
|
cnameHost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range hs.CNAMEAddresses {
|
||||||
|
ps, ok := snap.Ports[ip+":443"]
|
||||||
|
if !ok || !slices.Contains(ps.Hostnames, cnameHost) {
|
||||||
|
t.Errorf("no port state for %s at %s:443", cnameHost, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
certKey := ip + ":443:" + cnameHost
|
||||||
|
if _, ok := snap.Certificates[certKey]; !ok {
|
||||||
|
t.Errorf("no certificate state %s", certKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCNAMEThatCannotBeFollowedKeepsPrevious gives a name under
|
||||||
|
// .invalid, whose lookup fails, answers with a CNAME and no address.
|
||||||
|
// The addresses the previous check saved from following its CNAME are
|
||||||
|
// kept.
|
||||||
|
func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const name = "www.example.invalid"
|
||||||
|
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
current := hostnameState(map[string]map[string][]string{
|
||||||
|
nsA: {"CNAME": {"target.example.invalid."}},
|
||||||
|
})
|
||||||
|
prev := &state.HostnameState{CNAMEAddresses: []string{oldIP}}
|
||||||
|
|
||||||
|
// The result is the same whether or not live DNS answers, so the
|
||||||
|
// lookup is not retried.
|
||||||
|
_ = livednstest.Run(func(ctx context.Context) error {
|
||||||
|
w.ResolveCNAMEAddresses(ctx, name, current, prev)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
|
||||||
|
t.Errorf(
|
||||||
|
"saved %v, want %v",
|
||||||
|
current.CNAMEAddresses, prev.CNAMEAddresses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,15 @@ func (w *Watcher) ResolveNameserverAddresses(
|
|||||||
return w.resolveNameserverAddresses(ctx, nameservers, prev)
|
return w.resolveNameserverAddresses(ctx, nameservers, prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveCNAMEAddresses exports resolveCNAMEAddresses for testing.
|
||||||
|
func (w *Watcher) ResolveCNAMEAddresses(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
current, prev *state.HostnameState,
|
||||||
|
) {
|
||||||
|
w.resolveCNAMEAddresses(ctx, hostname, current, prev)
|
||||||
|
}
|
||||||
|
|
||||||
// DetectNSAddressChanges exports detectNSAddressChanges for testing.
|
// DetectNSAddressChanges exports detectNSAddressChanges for testing.
|
||||||
func (w *Watcher) DetectNSAddressChanges(
|
func (w *Watcher) DetectNSAddressChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
+58
-22
@@ -256,28 +256,9 @@ func (w *Watcher) checkDomain(
|
|||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Also look up A/AAAA records for the apex domain so that
|
// The apex domain's records are also checked as a hostname's, so
|
||||||
// port and TLS checks (which read HostnameState) can find
|
// that the port and TLS checks find its addresses.
|
||||||
// the domain's IP addresses.
|
w.checkHostname(ctx, domain)
|
||||||
results, err := w.resolver.LookupAllRecords(ctx, domain)
|
|
||||||
if err != nil {
|
|
||||||
w.log.Error(
|
|
||||||
"failed to lookup records for domain",
|
|
||||||
"domain", domain,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newState := buildHostnameState(results, now)
|
|
||||||
|
|
||||||
prevHS, hasPrevHS := w.state.GetHostnameState(domain)
|
|
||||||
if hasPrevHS && !w.firstRun {
|
|
||||||
w.detectHostnameChanges(ctx, domain, prevHS, newState)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.state.SetHostnameState(domain, newState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) detectNSChanges(
|
func (w *Watcher) detectNSChanges(
|
||||||
@@ -405,6 +386,9 @@ func (w *Watcher) checkHostname(
|
|||||||
newState := buildHostnameState(results, time.Now().UTC())
|
newState := buildHostnameState(results, time.Now().UTC())
|
||||||
|
|
||||||
prev, hasPrev := w.state.GetHostnameState(hostname)
|
prev, hasPrev := w.state.GetHostnameState(hostname)
|
||||||
|
|
||||||
|
w.resolveCNAMEAddresses(ctx, hostname, newState, prev)
|
||||||
|
|
||||||
if hasPrev && !w.firstRun {
|
if hasPrev && !w.firstRun {
|
||||||
w.detectHostnameChanges(ctx, hostname, prev, newState)
|
w.detectHostnameChanges(ctx, hostname, prev, newState)
|
||||||
}
|
}
|
||||||
@@ -412,6 +396,51 @@ func (w *Watcher) checkHostname(
|
|||||||
w.state.SetHostnameState(hostname, newState)
|
w.state.SetHostnameState(hostname, newState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveCNAMEAddresses saves in current the addresses at the end of
|
||||||
|
// hostname's CNAME chain, when the nameservers' answers in current hold
|
||||||
|
// a CNAME and no address. ResolveIPAddresses looks the name up again
|
||||||
|
// and follows the chain. When it fails, as when no nameserver of a zone
|
||||||
|
// in the chain answers, the addresses saved in prev, which may be nil,
|
||||||
|
// are kept.
|
||||||
|
func (w *Watcher) resolveCNAMEAddresses(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
current, prev *state.HostnameState,
|
||||||
|
) {
|
||||||
|
hasCNAME := false
|
||||||
|
|
||||||
|
for _, nsState := range current.RecordsByNameserver {
|
||||||
|
if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nsState.Records["CNAME"]) > 0 {
|
||||||
|
hasCNAME = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasCNAME {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := w.resolver.ResolveIPAddresses(ctx, hostname)
|
||||||
|
if err != nil {
|
||||||
|
w.log.Error(
|
||||||
|
"failed to follow CNAME",
|
||||||
|
"hostname", hostname,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
if prev != nil {
|
||||||
|
current.CNAMEAddresses = prev.CNAMEAddresses
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current.CNAMEAddresses = ips
|
||||||
|
}
|
||||||
|
|
||||||
// buildHostnameState saves each nameserver's response. A nameserver
|
// buildHostnameState saves each nameserver's response. A nameserver
|
||||||
// that answered, even with NXDOMAIN or no records, is saved as ok; one
|
// that answered, even with NXDOMAIN or no records, is saved as ok; one
|
||||||
// that timed out or failed is saved as error with the reason, and its
|
// that timed out or failed is saved as error with the reason, and its
|
||||||
@@ -751,6 +780,9 @@ func (w *Watcher) noNameserverAnswered(name string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectIPs returns the addresses saved for hostname: those in its
|
||||||
|
// nameservers' A and AAAA records, and those at the end of its CNAME
|
||||||
|
// chain.
|
||||||
func (w *Watcher) collectIPs(hostname string) []string {
|
func (w *Watcher) collectIPs(hostname string) []string {
|
||||||
hs, ok := w.state.GetHostnameState(hostname)
|
hs, ok := w.state.GetHostnameState(hostname)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -769,6 +801,10 @@ func (w *Watcher) collectIPs(hostname string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, ip := range hs.CNAMEAddresses {
|
||||||
|
ipSet[ip] = true
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]string, 0, len(ipSet))
|
result := make([]string, 0, len(ipSet))
|
||||||
for ip := range ipSet {
|
for ip := range ipSet {
|
||||||
result = append(result, ip)
|
result = append(result, ip)
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ func lookupNameservers(t *testing.T, domain string) []string {
|
|||||||
return nameservers
|
return nameservers
|
||||||
}
|
}
|
||||||
|
|
||||||
// addresses returns the A and AAAA values saved for a hostname.
|
// addresses returns the A and AAAA values saved for a hostname, and the
|
||||||
|
// addresses saved at the end of its CNAME chain.
|
||||||
func addresses(hs *state.HostnameState) []string {
|
func addresses(hs *state.HostnameState) []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
|
|
||||||
@@ -324,7 +325,7 @@ func addresses(hs *state.HostnameState) []string {
|
|||||||
ips = append(ips, nsState.Records["AAAA"]...)
|
ips = append(ips, nsState.Records["AAAA"]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ips
|
return append(ips, hs.CNAMEAddresses...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertNotified checks that a notification with this title and
|
// assertNotified checks that a notification with this title and
|
||||||
|
|||||||
Reference in New Issue
Block a user