Implement script execution on the fleetd agent (disabled by default) (#13569)

This commit is contained in:
Martin Angers 2023-08-30 14:02:44 -04:00 committed by GitHub
parent c0cb278a1f
commit 090b142c49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1037 additions and 4 deletions

View File

@ -0,0 +1 @@
* Added support in fleetd to execute scripts and send back results (disabled by default).

View File

@ -625,9 +625,9 @@ func (a *agent) execScripts(execIDs []string, orbitClient *service.OrbitClient)
// send a no-op result without executing if script exec is disabled // send a no-op result without executing if script exec is disabled
if err := orbitClient.SaveHostScriptResult(&fleet.HostScriptResultPayload{ if err := orbitClient.SaveHostScriptResult(&fleet.HostScriptResultPayload{
ExecutionID: execID, ExecutionID: execID,
Output: "script execution is disabled", Output: "script execution disabled",
Runtime: 0, Runtime: 0,
ExitCode: -1, ExitCode: -2,
}); err != nil { }); err != nil {
log.Println("save disabled host script result:", err) log.Println("save disabled host script result:", err)
return return

View File

@ -624,6 +624,7 @@ func main() {
windowsMDMEnrollmentCommandFrequency = time.Hour windowsMDMEnrollmentCommandFrequency = time.Hour
) )
configFetcher := update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL) configFetcher := update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL)
configFetcher = update.ApplyRunScriptsConfigFetcherMiddleware(configFetcher, c.Bool("enable-scripts"), orbitClient)
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":

View File

@ -0,0 +1,22 @@
//go:build !windows
package scripts
import (
"context"
"os/exec"
"path/filepath"
)
func execCmd(ctx context.Context, scriptPath string) (output []byte, exitCode int, err error) {
// initialize to -1 in case the process never starts
exitCode = -1
cmd := exec.CommandContext(ctx, "/bin/sh", scriptPath)
cmd.Dir = filepath.Dir(scriptPath)
output, err = cmd.CombinedOutput()
if cmd.ProcessState != nil {
exitCode = cmd.ProcessState.ExitCode()
}
return output, exitCode, err
}

View File

