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:
Deivid Soto 2026-04-06 10:09:07 +02:00
parent 819c727bf5
commit aa6acbabc9
8 changed files with 1030 additions and 24 deletions

View file

@ -0,0 +1,136 @@
//go:build manual
package engine
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/huin/goupnp/dcps/internetgateway2"
)
// TestUPnPLive is a manual integration test that requires a real router with UPnP/NAT-PMP.
// Run with: go test -tags manual -run TestUPnPLive -v ./internal/engine/
func TestUPnPLive(t *testing.T) {
fmt.Println("=== UPnP/NAT-PMP Live Test ===")
start := time.Now()
mapping, err := SetupUPnP(54321)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Port mapping FAILED after %s: %v", elapsed, err)
}
fmt.Printf("✅ SUCCESS in %s (protocol: %s)\n", elapsed, mapping.protocol)
fmt.Printf(" External IP: %s\n", mapping.ExternalIP)
fmt.Printf(" External Port: %d\n", mapping.ExternalPort)
fmt.Printf(" Internal Port: %d\n", mapping.InternalPort)
// Verify the port is actually mapped by listening and checking
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", mapping.InternalPort))
if err != nil {
t.Logf("⚠️ Could not listen on internal port %d: %v", mapping.InternalPort, err)
} else {
listener.Close()
fmt.Printf(" ✅ Internal port %d is available for listening\n", mapping.InternalPort)
}
// Cleanup
mapping.Remove()
fmt.Println("Port mapping removed.")
}
// TestNATPMPDirect tests NAT-PMP protocol directly against the gateway.
// Run with: go test -tags manual -run TestNATPMPDirect -v ./internal/engine/
func TestNATPMPDirect(t *testing.T) {
fmt.Println("=== NAT-PMP Direct Test ===")
gateway := defaultGateway()
if gateway == "" {
t.Fatal("Could not determine default gateway")
}
fmt.Printf("Gateway: %s\n\n", gateway)
conn, err := net.DialTimeout("udp4", gateway+":5351", 3*time.Second)
if err != nil {
t.Fatalf("Cannot connect to NAT-PMP: %v", err)
}
defer conn.Close()
// 1. External IP
fmt.Print("External IP via NAT-PMP: ")
extIP := natpmpExternalIP(conn)
if extIP == "" {
fmt.Println("(empty — router may not report it)")
} else {
fmt.Println(extIP)
}
// 2. TCP mapping
fmt.Print("TCP mapping 54321→54321: ")
extPort, lifetime, err := natpmpMapPort(conn, 2, 54321, 54321, 120)
if err != nil {
t.Fatalf("FAILED: %v", err)
}
fmt.Printf("✅ external=%d lifetime=%ds\n", extPort, lifetime)
// 3. Cleanup
fmt.Print("Deleting mapping: ")
_, _, err = natpmpMapPort(conn, 2, 54321, 0, 0)
if err != nil {
fmt.Printf("FAILED: %v\n", err)
} else {
fmt.Println("OK")
}
}
// TestUPnPSOAPDirect tests UPnP-IGD SOAP directly (for debugging routers where NAT-PMP isn't available).
// Run with: go test -tags manual -run TestUPnPSOAPDirect -v ./internal/engine/
func TestUPnPSOAPDirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
fmt.Println("=== UPnP-IGD SOAP Direct Test ===")
fmt.Println()
// Try WANIPConnection1
fmt.Print("Discovering WANIPConnection1... ")
clients, errs, err := internetgateway2.NewWANIPConnection1ClientsCtx(ctx)
if err != nil {
t.Fatalf("error: %v", err)
}
fmt.Printf("%d client(s), %d error(s)\n", len(clients), len(errs))
for _, e := range errs {
fmt.Printf(" err: %v\n", e)
}
if len(clients) == 0 {
t.Fatal("No WANIPConnection1 clients found")
}
client := clients[0]
fmt.Printf(" Device: %s\n", client.ServiceClient.RootDevice.Device.FriendlyName)
// GetExternalIPAddress
extIP, err := client.GetExternalIPAddress()
fmt.Printf(" External IP: %q (err: %v)\n", extIP, err)
// Try AddPortMapping
host := client.ServiceClient.RootDevice.URLBase.Host
localIP := localIPFor(host)
fmt.Printf(" Local IP: %s\n\n", localIP)
fmt.Print("AddPortMapping TCP 54321→54321: ")
err = client.AddPortMapping("", 54321, "TCP", 54321, localIP, true, "unarr-test", 120)
if err != nil {
fmt.Printf("FAILED: %v\n", err)
fmt.Println("\n⚠ UPnP SOAP AddPortMapping fails on this router. NAT-PMP should work as fallback.")
} else {
fmt.Println("OK")
client.DeletePortMapping("", 54321, "TCP")
fmt.Println("Mapping deleted.")
}
}