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
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue