feat(stream): add NAT-PMP port mapping for remote downloads
Replace anacrolix/upnp with huin/goupnp + custom NAT-PMP (RFC 6886) implementation. NAT-PMP is tried first (faster, more compatible with TP-Link routers), with UPnP-IGD SOAP as fallback. Gateway detection reads /proc/net/route for accuracy. Includes unit tests with mock NAT-PMP server and permanent e2e tests (build tag manual).
This commit is contained in:
parent
819c727bf5
commit
aa6acbabc9
8 changed files with 1030 additions and 24 deletions
|
|
@ -1,12 +1,18 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
alog "github.com/anacrolix/log"
|
||||
"github.com/anacrolix/upnp"
|
||||
"github.com/huin/goupnp/dcps/internetgateway2"
|
||||
)
|
||||
|
||||
// UPnPMapping represents an active port mapping on the router.
|
||||
|
|
@ -14,51 +20,407 @@ type UPnPMapping struct {
|
|||
ExternalIP string
|
||||
ExternalPort int
|
||||
InternalPort int
|
||||
device upnp.Device
|
||||
gateway string // for NAT-PMP cleanup
|
||||
protocol string // "natpmp" or "upnp"
|
||||
client upnpClient // for UPnP cleanup (nil if NAT-PMP)
|
||||
}
|
||||
|
||||
// upnpClient abstracts the IGD service methods we need (WANIPConnection or WANPPPConnection).
|
||||
type upnpClient interface {
|
||||
AddPortMapping(
|
||||
NewRemoteHost string,
|
||||
NewExternalPort uint16,
|
||||
NewProtocol string,
|
||||
NewInternalPort uint16,
|
||||
NewInternalClient string,
|
||||
NewEnabled bool,
|
||||
NewPortMappingDescription string,
|
||||
NewLeaseDuration uint32,
|
||||
) error
|
||||
DeletePortMapping(
|
||||
NewRemoteHost string,
|
||||
NewExternalPort uint16,
|
||||
NewProtocol string,
|
||||
) error
|
||||
GetExternalIPAddress() (string, error)
|
||||
}
|
||||
|
||||
// SetupUPnP discovers the gateway, maps the port, and gets the public IP.
|
||||
// Returns nil if UPnP is not available or fails.
|
||||
// Tries NAT-PMP first (faster, more compatible), falls back to UPnP-IGD SOAP.
|
||||
func SetupUPnP(internalPort int) (*UPnPMapping, error) {
|
||||
log.Println("stream: discovering UPnP gateway (10s timeout)...")
|
||||
devices := upnp.Discover(0, 10*time.Second, alog.Logger{})
|
||||
if len(devices) == 0 {
|
||||
return nil, fmt.Errorf("no UPnP devices found (is UPnP enabled on your router?)")
|
||||
log.Println("stream: discovering NAT gateway...")
|
||||
|
||||
gateway := defaultGateway()
|
||||
|
||||
// Try NAT-PMP first (preferred — works on most modern routers including TP-Link)
|
||||
if gateway != "" {
|
||||
if mapping, err := tryNATPMP(gateway, internalPort); err == nil {
|
||||
return mapping, nil
|
||||
} else {
|
||||
log.Printf("stream: NAT-PMP failed (%v), trying UPnP-IGD...", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("stream: found %d UPnP device(s), using %s", len(devices), devices[0].ID())
|
||||
device := devices[0]
|
||||
// Fall back to UPnP-IGD SOAP
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get public IP
|
||||
externalIP, err := device.GetExternalIPAddress()
|
||||
if mapping, err := tryWANIPConnection2(ctx, internalPort); err == nil {
|
||||
return mapping, nil
|
||||
}
|
||||
if mapping, err := tryWANIPConnection1(ctx, internalPort); err == nil {
|
||||
return mapping, nil
|
||||
}
|
||||
if mapping, err := tryWANPPPConnection1(ctx, internalPort); err == nil {
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no NAT gateway found (tried NAT-PMP, IGD2, IGD1, PPP)")
|
||||
}
|
||||
|
||||
// --- NAT-PMP implementation (RFC 6886) ---
|
||||
|
||||
func tryNATPMP(gateway string, port int) (*UPnPMapping, error) {
|
||||
conn, err := net.DialTimeout("udp4", gateway+":5351", 3*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NAT-PMP dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Map TCP port
|
||||
extPort, lifetime, err := natpmpMapPort(conn, 2, uint16(port), uint16(port), 7200)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NAT-PMP map TCP: %w", err)
|
||||
}
|
||||
|
||||
// Get external IP: try NAT-PMP first, fall back to public API
|
||||
extIP := natpmpExternalIP(conn)
|
||||
if extIP == "" {
|
||||
extIP = publicIPFallback()
|
||||
}
|
||||
if extIP == "" {
|
||||
// Clean up the mapping we just created
|
||||
if _, _, err := natpmpMapPort(conn, 2, uint16(port), 0, 0); err != nil {
|
||||
log.Printf("stream: failed to clean up NAT-PMP mapping after IP failure: %v", err)
|
||||
}
|
||||
return nil, fmt.Errorf("NAT-PMP: port mapped but could not determine external IP")
|
||||
}
|
||||
|
||||
log.Printf("stream: NAT-PMP port mapped %s:%d -> :%d (lease %ds)",
|
||||
extIP, extPort, port, lifetime)
|
||||
|
||||
return &UPnPMapping{
|
||||
ExternalIP: extIP,
|
||||
ExternalPort: int(extPort),
|
||||
InternalPort: port,
|
||||
gateway: gateway,
|
||||
protocol: "natpmp",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// natpmpMapPort sends a NAT-PMP mapping request.
|
||||
// opcode: 1=UDP, 2=TCP. lifetime=0 to delete.
|
||||
func natpmpMapPort(conn net.Conn, opcode byte, internalPort, suggestedExtPort uint16, lifetime uint32) (extPort uint16, actualLifetime uint32, err error) {
|
||||
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
req := make([]byte, 12)
|
||||
req[0] = 0 // version
|
||||
req[1] = opcode // 1=UDP, 2=TCP
|
||||
binary.BigEndian.PutUint16(req[4:6], internalPort)
|
||||
binary.BigEndian.PutUint16(req[6:8], suggestedExtPort)
|
||||
binary.BigEndian.PutUint32(req[8:12], lifetime)
|
||||
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return 0, 0, fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 16)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
if n < 16 {
|
||||
return 0, 0, fmt.Errorf("short response: %d bytes", n)
|
||||
}
|
||||
|
||||
resultCode := binary.BigEndian.Uint16(buf[2:4])
|
||||
if resultCode != 0 {
|
||||
names := map[uint16]string{
|
||||
1: "unsupported version", 2: "not authorized",
|
||||
3: "network failure", 4: "out of resources", 5: "unsupported opcode",
|
||||
}
|
||||
name := names[resultCode]
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
return 0, 0, fmt.Errorf("result %d (%s)", resultCode, name)
|
||||
}
|
||||
|
||||
extPort = binary.BigEndian.Uint16(buf[10:12])
|
||||
actualLifetime = binary.BigEndian.Uint32(buf[12:16])
|
||||
return extPort, actualLifetime, nil
|
||||
}
|
||||
|
||||
// natpmpExternalIP queries the external IP via NAT-PMP (opcode 0).
|
||||
func natpmpExternalIP(conn net.Conn) string {
|
||||
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := conn.Write([]byte{0, 0}); err != nil {
|
||||
return ""
|
||||
}
|
||||
buf := make([]byte, 12)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil || n < 12 {
|
||||
return ""
|
||||
}
|
||||
resultCode := binary.BigEndian.Uint16(buf[2:4])
|
||||
if resultCode != 0 {
|
||||
return ""
|
||||
}
|
||||
ip := net.IPv4(buf[8], buf[9], buf[10], buf[11])
|
||||
if ip.IsUnspecified() {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
// publicIPFallback fetches the external IP from a public API.
|
||||
func publicIPFallback() string {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
for _, url := range []string{
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
} {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||
resp.Body.Close()
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
continue
|
||||
}
|
||||
ip := strings.TrimSpace(string(body))
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- UPnP-IGD SOAP implementation ---
|
||||
|
||||
func tryWANIPConnection2(ctx context.Context, port int) (*UPnPMapping, error) {
|
||||
clients, _, err := internetgateway2.NewWANIPConnection2ClientsCtx(ctx)
|
||||
if err != nil || len(clients) == 0 {
|
||||
return nil, fmt.Errorf("WANIPConnection2: %v (found %d)", err, len(clients))
|
||||
}
|
||||
return setupMapping(clients[0].ServiceClient.RootDevice.URLBase.Host, &wanIP2Adapter{clients[0]}, port)
|
||||
}
|
||||
|
||||
func tryWANIPConnection1(ctx context.Context, port int) (*UPnPMapping, error) {
|
||||
clients, _, err := internetgateway2.NewWANIPConnection1ClientsCtx(ctx)
|
||||
if err != nil || len(clients) == 0 {
|
||||
return nil, fmt.Errorf("WANIPConnection1: %v (found %d)", err, len(clients))
|
||||
}
|
||||
return setupMapping(clients[0].ServiceClient.RootDevice.URLBase.Host, &wanIP1Adapter{clients[0]}, port)
|
||||
}
|
||||
|
||||
func tryWANPPPConnection1(ctx context.Context, port int) (*UPnPMapping, error) {
|
||||
clients, _, err := internetgateway2.NewWANPPPConnection1ClientsCtx(ctx)
|
||||
if err != nil || len(clients) == 0 {
|
||||
return nil, fmt.Errorf("WANPPPConnection1: %v (found %d)", err, len(clients))
|
||||
}
|
||||
return setupMapping(clients[0].ServiceClient.RootDevice.URLBase.Host, &wanPPP1Adapter{clients[0]}, port)
|
||||
}
|
||||
|
||||
func setupMapping(deviceHost string, client upnpClient, internalPort int) (*UPnPMapping, error) {
|
||||
externalIP, err := client.GetExternalIPAddress()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get external IP: %w", err)
|
||||
}
|
||||
log.Printf("stream: public IP via UPnP: %s", externalIP)
|
||||
if externalIP == "" {
|
||||
externalIP = publicIPFallback()
|
||||
}
|
||||
if externalIP == "" {
|
||||
return nil, fmt.Errorf("could not determine external IP")
|
||||
}
|
||||
|
||||
// Map port (same internal/external, 2h lease)
|
||||
mappedPort, err := device.AddPortMapping(upnp.TCP, internalPort, internalPort, "unarr stream", 2*time.Hour)
|
||||
localIP := localIPFor(deviceHost)
|
||||
|
||||
err = client.AddPortMapping(
|
||||
"", // remote host (empty = any)
|
||||
uint16(internalPort), // external port
|
||||
"TCP", // protocol
|
||||
uint16(internalPort), // internal port
|
||||
localIP, // internal client IP
|
||||
true, // enabled
|
||||
"unarr stream", // description
|
||||
7200, // lease duration (2 hours)
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add port mapping %d: %w", internalPort, err)
|
||||
}
|
||||
|
||||
log.Printf("stream: UPnP port mapped %s:%d -> local:%d (2h lease)", externalIP, mappedPort, internalPort)
|
||||
log.Printf("stream: UPnP port mapped %s:%d -> %s:%d (2h lease)", externalIP, internalPort, localIP, internalPort)
|
||||
return &UPnPMapping{
|
||||
ExternalIP: externalIP.String(),
|
||||
ExternalPort: mappedPort,
|
||||
ExternalIP: externalIP,
|
||||
ExternalPort: internalPort,
|
||||
InternalPort: internalPort,
|
||||
device: device,
|
||||
protocol: "upnp",
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// defaultGateway returns the default gateway IP.
|
||||
// Reads /proc/net/route on Linux, falls back to assuming .1 on the local subnet.
|
||||
func defaultGateway() string {
|
||||
// Try /proc/net/route first (Linux only, no external dependency)
|
||||
if gw := gatewayFromProcRoute(); gw != "" {
|
||||
return gw
|
||||
}
|
||||
|
||||
// Fallback: assume .1 on the local subnet (works for most home routers)
|
||||
conn, err := net.Dial("udp4", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ip := conn.LocalAddr().(*net.UDPAddr).IP.To4()
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
return net.IPv4(ip[0], ip[1], ip[2], 1).String()
|
||||
}
|
||||
|
||||
// gatewayFromProcRoute parses /proc/net/route for the default route gateway.
|
||||
func gatewayFromProcRoute() string {
|
||||
data, err := os.ReadFile("/proc/net/route")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
// Default route: destination is 00000000
|
||||
if fields[1] != "00000000" {
|
||||
continue
|
||||
}
|
||||
// Gateway is field 2 in little-endian hex
|
||||
gw, err := fmt.Sscanf(fields[2], "%x", new(uint32))
|
||||
if err != nil || gw != 1 {
|
||||
continue
|
||||
}
|
||||
var gwInt uint32
|
||||
fmt.Sscanf(fields[2], "%x", &gwInt)
|
||||
return fmt.Sprintf("%d.%d.%d.%d",
|
||||
gwInt&0xFF, (gwInt>>8)&0xFF, (gwInt>>16)&0xFF, (gwInt>>24)&0xFF)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// localIPFor returns the local IP that can reach the given host (typically the router).
|
||||
func localIPFor(host string) string {
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
h = host
|
||||
}
|
||||
conn, err := net.Dial("udp4", h+":1")
|
||||
if err != nil {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
defer conn.Close()
|
||||
return conn.LocalAddr().(*net.UDPAddr).IP.String()
|
||||
}
|
||||
|
||||
// Remove deletes the port mapping from the router.
|
||||
func (m *UPnPMapping) Remove() {
|
||||
if m == nil || m.device == nil {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if err := m.device.DeletePortMapping(upnp.TCP, m.ExternalPort); err != nil {
|
||||
|
||||
switch m.protocol {
|
||||
case "natpmp":
|
||||
m.removeNATPMP()
|
||||
case "upnp":
|
||||
m.removeUPnP()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *UPnPMapping) removeNATPMP() {
|
||||
if m.gateway == "" {
|
||||
return
|
||||
}
|
||||
conn, err := net.DialTimeout("udp4", m.gateway+":5351", 3*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("stream: failed to connect for NAT-PMP cleanup: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, _, err = natpmpMapPort(conn, 2, uint16(m.InternalPort), 0, 0)
|
||||
if err != nil {
|
||||
log.Printf("stream: failed to remove NAT-PMP mapping: %v", err)
|
||||
} else {
|
||||
log.Printf("stream: removed NAT-PMP mapping for port %d", m.ExternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *UPnPMapping) removeUPnP() {
|
||||
if m.client == nil {
|
||||
return
|
||||
}
|
||||
if err := m.client.DeletePortMapping("", uint16(m.ExternalPort), "TCP"); err != nil {
|
||||
log.Printf("stream: failed to remove UPnP mapping: %v", err)
|
||||
} else {
|
||||
log.Printf("stream: removed UPnP mapping for port %d", m.ExternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Adapters to unify WANIPConnection2, WANIPConnection1, WANPPPConnection1 ---
|
||||
|
||||
type wanIP2Adapter struct {
|
||||
c *internetgateway2.WANIPConnection2
|
||||
}
|
||||
|
||||
func (a *wanIP2Adapter) AddPortMapping(remoteHost string, extPort uint16, proto string, intPort uint16, intClient string, enabled bool, desc string, lease uint32) error {
|
||||
return a.c.AddPortMapping(remoteHost, extPort, proto, intPort, intClient, enabled, desc, lease)
|
||||
}
|
||||
func (a *wanIP2Adapter) DeletePortMapping(remoteHost string, extPort uint16, proto string) error {
|
||||
return a.c.DeletePortMapping(remoteHost, extPort, proto)
|
||||
}
|
||||
func (a *wanIP2Adapter) GetExternalIPAddress() (string, error) {
|
||||
return a.c.GetExternalIPAddress()
|
||||
}
|
||||
|
||||
type wanIP1Adapter struct {
|
||||
c *internetgateway2.WANIPConnection1
|
||||
}
|
||||
|
||||
func (a *wanIP1Adapter) AddPortMapping(remoteHost string, extPort uint16, proto string, intPort uint16, intClient string, enabled bool, desc string, lease uint32) error {
|
||||
return a.c.AddPortMapping(remoteHost, extPort, proto, intPort, intClient, enabled, desc, lease)
|
||||
}
|
||||
func (a *wanIP1Adapter) DeletePortMapping(remoteHost string, extPort uint16, proto string) error {
|
||||
return a.c.DeletePortMapping(remoteHost, extPort, proto)
|
||||
}
|
||||
func (a *wanIP1Adapter) GetExternalIPAddress() (string, error) {
|
||||
return a.c.GetExternalIPAddress()
|
||||
}
|
||||
|
||||
type wanPPP1Adapter struct {
|
||||
c *internetgateway2.WANPPPConnection1
|
||||
}
|
||||
|
||||
func (a *wanPPP1Adapter) AddPortMapping(remoteHost string, extPort uint16, proto string, intPort uint16, intClient string, enabled bool, desc string, lease uint32) error {
|
||||
return a.c.AddPortMapping(remoteHost, extPort, proto, intPort, intClient, enabled, desc, lease)
|
||||
}
|
||||
func (a *wanPPP1Adapter) DeletePortMapping(remoteHost string, extPort uint16, proto string) error {
|
||||
return a.c.DeletePortMapping(remoteHost, extPort, proto)
|
||||
}
|
||||
func (a *wanPPP1Adapter) GetExternalIPAddress() (string, error) {
|
||||
return a.c.GetExternalIPAddress()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue