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. // Construct the services.
persist := openDB(*configService) persist := openDB(*configService)
flamenco := buildFlamencoAPI(configService, persist) flamenco := buildFlamencoAPI(configService, persist)
e := buildWebService(flamenco, persist) e := buildWebService(flamenco, persist, ssdp)
installSignalHandler(mainCtxCancel) installSignalHandler(mainCtxCancel)
@ -121,7 +121,11 @@ func buildFlamencoAPI(configService *config.Service, persist *persistence.DB) ap
return flamenco 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 := echo.New()
e.HideBanner = true e.HideBanner = true
@ -154,6 +158,13 @@ func buildWebService(flamenco api.ServerInterface, persist api_impl.PersistenceS
return c.Redirect(http.StatusTemporaryRedirect, "/api/swagger-ui/") 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 // Log available routes
routeLogger := log.Level(zerolog.DebugLevel) routeLogger := log.Level(zerolog.DebugLevel)
routeLogger.Debug().Msg("available routes:") routeLogger.Debug().Msg("available routes:")

View File

@ -22,8 +22,11 @@ package upnp_ssdp
import ( import (
"context" "context"
"fmt"
"net/url" "net/url"
"path"
"git.blender.org/flamenco/internal/appinfo"
"github.com/fromkeith/gossdp" "github.com/fromkeith/gossdp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -60,8 +63,9 @@ func (s *Server) AddAdvertisement(serviceLocation string) {
// AddAdvertisementURLs constructs a service location from the given URLs, and // AddAdvertisementURLs constructs a service location from the given URLs, and
// adds the advertisement for it. // adds the advertisement for it.
func (s *Server) AddAdvertisementURLs(urls []url.URL) { func (s *Server) AddAdvertisementURLs(baseURLs []url.URL) {
for _, url := range urls { for _, url := range baseURLs {
url.Path = path.Join(url.Path, serviceDescriptionPath)
s.AddAdvertisement(url.String()) s.AddAdvertisement(url.String())
} }
} }
@ -99,3 +103,26 @@ func (s *Server) Run(ctx context.Context) {
s.log.Info().Msg("UPnP/SSDP advertisement stopped") 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 ( const (
FlamencoUUID = "aa80bc5f-d0af-46b8-8630-23bd7e80ec4d" FlamencoUUID = "aa80bc5f-d0af-46b8-8630-23bd7e80ec4d"
FlamencoServiceType = "urn:flamenco:manager:0" 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" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"sync" "sync"
"time" "time"
@ -76,14 +77,28 @@ func autodiscoverManager(ctx context.Context) (string, error) {
return usableURLs[0], nil return usableURLs[0], nil
} }
// pingManager connects to a Manager and returns true if it responds. // pingManager connects to a Manager and returns the service URL if it responds.
func pingManager(ctx context.Context, url string) bool { func pingManager(ctx context.Context, descriptionURL string) string {
logger := log.With().Str("manager", url).Logger() 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 { if err != nil {
logger.Warn().Err(err).Msg("unable to create API client with this URL") logger.Warn().Err(err).Msg("unable to create API client with this URL")
return false return ""
} }
resp, err := client.GetVersionWithResponse(ctx) 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 // 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. // 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") logger.Debug().Err(err).Msg("unable to get Flamenco version from Manager")
return false return ""
} }
if resp.JSON200 == nil { if resp.JSON200 == nil {
logger.Warn(). logger.Warn().
Int("httpStatus", resp.StatusCode()). Int("httpStatus", resp.StatusCode()).
Msg("unable to get Flamenco version, unexpected reply") Msg("unable to get Flamenco version, unexpected reply")
return false return ""
} }
logger.Info(). logger.Info().
Str("version", resp.JSON200.Version). Str("version", resp.JSON200.Version).
Str("name", resp.JSON200.Name). Str("name", resp.JSON200.Name).
Msg("found Flamenco Manager") Msg("found Flamenco Manager")
return true return serviceURL
} }
// pingManagers pings all URLs in parallel, returning only those that responded. // 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)) wg.Add(len(urls))
mutex := new(sync.Mutex) mutex := new(sync.Mutex)
pingURL := func(idx int, url string) { pingURL := func(idx int, descriptionURL string) {
defer wg.Done() defer wg.Done()
ok := pingManager(ctx, url) serviceURL := pingManager(ctx, descriptionURL)
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
if !ok { // This is either the actual service URL, or an empty string if the
// Erase the URL from the usable list. // description URL couldn't be reached/parsed.
// Modifying the original slice instead of appending to a new one ensures urls[idx] = serviceURL
// the original order is maintained.
urls[idx] = ""
}
} }
for idx, url := range urls { for idx, url := range urls {