Repository: pion/turn (go)

Issue:
Support of external IP address mapping
## Summary
When you want to host TURN server on AWS EC2 for instance, the instance can have a private IP address, but not public address directly. It allows us to have an associated public IP address (or Elastic IP, etc, there will be a NAT in betwee), but TURN server does not know about the public IP address at all. Since it is TURN server's responsibility to report the public IP address to the TURN client, there need to be a way to tell the associated public IP address to the TURN server.

## Motivation
To make pion/turn available for real use cases.

## Describe alternatives you've considered
Tell the external IP address via:
1. command line option
1. environment variable

## Additional context
coturn, for instance, have -X option for this purpose.


Pull request description:
Add external IP mapping
Resolves #56

See #56 for the motivation of this PR.

Patch:
diff --git a/internal/allocation/allocation_manager.go b/internal/allocation/allocation_manager.go
index 5509b51..28b9c11 100644
--- a/internal/allocation/allocation_manager.go
+++ b/internal/allocation/allocation_manager.go
@@ -21,6 +21,7 @@ type ManagerConfig struct {
 type Manager struct {
 	lock        sync.RWMutex
 	allocations map[string]*Allocation
+	extIPMap    map[string]net.IP
 	log         logging.LeveledLogger
 	net         *vnet.Net
 }
@@ -34,6 +35,7 @@ func NewManager(config *ManagerConfig) *Manager {
 		log:         config.LeveledLogger,
 		net:         config.Net,
 		allocations: make(map[string]*Allocation, 64),
+		extIPMap:    make(map[string]net.IP),
 	}
 }
 
