Pārlūkot izejas kodu

lib/nat: Add a nat package and service to track mappings on multiple IGDs

Audrius Butkevicius 9 gadi atpakaļ
vecāks
revīzija
19b4f3bfb4

+ 25 - 24
cmd/syncthing/main.go

@@ -38,13 +38,14 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/logger"
 	"github.com/syncthing/syncthing/lib/model"
+	"github.com/syncthing/syncthing/lib/nat"
 	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/relay"
 	"github.com/syncthing/syncthing/lib/symlinks"
 	"github.com/syncthing/syncthing/lib/tlsutil"
 	"github.com/syncthing/syncthing/lib/upgrade"
-	"github.com/syncthing/syncthing/lib/upnp"
+	_ "github.com/syncthing/syncthing/lib/upnp"
 	"github.com/syncthing/syncthing/lib/util"
 
 	"github.com/thejerf/suture"
@@ -557,10 +558,6 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 		}
 	}
 
-	// We reinitialize the predictable RNG with our device ID, to get a
-	// sequence that is always the same but unique to this syncthing instance.
-	util.PredictableRandom.Seed(util.SeedFromBytes(cert.Certificate[0]))
-
 	myID = protocol.NewDeviceID(cert.Certificate[0])
 	l.SetPrefix(fmt.Sprintf("[%s] ", myID.String()[:5]))
 
@@ -709,26 +706,30 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 
 	mainService.Add(m)
 
