Compare commits
6 Commits
fix/make-c
...
e63241cc3c
| Author | SHA1 | Date | |
|---|---|---|---|
| e63241cc3c | |||
| 5ab217bfd2 | |||
| 518a2cc42e | |||
|
|
4cb81aac24 | ||
|
|
203b581704 | ||
|
|
82fd68a41b |
4
Makefile
4
Makefile
@@ -18,7 +18,7 @@ fmt:
|
|||||||
goimports -w .
|
goimports -w .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -race -cover -timeout 30s ./...
|
go test -v -race -cover ./...
|
||||||
|
|
||||||
# Check runs all validation without making changes
|
# Check runs all validation without making changes
|
||||||
# Used by CI and Docker build - fails if anything is wrong
|
# Used by CI and Docker build - fails if anything is wrong
|
||||||
@@ -28,7 +28,7 @@ check:
|
|||||||
@echo "==> Running linter..."
|
@echo "==> Running linter..."
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
@echo "==> Running tests..."
|
@echo "==> Running tests..."
|
||||||
go test -v -race -short -timeout 30s ./...
|
go test -v -race ./...
|
||||||
@echo "==> Building..."
|
@echo "==> Building..."
|
||||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
|
||||||
@echo "==> All checks passed!"
|
@echo "==> All checks passed!"
|
||||||
|
|||||||
34
TESTING.md
Normal file
34
TESTING.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Testing Policy
|
||||||
|
|
||||||
|
## DNS Resolution Tests
|
||||||
|
|
||||||
|
All resolver tests **MUST** use live queries against real DNS servers.
|
||||||
|
No mocking of the DNS client layer is permitted.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
The resolver performs iterative resolution from root nameservers through
|
||||||
|
the full delegation chain. Mocked responses cannot faithfully represent
|
||||||
|
the variety of real-world DNS behavior (truncation, referrals, glue
|
||||||
|
records, DNSSEC, varied response times, EDNS, etc.). Testing against
|
||||||
|
real servers ensures the resolver works correctly in production.
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
- Tests hit real DNS infrastructure and require network access
|
||||||
|
- Test duration depends on network conditions; timeout tuning keeps
|
||||||
|
the suite within the 30-second target
|
||||||
|
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
|
||||||
|
plus processing margin
|
||||||
|
- Root server fan-out is limited to reduce parallel query load
|
||||||
|
- Flaky failures from transient network issues are acceptable and
|
||||||
|
should be investigated as potential resolver bugs, not papered over
|
||||||
|
with mocks or skip flags
|
||||||
|
|
||||||
|
### What NOT to do
|
||||||
|
|
||||||
|
- **Do not mock `DNSClient`** for resolver tests (the mock constructor
|
||||||
|
exists for unit-testing other packages that consume the resolver)
|
||||||
|
- **Do not add `-short` flags** to skip slow tests
|
||||||
|
- **Do not increase `-timeout`** to hide hanging queries
|
||||||
|
- **Do not modify linter configuration** to suppress findings
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
queryTimeoutDuration = 5 * time.Second
|
queryTimeoutDuration = 2 * time.Second
|
||||||
maxRetries = 2
|
maxRetries = 2
|
||||||
maxDelegation = 20
|
maxDelegation = 20
|
||||||
timeoutMultiplier = 2
|
timeoutMultiplier = 2
|
||||||
@@ -41,6 +42,22 @@ func rootServerList() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxRootServers = 3
|
||||||
|
|
||||||
|
// randomRootServers returns a shuffled subset of root servers.
|
||||||
|
func randomRootServers() []string {
|
||||||
|
all := rootServerList()
|
||||||
|
rand.Shuffle(len(all), func(i, j int) {
|
||||||
|
all[i], all[j] = all[j], all[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(all) > maxRootServers {
|
||||||
|
return all[:maxRootServers]
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
func checkCtx(ctx context.Context) error {
|
func checkCtx(ctx context.Context) error {
|
||||||
err := ctx.Err()
|
err := ctx.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,7 +319,7 @@ func (r *Resolver) resolveNSRecursive(
|
|||||||
msg.SetQuestion(domain, dns.TypeNS)
|
msg.SetQuestion(domain, dns.TypeNS)
|
||||||
msg.RecursionDesired = true
|
msg.RecursionDesired = true
|
||||||
|
|
||||||
for _, ip := range rootServerList()[:3] {
|
for _, ip := range randomRootServers() {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
}
|
}
|
||||||
@@ -333,7 +350,7 @@ func (r *Resolver) resolveARecord(
|
|||||||
msg.SetQuestion(hostname, dns.TypeA)
|
msg.SetQuestion(hostname, dns.TypeA)
|
||||||
msg.RecursionDesired = true
|
msg.RecursionDesired = true
|
||||||
|
|
||||||
for _, ip := range rootServerList()[:3] {
|
for _, ip := range randomRootServers() {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
}
|
}
|
||||||
@@ -385,7 +402,7 @@ func (r *Resolver) FindAuthoritativeNameservers(
|
|||||||
candidate := strings.Join(labels[i:], ".") + "."
|
candidate := strings.Join(labels[i:], ".") + "."
|
||||||
|
|
||||||
nsNames, err := r.followDelegation(
|
nsNames, err := r.followDelegation(
|
||||||
ctx, candidate, rootServerList(),
|
ctx, candidate, randomRootServers(),
|
||||||
)
|
)
|
||||||
if err == nil && len(nsNames) > 0 {
|
if err == nil && len(nsNames) > 0 {
|
||||||
sort.Strings(nsNames)
|
sort.Strings(nsNames)
|
||||||
|
|||||||
@@ -34,12 +34,8 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
|
|||||||
func testContext(t *testing.T) context.Context {
|
func testContext(t *testing.T) context.Context {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test requiring real DNS")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(
|
ctx, cancel := context.WithTimeout(
|
||||||
context.Background(), 15*time.Second,
|
context.Background(), 60*time.Second,
|
||||||
)
|
)
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -49,6 +50,8 @@ type Watcher struct {
|
|||||||
notify Notifier
|
notify Notifier
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
firstRun bool
|
firstRun bool
|
||||||
|
expiryNotifiedMu sync.Mutex
|
||||||
|
expiryNotified map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Watcher instance wired into the fx lifecycle.
|
// New creates a new Watcher instance wired into the fx lifecycle.
|
||||||
@@ -65,6 +68,7 @@ func New(
|
|||||||
tlsCheck: params.TLSCheck,
|
tlsCheck: params.TLSCheck,
|
||||||
notify: params.Notify,
|
notify: params.Notify,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
|
expiryNotified: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -108,6 +112,7 @@ func NewForTest(
|
|||||||
tlsCheck: tc,
|
tlsCheck: tc,
|
||||||
notify: n,
|
notify: n,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
|
expiryNotified: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +696,22 @@ func (w *Watcher) checkTLSExpiry(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate expiry warnings: don't re-notify for the same
|
||||||
|
// hostname within the TLS check interval.
|
||||||
|
dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip)
|
||||||
|
|
||||||
|
w.expiryNotifiedMu.Lock()
|
||||||
|
|
||||||
|
lastNotified, seen := w.expiryNotified[dedupKey]
|
||||||
|
if seen && time.Since(lastNotified) < w.config.TLSInterval {
|
||||||
|
w.expiryNotifiedMu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.expiryNotified[dedupKey] = time.Now()
|
||||||
|
w.expiryNotifiedMu.Unlock()
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Host: %s\nIP: %s\nCN: %s\n"+
|
"Host: %s\nIP: %s\nCN: %s\n"+
|
||||||
"Expires: %s (%.0f days)",
|
"Expires: %s (%.0f days)",
|
||||||
|
|||||||
@@ -506,6 +506,61 @@ func TestTLSExpiryWarning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTLSExpiryWarningDedup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{"www.example.com"}
|
||||||
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
|
w, deps := newTestWatcher(t, cfg)
|
||||||
|
|
||||||
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
||||||
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
||||||
|
}
|
||||||
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
||||||
|
"1.2.3.4",
|
||||||
|
}
|
||||||
|
deps.portChecker.results["1.2.3.4:80"] = true
|
||||||
|
deps.portChecker.results["1.2.3.4:443"] = true
|
||||||
|
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
||||||
|
CommonName: "www.example.com",
|
||||||
|
Issuer: "DigiCert",
|
||||||
|
NotAfter: time.Now().Add(3 * 24 * time.Hour),
|
||||||
|
SubjectAlternativeNames: []string{
|
||||||
|
"www.example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// First run = baseline, no notifications
|
||||||
|
w.RunOnce(ctx)
|
||||||
|
|
||||||
|
// Second run should fire one expiry warning
|
||||||
|
w.RunOnce(ctx)
|
||||||
|
|
||||||
|
// Third run should NOT fire another warning (dedup)
|
||||||
|
w.RunOnce(ctx)
|
||||||
|
|
||||||
|
notifications := deps.notifier.getNotifications()
|
||||||
|
|
||||||
|
expiryCount := 0
|
||||||
|
|
||||||
|
for _, n := range notifications {
|
||||||
|
if n.Title == "TLS Expiry Warning: www.example.com" {
|
||||||
|
expiryCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiryCount != 1 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected exactly 1 expiry warning (dedup), got %d",
|
||||||
|
expiryCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGracefulShutdown(t *testing.T) {
|
func TestGracefulShutdown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user