From aa6acbabc9d523953ff4d0063e1eed642f936dc5 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Mon, 6 Apr 2026 10:09:07 +0200 Subject: [PATCH] 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). --- go.mod | 3 +- go.sum | 2 + internal/engine/stream_server.go | 17 +- internal/engine/upnp.go | 404 +++++++++++++++++++++++-- internal/engine/upnp_debug_test.go | 127 ++++++++ internal/engine/upnp_live_test.go | 136 +++++++++ internal/engine/upnp_test.go | 364 ++++++++++++++++++++++ internal/engine/watch_reporter_test.go | 1 + 8 files changed, 1030 insertions(+), 24 deletions(-) create mode 100644 internal/engine/upnp_debug_test.go create mode 100644 internal/engine/upnp_live_test.go create mode 100644 internal/engine/upnp_test.go diff --git a/go.mod b/go.mod index 8cefa35..5457304 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 653faf6..47f09d2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 33995fa..97c7787 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -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, diff --git a/internal/engine/upnp.go b/internal/engine/upnp.go index 9211bd4..9361157 100644 --- a/internal/engine/upnp.go +++ b/internal/engine/upnp.go @@ -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() +} diff --git a/internal/engine/upnp_debug_test.go b/internal/engine/upnp_debug_test.go new file mode 100644 index 0000000..5e51770 --- /dev/null +++ b/internal/engine/upnp_debug_test.go @@ -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 ===") +} diff --git a/internal/engine/upnp_live_test.go b/internal/engine/upnp_live_test.go new file mode 100644 index 0000000..3bbdac7 --- /dev/null +++ b/internal/engine/upnp_live_test.go @@ -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.") + } +} diff --git a/internal/engine/upnp_test.go b/internal/engine/upnp_test.go new file mode 100644 index 0000000..c2e9592 --- /dev/null +++ b/internal/engine/upnp_test.go @@ -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) + } +} diff --git a/internal/engine/watch_reporter_test.go b/internal/engine/watch_reporter_test.go index 80a6e78..2965914 100644 --- a/internal/engine/watch_reporter_test.go +++ b/internal/engine/watch_reporter_test.go @@ -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 {