Script Timeout Agent Options Part 1 of 2 (#20266)

This commit is contained in:
Tim Lee
2024-07-10 14:33:39 -06:00
committed by GitHub
parent fc12b24851
commit 5ca22df90c
16 changed files with 163 additions and 28 deletions
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
@@ -270,10 +271,10 @@ Output:
scriptResult: &fleet.HostScriptResult{
ExitCode: ptr.Int64(-1),
Output: "Oh no!",
Message: fleet.RunScriptScriptTimeoutErrMsg,
Message: fleet.HostScriptTimeoutMessage(ptr.Int(int(scripts.MaxHostExecutionTime.Seconds()))),
},
expectOutput: `
Error: Timeout. Fleet stopped the script after 5 minutes to protect host performance.
Error: Timeout. Fleet stopped the script after 300 seconds to protect host performance.
Output before timeout:
+4 -3
View File
@@ -13,7 +13,6 @@ import (
"unicode/utf8"
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
)
@@ -31,6 +30,7 @@ type Client interface {
type Runner struct {
Client Client
ScriptExecutionEnabled bool
ScriptExecutionTimeout time.Duration
// tempDirFn is the function to call to get the temporary directory to use,
// inside of which the script-specific subdirectories will be created. If nil,
@@ -114,7 +114,7 @@ func (r *Runner) runOne(script *fleet.HostScriptResult) (finalErr error) {
return fmt.Errorf("write script file: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), scripts.MaxHostExecutionTime)
ctx, cancel := context.WithTimeout(context.Background(), r.ScriptExecutionTimeout)
defer cancel()
execCmdFn := r.execCmdFn
@@ -122,7 +122,7 @@ func (r *Runner) runOne(script *fleet.HostScriptResult) (finalErr error) {
execCmdFn = ExecCmd
}
start := time.Now()
log.Debug().Msgf("starting script execution of %v", script.ExecutionID)
log.Debug().Msgf("starting script execution of %v with timeout of %v", script.ExecutionID, r.ScriptExecutionTimeout)
output, exitCode, execErr := execCmdFn(ctx, scriptFile, nil)
log.Debug().Msgf("after script execution of %v", script.ExecutionID)
duration := time.Since(start)
@@ -144,6 +144,7 @@ func (r *Runner) runOne(script *fleet.HostScriptResult) (finalErr error) {
Output: string(output),
Runtime: int(duration.Seconds()),
ExitCode: exitCode,
Timeout: int(r.ScriptExecutionTimeout.Seconds()),
})
if err != nil {
return fmt.Errorf("save script result: %w", err)
+7
View File
@@ -10,6 +10,7 @@ import (
"github.com/fleetdm/fleet/v4/orbit/pkg/bitlocker"
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
fleetscripts "github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
)
@@ -346,6 +347,11 @@ func (h *runScriptsConfigReceiver) runDynamicScriptsEnabledCheck() {
// server sent a list of scripts to execute, starts a goroutine to execute
// them.
func (h *runScriptsConfigReceiver) Run(cfg *fleet.OrbitConfig) error {
timeout := fleetscripts.MaxHostExecutionTime
if cfg.ScriptExeTimeout > 0 {
timeout = time.Duration(cfg.ScriptExeTimeout) * time.Second
}
if len(cfg.Notifications.PendingScriptExecutionIDs) > 0 {
if h.mu.TryLock() {
log.Debug().Msgf("received request to run scripts %v", cfg.Notifications.PendingScriptExecutionIDs)
@@ -353,6 +359,7 @@ func (h *runScriptsConfigReceiver) Run(cfg *fleet.OrbitConfig) error {
runner := &scripts.Runner{
ScriptExecutionEnabled: h.scriptsEnabled(),
Client: h.ScriptsClient,
ScriptExecutionTimeout: timeout,
}
fn := runner.Run
if h.runScriptsFn != nil {
@@ -0,0 +1,38 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20240709183940, Down_20240709183940)
}
// At the time of this migration, all script timeouts are
// hardcoded to 300 seconds. This migration adds a timeout
// column to the host_script_results table to allow for
// custom timeouts.
func Up_20240709183940(tx *sql.Tx) error {
stmt := `
ALTER TABLE host_script_results
ADD COLUMN timeout INT DEFAULT NULL;
`
if _, err := tx.Exec(stmt); err != nil {
return err
}
stmt = `
UPDATE host_script_results
SET timeout = 300
WHERE timeout IS NULL;
`
if _, err := tx.Exec(stmt); err != nil {
return err
}
return nil
}
func Down_20240709183940(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,34 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20240709183940(t *testing.T) {
db := applyUpToPrev(t)
insertStmt := `
INSERT INTO host_script_results
(host_id, execution_id, output)
VALUES (?, ?, ?)
`
_, err := db.Exec(insertStmt, 1, 1, "output")
require.NoError(t, err)
applyNext(t, db)
selectStmt := `
SELECT timeout FROM host_script_results
WHERE host_id = ?
`
var timeout int
err = db.QueryRow(selectStmt, 1).Scan(&timeout)
require.NoError(t, err)
require.Equal(t, 300, timeout)
// inserting no timeout succeeds
_, err = db.Exec(insertStmt, 2, 2, "output")
require.NoError(t, err)
}
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -96,7 +96,8 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
UPDATE host_script_results SET
output = ?,
runtime = ?,
exit_code = ?
exit_code = ?,
timeout = ?
WHERE
host_id = ? AND
execution_id = ?`
@@ -138,6 +139,7 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
// it to a 32-bit signed integer.
// See /orbit/pkg/scripts/exec_windows.go
int32(result.ExitCode),
result.Timeout,
result.HostID,
result.ExecutionID,
)
@@ -236,6 +238,7 @@ func (ds *Datastore) getHostScriptExecutionResultDB(ctx context.Context, q sqlx.
hsr.output,
hsr.runtime,
hsr.exit_code,
hsr.timeout,
hsr.created_at,
hsr.user_id,
hsr.sync_request,
+5
View File
@@ -93,6 +93,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
Output: "foo",
Runtime: 2,
ExitCode: 0,
Timeout: 300,
})
require.NoError(t, err)
@@ -103,6 +104,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
Output: "foobarbaz",
Runtime: 22,
ExitCode: 1,
Timeout: 360,
})
require.NoError(t, err)
require.Nil(t, hsr)
@@ -119,6 +121,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
expectScript.Output = "foo"
expectScript.Runtime = 2
expectScript.ExitCode = ptr.Int64(0)
expectScript.Timeout = ptr.Int(300)
require.Equal(t, &expectScript, script)
// create another script execution request (null user id this time)
@@ -166,6 +169,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
Output: largeOutput,
Runtime: 10,
ExitCode: 1,
Timeout: 300,
})
require.NoError(t, err)
@@ -240,6 +244,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
Output: "foo",
Runtime: 1,
ExitCode: math.MaxUint32,
Timeout: 300,
})
require.NoError(t, err)
require.EqualValues(t, -1, *unsignedScriptResult.ExitCode)
+8
View File
@@ -11,7 +11,11 @@ import (
//go:generate go run ../../tools/osquery-agent-options agent_options_generated.go
const maxAgentScriptExecutionTimeout = 3600
type AgentOptions struct {
// ScriptExecutionTimeout is the maximum time in seconds that a script can run.
ScriptExecutionTimeout int `json:"script_execution_timeout,omitempty"`
// Config is the base config options.
Config json.RawMessage `json:"config"`
// Overrides includes any platform-based overrides.
@@ -49,6 +53,10 @@ func ValidateJSONAgentOptions(ctx context.Context, ds Datastore, rawJSON json.Ra
return err
}
if opts.ScriptExecutionTimeout > maxAgentScriptExecutionTimeout {
return fmt.Errorf("'script_execution_timeout' value exceeds limit. Maximum value is %d", maxAgentScriptExecutionTimeout)
}
if len(opts.CommandLineStartUpFlags) > 0 {
var flags osqueryCommandLineFlags
if err := JSONStrictDecode(bytes.NewReader(opts.CommandLineStartUpFlags), &flags); err != nil {
+4
View File
@@ -28,6 +28,10 @@ func TestValidateAgentOptions(t *testing.T) {
}
}}`, true, `unknown field "foo"`},
{"valid script timeout", `{"script_execution_timeout": 600}`, true, ""},
{"invalid script timeout", `{"script_execution_timeout": 3601}`, true, `script_execution_timeout' value exceeds limit. Maximum value is 3600`},
{"overrides.platform is null", `{"overrides": {
"platforms": {
"darwin": null
-1
View File
@@ -550,7 +550,6 @@ const (
RunScriptScriptsDisabledGloballyErrMsg = "Running scripts is disabled in organization settings."
RunScriptDisabledErrMsg = "Scripts are disabled for this host. To run scripts, deploy the fleetd agent with scripts enabled."
RunScriptsOrbitDisabledErrMsg = "Couldn't run script. To run a script, deploy the fleetd agent with --enable-scripts."
RunScriptScriptTimeoutErrMsg = "Timeout. Fleet stopped the script after 5 minutes to protect host performance."
RunScriptAsyncScriptEnqueuedErrMsg = "Script is running or will run when the host comes online."
RunScripSavedMaxLenErrMsg = "Script is too large. It's limited to 500,000 characters (approximately 10,000 lines)."
RunScripUnsavedMaxLenErrMsg = "Script is too large. It's limited to 10,000 characters (approximately 125 lines)."
+5 -4
View File
@@ -39,10 +39,11 @@ type OrbitConfigNotifications struct {
}
type OrbitConfig struct {
Flags json.RawMessage `json:"command_line_startup_flags,omitempty"`
Extensions json.RawMessage `json:"extensions,omitempty"`
NudgeConfig *NudgeConfig `json:"nudge_config,omitempty"`
Notifications OrbitConfigNotifications `json:"notifications,omitempty"`
ScriptExeTimeout int `json:"script_execution_timeout,omitempty"`
Flags json.RawMessage `json:"command_line_startup_flags,omitempty"`
Extensions json.RawMessage `json:"extensions,omitempty"`
NudgeConfig *NudgeConfig `json:"nudge_config,omitempty"`
Notifications OrbitConfigNotifications `json:"notifications,omitempty"`
// UpdateChannels contains the TUF channels to use on fleetd components.
//
// If UpdateChannels is nil it means the server isn't using/setting this feature.
+17 -2
View File
@@ -3,6 +3,7 @@ package fleet
import (
"bufio"
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
@@ -195,6 +196,7 @@ type HostScriptResultPayload struct {
Output string `json:"output"`
Runtime int `json:"runtime"`
ExitCode int `json:"exit_code"`
Timeout int `json:"timeout"`
}
// HostScriptResult represents a script result that was requested to execute on
@@ -218,6 +220,9 @@ 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 *int64 `json:"exit_code" db:"exit_code"`
// Timeout is the maximum time in seconds that the script was allowed to run
// at the time of execution.
Timeout *int `json:"timeout" db:"timeout"`
// 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.
@@ -266,7 +271,7 @@ func (hsr HostScriptResult) AuthzType() string {
// 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 {
func (hsr HostScriptResult) UserMessage(hostTimeout bool, hostTimeoutValue *int) string {
if hostTimeout {
return RunScriptHostTimeoutErrMsg
}
@@ -285,7 +290,7 @@ func (hsr HostScriptResult) UserMessage(hostTimeout bool) string {
switch *hsr.ExitCode {
case -1:
return RunScriptScriptTimeoutErrMsg
return HostScriptTimeoutMessage(hostTimeoutValue)
case -2:
return RunScriptDisabledErrMsg
default:
@@ -293,6 +298,16 @@ func (hsr HostScriptResult) UserMessage(hostTimeout bool) string {
}
}
func HostScriptTimeoutMessage(seconds *int) string {
var timeout int
if seconds == nil {
timeout = int(scripts.MaxHostExecutionTime.Seconds())
} else {
timeout = *seconds
}
return fmt.Sprintf("Timeout. Fleet stopped the script after %d seconds to protect host performance.", timeout)
}
func (hsr HostScriptResult) HostTimeout(waitForResultTime time.Duration) bool {
return hsr.SyncRequest && hsr.ExitCode == nil && time.Now().After(hsr.CreatedAt.Add(waitForResultTime))
}
+17 -1
View File
@@ -25,6 +25,7 @@ import (
"github.com/fleetdm/fleet/v4/ee/server/calendar"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/cron"
@@ -5566,6 +5567,21 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
require.Contains(t, extractServerErrorText(res.Body), fleet.RunScriptDisabledErrMsg)
res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: plainOsqueryHost.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), fleet.RunScriptDisabledErrMsg)
// create a execution request that will return a timeout
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted, &runResp)
// simulate a host response
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": -1, "output": "script execution error: signal: killed", "timeout": 900}`, *host.OrbitNodeKey, runSyncResp.ExecutionID)),
http.StatusOK, &orbitPostScriptResp)
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runSyncResp.ExecutionID, nil, http.StatusOK, &scriptResultResp)
require.Equal(t, host.ID, scriptResultResp.HostID)
require.Equal(t, "echo", scriptResultResp.ScriptContents)
require.Equal(t, int64(-1), *scriptResultResp.ExitCode)
require.Equal(t, "Timeout. Fleet stopped the script after 900 seconds to protect host performance.", scriptResultResp.Message)
require.Equal(t, "script execution error: signal: killed", scriptResultResp.Output)
}
func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
@@ -6733,7 +6749,7 @@ VALUES
name: "script-timeout",
exitCode: ptr.Int64(-1),
executedAt: now.Add(-1 * time.Hour),
expected: fleet.RunScriptScriptTimeoutErrMsg,
expected: fleet.HostScriptTimeoutMessage(ptr.Int(int(scripts.MaxHostExecutionTime.Seconds()))),
},
{
name: "pending",
+12 -10
View File
@@ -324,11 +324,12 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
}
return fleet.OrbitConfig{
Flags: opts.CommandLineStartUpFlags,
Extensions: extensionsFiltered,
Notifications: notifs,
NudgeConfig: nudgeConfig,
UpdateChannels: updateChannels,
ScriptExeTimeout: opts.ScriptExecutionTimeout,
Flags: opts.CommandLineStartUpFlags,
Extensions: extensionsFiltered,
Notifications: notifs,
NudgeConfig: nudgeConfig,
UpdateChannels: updateChannels,
}, nil
}
@@ -386,11 +387,12 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
}
return fleet.OrbitConfig{
Flags: opts.CommandLineStartUpFlags,
Extensions: extensionsFiltered,
Notifications: notifs,
NudgeConfig: nudgeConfig,
UpdateChannels: updateChannels,
ScriptExeTimeout: opts.ScriptExecutionTimeout,
Flags: opts.CommandLineStartUpFlags,
Extensions: extensionsFiltered,
Notifications: notifs,
NudgeConfig: nudgeConfig,
UpdateChannels: updateChannels,
}, nil
}
+2 -2
View File
@@ -114,7 +114,7 @@ func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.S
// response struct.
hostTimeout = true
}
result.Message = result.UserMessage(hostTimeout)
result.Message = result.UserMessage(hostTimeout, result.Timeout)
return runScriptSyncResponse{
HostScriptResult: result,
HostTimeout: hostTimeout,
@@ -371,7 +371,7 @@ func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet
// 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)
scriptResult.Message = scriptResult.UserMessage(hostTimeout, scriptResult.Timeout)
return &getScriptResultResponse{
ScriptContents: scriptResult.ScriptContents,