- task.go: fix deadlock in ToStatusUpdate() — calling Percent() (which RLocks) while already holding RLock caused deadlock when a writer was waiting; compute percent inline instead - usenet.go: fix data race in Cancel() — tracker and taskDir were read without the mutex while Download() writes them under it; read all fields under the same lock - upnp.go: fix UPnP Remove() blocking shutdown — run cleanup in goroutine with 10s deadline (removeNATPMP worst case is 3s dial + 5s deadline) - daemon.go: add path traversal protection for stream requests — validate sr.FilePath is within configured directories before os.Stat; defends against compromised API server sending arbitrary paths - client.go: add wakeClient without timeout for long-poll wake endpoint where context controls cancellation - sync.go: trigger immediate sync when entering watching mode so stream requests are picked up without waiting for the next scheduled interval
438 lines
13 KiB
Go
438 lines
13 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.
|
|
// It runs in a goroutine with a 5-second deadline so it never blocks shutdown.
|
|
func (m *UPnPMapping) Remove() {
|
|
if m == nil {
|
|
return
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
switch m.protocol {
|
|
case "natpmp":
|
|
m.removeNATPMP()
|
|
case "upnp":
|
|
m.removeUPnP()
|
|
}
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(10 * time.Second):
|
|
// removeNATPMP worst case: 3s dial + 5s natpmpMapPort deadline = 8s.
|
|
// 10s gives enough margin without blocking shutdown indefinitely.
|
|
log.Printf("stream: UPnP/NAT-PMP cleanup timed out after 10s — port %d may remain mapped", m.ExternalPort)
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|