UPnP/SSDP: actually serve a description.xml

The `description.xml` file is part of the UPnP Service Discovery protocol,
see https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rxad/9225d145-6b2c-40d5-8ee8-7d837379fc25
This commit is contained in:
Sybren A. Stüvel 2022-03-08 17:25:49 +01:00
parent 353da58ee9
commit 842255a065
4 changed files with 96 additions and 20 deletions

View File

@ -73,7 +73,7 @@ func main() {
// Construct the services.
persist := openDB(*configService)
flamenco := buildFlamencoAPI(configService, persist)
e := buildWebService(flamenco, persist)
e := buildWebService(flamenco, persist, ssdp)
installSignalHandler(mainCtxCancel)
@ -121,7 +121,11 @@ func buildFlamencoAPI(configService *config.Service, persist *persistence.DB) ap
return flamenco
}
func buildWebService(flamenco api.ServerInterface, persist api_impl.PersistenceService) *echo.Echo {
func buildWebService(
flamenco api.ServerInterface,
persist api_impl.PersistenceService,
ssdp *upnp_ssdp.Server,
) *echo.Echo {
e := echo.New()
e.HideBanner = true
@ -154,6 +158,13 @@ func buildWebService(flamenco api.ServerInterface, persist api_impl.PersistenceS
return c.Redirect(http.StatusTemporaryRedirect, "/api/swagger-ui/")
})
// Serve UPnP service descriptions.
if ssdp != nil {
e.GET(ssdp.DescriptionPath(), func(c echo.Context) error {
return c.XMLPretty(http.StatusOK, ssdp.Description(), " ")
})
}
// Log available routes
routeLogger := log.Level(zerolog.DebugLevel)
routeLogger.Debug().Msg("available routes:")

View File

@ -22,8 +22,11 @@ package upnp_ssdp
import (
"context"
"fmt"
"net/url"
"path"
"git.blender.org/flamenco/internal/appinfo"
"github.com/fromkeith/gossdp"
"github.com/rs/zerolog"
)
@ -60,8 +63,9 @@ func (s *Server) AddAdvertisement(serviceLocation string) {
// AddAdvertisementURLs constructs a service location from the given URLs, and
// adds the advertisement for it.
func (s *Server) AddAdvertisementURLs(urls []url.URL) {
for _, url := range urls {
func (s *Server) AddAdvertisementURLs(baseURLs []url.URL) {
for _, url := range baseURLs {
url.Path = path.Join(url.Path, serviceDescriptionPath)
s.AddAdvertisement(url.String())
}
}
@ -99,3 +103,26 @@ func (s *Server) Run(ctx context.Context) {
s.log.Info().Msg("UPnP/SSDP advertisement stopped")
}
func (s *Server) Description() Description {
return Description{
SpecVersion: SpecVersion{1, 0},
URLBase: "/",
Device: Device{
DeviceType: FlamencoServiceType,
FriendlyName: appinfo.FormattedApplicationInfo(),
ModelName: appinfo.ApplicationName,
ModelDescription: "Flamenco render farm, Manager component",
ModelURL: "https://flamenco.io/",
UDN: fmt.Sprintf("uuid:%s", FlamencoUUID),
Manufacturer: "Blender",
ManufacturerURL: "https://www.blender.org/",
ServiceList: []string{},
PresentationURL: "/",
},
}
}
func (s *Server) DescriptionPath() string {
return serviceDescriptionPath
}

View File

@ -24,4 +24,30 @@ package upnp_ssdp
const (
FlamencoUUID = "aa80bc5f-d0af-46b8-8630-23bd7e80ec4d"
FlamencoServiceType = "urn:flamenco:manager:0"
serviceDescriptionPath = "/upnp/description.xml"
)
// SSDP Service description, usually served on some URL ending in `/description.xml`.
type Description struct {
XMLName string `xml:"urn:schemas-upnp-org:device-1-0 root"`
SpecVersion SpecVersion `xml:"specVersion"`
URLBase string `xml:"URLBase"`
Device Device `xml:"device"`
}
type SpecVersion struct {
Major int `xml:"major"`
Minor int `xml:"minor"`
}
type Device struct {
DeviceType string `xml:"deviceType"`
FriendlyName string `xml:"friendlyName"`
Manufacturer string `xml:"manufacturer"`
ManufacturerURL string `xml:"manufacturerURL"`
ModelDescription string `xml:"modelDescription"`
ModelName string `xml:"modelName"`
ModelURL string `xml:"modelURL"`
UDN string `xml:"UDN"`
ServiceList []string `xml:"serviceList"` // not []string, but since the list is empty, it doesn't matter.
PresentationURL string `xml:"presentationURL"`
}

View File

@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"sync"
"time"
@ -76,14 +77,28 @@ func autodiscoverManager(ctx context.Context) (string, error) {
return usableURLs[0], nil
}
// pingManager connects to a Manager and returns true if it responds.
func pingManager(ctx context.Context, url string) bool {
logger := log.With().Str("manager", url).Logger()
// pingManager connects to a Manager and returns the service URL if it responds.
func pingManager(ctx context.Context, descriptionURL string) string {
var err error
parsedURL, err := url.Parse(descriptionURL)
if err != nil {
log.Warn().
Str("description", descriptionURL).
Err(err).
Msg("received invalid URL from autodiscovery, ignoring")
return ""
}
client, err := api.NewClientWithResponses(url)
// TODO: actually get the description XML from Flamenco Manager and use the path in there.
// For now, just assume it's on the root.
parsedURL.Path = "/"
serviceURL := parsedURL.String()
logger := log.With().Str("url", serviceURL).Logger()
client, err := api.NewClientWithResponses(serviceURL)
if err != nil {
logger.Warn().Err(err).Msg("unable to create API client with this URL")
return false
return ""
}
resp, err := client.GetVersionWithResponse(ctx)
@ -91,21 +106,21 @@ func pingManager(ctx context.Context, url string) bool {
// It is expected that some URLs will not work. Showing the error message at
// info/warn level will likely confuse people, so leave it at debug level.
logger.Debug().Err(err).Msg("unable to get Flamenco version from Manager")
return false
return ""
}
if resp.JSON200 == nil {
logger.Warn().
Int("httpStatus", resp.StatusCode()).
Msg("unable to get Flamenco version, unexpected reply")
return false
return ""
}
logger.Info().
Str("version", resp.JSON200.Version).
Str("name", resp.JSON200.Name).
Msg("found Flamenco Manager")
return true
return serviceURL
}
// pingManagers pings all URLs in parallel, returning only those that responded.
@ -116,19 +131,16 @@ func pingManagers(ctx context.Context, urls []string) []string {
wg.Add(len(urls))
mutex := new(sync.Mutex)
pingURL := func(idx int, url string) {
pingURL := func(idx int, descriptionURL string) {
defer wg.Done()
ok := pingManager(ctx, url)
serviceURL := pingManager(ctx, descriptionURL)
mutex.Lock()
defer mutex.Unlock()
if !ok {
// Erase the URL from the usable list.
// Modifying the original slice instead of appending to a new one ensures
// the original order is maintained.
urls[idx] = ""
}
// This is either the actual service URL, or an empty string if the
// description URL couldn't be reached/parsed.
urls[idx] = serviceURL
}
for idx, url := range urls {