From 35b8cde31f67b4fd63fdd92f6b19e7bd9fd5e9dd Mon Sep 17 00:00:00 2001 From: "J. Yi" <93548144+jyyi1@users.noreply.github.com> Date: Mon, 24 Jul 2023 11:33:16 -0400 Subject: [PATCH] feat(network): add DNS fallback (truncated) PacketProxy (#26) I added `dnstruncated.NewPacketProxy()` to the SDK. This type can be used when the remote Shadowsocks server **doesn't support UDP traffic at all**. By using this `PacketProxy`, we will always set the [`TC` (truncated) bit](https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.1) in the DNS responses for all UDP DNS requests. So a well-implemented DNS client (e.g. the browser, the `dig` command) should retry the same DNS request over TCP. Related PR: #24 , #27 --- README.md | 2 +- go.mod | 1 + go.sum | 10 + network/dnstruncate/doc.go | 35 +++ network/dnstruncate/packet_proxy.go | 154 +++++++++++++ network/dnstruncate/packet_proxy_test.go | 268 +++++++++++++++++++++++ network/errors.go | 13 +- network/lwip2transport/udp.go | 2 +- network/packet_listener_proxy.go | 5 +- network/packet_proxy.go | 7 +- 10 files changed, 487 insertions(+), 10 deletions(-) create mode 100644 network/dnstruncate/doc.go create mode 100644 network/dnstruncate/packet_proxy.go create mode 100644 network/dnstruncate/packet_proxy_test.go diff --git a/README.md b/README.md index 830daa4e..076ffd4d 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Alpha tasks: - Network-level libraries - [x] Add IP Device abstraction - [x] Add IP Device implementation based on go-tun2socks (LWIP) - - [ ] Add UDP handler to fallback to DNS-over-TCP + - [x] Add UDP handler to fallback to DNS-over-TCP - [x] Add DelegatePacketProxy for runtime PacketProxy replacement diff --git a/go.mod b/go.mod index 3432bc40..df0ebcca 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/eycorsican/go-tun2socks v1.16.11 + github.com/google/gopacket v1.1.19 github.com/shadowsocks/go-shadowsocks2 v0.1.5 github.com/stretchr/testify v1.8.2 golang.org/x/crypto v0.7.0 diff --git a/go.sum b/go.sum index 2d2f1724..db499008 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eycorsican/go-tun2socks v1.16.11 h1:+hJDNgisrYaGEqoSxhdikMgMJ4Ilfwm/IZDrWRrbaH8= github.com/eycorsican/go-tun2socks v1.16.11/go.mod h1:wgB2BFT8ZaPKyKOQ/5dljMG/YIow+AIXyq4KBwJ5sGQ= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -23,17 +25,25 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/network/dnstruncate/doc.go b/network/dnstruncate/doc.go new file mode 100644 index 00000000..4ba915e0 --- /dev/null +++ b/network/dnstruncate/doc.go @@ -0,0 +1,35 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package dnstruncate functions as an alternative implementation that handles DNS requests if the remote server doesn't +support UDP traffic. This is done by always setting the TC (truncated) bit in the DNS response header; it tells the +caller to resend the DNS request using TCP instead of UDP. As a result, no UDP requests are made to the remote server. + +This implementation is ported from the [go-tun2socks' dnsfallback.NewUDPHandler]. + +Note that UDP traffic that are not DNS requests are dropped. + +To create a [network.PacketProxy] that handles DNS requests locally: + + proxy, err := dnstruncate.NewPacketProxy() + if err != nil { + // handle error + } + +This `proxy` can then be used in, for example, lwip2transport.ConfigureDevice. + +[go-tun2socks' dnsfallback.NewUDPHandler]: https://github.com/eycorsican/go-tun2socks/blob/master/proxy/dnsfallback/udp.go +*/ +package dnstruncate diff --git a/network/dnstruncate/packet_proxy.go b/network/dnstruncate/packet_proxy.go new file mode 100644 index 00000000..67cb51f5 --- /dev/null +++ b/network/dnstruncate/packet_proxy.go @@ -0,0 +1,154 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnstruncate + +import ( + "errors" + "fmt" + "net" + "net/netip" + "sync/atomic" + + "github.com/Jigsaw-Code/outline-internal-sdk/internal/slicepool" + "github.com/Jigsaw-Code/outline-internal-sdk/network" +) + +// From [RFC 1035], the DNS message header contains the following fields: +// +// 1 1 1 1 1 1 +// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 +// +// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +// | ID | +// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +// |QR| Opcode |AA|TC|RD|RA| Z | RCODE | +// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +// | QDCOUNT | +// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +// | ANCOUNT | +// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +// | NSCOUNT | +// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +// | ARCOUNT | +// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ +// +// [RFC 1035]: https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.1 +const ( + standardDNSPort = uint16(53) // https://datatracker.ietf.org/doc/html/rfc1035#section-4.2 + dnsUdpMinMsgLen = 12 // A DNS message must at least contain the header + dnsUdpMaxMsgLen = 512 // https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.4 + + dnsUdpAnswerByte = 2 // The byte in the header containing QR and TC bit + dnsUdpResponseBit = uint8(0x80) // The QR bit within dnsUdpAnswerByte + dnsUdpTruncatedBit = uint8(0x02) // The TC bit within dnsUdpAnswerByte + dnsUdpRCodeByte = 3 // The byte in the header containing RCODE + dnsUdpRCodeMask = uint8(0x0f) // The RCODE bits within dnsUdpRCodeByte + dnsQDCntStartByte = 4 // The starting byte of QDCOUNT + dnsQDCntEndByte = 5 // The ending byte (inclusive) of QDCOUNT + dnsARCntStartByte = 6 // The starting byte of ANCOUNT + dnsARCntEndByte = 7 // The ending byte (inclusive) of ANCOUNT +) + +// packetBufferPool is used to create buffers to modify DNS requests +var packetBufferPool = slicepool.MakePool(dnsUdpMaxMsgLen) + +// dnsTruncateProxy is a network.PacketProxy that create dnsTruncateRequestHandler to handle DNS requests locally. +// +// Multiple goroutines may invoke methods on a dnsTruncateProxy simultaneously. +type dnsTruncateProxy struct { +} + +// dnsTruncateRequestHandler is a network.PacketRequestSender that handles DNS requests in UDP protocol locally, +// without sending the requests to the actual DNS resolver. It sets the TC (truncated) bit in the DNS response header +// to tell the caller to resend the DNS request over TCP. +// +// Multiple goroutines may invoke methods on a dnsTruncateProxy simultaneously. +type dnsTruncateRequestHandler struct { + closed atomic.Bool + respWriter network.PacketResponseReceiver +} + +// Compilation guard against interface implementation +var _ network.PacketProxy = (*dnsTruncateProxy)(nil) +var _ network.PacketRequestSender = (*dnsTruncateRequestHandler)(nil) + +// NewPacketProxy creates a new [network.PacketProxy] that can be used to handle DNS requests if the remote proxy +// doesn't support UDP traffic. It sets the TC (truncated) bit in the DNS response header to tell the caller to resend +// the DNS request over TCP. +// +// This [network.PacketProxy] should only be used if the remote proxy server doesn't support UDP traffic at all. Note +// that all other non-DNS UDP packets will be dropped by this [network.PacketProxy]. +func NewPacketProxy() (network.PacketProxy, error) { + return &dnsTruncateProxy{}, nil +} + +// NewSession implements [network.PacketProxy].NewSession(). It creates a new [network.PacketRequestSender] that will +// set the TC (truncated) bit and write the response to `respWriter`. +func (p *dnsTruncateProxy) NewSession(respWriter network.PacketResponseReceiver) (network.PacketRequestSender, error) { + if respWriter == nil { + return nil, errors.New("respWriter is required") + } + return &dnsTruncateRequestHandler{ + respWriter: respWriter, + }, nil +} + +// Close implements [network.PacketRequestSender].Close(), and it closes the corresponding +// [network.PacketResponseReceiver]. +func (h *dnsTruncateRequestHandler) Close() error { + if !h.closed.CompareAndSwap(false, true) { + return network.ErrClosed + } + h.respWriter.Close() + return nil +} + +// WriteTo implements [network.PacketRequestSender].WriteTo(). It parses a packet from p, and determines whether it is +// a valid DNS request. If so, it will write the DNS response with TC (truncated) bit set to the corresponding +// [network.PacketResponseReceiver] passed to NewSession. If it is not a valid DNS request, the packet will be +// discarded and returns an error. +func (h *dnsTruncateRequestHandler) WriteTo(p []byte, destination netip.AddrPort) (int, error) { + if h.closed.Load() { + return 0, network.ErrClosed + } + if destination.Port() != standardDNSPort { + return 0, fmt.Errorf("UDP traffic to non-DNS port %v is not supported: %w", destination.Port(), network.ErrPortUnreachable) + } + if len(p) < dnsUdpMinMsgLen { + return 0, fmt.Errorf("invalid DNS message of length %v, it must be at least %v bytes", len(p), dnsUdpMinMsgLen) + } + + // Allocate buffer from slicepool, because `go build -gcflags="-m"` shows a local array will escape to heap + slice := packetBufferPool.LazySlice() + buf := slice.Acquire() + defer slice.Release() + + // We need to copy p into buf because "WriteTo must not modify p, even temporarily". + n := copy(buf, p) + + // Set "Response", "Truncated" and "NoError" + // Note: gopacket is a good library doing this kind of things. But it will increase the binary size a lot. + // If we decide to use gopacket in the future, please evaluate the binary size and runtime memory consumption. + buf[dnsUdpAnswerByte] |= (dnsUdpResponseBit | dnsUdpTruncatedBit) + buf[dnsUdpRCodeByte] &= ^dnsUdpRCodeMask + + // Copy QDCOUNT to ANCOUNT. This is an incorrect workaround for some DNS clients (such as Windows 7); + // because without these clients won't retry over TCP. + // + // For reference: https://github.com/eycorsican/go-tun2socks/blob/master/proxy/dnsfallback/udp.go#L59-L63 + copy(buf[dnsARCntStartByte:dnsARCntEndByte+1], buf[dnsQDCntStartByte:dnsQDCntEndByte+1]) + + return h.respWriter.WriteFrom(buf[:n], net.UDPAddrFromAddrPort(destination)) +} diff --git a/network/dnstruncate/packet_proxy_test.go b/network/dnstruncate/packet_proxy_test.go new file mode 100644 index 00000000..ba69401a --- /dev/null +++ b/network/dnstruncate/packet_proxy_test.go @@ -0,0 +1,268 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnstruncate + +import ( + "fmt" + "net" + "net/netip" + "sync" + "testing" + + "github.com/Jigsaw-Code/outline-internal-sdk/network" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" +) + +// Make sure TC & NOERROR & ANCOUNT are set in the DNS response +func TestTruncatedBitIsSetInResponse(t *testing.T) { + session := newInstantDNSSessionForTest(t) + resolverAddr := netip.MustParseAddrPort("1.2.3.4:53") + require.NotNil(t, resolverAddr) + + dnsReq := constructDNSRequestOrResponse(t, false, 0x2468, []string{"www.google.com", "www.youtube.com"}) + expected := constructDNSRequestOrResponse(t, true, 0x2468, []string{"www.google.com", "www.youtube.com"}) + + dnsResp, err := session.Query(dnsReq, resolverAddr) + require.NoError(t, err) + require.Equal(t, expected, dnsResp) + + require.NoError(t, session.Close()) +} + +// Make sure invalid DNS requests should result in an error +func TestInvalidDNSRequestReturnsError(t *testing.T) { + session := newInstantDNSSessionForTest(t) + resolverAddr := netip.MustParseAddrPort("[::1]:53") + require.NotNil(t, resolverAddr) + + // dns request size too small + dnsReq := constructDNSRequestOrResponse(t, false, 0x2345, []string{"www.google.com"}) + _, err := session.Query(dnsReq[:11], resolverAddr) + require.Error(t, err) + session.AssertNoResponseFrom(net.UDPAddrFromAddrPort(resolverAddr)) + + // minimum valid dns request size + dnsResp, err := session.Query(dnsReq[:12], resolverAddr) + require.NoError(t, err) + require.NotNil(t, dnsResp) + + require.NoError(t, session.Close()) +} + +// Make sure proxy won't response on port other than 53 +func TestPacketNotSentToPort53ReturnsError(t *testing.T) { + session := newInstantDNSSessionForTest(t) + + invalidResolvers := []string{ + "3.4.5.6:54", + "127.0.0.1:52", + "6.5.4.3:853", + "8.8.8.8:443", + } + + dnsReq := constructDNSRequestOrResponse(t, false, 0x3456, []string{"www.google.com"}) + for _, resolver := range invalidResolvers { + resolverAddr := netip.MustParseAddrPort(resolver) + require.NotNil(t, resolverAddr) + + resp, err := session.Query(dnsReq, resolverAddr) + require.ErrorIs(t, err, network.ErrPortUnreachable) + require.Nil(t, resp) + session.AssertNoResponseFrom(net.UDPAddrFromAddrPort(resolverAddr)) + } + + require.NoError(t, session.Close()) +} + +// Make sure WriteTo a closed proxy should result in an error +func TestWriteToClosedProxyReturnsError(t *testing.T) { + session := newInstantDNSSessionForTest(t) + resolverAddr := netip.MustParseAddrPort("1.2.3.4:53") + require.NotNil(t, resolverAddr) + + require.NoError(t, session.Close()) + dnsReq := constructDNSRequestOrResponse(t, false, 0x4567, []string{"www.google.com"}) + resp, err := session.Query(dnsReq, resolverAddr) + require.ErrorIs(t, err, network.ErrClosed) + require.Nil(t, resp) + session.AssertNoResponseFrom(net.UDPAddrFromAddrPort(resolverAddr)) +} + +// Make sure NewSession returns an error for nil PacketResponseReceiver +func TestNewSessionWithNilResponseWriterReturnsError(t *testing.T) { + p := createProxyForTest(t) + s, err := p.NewSession(nil) + require.Error(t, err) + require.Nil(t, s) +} + +// Make sure multiple goroutines can call WriteTo to the same session +func TestMultipleWriteToRaceCondition(t *testing.T) { + const clientCnt = 20 + const iterationCntPerClient = 20 + + session := newInstantDNSSessionForTest(t) + + wg := &sync.WaitGroup{} + wg.Add(clientCnt) + for i := 0; i < clientCnt; i++ { + go func(idx int) { + resolverAddr := netip.MustParseAddrPort(fmt.Sprintf("127.0.0.%d:53", idx+1)) + require.NotNil(t, resolverAddr) + + for j := 0; j < iterationCntPerClient; j++ { + txid := uint16(idx*1000 + j) + req := constructDNSRequestOrResponse(t, false, txid, []string{"www.google.com"}) + expected := constructDNSRequestOrResponse(t, true, txid, []string{"www.google.com"}) + + resp, err := session.Query(req, resolverAddr) + require.NoError(t, err) + require.Equal(t, expected, resp) + } + wg.Done() + }(i) + } + + wg.Wait() + require.NoError(t, session.Close()) +} + +/********** Test utilities **********/ + +func createProxyForTest(t *testing.T) network.PacketProxy { + p, err := NewPacketProxy() + require.NoError(t, err) + require.NotNil(t, p) + return p +} + +func constructDNSQuestionsFromDomainNames(questions []string) []layers.DNSQuestion { + result := make([]layers.DNSQuestion, 0) + for _, name := range questions { + result = append(result, layers.DNSQuestion{ + Name: []byte(name), + Type: layers.DNSTypeA, + Class: layers.DNSClassIN, + }) + } + return result +} + +// constructDNSRequestOrResponse creates the following DNS request/response: +// +// [ `id` ]: 2 bytes +// [ Standard-Query/Response + Recursive ]: 0x01/0x81 +// [ Reserved/Response-No-Err ]: 0x00 +// [ Questions-Count ]: 2 bytes (= len(questions)) +// [ Answers Count ]: 2 bytes (= 0x00 0x00 / len(questions)) +// [ Authorities Count ]: 0x00 0x00 +// [ Resources Count ]: 0x00 0x01 +// [ `questions` ]: ? bytes +// [ Additional Resources ]: ? bytes (= OPT(payload_size=4096)) +// +// https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.1 +// +// The response is actually invalid because it doesn't contain any answers section (but Answers Count == 1). We have to +// do this due to the DNS retry logic in Windows 7: +// - https://github.com/eycorsican/go-tun2socks/blob/master/proxy/dnsfallback/udp.go#L59-L63 +func constructDNSRequestOrResponse(t *testing.T, response bool, id uint16, questions []string) []byte { + require.NotEmpty(t, questions) + pkt := layers.DNS{ + ID: id, + RD: true, + QDCount: uint16(len(questions)), + Questions: constructDNSQuestionsFromDomainNames(questions), + ARCount: 1, + Additionals: []layers.DNSResourceRecord{ + { + Type: layers.DNSTypeOPT, + Class: 4096, // Payload size + }, + }, + } + if response { + pkt.QR = true + pkt.TC = true + pkt.ResponseCode = layers.DNSResponseCodeNoErr + pkt.ANCount = uint16(len(questions)) + } + + buf := gopacket.NewSerializeBuffer() + err := pkt.SerializeTo(buf, gopacket.SerializeOptions{}) + require.NoError(t, err) + require.Greater(t, len(buf.Bytes()), 12) + return buf.Bytes() +} + +// instantPacketSession sends UDP request, and return the response instantly (see Query). +// So it only works for local PacketProxy, but not remote ones. +type instantPacketSession struct { + t *testing.T + sender network.PacketRequestSender + responses sync.Map // server addr -> response slice +} + +func newInstantDNSSessionForTest(t *testing.T) *instantPacketSession { + p := createProxyForTest(t) + s := &instantPacketSession{ + t: t, + } + sender, err := p.NewSession(s) + require.NoError(t, err) + require.NotNil(t, sender) + s.sender = sender + return s +} + +func (s *instantPacketSession) Query(req []byte, dest netip.AddrPort) ([]byte, error) { + n, err := s.sender.WriteTo(req, dest) + if err != nil { + require.Exactly(s.t, 0, n) + return nil, err + } + if len(req) < dnsUdpMaxMsgLen { + require.Exactly(s.t, len(req), n) + } else { + require.Exactly(s.t, dnsUdpMaxMsgLen, n) + } + + resp, ok := s.responses.Load(dest.String()) + require.True(s.t, ok) + require.NotNil(s.t, resp) + return resp.([]byte), nil +} + +func (s *instantPacketSession) AssertNoResponseFrom(source net.Addr) { + resp, ok := s.responses.Load(source.String()) + require.False(s.t, ok) + require.Nil(s.t, resp) +} + +func (s *instantPacketSession) Close() error { + return s.sender.Close() +} + +func (s *instantPacketSession) WriteFrom(p []byte, source net.Addr) (int, error) { + require.LessOrEqual(s.t, len(p), dnsUdpMaxMsgLen) + + buf := make([]byte, dnsUdpMaxMsgLen) + n := copy(buf, p) + require.LessOrEqual(s.t, n, dnsUdpMaxMsgLen) + + s.responses.Store(source.String(), buf[:n]) + return n, nil +} diff --git a/network/errors.go b/network/errors.go index ecde502b..8b706eec 100644 --- a/network/errors.go +++ b/network/errors.go @@ -21,8 +21,13 @@ import ( // Portable analogs of some common errors. // // Errors returned from this package and all sub-packages may be tested against these errors with [errors.Is]. +var ( + // ErrClosed is the error returned by an I/O call on a network device or proxy that has already been closed, or that is + // closed by another goroutine before the I/O is completed. This may be wrapped in another error, and should normally + // be tested using errors.Is(err, network.ErrClosed). + ErrClosed = errors.New("network device already closed") -// ErrClosed is the error returned by an I/O call on a network device or proxy that has already been closed, or that is -// closed by another goroutine before the I/O is completed. This may be wrapped in another error, and should normally -// be tested using errors.Is(err, network.ErrClosed). -var ErrClosed = errors.New("network device already closed") + // ErrPortUnreachable is an error that indicates a remote server's port cannot be reached. This may be wrapped in + // another error, and should normally be tested using errors.Is(err, network.ErrPortUnreachable). + ErrPortUnreachable = errors.New("port is not reachable") +) diff --git a/network/lwip2transport/udp.go b/network/lwip2transport/udp.go index 0e548058..3666d18f 100644 --- a/network/lwip2transport/udp.go +++ b/network/lwip2transport/udp.go @@ -62,7 +62,7 @@ func (h *udpHandler) ReceiveTo(tunConn lwip.UDPConn, data []byte, destAddr *net. } h.mu.Unlock() - _, err = reqSender.WriteTo(data, destAddr) + _, err = reqSender.WriteTo(data, destAddr.AddrPort()) return } diff --git a/network/packet_listener_proxy.go b/network/packet_listener_proxy.go index 903a9c1f..03908a8f 100644 --- a/network/packet_listener_proxy.go +++ b/network/packet_listener_proxy.go @@ -19,6 +19,7 @@ import ( "errors" "io" "net" + "net/netip" "sync" "time" @@ -113,11 +114,11 @@ func (proxy *packetListenerProxyAdapter) NewSession(respWriter PacketResponseRec // WriteTo implements [PacketRequestSender].WriteTo function. It simply forwards the packet to the underlying // [net.PacketConn].WriteTo function. -func (s *packetListenerRequestSender) WriteTo(p []byte, destination net.Addr) (int, error) { +func (s *packetListenerRequestSender) WriteTo(p []byte, destination netip.AddrPort) (int, error) { if err := s.resetWriteIdleTimer(); err != nil { return 0, err } - return s.proxyConn.WriteTo(p, destination) + return s.proxyConn.WriteTo(p, net.UDPAddrFromAddrPort(destination)) } // Close implements [PacketRequestSender].Close function. It closes the underlying [net.PacketConn]. This will also diff --git a/network/packet_proxy.go b/network/packet_proxy.go index b7936455..7de13ea0 100644 --- a/network/packet_proxy.go +++ b/network/packet_proxy.go @@ -14,7 +14,10 @@ package network -import "net" +import ( + "net" + "net/netip" +) // PacketProxy handles UDP traffic from the upstream network stack. The upstream network stack uses the NewSession // function to create a new UDP session that can send or receive UDP packets from PacketProxy. @@ -43,7 +46,7 @@ type PacketRequestSender interface { // to stop early. // // `p` must not be modified, and it must not be referenced after WriteTo returns. - WriteTo(p []byte, destination net.Addr) (int, error) + WriteTo(p []byte, destination netip.AddrPort) (int, error) // Close indicates that the sender is no longer accepting new requests. Any future attempts to call WriteTo on the // sender will fail with ErrClosed.