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