mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 23:09:08 +01: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"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/portforward/pcp"
|
"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.
|
// 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)
|
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
|
// State is persisted only for crash recovery cleanup
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ require (
|
|||||||
github.com/hashicorp/go-multierror v1.1.1
|
github.com/hashicorp/go-multierror v1.1.1
|
||||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
|
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
|
||||||
github.com/hashicorp/go-version v1.7.0
|
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/jackc/pgx/v5 v5.5.5
|
||||||
github.com/libdns/route53 v1.5.0
|
github.com/libdns/route53 v1.5.0
|
||||||
github.com/libp2p/go-nat v0.2.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/go-uuid v1.0.3 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
|||||||
Reference in New Issue
Block a user