diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d73608b74b..01f627ddb8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,7 +5,7 @@ If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [ ] Documented any API changes (docs/Using-Fleet/REST-API.md or docs/Contributing/API-for-contributors.md) -- [ ] Documented any permissions changes +- [ ] Documented any permissions changes (docs/Using Fleet/manage-access.md) - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features. - [ ] Added/updated tests diff --git a/changes/issue-13305-api-run-script b/changes/issue-13305-api-run-script new file mode 100644 index 0000000000..9b7cc90c01 --- /dev/null +++ b/changes/issue-13305-api-run-script @@ -0,0 +1 @@ +* Added `/scripts/run` and `scripts/run/sync` API endpoints to send a script to be executed on a host (and optionally wait for its results). diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index bbcdb5fe40..bd576cd916 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -10,6 +10,7 @@ - [Policies](#policies) - [Queries](#queries) - [Schedule (deprecated)](#schedule) +- [Scripts](#scripts) - [Sessions](#sessions) - [Software](#software) - [Targets](#targets) @@ -3126,7 +3127,7 @@ Retrieves the disk encryption key for a host. } ``` -### Get configuration profiles assigned to a host +### Get configuration profiles assigned to a host Requires Fleet's MDM properly [enabled and configured](https://fleetdm.com/docs/using-fleet/mdm-setup). @@ -5626,7 +5627,7 @@ load balancer timeout. ## Schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. - [Get schedule (deprecated)](#get-schedule) @@ -5641,7 +5642,7 @@ These API routes let you control your scheduled queries. ### Get schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `GET /api/v1/fleet/global/schedule` @@ -5715,7 +5716,7 @@ None. ### Add query to schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `POST /api/v1/fleet/global/schedule` @@ -5776,7 +5777,7 @@ None. ### Edit query in schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `PATCH /api/v1/fleet/global/schedule/{id}` @@ -5832,7 +5833,7 @@ None. ### Remove query from schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `DELETE /api/v1/fleet/global/schedule/{id}` @@ -5854,7 +5855,7 @@ None. ### Team schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. - [Get team schedule (deprecated)](#get-team-schedule) @@ -5866,7 +5867,7 @@ This allows you to easily configure scheduled queries that will impact a whole t #### Get team schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `GET /api/v1/fleet/teams/{id}/schedule` @@ -5946,7 +5947,7 @@ This allows you to easily configure scheduled queries that will impact a whole t #### Add query to team schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `POST /api/v1/fleet/teams/{id}/schedule` @@ -6004,7 +6005,7 @@ This allows you to easily configure scheduled queries that will impact a whole t #### Edit query in team schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `PATCH /api/v1/fleet/teams/{team_id}/schedule/{scheduled_query_id}` @@ -6061,7 +6062,7 @@ This allows you to easily configure scheduled queries that will impact a whole t #### Remove query from team schedule -> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. +> The schedule API endpoints are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. > Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. `DELETE /api/v1/fleet/teams/{team_id}/schedule/{scheduled_query_id}` @@ -6083,6 +6084,78 @@ This allows you to easily configure scheduled queries that will impact a whole t --- +## Scripts + +- [Run script asynchronously](#run-script-asynchronously) +- [Run script synchronously](#run-script-synchronously) + + +### Run script asynchronously + +_Available in Fleet Premium_ + +Creates a script execution request and returns the execution identifier to retrieve results at a later time. + +`POST /api/v1/fleet/scripts/run` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| host_id | integer | body | **Required**. The host id to run the script on. | +| script_contents | string | body | **Required**. The contents of the script to run. | + +#### Example + +`POST /api/v1/fleet/scripts/run` + +##### Default response + +`Status: 202` + +```json +{ + "host_id": 1227, + "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002" +} +``` + +### Run script synchronously + +_Available in Fleet Premium_ + +Creates a script execution request and waits for a result to return (up to a 1 minute timeout). + +`POST /api/v1/fleet/scripts/run/sync` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| host_id | integer | body | **Required**. The host id to run the script on. | +| script_contents | string | body | **Required**. The contents of the script to run. | + +#### Example + +`POST /api/v1/fleet/scripts/run/sync` + +##### Default response + +`Status: 200` + +```json +{ + "host_id": 1227, + "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002", + "script_contents": "echo 'hello'", + "output": "hello", + "runtime": 1, + "exit_code": 0 +} +``` + +--- + ## Sessions - [Get session info](#get-session-info) diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index fcfa3b4ce1..2095bdf291 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -87,6 +87,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | View metadata of MDM macOS bootstrap packages\* | | | ✅ | ✅ | | | Edit/upload MDM macOS bootstrap packages\* | | | ✅ | ✅ | ✅ | | Enable/disable MDM macOS setup end user authentication\* | | | ✅ | ✅ | ✅ | +| Run scripts on hosts\* | | | ✅ | ✅ | | \* Applies only to Fleet Premium @@ -149,6 +150,7 @@ Users that are members of multiple teams can be assigned different roles for eac | View metadata of MDM macOS bootstrap packages | | | ✅ | ✅ | | | Edit/upload MDM macOS bootstrap packages | | | ✅ | ✅ | ✅ | | Enable/disable MDM macOS setup end user authentication | | | ✅ | ✅ | ✅ | +| Run scripts on hosts | | | ✅ | ✅ | | \* Applies only to [Fleet REST API](https://fleetdm.com/docs/using-fleet/rest-api) @@ -156,4 +158,4 @@ Users that are members of multiple teams can be assigned different roles for eac - \ No newline at end of file + diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index a2b4c8749f..6c06138c5b 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -2,7 +2,11 @@ package service import ( "context" + "fmt" + "time" + "unicode/utf8" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -19,3 +23,109 @@ func (svc *Service) HostByIdentifier(ctx context.Context, identifier string, opt opts.IncludePolicies = true return svc.Service.HostByIdentifier(ctx, identifier, opts) } + +func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) { + const ( + maxScriptRuneLen = 10000 + maxPendingScriptAge = time.Minute // any script older than this is not considered pending anymore on that host + ) + + // must load the host (lite is enough, just for the team) to authorize + // with the proper team id. We cannot first authorize if the user can list + // hosts, because the user could have a write-only role (e.g. gitops). + host, err := svc.ds.HostLite(ctx, request.HostID) + if err != nil { + // if error is because the host does not exist, check first if the user + // had access to run a script (to prevent leaking valid host ids). + if fleet.IsNotFound(err) { + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionWrite); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get host lite") + } + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { + return nil, err + } + + if request.ScriptContents == "" { + return nil, fleet.NewInvalidArgumentError("script_contents", "a script to execute is required") + } + // look for the script length in bytes first, as rune counting a huge string + // can be expensive. + if len(request.ScriptContents) > utf8.UTFMax*maxScriptRuneLen { + return nil, fleet.NewInvalidArgumentError("script_contents", fmt.Sprintf("script is too long, must be at most %d characters", maxScriptRuneLen)) + } + // now that we know that the script is at most 4*maxScriptRuneLen bytes long, + // we can safely count the runes for a precise check. + if utf8.RuneCountInString(request.ScriptContents) > maxScriptRuneLen { + return nil, fleet.NewInvalidArgumentError("script_contents", fmt.Sprintf("script is too long, must be at most %d characters", maxScriptRuneLen)) + } + + // TODO(mna): any other validation we want to apply to the script? What is the "must be bash/powershell" check? + + pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID, maxPendingScriptAge) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list host pending script executions") + } + if len(pending) > 0 { + // TODO(mna): there are a number of issues with that validation: it only + // really says that there was a script execution _request_ that was made < 1m + // ago, and that blocks executing any more scripts on that host, but the + // host may not even have received the previous script for execution yet, + // so if we accept more scripts after 1m, we may end up having multiple + // scripts to execute on the host at the same time (or more likely in + // sequence, but still). This may be good enough for now, I think the whole + // idea of locking if a script is pending is meant to be temporary anyway. + return nil, fleet.NewInvalidArgumentError("script_contents", "a script is currently executing on the host") + } + + // create the script execution request + script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "create script execution request") + } + // TODO(mna): figure out how to send this to the host, either something to do + // here or via the DB checking if there are pending scripts for the host when + // sending queries or notifications. + if waitForResult <= 0 { + // async execution, return + return script, nil + } + + ctx, cancel := context.WithTimeout(ctx, waitForResult) + defer cancel() + + // if waiting for a result times out, we still want to return the script's + // execution request information along with the error, so that the caller can + // use the execution id for later checks. + timeoutResult := script + checkInterval := time.Second + after := time.NewTimer(checkInterval) + for { + select { + case <-ctx.Done(): + return timeoutResult, ctx.Err() + case <-after.C: + result, err := svc.ds.GetHostScriptExecutionResult(ctx, script.ExecutionID) + if err != nil { + // is that due to the context being canceled during the DB access? + if ctxErr := ctx.Err(); ctxErr != nil { + return timeoutResult, ctxErr + } + return nil, ctxerr.Wrap(ctx, err, "get script execution result") + } + if result.ExitCode.Valid { + // a result was received from the host, return + return result, nil + } + + // at a second to every attempt, until it reaches 5s (then check every 5s) + if checkInterval < 5*time.Second { + checkInterval += time.Second + } + after.Reset(checkInterval) + } + } +} diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 519c6eb6aa..6ff2d66f94 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -350,7 +350,7 @@ allow { object.observer_can_run == false is_null(subject.global_role) action == run - + is_null(object.team_id) not is_null(object.host_targets.teams) @@ -365,7 +365,7 @@ allow { object.observer_can_run == false is_null(subject.global_role) action == run - + team_role(subject, object.team_id) == [admin, maintainer, observer_plus][_] not is_null(object.host_targets.teams) @@ -395,7 +395,7 @@ allow { object.observer_can_run == false is_null(subject.global_role) action == run - + team_role(subject, object.team_id) == [admin, maintainer, observer_plus][_] # there are no team targets @@ -425,7 +425,7 @@ allow { object.observer_can_run == true is_null(subject.global_role) action == run - + is_null(object.team_id) not is_null(object.host_targets.teams) @@ -440,7 +440,7 @@ allow { object.observer_can_run == true is_null(subject.global_role) action == run - + team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_] not is_null(object.host_targets.teams) @@ -454,7 +454,7 @@ allow { object.observer_can_run == true is_null(subject.global_role) action == run - + is_null(object.team_id) # If role is admin, maintainer, observer_plus or observer on any team. @@ -470,7 +470,7 @@ allow { object.observer_can_run == true is_null(subject.global_role) action == run - + team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_] # there are no team targets @@ -814,3 +814,39 @@ allow { not is_null(subject) action == read } + +## +# Host Script Result (script execution and output) +## + +# Global admins and maintainers can write (execute) scripts (not gitops as this +# is not something that relates to fleetctl apply). +allow { + object.type == "host_script_result" + subject.global_role == [admin, maintainer][_] + action == write +} + +# Global admins, maintainers, observer_plus and observers can read scripts. +allow { + object.type == "host_script_result" + subject.global_role == [admin, maintainer, observer, observer_plus][_] + action == read +} + +# Team admin and maintainers can write (execute) scripts for their teams (not +# gitops as this is not something that relates to fleetctl apply). +allow { + object.type == "host_script_result" + not is_null(object.team_id) + team_role(subject, object.team_id) == [admin, maintainer][_] + action == write +} + +# Team admins, maintainers, observer_plus and observers can read scripts for their teams. +allow { + object.type == "host_script_result" + not is_null(object.team_id) + team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_] + action == read +} diff --git a/server/authz/policy_test.go b/server/authz/policy_test.go index 0dd7f8efd2..e4cb991e29 100644 --- a/server/authz/policy_test.go +++ b/server/authz/policy_test.go @@ -1814,6 +1814,96 @@ func TestAuthorizeMDMAppleCommand(t *testing.T) { }) } +func TestAuthorizeHostScriptResult(t *testing.T) { + t.Parallel() + + globalScript := &fleet.HostScriptResult{} + team1Script := &fleet.HostScriptResult{ + TeamID: ptr.Uint(1), + } + runTestCases(t, []authTestCase{ + {user: test.UserNoRoles, object: globalScript, action: write, allow: false}, + {user: test.UserNoRoles, object: globalScript, action: read, allow: false}, + {user: test.UserNoRoles, object: team1Script, action: write, allow: false}, + {user: test.UserNoRoles, object: team1Script, action: read, allow: false}, + + {user: test.UserAdmin, object: globalScript, action: write, allow: true}, + {user: test.UserAdmin, object: globalScript, action: read, allow: true}, + {user: test.UserAdmin, object: team1Script, action: write, allow: true}, + {user: test.UserAdmin, object: team1Script, action: read, allow: true}, + + {user: test.UserMaintainer, object: globalScript, action: write, allow: true}, + {user: test.UserMaintainer, object: globalScript, action: read, allow: true}, + {user: test.UserMaintainer, object: team1Script, action: write, allow: true}, + {user: test.UserMaintainer, object: team1Script, action: read, allow: true}, + + {user: test.UserObserver, object: globalScript, action: write, allow: false}, + {user: test.UserObserver, object: globalScript, action: read, allow: true}, + {user: test.UserObserver, object: team1Script, action: write, allow: false}, + {user: test.UserObserver, object: team1Script, action: read, allow: true}, + + {user: test.UserObserverPlus, object: globalScript, action: write, allow: false}, + {user: test.UserObserverPlus, object: globalScript, action: read, allow: true}, + {user: test.UserObserverPlus, object: team1Script, action: write, allow: false}, + {user: test.UserObserverPlus, object: team1Script, action: read, allow: true}, + + {user: test.UserGitOps, object: globalScript, action: write, allow: false}, + {user: test.UserGitOps, object: globalScript, action: read, allow: false}, + {user: test.UserGitOps, object: team1Script, action: write, allow: false}, + {user: test.UserGitOps, object: team1Script, action: read, allow: false}, + + {user: test.UserTeamAdminTeam1, object: globalScript, action: write, allow: false}, + {user: test.UserTeamAdminTeam1, object: globalScript, action: read, allow: false}, + {user: test.UserTeamAdminTeam1, object: team1Script, action: write, allow: true}, + {user: test.UserTeamAdminTeam1, object: team1Script, action: read, allow: true}, + + {user: test.UserTeamAdminTeam2, object: globalScript, action: write, allow: false}, + {user: test.UserTeamAdminTeam2, object: globalScript, action: read, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1Script, action: read, allow: false}, + + {user: test.UserTeamMaintainerTeam1, object: globalScript, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam1, object: globalScript, action: read, allow: false}, + {user: test.UserTeamMaintainerTeam1, object: team1Script, action: write, allow: true}, + {user: test.UserTeamMaintainerTeam1, object: team1Script, action: read, allow: true}, + + {user: test.UserTeamMaintainerTeam2, object: globalScript, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: globalScript, action: read, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1Script, action: read, allow: false}, + + {user: test.UserTeamObserverTeam1, object: globalScript, action: write, allow: false}, + {user: test.UserTeamObserverTeam1, object: globalScript, action: read, allow: false}, + {user: test.UserTeamObserverTeam1, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverTeam1, object: team1Script, action: read, allow: true}, + + {user: test.UserTeamObserverTeam2, object: globalScript, action: write, allow: false}, + {user: test.UserTeamObserverTeam2, object: globalScript, action: read, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1Script, action: read, allow: false}, + + {user: test.UserTeamObserverPlusTeam1, object: globalScript, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: globalScript, action: read, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: team1Script, action: read, allow: true}, + + {user: test.UserTeamObserverPlusTeam2, object: globalScript, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: globalScript, action: read, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1Script, action: read, allow: false}, + + {user: test.UserTeamGitOpsTeam1, object: globalScript, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: globalScript, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: team1Script, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: team1Script, action: read, allow: false}, + + {user: test.UserTeamGitOpsTeam2, object: globalScript, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: globalScript, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1Script, action: read, allow: false}, + }) +} + func TestJSONToInterfaceUser(t *testing.T) { t.Parallel() diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index a0941f2da5..5f29b057c9 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -18,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" + "github.com/google/uuid" "github.com/jmoiron/sqlx" ) @@ -464,6 +465,7 @@ var hostRefs = []string{ "host_disk_encryption_keys", "host_software_installed_paths", "host_dep_assignments", + "host_script_results", } // those host refs cannot be deleted using the host.id like the hostRefs above, @@ -4187,3 +4189,108 @@ func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []strin return result, nil } + +func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { + const ( + insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_contents, output) VALUES (?, ?, ?, '')` + getStmt = `SELECT id, host_id, execution_id, script_contents FROM host_script_results WHERE id = ?` + ) + + execID := uuid.New().String() + result, err := ds.writer(ctx).ExecContext(ctx, insStmt, + request.HostID, + execID, + request.ScriptContents, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "new host script execution request") + } + + var script fleet.HostScriptResult + id, _ := result.LastInsertId() + if err := ds.writer(ctx).GetContext(ctx, &script, getStmt, id); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting the created host script result to return") + } + return &script, nil +} + +func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error { + const updStmt = ` + UPDATE host_script_results SET + output = ?, + runtime = ?, + exit_code = ? + WHERE + host_id = ? AND + execution_id = ?` + + const maxOutputRuneLen = 10000 + output := result.Output + if len(output) > utf8.UTFMax*maxOutputRuneLen { + // truncate the bytes as we know the output is too long, no point + // converting more bytes than needed to runes. + output = output[len(output)-utf8.UTFMax*maxOutputRuneLen:] + } + if utf8.RuneCountInString(output) > maxOutputRuneLen { + outputRunes := []rune(output) + output = string(outputRunes[len(outputRunes)-maxOutputRuneLen:]) + } + + if _, err := ds.writer(ctx).ExecContext(ctx, updStmt, + output, + result.Runtime, + result.ExitCode, + result.HostID, + result.ExecutionID, + ); err != nil { + return ctxerr.Wrap(ctx, err, "update host script result") + } + return nil +} + +func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { + const listStmt = ` + SELECT + id, + host_id, + execution_id, + script_contents + FROM + host_script_results + WHERE + host_id = ? AND + exit_code IS NULL AND + created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)` + + var results []*fleet.HostScriptResult + seconds := int(ignoreOlder.Seconds()) + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, hostID, seconds); err != nil { + return nil, ctxerr.Wrap(ctx, err, "list pending host script results") + } + return results, nil +} + +func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { + const getStmt = ` + SELECT + id, + host_id, + execution_id, + script_contents, + output, + runtime, + exit_code + FROM + host_script_results + WHERE + execution_id = ?` + + var result fleet.HostScriptResult + if err := sqlx.GetContext(ctx, ds.reader(ctx), &result, getStmt, execID); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("HostScriptResult").WithName(execID)) + } + return nil, ctxerr.Wrap(ctx, err, "get host script result") + } + return &result, nil +} diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 2e7e3af4b1..c675e7a48a 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -151,6 +151,7 @@ func TestHosts(t *testing.T) { {"ListHostsLiteByUUIDs", testHostsListHostsLiteByUUIDs}, {"GetMatchingHostSerials", testGetMatchingHostSerials}, {"ListHostsLiteByIDs", testHostsListHostsLiteByIDs}, + {"HostScriptResult", testHostScriptResult}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -5775,6 +5776,8 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { require.NoError(t, err) err = ds.RecordHostBootstrapPackage(context.Background(), "command-uuid", host.UUID) require.NoError(t, err) + _, err = ds.NewHostScriptExecutionRequest(context.Background(), &fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "foo"}) + require.NoError(t, err) // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { @@ -7263,3 +7266,118 @@ func testHostsListHostsLiteByIDs(t *testing.T, ds *Datastore) { }) } } + +func testHostScriptResult(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // no script saved yet + pending, err := ds.ListPendingHostScriptExecutions(ctx, 1, time.Second) + require.NoError(t, err) + require.Empty(t, pending) + + _, err = ds.GetHostScriptExecutionResult(ctx, "abc") + require.Error(t, err) + var nfe *notFoundError + require.ErrorAs(t, err, &nfe) + + // create a createdScript execution request + createdScript, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ + HostID: 1, + ScriptContents: "echo", + }) + require.NoError(t, err) + require.NotZero(t, createdScript.ID) + require.NotEmpty(t, createdScript.ExecutionID) + require.Equal(t, uint(1), createdScript.HostID) + require.NotEmpty(t, createdScript.ExecutionID) + require.Equal(t, "echo", createdScript.ScriptContents) + require.False(t, createdScript.ExitCode.Valid) + require.Empty(t, createdScript.Output) + + // the script execution is now listed as pending for this host + pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second) + require.NoError(t, err) + require.Len(t, pending, 1) + require.Equal(t, createdScript.ID, pending[0].ID) + + // waiting for a second and an ignore of 0s ignores this script + time.Sleep(time.Second) + pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 0) + require.NoError(t, err) + require.Empty(t, pending) + + // record a result for this execution + err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: 1, + ExecutionID: createdScript.ExecutionID, + Output: "foo", + Runtime: 2, + ExitCode: 0, + }) + require.NoError(t, err) + + // it is not pending anymore + pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second) + require.NoError(t, err) + require.Empty(t, pending) + + // the script result can be retrieved + script, err := ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID) + require.NoError(t, err) + expectScript := *createdScript + expectScript.Output = "foo" + expectScript.Runtime = 2 + expectScript.ExitCode = sql.NullInt64{Int64: 0, Valid: true} + require.Equal(t, &expectScript, script) + + // create another script execution request + createdScript, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ + HostID: 1, + ScriptContents: "echo2", + }) + require.NoError(t, err) + require.NotZero(t, createdScript.ID) + require.NotEmpty(t, createdScript.ExecutionID) + + // the script result can be retrieved even if it has no result yet + script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID) + require.NoError(t, err) + require.Equal(t, createdScript, script) + + // record a result for this execution, with an output that is too large + largeOutput := strings.Repeat("a", 1000) + + strings.Repeat("b", 1000) + + strings.Repeat("c", 1000) + + strings.Repeat("d", 1000) + + strings.Repeat("e", 1000) + + strings.Repeat("f", 1000) + + strings.Repeat("g", 1000) + + strings.Repeat("h", 1000) + + strings.Repeat("i", 1000) + + strings.Repeat("j", 1000) + + strings.Repeat("k", 1000) + expectedOutput := strings.Repeat("b", 1000) + + strings.Repeat("c", 1000) + + strings.Repeat("d", 1000) + + strings.Repeat("e", 1000) + + strings.Repeat("f", 1000) + + strings.Repeat("g", 1000) + + strings.Repeat("h", 1000) + + strings.Repeat("i", 1000) + + strings.Repeat("j", 1000) + + strings.Repeat("k", 1000) + + err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: 1, + ExecutionID: createdScript.ExecutionID, + Output: largeOutput, + Runtime: 10, + ExitCode: 1, + }) + require.NoError(t, err) + + // the script result can be retrieved + script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID) + require.NoError(t, err) + require.Equal(t, expectedOutput, script.Output) +} diff --git a/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable.go b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable.go new file mode 100644 index 0000000000..5ba41602e1 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable.go @@ -0,0 +1,67 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20230814150442, Down_20230814150442) +} + +func Up_20230814150442(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE host_script_results ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + host_id INT(10) UNSIGNED NOT NULL, + + -- execution_id is a unique identifier (e.g. UUID) generated for each + -- execution of a script. + execution_id VARCHAR(255) NOT NULL, + + -- in the future, we may have a concept of "saved scripts" and in that case + -- the host_script_results may be associated with a script_id instead of + -- the actual script contents. If that's the case, it may be best to allow + -- this field to be NULL (if a saved script is used) but for now we don't + -- support this so I'm making it NOT NULL. + script_contents TEXT NOT NULL, + + -- output is the combination of stdout and stderr from the script execution. + output TEXT NOT NULL, + + -- runtime is the execution time of the script in seconds, rounded. + runtime INT(10) UNSIGNED NOT NULL DEFAULT 0, + + -- the exit code of the script execution, large enough to not assume too + -- much about the possible range (e.g. https://stackoverflow.com/a/328423/1094941) + -- It can be NULL to represent that the script results have not been received + -- yet, and -1 if the script executed but was terminated abruptly (e.g. due to + -- a signal/timeout, same as how Go reports this: https://pkg.go.dev/os#ProcessState.ExitCode). + exit_code INT(10) NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + + -- this index can be used to lookup results for a specific + -- execution (execution ids, e.g. when updating the row for results) + UNIQUE KEY idx_host_script_results_execution_id (execution_id), + + -- this index can be used to lookup results for a host, to check if a host is currently + -- executing a script (by host_id and with exit_code = NULL), and an created_at condition + -- can be added to dismiss a pending execution that's been running for too long (e.g. host + -- was offline and never sent results, we should eventually start accepting a new + -- script execution). + KEY idx_host_script_results_host_exit_created (host_id, exit_code, created_at) +)`) + if err != nil { + return fmt.Errorf("failed to create host_script_results table: %w", err) + } + + return nil +} + +func Down_20230814150442(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable_test.go b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable_test.go new file mode 100644 index 0000000000..2a5a87dd2e --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20230814150442_AddHostScriptResultsTable_test.go @@ -0,0 +1,88 @@ +package tables + +import ( + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestUp_20230814150442(t *testing.T) { + db := applyUpToPrev(t) + + // Apply current migration. + applyNext(t, db) + + // NOTE: output field must be provided explicitly (even if empty), because TEXT fields + // cannot have a default value. + insertStmt := `INSERT INTO host_script_results ( + host_id, execution_id, script_contents, output + ) VALUES (?, ?, ?, '')` + + hostID := 123 + execID := uuid.New().String() + scriptContents := "echo 'hello world'" + res, err := db.Exec(insertStmt, hostID, execID, scriptContents) + require.NoError(t, err) + + id, _ := res.LastInsertId() + require.Greater(t, id, int64(0)) + + type hostScriptResult struct { + ID int `db:"id"` + HostID int `db:"host_id"` + ExecutionID string `db:"execution_id"` + ScriptContents string `db:"script_contents"` + Output string `db:"output"` + Runtime int `db:"runtime"` + ExitCode sql.NullInt64 `db:"exit_code"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + // load the host we just created + var scriptResult hostScriptResult + selectStmt := `SELECT id, host_id, execution_id, script_contents, output, runtime, exit_code, created_at, updated_at + FROM host_script_results + WHERE id = ?` + err = db.Get(&scriptResult, selectStmt, id) + require.NoError(t, err) + + require.Equal(t, int(id), scriptResult.ID) + require.Equal(t, hostID, scriptResult.HostID) + require.Equal(t, execID, scriptResult.ExecutionID) + require.Equal(t, scriptContents, scriptResult.ScriptContents) + require.Empty(t, scriptResult.Output) + require.Zero(t, scriptResult.Runtime) + require.False(t, scriptResult.ExitCode.Valid) + require.NotZero(t, scriptResult.CreatedAt) + require.NotZero(t, scriptResult.UpdatedAt) + + // check pending executions for a given host + var countPending int + countPendingStmt := `SELECT COUNT(*) + FROM host_script_results + WHERE host_id = ? AND exit_code IS NULL` + err = db.Get(&countPending, countPendingStmt, hostID) + require.NoError(t, err) + require.Equal(t, 1, countPending) + + // update the host we just created + output := `hello world` + runtime := 10 + exitCode := int64(0) + updateStmt := `UPDATE host_script_results SET output = ?, runtime = ?, exit_code = ? WHERE host_id = ? AND execution_id = ?` + _, err = db.Exec(updateStmt, output, runtime, exitCode, hostID, execID) + require.NoError(t, err) + + // reload the updated host result + err = db.Get(&scriptResult, selectStmt, id) + require.NoError(t, err) + + require.Equal(t, output, scriptResult.Output) + require.Equal(t, runtime, scriptResult.Runtime) + require.True(t, scriptResult.ExitCode.Valid) + require.Equal(t, exitCode, scriptResult.ExitCode.Int64) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index e82b9fbece..9436cc74ad 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -329,6 +329,23 @@ CREATE TABLE `host_orbit_info` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; +CREATE TABLE `host_script_results` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `host_id` int(10) unsigned NOT NULL, + `execution_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `script_contents` text COLLATE utf8mb4_unicode_ci NOT NULL, + `output` text COLLATE utf8mb4_unicode_ci NOT NULL, + `runtime` int(10) unsigned NOT NULL DEFAULT '0', + `exit_code` int(10) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_host_script_results_execution_id` (`execution_id`), + KEY `idx_host_script_results_host_exit_created` (`host_id`,`exit_code`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `host_seen_times` ( `host_id` int(10) unsigned NOT NULL, `seen_time` timestamp NULL DEFAULT NULL, @@ -661,9 +678,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=202 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=203 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 09a4991ed6..524af371a8 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1009,6 +1009,25 @@ type Datastore interface { MDMWindowsInsertEnrolledDevice(ctx context.Context, device *MDMWindowsEnrolledDevice) error // MDMWindowsDeleteEnrolledDevice deletes a give MDMWindowsEnrolledDevice entry from the database using the device id. MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDeviceID string) error + + /////////////////////////////////////////////////////////////////////////////// + // Host Script Results + + // NewHostScriptExecutionRequest creates a new host script result entry with + // just the script to run information (result is not yet available). + NewHostScriptExecutionRequest(ctx context.Context, request *HostScriptRequestPayload) (*HostScriptResult, error) + // SetHostScriptExecutionResult stores the result of a host script execution. + SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) error + // GetHostScriptExecutionResult returns the result of a host script + // execution. It returns the host script results even if no results have been + // received, it is the caller's responsibility to check if that was the case + // (with ExitCode being null). + GetHostScriptExecutionResult(ctx context.Context, execID string) (*HostScriptResult, error) + // ListPendingHostScriptExecutions returns all the pending host script + // executions, which are those that have yet to record a result. Entries + // older than the ignoreOlder duration are ignored, considered too old to be + // pending. + ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*HostScriptResult, error) } const ( diff --git a/server/fleet/errors.go b/server/fleet/errors.go index be0283a0c3..25ea21fe31 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -311,31 +311,43 @@ func (e *MDMNotConfiguredError) Error() string { return "MDM features aren't turned on in Fleet. For more information about setting up MDM, please visit https://fleetdm.com/docs/using-fleet/mobile-device-management" } -// BadGatewayError is an error type that generates a 502 status code. -type BadGatewayError struct { +// GatewayError is an error type that generates a 502 or 504 status code. +type GatewayError struct { Message string err error + code int ErrorWithUUID } -// NewBadGatewayError returns a MDMBadGatewayError with the message and -// error specified. -func NewBadGatewayError(message string, err error) *BadGatewayError { - return &BadGatewayError{ +// NewBadGatewayError returns a GatewayError with the message and +// error specified and that returns a 502 status code. +func NewBadGatewayError(message string, err error) *GatewayError { + return &GatewayError{ Message: message, err: err, + code: http.StatusBadGateway, + } +} + +// NewGatewayTimeoutError returns a GatewayError with the message and +// error specified and that returns a 504 status code. +func NewGatewayTimeoutError(message string, err error) *GatewayError { + return &GatewayError{ + Message: message, + err: err, + code: http.StatusGatewayTimeout, } } // StatusCode implements the kithttp.StatusCoder interface so we can customize the // HTTP status code of the response returning this error. -func (e *BadGatewayError) StatusCode() int { - return http.StatusBadGateway +func (e *GatewayError) StatusCode() int { + return e.code } // Error returns the error message. -func (e *BadGatewayError) Error() string { +func (e *GatewayError) Error() string { msg := e.Message if e.err != nil { msg += ": " + e.err.Error() diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 37a26b1a3e..3b57ad378b 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -2,6 +2,7 @@ package fleet import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -1071,3 +1072,47 @@ type HostMacOSProfile struct { // InstallDate is the date the profile was installed on the host as reported by the host's clock. InstallDate time.Time `json:"install_date" db:"install_date"` } + +type HostScriptRequestPayload struct { + HostID uint `json:"host_id"` + ScriptContents string `json:"script_contents"` +} + +type HostScriptResultPayload struct { + HostID uint `json:"host_id"` + ExecutionID string `json:"execution_id"` + Output string `json:"output"` + Runtime int `json:"runtime"` + ExitCode int `json:"exit_code"` +} + +// HostScriptResult represents a script result that was requested to execute on +// a specific host. If no result was received yet for a script, the ExitCode +// field is null and the output is empty. +type HostScriptResult struct { + // ID is the unique row identifier of the host script result. + ID uint `json:"-" db:"id"` + // HostID is the host on which the script was executed. + HostID uint `json:"host_id" db:"host_id"` + // ExecutionID is a unique identifier for a single execution of the script. + ExecutionID string `json:"execution_id" db:"execution_id"` + // ScriptContents is the content of the script to execute. + ScriptContents string `json:"script_contents" db:"script_contents"` + // Output is the combined stdout/stderr output of the script. It is empty + // if no result was received yet. + Output string `json:"output" db:"output"` + // Runtime is the running time of the script in seconds, rounded. + Runtime int `json:"runtime" db:"runtime"` + // ExitCode is null if script execution result was never received from the + // host. It is -1 if it was received but the script did not terminate + // normally (same as how Go handles this: https://pkg.go.dev/os#ProcessState.ExitCode) + ExitCode sql.NullInt64 `json:"exit_code" db:"exit_code"` + + // TeamID is only used for authorization, it must be set to the team id of + // the host when checking authorization and is otherwise not set. + TeamID *uint `json:"team_id" db:"-"` +} + +func (hsr HostScriptResult) AuthzType() string { + return "host_script_result" +} diff --git a/server/fleet/service.go b/server/fleet/service.go index a29e86983a..478b72bad3 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -787,4 +787,12 @@ type Service interface { // GetMDMWindowsTOSContent returns TOS content GetMDMWindowsTOSContent(ctx context.Context, redirectUri string, reqID string) (string, error) + + /////////////////////////////////////////////////////////////////////////////// + // Host Script Execution + + // RunHostScript executes a script on a host and optionally waits for the + // result if waitForResult is > 0. If it times out waiting for a result, it + // fails with a 504 Gateway Timeout error. + RunHostScript(ctx context.Context, request *HostScriptRequestPayload, waitForResult time.Duration) (*HostScriptResult, error) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 5cecaab6ae..95ffe973e3 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -664,6 +664,14 @@ type MDMWindowsInsertEnrolledDeviceFunc func(ctx context.Context, device *fleet. type MDMWindowsDeleteEnrolledDeviceFunc func(ctx context.Context, mdmDeviceID string) error +type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) + +type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) error + +type GetHostScriptExecutionResultFunc func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) + +type ListPendingHostScriptExecutionsFunc func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -1634,6 +1642,18 @@ type DataStore struct { MDMWindowsDeleteEnrolledDeviceFunc MDMWindowsDeleteEnrolledDeviceFunc MDMWindowsDeleteEnrolledDeviceFuncInvoked bool + NewHostScriptExecutionRequestFunc NewHostScriptExecutionRequestFunc + NewHostScriptExecutionRequestFuncInvoked bool + + SetHostScriptExecutionResultFunc SetHostScriptExecutionResultFunc + SetHostScriptExecutionResultFuncInvoked bool + + GetHostScriptExecutionResultFunc GetHostScriptExecutionResultFunc + GetHostScriptExecutionResultFuncInvoked bool + + ListPendingHostScriptExecutionsFunc ListPendingHostScriptExecutionsFunc + ListPendingHostScriptExecutionsFuncInvoked bool + mu sync.Mutex } @@ -3897,3 +3917,31 @@ func (s *DataStore) MDMWindowsDeleteEnrolledDevice(ctx context.Context, mdmDevic s.mu.Unlock() return s.MDMWindowsDeleteEnrolledDeviceFunc(ctx, mdmDeviceID) } + +func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { + s.mu.Lock() + s.NewHostScriptExecutionRequestFuncInvoked = true + s.mu.Unlock() + return s.NewHostScriptExecutionRequestFunc(ctx, request) +} + +func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error { + s.mu.Lock() + s.SetHostScriptExecutionResultFuncInvoked = true + s.mu.Unlock() + return s.SetHostScriptExecutionResultFunc(ctx, result) +} + +func (s *DataStore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { + s.mu.Lock() + s.GetHostScriptExecutionResultFuncInvoked = true + s.mu.Unlock() + return s.GetHostScriptExecutionResultFunc(ctx, execID) +} + +func (s *DataStore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { + s.mu.Lock() + s.ListPendingHostScriptExecutionsFuncInvoked = true + s.mu.Unlock() + return s.ListPendingHostScriptExecutionsFunc(ctx, hostID, ignoreOlder) +} diff --git a/server/service/handler.go b/server/service/handler.go index e278618de6..285a9db430 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -439,6 +439,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/status/result_store", statusResultStoreEndpoint, nil) ue.GET("/api/_version_/fleet/status/live_query", statusLiveQueryEndpoint, nil) + ue.POST("/api/_version_/fleet/scripts/run", runScriptEndpoint, runScriptRequest{}) + ue.POST("/api/_version_/fleet/scripts/run/sync", runScriptSyncEndpoint, runScriptRequest{}) + // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update // `service.mdmAppleConfigurationRequiredEndpoints` when you add an diff --git a/server/service/hosts.go b/server/service/hosts.go index 93d30e0da8..49072b9c78 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -5,6 +5,7 @@ import ( "context" "encoding/csv" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -1588,3 +1589,99 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host return key, nil } + +//////////////////////////////////////////////////////////////////////////////// +// Run Script on a Host (async) +//////////////////////////////////////////////////////////////////////////////// + +type runScriptRequest struct { + HostID uint `json:"host_id"` + ScriptContents string `json:"script_contents"` +} + +type runScriptResponse struct { + Err error `json:"error,omitempty"` + HostID uint `json:"host_id,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` +} + +func (r runScriptResponse) error() error { return r.Err } +func (r runScriptResponse) Status() int { return http.StatusAccepted } + +func runScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*runScriptRequest) + + var noWait time.Duration + result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: req.HostID, + ScriptContents: req.ScriptContents, + }, noWait) + if err != nil { + return runScriptResponse{Err: err}, nil + } + return runScriptResponse{HostID: result.HostID, ExecutionID: result.ExecutionID}, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// Run Script on a Host (sync) +//////////////////////////////////////////////////////////////////////////////// + +type runScriptSyncResponse struct { + Err error `json:"error,omitempty"` + *fleet.HostScriptResult + + // only set if the error was a timeout waiting for a result + ErrorMessage string `json:"error_message,omitempty"` +} + +func (r runScriptSyncResponse) error() error { return r.Err } +func (r runScriptSyncResponse) Status() int { + if r.ErrorMessage != "" { + return http.StatusGatewayTimeout + } + return http.StatusOK +} + +// this is to be used only by tests, to be able to use a shorter timeout. +var testRunScriptWaitForResult time.Duration + +func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + waitForResult := time.Minute + if testRunScriptWaitForResult != 0 { + waitForResult = testRunScriptWaitForResult + } + + req := request.(*runScriptRequest) + result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: req.HostID, + ScriptContents: req.ScriptContents, + }, waitForResult) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + err = fleet.NewGatewayTimeoutError("script execution timed out waiting for a result", err) + // it should still return the execution id and host id in this situation, + // so the user knows what script request to look at in the UI. We cannot + // return an error (field Err) in this case, as the errorer interface's + // rendering logic would take over and only render the error part of the + // response struct. This is why we use the distinct ErrorMessage field to + // add the error message and status code to the response, along with the + // script request. + return runScriptSyncResponse{ + HostScriptResult: result, + ErrorMessage: err.Error(), + }, nil + } + return runScriptSyncResponse{Err: err}, nil + } + return runScriptSyncResponse{ + HostScriptResult: result, + }, nil +} + +func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index c32a0d234e..cecf167e4e 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -1080,3 +1080,155 @@ func TestHostMDMProfileDetail(t *testing.T) { }) } } + +func TestHostRunScript(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + // use a custom implementation of checkAuthErr as the service call will fail + // with a not found error for unknown host in case of authorization success, + // and the package-wide checkAuthErr requires no error. + checkAuthErr := func(t *testing.T, shouldFail bool, err error) { + if shouldFail { + require.Error(t, err) + require.Equal(t, (&authz.Forbidden{}).Error(), err.Error()) + } else if err != nil { + require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error()) + } + } + + teamHost := &fleet.Host{ID: 1, Hostname: "host-team", TeamID: ptr.Uint(1)} + noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil} + nonExistingHost := &fleet.Host{ID: 3, Hostname: "no-such-host", TeamID: nil} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + if hostID == 1 { + return teamHost, nil + } + if hostID == 2 { + return noTeamHost, nil + } + return nil, newNotFoundError() + } + ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { + return &fleet.HostScriptResult{HostID: request.HostID, ScriptContents: request.ScriptContents, ExecutionID: "abc"}, nil + } + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { + return nil, nil + } + + testCases := []struct { + name string + user *fleet.User + shouldFailTeamWrite bool + shouldFailGlobalWrite bool + }{ + { + name: "global admin", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global maintainer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global observer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "global observer+", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "global gitops", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team admin, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: true, + }, + { + name: "team maintainer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: true, + }, + { + name: "team observer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team observer+, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team gitops, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team admin, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team maintainer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team observer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team observer+, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "team gitops, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + _, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: "abc"}, 0) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + _, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: teamHost.ID, ScriptContents: "abc"}, 0) + checkAuthErr(t, tt.shouldFailTeamWrite, err) + + // a non-existing host is authorized as for global write (because we can't know what team it belongs to) + _, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: nonExistingHost.ID, ScriptContents: "abc"}, 0) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + }) + } +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 430bebfe64..7102fbd316 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -4579,6 +4579,13 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { // device migrate mdm endpoint returns an error if not premium createHostAndDeviceToken(t, s.ds, "some-token") s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "some-token"), nil, http.StatusPaymentRequired) + + // run a script + var runResp runScriptResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusPaymentRequired, &runResp) + + // run a script sync + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusPaymentRequired, &runResp) } // TestGlobalPoliciesBrowsing tests that team users can browse (read) global policies (see #3722). diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index cde0d77ca1..058ff9d768 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -3651,3 +3651,123 @@ func (s *integrationEnterpriseTestSuite) TestDesktopEndpointWithInvalidPolicy() require.NoError(t, desktopRes.Err) require.Equal(t, uint(0), *desktopRes.FailingPolicies) } + +func (s *integrationEnterpriseTestSuite) TestRunHostScript() { + t := s.T() + + testRunScriptWaitForResult = 2 * time.Second + defer func() { testRunScriptWaitForResult = 0 }() + + ctx := context.Background() + + host := createOrbitEnrolledHost(t, "linux", "", s.ds) + + // attempt to run a script on a non-existing host + var runResp runScriptResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID + 100, ScriptContents: "echo"}, http.StatusNotFound, &runResp) + + // attempt to run an empty script + res := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: ""}, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "a script to execute is required") + + // attempt to run an overly long script + res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", 10001)}, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "script is too long") + + // create a valid script execution request + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted, &runResp) + require.Equal(t, host.ID, runResp.HostID) + require.NotEmpty(t, runResp.ExecutionID) + + result, err := s.ds.GetHostScriptExecutionResult(ctx, runResp.ExecutionID) + require.NoError(t, err) + require.Equal(t, host.ID, result.HostID) + require.Equal(t, "echo", result.ScriptContents) + require.False(t, result.ExitCode.Valid) + + // attempt to run a sync script on a non-existing host + var runSyncResp runScriptSyncResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID + 100, ScriptContents: "echo"}, http.StatusNotFound, &runSyncResp) + + // attempt to sync run an empty script + res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: ""}, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "a script to execute is required") + + // attempt to sync run an overly long script + res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: strings.Repeat("a", 10001)}, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "script is too long") + + // attempt to create a valid sync script execution request, fails because the + // host has a pending script execution + res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "a script is currently executing on the host") + + // simulate a result being returned for the pending script + err = s.ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: host.ID, + ExecutionID: runResp.ExecutionID, + ExitCode: 0, + Output: "ok", + }) + require.NoError(t, err) + + // create a valid sync script execution request, fails because the + // request will time-out waiting for a result. + runSyncResp = runScriptSyncResponse{} + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusGatewayTimeout, &runSyncResp) + require.Equal(t, host.ID, runSyncResp.HostID) + require.NotEmpty(t, runSyncResp.ExecutionID) + require.Contains(t, runSyncResp.ErrorMessage, "script execution timed out waiting for a result") + + // simulate a result being returned for that pending script + err = s.ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: host.ID, + ExecutionID: runSyncResp.ExecutionID, + ExitCode: 0, + Output: "ok", + }) + require.NoError(t, err) + + // create a valid sync script execution request, and simulate a result + // arriving before timeout. + testRunScriptWaitForResult = 5 * time.Second + ctx, cancel := context.WithTimeout(ctx, testRunScriptWaitForResult) + defer cancel() + + go func() { + for range time.Tick(300 * time.Millisecond) { + pending, err := s.ds.ListPendingHostScriptExecutions(ctx, host.ID, 10*time.Second) + if err != nil { + t.Log(err) + return + } + if len(pending) > 0 { + // ignoring errors in this goroutine, the HTTP request below will fail if this fails + err = s.ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: host.ID, + ExecutionID: pending[0].ExecutionID, + Output: "ok", + Runtime: 1, + ExitCode: 0, + }) + if err != nil { + t.Log(err) + } + return + } + } + }() + + runSyncResp = runScriptSyncResponse{} + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusOK, &runSyncResp) + require.Equal(t, host.ID, runSyncResp.HostID) + require.NotEmpty(t, runSyncResp.ExecutionID) + require.Equal(t, "ok", runSyncResp.Output) + require.Equal(t, int64(0), runSyncResp.ExitCode.Int64) + require.Empty(t, runSyncResp.ErrorMessage) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 204dcee976..46669e8d20 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1261,6 +1261,7 @@ func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testi func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { t := s.T() + ctx := context.Background() devices := []godep.Device{ {SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},