From 0fe7b08fbbe089e28edfc2d03c4017a049aae79b Mon Sep 17 00:00:00 2001 From: houyuwushang <180804215+houyuwushang@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:34:18 +0800 Subject: [PATCH] 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> --- .github/workflows/go.test.yml | 25 ++ core/dnsserver/server.go | 3 +- core/dnsserver/udp.go | 131 ++++++++++ core/dnsserver/udp_other.go | 11 + core/dnsserver/udp_test.go | 371 ++++++++++++++++++++++++++++ core/dnsserver/udp_unix.go | 45 ++++ core/dnsserver/udp_windows.go | 107 ++++++++ core/dnsserver/udp_windows_test.go | 74 ++++++ man/coredns-view.7 | 9 +- plugin/pkg/proxyproto/proxyproto.go | 9 + plugin/view/README.md | 7 +- test/view_server_ip_test.go | 237 ++++++++++++++++++ 12 files changed, 1026 insertions(+), 3 deletions(-) create mode 100644 core/dnsserver/udp.go create mode 100644 core/dnsserver/udp_other.go create mode 100644 core/dnsserver/udp_test.go create mode 100644 core/dnsserver/udp_unix.go create mode 100644 core/dnsserver/udp_windows.go create mode 100644 core/dnsserver/udp_windows_test.go create mode 100644 test/view_server_ip_test.go diff --git a/.github/workflows/go.test.yml b/.github/workflows/go.test.yml index b3ae04943..dd879e210 100644 --- a/.github/workflows/go.test.yml +++ b/.github/workflows/go.test.yml @@ -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 diff --git a/core/dnsserver/server.go b/core/dnsserver/server.go index bb4d1ac08..c4a3e8a57 100644 --- a/core/dnsserver/server.go +++ b/core/dnsserver/server.go @@ -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() diff --git a/core/dnsserver/udp.go b/core/dnsserver/udp.go new file mode 100644 index 000000000..9e49e45ac --- /dev/null +++ b/core/dnsserver/udp.go @@ -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} +} diff --git a/core/dnsserver/udp_other.go b/core/dnsserver/udp_other.go new file mode 100644 index 000000000..7ebcebd0b --- /dev/null +++ b/core/dnsserver/udp_other.go @@ -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 } diff --git a/core/dnsserver/udp_test.go b/core/dnsserver/udp_test.go new file mode 100644 index 000000000..60ddf988a --- /dev/null +++ b/core/dnsserver/udp_test.go @@ -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) + }) +} diff --git a/core/dnsserver/udp_unix.go b/core/dnsserver/udp_unix.go new file mode 100644 index 000000000..9fcc550ff --- /dev/null +++ b/core/dnsserver/udp_unix.go @@ -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() +} diff --git a/core/dnsserver/udp_windows.go b/core/dnsserver/udp_windows.go new file mode 100644 index 000000000..7b069f52e --- /dev/null +++ b/core/dnsserver/udp_windows.go @@ -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 +} diff --git a/core/dnsserver/udp_windows_test.go b/core/dnsserver/udp_windows_test.go new file mode 100644 index 000000000..90caba15f --- /dev/null +++ b/core/dnsserver/udp_windows_test.go @@ -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) + } +} diff --git a/man/coredns-view.7 b/man/coredns-view.7 index 928a97de3..dadb115e0 100644 --- a/man/coredns-view.7 +++ b/man/coredns-view.7 @@ -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 diff --git a/plugin/pkg/proxyproto/proxyproto.go b/plugin/pkg/proxyproto/proxyproto.go index 593139aea..6ebbb598b 100644 --- a/plugin/pkg/proxyproto/proxyproto.go +++ b/plugin/pkg/proxyproto/proxyproto.go @@ -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 { diff --git a/plugin/view/README.md b/plugin/view/README.md index 0e46d6fac..c284de58a 100644 --- a/plugin/view/README.md +++ b/plugin/view/README.md @@ -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_ diff --git a/test/view_server_ip_test.go b/test/view_server_ip_test.go new file mode 100644 index 000000000..7acabe20f --- /dev/null +++ b/test/view_server_ip_test.go @@ -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() +}