[client] Add unicast UPnP discovery for port forwarding.

Add a UPnP IGD discovery fallback that sends unicast SSDP M-SEARCH requests directly to the default gateway when PCP and multicast discovery do not find a gateway.
This commit is contained in:
jnfrati
2026-07-02 15:27:02 +02:00
parent 7d4736de55
commit c0cb24b6ce
5 changed files with 740 additions and 2 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -0,0 +1,241 @@
// 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)
if err != nil {
return nil, err
}
var locations []string
for _, resp := range responses {
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
}

View File

@@ -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 = `<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion><major>1</major><minor>1</minor></specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:2</deviceType>
<friendlyName>Test IGD</friendlyName>
<manufacturer>test</manufacturer>
<modelName>test</modelName>
<UDN>uuid:test-igd</UDN>
<deviceList>
<device>
<deviceType>urn:schemas-upnp-org:device:WANDevice:2</deviceType>
<friendlyName>WANDevice</friendlyName>
<manufacturer>test</manufacturer>
<modelName>test</modelName>
<UDN>uuid:test-wandev</UDN>
<deviceList>
<device>
<deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:2</deviceType>
<friendlyName>WANConnectionDevice</friendlyName>
<manufacturer>test</manufacturer>
<modelName>test</modelName>
<UDN>uuid:test-wanconn</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:2</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>
</device>
</root>`
const soapFault725 = `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<s:Fault>
<faultcode>s:Client</faultcode>
<faultstring>UPnPError</faultstring>
<detail>
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0"><errorCode>725</errorCode><errorDescription>OnlyPermanentLeasesSupported</errorDescription></UPnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>`
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, `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>%s</s:Body>
</s:Envelope>`, inner)
}
switch {
case strings.Contains(action, "GetNATRSIPStatus"):
writeResponse(`<u:GetNATRSIPStatusResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:2">
<NewRSIPAvailable>0</NewRSIPAvailable>
<NewNATEnabled>1</NewNATEnabled>
</u:GetNATRSIPStatusResponse>`)
case strings.Contains(action, "AddPortMapping"):
if f.permanentLeaseOnly && !strings.Contains(string(body), "<NewLeaseDuration>0</NewLeaseDuration>") {
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(`<u:AddPortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:2"/>`)
case strings.Contains(action, "DeletePortMapping"):
f.mu.Lock()
f.delCalls = append(f.delCalls, string(body))
f.mu.Unlock()
writeResponse(`<u:DeletePortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:2"/>`)
case strings.Contains(action, "GetExternalIPAddress"):
writeResponse(`<u:GetExternalIPAddressResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:2">
<NewExternalIPAddress>203.0.113.1</NewExternalIPAddress>
</u:GetExternalIPAddressResponse>`)
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, "<NewInternalPort>51820</NewInternalPort>")
assert.Contains(t, mappingBody, "<NewInternalClient>127.0.0.1</NewInternalClient>")
assert.Contains(t, mappingBody, "<NewProtocol>UDP</NewProtocol>")
assert.Contains(t, mappingBody, "<NewLeaseDuration>7200</NewLeaseDuration>")
// 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("<NewExternalPort>%d</NewExternalPort>", 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
// <errorCode>725</errorCode> 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, `<errorCode>\s*725\s*</errorCode>`, err.Error())
externalPort, err := gateway.AddPortMapping(ctx, "udp", 51820, "NetBird", 0)
require.NoError(t, err)
assert.GreaterOrEqual(t, externalPort, 10000)
}

2
go.mod
View File

@@ -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