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).
426 lines
12 KiB
Go
426 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/huin/goupnp/dcps/internetgateway2"
|
|
)
|
|
|
|
// UPnPMapping represents an active port mapping on the router.
|
|
type UPnPMapping struct {
|
|
ExternalIP string
|
|
ExternalPort int
|
|
InternalPort int
|
|
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.
|
|
// Tries NAT-PMP first (faster, more compatible), falls back to UPnP-IGD SOAP.
|
|
func SetupUPnP(internalPort int) (*UPnPMapping, error) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Fall back to UPnP-IGD SOAP
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
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)
|
|
}
|
|
if externalIP == "" {
|
|
externalIP = publicIPFallback()
|
|
}
|
|
if externalIP == "" {
|
|
return nil, fmt.Errorf("could not determine external IP")
|
|
}
|
|
|
|
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 -> %s:%d (2h lease)", externalIP, internalPort, localIP, internalPort)
|
|
return &UPnPMapping{
|
|
ExternalIP: externalIP,
|
|
ExternalPort: internalPort,
|
|
InternalPort: internalPort,
|
|
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 {
|
|
return
|
|
}
|
|
|
|
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()
|
|
}
|