2 Commits
Author SHA1 Message Date
clawbot aaccabad50 watcher: an IPv6 address is written in brackets before its port (closes #270)
check / check (push) Successful in 4m33s
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, and a test checks that an IPv6 address's
certificate entry is removed when its port 443 closes.

Model: opus-5-5
2026-10-13 22:27:34 +02:00
clawbot 6b580a5a0f docs: TODO.md names only open issues as next steps (closes #281)
check / check (push) Canceled after 0s
Next Step was the trial run in issue 149 and Future Steps listed issue
144, both closed. Next Step is now the 1.0 tracking issue, 66, and
Future Steps holds only DNSSEC validation, 59, which comes after 1.0.
Status drops "No git tags", which stops being true once a tag is made.

Issue 149 is not added to Completed Steps: the issue asked for no change
there beyond this entry.

Model: opus-5-5
2026-10-13 22:00:04 +02:00
6 changed files with 121 additions and 26 deletions
+6
View File
@@ -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.
+8 -6
View File
@@ -10,15 +10,19 @@
# Status
pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked
on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0
milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Next Step
trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66
# 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
@@ -201,6 +205,4 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Future Steps
- 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
- DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59
+83
View File
@@ -0,0 +1,83 @@
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)
}
}
+5 -3
View File
@@ -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"},
)
}
+12 -13
View File
@@ -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)
+7 -4
View File
@@ -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)
}