1 Commits
Author SHA1 Message Date
sneak 83b68923d9 state: a state file without a section loads it empty (closes #267)
check / check (push) Waiting to run
Load fills in each of domains, hostnames, ports and certificates that
the file leaves out or holds as null with an empty map. Before, such a
section loaded as a nil map, and the first check's write into it
panicked, so a state file holding {} stopped the watcher at every
start.

A state test loads {} and a file missing each section in turn, and
checks that every section is an empty map. A watcher test loads {}, and
a file without the certificates section, runs the first check against
live DNS, and checks that it saved domain, hostname, port and
certificate entries.

Model: opus-5-5
2026-10-13 18:01:03 +00:00
6 changed files with 32 additions and 211 deletions
+3 -17
View File
@@ -223,14 +223,8 @@ 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, or connection refused when port 443 closed after the port check timeout, connection refused after previously succeeding).
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
@@ -300,10 +294,8 @@ 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, without a notification, the certificate entries - Each port check also removes the certificate entries for an address their name
for an address whose port 443 it found closed, and those for an address their no longer resolves to, except while none of the name's nameservers answer.
name no longer resolves to, except while none of the name's nameservers
answer.
### Web Dashboard ### Web Dashboard
@@ -666,12 +658,6 @@ for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
earlier check saved one. A state file without it loads, and the first check earlier check saved one. A state file without it loads, and the first check
after that saves it without a notification. after that saves it without a notification.
A port entry's key is its address and port, and a certificate entry's key its
address, port 443 and hostname, joined by colons. An IPv6 address is written in
brackets in these keys, as in `[2001:db8::1]:443` and
`[2001:db8::1]:443:www.example.com`. `/api/v1/status` and the dashboard show the
same keys.
A port entry's `hostnames` lists every name that resolves to its address, A port entry's `hostnames` lists every name that resolves to its address,
domains included. A port entry in the older format, with one `hostname` instead domains included. A port entry in the older format, with one `hostname` instead
of the `hostnames` list, loads as a list of that one name. of the `hostnames` list, loads as a list of that one name.
+6 -10
View File
@@ -10,21 +10,15 @@
# Status # Status
pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0 pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked
milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Next Step # Next Step
1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66 trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-13: an IPv6 address is written in brackets before its port, as in
`[2001:db8::1]:443`, in keys and Port Change notifications (closes #270).
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
Status says nothing about git tags (closes #281).
- 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
@@ -205,4 +199,6 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Future Steps # Future Steps
- DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59 - 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
-83
View File
@@ -1,83 +0,0 @@
package watcher_test
import (
"maps"
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// TestIPv6AddressInBrackets runs the port checks, not the watcher's
// first, on hostname state built here for a hostname at an IPv6 address
// whose port 443 the previous check found closed. It is open now. Then
// it runs the TLS checks and the port checks again. The port and
// certificate keys and the Port Change notification write the address
// in brackets, so its last group cannot be read as the port, and the
// second port checks keep the certificate entry, whose address they read
// back from its key. When a last port check finds port 443 closed, the
// certificate entry is removed. Nothing is looked up.
func TestIPv6AddressInBrackets(t *testing.T) {
t.Parallel()
const aaaaType = "AAAA"
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)
deps.state.SetHostnameState(host, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{aaaaType: {"2001:db8::1"}}),
},
))
deps.state.SetPortState("[2001:db8::1]:443", &state.PortState{
Hostnames: []string{host},
})
w.CheckAllPorts(t.Context())
w.RunTLSChecks(t.Context())
w.CheckAllPorts(t.Context())
snap := deps.state.GetSnapshot()
gotPorts := slices.Sorted(maps.Keys(snap.Ports))
wantPorts := []string{"[2001:db8::1]:443", "[2001:db8::1]:80"}
if !slices.Equal(gotPorts, wantPorts) {
t.Errorf("port entries %v, want %v", gotPorts, wantPorts)
}
gotCerts := slices.Sorted(maps.Keys(snap.Certificates))
wantCerts := []string{"[2001:db8::1]:443:www.example.net"}
if !slices.Equal(gotCerts, wantCerts) {
t.Errorf("certificate entries %v, want %v", gotCerts, wantCerts)
}
title := "Port Change: [2001:db8::1]:443"
want := `Hostnames: www.example.net
Address: [2001:db8::1]:443
Port now open`
got := deps.notifier.getNotifications()
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
}
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
gotCerts = slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
if len(gotCerts) != 0 {
t.Errorf("certificate entries %v with port 443 closed, want none", gotCerts)
}
}
+3 -70
View File
@@ -2,9 +2,7 @@ package watcher_test
import ( import (
"maps" "maps"
"net"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
@@ -174,7 +172,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
"A": {ip1}, "AAAA": {ip6}, "A": {ip1}, "AAAA": {ip6},
}), }),
}), }),
[]string{ip1 + ":443:" + host, "[" + ip6 + "]:443:" + host}, []string{ip1 + ":443:" + host, ip6 + ":443:" + host},
}, },
{ {
"no nameserver answered", "no nameserver answered",
@@ -184,7 +182,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
[]string{ []string{
ip1 + ":443:" + host, ip1 + ":443:" + host,
ip2 + ":443:" + host, ip2 + ":443:" + host,
"[" + ip6 + "]:443:" + host, ip6 + ":443:" + host,
}, },
}, },
} }
@@ -207,8 +205,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
for _, ip := range []string{ip1, ip2, ip6} { for _, ip := range []string{ip1, ip2, ip6} {
deps.state.SetCertificateState( deps.state.SetCertificateState(
net.JoinHostPort(ip, "443")+":"+host, ip+":443:"+host, &state.CertificateState{Status: "ok"},
&state.CertificateState{Status: "ok"},
) )
} }
@@ -225,67 +222,3 @@ 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)
}
}
}
+16 -24
View File
@@ -6,10 +6,8 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"maps" "maps"
"net"
"slices" "slices"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -980,8 +978,7 @@ 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 whose port 443 is closed or that their name no // an address their name no longer has.
// longer has.
w.cleanupStalePorts(associations) w.cleanupStalePorts(associations)
w.cleanupStaleCertificates() w.cleanupStaleCertificates()
} }
@@ -1002,7 +999,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
ips := w.collectIPs(name) ips := w.collectIPs(name)
for _, ip := range ips { for _, ip := range ips {
for _, port := range monitoredPorts { for _, port := range monitoredPorts {
key := net.JoinHostPort(ip, strconv.Itoa(port)) key := fmt.Sprintf("%s:%d", ip, port)
if assoc[key] == nil { if assoc[key] == nil {
assoc[key] = make(map[string]bool) assoc[key] = make(map[string]bool)
} }
@@ -1027,15 +1024,18 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
return result return result
} }
// parsePortKey splits an "ip:port" key, written by net.JoinHostPort, // parsePortKey splits an "ip:port" key into its components.
// into its components.
func parsePortKey(key string) (string, int) { func parsePortKey(key string) (string, int) {
ip, port, err := net.SplitHostPort(key) lastColon := strings.LastIndex(key, ":")
if err != nil { if lastColon < 0 {
return key, 0 return key, 0
} }
p, err := strconv.Atoi(port) ip := key[:lastColon]
var p int
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
if err != nil { if err != nil {
return ip, 0 return ip, 0
} }
@@ -1065,21 +1065,13 @@ func (w *Watcher) cleanupStalePorts(
} }
// cleanupStaleCertificates removes the certificate entries for an // cleanupStaleCertificates removes the certificate entries for an
// address whose port 443 was found closed, and for an address their // address their name no longer resolves to. An entry saved for a name
// name no longer resolves to. An entry saved for a name none of whose // none of whose nameservers answered is kept: that name's addresses are
// nameservers answered is kept unless its port is closed: that name's // not known, not gone.
// 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(net.JoinHostPort(ip, strconv.Itoa(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
@@ -1188,7 +1180,7 @@ func (w *Watcher) checkSinglePort(
return return
} }
key := net.JoinHostPort(ip, strconv.Itoa(port)) key := fmt.Sprintf("%s:%d", ip, port)
now := time.Now().UTC() now := time.Now().UTC()
prev, hasPrev := w.state.GetPortState(key) prev, hasPrev := w.state.GetPortState(key)
@@ -1250,7 +1242,7 @@ func (w *Watcher) checkTLSForHostname(
ips := w.collectIPs(hostname) ips := w.collectIPs(hostname)
for _, ip := range ips { for _, ip := range ips {
portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) portKey := fmt.Sprintf("%s:%d", ip, tlsPort)
ps, ok := w.state.GetPortState(portKey) ps, ok := w.state.GetPortState(portKey)
if !ok || !ps.Open { if !ok || !ps.Open {
@@ -1274,7 +1266,7 @@ func (w *Watcher) checkTLSCert(
return return
} }
certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
now := time.Now().UTC() now := time.Now().UTC()
prev, hasPrev := w.state.GetCertificateState(certKey) prev, hasPrev := w.state.GetCertificateState(certKey)
+4 -7
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"os" "os"
"slices" "slices"
"strings" "strings"
@@ -825,8 +824,7 @@ func TestPortStateChange(t *testing.T) {
hs, _ := deps.state.GetHostnameState(testHost) hs, _ := deps.state.GetHostnameState(testHost)
assertNotified( assertNotified(
t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"), t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning",
"warning",
) )
} }
@@ -1033,12 +1031,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
// Port and TLS checks must use the addresses this check found. // Port and TLS checks must use the addresses this check found.
for _, ip := range addresses(snap.Hostnames[testHost]) { for _, ip := range addresses(snap.Hostnames[testHost]) {
portKey := net.JoinHostPort(ip, "80") if _, ok := snap.Ports[ip+":80"]; !ok {
if _, ok := snap.Ports[portKey]; !ok { t.Error("port check used stale DNS: missing " + ip + ":80")
t.Error("port check used stale DNS: missing " + portKey)
} }
certKey := net.JoinHostPort(ip, "443") + ":" + testHost certKey := ip + ":443:" + testHost
if _, ok := snap.Certificates[certKey]; !ok { if _, ok := snap.Certificates[certKey]; !ok {
t.Error("TLS check used stale DNS: missing " + certKey) t.Error("TLS check used stale DNS: missing " + certKey)
} }