Compare commits
6 Commits
b18d29d586
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| f2970143d2 | |||
| 0eb57fc15b | |||
| 5739108dc7 | |||
| 54272c2be5 | |||
| e63241cc3c | |||
|
|
82fd68a41b |
@@ -24,4 +24,8 @@ var (
|
|||||||
// ErrContextCanceled wraps context cancellation for the
|
// ErrContextCanceled wraps context cancellation for the
|
||||||
// resolver's iterative queries.
|
// resolver's iterative queries.
|
||||||
ErrContextCanceled = errors.New("context canceled")
|
ErrContextCanceled = errors.New("context canceled")
|
||||||
|
|
||||||
|
// ErrSERVFAIL is returned when a DNS server responds with
|
||||||
|
// SERVFAIL after all retries are exhausted.
|
||||||
|
ErrSERVFAIL = errors.New("SERVFAIL from server")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -459,9 +459,15 @@ func (r *Resolver) queryAllTypes(
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
singleTypeMaxRetries = 3
|
||||||
|
singleTypeInitialBackoff = 100 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
type queryState struct {
|
type queryState struct {
|
||||||
gotNXDomain bool
|
gotNXDomain bool
|
||||||
gotSERVFAIL bool
|
gotSERVFAIL bool
|
||||||
|
gotTimeout bool
|
||||||
hasRecords bool
|
hasRecords bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +495,21 @@ func (r *Resolver) queryEachType(
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isTimeout checks whether an error represents a DNS timeout.
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also catch i/o timeout strings from the dns library.
|
||||||
|
return strings.Contains(err.Error(), "i/o timeout")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Resolver) querySingleType(
|
func (r *Resolver) querySingleType(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsIP string,
|
nsIP string,
|
||||||
@@ -497,19 +518,95 @@ func (r *Resolver) querySingleType(
|
|||||||
resp *NameserverResponse,
|
resp *NameserverResponse,
|
||||||
state *queryState,
|
state *queryState,
|
||||||
) {
|
) {
|
||||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
msg, lastErr := r.querySingleTypeWithRetry(
|
||||||
if err != nil {
|
ctx, nsIP, hostname, qtype,
|
||||||
|
)
|
||||||
|
if msg == nil {
|
||||||
|
r.recordRetryFailure(lastErr, state)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Rcode == dns.RcodeNameError {
|
r.handleDNSResponse(msg, resp, state)
|
||||||
state.gotNXDomain = true
|
}
|
||||||
|
|
||||||
return
|
func (r *Resolver) querySingleTypeWithRetry(
|
||||||
|
ctx context.Context,
|
||||||
|
nsIP string,
|
||||||
|
hostname string,
|
||||||
|
qtype uint16,
|
||||||
|
) (*dns.Msg, error) {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
backoff := singleTypeInitialBackoff
|
||||||
|
|
||||||
|
for attempt := range singleTypeMaxRetries {
|
||||||
|
if checkCtx(ctx) != nil {
|
||||||
|
return nil, ErrContextCanceled
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt > 0 {
|
||||||
|
if !waitBackoff(ctx, backoff) {
|
||||||
|
return nil, ErrContextCanceled
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff *= timeoutMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
if !isTimeout(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Rcode == dns.RcodeServerFailure {
|
if msg.Rcode == dns.RcodeServerFailure {
|
||||||
|
lastErr = ErrSERVFAIL
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitBackoff(ctx context.Context, d time.Duration) bool {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(d):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) recordRetryFailure(
|
||||||
|
lastErr error,
|
||||||
|
state *queryState,
|
||||||
|
) {
|
||||||
|
if lastErr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTimeout(lastErr) {
|
||||||
|
state.gotTimeout = true
|
||||||
|
} else if errors.Is(lastErr, ErrSERVFAIL) {
|
||||||
state.gotSERVFAIL = true
|
state.gotSERVFAIL = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) handleDNSResponse(
|
||||||
|
msg *dns.Msg,
|
||||||
|
resp *NameserverResponse,
|
||||||
|
state *queryState,
|
||||||
|
) {
|
||||||
|
if msg.Rcode == dns.RcodeNameError {
|
||||||
|
state.gotNXDomain = true
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -540,8 +637,12 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
|||||||
switch {
|
switch {
|
||||||
case state.gotNXDomain && !state.hasRecords:
|
case state.gotNXDomain && !state.hasRecords:
|
||||||
resp.Status = StatusNXDomain
|
resp.Status = StatusNXDomain
|
||||||
|
case state.gotTimeout && !state.hasRecords:
|
||||||
|
resp.Status = StatusTimeout
|
||||||
|
resp.Error = "all queries timed out after retries"
|
||||||
case state.gotSERVFAIL && !state.hasRecords:
|
case state.gotSERVFAIL && !state.hasRecords:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
|
resp.Error = "server failure (SERVFAIL) after retries"
|
||||||
case !state.hasRecords && !state.gotNXDomain:
|
case !state.hasRecords && !state.gotNXDomain:
|
||||||
resp.Status = StatusNoData
|
resp.Status = StatusNoData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
StatusError = "error"
|
StatusError = "error"
|
||||||
StatusNXDomain = "nxdomain"
|
StatusNXDomain = "nxdomain"
|
||||||
StatusNoData = "nodata"
|
StatusNoData = "nodata"
|
||||||
|
StatusTimeout = "timeout"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxCNAMEDepth is the maximum CNAME chain depth to follow.
|
// MaxCNAMEDepth is the maximum CNAME chain depth to follow.
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,6 +718,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)",
|
||||||
|
|||||||
@@ -588,6 +588,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