diff options
| author | Andrew Dunham <andrew@du.nham.ca> | 2024-01-22 16:48:26 -0500 |
|---|---|---|
| committer | Andrew Dunham <andrew@du.nham.ca> | 2024-01-23 21:29:29 -0500 |
| commit | fd94d96e2bb149cbd5b09e719d0412dfd9386e28 (patch) | |
| tree | 5ab19f97fe53c82c050463152d45167cfef3a246 | |
| parent | 75f1d3e7d75f67c9b46408b46d10d16d4236dc1a (diff) | |
| download | tailscale-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.go | 303 | ||||
| -rw-r--r-- | net/portmapper/portmapper.go | 4 | ||||
| -rw-r--r-- | net/portmapper/upnp.go | 13 | ||||
| -rw-r--r-- | net/portmapper/upnp_test.go | 170 |
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> +` |
