package api import ( ctx "context" "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" executor "github.com/OliveTin/OliveTin/internal/executor" installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo" sv "github.com/OliveTin/OliveTin/internal/stringvariables" ) 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 } api.executor.MapActionIdToBindingLock.RLock() pair := api.executor.MapActionIdToBinding[req.Msg.ActionId] api.executor.MapActionIdToBindingLock.RUnlock() if pair == nil || pair.Action == nil { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId)) } authenticatedUser := acl.UserFromContext(ctx, api.cfg) execReq := executor.ExecutionRequest{ Action: pair.Action, EntityPrefix: pair.EntityPrefix, 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.FindActionBindingByID(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.FindActionBindingByID(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.FindActionBindingByID(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) WatchExecution(req *apiv1.WatchExecutionRequest, srv apiv1.OliveTinApi_WatchExecutionServer) error { log.Infof("Watch") if logEntry, ok := api.executor.Logs[req.ExecutionUuid]; !ok { log.Errorf("Execution not found: %v", req.ExecutionUuid) return nil } else { if logEntry.ExecutionStarted { for !logEntry.ExecutionCompleted { tmp := make([]byte, 256) red, err := io.ReadAtLeast(logEntry.StdoutBuffer, tmp, 1) log.Infof("%v %v", red, err) srv.Send(&apiv1.WatchExecutionUpdate{ Update: string(tmp), }) } } return 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) GetDashboardComponents(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardComponentsRequest]) (*connect.Response[apiv1.GetDashboardComponentsResponse], 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) /* 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, countRemaining := 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 = countRemaining ret.PageSize = api.cfg.LogHistoryPageSize 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 } res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore" res.Contents = sv.GetAll() 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: v.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) 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 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) }