@ -0,0 +1,23 @@
//go:build windows
package scripts
import (
"context"
"os/exec"
"path/filepath"
)
func execCmd(ctx context.Context, scriptPath string) (output []byte, exitCode int, err error) {
// initialize to -1 in case the process never starts
exitCode = -1
// for Windows, we execute the file with powershell.
cmd := exec.CommandContext(ctx, "powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
cmd.Dir = filepath.Dir(scriptPath)
output, err = cmd.CombinedOutput()
if cmd.ProcessState != nil {
exitCode = cmd.ProcessState.ExitCode()
}
return output, exitCode, err
}

View File

@ -0,0 +1,181 @@
// Package scripts implements support to execute scripts on the host when
// requested by the Fleet server.
package scripts
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"unicode/utf8"
"github.com/fleetdm/fleet/v4/server/fleet"
)
const scriptExecTimeout = 30 * time.Second
// Client defines the methods required for the API requests to the server. The
// fleet.OrbitClient type satisfies this interface.
type Client interface {
GetHostScript(execID string) (*fleet.HostScriptResult, error)
SaveHostScriptResult(result *fleet.HostScriptResultPayload) error
}
// Runner is the type that processes scripts to execute, taking care of
// retrieving each script, saving it in a temporary directory, executing it and
// saving the results.
type Runner struct {
Client Client
ScriptExecutionEnabled bool
// 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,
// the user's temp dir will be used (can be set to t.TempDir in tests).
tempDirFn func() string
// execCmdFn can be set for tests to mock actual execution of the script. If
// nil, execCmd will be used, which has a different implementation on Windows
// and non-Windows platforms.
execCmdFn func(ctx context.Context, scriptPath string) ([]byte, int, error)
// can be set for tests to replace os.RemoveAll, which is called to remove
// the script's temporary directory after execution.
removeAllFn func(string) error
}
// Run processes all scripts identified by the execution IDs.
func (r *Runner) Run(execIDs []string) error {
var errs []error
for _, execID := range execIDs {
if !r.ScriptExecutionEnabled {
if err := r.runOneDisabled(execID); err != nil {
errs = append(errs, err)
}
continue
}
if err := r.runOne(execID); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
// NOTE: when we upgrade to Go1.20, we can use errors.Join, but for now we
// just concatenate the error messages in a single error that will be logged
// by orbit.
var sb strings.Builder
for i, e := range errs {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(e.Error())
}
return errors.New(sb.String())
}
return nil
}
func (r *Runner) runOne(execID string) (finalErr error) {
const maxOutputRuneLen = 10000
script, err := r.Client.GetHostScript(execID)
if err != nil {
return fmt.Errorf("get host script: %w", err)
}
if script.ExitCode.Valid {
// already a result stored for this execution, skip, it shouldn't be sent
// again by Fleet.
return nil
}
runDir, err := r.createRunDir(execID)
if err != nil {
return fmt.Errorf("create run directory: %w", err)
}
// prevent destruction of dir if this env var is set
if os.Getenv("FLEET_PREVENT_SCRIPT_TEMPDIR_DELETION") == "" {
defer func() {
fn := os.RemoveAll
if r.removeAllFn != nil {
fn = r.removeAllFn
}
err := fn(runDir)
if finalErr == nil && err != nil {
finalErr = fmt.Errorf("remove temp dir: %w", err)
}
}()
}
ext := ".sh"
if runtime.GOOS == "windows" {
ext = ".ps1"
}
scriptFile := filepath.Join(runDir, "script"+ext)
// the file does not need the executable bit set, it will be executed as
// argument to powershell or /bin/sh.
if err := os.WriteFile(scriptFile, []byte(script.ScriptContents), 0600); err != nil {
return fmt.Errorf("write script file: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), scriptExecTimeout)
defer cancel()
execCmdFn := r.execCmdFn
if execCmdFn == nil {
execCmdFn = execCmd
}
start := time.Now()
output, exitCode, execErr := execCmdFn(ctx, scriptFile)
duration := time.Since(start)
// report the output or the error
if execErr != nil {
output = append(output, []byte(fmt.Sprintf("\nscript execution error: %v", execErr))...)
}
// sanity-check the size of the output sent to the server, the actual
// trimming to 10K chars is done by the API endpoint, we just make sure not
// to send a ridiculously big payload that is sure to be over 10K chars.
if len(output) > (utf8.UTFMax * maxOutputRuneLen) {
output = output[len(output)-(utf8.UTFMax*maxOutputRuneLen):]
}
err = r.Client.SaveHostScriptResult(&fleet.HostScriptResultPayload{
ExecutionID: execID,
Output: string(output),
Runtime: int(duration.Seconds()),
ExitCode: exitCode,
})
if err != nil {
return fmt.Errorf("save script result: %w", err)
}
return nil
}
func (r *Runner) createRunDir(execID string) (string, error) {
var tempDir string // empty tempDir means use system default
if r.tempDirFn != nil {
tempDir = r.tempDirFn()
}
// MkdirTemp will only allow read/write by current user (root), which is what
// we want.
return os.MkdirTemp(tempDir, "fleet-"+execID+"-*")
}
func (r *Runner) runOneDisabled(execID string) error {
err := r.Client.SaveHostScriptResult(&fleet.HostScriptResultPayload{
ExecutionID: execID,
Output: "script execution disabled",
ExitCode: -2, // fleetctl knows that -2 means script was disabled on host
})
if err != nil {
return fmt.Errorf("save script result: %w", err)
}
return nil
}

View File

@ -0,0 +1,387 @@
package scripts
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/require"
)
func TestRunner(t *testing.T) {
cases := []struct {
desc string
// setup
client *mockClient
execer *mockExecCmd
enabled bool
execIDs []string
// expected
errContains string
execCalls int
}{
{
desc: "no exec ids",
client: &mockClient{},
execer: &mockExecCmd{},
enabled: true,
execCalls: 0,
},
{
desc: "one exec id, success",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{},
enabled: true,
execIDs: []string{"a"},
execCalls: 1,
},
{
desc: "one exec id disabled, success",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{},
enabled: false,
execIDs: []string{"a"},
execCalls: 0,
},
{
desc: "one ok, one unknown",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{},
enabled: true,
execIDs: []string{"a", "b"},
execCalls: 1,
errContains: "no such script: b",
},
{
desc: "one ok, one unknown, disabled",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{},
enabled: false,
execIDs: []string{"a", "b"},
execCalls: 0,
errContains: "", // no error because when scripts are disabled, the script is not fetched (save will not update anything)
},
{
desc: "multiple, success",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}, "b": {}, "c": {}}},
execer: &mockExecCmd{},
enabled: true,
execIDs: []string{"a", "b", "c"},
execCalls: 3,
},
{
desc: "multiple, disabled, success",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}, "b": {}, "c": {}}},
execer: &mockExecCmd{},
enabled: false,
execIDs: []string{"a", "b", "c"},
execCalls: 0,
},
{
desc: "failed to get script",
client: &mockClient{getErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{},
enabled: true,
execIDs: []string{"a"},
execCalls: 0,
errContains: "get host script: unexpected EOF",
},
{
desc: "failed to save script",
client: &mockClient{saveErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{},
enabled: true,
execIDs: []string{"a"},
execCalls: 1,
errContains: "save script result: unexpected EOF",
},
{
desc: "run returns error",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{err: io.ErrUnexpectedEOF},
enabled: true,
execIDs: []string{"a"},
execCalls: 1,
errContains: "", // no error reported, the run error is included in the results
},
{
desc: "failed to save script, disabled",
client: &mockClient{saveErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {}}},
execer: &mockExecCmd{},
enabled: false,
execIDs: []string{"a"},
execCalls: 0,
errContains: "save script result: unexpected EOF",
},
{
desc: "script with existing results",
client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: sql.NullInt64{Valid: true}}}},
execer: &mockExecCmd{},
enabled: true,
execIDs: []string{"a"},
execCalls: 0,
errContains: "", // no errors reported, script is just skipped
},
{
desc: "multiple errors reported, one get fails, one non-existing",
client: &mockClient{getErr: errFailOnce, scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: sql.NullInt64{Valid: true}}}},
execer: &mockExecCmd{},
enabled: true,
execIDs: []string{"a", "b"},
execCalls: 0,
errContains: "get host script: fail once\nget host script: no such script: b",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
runner := &Runner{
Client: c.client,
ScriptExecutionEnabled: c.enabled,
tempDirFn: t.TempDir,
execCmdFn: c.execer.run,
}
err := runner.Run(c.execIDs)
if c.errContains != "" {
require.ErrorContains(t, err, c.errContains)
} else {
require.NoError(t, err)
}
require.Equal(t, c.execCalls, c.execer.count)
})
}
}
func TestRunnerTempDir(t *testing.T) {
t.Run("deletes temp dir", func(t *testing.T) {
tempDir := t.TempDir()
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
runner := &Runner{
Client: client,
ScriptExecutionEnabled: true,
tempDirFn: func() string { return tempDir },
execCmdFn: execer.run,
}
err := runner.Run([]string{"a"})
require.NoError(t, err)
require.Equal(t, 1, execer.count)
require.Equal(t, "output", client.results["a"].Output)
// ensure the temp directory was removed after execution
entries, err := os.ReadDir(tempDir)
require.NoError(t, err)
require.Empty(t, entries)
})
t.Run("remove fails, returns original error", func(t *testing.T) {
tempDir := t.TempDir()
// client will fail saving the results, this is the error that should be
// returned (i.e. the remove dir error should not override it).
client := &mockClient{saveErr: io.ErrUnexpectedEOF, scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
runner := &Runner{
Client: client,
ScriptExecutionEnabled: true,
tempDirFn: func() string { return tempDir },
execCmdFn: execer.run,
removeAllFn: func(s string) error { return errors.New("remove failed") },
}
err := runner.Run([]string{"a"})
require.ErrorContains(t, err, "save script result: unexpected EOF")
require.Equal(t, 1, execer.count)
})
t.Run("remove fails, returns this error if the rest succeeded", func(t *testing.T) {
tempDir := t.TempDir()
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
runner := &Runner{
Client: client,
ScriptExecutionEnabled: true,
tempDirFn: func() string { return tempDir },
execCmdFn: execer.run,
removeAllFn: func(s string) error { return errors.New("remove failed") },
}
err := runner.Run([]string{"a"})
require.ErrorContains(t, err, "remove temp dir: remove failed")
require.Equal(t, 1, execer.count)
})
t.Run("keeps temp dir", func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("FLEET_PREVENT_SCRIPT_TEMPDIR_DELETION", "1")
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil}
runner := &Runner{
Client: client,
ScriptExecutionEnabled: true,
tempDirFn: func() string { return tempDir },
execCmdFn: execer.run,
}
err := runner.Run([]string{"a"})
require.NoError(t, err)
require.Equal(t, 1, execer.count)
require.Equal(t, "output", client.results["a"].Output)
entries, err := os.ReadDir(tempDir)
require.NoError(t, err)
require.Len(t, entries, 1)
// the entry is the script's execution directory
require.True(t, entries[0].IsDir())
require.Contains(t, entries[0].Name(), "fleet-a-")
runDir := filepath.Join(tempDir, entries[0].Name())
runEntries, err := os.ReadDir(runDir)
require.NoError(t, err)
require.Len(t, runEntries, 1) // run directory contains the script
b, err := os.ReadFile(filepath.Join(runDir, runEntries[0].Name()))
require.NoError(t, err)
require.Equal(t, "echo 'Hi'", string(b))
})
}
func TestRunnerResults(t *testing.T) {
output40K := strings.Repeat("a", 4000) +
strings.Repeat("b", 4000) +
strings.Repeat("c", 4000) +
strings.Repeat("d", 4000) +
strings.Repeat("e", 4000) +
strings.Repeat("f", 4000) +
strings.Repeat("g", 4000) +
strings.Repeat("h", 4000) +
strings.Repeat("i", 4000) +
strings.Repeat("j", 4000)
output44K := output40K + strings.Repeat("k", 4000)
errSuffix := "\nscript execution error: " + io.ErrUnexpectedEOF.Error()
cases := []struct {
desc string
output string
exitCode int
runErr error
wantOutput string
}{
{
desc: "exactly the limit",
output: output40K,
exitCode: 1,
runErr: nil,
wantOutput: output40K,
},
{
desc: "too many bytes",
output: output44K,
exitCode: 1,
runErr: nil,
wantOutput: output44K[strings.Index(output44K, "b"):],
},
{
desc: "empty with error",
output: "",
exitCode: -1,
runErr: io.ErrUnexpectedEOF,
wantOutput: errSuffix,
},
{
desc: "limit with error",
output: output40K,
exitCode: -1,
runErr: io.ErrUnexpectedEOF,
wantOutput: output40K[len(errSuffix):] + errSuffix,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}}
execer := &mockExecCmd{output: []byte(c.output), exitCode: c.exitCode, err: c.runErr}
runner := &Runner{
Client: client,
ScriptExecutionEnabled: true,
tempDirFn: t.TempDir,
execCmdFn: execer.run,
}
err := runner.Run([]string{"a"})
require.NoError(t, err)
require.Equal(t, 1, execer.count)
require.Equal(t, c.wantOutput, client.results["a"].Output)
require.Equal(t, c.exitCode, client.results["a"].ExitCode)
})
}
}
type mockExecCmd struct {
output []byte
exitCode int
err error
count int
execFn func() ([]byte, int, error)
}
func (m *mockExecCmd) run(ctx context.Context, scriptPath string) ([]byte, int, error) {
m.count++
if m.execFn != nil {
return m.execFn()
}
return m.output, m.exitCode, m.err
}
var errFailOnce = errors.New("fail once")
type mockClient struct {
scripts map[string]*fleet.HostScriptResult
results map[string]*fleet.HostScriptResultPayload
getErr error
saveErr error
}
func (m *mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) {
if m.getErr != nil {
err := m.getErr
if err == errFailOnce {
m.getErr = nil
}
return nil, err
}
script := m.scripts[execID]
if script == nil {
return nil, fmt.Errorf("no such script: %s", execID)
}
return script, nil
}
func (m *mockClient) SaveHostScriptResult(result *fleet.HostScriptResultPayload) error {
if m.results == nil {
m.results = make(map[string]*fleet.HostScriptResultPayload)
}
m.results[result.ExecutionID] = result
err := m.saveErr
if err == errFailOnce {
m.saveErr = nil
}
return err
}

