diff --git a/README.md b/README.md index f073613..6ea6eab 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,14 @@ notification endpoint set, changes show only on the dashboard; see - Certificate CN, issuer, or SANs changed (replacement detected, reports old and new CN and issuer). - 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. +- 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 @@ -294,8 +300,10 @@ clears them. 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 of addresses no configured name has. -- 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. +- Each port check also removes, without a notification, the certificate entries + 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 diff --git a/TODO.md b/TODO.md index d856eae..03e9ee3 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # 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 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 diff --git a/internal/watcher/removed_test.go b/internal/watcher/removed_test.go index a7c51e2..fe5e22c 100644 --- a/internal/watcher/removed_test.go +++ b/internal/watcher/removed_test.go @@ -3,6 +3,7 @@ package watcher_test import ( "maps" "slices" + "strings" "testing" "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) + } + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index f7346f9..3def3b0 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -978,7 +978,8 @@ func (w *Watcher) checkAllPorts(ctx context.Context) { // Phase 3: Remove port state entries that no longer have // 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.cleanupStaleCertificates() } @@ -1065,13 +1066,21 @@ 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. +// address whose port 443 was found closed, and for an address their +// name no longer resolves to. An entry saved for a name none of whose +// nameservers answered is kept unless its port is closed: that name's +// addresses are not known, not gone. func (w *Watcher) cleanupStaleCertificates() { for _, key := range w.state.GetAllCertificateKeys() { 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) || w.noNameserverAnswered(hostname) { continue