mirror of
https://github.com/fleetdm/fleet
synced 2026-05-06 06:48:54 +00:00
454 lines
15 KiB
Go
454 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/docker/go-units"
|
|
"github.com/fleetdm/fleet/v4/pkg/scripts"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
)
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Run Script on a Host (async)
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type runScriptRequest struct {
|
|
HostID uint `json:"host_id"`
|
|
ScriptID *uint `json:"script_id"`
|
|
ScriptContents string `json:"script_contents"`
|
|
}
|
|
|
|
type runScriptResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
HostID uint `json:"host_id,omitempty"`
|
|
ExecutionID string `json:"execution_id,omitempty"`
|
|
}
|
|
|
|
func (r runScriptResponse) error() error { return r.Err }
|
|
func (r runScriptResponse) Status() int { return http.StatusAccepted }
|
|
|
|
func runScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*runScriptRequest)
|
|
|
|
var noWait time.Duration
|
|
result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{
|
|
HostID: req.HostID,
|
|
ScriptID: req.ScriptID,
|
|
ScriptContents: req.ScriptContents,
|
|
}, noWait)
|
|
if err != nil {
|
|
return runScriptResponse{Err: err}, nil
|
|
}
|
|
return runScriptResponse{HostID: result.HostID, ExecutionID: result.ExecutionID}, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Run Script on a Host (sync)
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type runScriptSyncResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
*fleet.HostScriptResult
|
|
HostTimeout bool `json:"host_timeout"`
|
|
}
|
|
|
|
func (r runScriptSyncResponse) error() error { return r.Err }
|
|
func (r runScriptSyncResponse) Status() int {
|
|
if r.HostTimeout {
|
|
// The more proper response for a timeout on the server would be: StatusGatewayTimeout = 504
|
|
// However, as described in https://github.com/fleetdm/fleet/issues/15430 we will send:
|
|
// StatusRequestTimeout = 408 // RFC 9110, 15.5.9
|
|
// See: https://github.com/fleetdm/fleet/issues/15430#issuecomment-1847345617
|
|
return http.StatusRequestTimeout
|
|
}
|
|
return http.StatusOK
|
|
}
|
|
|
|
// this is to be used only by tests, to be able to use a shorter timeout.
|
|
var testRunScriptWaitForResult time.Duration
|
|
|
|
func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
waitForResult := scripts.MaxServerWaitTime
|
|
if testRunScriptWaitForResult != 0 {
|
|
waitForResult = testRunScriptWaitForResult
|
|
}
|
|
|
|
req := request.(*runScriptRequest)
|
|
result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{
|
|
HostID: req.HostID,
|
|
ScriptID: req.ScriptID,
|
|
ScriptContents: req.ScriptContents,
|
|
}, waitForResult)
|
|
var hostTimeout bool
|
|
if err != nil {
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
return runScriptSyncResponse{Err: err}, nil
|
|
}
|
|
// We should still return the execution id and host id in this timeout case,
|
|
// so the user knows what script request to look at in the UI. We cannot
|
|
// return an error (field Err) in this case, as the errorer interface's
|
|
// rendering logic would take over and only render the error part of the
|
|
// response struct.
|
|
hostTimeout = true
|
|
}
|
|
result.Message = result.UserMessage(hostTimeout)
|
|
return runScriptSyncResponse{
|
|
HostScriptResult: result,
|
|
HostTimeout: hostTimeout,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
// Get script result for a host
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
type getScriptResultRequest struct {
|
|
ExecutionID string `url:"execution_id"`
|
|
}
|
|
|
|
type getScriptResultResponse struct {
|
|
ScriptContents string `json:"script_contents"`
|
|
ScriptID *uint `json:"script_id"`
|
|
ExitCode *int64 `json:"exit_code"`
|
|
Output string `json:"output"`
|
|
Message string `json:"message"`
|
|
HostName string `json:"hostname"`
|
|
HostTimeout bool `json:"host_timeout"`
|
|
HostID uint `json:"host_id"`
|
|
ExecutionID string `json:"execution_id"`
|
|
Runtime int `json:"runtime"`
|
|
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getScriptResultResponse) error() error { return r.Err }
|
|
|
|
func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*getScriptResultRequest)
|
|
scriptResult, err := svc.GetScriptResult(ctx, req.ExecutionID)
|
|
if err != nil {
|
|
return getScriptResultResponse{Err: err}, nil
|
|
}
|
|
|
|
// TODO: move this logic out of the endpoint function and consolidate in either the service
|
|
// method or the fleet package
|
|
hostTimeout := scriptResult.HostTimeout(scripts.MaxServerWaitTime)
|
|
scriptResult.Message = scriptResult.UserMessage(hostTimeout)
|
|
|
|
return &getScriptResultResponse{
|
|
ScriptContents: scriptResult.ScriptContents,
|
|
ScriptID: scriptResult.ScriptID,
|
|
ExitCode: scriptResult.ExitCode,
|
|
Output: scriptResult.Output,
|
|
Message: scriptResult.Message,
|
|
HostName: scriptResult.Hostname,
|
|
HostTimeout: hostTimeout,
|
|
HostID: scriptResult.HostID,
|
|
ExecutionID: scriptResult.ExecutionID,
|
|
Runtime: scriptResult.Runtime,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Create a (saved) script (via a multipart file upload)
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type createScriptRequest struct {
|
|
TeamID *uint
|
|
Script *multipart.FileHeader
|
|
}
|
|
|
|
func (createScriptRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
var decoded createScriptRequest
|
|
|
|
err := r.ParseMultipartForm(512 * units.MiB) // same in-memory size as for other multipart requests we have
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
val := r.MultipartForm.Value["team_id"]
|
|
if len(val) > 0 {
|
|
teamID, err := strconv.ParseUint(val[0], 10, 64)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode team_id in multipart form: %s", err.Error())}
|
|
}
|
|
decoded.TeamID = ptr.Uint(uint(teamID))
|
|
}
|
|
|
|
fhs, ok := r.MultipartForm.File["script"]
|
|
if !ok || len(fhs) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for script"}
|
|
}
|
|
decoded.Script = fhs[0]
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
type createScriptResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
ScriptID uint `json:"script_id,omitempty"`
|
|
}
|
|
|
|
func (r createScriptResponse) error() error { return r.Err }
|
|
|
|
func createScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*createScriptRequest)
|
|
|
|
scriptFile, err := req.Script.Open()
|
|
if err != nil {
|
|
return &createScriptResponse{Err: err}, nil
|
|
}
|
|
defer scriptFile.Close()
|
|
|
|
script, err := svc.NewScript(ctx, req.TeamID, filepath.Base(req.Script.Filename), scriptFile)
|
|
if err != nil {
|
|
return createScriptResponse{Err: err}, nil
|
|
}
|
|
return createScriptResponse{ScriptID: script.ID}, nil
|
|
}
|
|
|
|
func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete a (saved) script
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteScriptRequest struct {
|
|
ScriptID uint `url:"script_id"`
|
|
}
|
|
|
|
type deleteScriptResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteScriptResponse) error() error { return r.Err }
|
|
func (r deleteScriptResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deleteScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*deleteScriptRequest)
|
|
err := svc.DeleteScript(ctx, req.ScriptID)
|
|
if err != nil {
|
|
return deleteScriptResponse{Err: err}, nil
|
|
}
|
|
return deleteScriptResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List (saved) scripts (paginated)
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listScriptsRequest struct {
|
|
TeamID *uint `query:"team_id,optional"`
|
|
ListOptions fleet.ListOptions `url:"list_options"`
|
|
}
|
|
|
|
type listScriptsResponse struct {
|
|
Meta *fleet.PaginationMetadata `json:"meta"`
|
|
Scripts []*fleet.Script `json:"scripts"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listScriptsResponse) error() error { return r.Err }
|
|
|
|
func listScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*listScriptsRequest)
|
|
scripts, meta, err := svc.ListScripts(ctx, req.TeamID, req.ListOptions)
|
|
if err != nil {
|
|
return listScriptsResponse{Err: err}, nil
|
|
}
|
|
return listScriptsResponse{
|
|
Meta: meta,
|
|
Scripts: scripts,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get/download a (saved) script
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getScriptRequest struct {
|
|
ScriptID uint `url:"script_id"`
|
|
Alt string `query:"alt,optional"`
|
|
}
|
|
|
|
type getScriptResponse struct {
|
|
*fleet.Script
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getScriptResponse) error() error { return r.Err }
|
|
|
|
type downloadFileResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
filename string
|
|
content []byte
|
|
contentType string // optional, defaults to application/octet-stream
|
|
}
|
|
|
|
func (r downloadFileResponse) error() error { return r.Err }
|
|
|
|
func (r downloadFileResponse) hijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(r.content)))
|
|
if r.contentType == "" {
|
|
r.contentType = "application/octet-stream"
|
|
}
|
|
w.Header().Set("Content-Type", r.contentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.filename))
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// OK to just log the error here as writing anything on
|
|
// `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the
|
|
// header provided
|
|
if n, err := w.Write(r.content); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
|
|
}
|
|
}
|
|
|
|
func getScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*getScriptRequest)
|
|
|
|
downloadRequested := req.Alt == "media"
|
|
script, content, err := svc.GetScript(ctx, req.ScriptID, downloadRequested)
|
|
if err != nil {
|
|
return getScriptResponse{Err: err}, nil
|
|
}
|
|
|
|
if downloadRequested {
|
|
return downloadFileResponse{
|
|
content: content,
|
|
filename: fmt.Sprintf("%s %s", time.Now().Format(time.DateOnly), script.Name),
|
|
}, nil
|
|
}
|
|
return getScriptResponse{Script: script}, nil
|
|
}
|
|
|
|
func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get Host Script Details
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostScriptDetailsRequest struct {
|
|
HostID uint `url:"id"`
|
|
ListOptions fleet.ListOptions `url:"list_options"`
|
|
}
|
|
|
|
type getHostScriptDetailsResponse struct {
|
|
Scripts []*fleet.HostScriptDetail `json:"scripts"`
|
|
Meta *fleet.PaginationMetadata `json:"meta"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostScriptDetailsResponse) error() error { return r.Err }
|
|
|
|
func getHostScriptDetailsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*getHostScriptDetailsRequest)
|
|
scripts, meta, err := svc.GetHostScriptDetails(ctx, req.HostID, req.ListOptions)
|
|
if err != nil {
|
|
return getHostScriptDetailsResponse{Err: err}, nil
|
|
}
|
|
return getHostScriptDetailsResponse{
|
|
Scripts: scripts,
|
|
Meta: meta,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Batch Replace Scripts
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type batchSetScriptsRequest struct {
|
|
TeamID *uint `json:"-" query:"team_id,optional"`
|
|
TeamName *string `json:"-" query:"team_name,optional"`
|
|
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
|
Scripts []fleet.ScriptPayload `json:"scripts"`
|
|
}
|
|
|
|
type batchSetScriptsResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r batchSetScriptsResponse) error() error { return r.Err }
|
|
|
|
func (r batchSetScriptsResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func batchSetScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*batchSetScriptsRequest)
|
|
if err := svc.BatchSetScripts(ctx, req.TeamID, req.TeamName, req.Scripts, req.DryRun); err != nil {
|
|
return batchSetScriptsResponse{Err: err}, nil
|
|
}
|
|
return batchSetScriptsResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|