diff --git a/client/internal/portforward/state.go b/client/internal/portforward/state.go index b1315cdc01c..33805a607f9 100644 --- a/client/internal/portforward/state.go +++ b/client/internal/portforward/state.go @@ -10,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/portforward/pcp" + "github.com/netbirdio/netbird/client/internal/portforward/upnp" ) // discoverGateway is the function used for NAT gateway discovery. @@ -24,7 +25,31 @@ func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { } log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err) - return nat.DiscoverGateway(ctx) + // Multicast SSDP is not delivered on all networks (hypervisor bridges, + // IGMP-snooping switches), while MiniUPnPd-based gateways also answer + // unicast M-SEARCH sent directly to them. Run a unicast UPnP search + // against the default gateway alongside the multicast discovery. + unicastResult := make(chan nat.NAT, 1) + go func() { + gateway, err := upnp.Discover(ctx) + if err != nil { + log.Debugf("unicast UPnP discovery failed: %v", err) + unicastResult <- nil + return + } + unicastResult <- gateway + }() + + gateway, err := nat.DiscoverGateway(ctx) + if err == nil { + return gateway, nil + } + + if gateway := <-unicastResult; gateway != nil { + log.Debugf("gateway found via unicast UPnP discovery") + return gateway, nil + } + return nil, err } // State is persisted only for crash recovery cleanup diff --git a/client/internal/portforward/upnp/nat.go b/client/internal/portforward/upnp/nat.go new file mode 100644 index 00000000000..b902fa6c388 --- /dev/null +++ b/client/internal/portforward/upnp/nat.go @@ -0,0 +1,155 @@ +package upnp + +import ( + "context" + "fmt" + "math" + "math/rand" + "net" + "sync" + "time" + + "github.com/huin/goupnp" + "github.com/libp2p/go-nat" +) + +var _ nat.NAT = (*NAT)(nil) + +// igdClient is the subset of the goupnp IGD service client API used for port mappings. +type igdClient interface { + GetExternalIPAddressCtx(ctx context.Context) (string, error) + AddPortMappingCtx(ctx context.Context, remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error + DeletePortMappingCtx(ctx context.Context, remoteHost string, externalPort uint16, protocol string) error + GetNATRSIPStatusCtx(ctx context.Context) (rsipAvailable bool, natEnabled bool, err error) +} + +// NAT implements the go-nat NAT interface using a UPnP IGD discovered via +// unicast SSDP. Port mapping semantics match go-nat's UPnP implementation: +// a random external port is chosen and remembered per internal port so +// renewals keep the same mapping. +type NAT struct { + client igdClient + natType string + root *goupnp.RootDevice + + mu sync.Mutex + ports map[int]int // internal port -> mapped external port +} + +// Type returns the kind of NAT port mapping service that is used. +func (n *NAT) Type() string { + return n.natType +} + +// GetDeviceAddress returns the internal address of the gateway device. +func (n *NAT) GetDeviceAddress() (net.IP, error) { + addr, err := net.ResolveUDPAddr("udp4", n.root.URLBase.Host) + if err != nil { + return nil, err + } + return addr.IP, nil +} + +// GetExternalAddress returns the external address of the gateway device. +func (n *NAT) GetExternalAddress() (net.IP, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ipString, err := n.client.GetExternalIPAddressCtx(ctx) + if err != nil { + return nil, err + } + + ip := net.ParseIP(ipString) + if ip == nil { + return nil, nat.ErrNoExternalAddress + } + return ip, nil +} + +// GetInternalAddress returns the local address used to reach the gateway device. +func (n *NAT) GetInternalAddress() (net.IP, error) { + conn, err := net.Dial("udp4", n.root.URLBase.Host) + if err != nil { + return nil, err + } + defer func() { + _ = conn.Close() + }() + + localAddr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return nil, nat.ErrNoInternalAddress + } + return localAddr.IP, nil +} + +// AddPortMapping maps a port on the local host to an external port. +func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, description string, timeout time.Duration) (int, error) { + proto, err := mapProtocol(protocol) + if err != nil { + return 0, err + } + + internalIP, err := n.GetInternalAddress() + if err != nil { + return 0, fmt.Errorf("get internal address: %w", err) + } + leaseDuration := uint32(timeout / time.Second) + + n.mu.Lock() + externalPort := n.ports[internalPort] + n.mu.Unlock() + + if externalPort > 0 { + err = n.client.AddPortMappingCtx(ctx, "", uint16(externalPort), proto, uint16(internalPort), internalIP.String(), true, description, leaseDuration) + if err == nil { + return externalPort, nil + } + } + + for range 3 { + port := randomPort() + err = n.client.AddPortMappingCtx(ctx, "", uint16(port), proto, uint16(internalPort), internalIP.String(), true, description, leaseDuration) + if err == nil { + n.mu.Lock() + n.ports[internalPort] = port + n.mu.Unlock() + return port, nil + } + } + return 0, err +} + +// DeletePortMapping removes a port mapping. +func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { + proto, err := mapProtocol(protocol) + if err != nil { + return err + } + + n.mu.Lock() + externalPort := n.ports[internalPort] + delete(n.ports, internalPort) + n.mu.Unlock() + + if externalPort == 0 { + return nil + } + return n.client.DeletePortMappingCtx(ctx, "", uint16(externalPort), proto) +} + +func mapProtocol(s string) (string, error) { + switch s { + case "udp": + return "UDP", nil + case "tcp": + return "TCP", nil + default: + return "", fmt.Errorf("invalid protocol: %s", s) + } +} + +func randomPort() int { + return rand.Intn(math.MaxUint16-10000) + 10000 +} diff --git a/client/internal/portforward/upnp/upnp.go b/client/internal/portforward/upnp/upnp.go new file mode 100644 index 00000000000..846d6f61387 --- /dev/null +++ b/client/internal/portforward/upnp/upnp.go @@ -0,0 +1,246 @@ +// Package upnp implements UPnP IGD gateway discovery over unicast SSDP. +// +// The multicast SSDP discovery performed by libp2p/go-nat is not delivered on +// networks that filter multicast (hypervisor bridges, IGMP-snooping switches, +// some Wi-Fi access points). Gateways based on MiniUPnPd (OPNsense, pfSense, +// OpenWrt) answer unicast M-SEARCH requests sent directly to them (UPnP Device +// Architecture 1.1, section 1.3.2), so searching the default gateway directly +// works where multicast discovery cannot. +package upnp + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "runtime" + "sort" + "strconv" + "time" + + "github.com/huin/goupnp" + "github.com/huin/goupnp/dcps/internetgateway2" + "github.com/huin/goupnp/httpu" + "github.com/libp2p/go-nat" + "github.com/libp2p/go-netroute" + log "github.com/sirupsen/logrus" +) + +const ( + ssdpPort = "1900" + // searchRepeats is how many times each M-SEARCH datagram is sent, since + // SSDP runs over UDP and datagrams may be dropped. + searchRepeats = 3 +) + +// searchTimeout is how long each search waits for responses. Variable so +// tests can shorten it. +var searchTimeout = 2 * time.Second + +// searchTargets are tried in order until one yields a usable gateway. +var searchTargets = []string{ + "urn:schemas-upnp-org:device:InternetGatewayDevice:2", + "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "ssdp:all", +} + +// serviceRank orders WAN connection services by preference, matching go-nat. +var serviceRank = map[string]int{ + internetgateway2.URN_WANIPConnection_2: 3, + internetgateway2.URN_WANIPConnection_1: 2, + internetgateway2.URN_WANPPPConnection_1: 1, +} + +// Discover attempts to find a UPnP IGD by sending unicast SSDP searches to +// the default gateway. +func Discover(ctx context.Context) (nat.NAT, error) { + gateway, err := getDefaultGateway() + if err != nil { + return nil, fmt.Errorf("get default gateway: %w", err) + } + return discover(ctx, net.JoinHostPort(gateway.String(), ssdpPort)) +} + +func discover(ctx context.Context, gatewayAddr string) (nat.NAT, error) { + client, err := httpu.NewHTTPUClient() + if err != nil { + return nil, fmt.Errorf("create SSDP client: %w", err) + } + defer func() { + if err := client.Close(); err != nil { + log.Debugf("close SSDP client: %v", err) + } + }() + + checked := make(map[string]bool) + for _, target := range searchTargets { + if err := ctx.Err(); err != nil { + return nil, err + } + + locations, err := search(client, gatewayAddr, target) + if err != nil { + log.Debugf("unicast SSDP search for %s at %s: %v", target, gatewayAddr, err) + continue + } + + for _, location := range locations { + if checked[location] { + continue + } + checked[location] = true + + gateway, err := natFromLocation(ctx, location) + if err != nil { + log.Debugf("UPnP device at %s: %v", location, err) + continue + } + return gateway, nil + } + } + + return nil, fmt.Errorf("no UPnP gateway found at %s", gatewayAddr) +} + +// search sends a unicast M-SEARCH to gatewayAddr and returns the description +// URLs of the devices that answered. +func search(client *httpu.HTTPUClient, gatewayAddr, searchTarget string) ([]string, error) { + mx := max(int(searchTimeout/time.Second), 1) + req := &http.Request{ + Method: "M-SEARCH", + // httpu sends the request to req.Host, making the search unicast. + Host: gatewayAddr, + URL: &url.URL{Opaque: "*"}, + // Header keys are set verbatim (map literal keys bypass Go's + // canonicalization) to keep the upper-case field names SSDP + // implementations expect. + Header: http.Header{ + "HOST": []string{gatewayAddr}, + "MAN": []string{`"ssdp:discover"`}, + "MX": []string{strconv.Itoa(mx)}, + "ST": []string{searchTarget}, + }, + } + + responses, err := client.Do(req, searchTimeout, searchRepeats) //nolint:bodyclose // httpu.Do returns a slice; bodies are closed in the loop below. + if err != nil { + return nil, err + } + + var locations []string + for _, resp := range responses { + if resp.Body != nil { + if err := resp.Body.Close(); err != nil { + log.Debugf("close SSDP response body: %v", err) + } + } + if resp.StatusCode != http.StatusOK { + continue + } + if location := resp.Header.Get("Location"); location != "" { + locations = append(locations, location) + } + } + return locations, nil +} + +// natFromLocation fetches the device description and returns a NAT backed by +// the most preferred WAN connection service it offers. +func natFromLocation(ctx context.Context, location string) (nat.NAT, error) { + loc, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("parse location: %w", err) + } + + root, err := goupnp.DeviceByURLCtx(ctx, loc) + if err != nil { + return nil, fmt.Errorf("fetch device description: %w", err) + } + + var services []*goupnp.Service + root.Device.VisitServices(func(srv *goupnp.Service) { + if serviceRank[srv.ServiceType] > 0 { + services = append(services, srv) + } + }) + sort.SliceStable(services, func(i, j int) bool { + return serviceRank[services[i].ServiceType] > serviceRank[services[j].ServiceType] + }) + + for _, srv := range services { + gateway, err := natFromService(ctx, root, loc, srv) + if err != nil { + log.Debugf("UPnP service %s at %s: %v", srv.ServiceType, location, err) + continue + } + return gateway, nil + } + + return nil, fmt.Errorf("no usable WAN connection service in device at %s", location) +} + +func natFromService(ctx context.Context, root *goupnp.RootDevice, loc *url.URL, srv *goupnp.Service) (nat.NAT, error) { + serviceClient := goupnp.ServiceClient{ + SOAPClient: srv.NewSOAPClient(), + RootDevice: root, + Location: loc, + Service: srv, + } + + var client igdClient + var natType string + switch srv.ServiceType { + case internetgateway2.URN_WANIPConnection_2: + client = &internetgateway2.WANIPConnection2{ServiceClient: serviceClient} + natType = "UPnP unicast (IP2)" + case internetgateway2.URN_WANIPConnection_1: + client = &internetgateway2.WANIPConnection1{ServiceClient: serviceClient} + natType = "UPnP unicast (IP1)" + case internetgateway2.URN_WANPPPConnection_1: + client = &internetgateway2.WANPPPConnection1{ServiceClient: serviceClient} + natType = "UPnP unicast (PPP1)" + default: + return nil, fmt.Errorf("unsupported service type %s", srv.ServiceType) + } + + _, isNAT, err := client.GetNATRSIPStatusCtx(ctx) + if err != nil { + return nil, fmt.Errorf("get NAT status: %w", err) + } + if !isNAT { + return nil, errors.New("gateway reports NAT disabled") + } + + return &NAT{ + client: client, + natType: natType, + root: root, + ports: make(map[int]int), + }, nil +} + +// getDefaultGateway returns the default IPv4 gateway using the system routing table. +func getDefaultGateway() (net.IP, error) { + router, err := netroute.New() + if err != nil { + return nil, err + } + + dst := net.IPv4zero + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android. + dst = net.IPv4(0, 0, 0, 1) + } + _, gateway, _, err := router.Route(dst) + if err != nil { + return nil, err + } + + if gateway == nil { + return nil, nat.ErrNoNATFound + } + + return gateway, nil +} diff --git a/client/internal/portforward/upnp/upnp_test.go b/client/internal/portforward/upnp/upnp_test.go new file mode 100644 index 00000000000..656fbae88cb --- /dev/null +++ b/client/internal/portforward/upnp/upnp_test.go @@ -0,0 +1,317 @@ +package upnp + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const rootDescXML = ` + + 11 + + urn:schemas-upnp-org:device:InternetGatewayDevice:2 + Test IGD + test + test + uuid:test-igd + + + urn:schemas-upnp-org:device:WANDevice:2 + WANDevice + test + test + uuid:test-wandev + + + urn:schemas-upnp-org:device:WANConnectionDevice:2 + WANConnectionDevice + test + test + uuid:test-wanconn + + + urn:schemas-upnp-org:service:WANIPConnection:2 + urn:upnp-org:serviceId:WANIPConn1 + /WANIPCn.xml + /ctl/IPConn + /evt/IPConn + + + + + + + +` + +const soapFault725 = ` + + + +s:Client +UPnPError + +725OnlyPermanentLeasesSupported + + + +` + +type addPortMappingCall struct { + body string +} + +// fakeIGD emulates a MiniUPnPd-style gateway: a UDP socket answering unicast +// SSDP M-SEARCH and an HTTP server serving the device description and SOAP +// control endpoint. +type fakeIGD struct { + t *testing.T + udpConn net.PacketConn + httpServer *httptest.Server + + // respondToST decides which search targets are answered. + respondToST func(st string) bool + // permanentLeaseOnly makes AddPortMapping fail with UPnP error 725 + // unless a permanent lease (duration 0) is requested. + permanentLeaseOnly bool + + mu sync.Mutex + addCalls []addPortMappingCall + delCalls []string +} + +func startFakeIGD(t *testing.T, respondToST func(st string) bool, permanentLeaseOnly bool) *fakeIGD { + t.Helper() + + f := &fakeIGD{ + t: t, + respondToST: respondToST, + permanentLeaseOnly: permanentLeaseOnly, + } + + mux := http.NewServeMux() + mux.HandleFunc("/rootDesc.xml", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", `text/xml; charset="utf-8"`) + _, _ = w.Write([]byte(rootDescXML)) + }) + mux.HandleFunc("/ctl/IPConn", f.handleSOAP) + f.httpServer = httptest.NewServer(mux) + t.Cleanup(f.httpServer.Close) + + udpConn, err := net.ListenPacket("udp4", "127.0.0.1:0") + require.NoError(t, err) + f.udpConn = udpConn + t.Cleanup(func() { + _ = udpConn.Close() + }) + go f.serveSSDP() + + return f +} + +// addr returns the address unicast M-SEARCH requests should be sent to. +func (f *fakeIGD) addr() string { + return f.udpConn.LocalAddr().String() +} + +func (f *fakeIGD) serveSSDP() { + buf := make([]byte, 2048) + for { + n, remote, err := f.udpConn.ReadFrom(buf) + if err != nil { + return + } + + request := string(buf[:n]) + if !strings.HasPrefix(request, "M-SEARCH") { + continue + } + + var st string + for line := range strings.SplitSeq(request, "\r\n") { + if v, ok := strings.CutPrefix(line, "ST:"); ok { + st = strings.TrimSpace(v) + } + } + if !f.respondToST(st) { + continue + } + + response := fmt.Sprintf("HTTP/1.1 200 OK\r\n"+ + "CACHE-CONTROL: max-age=1800\r\n"+ + "ST: %s\r\n"+ + "USN: uuid:test-igd::%s\r\n"+ + "EXT:\r\n"+ + "SERVER: test UPnP/1.1 MiniUPnPd/2.3.9\r\n"+ + "LOCATION: %s/rootDesc.xml\r\n"+ + "\r\n", st, st, f.httpServer.URL) + _, _ = f.udpConn.WriteTo([]byte(response), remote) + } +} + +func (f *fakeIGD) handleSOAP(w http.ResponseWriter, r *http.Request) { + body := make([]byte, r.ContentLength) + _, _ = r.Body.Read(body) + + action := r.Header.Get("Soapaction") + writeResponse := func(inner string) { + w.Header().Set("Content-Type", `text/xml; charset="utf-8"`) + _, _ = fmt.Fprintf(w, ` + +%s +`, inner) + } + + switch { + case strings.Contains(action, "GetNATRSIPStatus"): + writeResponse(` +0 +1 +`) + + case strings.Contains(action, "AddPortMapping"): + if f.permanentLeaseOnly && !strings.Contains(string(body), "0") { + w.Header().Set("Content-Type", `text/xml; charset="utf-8"`) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(soapFault725)) + return + } + f.mu.Lock() + f.addCalls = append(f.addCalls, addPortMappingCall{body: string(body)}) + f.mu.Unlock() + writeResponse(``) + + case strings.Contains(action, "DeletePortMapping"): + f.mu.Lock() + f.delCalls = append(f.delCalls, string(body)) + f.mu.Unlock() + writeResponse(``) + + case strings.Contains(action, "GetExternalIPAddress"): + writeResponse(` +203.0.113.1 +`) + + default: + w.WriteHeader(http.StatusInternalServerError) + } +} + +func shortenSearchTimeout(t *testing.T) { + t.Helper() + old := searchTimeout + searchTimeout = 250 * time.Millisecond + t.Cleanup(func() { + searchTimeout = old + }) +} + +func TestDiscoverUnicast(t *testing.T) { + shortenSearchTimeout(t) + igd := startFakeIGD(t, func(st string) bool { + return st == "urn:schemas-upnp-org:device:InternetGatewayDevice:2" + }, false) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gateway, err := discover(ctx, igd.addr()) + require.NoError(t, err) + assert.Equal(t, "UPnP unicast (IP2)", gateway.Type()) + + deviceAddr, err := gateway.GetDeviceAddress() + require.NoError(t, err) + assert.Equal(t, "127.0.0.1", deviceAddr.String()) + + externalIP, err := gateway.GetExternalAddress() + require.NoError(t, err) + assert.Equal(t, "203.0.113.1", externalIP.String()) + + externalPort, err := gateway.AddPortMapping(ctx, "udp", 51820, "NetBird", 2*time.Hour) + require.NoError(t, err) + assert.GreaterOrEqual(t, externalPort, 10000) + + igd.mu.Lock() + require.Len(t, igd.addCalls, 1) + mappingBody := igd.addCalls[0].body + igd.mu.Unlock() + assert.Contains(t, mappingBody, "51820") + assert.Contains(t, mappingBody, "127.0.0.1") + assert.Contains(t, mappingBody, "UDP") + assert.Contains(t, mappingBody, "7200") + + // Renewal reuses the previously mapped external port. + renewedPort, err := gateway.AddPortMapping(ctx, "udp", 51820, "NetBird", 2*time.Hour) + require.NoError(t, err) + assert.Equal(t, externalPort, renewedPort) + + require.NoError(t, gateway.DeletePortMapping(ctx, "udp", 51820)) + igd.mu.Lock() + require.Len(t, igd.delCalls, 1) + deleteBody := igd.delCalls[0] + igd.mu.Unlock() + assert.Contains(t, deleteBody, fmt.Sprintf("%d", externalPort)) +} + +func TestDiscoverSSDPAllFallback(t *testing.T) { + shortenSearchTimeout(t) + igd := startFakeIGD(t, func(st string) bool { + return st == "ssdp:all" + }, false) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gateway, err := discover(ctx, igd.addr()) + require.NoError(t, err) + assert.Equal(t, "UPnP unicast (IP2)", gateway.Type()) +} + +func TestDiscoverNoGateway(t *testing.T) { + shortenSearchTimeout(t) + // A socket that never answers. + udpConn, err := net.ListenPacket("udp4", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + _ = udpConn.Close() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err = discover(ctx, udpConn.LocalAddr().String()) + require.Error(t, err) +} + +func TestPermanentLeaseFaultSurfacesErrorCode(t *testing.T) { + shortenSearchTimeout(t) + igd := startFakeIGD(t, func(st string) bool { + return st == "urn:schemas-upnp-org:device:InternetGatewayDevice:2" + }, true) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gateway, err := discover(ctx, igd.addr()) + require.NoError(t, err) + + // The manager detects permanent-lease-only gateways by matching + // 725 in the error text and retries with TTL 0. + _, err = gateway.AddPortMapping(ctx, "udp", 51820, "NetBird", 2*time.Hour) + require.Error(t, err) + assert.Regexp(t, `\s*725\s*`, err.Error()) + + externalPort, err := gateway.AddPortMapping(ctx, "udp", 51820, "NetBird", 0) + require.NoError(t, err) + assert.GreaterOrEqual(t, externalPort, 10000) +} diff --git a/go.mod b/go.mod index e1c76260711..a9ddd61f2a8 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 + github.com/huin/goupnp v1.2.0 github.com/jackc/pgx/v5 v5.5.5 github.com/libdns/route53 v1.5.0 github.com/libp2p/go-nat v0.2.0 @@ -242,7 +243,6 @@ require ( github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/huandu/xstrings v1.5.0 // indirect - github.com/huin/goupnp v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect