mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Implement script execution on the fleetd agent (disabled by default) (#13569)
This commit is contained in:
parent
c0cb278a1f
commit
090b142c49
1
changes/issue-13307-run-script-on-agent
Normal file
1
changes/issue-13307-run-script-on-agent
Normal file
@ -0,0 +1 @@
|
|||||||
|
* Added support in fleetd to execute scripts and send back results (disabled by default).
|
@ -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
|
||||||
|
@ -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":
|
||||||
|
22
orbit/pkg/scripts/exec_nonwindows.go
Normal file
22
orbit/pkg/scripts/exec_nonwindows.go
Normal 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
|
||||||
|
}
|
23
orbit/pkg/scripts/exec_windows.go
Normal file
23
orbit/pkg/scripts/exec_windows.go
Normal 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
|
||||||
|
}
|
181
orbit/pkg/scripts/scripts.go
Normal file
181
orbit/pkg/scripts/scripts.go
Normal 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
|
||||||
|
}
|
387
orbit/pkg/scripts/scripts_test.go
Normal file
387
orbit/pkg/scripts/scripts_test.go
Normal 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
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
84
tools/run-scripts/main.go
Normal 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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user