mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
checkDnsLoop resolved with context.Background() and no dns.Client timeout, so the DNS client fell back to its own 2s default and the configured upstream timeout was ignored. The probes run serially on a one-minute ticker, so each unreachable local upstream stalled the loop for a fixed 2s no matter what the config asked for. This is most visible on Windows, where UDP to a closed loopback port does not surface an immediate refusal the way it does on Linux, so the probe waits out the full default. Apply the upstream timeout the way checkUpstreamOnce already does, and keep 2s for an upstream that configures none so the unconfigured case is unchanged. The probe context deliberately carries no logger. A resolver logs its own failure at error level with the endpoint in the message, which would place an Internal Domain resolver address in the retained journal; logUpstreamProbeFailure remains the reporter and bounds what the line may hold. TestInternalDomainsLoopCheckHidesResolverAddress covers this.
176 lines
5.1 KiB
Go
176 lines
5.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
)
|
|
|
|
const (
|
|
loopTestDomain = ".test"
|
|
loopTestQtype = dns.TypeTXT
|
|
|
|
// defaultLoopCheckTimeout bounds one loop probe when the upstream sets no
|
|
// timeout. It matches the DNS client's own default, so the unconfigured
|
|
// case keeps its previous behavior.
|
|
defaultLoopCheckTimeout = 2 * time.Second
|
|
)
|
|
|
|
// newLoopGuard returns new loopGuard.
|
|
func newLoopGuard() *loopGuard {
|
|
return &loopGuard{inflight: make(map[string]struct{})}
|
|
}
|
|
|
|
// loopGuard guards against DNS loop, ensuring only one query
|
|
// for a given domain is processed at a time.
|
|
type loopGuard struct {
|
|
mu sync.Mutex
|
|
inflight map[string]struct{}
|
|
}
|
|
|
|
// TryLock marks the domain as being processed.
|
|
func (lg *loopGuard) TryLock(domain string) bool {
|
|
lg.mu.Lock()
|
|
defer lg.mu.Unlock()
|
|
if _, inflight := lg.inflight[domain]; !inflight {
|
|
lg.inflight[domain] = struct{}{}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Unlock marks the domain as being done.
|
|
func (lg *loopGuard) Unlock(domain string) {
|
|
lg.mu.Lock()
|
|
defer lg.mu.Unlock()
|
|
delete(lg.inflight, domain)
|
|
}
|
|
|
|
// isLoop reports whether the given upstream config is detected as having DNS loop.
|
|
func (p *prog) isLoop(uc *ctrld.UpstreamConfig) bool {
|
|
p.loopMu.Lock()
|
|
defer p.loopMu.Unlock()
|
|
return p.loop[uc.UID()]
|
|
}
|
|
|
|
// detectLoop checks if the given DNS message is initialized sent by ctrld.
|
|
// If yes, marking the corresponding upstream as loop, prevent infinite DNS
|
|
// forwarding loop.
|
|
//
|
|
// See p.checkDnsLoop for more details how it works.
|
|
func (p *prog) detectLoop(msg *dns.Msg) {
|
|
if len(msg.Question) != 1 {
|
|
return
|
|
}
|
|
q := msg.Question[0]
|
|
if q.Qtype != loopTestQtype {
|
|
return
|
|
}
|
|
unFQDNname := strings.TrimSuffix(q.Name, ".")
|
|
uid := strings.TrimSuffix(unFQDNname, loopTestDomain)
|
|
p.loopMu.Lock()
|
|
if _, loop := p.loop[uid]; loop {
|
|
p.loop[uid] = loop
|
|
}
|
|
p.loopMu.Unlock()
|
|
}
|
|
|
|
// checkDnsLoop sends a message to check if there's any DNS forwarding loop
|
|
// with all the upstreams. The way it works based on dnsmasq --dns-loop-detect.
|
|
//
|
|
// - Generating a TXT test query and sending it to all upstream.
|
|
// - The test query is formed by upstream UID and test domain: <uid>.test
|
|
// - If the test query returns to ctrld, mark the corresponding upstream as loop (see p.detectLoop).
|
|
//
|
|
// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
|
func (p *prog) checkDnsLoop() {
|
|
mainLog.Load().Debug().Msg("start checking DNS loop")
|
|
upstream := make(map[string]*ctrld.UpstreamConfig)
|
|
// The probe loop below runs on the upstream UID, and the failure report
|
|
// needs the config key. Record the reference while the key is in hand.
|
|
reference := make(map[string]string)
|
|
p.loopMu.Lock()
|
|
for n, uc := range p.cfg.Upstream {
|
|
if p.um.isDown("upstream." + n) {
|
|
continue
|
|
}
|
|
// Do not send test query to external upstream.
|
|
if !canBeLocalUpstream(uc.Domain) {
|
|
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
|
|
continue
|
|
}
|
|
uid := uc.UID()
|
|
p.loop[uid] = false
|
|
upstream[uid] = uc
|
|
reference[uid] = upstreamPrefix + n
|
|
}
|
|
p.loopMu.Unlock()
|
|
|
|
for uid := range p.loop {
|
|
msg := loopTestMsg(uid)
|
|
uc := upstream[uid]
|
|
// Skipping upstream which is being marked as down.
|
|
if uc == nil {
|
|
continue
|
|
}
|
|
resolver, err := ctrld.NewResolver(uc)
|
|
if err != nil {
|
|
logUpstreamProbeFailure(reference[uid], uc, err, mainLog.Load().Warn, "could not perform loop check")
|
|
continue
|
|
}
|
|
// Bound the probe, like checkUpstreamOnce does. Without a deadline the
|
|
// DNS client falls back to its own default, so one unreachable local
|
|
// upstream stalls this serial loop far past the configured timeout.
|
|
timeout := defaultLoopCheckTimeout
|
|
if uc.Timeout > 0 {
|
|
timeout = time.Millisecond * time.Duration(uc.Timeout)
|
|
}
|
|
if err := resolveLoopTestMsg(resolver, msg, timeout); err != nil {
|
|
logUpstreamProbeFailure(reference[uid], uc, err, mainLog.Load().Warn, "could not send DNS loop check query")
|
|
}
|
|
}
|
|
mainLog.Load().Debug().Msg("end checking DNS loop")
|
|
}
|
|
|
|
// resolveLoopTestMsg sends one loop test query under its own deadline. The
|
|
// cancel runs before the next upstream, so a long loop leaks no contexts.
|
|
//
|
|
// The context deliberately carries no logger. A resolver logs its own failure
|
|
// at error level with the endpoint in the message, which would put an Internal
|
|
// Domain resolver address in the retained journal; logUpstreamProbeFailure is
|
|
// what reports this failure, and it bounds what the line may hold.
|
|
func resolveLoopTestMsg(resolver ctrld.Resolver, msg *dns.Msg, timeout time.Duration) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
_, err := resolver.Resolve(ctx, msg)
|
|
return err
|
|
}
|
|
|
|
// checkDnsLoopTicker performs p.checkDnsLoop every minute.
|
|
func (p *prog) checkDnsLoopTicker(ctx context.Context) {
|
|
timer := time.NewTicker(time.Minute)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-p.stopCh:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
case <-timer.C:
|
|
p.checkDnsLoop()
|
|
}
|
|
}
|
|
}
|
|
|
|
// loopTestMsg generates DNS message for checking loop.
|
|
func loopTestMsg(uid string) *dns.Msg {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion(dns.Fqdn(uid+loopTestDomain), loopTestQtype)
|
|
return msg
|
|
}
|