@@ -87,9 +89,9 @@ func (m *Manager) CreateAllocation(
 
 	a := NewAllocation(turnSocket, fiveTuple, m.log)
 
-	network := "udp4"
-	relayAddr := fmt.Sprintf("%s:%d", relayIP.String(), requestedPort)
-	conn, err := m.net.ListenPacket(network, relayAddr)
+	conn, err := m.net.ListenPacket(
+		"udp4",
+		fmt.Sprintf("%s:%d", relayIP.String(), requestedPort))
 	if err != nil {
 		return nil, err
 	}
@@ -97,7 +99,19 @@ func (m *Manager) CreateAllocation(
 	m.log.Debugf("listening on relay addr: %s", conn.LocalAddr().String())
 
 	a.RelaySocket = conn
-	a.RelayAddr = conn.LocalAddr()
+
+	// Determine RelayAddr.
+	// If there's a corresponding external IP address, replace
+	// the relay IP with the external one.
+	relayAddr := conn.LocalAddr().(*net.UDPAddr)
+	if extIP, ok := m.extIPMap[relayAddr.IP.String()]; ok {
+		a.RelayAddr = &net.UDPAddr{
+			IP:   extIP,
+			Port: relayAddr.Port,
+		}
+	} else {
+		a.RelayAddr = conn.LocalAddr()
+	}
 
 	a.lifetimeTimer = time.AfterFunc(lifetime, func() {
 		m.DeleteAllocation(a.fiveTuple)
@@ -128,3 +142,11 @@ func (m *Manager) DeleteAllocation(fiveTuple *FiveTuple) {
 		m.log.Errorf("Failed to close allocation: %v", err)
 	}
 }
+
+// AddExternalIPMapping add a external IP address mapping.
+func (m *Manager) AddExternalIPMapping(extIP, locIP net.IP) {
+	m.lock.Lock()
+	defer m.lock.Unlock()
+
+	m.extIPMap[locIP.String()] = extIP
+}
diff --git a/server.go b/server.go
index fea5ad1..5e15d80 100644
--- a/server.go
+++ b/server.go
@@ -7,6 +7,7 @@ import (
 	"math/rand"
 	"net"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -57,6 +58,7 @@ type Server struct {
 	listeners          []*listener
 	listenIPs          []net.IP
 	relayIPs           []net.IP
+	extIPMappings      [][2]net.IP
 	listenPort         int
 	realm              string
 	authHandler        AuthHandler
@@ -151,6 +153,33 @@ func (s *Server) AddRelayIPAddr(addrStr string) error {
 	return nil
 }
 
+// AddExternalIPAddr adds a mapping to an external IP address.
+// This is useful when the TURN server is behind a 1-to-1 NAT, or a D-NAT
+// (which is typical when hosting on AWS EC2).
+// The argument should be a dotted-decimal representation of external IP address.
+// If there is more than on IP address, the argument should be a cancatenation of
+// external IP and local IP delimited by a '/'.
+// Ex 1: "185.199.108.153"
+// Ex 2: "185.199.108.153/10.0.1.2"
+// By default, no address mapping is used.
+// This method must be called before calling Start().
+func (s *Server) AddExternalIPAddr(mapping string) error {
+	var extIP, locIP net.IP
+	ipStrs := strings.Split(mapping, "/")
+	extIP = net.ParseIP(ipStrs[0])
+	if extIP == nil {
+		return fmt.Errorf("invalid IP address: %s", ipStrs[0])
+	}
+	if len(ipStrs) > 1 {
+		locIP = net.ParseIP(ipStrs[1])
+		if locIP == nil {
+			return fmt.Errorf("invalid IP address: %s", ipStrs[1])
+		}
+	}
+	s.extIPMappings = append(s.extIPMappings, [2]net.IP{extIP, locIP})
+	return nil
+}
+
 // caller must hold the mutex
 func (s *Server) gatherSystemIPAddrs() ([]net.IP, error) {
 	s.log.Debug("gathering local IP address...")
@@ -249,7 +278,6 @@ func (s *Server) Start() error {
 	defer s.lock.Unlock()
 
 	var ips []net.IP
-
 	if len(s.listenIPs) == 0 {
 		var err error
 		ips, err = s.gatherSystemIPAddrs()
@@ -268,6 +296,24 @@ func (s *Server) Start() error {
 		s.relayIPs = s.listenIPs
 	}
 
+	// Set external IP mapping for the allocation manager
+	for _, mapping := range s.extIPMappings {
+		extIPIsV4 := (mapping[0].To4() != nil)
+		if mapping[1] == nil {
+			for _, relayIP := range s.relayIPs {
+				relayIPIsV4 := (relayIP.To4() != nil)
+				if (extIPIsV4 && relayIPIsV4) || (!extIPIsV4 && !relayIPIsV4) {
+					s.manager.AddExternalIPMapping(mapping[0], relayIP)
+				}
+			}
+		} else {
+			relayIPIsV4 := (mapping[1].To4() != nil)
+			if (extIPIsV4 && relayIPIsV4) || (!extIPIsV4 && !relayIPIsV4) {
+				s.manager.AddExternalIPMapping(mapping[0], mapping[1])
+			}
+		}
+	}
+
 	for _, localIP := range s.listenIPs {
 		err := s.listen(localIP)
 		if err != nil {
diff --git a/turn.go b/turn.go
index e998d9a..16437df 100644
--- a/turn.go
+++ b/turn.go
@@ -213,7 +213,8 @@ func (s *Server) handleAllocateRequest(conn net.PacketConn, srcAddr net.Addr, m
 	// if bad, redirect { stun.AttrErrorCode, 300 }
 	lifetimeDuration := allocationLifeTime(m)
 	a, err := s.manager.CreateAllocation(
-		fiveTuple, conn,
+		fiveTuple,
+		conn,
 		s.relayIPs[0], // TODO: allow more than one relay IP
 		requestedPort,
 		lifetimeDuration)
@@ -237,19 +238,14 @@ func (s *Server) handleAllocateRequest(conn net.PacketConn, srcAddr net.Addr, m
 		return respondWithError(err, messageIntegrity, stun.CodeBadRequest)
 	}
 
-	_, relayPort, err := ipnet.AddrIPPort(a.RelayAddr)
-	if err != nil {
-		return respondWithError(err, messageIntegrity, stun.CodeBadRequest)
-	}
-
-	dstIP, _, err := ipnet.AddrIPPort(dstAddr)
+	relayIP, relayPort, err := ipnet.AddrIPPort(a.RelayAddr)
 	if err != nil {
 		return respondWithError(err, messageIntegrity, stun.CodeBadRequest)
 	}
 
 	responseAttrs := []stun.Setter{
 		&proto.RelayedAddress{
-			IP:   dstIP,
+			IP:   relayIP,
 			Port: relayPort,
 		},
 		&proto.Lifetime{


Test patch:
diff --git a/internal/allocation/allocation_manager_test.go b/internal/allocation/allocation_manager_test.go
index e0ca9c4..40dae55 100644
--- a/internal/allocation/allocation_manager_test.go
+++ b/internal/allocation/allocation_manager_test.go
@@ -23,6 +23,7 @@ func TestManager(t *testing.T) {
 		{"DeleteAllocation", subTestDeleteAllocation},
 		{"AllocationTimeout", subTestAllocationTimeout},
 		{"Close", subTestManagerClose},
+		{"ExternalIPMapping", subTestExternalIPMapping},
 	}
 
 	network := "udp4"
@@ -146,6 +147,41 @@ func subTestManagerClose(t *testing.T, turnSocket net.PacketConn) {
 	}
 }
 
+// test for external mapping
+func subTestExternalIPMapping(t *testing.T, turnSocket net.PacketConn) {
+	m := newTestManager()
+	fiveTuple := randomFiveTuple()
+
+	extIP := net.ParseIP("1.2.3.4")
+	m.AddExternalIPMapping(extIP, net.IPv4zero)
+
+	a, err := m.CreateAllocation(
+		fiveTuple,
+		turnSocket,
+		net.IPv4zero,
+		0,
+		proto.DefaultLifetime)
+
+	if a == nil || err != nil {
+		t.Errorf("Failed to create allocation %v %v", a, err)
+	}
+
+	relAddr, ok := a.RelayAddr.(*net.UDPAddr)
+	if !ok {
+		t.Errorf("RelayAddr must be a net.UDPAddr")
+	}
+
+	// IP addr should match the external IP
+	if !relAddr.IP.Equal(extIP) {
+		t.Errorf("should have the external IP")
+	}
+
+	m.DeleteAllocation(fiveTuple)
+	if a = m.GetAllocation(fiveTuple); a != nil {
+		t.Errorf("Get allocation with %v should be nil after delete", fiveTuple)
+	}
+}
+
 func randomFiveTuple() *FiveTuple {
 	/* #nosec */
 	return &FiveTuple{
diff --git a/server_test.go b/server_test.go
index 6782776..0f19031 100644
--- a/server_test.go
+++ b/server_test.go
@@ -209,4 +209,49 @@ func TestServer(t *testing.T) {
 		err = server.Close()
 		assert.NoError(t, err, "should succeed")
 	})
+
+	t.Run("AddExternalIPAddr", func(t *testing.T) {
+		server := NewServer(&ServerConfig{
+			LoggerFactory: loggerFactory,
+		})
+
+		err := server.AddExternalIPAddr("1.2.3.4")
+		assert.NoError(t, err, "should succeed")
+
+		assert.Equal(t, 1, len(server.extIPMappings), "should be 1")
+		assert.True(
+			t,
+			server.extIPMappings[0][0].Equal(net.ParseIP("1.2.3.4")),
+			"should be true")
+		assert.Nil(t, server.extIPMappings[0][1], "should be nil")
+	})
+
+	t.Run("AddExternalIPAddr 2", func(t *testing.T) {
+		server := NewServer(&ServerConfig{
+			LoggerFactory: loggerFactory,
+		})
+
+		err := server.AddExternalIPAddr("1.2.3.4/10.0.0.2")
+		assert.NoError(t, err, "should succeed")
+		err = server.AddExternalIPAddr("1.2.3.5/10.0.0.3")
+		assert.NoError(t, err, "should succeed")
+
+		assert.Equal(t, 2, len(server.extIPMappings), "should be 2")
+		assert.True(
+			t,
+			server.extIPMappings[0][0].Equal(net.ParseIP("1.2.3.4")),
+			"should be true")
+		assert.True(
+			t,
+			server.extIPMappings[0][1].Equal(net.ParseIP("10.0.0.2")),
+			"should be true")
+		assert.True(
+			t,
+			server.extIPMappings[1][0].Equal(net.ParseIP("1.2.3.5")),
+			"should be true")
+		assert.True(
+			t,
+			server.extIPMappings[1][1].Equal(net.ParseIP("10.0.0.3")),
+			"should be true")
+	})
 }