diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 67d3c77a8c..bfdb88bf98 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,90 @@ +# Basic set up for Actions and Docker. Security updates enabled via GitHub settings for other ecosystems. + version: 2 -# updates intentionally left empty, as we were seeing too much volume of PRs, and breakages -# introduced by dependency version updates. Dependabot will continue to open security-related PRs, -# but non-security dependency updates must be done manually. -updates: [] +updates: + +# Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + pull-request-branch-name: + # Default is "/" which makes "docker tag" fail with + # "not a valid repository/tag: invalid reference format". + separator: "-" + # Add assignees + assignees: + - "lukeheath" + +# Maintain dependencies for Dockerfiles + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + reviewers: + - "fleetdm/go" + - "fleetdm/infra" + pull-request-branch-name: + # Default is "/" which makes "docker tag" fail with + # "not a valid repository/tag: invalid reference format". + separator: "-" + # Add assignees + assignees: + - "fleetdm/go" + - "fleetdm/infra" + +# Maintain dependencies for website NPM + - package-ecosystem: "npm" + directory: "/website" + labels: + - "website" + schedule: + interval: "daily" + # Disable version updates + open-pull-requests-limit: 0 + allow: + - dependency-type: "production" + reviewers: + - "eashaw" + pull-request-branch-name: + # Default is "/" which makes "docker tag" fail with + # "not a valid repository/tag: invalid reference format". + separator: "-" + assignees: + - "eashaw" + +# Maintain dependencies for Go + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + # Disable version updates + open-pull-requests-limit: 0 + reviewers: + - lucasmrod + pull-request-branch-name: + # Default is "/" which makes "docker tag" fail with + # "not a valid repository/tag: invalid reference format". + separator: "-" + # Add assignees + assignees: + - lucasmrod + +# Maintain dependencies for npm + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + # Disable version updates + open-pull-requests-limit: 0 + reviewers: + - lukeheath + allow: + - dependency-type: "production" + pull-request-branch-name: + # Default is "/" which makes "docker tag" fail with + # "not a valid repository/tag: invalid reference format". + separator: "-" + # Add assignees + assignees: + - lukeheath \ No newline at end of file diff --git a/.github/workflows/example-workflow.yaml b/.github/workflows/example-workflow.yaml index 75ca7e2964..5a19e87b9f 100644 --- a/.github/workflows/example-workflow.yaml +++ b/.github/workflows/example-workflow.yaml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Apply configuration profiles and updates - uses: fleetdm/fleet-mdm-gitops@026ee84a69cb89c869fedbe27c969bf89def418b + uses: fleetdm/fleet-mdm-gitops@15072f2739ef92c6357414ddd86e89b6bf302a2b with: FLEET_API_TOKEN: $FLEET_API_TOKEN FLEET_URL: $FLEET_URL diff --git a/changes/10102-host-script-details-api b/changes/10102-host-script-details-api new file mode 100644 index 0000000000..817ffbd35d --- /dev/null +++ b/changes/10102-host-script-details-api @@ -0,0 +1 @@ +- Added `GET /hosts/{id}/scripts` endpoint to retrieve status details of saved scripts applicable to a host. diff --git a/changes/13654-script-activity-logging b/changes/13654-script-activity-logging new file mode 100644 index 0000000000..5bbc9edcbc --- /dev/null +++ b/changes/13654-script-activity-logging @@ -0,0 +1 @@ +- Added activity logging for add, delete, and edit scripts. diff --git a/changes/bug-11314-disable-multicursor-editor b/changes/bug-11314-disable-multicursor-editor new file mode 100644 index 0000000000..b5fbb8c121 --- /dev/null +++ b/changes/bug-11314-disable-multicursor-editor @@ -0,0 +1 @@ +- Fleet UI: Disable multicursor editing for SQL editors diff --git a/changes/issue-14406-implement-script-host-details b/changes/issue-14406-implement-script-host-details new file mode 100644 index 0000000000..b900b68752 --- /dev/null +++ b/changes/issue-14406-implement-script-host-details @@ -0,0 +1 @@ +- implement scripts tab and table for host details page diff --git a/changes/issue-9829-scripts-api b/changes/issue-9829-scripts-api new file mode 100644 index 0000000000..402ad3287f --- /dev/null +++ b/changes/issue-9829-scripts-api @@ -0,0 +1,2 @@ +* Added API endpoints for script management. +* Updated the `POST /scripts/run` and `POST /scripts/run/sync` endpoints to accept an optional saved script ID instead of the script contents. diff --git a/changes/issue-9831-implement-scripts-page b/changes/issue-9831-implement-scripts-page new file mode 100644 index 0000000000..c21d40b483 --- /dev/null +++ b/changes/issue-9831-implement-scripts-page @@ -0,0 +1 @@ +- implement UI for scripts on the controls page diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 88e612a637..1bd33060cd 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -2021,6 +2021,9 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { ds.BulkSetPendingMDMAppleHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs, profileIDs []uint, uuids []string) error { return nil } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + return nil + } actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"}) yamlFilePath := writeTmpYml(t, actualYaml) diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index 31ec5a3f5f..ae3267d80c 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -88,7 +88,7 @@ func TestRunScriptCommand(t *testing.T) { scriptPath: func() string { return writeTmpScriptContents(t, maxChars, ".sh") }, - expectErrMsg: `Script is too large. It’s limited to 10,000 characters (approximately 125 lines).`, + expectErrMsg: `Script is too large. It's limited to 10,000 characters (approximately 125 lines).`, }, { name: "script empty", diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 35a2091761..146f64e82a 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -111,6 +111,7 @@ "metadata_url": "", "idp_name": "" } - } + }, + "scripts": null } } \ No newline at end of file diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index dc026f027c..c6d273df1c 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -39,6 +39,7 @@ spec: metadata: "" metadata_url: "" entity_id: "" + scripts: null org_info: org_logo_url: "" org_logo_url_light_background: "" diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 42a6018435..e6b915b875 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -70,6 +70,7 @@ "idp_name": "" } }, + "scripts": null, "sso_settings": { "enable_jit_provisioning": false, "enable_jit_role_sync": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 0e5b42befe..1ddb36b944 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -39,6 +39,7 @@ spec: metadata: "" metadata_url: "" entity_id: "" + scripts: null license: expiration: "0001-01-01T00:00:00Z" tier: free diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 6a99943e94..c8b587d149 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -38,6 +38,7 @@ "macos_setup_assistant": null } }, + "scripts": null, "user_count": 99, "host_count": 42 } @@ -98,6 +99,7 @@ "macos_setup_assistant": null } }, + "scripts": null, "user_count": 87, "host_count": 43 } diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index a6905cf569..61643d07db 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -17,6 +17,7 @@ spec: bootstrap_package: enable_end_user_authentication: false macos_setup_assistant: + scripts: null name: team1 --- apiVersion: v1 @@ -46,4 +47,5 @@ spec: bootstrap_package: enable_end_user_authentication: false macos_setup_assistant: + scripts: null name: team2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 5f25121e3b..c48bef7a3e 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -39,6 +39,7 @@ spec: metadata: "" metadata_url: "" entity_id: "" + scripts: null org_info: org_logo_url: "" org_logo_url_light_background: "" diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 4b2fd0c151..05207bb30e 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -39,6 +39,7 @@ spec: metadata: "" metadata_url: "" entity_id: "" + scripts: null org_info: org_logo_url: "" org_logo_url_light_background: "" diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index a3668e64b3..4c50064fa2 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -17,6 +17,7 @@ spec: macos_updates: deadline: null minimum_version: null + scripts: null name: tm1 --- apiVersion: v1 @@ -36,4 +37,5 @@ spec: macos_updates: deadline: null minimum_version: null + scripts: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 95e49d0321..0af52083b2 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -17,6 +17,7 @@ spec: macos_updates: deadline: null minimum_version: null + scripts: null name: tm1 --- apiVersion: v1 @@ -36,4 +37,5 @@ spec: macos_updates: deadline: null minimum_version: null + scripts: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 8ad10fc6c5..a32bec16f3 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -17,5 +17,6 @@ spec: macos_updates: deadline: null minimum_version: null + scripts: null name: tm1 diff --git a/docs/Configuration/configuration-files/README.md b/docs/Configuration/configuration-files/README.md index 8b8f34b8cf..f5a7ed2765 100644 --- a/docs/Configuration/configuration-files/README.md +++ b/docs/Configuration/configuration-files/README.md @@ -246,6 +246,9 @@ spec: - path/to/profile1.mobileconfig - path/to/profile2.mobileconfig enable_disk_encryption: true + scripts: + - path/to/script1.sh + - path/to/script2.sh ``` ### Team agent options @@ -329,6 +332,23 @@ spec: # the team-specific mdm options go here ``` +### Team scripts + +List of saved scripts that can be run on hosts that are part of the team. + +- Default value: none +- Config file format: + ```yaml +apiVersion: v1 +kind: team +spec: + team: + name: Client Platform Engineering + scripts: + - path/to/script1.sh + - path/to/script2.sh + ``` + ## Organization settings The `config` YAML file controls Fleet's organization settings and MDM features for hosts assigned to "No team." @@ -1147,6 +1167,20 @@ If you're using Fleet Premium, this enforces disk encryption on all hosts assign enable_disk_encryption: true ``` +#### Scripts + +List of saved scripts that can be run on all hosts. + +> If you want to add scripts to hosts on a specific team in Fleet, use the `team` YAML document. Learn how to create one [here](#teams). + +- Default value: none +- Config file format: + ```yaml + scripts: + - path/to/script1.sh + - path/to/script2.sh + ``` + #### Advanced configuration > **Note:** More settings are included in the [contributor documentation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Configuration-for-contributors.md). It's possible, although not recommended, to configure these settings in the YAML configuration file. diff --git a/docs/Configuration/fleet-server-configuration.md b/docs/Configuration/fleet-server-configuration.md index 250d5c6b17..588b000217 100644 --- a/docs/Configuration/fleet-server-configuration.md +++ b/docs/Configuration/fleet-server-configuration.md @@ -2945,11 +2945,11 @@ This content was moved to [Proxies](http://fleetdm.com/docs/deploy/proxies) on S

Configuring single sign-on (SSO)

