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).
364 lines
8.1 KiB
Go
364 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|