-	// The default port we announce, possibly modified by setupUPnP next.
-
-	uri, err := url.Parse(opts.ListenAddress[0])
-	if err != nil {
-		l.Fatalf("Failed to parse listen address %s: %v", opts.ListenAddress[0], err)
-	}
+	// Start NAT service
+	var natService *nat.Service
+	var mappings []*nat.Mapping
+	if opts.NATEnabled {
+		natService = nat.NewService(myID, cfg)
+		for _, addrStr := range opts.ListenAddress {
+			uri, err := url.Parse(addrStr)
+			if err != nil {
+				l.Fatalf("Failed to parse listen address %s: %v", addrStr, err)
+			}
 
-	addr, err := net.ResolveTCPAddr("tcp", uri.Host)
-	if err != nil {
-		l.Fatalln("Bad listen address:", err)
-	}
-	if addr.Port == 0 {
-		l.Fatalf("Listen address %s: invalid port", uri)
-	}
+			if uri.Scheme == "tcp" || uri.Scheme == "tcp4" {
+				addr, err := net.ResolveTCPAddr(uri.Scheme, uri.Host)
+				if err != nil {
+					l.Fatalln("Bad listen address:", err)
+				}
+				if addr.Port == 0 {
+					l.Fatalf("Listen address %s: invalid port", uri)
+				}
 
-	// Start UPnP
-	var upnpService *upnp.Service
-	if opts.UPnPEnabled {
-		upnpService = upnp.NewUPnPService(cfg, addr.Port)
-		mainService.Add(upnpService)
+				mappings = append(mappings, natService.NewMapping(nat.TCP, addr.IP, addr.Port))
+			}
+		}
+		mainService.Add(natService)
 	}
 
 	// Start relay management
@@ -746,7 +747,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 
 	// Start connection management
 
-	connectionService := connections.NewConnectionService(cfg, myID, m, tlsCfg, cachedDiscovery, upnpService, relayService, bepProtocolName, tlsDefaultCommonName, lans)
+	connectionService := connections.NewConnectionService(cfg, myID, m, tlsCfg, cachedDiscovery, mappings, relayService, bepProtocolName, tlsDefaultCommonName, lans)
 	mainService.Add(connectionService)
 
 	if cfg.Options().GlobalAnnEnabled {

+ 6 - 4
cmd/syncthing/verboseservice.go

@@ -147,11 +147,13 @@ func (s *verboseService) formatEvent(ev events.Event) string {
 		data := ev.Data.(map[string]string)
 		device := data["device"]
 		return fmt.Sprintf("Device %v was resumed", device)
-
 	case events.ExternalPortMappingChanged:
-		data := ev.Data.(map[string]int)
-		port := data["port"]
-		return fmt.Sprintf("External port mapping changed; new port is %d.", port)
+		data := ev.Data.(map[string]interface{})
+		protocol := data["protocol"]
+		local := data["local"]
+		added := data["added"]
+		removed := data["removed"]
+		return fmt.Sprintf("External port mapping changed; protocol: %s, local: %s, added: %s, removed: %s", protocol, local, added, removed)
 	case events.RelayStateChanged:
 		data := ev.Data.(map[string][]string)
 		newRelays := data["new"]

+ 1 - 1
gui/default/syncthing/settings/settingsModalView.html

@@ -40,7 +40,7 @@
                 <div class="form-group">
                   <div class="checkbox">
                     <label>
-                      <input id="UPnPEnabled" type="checkbox" ng-model="tmpOptions.upnpEnabled"> <span translate>Enable UPnP</span>
+                      <input id="NATEnabled" type="checkbox" ng-model="tmpOptions.natEnabled"> <span translate>Enable NAT traversal</span>
                     </label>
                   </div>
                 </div>

+ 4 - 0
lib/config/config.go

@@ -242,6 +242,10 @@ func convertV12V13(cfg *Configuration) {
 	// Not using the ignore cache is the new default. Disable it on existing
 	// configurations.
 	cfg.Options.CacheIgnoredFiles = false
+	cfg.Options.NATEnabled = cfg.Options.DeprecatedUPnPEnabled
+	cfg.Options.NATLeaseM = cfg.Options.DeprecatedUPnPLeaseM
+	cfg.Options.NATRenewalM = cfg.Options.DeprecatedUPnPRenewalM
+	cfg.Options.NATTimeoutS = cfg.Options.DeprecatedUPnPTimeoutS
 	cfg.Version = 13
 }
 

+ 8 - 8
lib/config/config_test.go

@@ -44,10 +44,10 @@ func TestDefaultValues(t *testing.T) {
 		RelaysEnabled:           true,
 		RelayReconnectIntervalM: 10,
 		StartBrowser:            true,
-		UPnPEnabled:             true,
-		UPnPLeaseM:              60,
-		UPnPRenewalM:            30,
-		UPnPTimeoutS:            10,
+		NATEnabled:              true,
+		NATLeaseM:               60,
+		NATRenewalM:             30,
+		NATTimeoutS:             10,
 		RestartOnWakeup:         true,
 		AutoUpgradeIntervalH:    12,
 		KeepTemporariesH:        24,
@@ -174,10 +174,10 @@ func TestOverriddenValues(t *testing.T) {
 		RelaysEnabled:           false,
 		RelayReconnectIntervalM: 20,
 		StartBrowser:            false,
-		UPnPEnabled:             false,
-		UPnPLeaseM:              90,
-		UPnPRenewalM:            15,
-		UPnPTimeoutS:            15,
+		NATEnabled:              false,
+		NATLeaseM:               90,
+		NATRenewalM:             15,
+		NATTimeoutS:             15,
 		RestartOnWakeup:         false,
 		AutoUpgradeIntervalH:    24,
 		KeepTemporariesH:        48,

+ 9 - 4
lib/config/optionsconfiguration.go

@@ -20,10 +20,10 @@ type OptionsConfiguration struct {
 	RelaysEnabled           bool     `xml:"relaysEnabled" json:"relaysEnabled" default:"true"`
 	RelayReconnectIntervalM int      `xml:"relayReconnectIntervalM" json:"relayReconnectIntervalM" default:"10"`
 	StartBrowser            bool     `xml:"startBrowser" json:"startBrowser" default:"true"`
-	UPnPEnabled             bool     `xml:"upnpEnabled" json:"upnpEnabled" default:"true"`
-	UPnPLeaseM              int      `xml:"upnpLeaseMinutes" json:"upnpLeaseMinutes" default:"60"`
-	UPnPRenewalM            int      `xml:"upnpRenewalMinutes" json:"upnpRenewalMinutes" default:"30"`
-	UPnPTimeoutS            int      `xml:"upnpTimeoutSeconds" json:"upnpTimeoutSeconds" default:"10"`
+	NATEnabled              bool     `xml:"natEnabled" json:"natEnabled" default:"true"`
+	NATLeaseM               int      `xml:"natLeaseMinutes" json:"natLeaseMinutes" default:"60"`
+	NATRenewalM             int      `xml:"natRenewalMinutes" json:"natRenewalMinutes" default:"30"`
+	NATTimeoutS             int      `xml:"natTimeoutSeconds" json:"natTimeoutSeconds" default:"10"`
 	URAccepted              int      `xml:"urAccepted" json:"urAccepted"` // Accepted usage reporting version; 0 for off (undecided), -1 for off (permanently)
 	URUniqueID              string   `xml:"urUniqueID" json:"urUniqueId"` // Unique ID for reporting purposes, regenerated when UR is turned on.
 	URURL                   string   `xml:"urURL" json:"urURL" default:"https://data.syncthing.net/newdata"`
@@ -40,6 +40,11 @@ type OptionsConfiguration struct {
 	ReleasesURL             string   `xml:"releasesURL" json:"releasesURL" default:"https://api.github.com/repos/syncthing/syncthing/releases?per_page=30"`
 	AlwaysLocalNets         []string `xml:"alwaysLocalNet" json:"alwaysLocalNets"`
 	OverwriteNames          bool     `xml:"overwriteNames" json:"overwriteNames" default:"false"`
+
+	DeprecatedUPnPEnabled  bool `xml:"upnpEnabled"`
+	DeprecatedUPnPLeaseM   int  `xml:"upnpLeaseMinutes"`
+	DeprecatedUPnPRenewalM int  `xml:"upnpRenewalMinutes"`
+	DeprecatedUPnPTimeoutS int  `xml:"upnpTimeoutSeconds"`
 }
 
 func (orig OptionsConfiguration) Copy() OptionsConfiguration {

+ 4 - 4
lib/config/testdata/overridenvalues.xml

@@ -17,10 +17,10 @@
         <relayReconnectIntervalM>20</relayReconnectIntervalM>
         <relayWithoutGlobalAnn>true</relayWithoutGlobalAnn>
         <startBrowser>false</startBrowser>
-        <upnpEnabled>false</upnpEnabled>
-        <upnpLeaseMinutes>90</upnpLeaseMinutes>
-        <upnpRenewalMinutes>15</upnpRenewalMinutes>
-        <upnpTimeoutSeconds>15</upnpTimeoutSeconds>
+        <natEnabled>false</natEnabled>
+        <natLeaseMinutes>90</natLeaseMinutes>
+        <natRenewalMinutes>15</natRenewalMinutes>
+        <natTimeoutSeconds>15</natTimeoutSeconds>
         <restartOnWakeup>false</restartOnWakeup>
         <autoUpgradeIntervalH>24</autoUpgradeIntervalH>
         <keepTemporariesH>48</keepTemporariesH>

+ 20 - 9
lib/connections/connections.go

@@ -19,11 +19,12 @@ import (
 	"github.com/juju/ratelimit"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/discover"
+	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/model"
+	"github.com/syncthing/syncthing/lib/nat"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/relay"
 	"github.com/syncthing/syncthing/lib/relay/client"
-	"github.com/syncthing/syncthing/lib/upnp"
 	"github.com/syncthing/syncthing/lib/util"
 
 	"github.com/thejerf/suture"
@@ -56,7 +57,7 @@ type Service struct {
 	tlsCfg               *tls.Config
 	discoverer           discover.Finder
 	conns                chan model.IntermediateConnection
-	upnpService          *upnp.Service
+	mappings             []*nat.Mapping
 	relayService         relay.Service
 	bepProtocolName      string
 	tlsDefaultCommonName string
@@ -71,7 +72,7 @@ type Service struct {
 	relaysEnabled bool
 }
 
-func NewConnectionService(cfg *config.Wrapper, myID protocol.DeviceID, mdl Model, tlsCfg *tls.Config, discoverer discover.Finder, upnpService *upnp.Service,
+func NewConnectionService(cfg *config.Wrapper, myID protocol.DeviceID, mdl Model, tlsCfg *tls.Config, discoverer discover.Finder, mappings []*nat.Mapping,
 	relayService relay.Service, bepProtocolName string, tlsDefaultCommonName string, lans []*net.IPNet) *Service {
 	service := &Service{
 		Supervisor:           suture.NewSimple("connections.Service"),
@@ -80,7 +81,7 @@ func NewConnectionService(cfg *config.Wrapper, myID protocol.DeviceID, mdl Model
 		model:                mdl,
 		tlsCfg:               tlsCfg,
 		discoverer:           discoverer,
-		upnpService:          upnpService,
+		mappings:             mappings,
 		relayService:         relayService,
 		conns:                make(chan model.IntermediateConnection),
 		bepProtocolName:      bepProtocolName,
@@ -140,6 +141,17 @@ func NewConnectionService(cfg *config.Wrapper, myID protocol.DeviceID, mdl Model
 		service.Add(serviceFunc(service.acceptRelayConns))
 	}
 
+	for _, mapping := range mappings {
+		mapping.OnChanged(func(m *nat.Mapping, added, removed []nat.Address) {
+			events.Default.Log(events.ExternalPortMappingChanged, map[string]interface{}{
+				"protocol": m.Protocol(),
+				"local":    m.Address().String(),
+				"added":    added,
+				"removed":  removed,
+			})
+		})
+	}
+
 	return service
 }
 
@@ -531,11 +543,10 @@ func (s *Service) addresses(includePrivateIPV4 bool) []string {
 		}
 	}
 
-	// Get an external port mapping from the upnpService, if it has one. If so,
-	// add it as another unspecified address.
-	if s.upnpService != nil {
-		if port := s.upnpService.ExternalPort(); port != 0 {
-			addrs = append(addrs, fmt.Sprintf("tcp://:%d", port))
+	// Add addresses provided by the mappings from the NAT service.
+	for _, mapping := range s.mappings {
+		for _, addr := range mapping.ExternalAddresses() {
+			addrs = append(addrs, fmt.Sprintf("tcp://%s", addr))
 		}
 	}
 

+ 22 - 0
lib/nat/debug.go

@@ -0,0 +1,22 @@
+// Copyright (C) 2015 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package nat
+
+import (
+	"os"
+	"strings"
+
+	"github.com/syncthing/syncthing/lib/logger"
+)
+
+var (
+	l = logger.DefaultLogger.NewFacility("nat", "NAT discovery and port mapping")
+)
+
+func init() {
+	l.SetDebug("nat", strings.Contains(os.Getenv("STTRACE"), "nat") || os.Getenv("STTRACE") == "all")
+}

+ 26 - 0
lib/nat/interface.go

@@ -0,0 +1,26 @@
+// Copyright (C) 2015 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package nat
+
+import (
+	"net"
+	"time"
+)
+
+type Protocol string
+
+const (
+	TCP Protocol = "TCP"
+	UDP          = "UDP"
+)
+
+type Device interface {
+	ID() string
+	GetLocalIPAddress() net.IP
+	AddPortMapping(protocol Protocol, internalPort, externalPort int, description string, duration time.Duration) (int, error)
+	GetExternalIPAddress() (net.IP, error)
+}

+ 30 - 0
lib/nat/registry.go

@@ -0,0 +1,30 @@
+// Copyright (C) 2015 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package nat
+
+import (
+	"time"
+)
+
+type DiscoverFunc func(renewal, timeout time.Duration) []Device
+
+var providers []DiscoverFunc
+
+func Register(provider DiscoverFunc) {
+	providers = append(providers, provider)
+}
+
+func discoverAll(renewal, timeout time.Duration) map[string]Device {
+	nats := make(map[string]Device)
+	for _, discoverFunc := range providers {
+		discoveredNATs := discoverFunc(renewal, timeout)
+		for _, discoveredNAT := range discoveredNATs {
+			nats[discoveredNAT.ID()] = discoveredNAT
+		}
+	}
+	return nats
+}

+ 298 - 0
lib/nat/service.go

@@ -0,0 +1,298 @@
+// Copyright (C) 2015 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package nat
+
+import (
+	"fmt"
+	"math/rand"
+	"net"
+	stdsync "sync"
+	"time"
+
+	"github.com/syncthing/syncthing/lib/config"
+	"github.com/syncthing/syncthing/lib/protocol"
+	"github.com/syncthing/syncthing/lib/sync"
+)
+
+// Service runs a loop for discovery of IGDs (Internet Gateway Devices) and
+// setup/renewal of a port mapping.
+type Service struct {
+	id        protocol.DeviceID
+	cfg       *config.Wrapper
+	stop      chan struct{}
+	immediate chan chan struct{}
+	timer     *time.Timer
+	announce  *stdsync.Once
+
+	mappings []*Mapping
+	mut      sync.RWMutex
+}
+
+func NewService(id protocol.DeviceID, cfg *config.Wrapper) *Service {
+	return &Service{
+		id:  id,
+		cfg: cfg,
+
+		immediate: make(chan chan struct{}),
+		timer:     time.NewTimer(time.Second),
+
+		mut: sync.NewRWMutex(),
+	}
+}
+
+func (s *Service) Serve() {
+	s.timer.Reset(0)
+	s.stop = make(chan struct{})
+	s.announce = &stdsync.Once{}
+
+	for {
+		select {
+		case result := <-s.immediate:
+			s.process()
+			close(result)
+		case <-s.timer.C:
+			s.process()
+		case <-s.stop:
+			s.timer.Stop()
+			return
+		}
+	}
+}
+
+func (s *Service) process() {
+	// toRenew are mappings which are due for renewal
+	// toUpdate are the remaining mappings, which will only be updated if one of
+	// the old IGDs has gone away, or a new IGD has appeared, but only if we
+	// actually need to perform a renewal.
+	var toRenew, toUpdate []*Mapping
+
+	renewIn := time.Duration(s.cfg.Options().NATRenewalM) * time.Minute
+	if renewIn == 0 {
+		// We always want to do renewal so lets just pick a nice sane number.
+		renewIn = 30 * time.Minute
+	}
+
+	s.mut.RLock()
+	for _, mapping := range s.mappings {
+		if mapping.expires.Before(time.Now()) {
+			toRenew = append(toRenew, mapping)
+		} else {
+			toUpdate = append(toUpdate, mapping)
+			mappingRenewIn := mapping.expires.Sub(time.Now())
+			if mappingRenewIn < renewIn {
+				renewIn = mappingRenewIn
+			}
+		}
+	}
+	s.mut.RUnlock()
+
+	s.timer.Reset(renewIn)
+
+	// Don't do anything, unless we really need to renew
+	if len(toRenew) == 0 {
+		return
+	}
+
+	nats := discoverAll(time.Duration(s.cfg.Options().NATRenewalM)*time.Minute, time.Duration(s.cfg.Options().NATTimeoutS)*time.Second)
+
+	s.announce.Do(func() {
+		suffix := "s"
+		if len(nats) == 1 {
+			suffix = ""
+		}
+		l.Infoln("Detected", len(nats), "NAT device"+suffix)
+	})
+
+	for _, mapping := range toRenew {
+		s.updateMapping(mapping, nats, true)
+	}
+
+	for _, mapping := range toUpdate {
+		s.updateMapping(mapping, nats, false)
+	}
+}
+
+func (s *Service) Stop() {
+	close(s.stop)
+}
+
+func (s *Service) NewMapping(protocol Protocol, ip net.IP, port int) *Mapping {
+	mapping := &Mapping{
+		protocol: protocol,
+		address: Address{
+			IP:   ip,
+			Port: port,
+		},
+		extAddresses: make(map[string]Address),
+		mut:          sync.NewRWMutex(),
+	}
+
+	s.mut.Lock()
+	s.mappings = append(s.mappings, mapping)
+	s.mut.Unlock()
+
+	return mapping
+}
+
+// Sync forces the service to recheck all mappings.
+func (s *Service) Sync() {
+	wait := make(chan struct{})
+	s.immediate <- wait
+	<-wait
+}
+
+// updateMapping compares the addresses of the existing mapping versus the natds
+// discovered, and removes any addresses of natds that do not exist, or tries to
+// acquire mappings for natds which the mapping was unaware of before.
+// Optionally takes renew flag which indicates whether or not we should renew
+// mappings with existing natds
+func (s *Service) updateMapping(mapping *Mapping, nats map[string]Device, renew bool) {
+	var added, removed []Address
+
+	renewalTime := time.Duration(s.cfg.Options().NATRenewalM) * time.Minute
+	mapping.expires = time.Now().Add(renewalTime)
+
+	newAdded, newRemoved := s.verifyExistingMappings(mapping, nats, renew)
+	added = append(added, newAdded...)
+	removed = append(removed, newRemoved...)
+
+	newAdded, newRemoved = s.acquireNewMappings(mapping, nats)
+	added = append(added, newAdded...)
+	removed = append(removed, newRemoved...)
+
+	if len(added) > 0 || len(removed) > 0 {
+		mapping.notify(added, removed)
+	}
+}
+
+func (s *Service) verifyExistingMappings(mapping *Mapping, nats map[string]Device, renew bool) ([]Address, []Address) {
+	var added, removed []Address
+
+	leaseTime := time.Duration(s.cfg.Options().NATLeaseM) * time.Minute
+
+	for id, address := range mapping.addressMap() {
+		// Delete addresses for NATDevice's that do not exist anymore
+		nat, ok := nats[id]
+		if !ok {
+			mapping.removeAddress(id)
+			removed = append(removed, address)
+			continue
+		} else if renew {
+			// Only perform renewals on the nat's that have the right local IP
+			// address
+			localIP := nat.GetLocalIPAddress()
+			if !mapping.validGateway(localIP) {
+				l.Debugf("Skipping %s for %s because of IP mismatch. %s != %s", id, mapping, mapping.address.IP, localIP)
+				continue
+			}
+
+			l.Debugf("Renewing %s -> %s mapping on %s", mapping, address, id)
+
+			addr, err := s.tryNATDevice(nat, mapping.address.Port, address.Port, leaseTime)
+			if err != nil {
+				l.Debugf("Failed to renew %s -> mapping on %s", mapping, address, id)
+				mapping.removeAddress(id)
+				removed = append(removed, address)
+				continue
+			}
+
+			l.Debugf("Renewed %s -> %s mapping on %s", mapping, address, id)
+
+			if !addr.Equal(address) {
+				mapping.removeAddress(id)
+				mapping.setAddress(id, addr)
+				removed = append(removed, address)
+				added = append(added, address)
+			}
+		}
+	}
+
+	return added, removed
+}
+
+func (s *Service) acquireNewMappings(mapping *Mapping, nats map[string]Device) ([]Address, []Address) {
+	var added, removed []Address
+
+	leaseTime := time.Duration(s.cfg.Options().NATLeaseM) * time.Minute
+	addrMap := mapping.addressMap()
+
+	for id, nat := range nats {
+		if _, ok := addrMap[id]; ok {
+			continue
+		}
+
+		// Only perform mappings on the nat's that have the right local IP
+		// address
+		localIP := nat.GetLocalIPAddress()
+		if !mapping.validGateway(localIP) {
+			l.Debugf("Skipping %s for %s because of IP mismatch. %s != %s", id, mapping, mapping.address.IP, localIP)
+			continue
+		}
+
+		l.Debugf("Acquiring %s mapping on %s", mapping, id)
+
+		addr, err := s.tryNATDevice(nat, mapping.address.Port, 0, leaseTime)
+		if err != nil {
+			l.Debugf("Failed to acquire %s mapping on %s", mapping, id)
+			continue
+		}
+
+		l.Debugf("Acquired %s -> %s mapping on %s", mapping, addr, id)
+
+		mapping.setAddress(id, addr)
+		added = append(added, addr)
+	}
+
+	return added, removed
+}
+
+// tryNATDevice tries to acquire a port mapping for the given internal address to
+// the given external port. If external port is 0, picks a pseudo-random port.
+func (s *Service) tryNATDevice(natd Device, intPort, extPort int, leaseTime time.Duration) (Address, error) {
+	var err error
+
+	// Generate a predictable random which is based on device ID + local port
+	// number so that the ports we'd try to acquire for the mapping would always
+	// be the same.
+	predictableRand := rand.New(rand.NewSource(int64(s.id.Short()) + int64(intPort)))
+
+	if extPort != 0 {
+		// First try renewing our existing mapping, if we have one.
+		name := fmt.Sprintf("syncthing-%d", extPort)
+		port, err := natd.AddPortMapping(TCP, intPort, extPort, name, leaseTime)
+		if err == nil {
+			extPort = port
+			goto findIP
+		}
+		l.Debugln("Error extending lease on", natd.ID(), err)
+	}
+
+	for i := 0; i < 10; i++ {
+		// Then try up to ten random ports.
+		extPort = 1024 + predictableRand.Intn(65535-1024)
+		name := fmt.Sprintf("syncthing-%d", extPort)
+		port, err := natd.AddPortMapping(TCP, intPort, extPort, name, leaseTime)
+		if err == nil {
+			extPort = port
+			goto findIP
+		}
+		l.Debugln("Error getting new lease on", natd.ID(), err)
+	}
+
+	return Address{}, err
+
+findIP:
+	ip, err := natd.GetExternalIPAddress()
+	if err != nil {
+		l.Debugln("Error getting external ip on", natd.ID(), err)
+		ip = nil
+	}
+	return Address{
+		IP:   ip,
+		Port: extPort,
+	}, nil
+}

+ 129 - 0
lib/nat/structs.go

@@ -0,0 +1,129 @@
+// Copyright (C) 2015 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package nat
+
+import (
+	"fmt"
+	"net"
+	"time"
+
+	"github.com/syncthing/syncthing/lib/sync"
+)
+
+type MappingChangeSubscriber func(*Mapping, []Address, []Address)
+
+type Mapping struct {
+	protocol Protocol
+	address  Address
+
+	extAddresses map[string]Address // NAT ID -> Address
+	expires      time.Time
+	subscribers  []MappingChangeSubscriber
+	mut          sync.RWMutex
+}
+
+func (m *Mapping) setAddress(id string, address Address) {
+	m.mut.Lock()
+	if existing, ok := m.extAddresses[id]; !ok || !existing.Equal(address) {
+		l.Infof("New NAT port mapping: external %s address %s to local address %s.", m.protocol, address, m.address)
+		m.extAddresses[id] = address
+	}
+	m.mut.Unlock()
+}
+
+func (m *Mapping) removeAddress(id string) {
+	m.mut.Lock()
+	addr, ok := m.extAddresses[id]
+	if ok {
+		l.Infof("Removing NAT port mapping: external %s address %s, NAT %s is no longer available.", m.protocol, addr, id)
+		delete(m.extAddresses, id)
+	}
+	m.mut.Unlock()
+}
+
+func (m *Mapping) notify(added, removed []Address) {
+	m.mut.RLock()
+	for _, subscriber := range m.subscribers {
+		subscriber(m, added, removed)
+	}
+	m.mut.RUnlock()
+}
+
+func (m *Mapping) addressMap() map[string]Address {
+	m.mut.RLock()
+	addrMap := m.extAddresses
+	m.mut.RUnlock()
+	return addrMap
+}
+
+func (m *Mapping) Protocol() Protocol {
+	return m.protocol
+}
+
+func (m *Mapping) Address() Address {
+	return m.address
+}
+
+func (m *Mapping) ExternalAddresses() []Address {
+	m.mut.RLock()
+	addrs := make([]Address, 0, len(m.extAddresses))
+	for _, addr := range m.extAddresses {
+		addrs = append(addrs, addr)
+	}
+	m.mut.RUnlock()
+	return addrs
+}
+
+func (m *Mapping) OnChanged(subscribed MappingChangeSubscriber) {
+	m.mut.Lock()
+	m.subscribers = append(m.subscribers, subscribed)
+	m.mut.Unlock()
+}
+
+func (m *Mapping) String() string {
+	return fmt.Sprintf("%s %s", m.protocol, m.address)
+}
+
+func (m *Mapping) GoString() string {
+	return m.String()
+}
+
+// Checks if the mappings local IP address matches the IP address of the gateway
+// For example, if we are explicitly listening on 192.168.0.12, there is no
+// point trying to acquire a mapping on a gateway to which the local IP is
+// 10.0.0.1. Fallback to true if any of the IPs is not there.
+func (m *Mapping) validGateway(ip net.IP) bool {
+	if m.address.IP == nil || ip == nil || m.address.IP.IsUnspecified() || ip.IsUnspecified() {
+		return true
+	}
+	return m.address.IP.Equal(ip)
+}
+
+// Address is essentially net.TCPAddr yet is more general, and has a few helper
+// methods which reduce boilerplate code.
+type Address struct {
+	IP   net.IP
+	Port int
+}
+
+func (a Address) Equal(b Address) bool {
+	return a.Port == b.Port && a.IP.Equal(b.IP)
+}
+
+func (a Address) String() string {
+	var ipStr string
+	if a.IP == nil {
+		ipStr = net.IPv4zero.String()
+	} else {
+		ipStr = a.IP.String()
+	}
+	return net.JoinHostPort(ipStr, fmt.Sprintf("%d", a.Port))
+}
+
+func (a Address) GoString() string {
+	return a.String()
+}

+ 54 - 0
lib/nat/structs_test.go

@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package nat
+
+import (
+	"net"
+	"testing"
+)
+
+func TestMappingValidGateway(t *testing.T) {
+	a := net.ParseIP("10.0.0.1")
+	b := net.ParseIP("192.168.0.1")
+	tests := []struct {
+		mappingLocalIP net.IP
+		gatewayLocalIP net.IP
+		expected       bool
+	}{
+		// Any of the IPs is nil or unspecified implies correct
+		{nil, nil, true},
+		{net.IPv4zero, net.IPv4zero, true},
+		{nil, net.IPv4zero, true},
+		{net.IPv4zero, nil, true},
+		{a, nil, true},
+		{b, nil, true},
+		{a, net.IPv4zero, true},
+		{b, net.IPv4zero, true},
+		{nil, a, true},
+		{nil, b, true},
+		{net.IPv4zero, a, true},
+		{net.IPv4zero, b, true},
+		// IPs are the same implies correct
+		{a, a, true},
+		{b, b, true},
+		// IPs are specified and different, implies incorrect
+		{a, b, false},
+		{b, a, false},
+	}
+
+	for _, test := range tests {
+		m := Mapping{
+			address: Address{
+				IP: test.mappingLocalIP,
+			},
+		}
+		result := m.validGateway(test.gatewayLocalIP)
+		if result != test.expected {
+			t.Errorf("Incorrect: local %s gateway %s result %t expected %t", test.mappingLocalIP, test.gatewayLocalIP, result, test.expected)
+		}
+	}
+}

+ 9 - 6
lib/upnp/igd.go

@@ -13,6 +13,9 @@ import (
 	"net"
 	"net/url"
 	"strings"
+	"time"
+
+	"github.com/syncthing/syncthing/lib/nat"
 )
 
 // An IGD is a UPnP InternetGatewayDevice.
@@ -24,7 +27,7 @@ type IGD struct {
 	localIPAddress net.IP
 }
 
-func (n *IGD) UUID() string {
+func (n *IGD) ID() string {
 	return n.uuid
 }
 
@@ -47,14 +50,14 @@ func (n *IGD) URL() *url.URL {
 // if action is fails for _any_ of the relevant services. For this reason, it
 // is generally better to configure port mapping for each individual service
 // instead.
-func (n *IGD) AddPortMapping(protocol Protocol, externalPort, internalPort int, description string, timeout int) error {
+func (n *IGD) AddPortMapping(protocol nat.Protocol, externalPort, internalPort int, description string, duration time.Duration) (int, error) {
 	for _, service := range n.services {
-		err := service.AddPortMapping(n.localIPAddress, protocol, externalPort, internalPort, description, timeout)
+		err := service.AddPortMapping(n.localIPAddress, protocol, externalPort, internalPort, description, duration)
 		if err != nil {
-			return err
+			return externalPort, err
 		}
 	}
-	return nil
+	return externalPort, nil
 }
 
 // DeletePortMapping deletes a port mapping from all relevant services on the
@@ -62,7 +65,7 @@ func (n *IGD) AddPortMapping(protocol Protocol, externalPort, internalPort int,
 // if action is fails for _any_ of the relevant services. For this reason, it
 // is generally better to configure port mapping for each individual service
 // instead.
-func (n *IGD) DeletePortMapping(protocol Protocol, externalPort int) error {
+func (n *IGD) DeletePortMapping(protocol nat.Protocol, externalPort int) error {
 	for _, service := range n.services {
 		err := service.DeletePortMapping(protocol, externalPort)
 		if err != nil {

+ 7 - 4
lib/upnp/igd_service.go

@@ -13,6 +13,9 @@ import (
 	"encoding/xml"
 	"fmt"
 	"net"
+	"time"
+
+	"github.com/syncthing/syncthing/lib/nat"
 )
 
 // An IGDService is a specific service provided by an IGD.
@@ -23,7 +26,7 @@ type IGDService struct {
 }
 
 // AddPortMapping adds a port mapping to the specified IGD service.
-func (s *IGDService) AddPortMapping(localIPAddress net.IP, protocol Protocol, externalPort, internalPort int, description string, timeout int) error {
+func (s *IGDService) AddPortMapping(localIPAddress net.IP, protocol nat.Protocol, externalPort, internalPort int, description string, duration time.Duration) error {
 	tpl := `<u:AddPortMapping xmlns:u="%s">
 	<NewRemoteHost></NewRemoteHost>
 	<NewExternalPort>%d</NewExternalPort>
@@ -34,10 +37,10 @@ func (s *IGDService) AddPortMapping(localIPAddress net.IP, protocol Protocol, ex
 	<NewPortMappingDescription>%s</NewPortMappingDescription>
 	<NewLeaseDuration>%d</NewLeaseDuration>
 	</u:AddPortMapping>`
-	body := fmt.Sprintf(tpl, s.URN, externalPort, protocol, internalPort, localIPAddress, description, timeout)
+	body := fmt.Sprintf(tpl, s.URN, externalPort, protocol, internalPort, localIPAddress, description, duration/time.Second)
 
 	response, err := soapRequest(s.URL, s.URN, "AddPortMapping", body)
-	if err != nil && timeout > 0 {
+	if err != nil && duration > 0 {
 		// Try to repair error code 725 - OnlyPermanentLeasesSupported
 		envelope := &soapErrorResponse{}
 		if unmarshalErr := xml.Unmarshal(response, envelope); unmarshalErr != nil {
@@ -52,7 +55,7 @@ func (s *IGDService) AddPortMapping(localIPAddress net.IP, protocol Protocol, ex
 }
 
 // DeletePortMapping deletes a port mapping from the specified IGD service.
-func (s *IGDService) DeletePortMapping(protocol Protocol, externalPort int) error {
+func (s *IGDService) DeletePortMapping(protocol nat.Protocol, externalPort int) error {
 	tpl := `<u:DeletePortMapping xmlns:u="%s">
 	<NewRemoteHost></NewRemoteHost>
 	<NewExternalPort>%d</NewExternalPort>

+ 0 - 132
lib/upnp/service.go

@@ -1,132 +0,0 @@
-// Copyright (C) 2015 The Syncthing Authors.
-//
-// This Source Code Form is subject to the terms of the Mozilla Public
-// License, v. 2.0. If a copy of the MPL was not distributed with this file,
-// You can obtain one at http://mozilla.org/MPL/2.0/.
-
-package upnp
-
-import (
-	"fmt"
-	"time"
-
-	"github.com/syncthing/syncthing/lib/config"
-	"github.com/syncthing/syncthing/lib/events"
-	"github.com/syncthing/syncthing/lib/sync"
-	"github.com/syncthing/syncthing/lib/util"
-)
-
-// Service runs a loop for discovery of IGDs (Internet Gateway Devices) and
-// setup/renewal of a port mapping.
-type Service struct {
-	cfg        *config.Wrapper
-	localPort  int
-	extPort    int
-	extPortMut sync.Mutex
-	stop       chan struct{}
-}
-
-func NewUPnPService(cfg *config.Wrapper, localPort int) *Service {
-	return &Service{
-		cfg:        cfg,
-		localPort:  localPort,
-		extPortMut: sync.NewMutex(),
-	}
-}
-
-func (s *Service) Serve() {
-	foundIGD := true
-	s.stop = make(chan struct{})
-
-	for {
-		igds := Discover(time.Duration(s.cfg.Options().UPnPTimeoutS) * time.Second)
-		if len(igds) > 0 {
-			foundIGD = true
-			s.extPortMut.Lock()
-			oldExtPort := s.extPort
-			s.extPortMut.Unlock()
-
-			newExtPort := s.tryIGDs(igds, oldExtPort)
-
-			s.extPortMut.Lock()
-			s.extPort = newExtPort
-			s.extPortMut.Unlock()
-		} else if foundIGD {
-			// Only print a notice if we've previously found an IGD or this is
-			// the first time around.
-			foundIGD = false
-			l.Infof("No UPnP device detected")
-		}
-
-		d := time.Duration(s.cfg.Options().UPnPRenewalM) * time.Minute
-		if d == 0 {
-			// We always want to do renewal so lets just pick a nice sane number.
-			d = 30 * time.Minute
-		}
-
-		select {
-		case <-s.stop:
-			return
-		case <-time.After(d):
-		}
-	}
-}
-
-func (s *Service) Stop() {
-	close(s.stop)
-}
-
-func (s *Service) ExternalPort() int {
-	s.extPortMut.Lock()
-	port := s.extPort
-	s.extPortMut.Unlock()
-	return port
-}
-
-func (s *Service) tryIGDs(igds []IGD, prevExtPort int) int {
-	// Lets try all the IGDs we found and use the first one that works.
-	// TODO: Use all of them, and sort out the resulting mess to the
-	// discovery announcement code...
-	for _, igd := range igds {
-		extPort, err := s.tryIGD(igd, prevExtPort)
-		if err != nil {
-			l.Warnf("Failed to set UPnP port mapping: external port %d on device %s.", extPort, igd.FriendlyIdentifier())
-			continue
-		}
-
-		if extPort != prevExtPort {
-			l.Infof("New UPnP port mapping: external port %d to local port %d.", extPort, s.localPort)
-			events.Default.Log(events.ExternalPortMappingChanged, map[string]int{"port": extPort})
-		}
-		l.Debugf("Created/updated UPnP port mapping for external port %d on device %s.", extPort, igd.FriendlyIdentifier())
-		return extPort
-	}
-
-	return 0
-}
-
-func (s *Service) tryIGD(igd IGD, suggestedPort int) (int, error) {
-	var err error
-	leaseTime := s.cfg.Options().UPnPLeaseM * 60
-
-	if suggestedPort != 0 {
-		// First try renewing our existing mapping.
-		name := fmt.Sprintf("syncthing-%d", suggestedPort)
-		err = igd.AddPortMapping(TCP, suggestedPort, s.localPort, name, leaseTime)
-		if err == nil {
-			return suggestedPort, nil
-		}
-	}
-
-	for i := 0; i < 10; i++ {
-		// Then try up to ten random ports.
-		extPort := 1024 + util.PredictableRandom.Intn(65535-1024)
-		name := fmt.Sprintf("syncthing-%d", extPort)
-		err = igd.AddPortMapping(TCP, extPort, s.localPort, name, leaseTime)
-		if err == nil {
-			return extPort, nil
-		}
-	}
-
-	return 0, err
-}

+ 8 - 10
lib/upnp/upnp.go

@@ -26,15 +26,13 @@ import (
 	"time"
 
 	"github.com/syncthing/syncthing/lib/dialer"
+	"github.com/syncthing/syncthing/lib/nat"
 	"github.com/syncthing/syncthing/lib/sync"
 )
 
-type Protocol string
-
-const (
-	TCP Protocol = "TCP"
-	UDP          = "UDP"
-)
+func init() {
+	nat.Register(Discover)
+}
 
 type upnpService struct {
 	ID         string `xml:"serviceId"`
@@ -55,8 +53,8 @@ type upnpRoot struct {
 
 // Discover discovers UPnP InternetGatewayDevices.
 // The order in which the devices appear in the results list is not deterministic.
-func Discover(timeout time.Duration) []IGD {
-	var results []IGD
+func Discover(renewal, timeout time.Duration) []nat.Device {
+	var results []nat.Device
 
 	interfaces, err := net.Interfaces()
 	if err != nil {
@@ -91,7 +89,7 @@ func Discover(timeout time.Duration) []IGD {
 nextResult:
 	for result := range resultChan {
 		for _, existingResult := range results {
-			if existingResult.uuid == result.uuid {
+			if existingResult.ID() == result.ID() {
 				l.Debugf("Skipping duplicate result %s with services:", result.uuid)
 				for _, service := range result.services {
 					l.Debugf("* [%s] %s", service.ID, service.URL)
@@ -100,7 +98,7 @@ nextResult:
 			}
 		}
 
-		results = append(results, result)
+		results = append(results, &result)
 		l.Debugf("UPnP discovery result %s with services:", result.uuid)
 		for _, service := range result.services {
 			l.Debugf("* [%s] %s", service.ID, service.URL)

+ 0 - 10
lib/util/random.go

@@ -17,16 +17,6 @@ import (
 // randomCharset contains the characters that can make up a randomString().
 const randomCharset = "01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-"
 
-// PredictableRandom is an RNG that will always have the same sequence. It
-// will be seeded with the device ID during startup, so that the sequence is
-// predictable but varies between instances.
-var PredictableRandom = mathRand.New(mathRand.NewSource(42))
-
-func init() {
-	// The default RNG should be seeded with something good.
-	mathRand.Seed(RandomInt64())
-}
-
 // RandomString returns a string of random characters (taken from
 // randomCharset) of the specified length.
 func RandomString(l int) string {

+ 1 - 20
lib/util/random_test.go

@@ -6,26 +6,7 @@
 
 package util
 
-import (
-	"runtime"
-	"sync"
-	"testing"
-)
-
-var predictableRandomTest sync.Once
-
-func TestPredictableRandom(t *testing.T) {
-	if runtime.GOARCH != "amd64" {
-		t.Skip("Test only for 64 bit platforms; but if it works there, it should work on 32 bit")
-	}
-	predictableRandomTest.Do(func() {
-		// predictable random sequence is predictable
-		e := int64(3440579354231278675)
-		if v := int64(PredictableRandom.Int()); v != e {
-			t.Errorf("Unexpected random value %d != %d", v, e)
-		}
-	})
-}
+import "testing"
 
 func TestSeedFromBytes(t *testing.T) {
 	// should always return the same seed for the same bytes