fix(local): handle names under .localhost. (#8151)

This commit is contained in:
Immanuel Tikhonov
2026-06-24 13:31:31 +04:00
committed by GitHub
parent faeb8ba699
commit e45ad5f87a
7 changed files with 211 additions and 38 deletions

View File

@@ -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.<domain>\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.<domain>\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.<domain>\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.<domain>\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.<domain>\fR names. It does \fInot\fP count \fB\fClocalhost.\fR
queries.
.PP

View File

@@ -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.<domain>`. 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.<domain>` 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.<domain>` 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.<domain>`
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.<domain>` 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

View File

@@ -17,6 +17,7 @@ var log = clog.NewWithPlugin("local")
// Local is a plugin that returns standard replies for local queries.
type Local struct {
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)

View File

@@ -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)
}
}

View File

@@ -8,11 +8,11 @@ import (
)
var (
// LocalhostCount report the number of times we've seen a localhost.<domain> 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.<domain> requests.",
Help: "Counter of special localhost subdomain requests.",
})
)

View File

@@ -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
}

View File

@@ -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")
}
}