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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
127
internal/engine/upnp_debug_test.go
Normal file
127
internal/engine/upnp_debug_test.go
Normal 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 ===")
|
||||
}
|
||||
136
internal/engine/upnp_live_test.go
Normal file
136
internal/engine/upnp_live_test.go
Normal 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.")
|
||||
}
|
||||
}
|
||||
364
internal/engine/upnp_test.go
Normal file
364
internal/engine/upnp_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue