1 Commit
Autore SHA1 Messaggio Data
sneak d1a687de26 ci: a push cancels its branch's older run, checkout keeps no token (closes #216)
check / check (push) Canceled after 0s
Every push queues a run on the shared runner, and a branch pushed again
left its older run queued for a head nobody needed. The workflow now puts
each branch's runs in one concurrency group with cancel-in-progress, so a
new push cancels that branch's older run, queued or running. The group is
keyed on the branch, so pushes to other branches never cancel runs on
`next` or `main`; a push to `next` itself does cancel the older `next` run.

The checkout step no longer writes the token into `.git/config`;
`script/cibuild` does not need it.

Model: opus-5-5
2026-10-02 02:32:04 +00:00
6 ha cambiato i file con 90 aggiunte e 157 eliminazioni
-2
Vedi File
@@ -21,8 +21,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-02: a push to a branch cancels that branch's older CI run, and the - 2026-10-02: a push to a branch cancels that branch's older CI run, and the
checkout leaves no token in `.git/config` (closes #216). checkout leaves no token in `.git/config` (closes #216).
- 2026-10-02: watcher tests send far fewer queries and a live attempt may take
18s; nameserver addresses are asked only for A, AAAA, CNAME (closes #214).
- 2026-10-02: the resolver tries root servers, and every other server list it - 2026-10-02: the resolver tries root servers, and every other server list it
walks, in a random order each time, not always from the top (closes #138). walks, in a random order each time, not always from the top (closes #138).
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any - 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
+5 -12
Vedi File
@@ -36,18 +36,11 @@ const (
// before the test fails. // before the test fails.
attempts = 3 attempts = 3
// AttemptTimeout bounds one attempt. It must fit the longest // AttemptTimeout bounds one attempt. Worst case for an operation
// operation, a watcher check, which sends over a hundred queries one // is attempts * AttemptTimeout plus the backoff — about 26
// after another and on a slow build host takes several times as long // seconds, well inside the 90-second `go test -timeout` backstop
// as the few seconds it takes on a fast one. An operation whose // even when several operations exhaust their attempts.
// every attempt fails takes attempts * AttemptTimeout plus the AttemptTimeout = 8 * time.Second
// backoff, about 56 seconds, after it waits for one of the
// Concurrency slots that every live operation in the test binary
// shares. So when live DNS does not answer at all, a test binary
// with more live operations than slots runs into the 90-second
// `go test -timeout` backstop instead of each test failing on its
// own.
AttemptTimeout = 18 * time.Second
// backoffBase is the delay after the first failed attempt; it is // backoffBase is the delay after the first failed attempt; it is
// multiplied by backoffFactor each time. // multiplied by backoffFactor each time.
+1 -1
Vedi File
@@ -34,7 +34,7 @@ func (r *Resolver) QueryEachNS(
nameservers []string, nameservers []string,
hostname string, hostname string,
) (map[string]*NameserverResponse, error) { ) (map[string]*NameserverResponse, error) {
return r.queryEachNS(ctx, nameservers, hostname, recordTypes()) return r.queryEachNS(ctx, nameservers, hostname)
} }
// ResolveNSIPs exports resolveNSIPs for testing. // ResolveNSIPs exports resolveNSIPs for testing.
+15 -44
Vedi File
@@ -533,39 +533,12 @@ func (r *Resolver) FindAuthoritativeNameservers(
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// recordTypes returns the record types a nameserver is asked for when a
// name is checked.
func recordTypes() []uint16 {
return []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV,
dns.TypeCAA, dns.TypeNS,
}
}
// addressTypes returns the record types ResolveIPAddresses asks for,
// the only ones it reads.
func addressTypes() []uint16 {
return []uint16{dns.TypeA, dns.TypeAAAA, dns.TypeCNAME}
}
// QueryNameserver queries a specific nameserver for all record // QueryNameserver queries a specific nameserver for all record
// types and builds a NameserverResponse. // types and builds a NameserverResponse.
func (r *Resolver) QueryNameserver( func (r *Resolver) QueryNameserver(
ctx context.Context, ctx context.Context,
nsHostname string, nsHostname string,
hostname string, hostname string,
) (*NameserverResponse, error) {
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
}
// queryNameserver queries a specific nameserver for the record types
// in qtypes and builds a NameserverResponse.
func (r *Resolver) queryNameserver(
ctx context.Context,
nsHostname string,
hostname string,
qtypes []uint16,
) (*NameserverResponse, error) { ) (*NameserverResponse, error) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
@@ -578,7 +551,7 @@ func (r *Resolver) queryNameserver(
hostname = dns.Fqdn(hostname) hostname = dns.Fqdn(hostname)
return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes) return r.queryAllTypes(ctx, nsHostname, nsIPs[0], hostname)
} }
// QueryNameserverIP queries a nameserver by its IP address directly, // QueryNameserverIP queries a nameserver by its IP address directly,
@@ -595,15 +568,14 @@ func (r *Resolver) QueryNameserverIP(
hostname = dns.Fqdn(hostname) hostname = dns.Fqdn(hostname)
return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes()) return r.queryAllTypes(ctx, nsHostname, nsIP, hostname)
} }
func (r *Resolver) queryTypes( func (r *Resolver) queryAllTypes(
ctx context.Context, ctx context.Context,
nsHostname string, nsHostname string,
nsIP string, nsIP string,
hostname string, hostname string,
qtypes []uint16,
) (*NameserverResponse, error) { ) (*NameserverResponse, error) {
resp := &NameserverResponse{ resp := &NameserverResponse{
Nameserver: nsHostname, Nameserver: nsHostname,
@@ -611,6 +583,12 @@ func (r *Resolver) queryTypes(
Status: StatusOK, Status: StatusOK,
} }
qtypes := []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV,
dns.TypeCAA, dns.TypeNS,
}
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp) state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
classifyResponse(resp, state) classifyResponse(resp, state)
@@ -801,19 +779,18 @@ func (r *Resolver) QueryAllNameservers(
return nil, err return nil, err
} }
return r.queryEachNS(ctx, nameservers, hostname, recordTypes()) return r.queryEachNS(ctx, nameservers, hostname)
} }
func (r *Resolver) queryEachNS( func (r *Resolver) queryEachNS(
ctx context.Context, ctx context.Context,
nameservers []string, nameservers []string,
hostname string, hostname string,
qtypes []uint16,
) (map[string]*NameserverResponse, error) { ) (map[string]*NameserverResponse, error) {
results := make(map[string]*NameserverResponse) results := make(map[string]*NameserverResponse)
for _, ns := range nameservers { for _, ns := range nameservers {
resp, err := r.queryNameserver(ctx, ns, hostname, qtypes) resp, err := r.QueryNameserver(ctx, ns, hostname)
// A query the context cut short says nothing about the // A query the context cut short says nothing about the
// nameserver, so it must not be returned as its failure. // nameserver, so it must not be returned as its failure.
@@ -858,10 +835,9 @@ func (r *Resolver) LookupAllRecords(
} }
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
// addresses, following CNAME chains up to MaxCNAMEDepth. It asks each // addresses, following CNAME chains up to MaxCNAMEDepth. When no
// nameserver of the name's zone for its A, AAAA and CNAME records only. // nameserver of the name's zone answered, it returns an error rather
// When no nameserver of the name's zone answered, it returns an error // than no addresses.
// rather than no addresses.
func (r *Resolver) ResolveIPAddresses( func (r *Resolver) ResolveIPAddresses(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -882,12 +858,7 @@ func (r *Resolver) resolveIPWithCNAME(
return nil, ErrCNAMEDepthExceeded return nil, ErrCNAMEDepthExceeded
} }
nameservers, err := r.FindAuthoritativeNameservers(ctx, hostname) results, err := r.QueryAllNameservers(ctx, hostname)
if err != nil {
return nil, err
}
results, err := r.queryEachNS(ctx, nameservers, hostname, addressTypes())
if err != nil { if err != nil {
return nil, err return nil, err
} }
-28
Vedi File
@@ -562,34 +562,6 @@ func TestResolveIPAddresses_CloudflareDomain(t *testing.T) {
assert.NotEmpty(t, ips) assert.NotEmpty(t, ips)
} }
// TestResolveIPAddresses_NameserverIPv4AndIPv6 looks up the addresses of
// one of cloudflare.com's nameservers, as a domain check does for each
// nameserver. That name has A and AAAA records, so both kinds of address
// come back.
func TestResolveIPAddresses_NameserverIPv4AndIPv6(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "cloudflare.com")
ips := liveResolveIPs(t, r, ns)
var ipv4, ipv6 int
for _, ip := range ips {
parsed := net.ParseIP(ip)
require.NotNil(t, parsed, "should be valid IP: %s", ip)
if parsed.To4() != nil {
ipv4++
} else {
ipv6++
}
}
assert.Positive(t, ipv4, "no IPv4 address for %s: %v", ns, ips)
assert.Positive(t, ipv6, "no IPv6 address for %s: %v", ns, ips)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Context cancellation tests // Context cancellation tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
+69 -70
Vedi File
@@ -26,22 +26,18 @@ import (
// The watcher looks these names up in live DNS with the real resolver, // The watcher looks these names up in live DNS with the real resolver,
// so tests assert on what the watcher does with the answers, never on // so tests assert on what the watcher does with the answers, never on
// the records these zones publish. The nameservers of testHost and // the records these zones publish. testHost's nameservers and addresses
// testSmallDomain stay the same between a test looking them up and its // stay the same from one check to the next, which the tests that check
// check. Every query a check sends is one more that can be lost, so the // it twice rely on, and testSmallDomain's nameservers stay the same
// tests keep them few. A check asks each of a name's nameservers about // between a test looking them up and its check. A domain check looks up
// every record type, and both names have two. A domain check also looks // each nameserver's addresses, about a second per nameserver, so the
// up each nameserver's addresses at every nameserver of the zone that // tests that check a domain use testSmallDomain, which has two
// nameserver is in: testSmallDomain's nameservers are in zones with two // nameservers, and check it once. The tests that query testDomain's
// nameservers, while a domain whose nameservers are in, say, // nameservers directly do no domain check.
// cloudflare.com, which has five, makes each domain check much longer.
// A test checks a domain only when it is about domains, and checks once,
// from saved state it builds, rather than twice. The tests that query
// testDomain's nameservers directly do no domain check.
const ( const (
testDomain = "google.com" testDomain = "google.com"
testSmallDomain = "desec.io" testSmallDomain = "example.com"
testHost = "example.org" testHost = "cloudflare.com"
testIssuer = "DigiCert" testIssuer = "DigiCert"
) )
@@ -263,48 +259,55 @@ func checkOnce(
// runChecks builds a watcher, lets prepare set up the saved state and // runChecks builds a watcher, lets prepare set up the saved state and
// stand-ins it starts from, and runs its checks once against live DNS. // stand-ins it starts from, and runs its checks once against live DNS.
// When the check finds no fresh address for a name (see checkOnce), the // If change is not nil, change then alters the saved state or stand-ins
// watcher is thrown away and all of this runs again on a new one, so a // and the checks run a second time. When either check finds no fresh
// failed attempt leaves nothing behind in the saved state, the // address for a name (see checkOnce), the watcher is thrown away and
// stand-ins or the notifications. // all of this runs again on a new one, so a failed attempt leaves
// nothing behind in the saved state, the stand-ins or the notifications.
func runChecks( func runChecks(
t *testing.T, t *testing.T,
cfg *config.Config, cfg *config.Config,
prepare func(deps *testDeps), prepare, change func(deps *testDeps),
) (*watcher.Watcher, *testDeps) { ) *testDeps {
t.Helper() t.Helper()
var ( var deps *testDeps
w *watcher.Watcher
deps *testDeps
)
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg) w, deps = newTestWatcher(t, cfg)
if prepare != nil { if prepare != nil {
prepare(deps) prepare(deps)
} }
err := checkOnce(ctx, w, deps)
if err != nil || change == nil {
return err
}
change(deps)
return checkOnce(ctx, w, deps) return checkOnce(ctx, w, deps)
}) })
return w, deps return deps
} }
// lookupNameservers returns the nameservers live DNS lists for name, // lookupNameservers returns the nameservers live DNS lists for domain,
// for a test to save in the state its check starts from. // for a test to save in the state its check starts from.
func lookupNameservers(t *testing.T, name string) []string { func lookupNameservers(t *testing.T, domain string) []string {
t.Helper() t.Helper()
res := resolver.NewFromLogger(slog.Default()) res := resolver.NewFromLogger(slog.Default())
var nameservers []string var nameservers []string
livednstest.Retry(t, "LookupNS("+name+")", func(ctx context.Context) error { livednstest.Retry(t, "LookupNS("+domain+")", func(ctx context.Context) error {
var err error var err error
nameservers, err = res.LookupNS(ctx, name) nameservers, err = res.LookupNS(ctx, domain)
return err return err
}) })
@@ -367,7 +370,7 @@ func TestFirstRunBaseline(t *testing.T) {
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
_, deps := runChecks(t, cfg, nil) deps := runChecks(t, cfg, nil, nil)
assertNoNotifications(t, deps) assertNoNotifications(t, deps)
assertStatePopulated(t, deps) assertStatePopulated(t, deps)
@@ -419,7 +422,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
_, deps := runChecks(t, cfg, nil) deps := runChecks(t, cfg, nil, nil)
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -459,11 +462,11 @@ func TestNSChangeDetection(t *testing.T) {
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
// The saved state lists nameservers that live DNS does not. // The saved state lists nameservers that live DNS does not.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testSmallDomain, &state.DomainState{ deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: []string{oldNS1, oldNS2}, Nameservers: []string{oldNS1, oldNS2},
}) })
}) }, nil)
assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning") assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning")
@@ -483,7 +486,7 @@ func TestNSAddressChangeDetection(t *testing.T) {
// The saved state lists the nameservers live DNS lists, each at an // The saved state lists the nameservers live DNS lists, each at an
// address live DNS never returns. // address live DNS never returns.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
nsAddresses := make(map[string][]string, len(nameservers)) nsAddresses := make(map[string][]string, len(nameservers))
for _, ns := range nameservers { for _, ns := range nameservers {
nsAddresses[ns] = []string{oldIP} nsAddresses[ns] = []string{oldIP}
@@ -493,7 +496,7 @@ func TestNSAddressChangeDetection(t *testing.T) {
Nameservers: nameservers, Nameservers: nameservers,
NameserverAddresses: nsAddresses, NameserverAddresses: nsAddresses,
}) })
}) }, nil)
title := "NS Address Change: " + testSmallDomain title := "NS Address Change: " + testSmallDomain
ds, _ := deps.state.GetDomainState(testSmallDomain) ds, _ := deps.state.GetDomainState(testSmallDomain)
@@ -539,12 +542,12 @@ func TestNSAddedAndRemovedIsNoAddressChange(t *testing.T) {
// The saved state lists oldNS1, which live DNS does not, in place of // The saved state lists oldNS1, which live DNS does not, in place of
// the first nameserver live DNS lists, so that the check finds that // the first nameserver live DNS lists, so that the check finds that
// one added and oldNS1 removed. Only oldNS1 has addresses saved. // one added and oldNS1 removed. Only oldNS1 has addresses saved.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testSmallDomain, &state.DomainState{ deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: append([]string{oldNS1}, nameservers[1:]...), Nameservers: append([]string{oldNS1}, nameservers[1:]...),
NameserverAddresses: map[string][]string{oldNS1: {oldIP}}, NameserverAddresses: map[string][]string{oldNS1: {oldIP}},
}) })
}) }, nil)
if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 { if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 {
t.Errorf("sent %d NS changes, want 1", n) t.Errorf("sent %d NS changes, want 1", n)
@@ -562,17 +565,15 @@ func TestRecordChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
nameservers := lookupNameservers(t, testHost) // Between the checks, save for every nameserver an address live DNS
// never returns.
// The saved state has every nameserver live DNS lists answering deps := runChecks(t, cfg, nil, func(deps *testDeps) {
// with an address live DNS never returns. hs, _ := deps.state.GetHostnameState(testHost)
_, deps := runChecks(t, cfg, func(deps *testDeps) { for _, nsState := range hs.RecordsByNameserver {
byNameserver := make(map[string]*state.NameserverRecordState) nsState.Records = map[string][]string{"A": {oldIP}}
for _, ns := range nameservers {
byNameserver[ns] = answered(map[string][]string{"A": {oldIP}})
} }
deps.state.SetHostnameState(testHost, saved(byNameserver)) deps.state.SetHostnameState(testHost, hs)
}) })
assertNotified(t, deps, "Record Change: "+testHost, "warning") assertNotified(t, deps, "Record Change: "+testHost, "warning")
@@ -584,15 +585,12 @@ func TestPortStateChange(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
w, deps := runChecks(t, cfg, nil) // Between the checks, every port closes.
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
// Every port closes, and the port checks run again. They look deps.portChecker.mu.Lock()
// nothing up. deps.portChecker.closed = true
deps.portChecker.mu.Lock() deps.portChecker.mu.Unlock()
deps.portChecker.closed = true })
deps.portChecker.mu.Unlock()
w.CheckAllPorts(t.Context())
hs, _ := deps.state.GetHostnameState(testHost) hs, _ := deps.state.GetHostnameState(testHost)
assertNotified( assertNotified(
@@ -612,7 +610,7 @@ func TestTLSExpiryWarning(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
_, deps := runChecks(t, cfg, expiresInThreeDays) deps := runChecks(t, cfg, expiresInThreeDays, nil)
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
} }
@@ -784,7 +782,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// The saved state says the last check found testHost at oldIP. // The saved state says the last check found testHost at oldIP.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetHostnameState(testHost, &state.HostnameState{ deps.state.SetHostnameState(testHost, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
oldNS1: { oldNS1: {
@@ -793,7 +791,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
}, },
}, },
}) })
}) }, nil)
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -924,20 +922,21 @@ func TestNSFailureAndRecovery(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
nameservers := lookupNameservers(t, testHost) // Between the checks, save every nameserver the first check found
// as one that did not answer, and add, as answering, one that live
// The saved state has every nameserver live DNS lists as one that // DNS does not list, which then disappears.
// did not answer, and, as answering, one that live DNS does not deps := runChecks(t, cfg, nil, func(deps *testDeps) {
// list, which then disappears. hs, _ := deps.state.GetHostnameState(testHost)
_, deps := runChecks(t, cfg, func(deps *testDeps) { for ns := range hs.RecordsByNameserver {
byNameserver := map[string]*state.NameserverRecordState{ hs.RecordsByNameserver[ns] = failed()
oldNS1: answered(map[string][]string{"A": {oldIP}}),
}
for _, ns := range nameservers {
byNameserver[ns] = failed()
} }
deps.state.SetHostnameState(testHost, saved(byNameserver)) hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{
Records: map[string][]string{"A": {oldIP}},
Status: "ok",
}
deps.state.SetHostnameState(testHost, hs)
}) })
assertNotified(t, deps, "NS Failure: "+testHost, "error") assertNotified(t, deps, "NS Failure: "+testHost, "error")