summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrew Dunham <andrew@du.nham.ca>2024-01-22 16:48:26 -0500
committerAndrew Dunham <andrew@du.nham.ca>2024-01-23 21:29:29 -0500
commitfd94d96e2bb149cbd5b09e719d0412dfd9386e28 (patch)
tree5ab19f97fe53c82c050463152d45167cfef3a246
parent75f1d3e7d75f67c9b46408b46d10d16d4236dc1a (diff)
downloadtailscale-fd94d96e2bb149cbd5b09e719d0412dfd9386e28.tar.xz
tailscale-fd94d96e2bb149cbd5b09e719d0412dfd9386e28.zip
net/portmapper: support legacy "urn:dslforum-org" portmapping services
These are functionally the same as the "urn:schemas-upnp-org" services with a few minor changes, and are still used by older devices. Support them to improve our ability to obtain an external IP on such networks. Updates #10911 Signed-off-by: Andrew Dunham <andrew@du.nham.ca> Change-Id: I05501fad9d6f0a3b8cf19fc95eee80e7d16cc2cf
-rw-r--r--net/portmapper/legacy_upnp.go303
-rw-r--r--net/portmapper/portmapper.go4
-rw-r--r--net/portmapper/upnp.go13
-rw-r--r--net/portmapper/upnp_test.go170
4 files changed, 488 insertions, 2 deletions
diff --git a/net/portmapper/legacy_upnp.go b/net/portmapper/legacy_upnp.go
new file mode 100644
index 000000000..042ced16c
--- /dev/null
+++ b/net/portmapper/legacy_upnp.go
@@ -0,0 +1,303 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !js
+
+// (no raw sockets in JS/WASM)
+
+package portmapper
+
+import (
+ "context"
+
+ "github.com/tailscale/goupnp"
+ "github.com/tailscale/goupnp/soap"
+)
+
+const (
+ urn_LegacyWANPPPConnection_1 = "urn:dslforum-org:service:WANPPPConnection:1"
+ urn_LegacyWANIPConnection_1 = "urn:dslforum-org:service:WANIPConnection:1"
+)
+
+// legacyWANPPPConnection1 is the same as internetgateway2.WANPPPConnection1,
+// except using the old URN that starts with "urn:dslforum-org".
+//
+// The definition for this can be found in older documentation about UPnP; for
+// the purposes of this implementation, we're referring to "DSL Forum TR-064:
+// LAN-Side DSL CPE Configuration", which, while deprecated, can be found at:
+//
+// https://www.broadband-forum.org/wp-content/uploads/2018/11/TR-064_Corrigendum-1.pdf
+// https://www.broadband-forum.org/pdfs/tr-064-1-0-1.pdf
+type legacyWANPPPConnection1 struct {
+ goupnp.ServiceClient
+}
+
+// AddPortMapping implements upnpClient
+func (client *legacyWANPPPConnection1) AddPortMapping(
+ ctx context.Context,
+ NewRemoteHost string,
+ NewExternalPort uint16,
+ NewProtocol string,
+ NewInternalPort uint16,
+ NewInternalClient string,
+ NewEnabled bool,
+ NewPortMappingDescription string,
+ NewLeaseDuration uint32,
+) (err error) {
+ // Request structure.
+ request := &struct {
+ NewRemoteHost string
+ NewExternalPort string
+ NewProtocol string
+ NewInternalPort string
+ NewInternalClient string
+ NewEnabled string
+ NewPortMappingDescription string
+ NewLeaseDuration string
+ }{}
+
+ if request.NewRemoteHost, err = soap.MarshalString(NewRemoteHost); err != nil {
+ return
+ }
+ if request.NewExternalPort, err = soap.MarshalUi2(NewExternalPort); err != nil {
+ return
+ }
+ if request.NewProtocol, err = soap.MarshalString(NewProtocol); err != nil {
+ return
+ }
+ if request.NewInternalPort, err = soap.MarshalUi2(NewInternalPort); err != nil {
+ return
+ }
+ if request.NewInternalClient, err = soap.MarshalString(NewInternalClient); err != nil {
+ return
+ }
+ if request.NewEnabled, err = soap.MarshalBoolean(NewEnabled); err != nil {
+ return
+ }
+ if request.NewPortMappingDescription, err = soap.MarshalString(NewPortMappingDescription); err != nil {
+ return
+ }
+ if request.NewLeaseDuration, err = soap.MarshalUi4(NewLeaseDuration); err != nil {
+ return
+ }
+
+ // Response structure.
+ response := any(nil)
+
+ // Perform the SOAP call.
+ return client.SOAPClient.PerformAction(ctx, urn_LegacyWANPPPConnection_1, "AddPortMapping", request, response)
+}
+
+// DeletePortMapping implements upnpClient
+func (client *legacyWANPPPConnection1) DeletePortMapping(ctx context.Context, NewRemoteHost string, NewExternalPort uint16, NewProtocol string) (err error) {
+ // Request structure.
+ request := &struct {
+ NewRemoteHost string
+ NewExternalPort string
+ NewProtocol string
+ }{}
+ if request.NewRemoteHost, err = soap.MarshalString(NewRemoteHost); err != nil {
+ return
+ }
+ if request.NewExternalPort, err = soap.MarshalUi2(NewExternalPort); err != nil {
+ return
+ }
+ if request.NewProtocol, err = soap.MarshalString(NewProtocol); err != nil {
+ return
+ }
+
+ // Response structure.
+ response := any(nil)
+
+ // Perform the SOAP call.
+ return client.SOAPClient.PerformAction(ctx, urn_LegacyWANPPPConnection_1, "DeletePortMapping", request, response)
+}
+
+// GetExternalIPAddress implements upnpClient
+func (client *legacyWANPPPConnection1) GetExternalIPAddress(ctx context.Context) (NewExternalIPAddress string, err error) {
+ // Request structure.
+ request := any(nil)
+
+ // Response structure.
+ response := &struct {
+ NewExternalIPAddress string
+ }{}
+
+ // Perform the SOAP call.
+ if err = client.SOAPClient.PerformAction(ctx, urn_LegacyWANPPPConnection_1, "GetExternalIPAddress", request, response); err != nil {
+ return
+ }
+
+ if NewExternalIPAddress, err = soap.UnmarshalString(response.NewExternalIPAddress); err != nil {
+ return
+ }
+ return
+}
+
+// GetStatusInfo implements upnpClient
+func (client *legacyWANPPPConnection1) GetStatusInfo(ctx context.Context) (NewConnectionStatus string, NewLastConnectionError string, NewUptime uint32, err error) {
+ // Request structure.
+ request := any(nil)
+
+ // Response structure.
+ response := &struct {
+ NewConnectionStatus string
+ NewLastConnectionError string
+ NewUpTime string // NOTE: the "T" is capitalized here, per the spec, though it's lowercase in the newer UPnP spec
+ }{}
+
+ // Perform the SOAP call.
+ if err = client.SOAPClient.PerformAction(ctx, urn_LegacyWANPPPConnection_1, "GetStatusInfo", request, response); err != nil {
+ return
+ }
+
+ if NewConnectionStatus, err = soap.UnmarshalString(response.NewConnectionStatus); err != nil {
+ return
+ }
+ if NewLastConnectionError, err = soap.UnmarshalString(response.NewLastConnectionError); err != nil {
+ return
+ }
+ if NewUptime, err = soap.UnmarshalUi4(response.NewUpTime); err != nil {
+ return
+ }
+ return
+}
+
+// legacyWANIPConnection1 is the same as internetgateway2.WANIPConnection1,
+// except using the old URN that starts with "urn:dslforum-org".
+//
+// See legacyWANPPPConnection1 for details on where this is defined.
+type legacyWANIPConnection1 struct {
+ goupnp.ServiceClient
+}
+
+// AddPortMapping implements upnpClient
+func (client *legacyWANIPConnection1) AddPortMapping(
+ ctx context.Context,
+ NewRemoteHost string,
+ NewExternalPort uint16,
+ NewProtocol string,
+ NewInternalPort uint16,
+ NewInternalClient string,
+ NewEnabled bool,
+ NewPortMappingDescription string,
+ NewLeaseDuration uint32,
+) (err error) {
+ // Request structure.
+ request := &struct {
+ NewRemoteHost string
+ NewExternalPort string
+ NewProtocol string
+ NewInternalPort string
+ NewInternalClient string
+ NewEnabled string
+ NewPortMappingDescription string
+ NewLeaseDuration string
+ }{}
+
+ if request.NewRemoteHost, err = soap.MarshalString(NewRemoteHost); err != nil {
+ return
+ }
+ if request.NewExternalPort, err = soap.MarshalUi2(NewExternalPort); err != nil {
+ return
+ }
+ if request.NewProtocol, err = soap.MarshalString(NewProtocol); err != nil {
+ return
+ }
+ if request.NewInternalPort, err = soap.MarshalUi2(NewInternalPort); err != nil {
+ return
+ }
+ if request.NewInternalClient, err = soap.MarshalString(NewInternalClient); err != nil {
+ return
+ }
+ if request.NewEnabled, err = soap.MarshalBoolean(NewEnabled); err != nil {
+ return
+ }
+ if request.NewPortMappingDescription, err = soap.MarshalString(NewPortMappingDescription); err != nil {
+ return
+ }
+ if request.NewLeaseDuration, err = soap.MarshalUi4(NewLeaseDuration); err != nil {
+ return
+ }
+
+ // Response structure.
+ response := any(nil)
+
+ // Perform the SOAP call.
+ return client.SOAPClient.PerformAction(ctx, urn_LegacyWANIPConnection_1, "AddPortMapping", request, response)
+}
+
+// DeletePortMapping implements upnpClient
+func (client *legacyWANIPConnection1) DeletePortMapping(ctx context.Context, NewRemoteHost string, NewExternalPort uint16, NewProtocol string) (err error) {
+ // Request structure.
+ request := &struct {
+ NewRemoteHost string
+ NewExternalPort string
+ NewProtocol string
+ }{}
+ if request.NewRemoteHost, err = soap.MarshalString(NewRemoteHost); err != nil {
+ return
+ }
+ if request.NewExternalPort, err = soap.MarshalUi2(NewExternalPort); err != nil {
+ return
+ }
+ if request.NewProtocol, err = soap.MarshalString(NewProtocol); err != nil {
+ return
+ }
+
+ // Response structure.
+ response := any(nil)
+
+ // Perform the SOAP call.
+ return client.SOAPClient.PerformAction(ctx, urn_LegacyWANIPConnection_1, "DeletePortMapping", request, response)
+}
+
+// GetExternalIPAddress implements upnpClient
+func (client *legacyWANIPConnection1) GetExternalIPAddress(ctx context.Context) (NewExternalIPAddress string, err error) {
+ // Request structure.
+ request := any(nil)
+
+ // Response structure.
+ response := &struct {
+ NewExternalIPAddress string
+ }{}
+
+ // Perform the SOAP call.
+ if err = client.SOAPClient.PerformAction(ctx, urn_LegacyWANIPConnection_1, "GetExternalIPAddress", request, response); err != nil {
+ return
+ }
+
+ if NewExternalIPAddress, err = soap.UnmarshalString(response.NewExternalIPAddress); err != nil {
+ return
+ }
+ return
+}
+
+// GetStatusInfo implements upnpClient
+func (client *legacyWANIPConnection1) GetStatusInfo(ctx context.Context) (NewConnectionStatus string, NewLastConnectionError string, NewUptime uint32, err error) {
+ // Request structure.
+ request := any(nil)
+
+ // Response structure.
+ response := &struct {
+ NewConnectionStatus string
+ NewLastConnectionError string
+ NewUpTime string // NOTE: the "T" is capitalized here, per the spec, though it's lowercase in the newer UPnP spec
+ }{}
+
+ // Perform the SOAP call.
+ if err = client.SOAPClient.PerformAction(ctx, urn_LegacyWANIPConnection_1, "GetStatusInfo", request, response); err != nil {
+ return
+ }
+
+ if NewConnectionStatus, err = soap.UnmarshalString(response.NewConnectionStatus); err != nil {
+ return
+ }
+ if NewLastConnectionError, err = soap.UnmarshalString(response.NewLastConnectionError); err != nil {
+ return
+ }
+ if NewUptime, err = soap.UnmarshalUi4(response.NewUpTime); err != nil {
+ return
+ }
+ return
+}
diff --git a/net/portmapper/portmapper.go b/net/portmapper/portmapper.go
index 54557287d..414922fd5 100644
--- a/net/portmapper/portmapper.go
+++ b/net/portmapper/portmapper.go
@@ -1199,6 +1199,10 @@ var (
// received a UPnP response from a port other than the UPnP port.
metricUPnPResponseAlternatePort = clientmetric.NewCounter("portmap_upnp_response_alternate_port")
+ // metricUPnPSelectLegacy counts the number of times that a legacy
+ // service was found in a UPnP response.
+ metricUPnPSelectLegacy = clientmetric.NewCounter("portmap_upnp_select_legacy")
+
// metricUPnPSelectSingle counts the number of times that only a single
// UPnP device was available in selectBestService.
metricUPnPSelectSingle = clientmetric.NewCounter("portmap_upnp_select_single")
diff --git a/net/portmapper/upnp.go b/net/portmapper/upnp.go
index 0f44463e6..acdb64f3b 100644
--- a/net/portmapper/upnp.go
+++ b/net/portmapper/upnp.go
@@ -289,6 +289,19 @@ func selectBestService(ctx context.Context, logf logger.Logf, root *goupnp.RootD
clients = append(clients, v)
}
+ // These are legacy services that were deprecated in 2015, but are
+ // still in use by older devices; try them just in case.
+ legacyClients, _ := goupnp.NewServiceClientsFromRootDevice(ctx, root, loc, urn_LegacyWANPPPConnection_1)
+ metricUPnPSelectLegacy.Add(int64(len(legacyClients)))
+ for _, client := range legacyClients {
+ clients = append(clients, &legacyWANPPPConnection1{client})
+ }
+ legacyClients, _ = goupnp.NewServiceClientsFromRootDevice(ctx, root, loc, urn_LegacyWANIPConnection_1)
+ metricUPnPSelectLegacy.Add(int64(len(legacyClients)))
+ for _, client := range legacyClients {
+ clients = append(clients, &legacyWANIPConnection1{client})
+ }
+
// If we have no clients, then return right now; if we only have one,
// just select and return it.
if len(clients) == 0 {
diff --git a/net/portmapper/upnp_test.go b/net/portmapper/upnp_test.go
index 4ca332ff1..87e8c9857 100644
--- a/net/portmapper/upnp_test.go
+++ b/net/portmapper/upnp_test.go
@@ -332,6 +332,86 @@ const (
</device>
</root>
`
+
+ noSupportedServicesRootDesc = `<?xml version="1.0"?>
+<root xmlns="urn:schemas-upnp-org:device-1-0">
+ <specVersion>
+ <major>1</major>
+ <minor>0</minor>
+ </specVersion>
+ <device>
+ <deviceType>urn:dslforum-org:device:InternetGatewayDevice:1</deviceType>
+ <friendlyName>Fake Router</friendlyName>
+ <manufacturer>Tailscale, Inc</manufacturer>
+ <manufacturerURL>http://www.tailscale.com</manufacturerURL>
+ <modelDescription>Fake Router</modelDescription>
+ <modelName>Test Model</modelName>
+ <modelNumber>v1</modelNumber>
+ <modelURL>http://www.tailscale.com</modelURL>
+ <serialNumber>123456789</serialNumber>
+ <UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>
+ <UPC>000000000001</UPC>
+ <serviceList>
+ <service>
+ <serviceType>urn:schemas-microsoft-com:service:OSInfo:1</serviceType>
+ <serviceId>urn:microsoft-com:serviceId:OSInfo1</serviceId>
+ <SCPDURL>/osinfo.xml</SCPDURL>
+ <controlURL>/upnp/control/aaaaaaaaaa/osinfo</controlURL>
+ <eventSubURL>/upnp/event/aaaaaaaaaa/osinfo</eventSubURL>
+ </service>
+ </serviceList>
+ <deviceList>
+ <device>
+ <deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
+ <friendlyName>WANDevice</friendlyName>
+ <manufacturer>Tailscale, Inc</manufacturer>
+ <manufacturerURL>http://www.tailscale.com</manufacturerURL>
+ <modelDescription>Tailscale Test Router</modelDescription>
+ <modelName>Test Model</modelName>
+ <modelNumber>v1</modelNumber>
+ <modelURL>http://www.tailscale.com</modelURL>
+ <serialNumber>123456789</serialNumber>
+ <UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>
+ <UPC>000000000001</UPC>
+ <serviceList>
+ <service>
+ <serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>
+ <serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>
+ <controlURL>/ctl/bbbbbbbb</controlURL>
+ <eventSubURL>/evt/bbbbbbbb</eventSubURL>
+ <SCPDURL>/WANCfg.xml</SCPDURL>
+ </service>
+ </serviceList>
+ <deviceList>
+ <device>
+ <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
+ <friendlyName>WANConnectionDevice</friendlyName>
+ <manufacturer>Tailscale, Inc</manufacturer>
+ <manufacturerURL>http://www.tailscale.com</manufacturerURL>
+ <modelDescription>Tailscale Test Router</modelDescription>
+ <modelName>Test Model</modelName>
+ <modelNumber>v1</modelNumber>
+ <modelURL>http://www.tailscale.com</modelURL>
+ <serialNumber>123456789</serialNumber>
+ <UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>
+ <UPC>000000000001</UPC>
+ <serviceList>
+ <service>
+ <serviceType>urn:tailscale:service:SomethingElse:1</serviceType>
+ <serviceId>urn:upnp-org:serviceId:TailscaleSomethingElse</serviceId>
+ <SCPDURL>/desc/SomethingElse.xml</SCPDURL>
+ <controlURL>/ctrlt/SomethingElse_1</controlURL>
+ <eventSubURL>/evt/SomethingElse_1</eventSubURL>
+ </service>
+ </serviceList>
+ </device>
+ </deviceList>
+ </device>
+ </deviceList>
+ <presentationURL>http://127.0.0.1</presentationURL>
+ </device>
+</root>
+`
)
func TestParseUPnPDiscoResponse(t *testing.T) {
@@ -402,7 +482,12 @@ func TestGetUPnPClient(t *testing.T) {
{
"huawei",
huaweiRootDescXML,
- // services not supported and thus returns nil, but shouldn't crash
+ "*portmapper.legacyWANPPPConnection1",
+ "saw UPnP type *portmapper.legacyWANPPPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; HG531 V1 (Huawei Technologies Co., Ltd.), method=single\n",
+ },
+ {
+ "not_supported",
+ noSupportedServicesRootDesc,
"<nil>",
"",
},
@@ -563,7 +648,7 @@ func TestGetUPnPPortMapping_NoValidServices(t *testing.T) {
igd.SetUPnPHandler(&upnpServer{
t: t,
- Desc: huaweiRootDescXML,
+ Desc: noSupportedServicesRootDesc,
})
c := newTestClient(t, igd)
@@ -591,6 +676,57 @@ func TestGetUPnPPortMapping_NoValidServices(t *testing.T) {
}
}
+// Tests the legacy behaviour with the pre-UPnP standard portmapping service.
+func TestGetUPnPPortMapping_Legacy(t *testing.T) {
+ igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer igd.Close()
+
+ // This is a very basic fake UPnP server handler.
+ handlers := map[string]any{
+ "AddPortMapping": testLegacyAddPortMappingResponse,
+ "GetExternalIPAddress": testLegacyGetExternalIPAddressResponse,
+ "GetStatusInfo": testLegacyGetStatusInfoResponse,
+ "DeletePortMapping": "", // Do nothing for test
+ }
+
+ igd.SetUPnPHandler(&upnpServer{
+ t: t,
+ Desc: huaweiRootDescXML,
+ Control: map[string]map[string]any{
+ "/ctrlt/WANPPPConnection_1": handlers,
+ },
+ })
+
+ c := newTestClient(t, igd)
+ defer c.Close()
+ c.debug.VerboseLogs = true
+
+ ctx := context.Background()
+ res, err := c.Probe(ctx)
+ if err != nil {
+ t.Fatalf("Probe: %v", err)
+ }
+ if !res.UPnP {
+ t.Errorf("didn't detect UPnP")
+ }
+
+ gw, myIP, ok := c.gatewayAndSelfIP()
+ if !ok {
+ t.Fatalf("could not get gateway and self IP")
+ }
+
+ ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0)
+ if !ok {
+ t.Fatal("could not get UPnP port mapping")
+ }
+ if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want {
+ t.Errorf("bad external address; got %v want %v", got, want)
+ }
+}
+
func TestGetUPnPPortMappingNoResponses(t *testing.T) {
igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true})
if err != nil {
@@ -892,3 +1028,33 @@ const testGetStatusInfoResponse = `<?xml version="1.0"?>
</s:Body>
</s:Envelope>
`
+
+const testLegacyAddPortMappingResponse = `<?xml version="1.0"?>
+<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
+ <s:Body>
+ <u:AddPortMappingResponse xmlns:u="urn:dslforum-org:service:WANPPPConnection:1"/>
+ </s:Body>
+</s:Envelope>
+`
+
+const testLegacyGetExternalIPAddressResponse = `<?xml version="1.0"?>
+<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
+ <s:Body>
+ <u:GetExternalIPAddressResponse xmlns:u="urn:dslforum-org:service:WANPPPConnection:1">
+ <NewExternalIPAddress>123.123.123.123</NewExternalIPAddress>
+ </u:GetExternalIPAddressResponse>
+ </s:Body>
+</s:Envelope>
+`
+
+const testLegacyGetStatusInfoResponse = `<?xml version="1.0"?>
+<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
+ <s:Body>
+ <u:GetStatusInfoResponse xmlns:u="urn:dslforum-org:service:WANPPPConnection:1">
+ <NewConnectionStatus>Connected</NewConnectionStatus>
+ <NewLastConnectionError>ERROR_NONE</NewLastConnectionError>
+ <NewUpTime>9999</NewUpTime>
+ </u:GetStatusInfoResponse>
+ </s:Body>
+</s:Envelope>
+`