Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b68923d9 |
@@ -223,14 +223,8 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
- Certificate CN, issuer, or SANs changed (replacement detected, reports old
|
||||
and new CN and issuer).
|
||||
- TLS connection failure to a previously-reachable IP:443 (handshake error,
|
||||
timeout, or connection refused when port 443 closed after the port check
|
||||
that found it open).
|
||||
timeout, connection refused after previously succeeding).
|
||||
- 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
|
||||
|
||||
@@ -300,10 +294,8 @@ clears them.
|
||||
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
|
||||
of addresses no configured name has.
|
||||
- Each port check also removes, without a notification, the certificate entries
|
||||
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.
|
||||
- Each port check also removes the certificate entries for an address their name
|
||||
no longer resolves to, except while none of the name's nameservers answer.
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
@@ -10,21 +10,15 @@
|
||||
|
||||
# Status
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
- 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
|
||||
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
|
||||
@@ -205,4 +199,6 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package watcher_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
@@ -174,7 +172,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",
|
||||
@@ -184,7 +182,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
||||
[]string{
|
||||
ip1 + ":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} {
|
||||
deps.state.SetCertificateState(
|
||||
net.JoinHostPort(ip, "443")+":"+host,
|
||||
&state.CertificateState{Status: "ok"},
|
||||
ip+":443:"+host, &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
@@ -6,10 +6,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -980,8 +978,7 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
|
||||
|
||||
// Phase 3: Remove port state entries that no longer have
|
||||
// any hostname referencing them, and certificate entries for
|
||||
// an address whose port 443 is closed or that their name no
|
||||
// longer has.
|
||||
// an address their name no longer has.
|
||||
w.cleanupStalePorts(associations)
|
||||
w.cleanupStaleCertificates()
|
||||
}
|
||||
@@ -1002,7 +999,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
|
||||
ips := w.collectIPs(name)
|
||||
for _, ip := range ips {
|
||||
for _, port := range monitoredPorts {
|
||||
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
key := fmt.Sprintf("%s:%d", ip, port)
|
||||
if assoc[key] == nil {
|
||||
assoc[key] = make(map[string]bool)
|
||||
}
|
||||
@@ -1027,15 +1024,18 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
|
||||
return result
|
||||
}
|
||||
|
||||
// parsePortKey splits an "ip:port" key, written by net.JoinHostPort,
|
||||
// into its components.
|
||||
// parsePortKey splits an "ip:port" key into its components.
|
||||
func parsePortKey(key string) (string, int) {
|
||||
ip, port, err := net.SplitHostPort(key)
|
||||
if err != nil {
|
||||
lastColon := strings.LastIndex(key, ":")
|
||||
if lastColon < 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 {
|
||||
return ip, 0
|
||||
}
|
||||
@@ -1065,21 +1065,13 @@ func (w *Watcher) cleanupStalePorts(
|
||||
}
|
||||
|
||||
// cleanupStaleCertificates removes the certificate entries for an
|
||||
// address whose port 443 was found closed, and for an address their
|
||||
// name no longer resolves to. An entry saved for a name none of whose
|
||||
// nameservers answered is kept unless its port is closed: that name's
|
||||
// addresses are not known, not gone.
|
||||
// address their name no longer resolves to. An entry saved for a name
|
||||
// none of whose nameservers answered is kept: that name's addresses are
|
||||
// not known, not gone.
|
||||
func (w *Watcher) cleanupStaleCertificates() {
|
||||
for _, key := range w.state.GetAllCertificateKeys() {
|
||||
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) ||
|
||||
w.noNameserverAnswered(hostname) {
|
||||
continue
|
||||
@@ -1188,7 +1180,7 @@ func (w *Watcher) checkSinglePort(
|
||||
return
|
||||
}
|
||||
|
||||
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
key := fmt.Sprintf("%s:%d", ip, port)
|
||||
now := time.Now().UTC()
|
||||
prev, hasPrev := w.state.GetPortState(key)
|
||||
|
||||
@@ -1250,7 +1242,7 @@ func (w *Watcher) checkTLSForHostname(
|
||||
ips := w.collectIPs(hostname)
|
||||
|
||||
for _, ip := range ips {
|
||||
portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort))
|
||||
portKey := fmt.Sprintf("%s:%d", ip, tlsPort)
|
||||
|
||||
ps, ok := w.state.GetPortState(portKey)
|
||||
if !ok || !ps.Open {
|
||||
@@ -1274,7 +1266,7 @@ func (w *Watcher) checkTLSCert(
|
||||
return
|
||||
}
|
||||
|
||||
certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname
|
||||
certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
|
||||
now := time.Now().UTC()
|
||||
prev, hasPrev := w.state.GetCertificateState(certKey)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -825,8 +824,7 @@ func TestPortStateChange(t *testing.T) {
|
||||
|
||||
hs, _ := deps.state.GetHostnameState(testHost)
|
||||
assertNotified(
|
||||
t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"),
|
||||
"warning",
|
||||
t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1033,12 +1031,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
||||
|
||||
// Port and TLS checks must use the addresses this check found.
|
||||
for _, ip := range addresses(snap.Hostnames[testHost]) {
|
||||
portKey := net.JoinHostPort(ip, "80")
|
||||
if _, ok := snap.Ports[portKey]; !ok {
|
||||
t.Error("port check used stale DNS: missing " + portKey)
|
||||
if _, ok := snap.Ports[ip+":80"]; !ok {
|
||||
t.Error("port check used stale DNS: missing " + ip + ":80")
|
||||
}
|
||||
|
||||
certKey := net.JoinHostPort(ip, "443") + ":" + testHost
|
||||
certKey := ip + ":443:" + testHost
|
||||
if _, ok := snap.Certificates[certKey]; !ok {
|
||||
t.Error("TLS check used stale DNS: missing " + certKey)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user