watcher: a name removed from the targets leaves the state (closes #223)
check / check (push) Canceled after 0s

At startup, before the first check, Run removes from the loaded state
the domain, hostname and certificate entries of names no longer in
DNSWATCHER_TARGETS, so the dashboard, /api/v1/status and the startup
notification count only configured names. A configured domain's own
records, saved as a hostname entry under its name, are kept. Nothing is
notified. Each port check, next to the removal of stale port entries,
now also removes the certificate entries for an address a name no
longer resolves to, except while none of its nameservers answered, as
port entries already were.

Model: opus-5-5
This commit is contained in:
2026-10-02 08:48:47 +00:00
parent 9b524e9d63
commit c2736241ee
6 changed files with 307 additions and 4 deletions
+65 -3
View File
@@ -130,6 +130,7 @@ func (w *Watcher) Run(ctx context.Context) {
"tlsInterval", w.config.TLSInterval.String(),
)
w.cleanupRemovedTargets()
w.RunOnce(ctx)
w.maybeSendTestNotification(ctx)
@@ -200,6 +201,31 @@ func (w *Watcher) detectFirstRun() {
}
}
// 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)
}
}
}
// runDNSChecks performs DNS resolution for all configured domains
// and hostnames, updating state with freshly resolved records.
// This must complete before port or TLS checks run so those
@@ -807,8 +833,10 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
}
// Phase 3: Remove port state entries that no longer have
// any hostname referencing them.
// any hostname referencing them, and certificate entries for
// an address their name no longer has.
w.cleanupStalePorts(associations)
w.cleanupStaleCertificates()
}
// buildPortAssociations constructs a map from IP:port keys to
@@ -892,11 +920,45 @@ func (w *Watcher) cleanupStalePorts(
}
}
// cleanupStaleCertificates removes the certificate entries for an
// address their name no longer resolves to. An entry saved for a name
// none of whose nameservers answered is kept: that name's addresses are
// not known, not gone.
func (w *Watcher) cleanupStaleCertificates() {
for _, key := range w.state.GetAllCertificateKeys() {
ip, hostname := parseCertKey(key)
if slices.Contains(w.collectIPs(hostname), ip) ||
w.noNameserverAnswered(hostname) {
continue
}
w.state.DeleteCertificateState(key)
}
}
// parseCertKey splits an "ip:port:hostname" certificate key into its
// address and hostname.
func parseCertKey(key string) (string, string) {
lastColon := strings.LastIndex(key, ":")
if lastColon < 0 {
return "", key
}
ip, _ := parsePortKey(key[:lastColon])
return ip, key[lastColon+1:]
}
// isConfigured reports whether name is a configured domain or hostname.
func (w *Watcher) isConfigured(name string) bool {
return w.isDomain(name) || slices.Contains(w.config.Hostnames, name)
}
// noNameserverAnswered reports whether name is a configured domain or
// hostname and none of its nameservers answered on its last check.
func (w *Watcher) noNameserverAnswered(name string) bool {
if !slices.Contains(w.config.Hostnames, name) &&
!slices.Contains(w.config.Domains, name) {
if !w.isConfigured(name) {
return false
}