// @title datarhei Core API // @version 3.0 // @description Expose REST API for the datarhei Core // @contact.name datarhei Core Support // @contact.url https://www.datarhei.com // @contact.email hello@datarhei.com // @license.name Apache 2.0 // @license.url https://github.com/datarhei/core/v16/blob/main/LICENSE // @BasePath / // @securityDefinitions.apikey ApiKeyAuth // @in header // @name Authorization // @Param Authorization header string true "Insert your access token" default(Bearer ) // @securityDefinitions.apikey ApiRefreshKeyAuth // @in header // @name Authorization // @securityDefinitions.apikey Auth0KeyAuth // @in header // @name Authorization // @securityDefinitions.basic BasicAuth package http import ( "fmt" "net/http" "strings" "github.com/datarhei/core/v16/cluster" cfgstore "github.com/datarhei/core/v16/config/store" "github.com/datarhei/core/v16/http/cache" "github.com/datarhei/core/v16/http/errorhandler" "github.com/datarhei/core/v16/http/fs" "github.com/datarhei/core/v16/http/graph/resolver" "github.com/datarhei/core/v16/http/handler" api "github.com/datarhei/core/v16/http/handler/api" httplog "github.com/datarhei/core/v16/http/log" "github.com/datarhei/core/v16/http/router" "github.com/datarhei/core/v16/http/validator" "github.com/datarhei/core/v16/iam" "github.com/datarhei/core/v16/log" "github.com/datarhei/core/v16/monitor" "github.com/datarhei/core/v16/net" "github.com/datarhei/core/v16/prometheus" "github.com/datarhei/core/v16/restream" "github.com/datarhei/core/v16/rtmp" "github.com/datarhei/core/v16/session" "github.com/datarhei/core/v16/srt" mwcache "github.com/datarhei/core/v16/http/middleware/cache" mwcors "github.com/datarhei/core/v16/http/middleware/cors" mwgzip "github.com/datarhei/core/v16/http/middleware/gzip" mwhlsrewrite "github.com/datarhei/core/v16/http/middleware/hlsrewrite" mwiam "github.com/datarhei/core/v16/http/middleware/iam" mwiplimit "github.com/datarhei/core/v16/http/middleware/iplimit" mwlog "github.com/datarhei/core/v16/http/middleware/log" mwmime "github.com/datarhei/core/v16/http/middleware/mime" mwredirect "github.com/datarhei/core/v16/http/middleware/redirect" mwsession "github.com/datarhei/core/v16/http/middleware/session" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" echoSwagger "github.com/swaggo/echo-swagger" // echo-swagger middleware // Expose the API docs _ "github.com/datarhei/core/v16/docs" ) var ListenAndServe = http.ListenAndServe type Config struct { Logger log.Logger LogBuffer log.BufferWriter LogEvents log.ChannelWriter Restream restream.Restreamer Metrics monitor.HistoryReader Prometheus prometheus.Reader MimeTypesFile string Filesystems []fs.FS IPLimiter net.IPLimitValidator Profiling bool Cors CorsConfig RTMP rtmp.Server SRT srt.Server Config cfgstore.Store Cache cache.Cacher Sessions session.RegistryReader Router router.Router ReadOnly bool Cluster cluster.Cluster IAM iam.IAM IAMSkipper func(ip string) bool } type CorsConfig struct { Origins []string } type Server interface { ServeHTTP(w http.ResponseWriter, r *http.Request) } type server struct { logger log.Logger handler struct { about *api.AboutHandler prometheus *handler.PrometheusHandler profiling *handler.ProfilingHandler ping *handler.PingHandler graph *api.GraphHandler jwt *api.JWTHandler } v3handler struct { log *api.LogHandler events *api.EventsHandler restream *api.RestreamHandler rtmp *api.RTMPHandler srt *api.SRTHandler config *api.ConfigHandler session *api.SessionHandler widget *api.WidgetHandler resources *api.MetricsHandler cluster *api.ClusterHandler iam *api.IAMHandler } middleware struct { iplimit echo.MiddlewareFunc log echo.MiddlewareFunc cors echo.MiddlewareFunc cache echo.MiddlewareFunc hlsrewrite echo.MiddlewareFunc iam echo.MiddlewareFunc } gzip struct { mimetypes []string } filesystems map[string]*filesystem router *echo.Echo mimeTypesFile string profiling bool readOnly bool } type filesystem struct { fs.FS handler *handler.FSHandler middleware echo.MiddlewareFunc } func NewServer(config Config) (Server, error) { s := &server{ logger: config.Logger, mimeTypesFile: config.MimeTypesFile, profiling: config.Profiling, readOnly: config.ReadOnly, } s.filesystems = map[string]*filesystem{} corsPrefixes := map[string][]string{ "/api": {"*"}, } for _, httpfs := range config.Filesystems { if _, ok := s.filesystems[httpfs.Name]; ok { return nil, fmt.Errorf("the filesystem name '%s' is already in use", httpfs.Name) } if !strings.HasPrefix(httpfs.Mountpoint, "/") { httpfs.Mountpoint = "/" + httpfs.Mountpoint } if !strings.HasSuffix(httpfs.Mountpoint, "/") { httpfs.Mountpoint = strings.TrimSuffix(httpfs.Mountpoint, "/") } if _, ok := corsPrefixes[httpfs.Mountpoint]; ok { return nil, fmt.Errorf("the mount point '%s' is already in use (%s)", httpfs.Mountpoint, httpfs.Name) } corsPrefixes[httpfs.Mountpoint] = config.Cors.Origins if config.Cluster != nil { if httpfs.Filesystem.Type() == "disk" || httpfs.Filesystem.Type() == "mem" { httpfs.Filesystem = fs.NewClusterFS(httpfs.Filesystem.Name(), httpfs.Filesystem, config.Cluster.ProxyReader()) } } filesystem := &filesystem{ FS: httpfs, handler: handler.NewFS(httpfs), } if httpfs.Filesystem.Type() == "disk" { filesystem.middleware = mwhlsrewrite.NewHLSRewriteWithConfig(mwhlsrewrite.HLSRewriteConfig{ PathPrefix: httpfs.Filesystem.Metadata("base"), }) } s.filesystems[filesystem.Name] = filesystem } if _, ok := corsPrefixes["/"]; !ok { return nil, fmt.Errorf("one filesystem must be mounted at /") } if config.Logger == nil { s.logger = log.New("") } mounts := []string{} for _, fs := range s.filesystems { mounts = append(mounts, fs.FS.Mountpoint) } s.middleware.iam = mwiam.NewWithConfig(mwiam.Config{ Skipper: func(c echo.Context) bool { return config.IAMSkipper(c.RealIP()) }, IAM: config.IAM, Mounts: mounts, WaitAfterFailedLogin: true, Logger: s.logger.WithComponent("IAM"), }) s.handler.about = api.NewAbout( config.Restream, func() []string { return config.IAM.Validators() }, ) s.handler.jwt = api.NewJWT(config.IAM) s.v3handler.iam = api.NewIAM(config.IAM) s.v3handler.log = api.NewLog( config.LogBuffer, ) s.v3handler.events = api.NewEvents( config.LogEvents, ) if config.Restream != nil { s.v3handler.restream = api.NewRestream( config.Restream, config.IAM, ) } if config.Prometheus != nil { s.handler.prometheus = handler.NewPrometheus( config.Prometheus.HTTPHandler(), ) } if config.Profiling { s.handler.profiling = handler.NewProfiling() } if config.IPLimiter != nil { s.middleware.iplimit = mwiplimit.NewWithConfig(mwiplimit.Config{ Limiter: config.IPLimiter, }) } s.handler.ping = handler.NewPing() if config.RTMP != nil { s.v3handler.rtmp = api.NewRTMP( config.RTMP, ) } if config.SRT != nil { s.v3handler.srt = api.NewSRT( config.SRT, ) } if config.Config != nil { s.v3handler.config = api.NewConfig( config.Config, ) } if config.Sessions == nil { config.Sessions, _ = session.New(session.Config{}) if config.Sessions.Collector("hlsingress") == nil { return nil, fmt.Errorf("hlsingress session collector must be available") } if config.Sessions.Collector("hls") == nil { return nil, fmt.Errorf("hls session collector must be available") } if config.Sessions.Collector("http") == nil { return nil, fmt.Errorf("http session collector must be available") } } s.v3handler.session = api.NewSession( config.Sessions, config.IAM, ) s.middleware.log = mwlog.NewWithConfig(mwlog.Config{ Logger: s.logger, }) s.v3handler.widget = api.NewWidget(api.WidgetConfig{ Restream: config.Restream, Registry: config.Sessions, }) s.v3handler.resources = api.NewMetrics(api.MetricsConfig{ Metrics: config.Metrics, }) if config.Cluster != nil { handler, err := api.NewCluster(config.Cluster, config.IAM) if err != nil { return nil, fmt.Errorf("cluster handler: %w", err) } s.v3handler.cluster = handler } if middleware, err := mwcors.NewWithConfig(mwcors.Config{ Prefixes: corsPrefixes, }); err != nil { return nil, err } else { s.middleware.cors = middleware } s.handler.graph = api.NewGraph(resolver.Resolver{ Restream: config.Restream, Monitor: config.Metrics, LogBuffer: config.LogBuffer, IAM: config.IAM, }, "/api/graph/query") s.gzip.mimetypes = []string{ "text/plain", "text/html", "text/javascript", "application/json", "application/x-mpegurl", "application/vnd.apple.mpegurl", "image/svg+xml", } s.router = echo.New() s.router.HTTPErrorHandler = errorhandler.HTTPErrorHandler s.router.Validator = validator.New() s.router.Use(s.middleware.log) s.router.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ LogErrorFunc: func(c echo.Context, err error, stack []byte) error { rows := strings.Split(string(stack), "\n") s.logger.Error().WithField("stack", rows).Log("recovered from a panic") return nil }, })) s.router.HideBanner = true s.router.HidePort = true s.router.Logger.SetOutput(httplog.NewWrapper(s.logger)) if s.middleware.cors != nil { s.router.Use(s.middleware.cors) } s.router.Use(s.middleware.iam) s.router.Use(mwsession.NewWithConfig(mwsession.Config{ HLSIngressCollector: config.Sessions.Collector("hlsingress"), HLSEgressCollector: config.Sessions.Collector("hls"), HTTPCollector: config.Sessions.Collector("http"), Skipper: func(c echo.Context) bool { // Exclude the API from the sessions path := c.Request().URL.Path if path == "/api" { return true } return strings.HasPrefix(path, "/api/") }, })) // Add static routes if path, target := config.Router.StaticRoute(); len(target) != 0 { group := s.router.Group(path) group.Use(middleware.AddTrailingSlashWithConfig(middleware.TrailingSlashConfig{ Skipper: func(c echo.Context) bool { return path != c.Request().URL.Path }, RedirectCode: 301, })) group.Use(middleware.StaticWithConfig(middleware.StaticConfig{ Skipper: middleware.DefaultSkipper, Root: target, Index: "index.html", IgnoreBase: true, })) } s.router.Use(mwredirect.NewWithConfig(mwredirect.Config{ Redirects: config.Router.FileRoutes(), })) for prefix, target := range config.Router.DirRoutes() { group := s.router.Group(prefix) group.Use(middleware.AddTrailingSlashWithConfig(middleware.TrailingSlashConfig{ Skipper: func(prefix string) func(c echo.Context) bool { return func(c echo.Context) bool { return prefix != c.Request().URL.Path } }(prefix), RedirectCode: 301, })) group.Use(middleware.StaticWithConfig(middleware.StaticConfig{ Skipper: middleware.DefaultSkipper, Root: target, Index: "index.html", IgnoreBase: true, })) } s.setRoutes() return s, nil } func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) } func (s *server) setRoutes() { gzipMiddleware := mwgzip.NewWithConfig(mwgzip.Config{ Level: mwgzip.BestSpeed, MinLength: 1000, Skipper: mwgzip.ContentTypeSkipper(nil), }) // API router grouo api := s.router.Group("/api") if s.middleware.iplimit != nil { api.Use(s.middleware.iplimit) } // The login endpoint should not be blocked by auth s.router.POST("/api/login", s.handler.jwt.Login) s.router.GET("/api/login/refresh", s.handler.jwt.Refresh) api.GET("", s.handler.about.About) // Swagger API documentation router group doc := s.router.Group("/api/swagger/*") doc.Use(gzipMiddleware) doc.GET("", echoSwagger.WrapHandler) // Mount filesystems for _, filesystem := range s.filesystems { // Define a local variable because later in the loop we have a closure filesystem := filesystem mountpoint := filesystem.Mountpoint + "/*" if filesystem.Mountpoint == "/" { mountpoint = "/*" } fs := s.router.Group(mountpoint) fs.Use(mwmime.NewWithConfig(mwmime.Config{ MimeTypesFile: s.mimeTypesFile, DefaultContentType: filesystem.DefaultContentType, })) if filesystem.Gzip { fs.Use(mwgzip.NewWithConfig(mwgzip.Config{ Skipper: mwgzip.ContentTypeSkipper(s.gzip.mimetypes), Level: mwgzip.BestSpeed, MinLength: 1000, })) } if filesystem.Cache != nil { mwcache := mwcache.NewWithConfig(mwcache.Config{ Cache: filesystem.Cache, }) fs.Use(mwcache) } if filesystem.middleware != nil { fs.Use(filesystem.middleware) } fs.GET("", filesystem.handler.GetFile) fs.HEAD("", filesystem.handler.GetFile) if filesystem.AllowWrite { fs.POST("", filesystem.handler.PutFile) fs.PUT("", filesystem.handler.PutFile) fs.DELETE("", filesystem.handler.DeleteFile) } } // Prometheus metrics if s.handler.prometheus != nil { metrics := s.router.Group("/metrics") if s.middleware.iplimit != nil { metrics.Use(s.middleware.iplimit) } metrics.GET("", s.handler.prometheus.Metrics) } // Health check s.router.GET("/ping", s.handler.ping.Ping) // Profiling routes if s.profiling { prof := s.router.Group("/profiling") if s.middleware.iplimit != nil { prof.Use(s.middleware.iplimit) } s.handler.profiling.Register(prof) } // GraphQL graphql := api.Group("/graph") graphql.Use(gzipMiddleware) graphql.GET("", s.handler.graph.Playground) graphql.POST("/query", s.handler.graph.Query) // APIv3 router group v3 := api.Group("/v3") v3.Use(gzipMiddleware) s.setRoutesV3(v3) } func (s *server) setRoutesV3(v3 *echo.Group) { if s.v3handler.widget != nil { // The widget endpoint should not be blocked by auth s.router.GET("/api/v3/widget/process/:id", s.v3handler.widget.Get) } // v3 IAM if s.v3handler.iam != nil { v3.GET("/iam/user", s.v3handler.iam.ListIdentities) v3.GET("/iam/user/:name", s.v3handler.iam.GetIdentity) if !s.readOnly { v3.POST("/iam/user", s.v3handler.iam.AddIdentity) v3.PUT("/iam/user/:name", s.v3handler.iam.UpdateIdentity) v3.PUT("/iam/user/:name/policy", s.v3handler.iam.UpdateIdentityPolicies) v3.DELETE("/iam/user/:name", s.v3handler.iam.RemoveIdentity) } } // v3 Restreamer if s.v3handler.restream != nil { v3.GET("/skills", s.v3handler.restream.Skills) v3.GET("/skills/reload", s.v3handler.restream.ReloadSkills) v3.GET("/process", s.v3handler.restream.GetAll) v3.GET("/process/:id", s.v3handler.restream.Get) v3.GET("/process/:id/config", s.v3handler.restream.GetConfig) v3.GET("/process/:id/state", s.v3handler.restream.GetState) v3.GET("/process/:id/report", s.v3handler.restream.GetReport) v3.GET("/process/:id/probe", s.v3handler.restream.Probe) v3.GET("/process/:id/metadata", s.v3handler.restream.GetProcessMetadata) v3.GET("/process/:id/metadata/:key", s.v3handler.restream.GetProcessMetadata) v3.GET("/metadata", s.v3handler.restream.GetMetadata) v3.GET("/metadata/:key", s.v3handler.restream.GetMetadata) if !s.readOnly { v3.POST("/process", s.v3handler.restream.Add) v3.PUT("/process/:id", s.v3handler.restream.Update) v3.DELETE("/process/:id", s.v3handler.restream.Delete) v3.PUT("/process/:id/command", s.v3handler.restream.Command) v3.PUT("/process/:id/metadata/:key", s.v3handler.restream.SetProcessMetadata) v3.PUT("/metadata/:key", s.v3handler.restream.SetMetadata) } // v3 Playout v3.GET("/process/:id/playout/:inputid/status", s.v3handler.restream.PlayoutStatus) v3.GET("/process/:id/playout/:inputid/reopen", s.v3handler.restream.PlayoutReopenInput) v3.GET("/process/:id/playout/:inputid/keyframe/*", s.v3handler.restream.PlayoutKeyframe) v3.GET("/process/:id/playout/:inputid/errorframe/encode", s.v3handler.restream.PlayoutEncodeErrorframe) if !s.readOnly { v3.PUT("/process/:id/playout/:inputid/errorframe/*", s.v3handler.restream.PlayoutSetErrorframe) v3.POST("/process/:id/playout/:inputid/errorframe/*", s.v3handler.restream.PlayoutSetErrorframe) v3.PUT("/process/:id/playout/:inputid/stream", s.v3handler.restream.PlayoutSetStream) } // v3 Report v3.GET("/report/process", s.v3handler.restream.SearchReportHistory) } // v3 Filesystems fshandlers := map[string]api.FSConfig{} for _, fs := range s.filesystems { fshandlers[fs.Name] = api.FSConfig{ Type: fs.Filesystem.Type(), Mountpoint: fs.Mountpoint, Handler: fs.handler, } } handler := api.NewFS(fshandlers) v3.GET("/fs", handler.List) v3.PUT("/fs", handler.FileOperation) v3.GET("/fs/:storage", handler.ListFiles) v3.GET("/fs/:storage/*", handler.GetFile, mwmime.NewWithConfig(mwmime.Config{ MimeTypesFile: s.mimeTypesFile, DefaultContentType: "application/data", })) v3.HEAD("/fs/:storage/*", handler.GetFile, mwmime.NewWithConfig(mwmime.Config{ MimeTypesFile: s.mimeTypesFile, DefaultContentType: "application/data", })) if !s.readOnly { v3.PUT("/fs/:storage/*", handler.PutFile) v3.DELETE("/fs/:storage", handler.DeleteFiles) v3.DELETE("/fs/:storage/*", handler.DeleteFile) } // v3 RTMP if s.v3handler.rtmp != nil { v3.GET("/rtmp", s.v3handler.rtmp.ListChannels) } // v3 SRT if s.v3handler.srt != nil { v3.GET("/srt", s.v3handler.srt.ListChannels) } // v3 Config if s.v3handler.config != nil { v3.GET("/config", s.v3handler.config.Get) if !s.readOnly { v3.PUT("/config", s.v3handler.config.Set) v3.GET("/config/reload", s.v3handler.config.Reload) } } // v3 Session if s.v3handler.session != nil { v3.GET("/session", s.v3handler.session.Summary) v3.GET("/session/active", s.v3handler.session.Active) v3.PUT("/session/token/:username", s.v3handler.session.CreateToken) } // v3 Cluster if s.v3handler.cluster != nil { v3.GET("/cluster", s.v3handler.cluster.About) v3.GET("/cluster/snapshot", s.v3handler.cluster.GetSnapshot) v3.GET("/cluster/db/process", s.v3handler.cluster.ListStoreProcesses) v3.GET("/cluster/db/process/:id", s.v3handler.cluster.GetStoreProcess) v3.GET("/cluster/db/user", s.v3handler.cluster.ListStoreIdentities) v3.GET("/cluster/db/user/:name", s.v3handler.cluster.ListStoreIdentity) v3.GET("/cluster/db/policies", s.v3handler.cluster.ListStorePolicies) v3.GET("/cluster/db/locks", s.v3handler.cluster.ListStoreLocks) v3.GET("/cluster/db/kv", s.v3handler.cluster.ListStoreKV) v3.GET("/cluster/iam/user", s.v3handler.cluster.ListIdentities) v3.GET("/cluster/iam/user/:name", s.v3handler.cluster.ListIdentity) v3.GET("/cluster/iam/policies", s.v3handler.cluster.ListPolicies) v3.GET("/cluster/process", s.v3handler.cluster.ListAllNodesProcesses) v3.GET("/cluster/process/:id", s.v3handler.cluster.GetAllNodesProcess) v3.GET("/cluster/node", s.v3handler.cluster.GetNodes) v3.GET("/cluster/node/:id", s.v3handler.cluster.GetNode) v3.GET("/cluster/node/:id/files", s.v3handler.cluster.GetNodeFiles) v3.GET("/cluster/node/:id/process", s.v3handler.cluster.ListNodeProcesses) v3.GET("/cluster/node/:id/version", s.v3handler.cluster.GetNodeVersion) if !s.readOnly { v3.PUT("/cluster/leave", s.v3handler.cluster.Leave) v3.POST("/cluster/process", s.v3handler.cluster.AddProcess) v3.PUT("/cluster/process/:id", s.v3handler.cluster.UpdateProcess) v3.DELETE("/cluster/process/:id", s.v3handler.cluster.DeleteProcess) v3.PUT("/cluster/process/:id/command", s.v3handler.cluster.SetProcessCommand) v3.PUT("/cluster/process/:id/metadata/:key", s.v3handler.cluster.SetProcessMetadata) v3.PUT("/cluster/iam/reload", s.v3handler.cluster.ReloadIAM) v3.POST("/cluster/iam/user", s.v3handler.cluster.AddIdentity) v3.PUT("/cluster/iam/user/:name", s.v3handler.cluster.UpdateIdentity) v3.PUT("/cluster/iam/user/:name/policy", s.v3handler.cluster.UpdateIdentityPolicies) v3.DELETE("/cluster/iam/user/:name", s.v3handler.cluster.RemoveIdentity) } } // v3 Log if s.v3handler.log != nil { v3.GET("/log", s.v3handler.log.Log) } // v3 Metrics if s.v3handler.resources != nil { v3.GET("/metrics", s.v3handler.resources.Describe) v3.POST("/metrics", s.v3handler.resources.Metrics) } // v3 Events if s.v3handler.events != nil { v3.POST("/events", s.v3handler.events.Events) } }