Adjust response payload, messages and validations for /scripts/run/* endpoints. (#13607)
This commit is contained in:
@@ -625,7 +625,7 @@ func (a *agent) execScripts(execIDs []string, orbitClient *service.OrbitClient)
|
||||
// send a no-op result without executing if script exec is disabled
|
||||
if err := orbitClient.SaveHostScriptResult(&fleet.HostScriptResultPayload{
|
||||
ExecutionID: execID,
|
||||
Output: "script execution disabled",
|
||||
Output: "Scripts are disabled",
|
||||
Runtime: 0,
|
||||
ExitCode: -2,
|
||||
}); err != nil {
|
||||
|
||||
@@ -6212,7 +6212,9 @@ Creates a script execution request and waits for a result to return (up to a 1 m
|
||||
"execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002",
|
||||
"script_contents": "echo 'hello'",
|
||||
"output": "hello",
|
||||
"message": "",
|
||||
"runtime": 1,
|
||||
"host_timeout": false,
|
||||
"exit_code": 0
|
||||
}
|
||||
```
|
||||
|
||||
+13
-18
@@ -3,7 +3,7 @@ package service
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -62,33 +62,32 @@ func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScript
|
||||
// look for the script length in bytes first, as rune counting a huge string
|
||||
// can be expensive.
|
||||
if len(request.ScriptContents) > utf8.UTFMax*maxScriptRuneLen {
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", fmt.Sprintf("script is too long, must be at most %d characters", maxScriptRuneLen))
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", "Error: Script is too large. It's limited to 10,000 characters (approximately 125 lines).")
|
||||
}
|
||||
// now that we know that the script is at most 4*maxScriptRuneLen bytes long,
|
||||
// we can safely count the runes for a precise check.
|
||||
if utf8.RuneCountInString(request.ScriptContents) > maxScriptRuneLen {
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", fmt.Sprintf("script is too long, must be at most %d characters", maxScriptRuneLen))
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", "Error: Script is too large. It's limited to 10,000 characters (approximately 125 lines).")
|
||||
}
|
||||
|
||||
// script must be a "text file", but that's not so simple to validate, so we
|
||||
// assume that if it is valid utf8 encoding, it is a text file (binary files
|
||||
// will often have invalid utf8 byte sequences).
|
||||
if !utf8.ValidString(request.ScriptContents) {
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", "script must be a valid utf8-encoded text file")
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", "Error: Wrong data format. Only plain text allowed.")
|
||||
}
|
||||
if strings.HasPrefix(request.ScriptContents, "#!") {
|
||||
// read the first line in a portable way
|
||||
s := bufio.NewScanner(strings.NewReader(request.ScriptContents))
|
||||
// if a hashbang is present, it can only be `/bin/sh` for now
|
||||
if s.Scan() && !scriptHashbangValidation.MatchString(s.Text()) {
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", "script cannot start with a hashbang (#!) other than #!/bin/sh")
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", `Error: Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`)
|
||||
}
|
||||
}
|
||||
|
||||
// host must be online if a "sync" script execution is requested (i.e. if we
|
||||
// will poll to get and return results).
|
||||
if waitForResult > 0 && host.Status(time.Now()) != fleet.StatusOnline {
|
||||
return nil, fleet.NewInvalidArgumentError("host_id", "host is offline")
|
||||
// host must be online
|
||||
if host.Status(time.Now()) != fleet.StatusOnline {
|
||||
return nil, fleet.NewInvalidArgumentError("host_id", "Error: Script can't run on offline host.")
|
||||
}
|
||||
|
||||
pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID, maxPendingScriptAge)
|
||||
@@ -96,15 +95,9 @@ func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScript
|
||||
return nil, ctxerr.Wrap(ctx, err, "list host pending script executions")
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
// TODO(mna): there are a number of issues with that validation: it only
|
||||
// really says that there was a script execution _request_ that was made < 1m
|
||||
// ago, and that blocks executing any more scripts on that host, but the
|
||||
// host may not even have received the previous script for execution yet,
|
||||
// so if we accept more scripts after 1m, we may end up having multiple
|
||||
// scripts to execute on the host at the same time (or more likely in
|
||||
// sequence, but still). This may be good enough for now, I think the whole
|
||||
// idea of locking if a script is pending is meant to be temporary anyway.
|
||||
return nil, fleet.NewInvalidArgumentError("script_contents", "a script is currently executing on the host")
|
||||
return nil, fleet.NewInvalidArgumentError(
|
||||
"script_contents", "Error: A script is already running on this host. Please wait about 1 minute to let it finish.",
|
||||
).WithStatus(http.StatusConflict)
|
||||
}
|
||||
|
||||
// create the script execution request, the host will be notified of the
|
||||
@@ -113,6 +106,7 @@ func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScript
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "create script execution request")
|
||||
}
|
||||
script.Hostname = host.DisplayName()
|
||||
|
||||
if waitForResult <= 0 {
|
||||
// async execution, return
|
||||
@@ -143,6 +137,7 @@ func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScript
|
||||
}
|
||||
if result.ExitCode.Valid {
|
||||
// a result was received from the host, return
|
||||
result.Hostname = host.DisplayName()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ func (r *Runner) createRunDir(execID string) (string, error) {
|
||||
func (r *Runner) runOneDisabled(execID string) error {
|
||||
err := r.Client.SaveHostScriptResult(&fleet.HostScriptResultPayload{
|
||||
ExecutionID: execID,
|
||||
Output: "script execution disabled",
|
||||
Output: "Scripts are disabled",
|
||||
ExitCode: -2, // fleetctl knows that -2 means script was disabled on host
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -4183,7 +4183,7 @@ func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []strin
|
||||
func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
|
||||
const (
|
||||
insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_contents, output) VALUES (?, ?, ?, '')`
|
||||
getStmt = `SELECT id, host_id, execution_id, script_contents FROM host_script_results WHERE id = ?`
|
||||
getStmt = `SELECT id, host_id, execution_id, script_contents, created_at FROM host_script_results WHERE id = ?`
|
||||
)
|
||||
|
||||
execID := uuid.New().String()
|
||||
@@ -4269,7 +4269,8 @@ func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID st
|
||||
script_contents,
|
||||
output,
|
||||
runtime,
|
||||
exit_code
|
||||
exit_code,
|
||||
created_at
|
||||
FROM
|
||||
host_script_results
|
||||
WHERE
|
||||
|
||||
@@ -1109,12 +1109,41 @@ type HostScriptResult struct {
|
||||
// host. It is -1 if it was received but the script did not terminate
|
||||
// normally (same as how Go handles this: https://pkg.go.dev/os#ProcessState.ExitCode)
|
||||
ExitCode sql.NullInt64 `json:"exit_code" db:"exit_code"`
|
||||
// CreatedAt is the creation timestamp of the script execution request. It is
|
||||
// not returned as part of the payloads, but is used to determine if the script
|
||||
// is too old to still expect a response from the host.
|
||||
CreatedAt time.Time `json:"-" db:"created_at"`
|
||||
|
||||
// TeamID is only used for authorization, it must be set to the team id of
|
||||
// the host when checking authorization and is otherwise not set.
|
||||
TeamID *uint `json:"team_id" db:"-"`
|
||||
|
||||
// Hostname can be set by the endpoint as extra information to make available
|
||||
// when generating the UserMessage associated with a response from an
|
||||
// execution. It is otherwise not part of the host_script_results table and
|
||||
// not returned as part of the resulting JSON.
|
||||
Hostname string `json:"-" db:"-"`
|
||||
}
|
||||
|
||||
func (hsr HostScriptResult) AuthzType() string {
|
||||
return "host_script_result"
|
||||
}
|
||||
|
||||
// UserMessage returns the user-friendly message to associate with the current
|
||||
// state of the HostScriptResult. This is returned as part of the API endpoints
|
||||
// for running a script synchronously (so that fleetctl can display it) and to
|
||||
// get the script results for an execution ID (e.g. when looking at the details
|
||||
// screen of a script execution activity in the website).
|
||||
func (hsr HostScriptResult) UserMessage(hostTimeout bool) string {
|
||||
switch {
|
||||
case hostTimeout:
|
||||
return "Error: Fleet hasn't heard from the host in over 1 minute because it went offline. Run the script again when the host comes back online."
|
||||
case !hostTimeout && time.Since(hsr.CreatedAt) > time.Minute:
|
||||
return "Error: Fleet hasn't heard from the host in over 1 minute because it went offline. Run the script again when the host comes back online."
|
||||
case hsr.ExitCode.Int64 == -1:
|
||||
return "Error: Timeout. Fleet stopped the script after 30 seconds to protect host performance."
|
||||
case !hsr.ExitCode.Valid:
|
||||
return "Script is running. To see if the script finished, close this modal and open it again."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1632,13 +1632,13 @@ type runScriptSyncResponse struct {
|
||||
Err error `json:"error,omitempty"`
|
||||
*fleet.HostScriptResult
|
||||
|
||||
// only set if the error was a timeout waiting for a result
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Message string `json:"message"`
|
||||
HostTimeout bool `json:"host_timeout"`
|
||||
}
|
||||
|
||||
func (r runScriptSyncResponse) error() error { return r.Err }
|
||||
func (r runScriptSyncResponse) Status() int {
|
||||
if r.ErrorMessage != "" {
|
||||
if r.HostTimeout {
|
||||
return http.StatusGatewayTimeout
|
||||
}
|
||||
return http.StatusOK
|
||||
@@ -1660,23 +1660,22 @@ func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.S
|
||||
}, waitForResult)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
err = fleet.NewGatewayTimeoutError("script execution timed out waiting for a result", err)
|
||||
// it should still return the execution id and host id in this situation,
|
||||
// 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. This is why we use the distinct ErrorMessage field to
|
||||
// add the error message and status code to the response, along with the
|
||||
// script request.
|
||||
// response struct.
|
||||
return runScriptSyncResponse{
|
||||
HostScriptResult: result,
|
||||
ErrorMessage: err.Error(),
|
||||
HostTimeout: true,
|
||||
Message: result.UserMessage(true),
|
||||
}, nil
|
||||
}
|
||||
return runScriptSyncResponse{Err: err}, nil
|
||||
}
|
||||
return runScriptSyncResponse{
|
||||
HostScriptResult: result,
|
||||
Message: result.UserMessage(false),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1242,14 +1242,14 @@ func TestHostRunScript(t *testing.T) {
|
||||
wantErr string
|
||||
}{
|
||||
{"empty script", "", "a script to execute is required"},
|
||||
{"overly long script", strings.Repeat("a", 10001), "script is too long"},
|
||||
{"invalid utf8", "\xff\xfa", "must be a valid utf8-encoded text file"},
|
||||
{"overly long script", strings.Repeat("a", 10001), "Script is too large."},
|
||||
{"invalid utf8", "\xff\xfa", "Wrong data format."},
|
||||
{"valid without hashbang", "echo 'a'", ""},
|
||||
{"valid with hashbang", "#!/bin/sh\necho 'a'", ""},
|
||||
{"valid with hashbang and spacing", "#! /bin/sh \necho 'a'", ""},
|
||||
{"valid with hashbang and Windows newline", "#! /bin/sh \r\necho 'a'", ""},
|
||||
{"invalid hashbang", "#!/bin/bash\necho 'a'", "cannot start with a hashbang"},
|
||||
{"invalid hashbang suffix", "#!/bin/sh -n\necho 'a'", "cannot start with a hashbang"},
|
||||
{"invalid hashbang", "#!/bin/bash\necho 'a'", "Interpreter not supported."},
|
||||
{"invalid hashbang suffix", "#!/bin/sh -n\necho 'a'", "Interpreter not supported."},
|
||||
}
|
||||
|
||||
ctx = viewer.NewContext(ctx, viewer.Viewer{User: test.UserAdmin})
|
||||
|
||||
@@ -3675,7 +3675,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
||||
// attempt to run an overly long script
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", 10001)}, http.StatusUnprocessableEntity)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "script is too long")
|
||||
require.Contains(t, errMsg, "Script is too large.")
|
||||
|
||||
// make sure the host is still seen as "online"
|
||||
err := s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
|
||||
@@ -3730,7 +3730,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
||||
// attempt to sync run an overly long script
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", 10001)}, http.StatusUnprocessableEntity)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "script is too long")
|
||||
require.Contains(t, errMsg, "Script is too large.")
|
||||
|
||||
// make sure the host is still seen as "online"
|
||||
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now())
|
||||
@@ -3738,9 +3738,9 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
||||
|
||||
// attempt to create a valid sync script execution request, fails because the
|
||||
// host has a pending script execution
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusConflict)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "a script is currently executing on the host")
|
||||
require.Contains(t, errMsg, "A script is already running on this host.")
|
||||
|
||||
// save a result via the orbit endpoint
|
||||
var orbitPostScriptResp orbitPostScriptResultResponse
|
||||
@@ -3761,7 +3761,8 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
||||
s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusGatewayTimeout, &runSyncResp)
|
||||
require.Equal(t, host.ID, runSyncResp.HostID)
|
||||
require.NotEmpty(t, runSyncResp.ExecutionID)
|
||||
require.Contains(t, runSyncResp.ErrorMessage, "script execution timed out waiting for a result")
|
||||
require.True(t, runSyncResp.HostTimeout)
|
||||
require.Contains(t, runSyncResp.Message, "Fleet hasn't heard from the host in over 1 minute because it went offline.")
|
||||
|
||||
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
|
||||
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, runSyncResp.ExecutionID)),
|
||||
@@ -3804,7 +3805,8 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
||||
require.Equal(t, "ok", runSyncResp.Output)
|
||||
require.True(t, runSyncResp.ExitCode.Valid)
|
||||
require.Equal(t, int64(0), runSyncResp.ExitCode.Int64)
|
||||
require.Empty(t, runSyncResp.ErrorMessage)
|
||||
require.False(t, runSyncResp.HostTimeout)
|
||||
require.Empty(t, runSyncResp.Message)
|
||||
|
||||
// make the host "offline"
|
||||
err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now().Add(-time.Hour))
|
||||
@@ -3814,5 +3816,11 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
|
||||
// is offline.
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "host is offline")
|
||||
require.Contains(t, errMsg, "Script can't run on offline host.")
|
||||
|
||||
// attempt to create an async script execution request, fails because the host
|
||||
// is offline.
|
||||
res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
|
||||
errMsg = extractServerErrorText(res.Body)
|
||||
require.Contains(t, errMsg, "Script can't run on offline host.")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user