mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 20:19:55 +00:00
[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:
@@ -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
|
||||
|
||||
155
client/internal/portforward/upnp/nat.go
Normal file
155
client/internal/portforward/upnp/nat.go
Normal 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
|
||||
}
|
||||
241
client/internal/portforward/upnp/upnp.go
Normal file
241
client/internal/portforward/upnp/upnp.go
Normal 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
|
||||
}
|
||||
317
client/internal/portforward/upnp/upnp_test.go
Normal file
317
client/internal/portforward/upnp/upnp_test.go
Normal 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
2
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
|
||||
|
||||
Reference in New Issue
Block a user