watcher: a port check that finds 443 closed removes its certificate entries (closes #265) #280
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user