watcher: a port check that finds 443 closed removes its certificate entries (closes #265)
check / check (push) Canceled after 0s

The TLS check runs only on an open port 443, and the certificate
cleanup removed an entry only when its name stopped resolving to the
address, so an entry stayed ok for as long as its port was closed.
The cleanup after the port checks now also removes the entries of an
address whose port 443 was found closed. Nothing is notified for the
removal; the Port Change already says the port closed. When the port
opens again, the next TLS check saves the certificate as new, with no
change or recovery notification. The README says both.

Judgement call: the entry goes even while none of the name's
nameservers answer, since the port check reached the address itself.

Model: opus-5-5
This commit was merged in pull request #280.
This commit is contained in:
2026-10-13 21:27:34 +02:00
parent 9e6871817a
commit cb89e4f8cf
4 changed files with 91 additions and 7 deletions
+11 -3
View File
@@ -223,8 +223,14 @@ notification endpoint set, changes show only on the dashboard; see
- Certificate CN, issuer, or SANs changed (replacement detected, reports old - Certificate CN, issuer, or SANs changed (replacement detected, reports old
and new CN and issuer). and new CN and issuer).
- TLS connection failure to a previously-reachable IP:443 (handshake error, - TLS connection failure to a previously-reachable IP:443 (handshake error,
timeout, connection refused after previously succeeding). timeout, or connection refused when port 443 closed after the port check
that found it open).
- TLS recovery: a previously-failing IP:443 now completes a handshake again. - TLS recovery: a previously-failing IP:443 now completes a handshake again.
- A port check that finds port 443 closed removes the certificate entries for
that IP address, without a notification of its own: the port change
notification reports the closed port. When the port opens again, the next TLS
check saves the certificate as new, without a certificate change or recovery
notification.
### Notifications ### Notifications
@@ -294,8 +300,10 @@ clears them.
port entry left with no name goes, so the dashboard and `/api/v1/status` no port entry left with no name goes, so the dashboard and `/api/v1/status` no
longer list or count it. The first check's port checks remove the port entries longer list or count it. The first check's port checks remove the port entries
of addresses no configured name has. of addresses no configured name has.
- Each port check also removes the certificate entries for an address their name - Each port check also removes, without a notification, the certificate entries
no longer resolves to, except while none of the name's nameservers answer. for an address whose port 443 it found closed, and those for an address their
name no longer resolves to, except while none of the name's nameservers
answer.
### Web Dashboard ### Web Dashboard
+2
View File
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-13: a port check that finds port 443 closed removes that address's
certificate entries, so none is left shown as `ok` (closes #265).
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with - 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
that section empty; the first check no longer crashes on it (closes #267). that section empty; the first check no longer crashes on it (closes #267).
- 2026-10-13: a stop before the first scan completes sends no startup - 2026-10-13: a stop before the first scan completes sends no startup
+65
View File
@@ -3,6 +3,7 @@ package watcher_test
import ( import (
"maps" "maps"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
@@ -222,3 +223,67 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
}) })
} }
} }
// TestCertificateStateForAPortClosed runs the port checks on hostname
// and port state built here for a configured hostname at ip1, whose
// port 443 the previous check found open and whose certificate entry
// is ok. The port is closed now: the certificate entry is removed, and
// only the Port Change is notified. When the port opens again, the next
// TLS check saves the certificate as new and notifies nothing about it.
// Nothing is looked up.
func TestCertificateStateForAPortClosed(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
certKey := ip1 + ":443:" + host
deps.state.SetHostnameState(host, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
deps.state.SetPortState(ip1+":443", &state.PortState{
Open: true, Hostnames: []string{host},
})
deps.state.SetCertificateState(certKey, &state.CertificateState{
CommonName: host, Status: "ok",
})
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
if _, ok := deps.state.GetCertificateState(certKey); ok {
t.Errorf("certificate entry %s kept with port 443 closed", certKey)
}
sent := deps.notifier.getNotifications()
if title := "Port Change: " + ip1 + ":443"; len(sent) != 1 ||
sent[0].Title != title {
t.Errorf("sent %v, want one %q", sent, title)
}
deps.portChecker.closed = false
w.CheckAllPorts(t.Context())
w.RunTLSChecks(t.Context())
cert, ok := deps.state.GetCertificateState(certKey)
if !ok || cert.Status != "ok" {
t.Errorf("certificate entry %s after port 443 opened: %+v", certKey, cert)
}
for _, n := range deps.notifier.getNotifications() {
if strings.HasPrefix(n.Title, "TLS") {
t.Errorf("sent %v, want no TLS notification", n)
}
}
}
+13 -4
View File
@@ -978,7 +978,8 @@ 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, and certificate entries for // any hostname referencing them, and certificate entries for
// an address their name no longer has. // an address whose port 443 is closed or that their name no
// longer has.
w.cleanupStalePorts(associations) w.cleanupStalePorts(associations)
w.cleanupStaleCertificates() w.cleanupStaleCertificates()
} }
@@ -1065,13 +1066,21 @@ func (w *Watcher) cleanupStalePorts(
} }
// cleanupStaleCertificates removes the certificate entries for an // cleanupStaleCertificates removes the certificate entries for an
// address their name no longer resolves to. An entry saved for a name // address whose port 443 was found closed, and for an address their
// none of whose nameservers answered is kept: that name's addresses are // name no longer resolves to. An entry saved for a name none of whose
// not known, not gone. // nameservers answered is kept unless its port is closed: 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)
ps, ok := w.state.GetPortState(fmt.Sprintf("%s:%d", ip, tlsPort))
if ok && !ps.Open {
w.state.DeleteCertificateState(key)
continue
}
if slices.Contains(w.collectIPs(hostname), ip) || if slices.Contains(w.collectIPs(hostname), ip) ||
w.noNameserverAnswered(hostname) { w.noNameserverAnswered(hostname) {
continue continue