From 16db6fcf3d982eafd256e7a836670a6b70f06f2c Mon Sep 17 00:00:00 2001 From: Maximilian Date: Mon, 11 Dec 2023 07:36:18 +0100 Subject: [PATCH] lib/nat, lib/upnp: IPv6 UPnP support (#9010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request allows syncthing to request an IPv6 [pinhole](https://en.wikipedia.org/wiki/Firewall_pinhole), addressing issue #7406. This helps users who prefer to use IPv6 for hosting their services or are forced to do so because of [CGNAT](https://en.wikipedia.org/wiki/Carrier-grade_NAT). Otherwise, such users would have to configure their firewall manually to allow syncthing traffic to pass through while IPv4 users can use UPnP to take care of network configuration already. ### Testing I have tested this in a virtual machine setup with miniupnpd running on the virtualized router. It successfully added an IPv6 pinhole when used with IPv6 only, an IPv4 port mapping when used with IPv4 only and both when dual-stack (IPv4 and IPv6) is used. Automated tests could be added for SOAP responses from the router but automatically testing this with a real network is likely infeasible. ### Documentation https://docs.syncthing.net/users/firewall.html could be updated to mention the fact that UPnP now works with IPv6, although this change is more "behind the scenes". --------- Co-authored-by: Simon Frei Co-authored-by: bt90 Co-authored-by: André Colomb --- cmd/strelaysrv/main.go | 10 +- lib/connections/tcp_listen.go | 10 +- lib/nat/interface.go | 14 +- lib/nat/service.go | 123 +++++++++++---- lib/nat/structs.go | 21 +-- lib/nat/structs_test.go | 4 +- lib/pmp/pmp.go | 15 +- lib/upnp/igd_service.go | 203 ++++++++++++++++++++---- lib/upnp/upnp.go | 280 ++++++++++++++++++++++++++++------ 9 files changed, 551 insertions(+), 129 deletions(-) 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"` +}