diff --git a/cmd/flamenco-manager/main.go b/cmd/flamenco-manager/main.go index 5067808a..75b10919 100644 --- a/cmd/flamenco-manager/main.go +++ b/cmd/flamenco-manager/main.go @@ -49,6 +49,7 @@ import ( "git.blender.org/flamenco/internal/manager/swagger_ui" "git.blender.org/flamenco/internal/manager/task_logs" "git.blender.org/flamenco/internal/manager/task_state_machine" + "git.blender.org/flamenco/internal/upnp_ssdp" "git.blender.org/flamenco/pkg/api" ) @@ -84,6 +85,13 @@ func main() { _, port, _ := net.SplitHostPort(listen) log.Info().Str("port", port).Msg("listening") + ssdp, err := upnp_ssdp.NewServer(log.Logger) + if err != nil { + log.Error().Err(err).Msg("error creating UPnP/SSDP server") + } else { + ssdp.AddAdvertisement(listen) // TODO: convert this to an entire URL. + } + // Construct the services. persist := openDB(*configService) flamenco := buildFlamencoAPI(configService, persist) @@ -119,6 +127,15 @@ func main() { } }() + // Start the UPnP/SSDP server. + if ssdp != nil { + wg.Add(1) + go func() { + defer wg.Done() + ssdp.Run(mainCtx) + }() + } + wg.Wait() log.Info().Msg("shutdown complete") } diff --git a/cmd/ssdp_client_poc/main.go b/cmd/ssdp_client_poc/main.go new file mode 100644 index 00000000..20e8b8ec --- /dev/null +++ b/cmd/ssdp_client_poc/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "os/signal" + "syscall" + "time" + + "git.blender.org/flamenco/internal/upnp_ssdp" + "github.com/mattn/go-colorable" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "golang.org/x/net/context" +) + +func main() { + output := zerolog.ConsoleWriter{Out: colorable.NewColorableStdout(), TimeFormat: time.RFC3339} + log.Logger = log.Output(output) + + c, err := upnp_ssdp.NewClient(log.Logger) + if err != nil { + panic(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Handle Ctrl+C + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + signal.Notify(signals, syscall.SIGTERM) + go func() { + for signum := range signals { + log.Info().Str("signal", signum.String()).Msg("signal received, shutting down") + cancel() + } + }() + + urls, err := c.Run(ctx) + if err != nil { + panic(err) + } + for _, url := range urls { + log.Info().Str("url", url).Msg("found URL") + } +} diff --git a/cmd/ssdp_server_poc/main.go b/cmd/ssdp_server_poc/main.go new file mode 100644 index 00000000..836fe992 --- /dev/null +++ b/cmd/ssdp_server_poc/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.blender.org/flamenco/internal/own_url" + "git.blender.org/flamenco/internal/upnp_ssdp" + "github.com/mattn/go-colorable" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "golang.org/x/net/context" +) + +func main() { + output := zerolog.ConsoleWriter{Out: colorable.NewColorableStdout(), TimeFormat: time.RFC3339} + log.Logger = log.Output(output) + + c, err := upnp_ssdp.NewServer(log.Logger) + if err != nil { + panic(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Handle Ctrl+C + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + signal.Notify(signals, syscall.SIGTERM) + go func() { + for signum := range signals { + log.Info().Str("signal", signum.String()).Msg("signal received, shutting down") + cancel() + } + }() + + urls, err := own_url.AvailableURLs(ctx, "http", ":8080", false) + urlStrings := []string{} + for _, url := range urls { + urlStrings = append(urlStrings, url.String()) + } + log.Info().Strs("urls", urlStrings).Msg("URLs to try") + + location := strings.Join(urlStrings, ";") + c.AddAdvertisement(location) + + c.Run(ctx) +} diff --git a/go.mod b/go.mod index 82eec697..12092fa7 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 // indirect + github.com/fromkeith/gossdp v0.0.0-20180102154144-1b2c43f6886e // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/glebarez/go-sqlite v1.14.7 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect diff --git a/go.sum b/go.sum index ab4294dd..c2fb6574 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7 h1:tYwu/z8Y0Nkk github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/fromkeith/gossdp v0.0.0-20180102154144-1b2c43f6886e h1:cG4ivpkHpkmWTaaLrgekDVR0xAr87V697T2c+WnUdiY= +github.com/fromkeith/gossdp v0.0.0-20180102154144-1b2c43f6886e/go.mod h1:7xQpS/YtlWo38XfIqje9GgtlPuBRatYcL23GlYBtgWM= github.com/getkin/kin-openapi v0.80.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/getkin/kin-openapi v0.88.0 h1:BjJ2JERWJbYE1o1RGEj/5LmR5qw7ecfl3O3su4ImR+0= github.com/getkin/kin-openapi v0.88.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= diff --git a/internal/own_url/interfaces.go b/internal/own_url/interfaces.go new file mode 100644 index 00000000..1ac140d1 --- /dev/null +++ b/internal/own_url/interfaces.go @@ -0,0 +1,129 @@ +package own_url + +/* (c) 2019, Blender Foundation - Sybren A. Stüvel + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import ( + "errors" + "fmt" + "net" + + "github.com/rs/zerolog/log" +) + +var ( + // ErrNoInterface is returned when no network interfaces with a real IP-address were found. + ErrNoInterface = errors.New("no network interface found") +) + +// networkInterfaces returns a list of interface addresses. +// Only those addresses that can be eached by a unicast TCP/IP connection are returned. +func networkInterfaces(includeLinkLocal, includeLocalhost bool) ([]net.IP, error) { + log.Debug().Msg("iterating over all network interfaces") + + interfaces, err := net.Interfaces() + if err != nil { + return []net.IP{}, err + } + + usableAddresses := make([]net.IP, 0) + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 { + log.Debug().Str("interface", iface.Name).Msg("skipping down interface") + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + ifaceAddresses := make([]net.IP, 0) + for k := range addrs { + var ip net.IP + switch a := addrs[k].(type) { + case *net.IPAddr: + ip = a.IP + case *net.IPNet: + ip = a.IP + default: + log.Warn(). + Interface("addr", addrs[k]). + Str("type", fmt.Sprintf("%T", addrs[k])). + Msg(" - skipping unknown interface type") + continue + } + + logger := log.With(). + Interface("ip", ip). + Str("iface", iface.Name). + Logger() + switch { + case ip.IsMulticast(): + logger.Debug().Msg(" - skipping multicast") + case ip.IsMulticast(): + logger.Debug().Msg(" - skipping multicast") + case ip.IsUnspecified(): + logger.Debug().Msg(" - skipping unspecified") + case !includeLinkLocal && ip.IsLinkLocalUnicast(): + logger.Debug().Msg(" - skipping link-local") + case !includeLocalhost && ip.IsLoopback(): + logger.Debug().Msg(" - skipping localhost") + default: + logger.Debug().Msg(" - usable") + ifaceAddresses = append(ifaceAddresses, ip) + } + } + + usableAddresses = append(usableAddresses, filterAddresses(ifaceAddresses)...) + } + + if len(usableAddresses) == 0 { + return usableAddresses, ErrNoInterface + } + + return usableAddresses, nil +} + +// filterAddresses removes "privacy extension" addresses. +// It assumes the list of addresses belong to the same network interface, and +// that the OS reports preferred (i.e. private/random) addresses before +// non-random ones. +func filterAddresses(addrs []net.IP) []net.IP { + keep := make([]net.IP, 0) + + var lastSeenIP net.IP + for _, addr := range addrs { + if addr.To4() != nil { + // IPv4 addresses are always kept. + keep = append(keep, addr) + continue + } + + lastSeenIP = addr + } + if len(lastSeenIP) > 0 { + keep = append(keep, lastSeenIP) + } + + return keep +} diff --git a/internal/own_url/own_url.go b/internal/own_url/own_url.go new file mode 100644 index 00000000..61394b0b --- /dev/null +++ b/internal/own_url/own_url.go @@ -0,0 +1,98 @@ +// Package own_url provides a way for a process to find a URL on which it can be reached. +package own_url + +import ( + "context" + "fmt" + "net" + "net/url" + + "github.com/rs/zerolog/log" +) + +func AvailableURLs(ctx context.Context, schema, listen string, includeLocal bool) ([]*url.URL, error) { + var ( + host, port string + portnum int + err error + ) + + if listen == "" { + panic("empty 'listen' parameter") + } + + // Figure out which port we're supposted to listen on. + if host, port, err = net.SplitHostPort(listen); err != nil { + return nil, fmt.Errorf("unable to split host and port in address '%s': %w", listen, err) + } + if portnum, err = net.DefaultResolver.LookupPort(ctx, "listen", port); err != nil { + return nil, fmt.Errorf("unable to look up port '%s': %w", port, err) + } + + // If the host is empty or ::0/0.0.0.0, show a list of URLs to connect to. + listenSpecificHost := false + var ip net.IP + if host != "" { + ip = net.ParseIP(host) + if ip == nil { + addrs, erresolve := net.DefaultResolver.LookupHost(ctx, host) + if erresolve != nil { + return nil, fmt.Errorf("unable to resolve listen host '%v': %w", host, erresolve) + } + if len(addrs) > 0 { + ip = net.ParseIP(addrs[0]) + } + } + if ip != nil && !ip.IsUnspecified() { + listenSpecificHost = true + } + } + + if listenSpecificHost { + // We can just construct a URL here, since we know it's a specific host anyway. + log.Debug().Str("host", ip.String()).Msg("listening on host") + + link := fmt.Sprintf("%s://%s:%d/", schema, host, portnum) + myURL, errparse := url.Parse(link) + if errparse != nil { + return nil, fmt.Errorf("unable to parse listen URL %s: %w", link, errparse) + } + return []*url.URL{myURL}, nil + } + + log.Debug().Str("host", host).Msg("not listening on any specific host") + + addrs, err := networkInterfaces(false, includeLocal) + if err == ErrNoInterface { + addrs, err = networkInterfaces(true, includeLocal) + } + if err != nil { + return nil, err + } + + log.Debug().Msg("iterating network interfaces to find possible URLs for Flamenco Manager.") + + links := make([]*url.URL, 0) + for _, addr := range addrs { + var strAddr string + if ipv4 := addr.To4(); ipv4 != nil { + strAddr = ipv4.String() + } else { + strAddr = fmt.Sprintf("[%s]", addr) + } + + constructedURL := fmt.Sprintf("%s://%s:%d/", schema, strAddr, portnum) + parsedURL, err := url.Parse(constructedURL) + if err != nil { + log.Warn(). + Str("address", strAddr). + Str("url", constructedURL). + Err(err). + Msg("skipping address, as it results in an unparseable URL") + continue + } + links = append(links, parsedURL) + } + + return links, nil +} diff --git a/internal/own_url/own_url_test.go b/internal/own_url/own_url_test.go new file mode 100644 index 00000000..8996c18f --- /dev/null +++ b/internal/own_url/own_url_test.go @@ -0,0 +1,26 @@ +// Package own_url provides a way for a process to find a URL on which it can be reached. +package own_url + +import ( + "context" + "testing" + "time" + + "github.com/mattn/go-colorable" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func TestAvailableURLs(t *testing.T) { + output := zerolog.ConsoleWriter{Out: colorable.NewColorableStdout(), TimeFormat: time.RFC3339} + log.Logger = log.Output(output) + + ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ctxCancel() + + _, err := AvailableURLs(ctx, "http", ":9999", true) + if err != nil { + t.Fatal(err) + } + // t.Fatalf("urls: %v", urls) +} diff --git a/internal/upnp_ssdp/client.go b/internal/upnp_ssdp/client.go new file mode 100644 index 00000000..926d4821 --- /dev/null +++ b/internal/upnp_ssdp/client.go @@ -0,0 +1,127 @@ +package upnp_ssdp + +/* ***** BEGIN GPL LICENSE BLOCK ***** + * + * Original Code Copyright (C) 2022 Blender Foundation. + * + * This file is part of Flamenco. + * + * Flamenco is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * Flamenco is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * Flamenco. If not, see . + * + * ***** END GPL LICENSE BLOCK ***** */ + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/fromkeith/gossdp" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type Client struct { + ssdp *gossdp.ClientSsdp + log *zerolog.Logger + + mutex *sync.Mutex + urls []string +} + +func NewClient(logger zerolog.Logger) (*Client, error) { + client := Client{ + log: &logger, + + mutex: new(sync.Mutex), + urls: make([]string, 0), + } + + wrap := wrappedLogger(&logger) + ssdp, err := gossdp.NewSsdpClientWithLogger(&client, wrap) + if err != nil { + return nil, fmt.Errorf("create UPnP/SSDP client: %w", err) + } + + client.ssdp = ssdp + return &client, nil +} + +func (c *Client) Run(ctx context.Context) ([]string, error) { + defer c.ssdp.Stop() + + log.Debug().Msg("waiting for UPnP/SSDP answer") + go c.ssdp.Start() + + var waitTime time.Duration + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(waitTime): + if err := c.ssdp.ListenFor(FlamencoServiceType); err != nil { + return nil, fmt.Errorf("unable to find Manager: %w", err) + } + waitTime = 1 * time.Second + + urls := c.receivedURLs() + if len(urls) > 0 { + return urls, nil + } + } + } +} + +// Response is called by the gossdp library on M-SEARCH responses. +func (c *Client) Response(message gossdp.ResponseMessage) { + logger := c.log.With(). + Int("maxAge", message.MaxAge). + Str("searchType", message.SearchType). + Str("deviceID", message.DeviceId). + Str("usn", message.Usn). + Str("location", message.Location). + Str("server", message.Server). + Str("urn", message.Urn). + Logger() + if message.DeviceId != FlamencoUUID { + logger.Debug().Msg("ignoring message from unknown device") + return + } + + logger.Debug().Msg("UPnP/SSDP message received") + c.appendURLs(message.Location) +} + +func (c *Client) appendURLs(location string) { + urls := strings.Split(location, LocationSeparator) + + c.mutex.Lock() + defer c.mutex.Unlock() + + c.urls = append(c.urls, urls...) + c.log.Debug(). + Int("new", len(urls)). + Int("total", len(c.urls)). + Msg("new URLs received") +} + +// receivedURLs takes a thread-safe copy of the URLs received so far. +func (c *Client) receivedURLs() []string { + c.mutex.Lock() + defer c.mutex.Unlock() + + urls := make([]string, len(c.urls)) + copy(urls, c.urls) + return urls +} diff --git a/internal/upnp_ssdp/logging.go b/internal/upnp_ssdp/logging.go new file mode 100644 index 00000000..325d96c7 --- /dev/null +++ b/internal/upnp_ssdp/logging.go @@ -0,0 +1,60 @@ +// package upnp_ssdp allows Workers to find their Manager on the LAN. +package upnp_ssdp + +/* ***** BEGIN GPL LICENSE BLOCK ***** + * + * Original Code Copyright (C) 2022 Blender Foundation. + * + * This file is part of Flamenco. + * + * Flamenco is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * Flamenco is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * Flamenco. If not, see . + * + * ***** END GPL LICENSE BLOCK ***** */ + +import ( + "github.com/fromkeith/gossdp" + "github.com/rs/zerolog" +) + +type ssdpLogger struct { + zlog *zerolog.Logger +} + +var _ gossdp.LoggerInterface = (*ssdpLogger)(nil) + +// wrappedLogger returns a gossdp.LoggerInterface-compatible wrapper around the given logger. +func wrappedLogger(logger *zerolog.Logger) *ssdpLogger { + return &ssdpLogger{ + zlog: logger, + } +} + +func (sl *ssdpLogger) Tracef(fmt string, args ...interface{}) { + sl.zlog.Debug().Msgf("SSDP: "+fmt, args...) +} + +func (sl *ssdpLogger) Infof(fmt string, args ...interface{}) { + sl.zlog.Info().Msgf("SSDP: "+fmt, args...) +} + +func (sl *ssdpLogger) Warnf(fmt string, args ...interface{}) { + sl.zlog.Warn().Msgf("SSDP: "+fmt, args...) +} + +func (sl *ssdpLogger) Errorf(fmt string, args ...interface{}) { + // Errors from the SSDP library are logged by that library AND returned as + // error, which then triggers our own code to log the error as well. Since our + // code can provide more context about what it's doing, demote SSDP errors to + // the warning level. + sl.zlog.Warn().Msgf("SSDP: "+fmt, args...) +} diff --git a/internal/upnp_ssdp/server.go b/internal/upnp_ssdp/server.go new file mode 100644 index 00000000..32a92de8 --- /dev/null +++ b/internal/upnp_ssdp/server.go @@ -0,0 +1,90 @@ +package upnp_ssdp + +/* ***** BEGIN GPL LICENSE BLOCK ***** + * + * Original Code Copyright (C) 2022 Blender Foundation. + * + * This file is part of Flamenco. + * + * Flamenco is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * Flamenco is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * Flamenco. If not, see . + * + * ***** END GPL LICENSE BLOCK ***** */ + +import ( + "context" + + "github.com/fromkeith/gossdp" + "github.com/rs/zerolog" +) + +// Server advertises services via UPnP/SSDP. +type Server struct { + ssdp *gossdp.Ssdp + log *zerolog.Logger + wrappedLog *ssdpLogger +} + +func NewServer(logger zerolog.Logger) (*Server, error) { + wrap := wrappedLogger(&logger) + ssdp, err := gossdp.NewSsdpWithLogger(nil, wrap) + if err != nil { + return nil, err + } + return &Server{ssdp, &logger, wrap}, nil +} + +// AddAdvertisement adds a service advertisement for Flamenco Manager. +// Must be called before calling Run(). +func (s *Server) AddAdvertisement(serviceLocation string) { + // Define the service we want to advertise + serverDef := gossdp.AdvertisableServer{ + ServiceType: FlamencoServiceType, + DeviceUuid: FlamencoUUID, + Location: serviceLocation, + MaxAge: 3600, // Number of seconds this advertisement is valid for. + } + s.ssdp.AdvertiseServer(serverDef) +} + +// Run starts the advertisement, and blocks until the context is closed. +func (s *Server) Run(ctx context.Context) { + s.log.Info().Msg("UPnP/SSDP advertisement starting") + + isStopping := false + + go func() { + // There is a bug in the SSDP library, where closing the server can cause a panic. + defer func() { + if isStopping { + // Only capture a panic when we expect one. + recover() + } + }() + + s.ssdp.Start() + }() + + <-ctx.Done() + + s.log.Debug().Msg("UPnP/SSDP advertisement stopping") + + // Sneakily disable warnings when shutting down, otherwise the read operation + // from the UDP socket will cause a warning. + tempLog := s.log.Level(zerolog.ErrorLevel) + s.wrappedLog.zlog = &tempLog + isStopping = true + s.ssdp.Stop() + s.wrappedLog.zlog = s.log + + s.log.Info().Msg("UPnP/SSDP advertisement stopped") +} diff --git a/internal/upnp_ssdp/upnp_ssdp.go b/internal/upnp_ssdp/upnp_ssdp.go new file mode 100644 index 00000000..5fee0047 --- /dev/null +++ b/internal/upnp_ssdp/upnp_ssdp.go @@ -0,0 +1,29 @@ +// package upnp_ssdp allows Workers to find their Manager on the LAN. +package upnp_ssdp + +/* ***** BEGIN GPL LICENSE BLOCK ***** + * + * Original Code Copyright (C) 2022 Blender Foundation. + * + * This file is part of Flamenco. + * + * Flamenco is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * Flamenco is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * Flamenco. If not, see . + * + * ***** END GPL LICENSE BLOCK ***** */ + +const ( + FlamencoUUID = "aa80bc5f-d0af-46b8-8630-23bd7e80ec4d" + FlamencoServiceType = "urn:flamenco:manager:0" + + LocationSeparator = ";" +)