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

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

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

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