core/dnsserver: preserve UDP packet destination addresses (#8644)

* core/dnsserver: preserve UDP packet destination addresses

Signed-off-by: houyuwushang <180804215+houyuwushang@users.noreply.github.com>

* test: modernize reverse UDP reply traversal

Signed-off-by: houyuwushang <180804215+houyuwushang@users.noreply.github.com>

* test: account for Unix IPv4 source-control capabilities

Signed-off-by: houyuwushang <180804215+houyuwushang@users.noreply.github.com>

---------

Signed-off-by: houyuwushang <180804215+houyuwushang@users.noreply.github.com>
This commit is contained in:
houyuwushang
2026-10-21 11:34:18 +08:00
committed by GitHub
parent e7f823b261
commit 0fe7b08fbb
12 changed files with 1026 additions and 3 deletions

View File

@@ -69,6 +69,9 @@ jobs:
- name: Test manual-registration mode
run: go test -race -tags=coredns_manual_registration ./core/dnsserver ./coremain
- name: Test UDP destination views
run: go test -race ./test -run '^TestViewServerIP'
test-plugins:
name: Test Plugins
strategy:
@@ -94,6 +97,28 @@ jobs:
shell: bash
run: ( cd plugin; go test -race ./... )
test-udp-macos:
name: Test UDP macOS
runs-on: macos-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: .go-version
- name: Add a second loopback address
run: sudo ifconfig lo0 alias 127.0.0.2
- name: Test UDP destination addresses
run: |
go test -race ./core/dnsserver ./plugin/pkg/proxyproto ./request
go test -race ./test -run '^TestViewServerIP'
test-e2e:
name: Test e2e
runs-on: ubuntu-latest

View File

@@ -240,6 +240,7 @@ func (s *Server) Serve(l net.Listener) error {
// ServePacket starts the server with an existing packetconn. It blocks until the server stops.
// This implements caddy.UDPServer interface.
func (s *Server) ServePacket(p net.PacketConn) error {
p = withUDPDestination(p)
// Use a custom writer decorator if one was configured.
var dw dns.DecorateWriter
if s.udpDecorateWriterFunc != nil {
@@ -249,7 +250,7 @@ func (s *Server) ServePacket(p net.PacketConn) error {
s.server[udp] = &dns.Server{PacketConn: p, Net: "udp", Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
ctx := context.WithValue(context.Background(), Key{}, s)
ctx = context.WithValue(ctx, LoopKey{}, 0)
s.ServeDNS(ctx, w, r)
s.ServeDNS(ctx, withUDPLocalAddr(w), r)
}), TsigSecret: s.TsigSecret, MsgAcceptFunc: s.msgAcceptFunc(), DecorateWriter: dw}
s.m.Unlock()

131
core/dnsserver/udp.go Normal file
View File

@@ -0,0 +1,131 @@
package dnsserver
import (
"net"
"strconv"
cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto"
"github.com/miekg/dns"
"github.com/pires/go-proxyproto"
)
// withUDPDestination preserves the destination of each packet on wildcard UDP
// listeners. Keep the original listener in Caddy: ServePacket also receives
// sockets inherited during a reload and must install the adapter there.
func withUDPDestination(p net.PacketConn) net.PacketConn {
if proxy, ok := p.(*cproxyproto.PacketConn); ok {
wrapped := *proxy
wrapped.PacketConn = withUDPDestination(proxy.PacketConn)
if proxy.ConnPolicy != nil {
wrapped.ConnPolicy = func(options proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
// Policy callbacks must keep seeing the native transport peer.
if packet, ok := options.Upstream.(*udpDestinationAddr); ok {
options.Upstream = packet.UDPAddr
}
return proxy.ConnPolicy(options)
}
}
return &wrapped
}
conn, ok := p.(*net.UDPConn)
if !ok {
return p
}
local := conn.LocalAddr().(*net.UDPAddr)
// Bound sockets already report their local address. In particular, avoid
// sending packet control messages on bound Darwin sockets (miekg/dns#724).
if !local.IP.IsUnspecified() || !enableUDPPacketInfo(conn) {
return p
}
return &udpDestinationConn{UDPConn: conn, local: *local}
}
type udpDestinationConn struct {
*net.UDPConn
local net.UDPAddr
}
type udpPacketInfo struct {
dst net.IP
ifIndex int
}
// Keep normal route selection for unicast replies. Scoped IPv6 addresses also
// need their interface; a global address must not force the incoming route.
func (p udpPacketInfo) replyInterface() int {
if p.dst.To4() == nil && p.dst.IsLinkLocalUnicast() {
return p.ifIndex
}
return 0
}
// udpDestinationAddr travels through dns.Server's generic PacketConn path,
// which retains the ReadFrom address for both RemoteAddr and WriteTo. All
// packet information belongs to this request, not to the shared listener.
type udpDestinationAddr struct {
*net.UDPAddr
local net.UDPAddr
info udpPacketInfo
}
func (a *udpDestinationAddr) LocalAddr() net.Addr { return &a.local }
func (c *udpDestinationConn) ReadFrom(b []byte) (int, net.Addr, error) {
oob := make([]byte, udpOOBSize)
n, oobn, flags, peer, err := c.ReadMsgUDP(b, oob)
if err != nil {
return n, peer, err
}
info := parseUDPPacketInfo(oob[:oobn], flags)
if info.dst == nil {
return n, peer, nil
}
if ip4 := info.dst.To4(); ip4 != nil {
info.dst = ip4
}
local := net.UDPAddr{IP: info.dst, Port: c.local.Port}
if info.dst.To4() == nil && (info.dst.IsLinkLocalUnicast() || info.dst.IsLinkLocalMulticast() || info.dst.IsInterfaceLocalMulticast()) && info.ifIndex > 0 {
local.Zone = strconv.Itoa(info.ifIndex)
}
return n, &udpDestinationAddr{UDPAddr: peer, local: local, info: info}, nil
}
func (c *udpDestinationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
if packet, ok := addr.(*udpDestinationAddr); ok {
n, _, err := c.WriteMsgUDP(b, udpSourceControl(packet.info), packet.UDPAddr)
if err == nil {
// Datagram writes are atomic. Windows can report zero bytes for a
// successful synchronous WSASendMsg on supported Go versions.
return len(b), nil
}
return n, err
}
return c.UDPConn.WriteTo(b, addr)
}
type udpResponseWriter struct {
dns.ResponseWriter
local, remote net.Addr
}
func (w *udpResponseWriter) LocalAddr() net.Addr { return w.local }
func (w *udpResponseWriter) RemoteAddr() net.Addr { return w.remote }
func withUDPLocalAddr(w dns.ResponseWriter) dns.ResponseWriter {
remote := w.RemoteAddr()
packet, ok := remote.(interface{ LocalAddr() net.Addr })
if !ok {
return w
}
local := packet.LocalAddr()
if local == nil {
return w
}
// Preserve the native peer type on plain UDP and the existing proxyproto
// address (including its client address and TLVs) on PROXY listeners.
if addr, ok := remote.(*udpDestinationAddr); ok {
remote = addr.UDPAddr
}
return &udpResponseWriter{ResponseWriter: w, local: local, remote: remote}
}

View File

@@ -0,0 +1,11 @@
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
package dnsserver
import "net"
const udpOOBSize = 0
func enableUDPPacketInfo(*net.UDPConn) bool { return false }
func parseUDPPacketInfo([]byte, int) udpPacketInfo { return udpPacketInfo{} }
func udpSourceControl(udpPacketInfo) []byte { return nil }

371
core/dnsserver/udp_test.go Normal file
View File

@@ -0,0 +1,371 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows
package dnsserver
import (
"bytes"
"context"
"encoding/hex"
"errors"
"net"
"runtime"
"slices"
"strconv"
"testing"
"time"
cproxyproto "github.com/coredns/coredns/plugin/pkg/proxyproto"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
"github.com/pires/go-proxyproto"
)
func udpTestConn(t *testing.T, network, address string) *net.UDPConn {
t.Helper()
addr, err := net.ResolveUDPAddr(network, address)
if err != nil {
t.Fatal(err)
}
conn, err := net.ListenUDP(network, addr)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
return conn
}
// Two requests from the same peer must keep independent destination addresses,
// even when their replies are sent in the opposite order.
func TestUDPDestinationReadWrite(t *testing.T) {
for _, tc := range []struct {
name, network, bind string
destinations []string
}{
{"ipv4", "udp4", "0.0.0.0:0", []string{"127.0.0.1", "127.0.0.2"}},
{"ipv6", "udp6", "[::]:0", []string{"::1", "::1"}},
{"dual_stack", "udp", "[::]:0", []string{"127.0.0.1", "::1"}},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.name == "dual_stack" && (runtime.GOOS == "openbsd" || runtime.GOOS == "dragonfly") {
t.Skip("the kernel requires IPv6-only sockets")
}
if tc.network != "udp4" {
probe, err := net.ListenPacket("udp6", "[::1]:0")
if err != nil {
t.Skipf("IPv6 loopback is unavailable: %v", err)
}
probe.Close()
}
if tc.name == "ipv4" {
switch runtime.GOOS {
case "windows":
// Winsock cannot select an unassigned 127.0.0.2 as the
// source. Use a second configured local address instead.
tc.destinations[1] = secondLocalIPv4(t)
case "aix", "dragonfly", "freebsd", "netbsd", "openbsd":
// x/net can receive IPv4 destinations but cannot marshal
// IPv4 source control on these systems. Preserve their
// existing routing behavior with one destination here.
tc.destinations[1] = tc.destinations[0]
default:
probe, err := net.ListenPacket("udp4", "127.0.0.2:0")
if err != nil {
t.Skipf("second loopback address is unavailable: %v", err)
}
probe.Close()
}
}
raw := udpTestConn(t, tc.network, tc.bind)
conn := withUDPDestination(raw)
if conn == raw {
t.Fatal("wildcard listener did not enable packet information")
}
port := raw.LocalAddr().(*net.UDPAddr).Port
peer := udpTestConn(t, tc.network, tc.bind)
var addresses []net.Addr
for i, dst := range tc.destinations {
payload := []byte{byte(i)}
if _, err := peer.WriteToUDP(payload, &net.UDPAddr{IP: net.ParseIP(dst), Port: port}); err != nil {
t.Fatal(err)
}
buf := make([]byte, 16)
n, addr, err := conn.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf[:n], payload) {
t.Fatalf("received %v, want %v", buf[:n], payload)
}
addresses = append(addresses, addr)
}
for i, addr := range slices.Backward(addresses) {
packet, ok := addr.(interface{ LocalAddr() net.Addr })
if !ok {
t.Fatalf("missing destination on %T", addr)
}
want := net.JoinHostPort(tc.destinations[i], strconv.Itoa(port))
if got := packet.LocalAddr().String(); got != want {
t.Errorf("destination = %s, want %s", got, want)
}
if n, err := conn.WriteTo([]byte{byte(i)}, addr); err != nil || n != 1 {
t.Fatalf("WriteTo = %d, %v, want 1 byte", n, err)
}
buf := make([]byte, 16)
n, source, err := peer.ReadFromUDP(buf)
if err != nil {
t.Fatal(err)
}
if n != 1 || buf[0] != byte(i) || source.String() != want {
t.Fatalf("reply = %v from %s, want [%d] from %s", buf[:n], source, i, want)
}
}
})
}
}
func secondLocalIPv4(t *testing.T) string {
t.Helper()
interfaces, err := net.Interfaces()
if err != nil {
t.Fatal(err)
}
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
t.Fatal(err)
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err == nil && ip.To4() != nil && ip.IsPrivate() && !ip.IsLoopback() {
return ip.String()
}
}
}
t.Skip("a second configured local IPv4 address is unavailable")
return ""
}
func TestUDPDestinationBoundSocket(t *testing.T) {
conn := udpTestConn(t, "udp4", "127.0.0.1:0")
if withUDPDestination(conn) != conn {
t.Fatal("explicitly bound listener should keep its existing UDP path")
}
addr := conn.LocalAddr().String()
peer := udpTestConn(t, "udp4", "127.0.0.1:0")
if _, err := peer.WriteToUDP([]byte("bound"), conn.LocalAddr().(*net.UDPAddr)); err != nil {
t.Fatal(err)
}
buf := make([]byte, 16)
n, from, err := conn.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if _, err := conn.WriteTo(buf[:n], from); err != nil {
t.Fatal(err)
}
if _, _, err := peer.ReadFrom(buf); err != nil {
t.Fatal(err)
}
if got := conn.LocalAddr().String(); got != addr {
t.Fatalf("socket binding changed after reply: %s -> %s", addr, got)
}
}
func TestUDPDestinationDeadlineAndClose(t *testing.T) {
raw := udpTestConn(t, "udp4", "0.0.0.0:0")
conn := withUDPDestination(raw)
if err := conn.SetReadDeadline(time.Now().Add(-time.Second)); err != nil {
t.Fatal(err)
}
_, _, err := conn.ReadFrom(make([]byte, 16))
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("expired deadline: %v", err)
}
if err := conn.SetReadDeadline(time.Time{}); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
_, _, err := conn.ReadFrom(make([]byte, 16))
done <- err
}()
conn.Close()
select {
case err := <-done:
if !errors.Is(err, net.ErrClosed) {
t.Fatalf("closed socket: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("closing the socket did not unblock ReadFrom")
}
}
func TestUDPDestinationMissingPacketInfo(t *testing.T) {
raw := udpTestConn(t, "udp4", "0.0.0.0:0")
// Deliberately leave packet-info socket options disabled.
conn := &udpDestinationConn{UDPConn: raw, local: *raw.LocalAddr().(*net.UDPAddr)}
peer := udpTestConn(t, "udp4", "127.0.0.1:0")
dst := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: conn.local.Port}
if _, err := peer.WriteToUDP([]byte("fallback"), dst); err != nil {
t.Fatal(err)
}
buf := make([]byte, 16)
n, addr, err := conn.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if _, ok := addr.(*net.UDPAddr); !ok {
t.Fatalf("missing metadata changed the peer address: %T", addr)
}
if conn.LocalAddr().String() != raw.LocalAddr().String() {
t.Fatal("missing metadata changed the listener address")
}
if count, err := conn.WriteTo(buf[:n], addr); err != nil || count != n {
t.Fatalf("fallback reply: %d, %v", count, err)
}
if _, _, err := peer.ReadFrom(buf); err != nil {
t.Fatal(err)
}
}
type udpAddressPlugin struct{}
func (udpAddressPlugin) Name() string { return "udp-address" }
func (udpAddressPlugin) ServeDNS(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
state := request.Request{W: w, Req: r}
rawTLVs := ""
if peer, ok := w.RemoteAddr().(*cproxyproto.Addr); ok {
rawTLVs = hex.EncodeToString(peer.RawTLVs())
}
m := new(dns.Msg).SetReply(r)
m.Answer = []dns.RR{&dns.TXT{
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET},
Txt: []string{state.LocalIP(), state.LocalPort(), state.IP(), state.Proto(), strconv.Itoa(state.Family()), rawTLVs},
}}
return dns.RcodeSuccess, w.WriteMsg(m)
}
func TestUDPDestinationServer(t *testing.T) {
for _, mode := range []string{"plain", "proxy", "proxy_local", "proxy_skip"} {
t.Run(mode, func(t *testing.T) {
raw := udpTestConn(t, "udp4", "0.0.0.0:0")
port := raw.LocalAddr().(*net.UDPAddr).Port
var pc net.PacketConn = raw
if mode != "plain" {
pc = &cproxyproto.PacketConn{PacketConn: raw, CaptureTLVs: true, UDPSessionTrackingTTL: time.Minute,
ConnPolicy: func(options proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
if _, ok := options.Upstream.(*net.UDPAddr); !ok {
t.Errorf("policy peer has type %T, want *net.UDPAddr", options.Upstream)
}
if options.Downstream.String() != raw.LocalAddr().String() {
t.Errorf("policy listener = %s, want %s", options.Downstream, raw.LocalAddr())
}
if mode == "proxy_skip" {
return proxyproto.SKIP, nil
}
return proxyproto.USE, nil
},
}
}
ready := make(chan struct{})
cfg := testConfig("dns", udpAddressPlugin{})
cfg.UDPDecorateWriterFunc = func(*Server) dns.DecorateWriter { close(ready); return nil }
srv, err := NewServer("0.0.0.0:0", []*Config{cfg})
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- srv.ServePacket(pc) }()
select {
case <-ready:
case <-time.After(5 * time.Second):
t.Fatal("UDP adapter did not start")
}
t.Cleanup(func() {
if err := srv.Stop(); err != nil {
t.Error(err)
}
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("UDP server did not stop")
}
})
conn, err := net.Dial("udp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
wantRemote, wantTLVs := "127.0.0.1", ""
if mode == "proxy" {
wantRemote, wantTLVs = "192.0.2.10", "ea00020102"
}
for i := range 2 {
m := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT)
packet, err := m.Pack()
if err != nil {
t.Fatal(err)
}
if i == 0 && (mode == "proxy" || mode == "proxy_local") {
header := &proxyproto.Header{Version: 2, Command: proxyproto.PROXY, TransportProtocol: proxyproto.UDPv4,
SourceAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.10"), Port: 1234},
DestinationAddr: &net.UDPAddr{IP: net.ParseIP("198.51.100.53"), Port: 53}}
if mode == "proxy_local" {
header.Command = proxyproto.LOCAL
}
if err := header.SetTLVs([]proxyproto.TLV{{Type: 0xEA, Value: []byte{1, 2}}}); err != nil {
t.Fatal(err)
}
prefix, err := header.Format()
if err != nil {
t.Fatal(err)
}
packet = append(prefix, packet...)
}
if _, err := conn.Write(packet); err != nil {
t.Fatal(err)
}
answer, err := (&dns.Conn{Conn: conn}).ReadMsg()
if err != nil {
t.Fatal(err)
}
if answer.Rcode != dns.RcodeSuccess || len(answer.Answer) != 1 {
t.Fatalf("unexpected response: %v", answer)
}
want := []string{"127.0.0.1", strconv.Itoa(port), wantRemote, "udp", "1", wantTLVs}
got := answer.Answer[0].(*dns.TXT).Txt
for j := range want {
if got[j] != want[j] {
t.Errorf("field %d = %q, want %q", j, got[j], want[j])
}
}
}
})
}
}
func FuzzUDPPacketInfo(f *testing.F) {
f.Add([]byte{})
f.Add([]byte{0xff, 0xff, 0xff, 0xff})
f.Add(udpSourceControl(udpPacketInfo{dst: net.ParseIP("127.0.0.1"), ifIndex: 1}))
f.Add(udpSourceControl(udpPacketInfo{dst: net.ParseIP("::1"), ifIndex: 1}))
f.Fuzz(func(t *testing.T, data []byte) {
info := parseUDPPacketInfo(data, 0)
if info.dst != nil && info.dst.To16() == nil {
t.Fatalf("invalid destination: %v", info.dst)
}
udpSourceControl(info)
})
}

View File

@@ -0,0 +1,45 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package dnsserver
import (
"net"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"golang.org/x/sys/unix"
)
var udpOOBSize = len(ipv4.NewControlMessage(ipv4.FlagDst|ipv4.FlagInterface)) +
len(ipv6.NewControlMessage(ipv6.FlagDst|ipv6.FlagInterface))
func enableUDPPacketInfo(conn *net.UDPConn) bool {
err4 := ipv4.NewPacketConn(conn).SetControlMessage(ipv4.FlagDst|ipv4.FlagInterface, true)
err6 := ipv6.NewPacketConn(conn).SetControlMessage(ipv6.FlagDst|ipv6.FlagInterface, true)
return err4 == nil || err6 == nil
}
func parseUDPPacketInfo(oob []byte, flags int) udpPacketInfo {
if flags&unix.MSG_CTRUNC != 0 {
return udpPacketInfo{}
}
cm6 := new(ipv6.ControlMessage)
if cm6.Parse(oob) == nil && cm6.Dst != nil {
return udpPacketInfo{dst: cm6.Dst, ifIndex: cm6.IfIndex}
}
cm4 := new(ipv4.ControlMessage)
if cm4.Parse(oob) == nil && cm4.Dst != nil {
return udpPacketInfo{dst: cm4.Dst, ifIndex: cm4.IfIndex}
}
return udpPacketInfo{}
}
func udpSourceControl(info udpPacketInfo) []byte {
if info.dst == nil || info.dst.IsUnspecified() || info.dst.IsMulticast() {
return nil
}
if ip4 := info.dst.To4(); ip4 != nil {
return (&ipv4.ControlMessage{Src: ip4, IfIndex: info.replyInterface()}).Marshal()
}
return (&ipv6.ControlMessage{Src: info.dst, IfIndex: info.replyInterface()}).Marshal()
}