View File

@ -2,10 +2,13 @@ package update
import ( import (
"errors" "errors"
"runtime"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles" "github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -274,3 +277,123 @@ func (w *windowsMDMEnrollmentConfigFetcher) attemptUnenrollment() {
log.Info().Msg("successfully called UnregisterDeviceWithManagement to unenroll Windows device") log.Info().Msg("successfully called UnregisterDeviceWithManagement to unenroll Windows device")
} }
} }
type runScriptsConfigFetcher struct {
// Fetcher is the OrbitConfigFetcher that will be wrapped. It is responsible
// for actually returning the orbit configuration or an error.
Fetcher OrbitConfigFetcher
// ScriptsExecutionEnabled indicates if this agent allows scripts execution.
// If it doesn't, scripts are not executed, but a response is returned to the
// Fleet server so it knows the agent processed the request. Note that this
// should be set to the value of the --scripts-enabled command-line flag. An
// additional, dynamic check is done automatically by the
// runScriptsConfigFetcher if this field is false to get the value from the
// MDM configuration profile.
ScriptsExecutionEnabled bool
// ScriptsClient is the client to use to fetch the script to execute and save
// back its results.
ScriptsClient scripts.Client
// the dynamic scripts enabled check is done to check via mdm configuration
// profile if the host is allowed to run dynamic scripts. It is only done
// on macos and only if ScriptsExecutionEnabled is false.
dynamicScriptsEnabled atomic.Bool
dynamicScriptsEnabledCheckInterval time.Duration
// for tests, if set will use this instead of profiles.GetFleetdConfig.
testGetFleetdConfig func() (*fleet.MDMAppleFleetdConfig, error)
// for tests, to be able to mock command execution. If nil, will use
// (scripts.Runner{...}).Run. To help with testing, the function receives as
// argument the scripts.Runner value that would've executed the call.
runScriptsFn func(*scripts.Runner, []string) error
// ensures only one script execution runs at a time
mu sync.Mutex
}
func ApplyRunScriptsConfigFetcherMiddleware(fetcher OrbitConfigFetcher, scriptsEnabled bool, scriptsClient scripts.Client) OrbitConfigFetcher {
scriptsFetcher := &runScriptsConfigFetcher{
Fetcher: fetcher,
ScriptsExecutionEnabled: scriptsEnabled,
ScriptsClient: scriptsClient,
dynamicScriptsEnabledCheckInterval: time.Minute,
}
// start the dynamic check for scripts enabled if required
scriptsFetcher.runDynamicScriptsEnabledCheck()
return scriptsFetcher
}
func (h *runScriptsConfigFetcher) runDynamicScriptsEnabledCheck() {
getFleetdConfig := h.testGetFleetdConfig
if getFleetdConfig == nil {
getFleetdConfig = profiles.GetFleetdConfig
}
// only run on macos and only if scripts are disabled by default for the
// agent (but always run if a test get fleetd config function is set).
if (runtime.GOOS == "darwin" && !h.ScriptsExecutionEnabled) || (h.testGetFleetdConfig != nil) {
go func() {
runCheck := func() {
cfg, err := getFleetdConfig()
if err != nil {
if err != profiles.ErrNotImplemented {
// note that an unenrolled host will not return an error, it will
// return the zero-value struct, so this logging should not be too
// noisy unless something goes wrong.
log.Info().Err(err).Msg("get fleetd configuration failed")
}
return
}
h.dynamicScriptsEnabled.Store(cfg.EnableScripts)
}
// check immediately at startup, before checking at the interval
runCheck()
// check every minute
for range time.Tick(h.dynamicScriptsEnabledCheckInterval) {
runCheck()
}
}()
}
}
// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet
// server sent a list of scripts to execute, starts a goroutine to execute
// them.
func (h *runScriptsConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) {
cfg, err := h.Fetcher.GetConfig()
if err == nil && len(cfg.Notifications.PendingScriptExecutionIDs) > 0 {
if h.mu.TryLock() {
log.Debug().Msgf("received request to run scripts %v", cfg.Notifications.PendingScriptExecutionIDs)
runner := &scripts.Runner{
// scripts are always enabled if the agent is started with the
// --scripts-enabled flag. If it is not started with this flag, then
// scripts are enabled only if the mdm profile says so.
ScriptExecutionEnabled: h.ScriptsExecutionEnabled || h.dynamicScriptsEnabled.Load(),
Client: h.ScriptsClient,
}
fn := runner.Run
if h.runScriptsFn != nil {
fn = func(execIDs []string) error {
return h.runScriptsFn(runner, execIDs)
}
}
go func() {
defer h.mu.Unlock()
if err := fn(cfg.Notifications.PendingScriptExecutionIDs); err != nil {
log.Info().Err(err).Msg("running scripts failed")
return
}
log.Debug().Msgf("running scripts %v succeeded", cfg.Notifications.PendingScriptExecutionIDs)
}()
}
}
return cfg, err
}

View File

@ -5,9 +5,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"sync/atomic"
"testing" "testing"
"time" "time"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/ptr"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -363,3 +365,211 @@ func TestWindowsMDMEnrollmentPrevented(t *testing.T) {
}) })
} }
} }
func TestRunScripts(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
var (
callsCount atomic.Int64
runFailure error
blockRun chan struct{}
)
mockRun := func(r *scripts.Runner, ids []string) error {
callsCount.Add(1)
if blockRun != nil {
<-blockRun
}
return runFailure
}
waitForRun := func(t *testing.T, r *runScriptsConfigFetcher) {
var ok bool
for start := time.Now(); !ok && time.Since(start) < time.Second; {
ok = r.mu.TryLock()
}
require.True(t, ok, "timed out waiting for the lock to become available")
r.mu.Unlock()
}
t.Run("no pending scripts", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset() })
fetcher := &dummyConfigFetcher{
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: nil,
}},
}
runner := &runScriptsConfigFetcher{
Fetcher: fetcher,
runScriptsFn: mockRun,
}
cfg, err := runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
// the lock should be available because no goroutine was started
require.True(t, runner.mu.TryLock())
require.Zero(t, callsCount.Load()) // no calls to execute scripts
require.Empty(t, logBuf.String()) // no logs written
})
t.Run("pending scripts succeed", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset() })
fetcher := &dummyConfigFetcher{
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a", "b", "c"},
}},
}
runner := &runScriptsConfigFetcher{
Fetcher: fetcher,
runScriptsFn: mockRun,
}
cfg, err := runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
waitForRun(t, runner)
require.Equal(t, int64(1), callsCount.Load()) // all scripts executed in a single run
require.Contains(t, logBuf.String(), "received request to run scripts [a b c]")
require.Contains(t, logBuf.String(), "running scripts [a b c] succeeded")
})
t.Run("pending scripts failed", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset(); runFailure = nil })
fetcher := &dummyConfigFetcher{
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a", "b", "c"},
}},
}
runFailure = io.ErrUnexpectedEOF
runner := &runScriptsConfigFetcher{
Fetcher: fetcher,
runScriptsFn: mockRun,
}
cfg, err := runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
waitForRun(t, runner)
require.Equal(t, int64(1), callsCount.Load()) // all scripts executed in a single run
require.Contains(t, logBuf.String(), "received request to run scripts [a b c]")
require.Contains(t, logBuf.String(), "running scripts failed")
require.Contains(t, logBuf.String(), io.ErrUnexpectedEOF.Error())
})
t.Run("concurrent run prevented", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset(); blockRun = nil })
fetcher := &dummyConfigFetcher{
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a", "b", "c"},
}},
}
blockRun = make(chan struct{})
runner := &runScriptsConfigFetcher{
Fetcher: fetcher,
runScriptsFn: mockRun,
}
cfg, err := runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
// call it again, while the previous run is still running
cfg, err = runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
// unblock the initial run
close(blockRun)
waitForRun(t, runner)
require.Equal(t, int64(1), callsCount.Load()) // only called once because of mutex
require.Contains(t, logBuf.String(), "received request to run scripts [a b c]")
require.Contains(t, logBuf.String(), "running scripts [a b c] succeeded")
})
t.Run("dynamic enabling of scripts", func(t *testing.T) {
t.Cleanup(logBuf.Reset)
fetcher := &dummyConfigFetcher{
cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a"},
}},
}
var (
scriptsEnabledCalls []bool
dynamicEnabled atomic.Bool
dynamicInterval = 300 * time.Millisecond
)
runner := &runScriptsConfigFetcher{
Fetcher: fetcher,
ScriptsExecutionEnabled: false,
runScriptsFn: func(r *scripts.Runner, s []string) error {
scriptsEnabledCalls = append(scriptsEnabledCalls, r.ScriptExecutionEnabled)
return nil
},
testGetFleetdConfig: func() (*fleet.MDMAppleFleetdConfig, error) {
return &fleet.MDMAppleFleetdConfig{
EnableScripts: dynamicEnabled.Load(),
}, nil
},
dynamicScriptsEnabledCheckInterval: dynamicInterval,
}
// the static Scripts Enabled flag is false, so it relies on the dynamic check
runner.runDynamicScriptsEnabledCheck()
// first call, scripts are disabled
cfg, err := runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
waitForRun(t, runner)
// swap scripts execution to true and wait to ensure the dynamic check
// did run.
dynamicEnabled.Store(true)
time.Sleep(dynamicInterval + 100*time.Millisecond)
// second call, scripts are enabled (change exec ID to "b")
cfg.Notifications.PendingScriptExecutionIDs[0] = "b"
cfg, err = runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
waitForRun(t, runner)
// swap scripts execution back to false and wait to ensure the dynamic
// check did run.
dynamicEnabled.Store(false)
time.Sleep(dynamicInterval + 100*time.Millisecond)
// third call, scripts are disabled (change exec ID to "c")
cfg.Notifications.PendingScriptExecutionIDs[0] = "c"
cfg, err = runner.GetConfig()
require.NoError(t, err) // the dummy fetcher never returns an error
require.Equal(t, fetcher.cfg, cfg) // the wrapper properly returns the expected config
waitForRun(t, runner)
// validate the Scripts Enabled flags that were passed to the runScriptsFn
require.Equal(t, []bool{false, true, false}, scriptsEnabledCalls)
require.Contains(t, logBuf.String(), "received request to run scripts [a]")
require.Contains(t, logBuf.String(), "running scripts [a] succeeded")
require.Contains(t, logBuf.String(), "received request to run scripts [b]")
require.Contains(t, logBuf.String(), "running scripts [b] succeeded")
require.Contains(t, logBuf.String(), "received request to run scripts [c]")
require.Contains(t, logBuf.String(), "running scripts [c] succeeded")
})
}

