mirror of
https://github.com/tailscale/tailscale.git
synced 2025-10-10 09:45:08 +00:00
net/portmapper: be smarter about selecting a UPnP device
Previously, we would select the first WANIPConnection2 (and related) client from the root device, without any additional checks. However, some routers expose multiple UPnP devices in various states, and simply picking the first available one can result in attempting to perform a portmap with a device that isn't functional. Instead, mimic what the miniupnpc code does, and prefer devices that are (a) reporting as Connected, and (b) have a valid external IP address. For our use-case, we additionally prefer devices that have an external IP address that's a public address, to increase the likelihood that we can obtain a direct connection from peers. Finally, we split out fetching the root device (getUPnPRootDevice) from selecting the best service within that root device (selectBestService), and add some extensive tests for various UPnP server behaviours. RELNOTE=Improve UPnP portmapping when multiple UPnP services exist Updates #8364 Signed-off-by: Andrew Dunham <andrew@du.nham.ca> Change-Id: I71795cd80be6214dfcef0fe83115a5e3fe4b8753
This commit is contained in:
@@ -218,19 +218,19 @@ func TestGetUPnPClient(t *testing.T) {
|
||||
"google",
|
||||
googleWifiRootDescXML,
|
||||
"*internetgateway2.WANIPConnection2",
|
||||
"saw UPnP type WANIPConnection2 at http://127.0.0.1:NNN/rootDesc.xml; OnHub (Google)\n",
|
||||
"saw UPnP type WANIPConnection2 at http://127.0.0.1:NNN/rootDesc.xml; OnHub (Google), method=single\n",
|
||||
},
|
||||
{
|
||||
"pfsense",
|
||||
pfSenseRootDescXML,
|
||||
"*internetgateway2.WANIPConnection1",
|
||||
"saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; FreeBSD router (FreeBSD)\n",
|
||||
"saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; FreeBSD router (FreeBSD), method=single\n",
|
||||
},
|
||||
{
|
||||
"mikrotik",
|
||||
mikrotikRootDescXML,
|
||||
"*internetgateway2.WANIPConnection1",
|
||||
"saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; MikroTik Router (MikroTik)\n",
|
||||
"saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; MikroTik Router (MikroTik), method=none\n",
|
||||
},
|
||||
// TODO(bradfitz): find a PPP one in the wild
|
||||
}
|
||||
@@ -246,13 +246,20 @@ func TestGetUPnPClient(t *testing.T) {
|
||||
defer ts.Close()
|
||||
gw, _ := netip.AddrFromSlice(ts.Listener.Addr().(*net.TCPAddr).IP)
|
||||
gw = gw.Unmap()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var logBuf tstest.MemLogger
|
||||
c, err := getUPnPClient(context.Background(), logBuf.Logf, DebugKnobs{}, gw, uPnPDiscoResponse{
|
||||
dev, loc, err := getUPnPRootDevice(ctx, logBuf.Logf, DebugKnobs{}, gw, uPnPDiscoResponse{
|
||||
Location: ts.URL + "/rootDesc.xml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := selectBestService(ctx, logBuf.Logf, dev, loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := fmt.Sprintf("%T", c)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v; want %v", got, tt.want)
|
||||
@@ -272,90 +279,53 @@ func TestGetUPnPPortMapping(t *testing.T) {
|
||||
}
|
||||
defer igd.Close()
|
||||
|
||||
rootDesc := ""
|
||||
|
||||
// This is a very basic fake UPnP server handler.
|
||||
var sawRequestWithLease atomic.Bool
|
||||
igd.SetUPnPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Logf("got UPnP request %s %s", r.Method, r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/rootDesc.xml":
|
||||
io.WriteString(w, rootDesc)
|
||||
case "/ctl/IPConn", "/upnp/control/yomkmsnooi/wanipconn-1":
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("error reading request body: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
handlers := map[string]any{
|
||||
"AddPortMapping": func(body []byte) (int, string) {
|
||||
// Decode a minimal body to determine whether we skip the request or not.
|
||||
var req struct {
|
||||
Protocol string `xml:"NewProtocol"`
|
||||
InternalPort string `xml:"NewInternalPort"`
|
||||
ExternalPort string `xml:"NewExternalPort"`
|
||||
InternalClient string `xml:"NewInternalClient"`
|
||||
LeaseDuration string `xml:"NewLeaseDuration"`
|
||||
}
|
||||
|
||||
// Decode the request type.
|
||||
var outerRequest struct {
|
||||
Body struct {
|
||||
Request struct {
|
||||
XMLName xml.Name
|
||||
} `xml:",any"`
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
if err := xml.Unmarshal(body, &outerRequest); err != nil {
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
t.Errorf("bad request: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
return http.StatusBadRequest, "bad request"
|
||||
}
|
||||
|
||||
requestType := outerRequest.Body.Request.XMLName.Local
|
||||
upnpRequest := outerRequest.Body.Inner
|
||||
t.Logf("UPnP request: %s", requestType)
|
||||
|
||||
switch requestType {
|
||||
case "AddPortMapping":
|
||||
// Decode a minimal body to determine whether we skip the request or not.
|
||||
var req struct {
|
||||
Protocol string `xml:"NewProtocol"`
|
||||
InternalPort string `xml:"NewInternalPort"`
|
||||
ExternalPort string `xml:"NewExternalPort"`
|
||||
InternalClient string `xml:"NewInternalClient"`
|
||||
LeaseDuration string `xml:"NewLeaseDuration"`
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(upnpRequest), &req); err != nil {
|
||||
t.Errorf("bad request: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Protocol != "UDP" {
|
||||
t.Errorf(`got Protocol=%q, want "UDP"`, req.Protocol)
|
||||
}
|
||||
if req.LeaseDuration != "0" {
|
||||
// Return a fake error to ensure that we fall back to a permanent lease.
|
||||
io.WriteString(w, testAddPortMappingPermanentLease)
|
||||
sawRequestWithLease.Store(true)
|
||||
} else {
|
||||
// Success!
|
||||
io.WriteString(w, testAddPortMappingResponse)
|
||||
}
|
||||
case "GetExternalIPAddress":
|
||||
io.WriteString(w, testGetExternalIPAddressResponse)
|
||||
|
||||
case "DeletePortMapping":
|
||||
// Do nothing for test
|
||||
|
||||
default:
|
||||
t.Errorf("unhandled UPnP request type %q", requestType)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
if req.Protocol != "UDP" {
|
||||
t.Errorf(`got Protocol=%q, want "UDP"`, req.Protocol)
|
||||
}
|
||||
default:
|
||||
t.Logf("ignoring request")
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
if req.LeaseDuration != "0" {
|
||||
// Return a fake error to ensure that we fall back to a permanent lease.
|
||||
sawRequestWithLease.Store(true)
|
||||
return http.StatusOK, testAddPortMappingPermanentLease
|
||||
}
|
||||
|
||||
// Success!
|
||||
return http.StatusOK, testAddPortMappingResponse
|
||||
},
|
||||
"GetExternalIPAddress": testGetExternalIPAddressResponse,
|
||||
"GetStatusInfo": testGetStatusInfoResponse,
|
||||
"DeletePortMapping": "", // Do nothing for test
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
rootDescsToTest := []string{testRootDesc, mikrotikRootDescXML}
|
||||
for _, rootDesc := range rootDescsToTest {
|
||||
igd.SetUPnPHandler(&upnpServer{
|
||||
t: t,
|
||||
Desc: rootDesc,
|
||||
Control: map[string]map[string]any{
|
||||
"/ctl/IPConn": handlers,
|
||||
"/upnp/control/yomkmsnooi/wanipconn-1": handlers,
|
||||
},
|
||||
})
|
||||
|
||||
for _, rootDesc = range rootDescsToTest {
|
||||
c := newTestClient(t, igd)
|
||||
t.Logf("Listening on upnp=%v", c.testUPnPPort)
|
||||
defer c.Close()
|
||||
@@ -391,6 +361,89 @@ func TestGetUPnPPortMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type upnpServer struct {
|
||||
t *testing.T
|
||||
Desc string // root device XML
|
||||
Control map[string]map[string]any // map["/url"]map["UPnPService"]response
|
||||
}
|
||||
|
||||
func (u *upnpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
u.t.Logf("got UPnP request %s %s", r.Method, r.URL.Path)
|
||||
if r.URL.Path == "/rootDesc.xml" {
|
||||
io.WriteString(w, u.Desc)
|
||||
return
|
||||
}
|
||||
if control, ok := u.Control[r.URL.Path]; ok {
|
||||
u.handleControl(w, r, control)
|
||||
return
|
||||
}
|
||||
|
||||
u.t.Logf("ignoring request")
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (u *upnpServer) handleControl(w http.ResponseWriter, r *http.Request, handlers map[string]any) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
u.t.Errorf("error reading request body: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the request type.
|
||||
var outerRequest struct {
|
||||
Body struct {
|
||||
Request struct {
|
||||
XMLName xml.Name
|
||||
} `xml:",any"`
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
if err := xml.Unmarshal(body, &outerRequest); err != nil {
|
||||
u.t.Errorf("bad request: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
requestType := outerRequest.Body.Request.XMLName.Local
|
||||
upnpRequest := outerRequest.Body.Inner
|
||||
u.t.Logf("UPnP request: %s", requestType)
|
||||
|
||||
handler, ok := handlers[requestType]
|
||||
if !ok {
|
||||
u.t.Errorf("unhandled UPnP request type %q", requestType)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch v := handler.(type) {
|
||||
case string:
|
||||
io.WriteString(w, v)
|
||||
case []byte:
|
||||
w.Write(v)
|
||||
|
||||
// Function handlers
|
||||
case func(string) string:
|
||||
io.WriteString(w, v(upnpRequest))
|
||||
case func([]byte) string:
|
||||
io.WriteString(w, v([]byte(upnpRequest)))
|
||||
|
||||
case func(string) (int, string):
|
||||
code, body := v(upnpRequest)
|
||||
w.WriteHeader(code)
|
||||
io.WriteString(w, body)
|
||||
case func([]byte) (int, string):
|
||||
code, body := v([]byte(upnpRequest))
|
||||
w.WriteHeader(code)
|
||||
io.WriteString(w, body)
|
||||
|
||||
default:
|
||||
u.t.Fatalf("invalid handler type: %T", v)
|
||||
http.Error(w, "invalid handler type", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const testRootDesc = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337">
|
||||
<specVersion>
|
||||
@@ -486,3 +539,15 @@ const testGetExternalIPAddressResponse = `<?xml version="1.0"?>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
`
|
||||
|
||||
const testGetStatusInfoResponse = `<?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:schemas-upnp-org:service:WANIPConnection:1">
|
||||
<NewConnectionStatus>Connected</NewConnectionStatus>
|
||||
<NewLastConnectionError>ERROR_NONE</NewLastConnectionError>
|
||||
<NewUptime>9999</NewUptime>
|
||||
</u:GetStatusInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
`
|
||||
|
Reference in New Issue
Block a user