api.go 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711
  1. package api
  2. import (
  3. ctx "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "net/http"
  8. "os"
  9. "path"
  10. "sort"
  11. "strings"
  12. "sync"
  13. "time"
  14. "connectrpc.com/connect"
  15. "google.golang.org/protobuf/encoding/protojson"
  16. apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
  17. apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
  18. "github.com/google/uuid"
  19. log "github.com/sirupsen/logrus"
  20. acl "github.com/OliveTin/OliveTin/internal/acl"
  21. auth "github.com/OliveTin/OliveTin/internal/auth"
  22. authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
  23. config "github.com/OliveTin/OliveTin/internal/config"
  24. entities "github.com/OliveTin/OliveTin/internal/entities"
  25. executor "github.com/OliveTin/OliveTin/internal/executor"
  26. installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
  27. "github.com/OliveTin/OliveTin/internal/tpl"
  28. connectproto "go.akshayshah.org/connectproto"
  29. )
  30. type oliveTinAPI struct {
  31. executor *executor.Executor
  32. cfg *config.Config
  33. // streamingClients is a set of currently connected clients.
  34. // The empty struct value models set semantics (keys only) and keeps add/remove O(1).
  35. // We use a map for efficient membership and deletion; ordering is not required.
  36. streamingClients map[*streamingClient]struct{}
  37. streamingClientsMutex sync.RWMutex
  38. }
  39. // This is used to avoid race conditions when iterating over the connectedClients map.
  40. // and holds the lock for as minimal time as possible to avoid blocking the API for too long.
  41. func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
  42. api.streamingClientsMutex.RLock()
  43. defer api.streamingClientsMutex.RUnlock()
  44. clients := make([]*streamingClient, 0, len(api.streamingClients))
  45. for client := range api.streamingClients {
  46. clients = append(clients, client)
  47. }
  48. return clients
  49. }
  50. type streamingClient struct {
  51. channel chan *apiv1.EventStreamResponse
  52. AuthenticatedUser *authpublic.AuthenticatedUser
  53. heartbeatStopOnce sync.Once
  54. heartbeatStop chan struct{}
  55. heartbeatDone chan struct{}
  56. }
  57. func (c *streamingClient) stopHeartbeat() {
  58. if c.heartbeatStop == nil || c.heartbeatDone == nil {
  59. return
  60. }
  61. c.heartbeatStopOnce.Do(func() {
  62. close(c.heartbeatStop)
  63. })
  64. <-c.heartbeatDone
  65. }
  66. // trySendEventToClient sends msg to the client's channel. Returns false if the channel is full or closed.
  67. func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
  68. if client == nil || msg == nil {
  69. return false
  70. }
  71. sent := sendToStreamingClientChannel(client.channel, msg)
  72. if !sent {
  73. log.Warnf("EventStream: client channel is full or closed, removing client")
  74. }
  75. return sent
  76. }
  77. func sendToStreamingClientChannel(ch chan *apiv1.EventStreamResponse, msg *apiv1.EventStreamResponse) (sent bool) {
  78. defer func() {
  79. if recover() != nil {
  80. sent = false
  81. }
  82. }()
  83. select {
  84. case ch <- msg:
  85. return true
  86. default:
  87. return false
  88. }
  89. }
  90. func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
  91. ret := &apiv1.KillActionResponse{
  92. ExecutionTrackingId: req.Msg.ExecutionTrackingId,
  93. }
  94. var execReqLogEntry *executor.InternalLogEntry
  95. execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId)
  96. if !ret.Found {
  97. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
  98. }
  99. if execReqLogEntry.Binding == nil {
  100. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId))
  101. }
  102. action := execReqLogEntry.Binding.Action
  103. if action == nil {
  104. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
  105. }
  106. log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
  107. user := auth.UserFromApiCall(ctx, req, api.cfg)
  108. api.killActionByTrackingId(user, action, execReqLogEntry, ret)
  109. return connect.NewResponse(ret), nil
  110. }
  111. func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
  112. if !acl.IsAllowedKill(api.cfg, user, action) {
  113. log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID)
  114. ret.Killed = false
  115. return
  116. }
  117. err := api.executor.Kill(execReqLogEntry)
  118. if err != nil {
  119. log.Warnf("Killing execution request err: %v", err)
  120. ret.AlreadyCompleted = true
  121. ret.Killed = false
  122. } else {
  123. ret.Killed = true
  124. }
  125. }
  126. func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
  127. pair, err := api.findBindingByIDOrNotFound(req.Msg.BindingId)
  128. if err != nil {
  129. return nil, err
  130. }
  131. authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
  132. args := startActionArgumentsFromProto(req.Msg.Arguments)
  133. justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args)
  134. if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil {
  135. return nil, connectInvalidJustification(err)
  136. }
  137. execReq := executor.ExecutionRequest{
  138. Binding: pair,
  139. TrackingID: req.Msg.UniqueTrackingId,
  140. Arguments: args,
  141. Justification: justification,
  142. AuthenticatedUser: authenticatedUser,
  143. Cfg: api.cfg,
  144. }
  145. api.executor.ExecRequest(&execReq)
  146. return connect.NewResponse(&apiv1.StartActionResponse{
  147. ExecutionTrackingId: execReq.TrackingID,
  148. }), nil
  149. }
  150. func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
  151. hash, err := createHash(req.Msg.Password)
  152. if err != nil {
  153. if errors.Is(err, ErrArgon2Busy) {
  154. return nil, connect.NewError(connect.CodeResourceExhausted, err)
  155. }
  156. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating hash: %w", err))
  157. }
  158. ret := &apiv1.PasswordHashResponse{
  159. Hash: hash,
  160. }
  161. return connect.NewResponse(ret), nil
  162. }
  163. func (api *oliveTinAPI) cookieSecure(header http.Header) bool {
  164. useTLS := header.Get("X-Forwarded-Proto") == "https"
  165. return useTLS || api.cfg.Security.ForceSecureCookies
  166. }
  167. func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest, response *connect.Response[apiv1.LocalUserLoginResponse], match bool, secure bool) {
  168. if match {
  169. user := api.cfg.FindUserByUsername(req.Username)
  170. if user != nil {
  171. sid := uuid.NewString()
  172. auth.RegisterUserSession(api.cfg, "local", sid, user.Username)
  173. log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: Session created and registered")
  174. cookie := &http.Cookie{
  175. Name: "olivetin-sid-local",
  176. Value: sid,
  177. MaxAge: 31556952,
  178. HttpOnly: true,
  179. Path: "/",
  180. Secure: secure,
  181. SameSite: http.SameSiteLaxMode,
  182. }
  183. response.Header().Set("Set-Cookie", cookie.String())
  184. log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: User logged in successfully.")
  185. } else {
  186. log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: Password matched but user lookup failed.")
  187. }
  188. } else {
  189. log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: User login failed.")
  190. }
  191. }
  192. func (api *oliveTinAPI) localUserLoginEarlyReject(req *connect.Request[apiv1.LocalUserLoginRequest]) *connect.Response[apiv1.LocalUserLoginResponse] {
  193. if !api.cfg.AuthLocalUsers.Enabled {
  194. return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
  195. }
  196. if isLocalInteractiveLoginDisabledForUser(api.cfg, req.Msg.Username) {
  197. log.WithFields(log.Fields{"username": req.Msg.Username}).Debug("LocalUserLogin: interactive login disabled (no password configured)")
  198. return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
  199. }
  200. return nil
  201. }
  202. func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
  203. if early := api.localUserLoginEarlyReject(req); early != nil {
  204. return early, nil
  205. }
  206. match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
  207. if err != nil {
  208. if errors.Is(err, ErrArgon2Busy) {
  209. return nil, connect.NewError(connect.CodeResourceExhausted, err)
  210. }
  211. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("checking password: %w", err))
  212. }
  213. response := connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: match})
  214. api.applyLocalLoginResult(req.Msg, response, match, api.cookieSecure(req.Header()))
  215. return response, nil
  216. }
  217. func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
  218. execReq := executor.ExecutionRequest{
  219. Binding: binding,
  220. TrackingID: uuid.NewString(),
  221. Arguments: args,
  222. Justification: justification,
  223. AuthenticatedUser: user,
  224. Cfg: api.cfg,
  225. }
  226. wg, _ := api.executor.ExecRequest(&execReq)
  227. wg.Wait()
  228. return api.executor.GetLog(execReq.TrackingID)
  229. }
  230. func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.ActionBinding, error) {
  231. binding := api.executor.FindBindingByID(actionId)
  232. if binding == nil || binding.Action == nil {
  233. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", actionId))
  234. }
  235. return binding, nil
  236. }
  237. func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.ActionBinding, error) {
  238. return api.findBindingOrNotFound(bindingId)
  239. }
  240. func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
  241. binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
  242. if err != nil {
  243. return nil, err
  244. }
  245. user := auth.UserFromApiCall(ctx, req, api.cfg)
  246. args := startActionArgumentsFromProto(req.Msg.Arguments)
  247. justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
  248. if err := validateJustificationRequired(binding.Action, justification, user); err != nil {
  249. return nil, connectInvalidJustification(err)
  250. }
  251. internalLogEntry, ok := api.startActionAndWaitRun(binding, args, justification, user)
  252. if !ok {
  253. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
  254. }
  255. return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
  256. LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
  257. }), nil
  258. }
  259. func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
  260. binding := api.executor.FindBindingByID(req.Msg.ActionId)
  261. if binding == nil || binding.Action == nil {
  262. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
  263. }
  264. args := make(map[string]string)
  265. execReq := executor.ExecutionRequest{
  266. Binding: binding,
  267. TrackingID: uuid.NewString(),
  268. Arguments: args,
  269. AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
  270. Cfg: api.cfg,
  271. }
  272. _, uniqueTrackingId := api.executor.ExecRequest(&execReq)
  273. return connect.NewResponse(&apiv1.StartActionByGetResponse{
  274. ExecutionTrackingId: uniqueTrackingId,
  275. }), nil
  276. }
  277. func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
  278. binding := api.executor.FindBindingByID(req.Msg.ActionId)
  279. if binding == nil || binding.Action == nil {
  280. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
  281. }
  282. args := make(map[string]string)
  283. user := auth.UserFromApiCall(ctx, req, api.cfg)
  284. execReq := executor.ExecutionRequest{
  285. Binding: binding,
  286. TrackingID: uuid.NewString(),
  287. Arguments: args,
  288. AuthenticatedUser: user,
  289. Cfg: api.cfg,
  290. }
  291. wg, _ := api.executor.ExecRequest(&execReq)
  292. wg.Wait()
  293. internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
  294. if ok {
  295. return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
  296. LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
  297. }), nil
  298. }
  299. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
  300. }
  301. func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
  302. if logEntry.Binding == nil || logEntry.Binding.Action == nil {
  303. return ""
  304. }
  305. expiryUnix := api.executor.GetTimeUntilAvailable(logEntry.Binding)
  306. if expiryUnix <= 0 {
  307. return ""
  308. }
  309. return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
  310. }
  311. func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
  312. pble := &apiv1.LogEntry{
  313. ActionTitle: logEntry.ActionTitle,
  314. ActionIcon: logEntry.ActionIcon,
  315. DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
  316. DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
  317. DatetimeIndex: logEntry.Index,
  318. Output: logEntry.Output,
  319. TimedOut: logEntry.TimedOut,
  320. Blocked: logEntry.Blocked,
  321. Queued: logEntry.Queued,
  322. QueuedForGroup: logEntry.QueuedForGroup,
  323. ExitCode: logEntry.ExitCode,
  324. Tags: logEntry.Tags,
  325. ExecutionTrackingId: logEntry.ExecutionTrackingID,
  326. ExecutionStarted: logEntry.ExecutionStarted,
  327. ExecutionFinished: logEntry.ExecutionFinished,
  328. User: logEntry.Username,
  329. BindingId: logEntry.GetBindingId(),
  330. DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
  331. Justification: logEntry.Justification,
  332. Arguments: logEntryArgumentsToProto(logEntry.Arguments),
  333. }
  334. if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
  335. pble.CanKill = acl.IsAllowedKill(api.cfg, authenticatedUser, logEntry.Binding.Action)
  336. }
  337. return pble
  338. }
  339. func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string) *executor.InternalLogEntry {
  340. logEntry, ok := api.executor.GetLog(executionTrackingId)
  341. if !ok {
  342. return nil
  343. }
  344. return logEntry
  345. }
  346. // This is the actual action ID, not the binding ID.
  347. func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
  348. var ile *executor.InternalLogEntry
  349. binding := api.executor.FindBindingByID(actionId)
  350. if binding == nil {
  351. return nil
  352. }
  353. logs := api.executor.GetLogsByBindingId(binding.ID)
  354. if len(logs) == 0 {
  355. return nil
  356. }
  357. if len(logs) == 0 {
  358. return nil
  359. } else {
  360. // Get last log entry
  361. ile = logs[len(logs)-1]
  362. }
  363. return ile
  364. }
  365. func (api *oliveTinAPI) resolveExecutionStatusForView(msg *apiv1.ExecutionStatusRequest, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, error) {
  366. ile := api.getExecutionStatusByRequest(msg)
  367. if ile == nil {
  368. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", msg.ExecutionTrackingId, msg.ActionId))
  369. }
  370. if !isValidLogEntry(ile) || !api.isLogEntryAllowed(ile, user) {
  371. return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
  372. }
  373. return ile, nil
  374. }
  375. func (api *oliveTinAPI) getExecutionStatusByRequest(msg *apiv1.ExecutionStatusRequest) *executor.InternalLogEntry {
  376. if msg.ExecutionTrackingId != "" {
  377. return getExecutionStatusByTrackingID(api, msg.ExecutionTrackingId)
  378. }
  379. return getMostRecentExecutionStatusByActionId(api, msg.ActionId)
  380. }
  381. func dashboardNavigationTargetsToPb(targets []executor.DashboardNavigationTarget) []*apiv1.DashboardNavigationTarget {
  382. if len(targets) == 0 {
  383. return nil
  384. }
  385. result := make([]*apiv1.DashboardNavigationTarget, 0, len(targets))
  386. for _, target := range targets {
  387. result = append(result, &apiv1.DashboardNavigationTarget{
  388. Title: target.Title,
  389. EntityType: target.EntityType,
  390. EntityKey: target.EntityKey,
  391. Path: target.Path,
  392. })
  393. }
  394. return result
  395. }
  396. func (api *oliveTinAPI) executionStatusBackToDashboards(ile *executor.InternalLogEntry) []*apiv1.DashboardNavigationTarget {
  397. if ile == nil || ile.Binding == nil {
  398. return nil
  399. }
  400. return dashboardNavigationTargetsToPb(ile.Binding.OnDashboards)
  401. }
  402. func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
  403. user := auth.UserFromApiCall(ctx, req, api.cfg)
  404. if err := api.checkDashboardAccess(user); err != nil {
  405. return nil, err
  406. }
  407. ile, err := api.resolveExecutionStatusForView(req.Msg, user)
  408. if err != nil {
  409. return nil, err
  410. }
  411. res := &apiv1.ExecutionStatusResponse{
  412. LogEntry: api.internalLogEntryToPb(ile, user),
  413. BackToDashboards: api.executionStatusBackToDashboards(ile),
  414. }
  415. return connect.NewResponse(res), nil
  416. }
  417. func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
  418. user := auth.UserFromApiCall(ctx, req, api.cfg)
  419. auth.RevokeSessionForProvider(api.cfg, user.Provider, user.SID)
  420. log.WithFields(log.Fields{
  421. "username": user.Username,
  422. "provider": user.Provider,
  423. }).Info("Logout: User logged out")
  424. response := connect.NewResponse(&apiv1.LogoutResponse{})
  425. secure := api.cookieSecure(req.Header())
  426. // Clear the local authentication cookie by setting it to expire
  427. localCookie := &http.Cookie{
  428. Name: "olivetin-sid-local",
  429. Value: "",
  430. MaxAge: -1, // This tells the browser to delete the cookie
  431. HttpOnly: true,
  432. Path: "/",
  433. Secure: secure,
  434. SameSite: http.SameSiteLaxMode,
  435. }
  436. response.Header().Set("Set-Cookie", localCookie.String())
  437. // Clear the OAuth2 authentication cookie by setting it to expire
  438. oauth2Cookie := &http.Cookie{
  439. Name: "olivetin-sid-oauth",
  440. Value: "",
  441. MaxAge: -1, // This tells the browser to delete the cookie
  442. HttpOnly: true,
  443. Path: "/",
  444. Secure: secure,
  445. SameSite: http.SameSiteLaxMode,
  446. }
  447. response.Header().Add("Set-Cookie", oauth2Cookie.String())
  448. return response, nil
  449. }
  450. func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
  451. user := auth.UserFromApiCall(ctx, req, api.cfg)
  452. if err := api.checkDashboardAccess(user); err != nil {
  453. return nil, err
  454. }
  455. resp, err := api.getActionBindingResponse(user, req.Msg.BindingId)
  456. if err != nil {
  457. return nil, err
  458. }
  459. return connect.NewResponse(resp), nil
  460. }
  461. func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedUser, bindingId string) (*apiv1.GetActionBindingResponse, error) {
  462. binding := api.executor.FindBindingByID(bindingId)
  463. if binding == nil || binding.Action == nil {
  464. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId))
  465. }
  466. if !api.userCanViewAction(user, binding.Action) {
  467. return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
  468. }
  469. return &apiv1.GetActionBindingResponse{
  470. Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
  471. BackToDashboards: dashboardNavigationTargetsToPb(binding.OnDashboards),
  472. }, nil
  473. }
  474. func (api *oliveTinAPI) userCanViewAction(user *authpublic.AuthenticatedUser, action *config.Action) bool {
  475. if user == nil {
  476. return true
  477. }
  478. return acl.IsAllowedView(api.cfg, user, action)
  479. }
  480. func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
  481. user := auth.UserFromApiCall(ctx, req, api.cfg)
  482. if err := api.checkDashboardAccess(user); err != nil {
  483. return nil, err
  484. }
  485. entityType := ""
  486. entityKey := ""
  487. if req.Msg != nil {
  488. entityType = req.Msg.EntityType
  489. entityKey = req.Msg.EntityKey
  490. }
  491. dashboardRenderRequest := api.createDashboardRenderRequest(user, entityType, entityKey)
  492. if api.isDefaultDashboard(req.Msg.Title) {
  493. return api.buildDefaultDashboardResponse(dashboardRenderRequest)
  494. }
  495. return api.buildCustomDashboardResponse(dashboardRenderRequest, req.Msg.Title)
  496. }
  497. func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser) error {
  498. if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin {
  499. return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard"))
  500. }
  501. return nil
  502. }
  503. func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
  504. rr := &DashboardRenderRequest{
  505. AuthenticatedUser: user,
  506. cfg: api.cfg,
  507. ex: api.executor,
  508. EntityType: entityType,
  509. EntityKey: entityKey,
  510. }
  511. populateActiveBindingStates(rr)
  512. return rr
  513. }
  514. func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
  515. return title == "default" || title == "" || title == "Actions"
  516. }
  517. func (api *oliveTinAPI) buildDefaultDashboardResponse(rr *DashboardRenderRequest) (*connect.Response[apiv1.GetDashboardResponse], error) {
  518. db := buildDefaultDashboard(rr)
  519. res := &apiv1.GetDashboardResponse{
  520. Dashboard: db,
  521. }
  522. return connect.NewResponse(res), nil
  523. }
  524. func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest, title string) (*connect.Response[apiv1.GetDashboardResponse], error) {
  525. res := &apiv1.GetDashboardResponse{
  526. Dashboard: renderDashboard(rr, title),
  527. }
  528. return connect.NewResponse(res), nil
  529. }
  530. func resolveLogsPageSize(requestPageSize, defaultPageSize int64) int64 {
  531. if requestPageSize == 0 {
  532. return defaultPageSize
  533. }
  534. if requestPageSize < 10 {
  535. return 10
  536. }
  537. if requestPageSize > 100 {
  538. return 100
  539. }
  540. return requestPageSize
  541. }
  542. func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
  543. user := auth.UserFromApiCall(ctx, req, api.cfg)
  544. if err := api.checkDashboardAccess(user); err != nil {
  545. return nil, err
  546. }
  547. pageSize := resolveLogsPageSize(req.Msg.GetPageSize(), api.cfg.LogHistoryPageSize)
  548. logEntries, paging, err := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, pageSize, req.Msg.DateFilter, req.Msg.GetFilter())
  549. if err != nil {
  550. return nil, connect.NewError(connect.CodeInvalidArgument, err)
  551. }
  552. ret := &apiv1.GetLogsResponse{}
  553. for _, le := range logEntries {
  554. ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
  555. }
  556. ret.CountRemaining = paging.CountRemaining
  557. ret.PageSize = paging.PageSize
  558. ret.TotalCount = paging.TotalCount
  559. ret.StartOffset = paging.StartOffset
  560. return connect.NewResponse(ret), nil
  561. }
  562. // isValidLogEntry checks if a log entry has all required fields populated.
  563. func isValidLogEntry(e *executor.InternalLogEntry) bool {
  564. return e != nil && e.Binding != nil && e.Binding.Action != nil
  565. }
  566. // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
  567. func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
  568. if user == nil || !isValidLogEntry(e) {
  569. return false
  570. }
  571. return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
  572. }
  573. // mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
  574. func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
  575. if user == nil {
  576. return false
  577. }
  578. return isValidLogEntry(entry) && api.isLogEntryAllowed(entry, user)
  579. }
  580. // buildEmptyPageResponse creates a response for an empty page.
  581. func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
  582. return &apiv1.GetActionLogsResponse{
  583. CountRemaining: 0,
  584. PageSize: page.size,
  585. TotalCount: page.total,
  586. StartOffset: page.start,
  587. }
  588. }
  589. // calculateReversedIndices computes the reversed indices for newest-first pagination.
  590. func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
  591. startIdx := page.total - page.end
  592. endIdx := page.total - page.start
  593. if startIdx < 0 {
  594. startIdx = 0
  595. }
  596. if endIdx > int64(filteredLen) {
  597. endIdx = int64(filteredLen)
  598. }
  599. return startIdx, endIdx
  600. }
  601. // buildActionLogsResponse builds the response with paginated log entries (newest first).
  602. func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
  603. startIdx, endIdx := calculateReversedIndices(page, len(filtered))
  604. ret := &apiv1.GetActionLogsResponse{}
  605. chunk := filtered[int(startIdx):int(endIdx)]
  606. for i := len(chunk) - 1; i >= 0; i-- {
  607. ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
  608. }
  609. ret.CountRemaining = page.start
  610. ret.PageSize = page.size
  611. ret.TotalCount = page.total
  612. ret.StartOffset = page.start
  613. return ret
  614. }
  615. func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
  616. user := auth.UserFromApiCall(ctx, req, api.cfg)
  617. if err := api.checkDashboardAccess(user); err != nil {
  618. return nil, err
  619. }
  620. filtered := api.filterLogsByACL(api.executor.GetLogsByBindingId(req.Msg.ActionId), user)
  621. page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
  622. if page.empty {
  623. return connect.NewResponse(buildEmptyPageResponse(page)), nil
  624. }
  625. return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
  626. }
  627. func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser) []*executor.InternalLogEntry {
  628. filtered := make([]*executor.InternalLogEntry, 0, len(entries))
  629. for _, e := range entries {
  630. if !isValidLogEntry(e) {
  631. continue
  632. }
  633. if api.isLogEntryAllowed(e, user) {
  634. filtered = append(filtered, e)
  635. }
  636. }
  637. return filtered
  638. }
  639. type pageInfo struct {
  640. total int64
  641. size int64
  642. start int64
  643. end int64
  644. empty bool
  645. }
  646. func paginate(total int64, size int64, start int64) pageInfo {
  647. if start < 0 {
  648. start = 0
  649. }
  650. if start >= total {
  651. return pageInfo{total: total, size: size, start: start, end: start, empty: true}
  652. }
  653. end := start + size
  654. if end > total {
  655. end = total
  656. }
  657. return pageInfo{total: total, size: size, start: start, end: end, empty: false}
  658. }
  659. /*
  660. This function is ONLY a helper for the UI - the arguments are validated properly
  661. on the StartAction -> Executor chain. This is here basically to provide helpful
  662. error messages more quickly before starting the action.
  663. It uses the same validation logic as the executor, including mangling argument
  664. values (e.g., datetime formatting, checkbox title-to-value conversion).
  665. */
  666. func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumentTypeRequest) bool {
  667. if msg.BindingId == "" || msg.ArgumentName == "" {
  668. return false
  669. }
  670. arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
  671. return arg == nil
  672. }
  673. func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
  674. if msg == nil || msg.BindingId == "" {
  675. return nil
  676. }
  677. return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId)
  678. }
  679. func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error {
  680. binding := api.executor.FindBindingByID(bindingID)
  681. if binding == nil || binding.Action == nil {
  682. return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
  683. }
  684. if !api.userCanViewAction(user, binding.Action) {
  685. return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
  686. }
  687. return nil
  688. }
  689. func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
  690. user := auth.UserFromApiCall(ctx, req, api.cfg)
  691. if err := api.checkDashboardAccess(user); err != nil {
  692. return nil, err
  693. }
  694. if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil {
  695. return nil, err
  696. }
  697. if api.argumentNotFoundForValidation(req.Msg) {
  698. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
  699. }
  700. return api.validateArgumentTypeConnectResponse(req.Msg)
  701. }
  702. func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
  703. err := api.validateArgumentTypeInternal(msg)
  704. desc := ""
  705. if err != nil {
  706. desc = err.Error()
  707. }
  708. return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
  709. Valid: err == nil,
  710. Description: desc,
  711. }), nil
  712. }
  713. func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgumentTypeRequest) error {
  714. if msg.BindingId == "" || msg.ArgumentName == "" {
  715. return executor.TypeSafetyCheck("", msg.Value, msg.Type)
  716. }
  717. arg, action := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
  718. if arg == nil {
  719. return fmt.Errorf("argument not found")
  720. }
  721. return executor.ValidateArgument(arg, msg.Value, action)
  722. }
  723. func (api *oliveTinAPI) findArgumentForValidation(bindingId string, argumentName string) (*config.ActionArgument, *config.Action) {
  724. binding := api.executor.FindBindingByID(bindingId)
  725. if binding == nil || binding.Action == nil {
  726. return nil, nil
  727. }
  728. arg := api.findArgumentByName(binding.Action, argumentName)
  729. return arg, binding.Action
  730. }
  731. func (api *oliveTinAPI) findArgumentByName(action *config.Action, name string) *config.ActionArgument {
  732. for i := range action.Arguments {
  733. if action.Arguments[i].Name == name {
  734. return &action.Arguments[i]
  735. }
  736. }
  737. return nil
  738. }
  739. func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
  740. user := auth.UserFromApiCall(ctx, req, api.cfg)
  741. if err := api.checkDashboardAccess(user); err != nil {
  742. return nil, err
  743. }
  744. res := &apiv1.WhoAmIResponse{
  745. AuthenticatedUser: user.Username,
  746. Usergroup: user.UsergroupLine,
  747. Provider: user.Provider,
  748. Sid: user.SID,
  749. Acls: user.Acls,
  750. }
  751. return connect.NewResponse(res), nil
  752. }
  753. func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *connect.Request[apiv1.SosReportRequest]) (*connect.Response[apiv1.SosReportResponse], error) {
  754. user := auth.UserFromApiCall(ctx, req, api.cfg)
  755. redactVersion := !user.EffectivePolicy.ShowVersionNumber
  756. sos := installationinfo.GetSosReport(redactVersion)
  757. if !api.cfg.InsecureAllowDumpSos {
  758. log.Info(sos)
  759. sos = "Your SOS Report has been logged to OliveTin logs.\n\nIf you are in a safe network, you can temporarily set `insecureAllowDumpSos: true` in your config.yaml, restart OliveTin, and refresh this page - it will put the output directly in the browser."
  760. }
  761. ret := &apiv1.SosReportResponse{
  762. Alert: sos,
  763. }
  764. return connect.NewResponse(ret), nil
  765. }
  766. func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.DumpVarsRequest]) (*connect.Response[apiv1.DumpVarsResponse], error) {
  767. res := &apiv1.DumpVarsResponse{}
  768. if !api.cfg.InsecureAllowDumpVars {
  769. res.Alert = "Dumping variables is not allowed by default because it is insecure."
  770. return connect.NewResponse(res), nil
  771. }
  772. jsonstring, err := json.MarshalIndent(tpl.GetNewGeneralTemplateContext(), "", " ")
  773. if err != nil {
  774. log.WithError(err).Error("DumpVars: failed to marshal template context from GetNewGeneralTemplateContext")
  775. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("dump vars: marshal template context: %w", err))
  776. }
  777. fmt.Printf("%s", jsonstring)
  778. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
  779. return connect.NewResponse(res), nil
  780. }
  781. func debugBindingActionTitle(binding *executor.ActionBinding) string {
  782. if binding == nil || binding.Action == nil {
  783. return ""
  784. }
  785. return binding.Action.Title
  786. }
  787. func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
  788. res := &apiv1.DumpPublicIdActionMapResponse{}
  789. res.Contents = make(map[string]*apiv1.DebugBinding)
  790. if !api.cfg.InsecureAllowDumpActionMap {
  791. res.Alert = "Dumping Public IDs is disallowed."
  792. return connect.NewResponse(res), nil
  793. }
  794. api.executor.MapActionBindingsLock.RLock()
  795. for k, v := range api.executor.MapActionBindings {
  796. res.Contents[k] = &apiv1.DebugBinding{
  797. ActionTitle: debugBindingActionTitle(v),
  798. }
  799. }
  800. api.executor.MapActionBindingsLock.RUnlock()
  801. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
  802. return connect.NewResponse(res), nil
  803. }
  804. func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.GetReadyzRequest]) (*connect.Response[apiv1.GetReadyzResponse], error) {
  805. res := &apiv1.GetReadyzResponse{
  806. Status: "OK",
  807. }
  808. return connect.NewResponse(res), nil
  809. }
  810. func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
  811. log.Debugf("EventStream: %v", req.Msg)
  812. // Set X-Accel-Buffering header to disable nginx buffering for this stream
  813. // https://github.com/OliveTin/OliveTin/issues/765
  814. srv.ResponseHeader().Set("X-Accel-Buffering", "no")
  815. user := auth.UserFromApiCall(ctx, req, api.cfg)
  816. if err := api.checkDashboardAccess(user); err != nil {
  817. return err
  818. }
  819. client := &streamingClient{
  820. channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
  821. AuthenticatedUser: user,
  822. heartbeatStop: make(chan struct{}),
  823. heartbeatDone: make(chan struct{}),
  824. }
  825. log.WithFields(log.Fields{
  826. "authenticatedUser": user.Username,
  827. }).Debugf("EventStream: client connected")
  828. api.streamingClientsMutex.Lock()
  829. api.streamingClients[client] = struct{}{}
  830. api.streamingClientsMutex.Unlock()
  831. go api.sendEventStreamHeartbeats(client)
  832. // loop over client channel and send events to connectedClient
  833. for msg := range client.channel {
  834. log.Debugf("Sending event to client: %v", msg)
  835. if err := srv.Send(msg); err != nil {
  836. log.Errorf("Error sending event to client: %v", err)
  837. // Remove disconnected client from the list
  838. api.removeClient(client)
  839. break
  840. }
  841. }
  842. log.Infof("EventStream: client disconnected")
  843. return nil
  844. }
  845. func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
  846. defer close(client.heartbeatDone)
  847. if !api.sendEventStreamHeartbeat(client) {
  848. go api.removeClient(client)
  849. return
  850. }
  851. ticker := time.NewTicker(10 * time.Second)
  852. defer ticker.Stop()
  853. api.runEventStreamHeartbeatLoop(client, ticker)
  854. }
  855. func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, ticker *time.Ticker) {
  856. for {
  857. if api.waitEventStreamHeartbeatOrDone(client.heartbeatStop, ticker) {
  858. return
  859. }
  860. if !api.sendEventStreamHeartbeat(client) {
  861. go api.removeClient(client)
  862. return
  863. }
  864. }
  865. }
  866. func (api *oliveTinAPI) waitEventStreamHeartbeatOrDone(done <-chan struct{}, ticker *time.Ticker) bool {
  867. select {
  868. case <-done:
  869. return true
  870. case <-ticker.C:
  871. return false
  872. }
  873. }
  874. func (api *oliveTinAPI) sendEventStreamHeartbeat(client *streamingClient) bool {
  875. msg := &apiv1.EventStreamResponse{
  876. Event: &apiv1.EventStreamResponse_Heartbeat{
  877. Heartbeat: &apiv1.EventHeartbeat{},
  878. },
  879. }
  880. return api.trySendEventToClient(client, msg)
  881. }
  882. func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
  883. if clientToRemove == nil {
  884. return
  885. }
  886. api.streamingClientsMutex.Lock()
  887. if _, exists := api.streamingClients[clientToRemove]; !exists {
  888. api.streamingClientsMutex.Unlock()
  889. return
  890. }
  891. delete(api.streamingClients, clientToRemove)
  892. api.streamingClientsMutex.Unlock()
  893. clientToRemove.stopHeartbeat()
  894. close(clientToRemove.channel)
  895. }
  896. func (api *oliveTinAPI) OnActionMapRebuilt() {
  897. toRemove := []*streamingClient{}
  898. for _, client := range api.copyOfStreamingClients() {
  899. msg := &apiv1.EventStreamResponse{
  900. Event: &apiv1.EventStreamResponse_ConfigChanged{
  901. ConfigChanged: &apiv1.EventConfigChanged{},
  902. },
  903. }
  904. if !api.trySendEventToClient(client, msg) {
  905. toRemove = append(toRemove, client)
  906. }
  907. }
  908. for _, client := range toRemove {
  909. api.removeClient(client)
  910. }
  911. }
  912. func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
  913. toRemove := []*streamingClient{}
  914. for _, client := range api.copyOfStreamingClients() {
  915. api.maybeSendExecutionStarted(client, ex, &toRemove)
  916. }
  917. for _, client := range toRemove {
  918. api.removeClient(client)
  919. }
  920. }
  921. func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) {
  922. if client == nil {
  923. return
  924. }
  925. if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) {
  926. return
  927. }
  928. msg := &apiv1.EventStreamResponse{
  929. Event: &apiv1.EventStreamResponse_ExecutionStarted{
  930. ExecutionStarted: &apiv1.EventExecutionStarted{
  931. LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
  932. },
  933. },
  934. }
  935. if !api.trySendEventToClient(client, msg) {
  936. *toRemove = append(*toRemove, client)
  937. }
  938. }
  939. func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
  940. toRemove := []*streamingClient{}
  941. for _, client := range api.copyOfStreamingClients() {
  942. api.maybeSendExecutionFinished(client, ile, &toRemove)
  943. }
  944. for _, client := range toRemove {
  945. api.removeClient(client)
  946. }
  947. }
  948. func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) {
  949. if client == nil {
  950. return
  951. }
  952. if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) {
  953. return
  954. }
  955. msg := &apiv1.EventStreamResponse{
  956. Event: &apiv1.EventStreamResponse_ExecutionFinished{
  957. ExecutionFinished: &apiv1.EventExecutionFinished{
  958. LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
  959. },
  960. },
  961. }
  962. if !api.trySendEventToClient(client, msg) {
  963. *toRemove = append(*toRemove, client)
  964. }
  965. }
  966. func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
  967. user := auth.UserFromApiCall(ctx, req, api.cfg)
  968. if err := api.checkDashboardAccess(user); err != nil {
  969. return nil, err
  970. }
  971. if !user.EffectivePolicy.ShowDiagnostics {
  972. return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("diagnostics are not available for your account"))
  973. }
  974. res := &apiv1.GetDiagnosticsResponse{
  975. SshFoundKey: installationinfo.Runtime.SshFoundKey,
  976. SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
  977. }
  978. return connect.NewResponse(res), nil
  979. }
  980. func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
  981. user := auth.UserFromApiCall(ctx, req, api.cfg)
  982. loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
  983. showVersion := user.EffectivePolicy.ShowVersionNumber
  984. currentVersion := ""
  985. availableVersion := ""
  986. if showVersion {
  987. currentVersion = installationinfo.Build.Version
  988. availableVersion = installationinfo.Runtime.AvailableVersion
  989. }
  990. res := &apiv1.InitResponse{
  991. ShowFooter: api.cfg.ShowFooter,
  992. ShowNavigation: api.cfg.ShowNavigation,
  993. ShowNewVersions: showVersion && api.cfg.ShowNewVersions,
  994. AvailableVersion: availableVersion,
  995. CurrentVersion: currentVersion,
  996. PageTitle: api.cfg.PageTitle,
  997. SectionNavigationStyle: api.cfg.SectionNavigationStyle,
  998. DefaultIconForBack: api.cfg.DefaultIconForBack,
  999. EnableCustomJs: api.cfg.EnableCustomJs,
  1000. AuthLoginUrl: api.cfg.AuthLoginUrl,
  1001. AuthLocalLogin: api.cfg.AuthLocalUsers.Enabled,
  1002. OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
  1003. AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
  1004. StyleMods: api.cfg.StyleMods,
  1005. RootDashboards: api.buildRootDashboards(user, api.cfg.Dashboards),
  1006. AuthenticatedUser: user.Username,
  1007. AuthenticatedUserProvider: user.Provider,
  1008. EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy),
  1009. BannerMessage: api.cfg.BannerMessage,
  1010. BannerCss: api.cfg.BannerCSS,
  1011. ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
  1012. ShowLogList: user.EffectivePolicy.ShowLogList,
  1013. LoginRequired: loginRequired,
  1014. AvailableThemes: discoverAvailableThemes(api.cfg),
  1015. ShowNavigateOnStartIcons: api.cfg.ShowNavigateOnStartIcons,
  1016. }
  1017. return connect.NewResponse(res), nil
  1018. }
  1019. // discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
  1020. // A theme is considered available if it has a theme.css file.
  1021. func discoverAvailableThemes(cfg *config.Config) []string {
  1022. configDir := cfg.GetDir()
  1023. if configDir == "" {
  1024. return []string{}
  1025. }
  1026. themesDir := path.Join(configDir, "custom-webui", "themes")
  1027. entries, err := os.ReadDir(themesDir)
  1028. if err != nil {
  1029. log.WithFields(log.Fields{
  1030. "themesDir": themesDir,
  1031. "error": err,
  1032. }).Tracef("Could not read themes directory")
  1033. return []string{}
  1034. }
  1035. themes := collectValidThemes(themesDir, entries)
  1036. sort.Strings(themes)
  1037. return themes
  1038. }
  1039. // collectValidThemes collects theme names from directory entries that have a theme.css file.
  1040. func collectValidThemes(themesDir string, entries []os.DirEntry) []string {
  1041. var themes []string
  1042. for _, entry := range entries {
  1043. if themeName := getValidThemeName(themesDir, entry); themeName != "" {
  1044. themes = append(themes, themeName)
  1045. }
  1046. }
  1047. return themes
  1048. }
  1049. // getValidThemeName returns the theme name if the entry is a valid theme directory with theme.css, otherwise returns empty string.
  1050. func getValidThemeName(themesDir string, entry os.DirEntry) string {
  1051. if !entry.IsDir() {
  1052. return ""
  1053. }
  1054. themeName := entry.Name()
  1055. themeCssPath := path.Join(themesDir, themeName, "theme.css")
  1056. if _, err := os.Stat(themeCssPath); err != nil {
  1057. return ""
  1058. }
  1059. return themeName
  1060. }
  1061. func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
  1062. var rootDashboards []string
  1063. dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
  1064. api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
  1065. api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
  1066. return rootDashboards
  1067. }
  1068. func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
  1069. defaultDashboard := buildDefaultDashboard(rr)
  1070. if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
  1071. log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
  1072. *rootDashboards = append(*rootDashboards, "Actions")
  1073. }
  1074. }
  1075. func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
  1076. for _, dashboard := range dashboards {
  1077. // We have to build the dashboard response instead of just looping over config.dashboards,
  1078. // because we need to check if the user has access to the dashboard
  1079. db := renderDashboard(rr, dashboard.Title)
  1080. if db != nil {
  1081. *rootDashboards = append(*rootDashboards, dashboard.Title)
  1082. }
  1083. }
  1084. }
  1085. func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
  1086. var publicProviders []*apiv1.OAuth2Provider
  1087. for providerKey, provider := range cfg.AuthOAuth2Providers {
  1088. publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
  1089. Title: provider.Title,
  1090. Icon: provider.Icon,
  1091. Key: providerKey,
  1092. })
  1093. }
  1094. sort.Slice(publicProviders, func(i, j int) bool {
  1095. return publicProviders[i].Key < publicProviders[j].Key
  1096. })
  1097. return publicProviders
  1098. }
  1099. func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLink {
  1100. var additionalLinks []*apiv1.AdditionalLink
  1101. for _, link := range links {
  1102. additionalLinks = append(additionalLinks, &apiv1.AdditionalLink{
  1103. Title: link.Title,
  1104. Url: link.Url,
  1105. })
  1106. }
  1107. return additionalLinks
  1108. }
  1109. func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
  1110. entry := api.getValidLogEntryForStreaming(executionTrackingId)
  1111. if entry == nil {
  1112. return
  1113. }
  1114. msg := &apiv1.EventStreamResponse{
  1115. Event: &apiv1.EventStreamResponse_OutputChunk{
  1116. OutputChunk: &apiv1.EventOutputChunk{
  1117. Output: string(content),
  1118. ExecutionTrackingId: executionTrackingId,
  1119. },
  1120. },
  1121. }
  1122. toRemove := []*streamingClient{}
  1123. for _, client := range api.copyOfStreamingClients() {
  1124. api.maybeSendOutputChunk(client, entry, msg, &toRemove)
  1125. }
  1126. for _, client := range toRemove {
  1127. api.removeClient(client)
  1128. }
  1129. }
  1130. func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry {
  1131. entry, ok := api.executor.GetLog(executionTrackingId)
  1132. if !ok || !isValidLogEntry(entry) {
  1133. return nil
  1134. }
  1135. return entry
  1136. }
  1137. func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) {
  1138. if client == nil {
  1139. return
  1140. }
  1141. if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) {
  1142. return
  1143. }
  1144. if !api.trySendEventToClient(client, msg) {
  1145. *toRemove = append(*toRemove, client)
  1146. }
  1147. }
  1148. func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
  1149. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1150. if err := api.checkDashboardAccess(user); err != nil {
  1151. return nil, err
  1152. }
  1153. entityMap := entities.GetEntities()
  1154. entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
  1155. res := &apiv1.GetEntitiesResponse{
  1156. EntityDefinitions: entityDefinitions,
  1157. }
  1158. return connect.NewResponse(res), nil
  1159. }
  1160. func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
  1161. instanceKeys := make([]string, 0, len(entityInstances))
  1162. for key := range entityInstances {
  1163. instanceKeys = append(instanceKeys, key)
  1164. }
  1165. sort.Strings(instanceKeys)
  1166. instances := make([]*apiv1.Entity, 0, len(instanceKeys))
  1167. for _, key := range instanceKeys {
  1168. e := entityInstances[key]
  1169. instances = append(instances, &apiv1.Entity{
  1170. Title: e.Title,
  1171. UniqueKey: e.UniqueKey,
  1172. Type: entityType,
  1173. Fields: entityListFields(e.Data, properties),
  1174. })
  1175. }
  1176. return instances
  1177. }
  1178. func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
  1179. var foundDashboards []string
  1180. seen := make(map[string]bool)
  1181. findEntityInComponents(entityTitle, "", dashboards, &foundDashboards, seen)
  1182. return foundDashboards
  1183. }
  1184. func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  1185. for _, component := range components {
  1186. if component.Entity == entityTitle {
  1187. addEntityDashboard(component, parentTitle, foundDashboards, seen)
  1188. }
  1189. if len(component.Contents) > 0 {
  1190. findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards, seen)
  1191. }
  1192. }
  1193. }
  1194. func addEntityDashboard(component *config.DashboardComponent, parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  1195. if component.Type == "directory" {
  1196. addEntityDirectory(component, foundDashboards, seen)
  1197. } else {
  1198. addParentDashboard(parentTitle, foundDashboards, seen)
  1199. }
  1200. }
  1201. func addEntityDirectory(component *config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  1202. dashboardTitle := component.Title + " [Entity Directory]"
  1203. if !seen[dashboardTitle] {
  1204. *foundDashboards = append(*foundDashboards, dashboardTitle)
  1205. seen[dashboardTitle] = true
  1206. seen[component.Title] = true
  1207. }
  1208. }
  1209. func addParentDashboard(parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  1210. if parentTitle != "" && !seen[parentTitle] {
  1211. *foundDashboards = append(*foundDashboards, parentTitle)
  1212. seen[parentTitle] = true
  1213. }
  1214. }
  1215. func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string {
  1216. var directories []string
  1217. for _, dashboard := range dashboards {
  1218. findDirectoriesInEntityFieldsetsRecursive(entityType, dashboard, &directories)
  1219. }
  1220. return directories
  1221. }
  1222. func findDirectoriesInEntityFieldsetsRecursive(entityType string, component *config.DashboardComponent, directories *[]string) {
  1223. if component.Entity == entityType {
  1224. collectDirectoriesFromComponent(component, directories)
  1225. }
  1226. if len(component.Contents) > 0 {
  1227. searchSubcomponentsForDirectories(entityType, component.Contents, directories)
  1228. }
  1229. }
  1230. func collectDirectoriesFromComponent(component *config.DashboardComponent, directories *[]string) {
  1231. for _, subitem := range component.Contents {
  1232. if subitem.Type == "directory" {
  1233. *directories = append(*directories, subitem.Title)
  1234. }
  1235. }
  1236. }
  1237. func searchSubcomponentsForDirectories(entityType string, contents []*config.DashboardComponent, directories *[]string) {
  1238. for _, subitem := range contents {
  1239. findDirectoriesInEntityFieldsetsRecursive(entityType, subitem, directories)
  1240. }
  1241. }
  1242. func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
  1243. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1244. if err := api.checkDashboardAccess(user); err != nil {
  1245. return nil, err
  1246. }
  1247. instances := entities.GetEntityInstances(req.Msg.Type)
  1248. if len(instances) == 0 {
  1249. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
  1250. }
  1251. entity, ok := instances[req.Msg.UniqueKey]
  1252. if !ok {
  1253. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
  1254. }
  1255. res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
  1256. res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
  1257. return connect.NewResponse(res), nil
  1258. }
  1259. func entityTypeIcon(cfg *config.Config, entityType string) string {
  1260. entityFile := entityFileForType(cfg, entityType)
  1261. if entityFile == nil {
  1262. return ""
  1263. }
  1264. return entityFile.Icon
  1265. }
  1266. func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
  1267. for _, entityFile := range cfg.Entities {
  1268. if entityFile != nil && entityFile.Name == entityType {
  1269. return entityFile
  1270. }
  1271. }
  1272. return nil
  1273. }
  1274. func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
  1275. if entityFile == nil {
  1276. return nil
  1277. }
  1278. return entityFile.Properties
  1279. }
  1280. func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
  1281. if len(properties) == 0 {
  1282. return nil
  1283. }
  1284. result := make([]*apiv1.EntityProperty, 0, len(properties))
  1285. for _, property := range properties {
  1286. result = append(result, &apiv1.EntityProperty{
  1287. Name: property.Name,
  1288. Title: property.Title,
  1289. })
  1290. }
  1291. return result
  1292. }
  1293. func entityListFields(data any, properties []config.EntityProperty) map[string]string {
  1294. if len(properties) == 0 {
  1295. return nil
  1296. }
  1297. fields := make(map[string]string, len(properties))
  1298. for _, property := range properties {
  1299. fields[property.Name] = entityPropertyValue(data, property.Name)
  1300. }
  1301. return fields
  1302. }
  1303. func entityPropertyValue(data any, propertyName string) string {
  1304. dataMap, ok := data.(map[string]any)
  1305. if !ok {
  1306. return ""
  1307. }
  1308. if value, found := dataMap[propertyName]; found {
  1309. return fmt.Sprintf("%v", value)
  1310. }
  1311. return entityPropertyValueCaseInsensitive(dataMap, propertyName)
  1312. }
  1313. func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
  1314. propertyNameLower := strings.ToLower(propertyName)
  1315. for key, value := range dataMap {
  1316. if strings.ToLower(key) == propertyNameLower {
  1317. return fmt.Sprintf("%v", value)
  1318. }
  1319. }
  1320. return ""
  1321. }
  1322. func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
  1323. properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
  1324. res := &apiv1.Entity{
  1325. Title: entity.Title,
  1326. UniqueKey: entity.UniqueKey,
  1327. Type: entityType,
  1328. Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
  1329. Fields: entityFieldsForResponse(entity.Data, properties),
  1330. Icon: entityTypeIcon(cfg, entityType),
  1331. }
  1332. return res
  1333. }
  1334. func serializeEntityFields(data any) map[string]string {
  1335. if data == nil {
  1336. return nil
  1337. }
  1338. dataMap, ok := data.(map[string]any)
  1339. if !ok {
  1340. return nil
  1341. }
  1342. fields := make(map[string]string)
  1343. for k, v := range dataMap {
  1344. fields[k] = fmt.Sprintf("%v", v)
  1345. }
  1346. return fields
  1347. }
  1348. func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
  1349. execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId)
  1350. if err != nil {
  1351. return nil, err
  1352. }
  1353. if err := validateRestartLogEntry(execReqLogEntry); err != nil {
  1354. return nil, err
  1355. }
  1356. authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
  1357. execReq := executor.ExecutionRequest{
  1358. Binding: execReqLogEntry.Binding,
  1359. Arguments: copyStringMap(execReqLogEntry.Arguments),
  1360. Justification: execReqLogEntry.Justification,
  1361. AuthenticatedUser: authenticatedUser,
  1362. Cfg: api.cfg,
  1363. }
  1364. api.executor.ExecRequest(&execReq)
  1365. return connect.NewResponse(&apiv1.StartActionResponse{
  1366. ExecutionTrackingId: execReq.TrackingID,
  1367. }), nil
  1368. }
  1369. func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*executor.InternalLogEntry, error) {
  1370. execReqLogEntry, found := api.executor.GetLog(executionTrackingId)
  1371. if !found {
  1372. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", executionTrackingId))
  1373. }
  1374. if execReqLogEntry.Binding == nil {
  1375. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", executionTrackingId))
  1376. }
  1377. if execReqLogEntry.Binding.Action == nil {
  1378. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", executionTrackingId))
  1379. }
  1380. return execReqLogEntry, nil
  1381. }
  1382. var (
  1383. executorListenersMu sync.Mutex
  1384. executorListeners = map[*executor.Executor]*oliveTinAPI{}
  1385. )
  1386. // RegisterExecutorListener registers the API server as an executor listener during startup.
  1387. // Call this before background goroutines that may trigger RebuildActionMap.
  1388. func RegisterExecutorListener(ex *executor.Executor) {
  1389. ensureExecutorListener(ex)
  1390. }
  1391. func ensureExecutorListener(ex *executor.Executor) *oliveTinAPI {
  1392. executorListenersMu.Lock()
  1393. defer executorListenersMu.Unlock()
  1394. if server, ok := executorListeners[ex]; ok {
  1395. return server
  1396. }
  1397. server := newServer(ex)
  1398. executorListeners[ex] = server
  1399. return server
  1400. }
  1401. func newServer(ex *executor.Executor) *oliveTinAPI {
  1402. server := &oliveTinAPI{
  1403. cfg: ex.Cfg,
  1404. executor: ex,
  1405. streamingClients: make(map[*streamingClient]struct{}),
  1406. }
  1407. ex.AddListener(server)
  1408. return server
  1409. }
  1410. func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
  1411. server := ensureExecutorListener(ex)
  1412. jsonOpt := connectproto.WithJSON(
  1413. protojson.MarshalOptions{
  1414. EmitUnpopulated: true, // https://github.com/OliveTin/OliveTin/issues/674
  1415. },
  1416. protojson.UnmarshalOptions{
  1417. DiscardUnknown: true,
  1418. },
  1419. )
  1420. return apiv1connect.NewOliveTinApiServiceHandler(server, jsonOpt)
  1421. }