diff --git a/README.md b/README.md index c702ce4..35c7da1 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ [![Tests](https://github.com/ccding/go-stun/actions/workflows/go.yml/badge.svg)](https://github.com/ccding/go-stun/actions/workflows/go.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-red.svg)](LICENSE) -`go-stun` is a Go library and command-line client for STUN over UDP. It can -discover the public IP address and port assigned to a UDP socket and, when the -server supports the required probes, classify the client's NAT behavior. +`go-stun` is a Go library and command-line client for STUN over UDP and TCP. It +can discover the public IP address and port assigned to a socket and, for UDP +when the server supports the required probes, classify the client's NAT +behavior. STUN is one building block for NAT traversal. This project does not implement a complete UDP hole-punching, ICE, or TURN solution. @@ -14,6 +15,7 @@ complete UDP hole-punching, ICE, or TURN solution. ## Features - Discover a socket's public (server-reflexive) IP address and port. +- Perform basic STUN Binding transactions over UDP or TCP. - Send [RFC 5389] Binding requests with `SOFTWARE` and `FINGERPRINT` attributes. - Perform classic NAT type discovery based on [RFC 3489]. @@ -76,12 +78,14 @@ Available options: | `-p port` | Bind requests to a local port; `0` selects an available port. | | `-b` | Run RFC 5780 mapping and filtering behavior tests. | | `-legacy` | Omit modern optional attributes for RFC 3489-only servers. | +| `-t transport` | Select `udp` (the default) or `tcp`. TCP performs basic Binding only. | | `-v level` | Set verbosity to `0` (quiet), `1` (protocol trace), or `2`/`3` (also dump packets in hex); values above `3` are rejected. | Use `go-stun -h` to see the current defaults. For example: ```console go-stun -s stun.example.com:3478 +go-stun -s stun.example.com:3478 -t tcp go-stun -s stun.example.com:3478 -b go-stun -v 1 ``` @@ -128,6 +132,21 @@ configuration methods include `SetServerHost`, `SetLocalIP`, `SetLocalPort`, applicable `net.Listen*` function; the caller remains responsible for closing the connection, and `Keepalive` can refresh its mapping. +For a basic Binding transaction over TCP, call `DiscoverTCP`: + +```go +mappedAddr, err := client.DiscoverTCP() +``` + +`DiscoverTCP` opens a TCP connection for the transaction and closes it before +returning. Because a reflexive TCP address remains useful only while its +connection is open, applications that need to retain the mapping should dial +the server themselves and use `NewClientWithTCPConnection`. The caller owns +that connection and can call `DiscoverTCP` again to refresh the mapping. Use +`SetTCPTimeout` to replace the [RFC 8489] default response timeout of 39.5 +seconds. When `DiscoverTCP` opens the connection itself, the same duration +also bounds connection establishment independently. + Run `go doc github.com/ccding/go-stun/stun` for documentation matching the version in your module. The linked [package reference] shows the latest tagged release and will not include `master`-only symbols until a new release is @@ -185,7 +204,8 @@ result with those servers. UDP requests use the RFC 3489 retransmission schedule: nine sends beginning at 100 ms, doubling up to a 1.6-second interval. A timed-out probe can consequently -take several seconds. +take several seconds. TCP requests rely on TCP reliability and are not +retransmitted at the STUN layer. ## Security @@ -221,3 +241,4 @@ checks. [RFC 3489]: https://www.rfc-editor.org/rfc/rfc3489.html [RFC 5389]: https://www.rfc-editor.org/rfc/rfc5389.html [RFC 5780]: https://www.rfc-editor.org/rfc/rfc5780.html +[RFC 8489]: https://www.rfc-editor.org/rfc/rfc8489.html diff --git a/main.go b/main.go index 373bdb0..65af437 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/ccding/go-stun/stun" ) @@ -30,6 +31,7 @@ func main() { var localIP = flag.String("i", "", "The ip on which to bind requests, set to empty will use default") var behaviorTestMode = flag.Bool("b", false, "Enable NAT behavior test mode") var legacyMode = flag.Bool("legacy", false, "Enable compatibility with RFC 3489-only STUN servers") + var transport = flag.String("t", "udp", "STUN transport (udp or tcp)") var verboseLevel = flag.Int("v", 0, "Verbose level (0: none, 1: verbose, 2: double verbose, 3: triple verbose)") flag.Parse() @@ -47,9 +49,14 @@ func main() { client.SetRFC3489Compatibility(*legacyMode) client.SetVerbose(*verboseLevel >= 1) client.SetVVerbose(*verboseLevel >= 2) + network := strings.ToLower(*transport) // Run behavior test if specified if *behaviorTestMode { + if network != "udp" { + fmt.Fprintln(os.Stderr, "Error: NAT behavior tests require UDP transport") + os.Exit(1) + } err := runBehaviorTest(client) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) @@ -58,14 +65,18 @@ func main() { return } - // Discover the NAT - nat, host, err := client.Discover() + // Discover the mapped transport address and, for UDP, the NAT type. + nat, host, hasNATType, err := runDiscovery(client, network) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } - fmt.Println("NAT Type:", nat) + if hasNATType { + fmt.Println("NAT Type:", nat) + } else { + fmt.Println("Transport: TCP") + } if host != nil { fmt.Println("External IP Family:", host.Family()) fmt.Println("External IP:", host.IP()) @@ -73,6 +84,24 @@ func main() { } } +type discoveryClient interface { + Discover() (stun.NATType, *stun.Host, error) + DiscoverTCP() (*stun.Host, error) +} + +func runDiscovery(client discoveryClient, transport string) (stun.NATType, *stun.Host, bool, error) { + switch transport { + case "udp": + nat, host, err := client.Discover() + return nat, host, true, err + case "tcp": + host, err := client.DiscoverTCP() + return stun.NATUnknown, host, false, err + default: + return stun.NATError, nil, false, fmt.Errorf("unsupported STUN transport %q; use udp or tcp", transport) + } +} + func runBehaviorTest(c *stun.Client) error { natBehavior, err := c.BehaviorTest() return writeBehaviorTestResult(os.Stdout, natBehavior, err) diff --git a/main_test.go b/main_test.go index 10436d2..e25ac16 100644 --- a/main_test.go +++ b/main_test.go @@ -33,6 +33,54 @@ func TestWriteBehaviorTestResultTreatsUnsupportedServerAsSuccess(t *testing.T) { } } +func TestRunDiscoveryRejectsUnknownTransport(t *testing.T) { + nat, host, hasNATType, err := runDiscovery(stun.NewClient(), "sctp") + if nat != stun.NATError || host != nil || hasNATType || err == nil { + t.Fatalf("runDiscovery() = %v, %#v, %v, %v", nat, host, hasNATType, err) + } +} + +type discoveryClientStub struct { + udpCalls int + tcpCalls int + udpErr error + tcpErr error +} + +func (c *discoveryClientStub) Discover() (stun.NATType, *stun.Host, error) { + c.udpCalls++ + return stun.NATFull, nil, c.udpErr +} + +func (c *discoveryClientStub) DiscoverTCP() (*stun.Host, error) { + c.tcpCalls++ + return nil, c.tcpErr +} + +func TestRunDiscoveryDispatchesUDP(t *testing.T) { + wantErr := errors.New("UDP failed") + client := &discoveryClientStub{udpErr: wantErr} + nat, host, hasNATType, err := runDiscovery(client, "udp") + if nat != stun.NATFull || host != nil || !hasNATType || !errors.Is(err, wantErr) { + t.Fatalf("runDiscovery() = %v, %#v, %v, %v", nat, host, hasNATType, err) + } + if client.udpCalls != 1 || client.tcpCalls != 0 { + t.Fatalf("calls = UDP %d, TCP %d", client.udpCalls, client.tcpCalls) + } +} + +func TestRunDiscoveryDispatchesTCP(t *testing.T) { + wantErr := errors.New("TCP failed") + client := &discoveryClientStub{tcpErr: wantErr} + nat, host, hasNATType, err := runDiscovery(client, "tcp") + if nat != stun.NATUnknown || host != nil || hasNATType || !errors.Is(err, wantErr) { + t.Fatalf("runDiscovery() = %v, %#v, %v, %v", nat, host, hasNATType, err) + } + if client.udpCalls != 0 || client.tcpCalls != 1 { + t.Fatalf("calls = UDP %d, TCP %d", client.udpCalls, client.tcpCalls) + } +} + func TestWriteBehaviorTestResultPreservesUnsupportedNoTranslation(t *testing.T) { var output bytes.Buffer behavior := &stun.NATBehavior{NoTranslation: true} diff --git a/stun/client.go b/stun/client.go index 29e71ee..88c4f39 100644 --- a/stun/client.go +++ b/stun/client.go @@ -18,10 +18,10 @@ import ( "errors" "net" "strconv" + "time" ) -// Client is a STUN client, which can be set STUN server address and is used -// to discover NAT type. +// Client performs STUN Binding transactions and UDP NAT behavior discovery. type Client struct { serverAddr string localIP string @@ -29,11 +29,15 @@ type Client struct { softwareName string rfc3489Mode bool conn net.PacketConn + tcpConn net.Conn + tcpConnSet bool + tcpTimeout time.Duration + tcpDial func(*net.Dialer, string) (net.Conn, error) logger *Logger } -// NewClient returns a client without network connection. The network -// connection will be build when calling Discover function. +// NewClient returns a client that creates its network connection when a +// discovery method is called. func NewClient() *Client { c := new(Client) c.SetSoftwareName(DefaultSoftwareName) diff --git a/stun/doc.go b/stun/doc.go index ceb9aa3..300ea39 100644 --- a/stun/doc.go +++ b/stun/doc.go @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package stun is a STUN (RFC 3489 and RFC 5389) client implementation in -// golang. +// Package stun is a STUN client implementation for basic Binding over UDP or +// TCP and NAT behavior discovery over UDP. // // It is extremely easy to use -- just one line of code. // diff --git a/stun/net.go b/stun/net.go index 5354949..41095bf 100644 --- a/stun/net.go +++ b/stun/net.go @@ -31,7 +31,14 @@ const ( ) func (c *Client) sendBindingReq(conn net.PacketConn, addr net.Addr, changeIP bool, changePort bool) (*response, error) { - // Construct packet. + pkt, err := c.newBindingRequest(changeIP, changePort) + if err != nil { + return nil, err + } + return c.send(pkt, conn, addr) +} + +func (c *Client) newBindingRequest(changeIP bool, changePort bool) (*packet, error) { pkt, err := newPacket() if err != nil { return nil, err @@ -67,8 +74,7 @@ func (c *Client) sendBindingReq(conn net.PacketConn, addr net.Addr, changeIP boo return nil, err } } - // Send packet. - return c.send(pkt, conn, addr) + return pkt, nil } // RFC 3489: Clients SHOULD retransmit the request starting with an interval @@ -126,25 +132,29 @@ func (c *Client) send(pkt *packet, conn net.PacketConn, addr net.Addr) (*respons continue } c.logger.Info("\n" + hex.Dump(packetBytes[0:length])) - if err := p.validateBindingResponseAttributes(); err != nil { - return nil, err - } - if p.types == typeBindingErrorResponse { - return nil, p.bindingError() - } - resp := newResponse(p, conn) - if resp.mappedAddr == nil { - return nil, errors.New("binding success response has no valid mapped address") - } - if raddr == nil { - return nil, errors.New("binding response has no source address") - } - resp.serverAddr = newHostFromStr(raddr.String()) - if resp.serverAddr == nil { - return nil, errors.New("binding response has an invalid source address") - } - return resp, nil + return processBindingResponse(p, conn, raddr) } } return nil, nil } + +func processBindingResponse(p *packet, conn localAddrProvider, source net.Addr) (*response, error) { + if err := p.validateBindingResponseAttributes(); err != nil { + return nil, err + } + if p.types == typeBindingErrorResponse { + return nil, p.bindingError() + } + resp := newResponse(p, conn) + if resp.mappedAddr == nil { + return nil, errors.New("binding success response has no valid mapped address") + } + if source == nil { + return nil, errors.New("binding response has no source address") + } + resp.serverAddr = newHostFromStr(source.String()) + if resp.serverAddr == nil { + return nil, errors.New("binding response has an invalid source address") + } + return resp, nil +} diff --git a/stun/response.go b/stun/response.go index 47b81e1..ed12a47 100644 --- a/stun/response.go +++ b/stun/response.go @@ -28,7 +28,11 @@ type response struct { identical bool // if mappedAddr is in local addr list } -func newResponse(pkt *packet, conn net.PacketConn) *response { +type localAddrProvider interface { + LocalAddr() net.Addr +} + +func newResponse(pkt *packet, conn localAddrProvider) *response { resp := &response{pkt, nil, nil, nil, nil, false} if pkt == nil { return resp diff --git a/stun/tcp.go b/stun/tcp.go new file mode 100644 index 0000000..edee4ad --- /dev/null +++ b/stun/tcp.go @@ -0,0 +1,204 @@ +// Copyright 2016 Cong Ding +// +// 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 +// +// http://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 stun + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "strconv" + "time" +) + +// DefaultTCPTimeout is the RFC 8489 default time to wait for a STUN response +// after sending a request over TCP. TCP provides reliability, so requests are +// not retransmitted at the STUN layer. When the client opens the connection, +// the same duration independently bounds connection establishment. +const DefaultTCPTimeout = 39500 * time.Millisecond + +// NewClientWithTCPConnection returns a client that performs TCP Binding +// transactions over conn. The caller owns conn and remains responsible for +// closing it. Keeping the connection open also keeps its TCP NAT mapping +// available for subsequent use. +func NewClientWithTCPConnection(conn net.Conn) *Client { + c := NewClient() + c.tcpConn = conn + c.tcpConnSet = true + return c +} + +// SetTCPTimeout sets how long a TCP Binding transaction waits for a response. +// When DiscoverTCP opens the connection, the same duration independently +// bounds connection establishment. The RFC 8489 default response timeout is +// DefaultTCPTimeout. +func (c *Client) SetTCPTimeout(timeout time.Duration) error { + if timeout <= 0 { + return errors.New("TCP timeout must be positive") + } + c.tcpTimeout = timeout + return nil +} + +// DiscoverTCP performs a basic STUN Binding transaction over TCP and returns +// the reflexive TCP transport address reported by the server. +// +// When the client was created with NewClientWithTCPConnection, DiscoverTCP +// reuses that caller-owned connection and leaves it open. Otherwise it opens a +// connection using the configured server and local address, then closes it +// after the transaction. A TCP mapping remains useful only while its +// connection stays open, so callers that need the mapping after this method +// returns should provide their own connection. +func (c *Client) DiscoverTCP() (*Host, error) { + c.ensureLogger() + if c.tcpConnSet { + return c.discoverTCP(c.tcpConn) + } + if c.conn != nil { + return nil, errors.New("TCP discovery cannot use a packet connection") + } + if c.serverAddr == "" { + c.SetServerAddr(DefaultServerAddr) + } + serverAddr, err := net.ResolveTCPAddr("tcp", c.serverAddr) + if err != nil { + return nil, err + } + localAddr, err := c.resolveLocalTCPAddr() + if err != nil { + return nil, err + } + if localAddr != nil { + c.logger.Debugln("Local TCP address:", localAddr) + } + dialer := net.Dialer{ + LocalAddr: localAddr, + Timeout: c.tcpTransactionTimeout(), + } + var conn net.Conn + if c.tcpDial != nil { + conn, err = c.tcpDial(&dialer, serverAddr.String()) + } else { + conn, err = dialer.Dial("tcp", serverAddr.String()) + } + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + return c.discoverTCP(conn) +} + +func (c *Client) discoverTCP(conn net.Conn) (*Host, error) { + if conn == nil { + return nil, errors.New("TCP connection is nil") + } + pkt, err := c.newBindingRequest(false, false) + if err != nil { + return nil, err + } + resp, err := c.sendTCP(pkt, conn) + if err != nil { + return nil, err + } + return resp.mappedAddr, nil +} + +func (c *Client) sendTCP(pkt *packet, conn net.Conn) (*response, error) { + deadline := time.Now().Add(c.tcpTransactionTimeout()) + if err := conn.SetDeadline(deadline); err != nil { + return nil, err + } + defer func() { _ = conn.SetDeadline(time.Time{}) }() + + wire := pkt.bytes() + c.logger.Info("\n" + hex.Dump(wire)) + if err := writeAll(conn, wire); err != nil { + return nil, err + } + + for { + frame, err := readTCPFrame(conn) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + return nil, fmt.Errorf("TCP STUN transaction timed out: %w", err) + } + return nil, err + } + p, err := newPacketFromBytes(frame) + if err != nil { + c.logger.Debugf("Discard TCP response: %v", err) + continue + } + if !bytes.Equal(pkt.transID, p.transID) { + c.logger.Debugln("Discard TCP response: transaction ID mismatch") + continue + } + if p.types != typeBindingResponse && p.types != typeBindingErrorResponse { + c.logger.Debugf("Discard TCP response: unexpected message type %#06x", p.types) + continue + } + c.logger.Info("\n" + hex.Dump(frame)) + return processBindingResponse(p, conn, conn.RemoteAddr()) + } +} + +func readTCPFrame(r io.Reader) ([]byte, error) { + header := make([]byte, 20) + if _, err := io.ReadFull(r, header); err != nil { + return nil, err + } + body := make([]byte, int(binary.BigEndian.Uint16(header[2:4]))) + if _, err := io.ReadFull(r, body); err != nil { + return nil, err + } + return append(header, body...), nil +} + +func writeAll(w io.Writer, data []byte) error { + for len(data) > 0 { + n, err := w.Write(data) + if n < 0 || n > len(data) { + return errors.New("invalid write length") + } + if n > 0 { + data = data[n:] + } + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + } + return nil +} + +func (c *Client) tcpTransactionTimeout() time.Duration { + if c.tcpTimeout > 0 { + return c.tcpTimeout + } + return DefaultTCPTimeout +} + +func (c *Client) resolveLocalTCPAddr() (*net.TCPAddr, error) { + if c.localPort == 0 && c.localIP == "" { + return nil, nil + } + address := net.JoinHostPort(c.localIP, strconv.Itoa(c.localPort)) + return net.ResolveTCPAddr("tcp", address) +} diff --git a/stun/tcp_test.go b/stun/tcp_test.go new file mode 100644 index 0000000..73de81f --- /dev/null +++ b/stun/tcp_test.go @@ -0,0 +1,392 @@ +// Copyright 2016 Cong Ding +// +// 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 +// +// http://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 stun + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +type addressedTCPConn struct { + net.Conn + local net.Addr + remote net.Addr +} + +func (c *addressedTCPConn) LocalAddr() net.Addr { return c.local } +func (c *addressedTCPConn) RemoteAddr() net.Addr { return c.remote } + +func tcpPipe() (*addressedTCPConn, net.Conn) { + client, server := net.Pipe() + return &addressedTCPConn{ + Conn: client, + local: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 5000}, + remote: &net.TCPAddr{IP: net.ParseIP("198.51.100.1"), Port: 3478}, + }, server +} + +func tcpSuccessResponse(request *packet, ip net.IP, port uint16) (*packet, error) { + response := bindingPacket(typeBindingResponse, request.transID) + xorPort := port ^ binary.BigEndian.Uint16(request.transID[:2]) + value := []byte{0, attributeFamilyIPv4, byte(xorPort >> 8), byte(xorPort)} + ip = ip.To4() + for i := range ip { + value = append(value, ip[i]^request.transID[i]) + } + mapped, err := newAttribute(attributeXorMappedAddress, value) + if err != nil { + return nil, err + } + if err := response.addAttribute(*mapped); err != nil { + return nil, err + } + return response, nil +} + +func TestDiscoverTCPReusesCallerConnection(t *testing.T) { + clientConn, serverConn := tcpPipe() + defer func() { _ = clientConn.Close() }() + defer func() { _ = serverConn.Close() }() + + serverErr := make(chan error, 1) + go func() { + for i := 0; i < 2; i++ { + wire, err := readTCPFrame(serverConn) + if err != nil { + serverErr <- err + return + } + request, err := newPacketFromBytes(wire) + if err != nil { + serverErr <- err + return + } + if request.types != typeBindingRequest || len(request.attributes) != 2 || + request.attributes[0].types != attributeSoftware || + request.attributes[1].types != attributeFingerprint { + serverErr <- errors.New("unexpected TCP Binding request") + return + } + response, err := tcpSuccessResponse(request, net.ParseIP("192.0.2.20"), uint16(40000+i)) + if err != nil { + serverErr <- err + return + } + responseWire := response.bytes() + if i == 0 { + if err := writeAll(serverConn, responseWire[:7]); err != nil { + serverErr <- err + return + } + responseWire = responseWire[7:] + } + if err := writeAll(serverConn, responseWire); err != nil { + serverErr <- err + return + } + } + serverErr <- nil + }() + + client := NewClientWithTCPConnection(clientConn) + if err := client.SetTCPTimeout(time.Second); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + host, err := client.DiscoverTCP() + if err != nil { + t.Fatal(err) + } + if got, want := host.String(), net.JoinHostPort("192.0.2.20", strconv.Itoa(40000+i)); got != want { + t.Fatalf("DiscoverTCP() = %s, want %s", got, want) + } + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } +} + +func TestDiscoverTCPDialsAndClosesConnection(t *testing.T) { + clientConn, serverConn := tcpPipe() + defer func() { _ = serverConn.Close() }() + + serverErr := make(chan error, 1) + go func() { + wire, err := readTCPFrame(serverConn) + if err != nil { + serverErr <- err + return + } + request, err := newPacketFromBytes(wire) + if err != nil { + serverErr <- err + return + } + response, err := tcpSuccessResponse(request, net.ParseIP("203.0.113.10"), 45000) + if err != nil { + serverErr <- err + return + } + if err := serverConn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + serverErr <- err + return + } + if err := writeAll(serverConn, response.bytes()); err != nil { + serverErr <- err + return + } + var one [1]byte + if _, err := serverConn.Read(one[:]); !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) { + serverErr <- errors.New("client did not close internally dialed TCP connection") + return + } + serverErr <- nil + }() + + client := NewClient() + client.SetServerAddr("198.51.100.1:3478") + client.SetLocalIP("127.0.0.1") + client.tcpDial = func(dialer *net.Dialer, address string) (net.Conn, error) { + if address != "198.51.100.1:3478" { + t.Fatalf("dial address = %q", address) + } + if dialer.Timeout != time.Second { + t.Fatalf("dial timeout = %v", dialer.Timeout) + } + local, ok := dialer.LocalAddr.(*net.TCPAddr) + if !ok || !local.IP.Equal(net.ParseIP("127.0.0.1")) { + t.Fatalf("dial local address = %#v", dialer.LocalAddr) + } + return clientConn, nil + } + if err := client.SetTCPTimeout(time.Second); err != nil { + t.Fatal(err) + } + host, err := client.DiscoverTCP() + if err != nil { + t.Fatal(err) + } + if got, want := host.String(), "203.0.113.10:45000"; got != want { + t.Fatalf("DiscoverTCP() = %s, want %s", got, want) + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } +} + +func TestSendTCPDiscardsUnrelatedFrames(t *testing.T) { + clientConn, serverConn := tcpPipe() + defer func() { _ = clientConn.Close() }() + defer func() { _ = serverConn.Close() }() + + serverErr := make(chan error, 1) + go func() { + wire, err := readTCPFrame(serverConn) + if err != nil { + serverErr <- err + return + } + request, err := newPacketFromBytes(wire) + if err != nil { + serverErr <- err + return + } + mismatch, err := tcpSuccessResponse(request, net.ParseIP("192.0.2.1"), 40000) + if err != nil { + serverErr <- err + return + } + mismatch.transID[len(mismatch.transID)-1] ^= 1 + wrongType := bindingPacket(typeBindingRequest, request.transID) + valid, err := tcpSuccessResponse(request, net.ParseIP("192.0.2.2"), 40001) + if err != nil { + serverErr <- err + return + } + frames := append(mismatch.bytes(), wrongType.bytes()...) + frames = append(frames, valid.bytes()...) + serverErr <- writeAll(serverConn, frames) + }() + + client := NewClientWithTCPConnection(clientConn) + if err := client.SetTCPTimeout(time.Second); err != nil { + t.Fatal(err) + } + host, err := client.DiscoverTCP() + if err != nil { + t.Fatal(err) + } + if got, want := host.String(), "192.0.2.2:40001"; got != want { + t.Fatalf("DiscoverTCP() = %s, want %s", got, want) + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } +} + +type timeoutTCPConn struct { + writes int + deadlines []time.Time +} + +func (c *timeoutTCPConn) Read([]byte) (int, error) { return 0, timeoutError{} } +func (c *timeoutTCPConn) Write(p []byte) (int, error) { c.writes++; return len(p), nil } +func (c *timeoutTCPConn) Close() error { return nil } +func (c *timeoutTCPConn) LocalAddr() net.Addr { return &net.TCPAddr{} } +func (c *timeoutTCPConn) RemoteAddr() net.Addr { return &net.TCPAddr{} } +func (c *timeoutTCPConn) SetDeadline(t time.Time) error { + c.deadlines = append(c.deadlines, t) + return nil +} +func (c *timeoutTCPConn) SetReadDeadline(time.Time) error { return nil } +func (c *timeoutTCPConn) SetWriteDeadline(time.Time) error { return nil } + +func TestSendTCPUsesOneRequestAndTransactionTimeout(t *testing.T) { + pkt, err := NewClient().newBindingRequest(false, false) + if err != nil { + t.Fatal(err) + } + conn := &timeoutTCPConn{} + client := NewClient() + if err := client.SetTCPTimeout(time.Second); err != nil { + t.Fatal(err) + } + resp, err := client.sendTCP(pkt, conn) + if resp != nil || err == nil || !strings.Contains(err.Error(), "TCP STUN transaction timed out") { + t.Fatalf("sendTCP() = %#v, %v", resp, err) + } + if conn.writes != 1 { + t.Fatalf("TCP writes = %d, want 1", conn.writes) + } + if len(conn.deadlines) != 2 || conn.deadlines[0].IsZero() || !conn.deadlines[1].IsZero() { + t.Fatalf("deadlines = %#v", conn.deadlines) + } +} + +func TestTCPBindingReturnsServerError(t *testing.T) { + clientConn, serverConn := tcpPipe() + defer func() { _ = clientConn.Close() }() + defer func() { _ = serverConn.Close() }() + + errorAttr := errorCodeAttribute(t, 420, "Unknown Attribute") + serverResult := make(chan error, 1) + go func() { + wire, err := readTCPFrame(serverConn) + if err != nil { + serverResult <- err + return + } + request, err := newPacketFromBytes(wire) + if err != nil { + serverResult <- err + return + } + response := bindingPacket(typeBindingErrorResponse, request.transID) + if err := response.addAttribute(*errorAttr); err != nil { + serverResult <- err + return + } + serverResult <- writeAll(serverConn, response.bytes()) + }() + + client := NewClientWithTCPConnection(clientConn) + if err := client.SetTCPTimeout(time.Second); err != nil { + t.Fatal(err) + } + host, err := client.DiscoverTCP() + var serverErr *ServerError + if host != nil || !errors.As(err, &serverErr) || serverErr.Code != 420 { + t.Fatalf("DiscoverTCP() = %#v, %#v", host, err) + } + if err := <-serverResult; err != nil { + t.Fatal(err) + } +} + +func TestTCPConfigurationValidation(t *testing.T) { + client := NewClient() + if client.tcpTransactionTimeout() != DefaultTCPTimeout { + t.Fatalf("default timeout = %v", client.tcpTransactionTimeout()) + } + for _, timeout := range []time.Duration{0, -time.Second} { + if err := client.SetTCPTimeout(timeout); err == nil { + t.Fatalf("SetTCPTimeout(%v) succeeded", timeout) + } + } + if host, err := NewClientWithTCPConnection(nil).DiscoverTCP(); host != nil || err == nil || err.Error() != "TCP connection is nil" { + t.Fatalf("nil TCP connection returned %#v, %v", host, err) + } + packetClient := NewClientWithConnection(&scriptedPacketConn{}) + if host, err := packetClient.DiscoverTCP(); host != nil || err == nil { + t.Fatalf("packet connection returned %#v, %v", host, err) + } + client.SetServerAddr(":") + if host, err := client.DiscoverTCP(); host != nil || err == nil { + t.Fatalf("invalid server returned %#v, %v", host, err) + } +} + +type chunkWriter struct { + max int + writes int + zeroWrite bool + buf bytes.Buffer + mu sync.Mutex +} + +func (w *chunkWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.writes++ + if w.zeroWrite { + return 0, nil + } + if len(p) > w.max { + p = p[:w.max] + } + return w.buf.Write(p) +} + +func TestTCPFrameHelpers(t *testing.T) { + frame := make([]byte, 24) + binary.BigEndian.PutUint16(frame[2:4], 4) + copy(frame[20:], []byte("body")) + got, err := readTCPFrame(bytes.NewReader(frame)) + if err != nil || !bytes.Equal(got, frame) { + t.Fatalf("readTCPFrame() = %x, %v", got, err) + } + if _, err := readTCPFrame(bytes.NewReader(frame[:22])); !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("truncated frame error = %v", err) + } + + w := &chunkWriter{max: 2} + if err := writeAll(w, []byte("abcdef")); err != nil { + t.Fatal(err) + } + if got := w.buf.String(); got != "abcdef" || w.writes != 3 { + t.Fatalf("writeAll result = %q in %d writes", got, w.writes) + } + if err := writeAll(&chunkWriter{max: 1, zeroWrite: true}, []byte("x")); !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("zero write error = %v", err) + } +}