package api import ( ctx "context" "encoding/json" "connectrpc.com/connect" 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" acl "github.com/OliveTin/OliveTin/internal/acl" 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" ) type oliveTinAPI struct { executor *executor.Executor cfg *config.Config connectedClients []*connectedClients } type connectedClients struct { channel chan *apiv1.EventStreamResponse AuthenticatedUser *acl.AuthenticatedUser } 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 { log.Warnf("Killing execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId) return connect.NewResponse(ret), nil } log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId) action := api.cfg.FindAction(execReqLogEntry.ActionTitle) if action == nil { log.Warnf("Killing execution request not possible - action not found: %v", execReqLogEntry.ActionTitle) ret.Killed = false return connect.NewResponse(ret), nil } user := acl.UserFromContext(ctx, api.cfg) api.killActionByTrackingId(user, action, execReqLogEntry, ret) return connect.NewResponse(ret), nil } func (api *oliveTinAPI) killActionByTrackingId(user *acl.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 } 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) { args := make(map[string]string) for _, arg := range req.Msg.Arguments { args[arg.Name] = arg.Value } pair := api.executor.FindBindingByID(req.Msg.BindingId) if pair == nil || pair.Action == nil { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId)) } authenticatedUser := acl.UserFromContext(ctx, api.cfg) execReq := executor.ExecutionRequest{ Action: pair.Action, Entity: pair.Entity, TrackingID: req.Msg.UniqueTrackingId, Arguments: args, AuthenticatedUser: authenticatedUser, Cfg: api.cfg, } api.executor.ExecRequest(&execReq) ret := &apiv1.StartActionResponse{ ExecutionTrackingId: execReq.TrackingID, } return connect.NewResponse(ret), 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 { 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) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) { match := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password) if match { //grpc.SendHeader(ctx, metadata.Pairs("set-username", req.Username)) log.WithFields(log.Fields{ "username": req.Msg.Username, }).Info("LocalUserLogin: User logged in successfully.") } else { log.WithFields(log.Fields{ "username": req.Msg.Username, }).Warn("LocalUserLogin: User login failed.") } return connect.NewResponse(&apiv1.LocalUserLoginResponse{ Success: match, }), nil } func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) { args := make(map[string]string) for _, arg := range req.Msg.Arguments { args[arg.Name] = arg.Value } user := acl.UserFromContext(ctx, api.cfg) execReq := executor.ExecutionRequest{ Action: api.executor.FindActionByBindingID(req.Msg.ActionId), 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.StartActionAndWaitResponse{ LogEntry: api.internalLogEntryToPb(internalLogEntry, user), }), nil } else { return nil, fmt.Errorf("execution not found") } } func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) { args := make(map[string]string) execReq := executor.ExecutionRequest{ Action: api.executor.FindActionByBindingID(req.Msg.ActionId), TrackingID: uuid.NewString(), Arguments: args, AuthenticatedUser: acl.UserFromContext(ctx, 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) { args := make(map[string]string) user := acl.UserFromContext(ctx, api.cfg) execReq := executor.ExecutionRequest{ Action: api.executor.FindActionByBindingID(req.Msg.ActionId), 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 } else { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found")) } } func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *acl.AuthenticatedUser) *apiv1.LogEntry { pble := &apiv1.LogEntry{ ActionTitle: logEntry.ActionTitle, ActionIcon: logEntry.ActionIcon, ActionId: logEntry.ActionId, 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, ExitCode: logEntry.ExitCode, Tags: logEntry.Tags, ExecutionTrackingId: logEntry.ExecutionTrackingID, ExecutionStarted: logEntry.ExecutionStarted, ExecutionFinished: logEntry.ExecutionFinished, User: logEntry.Username, } if !pble.ExecutionFinished { pble.CanKill = acl.IsAllowedKill(api.cfg, authenticatedUser, api.cfg.FindAction(logEntry.ActionConfigTitle)) } return pble } func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string) *executor.InternalLogEntry { logEntry, ok := api.executor.GetLog(executionTrackingId) if !ok { return nil } return logEntry } func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *executor.InternalLogEntry { var ile *executor.InternalLogEntry logs := api.executor.GetLogsByActionId(actionId) if len(logs) == 0 { return nil } else { // Get last log entry ile = logs[len(logs)-1] } return ile } func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) { res := &apiv1.ExecutionStatusResponse{} user := acl.UserFromContext(ctx, api.cfg) var ile *executor.InternalLogEntry if req.Msg.ExecutionTrackingId != "" { ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId) } else { ile = getMostRecentExecutionStatusById(api, req.Msg.ActionId) } if ile == nil { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", req.Msg.ExecutionTrackingId, req.Msg.ActionId)) } else { res.LogEntry = api.internalLogEntryToPb(ile, user) } return connect.NewResponse(res), nil } func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) { //user := acl.UserFromContext(ctx, cfg) //grpc.SendHeader(ctx, metadata.Pairs("logout-provider", user.Provider)) //grpc.SendHeader(ctx, metadata.Pairs("logout-sid", user.SID)) return nil, nil } func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) { binding := api.executor.FindBindingByID(req.Msg.BindingId) return connect.NewResponse(&apiv1.GetActionBindingResponse{ Action: buildAction(req.Msg.BindingId, binding, &DashboardRenderRequest{ cfg: api.cfg, AuthenticatedUser: acl.UserFromContext(ctx, api.cfg), ex: api.executor, }), }), nil } func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) { user := acl.UserFromContext(ctx, api.cfg) if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin { return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard")) } res := buildDashboardResponse(api.executor, api.cfg, user, req.Msg.Title) /* if len(res.Actions) == 0 { log.WithFields(log.Fields{ "username": user.Username, "usergroupLine": user.UsergroupLine, "provider": user.Provider, "acls": user.Acls, "availableActions": len(api.cfg.Actions), }).Warn("Zero actions found for user") } */ log.Tracef("GetDashboardComponents: %v", res) return connect.NewResponse(res), nil } func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) { user := acl.UserFromContext(ctx, api.cfg) ret := &apiv1.GetLogsResponse{} logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize) for _, logEntry := range logEntries { action := api.cfg.FindAction(logEntry.ActionTitle) if action == nil || acl.IsAllowedLogs(api.cfg, user, action) { pbLogEntry := api.internalLogEntryToPb(logEntry, user) ret.Logs = append(ret.Logs, pbLogEntry) } } ret.CountRemaining = pagingResult.CountRemaining ret.PageSize = pagingResult.PageSize ret.TotalCount = pagingResult.TotalCount ret.StartOffset = pagingResult.StartOffset return connect.NewResponse(ret), nil } /* 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. */ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) { err := executor.TypeSafetyCheck("", req.Msg.Value, req.Msg.Type) desc := "" if err != nil { desc = err.Error() } return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{ Valid: err == nil, Description: desc, }), nil } func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) { user := acl.UserFromContext(ctx, api.cfg) 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) { sos := installationinfo.GetSosReport() 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, _ := json.MarshalIndent(entities.GetAll(), "", " ") 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 (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.ActionEntityPair) if !api.cfg.InsecureAllowDumpActionMap { res.Alert = "Dumping Public IDs is disallowed." return connect.NewResponse(res), nil } api.executor.MapActionIdToBindingLock.RLock() for k, v := range api.executor.MapActionIdToBinding { res.Contents[k] = &apiv1.ActionEntityPair{ ActionTitle: v.Action.Title, EntityPrefix: "?", } } api.executor.MapActionIdToBindingLock.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) client := &connectedClients{ channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events AuthenticatedUser: acl.UserFromContext(ctx, api.cfg), } log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username) api.connectedClients = append(api.connectedClients, 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) } } log.Infof("EventStream: client disconnected") return nil } func (api *oliveTinAPI) OnActionMapRebuilt() { for _, client := range api.connectedClients { select { case client.channel <- &apiv1.EventStreamResponse{ Event: &apiv1.EventStreamResponse_ConfigChanged{ ConfigChanged: &apiv1.EventConfigChanged{}, }, }: default: log.Warnf("EventStream: client channel is full, dropping message") } } } func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) { for _, client := range api.connectedClients { select { case client.channel <- &apiv1.EventStreamResponse{ Event: &apiv1.EventStreamResponse_ExecutionStarted{ ExecutionStarted: &apiv1.EventExecutionStarted{ LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser), }, }, }: default: log.Warnf("EventStream: client channel is full, dropping message") } } } func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) { for _, client := range api.connectedClients { select { case client.channel <- &apiv1.EventStreamResponse{ Event: &apiv1.EventStreamResponse_ExecutionFinished{ ExecutionFinished: &apiv1.EventExecutionFinished{ LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser), }, }, }: default: log.Warnf("EventStream: client channel is full, dropping message") } } } func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) { 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 := acl.UserFromContext(ctx, api.cfg) res := &apiv1.InitResponse{ ShowFooter: api.cfg.ShowFooter, ShowNavigation: api.cfg.ShowNavigation, ShowNewVersions: api.cfg.ShowNewVersions, AvailableVersion: installationinfo.Runtime.AvailableVersion, CurrentVersion: installationinfo.Build.Version, 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: buildRootDashboards(api.cfg.Dashboards), AuthenticatedUser: user.Username, AuthenticatedUserProvider: user.Provider, EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy), BannerMessage: api.cfg.BannerMessage, BannerCss: api.cfg.BannerCSS, } return connect.NewResponse(res), nil } func buildRootDashboards(dashboards []*config.DashboardComponent) []string { var rootDashboards []string for _, dashboard := range dashboards { rootDashboards = append(rootDashboards, dashboard.Title) } return rootDashboards } func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider { var publicProviders []*apiv1.OAuth2Provider for _, provider := range cfg.AuthOAuth2Providers { publicProviders = append(publicProviders, &apiv1.OAuth2Provider{ Title: provider.Title, Url: provider.AuthUrl, Icon: provider.Icon, }) } 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) { for _, client := range api.connectedClients { select { case client.channel <- &apiv1.EventStreamResponse{ Event: &apiv1.EventStreamResponse_OutputChunk{ OutputChunk: &apiv1.EventOutputChunk{ Output: string(content), ExecutionTrackingId: executionTrackingId, }, }, }: default: log.Warnf("EventStream: client channel is full, dropping message") } } } func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) { res := &apiv1.GetEntitiesResponse{ EntityDefinitions: make([]*apiv1.EntityDefinition, 0), } for name, entityInstances := range entities.GetEntities() { def := &apiv1.EntityDefinition{ Title: name, UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards), } for _, e := range entityInstances { entity := &apiv1.Entity{ Title: e.Title, UniqueKey: e.UniqueKey, Type: name, } def.Instances = append(def.Instances, entity) } res.EntityDefinitions = append(res.EntityDefinitions, def) } return connect.NewResponse(res), nil } func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string { var foundDashboards []string findEntityInComponents(entityTitle, "", dashboards, &foundDashboards) return foundDashboards } func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string) { for _, component := range components { if component.Entity == entityTitle { *foundDashboards = append(*foundDashboards, parentTitle) } if len(component.Contents) > 0 { findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards) } } } func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) { res := &apiv1.Entity{} instances := entities.GetEntityInstances(req.Msg.Type) log.Infof("msg: %+v", req.Msg) if instances == nil || len(instances) == 0 { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type)) } if entity, ok := instances[req.Msg.UniqueKey]; !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)) } else { res.Title = entity.Title return connect.NewResponse(res), nil } } func newServer(ex *executor.Executor) *oliveTinAPI { server := oliveTinAPI{} server.cfg = ex.Cfg server.executor = ex ex.AddListener(&server) return &server } func GetNewHandler(ex *executor.Executor) (string, http.Handler) { server := newServer(ex) return apiv1connect.NewOliveTinApiServiceHandler(server) }