diff --git a/cmd/syncthing/main.go b/cmd/syncthing/main.go index f4b3b56cd..cec27f163 100644 --- a/cmd/syncthing/main.go +++ b/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. + // 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) + } - uri, err := url.Parse(opts.ListenAddress[0]) - if err != nil { - l.Fatalf("Failed to parse listen address %s: %v", opts.ListenAddress[0], err) - } + 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) + } - 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) - } - - // 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 { diff --git a/cmd/syncthing/verboseservice.go b/cmd/syncthing/verboseservice.go index c1b5e442f..1852920d6 100644 --- a/cmd/syncthing/verboseservice.go +++ b/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"] diff --git a/gui/default/syncthing/settings/settingsModalView.html b/gui/default/syncthing/settings/settingsModalView.html index 810b2ffe3..3247805e2 100644 --- a/gui/default/syncthing/settings/settingsModalView.html +++ b/gui/default/syncthing/settings/settingsModalView.html @@ -40,7 +40,7 @@
diff --git a/lib/config/config.go b/lib/config/config.go index 097c2182a..664b56517 100644 --- a/lib/config/config.go +++ b/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 } diff --git a/lib/config/config_test.go b/lib/config/config_test.go index 7edd65197..70241e851 100644 --- a/lib/config/config_test.go +++ b/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, diff --git a/lib/config/optionsconfiguration.go b/lib/config/optionsconfiguration.go index 783b43a3e..cfca3161d 100644 --- a/lib/config/optionsconfiguration.go +++ b/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 { diff --git a/lib/config/testdata/overridenvalues.xml b/lib/config/testdata/overridenvalues.xml index ee8a83eb9..bc60ab399 100755 --- a/lib/config/testdata/overridenvalues.xml +++ b/lib/config/testdata/overridenvalues.xml @@ -17,10 +17,10 @@ 20 true false - false - 90 - 15 - 15 + false + 90 + 15 + 15 false 24 48 diff --git a/lib/connections/connections.go b/lib/connections/connections.go index 01dbe82e5..8452f5b6e 100644 --- a/lib/connections/connections.go +++ b/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)) } } diff --git a/lib/nat/debug.go b/lib/nat/debug.go new file mode 100644 index 000000000..d8633b050 --- /dev/null +++ b/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") +} diff --git a/lib/nat/interface.go b/lib/nat/interface.go new file mode 100644 index 000000000..658a67b04 --- /dev/null +++ b/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) +} diff --git a/lib/nat/registry.go b/lib/nat/registry.go new file mode 100644 index 000000000..bab3d3c19 --- /dev/null +++ b/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 +} diff --git a/lib/nat/service.go b/lib/nat/service.go new file mode 100644 index 000000000..89e6844fe --- /dev/null +++ b/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 +} diff --git a/lib/nat/structs.go b/lib/nat/structs.go new file mode 100644 index 000000000..bc9278c26 --- /dev/null +++ b/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() +} diff --git a/lib/nat/structs_test.go b/lib/nat/structs_test.go new file mode 100644 index 000000000..3c2e201a8 --- /dev/null +++ b/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) + } + } +} diff --git a/lib/upnp/igd.go b/lib/upnp/igd.go index 695015213..af8ab87e3 100644 --- a/lib/upnp/igd.go +++ b/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 { diff --git a/lib/upnp/igd_service.go b/lib/upnp/igd_service.go index c9a1133be..a50f1aa5a 100644 --- a/lib/upnp/igd_service.go +++ b/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 := ` %d @@ -34,10 +37,10 @@ func (s *IGDService) AddPortMapping(localIPAddress net.IP, protocol Protocol, ex %s %d ` - 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 := ` %d diff --git a/lib/upnp/service.go b/lib/upnp/service.go deleted file mode 100644 index 7f0af4760..000000000 --- a/lib/upnp/service.go +++ /dev/null @@ -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 -} diff --git a/lib/upnp/upnp.go b/lib/upnp/upnp.go index 7d1528297..cdc36b13e 100644 --- a/lib/upnp/upnp.go +++ b/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) diff --git a/lib/util/random.go b/lib/util/random.go index ec086d2cc..fe77ecf4c 100644 --- a/lib/util/random.go +++ b/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 { diff --git a/lib/util/random_test.go b/lib/util/random_test.go index a63ab5ae9..8d6b5f710 100644 --- a/lib/util/random_test.go +++ b/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