-This content was moved to [Single sign-on (SSO)](http://fleetdm.com/deploy/single-sign-on-sso) on Sept 6th, 2023. +This content was moved to [Single sign-on (SSO)](http://fleetdm.com/docs/deploy/single-sign-on-sso) on Sept 6th, 2023.

Public IPs of devices

-This content was moved to [Public IPs](http://fleetdm.com/docs/deploy/public-ips) on Sept 6th, 2023. +This content was moved to [Public IPs](http://fleetdm.com/docs/deploy/public-ip) on Sept 6th, 2023. diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index ddefdb2e9a..3d5555ca8d 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -8,6 +8,7 @@ - [Device-authenticated routes](#device-authenticated-routes) - [Downloadable installers](#downloadable-installers) - [Setup](#setup) +- [Scripts](#scripts) This document includes the internal Fleet API routes that are helpful when developing or contributing to Fleet. @@ -1276,6 +1277,7 @@ If the `name` is not already associated with an existing team, this API route cr | mdm.macos_updates.deadline | string | body | The required installation date for Nudge to enforce the operating system version. | | mdm.macos_settings | object | body | The macOS-specific MDM settings. | | mdm.macos_settings.custom_settings | list | body | The list of .mobileconfig files to apply to hosts that belong to this team. | +| scripts | list | body | A list of script files to add to this team so they can be executed at a later time. | | mdm.macos_settings.enable_disk_encryption | bool | body | Whether disk encryption should be enabled for hosts that belong to this team. | | force | bool | query | Force apply the spec even if there are (ignorable) validation errors. Those are unknown keys and agent options-related validations. | | dry_run | bool | query | Validate the provided JSON for unknown keys and invalid value types and return any validation errors, but do not apply the changes. | @@ -1337,7 +1339,8 @@ If the `name` is not already associated with an existing team, this API route cr "custom_settings": ["path/to/profile1.mobileconfig"], "enable_disk_encryption": true } - } + }, + "scripts": ["path/to/script.sh"], } ] } @@ -2692,5 +2695,34 @@ If the Fleet instance is provided required parameters to complete setup. ``` +## Scripts + +### Batch-apply scripts + +_Available in Fleet Premium_ + +`POST /api/v1/fleet/scripts/batch` + +#### Parameters + +| Name | Type | In | Description | +| --------- | ------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| team_id | number | query | The ID of the team to add the scripts to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`. +| team_id | number | query | The ID of the team to add the scripts to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`. +| dry_run | bool | query | Validate the provided scripts and return any validation errors, but do not apply the changes. | +| scripts | array | body | An array of objects with the scripts payloads. Each item must contain `name` with the script name and `script_contents` with the script contents encoded in base64 | + +If both `team_id` and `team_name` parameters are included, this endpoint will respond with an error. If no `team_name` or `team_id` is provided, the scripts will be applied for **all hosts**. + +> Note that this endpoint replaces all the active scripts for the specified team (or no team). Any existing script that is not included in the list will be removed, and existing scripts with the same name as a new script will be edited. Providing an empty list of scripts will remove existing scripts. + +#### Example + +`POST /api/v1/fleet/mdm/scripts/batch` + +##### Default response + +`204` + diff --git a/docs/Deploy/Deploy-Fleet-on-CentOS.md b/docs/Deploy/Deploy-Fleet-on-CentOS.md index 173617fd27..016a3c94bf 100644 --- a/docs/Deploy/Deploy-Fleet-on-CentOS.md +++ b/docs/Deploy/Deploy-Fleet-on-CentOS.md @@ -19,15 +19,16 @@ vagrant ssh ### Installing Fleet -To install Fleet, [download](https://github.com/fleetdm/fleet/releases) the file named `Source code -(zip)`, rename, unzip, and move the latest Fleet binary to your desired install location. +To install Fleet, [download](https://github.com/fleetdm/fleet/releases) the latest release from GitHub. The binary is in an archive that uses this naming convention, including the current version: `fleet__linux.tar.gz`. -For example, after downloading: -```sh -mv .zip fleet.zip -unzip fleet.zip -d fleet -sudo cp fleet /usr/bin/ -sudo chmod u+x /usr/bin/fleet +Once the file is downloaded, extract the Fleet binary from the archive and copy it in to your desired location. + +For example: +```console +> tar -xf fleet__linux.tar.gz +> sudo cp fleet__linux/fleet /usr/bin/ +> /usr/bin/fleet version +fleet version ``` ### Installing and configuring dependencies diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index d42a9f912d..1dd01e49ac 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -875,6 +875,7 @@ None. "custom_settings": ["path/to/profile1.mobileconfig"], "enable_disk_encryption": true }, + "scripts": ["path/to/script.sh"], "end_user_authentication": { "entity_id": "", "issuer_uri": "", @@ -1074,6 +1075,7 @@ Modifies the Fleet's configuration with the supplied information. | webhook_url | string | body | _mdm.macos_migration settings_. The webhook url configured to receive requests to unenroll devices migrating from your old MDM solution. **Requires Fleet Premium license** | | custom_settings | list | body | _mdm.macos_settings settings_. Hosts that belong to no team and are enrolled into Fleet's MDM will have those custom profiles applied. | | enable_disk_encryption | boolean | body | _mdm.macos_settings settings_. Hosts that belong to no team and are enrolled into Fleet's MDM will have disk encryption enabled if set to true. **Requires Fleet Premium license** | +| scripts | list | body | A list of script files to add so they can be executed at a later time. | | enable_end_user_authentication | boolean | body | _mdm.macos_setup settings_. If set to true, end user authentication will be required during automatic MDM enrollment of new macOS devices. Settings for your IdP provider must also be [configured](https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience#end-user-authentication-and-eula). **Requires Fleet Premium license** | | additional_queries | boolean | body | Whether or not additional queries are enabled on hosts. | | force | bool | query | Force apply the agent options even if there are validation errors. | @@ -1849,8 +1851,11 @@ the `software` table. | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | disable_failing_policies| boolean | query | If "true", hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | -| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. | +| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | + If `additional_info_filters` is not specified, no `additional` information will be returned. @@ -2006,8 +2011,10 @@ Response payload with the `munki_issue_id` filter provided: | macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | -| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | If `additional_info_filters` is not specified, no `additional` information will be returned. @@ -3314,7 +3321,7 @@ requested by a web browser. | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `status`, `query` and `team_id`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | disable_failing_policies | boolean | query | If `true`, hosts will return failing policies as 0 (returned as the `issues` column) regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results. @@ -3741,8 +3748,10 @@ Returns a list of the hosts that belong to the specified label. | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Can be one of 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | | macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | -| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. | -| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** | +| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | +| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings_disk_encryption` is specified, then Windows Servers are excluded from the results. @@ -4106,7 +4115,7 @@ The summary can optionally be filtered by team ID. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | _Available in Fleet Premium_ The team id to filter the summary. | +| team_id | string | query | _Available in Fleet Premium_ The team ID to filter the summary. | #### Example @@ -6394,7 +6403,12 @@ This allows you to easily configure scheduled queries that will impact a whole t - [Run script asynchronously](#run-script-asynchronously) - [Run script synchronously](#run-script-synchronously) - +- [Get script result](#get-script-result) +- [Upload a script](#upload-a-script) +- [Delete a script](#delete-a-script) +- [List scripts](#list-scripts) +- [Get or download a script](#get-or-download-a-script) +- [Get script details by host](#get-script-details-by-host) ### Run script asynchronously @@ -6406,10 +6420,13 @@ Creates a script execution request and returns the execution identifier to retri #### 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. | +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| host_id | integer | body | **Required**. The ID of the host to run the script on. | +| script_id | integer | body | The ID of the existing saved script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_contents`. | +| script_contents | string | body | The contents of the script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_id`. | + +> Note that if both `script_id` and `script_contents` are included in the request, this endpoint will respond with an error. #### Example @@ -6439,7 +6456,10 @@ Creates a script execution request and waits for a result to return (up to a 1 m | 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. | +| script_id | integer | body | The ID of the existing saved script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_contents`. | +| script_contents | string | body | The contents of the script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_id`. | + +> Note that if both `script_id` and `script_contents` are included in the request, this endpoint will respond with an error. #### Example @@ -6498,6 +6518,239 @@ Gets the result of a script that was executed. > Note: `exit_code` can be `null` if Fleet hasn't heard back from the host yet. +### Upload a script + +_Available in Fleet Premium_ + +Uploads a script, making it available to run on hosts assigned to the specified team (or no team). + +`POST /api/v1/fleet/scripts` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| script | file | form | **Required**. The file containing the script. | +| team_id | integer | form | The team ID. If specified, the script will only be available to hosts assigned to this team. If not specified, the script will only be available to hosts on **no team**. | + +#### Example + +`POST /api/v1/fleet/scripts` + +##### Request headers + +```http +Content-Length: 306 +Content-Type: multipart/form-data; boundary=------------------------f02md47480und42y +``` + +##### Request body + +```http +--------------------------f02md47480und42y +Content-Disposition: form-data; name="team_id" + +1 +--------------------------f02md47480und42y +Content-Disposition: form-data; name="script"; filename="myscript.sh" +Content-Type: application/octet-stream + +echo "hello" +--------------------------f02md47480und42y-- + +``` + +##### Default response + +`Status: 200` + +```json +{ + "script_id": 1227 +} +``` + +### Delete a script + +_Available in Fleet Premium_ + +Deletes an existing script. + +`DELETE /api/v1/fleet/scripts/{id}` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| id | integer | path | **Required**. The ID of the script to delete. | + +#### Example + +`DELETE /api/v1/fleet/scripts/1` + +##### Default response + +`Status: 204` + +### List scripts + +_Available in Fleet Premium_ + +`GET /api/v1/fleet/scripts` + +#### Parameters + +| Name | Type | In | Description | +| --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | +| page | integer | query | Page number of the results to fetch. | +| per_page | integer | query | Results per page. | + +#### Example + +`GET /api/v1/fleet/scripts` + +##### Default response + +`Status: 200` + +```json +{ + "scripts": [ + { + "id": 1, + "team_id": null, + "name": "script_1.sh", + "created_at": "2023-07-30T13:41:07Z", + "updated_at": "2023-07-30T13:41:07Z" + }, + { + "id": 2, + "team_id": null, + "name": "script_2.sh", + "created_at": "2023-08-30T13:41:07Z", + "updated_at": "2023-08-30T13:41:07Z" + } + ], + "meta": { + "has_next_results": false, + "has_previous_results": false + } +} + +``` + +### Get or download a script + +_Available in Fleet Premium_ + +`GET /api/v1/fleet/scripts/{id}` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | ------------------------------------- | +| id | integer | path | **Required.** The desired script's ID. | +| alt | string | query | If specified and set to "media", downloads the script's contents. | + +#### Example (get a script) + +`GET /api/v1/fleet/scripts/123` + +##### Default response + +`Status: 200` + +```json +{ + "id": 123, + "team_id": null, + "name": "script_1.sh", + "created_at": "2023-07-30T13:41:07Z", + "updated_at": "2023-07-30T13:41:07Z" +} + +``` + +#### Example (download a script's contents) + +`GET /api/v1/fleet/scripts/123?alt=media` + +##### Example response headers + +```http +Content-Length: 13 +Content-Type: application/octet-stream +Content-Disposition: attachment;filename="2023-09-27 script_1.sh" +``` + +###### Example response body + +`Status: 200` + +``` +echo "hello" +``` + +### Get script details by host + +_Available in Fleet Premium_ + +`GET /api/v1/fleet/hosts/{id}/scripts` + +#### Parameters + +| Name | Type | In | Description | +| --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | +| page | integer | query | Page number of the results to fetch. | +| per_page | integer | query | Results per page. | + +#### Example + +`GET /api/v1/fleet/hosts/{id}/scripts` + +##### Default response + +`Status: 200` + +```json +{ + "scripts": [ + { + "script_id": 3, + "name": "remove-zoom-artifacts.sh", + "last_execution": { + "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002", + "executed_at": "2021-12-15T15:23:57Z", + "status": "error" + } + }, + { + "script_id": 5, + "name": "set-timezone.sh", + "last_execution": { + "id": "e797d6c6-3aae-11ee-be56-0242ac120002", + "executed_at": "2021-12-15T15:23:57Z", + "status": "pending" + } + }, + { + "script_id": 8, + "name": "uninstall-zoom.sh", + "last_execution": { + "id": "e797d6c6-3aae-11ee-be56-0242ac120002", + "executed_at": "2021-12-15T15:23:57Z", + "status": "ran" + } + } + ], + "meta": { + "has_next_results": false, + "has_previous_results": false + } +} + +``` + --- ## Sessions diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index 4654c37421..ceafb48a40 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -863,6 +863,61 @@ This activity contains the following fields: } ``` +### Type `added_script` + +Generated when a script is added to a team (or no team). + +This activity contains the following fields: +- "script_name": Name of the script. +- "team_id": The ID of the team that the script applies to, `null` if it applies to devices that are not in a team. +- "team_name": The name of the team that the script applies to, `null` if it applies to devices that are not in a team. + +#### Example + +```json +{ + "script_name": "set-timezones.sh", + "team_id": 123, + "team_name": "Workstations" +} +``` + +### Type `deleted_script` + +Generated when a script is deleted from a team (or no team). + +This activity contains the following fields: +- "script_name": Name of the script. +- "team_id": The ID of the team that the script applies to, `null` if it applies to devices that are not in a team. +- "team_name": The name of the team that the script applies to, `null` if it applies to devices that are not in a team. + +#### Example + +```json +{ + "script_name": "set-timezones.sh", + "team_id": 123, + "team_name": "Workstations" +} +``` + +### Type `edited_script` + +Generated when a user edits the scripts of a team (or no team) via the fleetctl CLI. + +This activity contains the following fields: +- "team_id": The ID of the team that the scripts apply to, `null` if they apply to devices that are not in a team. +- "team_name": The name of the team that the scripts apply to, `null` if they apply to devices that are not in a team. + +#### Example + +```json +{ + "team_id": 123, + "team_name": "Workstations" +} +``` + diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index 90774c86e1..78bc19a350 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -87,7 +87,10 @@ 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\* | | | ✅ | ✅ | | +| Run arbitrary scripts on hosts\* | | | ✅ | ✅ | | +| View saved scripts\* | ✅ | ✅ | ✅ | ✅ | | +| Edit/upload saved scripts\* | | | ✅ | ✅ | | +| Run saved scripts on hosts\* | ✅ | ✅ | ✅ | ✅ | | \* Applies only to Fleet Premium @@ -149,7 +152,12 @@ 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 | | | ✅ | ✅ | | +| Run arbitrary scripts on hosts | | | ✅ | ✅ | | +| View saved scripts | ✅ | ✅ | ✅ | ✅ | | +| Edit/upload saved scripts | | | ✅ | ✅ | | +| Run saved scripts on hosts | ✅ | ✅ | ✅ | ✅ | | +| View script details by host | ✅ | ✅ | ✅ | ✅ | | + \* Applies only to [Fleet REST API](https://fleetdm.com/docs/using-fleet/rest-api) diff --git a/ee/fleetd-chrome/package-lock.json b/ee/fleetd-chrome/package-lock.json index 033a064cd5..7914a75dcc 100644 --- a/ee/fleetd-chrome/package-lock.json +++ b/ee/fleetd-chrome/package-lock.json @@ -5510,10 +5510,16 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6130,9 +6136,9 @@ } }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -6142,10 +6148,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, diff --git a/ee/fleetd-chrome/yarn.lock b/ee/fleetd-chrome/yarn.lock index 3458d89886..0ecb15d3b5 100644 --- a/ee/fleetd-chrome/yarn.lock +++ b/ee/fleetd-chrome/yarn.lock @@ -3696,9 +3696,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== postcss@^8.4.21: - version "8.4.24" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.24.tgz#f714dba9b2284be3cc07dbd2fc57ee4dc972d2df" - integrity sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg== + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== dependencies: nanoid "^3.3.6" picocolors "^1.0.0" diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index aac92c1fc3..a2b4c8749f 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -2,11 +2,7 @@ package service import ( "context" - "net/http" - "time" - "github.com/fleetdm/fleet/v4/server/authz" - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -23,144 +19,3 @@ 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 maxPendingScriptAge = time.Minute // any script older than this is not considered pending anymore on that host - - // must load the host to get the team (cannot use lite, the last seen time is - // required to check if it is online) to authorize with the proper team id. - // We cannot first authorize if the user can list hosts, in case we - // eventually allow a write-only role (e.g. gitops). - host, err := svc.ds.Host(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 err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil { - return nil, fleet.NewInvalidArgumentError("script_contents", err.Error()) - } - - // host must be online - if host.Status(time.Now()) != fleet.StatusOnline { - return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg) - } - - 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 { - return nil, fleet.NewInvalidArgumentError( - "script_contents", fleet.RunScriptAlreadyRunningErrMsg, - ).WithStatus(http.StatusConflict) - } - - // create the script execution request, the host will be notified of the - // script execution request via the orbit config's Notifications mechanism. - script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "create script execution request") - } - script.Hostname = host.DisplayName() - - asyncExecution := waitForResult <= 0 - - err = svc.ds.NewActivity( - ctx, - authz.UserFromContext(ctx), - fleet.ActivityTypeRanScript{ - HostID: host.ID, - HostDisplayName: host.DisplayName(), - ScriptExecutionID: script.ExecutionID, - Async: asyncExecution, - }, - ) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for script execution request") - } - - if asyncExecution { - // 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 != nil { - // a result was received from the host, return - result.Hostname = host.DisplayName() - 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) - } - } -} - -func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { - scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID) - if err != nil { - if fleet.IsNotFound(err) { - if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil { - return nil, err - } - } - svc.authz.SkipAuthorization(ctx) - return nil, ctxerr.Wrap(ctx, err, "get script result") - } - - host, err := svc.ds.HostLite(ctx, scriptResult.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.ActionRead); 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.ActionRead); err != nil { - return nil, err - } - - scriptResult.Hostname = host.DisplayName() - - return scriptResult, nil -} diff --git a/ee/server/service/scripts.go b/ee/server/service/scripts.go new file mode 100644 index 0000000000..d2abd3745b --- /dev/null +++ b/ee/server/service/scripts.go @@ -0,0 +1,441 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" +) + +func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) { + const maxPendingScriptAge = time.Minute // any script older than this is not considered pending anymore on that host + + // must load the host to get the team (cannot use lite, the last seen time is + // required to check if it is online) to authorize with the proper team id. + // We cannot first authorize if the user can list hosts, in case we + // eventually allow a write-only role (e.g. gitops). + host, err := svc.ds.Host(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") + } + + // must check that only one of script id or contents is provided before + // authorization, as the permissions are not the same if a script id is + // provided. There's no harm in returning the error if this validation fails, + // since both values are user-provided it doesn't leak any internal + // information. + if request.ScriptID != nil && request.ScriptContents != "" { + svc.authz.SkipAuthorization(ctx) + return nil, fleet.NewInvalidArgumentError("script_id", `Only one of "script_id" or "script_contents" can be provided.`) + } + + // authorize with the host's team and the script id provided, as both affect + // the permissions. + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID, ScriptID: request.ScriptID}, fleet.ActionWrite); err != nil { + return nil, err + } + + if request.ScriptID != nil { + script, err := svc.ds.Script(ctx, *request.ScriptID) + if err != nil { + if fleet.IsNotFound(err) { + return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`). + WithStatus(http.StatusNotFound) + } + return nil, err + } + var scriptTmID, hostTmID uint + if script.TeamID != nil { + scriptTmID = *script.TeamID + } + if host.TeamID != nil { + hostTmID = *host.TeamID + } + if scriptTmID != hostTmID { + return nil, fleet.NewInvalidArgumentError("script_id", `The script does not belong to the same team (or no team) as the host.`) + } + + contents, err := svc.ds.GetScriptContents(ctx, *request.ScriptID) + if err != nil { + if fleet.IsNotFound(err) { + return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`). + WithStatus(http.StatusNotFound) + } + return nil, err + } + request.ScriptContents = string(contents) + } + + if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil { + return nil, fleet.NewInvalidArgumentError("script_contents", err.Error()) + } + + // host must be online + if host.Status(time.Now()) != fleet.StatusOnline { + return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg) + } + + 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 { + return nil, fleet.NewInvalidArgumentError( + "script_contents", fleet.RunScriptAlreadyRunningErrMsg, + ).WithStatus(http.StatusConflict) + } + + // create the script execution request, the host will be notified of the + // script execution request via the orbit config's Notifications mechanism. + script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "create script execution request") + } + script.Hostname = host.DisplayName() + + asyncExecution := waitForResult <= 0 + + err = svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeRanScript{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + ScriptExecutionID: script.ExecutionID, + Async: asyncExecution, + }, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for script execution request") + } + + if asyncExecution { + // 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 != nil { + // a result was received from the host, return + result.Hostname = host.DisplayName() + 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) + } + } +} + +func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { + scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID) + if err != nil { + if fleet.IsNotFound(err) { + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get script result") + } + + host, err := svc.ds.HostLite(ctx, scriptResult.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.ActionRead); 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.ActionRead); err != nil { + return nil, err + } + + scriptResult.Hostname = host.DisplayName() + + return scriptResult, nil +} + +func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) { + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { + return nil, err + } + + b, err := io.ReadAll(r) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "read script contents") + } + + script := &fleet.Script{ + TeamID: teamID, + Name: name, + ScriptContents: string(b), + } + if err := script.Validate(); err != nil { + return nil, fleet.NewInvalidArgumentError("script", err.Error()) + } + + savedScript, err := svc.ds.NewScript(ctx, script) + if err != nil { + var ( + existsErr fleet.AlreadyExistsError + fkErr fleet.ForeignKeyError + ) + if errors.As(err, &existsErr) { + err = fleet.NewInvalidArgumentError("script", "A script with this name already exists.").WithStatus(http.StatusConflict) + } else if errors.As(err, &fkErr) { + err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound) + } + return nil, ctxerr.Wrap(ctx, err, "create script") + } + + var teamName *string + if teamID != nil && *teamID != 0 { + tm, err := svc.teamByIDOrName(ctx, teamID, nil) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get team name for create script activity") + } + teamName = &tm.Name + } + + if err := svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeAddedScript{ + TeamID: teamID, + TeamName: teamName, + ScriptName: script.Name, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "new activity for create script") + } + + return savedScript, nil +} + +func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error { + script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionWrite) + if err != nil { + return err + } + + if err := svc.ds.DeleteScript(ctx, script.ID); err != nil { + return ctxerr.Wrap(ctx, err, "delete script") + } + + var teamName *string + if script.TeamID != nil && *script.TeamID != 0 { + tm, err := svc.teamByIDOrName(ctx, script.TeamID, nil) + if err != nil { + return ctxerr.Wrap(ctx, err, "get team name for delete script activity") + } + teamName = &tm.Name + } + + if err := svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeDeletedScript{ + TeamID: script.TeamID, + TeamName: teamName, + ScriptName: script.Name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "new activity for delete script") + } + + return nil +} + +func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, nil, err + } + + // cursor-based pagination is not supported for scripts + opt.After = "" + // custom ordering is not supported, always by name + opt.OrderKey = "name" + opt.OrderDirection = fleet.OrderAscending + // no matching query support + opt.MatchQuery = "" + // always include metadata for scripts + opt.IncludeMetadata = true + + return svc.ds.ListScripts(ctx, teamID, opt) +} + +func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) { + script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionRead) + if err != nil { + return nil, nil, err + } + + var content []byte + if withContent { + content, err = svc.ds.GetScriptContents(ctx, scriptID) + if err != nil { + return nil, nil, err + } + } + return script, content, nil +} + +func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) { + // first, get the script because we don't know which team id it is for. + script, err := svc.ds.Script(ctx, scriptID) + if err != nil { + if fleet.IsNotFound(err) { + // couldn't get the script to have its team, authorize with a no-team + // script as a fallback - the requested script does not exist so there's + // no way to know what team it would be for, and returning a 404 without + // authorization would leak the existing/non existing ids. + if err := svc.authz.Authorize(ctx, &fleet.Script{}, authzAction); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get script") + } + + // do the actual authorization with the script's team id + if err := svc.authz.Authorize(ctx, script, authzAction); err != nil { + return nil, err + } + return script, nil +} + +func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { + h, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + if fleet.IsNotFound(err) { + // if error is because the host does not exist, check first if the user + // had global access (to prevent leaking valid host ids). + if err := svc.authz.Authorize(ctx, &fleet.Script{}, fleet.ActionRead); err != nil { + return nil, nil, err + } + } + return nil, nil, err + } + + // cursor-based pagination is not supported for scripts + opt.After = "" + // custom ordering is not supported, always by name + opt.OrderKey = "name" + opt.OrderDirection = fleet.OrderAscending + // no matching query support + opt.MatchQuery = "" + // always include metadata for scripts + opt.IncludeMetadata = true + + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: h.TeamID}, fleet.ActionRead); err != nil { + return nil, nil, err + } + + return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt) +} + +func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error { + if maybeTmID != nil && maybeTmName != nil { + svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden" + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name")) + } + + var teamID *uint + var teamName *string + + if maybeTmID != nil || maybeTmName != nil { + team, err := svc.teamByIDOrName(ctx, maybeTmID, maybeTmName) + if err != nil { + return err + } + teamID = &team.ID + teamName = &team.Name + } + + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err) + } + + // any duplicate name in the provided set results in an error + scripts := make([]*fleet.Script, 0, len(payloads)) + byName := make(map[string]bool, len(payloads)) + for i, p := range payloads { + script := &fleet.Script{ + ScriptContents: string(p.ScriptContents), + Name: p.Name, + TeamID: teamID, + } + + if err := script.Validate(); err != nil { + return ctxerr.Wrap(ctx, + fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error())) + } + + if byName[script.Name] { + return ctxerr.Wrap(ctx, + fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), fmt.Sprintf("Couldn’t edit scripts. More than one script has the same file name: %q", script.Name)), + "duplicate script by name") + } + byName[script.Name] = true + scripts = append(scripts, script) + } + + if dryRun { + return nil + } + + if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil { + return ctxerr.Wrap(ctx, err, "batch saving scripts") + } + + if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedScript{ + TeamID: teamID, + TeamName: teamName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited scripts") + } + return nil +} diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 529408de47..9a6cb8b0f1 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -931,6 +931,10 @@ func (svc *Service) editTeamFromSpec( } team.Config.MDM.MacOSSetup.EnableEndUserAuthentication = spec.MDM.MacOSSetup.EnableEndUserAuthentication + if spec.Scripts.Set { + team.Config.Scripts = spec.Scripts + } + if len(secrets) > 0 { team.Secrets = secrets } diff --git a/frontend/__mocks__/scriptMock.ts b/frontend/__mocks__/scriptMock.ts index 65baefdd0f..f193b3d43e 100644 --- a/frontend/__mocks__/scriptMock.ts +++ b/frontend/__mocks__/scriptMock.ts @@ -1,4 +1,20 @@ -import { IScriptResultResponse } from "services/entities/scripts"; +import { + IHostScript, + IScript, + IScriptResultResponse, +} from "services/entities/scripts"; + +const DEFAULT_SCRIPT_MOCK: IScript = { + id: 1, + team_id: null, + name: "test script", + created_at: "2020-01-01T00:00:00.000Z", + updated_at: "2020-01-01T00:00:00.000Z", +}; + +export const createMockScript = (overrides?: Partial): IScript => { + return { ...DEFAULT_SCRIPT_MOCK, ...overrides }; +}; const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = { hostname: "Test Host", @@ -12,10 +28,24 @@ const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = { host_timeout: false, }; -const createMockScriptResult = ( +export const createMockScriptResult = ( overrides?: Partial ): IScriptResultResponse => { return { ...DEFAULT_SCRIPT_RESULT_MOCK, ...overrides }; }; -export default createMockScriptResult; +const DEFAULT_HOST_SCRIPT_MOCK: IHostScript = { + script_id: 1, + name: "test script", + last_execution: { + execution_id: "123", + executed_at: "2020-01-01T00:00:00.000Z", + status: "ran", + }, +}; + +export const createMockHostScript = ( + overrides?: Partial +): IHostScript => { + return { ...DEFAULT_HOST_SCRIPT_MOCK, ...overrides }; +}; diff --git a/frontend/components/Card/Card.tsx b/frontend/components/Card/Card.tsx index bd973e819b..36576967e6 100644 --- a/frontend/components/Card/Card.tsx +++ b/frontend/components/Card/Card.tsx @@ -3,12 +3,17 @@ import classnames from "classnames"; const baseClass = "card"; -type CardColors = "white" | "gray" | "purple" | "yellow"; +type BorderRadiusSize = "small" | "medium" | "large"; +type CardColor = "white" | "gray" | "purple" | "yellow"; interface ICardProps { children?: React.ReactNode; - /** defaults to white */ - color?: CardColors; + /** The size of the border radius. Defaults to `small` */ + borderRadiusSize?: BorderRadiusSize; + /** Includes the card shadows. Defaults to `false` */ + includeShadow?: boolean; + /** The color of the card. Defaults to `white` */ + color?: CardColor; className?: string; } @@ -16,8 +21,20 @@ interface ICardProps { * A generic card component that will be used to render content within a card with a border and * and selected background color. */ -const Card = ({ children, color = "white", className }: ICardProps) => { - const classNames = classnames(baseClass, `${baseClass}__${color}`, className); +const Card = ({ + children, + borderRadiusSize = "small", + includeShadow = false, + color = "white", + className, +}: ICardProps) => { + const classNames = classnames( + baseClass, + `${baseClass}__${color}`, + `${baseClass}__radius-${borderRadiusSize}`, + { [`${baseClass}__shadow`]: includeShadow }, + className + ); return
{children}
; }; diff --git a/frontend/components/Card/_styles.scss b/frontend/components/Card/_styles.scss index 8aa59ede04..ba3c6c68e2 100644 --- a/frontend/components/Card/_styles.scss +++ b/frontend/components/Card/_styles.scss @@ -3,6 +3,25 @@ border: 1px solid $ui-fleet-black-10; padding: $pad-large; + // radius styles + &__radius-small { + border-radius: $border-radius; + } + + &__radius-medium { + border-radius: $border-radius-large; + } + + &__radius-large { + border-radius: $border-radius-xxlarge; + } + + // box shadow styles + &__shadow { + box-shadow: $box-shadow; + } + + // color styles &__white { background-color: $core-white; } diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss index a085cdbfc3..41c6bf0a71 100644 --- a/frontend/components/InfoBanner/_styles.scss +++ b/frontend/components/InfoBanner/_styles.scss @@ -46,6 +46,9 @@ display: flex; flex-direction: column; gap: $pad-small; + p { + margin: $pad-small 0 0 0; + } } &__cta { diff --git a/frontend/components/TableContainer/DataTable/DropdownCell/DropdownCell.tsx b/frontend/components/TableContainer/DataTable/DropdownCell/DropdownCell.tsx index 6f5b4515d5..24cdb8521f 100644 --- a/frontend/components/TableContainer/DataTable/DropdownCell/DropdownCell.tsx +++ b/frontend/components/TableContainer/DataTable/DropdownCell/DropdownCell.tsx @@ -16,8 +16,8 @@ interface IDropdownCellProps { const DropdownCell = ({ options, - onChange, placeholder, + onChange, }: IDropdownCellProps): JSX.Element => { return (
diff --git a/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx b/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx index 3678f09b4b..eabf2da046 100644 --- a/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx @@ -4,7 +4,7 @@ import ReactTooltip from "react-tooltip"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; interface ITextCellProps { - value?: string | number | boolean | { timeString: string }; + value?: string | number | boolean | { timeString: string } | null; formatter?: (val: any) => JSX.Element | string; // string, number, or null greyed?: boolean; classes?: string; diff --git a/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss index c7ee1c90a9..39cdca6b09 100644 --- a/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss +++ b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss @@ -21,6 +21,8 @@ } .input-field { + padding-right: 16%; + &--disabled { letter-spacing: 0; } diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 818c3cb88d..8895198545 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -49,6 +49,9 @@ export enum ActivityType { EnabledWindowsMdm = "enabled_windows_mdm", DisabledWindowsMdm = "disabled_windows_mdm", RanScript = "ran_script", + AddedScript = "added_script", + DeletedScript = "deleted_script", + EditedScript = "edited_script", } export interface IActivity { created_at: string; @@ -91,4 +94,5 @@ export interface IActivityDetails { bootstrap_package_name?: string; name?: string; script_execution_id?: string; + script_name?: string; } diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 1120e1ab62..db43e33669 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -103,17 +103,6 @@ export const isWindowsDiskEncryptionStatus = ( export const FLEET_FILEVAULT_PROFILE_DISPLAY_NAME = "Disk encryption"; -// TODO: update when we have API -export interface IMdmScript { - id: number; - name: string; - ran: number; - pending: number; - errors: number; - created_at: string; - updated_at: string; -} - export interface IMdmSSOReponse { url: string; } diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index 4e6726c022..e451e8aa2a 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -769,4 +769,117 @@ describe("Activity Feed", () => { }) ).toBeInTheDocument(); }); + + it("renders an 'added_script' type activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.AddedScript, + details: { script_name: "foo.sh", team_name: "Alphas" }, + }); + render(); + + expect( + screen.getByText("added script ", { exact: false }) + ).toBeInTheDocument(); + expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument(); + expect( + screen.getByText(" to the ", { + exact: false, + }) + ).toBeInTheDocument(); + expect(screen.getByText("Alphas")).toBeInTheDocument(); + expect(screen.getByText(" team.", { exact: false })).toBeInTheDocument(); + const withNoTeams = screen.queryByText("no team"); + expect(withNoTeams).toBeNull(); + }); + + it("renders a 'deleted_script' type activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.DeletedScript, + details: { script_name: "foo.sh", team_name: "Alphas" }, + }); + render(); + + expect( + screen.getByText("deleted script ", { exact: false }) + ).toBeInTheDocument(); + expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument(); + expect( + screen.getByText(" from the ", { + exact: false, + }) + ).toBeInTheDocument(); + expect(screen.getByText("Alphas")).toBeInTheDocument(); + expect(screen.getByText(" team.", { exact: false })).toBeInTheDocument(); + const withNoTeams = screen.queryByText("no team"); + expect(withNoTeams).toBeNull(); + }); + + it("renders an 'added_script' type activity for hosts with no team.", () => { + const activity = createMockActivity({ + type: ActivityType.AddedScript, + details: { script_name: "foo.sh" }, + }); + render(); + + expect( + screen.getByText("added script ", { exact: false }) + ).toBeInTheDocument(); + expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument(); + expect( + screen.getByText("to no team.", { exact: false }) + ).toBeInTheDocument(); + }); + + it("renders a 'deleted_script' type activity for hosts with no team.", () => { + const activity = createMockActivity({ + type: ActivityType.DeletedScript, + details: { script_name: "foo.sh" }, + }); + render(); + + expect( + screen.getByText("deleted script ", { exact: false }) + ).toBeInTheDocument(); + expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument(); + expect( + screen.getByText("from no team.", { exact: false }) + ).toBeInTheDocument(); + }); + + it("renders an 'edited_script' type activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.EditedScript, + details: { team_name: "Alphas" }, + }); + render(); + + expect( + screen.getByText("edited scripts", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText(" for the ", { + exact: false, + }) + ).toBeInTheDocument(); + expect(screen.getByText("Alphas")).toBeInTheDocument(); + expect( + screen.getByText(" team via fleetctl.", { exact: false }) + ).toBeInTheDocument(); + const withNoTeams = screen.queryByText("no team"); + expect(withNoTeams).toBeNull(); + }); + it("renders an 'edited_script' type activity for hosts with no team.", () => { + const activity = createMockActivity({ + type: ActivityType.EditedScript, + details: {}, + }); + render(); + + expect( + screen.getByText("edited scripts", { exact: false }) + ).toBeInTheDocument(); + expect( + screen.getByText("for no team via fleetctl.", { exact: false }) + ).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 2ef3d66715..2acb4dcf07 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -518,6 +518,72 @@ const TAGGED_TEMPLATES = { ); }, + addedScript: (activity: IActivity) => { + const scriptName = activity.details?.script_name; + return ( + <> + {" "} + added{" "} + {scriptName ? ( + <> + script {scriptName}{" "} + + ) : ( + "a script " + )} + to{" "} + {activity.details?.team_name ? ( + <> + the {activity.details.team_name} team + + ) : ( + "no team" + )} + . + + ); + }, + deletedScript: (activity: IActivity) => { + const scriptName = activity.details?.script_name; + return ( + <> + {" "} + deleted{" "} + {scriptName ? ( + <> + script {scriptName}{" "} + + ) : ( + "a script " + )} + from{" "} + {activity.details?.team_name ? ( + <> + the {activity.details.team_name} team + + ) : ( + "no team" + )} + . + + ); + }, + editedScript: (activity: IActivity) => { + return ( + <> + {" "} + edited scripts for{" "} + {activity.details?.team_name ? ( + <> + the {activity.details.team_name} team + + ) : ( + "no team" + )}{" "} + via fleetctl. + + ); + }, }; const getDetail = ( @@ -634,6 +700,15 @@ const getDetail = ( case ActivityType.RanScript: { return TAGGED_TEMPLATES.ranScript(activity, onDetailsClick); } + case ActivityType.AddedScript: { + return TAGGED_TEMPLATES.addedScript(activity); + } + case ActivityType.DeletedScript: { + return TAGGED_TEMPLATES.deletedScript(activity); + } + case ActivityType.EditedScript: { + return TAGGED_TEMPLATES.editedScript(activity); + } default: { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); } diff --git a/frontend/pages/LabelPage/LabelForm/LabelForm.tsx b/frontend/pages/LabelPage/LabelForm/LabelForm.tsx index 4dc3945ad2..c2b640b3b4 100644 --- a/frontend/pages/LabelPage/LabelForm/LabelForm.tsx +++ b/frontend/pages/LabelPage/LabelForm/LabelForm.tsx @@ -104,6 +104,7 @@ const LabelForm = ({ const onLoad = (editor: IAceEditor) => { editor.setOptions({ enableLinking: true, + enableMultiselect: false, // Disables command + click creating multiple cursors }); // @ts-expect-error diff --git a/frontend/pages/ManageControlsPage/MacOSScripts/MacOSScripts.tsx b/frontend/pages/ManageControlsPage/MacOSScripts/MacOSScripts.tsx deleted file mode 100644 index 5ce01809b7..0000000000 --- a/frontend/pages/ManageControlsPage/MacOSScripts/MacOSScripts.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { useRef, useState } from "react"; - -import { IMdmScript } from "interfaces/mdm"; - -import CustomLink from "components/CustomLink"; - -import ScriptListHeading from "./components/ScriptListHeading"; -import ScriptListItem from "./components/ScriptListItem"; -import DeleteScriptModal from "./components/DeleteScriptModal"; -import FileUploader from "../components/FileUploader"; -import UploadList from "../components/UploadList"; -import RerunScriptModal from "./components/RerunScriptModal"; - -// TODO: remove when get integrate with API. -const scripts = [ - { - id: 1, - name: "Test.py", - ran: 57, - pending: 2304, - errors: 0, - created_at: new Date().toString(), - }, -]; - -const baseClass = "mac-os-scripts"; - -const MacOSScripts = () => { - const [showRerunScriptModal, setShowRerunScriptModal] = useState(false); - const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false); - - const selectedScript = useRef(null); - - const onClickRerun = (script: IMdmScript) => { - selectedScript.current = script; - setShowRerunScriptModal(true); - }; - - const onClickDelete = (script: IMdmScript) => { - selectedScript.current = script; - setShowDeleteScriptModal(true); - }; - - const onCancelRerun = () => { - selectedScript.current = null; - setShowRerunScriptModal(false); - }; - - const onCancelDelete = () => { - selectedScript.current = null; - setShowDeleteScriptModal(false); - }; - - // TODO: change when integrating with API - const onRerunScript = (scriptId: number) => { - console.log("rerun", scriptId); - setShowRerunScriptModal(false); - }; - - // TODO: change when integrating with API - const onDeleteScript = (scriptId: number) => { - console.log("delete", scriptId); - setShowDeleteScriptModal(false); - }; - - return ( -
-

- Upload scripts to change configuration and remediate issues on macOS - hosts. Each script runs once per host. All scripts can be rerun on end - users’ My device page. -

- ( - - )} - /> - { - return null; - }} - /> - {showRerunScriptModal && selectedScript.current && ( - - )} - {showDeleteScriptModal && selectedScript.current && ( - - )} -
- ); -}; - -export default MacOSScripts; diff --git a/frontend/pages/ManageControlsPage/MacOSScripts/_styles.scss b/frontend/pages/ManageControlsPage/MacOSScripts/_styles.scss deleted file mode 100644 index f5fa9ef546..0000000000 --- a/frontend/pages/ManageControlsPage/MacOSScripts/_styles.scss +++ /dev/null @@ -1,7 +0,0 @@ -.mac-os-scripts { - font-size: $x-small; - - &__description { - margin: $pad-xxlarge 0; - } -} diff --git a/frontend/pages/ManageControlsPage/MacOSScripts/index.ts b/frontend/pages/ManageControlsPage/MacOSScripts/index.ts deleted file mode 100644 index 5e3ff82cb5..0000000000 --- a/frontend/pages/ManageControlsPage/MacOSScripts/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./MacOSScripts"; diff --git a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx index d50d097679..5d95de538c 100644 --- a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx +++ b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx @@ -28,6 +28,10 @@ const controlsSubNav: IControlsSubNavItem[] = [ name: "Setup experience", pathname: PATHS.CONTROLS_SETUP_EXPERIENCE, }, + { + name: "Scripts", + pathname: PATHS.CONTROLS_SCRIPTS, + }, ]; interface IManageControlsPageProps { @@ -38,6 +42,7 @@ interface IManageControlsPageProps { hash?: string; query: { team_id?: string; + page?: string; }; }; router: InjectedRouter; // v3 @@ -60,6 +65,8 @@ const ManageControlsPage = ({ location, router, }: IManageControlsPageProps): JSX.Element => { + const page = parseInt(location?.query?.page || "", 10) || 0; + const { isFreeTier, isOnGlobalTeam, @@ -114,7 +121,7 @@ const ManageControlsPage = ({ - {React.cloneElement(children, { teamIdForApi })} + {React.cloneElement(children, { teamIdForApi, currentPage: page })}
); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx b/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx new file mode 100644 index 0000000000..46bbd62f0f --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/Scripts.tsx @@ -0,0 +1,165 @@ +import React, { useCallback, useContext, useRef, useState } from "react"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; +import { InjectedRouter } from "react-router"; + +import { AppContext } from "context/app"; +import PATHS from "router/paths"; +import scriptAPI, { + IListScriptsQueryKey, + IScript, + IScriptsResponse, +} from "services/entities/scripts"; + +import CustomLink from "components/CustomLink"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; + +import PremiumFeatureMessage from "components/PremiumFeatureMessage"; +import ScriptListHeading from "./components/ScriptListHeading"; +import ScriptListItem from "./components/ScriptListItem"; +import ScriptListPagination from "./components/ScriptListPagination"; +import DeleteScriptModal from "./components/DeleteScriptModal"; +import UploadList from "../components/UploadList"; +import ScriptUploader from "./components/ScriptUploader"; + +const baseClass = "scripts"; + +const SCRIPTS_PER_PAGE = 10; // TODO: confirm this is the desired default + +interface IScriptsProps { + router: InjectedRouter; // v3 + teamIdForApi: number; + currentPage: number; +} + +const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => { + const { isPremiumTier } = useContext(AppContext); + const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false); + + const selectedScript = useRef(null); + + const { + data: { scripts, meta } = {}, + isLoading, + isError, + refetch: refetchScripts, + } = useQuery< + IScriptsResponse, + AxiosError, + IScriptsResponse, + IListScriptsQueryKey[] + >( + [ + { + scope: "scripts", + team_id: teamIdForApi, + page: currentPage, + per_page: SCRIPTS_PER_PAGE, + }, + ], + ({ queryKey: [{ team_id, page, per_page }] }) => + scriptAPI.getScripts({ team_id, page, per_page }), + { + retry: false, + refetchOnWindowFocus: false, + staleTime: 3000, + } + ); + + // pagination controls + const path = PATHS.CONTROLS_SCRIPTS.concat(`?team_id=${teamIdForApi}`); + const onPrevPage = useCallback(() => { + router.push(path.concat(`&page=${currentPage - 1}`)); + }, [router, path, currentPage]); + const onNextPage = useCallback(() => { + router.push(path.concat(`&page=${currentPage + 1}`)); + }, [router, path, currentPage]); + + // The user is not a premium tier, so show the premium feature message. + if (!isPremiumTier) { + return ( + + ); + } + + const onClickDelete = (script: IScript) => { + selectedScript.current = script; + setShowDeleteScriptModal(true); + }; + + const onCancelDelete = () => { + selectedScript.current = null; + setShowDeleteScriptModal(false); + }; + + const onDeleteScript = () => { + selectedScript.current = null; + setShowDeleteScriptModal(false); + refetchScripts(); + }; + + const onUploadScript = () => { + refetchScripts(); + }; + + const renderScriptsList = () => { + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + if (currentPage === 0 && !scripts?.length) { + return null; + } + + return ( + <> + ( + + )} + /> + + + ); + }; + + return ( +
+

+ Upload scripts to change configuration and remediate issues on macOS + hosts. You can run scripts on individual hosts.{" "} + +

