Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
[![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.

## 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].
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
35 changes: 32 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/ccding/go-stun/stun"
)
Expand All @@ -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()

Expand All @@ -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)
Expand All @@ -58,21 +65,43 @@ 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())
fmt.Println("External Port:", host.Port())
}
}

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)
Expand Down
48 changes: 48 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
12 changes: 8 additions & 4 deletions stun/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,26 @@ 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
localPort int
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)
Expand Down
4 changes: 2 additions & 2 deletions stun/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
52 changes: 31 additions & 21 deletions stun/net.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
6 changes: 5 additions & 1 deletion stun/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading