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 @@ -331,6 +331,86 @@ const ( http://127.0.0.1 +` + + noSupportedServicesRootDesc = ` + + + 1 + 0 + + + urn:dslforum-org:device:InternetGatewayDevice:1 + Fake Router + Tailscale, Inc + http://www.tailscale.com + Fake Router + Test Model + v1 + http://www.tailscale.com + 123456789 + uuid:11111111-2222-3333-4444-555555555555 + 000000000001 + + + urn:schemas-microsoft-com:service:OSInfo:1 + urn:microsoft-com:serviceId:OSInfo1 + /osinfo.xml + /upnp/control/aaaaaaaaaa/osinfo + /upnp/event/aaaaaaaaaa/osinfo + + + + + urn:schemas-upnp-org:device:WANDevice:1 + WANDevice + Tailscale, Inc + http://www.tailscale.com + Tailscale Test Router + Test Model + v1 + http://www.tailscale.com + 123456789 + uuid:11111111-2222-3333-4444-555555555555 + 000000000001 + + + urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 + urn:upnp-org:serviceId:WANCommonIFC1 + /ctl/bbbbbbbb + /evt/bbbbbbbb + /WANCfg.xml + + + + + urn:schemas-upnp-org:device:WANConnectionDevice:1 + WANConnectionDevice + Tailscale, Inc + http://www.tailscale.com + Tailscale Test Router + Test Model + v1 + http://www.tailscale.com + 123456789 + uuid:11111111-2222-3333-4444-555555555555 + 000000000001 + + + urn:tailscale:service:SomethingElse:1 + urn:upnp-org:serviceId:TailscaleSomethingElse + /desc/SomethingElse.xml + /ctrlt/SomethingElse_1 + /evt/SomethingElse_1 + + + + + + + http://127.0.0.1 + + ` ) @@ -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, "", "", }, @@ -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 = ` ` + +const testLegacyAddPortMappingResponse = ` + + + + + +` + +const testLegacyGetExternalIPAddressResponse = ` + + + + 123.123.123.123 + + + +` + +const testLegacyGetStatusInfoResponse = ` + + + + Connected + ERROR_NONE + 9999 + + + +`