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

3
go.mod
View file

@ -7,12 +7,12 @@ require (
github.com/anacrolix/dht/v2 v2.23.0
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb
github.com/anacrolix/torrent v1.61.0
github.com/anacrolix/upnp v0.1.4
github.com/charmbracelet/huh v1.0.0
github.com/fatih/color v1.19.0
github.com/getsentry/sentry-go v0.44.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/huin/goupnp v1.3.0
github.com/olekukonko/tablewriter v1.1.4
github.com/spf13/cobra v1.10.2
github.com/torrentclaw/go-client v0.2.0
@ -35,6 +35,7 @@ require (
github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.6.0 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.2.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect

2
go.sum
View file

@ -260,6 +260,8 @@ github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=

View file

@ -32,6 +32,7 @@ type StreamServer struct {
port int
url string
upnpMapping *UPnPMapping
disableUPnP bool // for testing
lastActivity atomic.Int64 // UnixNano of last HTTP request
maxByteOffset atomic.Int64 // highest byte offset served (for watch progress estimation)
totalFileSize int64 // total file size in bytes (set on Start)
@ -154,8 +155,20 @@ func (ss *StreamServer) Start(ctx context.Context) (string, error) {
}
ss.port = listener.Addr().(*net.TCPAddr).Port
ss.url = fmt.Sprintf("http://%s:%d/stream", reachableIP(), ss.port)
log.Printf("stream: serving on %s", ss.url)
// Try UPnP/NAT-PMP for public internet access (remote downloads)
if !ss.disableUPnP {
if mapping, err := SetupUPnP(ss.port); err == nil {
ss.upnpMapping = mapping
ss.url = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
log.Printf("stream: UPnP success — public URL: %s", ss.url)
} else {
log.Printf("stream: UPnP unavailable (%v), falling back to LAN", err)
ss.url = fmt.Sprintf("http://%s:%d/stream", reachableIP(), ss.port)
}
} else {
ss.url = fmt.Sprintf("http://%s:%d/stream", reachableIP(), ss.port)
}
ss.server = &http.Server{
Handler: mux,

View file

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

View file

@ -0,0 +1,127 @@
//go:build manual
package engine
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/huin/goupnp"
"github.com/huin/goupnp/dcps/internetgateway2"
)
// TestUPnPDebug performs detailed UPnP discovery diagnostics.
// Run with: go test -tags manual -run TestUPnPDebug -v ./internal/engine/
func TestUPnPDebug(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
fmt.Println("=== UPnP Debug Diagnostics ===")
fmt.Println()
// 1. Check network interfaces
fmt.Println("--- Network Interfaces ---")
ifaces, _ := net.Interfaces()
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, _ := iface.Addrs()
for _, addr := range addrs {
fmt.Printf(" %s: %s (flags: %s)\n", iface.Name, addr, iface.Flags)
}
}
fmt.Println()
// 2. Raw SSDP discovery — search for ALL UPnP root devices
fmt.Println("--- Raw SSDP Discovery (all root devices) ---")
devices, err := goupnp.DiscoverDevicesCtx(ctx, "upnp:rootdevice")
if err != nil {
fmt.Printf(" Error: %v\n", err)
} else {
fmt.Printf(" Found %d root device(s)\n", len(devices))
for i, dev := range devices {
if dev.Err != nil {
fmt.Printf(" [%d] Error: %v\n", i, dev.Err)
continue
}
rd := dev.Root
fmt.Printf(" [%d] %s — %s (%s)\n", i, rd.Device.FriendlyName, rd.Device.DeviceType, rd.URLBase.String())
// List services
for _, svc := range rd.Device.Services {
fmt.Printf(" Service: %s\n", svc.ServiceType)
}
// List sub-devices
for _, sub := range rd.Device.Devices {
fmt.Printf(" SubDevice: %s — %s\n", sub.FriendlyName, sub.DeviceType)
for _, svc := range sub.Services {
fmt.Printf(" Service: %s\n", svc.ServiceType)
}
for _, sub2 := range sub.Devices {
fmt.Printf(" SubDevice: %s — %s\n", sub2.FriendlyName, sub2.DeviceType)
for _, svc := range sub2.Services {
fmt.Printf(" Service: %s\n", svc.ServiceType)
}
}
}
}
}
fmt.Println()
// 3. Try specific IGD service types
fmt.Println("--- IGD Service Discovery ---")
fmt.Print(" WANIPConnection2: ")
c2, errs2, err2 := internetgateway2.NewWANIPConnection2ClientsCtx(ctx)
if err2 != nil {
fmt.Printf("error: %v\n", err2)
} else {
fmt.Printf("%d client(s), %d error(s)\n", len(c2), len(errs2))
for _, e := range errs2 {
fmt.Printf(" err: %v\n", e)
}
for _, c := range c2 {
ip, err := c.GetExternalIPAddress()
fmt.Printf(" device=%s external_ip=%s err=%v\n",
c.ServiceClient.RootDevice.Device.FriendlyName, ip, err)
}
}
fmt.Print(" WANIPConnection1: ")
c1, errs1, err1 := internetgateway2.NewWANIPConnection1ClientsCtx(ctx)
if err1 != nil {
fmt.Printf("error: %v\n", err1)
} else {
fmt.Printf("%d client(s), %d error(s)\n", len(c1), len(errs1))
for _, e := range errs1 {
fmt.Printf(" err: %v\n", e)
}
for _, c := range c1 {
ip, err := c.GetExternalIPAddress()
fmt.Printf(" device=%s external_ip=%s err=%v\n",
c.ServiceClient.RootDevice.Device.FriendlyName, ip, err)
}
}
fmt.Print(" WANPPPConnection1: ")
cp, errsp, errp := internetgateway2.NewWANPPPConnection1ClientsCtx(ctx)
if errp != nil {
fmt.Printf("error: %v\n", errp)
} else {
fmt.Printf("%d client(s), %d error(s)\n", len(cp), len(errsp))
for _, e := range errsp {
fmt.Printf(" err: %v\n", e)
}
for _, c := range cp {
ip, err := c.GetExternalIPAddress()
fmt.Printf(" device=%s external_ip=%s err=%v\n",
c.ServiceClient.RootDevice.Device.FriendlyName, ip, err)
}
}
fmt.Println()
fmt.Println("=== Done ===")
}

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.")
}
}

View file

@ -0,0 +1,364 @@
package engine
import (
"encoding/binary"
"net"
"sync"
"testing"
"time"
)
// --- Mock NAT-PMP server ---
type mockNATPMPServer struct {
conn net.PacketConn
addr string
mu sync.Mutex
mappings map[uint16]natpmpMapping // internalPort → mapping
extIP net.IP
epoch uint32
closed chan struct{}
}
type natpmpMapping struct {
extPort uint16
protocol byte // 1=UDP, 2=TCP
lifetime uint32
}
func newMockNATPMP(extIP string) *mockNATPMPServer {
conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
panic(err)
}
s := &mockNATPMPServer{
conn: conn,
addr: conn.LocalAddr().String(),
mappings: make(map[uint16]natpmpMapping),
extIP: net.ParseIP(extIP).To4(),
epoch: 1000,
closed: make(chan struct{}),
}
go s.serve()
return s
}
func (s *mockNATPMPServer) Close() {
s.conn.Close()
<-s.closed
}
func (s *mockNATPMPServer) serve() {
defer close(s.closed)
buf := make([]byte, 64)
for {
n, addr, err := s.conn.ReadFrom(buf)
if err != nil {
return
}
if n < 2 {
continue
}
opcode := buf[1]
var resp []byte
switch opcode {
case 0: // External address request
resp = s.handleExternalAddress()
case 1, 2: // UDP/TCP mapping
if n >= 12 {
resp = s.handleMapping(buf[:n])
}
}
if resp != nil {
s.conn.WriteTo(resp, addr)
}
}
}
func (s *mockNATPMPServer) handleExternalAddress() []byte {
resp := make([]byte, 12)
resp[0] = 0 // version
resp[1] = 128 // opcode 0 + 128
// result code 0 = success
binary.BigEndian.PutUint32(resp[4:8], s.epoch)
copy(resp[8:12], s.extIP)
return resp
}
func (s *mockNATPMPServer) handleMapping(req []byte) []byte {
s.mu.Lock()
defer s.mu.Unlock()
opcode := req[1]
intPort := binary.BigEndian.Uint16(req[4:6])
sugExtPort := binary.BigEndian.Uint16(req[6:8])
lifetime := binary.BigEndian.Uint32(req[8:12])
resp := make([]byte, 16)
resp[0] = 0
resp[1] = 128 + opcode
binary.BigEndian.PutUint32(resp[4:8], s.epoch)
if lifetime == 0 {
// Delete mapping
delete(s.mappings, intPort)
binary.BigEndian.PutUint16(resp[8:10], intPort)
binary.BigEndian.PutUint16(resp[10:12], 0)
binary.BigEndian.PutUint32(resp[12:16], 0)
} else {
// Create mapping
extPort := sugExtPort
if extPort == 0 {
extPort = intPort
}
s.mappings[intPort] = natpmpMapping{
extPort: extPort,
protocol: opcode,
lifetime: lifetime,
}
binary.BigEndian.PutUint16(resp[8:10], intPort)
binary.BigEndian.PutUint16(resp[10:12], extPort)
binary.BigEndian.PutUint32(resp[12:16], lifetime)
}
return resp
}
// --- Mock UPnP client ---
type mockUPnPClient struct {
externalIP string
externalErr error
addErr error
deleteErr error
lastMapping *mockPortMapping
}
type mockPortMapping struct {
remoteHost string
extPort uint16
protocol string
intPort uint16
intClient string
enabled bool
description string
lease uint32
}
func (m *mockUPnPClient) GetExternalIPAddress() (string, error) {
return m.externalIP, m.externalErr
}
func (m *mockUPnPClient) AddPortMapping(remoteHost string, extPort uint16, proto string, intPort uint16, intClient string, enabled bool, desc string, lease uint32) error {
if m.addErr != nil {
return m.addErr
}
m.lastMapping = &mockPortMapping{
remoteHost: remoteHost,
extPort: extPort,
protocol: proto,
intPort: intPort,
intClient: intClient,
enabled: enabled,
description: desc,
lease: lease,
}
return nil
}
func (m *mockUPnPClient) DeletePortMapping(remoteHost string, extPort uint16, proto string) error {
return m.deleteErr
}
// --- Tests ---
func TestNATPMPMapAndDelete(t *testing.T) {
srv := newMockNATPMP("203.0.113.42")
defer srv.Close()
conn, err := net.DialTimeout("udp4", srv.addr, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
// Map port
extPort, lifetime, err := natpmpMapPort(conn, 2, 8080, 8080, 3600)
if err != nil {
t.Fatalf("map: %v", err)
}
if extPort != 8080 {
t.Errorf("expected external port 8080, got %d", extPort)
}
if lifetime != 3600 {
t.Errorf("expected lifetime 3600, got %d", lifetime)
}
// Verify mapping stored
srv.mu.Lock()
m, ok := srv.mappings[8080]
srv.mu.Unlock()
if !ok {
t.Fatal("mapping not stored in server")
}
if m.protocol != 2 {
t.Errorf("expected TCP (2), got %d", m.protocol)
}
// Delete
_, _, err = natpmpMapPort(conn, 2, 8080, 0, 0)
if err != nil {
t.Fatalf("delete: %v", err)
}
srv.mu.Lock()
_, ok = srv.mappings[8080]
srv.mu.Unlock()
if ok {
t.Error("mapping should have been deleted")
}
}
func TestNATPMPExternalIP(t *testing.T) {
srv := newMockNATPMP("93.184.216.34")
defer srv.Close()
conn, err := net.DialTimeout("udp4", srv.addr, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
ip := natpmpExternalIP(conn)
if ip != "93.184.216.34" {
t.Errorf("expected 93.184.216.34, got %q", ip)
}
}
func TestNATPMPExternalIPUnspecified(t *testing.T) {
srv := newMockNATPMP("0.0.0.0")
defer srv.Close()
conn, err := net.DialTimeout("udp4", srv.addr, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
ip := natpmpExternalIP(conn)
if ip != "" {
t.Errorf("expected empty for 0.0.0.0, got %q", ip)
}
}
func TestUPnPSetupMappingSuccess(t *testing.T) {
mock := &mockUPnPClient{externalIP: "198.51.100.1"}
mapping, err := setupMapping("192.168.1.1:1900", mock, 9000)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if mapping.ExternalIP != "198.51.100.1" {
t.Errorf("expected external IP 198.51.100.1, got %s", mapping.ExternalIP)
}
if mapping.ExternalPort != 9000 {
t.Errorf("expected port 9000, got %d", mapping.ExternalPort)
}
if mapping.protocol != "upnp" {
t.Errorf("expected protocol upnp, got %s", mapping.protocol)
}
if mock.lastMapping == nil {
t.Fatal("AddPortMapping not called")
}
if mock.lastMapping.protocol != "TCP" {
t.Errorf("expected TCP, got %s", mock.lastMapping.protocol)
}
if !mock.lastMapping.enabled {
t.Error("expected enabled=true")
}
}
func TestUPnPSetupMappingAddFails(t *testing.T) {
mock := &mockUPnPClient{
externalIP: "198.51.100.1",
addErr: net.ErrClosed,
}
_, err := setupMapping("192.168.1.1:1900", mock, 9000)
if err == nil {
t.Fatal("expected error from AddPortMapping")
}
}
func TestUPnPSetupMappingEmptyIP(t *testing.T) {
// When router returns empty IP and public IP fallback also fails
mock := &mockUPnPClient{externalIP: ""}
// setupMapping calls publicIPFallback() which requires internet.
// In unit tests, this may or may not work. We just verify it doesn't panic.
mapping, err := setupMapping("192.168.1.1:1900", mock, 9000)
if err != nil {
// Expected if no internet / public IP fallback fails
t.Logf("expected failure with empty IP: %v", err)
return
}
// If it succeeded (has internet), verify the mapping is valid
if mapping.ExternalIP == "" {
t.Error("mapping should have a non-empty external IP")
}
}
func TestUPnPMappingRemoveNATPMP(t *testing.T) {
// Remove() connects to gateway:5351 (standard NAT-PMP port).
// We can't redirect to a mock easily, but verify it doesn't panic
// even when the gateway is unreachable.
mapping := &UPnPMapping{
ExternalIP: "203.0.113.42",
ExternalPort: 8080,
InternalPort: 8080,
gateway: "192.0.2.1", // RFC 5737 TEST-NET — unreachable
protocol: "natpmp",
}
mapping.Remove() // should not panic, just log the error
}
func TestUPnPMappingRemoveUPnP(t *testing.T) {
mock := &mockUPnPClient{}
mapping := &UPnPMapping{
ExternalPort: 9000,
protocol: "upnp",
client: mock,
}
// Should not panic
mapping.Remove()
}
func TestUPnPMappingRemoveNil(t *testing.T) {
var m *UPnPMapping
m.Remove() // should not panic
}
func TestDefaultGateway(t *testing.T) {
gw := defaultGateway()
if gw == "" {
t.Skip("no network connectivity")
}
ip := net.ParseIP(gw)
if ip == nil {
t.Errorf("defaultGateway returned invalid IP: %q", gw)
}
}
func TestLocalIPFor(t *testing.T) {
ip := localIPFor("192.168.0.1:1900")
if ip == "0.0.0.0" {
t.Skip("no route to 192.168.0.1")
}
parsed := net.ParseIP(ip)
if parsed == nil {
t.Errorf("localIPFor returned invalid IP: %q", ip)
}
}

View file

@ -104,6 +104,7 @@ func TestStreamServerRangeTracking(t *testing.T) {
}
srv := NewStreamServerFromDisk(tmpFile, 0)
srv.disableUPnP = true
ctx := context.Background()
url, err := srv.Start(ctx)
if err != nil {