summaryrefslogtreecommitdiffhomepage
path: root/net/portmapper
diff options
context:
space:
mode:
Diffstat (limited to 'net/portmapper')
-rw-r--r--net/portmapper/disabled_stubs.go4
-rw-r--r--net/portmapper/portmapper.go59
-rw-r--r--net/portmapper/upnp.go135
-rw-r--r--net/portmapper/upnp_test.go95
4 files changed, 232 insertions, 61 deletions
diff --git a/net/portmapper/disabled_stubs.go b/net/portmapper/disabled_stubs.go
index fb1572e78..f7003b0ed 100644
--- a/net/portmapper/disabled_stubs.go
+++ b/net/portmapper/disabled_stubs.go
@@ -15,10 +15,6 @@ import (
type upnpClient interface{}
-func getUPnPClient(ctx context.Context, gw netaddr.IP) (upnpClient, error) {
- return nil, nil
-}
-
func (c *Client) getUPnPPortMapping(
ctx context.Context,
gw netaddr.IP,
diff --git a/net/portmapper/portmapper.go b/net/portmapper/portmapper.go
index e9ddf27c9..595ad2e08 100644
--- a/net/portmapper/portmapper.go
+++ b/net/portmapper/portmapper.go
@@ -14,9 +14,11 @@ import (
"fmt"
"io"
"net"
+ "net/http"
"sync"
"time"
+ "go4.org/mem"
"inet.af/netaddr"
"tailscale.com/net/interfaces"
"tailscale.com/net/netns"
@@ -62,8 +64,11 @@ type Client struct {
pmpPubIPTime time.Time // time pmpPubIP last verified
pmpLastEpoch uint32
- pcpSawTime time.Time // time we last saw PCP was available
- uPnPSawTime time.Time // time we last saw UPnP was available
+ pcpSawTime time.Time // time we last saw PCP was available
+
+ uPnPSawTime time.Time // time we last saw UPnP was available
+ uPnPMeta uPnPDiscoResponse // Location header from UPnP UDP discovery response
+ uPnPHTTPClient *http.Client // nil until needed
localPort uint16
@@ -560,27 +565,9 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
defer cancel()
defer closeCloserOnContextDone(ctx, uc)()
- if c.sawUPnPRecently() {
- res.UPnP = true
- } else {
- hasUPnP := make(chan bool, 1)
- defer func() {
- res.UPnP = <-hasUPnP
- }()
- go func() {
- client, err := getUPnPClient(ctx, gw)
- if err == nil && client != nil {
- hasUPnP <- true
- c.mu.Lock()
- c.uPnPSawTime = time.Now()
- c.mu.Unlock()
- }
- close(hasUPnP)
- }()
- }
-
pcpAddr := netaddr.IPPortFrom(gw, pcpPort).UDPAddr()
pmpAddr := netaddr.IPPortFrom(gw, pmpPort).UDPAddr()
+ upnpAddr := netaddr.IPPortFrom(gw, upnpPort).UDPAddr()
// Don't send probes to services that we recently learned (for
// the same gw/myIP) are available. See
@@ -595,11 +582,16 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
} else {
uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr)
}
+ if c.sawUPnPRecently() {
+ res.UPnP = true
+ } else {
+ uc.WriteTo(uPnPPacket, upnpAddr)
+ }
buf := make([]byte, 1500)
pcpHeard := false // true when we get any PCP response
for {
- if pcpHeard && res.PMP {
+ if pcpHeard && res.PMP && res.UPnP {
// Nothing more to discover.
return res, nil
}
@@ -612,6 +604,19 @@ func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
}
port := addr.(*net.UDPAddr).Port
switch port {
+ case upnpPort:
+ if mem.Contains(mem.B(buf[:n]), mem.S(":InternetGatewayDevice:")) {
+ meta, err := parseUPnPDiscoResponse(buf[:n])
+ if err != nil {
+ c.logf("unrecognized UPnP discovery response; ignoring")
+ }
+ // log.Printf("UPnP reply %+v, %q", meta, buf[:n])
+ res.UPnP = true
+ c.mu.Lock()
+ c.uPnPSawTime = time.Now()
+ c.uPnPMeta = meta
+ c.mu.Unlock()
+ }
case pcpPort: // same as pmpPort
if pres, ok := parsePCPResponse(buf[:n]); ok {
if pres.OpCode == pcpOpReply|pcpOpAnnounce {
@@ -724,3 +729,13 @@ func parsePCPResponse(b []byte) (res pcpResponse, ok bool) {
}
var pmpReqExternalAddrPacket = []byte{0, 0} // version 0, opcode 0 = "Public address request"
+
+const (
+ upnpPort = 1900
+)
+
+var uPnPPacket = []byte("M-SEARCH * HTTP/1.1\r\n" +
+ "HOST: 239.255.255.250:1900\r\n" +
+ "ST: ssdp:all\r\n" +
+ "MAN: \"ssdp:discover\"\r\n" +
+ "MX: 2\r\n\r\n")
diff --git a/net/portmapper/upnp.go b/net/portmapper/upnp.go
index 5ec91353e..9d3deb414 100644
--- a/net/portmapper/upnp.go
+++ b/net/portmapper/upnp.go
@@ -8,17 +8,27 @@
package portmapper
import (
+ "bufio"
+ "bytes"
"context"
"fmt"
+ "log"
"math/rand"
+ "net/http"
"net/url"
"time"
+ "github.com/tailscale/goupnp"
"github.com/tailscale/goupnp/dcps/internetgateway2"
"inet.af/netaddr"
"tailscale.com/control/controlknobs"
+ "tailscale.com/net/netns"
)
+// VerboseLogs controls verbose debug logging.
+// It exists for use by "tailscaled debug --portmap".
+var VerboseLogs bool
+
// References:
//
// WANIP Connection v2: http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v2-Service.pdf
@@ -44,7 +54,8 @@ func (u *upnpMapping) Release(ctx context.Context) {
}
// upnpClient is an interface over the multiple different clients exported by goupnp,
-// exposing the functions we need for portmapping. They are auto-generated from XML-specs.
+// exposing the functions we need for portmapping. Those clients are auto-generated from XML-specs,
+// which is why they're not very idiomatic.
type upnpClient interface {
AddPortMapping(
ctx context.Context,
@@ -77,7 +88,7 @@ type upnpClient interface {
// greater than 0. From the spec, it appears if it is set to 0, it will switch to using
// 604800 seconds, but not sure why this is desired. The recommended time is 3600 seconds.
leaseDurationSec uint32,
- ) (err error)
+ ) error
DeletePortMapping(ctx context.Context, remoteHost string, externalPort uint16, protocol string) error
GetExternalIPAddress(ctx context.Context) (externalIPAddress string, err error)
@@ -92,6 +103,8 @@ const tsPortMappingDesc = "tailscale-portmap"
// behavior of calling AddPortMapping with port = 0 to specify a wildcard port.
// It returns the new external port (which may not be identical to the external port specified),
// or an error.
+//
+// TODO(bradfitz): also returned the actual lease duration obtained. and check it regularly.
func addAnyPortMapping(
ctx context.Context,
upnp upnpClient,
@@ -130,51 +143,76 @@ func addAnyPortMapping(
return externalPort, err
}
-// getUPnPClients gets a client for interfacing with UPnP, ignoring the underlying protocol for
+// getUPnPClient gets a client for interfacing with UPnP, ignoring the underlying protocol for
// now.
// Adapted from https://github.com/huin/goupnp/blob/master/GUIDE.md.
-func getUPnPClient(ctx context.Context, gw netaddr.IP) (upnpClient, error) {
+//
+// The gw is the detected gateway.
+//
+// The meta is the most recently parsed UDP discovery packet response
+// from the Internet Gateway Device.
+//
+// The provided ctx is not retained in the returned upnpClient, but
+// its associated HTTP client is (if set via goupnp.WithHTTPClient).
+func getUPnPClient(ctx context.Context, gw netaddr.IP, meta uPnPDiscoResponse) (upnpClient, error) {
if controlknobs.DisableUPnP() {
return nil, nil
}
- ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
+
+ if meta.Location == "" {
+ return nil, nil
+ }
+
+ if VerboseLogs {
+ log.Printf("fetching %v", meta.Location)
+ }
+ u, err := url.Parse(meta.Location)
+ if err != nil {
+ return nil, err
+ }
+
+ ipp, err := netaddr.ParseIPPort(u.Host)
+ if err != nil {
+ return nil, fmt.Errorf("unexpected host %q in %q", u.Host, meta.Location)
+ }
+ if ipp.IP() != gw {
+ return nil, fmt.Errorf("UPnP discovered root %q does not match gateway IP %v; ignoring UPnP",
+ meta.Location, gw)
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
- // Attempt to connect over the multiple available connection types concurrently,
- // returning the fastest.
- // TODO(jknodt): this url seems super brittle? maybe discovery is better but this is faster
- u, err := url.Parse(fmt.Sprintf("http://%s:5000/rootDesc.xml", gw))
+ // This part does a network fetch.
+ root, err := goupnp.DeviceByURL(ctx, u)
if err != nil {
return nil, err
}
- clients := make(chan upnpClient, 3)
- go func() {
- var err error
- ip1Clients, err := internetgateway2.NewWANIPConnection1ClientsByURL(ctx, u)
- if err == nil && len(ip1Clients) > 0 {
- clients <- ip1Clients[0]
- }
- }()
- go func() {
- ip2Clients, err := internetgateway2.NewWANIPConnection2ClientsByURL(ctx, u)
- if err == nil && len(ip2Clients) > 0 {
- clients <- ip2Clients[0]
- }
- }()
- go func() {
- ppp1Clients, err := internetgateway2.NewWANPPPConnection1ClientsByURL(ctx, u)
- if err == nil && len(ppp1Clients) > 0 {
- clients <- ppp1Clients[0]
- }
- }()
+ // These parts don't do a network fetch.
+ // Pick the best service type available.
+ if cc, _ := internetgateway2.NewWANIPConnection2ClientsFromRootDevice(ctx, root, u); len(cc) > 0 {
+ return cc[0], nil
+ }
+ if cc, _ := internetgateway2.NewWANIPConnection1ClientsFromRootDevice(ctx, root, u); len(cc) > 0 {
+ return cc[0], nil
+ }
+ if cc, _ := internetgateway2.NewWANPPPConnection1ClientsFromRootDevice(ctx, root, u); len(cc) > 0 {
+ return cc[0], nil
+ }
+ return nil, nil
+}
- select {
- case client := <-clients:
- return client, nil
- case <-ctx.Done():
- return nil, ctx.Err()
+func (c *Client) upnpHTTPClientLocked() *http.Client {
+ if c.uPnPHTTPClient == nil {
+ c.uPnPHTTPClient = &http.Client{
+ Transport: &http.Transport{
+ DialContext: netns.NewDialer().DialContext,
+ IdleConnTimeout: 2 * time.Second, // LAN is cheap
+ },
+ }
}
+ return c.uPnPHTTPClient
}
// getUPnPPortMapping attempts to create a port-mapping over the UPnP protocol. On success,
@@ -199,11 +237,17 @@ func (c *Client) getUPnPPortMapping(
var err error
c.mu.Lock()
oldMapping, ok := c.mapping.(*upnpMapping)
+ meta := c.uPnPMeta
+ httpClient := c.upnpHTTPClientLocked()
c.mu.Unlock()
if ok && oldMapping != nil {
client = oldMapping.client
} else {
- client, err = getUPnPClient(ctx, gw)
+ ctx := goupnp.WithHTTPClient(ctx, httpClient)
+ client, err = getUPnPClient(ctx, gw, meta)
+ if VerboseLogs {
+ log.Printf("getUPnPClient: %T, %v", client, err)
+ }
if err != nil {
return netaddr.IPPort{}, false
}
@@ -221,11 +265,17 @@ func (c *Client) getUPnPPortMapping(
internal.IP().String(),
time.Second*pmpMapLifetimeSec,
)
+ if VerboseLogs {
+ log.Printf("addAnyPortMapping: %v, %v", newPort, err)
+ }
if err != nil {
return netaddr.IPPort{}, false
}
// TODO cache this ip somewhere?
extIP, err := client.GetExternalIPAddress(ctx)
+ if VerboseLogs {
+ log.Printf("client.GetExternalIPAddress: %v, %v", extIP, err)
+ }
if err != nil {
// TODO this doesn't seem right
return netaddr.IPPort{}, false
@@ -246,3 +296,18 @@ func (c *Client) getUPnPPortMapping(
c.localPort = newPort
return upnp.external, true
}
+
+type uPnPDiscoResponse struct {
+ Location string
+}
+
+// parseUPnPDiscoResponse parses a UPnP HTTP-over-UDP discovery response.
+func parseUPnPDiscoResponse(body []byte) (uPnPDiscoResponse, error) {
+ var r uPnPDiscoResponse
+ res, err := http.ReadResponse(bufio.NewReaderSize(bytes.NewReader(body), 128), nil)
+ if err != nil {
+ return r, err
+ }
+ r.Location = res.Header.Get("Location")
+ return r, nil
+}
diff --git a/net/portmapper/upnp_test.go b/net/portmapper/upnp_test.go
new file mode 100644
index 000000000..972ec50d9
--- /dev/null
+++ b/net/portmapper/upnp_test.go
@@ -0,0 +1,95 @@
+// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package portmapper
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "inet.af/netaddr"
+)
+
+// Google Wifi
+const (
+ googleWifiUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nUSN: uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece::urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nEXT:\r\nSERVER: Linux/5.4.0-1034-gcp UPnP/1.1 MiniUPnPd/1.9\r\nLOCATION: http://192.168.86.1:5000/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1\r\nBOOTID.UPNP.ORG: 1\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n"
+
+ googleWifiRootDescXML = `<?xml version="1.0"?>
+<root xmlns="urn:schemas-upnp-org:device-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><device><deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:2</deviceType><friendlyName>OnHub</friendlyName><manufacturer>Google</manufacturer><manufacturerURL>http://google.com/</manufacturerURL><modelDescription>Wireless Router</modelDescription><modelName>OnHub</modelName><modelNumber>1</modelNumber><modelURL>https://on.google.com/hub/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece</UDN><serviceList><service><serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType><serviceId>urn:upnp-org:serviceId:Layer3Forwarding1</serviceId><controlURL>/ctl/L3F</controlURL><eventSubURL>/evt/L3F</eventSubURL><SCPDURL>/L3F.xml</SCPDURL></service><service><serviceType>urn:schemas-upnp-org:service:DeviceProtection:1</serviceType><serviceId>urn:upnp-org:serviceId:DeviceProtection1</serviceId><controlURL>/ctl/DP</controlURL><eventSubURL>/evt/DP</eventSubURL><SCPDURL>/DP.xml</SCPDURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANDevice:2</deviceType><friendlyName>WANDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>WAN Device</modelDescription><modelName>WAN Device</modelName><modelNumber>20210414</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ecf</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType><serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId><controlURL>/ctl/CmnIfCfg</controlURL><eventSubURL>/evt/CmnIfCfg</eventSubURL><SCPDURL>/WANCfg.xml</SCPDURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:2</deviceType><friendlyName>WANConnectionDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>MiniUPnP daemon</modelDescription><modelName>MiniUPnPd</modelName><modelNumber>20210414</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>00000000</serialNumber><UDN>uuid:a9708184-a6c0-413a-bbac-11bcf7e30ec0</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:2</serviceType><serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId><controlURL>/ctl/IPConn</controlURL><eventSubURL>/evt/IPConn</eventSubURL><SCPDURL>/WANIPCn.xml</SCPDURL></service></serviceList></device></deviceList></device></deviceList><presentationURL>http://testwifi.here/</presentationURL></device></root>`
+)
+
+// pfSense 2.5.0-RELEASE / FreeBSD 12.2-STABLE
+const (
+ pfSenseUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nEXT:\r\nSERVER: FreeBSD/12.2-STABLE UPnP/1.1 MiniUPnPd/2.2.1\r\nLOCATION: http://192.168.1.1:2189/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1627958564\r\nBOOTID.UPNP.ORG: 1627958564\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n"
+
+ pfSenseRootDescXML = `<?xml version="1.0"?>
+<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337"><specVersion><major>1</major><minor>1</minor></specVersion><device><deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType><friendlyName>FreeBSD router</friendlyName><manufacturer>FreeBSD</manufacturer><manufacturerURL>http://www.freebsd.org/</manufacturerURL><modelDescription>FreeBSD router</modelDescription><modelName>FreeBSD router</modelName><modelNumber>2.5.0-RELEASE</modelNumber><modelURL>http://www.freebsd.org/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac11</UDN><serviceList><service><serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType><serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId><SCPDURL>/L3F.xml</SCPDURL><controlURL>/ctl/L3F</controlURL><eventSubURL>/evt/L3F</eventSubURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType><friendlyName>WANDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>WAN Device</modelDescription><modelName>WAN Device</modelName><modelNumber>20210205</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac12</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType><serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId><SCPDURL>/WANCfg.xml</SCPDURL><controlURL>/ctl/CmnIfCfg</controlURL><eventSubURL>/evt/CmnIfCfg</eventSubURL></service></serviceList><deviceList><device><deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType><friendlyName>WANConnectionDevice</friendlyName><manufacturer>MiniUPnP</manufacturer><manufacturerURL>http://miniupnp.free.fr/</manufacturerURL><modelDescription>MiniUPnP daemon</modelDescription><modelName>MiniUPnPd</modelName><modelNumber>20210205</modelNumber><modelURL>http://miniupnp.free.fr/</modelURL><serialNumber>BEE7052B</serialNumber><UDN>uuid:bee7052b-49e8-3597-b545-55a1e38ac13</UDN><UPC>000000000000</UPC><serviceList><service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType><serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId><SCPDURL>/WANIPCn.xml</SCPDURL><controlURL>/ctl/IPConn</controlURL><eventSubURL>/evt/IPConn</eventSubURL></service></serviceList></device></deviceList></device></deviceList><presentationURL>https://192.168.1.1/</presentationURL></device></root>`
+)
+
+func TestParseUPnPDiscoResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ headers string
+ want uPnPDiscoResponse
+ }{
+ {"google", googleWifiUPnPDisco, uPnPDiscoResponse{
+ Location: "http://192.168.86.1:5000/rootDesc.xml",
+ }},
+ {"pfsense", pfSenseUPnPDisco, uPnPDiscoResponse{
+ Location: "http://192.168.1.1:2189/rootDesc.xml",
+ }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := parseUPnPDiscoResponse([]byte(tt.headers))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("unexpected result:\n got: %+v\nwant: %+v\n", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetUPnPClient(t *testing.T) {
+ tests := []struct {
+ name string
+ xmlBody string
+ want string
+ }{
+ {"google", googleWifiRootDescXML, "*internetgateway2.WANIPConnection2"},
+ {"pfsense", pfSenseRootDescXML, "*internetgateway2.WANIPConnection1"},
+ // TODO(bradfitz): find a PPP one in the wild
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.RequestURI == "/rootDesc.xml" {
+ io.WriteString(w, tt.xmlBody)
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+ gw, _ := netaddr.FromStdIP(ts.Listener.Addr().(*net.TCPAddr).IP)
+ c, err := getUPnPClient(context.Background(), gw, uPnPDiscoResponse{
+ Location: ts.URL + "/rootDesc.xml",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := fmt.Sprintf("%T", c)
+ if got != tt.want {
+ t.Errorf("got %v; want %v", got, tt.want)
+ }
+ })
+ }
+}