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