Make the resolver pure DNS: JSON access log on stdout instead of a log store
The resolver kept its own query-log store (a bbolt ring, a live ring with SSE fanout, rolling per-domain counters and dashboard time-series) for the console. In a multi-node deployment each pod held a different slice of that, so every view disagreed with itself, and the cost sat on the query path. Charts belong in Grafana and log search in the log collector, so the store is gone. internal/accesslog writes one JSON line per notable query to stdout. The default is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped, refused); log_cached and sample_rate add ordinary answers for a debugging session. Lines go through a bounded buffer to a single writer goroutine and are dropped, and counted in fancydns_access_log_dropped_total, when the stream cannot keep up, so a stalled log pipe can never stall a query. The config key is access_log (the old qlog block is ignored, like any unknown key). The metrics gain what the dashboard used to compute: fancydns_group_queries_total by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear, /api/cache/top, the series rings, and the cache's per-name hit table, which took a mutex and built a string on every cache hit. Tests that read the old log now read the access-log lines; the contract test drops the removed endpoints. The web console still calls them and is changed in the next commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dd1117fd91
commit
c6c5ca4ff7
31 files changed
+818
-3389
No files matched your search
+47
-33
@@ -23,13 +23,13 @@ import (
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/accesslog"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ type Request struct {
|
||||
type Response struct {
|
||||
Msg *dns.Msg
|
||||
// Action is the pipeline classification.
|
||||
Action qlog.Action
|
||||
Action accesslog.Action
|
||||
// Group is the matched ACL group.
|
||||
Group string
|
||||
// BlockSource is the blocklist/rule that blocked the query, for the log.
|
||||
@@ -111,7 +111,7 @@ type Engine struct {
|
||||
aclH *acl.Holder
|
||||
local *localzone.Holder
|
||||
pool atomic.Pointer[resolver.Pool]
|
||||
logger *qlog.Logger
|
||||
logger *accesslog.Logger
|
||||
reverse *ReverseCache
|
||||
|
||||
// concurrency limiter: a buffered channel as a semaphore, which is
|
||||
@@ -161,7 +161,7 @@ type Options struct {
|
||||
// Config is the hot-reloadable config store.
|
||||
Config *config.Store
|
||||
// Logger receives query entries; may be nil.
|
||||
Logger *qlog.Logger
|
||||
Logger *accesslog.Logger
|
||||
// Blocklists is the compiled blocklist holder.
|
||||
Blocklists *blocklist.Holder
|
||||
// Cache is the answer cache.
|
||||
@@ -236,7 +236,7 @@ func (e *Engine) applyConfig(cfg *config.Config) {
|
||||
}
|
||||
|
||||
// Logger exposes the query logger (may be nil).
|
||||
func (e *Engine) Logger() *qlog.Logger { return e.logger }
|
||||
func (e *Engine) Logger() *accesslog.Logger { return e.logger }
|
||||
|
||||
// Cache exposes the answer cache (may be nil).
|
||||
func (e *Engine) Cache() *cache.Manager { return e.cache }
|
||||
@@ -288,7 +288,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
if err == errNotImplemented {
|
||||
res.Msg = notImpl(q)
|
||||
}
|
||||
res.Action = qlog.ActionError
|
||||
res.Action = accesslog.ActionError
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
}
|
||||
@@ -300,7 +300,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
// denied a client means it.
|
||||
if !decision.Allows() {
|
||||
res.Msg = refuse(q, decision)
|
||||
res.Action = qlog.ActionRefused
|
||||
res.Action = accesslog.ActionRefused
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
if cl := decision.MaxConcurrent; cl > 0 {
|
||||
if !e.acquireClient(client, cl) {
|
||||
res.Msg = servFail(q, "client concurrency limit")
|
||||
res.Action = qlog.ActionError
|
||||
res.Action = accesslog.ActionError
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
}
|
||||
@@ -327,7 +327,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
default:
|
||||
e.dropped.Add(1)
|
||||
res.Msg = servFail(q, "server overloaded")
|
||||
res.Action = qlog.ActionError
|
||||
res.Action = accesslog.ActionError
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
// Rate limiting.
|
||||
if decision.RateLimit > 0 && !e.allowRate(client, decision.RateLimit) {
|
||||
res.Msg = refuse(q, decision)
|
||||
res.Action = qlog.ActionRefused
|
||||
res.Action = accesslog.ActionRefused
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
// Group-level domain policy.
|
||||
if e.groupBlocks(decision, qname) {
|
||||
res.Msg = e.finalize(blockedResponse(q, e.blockCfg(), nil), q, req)
|
||||
res.Action = qlog.ActionBlock
|
||||
res.Action = accesslog.ActionBlock
|
||||
res.BlockSource = "acl:" + decision.Group
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
@@ -354,7 +354,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
if store := e.local.Get(); store != nil && store.Count() > 0 {
|
||||
if local, ok := e.answerLocal(q, store); ok {
|
||||
res.Msg = e.finalize(local, q, req)
|
||||
res.Action = qlog.ActionAllow
|
||||
res.Action = accesslog.ActionAllow
|
||||
// Authoritative local data is ActionAllow for the query log (the
|
||||
// query was permitted and answered), but it is its own metrics
|
||||
// outcome: counting it as upstream inflated the upstream column
|
||||
@@ -375,7 +375,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
if blocked != nil && !blocked.IsAllow() {
|
||||
switch blocked.Action {
|
||||
case blocklist.ActionDrop:
|
||||
res.Action = qlog.ActionDrop
|
||||
res.Action = accesslog.ActionDrop
|
||||
res.BlockSource = blocked.Source
|
||||
res.Drop = true
|
||||
res.Duration = time.Since(start)
|
||||
@@ -383,14 +383,14 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
return res
|
||||
case blocklist.ActionRedirect, blocklist.ActionCNAME:
|
||||
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
|
||||
res.Action = qlog.ActionRedirect
|
||||
res.Action = accesslog.ActionRedirect
|
||||
res.BlockSource = blocked.Source
|
||||
res.CacheHit = false
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
default:
|
||||
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
|
||||
res.Action = qlog.ActionBlock
|
||||
res.Action = accesslog.ActionBlock
|
||||
res.BlockSource = blocked.Source
|
||||
res.CacheHit = false
|
||||
e.finish(req, res, start, decision)
|
||||
@@ -414,7 +414,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
metrics.RecordStaleHit()
|
||||
}
|
||||
res.Msg = e.finalize(res.Msg, q, req)
|
||||
res.Action = qlog.ActionCached
|
||||
res.Action = accesslog.ActionCached
|
||||
if hit.Kind == cache.Stale {
|
||||
// The cached answer is expired, so its TTL is 0; hand back a
|
||||
// short one so clients do not cache the stale copy forever.
|
||||
@@ -432,7 +432,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
pool := e.pool.Load()
|
||||
if pool == nil {
|
||||
res.Msg = servFail(q, "no upstream configured")
|
||||
res.Action = qlog.ActionError
|
||||
res.Action = accesslog.ActionError
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
}
|
||||
@@ -450,7 +450,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
if err != nil || up == nil {
|
||||
metrics.RecordUpstreamError()
|
||||
res.Msg = servFail(q, "upstream failure: "+shortErr(err))
|
||||
res.Action = qlog.ActionError
|
||||
res.Action = accesslog.ActionError
|
||||
e.finish(req, res, start, decision)
|
||||
return res
|
||||
}
|
||||
@@ -466,13 +466,13 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
reply := e.finalize(e.fromUpstream(q, up, req), q, req)
|
||||
res.Msg = reply
|
||||
res.Upstream = addr
|
||||
res.Action = qlog.ActionAllow
|
||||
res.Action = accesslog.ActionAllow
|
||||
// A non-success RCODE is reported as an error through finish's metrics
|
||||
// call, so the counters stay in one place.
|
||||
if reply.Rcode != dns.RcodeServerFailure && reply.Rcode != dns.RcodeSuccess {
|
||||
res.Action = qlog.ActionError
|
||||
res.Action = accesslog.ActionError
|
||||
} else if reply.Rcode == dns.RcodeServerFailure {
|
||||
res.Action = qlog.ActionError
|
||||
res.Action = accesslog.ActionError
|
||||
}
|
||||
if e.cache != nil && !decision.SkipCache {
|
||||
key := cache.QueryKey(qname, qtype, q.Question[0].Qclass, cache.Do(q), 0)
|
||||
@@ -486,27 +486,33 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
||||
return res
|
||||
}
|
||||
|
||||
// finish attaches duration, records metrics and writes the query log.
|
||||
// finish attaches duration, records metrics and writes the access log line when the outcome calls for one.
|
||||
func (e *Engine) finish(req Request, res Response, start time.Time, d acl.Decision) {
|
||||
if res.Duration == 0 {
|
||||
res.Duration = time.Since(start)
|
||||
}
|
||||
metrics.Record(req.Transport, outcomeFor(res), res.Duration)
|
||||
if res.Action == qlog.ActionAllow && res.CacheHit {
|
||||
if res.Action == accesslog.ActionAllow && res.CacheHit {
|
||||
metrics.Record(req.Transport, metrics.OutcomeCache, res.Duration)
|
||||
}
|
||||
e.groupStats.observe(d.Group, res)
|
||||
metrics.RecordGroupQuery(d.Group, outcomeFor(res))
|
||||
rcode := "none"
|
||||
if res.Msg != nil {
|
||||
rcode = rcodeLabel(res.Msg.Rcode)
|
||||
}
|
||||
metrics.RecordRcode(rcode)
|
||||
if e.logger == nil {
|
||||
return
|
||||
}
|
||||
if !e.logger.ShouldLog(res.Action) && !d.LogQueries {
|
||||
return
|
||||
}
|
||||
entry := qlog.Entry{
|
||||
entry := accesslog.Entry{
|
||||
TS: start,
|
||||
Client: res.Client,
|
||||
Group: res.Group,
|
||||
Duration: res.Duration.Milliseconds(),
|
||||
Duration: res.Duration,
|
||||
Action: res.Action,
|
||||
Upstream: res.Upstream,
|
||||
List: res.BlockSource,
|
||||
@@ -518,6 +524,14 @@ func (e *Engine) finish(req Request, res Response, start time.Time, d acl.Decisi
|
||||
e.logger.Log(entry)
|
||||
}
|
||||
|
||||
// rcodeLabel is the metric label for an RCODE.
|
||||
func rcodeLabel(c int) string {
|
||||
if s, ok := dns.RcodeToString[c]; ok {
|
||||
return s
|
||||
}
|
||||
return "OTHER"
|
||||
}
|
||||
|
||||
// outcomeFor maps a finished query onto its metrics outcome. The action alone
|
||||
// is not enough: ActionAllow covers both an upstream answer and a local-zone
|
||||
// answer, and the two are different things for an operator reading the
|
||||
@@ -527,15 +541,15 @@ func outcomeFor(res Response) string {
|
||||
return metrics.OutcomeLocal
|
||||
}
|
||||
switch res.Action {
|
||||
case qlog.ActionCached:
|
||||
case accesslog.ActionCached:
|
||||
return metrics.OutcomeCache
|
||||
case qlog.ActionBlock:
|
||||
case accesslog.ActionBlock:
|
||||
return metrics.OutcomeBlocked
|
||||
case qlog.ActionRedirect:
|
||||
case accesslog.ActionRedirect:
|
||||
return metrics.OutcomeLocal
|
||||
case qlog.ActionError, qlog.ActionDrop:
|
||||
case accesslog.ActionError, accesslog.ActionDrop:
|
||||
return metrics.OutcomeError
|
||||
case qlog.ActionRefused:
|
||||
case accesslog.ActionRefused:
|
||||
return metrics.OutcomeRefused
|
||||
default:
|
||||
return metrics.OutcomeUpstream
|
||||
@@ -1174,11 +1188,11 @@ func (t *groupStatTable) observe(group string, res Response) {
|
||||
}
|
||||
s.queries++
|
||||
switch res.Action {
|
||||
case qlog.ActionBlock:
|
||||
case accesslog.ActionBlock:
|
||||
s.blocked++
|
||||
case qlog.ActionError, qlog.ActionDrop:
|
||||
case accesslog.ActionError, accesslog.ActionDrop:
|
||||
s.errors++
|
||||
case qlog.ActionCached:
|
||||
case accesslog.ActionCached:
|
||||
s.cache++
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
Reference in new issue
Block a user