+ {renderScriptsList()} + + {showDeleteScriptModal && selectedScript.current && ( + + )} +
+ ); +}; + +export default Scripts; diff --git a/frontend/pages/ManageControlsPage/Scripts/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/_styles.scss new file mode 100644 index 0000000000..2bd2962a68 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/_styles.scss @@ -0,0 +1,11 @@ +.scripts { + font-size: $x-small; + + &__description { + margin: $pad-xxlarge 0; + } + + &__premium-feature-message { + margin-top: $pad-xxxlarge; + } +} diff --git a/frontend/pages/ManageControlsPage/MacOSScripts/components/DeleteScriptModal/DeleteScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx similarity index 63% rename from frontend/pages/ManageControlsPage/MacOSScripts/components/DeleteScriptModal/DeleteScriptModal.tsx rename to frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx index c0f5cef6d0..4ee85097f5 100644 --- a/frontend/pages/ManageControlsPage/MacOSScripts/components/DeleteScriptModal/DeleteScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx @@ -1,4 +1,7 @@ -import React from "react"; +import React, { useContext } from "react"; + +import scriptAPI from "services/entities/scripts"; +import { NotificationContext } from "context/notification"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; @@ -9,21 +12,33 @@ interface IDeleteScriptModalProps { scriptName: string; scriptId: number; onCancel: () => void; - onDelete: (scriptId: number) => void; + onDone: () => void; } const DeleteScriptModal = ({ scriptName, scriptId, onCancel, - onDelete, + onDone, }: IDeleteScriptModalProps) => { + const { renderFlash } = useContext(NotificationContext); + + const onClickDelete = async (id: number) => { + try { + await scriptAPI.deleteScript(id); + renderFlash("success", "Successfully deleted!"); + } catch { + renderFlash("error", "Couldn’t delete. Please try again."); + } + onDone(); + }; + return ( onDelete(scriptId)} + onEnter={() => onClickDelete(scriptId)} > <>

@@ -34,7 +49,7 @@ const DeleteScriptModal = ({

-
- {script.ran} - - {script.pending} - - - {script.errors} - -
-
- + +
+ ); +}; + +export default ScriptsListPagination; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListPagination/__styles.scss b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListPagination/__styles.scss new file mode 100644 index 0000000000..0957c709d8 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListPagination/__styles.scss @@ -0,0 +1,42 @@ +.script-list-pagination { + display: flex; + justify-content: end; + padding-top: $pad-small; + + .button { + font-weight: $bold; + } + + &__button { + color: $core-vibrant-blue; + vertical-align: bottom; + padding: 6px; + + .fleeticon-chevronleft, + .fleeticon-chevronright { + &:before { + font-size: 0.5rem; + font-weight: $bold; + position: relative; + top: -2px; + } + } + + .fleeticon-chevronleft { + margin-right: $pad-small; + } + + .fleeticon-chevronright { + margin-left: $pad-small; + } + + &:first-of-type { + margin-right: $pad-large; + } + + &:hover:not(.button--disabled), + &:focus { + background-color: $ui-vibrant-blue-10; + } + } +} diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListPagination/index.ts b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListPagination/index.ts new file mode 100644 index 0000000000..83828eb05a --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListPagination/index.ts @@ -0,0 +1 @@ +export { default } from "./ScriptListPagination"; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx new file mode 100644 index 0000000000..531b76f7e1 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx @@ -0,0 +1,58 @@ +import React, { useContext, useState } from "react"; +import { AxiosResponse } from "axios"; + +import { IApiError } from "interfaces/errors"; +import { NotificationContext } from "context/notification"; +import scriptAPI from "services/entities/scripts"; + +import FileUploader from "pages/ManageControlsPage/components/FileUploader"; +import { getErrorMessage } from "./helpers"; + +const baseClass = "script-uploader"; + +interface IScriptPackageUploaderProps { + currentTeamId: number; + onUpload: () => void; +} + +const ScriptPackageUploader = ({ + currentTeamId, + onUpload, +}: IScriptPackageUploaderProps) => { + const { renderFlash } = useContext(NotificationContext); + const [showLoading, setShowLoading] = useState(false); + + const onUploadFile = async (files: FileList | null) => { + if (!files || files.length === 0) { + return; + } + + const file = files[0]; + + setShowLoading(true); + try { + await scriptAPI.uploadScript(file, currentTeamId); + renderFlash("success", "Successfully uploaded!"); + onUpload(); + } catch (e) { + const error = e as AxiosResponse; + renderFlash("error", `Couldn't upload. ${getErrorMessage(error)}`); + } finally { + setShowLoading(false); + } + }; + + return ( + + ); +}; + +export default ScriptPackageUploader; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/_styles.scss new file mode 100644 index 0000000000..4a6ba7fa63 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/_styles.scss @@ -0,0 +1,3 @@ +.script-uploader { + margin-top: $pad-xxlarge; +} diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/helpers.ts b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/helpers.ts new file mode 100644 index 0000000000..6e02323dc8 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/helpers.ts @@ -0,0 +1,14 @@ +import { AxiosResponse } from "axios"; +import { IApiError } from "interfaces/errors"; + +const UPLOAD_ERROR_MESSAGES = { + default: { + message: "Couldn’t upload. Please try again.", + }, +}; + +// eslint-disable-next-line import/prefer-default-export +export const getErrorMessage = (err: AxiosResponse) => { + const apiReason = err.data.errors[0].reason; + return apiReason || UPLOAD_ERROR_MESSAGES.default.message; +}; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/index.ts b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/index.ts new file mode 100644 index 0000000000..1c16c3a7e9 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/index.ts @@ -0,0 +1 @@ +export { default } from "./ScriptUploader"; diff --git a/frontend/pages/ManageControlsPage/Scripts/index.ts b/frontend/pages/ManageControlsPage/Scripts/index.ts new file mode 100644 index 0000000000..ee228e7774 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/index.ts @@ -0,0 +1 @@ +export { default } from "./Scripts"; diff --git a/frontend/pages/ManageControlsPage/components/FileUploader/FileUploader.tsx b/frontend/pages/ManageControlsPage/components/FileUploader/FileUploader.tsx index 6da81a9cfa..4502ec6474 100644 --- a/frontend/pages/ManageControlsPage/components/FileUploader/FileUploader.tsx +++ b/frontend/pages/ManageControlsPage/components/FileUploader/FileUploader.tsx @@ -4,13 +4,19 @@ import classnames from "classnames"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; import { IconNames } from "components/icons"; +import Card from "components/Card"; const baseClass = "file-uploader"; interface IFileUploaderProps { icon: IconNames; message: string; + additionalInfo?: string; isLoading?: boolean; + /** A comma seperated string of one or more file types accepted to upload. + * This is the same as the html accept attribute. + * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept + */ accept?: string; className?: string; onFileUpload: (files: FileList | null) => void; @@ -19,6 +25,7 @@ interface IFileUploaderProps { const FileUploader = ({ icon, message, + additionalInfo, isLoading = false, accept, className, @@ -27,10 +34,15 @@ const FileUploader = ({ const classes = classnames(baseClass, className); return ( -
+ -

{message}

- -
+ ); }; diff --git a/frontend/pages/ManageControlsPage/components/FileUploader/_styles.scss b/frontend/pages/ManageControlsPage/components/FileUploader/_styles.scss index d13e4522af..7409fbb003 100644 --- a/frontend/pages/ManageControlsPage/components/FileUploader/_styles.scss +++ b/frontend/pages/ManageControlsPage/components/FileUploader/_styles.scss @@ -8,11 +8,23 @@ padding: $pad-xlarge $pad-large; font-size: $x-small; text-align: center; + gap: $pad-small; + + &__message { + margin: 0; + } + + &__additional-info { + margin: 0; + color: $ui-fleet-black-50; + } input { display: none; } - .button { + + &__upload-button { + margin-top: 8px; padding: 0; } diff --git a/frontend/pages/ManageControlsPage/components/UploadList/UploadList.tsx b/frontend/pages/ManageControlsPage/components/UploadList/UploadList.tsx index 160e999ea8..74ce7bc5c2 100644 --- a/frontend/pages/ManageControlsPage/components/UploadList/UploadList.tsx +++ b/frontend/pages/ManageControlsPage/components/UploadList/UploadList.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { buildQueryStringFromParams } from "utilities/url"; const baseClass = "upload-list"; @@ -20,7 +21,6 @@ const UploadList = ({ ); }); - return (
{HeadingComponent && ( diff --git a/frontend/pages/ManageControlsPage/components/UploadList/_styles.scss b/frontend/pages/ManageControlsPage/components/UploadList/_styles.scss index fffbda697e..0f5017b102 100644 --- a/frontend/pages/ManageControlsPage/components/UploadList/_styles.scss +++ b/frontend/pages/ManageControlsPage/components/UploadList/_styles.scss @@ -2,7 +2,6 @@ &__header { padding: $pad-medium $pad-large; font-size: $x-small; - font-weight: $bold; border-bottom: 1px solid $ui-fleet-black-10; } diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 48212d6a10..db5bf09b87 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -54,6 +54,7 @@ import AboutCard from "../cards/About"; import AgentOptionsCard from "../cards/AgentOptions"; import LabelsCard from "../cards/Labels"; import MunkiIssuesCard from "../cards/MunkiIssues"; +import ScriptsCard from "../cards/Scripts"; import SoftwareCard from "../cards/Software"; import UsersCard from "../cards/Users"; import PoliciesCard from "../cards/Policies"; @@ -619,6 +620,11 @@ const HostDetailsPage = ({ title: "details", pathname: PATHS.HOST_DETAILS(hostIdFromURL), }, + { + name: "Scripts", + title: "scripts", + pathname: PATHS.HOST_SCRIPTS(hostIdFromURL), + }, { name: "Software", title: "software", @@ -643,15 +649,24 @@ const HostDetailsPage = ({ }, ]; + // we want the scripts tabs on the list for only mac hosts and premium tier atm. + // We filter it out for other platforms and non premium. + // TODO: improve this code. We can pull the tab list component out + // into its own component later. + const filteredSubNavTabs = + host?.platform === "darwin" && isPremiumTier + ? hostDetailsSubNav + : hostDetailsSubNav.filter((navItem) => navItem.title !== "scripts"); + const getTabIndex = (path: string): number => { - return hostDetailsSubNav.findIndex((navItem) => { + return filteredSubNavTabs.findIndex((navItem) => { // tab stays highlighted for paths that ends with same pathname return path.endsWith(navItem.pathname); }); }; const navigateToNav = (i: number): void => { - const navPath = hostDetailsSubNav[i].pathname; + const navPath = filteredSubNavTabs[i].pathname; router.push(navPath); }; @@ -686,6 +701,8 @@ const HostDetailsPage = ({ name: host?.mdm.macos_setup?.bootstrap_package_name, }; + const page = (location.query.page && parseInt(location.query.page, 10)) || 0; + return (
@@ -734,7 +751,7 @@ const HostDetailsPage = ({ onSelect={(i) => navigateToNav(i)} > - {hostDetailsSubNav.map((navItem) => { + {filteredSubNavTabs.map((navItem) => { // Bolding text when the tab is active causes a layout shift // so we add a hidden pseudo element with the same text string return {navItem.name}; @@ -767,6 +784,16 @@ const HostDetailsPage = ({ hostUsersEnabled={featuresConfig?.enable_host_users} /> + {host?.platform === "darwin" && isPremiumTier && ( + + + + )} { + const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false); + // used to track the current script execution id we want to show in the show + // details modal. + const scriptExecutionId = useRef(null); + + const { renderFlash } = useContext(NotificationContext); + + const { + data: hostScriptResponse, + isLoading: isLoadingScriptData, + isError: isErrorScriptData, + refetch: refetchScriptsData, + } = useQuery( + ["scripts", hostId, page], + () => scriptsAPI.getHostScripts(hostId as number, page), + { + refetchOnWindowFocus: false, + retry: false, + enabled: Boolean(hostId), + } + ); + + if (!hostId) return null; + + const onQueryChange = (data: ITableQueryData) => { + router.push(`${PATHS.HOST_SCRIPTS(hostId)}?page=${data.pageIndex}`); + }; + + const onActionSelection = async (action: string, script: IHostScript) => { + switch (action) { + case "showDetails": + if (!script.last_execution) return; + scriptExecutionId.current = script.last_execution.execution_id; + setShowScriptDetailsModal(true); + break; + case "run": + try { + await scriptsAPI.runScript(script.script_id); + refetchScriptsData(); + } catch (e) { + const error = e as AxiosResponse; + renderFlash("error", error.data.errors[0].reason); + } + break; + default: + break; + } + }; + + const onCancelScriptDetailsModal = () => { + setShowScriptDetailsModal(false); + scriptExecutionId.current = null; + }; + + if (isErrorScriptData) { + return ; + } + + const scriptHeaders = generateTableHeaders(onActionSelection); + const data = generateDataSet(hostScriptResponse?.scripts || [], isHostOnline); + + return ( + +

Scripts

+ {data && data.length === 0 ? ( + + ) : ( + <>} + showMarkAllPages={false} + isAllPagesSelected={false} + columns={scriptHeaders} + data={data} + isLoading={isLoadingScriptData} + onQueryChange={onQueryChange} + disableNextPage={hostScriptResponse?.meta.has_next_results} + defaultPageIndex={page} + disableCount + /> + )} + + {showScriptDetailsModal && scriptExecutionId.current && ( + + )} +
+ ); +}; + +export default Scripts; diff --git a/frontend/pages/hosts/details/cards/Scripts/ScriptsTableConfig.tsx b/frontend/pages/hosts/details/cards/Scripts/ScriptsTableConfig.tsx new file mode 100644 index 0000000000..38b3c9ae3e --- /dev/null +++ b/frontend/pages/hosts/details/cards/Scripts/ScriptsTableConfig.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import ReactTooltip from "react-tooltip"; + +import { COLORS } from "styles/var/colors"; + +import { IDropdownOption } from "interfaces/dropdownOption"; +import { IHostScript, ILastExecution } from "services/entities/scripts"; + +import DropdownCell from "components/TableContainer/DataTable/DropdownCell"; + +import ScriptStatusCell from "./components/ScriptStatusCell"; + +interface IStatusCellProps { + cell: { + value: ILastExecution | null; + }; +} + +interface IDropdownCellProps { + cell: { + value: IDropdownOption[]; + }; + row: { + original: IHostScript; + }; +} + +const ScriptRunActionDropdownLabel = ({ + scriptId, + disabled, +}: { + scriptId: number; + disabled: boolean; +}) => { + const tipId = `run-script-${scriptId}`; + return disabled ? ( + <> + + Run + + + You can only run the script when the host is online. + + + ) : ( + <>Run + ); +}; + +// eslint-disable-next-line import/prefer-default-export +export const generateTableHeaders = ( + actionSelectHandler: (value: string, script: IHostScript) => void +) => { + return [ + { + title: "Name", + Header: "Name", + disableSortBy: true, + accessor: "name", + }, + { + title: "Status", + Header: "Status", + disableSortBy: true, + accessor: "last_execution", + Cell: ({ cell: { value } }: IStatusCellProps) => { + return ; + }, + }, + { + title: "Actions", + Header: "", + disableSortBy: true, + accessor: "actions", + Cell: (cellProps: IDropdownCellProps) => ( + + actionSelectHandler(value, cellProps.row.original) + } + placeholder={"Actions"} + /> + ), + }, + ]; +}; + +// NOTE: may need current user ID later for permission on actions. +const generateActionDropdownOptions = ( + { script_id, last_execution }: IHostScript, + isHostOnline: boolean +): IDropdownOption[] => { + return [ + { + label: "Show details", + disabled: last_execution === null, + value: "showDetails", + }, + { + label: ( + + ), + disabled: !isHostOnline, + value: "run", + }, + ]; +}; + +export const generateDataSet = ( + scripts: IHostScript[], + isHostOnline: boolean +) => { + return scripts.map((script) => { + return { + ...script, + actions: generateActionDropdownOptions(script, isHostOnline), + }; + }); +}; diff --git a/frontend/pages/hosts/details/cards/Scripts/_styles.scss b/frontend/pages/hosts/details/cards/Scripts/_styles.scss new file mode 100644 index 0000000000..e14c219a13 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Scripts/_styles.scss @@ -0,0 +1,22 @@ +.host-scripts-section { + margin-top: $pad-medium; + padding: $pad-xxlarge; + + h2 { + font-size: $medium; + font-weight: $bold; + margin: 0 0 $pad-medium 0; + line-height: 1.5; + } + + .table-container__header-left { + display: block; + } + + .dropdown__option { + [data-id="tooltip"][id^="run-script"] { + font-size: $xx-small; + font-style: normal; + } + } +} diff --git a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/ScriptStatusCell.tsx b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/ScriptStatusCell.tsx new file mode 100644 index 0000000000..1cf8596706 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/ScriptStatusCell.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { formatDistanceToNow } from "date-fns"; + +import { + ILastExecution, + IScriptExecutionStatus, +} from "services/entities/scripts"; + +import StatusIndicatorWithIcon, { + IndicatorStatus, +} from "components/StatusIndicatorWithIcon/StatusIndicatorWithIcon"; +import TextCell from "components/TableContainer/DataTable/TextCell"; + +interface IScriptStatusDisplayConfig { + displayText: string; + iconStatus: IndicatorStatus; + tooltip: (executedAt?: string) => string; +} + +const STATUS_DISPLAY_CONFIG: Record< + IScriptExecutionStatus, + IScriptStatusDisplayConfig +> = { + ran: { + displayText: "Ran", + iconStatus: "success", + tooltip: (executedAt) => + `Script ran and exited with exit code 0. (${executedAt} ago)`, + }, + pending: { + displayText: "Pending", + iconStatus: "pendingPartial", + tooltip: () => "Script will run when the host comes online.", + }, + error: { + displayText: "Error", + iconStatus: "error", + tooltip: (executedAt) => + `Script ran and exited with a non-zero exit code. (${executedAt} ago)`, + }, +}; + +interface IScriptStatusCellProps { + lastExecution: ILastExecution | null; +} + +const ScriptStatusCell = ({ lastExecution }: IScriptStatusCellProps) => { + if (!lastExecution) { + return ; + } + + const { displayText, iconStatus, tooltip } = STATUS_DISPLAY_CONFIG[ + lastExecution.status + ]; + + const humanizedExecutedAt = formatDistanceToNow( + new Date(lastExecution.executed_at), + { includeSeconds: true } + ); + + return ( + + ); +}; + +export default ScriptStatusCell; diff --git a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss new file mode 100644 index 0000000000..2b0b194bf0 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss @@ -0,0 +1,3 @@ +.script-status-cell { + +} diff --git a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/index.ts b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/index.ts new file mode 100644 index 0000000000..d9f4a66975 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/index.ts @@ -0,0 +1 @@ +export { default } from "./ScriptStatusCell"; diff --git a/frontend/pages/hosts/details/cards/Scripts/index.ts b/frontend/pages/hosts/details/cards/Scripts/index.ts new file mode 100644 index 0000000000..ee228e7774 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Scripts/index.ts @@ -0,0 +1 @@ +export { default } from "./Scripts"; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index b132caf19a..22d90dd689 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -179,6 +179,7 @@ const PolicyForm = ({ const onLoad = (editor: IAceEditor) => { editor.setOptions({ enableLinking: true, + enableMultiselect: false, // Disables command + click creating multiple cursors }); // @ts-expect-error diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index ce2a027e32..b9cea87652 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -217,6 +217,7 @@ const EditQueryForm = ({ const onLoad = (editor: IAceEditor) => { editor.setOptions({ enableLinking: true, + enableMultiselect: false, // Disables command + click creating multiple cursors }); // @ts-expect-error diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 5410c1ac99..b376f7417b 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -57,6 +57,7 @@ import OSSettings from "pages/ManageControlsPage/OSSettings"; import SetupExperience from "pages/ManageControlsPage/SetupExperience/SetupExperience"; import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage"; import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMdmPage"; +import Scripts from "pages/ManageControlsPage/Scripts/Scripts"; import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage"; import PATHS from "router/paths"; @@ -179,6 +180,7 @@ const routes = ( + @@ -194,6 +196,7 @@ const routes = ( + { return `${URL_PREFIX}/hosts/${id}`; }, + HOST_SCRIPTS: (id: number): string => { + return `${URL_PREFIX}/hosts/${id}/scripts`; + }, HOST_SOFTWARE: (id: number): string => { return `${URL_PREFIX}/hosts/${id}/software`; }, diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts index 2a8eab315b..9fc555112d 100644 --- a/frontend/services/entities/scripts.ts +++ b/frontend/services/entities/scripts.ts @@ -1,6 +1,40 @@ import sendRequest from "services"; import endpoints from "utilities/endpoints"; +import { buildQueryStringFromParams } from "utilities/url"; +export interface IScript { + id: number; + team_id: number | null; + name: string; + created_at: string; + updated_at: string; +} + +/** Single script response from GET /script/:id */ +export type IScriptResponse = IScript; + +/** All scripts response from GET /scripts */ +export interface IScriptsResponse { + scripts: IScript[]; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + +export interface IListScriptsApiParams { + page?: number; + per_page?: number; + team_id?: number; +} + +export interface IListScriptsQueryKey extends IListScriptsApiParams { + scope: "scripts"; +} + +/** + * Script Result response from GET /scripts/results/:id + */ export interface IScriptResultResponse { hostname: string; host_id: number; @@ -13,9 +47,87 @@ export interface IScriptResultResponse { host_timeout: boolean; } +export type IScriptExecutionStatus = "ran" | "pending" | "error"; + +export interface ILastExecution { + execution_id: string; + executed_at: string; + status: IScriptExecutionStatus; +} + +export interface IHostScript { + script_id: number; + name: string; + last_execution: ILastExecution | null; +} + +/** + * Script response from GET /hosts/:id/scripts + */ +export interface IHostScriptsResponse { + scripts: IHostScript[]; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + export default { + getHostScripts(id: number, page?: number) { + const { HOST_SCRIPTS } = endpoints; + + let path = HOST_SCRIPTS(id); + if (page) { + path = `${path}?${buildQueryStringFromParams({ page })}`; + } + return sendRequest("GET", path); + }, + + getScripts(params: IListScriptsApiParams): Promise { + const { SCRIPTS } = endpoints; + const path = `${SCRIPTS}?${buildQueryStringFromParams({ ...params })}`; + + return sendRequest("GET", path); + }, + + getScript(id: number) { + const { SCRIPT } = endpoints; + return sendRequest("GET", SCRIPT(id)); + }, + + uploadScript(file: File, teamId?: number) { + const { SCRIPTS } = endpoints; + + const formData = new FormData(); + formData.append("script", file); + + if (teamId) { + formData.append("team_id", teamId.toString()); + } + + return sendRequest("POST", SCRIPTS, formData); + }, + + downloadScript(id: number) { + const { SCRIPT } = endpoints; + const path = `${SCRIPT(id)}?${buildQueryStringFromParams({ + alt: "media", + })}`; + return sendRequest("GET", path); + }, + + deleteScript(id: number) { + const { SCRIPT } = endpoints; + return sendRequest("DELETE", SCRIPT(id)); + }, + getScriptResult(executionId: string) { const { SCRIPT_RESULT } = endpoints; return sendRequest("GET", SCRIPT_RESULT(executionId)); }, + + runScript(id: number) { + const { SCRIPT_RUN } = endpoints; + return sendRequest("POST", SCRIPT_RUN, { script_id: id }); + }, }; diff --git a/frontend/styles/var/_global.scss b/frontend/styles/var/_global.scss index 7876654c4c..65567cb51d 100644 --- a/frontend/styles/var/_global.scss +++ b/frontend/styles/var/_global.scss @@ -1,3 +1,8 @@ +// border radius $border-radius: 4px; $border-radius-large: 6px; $border-radius-xlarge: 10px; +$border-radius-xxlarge: 16px; + +// box shadow +$box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4); diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index c78504a634..e322061b07 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -124,6 +124,10 @@ export default { VERSION: `/${API_VERSION}/fleet/version`, // SCRIPTS + HOST_SCRIPTS: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/scripts`, + SCRIPTS: `/${API_VERSION}/fleet/scripts`, + SCRIPT: (id: number) => `/${API_VERSION}/fleet/scripts/${id}`, SCRIPT_RESULT: (executionId: string) => `/${API_VERSION}/fleet/scripts/results/${executionId}`, + SCRIPT_RUN: `/${API_VERSION}/fleet/scripts/run`, }; diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index ad63dd38a3..391f75a345 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -77,7 +77,9 @@ tier: Premium usualDepartment: IT productCategories: [Device management] - - industryName: Safely execute custom scripts (macOS, Windows, and Linux) + - industryName: Script execution + fiendlyName: Safely execute custom scripts (macOS, Windows, and Linux) + documentationUrl: https://fleetdm.com/docs/using-fleet/scripts tier: Premium productCategories: [Device management, Endpoint operations] - industryName: End-user macOS update reminders (via Nudge) diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index a62495ae37..e756e1ffff 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -19,7 +19,7 @@ The 🚀 Engineering department at Fleet is directly responsible for writing and --> ## Contact us -- Any community memeber can file a 🦟 ["Bug report"](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=) +- Any community member can file a 🦟 ["Bug report"](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=). - Any Fleet team member can view the 🦟 ["Bugs" kanban board](https://app.zenhub.com/workspaces/-bugs-647f6d382e171b003416f51a/board) including the status on all reported bugs. - If urgent, or if you need help submiting an issue, mention a [team member](#team) in the [#help-engineering](https://fleetdm.slack.com/archives/C019WG4GH0A) Slack channel. - Any Fleet team member can view the dedicated sprint boards managed by this department: diff --git a/pkg/optjson/optjson.go b/pkg/optjson/optjson.go index a665b3bb52..58d77ccad7 100644 --- a/pkg/optjson/optjson.go +++ b/pkg/optjson/optjson.go @@ -92,3 +92,41 @@ func (b *Bool) UnmarshalJSON(data []byte) error { b.Valid = true return nil } + +type Slice[T any] struct { + Set bool + Valid bool + Value []T +} + +func SetSlice[T any](s []T) Slice[T] { + return Slice[T]{Set: true, Valid: true, Value: s} +} + +func (s Slice[T]) MarshalJSON() ([]byte, error) { + if !s.Valid { + return []byte("null"), nil + } + return json.Marshal(s.Value) +} + +func (s *Slice[T]) UnmarshalJSON(data []byte) error { + // If this method was called, the value was set. + s.Set = true + s.Valid = false + + if bytes.Equal(data, []byte("null")) { + // The key was set to null, blank the value + s.Value = []T{} + return nil + } + + // The key isn't set to null + var v []T + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Value = v + s.Valid = true + return nil +} diff --git a/pkg/optjson/optjson_test.go b/pkg/optjson/optjson_test.go index 264834a637..98b2850821 100644 --- a/pkg/optjson/optjson_test.go +++ b/pkg/optjson/optjson_test.go @@ -169,3 +169,190 @@ func TestBool(t *testing.T) { } }) } + +func TestSlice(t *testing.T) { + t.Run("slice of ints", func(t *testing.T) { + cases := []struct { + data string + wantErr string + wantRes Slice[int] + marshalAs string + }{ + {data: `[1,2,3]`, wantErr: "", wantRes: SetSlice([]int{1, 2, 3}), marshalAs: `[1,2,3]`}, + {data: `[]`, wantErr: "", wantRes: SetSlice([]int{}), marshalAs: `[]`}, + {data: `null`, wantErr: "", wantRes: Slice[int]{Set: true, Valid: false, Value: []int{}}, marshalAs: `null`}, + {data: `[1,"2",3]`, wantErr: "cannot unmarshal string", wantRes: Slice[int]{Set: true, Valid: false, Value: nil}, marshalAs: `null`}, + {data: `123`, wantErr: "cannot unmarshal number", wantRes: Slice[int]{Set: true, Valid: false, Value: []int(nil)}, marshalAs: `null`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var s Slice[int] + err := json.Unmarshal([]byte(c.data), &s) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, s) + + b, err := json.Marshal(s) + require.NoError(t, err) + require.Equal(t, c.marshalAs, string(b)) + }) + } + }) + + t.Run("slice of strings", func(t *testing.T) { + cases := []struct { + data string + wantErr string + wantRes Slice[string] + marshalAs string + }{ + {data: `["foo", "bar"]`, wantErr: "", wantRes: SetSlice([]string{"foo", "bar"}), marshalAs: `["foo","bar"]`}, + {data: `[""]`, wantErr: "", wantRes: SetSlice([]string{""}), marshalAs: `[""]`}, + {data: `null`, wantErr: "", wantRes: Slice[string]{Set: true, Valid: false, Value: []string{}}, marshalAs: `null`}, + {data: `["foo", 123]`, wantErr: "cannot unmarshal number", wantRes: Slice[string]{Set: true, Valid: false, Value: []string(nil)}, marshalAs: `null`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var s Slice[string] + err := json.Unmarshal([]byte(c.data), &s) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, s) + + b, err := json.Marshal(s) + require.NoError(t, err) + require.Equal(t, c.marshalAs, string(b)) + }) + } + }) + + t.Run("slice of bools", func(t *testing.T) { + cases := []struct { + data string + wantErr string + wantRes Slice[bool] + marshalAs string + }{ + {data: `[true, false]`, wantErr: "", wantRes: SetSlice([]bool{true, false}), marshalAs: `[true,false]`}, + {data: `[true, "false"]`, wantErr: "cannot unmarshal string", wantRes: Slice[bool]{Set: true, Valid: false, Value: []bool(nil)}, marshalAs: `null`}, + {data: `null`, wantErr: "", wantRes: Slice[bool]{Set: true, Valid: false, Value: []bool{}}, marshalAs: `null`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var s Slice[bool] + err := json.Unmarshal([]byte(c.data), &s) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, s) + + b, err := json.Marshal(s) + require.NoError(t, err) + require.Equal(t, c.marshalAs, string(b)) + }) + } + }) +} + +func TestSliceWithinStruct(t *testing.T) { + type Nested struct { + Numbers Slice[int] `json:"numbers"` + Words Slice[string] `json:"words"` + Flags Slice[bool] `json:"flags"` + } + + type Parent struct { + ID int `json:"id"` + Name string `json:"name"` + Nested Nested `json:"nested"` + } + + t.Run("struct", func(t *testing.T) { + cases := []struct { + data string + wantErr string + wantRes Parent + marshalAs string + }{ + {data: `{}`, wantErr: "", wantRes: Parent{}, marshalAs: `{"id": 0, "name": "", "nested": {"numbers": null, "words": null, "flags": null}}`}, + { + data: `{"id": 1, "name": "test", "nested": {"numbers": [1, 2, 3], "words": ["one", "two"], "flags": [true, false]}}`, + wantErr: "", + wantRes: Parent{ + ID: 1, + Name: "test", + Nested: Nested{ + Numbers: SetSlice([]int{1, 2, 3}), + Words: SetSlice([]string{"one", "two"}), + Flags: SetSlice([]bool{true, false}), + }, + }, + marshalAs: `{"id": 1, "name": "test", "nested": {"numbers": [1,2,3], "words": ["one","two"], "flags": [true,false]}}`, + }, + { + data: `{"id": 1, "name": "test", "nested": {"numbers": null, "words": ["one", "two"], "flags": [true, false]}}`, + wantErr: "", + wantRes: Parent{ + ID: 1, + Name: "test", + Nested: Nested{ + Numbers: Slice[int]{Set: true, Valid: false, Value: []int{}}, + Words: SetSlice([]string{"one", "two"}), + Flags: SetSlice([]bool{true, false}), + }, + }, + marshalAs: `{"id": 1, "name": "test", "nested": {"numbers": null, "words": ["one","two"], "flags": [true,false]}}`, + }, + { + data: `{"id": 1, "name": "test", "nested": {"numbers": [1, 2, 3], "words": null, "flags": [true, false]}}`, + wantErr: "", + wantRes: Parent{ + ID: 1, + Name: "test", + Nested: Nested{ + Numbers: SetSlice([]int{1, 2, 3}), + Words: Slice[string]{Set: true, Valid: false, Value: []string{}}, + Flags: SetSlice([]bool{true, false}), + }, + }, + marshalAs: `{"id": 1, "name": "test", "nested": {"numbers": [1,2,3], "words": null, "flags": [true,false]}}`, + }, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var p Parent + err := json.Unmarshal([]byte(c.data), &p) + + if c.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, p) + + b, err := json.Marshal(p) + require.NoError(t, err) + require.JSONEq(t, c.marshalAs, string(b)) + }) + } + }) +} diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 6ff2d66f94..2091081577 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -819,14 +819,25 @@ allow { # 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). +# Global admins and maintainers can write (execute) anonymous scripts (not +# gitops as this is not something that relates to fleetctl apply). allow { object.type == "host_script_result" + is_null(object.script_id) subject.global_role == [admin, maintainer][_] action == write } +# Global admins, maintainers, observer_plus and observers can write (execute) +# saved scripts (not gitops as this is not something that relates to fleetctl +# apply). +allow { + object.type == "host_script_result" + not is_null(object.script_id) + subject.global_role == [admin, maintainer, observer, observer_plus][_] + action == write +} + # Global admins, maintainers, observer_plus and observers can read scripts. allow { object.type == "host_script_result" @@ -834,15 +845,27 @@ allow { 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). +# Team admin and maintainers can write (execute) anonymous scripts for their +# teams (not gitops as this is not something that relates to fleetctl apply). allow { object.type == "host_script_result" + is_null(object.script_id) not is_null(object.team_id) team_role(subject, object.team_id) == [admin, maintainer][_] action == write } +# Team admins, maintainers, observer_plus and observers can write (execute) +# saved 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.script_id) + not is_null(object.team_id) + team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_] + action == write +} + # Team admins, maintainers, observer_plus and observers can read scripts for their teams. allow { object.type == "host_script_result" @@ -850,3 +873,37 @@ allow { team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_] action == read } + +## +# Scripts (saved script) +## + +# Global admins and maintainers can write (upload) saved scripts. +allow { + object.type == "script" + subject.global_role == [admin, maintainer][_] + action == write +} + +# Global admins, maintainers, observer_plus and observers can read scripts. +allow { + object.type == "script" + subject.global_role == [admin, maintainer, observer, observer_plus][_] + action == read +} + +# Team admin and maintainers can write (upload) saved scripts for their teams. +allow { + object.type == "script" + 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 == "script" + 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 e4cb991e29..277809bb0b 100644 --- a/server/authz/policy_test.go +++ b/server/authz/policy_test.go @@ -1818,7 +1818,162 @@ func TestAuthorizeHostScriptResult(t *testing.T) { t.Parallel() globalScript := &fleet.HostScriptResult{} - team1Script := &fleet.HostScriptResult{ + globalSavedScript := &fleet.HostScriptResult{ScriptID: ptr.Uint(1)} + team1Script := &fleet.HostScriptResult{TeamID: ptr.Uint(1)} + team1SavedScript := &fleet.HostScriptResult{TeamID: ptr.Uint(1), ScriptID: 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: globalSavedScript, action: write, allow: false}, + {user: test.UserNoRoles, object: globalSavedScript, action: read, allow: false}, + {user: test.UserNoRoles, object: team1Script, action: write, allow: false}, + {user: test.UserNoRoles, object: team1Script, action: read, allow: false}, + {user: test.UserNoRoles, object: team1SavedScript, action: write, allow: false}, + {user: test.UserNoRoles, object: team1SavedScript, 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: globalSavedScript, action: write, allow: true}, + {user: test.UserAdmin, object: globalSavedScript, action: read, allow: true}, + {user: test.UserAdmin, object: team1Script, action: write, allow: true}, + {user: test.UserAdmin, object: team1Script, action: read, allow: true}, + {user: test.UserAdmin, object: team1SavedScript, action: write, allow: true}, + {user: test.UserAdmin, object: team1SavedScript, 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: globalSavedScript, action: write, allow: true}, + {user: test.UserMaintainer, object: globalSavedScript, action: read, allow: true}, + {user: test.UserMaintainer, object: team1Script, action: write, allow: true}, + {user: test.UserMaintainer, object: team1Script, action: read, allow: true}, + {user: test.UserMaintainer, object: team1SavedScript, action: write, allow: true}, + {user: test.UserMaintainer, object: team1SavedScript, 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: globalSavedScript, action: write, allow: true}, + {user: test.UserObserver, object: globalSavedScript, action: read, allow: true}, + {user: test.UserObserver, object: team1Script, action: write, allow: false}, + {user: test.UserObserver, object: team1Script, action: read, allow: true}, + {user: test.UserObserver, object: team1SavedScript, action: write, allow: true}, + {user: test.UserObserver, object: team1SavedScript, 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: globalSavedScript, action: write, allow: true}, + {user: test.UserObserverPlus, object: globalSavedScript, action: read, allow: true}, + {user: test.UserObserverPlus, object: team1Script, action: write, allow: false}, + {user: test.UserObserverPlus, object: team1Script, action: read, allow: true}, + {user: test.UserObserverPlus, object: team1SavedScript, action: write, allow: true}, + {user: test.UserObserverPlus, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserGitOps, object: globalSavedScript, action: read, allow: false}, + {user: test.UserGitOps, object: team1Script, action: write, allow: false}, + {user: test.UserGitOps, object: team1Script, action: read, allow: false}, + {user: test.UserGitOps, object: team1SavedScript, action: write, allow: false}, + {user: test.UserGitOps, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamAdminTeam1, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamAdminTeam1, object: team1Script, action: write, allow: true}, + {user: test.UserTeamAdminTeam1, object: team1Script, action: read, allow: true}, + {user: test.UserTeamAdminTeam1, object: team1SavedScript, action: write, allow: true}, + {user: test.UserTeamAdminTeam1, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamAdminTeam2, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1Script, action: read, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1SavedScript, action: write, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam1, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamMaintainerTeam1, object: team1Script, action: write, allow: true}, + {user: test.UserTeamMaintainerTeam1, object: team1Script, action: read, allow: true}, + {user: test.UserTeamMaintainerTeam1, object: team1SavedScript, action: write, allow: true}, + {user: test.UserTeamMaintainerTeam1, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1Script, action: read, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1SavedScript, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamObserverTeam1, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamObserverTeam1, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverTeam1, object: team1Script, action: read, allow: true}, + {user: test.UserTeamObserverTeam1, object: team1SavedScript, action: write, allow: true}, + {user: test.UserTeamObserverTeam1, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamObserverTeam2, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1Script, action: read, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1SavedScript, action: write, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: team1Script, action: read, allow: true}, + {user: test.UserTeamObserverPlusTeam1, object: team1SavedScript, action: write, allow: true}, + {user: test.UserTeamObserverPlusTeam1, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1Script, action: read, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1SavedScript, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: team1Script, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: team1Script, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: team1SavedScript, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: team1SavedScript, 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: globalSavedScript, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: globalSavedScript, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1Script, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1Script, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1SavedScript, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1SavedScript, action: read, allow: false}, + }) +} + +func TestAuthorizeScript(t *testing.T) { + t.Parallel() + + globalScript := &fleet.Script{} + team1Script := &fleet.Script{ TeamID: ptr.Uint(1), } runTestCases(t, []authTestCase{ diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index a04d7d1c95..cbdeeba136 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -18,7 +18,6 @@ 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" ) @@ -4366,109 +4365,3 @@ 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, created_at 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, - created_at - 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 d7f4f1c090..1b053e8890 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -153,7 +153,6 @@ func TestHosts(t *testing.T) { {"ListHostsLiteByUUIDs", testHostsListHostsLiteByUUIDs}, {"GetMatchingHostSerials", testGetMatchingHostSerials}, {"ListHostsLiteByIDs", testHostsListHostsLiteByIDs}, - {"HostScriptResult", testHostScriptResult}, {"ListHostsWithPagination", testListHostsWithPagination}, } for _, c := range cases { @@ -7415,121 +7414,6 @@ 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.Nil(t, createdScript.ExitCode) - 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 = ptr.Int64(0) - 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) -} - func testListHostsWithPagination(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/datastore/mysql/migrations/tables/20231009094541_AddIndexToHostScriptResults.go b/server/datastore/mysql/migrations/tables/20231009094541_AddIndexToHostScriptResults.go new file mode 100644 index 0000000000..4317ef816b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20231009094541_AddIndexToHostScriptResults.go @@ -0,0 +1,28 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20231009094541, Down_20231009094541) +} + +func Up_20231009094541(tx *sql.Tx) error { + sql := ` +ALTER TABLE + host_script_results +ADD INDEX + idx_host_script_created_at (host_id, script_id, created_at); + ` + if _, err := tx.Exec(sql); err != nil { + return fmt.Errorf("add index host_script_created_at to host_script_results: %w", err) + } + + return nil +} + +func Down_20231009094541(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20231009094542_AddIndexesToScriptContents.go b/server/datastore/mysql/migrations/tables/20231009094542_AddIndexesToScriptContents.go new file mode 100644 index 0000000000..a502be16aa --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20231009094542_AddIndexesToScriptContents.go @@ -0,0 +1,18 @@ +package tables + +import ( + "database/sql" +) + +func init() { + MigrationClient.AddMigration(Up_20231009094542, Down_20231009094542) +} + +func Up_20231009094542(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE scripts ADD UNIQUE KEY idx_scripts_team_name (team_id, name)`) + return err +} + +func Down_20231009094542(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20231009094542_AddIndexesToScriptContents_test.go b/server/datastore/mysql/migrations/tables/20231009094542_AddIndexesToScriptContents_test.go new file mode 100644 index 0000000000..6e4de0d128 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20231009094542_AddIndexesToScriptContents_test.go @@ -0,0 +1,19 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20231009094542(t *testing.T) { + db := applyUpToPrev(t) + + idxExists := indexExists(db, "scripts", "idx_scripts_team_name") + require.False(t, idxExists) + + applyNext(t, db) + + idxExists = indexExists(db, "scripts", "idx_scripts_team_name") + require.True(t, idxExists) +} diff --git a/server/datastore/mysql/migrations/tables/migration.go b/server/datastore/mysql/migrations/tables/migration.go index 67233a6dc0..4359791a90 100644 --- a/server/datastore/mysql/migrations/tables/migration.go +++ b/server/datastore/mysql/migrations/tables/migration.go @@ -6,6 +6,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/goose" + "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) @@ -49,6 +50,23 @@ WHERE return count > 0 } +func indexExists(tx *sqlx.DB, table, index string) bool { + var count int + err := tx.QueryRow(` +SELECT COUNT(1) +FROM INFORMATION_SCHEMA.STATISTICS +WHERE table_schema = DATABASE() +AND table_name = ? +AND index_name = ? +`, table, index).Scan(&count) + + if err != nil { + return false + } + + return count > 0 +} + // updateAppConfigJSON updates the `json_value` stored in the `app_config_json` after applying the // supplied callback to the current config object. func updateAppConfigJSON(tx *sql.Tx, fn func(config *fleet.AppConfig) error) error { diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index caa350e5a7..7677381f7d 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -40,7 +40,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `carve_blocks` ( @@ -347,6 +347,7 @@ CREATE TABLE `host_script_results` ( UNIQUE KEY `idx_host_script_results_execution_id` (`execution_id`), KEY `idx_host_script_results_host_exit_created` (`host_id`,`exit_code`,`created_at`), KEY `fk_host_script_results_script_id` (`script_id`), + KEY `idx_host_script_created_at` (`host_id`,`script_id`,`created_at`), CONSTRAINT `fk_host_script_results_script_id` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -685,9 +686,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=212 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=214 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'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231002120317,1,'2020-01-01 01:01:01'),(210,20231004144338,1,'2020-01-01 01:01:01'),(211,20231004144339,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'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231002120317,1,'2020-01-01 01:01:01'),(210,20231004144338,1,'2020-01-01 01:01:01'),(211,20231004144339,1,'2020-01-01 01:01:01'),(212,20231009094541,1,'2020-01-01 01:01:01'),(213,20231009094542,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` ( @@ -1155,7 +1156,7 @@ CREATE TABLE `scripts` ( `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `idx_scripts_global_or_team_id_name` (`global_or_team_id`,`name`), - KEY `fk_scripts_team_id` (`team_id`), + UNIQUE KEY `idx_scripts_team_name` (`team_id`,`name`), CONSTRAINT `scripts_ibfk_1` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go new file mode 100644 index 0000000000..5888849325 --- /dev/null +++ b/server/datastore/mysql/scripts.go @@ -0,0 +1,428 @@ +package mysql + +import ( + "context" + "database/sql" + "fmt" + "time" + "unicode/utf8" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +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, script_id) VALUES (?, ?, ?, '', ?)` + getStmt = `SELECT id, host_id, execution_id, script_contents, created_at, script_id FROM host_script_results WHERE id = ?` + ) + + execID := uuid.New().String() + result, err := ds.writer(ctx).ExecContext(ctx, insStmt, + request.HostID, + execID, + request.ScriptContents, + request.ScriptID, + ) + 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_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, + script_id, + output, + runtime, + exit_code, + created_at + 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 +} + +func (ds *Datastore) NewScript(ctx context.Context, script *fleet.Script) (*fleet.Script, error) { + const insertStmt = ` +INSERT INTO + scripts ( + team_id, global_or_team_id, name, script_contents + ) +VALUES + (?, ?, ?, ?) +` + var globalOrTeamID uint + if script.TeamID != nil { + globalOrTeamID = *script.TeamID + } + res, err := ds.writer(ctx).ExecContext(ctx, insertStmt, + script.TeamID, globalOrTeamID, script.Name, script.ScriptContents) + if err != nil { + if isDuplicate(err) { + // name already exists for this team/global + err = alreadyExists("Script", script.Name) + } else if isChildForeignKeyError(err) { + // team does not exist + err = foreignKey("scripts", fmt.Sprintf("team_id=%v", script.TeamID)) + } + return nil, ctxerr.Wrap(ctx, err, "insert script") + } + id, _ := res.LastInsertId() + return ds.getScriptDB(ctx, ds.writer(ctx), uint(id)) +} + +func (ds *Datastore) Script(ctx context.Context, id uint) (*fleet.Script, error) { + return ds.getScriptDB(ctx, ds.reader(ctx), id) +} + +func (ds *Datastore) getScriptDB(ctx context.Context, q sqlx.QueryerContext, id uint) (*fleet.Script, error) { + const getStmt = ` +SELECT + id, + team_id, + name, + created_at, + updated_at +FROM + scripts +WHERE + id = ? +` + var script fleet.Script + if err := sqlx.GetContext(ctx, q, &script, getStmt, id); err != nil { + if err == sql.ErrNoRows { + return nil, notFound("Script").WithID(id) + } + return nil, ctxerr.Wrap(ctx, err, "get script") + } + return &script, nil +} + +func (ds *Datastore) GetScriptContents(ctx context.Context, id uint) ([]byte, error) { + const getStmt = ` +SELECT + script_contents +FROM + scripts +WHERE + id = ? +` + var contents []byte + if err := sqlx.GetContext(ctx, ds.reader(ctx), &contents, getStmt, id); err != nil { + if err == sql.ErrNoRows { + return nil, notFound("Script").WithID(id) + } + return nil, ctxerr.Wrap(ctx, err, "get script contents") + } + return contents, nil +} + +func (ds *Datastore) DeleteScript(ctx context.Context, id uint) error { + _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM scripts WHERE id = ?`, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete script") + } + return nil +} + +func (ds *Datastore) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { + var scripts []*fleet.Script + + const selectStmt = ` +SELECT + s.id, + s.team_id, + s.name, + s.created_at, + s.updated_at +FROM + scripts s +WHERE + s.global_or_team_id = ? +` + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + + args := []any{globalOrTeamID} + stmt, args := appendListOptionsWithCursorToSQL(selectStmt, args, &opt) + + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &scripts, stmt, args...); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "select scripts") + } + + var metaData *fleet.PaginationMetadata + if opt.IncludeMetadata { + metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0} + if len(scripts) > int(opt.PerPage) { + metaData.HasNextResults = true + scripts = scripts[:len(scripts)-1] + } + } + return scripts, metaData, nil +} + +func (ds *Datastore) GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + + type row struct { + ScriptID uint `db:"script_id"` + Name string `db:"name"` + HSRID *uint `db:"hsr_id"` + ExecutionID *string `db:"execution_id"` + ExecutedAt *time.Time `db:"executed_at"` + ExitCode *int64 `db:"exit_code"` + } + + sql := ` +SELECT + s.id AS script_id, + s.name, + hsr.id AS hsr_id, + hsr.created_at AS executed_at, + hsr.execution_id, + hsr.exit_code +FROM + scripts s + LEFT JOIN ( + SELECT + id, + host_id, + script_id, + execution_id, + created_at, + exit_code + FROM + host_script_results r + WHERE + host_id = ? + AND NOT EXISTS ( + SELECT + 1 + FROM + host_script_results + WHERE + host_id = ? + AND id != r.id + AND script_id = r.script_id + AND(created_at > r.created_at + OR(created_at = r.created_at + AND id > r.id)))) hsr + ON s.id = hsr.script_id +WHERE + (hsr.host_id IS NULL OR hsr.host_id = ?) + AND s.global_or_team_id = ?` + + args := []any{hostID, hostID, hostID, globalOrTeamID} + stmt, args := appendListOptionsWithCursorToSQL(sql, args, &opt) + + var rows []*row + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, args...); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get host script details") + } + + var metaData *fleet.PaginationMetadata + if opt.IncludeMetadata { + metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0} + if len(rows) > int(opt.PerPage) { + metaData.HasNextResults = true + rows = rows[:len(rows)-1] + } + } + + results := make([]*fleet.HostScriptDetail, 0, len(rows)) + for _, r := range rows { + results = append(results, fleet.NewHostScriptDetail(hostID, r.ScriptID, r.Name, r.ExecutionID, r.ExecutedAt, r.ExitCode, r.HSRID)) + } + + return results, metaData, nil +} + +func (ds *Datastore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + const loadExistingScripts = ` +SELECT + name +FROM + scripts +WHERE + global_or_team_id = ? AND + name IN (?) +` + const deleteAllScriptsInTeam = ` +DELETE FROM + scripts +WHERE + global_or_team_id = ? +` + + const deleteScriptsNotInList = ` +DELETE FROM + scripts +WHERE + global_or_team_id = ? AND + name NOT IN (?) +` + + const insertNewOrEditedScript = ` +INSERT INTO + scripts ( + team_id, global_or_team_id, name, script_contents + ) +VALUES + (?, ?, ?, ?) +ON DUPLICATE KEY UPDATE + script_contents = VALUES(script_contents) +` + + // use a team id of 0 if no-team + var globalOrTeamID uint + if tmID != nil { + globalOrTeamID = *tmID + } + + // build a list of names for the incoming scripts, will keep the + // existing ones if there's a match and no change + incomingNames := make([]string, len(scripts)) + // at the same time, index the incoming scripts keyed by name for ease + // of processing + incomingScripts := make(map[string]*fleet.Script, len(scripts)) + for i, p := range scripts { + incomingNames[i] = p.Name + incomingScripts[p.Name] = p + } + + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var existingScripts []*fleet.Script + + if len(incomingNames) > 0 { + // load existing scripts that match the incoming scripts by names + stmt, args, err := sqlx.In(loadExistingScripts, globalOrTeamID, incomingNames) + if err != nil { + return ctxerr.Wrap(ctx, err, "build query to load existing scripts") + } + if err := sqlx.SelectContext(ctx, tx, &existingScripts, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "load existing scripts") + } + } + + // figure out if we need to delete any scripts + keepNames := make([]string, 0, len(incomingNames)) + for _, p := range existingScripts { + if newS := incomingScripts[p.Name]; newS != nil { + keepNames = append(keepNames, p.Name) + } + } + + var ( + stmt string + args []any + err error + ) + if len(keepNames) > 0 { + // delete the obsolete scripts + stmt, args, err = sqlx.In(deleteScriptsNotInList, globalOrTeamID, keepNames) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to delete obsolete scripts") + } + } else { + stmt = deleteAllScriptsInTeam + args = []any{globalOrTeamID} + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "delete obsolete scripts") + } + + // insert the new scripts and the ones that have changed + for _, s := range incomingScripts { + if _, err := tx.ExecContext(ctx, insertNewOrEditedScript, tmID, globalOrTeamID, s.Name, s.ScriptContents); err != nil { + return ctxerr.Wrapf(ctx, err, "insert new/edited script with name %q", s.Name) + } + } + return nil + }) + +} diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go new file mode 100644 index 0000000000..eac35bc63e --- /dev/null +++ b/server/datastore/mysql/scripts_test.go @@ -0,0 +1,619 @@ +package mysql + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestScripts(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"HostScriptResult", testHostScriptResult}, + {"Scripts", testScripts}, + {"ListScripts", testListScripts}, + {"GetHostScriptDetails", testGetHostScriptDetails}, + {"BatchSetScripts", testBatchSetScripts}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + + c.fn(t, ds) + }) + } +} + +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.Nil(t, createdScript.ExitCode) + 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 = ptr.Int64(0) + 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) +} + +func testScripts(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // get unknown script + _, err := ds.Script(ctx, 123) + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + + // get unknown script contents + _, err = ds.GetScriptContents(ctx, 123) + require.ErrorAs(t, err, &nfe) + + // create global scriptGlobal + scriptGlobal, err := ds.NewScript(ctx, &fleet.Script{ + Name: "a", + ScriptContents: "echo", + }) + require.NoError(t, err) + require.NotZero(t, scriptGlobal.ID) + require.Nil(t, scriptGlobal.TeamID) + require.Equal(t, "a", scriptGlobal.Name) + require.Empty(t, scriptGlobal.ScriptContents) // we don't return the contents + + // get the global script + script, err := ds.Script(ctx, scriptGlobal.ID) + require.NoError(t, err) + require.Equal(t, scriptGlobal, script) + + // get the global script contents + contents, err := ds.GetScriptContents(ctx, scriptGlobal.ID) + require.NoError(t, err) + require.Equal(t, "echo", string(contents)) + + // create team script but team does not exist + _, err = ds.NewScript(ctx, &fleet.Script{ + Name: "a", + TeamID: ptr.Uint(123), + ScriptContents: "echo", + }) + require.Error(t, err) + var fkErr fleet.ForeignKeyError + require.ErrorAs(t, err, &fkErr) + + // create a team and a script for that team with the same name as global + tm, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) + require.NoError(t, err) + scriptTeam, err := ds.NewScript(ctx, &fleet.Script{ + Name: "a", + TeamID: &tm.ID, + ScriptContents: "echo 'team'", + }) + require.NoError(t, err) + require.NotEqual(t, scriptGlobal.ID, scriptTeam.ID) + require.NotNil(t, scriptTeam.TeamID) + require.Equal(t, tm.ID, *scriptTeam.TeamID) + + // get the team script + script, err = ds.Script(ctx, scriptTeam.ID) + require.NoError(t, err) + require.Equal(t, scriptTeam, script) + + // get the team script contents + contents, err = ds.GetScriptContents(ctx, scriptTeam.ID) + require.NoError(t, err) + require.Equal(t, "echo 'team'", string(contents)) + + // try to create another team script with the same name + _, err = ds.NewScript(ctx, &fleet.Script{ + Name: "a", + TeamID: &tm.ID, + ScriptContents: "echo", + }) + require.Error(t, err) + var existsErr fleet.AlreadyExistsError + require.ErrorAs(t, err, &existsErr) + + // same for a global script + _, err = ds.NewScript(ctx, &fleet.Script{ + Name: "a", + ScriptContents: "echo", + }) + require.Error(t, err) + require.ErrorAs(t, err, &existsErr) + + // create a script with a different name for the team works + _, err = ds.NewScript(ctx, &fleet.Script{ + Name: "b", + TeamID: &tm.ID, + ScriptContents: "echo", + }) + require.NoError(t, err) + + // deleting script "a for the team, then we can re-create it + err = ds.DeleteScript(ctx, scriptTeam.ID) + require.NoError(t, err) + scriptTeam2, err := ds.NewScript(ctx, &fleet.Script{ + Name: "a", + TeamID: &tm.ID, + ScriptContents: "echo", + }) + require.NoError(t, err) + require.NotEqual(t, scriptTeam.ID, scriptTeam2.ID) +} + +func testListScripts(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create three teams + tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + tm2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + tm3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) + require.NoError(t, err) + + // create 5 scripts for no team and team 1 + for i := 0; i < 5; i++ { + _, err = ds.NewScript(ctx, &fleet.Script{ + Name: string('a' + byte(i)), // i.e. "a", "b", "c", ... + ScriptContents: "echo", + }) + require.NoError(t, err) + _, err = ds.NewScript(ctx, &fleet.Script{Name: string('a' + byte(i)), TeamID: &tm1.ID, ScriptContents: "echo"}) + require.NoError(t, err) + } + + // create a single script for team 2 + _, err = ds.NewScript(ctx, &fleet.Script{Name: "a", TeamID: &tm2.ID, ScriptContents: "echo"}) + require.NoError(t, err) + + cases := []struct { + opts fleet.ListOptions + teamID *uint + wantNames []string + wantMeta *fleet.PaginationMetadata + }{ + { + opts: fleet.ListOptions{}, + wantNames: []string{"a", "b", "c", "d", "e"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{PerPage: 2}, + wantNames: []string{"a", "b"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{Page: 1, PerPage: 2}, + wantNames: []string{"c", "d"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{Page: 2, PerPage: 2}, + wantNames: []string{"e"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{PerPage: 3}, + teamID: &tm1.ID, + wantNames: []string{"a", "b", "c"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{Page: 1, PerPage: 3}, + teamID: &tm1.ID, + wantNames: []string{"d", "e"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{Page: 2, PerPage: 3}, + teamID: &tm1.ID, + wantNames: nil, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{PerPage: 3}, + teamID: &tm2.ID, + wantNames: []string{"a"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{Page: 0, PerPage: 2}, + teamID: &tm3.ID, + wantNames: nil, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.opts), func(t *testing.T) { + // always include metadata + c.opts.IncludeMetadata = true + scripts, meta, err := ds.ListScripts(ctx, c.teamID, c.opts) + require.NoError(t, err) + + require.Equal(t, len(c.wantNames), len(scripts)) + require.Equal(t, c.wantMeta, meta) + + var gotNames []string + if len(scripts) > 0 { + gotNames = make([]string, len(scripts)) + for i, s := range scripts { + gotNames[i] = s.Name + require.Equal(t, c.teamID, s.TeamID) + } + } + require.Equal(t, c.wantNames, gotNames) + }) + } +} + +func testGetHostScriptDetails(t *testing.T, ds *Datastore) { + ctx := context.Background() + + names := []string{"script-1", "script-2", "script-3", "script-4", "script-5"} + for _, r := range append(names[1:], names[0]) { + _, err := ds.NewScript(ctx, &fleet.Script{ + Name: r, + ScriptContents: "echo " + r, + }) + require.NoError(t, err) + } + + scripts, _, err := ds.ListScripts(ctx, nil, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, scripts, 5) + + insertResults := func(t *testing.T, hostID uint, script *fleet.Script, createdAt time.Time, execID string, exitCode *int64) { + stmt := ` +INSERT INTO + host_script_results (%s host_id, created_at, execution_id, exit_code, script_contents, output) +VALUES + (%s ?,?,?,?,?,?)` + + args := []interface{}{} + if script.ID == 0 { + stmt = fmt.Sprintf(stmt, "", "") + } else { + stmt = fmt.Sprintf(stmt, "script_id,", "?,") + args = append(args, script.ID) + } + args = append(args, hostID, createdAt, execID, exitCode, script.ScriptContents, "") + + ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, stmt, args...) + return err + }) + } + + now := time.Now().UTC().Truncate(time.Second) + + // add some results for script-1 + insertResults(t, 42, scripts[0], now.Add(-3*time.Minute), "execution-1-1", nil) + insertResults(t, 42, scripts[0], now.Add(-1*time.Minute), "execution-1-2", nil) // last execution for script-1, status "pending" + insertResults(t, 42, scripts[0], now.Add(-2*time.Minute), "execution-1-3", nil) + + // add some results for script-2 + insertResults(t, 42, scripts[1], now.Add(-3*time.Minute), "execution-2-1", ptr.Int64(0)) + insertResults(t, 42, scripts[1], now.Add(-1*time.Minute), "execution-2-2", ptr.Int64(1)) // last execution for script-2, status "error" + + // add some results for script-3 + insertResults(t, 42, scripts[2], now.Add(-1*time.Minute), "execution-3-1", ptr.Int64(0)) + insertResults(t, 42, scripts[2], now.Add(-1*time.Minute), "execution-3-2", ptr.Int64(0)) // last execution for script-3, status "ran" + insertResults(t, 42, scripts[2], now.Add(-2*time.Minute), "execution-3-3", ptr.Int64(0)) + + // add some results for script-4 + insertResults(t, 42, scripts[3], now.Add(-1*time.Minute), "execution-4-1", ptr.Int64(-2)) // last execution for script-4, status "error" + + // add some results for an ad-hoc, non-saved script, should not be included in results + insertResults(t, 42, &fleet.Script{Name: "script-6", ScriptContents: "echo script-6"}, now.Add(-1*time.Minute), "execution-6-1", ptr.Int64(0)) + + t.Run("results match expected formatting and filtering", func(t *testing.T) { + res, _, err := ds.GetHostScriptDetails(ctx, 42, nil, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, res, 5) + for _, r := range res { + switch r.ScriptID { + case scripts[0].ID: + require.Equal(t, scripts[0].Name, r.Name) + require.NotNil(t, r.LastExecution) + require.Equal(t, now.Add(-1*time.Minute), r.LastExecution.ExecutedAt) + require.Equal(t, "execution-1-2", r.LastExecution.ExecutionID) + require.Equal(t, "pending", r.LastExecution.Status) + case scripts[1].ID: + require.Equal(t, scripts[1].Name, r.Name) + require.NotNil(t, r.LastExecution) + require.Equal(t, now.Add(-1*time.Minute), r.LastExecution.ExecutedAt) + require.Equal(t, "execution-2-2", r.LastExecution.ExecutionID) + require.Equal(t, "error", r.LastExecution.Status) + case scripts[2].ID: + require.Equal(t, scripts[2].Name, r.Name) + require.NotNil(t, r.LastExecution) + require.Equal(t, now.Add(-1*time.Minute), r.LastExecution.ExecutedAt) + require.Equal(t, "execution-3-2", r.LastExecution.ExecutionID) + require.Equal(t, "ran", r.LastExecution.Status) + case scripts[3].ID: + require.Equal(t, scripts[3].Name, r.Name) + require.NotNil(t, r.LastExecution) + require.Equal(t, now.Add(-1*time.Minute), r.LastExecution.ExecutedAt) + require.Equal(t, "execution-4-1", r.LastExecution.ExecutionID) + require.Equal(t, "error", r.LastExecution.Status) + case scripts[4].ID: + require.Equal(t, scripts[4].Name, r.Name) + require.Nil(t, r.LastExecution) + default: + t.Errorf("unexpected script id: %d", r.ScriptID) + } + } + }) + + t.Run("empty slice returned if no scripts", func(t *testing.T) { + res, _, err := ds.GetHostScriptDetails(ctx, 42, ptr.Uint(1), fleet.ListOptions{}) // team 1 has no scripts + require.NoError(t, err) + require.NotNil(t, res) + require.Len(t, res, 0) + }) + + t.Run("list options are supported", func(t *testing.T) { + cases := []struct { + opts fleet.ListOptions + teamID *uint + wantNames []string + wantMeta *fleet.PaginationMetadata + }{ + { + opts: fleet.ListOptions{}, + wantNames: names, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{PerPage: 2}, + wantNames: names[:2], + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{Page: 1, PerPage: 2}, + wantNames: names[2:4], + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{Page: 2, PerPage: 2}, + wantNames: names[4:], + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%#v", c.opts), func(t *testing.T) { + // always include metadata + c.opts.IncludeMetadata = true + // custom ordering is not supported, always by name + c.opts.OrderKey = "name" + results, meta, err := ds.GetHostScriptDetails(ctx, 42, nil, c.opts) + require.NoError(t, err) + + require.Equal(t, len(c.wantNames), len(results)) + require.Equal(t, c.wantMeta, meta) + + var gotNames []string + if len(results) > 0 { + gotNames = make([]string, len(results)) + for i, r := range results { + gotNames[i] = r.Name + } + } + require.Equal(t, c.wantNames, gotNames) + }) + } + }) +} + +func testBatchSetScripts(t *testing.T, ds *Datastore) { + ctx := context.Background() + + applyAndExpect := func(newSet []*fleet.Script, tmID *uint, want []*fleet.Script) map[string]uint { + err := ds.BatchSetScripts(ctx, tmID, newSet) + require.NoError(t, err) + + if tmID == nil { + tmID = ptr.Uint(0) + } + got, _, err := ds.ListScripts(ctx, tmID, fleet.ListOptions{}) + require.NoError(t, err) + + // compare only the fields we care about + m := make(map[string]uint) + for _, gotScript := range got { + m[gotScript.Name] = gotScript.ID + if gotScript.TeamID != nil && *gotScript.TeamID == 0 { + gotScript.TeamID = nil + } + gotScript.ID = 0 + gotScript.CreatedAt = time.Time{} + gotScript.UpdatedAt = time.Time{} + } + // order is not guaranteed + require.ElementsMatch(t, want, got) + + return m + } + + // apply empty set for no-team + applyAndExpect(nil, nil, nil) + + // create a team + tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "_tm1"}) + require.NoError(t, err) + + // apply single script set for tm1 + sTm1 := applyAndExpect([]*fleet.Script{ + {Name: "N1", ScriptContents: "C1"}, + }, ptr.Uint(tm1.ID), []*fleet.Script{ + {Name: "N1", TeamID: ptr.Uint(tm1.ID)}, + }) + + // apply single script set for no-team + sNoTm := applyAndExpect([]*fleet.Script{ + {Name: "N1", ScriptContents: "C1"}, + }, nil, []*fleet.Script{ + {Name: "N1", TeamID: nil}, + }) + + // apply new script set for tm1 + sTm1b := applyAndExpect([]*fleet.Script{ + {Name: "N1", ScriptContents: "C1"}, + {Name: "N2", ScriptContents: "C2"}, + }, ptr.Uint(tm1.ID), []*fleet.Script{ + {Name: "N1", TeamID: ptr.Uint(tm1.ID)}, + {Name: "N2", TeamID: ptr.Uint(tm1.ID)}, + }) + // name for N1-I1 is unchanged + require.Equal(t, sTm1["I1"], sTm1b["I1"]) + + // apply edited (by contents only) script set for no-team + sNoTmb := applyAndExpect([]*fleet.Script{ + {Name: "N1", ScriptContents: "C1-changed"}, + }, nil, []*fleet.Script{ + {Name: "N1", TeamID: nil}, + }) + require.Equal(t, sNoTm["I1"], sNoTmb["I1"]) + + // apply edited script (by content only), unchanged script and new + // script for tm1 + sTm1c := applyAndExpect([]*fleet.Script{ + {Name: "N1", ScriptContents: "C1-updated"}, // content updated + {Name: "N2", ScriptContents: "C2"}, // unchanged + {Name: "N3", ScriptContents: "C3"}, // new + }, ptr.Uint(tm1.ID), []*fleet.Script{ + {Name: "N1", TeamID: ptr.Uint(tm1.ID)}, // content updated + {Name: "N2", TeamID: ptr.Uint(tm1.ID)}, // unchanged + {Name: "N3", TeamID: ptr.Uint(tm1.ID)}, // new + }) + // name for N1-I1 is unchanged + require.Equal(t, sTm1b["I1"], sTm1c["I1"]) + // identifier for N2-I2 is unchanged + require.Equal(t, sTm1b["I2"], sTm1c["I2"]) + + // apply only new scripts to no-team + applyAndExpect([]*fleet.Script{ + {Name: "N4", ScriptContents: "C4"}, + {Name: "N5", ScriptContents: "C5"}, + }, nil, []*fleet.Script{ + {Name: "N4", TeamID: nil}, + {Name: "N5", TeamID: nil}, + }) + + // clear scripts for tm1 + applyAndExpect(nil, ptr.Uint(1), nil) +} diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 0582e9bad3..a93d7f8791 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -72,6 +72,9 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeDisabledWindowsMDM{}, ActivityTypeRanScript{}, + ActivityTypeAddedScript{}, + ActivityTypeDeletedScript{}, + ActivityTypeEditedScript{}, } type ActivityDetails interface { @@ -1061,6 +1064,69 @@ func (a ActivityTypeRanScript) Documentation() (activity, details, detailsExampl }` } +type ActivityTypeAddedScript struct { + ScriptName string `json:"script_name"` + TeamID *uint `json:"team_id"` + TeamName *string `json:"team_name"` +} + +func (a ActivityTypeAddedScript) ActivityName() string { + return "added_script" +} + +func (a ActivityTypeAddedScript) Documentation() (activity, details, detailsExample string) { + return `Generated when a script is added to a team (or no team).`, + `This activity contains the following fields: +- "script_name": Name of the script. +- "team_id": The ID of the team that the script applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "team_name": The name of the team that the script applies to, ` + "`null`" + ` if it applies to devices that are not in a team.`, `{ + "script_name": "set-timezones.sh", + "team_id": 123, + "team_name": "Workstations" +}` +} + +type ActivityTypeDeletedScript struct { + ScriptName string `json:"script_name"` + TeamID *uint `json:"team_id"` + TeamName *string `json:"team_name"` +} + +func (a ActivityTypeDeletedScript) ActivityName() string { + return "deleted_script" +} + +func (a ActivityTypeDeletedScript) Documentation() (activity, details, detailsExample string) { + return `Generated when a script is deleted from a team (or no team).`, + `This activity contains the following fields: +- "script_name": Name of the script. +- "team_id": The ID of the team that the script applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "team_name": The name of the team that the script applies to, ` + "`null`" + ` if it applies to devices that are not in a team.`, `{ + "script_name": "set-timezones.sh", + "team_id": 123, + "team_name": "Workstations" +}` +} + +type ActivityTypeEditedScript struct { + TeamID *uint `json:"team_id"` + TeamName *string `json:"team_name"` +} + +func (a ActivityTypeEditedScript) ActivityName() string { + return "edited_script" +} + +func (a ActivityTypeEditedScript) Documentation() (activity, details, detailsExample string) { + return `Generated when a user edits the scripts of a team (or no team) via the fleetctl CLI.`, + `This activity contains the following fields: +- "team_id": The ID of the team that the scripts apply to, ` + "`null`" + ` if they apply to devices that are not in a team. +- "team_name": The name of the team that the scripts apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{ + "team_id": 123, + "team_name": "Workstations" +}` +} + // LogRoleChangeActivities logs activities for each role change, globally and one for each change in teams. func LogRoleChangeActivities(ctx context.Context, ds Datastore, adminUser *User, oldGlobalRole *string, oldTeamRoles []UserTeam, user *User) error { if user.GlobalRole != nil && (oldGlobalRole == nil || *oldGlobalRole != *user.GlobalRole) { diff --git a/server/fleet/app.go b/server/fleet/app.go index e0f2b6a966..e12e04892f 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -378,6 +378,12 @@ type AppConfig struct { MDM MDM `json:"mdm"` + // Scripts is a slice of script file paths. + // + // NOTE: These are only present here for informational purposes. + // (The source of truth for scripts is in MySQL.) + Scripts optjson.Slice[string] `json:"scripts"` + // when true, strictDecoding causes the UnmarshalJSON method to return an // error if there are unknown fields in the raw JSON. strictDecoding bool @@ -478,6 +484,12 @@ func (c *AppConfig) Copy() *AppConfig { copy(clone.MDM.MacOSSettings.CustomSettings, c.MDM.MacOSSettings.CustomSettings) } + if c.Scripts.Set { + scripts := make([]string, len(c.Scripts.Value)) + copy(scripts, c.Scripts.Value) + clone.Scripts = optjson.SetSlice[string](scripts) + } + return &clone } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index ac8873810b..dd4d0257d1 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1073,6 +1073,30 @@ type Datastore interface { // older than the ignoreOlder duration are ignored, considered too old to be // pending. ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*HostScriptResult, error) + + // NewScript creates a new saved script. + NewScript(ctx context.Context, script *Script) (*Script, error) + + // Script returns the saved script corresponding to id. + Script(ctx context.Context, id uint) (*Script, error) + + // GetScriptContents returns the raw script contents of the corresponding + // script. + GetScriptContents(ctx context.Context, id uint) ([]byte, error) + + // DeleteScript deletes the script identified by its id. + DeleteScript(ctx context.Context, id uint) error + + // ListScripts returns a paginated list of scripts corresponding to the + // criteria. + ListScripts(ctx context.Context, teamID *uint, opt ListOptions) ([]*Script, *PaginationMetadata, error) + + // GetHostScriptDetails returns the list of host script details for saved scripts applicable to + // a given host. + GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opts ListOptions) ([]*HostScriptDetail, *PaginationMetadata, error) + + // BatchSetScripts sets the scripts for the given team or no team. + BatchSetScripts(ctx context.Context, tmID *uint, scripts []*Script) error } const ( diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 9e22fa92c3..5fece04a6c 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -1,15 +1,12 @@ package fleet import ( - "bufio" "context" "encoding/json" "errors" "fmt" - "regexp" "strings" "time" - "unicode/utf8" ) type HostStatus string @@ -1121,134 +1118,3 @@ 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 *int64 `json:"exit_code" db:"exit_code"` - // CreatedAt is the creation timestamp of the script execution request. It is - // not returned as part of the payloads, but is used to determine if the script - // is too old to still expect a response from the host. - CreatedAt time.Time `json:"-" db:"created_at"` - - // TeamID is only used for authorization, it must be set to the team id of - // the host when checking authorization and is otherwise not set. - TeamID *uint `json:"team_id" db:"-"` - - // Message is the UserMessage associated with a response from an execution. - // It may be set by the endpoint and included in the resulting JSON but it is - // not otherwise part of the host_script_results table. - Message string `json:"message" db:"-"` - - // Hostname can be set by the endpoint as extra information to make available - // when generating the UserMessage associated with a response from an - // execution. It is otherwise not part of the host_script_results table and - // not returned as part of the resulting JSON. - Hostname string `json:"-" db:"-"` -} - -func (hsr HostScriptResult) AuthzType() string { - return "host_script_result" -} - -// UserMessage returns the user-friendly message to associate with the current -// state of the HostScriptResult. This is returned as part of the API endpoints -// for running a script synchronously (so that fleetctl can display it) and to -// get the script results for an execution ID (e.g. when looking at the details -// screen of a script execution activity in the website). -func (hsr HostScriptResult) UserMessage(hostTimeout bool) string { - if hostTimeout { - return RunScriptHostTimeoutErrMsg - } - - if hsr.ExitCode == nil { - if hsr.HostTimeout(1 * time.Minute) { - return RunScriptHostTimeoutErrMsg - } - return RunScriptAlreadyRunningErrMsg - } - - switch *hsr.ExitCode { - case -1: - return "Timeout. Fleet stopped the script after 30 seconds to protect host performance." - case -2: - return "Scripts are disabled for this host. To run scripts, deploy a Fleet installer with scripts enabled." - default: - return "" - } -} - -func (hsr HostScriptResult) HostTimeout(waitForResultTime time.Duration) bool { - return time.Now().After(hsr.CreatedAt.Add(waitForResultTime)) -} - -const MaxScriptRuneLen = 10000 - -func ValidateHostScriptContents(s string) error { - // anchored, so that it matches to the end of the line - scriptHashbangValidation := regexp.MustCompile(`^#!\s*/bin/sh\s*$`) - - if s == "" { - return errors.New("Script contents must not be empty.") - } - - // look for the script length in bytes first, as rune counting a huge string - // can be expensive. - if len(s) > utf8.UTFMax*MaxScriptRuneLen { - return errors.New("Script is too large. It’s limited to 10,000 characters (approximately 125 lines).") - } - - // now that we know that the script is at most 4*maxScriptRuneLen bytes long, - // we can safely count the runes for a precise check. - if utf8.RuneCountInString(s) > MaxScriptRuneLen { - return errors.New("Script is too large. It’s limited to 10,000 characters (approximately 125 lines).") - } - - // script must be a "text file", but that's not so simple to validate, so we - // assume that if it is valid utf8 encoding, it is a text file (binary files - // will often have invalid utf8 byte sequences). - if !utf8.ValidString(s) { - return errors.New("Wrong data format. Only plain text allowed.") - } - - if strings.HasPrefix(s, "#!") { - // read the first line in a portable way - s := bufio.NewScanner(strings.NewReader(s)) - // if a hashbang is present, it can only be `/bin/sh` for now - if s.Scan() && !scriptHashbangValidation.MatchString(s.Text()) { - return errors.New(`Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`) - } - } - - return nil -} diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go new file mode 100644 index 0000000000..5afc50f295 --- /dev/null +++ b/server/fleet/scripts.go @@ -0,0 +1,270 @@ +package fleet + +import ( + "bufio" + "errors" + "path/filepath" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// Script represents a saved script that can be executed on a host. +type Script struct { + ID uint `json:"id" db:"id"` + TeamID *uint `json:"team_id" db:"team_id"` + Name string `json:"name" db:"name"` + // ScriptContents is not returned in payloads nor is it returned + // from reading from the database, it is only used as payload to + // create the script. This is so that we minimize the number of + // times this potentially large field is transferred. + ScriptContents string `json:"-"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + // UpdatedAt serves as the "uploaded at" timestamp, since it is updated each + // time the script record gets updated. + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +func (s Script) AuthzType() string { + return "script" +} + +func (s *Script) Validate() error { + if s.Name == "" { + return errors.New("The file name must not be empty.") + } + if filepath.Ext(s.Name) != ".sh" { + return errors.New("The file should be a .sh file.") + } + + if err := ValidateHostScriptContents(s.ScriptContents); err != nil { + return err + } + + return nil +} + +// HostScriptDetail represents the details of a script that applies to a specific host. +type HostScriptDetail struct { + // HostID is the ID of the host. + HostID uint `json:"-"` + // ScriptID is the ID of the script. + ScriptID uint `json:"script_id"` + // Name is the name of the script. + Name string `json:"name"` + // LastExecution is the most recent execution of the script on the host. It is nil if the script + // has never executed on the host. + LastExecution *HostScriptExecution `json:"last_execution"` +} + +// NewHostScriptDetail creates a new HostScriptDetail and sets its LastExecution field based on the +// provided details. +func NewHostScriptDetail(hostID, scriptID uint, name string, executionID *string, executedAt *time.Time, exitCode *int64, hsrID *uint) *HostScriptDetail { + hs := HostScriptDetail{ + HostID: hostID, + ScriptID: scriptID, + Name: name, + } + hs.setLastExecution(executionID, executedAt, exitCode, hsrID) + return &hs +} + +// HostScriptExecution represents a single execution of a script on a host. +type HostScriptExecution struct { + // HostID is the ID of the host. + HostID uint `json:"-"` + // ScriptID is the ID of the script. + ScriptID uint `json:"-"` + // HSRID is the unique row identifier of the host_script_results table for this execution. + HSRID uint `json:"-"` + // ExecutionID is a unique identifier for a single execution of the script. + ExecutionID string `json:"execution_id"` + // ExecutedAt represents the time that the script was executed on the host. It should correspond to + // the created_at field of the host_script_results table for the associated HSRID. + ExecutedAt time.Time `json:"executed_at"` + // Status is the status of the script execution. It is one of "pending", "ran", or "error". It + // is derived from the exit_code field of the host_script_results table for the associated HSRID. + Status string `json:"status"` +} + +// SetLastExecution updates the LastExecution field of the HostScriptDetail if the provided details +// are more recent than the current LastExecution. It returns true if the LastExecution was updated. +func (hs *HostScriptDetail) setLastExecution(executionID *string, executedAt *time.Time, exitCode *int64, hsrID *uint) bool { + if hsrID == nil || executionID == nil || executedAt == nil { + // no new execution, nothing to do + return false + } + + newHSE := &HostScriptExecution{ + HSRID: *hsrID, + ExecutionID: *executionID, + ExecutedAt: *executedAt, + } + switch { + case exitCode == nil: + newHSE.Status = "pending" + case *exitCode == 0: + newHSE.Status = "ran" + default: + newHSE.Status = "error" + } + + if hs.LastExecution == nil { + // no previous execution, use the new one + hs.LastExecution = newHSE + return true + } + if newHSE.ExecutedAt.After(hs.LastExecution.ExecutedAt) { + // new execution is more recent, use it + hs.LastExecution = newHSE + return true + } + if newHSE.ExecutedAt == hs.LastExecution.ExecutedAt && newHSE.HSRID > hs.LastExecution.HSRID { + // same execution time, but new execution has a higher ID, use it + hs.LastExecution = newHSE + return true + } + + return false +} + +type HostScriptRequestPayload struct { + HostID uint `json:"host_id"` + ScriptID *uint `json:"script_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 *int64 `json:"exit_code" db:"exit_code"` + // CreatedAt is the creation timestamp of the script execution request. It is + // not returned as part of the payloads, but is used to determine if the script + // is too old to still expect a response from the host. + CreatedAt time.Time `json:"-" db:"created_at"` + // ScriptID is the id of the saved script to execute, or nil if this was an + // anonymous script execution. + ScriptID *uint `json:"script_id" db:"script_id"` + + // 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:"-"` + + // Message is the UserMessage associated with a response from an execution. + // It may be set by the endpoint and included in the resulting JSON but it is + // not otherwise part of the host_script_results table. + Message string `json:"message" db:"-"` + + // Hostname can be set by the endpoint as extra information to make available + // when generating the UserMessage associated with a response from an + // execution. It is otherwise not part of the host_script_results table and + // not returned as part of the resulting JSON. + Hostname string `json:"-" db:"-"` +} + +func (hsr HostScriptResult) AuthzType() string { + return "host_script_result" +} + +// UserMessage returns the user-friendly message to associate with the current +// state of the HostScriptResult. This is returned as part of the API endpoints +// for running a script synchronously (so that fleetctl can display it) and to +// get the script results for an execution ID (e.g. when looking at the details +// screen of a script execution activity in the website). +func (hsr HostScriptResult) UserMessage(hostTimeout bool) string { + if hostTimeout { + return RunScriptHostTimeoutErrMsg + } + + if hsr.ExitCode == nil { + if hsr.HostTimeout(1 * time.Minute) { + return RunScriptHostTimeoutErrMsg + } + return RunScriptAlreadyRunningErrMsg + } + + switch *hsr.ExitCode { + case -1: + return "Timeout. Fleet stopped the script after 30 seconds to protect host performance." + case -2: + return "Scripts are disabled for this host. To run scripts, deploy a Fleet installer with scripts enabled." + default: + return "" + } +} + +func (hsr HostScriptResult) HostTimeout(waitForResultTime time.Duration) bool { + return time.Now().After(hsr.CreatedAt.Add(waitForResultTime)) +} + +const MaxScriptRuneLen = 10000 + +// anchored, so that it matches to the end of the line +var scriptHashbangValidation = regexp.MustCompile(`^#!\s*/bin/sh\s*$`) + +func ValidateHostScriptContents(s string) error { + if s == "" { + return errors.New("Script contents must not be empty.") + } + + // look for the script length in bytes first, as rune counting a huge string + // can be expensive. + if len(s) > utf8.UTFMax*MaxScriptRuneLen { + return errors.New("Script is too large. It's limited to 10,000 characters (approximately 125 lines).") + } + + // now that we know that the script is at most 4*maxScriptRuneLen bytes long, + // we can safely count the runes for a precise check. + if utf8.RuneCountInString(s) > MaxScriptRuneLen { + return errors.New("Script is too large. It's limited to 10,000 characters (approximately 125 lines).") + } + + // script must be a "text file", but that's not so simple to validate, so we + // assume that if it is valid utf8 encoding, it is a text file (binary files + // will often have invalid utf8 byte sequences). + if !utf8.ValidString(s) { + return errors.New("Wrong data format. Only plain text allowed.") + } + + if strings.HasPrefix(s, "#!") { + // read the first line in a portable way + s := bufio.NewScanner(strings.NewReader(s)) + // if a hashbang is present, it can only be `/bin/sh` for now + if s.Scan() && !scriptHashbangValidation.MatchString(s.Text()) { + return errors.New(`Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`) + } + } + + return nil +} + +type ScriptPayload struct { + Name string `json:"name"` + ScriptContents []byte `json:"script_contents"` +} diff --git a/server/fleet/scripts_test.go b/server/fleet/scripts_test.go new file mode 100644 index 0000000000..fe6049bcfe --- /dev/null +++ b/server/fleet/scripts_test.go @@ -0,0 +1,104 @@ +package fleet + +import ( + "errors" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/require" +) + +func TestScriptValidate(t *testing.T) { + tests := []struct { + name string + script Script + wantErr error + }{ + { + name: "valid script", + script: Script{ + Name: "test.sh", + ScriptContents: "valid", + }, + wantErr: nil, + }, + { + name: "empty name", + script: Script{ + Name: "", + ScriptContents: "valid", + }, + wantErr: errors.New("The file name must not be empty."), + }, + { + name: "invalid extension", + script: Script{ + Name: "test.txt", + ScriptContents: "valid", + }, + wantErr: errors.New("The file should be a .sh file."), + }, + { + name: "invalid script content", + script: Script{ + Name: "test.sh", + ScriptContents: "", + }, + wantErr: errors.New("Script contents must not be empty."), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.script.Validate() + require.Equal(t, tt.wantErr, err) + }) + } +} + +func TestValidateHostScriptContents(t *testing.T) { + tests := []struct { + name string + script string + wantErr error + }{ + { + name: "empty script", + script: "", + wantErr: errors.New("Script contents must not be empty."), + }, + { + name: "too large by byte count", + script: strings.Repeat("a", utf8.UTFMax*MaxScriptRuneLen+1), + wantErr: errors.New("Script is too large. It's limited to 10,000 characters (approximately 125 lines)."), + }, + { + name: "too large by rune count", + script: strings.Repeat("🙂", MaxScriptRuneLen+1), + wantErr: errors.New("Script is too large. It's limited to 10,000 characters (approximately 125 lines)."), + }, + { + name: "invalid utf8 encoding", + script: string([]byte{0xff, 0xfe, 0xfd}), + wantErr: errors.New("Wrong data format. Only plain text allowed."), + }, + { + name: "unsupported interpreter", + script: "#!/bin/bash\necho 'hello'", + wantErr: errors.New(`Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`), + }, + { + name: "valid script", + script: "#!/bin/sh\necho 'hello'", + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateHostScriptContents(tt.script) + require.Equal(t, tt.wantErr, err) + }) + } +} diff --git a/server/fleet/service.go b/server/fleet/service.go index d0e97cb419..86420cc843 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -815,11 +815,34 @@ type Service interface { // 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) + // GetHostScript returns information about a host script execution. GetHostScript(ctx context.Context, execID string) (*HostScriptResult, error) + // SaveHostScriptResult saves information about execution of a script on a host. SaveHostScriptResult(ctx context.Context, result *HostScriptResultPayload) error // GetScriptResult returns the result of a script run GetScriptResult(ctx context.Context, execID string) (*HostScriptResult, error) + + // NewScript creates a new (saved) script with its content provided by the + // io.Reader r. + NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*Script, error) + + // DeleteScript deletes an existing (saved) script. + DeleteScript(ctx context.Context, scriptID uint) error + + // ListScripts returns a list of paginated saved scripts. + ListScripts(ctx context.Context, teamID *uint, opt ListOptions) ([]*Script, *PaginationMetadata, error) + + // GetScript returns the script corresponding to the provided id. If the + // download is requested, it also returns the script's contents. + GetScript(ctx context.Context, scriptID uint, downloadRequested bool) (*Script, []byte, error) + + // GetHostScriptDetails returns a list of scripts that apply to the provided host. + GetHostScriptDetails(ctx context.Context, hostID uint, opt ListOptions) ([]*HostScriptDetail, *PaginationMetadata, error) + + // BatchSetScripts replaces the scripts for a specified team or for + // hosts with no team. + BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []ScriptPayload, dryRun bool) error } diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 191ef0f3b1..bf33bede63 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -134,11 +134,12 @@ func (t *Team) UnmarshalJSON(b []byte) error { type TeamConfig struct { // AgentOptions is the options for osquery and Orbit. - AgentOptions *json.RawMessage `json:"agent_options,omitempty"` - WebhookSettings TeamWebhookSettings `json:"webhook_settings"` - Integrations TeamIntegrations `json:"integrations"` - Features Features `json:"features"` - MDM TeamMDM `json:"mdm"` + AgentOptions *json.RawMessage `json:"agent_options,omitempty"` + WebhookSettings TeamWebhookSettings `json:"webhook_settings"` + Integrations TeamIntegrations `json:"integrations"` + Features Features `json:"features"` + MDM TeamMDM `json:"mdm"` + Scripts optjson.Slice[string] `json:"scripts,omitempty"` } type TeamWebhookSettings struct { @@ -343,9 +344,10 @@ type TeamSpec struct { // set to the agent options JSON object. AgentOptions json.RawMessage `json:"agent_options,omitempty"` // marshals as "null" if omitempty is not set - Secrets []EnrollSecret `json:"secrets,omitempty"` - Features *json.RawMessage `json:"features"` - MDM TeamSpecMDM `json:"mdm"` + Secrets []EnrollSecret `json:"secrets,omitempty"` + Features *json.RawMessage `json:"features"` + MDM TeamSpecMDM `json:"mdm"` + Scripts optjson.Slice[string] `json:"scripts"` } // TeamSpecFromTeam returns a TeamSpec constructed from the given Team. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 2253afe93a..2c85a98eb4 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -700,6 +700,20 @@ type GetHostScriptExecutionResultFunc func(ctx context.Context, execID string) ( type ListPendingHostScriptExecutionsFunc func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) +type NewScriptFunc func(ctx context.Context, script *fleet.Script) (*fleet.Script, error) + +type ScriptFunc func(ctx context.Context, id uint) (*fleet.Script, error) + +type GetScriptContentsFunc func(ctx context.Context, id uint) ([]byte, error) + +type DeleteScriptFunc func(ctx context.Context, id uint) error + +type ListScriptsFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) + +type GetHostScriptDetailsFunc func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) + +type BatchSetScriptsFunc func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -1724,6 +1738,27 @@ type DataStore struct { ListPendingHostScriptExecutionsFunc ListPendingHostScriptExecutionsFunc ListPendingHostScriptExecutionsFuncInvoked bool + NewScriptFunc NewScriptFunc + NewScriptFuncInvoked bool + + ScriptFunc ScriptFunc + ScriptFuncInvoked bool + + GetScriptContentsFunc GetScriptContentsFunc + GetScriptContentsFuncInvoked bool + + DeleteScriptFunc DeleteScriptFunc + DeleteScriptFuncInvoked bool + + ListScriptsFunc ListScriptsFunc + ListScriptsFuncInvoked bool + + GetHostScriptDetailsFunc GetHostScriptDetailsFunc + GetHostScriptDetailsFuncInvoked bool + + BatchSetScriptsFunc BatchSetScriptsFunc + BatchSetScriptsFuncInvoked bool + mu sync.Mutex } @@ -4113,3 +4148,52 @@ func (s *DataStore) ListPendingHostScriptExecutions(ctx context.Context, hostID s.mu.Unlock() return s.ListPendingHostScriptExecutionsFunc(ctx, hostID, ignoreOlder) } + +func (s *DataStore) NewScript(ctx context.Context, script *fleet.Script) (*fleet.Script, error) { + s.mu.Lock() + s.NewScriptFuncInvoked = true + s.mu.Unlock() + return s.NewScriptFunc(ctx, script) +} + +func (s *DataStore) Script(ctx context.Context, id uint) (*fleet.Script, error) { + s.mu.Lock() + s.ScriptFuncInvoked = true + s.mu.Unlock() + return s.ScriptFunc(ctx, id) +} + +func (s *DataStore) GetScriptContents(ctx context.Context, id uint) ([]byte, error) { + s.mu.Lock() + s.GetScriptContentsFuncInvoked = true + s.mu.Unlock() + return s.GetScriptContentsFunc(ctx, id) +} + +func (s *DataStore) DeleteScript(ctx context.Context, id uint) error { + s.mu.Lock() + s.DeleteScriptFuncInvoked = true + s.mu.Unlock() + return s.DeleteScriptFunc(ctx, id) +} + +func (s *DataStore) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { + s.mu.Lock() + s.ListScriptsFuncInvoked = true + s.mu.Unlock() + return s.ListScriptsFunc(ctx, teamID, opt) +} + +func (s *DataStore) GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { + s.mu.Lock() + s.GetHostScriptDetailsFuncInvoked = true + s.mu.Unlock() + return s.GetHostScriptDetailsFunc(ctx, hostID, teamID, opts) +} + +func (s *DataStore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + s.mu.Lock() + s.BatchSetScriptsFuncInvoked = true + s.mu.Unlock() + return s.BatchSetScriptsFunc(ctx, tmID, scripts) +} diff --git a/server/service/appconfig.go b/server/service/appconfig.go index a3008831c1..0fb9d8469e 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -175,6 +175,7 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se WebhookSettings: appConfig.WebhookSettings, Integrations: appConfig.Integrations, MDM: appConfig.MDM, + Scripts: appConfig.Scripts, }, appConfigResponseFields: appConfigResponseFields{ UpdateInterval: updateIntervalConfig, diff --git a/server/service/client.go b/server/service/client.go index ccccf31746..4aaefd6e1e 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -379,6 +379,23 @@ func (c *Client) ApplyGroup( } } } + if scripts := extractAppCfgScripts(specs.AppConfig); scripts != nil { + files := resolveApplyRelativePaths(baseDir, scripts) + scriptPayloads := make([]fleet.ScriptPayload, len(files)) + for i, f := range files { + b, err := os.ReadFile(f) + if err != nil { + return fmt.Errorf("applying fleet config: %w", err) + } + scriptPayloads[i] = fleet.ScriptPayload{ + ScriptContents: b, + Name: filepath.Base(f), + } + } + if err := c.ApplyNoTeamScripts(scriptPayloads, opts); err != nil { + return fmt.Errorf("applying custom settings: %w", err) + } + } if err := c.ApplyAppConfig(specs.AppConfig, opts); err != nil { return fmt.Errorf("applying fleet config: %w", err) } @@ -439,6 +456,24 @@ func (c *Client) ApplyGroup( } } + tmScripts := extractTmSpecsScripts(specs.Teams) + tmScriptsPayloads := make(map[string][]fleet.ScriptPayload, len(tmScripts)) + for k, paths := range tmScripts { + files := resolveApplyRelativePaths(baseDir, paths) + scriptPayloads := make([]fleet.ScriptPayload, len(files)) + for i, f := range files { + b, err := os.ReadFile(f) + if err != nil { + return fmt.Errorf("applying fleet config: %w", err) + } + scriptPayloads[i] = fleet.ScriptPayload{ + ScriptContents: b, + Name: filepath.Base(f), + } + } + tmScriptsPayloads[k] = scriptPayloads + } + // Next, apply the teams specs before saving the profiles, so that any // non-existing team gets created. teamIDsByName, err := c.ApplyTeams(specs.Teams, opts) @@ -467,6 +502,13 @@ func (c *Client) ApplyGroup( } } } + if len(tmScriptsPayloads) > 0 { + for tmName, scripts := range tmScriptsPayloads { + if err := c.ApplyTeamScripts(tmName, scripts, opts); err != nil { + return fmt.Errorf("applying scripts for team %q: %w", tmName, err) + } + } + } if opts.DryRun { logfn("[+] would've applied %d teams\n", len(specs.Teams)) } else { @@ -567,6 +609,35 @@ func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string { return csStrings } +func extractAppCfgScripts(appCfg interface{}) []string { + asMap, ok := appCfg.(map[string]interface{}) + if !ok { + return nil + } + + scripts, ok := asMap["scripts"] + if !ok { + // scripts is not present + return nil + } + + scriptsAny, ok := scripts.([]interface{}) + if !ok || scriptsAny == nil { + // return a non-nil, empty slice instead, so the caller knows that the + // scripts key was actually provided. + return []string{} + } + + scriptsStrings := make([]string, 0, len(scriptsAny)) + for _, v := range scriptsAny { + s, _ := v.(string) + if s != "" { + scriptsStrings = append(scriptsStrings, s) + } + } + return scriptsStrings +} + // returns the custom settings keyed by team name. func extractTmSpecsMacOSCustomSettings(tmSpecs []json.RawMessage) map[string][]string { var m map[string][]string @@ -603,6 +674,37 @@ func extractTmSpecsMacOSCustomSettings(tmSpecs []json.RawMessage) map[string][]s return m } +func extractTmSpecsScripts(tmSpecs []json.RawMessage) map[string][]string { + var m map[string][]string + for _, tm := range tmSpecs { + var spec struct { + Name string `json:"name"` + Scripts json.RawMessage `json:"scripts"` + } + if err := json.Unmarshal(tm, &spec); err != nil { + // ignore, this will fail in the call to apply team specs + continue + } + if spec.Name != "" && len(spec.Scripts) > 0 { + if m == nil { + m = make(map[string][]string) + } + var scripts []string + if err := json.Unmarshal(spec.Scripts, &scripts); err != nil { + // ignore, will fail in apply team specs call + continue + } + if scripts == nil { + // to be consistent with the AppConfig custom settings, set it to an + // empty slice if the provided custom settings are present but empty. + scripts = []string{} + } + m[spec.Name] = scripts + } + } + return m +} + // returns the macos_setup keyed by team name. func extractTmSpecsMacOSSetup(tmSpecs []json.RawMessage) map[string]*fleet.MacOSSetup { var m map[string]*fleet.MacOSSetup diff --git a/server/service/client_hosts.go b/server/service/client_hosts.go index b4cdb5103c..eb818b9149 100644 --- a/server/service/client_hosts.go +++ b/server/service/client_hosts.go @@ -1,7 +1,10 @@ package service import ( + "encoding/csv" "fmt" + "net/url" + "strings" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" @@ -117,3 +120,26 @@ func (c *Client) TransferHosts(hosts []string, label string, status, searchQuery }{MatchQuery: searchQuery, Status: fleet.HostStatus(status), LabelID: labelIDPtr}} return c.authenticatedRequest(params, verb, path, &responseBody) } + +// GetHosts returns a report of all hosts. +// +// The first row holds the name of the columns and each subsequent row are +// the column values for each host. +func (c *Client) GetHostsReport(columns ...string) ([][]string, error) { + verb, path := "GET", "/api/latest/fleet/hosts/report" + query := make(url.Values) + query.Add("format", "csv") + if len(columns) > 0 { + query.Add("columns", strings.Join(columns, ",")) + } + response, err := c.AuthenticatedDo(verb, path, query.Encode(), nil) + if err != nil { + return nil, err + } + csvReader := csv.NewReader(response.Body) + records, err := csvReader.ReadAll() + if err != nil { + return nil, err + } + return records, nil +} diff --git a/server/service/client_scripts.go b/server/service/client_scripts.go index e6dce493b2..394cf066fc 100644 --- a/server/service/client_scripts.go +++ b/server/service/client_scripts.go @@ -51,3 +51,10 @@ func (c *Client) RunHostScriptSync(hostID uint, scriptContents []byte) (*fleet.H return &result, nil } + +// ApplyNoTeamScripts sends the list of scripts to be applied for the hosts in +// no team. +func (c *Client) ApplyNoTeamScripts(scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error { + verb, path := "POST", "/api/latest/fleet/scripts/batch" + return c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, nil, opts.RawQuery()) +} diff --git a/server/service/client_teams.go b/server/service/client_teams.go index 85812c9489..ea2192827e 100644 --- a/server/service/client_teams.go +++ b/server/service/client_teams.go @@ -82,3 +82,15 @@ func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error { var responseBody applyPolicySpecsResponse return c.authenticatedRequest(req, verb, path, &responseBody) } + +// ApplyTeamScripts sends the list of scripts to be applied for the specified +// team. +func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error { + verb, path := "POST", "/api/latest/fleet/scripts/batch" + query, err := url.ParseQuery(opts.RawQuery()) + if err != nil { + return err + } + query.Add("team_name", tmName) + return c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, nil, query.Encode()) +} diff --git a/server/service/handler.go b/server/service/handler.go index 64734b1464..f1c50486bd 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -446,6 +446,13 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/scripts/run", runScriptEndpoint, runScriptRequest{}) ue.POST("/api/_version_/fleet/scripts/run/sync", runScriptSyncEndpoint, runScriptRequest{}) ue.GET("/api/_version_/fleet/scripts/results/{execution_id}", getScriptResultEndpoint, getScriptResultRequest{}) + ue.POST("/api/_version_/fleet/scripts", createScriptEndpoint, createScriptRequest{}) + ue.GET("/api/_version_/fleet/scripts", listScriptsEndpoint, listScriptsRequest{}) + ue.GET("/api/_version_/fleet/scripts/{script_id:[0-9]+}", getScriptEndpoint, getScriptRequest{}) + ue.DELETE("/api/_version_/fleet/scripts/{script_id:[0-9]+}", deleteScriptEndpoint, deleteScriptRequest{}) + ue.POST("/api/_version_/fleet/scripts/batch", batchSetScriptsEndpoint, batchSetScriptsRequest{}) + + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/scripts", getHostScriptDetailsEndpoint, getHostScriptDetailsRequest{}) // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update diff --git a/server/service/hosts.go b/server/service/hosts.go index db568e87b6..0055aa66a4 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -6,7 +6,6 @@ import ( "crypto/tls" "encoding/csv" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -1635,152 +1634,3 @@ 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 - HostTimeout bool `json:"host_timeout"` -} - -func (r runScriptSyncResponse) error() error { return r.Err } -func (r runScriptSyncResponse) Status() int { - if r.HostTimeout { - 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 - -// waitForResultTime is the default timeout for the synchronous script execution. -const waitForResultTime = time.Minute - -func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - waitForResult := waitForResultTime - if testRunScriptWaitForResult != 0 { - waitForResult = testRunScriptWaitForResult - } - - req := request.(*runScriptRequest) - result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{ - HostID: req.HostID, - ScriptContents: req.ScriptContents, - }, waitForResult) - var hostTimeout bool - if err != nil { - if !errors.Is(err, context.DeadlineExceeded) { - return runScriptSyncResponse{Err: err}, nil - } - // We should still return the execution id and host id in this timeout case, - // 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. - hostTimeout = true - } - result.Message = result.UserMessage(hostTimeout) - return runScriptSyncResponse{ - HostScriptResult: result, - HostTimeout: hostTimeout, - }, 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 -} - -// ////////////////////////////////////////////////////////////////////////////// -// Get script result for a host -// ////////////////////////////////////////////////////////////////////////////// -type getScriptResultRequest struct { - ExecutionID string `url:"execution_id"` -} - -type getScriptResultResponse struct { - ScriptContents string `json:"script_contents"` - ExitCode *int64 `json:"exit_code"` - Output string `json:"output"` - Message string `json:"message"` - HostName string `json:"hostname"` - HostTimeout bool `json:"host_timeout"` - HostID uint `json:"host_id"` - ExecutionID string `json:"execution_id"` - Runtime int `json:"runtime"` - - Err error `json:"error,omitempty"` -} - -func (r getScriptResultResponse) error() error { return r.Err } - -func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - req := request.(*getScriptResultRequest) - scriptResult, err := svc.GetScriptResult(ctx, req.ExecutionID) - if err != nil { - return getScriptResultResponse{Err: err}, nil - } - - // check if a minute has passed since the script was created at - hostTimeout := scriptResult.HostTimeout(waitForResultTime) - scriptResult.Message = scriptResult.UserMessage(hostTimeout) - - return &getScriptResultResponse{ - ScriptContents: scriptResult.ScriptContents, - ExitCode: scriptResult.ExitCode, - Output: scriptResult.Output, - Message: scriptResult.Message, - HostName: scriptResult.Hostname, - HostTimeout: hostTimeout, - HostID: scriptResult.HostID, - ExecutionID: scriptResult.ExecutionID, - Runtime: scriptResult.Runtime, - }, nil -} - -func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*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 1b77fa3f70..cd7a5dc993 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "strconv" - "strings" "testing" "time" @@ -1243,352 +1242,3 @@ 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), SeenTime: time.Now()} - noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil, SeenTime: time.Now()} - 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.HostFunc = 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 - } - ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { - require.IsType(t, fleet.ActivityTypeRanScript{}, activity) - return nil - } - - t.Run("authorization checks", func(t *testing.T) { - 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) - }) - } - }) - - t.Run("script contents validation", func(t *testing.T) { - testCases := []struct { - name string - script string - wantErr string - }{ - {"empty script", "", "Script contents must not be empty."}, - {"overly long script", strings.Repeat("a", 10001), "Script is too large."}, - {"invalid utf8", "\xff\xfa", "Wrong data format."}, - {"valid without hashbang", "echo 'a'", ""}, - {"valid with hashbang", "#!/bin/sh\necho 'a'", ""}, - {"valid with hashbang and spacing", "#! /bin/sh \necho 'a'", ""}, - {"valid with hashbang and Windows newline", "#! /bin/sh \r\necho 'a'", ""}, - {"invalid hashbang", "#!/bin/bash\necho 'a'", "Interpreter not supported."}, - {"invalid hashbang suffix", "#!/bin/sh -n\necho 'a'", "Interpreter not supported."}, - } - - ctx = viewer.NewContext(ctx, viewer.Viewer{User: test.UserAdmin}) - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - _, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: tt.script}, 0) - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - } else { - require.NoError(t, err) - } - }) - } - }) -} - -func TestGetScriptResult(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}) - - const ( - noTeamHostExecID = "no-team-host" - teamHostExecID = "team-host" - nonExistingHostExecID = "non-existing-host" - ) - - 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), SeenTime: time.Now()} - noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil, SeenTime: time.Now()} - 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.GetHostScriptExecutionResultFunc = func(ctx context.Context, executionID string) (*fleet.HostScriptResult, error) { - switch executionID { - case noTeamHostExecID: - return &fleet.HostScriptResult{HostID: noTeamHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil - case teamHostExecID: - return &fleet.HostScriptResult{HostID: teamHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil - case nonExistingHostExecID: - return &fleet.HostScriptResult{HostID: nonExistingHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil - default: - return nil, newNotFoundError() - } - } - 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() - } - - testCases := []struct { - name string - user *fleet.User - shouldFailTeamRead bool - shouldFailGlobalRead bool - }{ - { - name: "global admin", - user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, - shouldFailTeamRead: false, - shouldFailGlobalRead: false, - }, - { - name: "global maintainer", - user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, - shouldFailTeamRead: false, - shouldFailGlobalRead: false, - }, - { - name: "global observer", - user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, - shouldFailTeamRead: false, - shouldFailGlobalRead: false, - }, - { - name: "global observer+", - user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, - shouldFailTeamRead: false, - shouldFailGlobalRead: false, - }, - { - name: "global gitops", - user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, - shouldFailTeamRead: true, - shouldFailGlobalRead: true, - }, - { - name: "team admin, belongs to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, - shouldFailTeamRead: false, - shouldFailGlobalRead: true, - }, - { - name: "team maintainer, belongs to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, - shouldFailTeamRead: false, - shouldFailGlobalRead: true, - }, - { - name: "team observer, belongs to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, - shouldFailTeamRead: false, - shouldFailGlobalRead: true, - }, - { - name: "team observer+, belongs to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, - shouldFailTeamRead: false, - shouldFailGlobalRead: true, - }, - { - name: "team gitops, belongs to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, - shouldFailTeamRead: true, - shouldFailGlobalRead: true, - }, - { - name: "team admin, DOES NOT belong to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, - shouldFailTeamRead: true, - shouldFailGlobalRead: true, - }, - { - name: "team maintainer, DOES NOT belong to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, - shouldFailTeamRead: true, - shouldFailGlobalRead: true, - }, - { - name: "team observer, DOES NOT belong to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, - shouldFailTeamRead: true, - shouldFailGlobalRead: true, - }, - { - name: "team observer+, DOES NOT belong to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, - shouldFailTeamRead: true, - shouldFailGlobalRead: true, - }, - { - name: "team gitops, DOES NOT belong to team", - user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, - shouldFailTeamRead: true, - shouldFailGlobalRead: true, - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - - _, err := svc.GetScriptResult(ctx, noTeamHostExecID) - checkAuthErr(t, tt.shouldFailGlobalRead, err) - _, err = svc.GetScriptResult(ctx, teamHostExecID) - checkAuthErr(t, tt.shouldFailTeamRead, err) - - // a non-existing host is authorized as for global write (because we can't know what team it belongs to) - _, err = svc.GetScriptResult(ctx, nonExistingHostExecID) - checkAuthErr(t, tt.shouldFailGlobalRead, err) - }) - } -} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6344915fac..8af8976a82 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -79,6 +79,7 @@ func (s *integrationTestSuite) TestSlowOsqueryHost() { SkipCreateTestUsers: true, //nolint:gosec // G112: server is just run for testing this explicit config. HTTPServerConfig: &http.Server{ReadTimeout: 2 * time.Second}, + EnableCachedDS: true, }, ) defer func() { @@ -4669,6 +4670,30 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { // get script result var scriptResultResp getScriptResultResponse s.DoJSON("GET", "/api/latest/fleet/scripts/results/test-id", nil, http.StatusPaymentRequired, &scriptResultResp) + + // create a saved script + body, headers := generateNewScriptMultipartRequest(t, nil, + "myscript.sh", []byte(`echo "hello"`), s.token) + s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusPaymentRequired, headers) + + // delete a saved script + var delScriptResp deleteScriptResponse + s.DoJSON("DELETE", "/api/latest/fleet/scripts/123", nil, http.StatusPaymentRequired, &delScriptResp) + + // list saved scripts + var listScriptsResp listScriptsResponse + s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusPaymentRequired, &listScriptsResp, "per_page", "10") + + // get a saved script + var getScriptResp getScriptResponse + s.DoJSON("GET", "/api/latest/fleet/scripts/123", nil, http.StatusPaymentRequired, &getScriptResp) + + // get host script details + var getHostScriptDetailsResp getHostScriptDetailsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/123/scripts", nil, http.StatusPaymentRequired, &getHostScriptDetailsResp) + + // batch set scripts + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, http.StatusPaymentRequired) } // 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 77ea383fb4..750e3e7ca2 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -11,6 +12,7 @@ import ( "os" "reflect" "sort" + "strconv" "strings" "testing" "time" @@ -54,9 +56,10 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { License: &fleet.LicenseInfo{ Tier: fleet.TierPremium, }, - Pool: s.redisPool, - Lq: s.lq, - Logger: log.NewLogfmtLogger(os.Stdout), + Pool: s.redisPool, + Lq: s.lq, + Logger: log.NewLogfmtLogger(os.Stdout), + EnableCachedDS: true, } users, server := RunServerForTestsWithDS(s.T(), s.ds, &config) s.server = server @@ -3932,6 +3935,147 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { require.Contains(t, errMsg, fleet.RunScriptHostOfflineErrMsg) } +func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() { + t := s.T() + + testRunScriptWaitForResult = 2 * time.Second + defer func() { testRunScriptWaitForResult = 0 }() + + ctx := context.Background() + + host := createOrbitEnrolledHost(t, "linux", "", s.ds) + tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + savedNoTmScript, err := s.ds.NewScript(ctx, &fleet.Script{ + TeamID: nil, + Name: "no_team_script.sh", + ScriptContents: "echo 'no team'", + }) + require.NoError(t, err) + savedTmScript, err := s.ds.NewScript(ctx, &fleet.Script{ + TeamID: &tm.ID, + Name: "team_script.sh", + ScriptContents: "echo 'team'", + }) + require.NoError(t, err) + + // 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, ScriptID: &savedNoTmScript.ID}, http.StatusNotFound, &runResp) + + // attempt to run with both script contents and id + res := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo", ScriptID: ptr.Uint(savedTmScript.ID + 999)}, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, `Only one of "script_id" or "script_contents" can be provided.`) + + // attempt to run with unknown script id + res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: ptr.Uint(savedTmScript.ID + 999)}, http.StatusNotFound) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, `No script exists for the provided "script_id".`) + + // make sure the host is still seen as "online" + err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now()) + require.NoError(t, err) + + // attempt to run a team script on a non-team host + res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedTmScript.ID}, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, `The script does not belong to the same team`) + + // make sure the host is still seen as "online" + err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now()) + require.NoError(t, err) + + // create a valid script execution request + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedNoTmScript.ID}, http.StatusAccepted, &runResp) + require.Equal(t, host.ID, runResp.HostID) + require.NotEmpty(t, runResp.ExecutionID) + + // an activity was created for the async script execution + s.lastActivityMatches( + fleet.ActivityTypeRanScript{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": %q, "script_execution_id": %q, "async": true}`, + host.ID, host.DisplayName(), runResp.ExecutionID, + ), + 0, + ) + + var scriptResultResp getScriptResultResponse + s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runResp.ExecutionID, nil, http.StatusOK, &scriptResultResp) + require.Equal(t, host.ID, scriptResultResp.HostID) + require.Equal(t, "echo 'no team'", scriptResultResp.ScriptContents) + require.Nil(t, scriptResultResp.ExitCode) + require.False(t, scriptResultResp.HostTimeout) + require.Contains(t, scriptResultResp.Message, fleet.RunScriptAlreadyRunningErrMsg) + require.NotNil(t, scriptResultResp.ScriptID) + require.Equal(t, savedNoTmScript.ID, *scriptResultResp.ScriptID) + + // verify that orbit would get the notification that it has a script to run + var orbitResp orbitGetConfigResponse + s.DoJSON("POST", "/api/fleet/orbit/config", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), + http.StatusOK, &orbitResp) + require.Equal(t, []string{scriptResultResp.ExecutionID}, orbitResp.Notifications.PendingScriptExecutionIDs) + + // the orbit endpoint to get a pending script to execute returns it + var orbitGetScriptResp orbitGetScriptResponse + s.DoJSON("POST", "/api/fleet/orbit/scripts/request", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)), + http.StatusOK, &orbitGetScriptResp) + require.Equal(t, host.ID, orbitGetScriptResp.HostID) + require.Equal(t, scriptResultResp.ExecutionID, orbitGetScriptResp.ExecutionID) + require.Equal(t, "echo 'no team'", orbitGetScriptResp.ScriptContents) + + // make sure the host is still seen as "online" + err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now()) + require.NoError(t, err) + + // 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, ScriptID: &savedNoTmScript.ID}, http.StatusConflict) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, fleet.RunScriptAlreadyRunningErrMsg) + + // save a result via the orbit endpoint + var orbitPostScriptResp orbitPostScriptResultResponse + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)), + http.StatusOK, &orbitPostScriptResp) + + // verify that orbit does not receive any pending script anymore + orbitResp = orbitGetConfigResponse{} + s.DoJSON("POST", "/api/fleet/orbit/config", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), + http.StatusOK, &orbitResp) + require.Empty(t, orbitResp.Notifications.PendingScriptExecutionIDs) + + // create a valid sync script execution request, fails because the + // request will time-out waiting for a result. + var runSyncResp runScriptSyncResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedNoTmScript.ID}, http.StatusGatewayTimeout, &runSyncResp) + require.Equal(t, host.ID, runSyncResp.HostID) + require.NotEmpty(t, runSyncResp.ExecutionID) + require.NotNil(t, runSyncResp.ScriptID) + require.Equal(t, savedNoTmScript.ID, *runSyncResp.ScriptID) + require.Equal(t, "echo 'no team'", runSyncResp.ScriptContents) + require.True(t, runSyncResp.HostTimeout) + require.Contains(t, runSyncResp.Message, fleet.RunScriptHostTimeoutErrMsg) + + // deleting the saved script does not impact the pending script + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", savedNoTmScript.ID), nil, http.StatusNoContent) + + // script id is now nil, but otherwise execution request is the same + scriptResultResp = getScriptResultResponse{} + s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runSyncResp.ExecutionID, nil, http.StatusOK, &scriptResultResp) + require.Equal(t, host.ID, scriptResultResp.HostID) + require.Equal(t, "echo 'no team'", scriptResultResp.ScriptContents) + require.Nil(t, scriptResultResp.ExitCode) + require.False(t, scriptResultResp.HostTimeout) + require.Contains(t, scriptResultResp.Message, fleet.RunScriptAlreadyRunningErrMsg) + require.Nil(t, scriptResultResp.ScriptID) +} + func (s *integrationEnterpriseTestSuite) TestOrbitConfigExtensions() { t := s.T() ctx := context.Background() @@ -4114,6 +4258,770 @@ func (s *integrationEnterpriseTestSuite) TestOrbitConfigExtensions() { }`), http.StatusBadRequest) } +func (s *integrationEnterpriseTestSuite) TestSavedScripts() { + t := s.T() + ctx := context.Background() + + // create a saved script for no team + var newScriptResp createScriptResponse + body, headers := generateNewScriptMultipartRequest(t, nil, + "script1.sh", []byte(`echo "hello"`), s.token) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) + err := json.NewDecoder(res.Body).Decode(&newScriptResp) + require.NoError(t, err) + require.NotZero(t, newScriptResp.ScriptID) + noTeamScriptID := newScriptResp.ScriptID + s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": null, "team_id": null}`, "script1.sh"), 0) + + // get the script + var getScriptResp getScriptResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusOK, &getScriptResp) + require.Equal(t, noTeamScriptID, getScriptResp.ID) + require.Nil(t, getScriptResp.TeamID) + require.Equal(t, "script1.sh", getScriptResp.Name) + require.NotZero(t, getScriptResp.CreatedAt) + require.NotZero(t, getScriptResp.UpdatedAt) + require.Empty(t, getScriptResp.ScriptContents) + + // download the script's content + res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusOK, "alt", "media") + b, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, `echo "hello"`, string(b)) + require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength) + require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script1.sh"), res.Header.Get("Content-Disposition")) + + // get a non-existing script + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID+999), nil, http.StatusNotFound, &getScriptResp) + // download a non-existing script + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID+999), nil, http.StatusNotFound, &getScriptResp, "alt", "media") + + // file name is empty + body, headers = generateNewScriptMultipartRequest(t, nil, + "", []byte(`echo "hello"`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusBadRequest, headers) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "no file headers for script") + + // file name is not .sh + body, headers = generateNewScriptMultipartRequest(t, nil, + "not_sh.txt", []byte(`echo "hello"`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "The file should be a .sh file") + + // file content is empty + body, headers = generateNewScriptMultipartRequest(t, nil, + "script2.sh", []byte(``), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Script contents must not be empty") + + // file content is too large + body, headers = generateNewScriptMultipartRequest(t, nil, + "script2.sh", []byte(strings.Repeat("a", 10001)), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Script is too large. It's limited to 10,000 characters") + + // invalid hashbang + body, headers = generateNewScriptMultipartRequest(t, nil, + "script2.sh", []byte(`#!/bin/python`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Interpreter not supported.") + + // script already exists with this name for this no-team + body, headers = generateNewScriptMultipartRequest(t, nil, + "script1.sh", []byte(`echo "hello"`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "A script with this name already exists") + + // team id does not exist + body, headers = generateNewScriptMultipartRequest(t, ptr.Uint(123), + "script1.sh", []byte(`echo "hello"`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusNotFound, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "The team does not exist.") + + // create a team + tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + // create with existing name for this time for a team + body, headers = generateNewScriptMultipartRequest(t, &tm.ID, + "script1.sh", []byte(`echo "team"`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) + err = json.NewDecoder(res.Body).Decode(&newScriptResp) + require.NoError(t, err) + require.NotZero(t, newScriptResp.ScriptID) + require.NotEqual(t, noTeamScriptID, newScriptResp.ScriptID) + tmScriptID := newScriptResp.ScriptID + s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0) + + // get team's script + getScriptResp = getScriptResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusOK, &getScriptResp) + require.Equal(t, tmScriptID, getScriptResp.ID) + require.NotNil(t, getScriptResp.TeamID) + require.Equal(t, tm.ID, *getScriptResp.TeamID) + require.Equal(t, "script1.sh", getScriptResp.Name) + require.NotZero(t, getScriptResp.CreatedAt) + require.NotZero(t, getScriptResp.UpdatedAt) + require.Empty(t, getScriptResp.ScriptContents) + + // download the team's script's content + res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusOK, "alt", "media") + b, err = io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, `echo "team"`, string(b)) + require.Equal(t, int64(len(`echo "team"`)), res.ContentLength) + require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script1.sh"), res.Header.Get("Content-Disposition")) + + // script already exists with this name for this team + body, headers = generateNewScriptMultipartRequest(t, &tm.ID, + "script1.sh", []byte(`echo "hello"`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "A script with this name already exists") + + // create with a different name for this team + body, headers = generateNewScriptMultipartRequest(t, &tm.ID, + "script2.sh", []byte(`echo "hello"`), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) + err = json.NewDecoder(res.Body).Decode(&newScriptResp) + require.NoError(t, err) + require.NotZero(t, newScriptResp.ScriptID) + require.NotEqual(t, noTeamScriptID, newScriptResp.ScriptID) + require.NotEqual(t, tmScriptID, newScriptResp.ScriptID) + s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script2.sh", tm.Name, tm.ID), 0) + + // delete the no-team script + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusNoContent) + s.lastActivityMatches("deleted_script", fmt.Sprintf(`{"script_name": %q, "team_name": null, "team_id": null}`, "script1.sh"), 0) + + // delete the initial team script + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", tmScriptID), nil, http.StatusNoContent) + s.lastActivityMatches("deleted_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0) + + // delete a non-existing script + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID), nil, http.StatusNotFound) +} + +func (s *integrationEnterpriseTestSuite) TestListSavedScripts() { + t := s.T() + ctx := context.Background() + + // create some teams + tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) + require.NoError(t, err) + + // create 5 scripts for no team and team 1 + for i := 0; i < 5; i++ { + _, err = s.ds.NewScript(ctx, &fleet.Script{ + Name: string('a' + byte(i)), // i.e. "a", "b", "c", ... + ScriptContents: "echo", + }) + require.NoError(t, err) + _, err = s.ds.NewScript(ctx, &fleet.Script{Name: string('a' + byte(i)), TeamID: &tm1.ID, ScriptContents: "echo"}) + require.NoError(t, err) + } + + // create a single script for team 2 + _, err = s.ds.NewScript(ctx, &fleet.Script{Name: "a", TeamID: &tm2.ID, ScriptContents: "echo"}) + require.NoError(t, err) + + cases := []struct { + queries []string // alternate query name and value + teamID *uint + wantNames []string + wantMeta *fleet.PaginationMetadata + }{ + { + wantNames: []string{"a", "b", "c", "d", "e"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2"}, + wantNames: []string{"a", "b"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2", "page", "1"}, + wantNames: []string{"c", "d"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "2", "page", "2"}, + wantNames: []string{"e"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3"}, + teamID: &tm1.ID, + wantNames: []string{"a", "b", "c"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "3", "page", "1"}, + teamID: &tm1.ID, + wantNames: []string{"d", "e"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3", "page", "2"}, + teamID: &tm1.ID, + wantNames: nil, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3"}, + teamID: &tm2.ID, + wantNames: []string{"a"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2"}, + teamID: &tm3.ID, + wantNames: nil, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) { + var listResp listScriptsResponse + queryArgs := c.queries + if c.teamID != nil { + queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID)) + } + s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusOK, &listResp, queryArgs...) + + require.Equal(t, len(c.wantNames), len(listResp.Scripts)) + require.Equal(t, c.wantMeta, listResp.Meta) + + var gotNames []string + if len(listResp.Scripts) > 0 { + gotNames = make([]string, len(listResp.Scripts)) + for i, s := range listResp.Scripts { + gotNames[i] = s.Name + if c.teamID == nil { + require.Nil(t, s.TeamID) + } else { + require.NotNil(t, s.TeamID) + require.Equal(t, *c.teamID, *s.TeamID) + } + } + } + require.Equal(t, c.wantNames, gotNames) + }) + } +} + +func (s *integrationEnterpriseTestSuite) TestHostScriptDetails() { + t := s.T() + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + // create some teams + tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team1"}) + require.NoError(t, err) + tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team2"}) + require.NoError(t, err) + tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-script-details-team3"}) + require.NoError(t, err) + + // create 5 scripts for no team and team 1 + for i := 0; i < 5; i++ { + _, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d", i), ScriptContents: "echo"}) + require.NoError(t, err) + _, err = s.ds.NewScript(ctx, &fleet.Script{Name: fmt.Sprintf("test-script-details-%d", i), TeamID: &tm1.ID, ScriptContents: "echo"}) + require.NoError(t, err) + } + + // create a single script for team 2 + _, err = s.ds.NewScript(ctx, &fleet.Script{Name: "test-script-details-team-2", TeamID: &tm2.ID, ScriptContents: "echo"}) + require.NoError(t, err) + + // create a host without a team + host0, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String("host0"), + NodeKey: ptr.String("host0"), + UUID: uuid.New().String(), + Hostname: "host0", + Platform: "darwin", + }) + require.NoError(t, err) + + // create a host for team 1 + host1, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String("host1"), + NodeKey: ptr.String("host1"), + UUID: uuid.New().String(), + Hostname: "host1", + Platform: "windows", + TeamID: &tm1.ID, + }) + require.NoError(t, err) + + // create a host for team 3 + host2, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String("host2"), + NodeKey: ptr.String("host2"), + UUID: uuid.New().String(), + Hostname: "host2", + Platform: "linux", + TeamID: &tm3.ID, + }) + require.NoError(t, err) + + insertResults := func(t *testing.T, hostID uint, script *fleet.Script, createdAt time.Time, execID string, exitCode *int64) { + stmt := ` +INSERT INTO + host_script_results (%s host_id, created_at, execution_id, exit_code, script_contents, output) +VALUES + (%s ?,?,?,?,?,?)` + + args := []interface{}{} + if script.ID == 0 { + stmt = fmt.Sprintf(stmt, "", "") + } else { + stmt = fmt.Sprintf(stmt, "script_id,", "?,") + args = append(args, script.ID) + } + args = append(args, hostID, createdAt, execID, exitCode, script.ScriptContents, "") + + mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, stmt, args...) + return err + }) + } + + // insert some ad hoc script results, these are never included in the host script details + insertResults(t, host0.ID, &fleet.Script{Name: "ad hoc script", ScriptContents: "echo foo"}, now, "ad-hoc-0", ptr.Int64(0)) + insertResults(t, host1.ID, &fleet.Script{Name: "ad hoc script", ScriptContents: "echo foo"}, now.Add(-1*time.Hour), "ad-hoc-1", ptr.Int64(1)) + + t.Run("no team", func(t *testing.T) { + noTeamScripts, _, err := s.ds.ListScripts(ctx, nil, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamScripts, 5) + + // insert saved script results for host0 + insertResults(t, host0.ID, noTeamScripts[0], now, "exec0-0", ptr.Int64(0)) // expect status ran + insertResults(t, host0.ID, noTeamScripts[1], now.Add(-1*time.Hour), "exec0-1", ptr.Int64(1)) // expect status error + insertResults(t, host0.ID, noTeamScripts[2], now.Add(-2*time.Hour), "exec0-2", nil) // expect status pending + + // insert some ad hoc script results, these are never included in the host script details + insertResults(t, host0.ID, &fleet.Script{Name: "ad hoc script", ScriptContents: "echo foo"}, now.Add(-3*time.Hour), "exec0-3", ptr.Int64(0)) + + // check host script details, should include all no team scripts + var resp getHostScriptDetailsResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host0.ID), nil, http.StatusOK, &resp) + require.Len(t, resp.Scripts, len(noTeamScripts)) + byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts)) + for _, s := range resp.Scripts { + byScriptID[s.ScriptID] = s + } + for i, s := range noTeamScripts { + gotScript, ok := byScriptID[s.ID] + require.True(t, ok) + require.Equal(t, s.Name, gotScript.Name) + switch i { + case 0: + require.NotNil(t, gotScript.LastExecution) + require.Equal(t, "exec0-0", gotScript.LastExecution.ExecutionID) + require.Equal(t, now, gotScript.LastExecution.ExecutedAt) + require.Equal(t, "ran", gotScript.LastExecution.Status) + case 1: + require.NotNil(t, gotScript.LastExecution) + require.Equal(t, "exec0-1", gotScript.LastExecution.ExecutionID) + require.Equal(t, now.Add(-1*time.Hour), gotScript.LastExecution.ExecutedAt) + require.Equal(t, "error", gotScript.LastExecution.Status) + case 2: + require.NotNil(t, gotScript.LastExecution) + require.Equal(t, "exec0-2", gotScript.LastExecution.ExecutionID) + require.Equal(t, now.Add(-2*time.Hour), gotScript.LastExecution.ExecutedAt) + require.Equal(t, "pending", gotScript.LastExecution.Status) + default: + require.Nil(t, gotScript.LastExecution) + } + } + }) + + t.Run("team 1", func(t *testing.T) { + tm1Scripts, _, err := s.ds.ListScripts(ctx, &tm1.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, tm1Scripts, 5) + + // insert results for host1 + insertResults(t, host1.ID, tm1Scripts[0], now, "exec1-0", ptr.Int64(0)) // expect status ran + + // check host script details, should match team 1 + var resp getHostScriptDetailsResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host1.ID), nil, http.StatusOK, &resp) + require.Len(t, resp.Scripts, len(tm1Scripts)) + byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts)) + for _, s := range resp.Scripts { + byScriptID[s.ScriptID] = s + } + for i, s := range tm1Scripts { + gotScript, ok := byScriptID[s.ID] + require.True(t, ok) + require.Equal(t, s.Name, gotScript.Name) + switch i { + case 0: + require.NotNil(t, gotScript.LastExecution) + require.Equal(t, "exec1-0", gotScript.LastExecution.ExecutionID) + require.Equal(t, now, gotScript.LastExecution.ExecutedAt) + require.Equal(t, "ran", gotScript.LastExecution.Status) + default: + require.Nil(t, gotScript.LastExecution) + } + } + }) + + t.Run("deleted script", func(t *testing.T) { + noTeamScripts, _, err := s.ds.ListScripts(ctx, nil, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamScripts, 5) + + // delete a script + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScripts[0].ID), nil, http.StatusNoContent) + + // check host script details, should not include deleted script + var resp getHostScriptDetailsResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host0.ID), nil, http.StatusOK, &resp) + require.Len(t, resp.Scripts, len(noTeamScripts)-1) + byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts)) + for _, s := range resp.Scripts { + require.NotEqual(t, noTeamScripts[0].ID, s.ScriptID) + byScriptID[s.ScriptID] = s + } + for i, s := range noTeamScripts { + gotScript, ok := byScriptID[s.ID] + if i == 0 { + require.False(t, ok) + } else { + require.True(t, ok) + require.Equal(t, s.Name, gotScript.Name) + switch i { + case 1: + require.NotNil(t, gotScript.LastExecution) + require.Equal(t, "exec0-1", gotScript.LastExecution.ExecutionID) + require.Equal(t, now.Add(-1*time.Hour), gotScript.LastExecution.ExecutedAt) + require.Equal(t, "error", gotScript.LastExecution.Status) + case 2: + require.NotNil(t, gotScript.LastExecution) + require.Equal(t, "exec0-2", gotScript.LastExecution.ExecutionID) + require.Equal(t, now.Add(-2*time.Hour), gotScript.LastExecution.ExecutedAt) + require.Equal(t, "pending", gotScript.LastExecution.Status) + case 3, 4: + require.Nil(t, gotScript.LastExecution) + default: + require.Fail(t, "unexpected script") + } + } + } + }) + + t.Run("transfer team", func(t *testing.T) { + s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{ + TeamID: &tm2.ID, + HostIDs: []uint{host1.ID}, + }, http.StatusOK, &addHostsToTeamResponse{}) + + tm2Scripts, _, err := s.ds.ListScripts(ctx, &tm2.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, tm2Scripts, 1) + + // check host script details, should not include prior team's scripts + var resp getHostScriptDetailsResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host1.ID), nil, http.StatusOK, &resp) + require.Len(t, resp.Scripts, len(tm2Scripts)) + byScriptID := make(map[uint]*fleet.HostScriptDetail, len(resp.Scripts)) + for _, s := range resp.Scripts { + byScriptID[s.ScriptID] = s + } + for _, s := range tm2Scripts { + gotScript, ok := byScriptID[s.ID] + require.True(t, ok) + require.Equal(t, s.Name, gotScript.Name) + require.Nil(t, gotScript.LastExecution) + } + }) + + t.Run("no scripts", func(t *testing.T) { + var resp getHostScriptDetailsResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/scripts", host2.ID), nil, http.StatusOK, &resp) + require.NotNil(t, resp.Scripts) + require.Len(t, resp.Scripts, 0) + }) +} + +// generates the body and headers part of a multipart request ready to be +// used via s.DoRawWithHeaders to POST /api/_version_/fleet/scripts. +func generateNewScriptMultipartRequest(t *testing.T, tmID *uint, + fileName string, fileContent []byte, token string, +) (*bytes.Buffer, map[string]string) { + return generateMultipartRequest(t, tmID, "script", fileName, fileContent, token) +} + +func (s *integrationEnterpriseTestSuite) TestAppConfigScripts() { + t := s.T() + + // set the script fields + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": ["foo", "bar"] }`), http.StatusOK, &acResp) + assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value) + + // check that they are returned by a GET /config + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value) + + // patch without specifying the scripts fields, should not remove them + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{}`), http.StatusOK, &acResp) + assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value) + + // patch with explicitly empty scripts fields, would remove + // them but this is a dry-run + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": null }`), http.StatusOK, &acResp, "dry_run", "true") + assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value) + + // patch with explicitly empty scripts fields, removes them + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": null }`), http.StatusOK, &acResp) + assert.Empty(t, acResp.Scripts.Value) + + // set the script fields again + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": ["foo", "bar"] }`), http.StatusOK, &acResp) + assert.ElementsMatch(t, []string{"foo", "bar"}, acResp.Scripts.Value) + + // patch with an empty array sets the scripts to an empty array as well + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": [] }`), http.StatusOK, &acResp) + assert.Empty(t, acResp.Scripts.Value) + + // patch with an invalid array returns an error + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "scripts": ["foo", 1] }`), http.StatusBadRequest, &acResp) + assert.Empty(t, acResp.Scripts.Value) +} + +func (s *integrationEnterpriseTestSuite) TestApplyTeamsScriptsConfig() { + t := s.T() + + // create a team through the service so it initializes the agent ops + teamName := t.Name() + "team1" + team := &fleet.Team{ + Name: teamName, + Description: "desc team1", + } + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + team = createTeamResp.Team + + // apply with scripts + // must not use applyTeamSpecsRequest and marshal it as JSON, as it will set + // all keys to their zerovalue, and some are only valid with mdm enabled. + teamSpecs := map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "scripts": []string{"foo", "bar"}, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + + // retrieving the team returns the scripts + var teamResp getTeamResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Equal(t, []string{"foo", "bar"}, teamResp.Team.Config.Scripts.Value) + + // apply without custom scripts specified, should not replace existing scripts + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Equal(t, []string{"foo", "bar"}, teamResp.Team.Config.Scripts.Value) + + // apply with explicitly empty custom scripts would clear the existing + // scripts, but dry-run + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "scripts": nil, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true") + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Equal(t, []string{"foo", "bar"}, teamResp.Team.Config.Scripts.Value) + + // apply with explicitly empty scripts clears the existing scripts + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "scripts": nil, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Empty(t, teamResp.Team.Config.Scripts.Value) + + // patch with an invalid array returns an error + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "scripts": []any{"foo", 1}, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest) + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Empty(t, teamResp.Team.Config.Scripts.Value) +} + +func (s *integrationEnterpriseTestSuite) TestBatchApplyScriptsEndpoints() { + t := s.T() + ctx := context.Background() + + saveAndCheckScripts := func(team *fleet.Team, scripts []fleet.ScriptPayload) { + var teamID *uint + teamIDStr := "" + teamActivity := `{"team_id": null, "team_name": null}` + if team != nil { + teamID = &team.ID + teamIDStr = strconv.Itoa(int(team.ID)) + teamActivity = fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, team.Name) + } + + // create and check activities + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: scripts}, http.StatusNoContent, "team_id", teamIDStr) + s.lastActivityMatches( + fleet.ActivityTypeEditedScript{}.ActivityName(), + teamActivity, + 0, + ) + + // check that the right values got stored in the db + var listResp listScriptsResponse + s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusOK, &listResp, "team_id", teamIDStr) + require.Len(t, listResp.Scripts, len(scripts)) + + got := make([]fleet.ScriptPayload, len(scripts)) + for i, gotScript := range listResp.Scripts { + // add the script contents + res := s.Do("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", gotScript.ID), nil, http.StatusOK, "alt", "media") + b, err := io.ReadAll(res.Body) + require.NoError(t, err) + got[i] = fleet.ScriptPayload{ + Name: gotScript.Name, + ScriptContents: b, + } + // check that it belongs to the right team + require.Equal(t, teamID, gotScript.TeamID) + } + + require.ElementsMatch(t, scripts, got) + } + + // create a new team + tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_scripts"}) + require.NoError(t, err) + + // apply an empty set to no-team + saveAndCheckScripts(nil, nil) + + // apply to both team id and name + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, + http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name) + + // invalid team name + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, + http.StatusNotFound, "team_name", uuid.New().String()) + + // duplicate script names + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: []fleet.ScriptPayload{ + {Name: "N1.sh", ScriptContents: []byte("foo")}, + {Name: "N1.sh", ScriptContents: []byte("bar")}, + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + + // invalid script name + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: []fleet.ScriptPayload{ + {Name: "N1", ScriptContents: []byte("foo")}, + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + + // empty script name + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: []fleet.ScriptPayload{ + {Name: "", ScriptContents: []byte("foo")}, + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + + // successfully apply a scripts for the team + saveAndCheckScripts(tm, []fleet.ScriptPayload{ + {Name: "N1.sh", ScriptContents: []byte("foo")}, + {Name: "N2.sh", ScriptContents: []byte("bar")}, + }) + + // successfully apply scripts for "no team" + saveAndCheckScripts(nil, []fleet.ScriptPayload{ + {Name: "N1.sh", ScriptContents: []byte("foo")}, + {Name: "N2.sh", ScriptContents: []byte("bar")}, + }) + + // edit, delete and add a new one for "no team" + saveAndCheckScripts(nil, []fleet.ScriptPayload{ + {Name: "N2.sh", ScriptContents: []byte("bar-edited")}, + {Name: "N3.sh", ScriptContents: []byte("baz")}, + }) + + // edit, delete and add a new one for the team + saveAndCheckScripts(tm, []fleet.ScriptPayload{ + {Name: "N2.sh", ScriptContents: []byte("bar-edited")}, + {Name: "N3.sh", ScriptContents: []byte("baz")}, + }) + + // remove all scripts for a team + saveAndCheckScripts(tm, nil) + + // remove all scripts for "no team" + saveAndCheckScripts(nil, nil) +} + func (s *integrationEnterpriseTestSuite) TestTeamConfigDetailQueriesOverrides() { ctx := context.Background() t := s.T() @@ -4172,7 +5080,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamConfigDetailQueriesOverrides() require.NotContains(t, dqResp.Queries, "fleet_detail_query_users") require.NotContains(t, dqResp.Queries, "fleet_detail_query_disk_encryption_linux") require.Contains(t, dqResp.Queries, "fleet_detail_query_software_linux") - require.Contains(t, dqResp.Queries, "fleet_distributed_query_17") + require.Contains(t, dqResp.Queries, "fleet_distributed_query_21") spec = []byte(fmt.Sprintf(` name: %s @@ -4200,5 +5108,5 @@ func (s *integrationEnterpriseTestSuite) TestTeamConfigDetailQueriesOverrides() require.Contains(t, dqResp.Queries, "fleet_detail_query_users") require.Contains(t, dqResp.Queries, "fleet_detail_query_disk_encryption_linux") require.Contains(t, dqResp.Queries, "fleet_detail_query_software_linux") - require.Contains(t, dqResp.Queries, "fleet_distributed_query_17") + require.Contains(t, dqResp.Queries, "fleet_distributed_query_21") } diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 618d6c2dcd..71afd8a19d 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -5526,6 +5526,12 @@ func (s *integrationMDMTestSuite) assertConfigProfilesByIdentifier(teamID *uint, // used via s.DoRawWithHeaders to POST /api/_version_/fleet/mdm/apple/profiles. func generateNewProfileMultipartRequest(t *testing.T, tmID *uint, fileName string, fileContent []byte, token string, +) (*bytes.Buffer, map[string]string) { + return generateMultipartRequest(t, tmID, "profile", fileName, fileContent, token) +} + +func generateMultipartRequest(t *testing.T, tmID *uint, + uploadFileField, fileName string, fileContent []byte, token string, ) (*bytes.Buffer, map[string]string) { var body bytes.Buffer @@ -5535,7 +5541,7 @@ func generateNewProfileMultipartRequest(t *testing.T, tmID *uint, require.NoError(t, err) } - ff, err := writer.CreateFormFile("profile", fileName) + ff, err := writer.CreateFormFile(uploadFileField, fileName) require.NoError(t, err) _, err = io.Copy(ff, bytes.NewReader(fileContent)) require.NoError(t, err) diff --git a/server/service/scripts.go b/server/service/scripts.go new file mode 100644 index 0000000000..6e309b19c4 --- /dev/null +++ b/server/service/scripts.go @@ -0,0 +1,447 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "path/filepath" + "strconv" + "time" + + "github.com/docker/go-units" + "github.com/fleetdm/fleet/v4/server/contexts/logging" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" +) + +//////////////////////////////////////////////////////////////////////////////// +// Run Script on a Host (async) +//////////////////////////////////////////////////////////////////////////////// + +type runScriptRequest struct { + HostID uint `json:"host_id"` + ScriptID *uint `json:"script_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, + ScriptID: req.ScriptID, + 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 + HostTimeout bool `json:"host_timeout"` +} + +func (r runScriptSyncResponse) error() error { return r.Err } +func (r runScriptSyncResponse) Status() int { + if r.HostTimeout { + 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 + +// waitForResultTime is the default timeout for the synchronous script execution. +const waitForResultTime = time.Minute + +func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + waitForResult := waitForResultTime + if testRunScriptWaitForResult != 0 { + waitForResult = testRunScriptWaitForResult + } + + req := request.(*runScriptRequest) + result, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: req.HostID, + ScriptID: req.ScriptID, + ScriptContents: req.ScriptContents, + }, waitForResult) + var hostTimeout bool + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) { + return runScriptSyncResponse{Err: err}, nil + } + // We should still return the execution id and host id in this timeout case, + // 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. + hostTimeout = true + } + result.Message = result.UserMessage(hostTimeout) + return runScriptSyncResponse{ + HostScriptResult: result, + HostTimeout: hostTimeout, + }, 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 +} + +// ////////////////////////////////////////////////////////////////////////////// +// Get script result for a host +// ////////////////////////////////////////////////////////////////////////////// +type getScriptResultRequest struct { + ExecutionID string `url:"execution_id"` +} + +type getScriptResultResponse struct { + ScriptContents string `json:"script_contents"` + ScriptID *uint `json:"script_id"` + ExitCode *int64 `json:"exit_code"` + Output string `json:"output"` + Message string `json:"message"` + HostName string `json:"hostname"` + HostTimeout bool `json:"host_timeout"` + HostID uint `json:"host_id"` + ExecutionID string `json:"execution_id"` + Runtime int `json:"runtime"` + + Err error `json:"error,omitempty"` +} + +func (r getScriptResultResponse) error() error { return r.Err } + +func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getScriptResultRequest) + scriptResult, err := svc.GetScriptResult(ctx, req.ExecutionID) + if err != nil { + return getScriptResultResponse{Err: err}, nil + } + + // check if a minute has passed since the script was created at + hostTimeout := scriptResult.HostTimeout(waitForResultTime) + scriptResult.Message = scriptResult.UserMessage(hostTimeout) + + return &getScriptResultResponse{ + ScriptContents: scriptResult.ScriptContents, + ScriptID: scriptResult.ScriptID, + ExitCode: scriptResult.ExitCode, + Output: scriptResult.Output, + Message: scriptResult.Message, + HostName: scriptResult.Hostname, + HostTimeout: hostTimeout, + HostID: scriptResult.HostID, + ExecutionID: scriptResult.ExecutionID, + Runtime: scriptResult.Runtime, + }, nil +} + +func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// Create a (saved) script (via a multipart file upload) +//////////////////////////////////////////////////////////////////////////////// + +type createScriptRequest struct { + TeamID *uint + Script *multipart.FileHeader +} + +func (createScriptRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + var decoded createScriptRequest + + err := r.ParseMultipartForm(512 * units.MiB) // same in-memory size as for other multipart requests we have + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + val := r.MultipartForm.Value["team_id"] + if len(val) > 0 { + teamID, err := strconv.ParseUint(val[0], 10, 64) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode team_id in multipart form: %s", err.Error())} + } + decoded.TeamID = ptr.Uint(uint(teamID)) + } + + fhs, ok := r.MultipartForm.File["script"] + if !ok || len(fhs) < 1 { + return nil, &fleet.BadRequestError{Message: "no file headers for script"} + } + decoded.Script = fhs[0] + + return &decoded, nil +} + +type createScriptResponse struct { + Err error `json:"error,omitempty"` + ScriptID uint `json:"script_id,omitempty"` +} + +func (r createScriptResponse) error() error { return r.Err } + +func createScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*createScriptRequest) + + scriptFile, err := req.Script.Open() + if err != nil { + return &createScriptResponse{Err: err}, nil + } + defer scriptFile.Close() + + script, err := svc.NewScript(ctx, req.TeamID, filepath.Base(req.Script.Filename), scriptFile) + if err != nil { + return createScriptResponse{Err: err}, nil + } + return createScriptResponse{ScriptID: script.ID}, nil +} + +func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// Delete a (saved) script +//////////////////////////////////////////////////////////////////////////////// + +type deleteScriptRequest struct { + ScriptID uint `url:"script_id"` +} + +type deleteScriptResponse struct { + Err error `json:"error,omitempty"` +} + +func (r deleteScriptResponse) error() error { return r.Err } +func (r deleteScriptResponse) Status() int { return http.StatusNoContent } + +func deleteScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*deleteScriptRequest) + err := svc.DeleteScript(ctx, req.ScriptID) + if err != nil { + return deleteScriptResponse{Err: err}, nil + } + return deleteScriptResponse{}, nil +} + +func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// List (saved) scripts (paginated) +//////////////////////////////////////////////////////////////////////////////// + +type listScriptsRequest struct { + TeamID *uint `query:"team_id,optional"` + ListOptions fleet.ListOptions `url:"list_options"` +} + +type listScriptsResponse struct { + Meta *fleet.PaginationMetadata `json:"meta"` + Scripts []*fleet.Script `json:"scripts"` + Err error `json:"error,omitempty"` +} + +func (r listScriptsResponse) error() error { return r.Err } + +func listScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*listScriptsRequest) + scripts, meta, err := svc.ListScripts(ctx, req.TeamID, req.ListOptions) + if err != nil { + return listScriptsResponse{Err: err}, nil + } + return listScriptsResponse{ + Meta: meta, + Scripts: scripts, + }, nil +} + +func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// Get/download a (saved) script +//////////////////////////////////////////////////////////////////////////////// + +type getScriptRequest struct { + ScriptID uint `url:"script_id"` + Alt string `query:"alt,optional"` +} + +type getScriptResponse struct { + *fleet.Script + Err error `json:"error,omitempty"` +} + +func (r getScriptResponse) error() error { return r.Err } + +type downloadScriptResponse struct { + Err error `json:"error,omitempty"` + filename string + content []byte +} + +func (r downloadScriptResponse) error() error { return r.Err } + +func (r downloadScriptResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Length", strconv.Itoa(len(r.content))) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.filename)) + w.Header().Set("X-Content-Type-Options", "nosniff") + + // OK to just log the error here as writing anything on + // `http.ResponseWriter` sets the status code to 200 (and it can't be + // changed.) Clients should rely on matching content-length with the + // header provided + if n, err := w.Write(r.content); err != nil { + logging.WithExtras(ctx, "err", err, "bytes_copied", n) + } +} + +func getScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getScriptRequest) + + downloadRequested := req.Alt == "media" + script, content, err := svc.GetScript(ctx, req.ScriptID, downloadRequested) + if err != nil { + return getScriptResponse{Err: err}, nil + } + + if downloadRequested { + return downloadScriptResponse{ + content: content, + filename: fmt.Sprintf("%s %s", time.Now().Format(time.DateOnly), script.Name), + }, nil + } + return getScriptResponse{Script: script}, nil +} + +func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// Get Host Script Details +//////////////////////////////////////////////////////////////////////////////// + +type getHostScriptDetailsRequest struct { + HostID uint `url:"id"` + ListOptions fleet.ListOptions `url:"list_options"` +} + +type getHostScriptDetailsResponse struct { + Scripts []*fleet.HostScriptDetail `json:"scripts"` + Meta *fleet.PaginationMetadata `json:"meta"` + Err error `json:"error,omitempty"` +} + +func (r getHostScriptDetailsResponse) error() error { return r.Err } + +func getHostScriptDetailsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getHostScriptDetailsRequest) + scripts, meta, err := svc.GetHostScriptDetails(ctx, req.HostID, req.ListOptions) + if err != nil { + return getHostScriptDetailsResponse{Err: err}, nil + } + return getHostScriptDetailsResponse{ + Scripts: scripts, + Meta: meta, + }, nil +} + +func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// Batch Replace Scripts +//////////////////////////////////////////////////////////////////////////////// + +type batchSetScriptsRequest struct { + TeamID *uint `json:"-" query:"team_id,optional"` + TeamName *string `json:"-" query:"team_name,optional"` + DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes + Scripts []fleet.ScriptPayload `json:"scripts"` +} + +type batchSetScriptsResponse struct { + Err error `json:"error,omitempty"` +} + +func (r batchSetScriptsResponse) error() error { return r.Err } + +func (r batchSetScriptsResponse) Status() int { return http.StatusNoContent } + +func batchSetScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*batchSetScriptsRequest) + if err := svc.BatchSetScripts(ctx, req.TeamID, req.TeamName, req.Scripts, req.DryRun); err != nil { + return batchSetScriptsResponse{Err: err}, nil + } + return batchSetScriptsResponse{}, nil +} + +func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} diff --git a/server/service/scripts_test.go b/server/service/scripts_test.go new file mode 100644 index 0000000000..8251cc62d5 --- /dev/null +++ b/server/service/scripts_test.go @@ -0,0 +1,835 @@ +package service + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +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), SeenTime: time.Now()} + noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil, SeenTime: time.Now()} + 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.HostFunc = 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 + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + require.IsType(t, fleet.ActivityTypeRanScript{}, activity) + return nil + } + ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) { + return &fleet.Script{ID: id}, nil + } + ds.GetScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { + return []byte("echo"), nil + } + + t.Run("authorization checks", func(t *testing.T) { + testCases := []struct { + name string + user *fleet.User + scriptID *uint + shouldFailTeamWrite bool + shouldFailGlobalWrite bool + }{ + { + name: "global admin", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global admin saved", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + scriptID: ptr.Uint(1), + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global maintainer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global maintainer saved", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + scriptID: ptr.Uint(1), + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global observer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "global observer saved", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + scriptID: ptr.Uint(1), + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global observer+", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "global observer+ saved", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + scriptID: ptr.Uint(1), + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + }, + { + name: "global gitops", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + }, + { + name: "global gitops saved", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + scriptID: ptr.Uint(1), + 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 admin, belongs to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + scriptID: ptr.Uint(1), + 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 maintainer, belongs to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + scriptID: ptr.Uint(1), + 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, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + scriptID: ptr.Uint(1), + shouldFailTeamWrite: false, + 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 observer+, belongs to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, + scriptID: ptr.Uint(1), + shouldFailTeamWrite: false, + 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 gitops, belongs to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, + scriptID: ptr.Uint(1), + 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 admin, DOES NOT belong to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + scriptID: ptr.Uint(1), + 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 maintainer, DOES NOT belong to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + scriptID: ptr.Uint(1), + 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, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + scriptID: ptr.Uint(1), + 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 observer+, DOES NOT belong to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, + scriptID: ptr.Uint(1), + 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, + }, + { + name: "team gitops, DOES NOT belong to team, saved", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, + scriptID: ptr.Uint(1), + 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}) + + contents := "abc" + if tt.scriptID != nil { + contents = "" + } + _, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: contents, ScriptID: tt.scriptID}, 0) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + _, err = svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: teamHost.ID, ScriptContents: contents, ScriptID: tt.scriptID}, 0) + checkAuthErr(t, tt.shouldFailTeamWrite, err) + + if tt.scriptID == nil { + // 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) + } + }) + } + }) + + t.Run("script contents validation", func(t *testing.T) { + testCases := []struct { + name string + script string + wantErr string + }{ + {"empty script", "", "Script contents must not be empty."}, + {"overly long script", strings.Repeat("a", 10001), "Script is too large."}, + {"invalid utf8", "\xff\xfa", "Wrong data format."}, + {"valid without hashbang", "echo 'a'", ""}, + {"valid with hashbang", "#!/bin/sh\necho 'a'", ""}, + {"valid with hashbang and spacing", "#! /bin/sh \necho 'a'", ""}, + {"valid with hashbang and Windows newline", "#! /bin/sh \r\necho 'a'", ""}, + {"invalid hashbang", "#!/bin/bash\necho 'a'", "Interpreter not supported."}, + {"invalid hashbang suffix", "#!/bin/sh -n\necho 'a'", "Interpreter not supported."}, + } + + ctx = viewer.NewContext(ctx, viewer.Viewer{User: test.UserAdmin}) + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + _, err := svc.RunHostScript(ctx, &fleet.HostScriptRequestPayload{HostID: noTeamHost.ID, ScriptContents: tt.script}, 0) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } + }) +} + +func TestGetScriptResult(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}) + + const ( + noTeamHostExecID = "no-team-host" + teamHostExecID = "team-host" + nonExistingHostExecID = "non-existing-host" + ) + + 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), SeenTime: time.Now()} + noTeamHost := &fleet.Host{ID: 2, Hostname: "host-no-team", TeamID: nil, SeenTime: time.Now()} + 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.GetHostScriptExecutionResultFunc = func(ctx context.Context, executionID string) (*fleet.HostScriptResult, error) { + switch executionID { + case noTeamHostExecID: + return &fleet.HostScriptResult{HostID: noTeamHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil + case teamHostExecID: + return &fleet.HostScriptResult{HostID: teamHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil + case nonExistingHostExecID: + return &fleet.HostScriptResult{HostID: nonExistingHost.ID, ScriptContents: "abc", ExecutionID: executionID}, nil + default: + return nil, newNotFoundError() + } + } + 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() + } + + testCases := []struct { + name string + user *fleet.User + shouldFailTeamRead bool + shouldFailGlobalRead bool + }{ + { + name: "global admin", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global maintainer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer+", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global gitops", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team admin, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team maintainer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team observer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team observer+, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team gitops, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team admin, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team maintainer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team observer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team observer+, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team gitops, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + _, err := svc.GetScriptResult(ctx, noTeamHostExecID) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, err = svc.GetScriptResult(ctx, teamHostExecID) + checkAuthErr(t, tt.shouldFailTeamRead, err) + + // a non-existing host is authorized as for global write (because we can't know what team it belongs to) + _, err = svc.GetScriptResult(ctx, nonExistingHostExecID) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + }) + } +} + +func TestSavedScripts(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}) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.NewScriptFunc = func(ctx context.Context, script *fleet.Script) (*fleet.Script, error) { + newScript := *script + newScript.ID = 1 + return &newScript, nil + } + const ( + team1ScriptID = 1 + noTeamScriptID = 2 + ) + ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) { + switch id { + case team1ScriptID: + return &fleet.Script{ID: id, TeamID: ptr.Uint(1)}, nil + default: + return &fleet.Script{ID: id}, nil + } + } + ds.GetScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { + return []byte("echo"), nil + } + ds.DeleteScriptFunc = func(ctx context.Context, id uint) error { + return nil + } + ds.ListScriptsFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { + return nil, &fleet.PaginationMetadata{}, nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { + return &fleet.Team{ID: 0}, nil + } + + testCases := []struct { + name string + user *fleet.User + shouldFailTeamWrite bool + shouldFailGlobalWrite bool + shouldFailTeamRead bool + shouldFailGlobalRead bool + }{ + { + name: "global admin", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global maintainer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer+", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global gitops", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: true, + shouldFailGlobalRead: 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, + shouldFailTeamRead: false, + shouldFailGlobalRead: 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, + shouldFailTeamRead: false, + shouldFailGlobalRead: 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, + shouldFailTeamRead: false, + shouldFailGlobalRead: 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, + shouldFailTeamRead: false, + shouldFailGlobalRead: 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, + shouldFailTeamRead: true, + shouldFailGlobalRead: 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, + shouldFailTeamRead: true, + shouldFailGlobalRead: 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, + shouldFailTeamRead: true, + shouldFailGlobalRead: 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, + shouldFailTeamRead: true, + shouldFailGlobalRead: 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, + shouldFailTeamRead: true, + shouldFailGlobalRead: 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, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + _, err := svc.NewScript(ctx, nil, "test.sh", strings.NewReader("echo")) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + err = svc.DeleteScript(ctx, noTeamScriptID) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + _, _, err = svc.ListScripts(ctx, nil, fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, _, err = svc.GetScript(ctx, noTeamScriptID, false) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, _, err = svc.GetScript(ctx, noTeamScriptID, true) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + + _, err = svc.NewScript(ctx, ptr.Uint(1), "test.sh", strings.NewReader("echo")) + checkAuthErr(t, tt.shouldFailTeamWrite, err) + err = svc.DeleteScript(ctx, team1ScriptID) + checkAuthErr(t, tt.shouldFailTeamWrite, err) + _, _, err = svc.ListScripts(ctx, ptr.Uint(1), fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailTeamRead, err) + _, _, err = svc.GetScript(ctx, team1ScriptID, false) + checkAuthErr(t, tt.shouldFailTeamRead, err) + _, _, err = svc.GetScript(ctx, team1ScriptID, true) + checkAuthErr(t, tt.shouldFailTeamRead, err) + }) + } +} + +func TestHostScriptDetails(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}) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + + testCases := []struct { + name string + user *fleet.User + shouldFailTeamRead bool + shouldFailGlobalRead bool + }{ + { + name: "global admin", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global maintainer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer+", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global gitops", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team admin, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team maintainer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team observer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team observer+, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team gitops, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team admin, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team maintainer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team observer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team observer+, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team gitops, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + t.Run("no team host script details", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + require.Equal(t, uint(42), hostID) + return &fleet.Host{ID: hostID}, nil + } + ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { + require.Nil(t, teamID) + return []*fleet.HostScriptDetail{}, nil, nil + } + _, _, err := svc.GetHostScriptDetails(ctx, 42, fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + }) + + t.Run("team host script details", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + require.Equal(t, uint(42), hostID) + return &fleet.Host{ID: hostID, TeamID: ptr.Uint(1)}, nil + } + ds.GetHostScriptDetailsFunc = func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { + require.NotNil(t, teamID) + require.Equal(t, uint(1), *teamID) + return []*fleet.HostScriptDetail{}, nil, nil + } + _, _, err := svc.GetHostScriptDetails(ctx, 42, fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailTeamRead, err) + }) + + t.Run("host not found", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + require.Equal(t, uint(43), hostID) + return nil, ¬FoundError{} + } + _, _, err := svc.GetHostScriptDetails(ctx, 43, fleet.ListOptions{}) + if tt.shouldFailGlobalRead { + checkAuthErr(t, tt.shouldFailGlobalRead, err) + } else { + require.True(t, fleet.IsNotFound(err)) + } + }) + }) + } +} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 3f82a76390..be724b7781 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -25,6 +25,7 @@ import ( "github.com/fleetdm/fleet/v4/server/sso" "github.com/fleetdm/fleet/v4/server/test" "github.com/ghodss/yaml" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -162,6 +163,18 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { // SyncHostsSoftware performs a cleanup. err = ts.ds.SyncHostsSoftware(ctx, time.Now()) require.NoError(t, err) + + // delete orphaned scripts + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM scripts`) + return err + }) + + // delete orphaned host_script_results + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM host_script_results`) + return err + }) } func (ts *withServer) Do(verb, path string, params interface{}, expectedStatusCode int, queryParams ...string) *http.Response { diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 3bb161cd0a..7b90c43c0f 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -17,6 +17,7 @@ import ( eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/license" + "github.com/fleetdm/fleet/v4/server/datastore/cached_mysql" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/logging" "github.com/fleetdm/fleet/v4/server/mail" @@ -288,9 +289,13 @@ type TestServerOpts struct { UseMailService bool APNSTopic string ProfileMatcher fleet.ProfileMatcher + EnableCachedDS bool } func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) { + if len(opts) > 0 && opts[0].EnableCachedDS { + ds = cached_mysql.New(ds) + } var rs fleet.QueryResultStore if len(opts) > 0 && opts[0].Rs != nil { rs = opts[0].Rs diff --git a/terraform/addons/external-vuln-scans/main.tf b/terraform/addons/external-vuln-scans/main.tf index fd6a031016..2f90af7ba6 100644 --- a/terraform/addons/external-vuln-scans/main.tf +++ b/terraform/addons/external-vuln-scans/main.tf @@ -10,7 +10,7 @@ data "aws_iam_policy_document" "assume_role" { principals { type = "Service" - identifiers = ["events.amazonaws.com"] + identifiers = ["events.amazonaws.com", "ecs-tasks.amazonaws.com"] } actions = ["sts:AssumeRole"] @@ -32,6 +32,11 @@ data "aws_iam_policy_document" "ecs_events_run_task_with_any_role" { effect = "Allow" actions = ["ecs:RunTask"] resources = [replace(var.task_definition.arn, "/:\\d+$/", ":*")] + condition { + test = "ArnEquals" + values = [var.ecs_cluster.cluster_arn] + variable = "ecs:cluster" + } } } resource "aws_iam_role_policy" "ecs_events_run_task_with_any_role" { diff --git a/tools/loadtest/fleetd_labels/README.md b/tools/loadtest/fleetd_labels/README.md new file mode 100644 index 0000000000..59fe40a3cf --- /dev/null +++ b/tools/loadtest/fleetd_labels/README.md @@ -0,0 +1,6 @@ +# fleetd_labels + +This tool can be used to set up a fixed set of manual labels to the hosts in a Fleet deployment. +This utility was used for load testing https://github.com/fleetdm/fleet/issues/13287 which required a set of labels applied to hosts. + +The hardcoded numbers defined herein were agreed upon with the target customer. \ No newline at end of file diff --git a/tools/loadtest/fleetd_labels/main.go b/tools/loadtest/fleetd_labels/main.go new file mode 100644 index 0000000000..6f00f767ff --- /dev/null +++ b/tools/loadtest/fleetd_labels/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "flag" + "fmt" + "log" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/service" +) + +func printf(format string, a ...any) { + fmt.Printf(time.Now().UTC().Format("2006-01-02T15:04:05Z")+": "+format, a...) +} + +func batchHostnames(hostnames []string) [][]string { + const batchSize = 500 + batches := make([][]string, 0, (len(hostnames)+batchSize-1)/batchSize) + + for batchSize < len(hostnames) { + hostnames, batches = hostnames[batchSize:], append(batches, hostnames[0:batchSize:batchSize]) + } + batches = append(batches, hostnames) + return batches +} + +func main() { + fleetURL := flag.String("fleet_url", "", "URL (with protocol and port of Fleet server)") + apiToken := flag.String("api_token", "", "API authentication token to use on API calls") + debug := flag.Bool("debug", false, "Debug mode") + + flag.Parse() + + if *fleetURL == "" { + log.Fatal("missing fleet_url argument") + } + if *apiToken == "" { + log.Fatal("missing api_token argument") + } + var clientOpts []service.ClientOption + if *debug { + clientOpts = append(clientOpts, service.EnableClientDebug()) + } + apiClient, err := service.NewClient(*fleetURL, true, "", "", clientOpts...) + if err != nil { + panic(err) + } + apiClient.SetToken(*apiToken) + + printf("Fetching hosts...\n") + records, err := apiClient.GetHostsReport("hostname", "platform") + if err != nil { + panic(err) + } + var ( + macOSHosts []string + windowsHosts []string + linuxHosts []string + ) + for i, record := range records { + if i == 0 { + continue + } + hostname := record[0] + platform := fleet.PlatformFromHost(record[1]) + switch platform { + case "linux": + linuxHosts = append(linuxHosts, hostname) + case "darwin": + macOSHosts = append(macOSHosts, hostname) + case "windows": + windowsHosts = append(windowsHosts, hostname) + } + } + printf("Got linux=%d, windows=%d, macOS=%d\n", len(linuxHosts), len(windowsHosts), len(macOSHosts)) + + printf("Applying manual labels...\n") + for _, labelSpec := range []*fleet.LabelSpec{ + // Applying a static/manual label to only 80% of linux hosts. + { + Name: "Manual Label For Linux Hosts", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: linuxHosts[:int(0.8*float64(len(linuxHosts)))], + }, + // Apply 4 static/manual labels to all macOS hosts. + // This is to add more entries to the `labels` and `label_membership` tables. + { + Name: "Manual Label macOS 1", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: macOSHosts, + }, + { + Name: "Manual Label macOS 2", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: macOSHosts, + }, + { + Name: "Manual Label macOS 3", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: macOSHosts, + }, + { + Name: "Manual Label macOS 4", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: macOSHosts, + }, + // Apply 5 static/manual labels to all Windows hosts. + // This is to add more entries to the `labels` and `label_membership` tables. + { + Name: "Manual Label Windows 1", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: windowsHosts, + }, + { + Name: "Manual Label Windows 2", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: windowsHosts, + }, + { + Name: "Manual Label Windows 3", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: windowsHosts, + }, + { + Name: "Manual Label Windows 4", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: windowsHosts, + }, + { + Name: "Manual Label Windows 5", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: windowsHosts, + }, + } { + for _, batch := range batchHostnames(labelSpec.Hosts) { + labelSpecSubset := *labelSpec + labelSpecSubset.Hosts = batch + printf("Applying label %s to %d hosts...\n", labelSpecSubset.Name, len(labelSpecSubset.Hosts)) + if err := apiClient.ApplyLabels([]*fleet.LabelSpec{&labelSpecSubset}); err != nil { + panic(err) + } + printf("Applied label %s to %d hosts\n", labelSpecSubset.Name, len(labelSpecSubset.Hosts)) + } + printf("Applied %s\n", labelSpec.Name) + } +} diff --git a/website/views/layouts/layout-sandbox.ejs b/website/views/layouts/layout-sandbox.ejs index 005307aec1..881cb42294 100644 --- a/website/views/layouts/layout-sandbox.ejs +++ b/website/views/layouts/layout-sandbox.ejs @@ -133,132 +133,135 @@ <%/* End Google Tag Manager (noscript) */%>
- <%// Page header%> -
- - Fleet logo - - <%/* Mobile Navigation menu */%> -
- -
-
Get started -

Have a large deployment? contact sales.

+

Have a large deployment? Contact sales.