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
+232 -48
View File
@@ -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}
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 := `<?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"`
}