From e45ad5f87ae2d5e097523a6f97d3257f86ad9491 Mon Sep 17 00:00:00 2001 From: Immanuel Tikhonov <122638311+immanuwell@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:31:31 +0400 Subject: [PATCH] fix(local): handle names under .localhost. (#8151) --- man/coredns-local.7 | 45 ++++++++++++++++------ plugin/local/README.md | 33 +++++++++++----- plugin/local/local.go | 10 +++-- plugin/local/local_test.go | 77 ++++++++++++++++++++++++++++++++++++-- plugin/local/metrics.go | 4 +- plugin/local/setup.go | 42 +++++++++++++++++---- plugin/local/setup_test.go | 38 +++++++++++++++++++ 7 files changed, 211 insertions(+), 38 deletions(-) diff --git a/man/coredns-local.7 b/man/coredns-local.7 index 4f0611b19..bd0651cec 100644 --- a/man/coredns-local.7 +++ b/man/coredns-local.7 @@ -1,24 +1,26 @@ -.\" Generated by Mmark Markdown Processer - mmark.miek.nl -.TH "COREDNS-LOCAL" 7 "March 2026" "CoreDNS" "CoreDNS Plugins" +.\" Generated by Mmark Markdown Processor - mmark.miek.nl +.TH "COREDNS-LOCAL" 7 "June 2026" "CoreDNS" "CoreDNS Plugins" .SH "NAME" .PP -\fIlocal\fP - respond to local names. +\fIlocal\fP \- respond to local names. .SH "DESCRIPTION" .PP -\fIlocal\fP will respond with a basic reply to a "local request". Local request are defined to be -names in the following zones: localhost, 0.in-addr.arpa, 127.in-addr.arpa and 255.in-addr.arpa \fIand\fP -any query asking for \fB\fClocalhost.\fR. When seeing the latter a metric counter is increased and -if \fIdebug\fP is enabled a debug log is emitted. +\fIlocal\fP will respond with a basic reply to a "local request". Local requests are defined to be +names in the following zones: localhost, 0.in\-addr.arpa, 127.in\-addr.arpa and 255.in\-addr.arpa, +any query under \fB\fC.localhost.\fR, and, by default for backward compatibility, any query prefixed by +\fB\fClocalhost.\fR. When seeing one of the non\-apex localhost forms a metric counter is increased and if +\fIdebug\fP is enabled a debug log is emitted. .PP -With \fIlocal\fP enabled any query falling under these zones will get a reply. The prevents the query +With \fIlocal\fP enabled any query falling under these zones will get a reply. This prevents the query from "escaping" to the internet and putting strain on external infrastructure. .PP -The zones are mostly empty, only \fB\fClocalhost.\fR address records (A and AAAA) are defined and a -\fB\fC1.0.0.127.in-addr.arpa.\fR reverse (PTR) record. +The zones are mostly empty, only \fB\fClocalhost.\fR, names under \fB\fC.localhost.\fR, and legacy +\fB\fClocalhost.\fR names return loopback address records (A and AAAA), and only +\fB\fC1.0.0.127.in-addr.arpa.\fR has a reverse (PTR) record. .SH "SYNTAX" .PP @@ -30,13 +32,32 @@ local .fi .RE +.PP +.RS + +.nf +local { + localhost\_prefix on|off +} + +.fi +.RE + +.PP +\fB\fClocalhost_prefix\fR controls the legacy \fB\fClocalhost.\fR behavior. The default is \fB\fCon\fR for +backward compatibility. Set it to \fB\fCoff\fR to only treat names under \fB\fC.localhost.\fR as special, which +matches RFC 6761. The legacy prefix behavior is deprecated and may be disabled by default in a +future release. + .SH "METRICS" .PP If monitoring is enabled (via the \fIprometheus\fP plugin) then the following metric is exported: .IP \(bu 4 -\fB\fCcoredns_local_localhost_requests_total{}\fR - a counter of the number of \fB\fClocalhost.\fR -requests CoreDNS has seen. Note this does \fInot\fP count \fB\fClocalhost.\fR queries. +\fB\fCcoredns_local_localhost_requests_total{}\fR \- a counter of the number of non\-apex localhost +special\-case queries CoreDNS has seen. This includes \fB\fC.localhost.\fR names and, when +\fB\fClocalhost_prefix\fR is \fB\fCon\fR, legacy \fB\fClocalhost.\fR names. It does \fInot\fP count \fB\fClocalhost.\fR +queries. .PP diff --git a/plugin/local/README.md b/plugin/local/README.md index 08fff0103..944111822 100644 --- a/plugin/local/README.md +++ b/plugin/local/README.md @@ -6,16 +6,18 @@ ## Description -*local* will respond with a basic reply to a "local request". Local request are defined to be -names in the following zones: localhost, 0.in-addr.arpa, 127.in-addr.arpa and 255.in-addr.arpa *and* -any query asking for `localhost.`. When seeing the latter a metric counter is increased and -if *debug* is enabled a debug log is emitted. +*local* will respond with a basic reply to a "local request". Local requests are defined to be +names in the following zones: localhost, 0.in-addr.arpa, 127.in-addr.arpa and 255.in-addr.arpa, +any query under `.localhost.`, and, by default for backward compatibility, any query prefixed by +`localhost.`. When seeing one of the non-apex localhost forms a metric counter is increased and if +*debug* is enabled a debug log is emitted. -With *local* enabled any query falling under these zones will get a reply. The prevents the query +With *local* enabled any query falling under these zones will get a reply. This prevents the query from "escaping" to the internet and putting strain on external infrastructure. -The zones are mostly empty, only `localhost.` address records (A and AAAA) are defined and a -`1.0.0.127.in-addr.arpa.` reverse (PTR) record. +The zones are mostly empty, only `localhost.`, names under `.localhost.`, and legacy +`localhost.` names return loopback address records (A and AAAA), and only +`1.0.0.127.in-addr.arpa.` has a reverse (PTR) record. ## Syntax @@ -23,12 +25,25 @@ The zones are mostly empty, only `localhost.` address records (A and AAAA) are d local ~~~ +~~~ txt +local { + localhost_prefix on|off +} +~~~ + +`localhost_prefix` controls the legacy `localhost.` behavior. The default is `on` for +backward compatibility. Set it to `off` to only treat names under `.localhost.` as special, which +matches RFC 6761. The legacy prefix behavior is deprecated and may be disabled by default in a +future release. + ## Metrics If monitoring is enabled (via the *prometheus* plugin) then the following metric is exported: -* `coredns_local_localhost_requests_total{}` - a counter of the number of `localhost.` - requests CoreDNS has seen. Note this does *not* count `localhost.` queries. +* `coredns_local_localhost_requests_total{}` - a counter of the number of non-apex localhost + special-case queries CoreDNS has seen. This includes `.localhost.` names and, when + `localhost_prefix` is `on`, legacy `localhost.` names. It does *not* count `localhost.` + queries. Note that this metric *does not* have a `server` label, because it's more interesting to find the client(s) performing these queries than to see which server handled it. You'll need to inspect the diff --git a/plugin/local/local.go b/plugin/local/local.go index 570f113da..d7ceae424 100644 --- a/plugin/local/local.go +++ b/plugin/local/local.go @@ -16,7 +16,8 @@ var log = clog.NewWithPlugin("local") // Local is a plugin that returns standard replies for local queries. type Local struct { - Next plugin.Handler + Next plugin.Handler + disableLocalhostPrefix bool } var zones = []string{"localhost.", "0.in-addr.arpa.", "127.in-addr.arpa.", "255.in-addr.arpa."} @@ -37,8 +38,11 @@ func (l Local) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) ( qname := state.QName() lc := len("localhost.") - if len(state.Name()) > lc && strings.HasPrefix(state.Name(), "localhost.") { - // we have multiple labels, but the first one is localhost, intercept this and return 127.0.0.1 or ::1 + name := state.Name() + matchesRFCSubdomain := len(name) > lc && strings.HasSuffix(name, ".localhost.") + matchesLegacyPrefix := len(name) > lc && !l.disableLocalhostPrefix && strings.HasPrefix(name, "localhost.") + if matchesRFCSubdomain || matchesLegacyPrefix { + // Intercept special localhost subdomain queries and return loopback addresses for A/AAAA queries. log.Debugf("Intercepting localhost query for %q %s, from %s", state.Name(), state.Type(), state.IP()) LocalhostCount.Inc() reply := doLocalhost(state) diff --git a/plugin/local/local_test.go b/plugin/local/local_test.go index 8e1561ad4..baf8c24a0 100644 --- a/plugin/local/local_test.go +++ b/plugin/local/local_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/coredns/coredns/plugin" "github.com/coredns/coredns/plugin/pkg/dnstest" "github.com/coredns/coredns/plugin/test" @@ -24,13 +25,12 @@ var testcases = []struct { {"localhost.", dns.TypeSOA, dns.RcodeSuccess, test.SOA("localhost. IN SOA root.localhost. localhost. 1 0 0 0 0"), nil}, {"127.in-addr.arpa.", dns.TypeA, dns.RcodeSuccess, nil, test.SOA("127.in-addr.arpa. IN SOA root.localhost. localhost. 1 0 0 0 0")}, {"localhost.", dns.TypeMX, dns.RcodeSuccess, nil, test.SOA("localhost. IN SOA root.localhost. localhost. 1 0 0 0 0")}, - {"a.localhost.", dns.TypeA, dns.RcodeNameError, nil, test.SOA("localhost. IN SOA root.localhost. localhost. 1 0 0 0 0")}, + {"a.localhost.", dns.TypeA, dns.RcodeSuccess, test.A("a.localhost. IN A 127.0.0.1"), nil}, + {"a.localhost.", dns.TypeAAAA, dns.RcodeSuccess, test.AAAA("a.localhost. IN AAAA ::1"), nil}, + {"a.localhost.", dns.TypeMX, dns.RcodeSuccess, nil, test.SOA("a.localhost. IN SOA root.localhost. localhost. 1 0 0 0 0")}, {"1.0.0.127.in-addr.arpa.", dns.TypePTR, dns.RcodeSuccess, test.PTR("1.0.0.127.in-addr.arpa. IN PTR localhost."), nil}, {"1.0.0.127.in-addr.arpa.", dns.TypeMX, dns.RcodeSuccess, nil, test.SOA("127.in-addr.arpa. IN SOA root.localhost. localhost. 1 0 0 0 0")}, {"2.0.0.127.in-addr.arpa.", dns.TypePTR, dns.RcodeNameError, nil, test.SOA("127.in-addr.arpa. IN SOA root.localhost. localhost. 1 0 0 0 0")}, - {"localhost.example.net.", dns.TypeA, dns.RcodeSuccess, test.A("localhost.example.net. IN A 127.0.0.1"), nil}, - {"localhost.example.net.", dns.TypeAAAA, dns.RcodeSuccess, test.AAAA("localhost.example.net IN AAAA ::1"), nil}, - {"localhost.example.net.", dns.TypeSOA, dns.RcodeSuccess, nil, test.SOA("localhost.example.net. IN SOA root.localhost.example.net. localhost.example.net. 1 0 0 0 0")}, } func TestLocal(t *testing.T) { @@ -75,3 +75,72 @@ func TestLocal(t *testing.T) { } } } + +func TestLocalInterceptsLocalhostPrefixByDefault(t *testing.T) { + req := new(dns.Msg) + req.SetQuestion("localhost.example.net.", dns.TypeA) + + nextCalled := false + l := Local{Next: plugin.HandlerFunc(func(_ context.Context, _ dns.ResponseWriter, _ *dns.Msg) (int, error) { + nextCalled = true + return dns.RcodeSuccess, nil + })} + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := l.ServeDNS(context.TODO(), rec, req) + if err != nil { + t.Fatalf("expected no error, got %q", err) + } + if nextCalled { + t.Fatal("expected legacy localhost prefix query to be intercepted by default") + } + if rec.Msg.Rcode != dns.RcodeSuccess { + t.Fatalf("expected rcode %d, got %d", dns.RcodeSuccess, rec.Msg.Rcode) + } + if len(rec.Msg.Answer) != 1 { + t.Fatalf("expected 1 answer RR, got %d", len(rec.Msg.Answer)) + } + if got := rec.Msg.Answer[0].Header().Name; got != "localhost.example.net." { + t.Fatalf("expected answer name %q, got %q", "localhost.example.net.", got) + } +} + +func TestLocalDoesNotInterceptLocalhostPrefixWhenDisabled(t *testing.T) { + req := new(dns.Msg) + req.SetQuestion("localhost.example.net.", dns.TypeA) + + expected := test.A("localhost.example.net. IN A 192.0.2.1") + nextCalled := false + l := Local{ + disableLocalhostPrefix: true, + Next: plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + nextCalled = true + + m := new(dns.Msg) + m.SetReply(r) + m.Answer = []dns.RR{expected} + if err := w.WriteMsg(m); err != nil { + return dns.RcodeServerFailure, err + } + return dns.RcodeSuccess, nil + }), + } + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := l.ServeDNS(context.TODO(), rec, req) + if err != nil { + t.Fatalf("expected no error, got %q", err) + } + if !nextCalled { + t.Fatal("expected query to fall through to the next plugin") + } + if rec.Msg.Rcode != dns.RcodeSuccess { + t.Fatalf("expected rcode %d, got %d", dns.RcodeSuccess, rec.Msg.Rcode) + } + if len(rec.Msg.Answer) != 1 { + t.Fatalf("expected 1 answer RR, got %d", len(rec.Msg.Answer)) + } + if got := rec.Msg.Answer[0].Header().Name; got != expected.Header().Name { + t.Fatalf("expected answer name %q, got %q", expected.Header().Name, got) + } +} diff --git a/plugin/local/metrics.go b/plugin/local/metrics.go index 361f9ab33..2863a72c9 100644 --- a/plugin/local/metrics.go +++ b/plugin/local/metrics.go @@ -8,11 +8,11 @@ import ( ) var ( - // LocalhostCount report the number of times we've seen a localhost. query. + // LocalhostCount reports the number of times we've seen a non-apex localhost special-case query. LocalhostCount = promauto.NewCounter(prometheus.CounterOpts{ Namespace: plugin.Namespace, Subsystem: "local", Name: "localhost_requests_total", - Help: "Counter of localhost. requests.", + Help: "Counter of special localhost subdomain requests.", }) ) diff --git a/plugin/local/setup.go b/plugin/local/setup.go index 798a69c4d..c219c326c 100644 --- a/plugin/local/setup.go +++ b/plugin/local/setup.go @@ -9,15 +9,10 @@ import ( func init() { plugin.Register("local", setup) } func setup(c *caddy.Controller) error { - c.Next() // 'local' - if c.NextArg() { - return plugin.Error("local", c.ArgErr()) + l, err := parse(c) + if err != nil { + return plugin.Error("local", err) } - if c.NextBlock() { - return plugin.Error("local", c.Errf("unknown property '%s'", c.Val())) - } - - l := Local{} dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { l.Next = next @@ -26,3 +21,34 @@ func setup(c *caddy.Controller) error { return nil } + +func parse(c *caddy.Controller) (Local, error) { + l := Local{} + + for c.Next() { + if len(c.RemainingArgs()) != 0 { + return l, c.ArgErr() + } + for c.NextBlock() { + switch c.Val() { + case "localhost_prefix": + args := c.RemainingArgs() + if len(args) != 1 { + return l, c.ArgErr() + } + switch args[0] { + case "on": + l.disableLocalhostPrefix = false + case "off": + l.disableLocalhostPrefix = true + default: + return l, c.Errf("localhost_prefix expects 'on' or 'off', got %q", args[0]) + } + default: + return l, c.Errf("unknown property '%s'", c.Val()) + } + } + } + + return l, nil +} diff --git a/plugin/local/setup_test.go b/plugin/local/setup_test.go index a3a3ccb44..f3532a763 100644 --- a/plugin/local/setup_test.go +++ b/plugin/local/setup_test.go @@ -1,10 +1,14 @@ package local import ( + "context" "testing" "github.com/coredns/caddy" "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/miekg/dns" ) func TestSetup(t *testing.T) { @@ -32,3 +36,37 @@ func TestSetupRejectsBlockOptions(t *testing.T) { t.Fatal("expected error for unexpected block option, got nil") } } + +func TestSetupLocalhostPrefixOption(t *testing.T) { + c := caddy.NewTestController("dns", `local { + localhost_prefix off + }`) + if err := setup(c); err != nil { + t.Fatalf("expected no errors, but got: %v", err) + } + + cfg := dnsserver.GetConfig(c) + if len(cfg.Plugin) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(cfg.Plugin)) + } + + handler := cfg.Plugin[0](plugin.HandlerFunc(func(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) { + return 0, nil + })) + l, ok := handler.(Local) + if !ok { + t.Fatalf("expected Local handler, got %T", handler) + } + if !l.disableLocalhostPrefix { + t.Fatal("expected localhost_prefix off to disable legacy localhost prefix handling") + } +} + +func TestSetupRejectsInvalidLocalhostPrefixValue(t *testing.T) { + c := caddy.NewTestController("dns", `local { + localhost_prefix maybe + }`) + if err := setup(c); err == nil { + t.Fatal("expected error for invalid localhost_prefix value, got nil") + } +}