Compare commits
1
Commits
v1.0.0-rc1
...
2de3c4b820
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de3c4b820 |
@@ -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
|
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.
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
|||||||
|
|
||||||
# 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
|
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
|
||||||
Status says nothing about git tags (closes #281).
|
Status says nothing about git tags (closes #281).
|
||||||
- 2026-10-13: a port check that finds port 443 closed removes that address's
|
- 2026-10-13: a port check that finds port 443 closed removes that address's
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package watcher_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
|
"net"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -173,7 +174,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",
|
||||||
@@ -183,7 +184,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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,8 @@ 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(
|
||||||
ip+":443:"+host, &state.CertificateState{Status: "ok"},
|
net.JoinHostPort(ip, "443")+":"+host,
|
||||||
|
&state.CertificateState{Status: "ok"},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-13
@@ -6,8 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1000,7 +1002,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 := fmt.Sprintf("%s:%d", ip, port)
|
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||||
if assoc[key] == nil {
|
if assoc[key] == nil {
|
||||||
assoc[key] = make(map[string]bool)
|
assoc[key] = make(map[string]bool)
|
||||||
}
|
}
|
||||||
@@ -1025,18 +1027,15 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
|
|||||||
return result
|
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) {
|
func parsePortKey(key string) (string, int) {
|
||||||
lastColon := strings.LastIndex(key, ":")
|
ip, port, err := net.SplitHostPort(key)
|
||||||
if lastColon < 0 {
|
if err != nil {
|
||||||
return key, 0
|
return key, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := key[:lastColon]
|
p, err := strconv.Atoi(port)
|
||||||
|
|
||||||
var p int
|
|
||||||
|
|
||||||
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ip, 0
|
return ip, 0
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1073,7 @@ 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))
|
ps, ok := w.state.GetPortState(net.JoinHostPort(ip, strconv.Itoa(tlsPort)))
|
||||||
if ok && !ps.Open {
|
if ok && !ps.Open {
|
||||||
w.state.DeleteCertificateState(key)
|
w.state.DeleteCertificateState(key)
|
||||||
|
|
||||||
@@ -1189,7 +1188,7 @@ func (w *Watcher) checkSinglePort(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key := fmt.Sprintf("%s:%d", ip, port)
|
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
prev, hasPrev := w.state.GetPortState(key)
|
prev, hasPrev := w.state.GetPortState(key)
|
||||||
|
|
||||||
@@ -1251,7 +1250,7 @@ func (w *Watcher) checkTLSForHostname(
|
|||||||
ips := w.collectIPs(hostname)
|
ips := w.collectIPs(hostname)
|
||||||
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
portKey := fmt.Sprintf("%s:%d", ip, tlsPort)
|
portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort))
|
||||||
|
|
||||||
ps, ok := w.state.GetPortState(portKey)
|
ps, ok := w.state.GetPortState(portKey)
|
||||||
if !ok || !ps.Open {
|
if !ok || !ps.Open {
|
||||||
@@ -1275,7 +1274,7 @@ func (w *Watcher) checkTLSCert(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
|
certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
prev, hasPrev := w.state.GetCertificateState(certKey)
|
prev, hasPrev := w.state.GetCertificateState(certKey)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -824,7 +825,8 @@ func TestPortStateChange(t *testing.T) {
|
|||||||
|
|
||||||
hs, _ := deps.state.GetHostnameState(testHost)
|
hs, _ := deps.state.GetHostnameState(testHost)
|
||||||
assertNotified(
|
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.
|
// 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]) {
|
||||||
if _, ok := snap.Ports[ip+":80"]; !ok {
|
portKey := net.JoinHostPort(ip, "80")
|
||||||
t.Error("port check used stale DNS: missing " + 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 {
|
if _, ok := snap.Certificates[certKey]; !ok {
|
||||||
t.Error("TLS check used stale DNS: missing " + certKey)
|
t.Error("TLS check used stale DNS: missing " + certKey)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user