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"` +}