From 61b44fe86f9706caeef70c4645734ca5675f8532 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sun, 29 Mar 2026 23:55:10 +0200 Subject: [PATCH] feat(stream): UPnP port forwarding for remote video playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add UPnP discovery and automatic port mapping (like Plex Remote Access) - Stream server binds to 0.0.0.0 and reports public IP via UPnP - Fallback chain: UPnP public IP → Tailscale IP → LAN IP - Clean up port mapping on shutdown - Bump version to 0.3.0-dev --- internal/cmd/version.go | 2 +- internal/engine/stream_server.go | 63 ++++++++++++++++++++++++++++---- internal/engine/upnp.go | 60 ++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 internal/engine/upnp.go diff --git a/internal/cmd/version.go b/internal/cmd/version.go index d630335..2b0ca15 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.2.0-dev" +var Version = "0.3.0-dev" diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 9fbc937..1635d69 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "os/exec" "path/filepath" "strings" "time" @@ -23,10 +24,11 @@ type fileProvider interface { // StreamServer serves a torrent file over HTTP with Range request support. type StreamServer struct { - provider fileProvider - server *http.Server - port int - url string + provider fileProvider + server *http.Server + port int + url string + upnpMapping *UPnPMapping } // NewStreamServer creates a new HTTP server for streaming via StreamEngine. @@ -91,12 +93,15 @@ func NewStreamServerFromDisk(filePath string, port int) *StreamServer { } } -// Start begins serving the file on localhost. Returns the full URL. +// Start begins serving the file on all interfaces. Returns the best reachable URL: +// 1. UPnP public IP (accessible from anywhere on the internet) +// 2. Tailscale IP (accessible from any device in the tailnet) +// 3. LAN IP (accessible from local network) func (ss *StreamServer) Start(ctx context.Context) (string, error) { mux := http.NewServeMux() mux.HandleFunc("/stream", ss.handler) - addr := fmt.Sprintf("127.0.0.1:%d", ss.port) + addr := fmt.Sprintf("0.0.0.0:%d", ss.port) listener, err := net.Listen("tcp", addr) if err != nil { return "", fmt.Errorf("listen on %s: %w", addr, err) @@ -104,7 +109,17 @@ func (ss *StreamServer) Start(ctx context.Context) (string, error) { // Extract actual port (important when port=0) ss.port = listener.Addr().(*net.TCPAddr).Port - ss.url = fmt.Sprintf("http://127.0.0.1:%d/stream", ss.port) + + // Try UPnP for public internet access (like Plex Remote Access) + if mapping, upnpErr := setupUPnP(ss.port); upnpErr == nil { + ss.upnpMapping = mapping + ss.url = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort) + log.Printf("stream: UPnP mapped %s:%d -> local:%d", mapping.ExternalIP, mapping.ExternalPort, ss.port) + } else { + // Fallback: Tailscale IP > LAN IP > 127.0.0.1 + ss.url = fmt.Sprintf("http://%s:%d/stream", reachableIP(), ss.port) + log.Printf("stream: UPnP unavailable (%v), using %s", upnpErr, ss.url) + } ss.server = &http.Server{ Handler: mux, @@ -126,8 +141,9 @@ func (ss *StreamServer) URL() string { return ss.url } // Port returns the bound port. func (ss *StreamServer) Port() int { return ss.port } -// Shutdown gracefully stops the HTTP server. +// Shutdown gracefully stops the HTTP server and removes the UPnP port mapping. func (ss *StreamServer) Shutdown(ctx context.Context) error { + ss.upnpMapping.Remove() if ss.server != nil { return ss.server.Shutdown(ctx) } @@ -160,6 +176,37 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, ss.provider.FileName(), time.Time{}, reader) } +// reachableIP returns the best IP to use for the stream URL, in priority order: +// 1. Tailscale IP (100.x.x.x) — accessible from anywhere via Tailscale mesh +// 2. LAN IP — accessible from local network +// 3. 127.0.0.1 — fallback (same machine only) +func reachableIP() string { + // 1. Try Tailscale — gives an IP reachable from any device in the tailnet + if ip := tailscaleIP(); ip != "" { + return ip + } + // 2. Fall back to LAN IP + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "127.0.0.1" + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).IP.String() +} + +// tailscaleIP returns the Tailscale IPv4 address, or "" if Tailscale isn't running. +func tailscaleIP() string { + out, err := exec.Command("tailscale", "ip", "-4").Output() + if err != nil { + return "" + } + ip := strings.TrimSpace(string(out)) + if net.ParseIP(ip) == nil { + return "" + } + return ip +} + func mimeTypeFromExt(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) switch ext { diff --git a/internal/engine/upnp.go b/internal/engine/upnp.go new file mode 100644 index 0000000..c321338 --- /dev/null +++ b/internal/engine/upnp.go @@ -0,0 +1,60 @@ +package engine + +import ( + "fmt" + "log" + "time" + + alog "github.com/anacrolix/log" + "github.com/anacrolix/upnp" +) + +// UPnPMapping represents an active port mapping on the router. +type UPnPMapping struct { + ExternalIP string + ExternalPort int + InternalPort int + device upnp.Device +} + +// setupUPnP discovers the gateway, maps the port, and gets the public IP. +// Returns nil if UPnP is not available or fails. +func setupUPnP(internalPort int) (*UPnPMapping, error) { + devices := upnp.Discover(0, 5*time.Second, alog.Logger{}) + if len(devices) == 0 { + return nil, fmt.Errorf("no UPnP devices found") + } + + device := devices[0] + + // Get public IP + externalIP, err := device.GetExternalIPAddress() + if err != nil { + return nil, fmt.Errorf("get external IP: %w", err) + } + + // Map port (0 = let router choose external port, 2h lease) + mappedPort, err := device.AddPortMapping(upnp.TCP, internalPort, internalPort, "unarr stream", 2*time.Hour) + if err != nil { + return nil, fmt.Errorf("add port mapping: %w", err) + } + + return &UPnPMapping{ + ExternalIP: externalIP.String(), + ExternalPort: mappedPort, + InternalPort: internalPort, + device: device, + }, nil +} + +// Remove deletes the port mapping from the router. +func (m *UPnPMapping) Remove() { + if m == nil || m.device == nil { + return + } + if err := m.device.DeletePortMapping(upnp.TCP, m.ExternalPort); err != nil { + log.Printf("stream: failed to remove UPnP mapping: %v", err) + } else { + log.Printf("stream: removed UPnP mapping for port %d", m.ExternalPort) + } +}