View File

@@ -0,0 +1,107 @@
package dnsserver
import (
"encoding/binary"
"math"
"net"
"strconv"
"golang.org/x/sys/windows"
)
// WSACMSGHDR contains a native-size length followed by two 32-bit integers.
// Each control message is aligned to SIZE_T; its length excludes tail padding.
const udpCmsgWord = strconv.IntSize / 8
const udpCmsgHeader = udpCmsgWord + 8
var udpOOBSize = udpCmsgAlign(udpCmsgHeader+8) + udpCmsgAlign(udpCmsgHeader+20)
func udpCmsgAlign(n int) int { return (n + udpCmsgWord - 1) & ^(udpCmsgWord - 1) }
func enableUDPPacketInfo(conn *net.UDPConn) bool {
raw, err := conn.SyscallConn()
if err != nil {
return false
}
var err4, err6 error
err = raw.Control(func(fd uintptr) {
// Dual-stack sockets need both options to receive both families.
err4 = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IP, windows.IP_PKTINFO, 1)
err6 = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_PKTINFO, 1)
})
return err == nil && (err4 == nil || err6 == nil)
}
func parseUDPPacketInfo(oob []byte, flags int) udpPacketInfo {
if flags&windows.MSG_CTRUNC != 0 {
return udpPacketInfo{}
}
for len(oob) >= udpCmsgHeader {
var length uint64
if udpCmsgWord == 8 {
length = binary.NativeEndian.Uint64(oob)
} else {
length = uint64(binary.NativeEndian.Uint32(oob))
}
if length < udpCmsgHeader || length > uint64(len(oob)) || length > math.MaxInt32 {
return udpPacketInfo{}
}
level := binary.NativeEndian.Uint32(oob[udpCmsgWord:])
kind := binary.NativeEndian.Uint32(oob[udpCmsgWord+4:])
data := oob[udpCmsgHeader:length]
ipLen := 0
switch {
case level == windows.IPPROTO_IP && kind == windows.IP_PKTINFO:
ipLen = net.IPv4len
case level == windows.IPPROTO_IPV6 && kind == windows.IPV6_PKTINFO:
ipLen = net.IPv6len
}
if ipLen != 0 {
if len(data) < ipLen+4 {
return udpPacketInfo{}
}
ifIndex := binary.NativeEndian.Uint32(data[ipLen:])
if ifIndex > math.MaxInt32 {
return udpPacketInfo{}
}
return udpPacketInfo{dst: net.IP(data[:ipLen]), ifIndex: int(ifIndex)}
}
next := udpCmsgAlign(int(length))
if next < int(length) || next > len(oob) {
break
}
oob = oob[next:]
}
return udpPacketInfo{}
}
func udpSourceControl(info udpPacketInfo) []byte {
if info.dst == nil || info.dst.IsUnspecified() || info.dst.IsMulticast() {
return nil
}
ifIndex := info.replyInterface()
if ifIndex < 0 || ifIndex > math.MaxInt32 {
return nil
}
ip := info.dst.To4()
level, kind := uint32(windows.IPPROTO_IP), uint32(windows.IP_PKTINFO)
if ip == nil {
ip = info.dst.To16()
level, kind = windows.IPPROTO_IPV6, windows.IPV6_PKTINFO
}
if ip == nil {
return nil
}
length := udpCmsgHeader + len(ip) + 4
oob := make([]byte, udpCmsgAlign(length))
if udpCmsgWord == 8 {
binary.NativeEndian.PutUint64(oob, uint64(length))
} else {
binary.NativeEndian.PutUint32(oob, uint32(length)) //nolint:gosec // Header plus a 4- or 16-byte IP and a 4-byte interface.
}
binary.NativeEndian.PutUint32(oob[udpCmsgWord:], level)
binary.NativeEndian.PutUint32(oob[udpCmsgWord+4:], kind)
copy(oob[udpCmsgHeader:], ip)
binary.NativeEndian.PutUint32(oob[udpCmsgHeader+len(ip):], uint32(ifIndex))
return oob
}

View File

@@ -0,0 +1,74 @@
package dnsserver
import (
"bytes"
"encoding/binary"
"encoding/hex"
"net"
"testing"
"golang.org/x/sys/windows"
)
func TestUDPPacketInfoWindows(t *testing.T) {
// Native WSACMSGHDR followed by IN_PKTINFO/IN6_PKTINFO. These fixtures
// include the receiving interface, unlike unscoped send control messages.
ipv4 := "180000000000000000000000130000007f00000107000000"
ipv6 := "24000000000000002900000013000000fe8000000000000000000000000000010700000000000000"
if udpCmsgWord == 4 {
ipv4 = "1400000000000000130000007f00000107000000"
ipv6 = "200000002900000013000000fe80000000000000000000000000000107000000"
}
for _, tc := range []struct{ name, encoded, dst string }{
{"ipv4", ipv4, "127.0.0.1"},
{"ipv6", ipv6, "fe80::1"},
} {
t.Run(tc.name, func(t *testing.T) {
control, err := hex.DecodeString(tc.encoded)
if err != nil {
t.Fatal(err)
}
info := parseUDPPacketInfo(control, 0)
if !info.dst.Equal(net.ParseIP(tc.dst)) || info.ifIndex != 7 {
t.Fatalf("packet info = %+v, want %s on interface 7", info, tc.dst)
}
if got := parseUDPPacketInfo(control, windows.MSG_CTRUNC); got.dst != nil {
t.Fatal("truncated control data must not supply a destination")
}
for n := range udpCmsgHeader + len(info.dst) + 4 {
if got := parseUDPPacketInfo(control[:n], 0); got.dst != nil {
t.Errorf("accepted truncated control data of length %d", n)
}
}
if tc.name == "ipv4" {
// Global/loopback replies retain route selection; only scoped
// IPv6 replies carry an outgoing interface constraint.
binary.NativeEndian.PutUint32(control[udpCmsgHeader+4:], 0)
}
if got := udpSourceControl(info); !bytes.Equal(got, control) {
t.Errorf("source control = %x, want %x", got, control)
}
})
}
}
func TestUDPPacketInfoWindowsMalformed(t *testing.T) {
valid := udpSourceControl(udpPacketInfo{dst: net.ParseIP("127.0.0.1")})
for _, length := range []uint64{0, udpCmsgHeader - 1, udpCmsgHeader, udpCmsgHeader + 3, uint64(len(valid) + 1), ^uint64(0)} {
control := bytes.Clone(valid)
if udpCmsgWord == 8 {
binary.NativeEndian.PutUint64(control, length)
} else {
binary.NativeEndian.PutUint32(control, uint32(length))
}
if got := parseUDPPacketInfo(control, 0); got.dst != nil {
t.Errorf("accepted control length %d", length)
}
}
unknown := bytes.Clone(valid)
binary.NativeEndian.PutUint32(unknown[udpCmsgWord+4:], 0xffff)
control := append(unknown, valid...)
if got := parseUDPPacketInfo(control, 0); !got.dst.Equal(net.IPv4(127, 0, 0, 1)) {
t.Fatalf("did not find packet info after an unknown message: %+v", got)
}
}

