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:
Deivid Soto 2026-04-06 10:09:07 +02:00
parent 819c727bf5
commit aa6acbabc9
8 changed files with 1030 additions and 24 deletions

View file

@ -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,