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:
co-authored by
Simon Frei
bt90
André Colomb
parent
4c5528bd0e
commit
16db6fcf3d
+173
-30
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user