View File

@@ -179,7 +179,7 @@ functions defined below.
.IP \(bu 4
\fB\fCproto() string\fR: protocol used (tcp or udp)
.IP \(bu 4
\fB\fCserver_ip() string\fR: server's IP address; for IPv6 addresses these are enclosed in brackets: \fB\fC[::1]\fR
\fB\fCserver_ip() string\fR: server's destination IP address; IPv6 addresses have no brackets: \fB\fC::1\fR
.IP \(bu 4
\fB\fCserver_port() string\fR : server's port
.IP \(bu 4
@@ -188,6 +188,13 @@ functions defined below.
\fB\fCtype() string\fR: type of the request (A, AAAA, TXT, ...)
.PP
For wildcard UDP listeners, \fB\fCserver_ip()\fR uses the destination address of the individual packet
when the operating system supplies packet information. If that information is unavailable, including
with custom packet listeners that do not expose it, the listening address is used instead.
With \fIproxyproto\fP, this is the transport packet's destination, not the destination claimed in the PROXY header.
.SS "UTILITY FUNCTIONS"
.IP \(bu 4
\fB\fCincidr(ip string, cidr string) bool\fR: returns true if \fIip\fP is within \fIcidr\fP

View File

@@ -200,6 +200,15 @@ func (a *Addr) Unwrap() net.Addr {
return a.r
}
// LocalAddr returns the transport packet's destination when the underlying
// address carries one. It is independent of the PROXY header's destination.
func (a *Addr) LocalAddr() net.Addr {
if packet, ok := a.u.(interface{ LocalAddr() net.Addr }); ok {
return packet.LocalAddr()
}
return nil
}
// tlvsFor returns the header's raw TLVs when TLV capture is enabled, else nil.
func (c *PacketConn) tlvsFor(header *proxyproto.Header) []byte {
if !c.CaptureTLVs {

View File

@@ -136,11 +136,16 @@ functions defined below.
* `opcode() int`: query OPCODE
* `port() string`: client's port
* `proto() string`: protocol used (tcp or udp)
* `server_ip() string`: server's IP address; for IPv6 addresses these are enclosed in brackets: `[::1]`
* `server_ip() string`: server's destination IP address; IPv6 addresses have no brackets: `::1`
* `server_port() string` : server's port
* `size() int`: request size in bytes
* `type() string`: type of the request (A, AAAA, TXT, ...)
For wildcard UDP listeners, `server_ip()` uses the destination address of the individual packet
when the operating system supplies packet information. If that information is unavailable, including
with custom packet listeners that do not expose it, the listening address is used instead.
With *proxyproto*, this is the transport packet's destination, not the destination claimed in the PROXY header.
#### Utility Functions
* `incidr(ip string, cidr string) bool`: returns true if _ip_ is within _cidr_

237
test/view_server_ip_test.go Normal file
View File

@@ -0,0 +1,237 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows
package test
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"runtime"
"strings"
"testing"
"time"
clog "github.com/coredns/coredns/plugin/pkg/log"
"github.com/miekg/dns"
)
func TestViewServerIP(t *testing.T) {
for _, tc := range []struct{ name, bind, target string }{
{"wildcard4", "0.0.0.0", "127.0.0.1"},
{"wildcard6", "::", "::1"},
{"dual_stack", "::", "127.0.0.1"},
{"bound4", "127.0.0.1", "127.0.0.1"},
{"bound6", "::1", "::1"},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.name == "dual_stack" && (runtime.GOOS == "openbsd" || runtime.GOOS == "dragonfly") {
t.Skip("the kernel requires IPv6-only sockets")
}
if net.ParseIP(tc.bind).To4() == nil {
checkIPv6Loopback(t)
}
corefile := fmt.Sprintf(`.:0 {
bind %s
view destination {
expr server_ip() == '%s' && name() == 'destination.example.'
}
template IN TXT . {
answer "{{ .Name }} 60 IN TXT \"matched\""
}
}`, tc.bind, tc.target)
instance, udp, tcp, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { instance.Stop() })
for _, network := range []string{"udp", "tcp"} {
t.Run(network, func(t *testing.T) {
listener := udp
if network == "tcp" {
listener = tcp
}
_, port, err := net.SplitHostPort(listener)
if err != nil {
t.Fatal(err)
}
client := &dns.Client{Net: network, Timeout: 2 * time.Second}
conn, err := client.Dial(net.JoinHostPort(tc.target, port))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if network == "udp" {
waitForViewUDP(t, net.JoinHostPort(tc.target, port))
}
for range 2 {
for _, name := range []string{"destination.example.", "unmatched.example."} {
answer, _, err := client.ExchangeWithConn(new(dns.Msg).SetQuestion(name, dns.TypeTXT), conn)
if err != nil {
t.Fatal(err)
}
want := dns.RcodeRefused
if name == "destination.example." {
want = dns.RcodeSuccess
}
if answer.Rcode != want {
t.Errorf("query %s to %s: rcode=%s, want %s", name, tc.target, dns.RcodeToString[answer.Rcode], dns.RcodeToString[want])
}
if want == dns.RcodeSuccess && (len(answer.Answer) != 1 || answer.Answer[0].String() != name+"\t60\tIN\tTXT\t\"matched\"") {
t.Errorf("unexpected answer: %v", answer.Answer)
}
}
}
})
}
})
}
}
func checkIPv6Loopback(t *testing.T) {
t.Helper()
conn, err := net.ListenPacket("udp6", "[::1]:0")
if err != nil {
t.Skipf("IPv6 loopback is unavailable: %v", err)
}
conn.Close()
}
// Caddy returns before the serving goroutines start. Complete one exchange so
// assertions do not depend on packet information for a datagram queued before
// ServePacket enabled the socket options. The view may refuse this probe.
func waitForViewUDP(t *testing.T, address string) {
t.Helper()
client := &dns.Client{Net: "udp", Timeout: 2 * time.Second}
if _, _, err := client.Exchange(new(dns.Msg).SetQuestion("ready.invalid.", dns.TypeTXT), address); err != nil {
t.Fatal(err)
}
}
func TestViewServerIPConcurrent(t *testing.T) {
if runtime.GOOS == "openbsd" || runtime.GOOS == "dragonfly" {
t.Skip("the kernel requires IPv6-only sockets")
}
checkIPv6Loopback(t)
instance, udp, _, err := CoreDNSServerAndPorts(`.:0 {
bind ::
view destination {
expr (server_ip() == '127.0.0.1' && name() == 'ipv4.example.') || (server_ip() == '::1' && name() == 'ipv6.example.')
}
template IN TXT . {
answer "{{ .Name }} 60 IN TXT \"matched\""
}
}`)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { instance.Stop() })
_, port, err := net.SplitHostPort(udp)
if err != nil {
t.Fatal(err)
}
waitForViewUDP(t, net.JoinHostPort("127.0.0.1", port))
for _, tc := range []struct{ target, name string }{
{"127.0.0.1", "ipv4.example."},
{"::1", "ipv6.example."},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &dns.Client{Net: "udp", Timeout: 2 * time.Second}
conn, err := client.Dial(net.JoinHostPort(tc.target, port))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
for range 50 {
answer, _, err := client.ExchangeWithConn(new(dns.Msg).SetQuestion(tc.name, dns.TypeTXT), conn)
if err != nil {
t.Fatal(err)
}
if answer.Rcode != dns.RcodeSuccess || len(answer.Answer) != 1 {
t.Fatalf("view for %s did not match: %v", tc.target, answer)
}
}
})
}
}
// Isolate request logging and Caddy restart callbacks from other integration
// tests. The child exercises the actual log plugin and its {local} formatter.
func TestViewServerIPLogAndReload(t *testing.T) {
bin, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-test.run=^TestViewServerIPLogHelper$", "-test.v")
cmd.Env = append(os.Environ(), "COREDNS_TEST_UDP_LOCAL=1", "GORACE=atexit_sleep_ms=0")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("log/reload process: %v\n%s", err, output)
}
if !strings.Contains(string(output), "LOCAL=127.0.0.1") || strings.Contains(string(output), "LOCAL=0.0.0.0") {
t.Fatalf("incorrect request log destination:\n%s", output)
}
if runtime.GOOS != "windows" && !strings.Contains(string(output), "RELOADED=127.0.0.1") {
t.Fatalf("no destination log after listener reuse:\n%s", output)
}
}
func TestViewServerIPLogHelper(t *testing.T) {
if os.Getenv("COREDNS_TEST_UDP_LOCAL") != "1" {
return
}
clog.SetOutput(os.Stdout)
corefile := `.:0 {
bind 0.0.0.0
view destination {
expr server_ip() == '127.0.0.1'
}
log . "LOCAL={local}"
template IN TXT . {
answer "{{ .Name }} 60 IN TXT \"matched\""
}
}`
instance, udp, _, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
defer func() { instance.Stop() }()
_, port, err := net.SplitHostPort(udp)
if err != nil {
t.Fatal(err)
}
waitForViewUDP(t, net.JoinHostPort("127.0.0.1", port))
query := func() {
t.Helper()
client := &dns.Client{Net: "udp", Timeout: 2 * time.Second}
answer, _, err := client.Exchange(new(dns.Msg).SetQuestion("destination.example.", dns.TypeTXT), net.JoinHostPort("127.0.0.1", port))
if err != nil {
t.Fatal(err)
}
if answer.Rcode != dns.RcodeSuccess || len(answer.Answer) != 1 {
t.Fatalf("destination view did not match: %v", answer)
}
}
query()
// Caddy cannot inherit Windows socket file descriptors on restart.
if runtime.GOOS == "windows" {
return
}
// Retain the configured :0 address so Caddy finds the old listener by
// its configuration key. A fresh listen would choose a different port.
corefile = strings.Replace(corefile, "LOCAL=", "RELOADED=", 1)
next, err := instance.Restart(NewInput(corefile))
if err != nil {
t.Fatal(err)
}
instance = next
if got, _ := CoreDNSServerPorts(instance, 0); got != udp {
t.Fatalf("listener was not reused: %s -> %s", udp, got)
}
query()
}