Repository: syncthing/syncthing (language: go)

Issue:
Web GUI loses config changes when doing multiple modifications (e.g. on slow hardware or remotely)
I assume this problem has affected the Web GUI since basically forever, or at least I myself have always had this issue when operating it. Basically, if you do multiple modifications quickly where each of them is supposed to save changes to the config, some of them will likely be lost, especially when dealing with slow hardware or slow connections (e.g. when operating the GUI remotely).

To reproduce (on slow hardware or a remote connection):

1. Edit multiple folders quickly one after another (e.g. by changing their folder type to something else).
2. Wait for the GUI to become responsive again.
3. It is very likely that only some of the folders will have their type changed.
4. The rest of the modifications have been lost and need to be re-done.

I think a solution to this could be to simply force the GUI not to accept any input before actually saving the previous modifications to the config. One method to achieve this may be to show a "saving changes" pop-up or similar so that the user would know that something is happening while also being prevented from operating the GUI. Of course, this method isn't flawless as it's possible to have the same GUI open in multiple browser tabs and try to make changes from there, etc. but still I believe that it would be much better than the current state where modifications can be lost very easily.

I may try to do something about this problem myself, but of course, if someone more skilled is willing to do work in this direction, it would be very welcome too.

Issues related to this problem: https://github.com/syncthing/syncthing/issues/8589 (and possibly others, I will add them here if I find any)


Discussion / hints:

Fix patch:
diff --git a/cmd/strelaysrv/main.go b/cmd/strelaysrv/main.go
index be682a565b1..bc24912f6bd 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 242bcfeab65..2d0c2fc98ac 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 39983993923..6f154a476fc 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 17b0d40ea8d..cc93e933ac5 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 29be5b8d154..f4f8cbaf411 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/pmp/pmp.go b/lib/pmp/pmp.go
index fe711b702d0..d6b71f66939 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 fa7bb3ccd4f..f31b65d16b7 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 = `<u:AddPinhole xmlns:u="%s">
+	<RemoteHost></RemoteHost>
+	<RemotePort>0</RemotePort>
+	<Protocol>%d</Protocol>
+	<InternalPort>%d</InternalPort>
+	<InternalClient>%s</InternalClient>
+	<LeaseTime>%d</LeaseTime>
+	</u:AddPinhole>`
+
+	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 := `<u:AddPortMapping xmlns:u="%s">
-	<NewRemoteHost></NewRemoteHost>
-	<NewExternalPort>%d</NewExternalPort>
-	<NewProtocol>%s</NewProtocol>
-	<NewInternalPort>%d</NewInternalPort>
-	<NewInternalClient>%s</NewInternalClient>
-	<NewEnabled>1</NewEnabled>
-	<NewPortMappingDescription>%s</NewPortMappingDescription>
-	<NewLeaseDuration>%d</NewLeaseDuration>
-	</u:AddPortMapping>`
-	body := fmt.Sprintf(tpl, s.URN, externalPort, protocol, internalPort, s.LocalIP, description, duration/time.Second)
-
-	response, err := soapRequest(ctx, s.URL, s.URN, "AddPortMapping", body)
+	if s.LocalIPv4 == nil {
+		return 0, errors.New("no local IPv4")
+	}
+
+	const template = `<u:AddPortMapping xmlns:u="%s">
+		<NewRemoteHost></NewRemoteHost>
+		<NewExternalPort>%d</NewExternalPort>
+		<NewProtocol>%s</NewProtocol>
+		<NewInternalPort>%d</NewInternalPort>
+		<NewInternalClient>%s</NewInternalClient>
+		<NewEnabled>1</NewEnabled>
+		<NewPortMappingDescription>%s</NewPortMappingDescription>
+		<NewLeaseDuration>%d</NewLeaseDuration>
+		</u:AddPortMapping>`
+	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 := `<u:DeletePortMapping xmlns:u="%s">
+	const template = `<u:DeletePortMapping xmlns:u="%s">
 	<NewRemoteHost></NewRemoteHost>
 	<NewExternalPort>%d</NewExternalPort>
 	<NewProtocol>%s</NewProtocol>
 	</u:DeletePortMapping>`
-	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 := `<u:GetExternalIPAddress xmlns:u="%s" />`
-
-	body := fmt.Sprintf(tpl, s.URN)
+func (s *IGDService) GetExternalIPv4Address(ctx context.Context) (net.IP, error) {
+	const template = `<u:GetExternalIPAddress xmlns:u="%s" />`
 
+	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
+}
+
+// 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 servic
+// 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 3ceafb5f7b1..0cafe5fd209 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 <timeout> 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}
+
+		template = `M-SEARCH * HTTP/1.1
+HOST: [FF05::C]:1900
+ST: %s
+MAN: "ssdp:discover"
+MX: %d
+USER-AGENT: syncthing/%s
 
-	tpl := `M-SEARCH * HTTP/1.1
+`
+	} 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) {
+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, "tcp", url.Host)
+
+	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,
+			urnWANDeviceV2,
+			urnWANConnectionDeviceV2,
+			[]string{urnWANIPv6FirewallControlV1},
+			netInterface)
+
+		result = append(result, descriptions...)
+	} else if device.DeviceType == urnIgdV1 {
 		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"})
+			urnWANDeviceV1,
+			urnWANConnectionDeviceV1,
+			[]string{urnWANIPConnectionV1, urnWANPPPConnectionV1},
+			netInterface)
 
 		result = append(result, descriptions...)
-	} else if device.DeviceType == "urn:schemas-upnp-org:device:InternetGatewayDevice:2" {
+	} else if device.DeviceType == urnIgdV2 {
 		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"})
+			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 := `<?xml version="1.0" ?>
+	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 = `<?xml version="1.0" ?>
 	<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
 	<s:Body>%s</s:Body>
 	</s:Envelope>
 `
 	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"`
+}


Test patch:
diff --git a/lib/nat/structs_test.go b/lib/nat/structs_test.go
index b43e52e08cf..bedb3dda9cc 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)
 }