diff --git a/cmd/strelaysrv/main.go b/cmd/strelaysrv/main.go
index be682a565..bc24912f6 100644
--- a/cmd/strelaysrv/main.go
+++ b/cmd/strelaysrv/main.go
@@ -194,7 +194,15 @@ func main() {
cfg.Options.NATTimeoutS = natTimeout
})
natSvc := nat.NewService(id, wrapper)
- mapping := mapping{natSvc.NewMapping(nat.TCP, addr.IP, addr.Port)}
+ var ipVersion nat.IPVersion
+ if strings.HasSuffix(proto, "4") {
+ ipVersion = nat.IPv4Only
+ } else if strings.HasSuffix(proto, "6") {
+ ipVersion = nat.IPv6Only
+ } else {
+ ipVersion = nat.IPvAny
+ }
+ mapping := mapping{natSvc.NewMapping(nat.TCP, ipVersion, addr.IP, addr.Port)}
if natEnabled {
ctx, cancel := context.WithCancel(context.Background())
diff --git a/lib/connections/tcp_listen.go b/lib/connections/tcp_listen.go
index 242bcfeab..2d0c2fc98 100644
--- a/lib/connections/tcp_listen.go
+++ b/lib/connections/tcp_listen.go
@@ -77,7 +77,15 @@ func (t *tcpListener) serve(ctx context.Context) error {
l.Infof("TCP listener (%v) starting", tcaddr)
defer l.Infof("TCP listener (%v) shutting down", tcaddr)
- mapping := t.natService.NewMapping(nat.TCP, tcaddr.IP, tcaddr.Port)
+ var ipVersion nat.IPVersion
+ if t.uri.Scheme == "tcp4" {
+ ipVersion = nat.IPv4Only
+ } else if t.uri.Scheme == "tcp6" {
+ ipVersion = nat.IPv6Only
+ } else {
+ ipVersion = nat.IPvAny
+ }
+ mapping := t.natService.NewMapping(nat.TCP, ipVersion, tcaddr.IP, tcaddr.Port)
mapping.OnChanged(func() {
t.notifyAddressesChanged(t)
})
diff --git a/lib/nat/interface.go b/lib/nat/interface.go
index 399839939..6f154a476 100644
--- a/lib/nat/interface.go
+++ b/lib/nat/interface.go
@@ -19,9 +19,19 @@ const (
UDP Protocol = "UDP"
)
+type IPVersion int8
+
+const (
+ IPvAny = iota
+ IPv4Only
+ IPv6Only
+)
+
type Device interface {
ID() string
- GetLocalIPAddress() net.IP
+ GetLocalIPv4Address() net.IP
AddPortMapping(ctx context.Context, protocol Protocol, internalPort, externalPort int, description string, duration time.Duration) (int, error)
- GetExternalIPAddress(ctx context.Context) (net.IP, error)
+ AddPinhole(ctx context.Context, protocol Protocol, addr Address, duration time.Duration) ([]net.IP, error)
+ GetExternalIPv4Address(ctx context.Context) (net.IP, error)
+ SupportsIPVersion(version IPVersion) bool
}
diff --git a/lib/nat/service.go b/lib/nat/service.go
index 17b0d40ea..cc93e933a 100644
--- a/lib/nat/service.go
+++ b/lib/nat/service.go
@@ -162,15 +162,16 @@ func (s *Service) scheduleProcess() {
}
}
-func (s *Service) NewMapping(protocol Protocol, ip net.IP, port int) *Mapping {
+func (s *Service) NewMapping(protocol Protocol, ipVersion IPVersion, ip net.IP, port int) *Mapping {
mapping := &Mapping{
protocol: protocol,
address: Address{
IP: ip,
Port: port,
},
- extAddresses: make(map[string]Address),
+ extAddresses: make(map[string][]Address),
mut: sync.NewRWMutex(),
+ ipVersion: ipVersion,
}
s.mut.Lock()
@@ -224,7 +225,7 @@ func (s *Service) updateMapping(ctx context.Context, mapping *Mapping, nats map[
func (s *Service) verifyExistingLocked(ctx context.Context, mapping *Mapping, nats map[string]Device, renew bool) (change bool) {
leaseTime := time.Duration(s.cfg.Options().NATLeaseM) * time.Minute
- for id, address := range mapping.extAddresses {
+ for id, extAddrs := range mapping.extAddresses {
select {
case <-ctx.Done():
return false
@@ -239,28 +240,37 @@ func (s *Service) verifyExistingLocked(ctx context.Context, mapping *Mapping, na
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) {
+ // address. For IPv6 the IP addresses are discovered by the service itself,
+ // so this check is skipped.
+ localIP := nat.GetLocalIPv4Address()
+ if !mapping.validGateway(localIP) && nat.SupportsIPVersion(IPv4Only) {
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)
+ if !nat.SupportsIPVersion(mapping.ipVersion) {
+ l.Debugf("Skipping renew on gateway %s because it doesn't match the listener address family", nat.ID())
+ continue
+ }
- addr, err := s.tryNATDevice(ctx, nat, mapping.address.Port, address.Port, leaseTime)
+ l.Debugf("Renewing %s -> %v open port on %s", mapping, extAddrs, id)
+ // extAddrs either contains one IPv4 address, or possibly several
+ // IPv6 addresses all using the same port. Therefore the first
+ // entry always has the external port.
+ responseAddrs, err := s.tryNATDevice(ctx, nat, mapping.address, extAddrs[0].Port, leaseTime)
if err != nil {
- l.Debugf("Failed to renew %s -> mapping on %s", mapping, address, id)
+ l.Debugf("Failed to renew %s -> %v open port on %s", mapping, extAddrs, id)
mapping.removeAddressLocked(id)
change = true
continue
}
- l.Debugf("Renewed %s -> %s mapping on %s", mapping, address, id)
+ l.Debugf("Renewed %s -> %v open port on %s", mapping, extAddrs, id)
- if !addr.Equal(address) {
- mapping.removeAddressLocked(id)
- mapping.setAddressLocked(id, addr)
+ // We shouldn't rely on the order in which the addresses are returned.
+ // Therefore, we test for set equality and report change if there is any difference.
+ if !addrSetsEqual(responseAddrs, extAddrs) {
+ mapping.setAddressLocked(id, responseAddrs)
change = true
}
}
@@ -286,23 +296,27 @@ func (s *Service) acquireNewLocked(ctx context.Context, mapping *Mapping, nats m
// Only perform mappings on the nat's that have the right local IP
// address
- localIP := nat.GetLocalIPAddress()
- if !mapping.validGateway(localIP) {
+ localIP := nat.GetLocalIPv4Address()
+ if !mapping.validGateway(localIP) && nat.SupportsIPVersion(IPv4Only) {
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)
+ l.Debugf("Trying to open port %s on %s", mapping, id)
- addr, err := s.tryNATDevice(ctx, nat, mapping.address.Port, 0, leaseTime)
- if err != nil {
- l.Debugf("Failed to acquire %s mapping on %s", mapping, id)
+ if !nat.SupportsIPVersion(mapping.ipVersion) {
+ l.Debugf("Skipping firewall traversal on gateway %s because it doesn't match the listener address family", nat.ID())
continue
}
- l.Debugf("Acquired %s -> %s mapping on %s", mapping, addr, id)
+ addrs, err := s.tryNATDevice(ctx, nat, mapping.address, 0, leaseTime)
+ if err != nil {
+ l.Debugf("Failed to acquire %s open port on %s", mapping, id)
+ continue
+ }
- mapping.setAddressLocked(id, addr)
+ l.Debugf("Opened port %s -> %v on %s", mapping, addrs, id)
+ mapping.setAddressLocked(id, addrs)
change = true
}
@@ -311,19 +325,36 @@ func (s *Service) acquireNewLocked(ctx context.Context, mapping *Mapping, nats m
// 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(ctx context.Context, natd Device, intPort, extPort int, leaseTime time.Duration) (Address, error) {
+func (s *Service) tryNATDevice(ctx context.Context, natd Device, intAddr Address, extPort int, leaseTime time.Duration) ([]Address, error) {
var err error
var port int
+ // For IPv6, we just try to create the pinhole. If it fails, nothing can be done (probably no IGDv2 support).
+ // If it already exists, the relevant UPnP standard requires that the gateway recognizes this and updates the lease time.
+ // Since we usually have a global unicast IPv6 address so no conflicting mappings, we just request the port we're running on
+ if natd.SupportsIPVersion(IPv6Only) {
+ ipaddrs, err := natd.AddPinhole(ctx, TCP, intAddr, leaseTime)
+ var addrs []Address
+ for _, ipaddr := range ipaddrs {
+ addrs = append(addrs, Address{
+ ipaddr,
+ intAddr.Port,
+ })
+ }
+ if err != nil {
+ l.Debugln("Error extending lease on", natd.ID(), err)
+ }
+ return addrs, err
+ }
// Generate a predictable random which is based on device ID + local port + hash of the device ID
// number so that the ports we'd try to acquire for the mapping would always be the same for the
// same device trying to get the same internal port.
- predictableRand := rand.New(rand.NewSource(int64(s.id.Short()) + int64(intPort) + hash(natd.ID())))
+ predictableRand := rand.New(rand.NewSource(int64(s.id.Short()) + int64(intAddr.Port) + hash(natd.ID())))
if extPort != 0 {
// First try renewing our existing mapping, if we have one.
name := fmt.Sprintf("syncthing-%d", extPort)
- port, err = natd.AddPortMapping(ctx, TCP, intPort, extPort, name, leaseTime)
+ port, err = natd.AddPortMapping(ctx, TCP, intAddr.Port, extPort, name, leaseTime)
if err == nil {
extPort = port
goto findIP
@@ -334,32 +365,34 @@ func (s *Service) tryNATDevice(ctx context.Context, natd Device, intPort, extPor
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
- return Address{}, ctx.Err()
+ return []Address{}, ctx.Err()
default:
}
// Then try up to ten random ports.
extPort = 1024 + predictableRand.Intn(65535-1024)
name := fmt.Sprintf("syncthing-%d", extPort)
- port, err = natd.AddPortMapping(ctx, TCP, intPort, extPort, name, leaseTime)
+ port, err = natd.AddPortMapping(ctx, TCP, intAddr.Port, extPort, name, leaseTime)
if err == nil {
extPort = port
goto findIP
}
- l.Debugln("Error getting new lease on", natd.ID(), err)
+ l.Debugf("Error getting new lease on %s: %s", natd.ID(), err)
}
- return Address{}, err
+ return nil, err
findIP:
- ip, err := natd.GetExternalIPAddress(ctx)
+ ip, err := natd.GetExternalIPv4Address(ctx)
if err != nil {
- l.Debugln("Error getting external ip on", natd.ID(), err)
+ l.Debugf("Error getting external ip on %s: %s", natd.ID(), err)
ip = nil
}
- return Address{
- IP: ip,
- Port: extPort,
+ return []Address{
+ {
+ IP: ip,
+ Port: extPort,
+ },
}, nil
}
@@ -372,3 +405,27 @@ func hash(input string) int64 {
h.Write([]byte(input))
return int64(h.Sum64())
}
+
+func addrSetsEqual(a []Address, b []Address) bool {
+ if len(a) != len(b) {
+ return false
+ }
+
+ // TODO: Rewrite this using slice.Contains once Go 1.21 is the minimum Go version.
+ for _, aElem := range a {
+ aElemFound := false
+ for _, bElem := range b {
+ if bElem.Equal(aElem) {
+ aElemFound = true
+ break
+ }
+ }
+ if !aElemFound {
+ // Found element in a that is not in b.
+ return false
+ }
+ }
+
+ // b contains all elements of a and their lengths are equal, so the sets are equal.
+ return true
+}
diff --git a/lib/nat/structs.go b/lib/nat/structs.go
index 29be5b8d1..f4f8cbaf4 100644
--- a/lib/nat/structs.go
+++ b/lib/nat/structs.go
@@ -17,24 +17,25 @@ import (
type MappingChangeSubscriber func()
type Mapping struct {
- protocol Protocol
- address Address
+ protocol Protocol
+ ipVersion IPVersion
+ address Address
- extAddresses map[string]Address // NAT ID -> Address
+ extAddresses map[string][]Address // NAT ID -> Address
expires time.Time
subscribers []MappingChangeSubscriber
mut sync.RWMutex
}
-func (m *Mapping) setAddressLocked(id string, address Address) {
- l.Infof("New NAT port mapping: external %s address %s to local address %s.", m.protocol, address, m.address)
- m.extAddresses[id] = address
+func (m *Mapping) setAddressLocked(id string, addresses []Address) {
+ l.Infof("New external port opened: external %s address(es) %v to local address %s.", m.protocol, addresses, m.address)
+ m.extAddresses[id] = addresses
}
func (m *Mapping) removeAddressLocked(id string) {
- addr, ok := m.extAddresses[id]
+ addresses, 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)
+ l.Infof("Removing external open port: %s address(es) %v for gateway %s.", m.protocol, addresses, id)
delete(m.extAddresses, id)
}
}
@@ -73,7 +74,7 @@ func (m *Mapping) ExternalAddresses() []Address {
m.mut.RLock()
addrs := make([]Address, 0, len(m.extAddresses))
for _, addr := range m.extAddresses {
- addrs = append(addrs, addr)
+ addrs = append(addrs, addr...)
}
m.mut.RUnlock()
return addrs
@@ -86,7 +87,7 @@ func (m *Mapping) OnChanged(subscribed MappingChangeSubscriber) {
}
func (m *Mapping) String() string {
- return fmt.Sprintf("%s %s", m.protocol, m.address)
+ return fmt.Sprintf("%s/%s", m.address, m.protocol)
}
func (m *Mapping) GoString() string {
diff --git a/lib/nat/structs_test.go b/lib/nat/structs_test.go
index b43e52e08..bedb3dda9 100644
--- a/lib/nat/structs_test.go
+++ b/lib/nat/structs_test.go
@@ -71,10 +71,10 @@ func TestMappingClearAddresses(t *testing.T) {
// Mock a mapped port; avoids the need to actually map a port
ip := net.ParseIP("192.168.0.1")
m := natSvc.NewMapping(TCP, ip, 1024)
- m.extAddresses["test"] = Address{
+ m.extAddresses["test"] = []Address{{
IP: ip,
Port: 1024,
- }
+ }}
// Now try and remove the mapped port; prior to #4829 this deadlocked
natSvc.RemoveMapping(m)
}
diff --git a/lib/pmp/pmp.go b/lib/pmp/pmp.go
index fe711b702..d6b71f669 100644
--- a/lib/pmp/pmp.go
+++ b/lib/pmp/pmp.go
@@ -92,7 +92,7 @@ func (w *wrapper) ID() string {
return fmt.Sprintf("NAT-PMP@%s", w.gatewayIP.String())
}
-func (w *wrapper) GetLocalIPAddress() net.IP {
+func (w *wrapper) GetLocalIPv4Address() net.IP {
return w.localIP
}
@@ -116,7 +116,18 @@ func (w *wrapper) AddPortMapping(ctx context.Context, protocol nat.Protocol, int
return port, err
}
-func (w *wrapper) GetExternalIPAddress(ctx context.Context) (net.IP, error) {
+func (*wrapper) AddPinhole(_ context.Context, _ nat.Protocol, _ nat.Address, _ time.Duration) ([]net.IP, error) {
+ // NAT-PMP doesn't support pinholes.
+ return nil, errors.New("adding IPv6 pinholes is unsupported on NAT-PMP")
+}
+
+func (*wrapper) SupportsIPVersion(version nat.IPVersion) bool {
+ // NAT-PMP gateways should always try to create port mappings and not pinholes
+ // since NAT-PMP doesn't support IPv6.
+ return version == nat.IPvAny || version == nat.IPv4Only
+}
+
+func (w *wrapper) GetExternalIPv4Address(ctx context.Context) (net.IP, error) {
var result *natpmp.GetExternalAddressResult
err := svcutil.CallWithContext(ctx, func() error {
var err error
diff --git a/lib/upnp/igd_service.go b/lib/upnp/igd_service.go
index fa7bb3ccd..f31b65d16 100644
--- a/lib/upnp/igd_service.go
+++ b/lib/upnp/igd_service.go
@@ -35,6 +35,7 @@ package upnp
import (
"context"
"encoding/xml"
+ "errors"
"fmt"
"net"
"time"
@@ -49,33 +50,163 @@ type IGDService struct {
ServiceID string
URL string
URN string
- LocalIP net.IP
+ LocalIPv4 net.IP
+ Interface *net.Interface
+
+ nat.Service
+}
+
+// AddPinhole adds an IPv6 pinhole in accordance to http://upnp.org/specs/gw/UPnP-gw-WANIPv6FirewallControl-v1-Service.pdf
+// This is attempted for each IPv6 on the interface.
+func (s *IGDService) AddPinhole(ctx context.Context, protocol nat.Protocol, intAddr nat.Address, duration time.Duration) ([]net.IP, error) {
+ var returnErr error
+ var successfulIPs []net.IP
+ if s.Interface == nil {
+ return nil, errors.New("no interface")
+ }
+
+ addrs, err := s.Interface.Addrs()
+ if err != nil {
+ return nil, err
+ }
+
+ if !intAddr.IP.IsUnspecified() {
+ // We have an explicit listener address. Check if that's on the interface
+ // and pinhole it if so. It's not an error if not though, so don't return
+ // an error if one doesn't occur.
+ if intAddr.IP.To4() != nil {
+ l.Debugf("Listener is IPv4. Not using gateway %s", s.ID())
+ return nil, nil
+ }
+ for _, addr := range addrs {
+ ip, _, err := net.ParseCIDR(addr.String())
+ if err != nil {
+ return nil, err
+ }
+
+ if ip.Equal(intAddr.IP) {
+ err := s.tryAddPinholeForIP6(ctx, protocol, intAddr.Port, duration, intAddr.IP)
+ if err != nil {
+ return nil, err
+ }
+ return []net.IP{
+ intAddr.IP,
+ }, nil
+ }
+
+ l.Debugf("Listener IP %s not on interface for gateway %s", intAddr.IP, s.ID())
+ }
+ return nil, nil
+ }
+
+ // Otherwise, try to get a pinhole for all IPs, since we are listening on all
+ for _, addr := range addrs {
+ ip, _, err := net.ParseCIDR(addr.String())
+ if err != nil {
+ l.Infof("Couldn't parse address %s: %s", addr, err)
+ continue
+ }
+
+ // Note that IsGlobalUnicast allows ULAs.
+ if ip.To4() != nil || !ip.IsGlobalUnicast() || ip.IsPrivate() {
+ continue
+ }
+
+ if err := s.tryAddPinholeForIP6(ctx, protocol, intAddr.Port, duration, ip); err != nil {
+ l.Infof("Couldn't add pinhole for [%s]:%d/%s. %s", ip, intAddr.Port, protocol, err)
+ returnErr = err
+ } else {
+ successfulIPs = append(successfulIPs, ip)
+ }
+ }
+
+ if len(successfulIPs) > 0 {
+ // (Maybe partial) success, we added a pinhole for at least one GUA.
+ return successfulIPs, nil
+ } else {
+ return nil, returnErr
+ }
+}
+
+func (s *IGDService) tryAddPinholeForIP6(ctx context.Context, protocol nat.Protocol, port int, duration time.Duration, ip net.IP) error {
+ var protoNumber int
+ if protocol == nat.TCP {
+ protoNumber = 6
+ } else if protocol == nat.UDP {
+ protoNumber = 17
+ } else {
+ return errors.New("protocol not supported")
+ }
+
+ const template = `
+
+ 0
+ %d
+ %d
+ %s
+ %d
+ `
+
+ body := fmt.Sprintf(template, s.URN, protoNumber, port, ip, duration/time.Second)
+
+ // IP should be a global unicast address, so we can use it as the source IP.
+ // By the UPnP spec, the source address for unauthenticated clients should be
+ // the same as the InternalAddress the pinhole is requested for.
+ // Currently, WANIPv6FirewallProtocol is restricted to IPv6 gateways, so we can always set the IP.
+ resp, err := soapRequestWithIP(ctx, s.URL, s.URN, "AddPinhole", body, &net.TCPAddr{IP: ip})
+ if err != nil && resp != nil {
+ var errResponse soapErrorResponse
+ if unmarshalErr := xml.Unmarshal(resp, &errResponse); unmarshalErr != nil {
+ // There is an error response that we cannot parse.
+ return unmarshalErr
+ }
+ // There is a parsable UPnP error. Return that.
+ return fmt.Errorf("UPnP error: %s (%d)", errResponse.ErrorDescription, errResponse.ErrorCode)
+ } else if resp != nil {
+ var succResponse soapAddPinholeResponse
+ if unmarshalErr := xml.Unmarshal(resp, &succResponse); unmarshalErr != nil {
+ // Ignore errors since this is only used for debug logging.
+ l.Debugf("Failed to parse response from gateway %s: %s", s.ID(), unmarshalErr)
+ } else {
+ l.Debugf("UPnPv6: UID for pinhole on [%s]:%d/%s is %d on gateway %s", ip, port, protocol, succResponse.UniqueID, s.ID())
+ }
+ }
+ // Either there was no error or an error not handled above (no response, e.g. network error).
+ return err
}
// AddPortMapping adds a port mapping to the specified IGD service.
func (s *IGDService) AddPortMapping(ctx context.Context, protocol nat.Protocol, internalPort, externalPort int, description string, duration time.Duration) (int, error) {
- tpl := `
-
- %d
- %s
- %d
- %s
- 1
- %s
- %d
- `
- body := fmt.Sprintf(tpl, s.URN, externalPort, protocol, internalPort, s.LocalIP, description, duration/time.Second)
+ if s.LocalIPv4 == nil {
+ return 0, errors.New("no local IPv4")
+ }
- response, err := soapRequest(ctx, s.URL, s.URN, "AddPortMapping", body)
+ const template = `
+
+ %d
+ %s
+ %d
+ %s
+ 1
+ %s
+ %d
+ `
+ body := fmt.Sprintf(template, s.URN, externalPort, protocol, internalPort, s.LocalIPv4, description, duration/time.Second)
+
+ response, err := soapRequestWithIP(ctx, s.URL, s.URN, "AddPortMapping", body, &net.TCPAddr{IP: s.LocalIPv4})
if err != nil && duration > 0 {
// Try to repair error code 725 - OnlyPermanentLeasesSupported
- envelope := &soapErrorResponse{}
- if unmarshalErr := xml.Unmarshal(response, envelope); unmarshalErr != nil {
+ var envelope soapErrorResponse
+ if unmarshalErr := xml.Unmarshal(response, &envelope); unmarshalErr != nil {
return externalPort, unmarshalErr
}
+
if envelope.ErrorCode == 725 {
return s.AddPortMapping(ctx, protocol, internalPort, externalPort, description, 0)
}
+
+ err = fmt.Errorf("UPnP Error: %s (%d)", envelope.ErrorDescription, envelope.ErrorCode)
+ l.Infof("Couldn't add port mapping for %s (external port %d -> internal port %d/%s): %s", s.LocalIPv4, externalPort, internalPort, protocol, err)
}
return externalPort, err
@@ -83,34 +214,32 @@ func (s *IGDService) AddPortMapping(ctx context.Context, protocol nat.Protocol,
// DeletePortMapping deletes a port mapping from the specified IGD service.
func (s *IGDService) DeletePortMapping(ctx context.Context, protocol nat.Protocol, externalPort int) error {
- tpl := `
+ const template = `
%d
%s
`
- body := fmt.Sprintf(tpl, s.URN, externalPort, protocol)
+
+ body := fmt.Sprintf(template, s.URN, externalPort, protocol)
_, err := soapRequest(ctx, s.URL, s.URN, "DeletePortMapping", body)
return err
}
-// GetExternalIPAddress queries the IGD service for its external IP address.
+// GetExternalIPv4Address queries the IGD service for its external IP address.
// Returns nil if the external IP address is invalid or undefined, along with
// any relevant errors
-func (s *IGDService) GetExternalIPAddress(ctx context.Context) (net.IP, error) {
- tpl := ``
-
- body := fmt.Sprintf(tpl, s.URN)
+func (s *IGDService) GetExternalIPv4Address(ctx context.Context) (net.IP, error) {
+ const template = ``
+ body := fmt.Sprintf(template, s.URN)
response, err := soapRequest(ctx, s.URL, s.URN, "GetExternalIPAddress", body)
-
if err != nil {
return nil, err
}
- envelope := &soapGetExternalIPAddressResponseEnvelope{}
- err = xml.Unmarshal(response, envelope)
- if err != nil {
+ var envelope soapGetExternalIPAddressResponseEnvelope
+ if err := xml.Unmarshal(response, &envelope); err != nil {
return nil, err
}
@@ -119,12 +248,26 @@ func (s *IGDService) GetExternalIPAddress(ctx context.Context) (net.IP, error) {
return result, nil
}
-// GetLocalIPAddress returns local IP address used to contact this service
-func (s *IGDService) GetLocalIPAddress() net.IP {
- return s.LocalIP
+// GetLocalIPv4Address returns local IP address used to contact this service
+func (s *IGDService) GetLocalIPv4Address() net.IP {
+ return s.LocalIPv4
}
-// ID returns a unique ID for the servic
+// SupportsIPVersion checks whether this is a WANIPv6FirewallControl device,
+// in which case pinholing instead of port mapping should be done
+func (s *IGDService) SupportsIPVersion(version nat.IPVersion) bool {
+ if version == nat.IPvAny {
+ return true
+ } else if version == nat.IPv6Only {
+ return s.URN == urnWANIPv6FirewallControlV1
+ } else if version == nat.IPv4Only {
+ return s.URN != urnWANIPv6FirewallControlV1
+ }
+
+ return true
+}
+
+// ID returns a unique ID for the service
func (s *IGDService) ID() string {
return s.UUID + "/" + s.Device.FriendlyName + "/" + s.ServiceID + "/" + s.URN + "/" + s.URL
}
diff --git a/lib/upnp/upnp.go b/lib/upnp/upnp.go
index 3ceafb5f7..0cafe5fd2 100644
--- a/lib/upnp/upnp.go
+++ b/lib/upnp/upnp.go
@@ -43,10 +43,12 @@ import (
"net"
"net/http"
"net/url"
+ "runtime"
"strings"
"sync"
"time"
+ "github.com/syncthing/syncthing/lib/build"
"github.com/syncthing/syncthing/lib/dialer"
"github.com/syncthing/syncthing/lib/nat"
"github.com/syncthing/syncthing/lib/osutil"
@@ -63,6 +65,7 @@ type upnpService struct {
}
type upnpDevice struct {
+ IsIPv6 bool
DeviceType string `xml:"deviceType"`
FriendlyName string `xml:"friendlyName"`
Devices []upnpDevice `xml:"deviceList>device"`
@@ -82,6 +85,20 @@ func (e *UnsupportedDeviceTypeError) Error() string {
return fmt.Sprintf("Unsupported UPnP device of type %s", e.deviceType)
}
+const (
+ urnIgdV1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+ urnIgdV2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2"
+ urnWANDeviceV1 = "urn:schemas-upnp-org:device:WANDevice:1"
+ urnWANDeviceV2 = "urn:schemas-upnp-org:device:WANDevice:2"
+ urnWANConnectionDeviceV1 = "urn:schemas-upnp-org:device:WANConnectionDevice:1"
+ urnWANConnectionDeviceV2 = "urn:schemas-upnp-org:device:WANConnectionDevice:2"
+ urnWANIPConnectionV1 = "urn:schemas-upnp-org:service:WANIPConnection:1"
+ urnWANIPConnectionV2 = "urn:schemas-upnp-org:service:WANIPConnection:2"
+ urnWANIPv6FirewallControlV1 = "urn:schemas-upnp-org:service:WANIPv6FirewallControl:1"
+ urnWANPPPConnectionV1 = "urn:schemas-upnp-org:service:WANPPPConnection:1"
+ urnWANPPPConnectionV2 = "urn:schemas-upnp-org:service:WANPPPConnection:2"
+)
+
// Discover discovers UPnP InternetGatewayDevices.
// The order in which the devices appear in the results list is not deterministic.
func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
@@ -102,13 +119,28 @@ func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
continue
}
- for _, deviceType := range []string{"urn:schemas-upnp-org:device:InternetGatewayDevice:1", "urn:schemas-upnp-org:device:InternetGatewayDevice:2"} {
- wg.Add(1)
- go func(intf net.Interface, deviceType string) {
- discover(ctx, &intf, deviceType, timeout, resultChan)
- wg.Done()
- }(intf, deviceType)
- }
+ wg.Add(1)
+ // Discovery is done sequentially per interface because we discovered that
+ // FritzBox routers return a broken result sometimes if the IPv4 and IPv6
+ // request arrive at the same time.
+ go func(iface net.Interface) {
+ defer wg.Done()
+ hasGUA, err := interfaceHasGUAIPv6(iface)
+ if err != nil {
+ l.Debugf("Couldn't check for IPv6 GUAs on %s: %s", iface.Name, err)
+ } else if hasGUA {
+ // Discover IPv6 gateways on interface. Only discover IGDv2, since IGDv1
+ // + IPv6 is not standardized and will lead to duplicates on routers.
+ // Only do this when a non-link-local IPv6 is available. if we can't
+ // enumerate the interface, the IPv6 code will not work anyway
+ discover(ctx, &iface, urnIgdV2, timeout, resultChan, true)
+ }
+
+ // Discover IPv4 gateways on interface.
+ for _, deviceType := range []string{urnIgdV2, urnIgdV1} {
+ discover(ctx, &iface, deviceType, timeout, resultChan, false)
+ }
+ }(intf)
}
go func() {
@@ -117,7 +149,6 @@ func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
}()
seenResults := make(map[string]bool)
-
for {
select {
case result, ok := <-resultChan:
@@ -141,33 +172,59 @@ func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
// Search for UPnP InternetGatewayDevices for seconds.
// The order in which the devices appear in the result list is not deterministic
-func discover(ctx context.Context, intf *net.Interface, deviceType string, timeout time.Duration, results chan<- nat.Device) {
- ssdp := &net.UDPAddr{IP: []byte{239, 255, 255, 250}, Port: 1900}
+func discover(ctx context.Context, intf *net.Interface, deviceType string, timeout time.Duration, results chan<- nat.Device, ip6 bool) {
+ var ssdp net.UDPAddr
+ var template string
+ if ip6 {
+ ssdp = net.UDPAddr{IP: []byte{0xFF, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C}, Port: 1900}
- tpl := `M-SEARCH * HTTP/1.1
+ template = `M-SEARCH * HTTP/1.1
+HOST: [FF05::C]:1900
+ST: %s
+MAN: "ssdp:discover"
+MX: %d
+USER-AGENT: syncthing/%s
+
+`
+ } else {
+ ssdp = net.UDPAddr{IP: []byte{239, 255, 255, 250}, Port: 1900}
+
+ template = `M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
ST: %s
MAN: "ssdp:discover"
MX: %d
-USER-AGENT: syncthing/1.0
+USER-AGENT: syncthing/%s
`
- searchStr := fmt.Sprintf(tpl, deviceType, timeout/time.Second)
+ }
+
+ searchStr := fmt.Sprintf(template, deviceType, timeout/time.Second, build.Version)
search := []byte(strings.ReplaceAll(searchStr, "\n", "\r\n") + "\r\n")
l.Debugln("Starting discovery of device type", deviceType, "on", intf.Name)
- socket, err := net.ListenMulticastUDP("udp4", intf, &net.UDPAddr{IP: ssdp.IP})
+ proto := "udp4"
+ if ip6 {
+ proto = "udp6"
+ }
+ socket, err := net.ListenMulticastUDP(proto, intf, &net.UDPAddr{IP: ssdp.IP})
+
if err != nil {
- l.Debugln("UPnP discovery: listening to udp multicast:", err)
+ if runtime.GOOS == "windows" && ip6 {
+ // Requires https://github.com/golang/go/issues/63529 to be fixed.
+ l.Infoln("Support for IPv6 UPnP is currently not available on Windows:", err)
+ } else {
+ l.Debugln("UPnP discovery: listening to udp multicast:", err)
+ }
return
}
defer socket.Close() // Make sure our socket gets closed
l.Debugln("Sending search request for device type", deviceType, "on", intf.Name)
- _, err = socket.WriteTo(search, ssdp)
+ _, err = socket.WriteTo(search, &ssdp)
if err != nil {
if e, ok := err.(net.Error); !ok || !e.Timeout() {
l.Debugln("UPnP discovery: sending search request:", err)
@@ -190,7 +247,7 @@ loop:
break
}
- n, _, err := socket.ReadFrom(resp)
+ n, udpAddr, err := socket.ReadFromUDP(resp)
if err != nil {
select {
case <-ctx.Done():
@@ -204,7 +261,7 @@ loop:
break
}
- igds, err := parseResponse(ctx, deviceType, resp[:n])
+ igds, err := parseResponse(ctx, deviceType, udpAddr, resp[:n], intf)
if err != nil {
switch err.(type) {
case *UnsupportedDeviceTypeError:
@@ -228,7 +285,7 @@ loop:
l.Debugln("Discovery for device type", deviceType, "on", intf.Name, "finished.")
}
-func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDService, error) {
+func parseResponse(ctx context.Context, deviceType string, addr *net.UDPAddr, resp []byte, netInterface *net.Interface) ([]IGDService, error) {
l.Debugln("Handling UPnP response:\n\n" + string(resp))
reader := bufio.NewReader(bytes.NewBuffer(resp))
@@ -249,9 +306,14 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
}
deviceDescriptionURL, err := url.Parse(deviceDescriptionLocation)
-
if err != nil {
l.Infoln("Invalid IGD location: " + err.Error())
+ return nil, err
+ }
+
+ if err != nil {
+ l.Infoln("Invalid source IP for IGD: " + err.Error())
+ return nil, err
}
deviceUSN := response.Header.Get("USN")
@@ -259,6 +321,26 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
return nil, errors.New("invalid IGD response: USN not specified")
}
+ deviceIP := net.ParseIP(deviceDescriptionURL.Hostname())
+ // If the hostname of the device parses as an IPv6 link-local address, we need
+ // to use the source IP address of the response as the hostname
+ // instead of the one given, since only the former contains the zone index,
+ // while the URL returned from the gateway cannot contain the zone index.
+ // (It can't know how interfaces are named/numbered on our machine)
+ if deviceIP != nil && deviceIP.To4() == nil && deviceIP.IsLinkLocalUnicast() {
+ ipAddr := net.IPAddr{
+ IP: addr.IP,
+ Zone: addr.Zone,
+ }
+
+ deviceDescriptionPort := deviceDescriptionURL.Port()
+ deviceDescriptionURL.Host = "[" + ipAddr.String() + "]"
+ if deviceDescriptionPort != "" {
+ deviceDescriptionURL.Host += ":" + deviceDescriptionPort
+ }
+ deviceDescriptionLocation = deviceDescriptionURL.String()
+ }
+
deviceUUID := strings.TrimPrefix(strings.Split(deviceUSN, "::")[0], "uuid:")
response, err = http.Get(deviceDescriptionLocation)
if err != nil {
@@ -276,16 +358,27 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
return nil, err
}
- // Figure out our IP number, on the network used to reach the IGD.
- // We do this in a fairly roundabout way by connecting to the IGD and
- // checking the address of the local end of the socket. I'm open to
- // suggestions on a better way to do this...
- localIPAddress, err := localIP(ctx, deviceDescriptionURL)
+ // Figure out our IPv4 address on the interface used to reach the IGD.
+ localIPv4Address, err := localIPv4(netInterface)
if err != nil {
- return nil, err
+ // On Android, we cannot enumerate IP addresses on interfaces directly.
+ // Therefore, we just try to connect to the IGD and look at which source IP
+ // address was used. This is not ideal, but it's the best we can do. Maybe
+ // we are on an IPv6-only network though, so don't error out in case pinholing is available.
+ localIPv4Address, err = localIPv4Fallback(ctx, deviceDescriptionURL)
+ if err != nil {
+ l.Infoln("Unable to determine local IPv4 address for IGD: " + err.Error())
+ }
}
- services, err := getServiceDescriptions(deviceUUID, localIPAddress, deviceDescriptionLocation, upnpRoot.Device)
+ // This differs from IGDService.SupportsIPVersion(). While that method
+ // determines whether an already completely discovered device uses the IPv6
+ // firewall protocol, this just checks if the gateway's is IPv6. Currently we
+ // only want to discover IPv6 UPnP endpoints on IPv6 gateways and vice versa,
+ // which is why this needs to be stored but technically we could forgo this check
+ // and try WANIPv6FirewallControl via IPv4. This leads to errors though so we don't do it.
+ upnpRoot.Device.IsIPv6 = addr.IP.To4() == nil
+ services, err := getServiceDescriptions(deviceUUID, localIPv4Address, deviceDescriptionLocation, upnpRoot.Device, netInterface)
if err != nil {
return nil, err
}
@@ -293,16 +386,46 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
return services, nil
}
-func localIP(ctx context.Context, url *url.URL) (net.IP, error) {
- timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
- defer cancel()
- conn, err := dialer.DialContext(timeoutCtx, "tcp", url.Host)
+func localIPv4(netInterface *net.Interface) (net.IP, error) {
+ addrs, err := netInterface.Addrs()
if err != nil {
return nil, err
}
+
+ for _, addr := range addrs {
+ ip, _, err := net.ParseCIDR(addr.String())
+ if err != nil {
+ continue
+ }
+
+ if ip.To4() != nil {
+ return ip, nil
+ }
+ }
+
+ return nil, errors.New("no IPv4 address found for interface " + netInterface.Name)
+}
+
+func localIPv4Fallback(ctx context.Context, url *url.URL) (net.IP, error) {
+ timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
+ defer cancel()
+
+ conn, err := dialer.DialContext(timeoutCtx, "udp4", url.Host)
+
+ if err != nil {
+ return nil, err
+ }
+
defer conn.Close()
- return osutil.IPFromAddr(conn.LocalAddr())
+ ip, err := osutil.IPFromAddr(conn.LocalAddr())
+ if err != nil {
+ return nil, err
+ }
+ if ip.To4() == nil {
+ return nil, errors.New("tried to obtain IPv4 through fallback but got IPv6 address")
+ }
+ return ip, nil
}
func getChildDevices(d upnpDevice, deviceType string) []upnpDevice {
@@ -325,21 +448,36 @@ func getChildServices(d upnpDevice, serviceType string) []upnpService {
return result
}
-func getServiceDescriptions(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice) ([]IGDService, error) {
+func getServiceDescriptions(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice, netInterface *net.Interface) ([]IGDService, error) {
var result []IGDService
- if device.DeviceType == "urn:schemas-upnp-org:device:InternetGatewayDevice:1" {
+ if device.IsIPv6 && device.DeviceType == urnIgdV1 {
+ // IPv6 UPnP is only standardized for IGDv2. Furthermore, any WANIPConn services for IPv4 that
+ // we may discover here are likely to be broken because many routers make the choice to not allow
+ // port mappings for IPs differing from the source IP of the device making the request (which would be v6 here)
+ return nil, nil
+ } else if device.IsIPv6 && device.DeviceType == urnIgdV2 {
descriptions := getIGDServices(deviceUUID, localIPAddress, rootURL, device,
- "urn:schemas-upnp-org:device:WANDevice:1",
- "urn:schemas-upnp-org:device:WANConnectionDevice:1",
- []string{"urn:schemas-upnp-org:service:WANIPConnection:1", "urn:schemas-upnp-org:service:WANPPPConnection:1"})
+ urnWANDeviceV2,
+ urnWANConnectionDeviceV2,
+ []string{urnWANIPv6FirewallControlV1},
+ netInterface)
result = append(result, descriptions...)
- } else if device.DeviceType == "urn:schemas-upnp-org:device:InternetGatewayDevice:2" {
+ } else if device.DeviceType == urnIgdV1 {
descriptions := getIGDServices(deviceUUID, localIPAddress, rootURL, device,
- "urn:schemas-upnp-org:device:WANDevice:2",
- "urn:schemas-upnp-org:device:WANConnectionDevice:2",
- []string{"urn:schemas-upnp-org:service:WANIPConnection:2", "urn:schemas-upnp-org:service:WANPPPConnection:2"})
+ urnWANDeviceV1,
+ urnWANConnectionDeviceV1,
+ []string{urnWANIPConnectionV1, urnWANPPPConnectionV1},
+ netInterface)
+
+ result = append(result, descriptions...)
+ } else if device.DeviceType == urnIgdV2 {
+ descriptions := getIGDServices(deviceUUID, localIPAddress, rootURL, device,
+ urnWANDeviceV2,
+ urnWANConnectionDeviceV2,
+ []string{urnWANIPConnectionV2, urnWANPPPConnectionV2},
+ netInterface)
result = append(result, descriptions...)
} else {
@@ -352,7 +490,7 @@ func getServiceDescriptions(deviceUUID string, localIPAddress net.IP, rootURL st
return result, nil
}
-func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice, wanDeviceURN string, wanConnectionURN string, URNs []string) []IGDService {
+func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice, wanDeviceURN string, wanConnectionURN string, URNs []string, netInterface *net.Interface) []IGDService {
var result []IGDService
devices := getChildDevices(device, wanDeviceURN)
@@ -373,7 +511,9 @@ func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, de
for _, URN := range URNs {
services := getChildServices(connection, URN)
- l.Debugln(rootURL, "- no services of type", URN, " found on connection.")
+ if len(services) == 0 {
+ l.Debugln(rootURL, "- no services of type", URN, " found on connection.")
+ }
for _, service := range services {
if service.ControlURL == "" {
@@ -390,7 +530,8 @@ func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, de
ServiceID: service.ID,
URL: u.String(),
URN: service.Type,
- LocalIP: localIPAddress,
+ Interface: netInterface,
+ LocalIPv4: localIPAddress,
}
result = append(result, service)
@@ -428,14 +569,18 @@ func replaceRawPath(u *url.URL, rp string) {
}
func soapRequest(ctx context.Context, url, service, function, message string) ([]byte, error) {
- tpl := `
+ return soapRequestWithIP(ctx, url, service, function, message, nil)
+}
+
+func soapRequestWithIP(ctx context.Context, url, service, function, message string, localIP *net.TCPAddr) ([]byte, error) {
+ const template = `
%s
`
var resp []byte
- body := fmt.Sprintf(tpl, message)
+ body := fmt.Sprintf(template, message)
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(body))
if err != nil {
@@ -453,13 +598,27 @@ func soapRequest(ctx context.Context, url, service, function, message string) ([
l.Debugln("SOAP Action: " + req.Header.Get("SOAPAction"))
l.Debugln("SOAP Request:\n\n" + body)
- r, err := http.DefaultClient.Do(req)
+ dialer := net.Dialer{
+ LocalAddr: localIP,
+ }
+ transport := &http.Transport{
+ DialContext: dialer.DialContext,
+ }
+ httpClient := &http.Client{
+ Transport: transport,
+ }
+ r, err := httpClient.Do(req)
if err != nil {
l.Debugln("SOAP do:", err)
return resp, err
}
- resp, _ = io.ReadAll(r.Body)
+ resp, err = io.ReadAll(r.Body)
+ if err != nil {
+ l.Debugf("Error reading SOAP response: %s, partial response (if present):\n\n%s", resp)
+ return resp, err
+ }
+
l.Debugf("SOAP Response: %s\n\n%s\n\n", r.Status, resp)
r.Body.Close()
@@ -471,6 +630,27 @@ func soapRequest(ctx context.Context, url, service, function, message string) ([
return resp, nil
}
+func interfaceHasGUAIPv6(intf net.Interface) (bool, error) {
+ addrs, err := intf.Addrs()
+ if err != nil {
+ return false, err
+ }
+
+ for _, addr := range addrs {
+ ip, _, err := net.ParseCIDR(addr.String())
+ if err != nil {
+ return false, err
+ }
+
+ // IsGlobalUnicast returns true for ULAs, so check for those separately.
+ if ip.To4() == nil && ip.IsGlobalUnicast() && !ip.IsPrivate() {
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
+
type soapGetExternalIPAddressResponseEnvelope struct {
XMLName xml.Name
Body soapGetExternalIPAddressResponseBody `xml:"Body"`
@@ -489,3 +669,7 @@ type soapErrorResponse struct {
ErrorCode int `xml:"Body>Fault>detail>UPnPError>errorCode"`
ErrorDescription string `xml:"Body>Fault>detail>UPnPError>errorDescription"`
}
+
+type soapAddPinholeResponse struct {
+ UniqueID int `xml:"Body>AddPinholeResponse>UniqueID"`
+}