From 2e80eb85d2e9e1d9f7b7850b5ad1a00a7d4e4f59 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 8 Jun 2025 17:41:52 +0200 Subject: [PATCH] Changes to be committed: modified: cmd/pmomusic/main.go new file: internal/netutils/ip_detect.go new file: internal/netutils/list_all_ip.go modified: internal/soap/soap.go modified: internal/ssdp/responder.go modified: internal/ssdp/ssdp.go modified: internal/upnp/http.go new file: internal/upnp/server.go new file: internal/upnp/xml/AVTransport.xml new file: internal/upnp/xml/ConnectionManager.xml new file: internal/upnp/xml/RenderingControl.xml --- cmd/pmomusic/main.go | 10 ++- internal/netutils/ip_detect.go | 92 +++++++++++++++++++++++++ internal/netutils/list_all_ip.go | 49 +++++++++++++ internal/soap/soap.go | 61 ++++++++++++++-- internal/ssdp/responder.go | 16 +++-- internal/ssdp/ssdp.go | 10 --- internal/upnp/http.go | 22 ++---- internal/upnp/server.go | 45 ++++++++++++ internal/upnp/xml/AVTransport.xml | 81 ++++++++++++++++++++++ internal/upnp/xml/ConnectionManager.xml | 31 +++++++++ internal/upnp/xml/RenderingControl.xml | 43 ++++++++++++ 11 files changed, 420 insertions(+), 40 deletions(-) create mode 100644 internal/netutils/ip_detect.go create mode 100644 internal/netutils/list_all_ip.go create mode 100644 internal/upnp/server.go create mode 100644 internal/upnp/xml/AVTransport.xml create mode 100644 internal/upnp/xml/ConnectionManager.xml create mode 100644 internal/upnp/xml/RenderingControl.xml diff --git a/cmd/pmomusic/main.go b/cmd/pmomusic/main.go index 7cfe12a6..c1e1e805 100644 --- a/cmd/pmomusic/main.go +++ b/cmd/pmomusic/main.go @@ -3,12 +3,18 @@ package main import ( "log" + "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/netutils" "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/ssdp" "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/upnp" ) func main() { - localIP := ssdp.GetLocalIP() + localIP, err := netutils.GuessLocalIP() + + if err != nil { + log.Fatalf("Could not guess local IP: %v", err) + } + location := "http://" + localIP + ":1400/description.xml" usn := "uuid:pmomusic-renderer-001" @@ -18,5 +24,5 @@ func main() { // Lance le serveur HTTP log.Println("Starting HTTP server at:", location) - upnp.StartHTTPServer(usn) + upnp.StartHTTPServer(usn, localIP, 1400) } diff --git a/internal/netutils/ip_detect.go b/internal/netutils/ip_detect.go new file mode 100644 index 00000000..7c89067e --- /dev/null +++ b/internal/netutils/ip_detect.go @@ -0,0 +1,92 @@ +package netutils + +import ( + "errors" + "net" + "sort" + "strings" +) + +// GuessLocalIP returns the best-guess local IP address usable for UPnP location, +// in order of preference: eth0 > en* > wl* > any private, non-loopback IP. +func GuessLocalIP() (string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return "", err + } + + type scoredIP struct { + ip net.IP + score int + } + + var candidates []scoredIP + + for _, iface := range ifaces { + // Skip interfaces that are down or loopback + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, addr := range addrs { + var ip net.IP + + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + + // Skip non-IPv4 or non-private addresses + if ip == nil || ip.IsLoopback() || ip.To4() == nil || !isPrivateIPv4(ip) { + continue + } + + score := scoreInterfaceName(iface.Name) + candidates = append(candidates, scoredIP{ip: ip, score: score}) + } + } + + if len(candidates) == 0 { + return "", errors.New("no suitable local IP found") + } + + // Prefer interfaces with higher score + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].score > candidates[j].score + }) + + return candidates[0].ip.String(), nil +} + +func isPrivateIPv4(ip net.IP) bool { + private := []string{ + "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.2", "192.168.", + } + ipStr := ip.String() + for _, p := range private { + if strings.HasPrefix(ipStr, p) { + return true + } + } + return false +} + +func scoreInterfaceName(name string) int { + switch { + case name == "eth0": + return 100 + case strings.HasPrefix(name, "en"): + return 80 + case name == "wlan0" || strings.HasPrefix(name, "wl"): + return 60 + default: + return 10 + } +} diff --git a/internal/netutils/list_all_ip.go b/internal/netutils/list_all_ip.go new file mode 100644 index 00000000..19240745 --- /dev/null +++ b/internal/netutils/list_all_ip.go @@ -0,0 +1,49 @@ +package netutils + +import ( + "net" +) + +// ListAllIPs returns a map of interface names to their associated IPv4 addresses. +func ListAllIPs() map[string][]string { + result := make(map[string][]string) + + ifaces, err := net.Interfaces() + if err != nil { + result["error"] = []string{err.Error()} + return result + } + + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 { + continue // Ignore down interfaces + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + var ips []string + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + + if ip == nil || ip.To4() == nil || ip.IsLoopback() { + continue + } + ips = append(ips, ip.String()) + } + + if len(ips) > 0 { + result[iface.Name] = ips + } + } + + return result +} diff --git a/internal/soap/soap.go b/internal/soap/soap.go index d7f2d016..ca536cd1 100644 --- a/internal/soap/soap.go +++ b/internal/soap/soap.go @@ -1,18 +1,69 @@ package soap import ( + "bytes" + "encoding/xml" "io" "log" "net/http" ) func HandleSOAP(w http.ResponseWriter, r *http.Request) { - action := r.Header.Get("SOAPACTION") - body, _ := io.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) + if err != nil { + log.Println("Failed to read SOAP request:", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + defer r.Body.Close() - log.Println("----------") - log.Println("SOAPAction:", action) - log.Println("Body:\n", string(body)) + log.Println("Received SOAP request:") + log.Println(string(body)) + // Extract action name from the SOAP body + action := extractSOAPAction(body) + log.Printf("SOAP Action: %s\n", action) + + // Send dummy response + w.Header().Set("Content-Type", "text/xml; charset=\"utf-8\"") w.WriteHeader(http.StatusOK) + w.Write([]byte(dummySOAPResponse(action))) +} + +func extractSOAPAction(body []byte) string { + type Envelope struct { + Body struct { + XMLName xml.Name + } `xml:"Body"` + } + + var env Envelope + if err := xml.Unmarshal(body, &env); err != nil { + log.Println("SOAP parse error:", err) + return "UnknownAction" + } + + decoder := xml.NewDecoder(bytes.NewReader(body)) + for { + tok, err := decoder.Token() + if err != nil { + break + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local != "Envelope" && se.Name.Local != "Body" { + return se.Name.Local + } + } + return "UnknownAction" +} + +func dummySOAPResponse(action string) string { + // You can specialize per action if needed. + return ` + + + + + +` } diff --git a/internal/ssdp/responder.go b/internal/ssdp/responder.go index 2dece7ec..19064f48 100644 --- a/internal/ssdp/responder.go +++ b/internal/ssdp/responder.go @@ -43,14 +43,19 @@ func StartSSDPResponder(usn, location string) { st := extractHeader(data, "ST") log.Printf("🔎 M-SEARCH from %s, ST=%s\n", src.String(), st) - if st == "ssdp:all" || st == serviceType { - go sendSSDPResponse(src, usn, location) + if st == "ssdp:all" || + st == serviceType || + strings.HasPrefix(st, "urn:schemas-upnp-org:service:") || + strings.HasPrefix(st, "urn:av-openhome-org:service:") || + strings.HasPrefix(st, "urn:bubblesoftapps-com:service:") { + + go sendSSDPResponse(src, usn, location, st) } } } } -func sendSSDPResponse(dst *net.UDPAddr, usn, location string) { +func sendSSDPResponse(dst *net.UDPAddr, usn, location, st string) { resp := fmt.Sprintf( "HTTP/1.1 200 OK\r\n"+ "CACHE-CONTROL: max-age=1800\r\n"+ @@ -63,11 +68,12 @@ func sendSSDPResponse(dst *net.UDPAddr, usn, location string) { "\r\n", time.Now().Format(time.RFC1123), location, - serviceType, + st, usn, - serviceType, + st, ) + // envoie UDP conn, err := net.DialUDP("udp4", nil, dst) if err != nil { log.Println("Failed to dial UDP to respond:", err) diff --git a/internal/ssdp/ssdp.go b/internal/ssdp/ssdp.go index 4d340135..2f5149f6 100644 --- a/internal/ssdp/ssdp.go +++ b/internal/ssdp/ssdp.go @@ -2,7 +2,6 @@ package ssdp import ( "log" - "net" "time" "github.com/koron/go-ssdp" @@ -32,12 +31,3 @@ func AnnounceRenderer(usn, location string) { // Un jour on voudra faire ça à l'arrêt : // adv.Close() // envoie le ssdp:byebye } -func GetLocalIP() string { - conn, err := net.Dial("udp", "239.255.255.250:1900") - if err != nil { - return "127.0.0.1" - } - defer conn.Close() - localAddr := conn.LocalAddr().(*net.UDPAddr) - return localAddr.IP.String() -} diff --git a/internal/upnp/http.go b/internal/upnp/http.go index 58a9c859..bb162595 100644 --- a/internal/upnp/http.go +++ b/internal/upnp/http.go @@ -2,30 +2,16 @@ package upnp import ( "fmt" - "log" - "net/http" - - "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/soap" ) -func StartHTTPServer(usn string) { - http.HandleFunc("/description.xml", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/xml") - w.Write([]byte(generateDeviceDescription(usn))) - }) - - http.HandleFunc("/upnp/control/AVTransport", soap.HandleSOAP) - - log.Fatal(http.ListenAndServe(":1400", nil)) -} - -func generateDeviceDescription(usn string) string { +func generateDeviceDescription(usn, ip string, port uint) string { return fmt.Sprintf(` 1 0 + http://%s:%d/ urn:schemas-upnp-org:device:MediaRenderer:1 pmomusic Fake Renderer @@ -36,7 +22,7 @@ func generateDeviceDescription(usn string) string { 1.0 http://example.com/model %s - http://%s + http://%s:%d urn:schemas-upnp-org:service:AVTransport:1 @@ -61,5 +47,5 @@ func generateDeviceDescription(usn string) string { -`, usn, "127.0.0.1:1400") // à adapter à ton IP:port réel +`, ip, port, usn, ip, port) } diff --git a/internal/upnp/server.go b/internal/upnp/server.go new file mode 100644 index 00000000..b69ef18c --- /dev/null +++ b/internal/upnp/server.go @@ -0,0 +1,45 @@ +package upnp + +import ( + "embed" + "fmt" + "io/fs" + "log" + "net/http" + + "gargoton.petite-maison-orange.fr/eric/pmomusic/internal/soap" +) + +//go:embed xml/*.xml +var embeddedXML embed.FS + +// ServeStaticXML mounts handlers for SCPD XML files. +func ServeStaticXML(mux *http.ServeMux) { + subFS, err := fs.Sub(embeddedXML, "xml") + if err != nil { + panic("failed to create sub FS: " + err.Error()) + } + mux.Handle("/scpd/", http.StripPrefix("/scpd/", http.FileServer(http.FS(subFS)))) +} + +func StartHTTPServer(usn, ip string, port uint) { + mux := http.NewServeMux() + + // Device description + mux.HandleFunc("/description.xml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.Write([]byte(generateDeviceDescription(usn, ip, port))) + }) + + // SOAP control endpoints + mux.HandleFunc("/upnp/control/AVTransport", soap.HandleSOAP) + mux.HandleFunc("/upnp/control/RenderingControl", soap.HandleSOAP) + mux.HandleFunc("/upnp/control/ConnectionManager", soap.HandleSOAP) + + // Serve static SCPD XML files + ServeStaticXML(mux) + + addr := fmt.Sprintf("%s:%d", ip, port) + log.Printf("Serving UPnP fake renderer at http://%s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} diff --git a/internal/upnp/xml/AVTransport.xml b/internal/upnp/xml/AVTransport.xml new file mode 100644 index 00000000..725d8d31 --- /dev/null +++ b/internal/upnp/xml/AVTransport.xml @@ -0,0 +1,81 @@ + + + + 1 + 0 + + + + SetAVTransportURI + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + CurrentURI + in + AVTransportURI + + + CurrentURIMetaData + in + AVTransportURIMetaData + + + + + + Play + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + Speed + in + TransportPlaySpeed + + + + + + Stop + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + + + + + + A_ARG_TYPE_InstanceID + ui4 + + + + AVTransportURI + string + + + + AVTransportURIMetaData + string + + + + TransportPlaySpeed + string + + 1 + + + + diff --git a/internal/upnp/xml/ConnectionManager.xml b/internal/upnp/xml/ConnectionManager.xml new file mode 100644 index 00000000..d12f286e --- /dev/null +++ b/internal/upnp/xml/ConnectionManager.xml @@ -0,0 +1,31 @@ + + + 10 + + + GetProtocolInfo + + + Source + out + SourceProtocolInfo + + + Sink + out + SinkProtocolInfo + + + + + + + SourceProtocolInfo + string + + + SinkProtocolInfo + string + + + diff --git a/internal/upnp/xml/RenderingControl.xml b/internal/upnp/xml/RenderingControl.xml new file mode 100644 index 00000000..91fe5067 --- /dev/null +++ b/internal/upnp/xml/RenderingControl.xml @@ -0,0 +1,43 @@ + + + 10 + + + SetVolume + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + Channel + in + A_ARG_TYPE_Channel + + + DesiredVolume + in + Volume + + + + + + + A_ARG_TYPE_InstanceID + ui4 + + + A_ARG_TYPE_Channel + string + + Master + + + + Volume + ui2 + + +