| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637 |
- package api
- import (
- ctx "context"
- "encoding/json"
- "errors"
- "os"
- "path"
- "sort"
- "connectrpc.com/connect"
- "google.golang.org/protobuf/encoding/protojson"
- apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
- apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
- "github.com/google/uuid"
- log "github.com/sirupsen/logrus"
- "fmt"
- "net/http"
- "sync"
- "time"
- acl "github.com/OliveTin/OliveTin/internal/acl"
- auth "github.com/OliveTin/OliveTin/internal/auth"
- authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
- config "github.com/OliveTin/OliveTin/internal/config"
- entities "github.com/OliveTin/OliveTin/internal/entities"
- executor "github.com/OliveTin/OliveTin/internal/executor"
- installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
- "github.com/OliveTin/OliveTin/internal/tpl"
- connectproto "go.akshayshah.org/connectproto"
- )
- type oliveTinAPI struct {
- executor *executor.Executor
- cfg *config.Config
- // streamingClients is a set of currently connected clients.
- // The empty struct value models set semantics (keys only) and keeps add/remove O(1).
- // We use a map for efficient membership and deletion; ordering is not required.
- streamingClients map[*streamingClient]struct{}
- streamingClientsMutex sync.RWMutex
- }
- // This is used to avoid race conditions when iterating over the connectedClients map.
- // and holds the lock for as minimal time as possible to avoid blocking the API for too long.
- func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
- api.streamingClientsMutex.RLock()
- defer api.streamingClientsMutex.RUnlock()
- clients := make([]*streamingClient, 0, len(api.streamingClients))
- for client := range api.streamingClients {
- clients = append(clients, client)
- }
- return clients
- }
- type streamingClient struct {
- channel chan *apiv1.EventStreamResponse
- AuthenticatedUser *authpublic.AuthenticatedUser
- heartbeatStopOnce sync.Once
- heartbeatStop chan struct{}
- heartbeatDone chan struct{}
- }
- func (c *streamingClient) stopHeartbeat() {
- if c.heartbeatStop == nil || c.heartbeatDone == nil {
- return
- }
- c.heartbeatStopOnce.Do(func() {
- close(c.heartbeatStop)
- })
- <-c.heartbeatDone
- }
- // trySendEventToClient sends msg to the client's channel. Returns false if the channel is full or closed.
- func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
- if client == nil || msg == nil {
- return false
- }
- sent := sendToStreamingClientChannel(client.channel, msg)
- if !sent {
- log.Warnf("EventStream: client channel is full or closed, removing client")
- }
- return sent
- }
- func sendToStreamingClientChannel(ch chan *apiv1.EventStreamResponse, msg *apiv1.EventStreamResponse) (sent bool) {
- defer func() {
- if recover() != nil {
- sent = false
- }
- }()
- select {
- case ch <- msg:
- return true
- default:
- return false
- }
- }
- func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
- ret := &apiv1.KillActionResponse{
- ExecutionTrackingId: req.Msg.ExecutionTrackingId,
- }
- var execReqLogEntry *executor.InternalLogEntry
- execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId)
- if !ret.Found {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
- }
- if execReqLogEntry.Binding == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId))
- }
- action := execReqLogEntry.Binding.Action
- if action == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
- }
- log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- api.killActionByTrackingId(user, action, execReqLogEntry, ret)
- return connect.NewResponse(ret), nil
- }
- func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
- if !acl.IsAllowedKill(api.cfg, user, action) {
- log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID)
- ret.Killed = false
- return
- }
- err := api.executor.Kill(execReqLogEntry)
- if err != nil {
- log.Warnf("Killing execution request err: %v", err)
- ret.AlreadyCompleted = true
- ret.Killed = false
- } else {
- ret.Killed = true
- }
- }
- func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
- pair, err := api.findBindingByIDOrNotFound(req.Msg.BindingId)
- if err != nil {
- return nil, err
- }
- authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := validateJustificationRequired(pair.Action, req.Msg.Justification, authenticatedUser); err != nil {
- return nil, connectInvalidJustification(err)
- }
- execReq := executor.ExecutionRequest{
- Binding: pair,
- TrackingID: req.Msg.UniqueTrackingId,
- Arguments: startActionArgumentsFromProto(req.Msg.Arguments),
- Justification: req.Msg.Justification,
- AuthenticatedUser: authenticatedUser,
- Cfg: api.cfg,
- }
- api.executor.ExecRequest(&execReq)
- return connect.NewResponse(&apiv1.StartActionResponse{
- ExecutionTrackingId: execReq.TrackingID,
- }), nil
- }
- func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
- hash, err := createHash(req.Msg.Password)
- if err != nil {
- if errors.Is(err, ErrArgon2Busy) {
- return nil, connect.NewError(connect.CodeResourceExhausted, err)
- }
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating hash: %w", err))
- }
- ret := &apiv1.PasswordHashResponse{
- Hash: hash,
- }
- return connect.NewResponse(ret), nil
- }
- func (api *oliveTinAPI) cookieSecure(header http.Header) bool {
- useTLS := header.Get("X-Forwarded-Proto") == "https"
- return useTLS || api.cfg.Security.ForceSecureCookies
- }
- func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest, response *connect.Response[apiv1.LocalUserLoginResponse], match bool, secure bool) {
- if match {
- user := api.cfg.FindUserByUsername(req.Username)
- if user != nil {
- sid := uuid.NewString()
- auth.RegisterUserSession(api.cfg, "local", sid, user.Username)
- log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: Session created and registered")
- cookie := &http.Cookie{
- Name: "olivetin-sid-local",
- Value: sid,
- MaxAge: 31556952,
- HttpOnly: true,
- Path: "/",
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- }
- response.Header().Set("Set-Cookie", cookie.String())
- log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: User logged in successfully.")
- } else {
- log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: Password matched but user lookup failed.")
- }
- } else {
- log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: User login failed.")
- }
- }
- func (api *oliveTinAPI) localUserLoginEarlyReject(req *connect.Request[apiv1.LocalUserLoginRequest]) *connect.Response[apiv1.LocalUserLoginResponse] {
- if !api.cfg.AuthLocalUsers.Enabled {
- return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
- }
- if isLocalInteractiveLoginDisabledForUser(api.cfg, req.Msg.Username) {
- log.WithFields(log.Fields{"username": req.Msg.Username}).Debug("LocalUserLogin: interactive login disabled (no password configured)")
- return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
- }
- return nil
- }
- func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
- if early := api.localUserLoginEarlyReject(req); early != nil {
- return early, nil
- }
- match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
- if err != nil {
- if errors.Is(err, ErrArgon2Busy) {
- return nil, connect.NewError(connect.CodeResourceExhausted, err)
- }
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("checking password: %w", err))
- }
- response := connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: match})
- api.applyLocalLoginResult(req.Msg, response, match, api.cookieSecure(req.Header()))
- return response, nil
- }
- func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
- execReq := executor.ExecutionRequest{
- Binding: binding,
- TrackingID: uuid.NewString(),
- Arguments: args,
- Justification: justification,
- AuthenticatedUser: user,
- Cfg: api.cfg,
- }
- wg, _ := api.executor.ExecRequest(&execReq)
- wg.Wait()
- return api.executor.GetLog(execReq.TrackingID)
- }
- func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.ActionBinding, error) {
- binding := api.executor.FindBindingByID(actionId)
- if binding == nil || binding.Action == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", actionId))
- }
- return binding, nil
- }
- func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.ActionBinding, error) {
- return api.findBindingOrNotFound(bindingId)
- }
- func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
- binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
- if err != nil {
- return nil, err
- }
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := validateJustificationRequired(binding.Action, req.Msg.Justification, user); err != nil {
- return nil, connectInvalidJustification(err)
- }
- internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user)
- if !ok {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
- }
- return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
- LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
- }), nil
- }
- func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
- binding := api.executor.FindBindingByID(req.Msg.ActionId)
- if binding == nil || binding.Action == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
- }
- args := make(map[string]string)
- execReq := executor.ExecutionRequest{
- Binding: binding,
- TrackingID: uuid.NewString(),
- Arguments: args,
- AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
- Cfg: api.cfg,
- }
- _, uniqueTrackingId := api.executor.ExecRequest(&execReq)
- return connect.NewResponse(&apiv1.StartActionByGetResponse{
- ExecutionTrackingId: uniqueTrackingId,
- }), nil
- }
- func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
- binding := api.executor.FindBindingByID(req.Msg.ActionId)
- if binding == nil || binding.Action == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
- }
- args := make(map[string]string)
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- execReq := executor.ExecutionRequest{
- Binding: binding,
- TrackingID: uuid.NewString(),
- Arguments: args,
- AuthenticatedUser: user,
- Cfg: api.cfg,
- }
- wg, _ := api.executor.ExecRequest(&execReq)
- wg.Wait()
- internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
- if ok {
- return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
- LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
- }), nil
- }
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
- }
- func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
- if logEntry.Binding == nil || logEntry.Binding.Action == nil {
- return ""
- }
- expiryUnix := api.executor.GetTimeUntilAvailable(logEntry.Binding)
- if expiryUnix <= 0 {
- return ""
- }
- return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
- }
- func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
- pble := &apiv1.LogEntry{
- ActionTitle: logEntry.ActionTitle,
- ActionIcon: logEntry.ActionIcon,
- DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
- DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
- DatetimeIndex: logEntry.Index,
- Output: logEntry.Output,
- TimedOut: logEntry.TimedOut,
- Blocked: logEntry.Blocked,
- Queued: logEntry.Queued,
- QueuedForGroup: logEntry.QueuedForGroup,
- ExitCode: logEntry.ExitCode,
- Tags: logEntry.Tags,
- ExecutionTrackingId: logEntry.ExecutionTrackingID,
- ExecutionStarted: logEntry.ExecutionStarted,
- ExecutionFinished: logEntry.ExecutionFinished,
- User: logEntry.Username,
- BindingId: logEntry.GetBindingId(),
- DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
- Justification: logEntry.Justification,
- Arguments: logEntryArgumentsToProto(logEntry.Arguments),
- }
- if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
- pble.CanKill = acl.IsAllowedKill(api.cfg, authenticatedUser, logEntry.Binding.Action)
- }
- return pble
- }
- func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string) *executor.InternalLogEntry {
- logEntry, ok := api.executor.GetLog(executionTrackingId)
- if !ok {
- return nil
- }
- return logEntry
- }
- // This is the actual action ID, not the binding ID.
- func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
- var ile *executor.InternalLogEntry
- binding := api.executor.FindBindingByID(actionId)
- if binding == nil {
- return nil
- }
- logs := api.executor.GetLogsByBindingId(binding.ID)
- if len(logs) == 0 {
- return nil
- }
- if len(logs) == 0 {
- return nil
- } else {
- // Get last log entry
- ile = logs[len(logs)-1]
- }
- return ile
- }
- func (api *oliveTinAPI) resolveExecutionStatusForView(msg *apiv1.ExecutionStatusRequest, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, error) {
- ile := api.getExecutionStatusByRequest(msg)
- if ile == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", msg.ExecutionTrackingId, msg.ActionId))
- }
- if !isValidLogEntry(ile) || !api.isLogEntryAllowed(ile, user) {
- return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
- }
- return ile, nil
- }
- func (api *oliveTinAPI) getExecutionStatusByRequest(msg *apiv1.ExecutionStatusRequest) *executor.InternalLogEntry {
- if msg.ExecutionTrackingId != "" {
- return getExecutionStatusByTrackingID(api, msg.ExecutionTrackingId)
- }
- return getMostRecentExecutionStatusByActionId(api, msg.ActionId)
- }
- func dashboardNavigationTargetsToPb(targets []executor.DashboardNavigationTarget) []*apiv1.DashboardNavigationTarget {
- if len(targets) == 0 {
- return nil
- }
- result := make([]*apiv1.DashboardNavigationTarget, 0, len(targets))
- for _, target := range targets {
- result = append(result, &apiv1.DashboardNavigationTarget{
- Title: target.Title,
- EntityType: target.EntityType,
- EntityKey: target.EntityKey,
- Path: target.Path,
- })
- }
- return result
- }
- func (api *oliveTinAPI) executionStatusBackToDashboards(ile *executor.InternalLogEntry) []*apiv1.DashboardNavigationTarget {
- if ile == nil || ile.Binding == nil {
- return nil
- }
- return dashboardNavigationTargetsToPb(ile.Binding.OnDashboards)
- }
- func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- ile, err := api.resolveExecutionStatusForView(req.Msg, user)
- if err != nil {
- return nil, err
- }
- res := &apiv1.ExecutionStatusResponse{
- LogEntry: api.internalLogEntryToPb(ile, user),
- BackToDashboards: api.executionStatusBackToDashboards(ile),
- }
- return connect.NewResponse(res), nil
- }
- func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- auth.RevokeSessionForProvider(api.cfg, user.Provider, user.SID)
- log.WithFields(log.Fields{
- "username": user.Username,
- "provider": user.Provider,
- }).Info("Logout: User logged out")
- response := connect.NewResponse(&apiv1.LogoutResponse{})
- secure := api.cookieSecure(req.Header())
- // Clear the local authentication cookie by setting it to expire
- localCookie := &http.Cookie{
- Name: "olivetin-sid-local",
- Value: "",
- MaxAge: -1, // This tells the browser to delete the cookie
- HttpOnly: true,
- Path: "/",
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- }
- response.Header().Set("Set-Cookie", localCookie.String())
- // Clear the OAuth2 authentication cookie by setting it to expire
- oauth2Cookie := &http.Cookie{
- Name: "olivetin-sid-oauth",
- Value: "",
- MaxAge: -1, // This tells the browser to delete the cookie
- HttpOnly: true,
- Path: "/",
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- }
- response.Header().Add("Set-Cookie", oauth2Cookie.String())
- return response, nil
- }
- func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- resp, err := api.getActionBindingResponse(user, req.Msg.BindingId)
- if err != nil {
- return nil, err
- }
- return connect.NewResponse(resp), nil
- }
- func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedUser, bindingId string) (*apiv1.GetActionBindingResponse, error) {
- binding := api.executor.FindBindingByID(bindingId)
- if binding == nil || binding.Action == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId))
- }
- if !api.userCanViewAction(user, binding.Action) {
- return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
- }
- return &apiv1.GetActionBindingResponse{
- Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
- BackToDashboards: dashboardNavigationTargetsToPb(binding.OnDashboards),
- }, nil
- }
- func (api *oliveTinAPI) userCanViewAction(user *authpublic.AuthenticatedUser, action *config.Action) bool {
- if user == nil {
- return true
- }
- return acl.IsAllowedView(api.cfg, user, action)
- }
- func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- entityType := ""
- entityKey := ""
- if req.Msg != nil {
- entityType = req.Msg.EntityType
- entityKey = req.Msg.EntityKey
- }
- dashboardRenderRequest := api.createDashboardRenderRequest(user, entityType, entityKey)
- if api.isDefaultDashboard(req.Msg.Title) {
- return api.buildDefaultDashboardResponse(dashboardRenderRequest)
- }
- return api.buildCustomDashboardResponse(dashboardRenderRequest, req.Msg.Title)
- }
- func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser) error {
- if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin {
- return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard"))
- }
- return nil
- }
- func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
- rr := &DashboardRenderRequest{
- AuthenticatedUser: user,
- cfg: api.cfg,
- ex: api.executor,
- EntityType: entityType,
- EntityKey: entityKey,
- }
- populateActiveBindingStates(rr)
- return rr
- }
- func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
- return title == "default" || title == "" || title == "Actions"
- }
- func (api *oliveTinAPI) buildDefaultDashboardResponse(rr *DashboardRenderRequest) (*connect.Response[apiv1.GetDashboardResponse], error) {
- db := buildDefaultDashboard(rr)
- res := &apiv1.GetDashboardResponse{
- Dashboard: db,
- }
- return connect.NewResponse(res), nil
- }
- func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest, title string) (*connect.Response[apiv1.GetDashboardResponse], error) {
- res := &apiv1.GetDashboardResponse{
- Dashboard: renderDashboard(rr, title),
- }
- return connect.NewResponse(res), nil
- }
- func resolveLogsPageSize(requestPageSize, defaultPageSize int64) int64 {
- if requestPageSize == 0 {
- return defaultPageSize
- }
- if requestPageSize < 10 {
- return 10
- }
- if requestPageSize > 100 {
- return 100
- }
- return requestPageSize
- }
- func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- pageSize := resolveLogsPageSize(req.Msg.GetPageSize(), api.cfg.LogHistoryPageSize)
- logEntries, paging, err := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, pageSize, req.Msg.DateFilter, req.Msg.GetFilter())
- if err != nil {
- return nil, connect.NewError(connect.CodeInvalidArgument, err)
- }
- ret := &apiv1.GetLogsResponse{}
- for _, le := range logEntries {
- ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
- }
- ret.CountRemaining = paging.CountRemaining
- ret.PageSize = paging.PageSize
- ret.TotalCount = paging.TotalCount
- ret.StartOffset = paging.StartOffset
- return connect.NewResponse(ret), nil
- }
- // isValidLogEntry checks if a log entry has all required fields populated.
- func isValidLogEntry(e *executor.InternalLogEntry) bool {
- return e != nil && e.Binding != nil && e.Binding.Action != nil
- }
- // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
- func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
- if user == nil || !isValidLogEntry(e) {
- return false
- }
- return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
- }
- // mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
- func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
- if user == nil {
- return false
- }
- return isValidLogEntry(entry) && api.isLogEntryAllowed(entry, user)
- }
- // buildEmptyPageResponse creates a response for an empty page.
- func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
- return &apiv1.GetActionLogsResponse{
- CountRemaining: 0,
- PageSize: page.size,
- TotalCount: page.total,
- StartOffset: page.start,
- }
- }
- // calculateReversedIndices computes the reversed indices for newest-first pagination.
- func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
- startIdx := page.total - page.end
- endIdx := page.total - page.start
- if startIdx < 0 {
- startIdx = 0
- }
- if endIdx > int64(filteredLen) {
- endIdx = int64(filteredLen)
- }
- return startIdx, endIdx
- }
- // buildActionLogsResponse builds the response with paginated log entries (newest first).
- func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
- startIdx, endIdx := calculateReversedIndices(page, len(filtered))
- ret := &apiv1.GetActionLogsResponse{}
- chunk := filtered[int(startIdx):int(endIdx)]
- for i := len(chunk) - 1; i >= 0; i-- {
- ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
- }
- ret.CountRemaining = page.start
- ret.PageSize = page.size
- ret.TotalCount = page.total
- ret.StartOffset = page.start
- return ret
- }
- func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- filtered := api.filterLogsByACL(api.executor.GetLogsByBindingId(req.Msg.ActionId), user)
- page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
- if page.empty {
- return connect.NewResponse(buildEmptyPageResponse(page)), nil
- }
- return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
- }
- func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser) []*executor.InternalLogEntry {
- filtered := make([]*executor.InternalLogEntry, 0, len(entries))
- for _, e := range entries {
- if !isValidLogEntry(e) {
- continue
- }
- if api.isLogEntryAllowed(e, user) {
- filtered = append(filtered, e)
- }
- }
- return filtered
- }
- type pageInfo struct {
- total int64
- size int64
- start int64
- end int64
- empty bool
- }
- func paginate(total int64, size int64, start int64) pageInfo {
- if start < 0 {
- start = 0
- }
- if start >= total {
- return pageInfo{total: total, size: size, start: start, end: start, empty: true}
- }
- end := start + size
- if end > total {
- end = total
- }
- return pageInfo{total: total, size: size, start: start, end: end, empty: false}
- }
- /*
- This function is ONLY a helper for the UI - the arguments are validated properly
- on the StartAction -> Executor chain. This is here basically to provide helpful
- error messages more quickly before starting the action.
- It uses the same validation logic as the executor, including mangling argument
- values (e.g., datetime formatting, checkbox title-to-value conversion).
- */
- func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumentTypeRequest) bool {
- if msg.BindingId == "" || msg.ArgumentName == "" {
- return false
- }
- arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
- return arg == nil
- }
- func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
- if msg == nil || msg.BindingId == "" {
- return nil
- }
- return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId)
- }
- func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error {
- binding := api.executor.FindBindingByID(bindingID)
- if binding == nil || binding.Action == nil {
- return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
- }
- if !api.userCanViewAction(user, binding.Action) {
- return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
- }
- return nil
- }
- func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil {
- return nil, err
- }
- if api.argumentNotFoundForValidation(req.Msg) {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
- }
- return api.validateArgumentTypeConnectResponse(req.Msg)
- }
- func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
- err := api.validateArgumentTypeInternal(msg)
- desc := ""
- if err != nil {
- desc = err.Error()
- }
- return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
- Valid: err == nil,
- Description: desc,
- }), nil
- }
- func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgumentTypeRequest) error {
- if msg.BindingId == "" || msg.ArgumentName == "" {
- return executor.TypeSafetyCheck("", msg.Value, msg.Type)
- }
- arg, action := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
- if arg == nil {
- return fmt.Errorf("argument not found")
- }
- return executor.ValidateArgument(arg, msg.Value, action)
- }
- func (api *oliveTinAPI) findArgumentForValidation(bindingId string, argumentName string) (*config.ActionArgument, *config.Action) {
- binding := api.executor.FindBindingByID(bindingId)
- if binding == nil || binding.Action == nil {
- return nil, nil
- }
- arg := api.findArgumentByName(binding.Action, argumentName)
- return arg, binding.Action
- }
- func (api *oliveTinAPI) findArgumentByName(action *config.Action, name string) *config.ActionArgument {
- for i := range action.Arguments {
- if action.Arguments[i].Name == name {
- return &action.Arguments[i]
- }
- }
- return nil
- }
- func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- res := &apiv1.WhoAmIResponse{
- AuthenticatedUser: user.Username,
- Usergroup: user.UsergroupLine,
- Provider: user.Provider,
- Sid: user.SID,
- Acls: user.Acls,
- }
- return connect.NewResponse(res), nil
- }
- func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *connect.Request[apiv1.SosReportRequest]) (*connect.Response[apiv1.SosReportResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- redactVersion := !user.EffectivePolicy.ShowVersionNumber
- sos := installationinfo.GetSosReport(redactVersion)
- if !api.cfg.InsecureAllowDumpSos {
- log.Info(sos)
- 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."
- }
- ret := &apiv1.SosReportResponse{
- Alert: sos,
- }
- return connect.NewResponse(ret), nil
- }
- func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.DumpVarsRequest]) (*connect.Response[apiv1.DumpVarsResponse], error) {
- res := &apiv1.DumpVarsResponse{}
- if !api.cfg.InsecureAllowDumpVars {
- res.Alert = "Dumping variables is not allowed by default because it is insecure."
- return connect.NewResponse(res), nil
- }
- jsonstring, err := json.MarshalIndent(tpl.GetNewGeneralTemplateContext(), "", " ")
- if err != nil {
- log.WithError(err).Error("DumpVars: failed to marshal template context from GetNewGeneralTemplateContext")
- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("dump vars: marshal template context: %w", err))
- }
- fmt.Printf("%s", jsonstring)
- res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
- return connect.NewResponse(res), nil
- }
- func debugBindingActionTitle(binding *executor.ActionBinding) string {
- if binding == nil || binding.Action == nil {
- return ""
- }
- return binding.Action.Title
- }
- func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
- res := &apiv1.DumpPublicIdActionMapResponse{}
- res.Contents = make(map[string]*apiv1.DebugBinding)
- if !api.cfg.InsecureAllowDumpActionMap {
- res.Alert = "Dumping Public IDs is disallowed."
- return connect.NewResponse(res), nil
- }
- api.executor.MapActionBindingsLock.RLock()
- for k, v := range api.executor.MapActionBindings {
- res.Contents[k] = &apiv1.DebugBinding{
- ActionTitle: debugBindingActionTitle(v),
- }
- }
- api.executor.MapActionBindingsLock.RUnlock()
- res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
- return connect.NewResponse(res), nil
- }
- func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.GetReadyzRequest]) (*connect.Response[apiv1.GetReadyzResponse], error) {
- res := &apiv1.GetReadyzResponse{
- Status: "OK",
- }
- return connect.NewResponse(res), nil
- }
- func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
- log.Debugf("EventStream: %v", req.Msg)
- // Set X-Accel-Buffering header to disable nginx buffering for this stream
- // https://github.com/OliveTin/OliveTin/issues/765
- srv.ResponseHeader().Set("X-Accel-Buffering", "no")
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return err
- }
- client := &streamingClient{
- channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
- AuthenticatedUser: user,
- heartbeatStop: make(chan struct{}),
- heartbeatDone: make(chan struct{}),
- }
- log.WithFields(log.Fields{
- "authenticatedUser": user.Username,
- }).Debugf("EventStream: client connected")
- api.streamingClientsMutex.Lock()
- api.streamingClients[client] = struct{}{}
- api.streamingClientsMutex.Unlock()
- go api.sendEventStreamHeartbeats(client)
- // loop over client channel and send events to connectedClient
- for msg := range client.channel {
- log.Debugf("Sending event to client: %v", msg)
- if err := srv.Send(msg); err != nil {
- log.Errorf("Error sending event to client: %v", err)
- // Remove disconnected client from the list
- api.removeClient(client)
- break
- }
- }
- log.Infof("EventStream: client disconnected")
- return nil
- }
- func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
- defer close(client.heartbeatDone)
- if !api.sendEventStreamHeartbeat(client) {
- go api.removeClient(client)
- return
- }
- ticker := time.NewTicker(10 * time.Second)
- defer ticker.Stop()
- api.runEventStreamHeartbeatLoop(client, ticker)
- }
- func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, ticker *time.Ticker) {
- for {
- if api.waitEventStreamHeartbeatOrDone(client.heartbeatStop, ticker) {
- return
- }
- if !api.sendEventStreamHeartbeat(client) {
- go api.removeClient(client)
- return
- }
- }
- }
- func (api *oliveTinAPI) waitEventStreamHeartbeatOrDone(done <-chan struct{}, ticker *time.Ticker) bool {
- select {
- case <-done:
- return true
- case <-ticker.C:
- return false
- }
- }
- func (api *oliveTinAPI) sendEventStreamHeartbeat(client *streamingClient) bool {
- msg := &apiv1.EventStreamResponse{
- Event: &apiv1.EventStreamResponse_Heartbeat{
- Heartbeat: &apiv1.EventHeartbeat{},
- },
- }
- return api.trySendEventToClient(client, msg)
- }
- func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
- if clientToRemove == nil {
- return
- }
- api.streamingClientsMutex.Lock()
- if _, exists := api.streamingClients[clientToRemove]; !exists {
- api.streamingClientsMutex.Unlock()
- return
- }
- delete(api.streamingClients, clientToRemove)
- api.streamingClientsMutex.Unlock()
- clientToRemove.stopHeartbeat()
- close(clientToRemove.channel)
- }
- func (api *oliveTinAPI) OnActionMapRebuilt() {
- toRemove := []*streamingClient{}
- for _, client := range api.copyOfStreamingClients() {
- msg := &apiv1.EventStreamResponse{
- Event: &apiv1.EventStreamResponse_ConfigChanged{
- ConfigChanged: &apiv1.EventConfigChanged{},
- },
- }
- if !api.trySendEventToClient(client, msg) {
- toRemove = append(toRemove, client)
- }
- }
- for _, client := range toRemove {
- api.removeClient(client)
- }
- }
- func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
- toRemove := []*streamingClient{}
- for _, client := range api.copyOfStreamingClients() {
- api.maybeSendExecutionStarted(client, ex, &toRemove)
- }
- for _, client := range toRemove {
- api.removeClient(client)
- }
- }
- func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) {
- if client == nil {
- return
- }
- if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) {
- return
- }
- msg := &apiv1.EventStreamResponse{
- Event: &apiv1.EventStreamResponse_ExecutionStarted{
- ExecutionStarted: &apiv1.EventExecutionStarted{
- LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
- },
- },
- }
- if !api.trySendEventToClient(client, msg) {
- *toRemove = append(*toRemove, client)
- }
- }
- func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
- toRemove := []*streamingClient{}
- for _, client := range api.copyOfStreamingClients() {
- api.maybeSendExecutionFinished(client, ile, &toRemove)
- }
- for _, client := range toRemove {
- api.removeClient(client)
- }
- }
- func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) {
- if client == nil {
- return
- }
- if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) {
- return
- }
- msg := &apiv1.EventStreamResponse{
- Event: &apiv1.EventStreamResponse_ExecutionFinished{
- ExecutionFinished: &apiv1.EventExecutionFinished{
- LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
- },
- },
- }
- if !api.trySendEventToClient(client, msg) {
- *toRemove = append(*toRemove, client)
- }
- }
- func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- if !user.EffectivePolicy.ShowDiagnostics {
- return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("diagnostics are not available for your account"))
- }
- res := &apiv1.GetDiagnosticsResponse{
- SshFoundKey: installationinfo.Runtime.SshFoundKey,
- SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
- }
- return connect.NewResponse(res), nil
- }
- func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
- showVersion := user.EffectivePolicy.ShowVersionNumber
- currentVersion := ""
- availableVersion := ""
- if showVersion {
- currentVersion = installationinfo.Build.Version
- availableVersion = installationinfo.Runtime.AvailableVersion
- }
- res := &apiv1.InitResponse{
- ShowFooter: api.cfg.ShowFooter,
- ShowNavigation: api.cfg.ShowNavigation,
- ShowNewVersions: showVersion && api.cfg.ShowNewVersions,
- AvailableVersion: availableVersion,
- CurrentVersion: currentVersion,
- PageTitle: api.cfg.PageTitle,
- SectionNavigationStyle: api.cfg.SectionNavigationStyle,
- DefaultIconForBack: api.cfg.DefaultIconForBack,
- EnableCustomJs: api.cfg.EnableCustomJs,
- AuthLoginUrl: api.cfg.AuthLoginUrl,
- AuthLocalLogin: api.cfg.AuthLocalUsers.Enabled,
- OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
- AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
- StyleMods: api.cfg.StyleMods,
- RootDashboards: api.buildRootDashboards(user, api.cfg.Dashboards),
- AuthenticatedUser: user.Username,
- AuthenticatedUserProvider: user.Provider,
- EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy),
- BannerMessage: api.cfg.BannerMessage,
- BannerCss: api.cfg.BannerCSS,
- ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
- ShowLogList: user.EffectivePolicy.ShowLogList,
- LoginRequired: loginRequired,
- AvailableThemes: discoverAvailableThemes(api.cfg),
- ShowNavigateOnStartIcons: api.cfg.ShowNavigateOnStartIcons,
- }
- return connect.NewResponse(res), nil
- }
- // discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
- // A theme is considered available if it has a theme.css file.
- func discoverAvailableThemes(cfg *config.Config) []string {
- configDir := cfg.GetDir()
- if configDir == "" {
- return []string{}
- }
- themesDir := path.Join(configDir, "custom-webui", "themes")
- entries, err := os.ReadDir(themesDir)
- if err != nil {
- log.WithFields(log.Fields{
- "themesDir": themesDir,
- "error": err,
- }).Tracef("Could not read themes directory")
- return []string{}
- }
- themes := collectValidThemes(themesDir, entries)
- sort.Strings(themes)
- return themes
- }
- // collectValidThemes collects theme names from directory entries that have a theme.css file.
- func collectValidThemes(themesDir string, entries []os.DirEntry) []string {
- var themes []string
- for _, entry := range entries {
- if themeName := getValidThemeName(themesDir, entry); themeName != "" {
- themes = append(themes, themeName)
- }
- }
- return themes
- }
- // getValidThemeName returns the theme name if the entry is a valid theme directory with theme.css, otherwise returns empty string.
- func getValidThemeName(themesDir string, entry os.DirEntry) string {
- if !entry.IsDir() {
- return ""
- }
- themeName := entry.Name()
- themeCssPath := path.Join(themesDir, themeName, "theme.css")
- if _, err := os.Stat(themeCssPath); err != nil {
- return ""
- }
- return themeName
- }
- func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
- var rootDashboards []string
- dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
- api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
- api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
- return rootDashboards
- }
- func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
- defaultDashboard := buildDefaultDashboard(rr)
- if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
- log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
- *rootDashboards = append(*rootDashboards, "Actions")
- }
- }
- func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
- for _, dashboard := range dashboards {
- // We have to build the dashboard response instead of just looping over config.dashboards,
- // because we need to check if the user has access to the dashboard
- db := renderDashboard(rr, dashboard.Title)
- if db != nil {
- *rootDashboards = append(*rootDashboards, dashboard.Title)
- }
- }
- }
- func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
- var publicProviders []*apiv1.OAuth2Provider
- for providerKey, provider := range cfg.AuthOAuth2Providers {
- publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
- Title: provider.Title,
- Icon: provider.Icon,
- Key: providerKey,
- })
- }
- sort.Slice(publicProviders, func(i, j int) bool {
- return publicProviders[i].Key < publicProviders[j].Key
- })
- return publicProviders
- }
- func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLink {
- var additionalLinks []*apiv1.AdditionalLink
- for _, link := range links {
- additionalLinks = append(additionalLinks, &apiv1.AdditionalLink{
- Title: link.Title,
- Url: link.Url,
- })
- }
- return additionalLinks
- }
- func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
- entry := api.getValidLogEntryForStreaming(executionTrackingId)
- if entry == nil {
- return
- }
- msg := &apiv1.EventStreamResponse{
- Event: &apiv1.EventStreamResponse_OutputChunk{
- OutputChunk: &apiv1.EventOutputChunk{
- Output: string(content),
- ExecutionTrackingId: executionTrackingId,
- },
- },
- }
- toRemove := []*streamingClient{}
- for _, client := range api.copyOfStreamingClients() {
- api.maybeSendOutputChunk(client, entry, msg, &toRemove)
- }
- for _, client := range toRemove {
- api.removeClient(client)
- }
- }
- func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry {
- entry, ok := api.executor.GetLog(executionTrackingId)
- if !ok || !isValidLogEntry(entry) {
- return nil
- }
- return entry
- }
- func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) {
- if client == nil {
- return
- }
- if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) {
- return
- }
- if !api.trySendEventToClient(client, msg) {
- *toRemove = append(*toRemove, client)
- }
- }
- func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- entityMap := entities.GetEntities()
- entityNames := make([]string, 0, len(entityMap))
- for name := range entityMap {
- entityNames = append(entityNames, name)
- }
- sort.Strings(entityNames)
- entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
- for _, name := range entityNames {
- def := &apiv1.EntityDefinition{
- Title: name,
- UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
- Instances: buildSortedEntityInstances(name, entityMap[name]),
- }
- entityDefinitions = append(entityDefinitions, def)
- }
- res := &apiv1.GetEntitiesResponse{
- EntityDefinitions: entityDefinitions,
- }
- return connect.NewResponse(res), nil
- }
- func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity) []*apiv1.Entity {
- instanceKeys := make([]string, 0, len(entityInstances))
- for key := range entityInstances {
- instanceKeys = append(instanceKeys, key)
- }
- sort.Strings(instanceKeys)
- instances := make([]*apiv1.Entity, 0, len(instanceKeys))
- for _, key := range instanceKeys {
- e := entityInstances[key]
- instances = append(instances, &apiv1.Entity{
- Title: e.Title,
- UniqueKey: e.UniqueKey,
- Type: entityType,
- })
- }
- return instances
- }
- func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
- var foundDashboards []string
- seen := make(map[string]bool)
- findEntityInComponents(entityTitle, "", dashboards, &foundDashboards, seen)
- return foundDashboards
- }
- func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
- for _, component := range components {
- if component.Entity == entityTitle {
- addEntityDashboard(component, parentTitle, foundDashboards, seen)
- }
- if len(component.Contents) > 0 {
- findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards, seen)
- }
- }
- }
- func addEntityDashboard(component *config.DashboardComponent, parentTitle string, foundDashboards *[]string, seen map[string]bool) {
- if component.Type == "directory" {
- addEntityDirectory(component, foundDashboards, seen)
- } else {
- addParentDashboard(parentTitle, foundDashboards, seen)
- }
- }
- func addEntityDirectory(component *config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
- dashboardTitle := component.Title + " [Entity Directory]"
- if !seen[dashboardTitle] {
- *foundDashboards = append(*foundDashboards, dashboardTitle)
- seen[dashboardTitle] = true
- seen[component.Title] = true
- }
- }
- func addParentDashboard(parentTitle string, foundDashboards *[]string, seen map[string]bool) {
- if parentTitle != "" && !seen[parentTitle] {
- *foundDashboards = append(*foundDashboards, parentTitle)
- seen[parentTitle] = true
- }
- }
- func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string {
- var directories []string
- for _, dashboard := range dashboards {
- findDirectoriesInEntityFieldsetsRecursive(entityType, dashboard, &directories)
- }
- return directories
- }
- func findDirectoriesInEntityFieldsetsRecursive(entityType string, component *config.DashboardComponent, directories *[]string) {
- if component.Entity == entityType {
- collectDirectoriesFromComponent(component, directories)
- }
- if len(component.Contents) > 0 {
- searchSubcomponentsForDirectories(entityType, component.Contents, directories)
- }
- }
- func collectDirectoriesFromComponent(component *config.DashboardComponent, directories *[]string) {
- for _, subitem := range component.Contents {
- if subitem.Type == "directory" {
- *directories = append(*directories, subitem.Title)
- }
- }
- }
- func searchSubcomponentsForDirectories(entityType string, contents []*config.DashboardComponent, directories *[]string) {
- for _, subitem := range contents {
- findDirectoriesInEntityFieldsetsRecursive(entityType, subitem, directories)
- }
- }
- func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
- user := auth.UserFromApiCall(ctx, req, api.cfg)
- if err := api.checkDashboardAccess(user); err != nil {
- return nil, err
- }
- instances := entities.GetEntityInstances(req.Msg.Type)
- if len(instances) == 0 {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
- }
- entity, ok := instances[req.Msg.UniqueKey]
- if !ok {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
- }
- res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards)
- return connect.NewResponse(res), nil
- }
- func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity {
- res := &apiv1.Entity{
- Title: entity.Title,
- UniqueKey: entity.UniqueKey,
- Type: entityType,
- Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
- Fields: serializeEntityFields(entity.Data),
- }
- return res
- }
- func serializeEntityFields(data any) map[string]string {
- if data == nil {
- return nil
- }
- dataMap, ok := data.(map[string]any)
- if !ok {
- return nil
- }
- fields := make(map[string]string)
- for k, v := range dataMap {
- fields[k] = fmt.Sprintf("%v", v)
- }
- return fields
- }
- func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
- execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId)
- if err != nil {
- return nil, err
- }
- if err := validateRestartLogEntry(execReqLogEntry); err != nil {
- return nil, err
- }
- authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
- execReq := executor.ExecutionRequest{
- Binding: execReqLogEntry.Binding,
- Arguments: copyStringMap(execReqLogEntry.Arguments),
- Justification: execReqLogEntry.Justification,
- AuthenticatedUser: authenticatedUser,
- Cfg: api.cfg,
- }
- api.executor.ExecRequest(&execReq)
- return connect.NewResponse(&apiv1.StartActionResponse{
- ExecutionTrackingId: execReq.TrackingID,
- }), nil
- }
- func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*executor.InternalLogEntry, error) {
- execReqLogEntry, found := api.executor.GetLog(executionTrackingId)
- if !found {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", executionTrackingId))
- }
- if execReqLogEntry.Binding == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", executionTrackingId))
- }
- if execReqLogEntry.Binding.Action == nil {
- return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", executionTrackingId))
- }
- return execReqLogEntry, nil
- }
- var (
- executorListenersMu sync.Mutex
- executorListeners = map[*executor.Executor]*oliveTinAPI{}
- )
- // RegisterExecutorListener registers the API server as an executor listener during startup.
- // Call this before background goroutines that may trigger RebuildActionMap.
- func RegisterExecutorListener(ex *executor.Executor) {
- ensureExecutorListener(ex)
- }
- func ensureExecutorListener(ex *executor.Executor) *oliveTinAPI {
- executorListenersMu.Lock()
- defer executorListenersMu.Unlock()
- if server, ok := executorListeners[ex]; ok {
- return server
- }
- server := newServer(ex)
- executorListeners[ex] = server
- return server
- }
- func newServer(ex *executor.Executor) *oliveTinAPI {
- server := &oliveTinAPI{
- cfg: ex.Cfg,
- executor: ex,
- streamingClients: make(map[*streamingClient]struct{}),
- }
- ex.AddListener(server)
- return server
- }
- func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
- server := ensureExecutorListener(ex)
- jsonOpt := connectproto.WithJSON(
- protojson.MarshalOptions{
- EmitUnpopulated: true, // https://github.com/OliveTin/OliveTin/issues/674
- },
- protojson.UnmarshalOptions{
- DiscardUnknown: true,
- },
- )
- return apiv1connect.NewOliveTinApiServiceHandler(server, jsonOpt)
- }
|