From bd22c1ce97333dada2b748aaef5fbc5af1d468fa Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 19:32:21 +0000 Subject: [PATCH] watcher: an IPv6 address is written in brackets before its port (closes #270) Port and certificate keys were joined as address:port, so an IPv6 address ran into its port: 2a01:4f8:2200:fd11::1:80 is also an address on its own. Keys are now joined with net.JoinHostPort and split with net.SplitHostPort, giving [2a01:4f8:2200:fd11::1]:80 in the state file, /api/v1/status, the dashboard and Port Change notifications; an IPv4 address is unchanged. A certificate key still splits at its last colon, since a hostname has none. Tests that built IPv6 keys by hand now use the bracketed form. Model: opus-5-5 --- README.md | 6 +++ TODO.md | 2 + internal/watcher/ipv6_test.go | 73 ++++++++++++++++++++++++++++++++ internal/watcher/removed_test.go | 8 ++-- internal/watcher/watcher.go | 25 ++++++----- internal/watcher/watcher_test.go | 11 +++-- 6 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 internal/watcher/ipv6_test.go diff --git a/README.md b/README.md index 6ea6eab..1bcbf1c 100644 --- a/README.md +++ b/README.md @@ -666,6 +666,12 @@ 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 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, 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. diff --git a/TODO.md b/TODO.md index 03e9ee3..242387c 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: 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: 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 diff --git a/internal/watcher/ipv6_test.go b/internal/watcher/ipv6_test.go new file mode 100644 index 0000000..d23f766 --- /dev/null +++ b/internal/watcher/ipv6_test.go @@ -0,0 +1,73 @@ +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. 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) + } +} diff --git a/internal/watcher/removed_test.go b/internal/watcher/removed_test.go index fe5e22c..2549b18 100644 --- a/internal/watcher/removed_test.go +++ b/internal/watcher/removed_test.go @@ -2,6 +2,7 @@ package watcher_test import ( "maps" + "net" "slices" "strings" "testing" @@ -173,7 +174,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) { "A": {ip1}, "AAAA": {ip6}, }), }), - []string{ip1 + ":443:" + host, ip6 + ":443:" + host}, + []string{ip1 + ":443:" + host, "[" + ip6 + "]:443:" + host}, }, { "no nameserver answered", @@ -183,7 +184,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) { []string{ ip1 + ":443:" + host, ip2 + ":443:" + host, - ip6 + ":443:" + host, + "[" + ip6 + "]:443:" + host, }, }, } @@ -206,7 +207,8 @@ func TestCertificateStateForAnAddressGone(t *testing.T) { for _, ip := range []string{ip1, ip2, ip6} { deps.state.SetCertificateState( - ip+":443:"+host, &state.CertificateState{Status: "ok"}, + net.JoinHostPort(ip, "443")+":"+host, + &state.CertificateState{Status: "ok"}, ) } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 3def3b0..45962b9 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,8 +6,10 @@ import ( "fmt" "log/slog" "maps" + "net" "slices" "sort" + "strconv" "strings" "time" @@ -1000,7 +1002,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string { ips := w.collectIPs(name) for _, ip := range ips { for _, port := range monitoredPorts { - key := fmt.Sprintf("%s:%d", ip, port) + key := net.JoinHostPort(ip, strconv.Itoa(port)) if assoc[key] == nil { assoc[key] = make(map[string]bool) } @@ -1025,18 +1027,15 @@ func (w *Watcher) buildPortAssociations() map[string][]string { return result } -// parsePortKey splits an "ip:port" key into its components. +// parsePortKey splits an "ip:port" key, written by net.JoinHostPort, +// into its components. func parsePortKey(key string) (string, int) { - lastColon := strings.LastIndex(key, ":") - if lastColon < 0 { + ip, port, err := net.SplitHostPort(key) + if err != nil { return key, 0 } - ip := key[:lastColon] - - var p int - - _, err := fmt.Sscanf(key[lastColon+1:], "%d", &p) + p, err := strconv.Atoi(port) if err != nil { return ip, 0 } @@ -1074,7 +1073,7 @@ 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)) + ps, ok := w.state.GetPortState(net.JoinHostPort(ip, strconv.Itoa(tlsPort))) if ok && !ps.Open { w.state.DeleteCertificateState(key) @@ -1189,7 +1188,7 @@ func (w *Watcher) checkSinglePort( return } - key := fmt.Sprintf("%s:%d", ip, port) + key := net.JoinHostPort(ip, strconv.Itoa(port)) now := time.Now().UTC() prev, hasPrev := w.state.GetPortState(key) @@ -1251,7 +1250,7 @@ func (w *Watcher) checkTLSForHostname( ips := w.collectIPs(hostname) for _, ip := range ips { - portKey := fmt.Sprintf("%s:%d", ip, tlsPort) + portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) ps, ok := w.state.GetPortState(portKey) if !ok || !ps.Open { @@ -1275,7 +1274,7 @@ func (w *Watcher) checkTLSCert( return } - certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname) + certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname now := time.Now().UTC() prev, hasPrev := w.state.GetCertificateState(certKey) diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 869df60..79feb23 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "net" "os" "slices" "strings" @@ -824,7 +825,8 @@ func TestPortStateChange(t *testing.T) { hs, _ := deps.state.GetHostnameState(testHost) assertNotified( - t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning", + t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"), + "warning", ) } @@ -1031,11 +1033,12 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { // Port and TLS checks must use the addresses this check found. for _, ip := range addresses(snap.Hostnames[testHost]) { - if _, ok := snap.Ports[ip+":80"]; !ok { - t.Error("port check used stale DNS: missing " + ip + ":80") + portKey := net.JoinHostPort(ip, "80") + if _, ok := snap.Ports[portKey]; !ok { + t.Error("port check used stale DNS: missing " + portKey) } - certKey := ip + ":443:" + testHost + certKey := net.JoinHostPort(ip, "443") + ":" + testHost if _, ok := snap.Certificates[certKey]; !ok { t.Error("TLS check used stale DNS: missing " + certKey) }