api.go 56 KB

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