View File

@ -4219,7 +4219,7 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
if len(output) > utf8.UTFMax*maxOutputRuneLen { if len(output) > utf8.UTFMax*maxOutputRuneLen {
// truncate the bytes as we know the output is too long, no point // truncate the bytes as we know the output is too long, no point
// converting more bytes than needed to runes. // converting more bytes than needed to runes.
output = output[len(output)-utf8.UTFMax*maxOutputRuneLen:] output = output[len(output)-(utf8.UTFMax*maxOutputRuneLen):]
} }
if utf8.RuneCountInString(output) > maxOutputRuneLen { if utf8.RuneCountInString(output) > maxOutputRuneLen {
outputRunes := []rune(output) outputRunes := []rune(output)

View File

@ -3802,6 +3802,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
require.Equal(t, host.ID, runSyncResp.HostID) require.Equal(t, host.ID, runSyncResp.HostID)
require.NotEmpty(t, runSyncResp.ExecutionID) require.NotEmpty(t, runSyncResp.ExecutionID)
require.Equal(t, "ok", runSyncResp.Output) require.Equal(t, "ok", runSyncResp.Output)
require.True(t, runSyncResp.ExitCode.Valid)
require.Equal(t, int64(0), runSyncResp.ExitCode.Int64) require.Equal(t, int64(0), runSyncResp.ExitCode.Int64)
require.Empty(t, runSyncResp.ErrorMessage) require.Empty(t, runSyncResp.ErrorMessage)

View File

@ -163,7 +163,7 @@ func getOrbitConfigEndpoint(ctx context.Context, request interface{}, svc fleet.
} }
func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, error) { func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, error) {
const pendingScriptMaxAge = 24 * time.Hour const pendingScriptMaxAge = time.Minute
// this is not a user-authenticated endpoint // this is not a user-authenticated endpoint
svc.authz.SkipAuthorization(ctx) svc.authz.SkipAuthorization(ctx)

84
tools/run-scripts/main.go Normal file
View File

@ -0,0 +1,84 @@
// Command run-scripts is a tool for testing script execution on hosts exactly
// as Orbit would do following reception of a Fleet server notification of
// pending script(s) to execute.
//
// It allows to run such scripts without having to build and deploy orbit on
// the target host and without having to enroll that host in fleet and have the
// fleet server send script execution requests to it.
//
// The results of script execution, as reported by the host, are printed to the
// standard output.
//
// Usage on the host:
//
// run-scripts
// run-scripts -exec-id my-specific-id -content 'echo "Hello, world!"'
// run-scripts -scripts-disabled -content 'echo "Hello, world!"'
// run-scripts -scripts-count 10
package main
import (
"flag"
"fmt"
"log"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/google/uuid"
)
func main() {
execIDFlag := flag.String("exec-id", "", "Execution ID of the script, will be auto-generated if empty.")
contentFlag := flag.String("content", "echo \"Hello\"", "Content of the script to execute.")
scriptsDisabledFlag := flag.Bool("scripts-disabled", false, "Disable execution of scripts on the host.")
scriptsCountFlag := flag.Int("scripts-count", 1, "Number of scripts to execute. If > 1, the content will all be the same and exec-id will be auto-generated.")
flag.Parse()
if *scriptsCountFlag < 1 {
log.Fatal("scripts-count must be >= 1")
}
cli := mockClient{content: *contentFlag}
runner := &scripts.Runner{
ScriptExecutionEnabled: !*scriptsDisabledFlag,
Client: cli,
}
execIDs := make([]string, *scriptsCountFlag)
for i := range execIDs {
if *execIDFlag != "" && len(execIDs) == 1 {
execIDs[i] = *execIDFlag
break
}
execIDs[i] = uuid.New().String()
}
if err := runner.Run(execIDs); err != nil {
log.Fatal(err)
}
}
type mockClient struct {
content string
}
func (m mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) {
return &fleet.HostScriptResult{
HostID: 1,
ExecutionID: execID,
ScriptContents: m.content,
}, nil
}
func (m mockClient) SaveHostScriptResult(result *fleet.HostScriptResultPayload) error {
fmt.Printf(`
Script result for %q:
Exit code: %d
Runtime: %d second(s)
Output:
%s
---
`, result.ExecutionID, result.ExitCode, result.Runtime, result.Output)
return nil
}