lib/nat, lib/upnp: IPv6 UPnP support (#9010)

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 <freisim93@gmail.com>
Co-authored-by: bt90 <btom1990@googlemail.com>
Co-authored-by: André Colomb <github.com@andre.colomb.de>
This commit is contained in:
Maximilian
2023-12-11 07:36:18 +01:00
committed by GitHub
co-authored by Simon Frei bt90 André Colomb
parent 4c5528bd0e
commit 16db6fcf3d
9 changed files with 551 additions and 129 deletions
+173 -30
View File
@@ -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)
if s.LocalIPv4 == nil {
return 0, errors.New("no local IPv4")
}
response, err := soapRequest(ctx, s.URL, s.URN, "AddPortMapping", body)
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
}
// 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
}