diff --git a/.github/workflows/fleet-and-orbit.yml b/.github/workflows/fleet-and-orbit.yml index 4cab7da482..571d59d067 100644 --- a/.github/workflows/fleet-and-orbit.yml +++ b/.github/workflows/fleet-and-orbit.yml @@ -111,7 +111,7 @@ jobs: done - name: Start Infra Dependencies - run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker-compose up -d mysql redis & + run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker compose up -d mysql redis & - name: Install JS Dependencies run: make deps-js diff --git a/.github/workflows/generate-desktop-targets.yml b/.github/workflows/generate-desktop-targets.yml index d8269e9948..e3835a9ac2 100644 --- a/.github/workflows/generate-desktop-targets.yml +++ b/.github/workflows/generate-desktop-targets.yml @@ -24,7 +24,7 @@ defaults: shell: bash env: - FLEET_DESKTOP_VERSION: 1.29.0 + FLEET_DESKTOP_VERSION: 1.30.0 permissions: contents: read diff --git a/.github/workflows/generate-osqueryd-targets.yml b/.github/workflows/generate-osqueryd-targets.yml index 12e08bddac..b5518c995a 100644 --- a/.github/workflows/generate-osqueryd-targets.yml +++ b/.github/workflows/generate-osqueryd-targets.yml @@ -24,7 +24,7 @@ defaults: shell: bash env: - OSQUERY_VERSION: 5.12.2 + OSQUERY_VERSION: 5.13.0 permissions: contents: read diff --git a/.github/workflows/test-db-changes.yml b/.github/workflows/test-db-changes.yml index 301645008e..ecfe464072 100644 --- a/.github/workflows/test-db-changes.yml +++ b/.github/workflows/test-db-changes.yml @@ -46,7 +46,7 @@ jobs: - name: Start Infra Dependencies # Use & to background this - run: docker-compose up -d mysql_test & + run: docker compose up -d mysql_test & - name: Verify test schema changes run: | diff --git a/.github/workflows/test-go.yaml b/.github/workflows/test-go.yaml index 0f128b8025..5256806b5f 100644 --- a/.github/workflows/test-go.yaml +++ b/.github/workflows/test-go.yaml @@ -70,7 +70,7 @@ jobs: # Pre-starting dependencies here means they are ready to go when we need them. - name: Start Infra Dependencies # Use & to background this - run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker-compose -f docker-compose.yml -f docker-compose-redis-cluster.yml up -d mysql_test mysql_replica_test redis redis-cluster-1 redis-cluster-2 redis-cluster-3 redis-cluster-4 redis-cluster-5 redis-cluster-6 redis-cluster-setup minio saml_idp mailhog mailpit smtp4dev_test & + run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker compose -f docker-compose.yml -f docker-compose-redis-cluster.yml up -d mysql_test mysql_replica_test redis redis-cluster-1 redis-cluster-2 redis-cluster-3 redis-cluster-4 redis-cluster-5 redis-cluster-6 redis-cluster-setup minio saml_idp mailhog mailpit smtp4dev_test & - name: Add TLS certificate for SMTP Tests run: | @@ -98,13 +98,13 @@ jobs: - name: Wait for mysql run: | echo "waiting for mysql..." - until docker-compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do + until docker compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do echo "." sleep 1 done echo "mysql is ready" echo "waiting for mysql replica..." - until docker-compose exec -T mysql_replica_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do + until docker compose exec -T mysql_replica_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do echo "." sleep 1 done diff --git a/Makefile b/Makefile index 8f7cf455d5..c7eaac214c 100644 --- a/Makefile +++ b/Makefile @@ -281,7 +281,7 @@ binary-arch: .pre-binary-arch .pre-binary-bundle .pre-fleet # Drop, create, and migrate the e2e test database e2e-reset-db: - docker-compose exec -T mysql_test bash -c 'echo "drop database if exists e2e; create database e2e;" | MYSQL_PWD=toor mysql -uroot' + docker compose exec -T mysql_test bash -c 'echo "drop database if exists e2e; create database e2e;" | MYSQL_PWD=toor mysql -uroot' ./build/fleet prepare db --mysql_address=localhost:3307 --mysql_username=root --mysql_password=toor --mysql_database=e2e e2e-setup: @@ -312,7 +312,7 @@ e2e-serve-premium: e2e-reset-db # Usage: # make e2e-set-desktop-token host_id=1 token=foo e2e-set-desktop-token: - docker-compose exec -T mysql_test bash -c 'echo "INSERT INTO e2e.host_device_auth (host_id, token) VALUES ($(host_id), \"$(token)\") ON DUPLICATE KEY UPDATE token=VALUES(token)" | MYSQL_PWD=toor mysql -uroot' + docker compose exec -T mysql_test bash -c 'echo "INSERT INTO e2e.host_device_auth (host_id, token) VALUES ($(host_id), \"$(token)\") ON DUPLICATE KEY UPDATE token=VALUES(token)" | MYSQL_PWD=toor mysql -uroot' changelog: sh -c "find changes -type f | grep -v .keep | xargs -I {} sh -c 'grep \"\S\" {}; echo' > new-CHANGELOG.md" @@ -347,7 +347,7 @@ fleetd-tuf: # Reset the development DB db-reset: - docker-compose exec -T mysql bash -c 'echo "drop database if exists fleet; create database fleet;" | MYSQL_PWD=toor mysql -uroot' + docker compose exec -T mysql bash -c 'echo "drop database if exists fleet; create database fleet;" | MYSQL_PWD=toor mysql -uroot' ./build/fleet prepare db --dev # Back up the development DB to file @@ -424,6 +424,14 @@ endif tar czf $(out-path)/swiftDialog.app.tar.gz -C $(TMP_DIR)/swiftDialog_pkg_payload_expanded/Library/Application\ Support/Dialog/ Dialog.app rm -rf $(TMP_DIR) +# Generate escrowBuddy.pkg bundle from the Escrow Buddy repo. +# +# Usage: +# make escrow-buddy-pkg version=1.0.0 out-path=. +escrow-buddy-pkg: + curl -L https://github.com/macadmins/escrow-buddy/releases/download/v$(version)/Escrow.Buddy-$(version).pkg --output $(out-path)/escrowBuddy.pkg + + # Build and generate desktop.app.tar.gz bundle. # # Usage: diff --git a/README.md b/README.md index 7e8651a618..3c23f067c3 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,6 @@ Fleet has no ambition to replace all of your other tools. (Though it might repl Fleet plays well with Munki, Chef, Puppet, and Ansible, as well as with security tools like Crowdstrike and SentinelOne. For example, you can use the free version of Fleet to quickly report on what hosts are _actually_ running your EDR agent. -While most folks prefer to use one or the other, Fleet can also coexist peacefully with Rapid7 and other agent-based vulnerability scanners. This can be useful during migrations. - #### Free as in free The free version of Fleet will [always be free](https://fleetdm.com/pricing). Fleet is [independently backed](https://linkedin.com/company/fleetdm) and actively maintained with the help of many amazing [contributors](https://github.com/fleetdm/fleet/graphs/contributors). diff --git a/articles/deploy-fleet-on-aws-ecs.md b/articles/deploy-fleet-on-aws-ecs.md index ac9428865b..38b802fb9b 100644 --- a/articles/deploy-fleet-on-aws-ecs.md +++ b/articles/deploy-fleet-on-aws-ecs.md @@ -1,6 +1,6 @@ # Deploy Fleet on AWS ECS -> **This article was archived on May 16, 2024,** and may be outdated. Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for our recommended deployment method. +> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. ![Deploy Fleet on AWS ECS](../website/assets/images/articles/deploy-fleet-on-aws-ecs-800x450@2x.png) diff --git a/articles/deploy-fleet-on-aws-with-terraform.md b/articles/deploy-fleet-on-aws-with-terraform.md index 6853032a50..38f8de9b95 100644 --- a/articles/deploy-fleet-on-aws-with-terraform.md +++ b/articles/deploy-fleet-on-aws-with-terraform.md @@ -1,6 +1,6 @@ # Deploy Fleet on AWS with Terraform -> **This article was archived on May 16, 2024,** and may be outdated. Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for our recommended deployment method. +> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. ![Deploy Fleet on AWS ECS](../website/assets/images/articles/deploy-fleet-on-aws-with-terraform-800x450@2x.png) diff --git a/articles/deploy-fleet-on-centos.md b/articles/deploy-fleet-on-centos.md index b832e0f4f1..f9439d5773 100644 --- a/articles/deploy-fleet-on-centos.md +++ b/articles/deploy-fleet-on-centos.md @@ -1,6 +1,6 @@ # Deploy Fleet on CentOS -> **This article was archived on May 16, 2024,** and may be outdated. Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for our recommended deployment method. +> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. ![Deploy Fleet on CentOS](../website/assets/images/articles/deploy-fleet-on-centos-800x450@2x.png) diff --git a/articles/deploy-fleet-on-cloudgov.md b/articles/deploy-fleet-on-cloudgov.md index 5e5e2b1644..c075ab6c9b 100644 --- a/articles/deploy-fleet-on-cloudgov.md +++ b/articles/deploy-fleet-on-cloudgov.md @@ -1,6 +1,6 @@ # Deploy Fleet on Cloud.gov (Cloud Foundry) -> **This article was archived on May 16, 2024,** and may be outdated. Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for our recommended deployment method. +> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. ![Deploy Fleet on Cloud.gov](../website/assets/images/articles/deploy-fleet-on-cloudgov-800x450@2x.png) diff --git a/articles/deploy-fleet-on-hetzner-cloud.md b/articles/deploy-fleet-on-hetzner-cloud.md index a7cd76f1c4..ec6c7da9a9 100644 --- a/articles/deploy-fleet-on-hetzner-cloud.md +++ b/articles/deploy-fleet-on-hetzner-cloud.md @@ -1,6 +1,6 @@ # Deploy Fleet on Hetzner Cloud -> **This article was archived on May 16, 2024,** and may be outdated. Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for our recommended deployment method. +> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. ![Deploy Fleet on Hetzner Cloud](../website/assets/images/articles/deploy-fleet-on-hetzner-cloud-800x450@2x.png) diff --git a/articles/deploy-fleet-on-kubernetes.md b/articles/deploy-fleet-on-kubernetes.md index ea85bbb07d..5e5144efc5 100644 --- a/articles/deploy-fleet-on-kubernetes.md +++ b/articles/deploy-fleet-on-kubernetes.md @@ -1,6 +1,6 @@ # Deploy Fleet on Kubernetes -> **This article was archived on May 16, 2024,** and may be outdated. Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for our recommended deployment method. +> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. ![Deploy Fleet on Kubernetes](../website/assets/images/articles/deploy-fleet-on-kubernetes-800x450@2x.png) diff --git a/articles/deploy-fleet-on-render.md b/articles/deploy-fleet-on-render.md index 1b5ba740b9..703d872cf6 100644 --- a/articles/deploy-fleet-on-render.md +++ b/articles/deploy-fleet-on-render.md @@ -1,6 +1,6 @@ # Deploy Fleet on Render -> **This article was archived on May 16, 2024,** and may be outdated. Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for our recommended deployment method. +> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. ![Deploy Fleet on Render](../website/assets/images/articles/deploy-fleet-on-render-800x450@2x.png) diff --git a/articles/fleet-in-your-calendar-introducing-maintenance-windows.md b/articles/fleet-in-your-calendar-introducing-maintenance-windows.md index 5f23f9bbce..534b993835 100644 --- a/articles/fleet-in-your-calendar-introducing-maintenance-windows.md +++ b/articles/fleet-in-your-calendar-introducing-maintenance-windows.md @@ -1,5 +1,7 @@ # Fleet in your calendar: introducing maintenance windows +> Unlike other Fleet features which take advantage of declarative device management (DDM), the approach described in this article still uses traditional MDM commands. (More to come.) + ![Fleet in your calendar: introducing maintenance windows](../website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-cover-900x450@2x.png) Fleet is excited to announce the release of "maintenance windows", a new feature in Fleet v4.48 that helps make sure OS updates occur during times that disrupt your users the least. Now, just like any good colleague, when Fleet needs some of your time, it puts it on your calendar. This approach avoids interrupting your key activities or important meetings, whether in the office, on the road, or working remotely. diff --git a/articles/osquery-evented-tables-overview.md b/articles/osquery-evented-tables-overview.md index 883f0bc8ab..f1316a85f1 100644 --- a/articles/osquery-evented-tables-overview.md +++ b/articles/osquery-evented-tables-overview.md @@ -121,7 +121,7 @@ On macOS, there are two utilities that enable osquery process auditing: [OpenBSM To use the `es_process_events` tables, use the flag `--disable_endpointsecurity=false`. See the [EndpointSecurity instructions](https://osquery.readthedocs.io/en/latest/deployment/process-auditing/#auditing-processes-with-endpointsecurity) for more information. To use `process_events` and `socket_events` with OpenBSM, see the [OpenBSM instructions](https://osquery.readthedocs.io/en/latest/deployment/process-auditing/#auditing-processes-with-openbsm). #### Windows -Currently, osquery does not support process auditing for Windows. To learn more about process auditing on Windows, visit [Microsoft's security auditing overview](https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/security-auditing-overview). Fleet is tracking work to build process auditing for Windows in osquery. [Stay up to date on GitHub](https://github.com/fleetdm/fleet/issues/7732). +Fleet supports auditing process events on Windows via the `process_etw_events` table. To learn more about process auditing on Windows, visit [Microsoft's security auditing overview](https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/security-auditing-overview). Fleet is tracking work to add file auditing for Windows in osquery. [Stay up to date on GitHub](https://github.com/fleetdm/fleet/issues/20946). ### YARA scanning [YARA](https://virustotal.github.io/yara/) is a malware research and detection tool available on Linux and macOS that allows users to create descriptions of malware families based on patterns of text or binary code. Each potential piece of malware is matched against a YARA rule and triggers if the specified conditions are met. diff --git a/changes/13157-fv-escrow b/changes/13157-fv-escrow new file mode 100644 index 0000000000..e6804a05ec --- /dev/null +++ b/changes/13157-fv-escrow @@ -0,0 +1 @@ +* `fleetd` now uses Escrow Buddy to rotate FileVault keys. Internal API endpoints documented in the API for contributors have been modified and/or removed. diff --git a/changes/19674-dep-min-os-version b/changes/19674-dep-min-os-version deleted file mode 100644 index b9adefe9ec..0000000000 --- a/changes/19674-dep-min-os-version +++ /dev/null @@ -1 +0,0 @@ -- Updated MDM features to enforce minimum OS version settings during Apple Automated Device Enrollment (ADE). diff --git a/changes/20194-sort-label-names-in-ui b/changes/20194-sort-label-names-in-ui new file mode 100644 index 0000000000..2f27f77f0b --- /dev/null +++ b/changes/20194-sort-label-names-in-ui @@ -0,0 +1 @@ +- display the label names case-insensitive alphabetical order in the fleet UI diff --git a/changes/20271-deleted-host-software-installs b/changes/20271-deleted-host-software-installs new file mode 100644 index 0000000000..674b8a823f --- /dev/null +++ b/changes/20271-deleted-host-software-installs @@ -0,0 +1 @@ +- Fig bug where software install results could not be retrieved for deleted hosts in the activity feed diff --git a/changes/20395-DE-table-style-fix b/changes/20395-DE-table-style-fix new file mode 100644 index 0000000000..8907c36986 --- /dev/null +++ b/changes/20395-DE-table-style-fix @@ -0,0 +1 @@ +* Fix a styling issue in the Controls > OS Settings > disk encryption table \ No newline at end of file diff --git a/changes/20440-Notion-exe-installer-name b/changes/20440-Notion-exe-installer-name new file mode 100644 index 0000000000..bc3996cc5d --- /dev/null +++ b/changes/20440-Notion-exe-installer-name @@ -0,0 +1 @@ +* Added a special-case to properly name the Notion .exe Windows installer the same as how it will be reported by osquery post-install. diff --git a/changes/20604-hosts-page-pagination b/changes/20604-hosts-page-pagination new file mode 100644 index 0000000000..c1f68d5f94 --- /dev/null +++ b/changes/20604-hosts-page-pagination @@ -0,0 +1 @@ +* Fix a bug where hosts page would sometimes allow excess pagination \ No newline at end of file diff --git a/changes/20747-gitops-software-query b/changes/20747-gitops-software-query new file mode 100644 index 0000000000..100efc17f3 --- /dev/null +++ b/changes/20747-gitops-software-query @@ -0,0 +1 @@ +- Use new gitops format for software pre install query diff --git a/changes/20751-detect-held-linux-packages-as-installed b/changes/20751-detect-held-linux-packages-as-installed new file mode 100644 index 0000000000..6aa524ce80 --- /dev/null +++ b/changes/20751-detect-held-linux-packages-as-installed @@ -0,0 +1 @@ +Linux .deb packages 'on hold' are now included in the installed software list. diff --git a/changes/20882-ui-update-turn-on-mdm-banner b/changes/20882-ui-update-turn-on-mdm-banner new file mode 100644 index 0000000000..eca36625ce --- /dev/null +++ b/changes/20882-ui-update-turn-on-mdm-banner @@ -0,0 +1 @@ +- Updated text for "Turn on MDM" banners in UI. \ No newline at end of file diff --git a/changes/20933-disable-overlay-other-workflows-modal b/changes/20933-disable-overlay-other-workflows-modal new file mode 100644 index 0000000000..e0386552c5 --- /dev/null +++ b/changes/20933-disable-overlay-other-workflows-modal @@ -0,0 +1 @@ +- add a disabled overlay to the Other Workflows modal on the policy page. diff --git a/changes/21006-fleetctl-preview b/changes/21006-fleetctl-preview new file mode 100644 index 0000000000..9fe2fd3286 --- /dev/null +++ b/changes/21006-fleetctl-preview @@ -0,0 +1 @@ +* Fixed a bug in `fleetctl preview` that was causing it to fail if Docker was installed without support for the deprecated `docker-compose` CLI diff --git a/changes/fix-software-array-migration b/changes/fix-software-array-migration new file mode 100644 index 0000000000..27536ccc1e --- /dev/null +++ b/changes/fix-software-array-migration @@ -0,0 +1,2 @@ +- Adds a migration to migrate older team configurations to the new version that includes both + installers and App Store apps. \ No newline at end of file diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go index 751c74709f..a84ade90f0 100644 --- a/cmd/fleetctl/apply.go +++ b/cmd/fleetctl/apply.go @@ -90,7 +90,7 @@ func applyCommand() *cli.Command { opts.TeamForPolicies = policiesTeamName } baseDir := filepath.Dir(flFilename) - _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts) + _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts) if err != nil { return err } diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go index c56016eca3..6f13fd29e9 100644 --- a/cmd/fleetctl/gitops.go +++ b/cmd/fleetctl/gitops.go @@ -90,7 +90,7 @@ func gitopsCommand() *cli.Command { secrets := make(map[string]struct{}) for _, flFilename := range flFilenames.Value() { baseDir := filepath.Dir(flFilename) - config, err := spec.GitOpsFromFile(flFilename, baseDir) + config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig) if err != nil { return err } diff --git a/cmd/fleetctl/gitops_enterprise_integration_test.go b/cmd/fleetctl/gitops_enterprise_integration_test.go index ab3f8bf835..7089ef6893 100644 --- a/cmd/fleetctl/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/gitops_enterprise_integration_test.go @@ -176,6 +176,7 @@ contexts: fmt.Sprintf( ` controls: +software: queries: policies: agent_options: @@ -230,5 +231,4 @@ team_settings: for _, fileName := range teamFileNames { _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName}) } - } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 5f504a8106..22bd28845a 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -36,7 +36,7 @@ const ( orgName = "GitOps Test" ) -func TestFilenameValidation(t *testing.T) { +func TestFilenameGitOpsValidation(t *testing.T) { filename := strings.Repeat("a", filenameMaxLength+1) _, err := runAppNoChecks([]string{"gitops", "-f", filename}) assert.ErrorContains(t, err, "file name must be less than") @@ -207,6 +207,9 @@ func TestBasicGlobalPremiumGitOps(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return &fleet.Job{}, nil } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -238,6 +241,7 @@ org_settings: org_logo_url_light_background: "" org_name: ${ORG_NAME} secrets: +software: `, ) require.NoError(t, err) @@ -381,6 +385,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: ${TEST_SECRET} +software: `, ) require.NoError(t, err) @@ -538,6 +543,7 @@ func TestFullGlobalGitOps(t *testing.T) { t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName) + t.Setenv("SOFTWARE_INSTALLER_URL", fleetServerURL) file := "./testdata/gitops/global_config_no_paths.yml" // Dry run should fail because Apple BM Default Team does not exist and premium license is not set @@ -834,6 +840,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"}] +software: `, ) require.NoError(t, err) @@ -1011,6 +1018,7 @@ org_settings: org_logo_url_light_background: "" org_name: ${ORG_NAME} secrets: [{"secret":"globalSecret"}] +software: `, ) require.NoError(t, err) @@ -1030,6 +1038,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"}] +software: `, ) require.NoError(t, err) @@ -1045,6 +1054,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"},{"secret":"globalSecret"}] +software: `, ) require.NoError(t, err) @@ -1215,24 +1225,69 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."}, {"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 500 MB"}, {"testdata/gitops/team_software_installer_valid.yml", ""}, + {"testdata/gitops/team_software_installer_valid_apply.yml", ""}, {"testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, + {"testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.packages of type bool"}, + {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { - ds, _, _ := setupFullGitOpsPremiumServer(t) + setupFullGitOpsPremiumServer(t) - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { - return nil - } - ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { - return nil + _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) + if c.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, c.wantErr) } + }) + } +} +func TestTeamSoftwareInstallersGitopsQueryEnv(t *testing.T) { + startSoftwareInstallerServer(t) + ds, _, _ := setupFullGitOpsPremiumServer(t) + + t.Setenv("QUERY_VAR", "IT_WORKS") + + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + if installers[0].PreInstallQuery != "select IT_WORKS" { + return fmt.Errorf("Missing env var, got %s", installers[0].PreInstallQuery) + } + return nil + } + + _, err := runAppNoChecks([]string{"gitops", "-f", "testdata/gitops/team_software_installer_valid_env_query.yml"}) + require.NoError(t, err) +} + +func TestNoTeamSoftwareInstallersGitOps(t *testing.T) { + startSoftwareInstallerServer(t) + + cases := []struct { + file string + wantErr string + }{ + {"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."}, + {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."}, + {"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 500 MB"}, + {"testdata/gitops/no_team_software_installer_valid.yml", ""}, + {"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, + {"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"}, + {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"}, + } + for _, c := range cases { + t.Run(filepath.Base(c.file), func(t *testing.T) { + setupFullGitOpsPremiumServer(t) + + t.Setenv("APPLE_BM_DEFAULT_TEAM", "") _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go index a48734871c..e11dc8bf9e 100644 --- a/cmd/fleetctl/preview.go +++ b/cmd/fleetctl/preview.go @@ -74,7 +74,7 @@ func (d dockerCompose) Command(arg ...string) *exec.Cmd { func newDockerCompose() (dockerCompose, error) { // first, check if `docker compose` is available - if err := exec.Command("docker compose").Run(); err == nil { + if err := exec.Command("docker", "compose").Run(); err == nil { return dockerCompose{dockerComposeV2}, nil } @@ -387,7 +387,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st } // this only applies standard queries, the base directory is not used, // so pass in the current working directory. - _, err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplyClientSpecOptions{}) + _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}) if err != nil { return err } diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index 76936e3ad5..7d3dfe40f2 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -187,3 +187,4 @@ org_settings: secrets: # These secrets are used to enroll hosts to the "All teams" team - secret: SampleSecret123 - secret: ABC +software: diff --git a/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml b/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml index 177c1c80cf..b098442585 100644 --- a/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml +++ b/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml @@ -91,3 +91,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml b/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml index da75847cd5..e6231bf030 100644 --- a/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml +++ b/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml @@ -97,3 +97,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: diff --git a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml index 9d2ac6e69f..1a100de6f3 100644 --- a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml +++ b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml @@ -93,3 +93,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml index ba1d06f784..5208ba7248 100644 --- a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml +++ b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml @@ -91,3 +91,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/lib/query_multiple.yml b/cmd/fleetctl/testdata/gitops/lib/query_multiple.yml index c3109b5f71..f14ea9ea0e 100644 --- a/cmd/fleetctl/testdata/gitops/lib/query_multiple.yml +++ b/cmd/fleetctl/testdata/gitops/lib/query_multiple.yml @@ -1,11 +1,4 @@ -apiVersion: v1 -kind: query -spec: - name: query_ruby +- name: query_ruby query: select 1 ---- -apiVersion: v1 -kind: query -spec: - name: query_ruby2 +- name: query_ruby2 query: select 2 diff --git a/cmd/fleetctl/testdata/gitops/lib/query_multiple_apply.yml b/cmd/fleetctl/testdata/gitops/lib/query_multiple_apply.yml new file mode 100644 index 0000000000..c3109b5f71 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/query_multiple_apply.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: query +spec: + name: query_ruby + query: select 1 +--- +apiVersion: v1 +kind: query +spec: + name: query_ruby2 + query: select 2 diff --git a/cmd/fleetctl/testdata/gitops/lib/query_ruby.yml b/cmd/fleetctl/testdata/gitops/lib/query_ruby.yml index 28714447bf..bb61c6b32a 100644 --- a/cmd/fleetctl/testdata/gitops/lib/query_ruby.yml +++ b/cmd/fleetctl/testdata/gitops/lib/query_ruby.yml @@ -1,5 +1,2 @@ -apiVersion: v1 -kind: query -spec: - name: query_ruby +- name: query_ruby query: select 1 diff --git a/cmd/fleetctl/testdata/gitops/lib/query_ruby_apply.yml b/cmd/fleetctl/testdata/gitops/lib/query_ruby_apply.yml new file mode 100644 index 0000000000..28714447bf --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/query_ruby_apply.yml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: query +spec: + name: query_ruby + query: select 1 diff --git a/cmd/fleetctl/testdata/gitops/lib/query_ruby_env.yml b/cmd/fleetctl/testdata/gitops/lib/query_ruby_env.yml new file mode 100644 index 0000000000..c9b15ff229 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/query_ruby_env.yml @@ -0,0 +1,2 @@ +- name: query_ruby + query: select ${QUERY_VAR} diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml new file mode 100644 index 0000000000..d3bcada54e --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml @@ -0,0 +1,19 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/notfound.sh \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml new file mode 100644 index 0000000000..acee06d683 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml @@ -0,0 +1,18 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt + self_service: "not a boolean" \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml new file mode 100644 index 0000000000..6d83a9daed --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml @@ -0,0 +1,22 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml new file mode 100644 index 0000000000..cd7332f91e --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml @@ -0,0 +1,17 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml new file mode 100644 index 0000000000..ac0a436360 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml @@ -0,0 +1,21 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + post_install_script: + path: lib/notfound.sh \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml new file mode 100644 index 0000000000..a2b5419c05 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml @@ -0,0 +1,23 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_multiple.yml + post_install_script: + path: lib/post_install_ruby.sh \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml new file mode 100644 index 0000000000..bafde42691 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml @@ -0,0 +1,21 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/notfound.yml \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml new file mode 100644 index 0000000000..db4ffd3211 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml @@ -0,0 +1,17 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml new file mode 100644 index 0000000000..2bc609b931 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml @@ -0,0 +1,17 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml new file mode 100644 index 0000000000..e0fcaa490e --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml @@ -0,0 +1,25 @@ +# Test config +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml new file mode 100644 index 0000000000..7c88f9963d --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml @@ -0,0 +1,23 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_multiple_apply.yml + post_install_script: + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid_apply.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_apply.yml new file mode 100644 index 0000000000..9fd14c4922 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_apply.yml @@ -0,0 +1,25 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby_apply.yml + post_install_script: + path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid_env_query.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_env_query.yml new file mode 100644 index 0000000000..1c60d3f469 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_env_query.yml @@ -0,0 +1,25 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby_env.yml + post_install_script: + path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 8e42d4480b..4699b76d3d 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -2481,6 +2481,7 @@ Gets all information required by Fleet Desktop, this includes things like the nu ```json { "failing_policies_count": 3, + "self_service": true, "notifications": { "needs_mdm_migration": true, "renew_enrollment_profile": false, @@ -2755,27 +2756,6 @@ Signals the Fleet server to send a webbook request with the device UUID and seri --- -#### Trigger FileVault key escrow - -Sends a signal to Fleet Desktop to initiate a FileVault key escrow. This is useful for setting the escrow key initially as well as in scenarios where a token rotation is required. **Requires Fleet Premium license** - -`POST /api/v1/fleet/device/{token}/rotate_encryption_key` - -##### Parameters - -| Name | Type | In | Description | -| ----- | ------ | ---- | ---------------------------------- | -| token | string | path | The device's authentication token. | - -##### Example - -`POST /api/v1/fleet/device/abcdef012456789/rotate_encryption_key` - -##### Default response - -`Status: 204` - - ### Report an agent error Notifies the server about an agent error, resulting in two outcomes: @@ -2977,8 +2957,8 @@ _Available in Fleet Premium._ | Name | Type | In | Description | | --------- | ------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| team_id | number | query | The ID of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`. | -| team_name | string | query | The name of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`. | +| team_id | number | query | The ID of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`. Ommitting these parameters will add software to 'No Team'. | +| team_name | string | query | The name of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`. Ommitting these parameters will add software to 'No Team'. | | dry_run | bool | query | If `true`, will validate the provided software packages and return any validation errors, but will not apply the changes. | | software | list | body | An array of software objects. Each object consists of:`url`- URL to the software package (PKG, MSI, EXE or DEB),`install_script` - command that Fleet runs to install software, `pre_install_query` - condition query that determines if the install will proceed, and `post_install_script` - script that runs after software install. | diff --git a/docs/Deploy/Reference-Architectures.md b/docs/Deploy/Reference-Architectures.md index 06a7d8dd5a..630b8561b5 100644 --- a/docs/Deploy/Reference-Architectures.md +++ b/docs/Deploy/Reference-Architectures.md @@ -150,7 +150,7 @@ In some cases adding a read replica can increase database performance for specif #### Traffic load balancing Load balancing enables distributing request traffic over many instances of the backend application. Using AWS Application -Load Balancer can also [offload SSL termination](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html), freeing Fleet to spend the majority of it's allocated compute dedicated +Load Balancer can also [offload SSL termination](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html), freeing Fleet to spend the majority of its allocated compute dedicated to its core functionality. More details about ALB can be found [here](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). _**Note if using [terraform reference architecture](https://github.com/fleetdm/fleet/tree/main/infrastructure/dogfood/terraform/aws#terraform) all configurations can dynamically scale based on load(cpu/memory) and all configurations diff --git a/docs/Get started/tutorials-and-guides.md b/docs/Get started/tutorials-and-guides.md index 0b1584f6a3..612f7c1b23 100644 --- a/docs/Get started/tutorials-and-guides.md +++ b/docs/Get started/tutorials-and-guides.md @@ -67,5 +67,7 @@ A collection of guides to help you get up and running with Fleet. - [Generate process trees with osquery](https://fleetdm.com/guides/generate-process-trees-with-osquery) +See all guides + diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 4e711ccb86..a447e15a12 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -3102,18 +3102,17 @@ Returns the information of the specified host. ### Get host by identifier -Returns the information of the host specified using the `uuid`, `hardware_serial`, `osquery_host_id`, `hostname`, or -`node_key` as an identifier. +Returns the information of the host specified using the `hostname`, `uuid`, or `hardware_serial` as an identifier. -If `hostname` is specified when there is more than one host with the same hostname, the endpoint returns the first matching host. In Fleet, hostnames are fully qualified domain names (FQDNs). +If `hostname` is specified when there is more than one host with the same hostname, the endpoint returns the first matching host. In Fleet, hostnames are fully qualified domain names (FQDNs). `hostname` (e.g. johns-macbook-air.local) is not the same as `display_name` (e.g. John's MacBook Air). `GET /api/v1/fleet/hosts/identifier/:identifier` #### Parameters -| Name | Type | In | Description | -| ---------- | ----------------- | ---- | ----------------------------------------------------------------------------- | -| identifier | integer or string | path | **Required**. The host's `hardware_serial`, `uuid`, `osquery_host_id`, `hostname`, or `node_key` | +| Name | Type | In | Description | +| ---------- | ----------------- | ---- | ------------------------------------------------------------------ | +| identifier | string | path | **Required**. The host's `hostname`, `uuid`, or `hardware_serial`. | | exclude_software | boolean | query | If `true`, the response will not include a list of installed software for the host. | #### Example @@ -3505,6 +3504,7 @@ This is the API route used by the **My device** page in Fleet desktop to display ] } }, + "self_service": true, "org_logo_url": "https://example.com/logo.jpg", "license": { "tier": "free", diff --git a/docs/Using Fleet/Supported-host-operating-systems.md b/docs/Using Fleet/Supported-host-operating-systems.md index c7adedff89..ff8cd5480c 100644 --- a/docs/Using Fleet/Supported-host-operating-systems.md +++ b/docs/Using Fleet/Supported-host-operating-systems.md @@ -28,7 +28,7 @@ Fleet's agent (fleetd) generated for MacOS by `fleetctl package` does not includ > Ubuntu 24.04 comes with Wayland enabled by default. To use X11 instead of Wayland you can set > `WaylandEnable=false` in `/etc/gdm3/custom.conf` and reboot. -> Fedora, CentOS 8 and 9 require a [gnome extension](https://extensions.gnome.org/extension/615/appindicator-support/) and Google Chrome set to the default browser for Fleet Desktop. +> Fedora, CentOS 8 and 9 require a [gnome extension](https://extensions.gnome.org/extension/615/appindicator-support/) and Google Chrome for Fleet Desktop. > The `fleetctl package` command is not supported on DISA-STIG distribution. diff --git a/docs/Using Fleet/Understanding-host-vitals.md b/docs/Using Fleet/Understanding-host-vitals.md index 77b9e68ca9..3cdabf2fde 100644 --- a/docs/Using Fleet/Understanding-host-vitals.md +++ b/docs/Using Fleet/Understanding-host-vitals.md @@ -504,7 +504,7 @@ SELECT '' AS arch, '' AS installed_path FROM deb_packages -WHERE status = 'install ok installed' +WHERE status LIKE '% ok installed' UNION SELECT package AS name, diff --git a/docs/Using Fleet/enroll-hosts.md b/docs/Using Fleet/enroll-hosts.md index 952383ec57..6112eec51e 100644 --- a/docs/Using Fleet/enroll-hosts.md +++ b/docs/Using Fleet/enroll-hosts.md @@ -12,7 +12,7 @@ Fleet supports the [latest version of osquery](https://github.com/osquery/osquer ## CLI -> You must have `fleetctl` installed. [Learn how to install `fleetctl`](https://fleetdm.com/fleetctl-preview). +> You must have `fleetctl` installed. [Learn how to install `fleetctl`](https://fleetdm.com/docs/using-fleet/fleetctl-cli#installing-fleetctl). The `fleetctl package` command is used to generate Fleet's agent (fleetd). diff --git a/ee/fleetctl/updates.go b/ee/fleetctl/updates.go index 8ef04ef1f6..2e55081fa4 100644 --- a/ee/fleetctl/updates.go +++ b/ee/fleetctl/updates.go @@ -270,6 +270,8 @@ func updatesAddFunc(c *cli.Context) error { dstPath += ".exe" case strings.HasSuffix(target, ".app.tar.gz"): dstPath += ".app.tar.gz" + case strings.HasSuffix(target, ".pkg"): + dstPath += ".pkg" // osquery extensions require the .ext suffix case strings.HasSuffix(target, ".ext"): dstPath += ".ext" diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go index 713ea9af52..39c31d4807 100644 --- a/ee/server/calendar/google_calendar.go +++ b/ee/server/calendar/google_calendar.go @@ -144,6 +144,9 @@ func adjustEmail(email string) string { func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) { result, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Settings == nil { + return nil, errors.New("calendar service or settings not initialized") + } return lowLevelAPI.service.Settings.Get(name).Do() }, ) @@ -153,6 +156,9 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar func (lowLevelAPI *GoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) { result, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Events == nil { + return nil, errors.New("calendar service or events not initialized (CreateEvent)") + } return lowLevelAPI.service.Events.Insert(calendarID, event).Do() }, ) @@ -162,6 +168,9 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) func (lowLevelAPI *GoogleCalendarLowLevelAPI) UpdateEvent(event *calendar.Event) (*calendar.Event, error) { result, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Events == nil { + return nil, errors.New("calendar service or events not initialized (UpdateEvent)") + } return lowLevelAPI.service.Events.Update(calendarID, event.Id, event).Do() }, ) @@ -171,6 +180,9 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) UpdateEvent(event *calendar.Event) func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) { result, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Events == nil { + return nil, errors.New("calendar service or events not initialized (GetEvent)") + } return lowLevelAPI.service.Events.Get(calendarID, id).IfNoneMatch(eTag).Do() }, ) @@ -180,6 +192,9 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calend func (lowLevelAPI *GoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) { result, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Events == nil { + return nil, errors.New("calendar service or events not initialized (ListEvents)") + } // Default maximum number of events returned is 250, which should be sufficient for most calendars. return lowLevelAPI.service.Events.List(calendarID). EventTypes("default"). @@ -197,6 +212,9 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error { _, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Events == nil { + return nil, errors.New("calendar service or events not initialized (DeleteEvent)") + } return nil, lowLevelAPI.service.Events.Delete(calendarID, id).Do() }, ) @@ -206,6 +224,9 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error { func (lowLevelAPI *GoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { resp, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Events == nil { + return nil, errors.New("calendar service or events not initialized (Watch)") + } return lowLevelAPI.service.Events.Watch(calendarID, &calendar.Channel{ Id: channelID, // channelID is also used for authentication -- it should be a random value Type: "web_hook", @@ -226,6 +247,9 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID func (lowLevelAPI *GoogleCalendarLowLevelAPI) Stop(channelID string, resourceID string) error { _, err := lowLevelAPI.withRetry( func() (any, error) { + if lowLevelAPI.service == nil || lowLevelAPI.service.Channels == nil { + return nil, errors.New("calendar service or channels not initialized (Stop)") + } return nil, lowLevelAPI.service.Channels.Stop(&calendar.Channel{ Id: channelID, ResourceId: resourceID, @@ -274,30 +298,30 @@ func (c *GoogleCalendar) Configure(userEmail string) error { } func (c *GoogleCalendar) UpdateEventBody(event *fleet.CalendarEvent, - genBodyFn fleet.CalendarGenBodyFn) error { + genBodyFn fleet.CalendarGenBodyFn) (string, error) { details, err := c.unmarshalDetails(event) if err != nil { - return err + return "", err } gEvent, err := c.config.API.GetEvent(details.ID, "") if err != nil { - return ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event") + return "", ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event") } // Check if the current description contains the conflict text conflict := strings.Contains(gEvent.Description, fleet.CalendarEventConflictText) var ok bool gEvent.Description, ok, err = genBodyFn(conflict) if err != nil { - return ctxerr.Wrap(c.config.Context, err, "generating calendar event body") + return "", ctxerr.Wrap(c.config.Context, err, "generating calendar event body") } if !ok { - return nil + return "", nil } - _, err = c.config.API.UpdateEvent(gEvent) + updatedEvent, err := c.config.API.UpdateEvent(gEvent) if err != nil { - return ctxerr.Wrap(c.config.Context, err, "updating Google calendar event") + return "", ctxerr.Wrap(c.config.Context, err, "updating Google calendar event") } - return nil + return updatedEvent.Etag, nil } func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn fleet.CalendarGenBodyFn, @@ -417,7 +441,8 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn if err != nil { return nil, false, err } - fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent, event.UUID, details.ChannelID, details.ResourceID) + fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent, event.UUID, details.ChannelID, details.ResourceID, + details.BodyTag) if err != nil { return nil, false, err } @@ -650,8 +675,8 @@ func (c *GoogleCalendar) createEvent( resourceID = opts.ResourceID } - // Convert Google event to Fleet event - fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelID, resourceID) + // Convert Google event to Fleet event. Body tag will be updated by the calling function. + fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelID, resourceID, "body_tag") if err != nil { return nil, err } @@ -702,8 +727,7 @@ func getLocation(tz string, config *GoogleCalendarConfig) *time.Location { } func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event, eventUUID string, - channelID string, - resourceID string) ( + channelID string, resourceID string, bodyTag string) ( *fleet.CalendarEvent, error, ) { tzName := c.location.String() @@ -718,6 +742,7 @@ func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime ti ETag: event.Etag, ChannelID: channelID, ResourceID: resourceID, + BodyTag: bodyTag, } detailsJson, err := json.Marshal(details) if err != nil { diff --git a/ee/server/service/calendar.go b/ee/server/service/calendar.go index 4f5f971b56..75886f1053 100644 --- a/ee/server/service/calendar.go +++ b/ee/server/service/calendar.go @@ -235,7 +235,9 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee return ctxerr.Wrap(ctx, err, "set recent update flag") } // Event was updated, so we need to save it - err = event.SaveBodyTag(generatedTag) + if generatedTag != "" { + err = event.SaveDataItems("body_tag", generatedTag) + } if err != nil { return ctxerr.Wrap(ctx, err, "save calendar event body tag") } diff --git a/ee/server/service/devices.go b/ee/server/service/devices.go index 4208ee15c5..590067e916 100644 --- a/ee/server/service/devices.go +++ b/ee/server/service/devices.go @@ -17,10 +17,6 @@ func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([ return svc.ds.ListPoliciesForHost(ctx, host) } -func (svc *Service) RequestEncryptionKeyRotation(ctx context.Context, hostID uint) error { - return svc.ds.SetDiskEncryptionResetStatus(ctx, hostID, true) -} - const refetchMDMUnenrollCriticalQueryDuration = 3 * time.Minute // TriggerMigrateMDMDevice triggers the webhook associated with the MDM diff --git a/ee/server/service/embedded_scripts/linux_lock.sh b/ee/server/service/embedded_scripts/linux_lock.sh index f6961f3b42..227b05fd73 100644 --- a/ee/server/service/embedded_scripts/linux_lock.sh +++ b/ee/server/service/embedded_scripts/linux_lock.sh @@ -38,6 +38,12 @@ for user in $logged_in; do done # Create the pam_nologin file -touch /etc/nologin +echo "Locked by administrator" > /etc/nologin + +# Disable systemd-user-sessions, a service that deletes /etc/nologin +if [ -f /usr/lib/systemd/system/systemd-user-sessions.service ]; then + systemctl mask systemd-user-sessions + systemctl daemon-reload +fi echo "All non-root users have been logged out and their accounts locked." diff --git a/ee/server/service/embedded_scripts/linux_unlock.sh b/ee/server/service/embedded_scripts/linux_unlock.sh index b9d0554f7a..6e6880c542 100644 --- a/ee/server/service/embedded_scripts/linux_unlock.sh +++ b/ee/server/service/embedded_scripts/linux_unlock.sh @@ -21,4 +21,11 @@ do done # Remove the pam_nologin file -rm /etc/nologin +[ -f /etc/nologin ] && rm /etc/nologin + +# Enable systemd-user-sessions, a service that deletes /etc/nologin +if [ -f /usr/lib/systemd/system/systemd-user-sessions.service ]; then + systemctl unmask systemd-user-sessions + systemctl daemon-reload + /usr/lib/systemd/systemd-user-sessions start +fi diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 06ffb9f941..ad0dbe0a81 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -20,6 +20,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log/level" "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -121,7 +122,7 @@ func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet. } var teamName *string - if teamID != nil { + if teamID != nil && *teamID != 0 { t, err := svc.ds.Team(ctx, *teamID) if err != nil { return ctxerr.Wrap(ctx, err, "getting team name for deleted VPP app") @@ -161,11 +162,19 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof teamName = &t.Name } + var teamID *uint + switch { + case meta.TeamID == nil: + teamID = ptr.Uint(0) + case meta.TeamID != nil: + teamID = meta.TeamID + } + if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{ SoftwareTitle: meta.SoftwareTitle, SoftwarePackage: meta.Name, TeamName: teamName, - TeamID: meta.TeamID, + TeamID: teamID, SelfService: meta.SelfService, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for deleted software") @@ -425,7 +434,7 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host return ctxerr.Wrapf(ctx, err, "sending command to install VPP %s application to host with serial %s", vppApp.AdamID, host.HardwareSerial) } - err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, user.ID, vppApp.AdamID, cmdUUID, eventID) + err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, user.ID, vppApp.VPPAppID, cmdUUID, eventID) if err != nil { return ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID) } @@ -463,12 +472,38 @@ func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID st res, err := svc.ds.GetSoftwareInstallResults(ctx, resultUUID) if err != nil { - return nil, err + if fleet.IsNotFound(err) { + if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get software install result") } - // Team specific auth check - if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: res.HostTeamID}, fleet.ActionRead); err != nil { - return nil, err + if res.HostDeletedAt == nil { + // host is not deleted, get it and authorize for the host's team + host, err := svc.ds.HostLite(ctx, res.HostID) + // 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 err != nil { + if fleet.IsNotFound(err) { + if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get host lite") + } + // Team specific auth check + if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionRead); err != nil { + return nil, err + } + } else { + // host was deleted, authorize for no-team as a fallback + if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil { + return nil, err + } } res.EnhanceOutputDetails() @@ -545,25 +580,24 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f const maxInstallerSizeBytes int64 = 1024 * 1024 * 500 func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) error { - if tmName == "" { - svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden" - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "must not be empty")) - } - if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil { return err } - tm, err := svc.ds.TeamByName(ctx, tmName) - if err != nil { - // If this is a dry run, the team may not have been created yet - if dryRun && fleet.IsNotFound(err) { - return nil + var teamID *uint + if tmName != "" { + tm, err := svc.ds.TeamByName(ctx, tmName) + if err != nil { + // If this is a dry run, the team may not have been created yet + if dryRun && fleet.IsNotFound(err) { + return nil + } + return err } - return err + teamID = &tm.ID } - if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &tm.ID}, fleet.ActionWrite); err != nil { + if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { return ctxerr.Wrap(ctx, err, "validating authorization") } @@ -637,7 +671,7 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin } installer := &fleet.UploadSoftwareInstallerPayload{ - TeamID: &tm.ID, + TeamID: teamID, InstallScript: p.InstallScript, PreInstallQuery: p.PreInstallQuery, PostInstallScript: p.PostInstallScript, @@ -697,7 +731,7 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin } } - if err := svc.ds.BatchSetSoftwareInstallers(ctx, &tm.ID, installers); err != nil { + if err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, installers); err != nil { return ctxerr.Wrap(ctx, err, "batch set software installers") } diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 4b8df03123..3d25abafe9 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1246,7 +1246,7 @@ func (svc *Service) editTeamFromSpec( if spec.Software != nil { if team.Config.Software == nil { - team.Config.Software = &fleet.TeamSpecSoftware{} + team.Config.Software = &fleet.SoftwareSpec{} } if spec.Software.Packages.Set { diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx index c4991f0fd7..f6ed1a97be 100644 --- a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx @@ -13,7 +13,6 @@ import Spinner from "components/Spinner/Spinner"; import { IMdmCommandResult } from "interfaces/mdm"; import { IActivityDetails } from "interfaces/activity"; -import { IconNames } from "components/icons"; import { getInstallDetailsStatusPredicate, INSTALL_DETAILS_STATUS_ICONS, @@ -46,7 +45,10 @@ export const AppInstallDetails = ({ return mdmApi.getCommandResults(command_uuid).then((response) => { const results = response.results?.[0]; if (!results) { - return Promise.reject(new Error("No data returned")); + // FIXME: It's currently possible that the command results API response is empty for pending + // commands. As a temporary workaround to handle this case, we'll ignore the empty response and + // display some minimal pending UI. This should be removed once the API response is fixed. + return {} as IMdmCommandResult; } return { ...results, @@ -66,31 +68,40 @@ export const AppInstallDetails = ({ } else if (isError) { return ; } else if (!result) { - // FIXME: Find a better solution for this. - return ; + // FIXME: It's currently possible that the command results API response is empty for pending + // commands. As a temporary workaround to handle this case, we'll ignore the empty response and + // display some minimal pending UI. This should be updated once the API response is fixed. } + const displayStatus = (status as SoftwareInstallStatus) || "pending"; + const iconName = INSTALL_DETAILS_STATUS_ICONS[displayStatus]; + // Note: We need to reconcile status values from two different sources. From props, we // get the status from the activity item details (which can be "failed", "pending", or // "installed"). From the command results API response, we also receive the raw status // from the MDM protocol, e.g., "NotNow" or "Acknowledged". We need to display some special // messaging for the "NotNow" status, which otherwise would be treated as "pending". - const isStatusNotNow = result.status === "NotNow"; - let iconName: IconNames; + const isStatusNotNow = result?.status === "NotNow"; let predicate: string; let subordinate: string; if (isStatusNotNow) { - iconName = INSTALL_DETAILS_STATUS_ICONS.pending; predicate = "tried to install"; subordinate = " but couldn’t because the host was locked or was running on battery power while in Power Nap. Fleet will try again"; } else { - iconName = INSTALL_DETAILS_STATUS_ICONS[status as SoftwareInstallStatus]; - predicate = getInstallDetailsStatusPredicate(status); + predicate = getInstallDetailsStatusPredicate(displayStatus); subordinate = status === "pending" ? " when it comes online" : ""; } - const showCommandResponse = isStatusNotNow || status !== "pending"; + const formattedHost = host_display_name ? ( + {host_display_name} + ) : ( + "the host" + ); + + const showCommandPayload = !!result?.payload; + const showCommandResponse = + !!result?.result && (isStatusNotNow || status !== "pending"); return ( <> @@ -98,20 +109,21 @@ export const AppInstallDetails = ({
{!!iconName && } - Fleet {predicate} {software_title} on{" "} - {host_display_name} + Fleet {predicate} {software_title} on {formattedHost} {subordinate}.
-
- Request payload: - -
+ {showCommandPayload && ( +
+ Request payload: + +
+ )} {showCommandResponse && (
- The response from {host_display_name}: + The response from {formattedHost}: diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx index 27480cd8e5..61c2d5f04f 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useQuery } from "react-query"; +import { IActivityDetails } from "interfaces/activity"; import { ISoftwareInstallResult, ISoftwareInstallResults, @@ -21,17 +22,28 @@ import { const baseClass = "software-install-details"; +// TODO: Expand to include more details as needed +export type IPackageInstallDetails = Pick< + IActivityDetails, + "install_uuid" | "host_display_name" +>; + const StatusMessage = ({ result: { host_display_name, software_package, software_title, status }, }: { result: ISoftwareInstallResult; }) => { + const formattedHost = host_display_name ? ( + {host_display_name} + ) : ( + "the host" + ); return (
Fleet {getInstallDetailsStatusPredicate(status)} {software_title}{" "} - ({software_package}) on {host_display_name} + ({software_package}) on {formattedHost} {status === "pending" ? " when it comes online" : ""}.
@@ -56,18 +68,17 @@ const Output = ({ }; export const SoftwareInstallDetails = ({ - installUuid, -}: { - installUuid: string; -}) => { + host_display_name = "", + install_uuid = "", +}: IPackageInstallDetails) => { const { data: result, isLoading, isError } = useQuery< ISoftwareInstallResults, Error, ISoftwareInstallResult >( - ["softwareInstallResults", installUuid], + ["softwareInstallResults", install_uuid], () => { - return softwareAPI.getSoftwareInstallResult(installUuid); + return softwareAPI.getSoftwareInstallResult(install_uuid); }, { refetchOnWindowFocus: false, @@ -88,7 +99,11 @@ export const SoftwareInstallDetails = ({ return ( <>
- + {result.status !== "pending" && ( <> {result.pre_install_query_output && ( @@ -106,10 +121,10 @@ export const SoftwareInstallDetails = ({ }; export const SoftwareInstallDetailsModal = ({ - installUuid, + details, onCancel, }: { - installUuid: string; + details: IPackageInstallDetails; onCancel: () => void; }) => { return ( @@ -121,7 +136,7 @@ export const SoftwareInstallDetailsModal = ({ > <>
- +
-
{children}
+ +
+ {isContentDisabled && ( +
+ )} +
{children}
+
); diff --git a/frontend/components/Modal/_styles.scss b/frontend/components/Modal/_styles.scss index 33fc9939eb..04dc539176 100644 --- a/frontend/components/Modal/_styles.scss +++ b/frontend/components/Modal/_styles.scss @@ -16,7 +16,7 @@ } } - &__content { + &__content-wrapper { margin-top: $pad-large; font-size: $x-small; @@ -100,4 +100,21 @@ width: auto; } } + + // these styles are for the modal content when it is disabled + &__content-wrapper-disabled { + position: relative; + } + + &__content-disabled { + transition: opacity 150ms ease-in-out; + opacity: 0.5; // this adds a disabled effect to the modal content + } + + &__disabled-overlay { + position: absolute; + height: 100%; + width: 100%; + z-index: 1000; + } } diff --git a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx index 86944020b6..7dbf45785d 100644 --- a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx +++ b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx @@ -56,6 +56,7 @@ const REGEX_GLOBAL_PAGES = { const REGEX_EXCLUDE_NO_TEAM_PAGES = { MANAGE_POLICIES: /\/policies\/manage/i, + MANAGE_QUERIES: /\/queries\/manage/i, }; const testDetailPage = (path: string, re: RegExp) => { @@ -96,7 +97,6 @@ const SiteTopNav = ({ isGlobalMaintainer, isAnyTeamMaintainer, isNoAccess, - isSandboxMode, } = useContext(AppContext); const isActiveDetailPage = isDetailPage(currentPath); @@ -187,7 +187,7 @@ const SiteTopNav = ({ { @@ -238,7 +237,6 @@ const SiteTopNav = ({ currentUser={currentUser} isAnyTeamAdmin={isAnyTeamAdmin} isGlobalAdmin={isGlobalAdmin} - isSandboxMode={isSandboxMode} /> ); diff --git a/frontend/components/top_nav/SiteTopNav/navItems.ts b/frontend/components/top_nav/SiteTopNav/navItems.ts index cbf0654723..00e145ae4b 100644 --- a/frontend/components/top_nav/SiteTopNav/navItems.ts +++ b/frontend/components/top_nav/SiteTopNav/navItems.ts @@ -27,8 +27,7 @@ export default ( isAnyTeamAdmin = false, isAnyTeamMaintainer = false, isGlobalMaintainer = false, - isNoAccess = false, - isSandboxMode = false + isNoAccess = false ): INavItem[] => { if (!user) { return []; @@ -67,7 +66,7 @@ export default ( regex: new RegExp(`^${URL_PREFIX}/controls/`), pathname: PATHS.CONTROLS, }, - exclude: isSandboxMode || !isMaintainerOrAdmin, + exclude: !isMaintainerOrAdmin, withParams: { type: "query", names: ["team_id"] }, }, { diff --git a/frontend/components/top_nav/UserMenu/UserMenu.tsx b/frontend/components/top_nav/UserMenu/UserMenu.tsx index 4e08464120..38f7986a43 100644 --- a/frontend/components/top_nav/UserMenu/UserMenu.tsx +++ b/frontend/components/top_nav/UserMenu/UserMenu.tsx @@ -17,7 +17,6 @@ interface IUserMenuProps { isAnyTeamAdmin: boolean | undefined; isGlobalAdmin: boolean | undefined; currentUser: IUser; - isSandboxMode?: boolean; } const UserMenu = ({ @@ -26,7 +25,6 @@ const UserMenu = ({ isAnyTeamAdmin, isGlobalAdmin, currentUser, - isSandboxMode = false, }: IUserMenuProps): JSX.Element => { const accountNavigate = onNavItemClick(PATHS.ACCOUNT); const dropdownItems = [ @@ -44,7 +42,7 @@ const UserMenu = ({ }, ]; - if (isGlobalAdmin && !isSandboxMode) { + if (isGlobalAdmin) { const manageUsersNavigate = onNavItemClick(PATHS.ADMIN_USERS); const manageUserNavItem = { diff --git a/frontend/context/app.tsx b/frontend/context/app.tsx index bf84e72aa3..da774708d9 100644 --- a/frontend/context/app.tsx +++ b/frontend/context/app.tsx @@ -11,6 +11,7 @@ import { import { IUser } from "interfaces/user"; import permissions from "utilities/permissions"; import sort from "utilities/sort"; +import { hasLicenseExpired, willExpireWithinXDays } from "utilities/helpers"; enum ACTIONS { SET_AVAILABLE_TEAMS = "SET_AVAILABLE_TEAMS", @@ -144,6 +145,12 @@ type InitialStateType = { isOnlyObserver?: boolean; isObserverPlus?: boolean; isNoAccess?: boolean; + isAppleBmExpired: boolean; + isApplePnsExpired: boolean; + isVppExpired: boolean; + willAppleBmExpire: boolean; + willApplePnsExpire: boolean; + willVppExpire: boolean; abmExpiry?: string; apnsExpiry?: string; vppExpiry?: string; @@ -206,6 +213,12 @@ export const initialState = { filteredSoftwarePath: undefined, filteredQueriesPath: undefined, filteredPoliciesPath: undefined, + isAppleBmExpired: false, + isApplePnsExpired: false, + isVppExpired: false, + willAppleBmExpire: false, + willApplePnsExpire: false, + willVppExpire: false, setAvailableTeams: () => null, setCurrentUser: () => null, setCurrentTeam: () => null, @@ -339,6 +352,8 @@ const reducer = (state: InitialStateType, action: IAction) => { return { ...state, abmExpiry, + isAppleBmExpired: hasLicenseExpired(abmExpiry), + willAppleBmExpire: willExpireWithinXDays(abmExpiry, 30), }; } case ACTIONS.SET_APNS_EXPIRY: { @@ -346,6 +361,8 @@ const reducer = (state: InitialStateType, action: IAction) => { return { ...state, apnsExpiry, + isApplePnsExpired: hasLicenseExpired(apnsExpiry), + willApplePnsExpire: willExpireWithinXDays(apnsExpiry, 30), }; } case ACTIONS.SET_VPP_EXPIRY: { @@ -353,6 +370,8 @@ const reducer = (state: InitialStateType, action: IAction) => { return { ...state, vppExpiry, + isVppExpired: hasLicenseExpired(vppExpiry), + willVppExpire: willExpireWithinXDays(vppExpiry, 30), }; } case ACTIONS.SET_SANDBOX_EXPIRY: { @@ -418,6 +437,12 @@ const AppProvider = ({ children }: Props): JSX.Element => { abmExpiry: state.abmExpiry, apnsExpiry: state.apnsExpiry, vppExpiry: state.vppExpiry, + isAppleBmExpired: state.isAppleBmExpired, + isApplePnsExpired: state.isApplePnsExpired, + isVppExpired: state.isVppExpired, + willAppleBmExpire: state.willAppleBmExpire, + willApplePnsExpire: state.willApplePnsExpire, + willVppExpire: state.willVppExpire, noSandboxHosts: state.noSandboxHosts, filteredHostsPath: state.filteredHostsPath, filteredSoftwarePath: state.filteredSoftwarePath, diff --git a/frontend/hooks/useTeamIdParam.ts b/frontend/hooks/useTeamIdParam.ts index f1683aa204..530ec3bb9f 100644 --- a/frontend/hooks/useTeamIdParam.ts +++ b/frontend/hooks/useTeamIdParam.ts @@ -395,6 +395,8 @@ export const useTeamIdParam = ({ currentTeamName: currentTeam?.name, currentTeamSummary: currentTeam, isAnyTeamSelected: isAnyTeamSelected(currentTeam?.id), + isAllTeamsSelected: + !isAnyTeamSelected(currentTeam?.id) && currentTeam?.id !== 0, isRouteOk, isTeamAdmin: !!currentTeam?.id && permissions.isTeamAdmin(currentUser, currentTeam.id), diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index 3a5d520d27..c9e6b08af9 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -37,7 +37,10 @@ const ActivityFeed = ({ const [pageIndex, setPageIndex] = useState(0); const [showShowQueryModal, setShowShowQueryModal] = useState(false); const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false); - const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState(""); + const [ + packageInstallDetails, + setPackageInstallDetails, + ] = useState(null); const [ appInstallDetails, setAppInstallDetails, @@ -88,7 +91,6 @@ const ActivityFeed = ({ activityType: ActivityType, details: IActivityDetails ) => { - console.log("activityType", activityType); switch (activityType) { case ActivityType.LiveQuery: queryShown.current = details.query_sql ?? ""; @@ -102,10 +104,10 @@ const ActivityFeed = ({ setShowScriptDetailsModal(true); break; case ActivityType.InstalledSoftware: - setInstalledSoftwareUuid(details.install_uuid ?? ""); + setPackageInstallDetails({ ...details }); break; case ActivityType.InstalledAppStoreApp: - setAppInstallDetails(details); + setAppInstallDetails({ ...details }); break; default: break; @@ -197,10 +199,10 @@ const ActivityFeed = ({ onCancel={() => setShowScriptDetailsModal(false)} /> )} - {installedSoftwareUuid && ( + {packageInstallDetails && ( setInstalledSoftwareUuid("")} + details={packageInstallDetails} + onCancel={() => setPackageInstallDetails(null)} /> )} {appInstallDetails && ( diff --git a/frontend/pages/ManageControlsPage/OSSettings/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/_styles.scss index 986234a842..37228609c6 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/_styles.scss @@ -18,4 +18,9 @@ .side-nav__card-container > .custom-settings { max-width: none; } + @media (max-width: 1120px) { + .side-nav__nav-list { + padding-right: 0; + } + } } diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss index 81c563065c..14828cfb9d 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss @@ -99,6 +99,10 @@ flex-direction: column; align-items: center; gap: $pad-small; + + &--message { + text-align: center; + } } &__button-wrap { diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx index 88b7e96ed9..12191a9641 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx @@ -7,7 +7,7 @@ import { NotificationContext } from "context/notification"; import { IApiError } from "interfaces/errors"; import { ILabelSummary } from "interfaces/label"; -import labelsAPI from "services/entities/labels"; +import labelsAPI, { getCustomLabels } from "services/entities/labels"; import mdmAPI from "services/entities/mdm"; // @ts-ignore @@ -250,10 +250,7 @@ const AddProfileModal = ({ isError: isErrorLabels, } = useQuery( ["custom_labels"], - () => - labelsAPI - .summary() - .then((res) => res.labels.filter((l) => l.label_type !== "builtin")), + () => labelsAPI.summary().then((res) => getCustomLabels(res.labels)), { enabled: isPremiumTier, diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/_styles.scss index 222268e7db..087e2a54d1 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/_styles.scss @@ -57,10 +57,10 @@ flex-direction: column; align-items: center; gap: $pad-small; - } - &__profile-graphic--message { - text-align: center; + &--message { + text-align: center; + } } &__button-wrap { diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss index 74a24f3ac2..45790a5389 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/_styles.scss @@ -9,11 +9,29 @@ border-right: none; } - @media (max-width: 1120px) { - .view-hosts-link { - span { - display: none; - } + .linkToFilteredHosts__header { + width: auto; + max-width: 120px; + } + +} + +@media (max-width: 1120px) { + .view-hosts-link { + span { + display: none; } } -} + .linkToFilteredHosts { + &__header { + width: 0; + .column-header { + width: 0; + } + } + &__cell { + width: min-content; + } + + } +} \ No newline at end of file diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index 7def709f9a..34aeb03cc5 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -151,10 +151,13 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false); const [showAddSoftwareModal, setShowAddSoftwareModal] = useState(false); const [resetPageIndex, setResetPageIndex] = useState(false); + const [addedSoftwareToken, setAddedSoftwareToken] = useState( + null + ); const { currentTeamId, - isAnyTeamSelected, + isAllTeamsSelected, isRouteOk, teamIdForApi, userTeams, @@ -315,7 +318,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { const renderPageActions = () => { const canManageAutomations = - isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected); + isGlobalAdmin && (!isPremiumTier || isAllTeamsSelected); const canAddSoftware = isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer; @@ -345,8 +348,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { const renderHeaderDescription = () => { return (

- Manage software and search for installed software, OS and - vulnerabilities {isAnyTeamSelected ? "on this team" : "for all hosts"}. + Manage software and search for installed software, OS, and + vulnerabilities {isAllTeamsSelected ? "for all hosts" : "on this team"}.

); }; @@ -385,6 +388,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { showExploitedVulnerabilitiesOnly, softwareFilter, resetPageIndex, + addedSoftwareToken, })} ); @@ -424,6 +428,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { teamId={currentTeamId ?? 0} router={router} onExit={toggleAddSoftwareModal} + setAddedSoftwareToken={setAddedSoftwareToken} /> )} diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx index 213cc1ec46..ea31ff42d4 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx @@ -76,11 +76,13 @@ const getSoftwareNameCellData = ( iconUrl = app_store_app.icon_url; } + const isAllTeams = teamId === undefined; + return { name: softwareTitle.name, source: softwareTitle.source, path: softwareTitleDetailsPath, - hasPackage: hasPackage && !!teamId, + hasPackage: hasPackage && !isAllTeams, isSelfService, iconUrl, }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx index 1a90c284f5..6456d616ea 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx @@ -43,6 +43,7 @@ interface ISoftwareTitlesProps { currentPage: number; teamId?: number; resetPageIndex: boolean; + addedSoftwareToken: string | null; } const SoftwareTitles = ({ @@ -56,6 +57,7 @@ const SoftwareTitles = ({ currentPage, teamId, resetPageIndex, + addedSoftwareToken, }: ISoftwareTitlesProps) => { const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS; @@ -80,6 +82,7 @@ const SoftwareTitles = ({ orderDirection, orderKey, teamId, + addedSoftwareToken, ...getSoftwareFilterForQueryKey(softwareFilter), }, ], @@ -113,6 +116,7 @@ const SoftwareTitles = ({ orderKey, teamId, vulnerable: softwareFilter === "vulnerableSoftware", + addedSoftwareToken, }, ], ({ queryKey: [queryKey] }) => diff --git a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx index 78e559b750..2e7e4b17a5 100644 --- a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx @@ -21,9 +21,15 @@ interface IAddPackageProps { teamId: number; router: InjectedRouter; onExit: () => void; + setAddedSoftwareToken: (token: string) => void; } -const AddPackage = ({ teamId, router, onExit }: IAddPackageProps) => { +const AddPackage = ({ + teamId, + router, + onExit, + setAddedSoftwareToken, +}: IAddPackageProps) => { const { renderFlash } = useContext(NotificationContext); const [isUploading, setIsUploading] = useState(false); @@ -86,7 +92,8 @@ const AddPackage = ({ teamId, router, onExit }: IAddPackageProps) => { } else { newQueryParams.available_for_install = true; } - + // any unique string - triggers SW refetch + setAddedSoftwareToken(`${Date.now()}`); router.push( `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}` ); diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx index f6af29e7b4..7f55adbc9a 100644 --- a/frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/components/AddSoftwareModal/AddSoftwareModal.tsx @@ -37,12 +37,14 @@ interface IAddSoftwareModalProps { teamId: number; router: InjectedRouter; onExit: () => void; + setAddedSoftwareToken: (token: string) => void; } const AddSoftwareModal = ({ teamId, router, onExit, + setAddedSoftwareToken, }: IAddSoftwareModalProps) => { return ( App Store (VPP) - + - + diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx index b6191d228b..4a210b93d5 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx @@ -22,7 +22,7 @@ import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; import { buildQueryStringFromParams } from "utilities/url"; import SoftwareIcon from "../icons/SoftwareIcon"; -import { getErrorMessage } from "./helpers"; +import { getErrorMessage, getUniqueAppId } from "./helpers"; const baseClass = "app-store-vpp"; @@ -64,10 +64,16 @@ const NoVppAppsCard = () => ( interface IVppAppListItemProps { app: IVppApp; selected: boolean; + uniqueAppId: string; onSelect: (software: IVppApp) => void; } -const VppAppListItem = ({ app, selected, onSelect }: IVppAppListItemProps) => { +const VppAppListItem = ({ + app, + selected, + uniqueAppId, + onSelect, +}: IVppAppListItemProps) => { return (
  • { {app.name} } - id={`vppApp-${app.app_store_id}`} + id={`vppApp-${uniqueAppId}`} checked={selected} - value={app.app_store_id.toString()} + value={uniqueAppId} name="vppApp" onChange={() => onSelect(app)} /> @@ -98,28 +104,41 @@ interface IVppAppListProps { onSelect: (app: IVppApp) => void; } -const VppAppList = ({ apps, selectedApp, onSelect }: IVppAppListProps) => ( -
    -
      - {apps.map((app) => ( - - ))} -
    -
    -); +const VppAppList = ({ apps, selectedApp, onSelect }: IVppAppListProps) => { + const uniqueSelectedAppId = selectedApp ? getUniqueAppId(selectedApp) : null; + return ( +
    +
      + {apps.map((app) => { + const uniqueAppId = getUniqueAppId(app); + return ( + + ); + })} +
    +
    + ); +}; interface IAppStoreVppProps { teamId: number; router: InjectedRouter; onExit: () => void; + setAddedSoftwareToken: (token: string) => void; } -const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => { +const AppStoreVpp = ({ + teamId, + router, + onExit, + setAddedSoftwareToken, +}: IAppStoreVppProps) => { const { renderFlash } = useContext(NotificationContext); const [isSubmitDisabled, setIsSubmitDisabled] = useState(true); const [selectedApp, setSelectedApp] = useState(null); @@ -160,7 +179,11 @@ const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => { } try { - await mdmAppleAPI.addVppApp(teamId, selectedApp.app_store_id); + await mdmAppleAPI.addVppApp( + teamId, + selectedApp.app_store_id, + selectedApp.platform + ); renderFlash( "success", <> @@ -172,6 +195,8 @@ const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => { team_id: teamId, available_for_install: true, }); + // any unique string - triggers SW refetch + setAddedSoftwareToken(`${Date.now()}`); router.push(`${PATHS.SOFTWARE}?${queryParams}`); } catch (e) { renderFlash("error", getErrorMessage(e)); diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx index f359a6441d..a0130de802 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx @@ -1,5 +1,6 @@ import React from "react"; import { getErrorReason } from "interfaces/errors"; +import { IVppApp } from "services/entities/mdm_apple"; const ADD_SOFTWARE_ERROR_PREFIX = "Couldn’t add software."; const DEFAULT_ERROR_MESSAGE = `${ADD_SOFTWARE_ERROR_PREFIX} Please try again.`; @@ -40,3 +41,6 @@ export const getErrorMessage = (e: unknown) => { } return DEFAULT_ERROR_MESSAGE; }; + +export const getUniqueAppId = (app: IVppApp) => + `${app.app_store_id}_${app.platform}`; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx index a4289664e6..84d75fd073 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from "react-query"; import { AxiosError } from "axios"; import PATHS from "router/paths"; +import { AppContext } from "context/app"; import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; import mdmAppleAPI, { IGetVppInfoResponse } from "services/entities/mdm_apple"; @@ -121,6 +122,7 @@ interface IVppSetupPageProps { const VppSetupPage = ({ router }: IVppSetupPageProps) => { const [showDisableModal, setShowDisableModal] = useState(false); const [showRenewModal, setShowRenewModal] = useState(false); + const { setVppExpiry } = useContext(AppContext); const { data: vppData, @@ -134,6 +136,9 @@ const VppSetupPage = ({ router }: IVppSetupPageProps) => { { ...DEFAULT_USE_QUERY_OPTIONS, retry: false, + onSuccess: (data) => { + setVppExpiry(data.renew_date); + }, } ); diff --git a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx index 114b537d3b..910f4813f3 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx @@ -55,7 +55,7 @@ export const LABEL_SLUG_PREFIX = "labels/"; export const DEFAULT_SORT_HEADER = "display_name"; export const DEFAULT_SORT_DIRECTION = "asc"; -export const DEFAULT_PAGE_SIZE = 20; +export const DEFAULT_PAGE_SIZE = 50; export const DEFAULT_PAGE_INDEX = 0; export const getHostSelectStatuses = (isSandboxMode = false) => { diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index e5bd75fd0e..71f2386c58 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -405,8 +405,8 @@ const ManageHostsPage = ({ osName, osVersion, vulnerability, - page: tableQueryData ? tableQueryData.pageIndex : 0, - perPage: tableQueryData ? tableQueryData.pageSize : 50, + page: tableQueryData ? tableQueryData.pageIndex : DEFAULT_PAGE_INDEX, + perPage: tableQueryData ? tableQueryData.pageSize : DEFAULT_PAGE_SIZE, device_mapping: true, osSettings: osSettingsStatus, diskEncryptionStatus, @@ -1578,7 +1578,7 @@ const ManageHostsPage = ({ } defaultPageIndex={page || DEFAULT_PAGE_INDEX} defaultSearchQuery={searchQuery} - pageSize={50} + pageSize={DEFAULT_PAGE_SIZE} additionalQueries={JSON.stringify(selectedFilters)} inputPlaceHolder={HOSTS_SEARCH_BOX_PLACEHOLDER} actionButton={{ diff --git a/frontend/pages/hosts/ManageHostsPage/components/LabelFilterSelect/helpers.ts b/frontend/pages/hosts/ManageHostsPage/components/LabelFilterSelect/helpers.ts index b6aad87e53..746a2b6ddb 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/LabelFilterSelect/helpers.ts +++ b/frontend/pages/hosts/ManageHostsPage/components/LabelFilterSelect/helpers.ts @@ -1,4 +1,6 @@ import { ILabel } from "interfaces/label"; +import { getCustomLabels } from "services/entities/labels"; + import { EMPTY_OPTION, FILTERED_LINUX, NO_LABELS_OPTION } from "./constants"; export interface IEmptyOption { @@ -27,7 +29,7 @@ const createOptionGroup = ( /** Will create the custom label group options and handles when no labels have been created yet or * will filter by the desired search query */ const createCustomLabelOptions = (labels: ILabel[], query: string) => { - const customLabels = labels.filter((label) => label.label_type === "regular"); + const customLabels = getCustomLabels(labels); let customLabelGroupOptions: ILabel[] | IEmptyOption[]; if (customLabels.length === 0) { diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 91e9292dd9..ba78e6ab25 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -42,7 +42,6 @@ import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetail import AutoEnrollMdmModal from "./AutoEnrollMdmModal"; import ManualEnrollMdmModal from "./ManualEnrollMdmModal"; import OSSettingsModal from "../OSSettingsModal"; -import ResetKeyModal from "./ResetKeyModal"; import BootstrapPackageModal from "../HostDetailsPage/modals/BootstrapPackageModal"; import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware"; import SelfService from "../cards/Software/SelfService"; @@ -90,7 +89,6 @@ const DeviceUserPage = ({ const [isPremiumTier, setIsPremiumTier] = useState(false); const [showInfoModal, setShowInfoModal] = useState(false); const [showEnrollMdmModal, setShowEnrollMdmModal] = useState(false); - const [showResetKeyModal, setShowResetKeyModal] = useState(false); const [refetchStartTime, setRefetchStartTime] = useState(null); const [showRefetchSpinner, setShowRefetchSpinner] = useState(false); const [orgLogoURL, setOrgLogoURL] = useState(""); @@ -248,10 +246,6 @@ const DeviceUserPage = ({ setShowEnrollMdmModal(!showEnrollMdmModal); }, [showEnrollMdmModal, setShowEnrollMdmModal]); - const toggleResetKeyModal = useCallback(() => { - setShowResetKeyModal(!showResetKeyModal); - }, [showResetKeyModal, setShowResetKeyModal]); - const togglePolicyDetailsModal = useCallback( (policy: IHostPolicy) => { setShowPolicyDetailsModal(!showPolicyDetailsModal); @@ -361,7 +355,6 @@ const DeviceUserPage = ({ host.mdm.macos_settings?.action_required ?? null } onTurnOnMdm={toggleEnrollMdmModal} - onResetKey={toggleResetKeyModal} /> {showInfoModal && } {showEnrollMdmModal && renderEnrollMdmModal()} - {showResetKeyModal && ( - - )} )} {!!host && showPolicyDetailsModal && ( diff --git a/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/ResetKeyModal.tsx b/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/ResetKeyModal.tsx deleted file mode 100644 index e9fe2b3f05..0000000000 --- a/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/ResetKeyModal.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; - -import Button from "components/buttons/Button"; -import Modal from "components/Modal"; -import mdmAPI from "services/entities/mdm"; -import { useQuery } from "react-query"; -import Spinner from "components/Spinner"; -import DataError from "components/DataError"; - -interface IResetKeyModalProps { - onClose: () => void; - deviceAuthToken: string; -} - -const baseClass = "reset-key-modal"; - -const ResetKeyModal = ({ - onClose, - deviceAuthToken, -}: IResetKeyModalProps): JSX.Element => { - const { isLoading: isLoadingResetDEKey, error: errorResetDEKey } = useQuery( - ["resetDEkey", deviceAuthToken], - () => mdmAPI.resetEncryptionKey(deviceAuthToken), - { refetchOnWindowFocus: false } - ); - - const renderModalBody = () => { - if (isLoadingResetDEKey) { - return ; - } - if (errorResetDEKey) { - return ; - } - - return ( -
    -
      -
    1. - Wait 30 seconds for the Reset disk encryption key pop up to - open. -
    2. -
    3. - In the popup, enter the password you use to login to your Mac. -
    4. -
    5. - Close this window and select Refetch on your My device page. - This tells your organization that you reset your key. -
    6. -
    -
    - -
    -
    - ); - }; - return ( - - {renderModalBody()} - - ); -}; - -export default ResetKeyModal; diff --git a/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/_styles.scss deleted file mode 100644 index f40c1f1c1d..0000000000 --- a/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/_styles.scss +++ /dev/null @@ -1,10 +0,0 @@ -.reset-key-modal { - ol { - padding-left: 0; - } - - li { - margin-bottom: $pad-large; - list-style: number inside; - } -} diff --git a/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/index.ts b/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/index.ts deleted file mode 100644 index 3cd713ee62..0000000000 --- a/frontend/pages/hosts/details/DeviceUserPage/ResetKeyModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ResetKeyModal"; diff --git a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tests.tsx b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tests.tsx index dc7a052653..cd3f28c01b 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tests.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tests.tsx @@ -6,8 +6,7 @@ import DeviceUserBanners from "./DeviceUserBanners"; describe("Device User Banners", () => { const turnOnMdmExpcetedText = /Mobile device management \(MDM\) is off\./; - const logoutDiskEncryptExpectedText = /Disk encryption: Log out of your device or restart to turn on disk encryption\./; - const resetKeyDiskEncryptExpcetedText = /Disk encryption: Reset your disk encryption key\./; + const resetKeyDiskEncryptExpcetedText = /Disk encryption: Log out of your device or restart it to safeguard your data in case your device is lost or stolen\./; it("renders the turn on mdm banner correctly", () => { render( @@ -19,28 +18,11 @@ describe("Device User Banners", () => { diskEncryptionStatus={null} diskEncryptionActionRequired={null} onTurnOnMdm={noop} - onResetKey={noop} /> ); expect(screen.getByText(turnOnMdmExpcetedText)).toBeInTheDocument(); }); - it("renders the logout for disk encrpytion banner correctly", () => { - render( - - ); - expect(screen.getByText(logoutDiskEncryptExpectedText)).toBeInTheDocument(); - }); - it("renders the reset key for disk encryption banner correctly", () => { render( { diskEncryptionStatus="action_required" diskEncryptionActionRequired="rotate_key" onTurnOnMdm={noop} - onResetKey={noop} /> ); expect( @@ -59,28 +40,6 @@ describe("Device User Banners", () => { ).toBeInTheDocument(); }); - it("renders only one banner in a priority order", () => { - // set up to render logout disk encryption banner, which is 2nd in priority - render( - - ); - - expect(screen.queryByText(turnOnMdmExpcetedText)).not.toBeInTheDocument(); - expect(screen.getByText(logoutDiskEncryptExpectedText)).toBeInTheDocument(); - expect( - screen.queryByText(resetKeyDiskEncryptExpcetedText) - ).not.toBeInTheDocument(); - }); - it("renders no banner correctly", () => { // setup so mdm is not enabled and configured. render( @@ -92,13 +51,12 @@ describe("Device User Banners", () => { diskEncryptionStatus={null} diskEncryptionActionRequired={null} onTurnOnMdm={noop} - onResetKey={noop} /> ); expect(screen.queryByText(turnOnMdmExpcetedText)).not.toBeInTheDocument(); expect( - screen.queryByText(logoutDiskEncryptExpectedText) + screen.queryByText(resetKeyDiskEncryptExpcetedText) ).not.toBeInTheDocument(); expect( screen.queryByText(resetKeyDiskEncryptExpcetedText) diff --git a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx index 9cc5e3256a..286101a026 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx @@ -15,7 +15,6 @@ interface IDeviceUserBannersProps { diskEncryptionStatus: DiskEncryptionStatus | null; diskEncryptionActionRequired: MacDiskEncryptionActionRequired | null; onTurnOnMdm: () => void; - onResetKey: () => void; } const DeviceUserBanners = ({ @@ -26,7 +25,6 @@ const DeviceUserBanners = ({ diskEncryptionStatus, diskEncryptionActionRequired, onTurnOnMdm, - onResetKey, }: IDeviceUserBannersProps) => { const isMdmUnenrolled = mdmEnrollmentStatus === "Off" || mdmEnrollmentStatus === null; @@ -37,11 +35,6 @@ const DeviceUserBanners = ({ const showTurnOnMdmBanner = hostPlatform === "darwin" && isMdmUnenrolled && mdmEnabledAndConfigured; - const showDiskEncryptionLogoutRestart = - diskEncryptionBannersEnabled && - diskEncryptionStatus === "action_required" && - diskEncryptionActionRequired === "log_out"; - const showDiskEncryptionKeyResetRequired = diskEncryptionBannersEnabled && diskEncryptionStatus === "action_required" && @@ -53,34 +46,24 @@ const DeviceUserBanners = ({ ); - const resetKeyButton = ( - - ); - const renderBanner = () => { if (showTurnOnMdmBanner) { return ( Mobile device management (MDM) is off. MDM allows your organization to - change settings and install software. This lets your organization keep - your device up to date so you don't have to. + enforce settings, OS updates, disk encryption, and more. This lets + your organization keep your device up to date so you don't have + to. ); - } else if (showDiskEncryptionLogoutRestart) { + } + + if (showDiskEncryptionKeyResetRequired) { return ( - Disk encryption: Log out of your device or restart to turn on disk - encryption. Then, select Refetch. This prevents - unauthorized access to the information on your device. - - ); - } else if (showDiskEncryptionKeyResetRequired) { - return ( - - Disk encryption: Reset your disk encryption key. This lets your - organization help you unlock your device if you forget your password. + Disk encryption: Log out of your device or restart it to safeguard + your data in case your device is lost or stolen. After, select{" "} + Refetch to clear this banner. ); } diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 2f2c88a5bd..cf63e55962 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -53,6 +53,8 @@ import { HOST_OSQUERY_DATA, } from "utilities/constants"; +import { Platform } from "interfaces/platform"; + import Spinner from "components/Spinner"; import TabsWrapper from "components/TabsWrapper"; import MainContent from "components/MainContent"; @@ -62,7 +64,10 @@ import { AppInstallDetailsModal, IAppInstallDetails, } from "components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails"; -import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails"; +import { + SoftwareInstallDetailsModal, + IPackageInstallDetails, +} from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -172,7 +177,10 @@ const HostDetailsPage = ({ const [selectedPolicy, setSelectedPolicy] = useState( null ); - const [softwareInstallUuid, setSoftwareInstallUuid] = useState(""); + const [ + packageInstallDetails, + setPackageInstallDetails, + ] = useState(null); const [ appInstallDetails, setAppInstallDetails, @@ -577,15 +585,29 @@ const HostDetailsPage = ({ setScriptDetailsId(details?.script_execution_id || ""); break; case "installed_software": - setSoftwareInstallUuid(details?.install_uuid || ""); + setPackageInstallDetails({ + ...details, + // FIXME: It seems like the backend is not using the correct display name when it returns + // upcoming install activities. As a workaround, we'll prefer the display name from + // the host object if it's available. + host_display_name: + host?.display_name || details?.host_display_name || "", + }); break; case "installed_app_store_app": - setAppInstallDetails({ ...details }); + setAppInstallDetails({ + ...details, + // FIXME: It seems like the backend is not using the correct display name when it returns + // upcoming install activities. As a workaround, we'll prefer the display name from + // the host object if it's available. + host_display_name: + host?.display_name || details?.host_display_name || "", + }); break; default: // do nothing } }, - [] + [host?.display_name] ); const onLabelClick = (label: ILabel) => { @@ -618,7 +640,7 @@ const HostDetailsPage = ({ }, [refetchPastActivities, refetchUpcomingActivities]); const onCancelSoftwareInstallDetailsModal = useCallback(() => { - setSoftwareInstallUuid(""); + setPackageInstallDetails(null); }, []); const onCancelAppInstallDetailsModal = useCallback(() => { @@ -901,8 +923,11 @@ const HostDetailsPage = ({ )} - {!!softwareInstallUuid && ( + {!!packageInstallDetails && ( )} diff --git a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx index ea3110af9b..fbd7e94c24 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx @@ -23,21 +23,21 @@ const HostDetailsBanners = ({ connectedToFleetMdm, diskEncryptionStatus, }: IHostDetailsBannersProps) => { - const { config, isPremiumTier, apnsExpiry, abmExpiry } = useContext( - AppContext - ); + const { + config, + isPremiumTier, + isAppleBmExpired, + isApplePnsExpired, + isVppExpired, + willAppleBmExpire, + willApplePnsExpire, + willVppExpire, + } = useContext(AppContext); // Checks to see if an app-wide banner is being shown (the ABM terms, ABM expiry, // or APNs expiry banner) in a parent component. App-wide banners found in parent // component take priority over host details page-level banners. const isAppleBmTermsExpired = config?.mdm?.apple_bm_terms_expired; - const isApplePnsExpired = hasLicenseExpired(apnsExpiry || ""); - const willApplePnsExpireIn30Days = willExpireWithinXDays( - apnsExpiry || "", - 30 - ); - const isAppleBmExpired = hasLicenseExpired(abmExpiry || ""); - const willAppleBmExpireIn30Days = willExpireWithinXDays(abmExpiry || "", 30); const isFleetLicenseExpired = hasLicenseExpired( config?.license.expiration || "" ); @@ -46,9 +46,11 @@ const HostDetailsBanners = ({ isPremiumTier && (isAppleBmTermsExpired || isApplePnsExpired || - willApplePnsExpireIn30Days || + willApplePnsExpire || isAppleBmExpired || - willAppleBmExpireIn30Days || + willAppleBmExpire || + isVppExpired || + willVppExpire || isFleetLicenseExpired); const isMdmUnenrolled = @@ -71,16 +73,15 @@ const HostDetailsBanners = ({
    {showTurnOnMdmInfoBanner && ( - To change settings and install software, ask the end user to follow - the Turn on MDM instructions on their{" "} - My device page. + To enforce settings, OS updates, disk encryption, and more, ask the + end user to follow the Turn on MDM instructions on + their My device page. )} {showDiskEncryptionUserActionRequired && ( Disk encryption: Requires action from the end user. Ask the end user - to follow Disk encryption instructions on their{" "} - My device page. + to log out of their device or restart it. )}
    diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index 3049a8273a..c2f57cc0e1 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -12,6 +12,7 @@ import deviceAPI, { IGetDeviceSoftwareResponse, } from "services/entities/device_user"; import { IHostSoftware, ISoftware } from "interfaces/software"; +import { Platform } from "interfaces/platform"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; @@ -34,8 +35,10 @@ export interface ITableSoftware extends Omit { interface IHostSoftwareProps { /** This is the host id or the device token */ id: number | string; + /** The host's platform. Only used for the host details page, so can be omited on the Device User Page. */ + platform?: Platform; softwareUpdatedAt?: string; - isFleetdHost: boolean; + hostCanInstallSoftware: boolean; router: InjectedRouter; queryParams: ReturnType; pathname: string; @@ -82,8 +85,9 @@ export const parseHostSoftwareQueryParams = (queryParams: { const HostSoftware = ({ id, + platform, softwareUpdatedAt, - isFleetdHost, + hostCanInstallSoftware, router, queryParams, pathname, @@ -93,6 +97,8 @@ const HostSoftware = ({ isMyDevicePage = false, }: IHostSoftwareProps) => { const { renderFlash } = useContext(NotificationContext); + const vulnFilterAndNotSupported = + ["ios", "ipados"].includes(platform ?? "") && queryParams.vulnerable; const { isGlobalAdmin, isGlobalMaintainer, @@ -129,7 +135,8 @@ const HostSoftware = ({ }, { ...DEFAULT_USE_QUERY_OPTIONS, - enabled: isSoftwareEnabled && !isMyDevicePage, // if disabled, we'll always show a generic "No software detected" message + enabled: + isSoftwareEnabled && !isMyDevicePage && !vulnFilterAndNotSupported, keepPreviousData: true, staleTime: 7000, } @@ -158,7 +165,7 @@ const HostSoftware = ({ ({ queryKey }) => deviceAPI.getDeviceSoftware(queryKey[0]), { ...DEFAULT_USE_QUERY_OPTIONS, - enabled: isSoftwareEnabled && isMyDevicePage, // if disabled, we'll always show a generic "No software detected" message + enabled: isSoftwareEnabled && isMyDevicePage, // if disabled, we'll always show a generic "No software detected" message. No DUP for iPad/iPhone keepPreviousData: true, staleTime: 7000, } @@ -169,7 +176,7 @@ const HostSoftware = ({ [isMyDevicePage, refetchDeviceSoftware, refetchHostSoftware] ); - const canInstallSoftware = Boolean( + const userHasSWInstallPermission = Boolean( isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer ); @@ -213,19 +220,19 @@ const HostSoftware = ({ : generateHostSoftwareTableConfig({ router, installingSoftwareId, - canInstall: canInstallSoftware, + userHasSWInstallPermission, onSelectAction, teamId: hostTeamId, - isFleetdHost, + hostCanInstallSoftware, }); }, [ isMyDevicePage, router, installingSoftwareId, - canInstallSoftware, + userHasSWInstallPermission, onSelectAction, hostTeamId, - isFleetdHost, + hostCanInstallSoftware, ]); const isLoading = isMyDevicePage @@ -251,7 +258,10 @@ const HostSoftware = ({ if (isLoading) { return ; } - + // will never be the case - to handle `platform` typing discrepancy with DeviceUserPage + if (!platform) { + return null; + } return ( <> {isError && } @@ -260,7 +270,20 @@ const HostSoftware = ({ isLoading={ isMyDevicePage ? deviceSoftwareFetching : hostSoftwareFetching } - data={data} + // this could be cleaner, however, we are going to revert this commit anyway once vulns are + // supported for iPad/iPhone, by the end of next sprint + data={ + vulnFilterAndNotSupported + ? ({ + count: 0, + meta: { + has_next_results: false, + has_previous_results: false, + }, + } as IGetHostSoftwareResponse) + : data + } // eshould be mpty for iPad/iPhone since API call is disabled, but to be sure to trigger empty state + platform={platform} router={router} tableConfig={tableConfig} sortHeader={queryParams.order_key} diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx index 6ac5f7a4a9..cf37cd0065 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx @@ -8,6 +8,12 @@ import { QueryParams } from "utilities/url"; import { ISoftwareDropdownFilterVal } from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers"; +import { + ApplePlatform, + APPLE_PLATFORM_DISPLAY_NAMES, + Platform, +} from "interfaces/platform"; + import TableContainer from "components/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer"; // @ts-ignore @@ -15,6 +21,7 @@ import Dropdown from "components/forms/fields/Dropdown"; import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable"; import TableCount from "components/TableContainer/TableCount"; +import { VulnsNotSupported } from "pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable"; const DEFAULT_PAGE_SIZE = 20; @@ -45,6 +52,7 @@ export const DROPDOWN_OPTIONS = [ interface IHostSoftwareTableProps { tableConfig: any; // TODO: type data?: IGetHostSoftwareResponse | IGetDeviceSoftwareResponse; + platform: Platform; isLoading: boolean; router: InjectedRouter; sortHeader: string; @@ -60,6 +68,7 @@ interface IHostSoftwareTableProps { const HostSoftwareTable = ({ tableConfig, data, + platform, isLoading, router, sortHeader, @@ -167,7 +176,7 @@ const HostSoftwareTable = ({ [determineQueryParamChange, pagePath, generateNewQueryParams, router] ); - const count = data?.count || data?.software.length || 0; + const count = data?.count || data?.software?.length || 0; const isSoftwareNotDetected = count === 0 && searchQuery === ""; const memoizedSoftwareCount = useCallback(() => { @@ -179,8 +188,17 @@ const HostSoftwareTable = ({ }, [count, isSoftwareNotDetected]); const memoizedEmptyComponent = useCallback(() => { - return ; - }, [searchQuery]); + const vulnFilterAndNotSupported = + ["ios", "ipados"].includes(platform) && + hostSoftwareFilter === "vulnerableSoftware"; + return vulnFilterAndNotSupported ? ( + + ) : ( + + ); + }, [hostSoftwareFilter, platform, searchQuery]); return (
    diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index b01806032f..66777b1ed3 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -50,17 +50,17 @@ type IVulnerabilitiesCellProps = IInstalledVersionsCellProps; // type IActionsCellProps = CellProps; const generateActions = ({ - canInstall, + userHasSWInstallPermission, + hostCanInstallSoftware, installingSoftwareId, - isFleetdHost, softwareId, status, software_package, app_store_app, }: { - canInstall: boolean; + userHasSWInstallPermission: boolean; + hostCanInstallSoftware: boolean; installingSoftwareId: number | null; - isFleetdHost: boolean; softwareId: number; status: SoftwareInstallStatus | null; software_package: IHostSoftwarePackage | null; @@ -78,14 +78,18 @@ const generateActions = ({ } const hasSoftwareToInstall = !!software_package || !!app_store_app; - // remove install if there is no package to install - if (!hasSoftwareToInstall || !canInstall) { + // remove install if there is no package to install or if the software is already installed + if ( + !hasSoftwareToInstall || + !userHasSWInstallPermission || + status === "installed" + ) { actions.splice(indexInstallAction, 1); return actions; } - // disable install option if not a fleetd host - if (!isFleetdHost) { + // disable install option if not a fleetd, iPad, or iOS host + if (!hostCanInstallSoftware) { actions[indexInstallAction].disabled = true; actions[indexInstallAction].tooltipContent = "To install software on this host, deploy the fleetd agent with --enable-scripts and refetch host vitals."; @@ -102,9 +106,9 @@ const generateActions = ({ }; interface ISoftwareTableHeadersProps { - canInstall: boolean; + userHasSWInstallPermission: boolean; + hostCanInstallSoftware: boolean; installingSoftwareId: number | null; - isFleetdHost: boolean; router: InjectedRouter; teamId: number; onSelectAction: (software: IHostSoftware, action: string) => void; @@ -113,9 +117,9 @@ interface ISoftwareTableHeadersProps { // NOTE: cellProps come from react-table // more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties export const generateSoftwareTableHeaders = ({ - canInstall, + userHasSWInstallPermission, + hostCanInstallSoftware, installingSoftwareId, - isFleetdHost, router, teamId, onSelectAction, @@ -202,8 +206,8 @@ export const generateSoftwareTableHeaders = ({ ( <> - Fleet installed software on these hosts. ( - {dateAgo(lastInstall as string)}) + Fleet installed software on this host + {dateAgo(lastInstall as string)}). Currently, if the software is + deleted, the “Installed” status won’t be updated. ), }, diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx index 9c4146da4d..76b7cd7f7f 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -41,7 +41,7 @@ const STATUS_CONFIG: Record = { tooltip: ({ lastInstalledAt = "" }) => ( <> Software failed to install - {lastInstalledAt ? `(${dateAgo(lastInstalledAt)})` : ""}. Select{" "} + {lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "} Retry to install again, or contact your IT department. ), diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx index f8373d8c14..2fff0a4fb4 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx +++ b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx @@ -149,7 +149,8 @@ const InstallDetailsContent = ({ } else if (hasHostSoftwarePackageLastInstall(software)) { return ( ); } diff --git a/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss index 924a16b0d8..c1cfe99bb9 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/components/AddPolicyModal/_styles.scss @@ -1,17 +1,25 @@ .add-policy-modal { height: 90%; overflow: hidden; - min-height: 460px; - max-height: fit-content; + + // we have to reach into the modal component classes to style the content + // correctly. This is because this modal always has a fixed height and + // the content is scrollable. + .modal__content-wrapper { + height: 95%; + } .modal__content { - height: 90%; - overflow: scroll; + height: 100%; display: flex; flex-direction: column; gap: $pad-large; } + &__policy-selection { + overflow-y: auto; + } + .Select-multi-value-wrapper { display: flex; } diff --git a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx index 75c66b5fd7..d34ae56ff6 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx @@ -367,6 +367,7 @@ const OtherWorkflowsModal = ({ title="Other workflows" className={baseClass} width="large" + isContentDisabled={isUpdating} >
    [A, a, B, b, C, c]) + */ +export const getCustomLabels = ( + labels: T[] +) => { + if (labels.length === 0) { + return []; + } + + return labels + .filter((label) => label.label_type === "regular") + .sort((a, b) => { + // Found this technique here + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare + // This is a case insensitive sort + return a.name.localeCompare(b.name, undefined, { + sensitivity: "base", + }); + }); +}; + export default { create: ( formData: IDynamicLabelFormData | IManualLabelFormData diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index a5b7409922..f8cae4461f 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -83,10 +83,6 @@ export interface IGetMdmCommandResultsResponse { } const mdmService = { - resetEncryptionKey: (token: string) => { - const { DEVICE_USER_RESET_ENCRYPTION_KEY } = endpoints; - return sendRequest("POST", DEVICE_USER_RESET_ENCRYPTION_KEY(token)); - }, unenrollHostFromMdm: (hostId: number, timeout?: number) => { const { HOST_MDM_UNENROLL } = endpoints; return sendRequest( diff --git a/frontend/services/entities/mdm_apple.ts b/frontend/services/entities/mdm_apple.ts index 820d0d2bf4..34ea85747f 100644 --- a/frontend/services/entities/mdm_apple.ts +++ b/frontend/services/entities/mdm_apple.ts @@ -69,11 +69,12 @@ export default { return sendRequest("GET", path); }, - addVppApp: (teamId: number, appStoreId: string) => { + addVppApp: (teamId: number, appStoreId: string, platform: ApplePlatform) => { const { MDM_APPLE_VPP_APPS } = endpoints; return sendRequest("POST", MDM_APPLE_VPP_APPS, { app_store_id: appStoreId, team_id: teamId, + platform, }); }, }; diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index ecb4d98847..d3f85f9d16 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -56,10 +56,14 @@ export interface ISoftwareVersionResponse { } export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams { + // used to trigger software refetches from sibling pages + addedSoftwareToken: string | null; scope: "software-versions"; } export interface ISoftwareTitlesQueryKey extends ISoftwareApiParams { + // used to trigger software refetches from sibling pages + addedSoftwareToken: string | null; scope: "software-titles"; } diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 235029bf70..92553ada42 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -31,9 +31,6 @@ export default { `/${API_VERSION}/fleet/device/${token}/software`, DEVICE_SOFTWARE_INSTALL: (token: string, softwareTitleId: number) => `/${API_VERSION}/fleet/device/${token}/software/install/${softwareTitleId}`, - DEVICE_USER_RESET_ENCRYPTION_KEY: (token: string): string => { - return `/${API_VERSION}/fleet/device/${token}/rotate_encryption_key`; - }, DEVICE_USER_MDM_ENROLLMENT_PROFILE: (token: string): string => { return `/${API_VERSION}/fleet/device/${token}/mdm/apple/manual_enrollment_profile`; }, diff --git a/go.mod b/go.mod index f08fad8627..f735a2244b 100644 --- a/go.mod +++ b/go.mod @@ -126,7 +126,7 @@ require ( golang.org/x/text v0.16.0 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d google.golang.org/api v0.169.0 - google.golang.org/grpc v1.64.0 + google.golang.org/grpc v1.64.1 gopkg.in/guregu/null.v3 v3.5.0 gopkg.in/ini.v1 v1.67.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 diff --git a/go.sum b/go.sum index f0d7931bb3..7eceaae2aa 100644 --- a/go.sum +++ b/go.sum @@ -1755,8 +1755,8 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/handbook/company/communications.md b/handbook/company/communications.md index 8d8a7c1f60..0c3eed5af7 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -133,7 +133,7 @@ It's important for Fleet to engage at [events](https://docs.google.com/spreadshe #### Event lead follow-up -Eventgoers are expecting a timely follow-up from Fleet based on the conversations that they had at the event. It is up to Digital Marketing Manager to make sure this process is followed. +Eventgoers expect a timely follow-up from Fleet based on the conversations that they had at the event. 1. Once a list of badge scans is available, Fleeties that attended the event are to add any follow up notes that note buying situation, amount of endpoints, level of interest, and general talking points. 2. Within 3 business days of returning from the event, attendees will set up a debrief meeting with the demand team to discuss follow-up. diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index ccf0f918fe..4514c3c95e 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -226,6 +226,7 @@ tier: Premium jamfProHasFeature: appleOnly jamfProtectHasFeature: no + isExperimental: yes usualDepartment: IT productCategories: [Device management] pricingTableCategories: [Device management] @@ -247,6 +248,7 @@ tier: Premium jamfProHasFeature: yes jamfProtectHasFeature: no + isExperimental: yes usualDepartment: IT productCategories: [Device management] pricingTableCategories: [Device management] @@ -689,6 +691,7 @@ tier: Premium jamfProHasFeature: no jamfProtectHasFeature: no + isExperimental: yes productCategories: [Endpoint operations] pricingTableCategories: [Endpoint operations] # @@ -1099,7 +1102,7 @@ # ╚╗╔╝╠═╣╠╦╝║╠═╣╠╩╗║ ║╣ ╠═╣║ ╦║╣ ║║║ ║ ╚╗╔╝║╣ ╠╦╝╚═╗║║ ║║║║╚═╗ # ╚╝ ╩ ╩╩╚═╩╩ ╩╚═╝╩═╝╚═╝ ╩ ╩╚═╝╚═╝╝╚╝ ╩ ╚╝ ╚═╝╩╚═╚═╝╩╚═╝╝╚╝╚═╝ - industryName: Variable agent versions - descrption: Manage agents remotely by setting different versions per-baseline. + description: Manage agents remotely by setting different versions per-baseline. documentationUrl: https://fleetdm.com/docs/configuration/agent-configuration#configure-fleetd-update-channels tier: Premium jamfProHasFeature: no @@ -1477,3 +1480,18 @@ usualDepartment: IT productCategories: [Device management] pricingTableCategories: [Integrations] +# +# ╔╦╗╦═╗╦╔═╗╔═╗╔═╗╦═╗ ╔═╗ ╦ ╦╔═╗╦═╗╦╔═╔═╗╦ ╔═╗╦ ╦ ╔╗ ╔═╗╔═╗╔═╗╔╦╗ ╔═╗╔╗╔ ╔═╗ +# ║ ╠╦╝║║ ╦║ ╦║╣ ╠╦╝ ╠═╣ ║║║║ ║╠╦╝╠╩╗╠╣ ║ ║ ║║║║ ╠╩╗╠═╣╚═╗║╣ ║║ ║ ║║║║ ╠═╣ +# ╩ ╩╚═╩╚═╝╚═╝╚═╝╩╚═ ╩ ╩ ╚╩╝╚═╝╩╚═╩ ╩╚ ╩═╝╚═╝╚╩╝ ╚═╝╩ ╩╚═╝╚═╝═╩╝ ╚═╝╝╚╝ ╩ ╩ +# ╔═╗╔═╗╦╦ ╦╔╗╔╔═╗ ╔═╗╔═╗╦ ╦╔═╗╦ ╦ +# ╠╣ ╠═╣║║ ║║║║║ ╦ ╠═╝║ ║║ ║║ ╚╦╝ +# ╚ ╩ ╩╩╩═╝╩╝╚╝╚═╝ ╩ ╚═╝╩═╝╩╚═╝ ╩ +- industryName: Trigger a workflow based on a failing policy + documentationUrl: https://fleetdm.com/docs/using-fleet/automations#policy-automations + productCategories: [Endpoint operations,Device management] + pricingTableCategories: [Integrations] + usualDepartment: IT + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: no diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index 17b3acbd15..f084beaf7e 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -296,33 +296,21 @@ To make a feature request or advocate for a feature request from a customer or c Requests are weighed from top to bottom while prioritizing attendee requests. This means that if the individual that added a feature request is not in attendance, the feature request will be discussed towards the end of the call if there's time. -To be acceptable for consideration, a request must: -- Have a clear proposed change -- Have a well-articulated underlying user need -- Specify the requestor (either internal stakeholder or customer or community user) - -To help the product team, other pieces of information can be optionally included: -- How would they solve the problem without any changes if pressed? -- How does this change fit into the requester's overall usage of Fleet? -- What other potential changes to the product have you considered? - -To ensure your request appears on the ["Feature Fest" board](https://app.zenhub.com/workspaces/-feature-fest-651b2962605ba29209324c57/board): -- Add the `~feature fest` label to your issue -- Add the relevant customer label (if applicable) - -To maximize your chances of having a feature accepted, requesters can visit the [🗣 Product office hours](#rituals) meeting to get feedback on requests prior to being accepted. - ### How feature requests are evaluated Digestion of these new product ideas (requests) happens at the **🎁🗣 Feature Fest** meeting. -At the **🎁🗣 Feature Fest** meeting, the DRI (Head of Product) weighs all requests on the board. When the team weighs a request, it is immediately prioritized or put to the side. +Before the **🎁🗣 Feature Fest** meeting, the [Customer renewals DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris) goes through the "Inbox" column and removes customer requests that are not a high priority for the business. Stakeholders will be notified by the Customer renewals DRI. -Product Managers prioritize all potential product improvements worked on by Fleeties. Anyone (Fleeties, customers, and community members) are invited to suggest improvements. +All community and contributor requests (non-customer) are left in the inbox. A high priority customer request may be a request that's blocking a customer from getting their job done or a request that's critical for customer renewal. -- A _request is prioritized_ when the DRI decides it is a priority. When this happens, the team sets the request to be estimated within five business days. +Before the meeting, the Feature prioritization DRI adds requests from Fleet's roadmap that are planned for the next design sprint. The quarterly roadmap is in the [OKRs spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=0#gid=0). + +At the **🎁🗣 Feature Fest** meeting, the Feature prioritization DRI weighs all requests in the inbox. When the team weighs a request, it is immediately prioritized or put to the side (not prioritized). + +- A _request is prioritized_ when the Feature prioritization DRI decides it is a priority. - A _request is put to the side_ when the business perceives competing priorities as more pressing in the immediate moment. -If a feature is not prioritized during a 🎁🗣 Feature Fest meeting, it only means the feature has been rejected _at that time_. Requestors will be notified by the Head of Product, and they can resubmit their request at a future meeting. +If a feature is not prioritized during a 🎁🗣 Feature Fest meeting, it only means the feature has been rejected _at that time_. Requestors will be notified by the Feature prioritization DRI, and they can resubmit their request at a future meeting. Requests are weighed by: - The completeness of the request (see [making a request](#making-a-request)) @@ -331,16 +319,10 @@ Requests are weighed by: - How well the request fits within Fleet's product vision and roadmap - Whether the feature seems like it can be designed, estimated, and developed in 6 weeks, given its individual complexity and when combined with other work already accepted -### Customer feature requests -The product team's goal is to prioritize 16 customer feature requests at Feature Fest, then take them from settled to shipped. The customer success team is responsible for providing the Head of Product a live count during the Feature Fest meeting. Product Operations is responsible for monitoring this KPI and raising alarms throughout the design and engineering sprints. -> Customer stories should be estimated at 1-3 points each to count as 1 request. If a feature request spans across multiple customers, it will be counted as the number of customers involved. - ### After the feature is accepted -After the 🎁🗣 Feature Fest meeting, Product Operations will clear the Feature Fest board as follows: -**Prioritized features:** Remove `feature fest` label, add `:product` label, and assign the group Product Manager. -**Put to the side features:** Remove `feature fest` label and close the issue. - -Group Product Managers will then develop user stories for the prioritized features. +After the 🎁🗣 Feature Fest meeting, the Feature prioritization DRI will clear the Feature Fest board as follows: +**Prioritized features:** Remove `feature fest` label, add `:product` label, and move the issue to the "Ready" column in the drafting board. The request will then be assigned to a [Product Designer](https://fleetdm.com/handbook/company/product-groups#current-product-groups) during the "Design sprint kick-off" ritual. +**Put to the side features:** Remove `feature fest` label and notify the requestor. > The product team's commitment to the requester is that a prioritized feature will be delivered within 6 weeks or the requester will be notified within 1 business day of the decision to de-prioritize the feature. diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index b4bc7db24a..d0015e568a 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -17,7 +17,7 @@ This page details processes specific to working [with](#contact-us) and [within] ## Contact us - To **make a request** of this department, [create an issue](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=digital-experience-request.md&title=TODO%3A+) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in the [#g-digital-experience](https://fleetdm.slack.com/archives/C058S8PFSK0) Slack channel. - - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/g-digital-experience-6451748b4eb15200131d4bab/board) for this department, including pending tasks and the status of new requests. + - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/g-digital-experience-6451748b4eb15200131d4bab/board?sprints=none) for this department, including pending tasks and the status of new requests. - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. @@ -517,6 +517,12 @@ It's not enough to just "delete" a recording of a meeting in Gong. Instead, use - Search for the title of the meeting Google Drive and delete the auto-generated Google Doc containing the transcript. - Always check back to ensure the recording **and** transcript were both deleted. +### Update a company brand front + +Fleet has several brand fronts that need to be updated from time to time. Check each [brand front](https://docs.google.com/spreadsheets/d/1c15vwMZytpCLHUdGvXxi0d6WGgPcQU1UBMniC1F9oKk/edit?gid=0#gid=0) for consistency and update as needed with the following: +- The current pitch, found in the blurbs section of the [🎐 Why Fleet?](https://docs.google.com/document/d/1E0VU4AcB6UTVRd4JKD45Saxh9Gz-mkO3LnGSTBDLEZo/edit#heading=h.uovxedjegxdc) doc. +- The current [brand imagery](https://www.figma.com/design/1J2yxqH8Q7u8V7YTtA1iej/Social-media-(logos%2C-covers%2C-banners)?node-id=3962-65895). Check this [Loom video](https://www.loom.com/share/4432646cc9614046aaa4a74da1c0adb5?sid=2f84779f-f0bd-4055-be69-282c5a16f5c5) for more info. + ## Rituals diff --git a/handbook/digital-experience/digital-experience.rituals.yml b/handbook/digital-experience/digital-experience.rituals.yml index bc2b4db7ca..5b9f8d155e 100644 --- a/handbook/digital-experience/digital-experience.rituals.yml +++ b/handbook/digital-experience/digital-experience.rituals.yml @@ -18,6 +18,13 @@ description: "Run through the entire website in `?utm_content=clear` mode and build a fresh outline of the headings to make sure they all still make sense." moreInfoUrl: "" dri: "mike-j-thomas" +- + task: "Check brand fronts are up to date" + startedOn: "2024-08-01" + frequency: "Quarterly" + description: "Check all brand fronts for consistancy and update as needed with the current product pitch and graphics." + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#update-a-company-brand-front" + dri: "mike-j-thomas" - task: "Check production dependencies of fleetdm.com" startedOn: "2023-11-10" diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 7e1d08e84b..d6f18bde2c 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -1,7 +1,10 @@ # Engineering + This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. + ## Team + | Role                            | Contributor(s) | |:--------------------------------|:-----------------------------------------------------------------------------------------------------------| | Chief Technology Officer (CTO) | [Luke Heath](https://www.linkedin.com/in/lukeheath/) _([@lukeheath](https://github.com/lukeheath))_ @@ -10,15 +13,21 @@ This handbook page details processes specific to working [with](#contact-us) and | Quality Assurance Engineer (QA) | _See [🛩️ Product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ | Software Engineer | _See [🛩️ Product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ + ## Contact us + - To **make a request** of this department, [create an issue](https://fleetdm.com/handbook/company/product-groups#current-product-groups) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in the [#help-engineering](https://fleetdm.slack.com/archives/C019WG4GH0A) Slack channel. - Any Fleet team member can [view the kanban boards](https://fleetdm.com/handbook/company/product-groups#current-product-groups) for this department, including pending tasks and the status of new requests. - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. + ## Responsibilities + The 🚀 Engineering department at Fleet is directly responsible for writing and maintaining the [code](https://github.com/fleetdm/fleet) for Fleet's core product and infrastructure. + ### Record engineering KPIs + We track the success of this process by observing the throughput of issues through the system and identifying where buildups (and therefore bottlenecks) are occurring. The metrics are: * Number of bugs opened this week @@ -28,6 +37,7 @@ The metrics are: Each week these are tracked and shared in the weekly KPI sheet by Luke Heath. + ### Write a feature guide We write [guides](https://fleetdm.com/guides) for all new features. Feature guides are published before the feature is released so that our users understand how the feature is intended to work. A guide is a type of article, so the process for writing a guide and article is the same. @@ -39,6 +49,7 @@ We write [guides](https://fleetdm.com/guides) for all new features. Feature guid ### Create an engineering-initiated story + Engineering-initiated stories are types of user stories created by engineers to make technical changes to Fleet. Technical changes should improve the user experience or contributor experience. For example, optimizing SQL that improves the response time of an API endpoint improves user experience by reducing latency. A script that generates common boilerplate, or automated tests to cover important business logic, improves the quality of life for contributors, making them happier and more productive, resulting in faster delivery of features to our customers. It is important to frame engineering-initiated user stories the same way we frame all user stories. Stay focused on how this technical change will drive value for our users. @@ -53,46 +64,50 @@ If there are no product changes, and the DRI decides to prioritize the story, th > We prefer the term engineering-initiated stories over technical debt because the user story format helps keep us focused on our users and contributors. + ### Fix a bug + All bug fix pull requests should have a mention back to the issue they resolve with # in the description or even in a comment. Please do not use any [automated words](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) since we don't want the tickets auto-closing when PR's are merged. If the bug is labeled `~unreleased bug`, branch off and put your PR into `main`. These issues can be closed as soon as they complete QA. If the bug is labeled `~released bug`, branch off and put your PR into `main`. After merging checkout the latest tag, for example `git checkout fleet-v4.48.2`, then `git fetch; git cherry-pick `. If the cherry-pick fails with a conflict call out in the ticket how to resolve or if it is sufficiently complicated call out this fix is not suited for the patch release process and should only be included in the end of sprint release. This approach makes sure the bug fix is not built on top of unreleased feature code, which can cause merge conflicts during patch releases. -### Begin a merge freeze -To ensure release quality, Fleet has a freeze period for testing beginning the Tuesday before the release at 11:00 AM Pacific. Effective at the start of the freeze period, a release candidate branch is created and frozen at `minor-fleet-v4.x.x` and no additional feature work is merged without approval. -Bugs are exempt from the release freeze period. +### Create a release candidate -Before beginning the freeze, create the release candidate branch. [Run the first step](https://github.com/fleetdm/fleet/tree/main/tools/release#minor-release-typically-end-of-sprint) of the minor release section of the Fleet releases script to create the release candidate branch, the release QA issue, and announce the release candidate in Slack. +All minor releases go through the release candidate process before they are published. A release candidate for the next minor release is created on the Tuesday before the release at 11:00 AM Pacific. A release candidate branch is created at `minor-fleet-v4.x.x` and no additional feature work is merged without EM and QA approval. -After creating the release candidate branch, open the [repo settings on Merge Freeze](https://app.mergefreeze.com/installations/3704/branches/6847/edit) and populate the "Protected branch name" field with the name of the release candidate branch. Then, [open the repo on Merge Freeze](https://www.mergefreeze.com/installations/3704/branches/6847) and click the "Freeze now" button. This will freeze the selected release candidate branch and require any PRs to be manually unfrozen before merging. PRs may be manually unfrozen in Merge Freeze using the PR number. +All bug fixes that are merged into `main` after the release candidate is created are merged into the release candidate by the engineer responsible for the fix. -> Any Fleetie can [unfreeze PRs on Merge Freeze](https://www.mergefreeze.com/installations/3704/branches) if the PR contains documentation changes or bug fixes only. If the PR contains other changes, please confirm with your manager before unfreezing. +[Run the first step](https://github.com/fleetdm/fleet/tree/main/tools/release#minor-release-typically-end-of-sprint) of the minor release section of the Fleet releases script to create the release candidate branch, the release QA issue, and announce the release candidate in Slack. -### Deploy the release candidate to QA Wolf during merge freeze -During merge freeze, deploy the release candidate to our QA Wolf instance every morning instead of `main` to ensure that any new bugs reported by QA Wolf are in the upcoming release and need to be fixed before publishing the release. + +### Deploy the release candidate to QA Wolf + +During the release candidate period, the release candidate is deployed to our QA Wolf instance every morning instead of `main` to ensure that any new bugs reported by QA Wolf are in the upcoming release and need to be fixed before publishing the release. Open the [confidential repo environment variables](https://github.com/fleetdm/confidential/settings/variables/actions) page and update the `QAWOLF_DEPLOY_TAG` repository variable with the name of the release candidate branch. -### Merge a pull request during the freeze period -We merge bug fixes, documentation changes, and website updates during the freeze period, but we do not merge other code changes. This minimizes code churn and helps ensure a stable release. To merge a bug fix, you must first unfreeze the PR in [Merge Freeze](https://app.mergefreeze.com/installations/3704/branches), and click the "Unfreeze 1 pull request" text link. + +### Merge bug fixes into the release candidate + +Only merge bug fixes during the release candidate period to minimize code churn and help ensure a stable release. To merge a bug fix into the release candidate, it should first be merged into `main`. Then, `git checkout` the release candidate branch and create a new local branch. Next, `git cherry-pick` your commit from `main` into your new local branch, then create a pull request from your new branch to the release candidate. This process ensures your bug fix is included in `main` for future releases, as well as the release candidate branch for the pending release. > To allow a stable release test, the final 24 hours before release is a deep freeze when only bugs with the `~release-blocker` or `~unreleased-bug` labels are merged. -If there is partially merged feature work when freeze begins, the previously merged code must be reverted. If there is an exceptional, business-critical need to merge feature work during freeze, as determined by the [release ritual DRI](#rituals), the following exception process may be followed: +If there is partially merged feature work when the release candidate is created, the previously merged code must be reverted. If there is an exceptional, business-critical need to merge feature work into the release candidate, as determined by the [release ritual DRI](#rituals), the release candidate [feature merge exception process](https://fleetdm.com/handbook/engineering#request-release-candidate-feature-merge-exception) may be followed. -1. The engineer requesting the feature work merge exception during freeze notifies their Engineering Manager. -2. The Engineering Manager notifies the QA lead for the product group and the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals). -3. The Engineering Manager, QA lead, and [release ritual DRI](#rituals) must all approve the feature work PR before it is unfrozen and merged. +### Request release candidate feature merge exception -> This exception process should be avoided whenever possible. Any feature work merged during freeze will likely result in a significant release delay. +1. Notify product group EM that feature work will not merge into `main` before the release candidate is cut and requires a feature merge exception. +2. EM notifies QA lead for the product group and the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals). +3. EM, QA lead, and [release ritual DRI](#rituals) must all approve the feature work PR before it is merged into the release candidate branch. + +> This exception process should be avoided whenever possible. Any feature work merged into the release candidate will likely result in a significant release delay. -### Merge a bug fix during the freeze period -To merge a bug fix into the release candidate during freeze, it should first be merged into `main`. Then, `git checkout` the release candidate branch and create a new branch. Next, `git cherry-pick` your -commit from `main` into your new branch, then create a pull request from your new branch to the release candidate. This process ensures your bug fix is included in `main` for future releases, as well as the release candidate branch for the next release. ### Confirm latest versions of dependencies + Before kicking off release QA, confirm that we are using the latest versions of dependencies we want to keep up-to-date with each release. Currently, those dependencies are: 1. **Go**: Latest minor release @@ -133,13 +148,19 @@ If an announcement is found for either data source that may impact data feed ava If code changes are found for any `fleetd` components, create a new release QA issue to update `fleetd`. Delete the top section for Fleet core, and retain the bottom section for `fleetd`. Populate the necessary version changes for each `fleetd` component. + ### Indicate your product group is release-ready -Once a product group completes its QA process during the freeze period, its QA lead moves the smoke testing ticket to the "Ready for release" column on their ZenHub board. They then notify the release ritual DRI by tagging them in a comment, indicating that their group is prepared for release. The release ritual DRI starts the [release process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md) after all QA leads have made these updates and confirmed their readiness for release. + +Once a product group completes its QA process during the release candidate period, its QA lead moves the smoke testing ticket to the "Ready for release" column on their ZenHub board. They then notify the release ritual DRI by tagging them in a comment, indicating that their group is prepared for release. The release ritual DRI starts the [release process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md) after all QA leads have made these updates and confirmed their readiness for release. + ### Prepare Fleet release + Documentation on completing the release process can be found [here](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md). + ### Deploy a new release to dogfood + After each Fleet release, the new release is deployed to Fleet's "dogfood" (internal) instance. How to deploy a new release to dogfood: @@ -154,7 +175,9 @@ How to deploy a new release to dogfood: > > Note that "fleetdm/fleet:main" is not a image name, instead use the commit hash in place of "main". + ### Conclude current milestone + Immediately after publishing a new release, we close out the associated GitHub issues and milestones. 1. **Rename current milestone**: In GitHub, [change the current milestone name](https://github.com/fleetdm/fleet/milestones) from `4.x.x-tentative` to `4.x.x`. `4.37.0-tentative` becomes `4.37.0`. @@ -175,16 +198,18 @@ Immediately after publishing a new release, we close out the associated GitHub i 10. **Create next milestone**: Create a new milestone for the next versioned release, `4.x.x-tentative`. -11. **Remove the freeze**: [Open the repo in Merge Freeze](https://app.mergefreeze.com/installations/3704/branches/6847) and click the "Unfreeze" button. +11. Announce that the release milestone has been closed in #help-engineering. -12. Announce that `main` is unfrozen and the milestone has been closed in #help-engineering. +12. Visit the [confidential repo variables](https://github.com/fleetdm/confidential/settings/variables/actions) page and update the `QAWOLF_DEPLOY_TAG` repository variable to `main` so that the latest code is deployed to QA Wolf every morning. -13. Visit the [confidential repo variables](https://github.com/fleetdm/confidential/settings/variables/actions) page and update the `QAWOLF_DEPLOY_TAG` repository variable to `main` so that the latest code is deployed to QA Wolf every morning. ### Update the Fleet releases calendar + The [Fleet releases Google calendar](https://calendar.google.com/calendar/embed?src=c_v7943deqn1uns488a65v2d94bs%40group.calendar.google.com&ctz=America%2FChicago) is kept up-to-date by the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals). Any change to targeted release dates is reflected on this calendar. + ### Secure company-issued equipment for a team member + As soon as an offer is accepted, Fleet provides laptops and YubiKey security keys for core team members to use while working at Fleet. The IT engineer will work with the new team member to get their equipment requested and shipped to them on time. - [**Check the Fleet IT warehouse**](https://docs.google.com/spreadsheets/d/1hFlymLlRWIaWeVh14IRz03yE-ytBLfUaqVz0VVmmoGI/edit#gid=0) before purchasing any equipment including laptops, monitors, and Yubikeys to ensure we efficiently [utilize existing assets before spending money](https://fleetdm.com/handbook/company/why-this-way#why-spend-less). If Fleet IT warehouse inventory can meet the needs of the request, file a [warehouse request](https://github.com/fleetdm/confidential/issues/new?assignees=sampfluger88&labels=%23g-digital-experience&projects=&template=warehouse-request.md&title=%F0%9F%92%BB+Warehouse+request). @@ -202,17 +227,23 @@ As soon as an offer is accepted, Fleet provides laptops and YubiKey security key - Include delivery tracking information when closing the support request so the new employee can be notified. + ### Register a domain for Fleet + Domain name registrations are handled through Namecheap. Access is managed via 1Password. + ### Fix a laptop that's not checking in + It is [possible for end users to remove launch agents](https://github.com/fleetdm/confidential/issues/6088) (this is true not just for osquery, but for anything). If the host has MDM turned on, use the `fleetctl mdm run-command` CLI command to push the XML file located at https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/macos-send-fleetd.xml to the device, which will reinstall fleetd. If the host doesn't have MDM turned on or isn't enrolled to dogfood, it is beyond our ability to control remotely. + ### Enroll a macOS host in dogfood + When a device is purchased using the Apple eCommerce store, the device is automatically enrolled in Apple Business Manager (ABM) and assigned to the correct server to ensure the device is in dogfood. You can confirm that the device has been ordered correctly by following these steps: - Log into ABM @@ -229,14 +260,20 @@ On occasion there will be a need to manually enroll a macOS host in dogfood. Thi - Once complete, follow instructions to reset disk encryption key. - Disk encryption key will now be stored in Fleet dogfood, which signifies that the device is now enrolled in dogfood. + ### Enroll a Windows or Ubuntu Linux device in dogfood + To enroll a windows or Ubuntu Linux device in dogfood, instruct the user to install fleetd for their platform from internal shared drive folder [here](https://drive.google.com/drive/folders/1-hMwk4P7NRzCU5kDxkEcOo8Sluuaux1h?usp=drive_link). Once the user has installed fleetd, verify the device is correctly enrolled by confirming the device encryption key is in dogfood. + ### Enroll a ChromeOS device in dogfood + ChromeOS devices are automatically enrolled in dogfood after the IT admin sets up automatic enrollment. This is done in dogfood by following the steps found in the dialog popup when selecting "Add hosts > ChromeOS" from the dogfood Hosts page. + ### Lock a macOS host in dogfood using fleetctl CLI tool + - Download the lock command XML file from Google Drive [here](https://drive.google.com/file/d/1o6vJ1fHilRtBmyKAj0I5URiKn77qe4gS/view?usp=drive_link). - Customize any messaging that will appear on the locked device, and modify the pin for unlocking the device by editing the file in text editor. - Note you will need to safely store the recovery pin for the device, suggest using 1Password or other secure storage method @@ -252,7 +289,9 @@ ChromeOS devices are automatically enrolled in dogfood after the IT admin sets u - Enter disk encryption key on laptop. This should prompt you to create a new password. - You will then be logged into the default device profile, and can complete any needed actions (wipe, recover data). + ### Book an event + Fleet's Client Platform Engineer & Community Advocate is responsible for booking events that Fleet has chosen to attend and/or sponsor. To book an event, complete the steps in each event issue. Contact the [🫧 Digital Marketing Manager](https://fleetdm.com/handbook/demand#team) as needed with any questions or blockers to booking an event. > Note: The Demand department [settles all event strategy](https://fleetdm.com/handbook/demand#settle-event-strategy) prior to booking an event. @@ -269,6 +308,7 @@ Article creation begins with creation of an issue using the "Article request" te Check the "📃 Planned articles" column in [#g-demand board](https://app.zenhub.com/workspaces/g-demand-64e6c8e2d35c7f001a457b7f/board) and continue to work through steps in each event's issue. --> + ### Order SWAG **To order T-shirts:** @@ -300,14 +340,18 @@ Check the "📃 Planned articles" column in [#g-demand board](https://app.zenhub - [Sharpie Fine Point Markers](https://www.everythingbranded.com/product/sharpie-fine-point-332908) - [Custom sticky note pads](https://www.everythingbranded.com/product/custom-sticky-notes-585601) (design is in the StickerMule [brand kit](https://www.stickermule.com/studio/brand-kits)) + ### Review another product group's pull request + Some code paths require pull request review from multiple product groups to confirm there are no unintended side effects of the change for another product group. All code paths defined in [CODEOWNERS](https://github.com/fleetdm/fleet/blob/main/CODEOWNERS) that are assigned to individual engineers across multiple product groups must be approved by one engineer from each product group before merging. + ### Review a community pull request + If you're assigned a community pull request for review, it is important to keep things moving for the contributor. The goal is to not go more than one business day without following up with the contributor. A PR should be merged if: @@ -334,7 +378,9 @@ For PRs that will not be merged: - Thank the contributor for their effort and explain why the changes won't be merged. - Close the PR. + ### Merge a community pull request + When merging a pull request from a community contributor: - Ensure that the checklist for the submitter is complete. @@ -343,10 +389,14 @@ When merging a pull request from a community contributor: - Thank and congratulate the contributor. - Share the merged PR with the team in the #help-promote channel of Fleet Slack to be publicized on social media. Those who contribute to Fleet and are recognized for their contributions often become great champions for the project. + ### Close a stale community issue + If a community member opens an issue that we can't reproduce leave a comment asking the author for more context. After one week with no reply, close the issue with a comment letting them know they are welcome to re-open it with any updates. + ### Schedule developer on-call workload + Engineering managers are asked to be aware of the [on-call rotation](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) and schedule a light workload for engineers while they are on-call. While it varies week to week considerably, the on-call responsibilities can sometimes take up a substantial portion of the engineer's time. We aspire to clear sprint work for the on-call engineer, but due to capacity or other constraints, sometimes the on-call engineer is required for sprint work. When this is the case, the EM will work with the on-call engineer to take over support requests or @oncall assignment completely when necessary. @@ -361,7 +411,9 @@ Some ideas: - Create a blog post (or other content) for fleetdm.com. - Try out an experimental refactor. + ### Edit a DNS record + We use Cloudflare to manage the DNS records of fleetdm.com and our other domains. To make DNS changes in Cloudflare: 1. Log into your Cloudflare account and select the "Fleet" account. 2. Select the domain you want to change and go to the DNS panel on that domain's dashboard. @@ -369,22 +421,28 @@ We use Cloudflare to manage the DNS records of fleetdm.com and our other domains > If you need access to Fleet's Cloudflare account, please ask the [DRI](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility) [Luke Heath](https://fleetdm.com/handbook/engineering#team) in Slack for an invitation. + ### Assume developer on-call alias + The on-call developer is responsible for: - Knowing [the on-call rotation](https://fleetdm.com/handbook/company/product-groups#the-developer-on-call-rotation). - Preforming the [on-call responsibilities](https://fleetdm.com/handbook/company/product-groups#developer-on-call-responsibilities). - [Escalating community questions and issues](https://fleetdm.com/handbook/company/product-groups#escalations). - Successfully [transferring the on-call persona to the next developer](https://fleetdm.com/handbook/company/product-groups#changing-of-the-guard). + ### Notify stakeholders when a user story is pushed to the next release -[User stories](https://fleetdm.com/handbook/company/product-groups#scrum-items) are intended to be completed in a single sprint. When a user story selected for a release has not merged into `main` by the time the [merge freeze](https://fleetdm.com/handbook/engineering#begin-a-merge-freeze) begins, it is the product group EM's responsibility to notify stakeholders: + +[User stories](https://fleetdm.com/handbook/company/product-groups#scrum-items) are intended to be completed in a single sprint. When a user story selected for a release has not merged into `main` by the time the release candidate is created, it is the product group EM's responsibility to notify stakeholders: 1. Add the `~pushed` label to the user story. 2. Update the user story's milestone to the next minor version milestone. 3. Comment on the GitHub issue and at-mention the PM and anyone listed in the requester field. 4. If `customer-` labels are applied to the user story, at-mention the [VP of Customer Success](https://fleetdm.com/handbook/customer-success#team). + ### Run Fleet locally for QA purposes + To try Fleet locally for QA purposes, run `fleetctl preview`, which defaults to running the latest stable release. To target a different version of Fleet, use the `--tag` flag to target any tag in [Docker Hub](https://hub.docker.com/r/fleetdm/fleet/tags?page=1&ordering=last_updated), including any git commit hash or branch name. For example, to QA the latest code on the `main` branch of fleetdm/fleet, you can run: `fleetctl preview --tag=main`. @@ -395,7 +453,9 @@ For each bug found, please use the [bug report template](https://github.com/flee For unreleased bugs in an active sprint, a new bug is created with the `~unreleased bug` label. The `:release` label and associated product group label is added, and the engineer responsible for the feature is assigned. If QA is unsure who the bug should be assigned to, it is assigned to the EM. Fixing the bug becomes part of the story. + ### Accept new Apple developer account terms + Engineering is responsible for managing third-party accounts required to support engineering infrastructure. We use the official Fleet Apple developer account to notarize installers we generate for Apple devices. Whenever Apple releases new terms of service, we are unable to notarize new packages until the new terms are accepted. When this occurs, we will begin receiving the following error message when attempting to notarize packages: "You must first sign the relevant contracts online." To resolve this error, follow the steps below. @@ -410,6 +470,7 @@ When this occurs, we will begin receiving the following error message when attem 5. Accept the new terms of service. + ### Interview a developer candidate Ensure the interview process follows these steps in order. This process must follow [creating a new position](https://fleetdm.com/handbook/company/leadership#creating-a-new-position) through [receiving job applications](https://fleetdm.com/handbook/company/leadership#receiving-job-applications). Once the position is approved manage this process per candidate in a [hiring pipeline](https://drive.google.com/drive/folders/1dLZaor9dQmAxcxyU6prm-MWNd-C-U8_1?usp=drive_link) @@ -424,7 +485,9 @@ Ensure the interview process follows these steps in order. This process must fol If the candidate passes all of these steps then continue with [hiring a new team member](https://fleetdm.com/handbook/company/leadership#hiring-a-new-team-member). + ### Renew MDM certificate signing request (CSR) + The certificate signing request (CSR) certificate expires every year. It needs to be renewed prior to expiring. This is notified to the team by the MDM calendar event [IMPORTANT: Renew MDM CSR certificate](https://calendar.google.com/calendar/u/0/r/eventedit/MmdqNTY4dG9nbWZycnNxbDBzYjQ5dGplM2FfMjAyNDA5MDlUMTczMDAwWiBjXzMyMjM3NjgyZGRlOThlMzI4MjVhNTY1ZDEyZjk0MDEyNmNjMWI0ZDljYjZjNjgyYzQ2MjcxZGY0N2UzNjM5NDZAZw) Steps to renew the certificate: @@ -444,7 +507,9 @@ Steps to renew the certificate: 10. Verify by logging into a normal apple account (not billing@...) and Generate a new Push Certificate following our [setup MDM](https://fleetdm.com/docs/using-fleet/mdm-setup) steps and verify the Expiration date is 1 year from today. 11. Adjust calendar event to be between 2-4 weeks before the next expiration. + ### Perform an incident postmortem + Conduct a postmortem meetings for every service or feature outage and every critical bug, whether it's a customer's environment or on fleetdm.com. 1. Copy this [postmortem template](https://docs.google.com/document/d/1Ajp2LfIclWfr4Bm77lnUggkYNQyfjePiWSnBv1b1nwM/edit?usp=sharing) document and pre-populate where possible. @@ -454,7 +519,9 @@ Conduct a postmortem meetings for every service or feature outage and every crit [Example Finished Document](https://docs.google.com/document/d/1YnETKhH9R7STAY-PaFnPy2qxhNht2EAFfkv-kyEwebQ/edit?usp=share_link) + ### Process incoming equipment + Upon receiving any device, follow these steps to process incoming equipment. 1. Search for the SN of the physical device in the ["Company equipment" spreadsheet](https://docs.google.com/spreadsheets/d/1hFlymLlRWIaWeVh14IRz03yE-ytBLfUaqVz0VVmmoGI/edit#gid=0) to confirm the correct equipment was received. 3. Visibly inspect equipment and all related components (e.g. laptop charger) for damage. @@ -466,7 +533,9 @@ Upon receiving any device, follow these steps to process incoming equipment. 9. Follow the prompts to activate the device and reinstall the appropriate version of macOS. > If you are prevented from completing the steps above, create a ["💻 IT support issue](https://github.com/fleetdm/confidential/issues/new?assignees=%40spokanemac&labels=%3Ahelp-it&projects=&template=request-it-support.md&title=%F0%9F%92%BB+Request+IT+support) for IT, for the device to be scheduled for troubleshooting and remediation. Please note in the issue where you encountered blockers to completing the steps. + ### Ship approved equipment + Once the Business Operations department approves inventory to be shipped from Fleet IT, follow these step to ship the equipment. 1. Compare the equipment request issue with the ["Company equipment" spreadsheet](https://docs.google.com/spreadsheets/d/1hFlymLlRWIaWeVh14IRz03yE-ytBLfUaqVz0VVmmoGI/edit#gid=0) and verify physical inventory. 2. Plug in the device and ensure inventory has been correctly processed and all components are present (e.g. charger cord, power converter). @@ -476,9 +545,12 @@ Once the Business Operations department approves inventory to be shipped from Fl 6. Ship via FedEx to the address listed in the equipment request. 7. Add a comment to the equipment request issue, at-mentioning the requestor with the FedEx tracking info and close the issue. + ## Rituals + + #### Stubs The following stubs are included only to make links backward compatible. @@ -546,6 +618,15 @@ Please see [handbook/engineering#accept-new-apple-developer-account-terms](https ##### Merging during the freeze period Please see [handbook/engineering#merge-a-pull-request-during-the-freeze-period](https://fleetdm.com/handbook/engineering#merge-a-pull-request-during-the-freeze-period) +##### Merge a bug fix during the freeze period +Please see [merge-bug-fixes-into-the-release-candidate](https://fleetdm.com/handbook/engineering#merge-bug-fixes-into-the-release-candidate) + +##### Merge a pull request during the freeze period +Please see [merge-bug-fixes-into-the-release-candidate](https://fleetdm.com/handbook/engineering#merge-bug-fixes-into-the-release-candidate) + +##### Begin a merge freeze +Please see [handbook/engineering#create-a-release-candidate](https://fleetdm.com/handbook/engineering#create-a-release-candidate) + ##### Scrum boards Please see [handbook//product-groups#current-product-groups](https://fleetdm.com/handbook/engineering#contact-us) diff --git a/handbook/engineering/engineering.rituals.yml b/handbook/engineering/engineering.rituals.yml index 4ff04b98b9..62432b78c3 100644 --- a/handbook/engineering/engineering.rituals.yml +++ b/handbook/engineering/engineering.rituals.yml @@ -43,11 +43,11 @@ moreInfoUrl: "https://github.com/fleetdm/fleet/security" dri: "lukeheath" - - task: "Freeze ritual" + task: "Release candidate ritual" startedOn: "2023-08-09" frequency: "Triweekly" - description: "Go through the process of freezing the `main` branch to prepare for the next release." - moreInfoUrl: "https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases" + description: "Go through the process of create a release candidate." + moreInfoUrl: "https://github.com/fleetdm/fleet/blob/main/tools/release/README.md#minor-release-typically-end-of-sprint" dri: "lukeheath" - task: "Release ritual" diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index af31d259ee..4774fe642c 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -10,7 +10,7 @@ This handbook page details processes specific to working [with](#contact-us) and ## Contact us - To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?labels=%3Aproduct&title=Product%20design%20request%C2%BB______________________&template=custom-request.md) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in `#help-design`. - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. - - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-digital-experience-6451748b4eb15200131d4bab/board?sprints=none) for this department, including pending tasks and the status of new requests. + - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-digital-experience-6451748b4eb15200131d4bab/board) for this department, including pending tasks and the status of new requests. ## Responsibilities The Product Design department is responsible for reviewing and collecting feedback from users, would-be users, and future users, prioritizing changes, designing the changes, and delivering these changes to the engineering team. Product Design prioritizes and shapes all changes involving functionality or usage, including the UI, REST API, command line, and webhooks. diff --git a/handbook/product-design/product-design.rituals.yml b/handbook/product-design/product-design.rituals.yml index c72a9a6207..3ab9203579 100644 --- a/handbook/product-design/product-design.rituals.yml +++ b/handbook/product-design/product-design.rituals.yml @@ -73,4 +73,4 @@ frequency: "Weekly" description: "Review stories in the “In review“ column on the drafting board with the “~apple-mdm-maturity“ label. Would this be usable for an IT admin and how does it compare to Jamf?" moreInfoUrl: - dri: "noahtalerman" + dri: "lukeheath" diff --git a/infrastructure/render/README.md b/infrastructure/render/README.md index 23bd1618d0..2a464c75ae 100644 --- a/infrastructure/render/README.md +++ b/infrastructure/render/README.md @@ -1,43 +1,43 @@ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/fleetdm/fleet) -# Fleet Deployment Guide +# Fleet deployment guide This guide outlines the services configured in the Render blueprint for deploying the Fleet system, which includes a web service, a MySQL database, and a Redis server. -## Services Overview +## Services overview -### 1. Fleet Web Service +### 1. Fleet web service - **Type:** Web - **Runtime:** Image - **Image:** `fleetdm/fleet:latest` - **Description:** Main web service running the Fleet application, which is deployed using the latest Fleet Docker image. Configured to prepare the database before deployment. -- **Health Check Path:** `/healthz` -- **Environment Variables:** Connects to MySQL and Redis using service-bound environment variables. +- **Health check path:** `/healthz` +- **Environment variables:** Connects to MySQL and Redis using service-bound environment variables. -### 2. Fleet MySQL Database -- **Type:** Private Service (pserv) +### 2. Fleet MySQL database +- **Type:** Private service (pserv) - **Runtime:** Docker - **Repository:** [MySQL Example on Render](https://github.com/render-examples/mysql) - **Disk:** 10 GB mounted at `/var/lib/mysql` - **Description:** MySQL database used by the Fleet web service. Environment variables for database credentials are managed within the service and some are automatically generated. -### 3. Fleet Redis Service -- **Type:** Private Service (pserv) +### 3. Fleet Redis service +- **Type:** Private service (pserv) - **Runtime:** Image - **Repository:** [Redis Docker image](https://hub.docker.com/_/redis) - **Description:** Redis service for caching and other in-memory data storage needs of the Fleet web service. -## Deployment Guide +## Deployment guide ### Prerequisites - You need an account on [Render](https://render.com). - Familiarity with Render's dashboard and deployment concepts. -### Steps to Deploy +### Steps to deploy Click the deploy on render button or import the blueprint from the Render service deployment dashboard. -### Post-Deployment +### Post-deployment Navigate to the generated URL and run through the initial setup. If you have a license key you can add it post-deploy as an environment variable `FLEET_LICENSE_KEY=value` in the Fleet service configuration. diff --git a/orbit/CHANGELOG.md b/orbit/CHANGELOG.md index 6596e4286f..b6f9278baa 100644 --- a/orbit/CHANGELOG.md +++ b/orbit/CHANGELOG.md @@ -1,3 +1,7 @@ +## Orbit 1.30.0 (Aug 05, 2024) + +* Use Escrow Buddy to rotate FileVault keys on macOS + ## Orbit 1.29.0 (Jul 24, 2024) * Fixed a startup bug by performing an early restart of orbit if an agent options setting has changed. diff --git a/orbit/TUF.md b/orbit/TUF.md index 1671d78545..229455dfc0 100644 --- a/orbit/TUF.md +++ b/orbit/TUF.md @@ -12,13 +12,15 @@ Following are the currently deployed versions of fleetd components on the `stabl | osqueryd | 5.12.1 | 5.12.1 | 5.12.1 | 5.12.1 | | nudge | 1.1.10.81462 | - | - | - | | swiftDialog | 2.1.0 | - | - | - | +| escrowBuddy | 1.0.0 | - | - | - | ## `edge` | Component\OS | macOS | Linux | Windows | Linux (arm64) | |--------------|--------|--------|---------|---------------| -| orbit | 1.29.0 | 1.29.0 | 1.29.0 | 1.29.0 | -| desktop | 1.29.0 | 1.29.0 | 1.29.0 | 1.29.0 | -| osqueryd | 5.12.2 | 5.12.2 | 5.12.2 | 5.12.1 | +| orbit | 1.30.0 | 1.30.0 | 1.30.0 | 1.30.0 | +| desktop | 1.30.0 | 1.30.0 | 1.30.0 | 1.30.0 | +| osqueryd | 5.13.0 | 5.13.0 | 5.13.0 | 5.13.0 | | nudge | - | - | - | - | | swiftDialog | - | - | - | - | +| escrowBuddy | - | - | - | - | diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index cf24f3c0ef..40d1d5283f 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -870,7 +870,6 @@ func main() { orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{ UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval, })) - orbitClient.RegisterConfigReceiver(update.ApplyDiskEncryptionRunnerMiddleware()) orbitClient.RegisterConfigReceiver(update.ApplySwiftDialogDownloaderMiddleware(updateRunner)) case "windows": orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMEnrollmentFetcherMiddleware(windowsMDMEnrollmentCommandFrequency, orbitHostInfo.HardwareUUID, orbitClient)) @@ -1220,6 +1219,15 @@ func main() { softwareRunner := installer.NewRunner(orbitClient, r.ExtensionSocketPath(), scriptsEnabledFn) orbitClient.RegisterConfigReceiver(softwareRunner) + if runtime.GOOS == "darwin" { + log.Info().Msgf("orbitClient.GetServerCapabilities() %+v", orbitClient.GetServerCapabilities()) + if orbitClient.GetServerCapabilities().Has(fleet.CapabilityEscrowBuddy) { + orbitClient.RegisterConfigReceiver(update.NewEscrowBuddyRunner(updateRunner, 5*time.Minute)) + } else { + orbitClient.RegisterConfigReceiver(update.ApplyDiskEncryptionRunnerMiddleware()) + } + } + // Install a signal handler ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/orbit/pkg/update/escrow_buddy.go b/orbit/pkg/update/escrow_buddy.go new file mode 100644 index 0000000000..e1f6fdf116 --- /dev/null +++ b/orbit/pkg/update/escrow_buddy.go @@ -0,0 +1,120 @@ +package update + +import ( + "fmt" + "sync" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/rs/zerolog/log" +) + +// EscrowBuddyRunner sets up [Escrow Buddy][1] to rotate FileVault keys on +// macOS without user interaction. This runner: +// +// - Ensures Escrow Buddy is added as a target for the update runner, so the +// authorization plugin is downloaded and installed. +// - Shells out to call `defaults` to configure Escrow Buddy according to +// server instructions provided via notifications. +// +// [1]: https://github.com/macadmins/escrow-buddy +type EscrowBuddyRunner struct { + // updateRunner is the wrapped Runner where Escrow Buddy will be set as + // a target. + updateRunner *Runner + // runCmdFunc can be set in tests to mock the command executed to + // configure Escrow Buddy + runCmdFunc func(cmd string, args ...string) error + // runMu guards runs to prevent multiple Run calls happening at the + // same time. + runMu sync.Mutex + // lastRun is used to guarantee that the run interval is enforced + lastRun time.Time + // interval defines how often Run is allowed to perform work + interval time.Duration +} + +// NewEscrowBuddyRunner returns a new instance configured with the provided values +func NewEscrowBuddyRunner(runner *Runner, interval time.Duration) fleet.OrbitConfigReceiver { + return &EscrowBuddyRunner{updateRunner: runner, interval: interval} +} + +func (e *EscrowBuddyRunner) Run(cfg *fleet.OrbitConfig) error { + log.Debug().Msgf("EscrowBuddyRunner: notification: %t", cfg.Notifications.RotateDiskEncryptionKey) + + if e.updateRunner == nil { + log.Info().Msg("EscrowBuddyRunner: received nil UpdateRunner, this probably indicates that updates are turned off. Skipping any actions related to Disk encryption") + return nil + } + + if !e.runMu.TryLock() { + log.Debug().Msg("EscrowBuddyRunner: a previous instance is currently running, returning early") + return nil + } + + defer e.runMu.Unlock() + if time.Since(e.lastRun) < e.interval { + log.Debug().Msgf("EscrowBuddyRunner: last run (%v) is less than the configured interval (%v), returning early", e.lastRun, e.interval) + return nil + } + + updaterHasTarget := e.updateRunner.HasRunnerOptTarget("escrowBuddy") + // if the notification is false, it could mean that we shouldn't do + // anything at all (eg: MDM is not configured) or that this host + // doesn't need to rotate the key. + // + // if Escrow Buddy is a TUF target, it means that we tried to rotate + // the key before, and we must disable it to keep the local state as + // instructed by the server. + if !cfg.Notifications.RotateDiskEncryptionKey { + if updaterHasTarget { + log.Debug().Msg("EscrowBuddyRunner: disabling disk encryption rotation") + e.lastRun = time.Now() + return e.setGenerateNewKeyTo(false) + } + + log.Debug().Msg("EscrowBuddyRunner: skipping any actions related to disk encryption") + return nil + } + + runnerHasLocalHash := e.updateRunner.HasLocalHash("escrowBuddy") + if !updaterHasTarget || !runnerHasLocalHash { + log.Info().Msg("refreshing the update runner config with Escrow Buddy targets and hashes") + log.Debug().Msgf("updater has target: %t, runner has local hash: %t", updaterHasTarget, runnerHasLocalHash) + if err := e.setTargetsAndHashes(); err != nil { + return fmt.Errorf("setting Escrow Buddy targets and hashes: %w", err) + } + } + + log.Debug().Msg("EscrowBuddyRunner: enabling disk encryption rotation") + if err := e.setGenerateNewKeyTo(true); err != nil { + return fmt.Errorf("enabling disk encryption rotation: %w", err) + } + + e.lastRun = time.Now() + return nil +} + +func (e *EscrowBuddyRunner) setTargetsAndHashes() error { + e.updateRunner.AddRunnerOptTarget("escrowBuddy") + e.updateRunner.updater.SetTargetInfo("escrowBuddy", EscrowBuddyMacOSTarget) + // we don't want to keep escrowBuddy as a target if we failed to update the + // cached hashes in the runner. + if err := e.updateRunner.StoreLocalHash("escrowBuddy"); err != nil { + log.Debug().Msgf("removing escrowBuddy from target options, error updating local hashes: %s", err) + e.updateRunner.RemoveRunnerOptTarget("escrowBuddy") + e.updateRunner.updater.RemoveTargetInfo("escrowBuddy") + return err + } + return nil +} + +func (e *EscrowBuddyRunner) setGenerateNewKeyTo(enabled bool) error { + log.Debug().Msgf("running defaults write to configure Escrow Buddy with value %t", enabled) + cmd := fmt.Sprintf("defaults write /Library/Preferences/com.netflix.Escrow-Buddy.plist GenerateNewKey -bool %t", enabled) + fn := e.runCmdFunc + if fn == nil { + fn = runCmdCollectErr + } + return fn("sh", "-c", cmd) +} diff --git a/orbit/pkg/update/escrow_buddy_test.go b/orbit/pkg/update/escrow_buddy_test.go new file mode 100644 index 0000000000..0ed61883b0 --- /dev/null +++ b/orbit/pkg/update/escrow_buddy_test.go @@ -0,0 +1,86 @@ +package update + +import ( + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +func TestEscrowBuddy(t *testing.T) { + testingSuite := new(escrowBuddyTestSuite) + testingSuite.s = &testingSuite.Suite + suite.Run(t, testingSuite) +} + +type escrowBuddyTestSuite struct { + suite.Suite + withTUF +} + +func (s *escrowBuddyTestSuite) TestUpdatesDisabled() { + t := s.T() + cfg := &fleet.OrbitConfig{} + cfg.Notifications.RotateDiskEncryptionKey = true + r := NewEscrowBuddyRunner(nil, time.Second) + err := r.Run(cfg) + require.NoError(t, err) +} + +func (s *escrowBuddyTestSuite) TestEscrowBuddyRotatesKey() { + t := s.T() + updater := &Updater{ + client: s.client, + opt: Options{Targets: make(map[string]TargetInfo), RootDirectory: t.TempDir()}, + } + runner := &Runner{updater: updater, localHashes: make(map[string][]byte)} + escrowBuddyPath := "escrowBuddy/macos/stable/escrowBuddy.pkg" + + cfg := &fleet.OrbitConfig{} + r := &EscrowBuddyRunner{updateRunner: runner, interval: time.Millisecond} + // mock the command to run the defaults cli + cmdCalls := []map[string]any{} + r.runCmdFunc = func(cmd string, args ...string) error { + cmdCalls = append(cmdCalls, map[string]any{"cmd": cmd, "args": args}) + return nil + } + + // no new target added if the notification is not set + err := r.Run(cfg) + require.NoError(t, err) + targets := runner.updater.opt.Targets + require.Len(t, targets, 0) + require.Empty(t, cmdCalls) + + // there's an error when the remote repo doesn't have the target yet + cfg.Notifications.RotateDiskEncryptionKey = true + err = r.Run(cfg) + require.ErrorContains(t, err, "tuf: file not found") + require.Empty(t, cmdCalls) + + // add escrow buddy to the remote + s.addRemoteTarget(escrowBuddyPath) + + err = r.Run(cfg) + require.NoError(t, err) + require.Len(t, cmdCalls, 1) + require.Equal(t, cmdCalls[0]["cmd"], "sh") + require.Equal(t, cmdCalls[0]["args"], []string{"-c", "defaults write /Library/Preferences/com.netflix.Escrow-Buddy.plist GenerateNewKey -bool true"}) + + targets = runner.updater.opt.Targets + require.Len(t, targets, 1) + ti, ok := targets["escrowBuddy"] + require.True(t, ok) + require.EqualValues(t, EscrowBuddyMacOSTarget, ti) + + time.Sleep(3 * time.Millisecond) + cfg.Notifications.RotateDiskEncryptionKey = false + err = r.Run(cfg) + require.NoError(t, err) + require.Len(t, cmdCalls, 2) + require.Equal(t, cmdCalls[1]["cmd"], "sh") + require.Equal(t, cmdCalls[1]["args"], []string{"-c", "defaults write /Library/Preferences/com.netflix.Escrow-Buddy.plist GenerateNewKey -bool false"}) + +} diff --git a/orbit/pkg/update/options.go b/orbit/pkg/update/options.go index 90cdcaf62a..ed04f5a10b 100644 --- a/orbit/pkg/update/options.go +++ b/orbit/pkg/update/options.go @@ -122,4 +122,10 @@ var ( TargetFile: "swiftDialog.app.tar.gz", ExtractedExecSubPath: []string{"Dialog.app", "Contents", "MacOS", "Dialog"}, } + + EscrowBuddyMacOSTarget = TargetInfo{ + Platform: "macos", + Channel: "stable", + TargetFile: "escrowBuddy.pkg", + } ) diff --git a/orbit/pkg/update/testing_utils.go b/orbit/pkg/update/testing_utils.go index 53c943f2c8..83698e240e 100644 --- a/orbit/pkg/update/testing_utils.go +++ b/orbit/pkg/update/testing_utils.go @@ -85,6 +85,7 @@ func (ts *withTUF) SetupSuite() { ts.mockFiles = map[string][]byte{ "nudge/macos/stable/nudge.app.tar.gz": ts.memTarGz("/Nudge.app/Contents/MacOS/Nudge", "nudge"), "osqueryd/macos/stable/osqueryd.app.tar.gz": ts.memTarGz("osqueryd", "osqueryd"), + "escrowBuddy/macos/stable/escrowBuddy.pkg": {}, } ts.store = tuf.MemoryStore(nil, ts.mockFiles) diff --git a/orbit/pkg/update/update.go b/orbit/pkg/update/update.go index c6b00df89e..56e4a79b7f 100644 --- a/orbit/pkg/update/update.go +++ b/orbit/pkg/update/update.go @@ -384,6 +384,11 @@ func (u *Updater) get(target string) (*LocalTarget, error) { return nil, fmt.Errorf("failed to remove old extracted dir: %q: %w", localTarget.DirPath, err) } } + if strings.HasSuffix(localTarget.Path, ".pkg") && runtime.GOOS == "darwin" { + if err := installPKG(localTarget.Path); err != nil { + return nil, fmt.Errorf("updating pkg: %w", err) + } + } } else { log.Debug().Str("path", localTarget.Path).Str("target", target).Msg("found expected target locally") } @@ -392,6 +397,11 @@ func (u *Updater) get(target string) (*LocalTarget, error) { if err := u.download(target, repoPath, localTarget.Path, localTarget.Info.CustomCheckExec); err != nil { return nil, fmt.Errorf("download %q: %w", repoPath, err) } + if strings.HasSuffix(localTarget.Path, ".pkg") && runtime.GOOS == "darwin" { + if err := installPKG(localTarget.Path); err != nil { + return nil, fmt.Errorf("installing pkg for the first time: %w", err) + } + } default: return nil, fmt.Errorf("stat %q: %w", localTarget.Path, err) } @@ -558,6 +568,14 @@ func (u *Updater) checkExec(target, tmpPath string, customCheckExec func(execPat tmpPath = filepath.Join(append([]string{filepath.Dir(tmpPath)}, localTarget.Info.ExtractedExecSubPath...)...) } + if strings.HasSuffix(tmpPath, ".pkg") && runtime.GOOS == "darwin" { + cmd := exec.Command("pkgutil", "--payload-files", tmpPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("running pkgutil to verify %s: %s: %w", tmpPath, string(out), err) + } + return nil + } + if customCheckExec != nil { if err := customCheckExec(tmpPath); err != nil { return fmt.Errorf("custom exec new version failed: %w", err) @@ -633,6 +651,14 @@ func extractTarGz(path string) error { } } +func installPKG(path string) error { + cmd := exec.Command("installer", "-pkg", path, "-target", "/") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("running pkgutil to install %s: %s: %w", path, string(out), err) + } + return nil +} + func (u *Updater) initializeDirectories() error { for _, dir := range []string{ filepath.Join(u.opt.RootDirectory, binDir), diff --git a/pkg/file/file_test.go b/pkg/file/file_test.go index ceccdc9f6c..73d1ad5573 100644 --- a/pkg/file/file_test.go +++ b/pkg/file/file_test.go @@ -104,10 +104,11 @@ func TestExists(t *testing.T) { // // That is, it breaks the file name at the dollar sign and the first part is // the expected name, the second is the expected version, the third is the -// hex-encoded hash. Note that by default, files in testdata/installers are NOT -// included in git, so the test files must be added manually (for size and -// licenses considerations). Why the dollar sign? Because dots, dashes and -// underlines are more likely to be part of the name or version. +// hex-encoded hash and the fourth is the bundle identifier. Note that by +// default, files in testdata/installers are NOT included in git, so the test +// files must be added manually (for size and licenses considerations). Why the +// dollar sign? Because dots, dashes and underlines are more likely to be part +// of the name or version. func TestExtractInstallerMetadata(t *testing.T) { dents, err := os.ReadDir(filepath.Join("testdata", "installers")) if err != nil { @@ -120,8 +121,8 @@ func TestExtractInstallerMetadata(t *testing.T) { } t.Run(dent.Name(), func(t *testing.T) { parts := strings.Split(strings.TrimSuffix(dent.Name(), filepath.Ext(dent.Name())), "$") - if len(parts) < 3 { - t.Fatalf("invalid filename, expected at least 3 sections, got %d: %s", len(parts), dent.Name()) + if len(parts) < 4 { + t.Fatalf("invalid filename, expected at least 4 sections, got %d: %s", len(parts), dent.Name()) } wantName, wantVersion, wantHash, wantBundleIdentifier := parts[0], parts[1], parts[2], parts[3] wantExtension := strings.TrimPrefix(filepath.Ext(dent.Name()), ".") diff --git a/pkg/file/pe.go b/pkg/file/pe.go index 1a436665e9..1b0e053c09 100644 --- a/pkg/file/pe.go +++ b/pkg/file/pe.go @@ -50,9 +50,37 @@ func ExtractPEMetadata(r io.Reader) (*InstallerMetadata, error) { if err != nil { return nil, fmt.Errorf("error parsing PE version resources: %w", err) } - return &InstallerMetadata{ + return applySpecialCases(&InstallerMetadata{ Name: strings.TrimSpace(v["ProductName"]), Version: strings.TrimSpace(v["ProductVersion"]), SHASum: h.Sum(nil), - }, nil + }, v), nil +} + +var exeSpecialCases = map[string]func(*InstallerMetadata, map[string]string) *InstallerMetadata{ + "Notion": func(meta *InstallerMetadata, resources map[string]string) *InstallerMetadata { + if meta.Version != "" { + meta.Name = meta.Name + " " + meta.Version + } + return meta + }, +} + +// Unlike .exe files that are the software itself (and just need to be copied +// over to the host), and unlike standard installer formats like .msi where the +// metadata defines the name under which the software will be installed, .exe +// installers may do pretty much anything they want when installing the +// software, regardless of what the .exe metadata contains. +// +// For example, the Notion .exe installer installs the app under a name like +// "Notion 3.11.1", and not just "Notion". There's no way to detect that by +// parsing the installer's metadata, so we need to apply some special cases at +// least for the most popular apps that use unusual naming. +// +// See https://github.com/fleetdm/fleet/issues/20440#issuecomment-2260500661 +func applySpecialCases(meta *InstallerMetadata, resources map[string]string) *InstallerMetadata { + if fn := exeSpecialCases[meta.Name]; fn != nil { + return fn(meta, resources) + } + return meta } diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index b6eea43708..e29e9138dc 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -61,12 +61,12 @@ type GitOps struct { } type GitOpsSoftware struct { - Packages []*fleet.TeamSpecSoftwarePackage + Packages []*fleet.SoftwarePackageSpec AppStoreApps []*fleet.TeamSpecAppStoreApp } // GitOpsFromFile parses a GitOps yaml file. -func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) { +func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig) (*GitOps, error) { b, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %s: %w", filePath, err) @@ -96,18 +96,16 @@ func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) { // Figure out if this is an org or team settings file teamRaw, teamOk := top["name"] teamSettingsRaw, teamSettingsOk := top["team_settings"] - teamSoftware, teamSoftwareOk := top["software"] orgSettingsRaw, orgOk := top["org_settings"] if orgOk { - if teamOk || teamSettingsOk || teamSoftwareOk { - multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'team_settings' or 'software'")) + if teamOk || teamSettingsOk { + multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'team_settings'")) } else { multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError) } } else if teamOk && teamSettingsOk { multiError = parseName(teamRaw, result, multiError) multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError) - multiError = parseSoftware(teamSoftware, result, baseDir, multiError) } else { multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required")) } @@ -118,6 +116,10 @@ func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) { multiError = parsePolicies(top, result, baseDir, multiError) multiError = parseQueries(top, result, baseDir, multiError) + if appConfig != nil && appConfig.License.IsPremium() { + multiError = parseSoftware(top, result, baseDir, multiError) + } + return result, multiError.ErrorOrNil() } @@ -523,11 +525,15 @@ func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string return multiError } -func parseSoftware(softwareRaw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { - var software fleet.TeamSpecSoftware +func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + softwareRaw, ok := top["software"] + if !ok { + return multierror.Append(multiError, errors.New("'software' is required")) + } + var software fleet.SoftwareSpec if len(softwareRaw) > 0 { if err := json.Unmarshal(softwareRaw, &software); err != nil { - return multierror.Append(multiError, fmt.Errorf("failed to unmarshall software: %v", err)) + return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err)) } } if software.AppStoreApps.Set { diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 644c3e453e..58e12553b9 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -53,7 +53,7 @@ func createTempFile(t *testing.T, pattern, contents string) (filePath string, ba func gitOpsFromString(t *testing.T, s string) (*GitOps, error) { path, basePath := createTempFile(t, "", s) - return GitOpsFromFile(path, basePath) + return GitOpsFromFile(path, basePath, nil) } func TestValidGitOpsYaml(t *testing.T) { @@ -108,7 +108,7 @@ func TestValidGitOpsYaml(t *testing.T) { t.Parallel() } - gitops, err := GitOpsFromFile(test.filePath, "./testdata") + gitops, err := GitOpsFromFile(test.filePath, "./testdata", nil) require.NoError(t, err) if test.isTeam { @@ -336,20 +336,20 @@ func TestMixingGlobalAndTeamConfig(t *testing.T) { config := getGlobalConfig(nil) config += "name: TeamName\n" _, err := gitOpsFromString(t, config) - assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'") // Mixing org_settings and team_settings config = getGlobalConfig(nil) config += "team_settings:\n secrets: []\n" _, err = gitOpsFromString(t, config) - assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'") // Mixing org_settings and team name and team_settings config = getGlobalConfig(nil) config += "name: TeamName\n" config += "team_settings:\n secrets: []\n" _, err = gitOpsFromString(t, config) - assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'") } func TestInvalidGitOpsYaml(t *testing.T) { @@ -696,7 +696,7 @@ func TestGitOpsPaths(t *testing.T) { err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil) assert.NoError(t, err) // Test a bad path @@ -709,7 +709,7 @@ func TestGitOpsPaths(t *testing.T) { err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil) assert.ErrorContains(t, err, "no such file or directory") // Test a bad file -- cannot be unmarshalled @@ -744,7 +744,7 @@ func TestGitOpsPaths(t *testing.T) { } err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil) assert.ErrorContains(t, err, "nested paths are not supported") }, ) diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go index d760f8b1d3..10bafd76da 100644 --- a/pkg/spec/spec.go +++ b/pkg/spec/spec.go @@ -26,6 +26,7 @@ type Group struct { Packs []*fleet.PackSpec Labels []*fleet.LabelSpec Policies []*fleet.PolicySpec + Software []*fleet.SoftwarePackageSpec // This needs to be interface{} to allow for the patch logic. Otherwise we send a request that looks to the // server like the user explicitly set the zero values. AppConfig interface{} diff --git a/scripts/mdm/linux/linux-lock.sh b/scripts/mdm/linux/linux-lock.sh index f6961f3b42..227b05fd73 100644 --- a/scripts/mdm/linux/linux-lock.sh +++ b/scripts/mdm/linux/linux-lock.sh @@ -38,6 +38,12 @@ for user in $logged_in; do done # Create the pam_nologin file -touch /etc/nologin +echo "Locked by administrator" > /etc/nologin + +# Disable systemd-user-sessions, a service that deletes /etc/nologin +if [ -f /usr/lib/systemd/system/systemd-user-sessions.service ]; then + systemctl mask systemd-user-sessions + systemctl daemon-reload +fi echo "All non-root users have been logged out and their accounts locked." diff --git a/scripts/mdm/linux/linux-unlock.sh b/scripts/mdm/linux/linux-unlock.sh index b9d0554f7a..6e6880c542 100644 --- a/scripts/mdm/linux/linux-unlock.sh +++ b/scripts/mdm/linux/linux-unlock.sh @@ -21,4 +21,11 @@ do done # Remove the pam_nologin file -rm /etc/nologin +[ -f /etc/nologin ] && rm /etc/nologin + +# Enable systemd-user-sessions, a service that deletes /etc/nologin +if [ -f /usr/lib/systemd/system/systemd-user-sessions.service ]; then + systemctl unmask systemd-user-sessions + systemctl daemon-reload + /usr/lib/systemd/systemd-user-sessions start +fi diff --git a/server/cron/calendar_cron.go b/server/cron/calendar_cron.go index 4e717a84b6..ed943caa52 100644 --- a/server/cron/calendar_cron.go +++ b/server/cron/calendar_cron.go @@ -396,6 +396,7 @@ func processFailingHostExistingCalendarEvent( // Function to generate calendar event body. var generatedTag string + var newETag string var genBodyFn fleet.CalendarGenBodyFn = func(conflict bool) (string, bool, error) { var body string body, generatedTag = calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger) @@ -409,7 +410,7 @@ func processFailingHostExistingCalendarEvent( updatedBodyTag := getBodyTag(ctx, ds, host, policyIDtoPolicy, logger) if currentBodyTag != updatedBodyTag && updatedBodyTag != "" { - err = userCalendar.UpdateEventBody(calendarEvent, genBodyFn) + newETag, err = userCalendar.UpdateEventBody(calendarEvent, genBodyFn) if err != nil { return fmt.Errorf("update event body: %w", err) } @@ -440,8 +441,8 @@ func processFailingHostExistingCalendarEvent( } if updated { - if generatedTag != "" { - err = updatedEvent.SaveBodyTag(generatedTag) + if generatedTag != "" && newETag != "" { + err = updatedEvent.SaveDataItems("body_tag", generatedTag, "etag", newETag) if err != nil { return fmt.Errorf("save calendar event body tag: %w", err) } @@ -623,7 +624,7 @@ func attemptCreatingEventOnUserCalendar( var dee fleet.DayEndedError switch { case err == nil: - err = calendarEvent.SaveBodyTag(generatedTag) + err = calendarEvent.SaveDataItems("body_tag", generatedTag) if err != nil { return nil, err } diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 1fbc21a99a..d0e6dee037 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -368,7 +368,7 @@ LEFT OUTER JOIN LEFT OUTER JOIN host_display_names hdn ON hdn.host_id = hvsi.host_id LEFT OUTER JOIN - vpp_apps vpa ON hvsi.adam_id = vpa.adam_id + vpp_apps vpa ON hvsi.adam_id = vpa.adam_id AND hvsi.platform = vpa.platform LEFT OUTER JOIN software_titles st ON st.id = vpa.title_id WHERE diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 0bb49993b6..a0e95f7d75 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -4603,61 +4603,3 @@ LIMIT 500 return deviceUUIDs, nil } - -func (ds *Datastore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { - stmt := ` -SELECT - team_id, platform -FROM - hosts h -JOIN - host_dep_assignments hdep ON h.id = host_id -WHERE - hardware_serial = ? AND deleted_at IS NULL -LIMIT 1` - - var dest struct { - TeamID *uint `db:"team_id"` - Platform string `db:"platform"` - } - if err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, serial); err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting team id for host") - } - - var settings fleet.AppleOSUpdateSettings - if dest.TeamID == nil { - // use the global settings - ac, err := ds.AppConfig(ctx) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting app config for os update settings") - } - switch dest.Platform { - case "ios": - settings = ac.MDM.IOSUpdates - case "ipados": - settings = ac.MDM.IPadOSUpdates - case "darwin": - settings = ac.MDM.MacOSUpdates - default: - return nil, ctxerr.New(ctx, fmt.Sprintf("unsupported platform %s", dest.Platform)) - } - } else { - // use the team settings - tm, err := ds.TeamWithoutExtras(ctx, *dest.TeamID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting team os update settings") - } - switch dest.Platform { - case "ios": - settings = tm.Config.MDM.IOSUpdates - case "ipados": - settings = tm.Config.MDM.IPadOSUpdates - case "darwin": - settings = tm.Config.MDM.MacOSUpdates - default: - return nil, ctxerr.New(ctx, fmt.Sprintf("unsupported platform %s", dest.Platform)) - } - } - - return &settings, nil -} diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 53c8423809..35a8a87497 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -6001,7 +6001,6 @@ func testGetHostUUIDsWithPendingMDMAppleCommands(t *testing.T, ds *Datastore) { require.NoError(t, err) require.ElementsMatch(t, []string{hosts[1].UUID, hosts[2].UUID}, uuids) } - func testHostDetailsMDMProfilesIOSIPadOS(t *testing.T, ds *Datastore) { ctx := context.Background() @@ -6144,174 +6143,3 @@ func testHostDetailsMDMProfilesIOSIPadOS(t *testing.T, ds *Datastore) { require.Equal(t, fleet.MDMDeliveryVerified, *gotProfs[0].Status) } } - -func TestGetMDMAppleOSUpdatesSettingsByHostSerial(t *testing.T) { - ds := CreateMySQLDS(t) - defer ds.Close() - - keys := []string{"ios", "ipados", "macos"} - devicesByKey := map[string]godep.Device{ - "ios": {SerialNumber: "dep-serial-ios-updates", DeviceFamily: "iPhone"}, - "ipados": {SerialNumber: "dep-serial-ipados-updates", DeviceFamily: "iPad"}, - "macos": {SerialNumber: "dep-serial-macos-updates", DeviceFamily: "Mac"}, - } - - getConfigSettings := func(teamID uint, key string) *fleet.AppleOSUpdateSettings { - var settings fleet.AppleOSUpdateSettings - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - stmt := fmt.Sprintf(`SELECT json_value->'$.mdm.%s_updates' FROM app_config_json`, key) - if teamID > 0 { - stmt = fmt.Sprintf(`SELECT config->'$.mdm.%s_updates' FROM teams WHERE id = %d`, key, teamID) - } - var raw json.RawMessage - if err := sqlx.GetContext(context.Background(), q, &raw, stmt); err != nil { - return err - } - if err := json.Unmarshal(raw, &settings); err != nil { - return err - } - return nil - }) - return &settings - } - - setConfigSettings := func(teamID uint, key string, minVersion string) { - var mv *string - if minVersion != "" { - mv = &minVersion - } - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - stmt := fmt.Sprintf(`UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.%s_updates.minimum_version', ?)`, key) - if teamID > 0 { - stmt = fmt.Sprintf(`UPDATE teams SET config = JSON_SET(config, '$.mdm.%s_updates.minimum_version', ?) WHERE id = %d`, key, teamID) - } - if _, err := q.ExecContext(context.Background(), stmt, mv); err != nil { - return err - } - return nil - }) - } - - checkExpectedVersion := func(t *testing.T, gotSettings *fleet.AppleOSUpdateSettings, expectedVersion string) { - if expectedVersion == "" { - require.True(t, gotSettings.MinimumVersion.Set) - require.False(t, gotSettings.MinimumVersion.Valid) - require.Empty(t, gotSettings.MinimumVersion.Value) - } else { - require.True(t, gotSettings.MinimumVersion.Set) - require.True(t, gotSettings.MinimumVersion.Valid) - require.Equal(t, expectedVersion, gotSettings.MinimumVersion.Value) - } - } - - checkDevice := func(t *testing.T, teamID uint, key string, wantVersion string) { - checkExpectedVersion(t, getConfigSettings(teamID, key), wantVersion) - gotSettings, err := ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), devicesByKey[key].SerialNumber) - require.NoError(t, err) - checkExpectedVersion(t, gotSettings, wantVersion) - } - - // empty global settings to start - for _, key := range keys { - checkExpectedVersion(t, getConfigSettings(0, key), "") - } - - // ingest some test devices - n, _, err := ds.IngestMDMAppleDevicesFromDEPSync(context.Background(), []godep.Device{devicesByKey["ios"], devicesByKey["ipados"], devicesByKey["macos"]}) - require.NoError(t, err) - require.Equal(t, int64(3), n) - hostIDsByKey := map[string]uint{} - for key, device := range devicesByKey { - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - var hid uint - err = sqlx.GetContext(context.Background(), q, &hid, "SELECT id FROM hosts WHERE hardware_serial = ?", device.SerialNumber) - require.NoError(t, err) - hostIDsByKey[key] = hid - return nil - }) - } - - // not set in global config, so devics should return empty - checkDevice(t, 0, "ios", "") - checkDevice(t, 0, "ipados", "") - checkDevice(t, 0, "macos", "") - - // set the minimum version for ios - setConfigSettings(0, "ios", "17.1") - checkDevice(t, 0, "ios", "17.1") - checkDevice(t, 0, "ipados", "") // no change - checkDevice(t, 0, "macos", "") // no change - - // set the minimum version for ipados - setConfigSettings(0, "ipados", "17.2") - checkDevice(t, 0, "ios", "17.1") // no change - checkDevice(t, 0, "ipados", "17.2") - checkDevice(t, 0, "macos", "") // no change - - // set the minimum version for macos - setConfigSettings(0, "macos", "14.5") - checkDevice(t, 0, "ios", "17.1") // no change - checkDevice(t, 0, "ipados", "17.2") // no change - checkDevice(t, 0, "macos", "14.5") - - // create a team - team, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) - require.NoError(t, err) - - // empty team settings to start - for _, key := range keys { - checkExpectedVersion(t, getConfigSettings(team.ID, key), "") - } - - // transfer ios and ipados to the team - err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{hostIDsByKey["ios"], hostIDsByKey["ipados"]}) - require.NoError(t, err) - - checkDevice(t, team.ID, "ios", "") // team settings are empty to start - checkDevice(t, team.ID, "ipados", "") // team settings are empty to start - checkDevice(t, 0, "macos", "14.5") // no change, still global - - setConfigSettings(team.ID, "ios", "17.3") - checkDevice(t, team.ID, "ios", "17.3") // team settings are set for ios - checkDevice(t, team.ID, "ipados", "") // team settings are empty for ipados - checkDevice(t, 0, "macos", "14.5") // no change, still global - - setConfigSettings(team.ID, "ipados", "17.4") - checkDevice(t, team.ID, "ios", "17.3") // no change in team settings for ios - checkDevice(t, team.ID, "ipados", "17.4") // team settings are set for ipados - checkDevice(t, 0, "macos", "14.5") // no change, still global - - // transfer macos to the team - err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{hostIDsByKey["macos"]}) - require.NoError(t, err) - checkDevice(t, team.ID, "macos", "") // team settings are empty for macos - - setConfigSettings(team.ID, "macos", "14.6") - checkDevice(t, team.ID, "macos", "14.6") // team settings are set for macos - - // create a non-DEP host - _, err = ds.NewHost(context.Background(), &fleet.Host{ - DetailUpdatedAt: time.Now(), - LabelUpdatedAt: time.Now(), - PolicyUpdatedAt: time.Now(), - SeenTime: time.Now(), - OsqueryHostID: ptr.String("non-dep-osquery-id"), - NodeKey: ptr.String("non-dep-node-key"), - UUID: "non-dep-uuid", - Hostname: "non-dep-hostname", - Platform: "macos", - HardwareSerial: "non-dep-serial", - }) - - // non-DEP host should return not found - _, err = ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), "non-dep-serial") - require.ErrorIs(t, err, sql.ErrNoRows) - - // deleted DEP host should return not found - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(context.Background(), "UPDATE host_dep_assignments SET deleted_at = NOW() WHERE host_id = ?", hostIDsByKey["macos"]) - return err - }) - _, err = ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), devicesByKey["macos"].SerialNumber) - require.ErrorIs(t, err, sql.ErrNoRows) -} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 1afd8e93de..fb4b88f077 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -541,7 +541,8 @@ var additionalHostRefsByUUID = map[string]string{ // the rows are not deleted when the host is deleted, only a soft delete is // performed by setting a timestamp column to the current time. var additionalHostRefsSoftDelete = map[string]string{ - "host_script_results": "host_deleted_at", + "host_script_results": "host_deleted_at", + "host_software_installs": "host_deleted_at", } func (ds *Datastore) DeleteHost(ctx context.Context, hid uint) error { @@ -1045,7 +1046,7 @@ func (ds *Datastore) applyHostFilters( if err != nil { return "", nil, ctxerr.Wrap(ctx, err, "get vpp app by team and title id") } - vppAppJoin, vppAppParams, err := ds.vppAppJoin(vppApp.AdamID, *opt.SoftwareStatusFilter) + vppAppJoin, vppAppParams, err := ds.vppAppJoin(vppApp.VPPAppID, *opt.SoftwareStatusFilter) if err != nil { return "", nil, ctxerr.Wrap(ctx, err, "vpp app join") } @@ -2271,7 +2272,6 @@ func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) h.policy_updated_at, h.public_ip, h.orbit_node_key, - COALESCE(hdek.reset_requested, false) AS disk_encryption_reset_requested, IF(hdep.host_id AND ISNULL(hdep.deleted_at), true, false) AS dep_assigned_to_fleet, hd.encrypted as disk_encryption_enabled, COALESCE(hdek.decryptable, false) as encryption_key_available, @@ -4944,7 +4944,7 @@ func (ds *Datastore) ListUpcomingHostMaintenanceWindows(ctx context.Context, hid WHERE hce.host_id = ? AND - ce.start_time > NOW() + ce.start_time > NOW() ORDER BY ce.start_time ` @@ -4955,20 +4955,6 @@ func (ds *Datastore) ListUpcomingHostMaintenanceWindows(ctx context.Context, hid return mws, nil } -func (ds *Datastore) SetDiskEncryptionResetStatus(ctx context.Context, hostID uint, status bool) error { - const stmt = ` - INSERT INTO host_disk_encryption_keys (host_id, reset_requested, base64_encrypted) - VALUES (?, ?, '') - ON DUPLICATE KEY UPDATE - reset_requested = VALUES(reset_requested)` - - _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, status) - if err != nil { - return ctxerr.Wrap(ctx, err, "upsert disk encryption reset status") - } - return nil -} - // countHostNotResponding counts the hosts that haven't been submitting results for sent queries. // // Notes: diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index fb37661ccf..379adf166d 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -6743,6 +6743,15 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { `, host.ID, calendarEventID) require.NoError(t, err) + softwareInstaller, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "", + PreInstallQuery: "", + Title: "ChocolateRain", + }) + require.NoError(t, err) + _, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false) + require.NoError(t, err) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool @@ -7616,7 +7625,6 @@ func testHostsLoadHostByOrbitNodeKey(t *testing.T, ds *Datastore) { // the returned host by LoadHostByOrbitNodeKey will have the orbit key stored h.OrbitNodeKey = &orbitKey - h.DiskEncryptionResetRequested = ptr.Bool(false) returned, err := ds.LoadHostByOrbitNodeKey(ctx, orbitKey) require.NoError(t, err) @@ -7696,8 +7704,8 @@ func testHostsLoadHostByOrbitNodeKey(t *testing.T, ds *Datastore) { require.NoError(t, err) loadFleet, err = ds.LoadHostByOrbitNodeKey(ctx, *hFleet.OrbitNodeKey) require.NoError(t, err) - require.True(t, loadFleet.MDM.EncryptionKeyAvailable) require.NoError(t, err) + require.True(t, loadFleet.MDM.EncryptionKeyAvailable) require.NotNil(t, loadFleet.DiskEncryptionEnabled) require.True(t, *loadFleet.DiskEncryptionEnabled) @@ -8361,8 +8369,8 @@ func testHostsEncryptionKeyRawDecryption(t *testing.T, ds *Datastore) { // no disk encryption key information got, err := ds.Host(ctx, host.ID) require.NoError(t, err) - require.False(t, got.MDM.EncryptionKeyAvailable) require.NotNil(t, got.MDM.TestGetRawDecryptable()) + require.False(t, got.MDM.EncryptionKeyAvailable) require.Equal(t, -1, *got.MDM.TestGetRawDecryptable()) // create the encryption key row, but unknown decryptable @@ -8380,8 +8388,8 @@ func testHostsEncryptionKeyRawDecryption(t *testing.T, ds *Datastore) { got, err = ds.Host(ctx, host.ID) require.NoError(t, err) - require.False(t, got.MDM.EncryptionKeyAvailable) require.NotNil(t, got.MDM.TestGetRawDecryptable()) + require.False(t, got.MDM.EncryptionKeyAvailable) require.Equal(t, 0, *got.MDM.TestGetRawDecryptable()) // mark the key as decryptable @@ -8390,8 +8398,8 @@ func testHostsEncryptionKeyRawDecryption(t *testing.T, ds *Datastore) { got, err = ds.Host(ctx, host.ID) require.NoError(t, err) - require.True(t, got.MDM.EncryptionKeyAvailable) require.NotNil(t, got.MDM.TestGetRawDecryptable()) + require.True(t, got.MDM.EncryptionKeyAvailable) require.Equal(t, 1, *got.MDM.TestGetRawDecryptable()) } diff --git a/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls.go b/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls.go new file mode 100644 index 0000000000..6b70578339 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls.go @@ -0,0 +1,60 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240801115359, Down_20240801115359) +} + +func Up_20240801115359(tx *sql.Tx) error { + if columnExists(tx, "host_vpp_software_installs", "platform") { + return nil + } + + _, err := tx.Exec(` + ALTER TABLE host_vpp_software_installs + ADD COLUMN platform VARCHAR(10) COLLATE utf8mb4_unicode_ci NOT NULL`) + if err != nil { + return fmt.Errorf("adding platform to host_vpp_software_installs: %w", err) + } + + updateStmt := ` + UPDATE host_vpp_software_installs hvsi INNER JOIN hosts h ON h.id = hvsi.host_id + SET hvsi.platform = h.platform, hvsi.updated_at = hvsi.updated_at` + + _, err = tx.Exec(updateStmt) + if err != nil { + return fmt.Errorf("updating platform in host_vpp_software_installs: %w", err) + } + + // Since hosts may be missing, we need to update the platform for records that were not updated + updateStmt2 := ` + UPDATE host_vpp_software_installs hvsi INNER JOIN vpp_apps vap ON vap.adam_id = hvsi.adam_id + SET hvsi.platform = vap.platform, hvsi.updated_at = hvsi.updated_at + WHERE hvsi.platform = ''` + + _, err = tx.Exec(updateStmt2) + if err != nil { + return fmt.Errorf("updating platform in host_vpp_software_installs part 2: %w", err) + } + + _, err = tx.Exec(`ALTER TABLE host_vpp_software_installs DROP INDEX adam_id, ADD INDEX (adam_id, platform)`) + if err != nil { + return fmt.Errorf("updating key in host_vpp_software_installs: %w", err) + } + _, err = tx.Exec(` + ALTER TABLE host_vpp_software_installs DROP FOREIGN KEY host_vpp_software_installs_ibfk_2, + ADD FOREIGN KEY host_vpp_software_installs_ibfk_3 (adam_id, platform) REFERENCES vpp_apps (adam_id, platform) ON DELETE CASCADE`) + if err != nil { + return fmt.Errorf("updating foreign key in host_vpp_software_installs: %w", err) + } + + return nil +} + +func Down_20240801115359(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls_test.go b/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls_test.go new file mode 100644 index 0000000000..98a5a19f26 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls_test.go @@ -0,0 +1,68 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUp_20240801115359(t *testing.T) { + db := applyUpToPrev(t) + + // Create user + u1 := execNoErrLastID(t, db, `INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`, "u1", "u1@b.c", "1234", "salt") + // Create host + insertHostStmt := ` + INSERT INTO hosts ( + hostname, uuid, platform, osquery_version, os_version, build, platform_like, code_name, + cpu_type, cpu_subtype, cpu_brand, hardware_vendor, hardware_model, hardware_version, + hardware_serial, computer_name, team_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + hostName := "Dummy Hostname" + hostUUID := "12345678-1234-1234-1234-123456789012" + hostPlatform := "ios" + osqueryVer := "5.9.1" + osVersion := "Windows 10" + buildVersion := "10.0.19042.1234" + platformLike := "apple" + codeName := "20H2" + cpuType := "x86_64" + cpuSubtype := "x86_64" + cpuBrand := "Intel" + hwVendor := "Dell Inc." + hwModel := "OptiPlex 7090" + hwVersion := "1.0" + hwSerial := "ABCDEFGHIJ" + computerName := "DESKTOP-TEST" + + hostID := execNoErrLastID(t, db, insertHostStmt, hostName, hostUUID, hostPlatform, osqueryVer, + osVersion, buildVersion, platformLike, codeName, cpuType, cpuSubtype, cpuBrand, hwVendor, hwModel, hwVersion, hwSerial, computerName, nil) + hostIDMissing := hostID + 1 + + // Create VPP app + adamID := "a" + execNoErr( + t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?,?)`, adamID, hostPlatform, + ) + // Create another VPP app for a different platform + execNoErr( + t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?,?)`, adamID, "unused", + ) + + // create an install on a known host + hvsi1 := execNoErrLastID(t, db, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, user_id) VALUES (?,?,?,?)`, hostID, adamID, "command_uuid", u1) + // create an install on a missing host + hvsi2 := execNoErrLastID(t, db, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, user_id) VALUES (?,?,?,?)`, hostIDMissing, adamID, "command_uuid2", u1) + + // Apply current migration. + applyNext(t, db) + + // Check that the platform column was updated. + var platformResp string + require.NoError(t, db.Get(&platformResp, `SELECT platform FROM host_vpp_software_installs WHERE id = ?`, hvsi1)) + assert.Equal(t, hostPlatform, platformResp) + require.NoError(t, db.Get(&platformResp, `SELECT platform FROM host_vpp_software_installs WHERE id = ?`, hvsi2)) + assert.Equal(t, hostPlatform, platformResp) +} diff --git a/server/datastore/mysql/migrations/tables/20240802101043_AddSoftwareInstallResultDeletedAt.go b/server/datastore/mysql/migrations/tables/20240802101043_AddSoftwareInstallResultDeletedAt.go new file mode 100644 index 0000000000..b5f7e79213 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240802101043_AddSoftwareInstallResultDeletedAt.go @@ -0,0 +1,39 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240802101043, Down_20240802101043) +} + +// This is a new copy of a previous out-of-order migration +func Up_20240802101043(tx *sql.Tx) error { + _, err := tx.Exec("ALTER TABLE host_software_installs ADD COLUMN host_deleted_at timestamp NULL DEFAULT NULL") + if err != nil { + return fmt.Errorf("failed to create host_deleted_at column on host_software_installs table: %w", err) + } + _, err = tx.Exec(` +UPDATE + host_software_installs i +LEFT JOIN + hosts h + ON i.host_id = h.id +SET + i.host_deleted_at = NOW() +WHERE + i.host_deleted_at IS NULL +AND + h.id IS NULL +`) + if err != nil { + return fmt.Errorf("failed to update host_software_installs.host_deleted_at for hosts that no longer exist: %w", err) + } + return nil +} + +func Down_20240802101043(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig.go b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig.go new file mode 100644 index 0000000000..c91e99099e --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig.go @@ -0,0 +1,67 @@ +package tables + +import ( + "database/sql" + "encoding/json" + "fmt" + "reflect" + + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" +) + +func init() { + MigrationClient.AddMigration(Up_20240802113716, Down_20240802113716) +} + +func Up_20240802113716(tx *sql.Tx) error { + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + + type row struct { + Config json.RawMessage `db:"config"` + ID uint `db:"id"` + } + + var rows []row + if err := txx.Select(&rows, "SELECT config, id FROM teams"); err != nil { + return fmt.Errorf("selecting team configs: %w", err) + } + + for _, r := range rows { + + config := make(map[string]any) + if err := json.Unmarshal(r.Config, &config); err != nil { + return fmt.Errorf("unmarshal team config: %w", err) + } + softwareData, ok := config["software"] + if !ok { + continue + } + + rt := reflect.TypeOf(config["software"]) + if rt == nil { + continue + } + + if rt.Kind() == reflect.Slice { + // then we have an older config without the new fields + // Note: we are setting the new key to be whatever the old key was (if it was null, then + // it's set to null, if it was empty array, then it's set to empty array) + config["software"] = map[string]any{"packages": softwareData} + b, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("marshal updated team config: %w", err) + } + if _, err := tx.Exec(`UPDATE teams SET config = ? WHERE id = ?`, b, r.ID); err != nil { + return fmt.Errorf("updating config for team %d: %w", r.ID, err) + } + } + + } + + return nil +} + +func Down_20240802113716(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig_test.go b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig_test.go new file mode 100644 index 0000000000..0bd2edcab4 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig_test.go @@ -0,0 +1,268 @@ +package tables + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestUp_20240802113716(t *testing.T) { + db := applyUpToPrev(t) + + badCfg := ` +{ + "mdm": { + "ios_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_setup": { + "bootstrap_package": "", + "macos_setup_assistant": "", + "enable_end_user_authentication": false, + "enable_release_device_manually": false + }, + "macos_updates": { + "deadline": "", + "minimum_version": "" + }, + "ipados_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_settings": { + "custom_settings": [] + }, + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + }, + "windows_settings": { + "custom_settings": [] + }, + "enable_disk_encryption": false + }, + "scripts": [], + "features": { + "enable_host_users": true, + "enable_software_inventory": true + }, + "software": [ + { + "url": "http://localhost:8100/1Password.pkg", + "self_service": true, + "install_script": { + "path": "" + }, + "pre_install_query": { + "path": "" + }, + "post_install_script": { + "path": "" + } + } + ], + "integrations": { + "jira": null, + "zendesk": null, + "google_calendar": { + "webhook_url": "", + "enable_calendar_events": false + } + }, + "webhook_settings": { + "host_status_webhook": { + "days_count": 0, + "destination_url": "", + "host_percentage": 0, + "enable_host_status_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": 30, + "host_expiry_enabled": true + } +} + +` + + badCfgEmptyArr := ` +{ + "mdm": { + "ios_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_setup": { + "bootstrap_package": "", + "macos_setup_assistant": "", + "enable_end_user_authentication": false, + "enable_release_device_manually": false + }, + "macos_updates": { + "deadline": "", + "minimum_version": "" + }, + "ipados_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_settings": { + "custom_settings": [] + }, + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + }, + "windows_settings": { + "custom_settings": [] + }, + "enable_disk_encryption": false + }, + "scripts": [], + "features": { + "enable_host_users": true, + "enable_software_inventory": true + }, + "software": [], + "integrations": { + "jira": null, + "zendesk": null, + "google_calendar": { + "webhook_url": "", + "enable_calendar_events": false + } + }, + "webhook_settings": { + "host_status_webhook": { + "days_count": 0, + "destination_url": "", + "host_percentage": 0, + "enable_host_status_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": 30, + "host_expiry_enabled": true + } +} +` + + badCfgNoSoftwareField := ` +{ + "mdm": { + "ios_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_setup": { + "bootstrap_package": "", + "macos_setup_assistant": "", + "enable_end_user_authentication": false, + "enable_release_device_manually": false + }, + "macos_updates": { + "deadline": "", + "minimum_version": "" + }, + "ipados_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_settings": { + "custom_settings": [] + }, + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + }, + "windows_settings": { + "custom_settings": [] + }, + "enable_disk_encryption": false + }, + "scripts": [], + "features": { + "enable_host_users": true, + "enable_software_inventory": true + }, + "integrations": { + "jira": null, + "zendesk": null, + "google_calendar": { + "webhook_url": "", + "enable_calendar_events": false + } + }, + "webhook_settings": { + "host_status_webhook": { + "days_count": 0, + "destination_url": "", + "host_percentage": 0, + "enable_host_status_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": 30, + "host_expiry_enabled": true + } +} +` + + tid1 := execNoErrLastID(t, db, `INSERT INTO teams (name, config) VALUES (?,?)`, "team 1", badCfg) + tid2 := execNoErrLastID(t, db, `INSERT INTO teams (name, config) VALUES (?,?)`, "team 2", badCfgEmptyArr) + tid3 := execNoErrLastID(t, db, `INSERT INTO teams (name, config) VALUES (?,?)`, "team 3", badCfgNoSoftwareField) + + // Apply current migration. + applyNext(t, db) + + var team fleet.Team + require.NoError(t, db.Get(&team, "SELECT id, config FROM teams WHERE id = ?", tid1)) + + // Team with a package should see it in the new field + require.NotNil(t, team.Config.Software) + require.True(t, team.Config.Software.Packages.Set) + require.True(t, team.Config.Software.Packages.Valid) + require.Len(t, team.Config.Software.Packages.Value, 1) + + require.False(t, team.Config.Software.AppStoreApps.Set) + require.False(t, team.Config.Software.AppStoreApps.Valid) + require.Len(t, team.Config.Software.AppStoreApps.Value, 0) + + team = fleet.Team{} + require.NoError(t, db.Get(&team, "SELECT id, config FROM teams WHERE id = ?", tid2)) + + // Team with an empty array originally should have JSON null set for packages + require.NotNil(t, team.Config.Software) + require.True(t, team.Config.Software.Packages.Set) + require.True(t, team.Config.Software.Packages.Valid) + require.Len(t, team.Config.Software.Packages.Value, 0) + + require.False(t, team.Config.Software.AppStoreApps.Set) + require.False(t, team.Config.Software.AppStoreApps.Valid) + require.Len(t, team.Config.Software.AppStoreApps.Value, 0) + + team = fleet.Team{} + require.NoError(t, db.Get(&team, "SELECT id, config FROM teams WHERE id = ?", tid3)) + + require.Nil(t, team.Config.Software) +} diff --git a/server/datastore/mysql/migrations_test.go b/server/datastore/mysql/migrations_test.go index a8a010dc8f..e782e0d2b2 100644 --- a/server/datastore/mysql/migrations_test.go +++ b/server/datastore/mysql/migrations_test.go @@ -64,7 +64,7 @@ func TestMigrations(t *testing.T) { // Dump schema to dumpfile cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_test", + "docker", "compose", "exec", "-T", "mysql_test", // Command run inside container "mysqldump", "-u"+testUsername, "-p"+testPassword, "TestMigrations", "--compact", "--skip-comments", ) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 74f18b3289..9efa895008 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -519,6 +519,7 @@ CREATE TABLE `host_software_installs` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `self_service` tinyint(1) NOT NULL DEFAULT '0', + `host_deleted_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_host_software_installs_execution_id` (`execution_id`), KEY `fk_host_software_installs_installer_id` (`software_installer_id`), @@ -563,13 +564,14 @@ CREATE TABLE `host_vpp_software_installs` ( `associated_event_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_host_vpp_software_installs_command_uuid` (`command_uuid`), KEY `user_id` (`user_id`), - KEY `adam_id` (`adam_id`), + KEY `adam_id` (`adam_id`,`platform`), CONSTRAINT `host_vpp_software_installs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, - CONSTRAINT `host_vpp_software_installs_ibfk_2` FOREIGN KEY (`adam_id`) REFERENCES `vpp_apps` (`adam_id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CONSTRAINT `host_vpp_software_installs_ibfk_3` FOREIGN KEY (`adam_id`, `platform`) REFERENCES `vpp_apps` (`adam_id`, `platform`) ON DELETE CASCADE +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -968,9 +970,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=292 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=295 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,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,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,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index b91a38a89d..76ab0da294 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2129,7 +2129,7 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id LEFT OUTER JOIN vpp_apps vap ON st.id = vap.title_id LEFT OUTER JOIN - host_vpp_software_installs hvsi ON vap.adam_id = hvsi.adam_id AND hvsi.host_id = :host_id + host_vpp_software_installs hvsi ON vap.adam_id = hvsi.adam_id AND vap.platform = hvsi.platform AND hvsi.host_id = :host_id LEFT OUTER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid WHERE @@ -2143,7 +2143,7 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id ( hvsi.id IS NULL OR hvsi.id = ( SELECT hvsi2.id FROM host_vpp_software_installs hvsi2 - WHERE hvsi2.host_id = hvsi.host_id AND hvsi2.adam_id = hvsi.adam_id + WHERE hvsi2.host_id = hvsi.host_id AND hvsi2.adam_id = hvsi.adam_id AND hvsi2.platform = hvsi.platform ORDER BY hvsi2.created_at DESC LIMIT 1 ) ) AND -- software is installed on host @@ -2159,8 +2159,6 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id ) OR -- or software install has been attempted on host (via installer or VPP app) hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL ) - -- make sure VPP platform matches - AND (vap.platform IS NULL OR vap.platform = :host_platform) %s %s `, softwareInstallerHostStatusNamedQuery("hsi", ""), vppAppHostStatusNamedQuery("hvsi", "ncr", ""), onlySelfServiceClause, onlyVulnerableClause) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 9539ca371a..c491d38139 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -337,19 +337,17 @@ SELECT hsi.post_install_script_output, hsi.install_script_output, hsi.host_id AS host_id, - h.computer_name AS host_display_name, st.name AS software_title, st.id AS software_title_id, COALESCE(%s, '') AS status, si.filename AS software_package, - h.team_id AS host_team_id, hsi.user_id AS user_id, hsi.post_install_script_exit_code, hsi.install_script_exit_code, - hsi.self_service + hsi.self_service, + hsi.host_deleted_at FROM host_software_installs hsi - JOIN hosts h ON h.id = hsi.host_id JOIN software_installers si ON si.id = hsi.software_installer_id JOIN software_titles st ON si.title_id = st.id WHERE @@ -400,6 +398,7 @@ WHERE FROM host_software_installs WHERE software_installer_id = :installer_id + AND host_deleted_at IS NULL GROUP BY host_id)) s`, softwareInstallerHostStatusNamedQuery("hsi", "status")) @@ -421,7 +420,7 @@ WHERE return &dest, nil } -func (ds *Datastore) vppAppJoin(adamID string, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) { +func (ds *Datastore) vppAppJoin(appID fleet.VPPAppID, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) { stmt := fmt.Sprintf(`JOIN ( SELECT host_id @@ -430,13 +429,13 @@ FROM LEFT OUTER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid WHERE - adam_id = :adam_id + adam_id = :adam_id AND platform = :platform AND hvsi.id IN( SELECT max(id) -- ensure we use only the most recent install attempt for each host FROM host_vpp_software_installs WHERE - adam_id = :adam_id + adam_id = :adam_id AND platform = :platform GROUP BY host_id, adam_id) AND (%s) = :status) hss ON hss.host_id = h.id @@ -444,7 +443,8 @@ WHERE return sqlx.Named(stmt, map[string]interface{}{ "status": status, - "adam_id": adamID, + "adam_id": appID.AdamID, + "platform": appID.Platform, "software_status_installed": fleet.SoftwareInstallerInstalled, "software_status_failed": fleet.SoftwareInstallerFailed, "software_status_pending": fleet.SoftwareInstallerPending, diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index be7fbbb69f..f2630770a7 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -328,7 +328,6 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { require.Equal(t, tc.expectedStatus, res.Status) require.Equal(t, swFilename, res.SoftwarePackage) require.Equal(t, host.ID, res.HostID) - require.Equal(t, host.DisplayName(), res.HostDisplayName) require.Equal(t, tc.preInstallQueryOutput, res.PreInstallQueryOutput) require.Equal(t, tc.postInstallScriptOutput, res.PostInstallScriptOutput) require.Equal(t, tc.installScriptOutput, res.Output) diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index c73bc4e37f..2c214616dc 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -40,7 +40,7 @@ FROM software_titles st LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? LEFT JOIN vpp_apps vap ON vap.title_id = st.id -LEFT JOIN vpp_apps_teams vat ON vat.global_or_team_id = ? AND vat.adam_id = vap.adam_id +LEFT JOIN vpp_apps_teams vat ON vat.global_or_team_id = ? AND vat.adam_id = vap.adam_id AND vat.platform = vap.platform WHERE st.id = ? AND ((sthc.hosts_count > 0 AND %s) OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL) GROUP BY diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index 5fdc6a9082..f36ed5f961 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -237,7 +237,7 @@ func setupRealReplica(t testing.TB, testName string, ds *Datastore, options *dbO func() { // Stop slave if out, err := exec.Command( - "docker-compose", "exec", "-T", "mysql_replica_test", + "docker", "compose", "exec", "-T", "mysql_replica_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, @@ -282,7 +282,7 @@ func setupRealReplica(t testing.TB, testName string, ds *Datastore, options *dbO // Configure slave and start replication if out, err := exec.Command( - "docker-compose", "exec", "-T", "mysql_replica_test", + "docker", "compose", "exec", "-T", "mysql_replica_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, @@ -348,7 +348,7 @@ func initializeDatabase(t testing.TB, testName string, opts *DatastoreTestOption ) cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_test", + "docker", "compose", "exec", "-T", "mysql_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, @@ -369,7 +369,7 @@ func initializeDatabase(t testing.TB, testName string, opts *DatastoreTestOption ) cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_replica_test", + "docker", "compose", "exec", "-T", "mysql_replica_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index ddc5bdcde7..72bc53ecd7 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -45,7 +45,8 @@ WHERE return &app, nil } -func (ds *Datastore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, adamID string) (*fleet.VPPAppStatusSummary, error) { +func (ds *Datastore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, appID fleet.VPPAppID) (*fleet.VPPAppStatusSummary, + error) { var dest fleet.VPPAppStatusSummary stmt := fmt.Sprintf(` @@ -63,7 +64,7 @@ INNER JOIN LEFT OUTER JOIN nano_command_results ncr ON ncr.id = h.uuid AND ncr.command_uuid = hvsi.command_uuid WHERE - hvsi.adam_id = :adam_id AND + hvsi.adam_id = :adam_id AND hvsi.platform = :platform AND (h.team_id = :team_id OR (h.team_id IS NULL AND :team_id = 0)) AND hvsi.id IN ( SELECT @@ -71,7 +72,7 @@ WHERE FROM host_vpp_software_installs hvsi2 WHERE - hvsi2.adam_id = :adam_id + hvsi2.adam_id = :adam_id AND hvsi2.platform = :platform GROUP BY hvsi2.host_id ) @@ -83,7 +84,8 @@ WHERE } query, args, err := sqlx.Named(stmt, map[string]interface{}{ - "adam_id": adamID, + "adam_id": appID.AdamID, + "platform": appID.Platform, "team_id": tmID, "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, "mdm_status_error": fleet.MDMAppleStatusError, @@ -430,15 +432,17 @@ WHERE vat.global_or_team_id = ? AND va.title_id = ? return &dest, nil } -func (ds *Datastore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, adamID, commandUUID, associatedEventID string) error { +func (ds *Datastore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, appID fleet.VPPAppID, + commandUUID, associatedEventID string) error { stmt := ` INSERT INTO host_vpp_software_installs - (host_id, adam_id, command_uuid, user_id, associated_event_id) + (host_id, adam_id, platform, command_uuid, user_id, associated_event_id) VALUES - (?,?,?,?,?) + (?,?,?,?,?,?) ` - if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, adamID, commandUUID, userID, associatedEventID); err != nil { + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, appID.AdamID, appID.Platform, commandUUID, userID, + associatedEventID); err != nil { return ctxerr.Wrap(ctx, err, "insert into host_vpp_software_installs") } diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 449789ebe1..27fce609d3 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -188,15 +188,15 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, nil) require.NoError(t, err) - vpp1 := va1.AdamID + vpp1 := va1.VPPAppID va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, &team1.ID) require.NoError(t, err) - vpp2 := va2.AdamID + vpp2 := va2.VPPAppID va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, nil) require.NoError(t, err) - vpp3 := va3.AdamID + vpp3 := va3.VPPAppID _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, &team1.ID) require.NoError(t, err) @@ -251,7 +251,7 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.NoError(t, err) // simulate an install request of vpp1 on h1 - cmd1 := createVPPAppInstallRequest(t, ds, h1, vpp1, user.ID) + cmd1 := createVPPAppInstallRequest(t, ds, h1, vpp1.AdamID, user.ID) summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) require.NoError(t, err) @@ -266,8 +266,8 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { // create a new request for h1 that supercedes the failed on, and a request // for h2 with a successful result. - cmd2 := createVPPAppInstallRequest(t, ds, h1, vpp1, user.ID) - cmd3 := createVPPAppInstallRequest(t, ds, h2, vpp1, user.ID) + cmd2 := createVPPAppInstallRequest(t, ds, h1, vpp1.AdamID, user.ID) + cmd3 := createVPPAppInstallRequest(t, ds, h2, vpp1.AdamID, user.ID) createVPPAppInstallResult(t, ds, h2, cmd3, fleet.MDMAppleStatusAcknowledged) actUser, act, err := ds.GetPastActivityDataForVPPAppInstall(ctx, &mdm.CommandResults{CommandUUID: cmd3}) @@ -293,7 +293,7 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 0}, summary) // simulate a successful request for team app vpp2 on h3 - cmd4 := createVPPAppInstallRequest(t, ds, h3, vpp2, user.ID) + cmd4 := createVPPAppInstallRequest(t, ds, h3, vpp2.AdamID, user.ID) createVPPAppInstallResult(t, ds, h3, cmd4, fleet.MDMAppleStatusAcknowledged) summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, &team1.ID, vpp2) @@ -302,11 +302,11 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { // simulate a successful, failed and pending request for app vpp3 on team // (h3) and no team (h1, h2) - cmd5 := createVPPAppInstallRequest(t, ds, h3, vpp3, user.ID) + cmd5 := createVPPAppInstallRequest(t, ds, h3, vpp3.AdamID, user.ID) createVPPAppInstallResult(t, ds, h3, cmd5, fleet.MDMAppleStatusAcknowledged) - cmd6 := createVPPAppInstallRequest(t, ds, h1, vpp3, user.ID) + cmd6 := createVPPAppInstallRequest(t, ds, h1, vpp3.AdamID, user.ID) createVPPAppInstallResult(t, ds, h1, cmd6, fleet.MDMAppleStatusCommandFormatError) - createVPPAppInstallRequest(t, ds, h2, vpp3, user.ID) + createVPPAppInstallRequest(t, ds, h2, vpp3.AdamID, user.ID) // for no team, it sees the failed and pending counts summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp3) @@ -330,8 +330,9 @@ func createVPPAppInstallRequest(t *testing.T, ds *Datastore, host *fleet.Host, a require.NoError(t, err) ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, user_id) VALUES (?, ?, ?, ?)`, - host.ID, adamID, cmdUUID, userID) + _, err := q.ExecContext(ctx, + `INSERT INTO host_vpp_software_installs (host_id, adam_id, platform, command_uuid, user_id) VALUES (?, ?, ?, ?, ?)`, + host.ID, adamID, host.Platform, cmdUUID, userID) return err }) return cmdUUID @@ -401,10 +402,10 @@ func testVPPApps(t *testing.T, ds *Datastore) { GlobalRole: ptr.String(fleet.RoleAdmin), }) require.NoError(t, err) - err = ds.InsertHostVPPSoftwareInstall(ctx, 1, u.ID, app1.AdamID, "a", "b") + err = ds.InsertHostVPPSoftwareInstall(ctx, 1, u.ID, app1.VPPAppID, "a", "b") require.NoError(t, err) - err = ds.InsertHostVPPSoftwareInstall(ctx, 2, u.ID, app2.AdamID, "c", "d") + err = ds.InsertHostVPPSoftwareInstall(ctx, 2, u.ID, app2.VPPAppID, "c", "d") require.NoError(t, err) var results []struct { diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index b86434ac8e..e1cabf2533 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -841,53 +841,3 @@ type MDMAppleDDMActivation struct { ServerToken string `json:"ServerToken"` Type string `json:"Type"` // "com.apple.activation.simple" } - -// MDMAppleMachineInfo is a [device's information][1] sent as part of an MDM enrollment profile request -// -// [1]: https://developer.apple.com/documentation/devicemanagement/machineinfo -type MDMAppleMachineInfo struct { - IMEI string `plist:"IMEI,omitempty"` - Language string `plist:"LANGUAGE,omitempty"` - MDMCanRequestSoftwareUpdate bool `plist:"MDM_CAN_REQUEST_SOFTWARE_UPDATE"` - MEID string `plist:"MEID,omitempty"` - OSVersion string `plist:"OS_VERSION"` - PairingToken string `plist:"PAIRING_TOKEN,omitempty"` - Product string `plist:"PRODUCT"` - Serial string `plist:"SERIAL"` - SoftwareUpdateDeviceID string `plist:"SOFTWARE_UPDATE_DEVICE_ID,omitempty"` - SupplementalBuildVersion string `plist:"SUPPLEMENTAL_BUILD_VERSION,omitempty"` - SupplementalOSVersionExtra string `plist:"SUPPLEMENTAL_OS_VERSION_EXTRA,omitempty"` - UDID string `plist:"UDID"` - Version string `plist:"VERSION"` -} - -// MDMAppleSoftwareUpdateRequiredCode is the [code][1] specified by Apple to indicate that the device -// needs to perform a software update before enrollment and setup can proceed. -// -// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired -const MDMAppleSoftwareUpdateRequiredCode = "com.apple.softwareupdate.required" - -// MDMAppleSoftwareUpdateRequiredDetails is the [details][1] specified by Apple for the -// required software update. -// -// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired/details -type MDMAppleSoftwareUpdateRequiredDetails struct { - OSVersion string `json:"OSVersion"` - BuildVersion string `json:"BuildVersion"` -} - -// MDMAppleSoftwareUpdateRequired is the [error response][1] specified by Apple to indicate that the device -// needs to perform a software update before enrollment and setup can proceed. -// -// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired -type MDMAppleSoftwareUpdateRequired struct { - Code string `json:"code"` // "com.apple.softwareupdate.required" - Details MDMAppleSoftwareUpdateRequiredDetails `json:"details"` -} - -func NewMDMAppleSoftwareUpdateRequired(settings AppleOSUpdateSettings) *MDMAppleSoftwareUpdateRequired { - return &MDMAppleSoftwareUpdateRequired{ - Code: MDMAppleSoftwareUpdateRequiredCode, - Details: MDMAppleSoftwareUpdateRequiredDetails{OSVersion: settings.MinimumVersion.Value}, - } -} diff --git a/server/fleet/calendar.go b/server/fleet/calendar.go index 72c3e9e0ba..fa9de8f07a 100644 --- a/server/fleet/calendar.go +++ b/server/fleet/calendar.go @@ -42,8 +42,8 @@ type UserCalendar interface { GetAndUpdateEvent(event *CalendarEvent, genBodyFn CalendarGenBodyFn, opts CalendarGetAndUpdateEventOpts) (updatedEvent *CalendarEvent, updated bool, err error) - // UpdateEventBody updates the body of the calendar event. - UpdateEventBody(event *CalendarEvent, genBodyFn CalendarGenBodyFn) error + // UpdateEventBody updates the body of the calendar event and returns new ETag + UpdateEventBody(event *CalendarEvent, genBodyFn CalendarGenBodyFn) (string, error) // DeleteEvent deletes the event with the given ID. DeleteEvent(event *CalendarEvent) error // StopEventChannel stops the event's callback channel. diff --git a/server/fleet/calendar_events.go b/server/fleet/calendar_events.go index 44d11306fc..5e730e283b 100644 --- a/server/fleet/calendar_events.go +++ b/server/fleet/calendar_events.go @@ -2,6 +2,7 @@ package fleet import ( "encoding/json" + "errors" "fmt" "time" ) @@ -30,7 +31,10 @@ func (ce *CalendarEvent) GetBodyTag() string { return d.BodyTag } -func (ce *CalendarEvent) SaveBodyTag(bodyTag string) error { +func (ce *CalendarEvent) SaveDataItems(keysAndValues ...string) error { + if len(keysAndValues)%2 != 0 { + return errors.New("SaveDataItem requires an even number of arguments") + } var result map[string]any if len(ce.Data) > 0 { err := json.Unmarshal(ce.Data, &result) @@ -40,7 +44,11 @@ func (ce *CalendarEvent) SaveBodyTag(bodyTag string) error { } else { result = make(map[string]any, 1) } - result["body_tag"] = bodyTag + for i := 0; i < len(keysAndValues); i += 2 { + key := keysAndValues[i] + value := keysAndValues[i+1] + result[key] = value + } data, err := json.Marshal(result) if err != nil { return fmt.Errorf("could not marshal event data: %w", err) diff --git a/server/fleet/calendar_events_test.go b/server/fleet/calendar_events_test.go index 3e79bd6bb4..931cd3889f 100644 --- a/server/fleet/calendar_events_test.go +++ b/server/fleet/calendar_events_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestBodyTag(t *testing.T) { +func TestSaveDataItems(t *testing.T) { t.Parallel() var event CalendarEvent @@ -16,7 +16,7 @@ func TestBodyTag(t *testing.T) { assert.Equal(t, "", event.GetBodyTag()) bodyTag := "bodyTag" - require.NoError(t, event.SaveBodyTag(bodyTag)) + require.NoError(t, event.SaveDataItems("body_tag", bodyTag)) assert.Equal(t, bodyTag, event.GetBodyTag()) testMap := make(map[string]any, 5) @@ -29,11 +29,11 @@ func TestBodyTag(t *testing.T) { event.Data = data assert.Equal(t, oldBodyTag, event.GetBodyTag()) - require.NoError(t, event.SaveBodyTag(bodyTag)) + require.NoError(t, event.SaveDataItems("body_tag", bodyTag)) assert.Equal(t, bodyTag, event.GetBodyTag()) // Make sure data was not modified - require.NoError(t, event.SaveBodyTag(oldBodyTag)) + require.NoError(t, event.SaveDataItems("body_tag", oldBodyTag)) var result map[string]any require.NoError(t, json.Unmarshal(event.Data, &result)) assert.Equal(t, testMap, result) diff --git a/server/fleet/capabilities.go b/server/fleet/capabilities.go index b1bfff1139..2e12810be8 100644 --- a/server/fleet/capabilities.go +++ b/server/fleet/capabilities.go @@ -78,6 +78,8 @@ const ( // CapabilityEndUserEmail denotes the ability of the server to support // receiving the end-user email from orbit. CapabilityEndUserEmail Capability = "end_user_email" + // CapabilityEscrowBuddy allows to use Escrow Buddy to rotate FileVault keys + CapabilityEscrowBuddy Capability = "escrow_buddy" ) func GetServerOrbitCapabilities() CapabilityMap { @@ -85,6 +87,7 @@ func GetServerOrbitCapabilities() CapabilityMap { CapabilityOrbitEndpoints: {}, CapabilityTokenRotation: {}, CapabilityEndUserEmail: {}, + CapabilityEscrowBuddy: {}, } } @@ -96,5 +99,11 @@ func GetServerDeviceCapabilities() CapabilityMap { return capabilities } +func GetOrbitClientCapabilities() CapabilityMap { + return CapabilityMap{ + CapabilityEscrowBuddy: {}, + } +} + // CapabilitiesHeader is the header name used to communicate the capabilities. const CapabilitiesHeader = "X-Fleet-Capabilities" diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index cb85d7782e..6847f398af 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -883,8 +883,6 @@ type Datastore interface { // GetHostDiskEncryptionKey returns the encryption key information for a given host GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*HostDiskEncryptionKey, error) - SetDiskEncryptionResetStatus(ctx context.Context, hostID uint, status bool) error - // GetHostCertAssociationsToExpire retrieves host certificate // associations that are close to expire and don't have a renewal in // progress based on the provided arguments. @@ -1288,10 +1286,6 @@ type Datastore interface { // the provided value. MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *MDMDeliveryStatus, detail string) error - // GetMDMAppleOSUpdatesSettingsByHostSerial returns applicable Apple OS update settings (if any) - // for the host with the given serial number. The host must be DEP assigned to Fleet. - GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (*AppleOSUpdateSettings, error) - // InsertMDMConfigAssets inserts MDM related config assets, such as SCEP and APNS certs and keys. InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error @@ -1578,7 +1572,7 @@ type Datastore interface { // GetSummaryHostVPPAppInstalls returns the VPP app install summary for the // given team and VPP app adam_id. - GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, adamID string) (*VPPAppStatusSummary, error) + GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, appID VPPAppID) (*VPPAppStatusSummary, error) GetSoftwareInstallResults(ctx context.Context, resultsUUID string) (*HostSoftwareInstallerResult, error) @@ -1597,7 +1591,7 @@ type Datastore interface { SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs []VPPAppID) error InsertVPPAppWithTeam(ctx context.Context, app *VPPApp, teamID *uint) (*VPPApp, error) - InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, adamID, commandUUID, associatedEventID string) error + InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, appID VPPAppID, commandUUID, associatedEventID string) error GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*User, *ActivityInstalledAppStoreApp, error) } diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 8dc1eca42e..ac0e86723d 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -321,10 +321,6 @@ type Host struct { // omitted if we don't have encryption information yet. DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty" db:"disk_encryption_enabled" csv:"-"` - // DiskEncryptionResetRequested is only fetched when loading a host by - // orbit_node_key, and so it's not used in the UI. - DiskEncryptionResetRequested *bool `json:"disk_encryption_reset_requested,omitempty" db:"disk_encryption_reset_requested" csv:"-"` - HostIssues `json:"issues,omitempty" csv:"-"` // DeviceMapping is in fact included in the CSV export, but it is not directly @@ -574,11 +570,7 @@ func (d *MDMHostData) PopulateOSSettingsAndMacOSSettings(profiles []HostMDMApple // but either we didn't get an encryption key or we're not able to // decrypt the key we've got settings.DiskEncryption = DiskEncryptionActionRequired.addrOf() - if *d.rawDecryptable == 0 { - settings.ActionRequired = ActionRequiredRotateKey.addrOf() - } else { - settings.ActionRequired = ActionRequiredLogOut.addrOf() - } + settings.ActionRequired = ActionRequiredRotateKey.addrOf() } else { // if [a FileVault profile is pending to be installed or] the // matching row in host_disk_encryption_keys has a field decryptable @@ -1230,19 +1222,3 @@ func IsEligibleForDEPMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetM // the checkout message from the host. (!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet) } - -// IsEligibleForBitLockerEncryption checks if the host needs to enforce disk -// encryption using Fleet MDM features. -func IsEligibleForBitLockerEncryption(h *Host, mdmInfo *HostMDM, isConnectedToFleetMDM bool) bool { - isServer := mdmInfo != nil && mdmInfo.IsServer - isWindows := h.FleetPlatform() == "windows" - needsEncryption := h.DiskEncryptionEnabled != nil && !*h.DiskEncryptionEnabled - encryptedWithoutKey := h.DiskEncryptionEnabled != nil && *h.DiskEncryptionEnabled && !h.MDM.EncryptionKeyAvailable - - return isWindows && - h.IsOsqueryEnrolled() && - isConnectedToFleetMDM && - !isServer && - mdmInfo != nil && - (needsEncryption || encryptedWithoutKey) -} diff --git a/server/fleet/hosts_test.go b/server/fleet/hosts_test.go index e350d701cd..94d0cd40a0 100644 --- a/server/fleet/hosts_test.go +++ b/server/fleet/hosts_test.go @@ -214,50 +214,6 @@ func TestMDMEnrollmentStatus(t *testing.T) { } } -func TestIsEligibleForBitLockerEncryption(t *testing.T) { - require.False(t, IsEligibleForBitLockerEncryption(&Host{}, &HostMDM{}, false)) - - hostThatNeedsEnforcement := &Host{ - Platform: "windows", - OsqueryHostID: ptr.String("test"), - MDM: MDMHostData{ - EncryptionKeyAvailable: false, - }, - DiskEncryptionEnabled: ptr.Bool(false), - } - hostThatNeedsEnforcementMdmInfo := &HostMDM{ - Name: WellKnownMDMFleet, - Enrolled: true, - IsServer: false, - InstalledFromDep: true, - } - require.True(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, true)) - - // macOS hosts are not elegible - hostThatNeedsEnforcement.Platform = "darwin" - require.False(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, true)) - hostThatNeedsEnforcement.Platform = "windows" - require.True(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, true)) - - // hosts with disk encryption already enabled are elegible only if we - // can't decrypt the key - hostThatNeedsEnforcement.DiskEncryptionEnabled = ptr.Bool(true) - require.True(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, true)) - hostThatNeedsEnforcement.MDM.EncryptionKeyAvailable = true - require.False(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, true)) - - hostThatNeedsEnforcement.DiskEncryptionEnabled = ptr.Bool(false) - hostThatNeedsEnforcement.MDM.EncryptionKeyAvailable = false - require.True(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, true)) - - // hosts without MDMinfo are not elegible - require.False(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, nil, true)) - require.True(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, true)) - - // hosts that are not enrolled in MDM are not elegible - require.False(t, IsEligibleForBitLockerEncryption(hostThatNeedsEnforcement, hostThatNeedsEnforcementMdmInfo, false)) -} - func TestIsEligibleForDEPMigration(t *testing.T) { testCases := []struct { name string diff --git a/server/fleet/service.go b/server/fleet/service.go index be8cf36adc..0148eb4162 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -918,9 +918,6 @@ type Service interface { GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte, error) - // CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment - CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error) - /////////////////////////////////////////////////////////////////////////////// // CronSchedulesService @@ -931,8 +928,6 @@ type Service interface { // for all hosts that are already marked as failing. ResetAutomation(ctx context.Context, teamIDs, policyIDs []uint) error - RequestEncryptionKeyRotation(ctx context.Context, hostID uint) error - /////////////////////////////////////////////////////////////////////////////// // Windows MDM diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 85cb4aa6a9..70a8f2ca36 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/ptr" ) @@ -155,8 +156,6 @@ type HostSoftwareInstallerResult struct { SoftwarePackage string `json:"software_package" db:"software_package"` // HostID is the ID of the host. HostID uint `json:"host_id" db:"host_id"` - // HostDisplayName is the display name of the host. - HostDisplayName string `json:"host_display_name" db:"host_display_name"` // Status is the status of the software installer package on the host. Status SoftwareInstallerStatus `json:"status" db:"status"` // Detail is the detail of the software installer package on the host. TODO: does this field @@ -172,9 +171,6 @@ type HostSoftwareInstallerResult struct { CreatedAt time.Time `json:"created_at" db:"created_at"` // UpdatedAt is the time the software installer request was last updated. UpdatedAt *time.Time `json:"updated_at" db:"updated_at"` - // HostTeamID is the team ID of the host on which this software install was attempted. This - // field is not sent in the response, it is only used for internal authorization. - HostTeamID *uint `json:"-" db:"host_team_id"` // UserID is the user ID that requested the software installation on that host. UserID *uint `json:"-" db:"user_id"` // InstallScriptExitCode is used internally to determine the output displayed to the user. @@ -184,6 +180,9 @@ type HostSoftwareInstallerResult struct { // SelfService indicates that the installation was queued by the // end user and not an administrator SelfService bool `json:"self_service" db:"self_service"` + // HostDeletedAt indicates if the data is associated with a + // deleted host + HostDeletedAt *time.Time `json:"-" db:"host_deleted_at"` } const ( @@ -336,6 +335,19 @@ type SoftwarePackageOrApp struct { LastInstall *HostSoftwareInstall `json:"last_install"` } +type SoftwarePackageSpec struct { + URL string `json:"url"` + SelfService bool `json:"self_service"` + PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` + InstallScript TeamSpecSoftwareAsset `json:"install_script"` + PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` +} + +type SoftwareSpec struct { + Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"` + AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"` +} + // HostSoftwareInstall represents installation of software on a host from a // Fleet software installer. type HostSoftwareInstall struct { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d5eaa71f8b..fa9734f6c1 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -155,7 +155,7 @@ type TeamConfig struct { Features Features `json:"features"` MDM TeamMDM `json:"mdm"` Scripts optjson.Slice[string] `json:"scripts,omitempty"` - Software *TeamSpecSoftware `json:"software,omitempty"` + Software *SoftwareSpec `json:"software,omitempty"` } type TeamWebhookSettings struct { @@ -168,23 +168,10 @@ type TeamSpecSoftwareAsset struct { Path string `json:"path"` } -type TeamSpecSoftware struct { - Packages optjson.Slice[TeamSpecSoftwarePackage] `json:"packages,omitempty"` - AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"` -} - type TeamSpecAppStoreApp struct { AppStoreID string `json:"app_store_id"` } -type TeamSpecSoftwarePackage struct { - URL string `json:"url"` - SelfService bool `json:"self_service"` - PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` - InstallScript TeamSpecSoftwareAsset `json:"install_script"` - PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` -} - type TeamMDM struct { EnableDiskEncryption bool `json:"enable_disk_encryption"` MacOSUpdates AppleOSUpdateSettings `json:"macos_updates"` @@ -450,7 +437,7 @@ type TeamSpec struct { Scripts optjson.Slice[string] `json:"scripts"` WebhookSettings TeamSpecWebhookSettings `json:"webhook_settings"` Integrations TeamSpecIntegrations `json:"integrations"` - Software *TeamSpecSoftware `json:"software,omitempty"` + Software *SoftwareSpec `json:"software,omitempty"` } type TeamSpecWebhookSettings struct { diff --git a/server/mdm/apple/AppleIncRootCertificate.cer b/server/mdm/apple/AppleIncRootCertificate.cer deleted file mode 100644 index 8a9ff24741..0000000000 Binary files a/server/mdm/apple/AppleIncRootCertificate.cer and /dev/null differ diff --git a/server/mdm/apple/deviceinfo.go b/server/mdm/apple/deviceinfo.go deleted file mode 100644 index ebed273690..0000000000 --- a/server/mdm/apple/deviceinfo.go +++ /dev/null @@ -1,179 +0,0 @@ -// The contents of this file have been copied and modified pursuant to the following -// license from the original source: -// https://github.com/korylprince/dep-webview-oidc/blob/2dd846a54fed04c16dd227b8c6c31665b4d0ebd8/header/header.go -// -// MIT License -// -// Copyright (c) 2023 Kory Prince -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package apple_mdm - -import ( - "bytes" - "crypto" - "crypto/rsa" - "crypto/sha1" // nolint:gosec // See comments regarding Apple's Root CA below - "crypto/x509" - _ "embed" - "encoding/base64" - "errors" - "fmt" - - "github.com/groob/plist" - "go.mozilla.org/pkcs7" -) - -const DeviceInfoHeader = "x-apple-aspen-deviceinfo" - -// appleRootCert is https://www.apple.com/appleca/AppleIncRootCertificate.cer -// -//go:embed AppleIncRootCertificate.cer -var appleRootCert []byte - -func newAppleRootCert() *x509.Certificate { - cert, err := x509.ParseCertificate(appleRootCert) - if err != nil { - panic(fmt.Errorf("could not parse cert: %w", err)) - } - return cert -} - -// appleRootCA is Apple's Root CA parsed to an *x509.Certificate -var appleRootCA = newAppleRootCert() - -// MachineInfo is a [device's information] sent as part of an MDM enrollment profile request -// -// [device's information]: https://developer.apple.com/documentation/devicemanagement/machineinfo -type MachineInfo struct { - IMEI string `plist:"IMEI,omitempty"` - Language string `plist:"LANGUAGE,omitempty"` - MDMCanRequestSoftwareUpdate bool `plist:"MDM_CAN_REQUEST_SOFTWARE_UPDATE"` - MEID string `plist:"MEID,omitempty"` - OSVersion string `plist:"OS_VERSION"` - PairingToken string `plist:"PAIRING_TOKEN,omitempty"` - Product string `plist:"PRODUCT"` - Serial string `plist:"SERIAL"` - SoftwareUpdateDeviceID string `plist:"SOFTWARE_UPDATE_DEVICE_ID,omitempty"` - SupplementalBuildVersion string `plist:"SUPPLEMENTAL_BUILD_VERSION,omitempty"` - SupplementalOSVersionExtra string `plist:"SUPPLEMENTAL_OS_VERSION_EXTRA,omitempty"` - UDID string `plist:"UDID"` - Version string `plist:"VERSION"` -} - -// verifyPKCS7SHA1RSA performs a manual SHA1withRSA verification, since it's deprecated in Go 1.18. -// If verifyChain is true, the signer certificate and its chain of certificates is verified against Apple's Root CA. -// Also note that the certificate validity time window of the signing cert is not checked, since the cert is expired. -// This follows guidance from Apple on the expired certificate. -func verifyPKCS7SHA1RSA(p7 *pkcs7.PKCS7, verifyChain bool) error { - if len(p7.Signers) == 0 { - return errors.New("not signed") - } - - // get signing cert - issuer := p7.Signers[0].IssuerAndSerialNumber - var signer *x509.Certificate - for _, cert := range p7.Certificates { - if bytes.Equal(cert.RawIssuer, issuer.IssuerName.FullBytes) && cert.SerialNumber.Cmp(issuer.SerialNumber) == 0 { - signer = cert - } - } - - // get sha1 hash of content - hashed := sha1.Sum(p7.Content) // nolint:gosec - - // verify content signature - signature := p7.Signers[0].EncryptedDigest - if err := rsa.VerifyPKCS1v15(signer.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], signature); err != nil { - return fmt.Errorf("signature could not be verified: %w", err) - } - - if !verifyChain { - return nil - } - - // verify chain from signer to root - cert := signer -outer: - for { - // check if cert is signed by root - if bytes.Equal(cert.RawIssuer, appleRootCA.RawSubject) { - hashed := sha1.Sum(cert.RawTBSCertificate) // nolint:gosec - // check signature - if err := rsa.VerifyPKCS1v15(appleRootCA.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], cert.Signature); err != nil { - return fmt.Errorf("could not verify root CA signature: %w", err) - } - return nil - } - for _, c := range p7.Certificates { - if cert == c { - continue - } - // check if cert is signed by intermediate cert in chain - if bytes.Equal(cert.RawIssuer, c.RawSubject) { - // check signature - hashed := sha1.Sum(cert.RawTBSCertificate) // nolint:gosec - if err := rsa.VerifyPKCS1v15(c.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], cert.Signature); err != nil { - return fmt.Errorf("could not verify chained certificate signature: %w", err) - } - cert = c - continue outer - } - } - return errors.New("certificate root not found") - } -} - -// ParseDeviceinfo attempts to parse the provided string, assuming it to be the base64-encoded value -// of an x-apple-aspen-deviceinfo header. If successful, it returns the parsed *MachineInfo. If the -// verify parameter is specified as true, the signature is also verified against Apple's Root CA and -// an error will be returned if the signature is invalid. -// -// Warning: The information in this header, despite being signed by Apple PKI, shouldn't be trusted -// for device attestation or other security purposes. See the related [documentation] and referenced -// [article] for more information. -// -// [documentation]: https://github.com/korylprince/dep-webview-oidc/blob/2dd846a54fed04c16dd227b8c6c31665b4d0ebd8/docs/Architecture.md#x-apple-aspen-deviceinfo-header -// [article]: https://duo.com/labs/research/mdm-me-maybe -func ParseDeviceinfo(b64 string, verify bool) (*MachineInfo, error) { - buf, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - return nil, fmt.Errorf("could not decode base64: %w", err) - } - - p7, err := pkcs7.Parse(buf) - if err != nil { - return nil, fmt.Errorf("could not decode pkcs7: %w", err) - } - - // verify signature and certificate chain - if verify { - if err = verifyPKCS7SHA1RSA(p7, verify); err != nil { - return nil, fmt.Errorf("could not verify signature: %w", err) - } - } - - info := new(MachineInfo) - if err = plist.Unmarshal(p7.Content, info); err != nil { - return nil, fmt.Errorf("could not decode plist: %w", err) - } - - return info, nil -} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index fdbd7a6238..e062476d60 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -618,8 +618,6 @@ type SetHostsDiskEncryptionKeyStatusFunc func(ctx context.Context, hostIDs []uin type GetHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) -type SetDiskEncryptionResetStatusFunc func(ctx context.Context, hostID uint, status bool) error - type GetHostCertAssociationsToExpireFunc func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) type SetCommandForPendingSCEPRenewalFunc func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error @@ -848,8 +846,6 @@ type MDMAppleStoreDDMStatusReportFunc func(ctx context.Context, hostUUID string, type MDMAppleSetPendingDeclarationsAsFunc func(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error -type GetMDMAppleOSUpdatesSettingsByHostSerialFunc func(ctx context.Context, hostSerial string) (*fleet.AppleOSUpdateSettings, error) - type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) @@ -992,7 +988,7 @@ type DeleteVPPAppFromTeamFunc func(ctx context.Context, teamID *uint, appID flee type GetSummaryHostSoftwareInstallsFunc func(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerStatusSummary, error) -type GetSummaryHostVPPAppInstallsFunc func(ctx context.Context, teamID *uint, adamID string) (*fleet.VPPAppStatusSummary, error) +type GetSummaryHostVPPAppInstallsFunc func(ctx context.Context, teamID *uint, appID fleet.VPPAppID) (*fleet.VPPAppStatusSummary, error) type GetSoftwareInstallResultsFunc func(ctx context.Context, resultsUUID string) (*fleet.HostSoftwareInstallerResult, error) @@ -1010,7 +1006,7 @@ type SetTeamVPPAppsFunc func(ctx context.Context, teamID *uint, appIDs []fleet.V type InsertVPPAppWithTeamFunc func(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) -type InsertHostVPPSoftwareInstallFunc func(ctx context.Context, hostID uint, userID uint, adamID string, commandUUID string, associatedEventID string) error +type InsertHostVPPSoftwareInstallFunc func(ctx context.Context, hostID uint, userID uint, appID fleet.VPPAppID, commandUUID string, associatedEventID string) error type GetPastActivityDataForVPPAppInstallFunc func(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) @@ -1912,9 +1908,6 @@ type DataStore struct { GetHostDiskEncryptionKeyFunc GetHostDiskEncryptionKeyFunc GetHostDiskEncryptionKeyFuncInvoked bool - SetDiskEncryptionResetStatusFunc SetDiskEncryptionResetStatusFunc - SetDiskEncryptionResetStatusFuncInvoked bool - GetHostCertAssociationsToExpireFunc GetHostCertAssociationsToExpireFunc GetHostCertAssociationsToExpireFuncInvoked bool @@ -2257,9 +2250,6 @@ type DataStore struct { MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFuncInvoked bool - GetMDMAppleOSUpdatesSettingsByHostSerialFunc GetMDMAppleOSUpdatesSettingsByHostSerialFunc - GetMDMAppleOSUpdatesSettingsByHostSerialFuncInvoked bool - InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFuncInvoked bool @@ -4602,13 +4592,6 @@ func (s *DataStore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) ( return s.GetHostDiskEncryptionKeyFunc(ctx, hostID) } -func (s *DataStore) SetDiskEncryptionResetStatus(ctx context.Context, hostID uint, status bool) error { - s.mu.Lock() - s.SetDiskEncryptionResetStatusFuncInvoked = true - s.mu.Unlock() - return s.SetDiskEncryptionResetStatusFunc(ctx, hostID, status) -} - func (s *DataStore) GetHostCertAssociationsToExpire(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) { s.mu.Lock() s.GetHostCertAssociationsToExpireFuncInvoked = true @@ -5407,13 +5390,6 @@ func (s *DataStore) MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUU return s.MDMAppleSetPendingDeclarationsAsFunc(ctx, hostUUID, status, detail) } -func (s *DataStore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (*fleet.AppleOSUpdateSettings, error) { - s.mu.Lock() - s.GetMDMAppleOSUpdatesSettingsByHostSerialFuncInvoked = true - s.mu.Unlock() - return s.GetMDMAppleOSUpdatesSettingsByHostSerialFunc(ctx, hostSerial) -} - func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { s.mu.Lock() s.InsertMDMConfigAssetsFuncInvoked = true @@ -5911,11 +5887,11 @@ func (s *DataStore) GetSummaryHostSoftwareInstalls(ctx context.Context, installe return s.GetSummaryHostSoftwareInstallsFunc(ctx, installerID) } -func (s *DataStore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, adamID string) (*fleet.VPPAppStatusSummary, error) { +func (s *DataStore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, appID fleet.VPPAppID) (*fleet.VPPAppStatusSummary, error) { s.mu.Lock() s.GetSummaryHostVPPAppInstallsFuncInvoked = true s.mu.Unlock() - return s.GetSummaryHostVPPAppInstallsFunc(ctx, teamID, adamID) + return s.GetSummaryHostVPPAppInstallsFunc(ctx, teamID, appID) } func (s *DataStore) GetSoftwareInstallResults(ctx context.Context, resultsUUID string) (*fleet.HostSoftwareInstallerResult, error) { @@ -5974,11 +5950,11 @@ func (s *DataStore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, return s.InsertVPPAppWithTeamFunc(ctx, app, teamID) } -func (s *DataStore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, userID uint, adamID string, commandUUID string, associatedEventID string) error { +func (s *DataStore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, userID uint, appID fleet.VPPAppID, commandUUID string, associatedEventID string) error { s.mu.Lock() s.InsertHostVPPSoftwareInstallFuncInvoked = true s.mu.Unlock() - return s.InsertHostVPPSoftwareInstallFunc(ctx, hostID, userID, adamID, commandUUID, associatedEventID) + return s.InsertHostVPPSoftwareInstallFunc(ctx, hostID, userID, appID, commandUUID, associatedEventID) } func (s *DataStore) GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 2118b13670..3ff24a0eb0 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -18,7 +18,6 @@ import ( "sync" "time" - "github.com/Masterminds/semver" "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/optjson" @@ -1288,38 +1287,6 @@ func (svc *Service) EnqueueMDMAppleCommand( type mdmAppleEnrollRequest struct { Token string `query:"token"` EnrollmentReference string `query:"enrollment_reference,optional"` - MachineInfo *fleet.MDMAppleMachineInfo -} - -func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { - decoded := mdmAppleEnrollRequest{} - - tok := r.URL.Query().Get("token") - if tok == "" { - return nil, &fleet.BadRequestError{ - Message: "token is required", - } - } - decoded.Token = tok - - er := r.URL.Query().Get("enrollment_reference") - decoded.EnrollmentReference = er - - // Parse the machine info from the request body - di := r.Header.Get("x-apple-aspen-deviceinfo") - if di != "" { - // extract x-apple-aspen-deviceinfo custom header from request - parsed, err := apple_mdm.ParseDeviceinfo(di, true) - if err != nil { - return nil, &fleet.BadRequestError{ - Message: "unable to parse deviceinfo header", - } - } - p := fleet.MDMAppleMachineInfo(*parsed) - decoded.MachineInfo = &p - } - - return &decoded, nil } func (r mdmAppleEnrollResponse) error() error { return r.Err } @@ -1329,20 +1296,9 @@ type mdmAppleEnrollResponse struct { // Profile field is used in hijackRender for the response. Profile []byte - - SoftwareUpdateRequired *fleet.MDMAppleSoftwareUpdateRequired } func (r mdmAppleEnrollResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { - if r.SoftwareUpdateRequired != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - if err := json.NewEncoder(w).Encode(r.SoftwareUpdateRequired); err != nil { - encodeError(ctx, ctxerr.New(ctx, "failed to encode software update required"), w) - } - return - } - w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Profile)), 10)) w.Header().Set("Content-Type", "application/x-apple-aspen-config") w.Header().Set("X-Content-Type-Options", "nosniff") @@ -1360,16 +1316,6 @@ func (r mdmAppleEnrollResponse) hijackRender(ctx context.Context, w http.Respons func mdmAppleEnrollEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*mdmAppleEnrollRequest) - sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, req.MachineInfo) - if err != nil { - return mdmAppleEnrollResponse{Err: err}, nil - } - if sur != nil { - return mdmAppleEnrollResponse{ - SoftwareUpdateRequired: sur, - }, nil - } - profile, err := svc.GetMDMAppleEnrollmentProfileByToken(ctx, req.Token, req.EnrollmentReference) if err != nil { return mdmAppleEnrollResponse{Err: err}, nil @@ -1430,62 +1376,6 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok return signed, nil } -func (svc *Service) CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) { - // skipauth: The enroll profile endpoint is unauthenticated. - svc.authz.SkipAuthorization(ctx) - - if m == nil { - level.Info(svc.logger).Log("msg", "no machine info, skipping os version check") - return nil, nil - } - - if !m.MDMCanRequestSoftwareUpdate { - level.Info(svc.logger).Log("msg", "mdm cannot request software update, skipping os version check", "machine_info", *m) - return nil, nil - } - - // NOTE: Under the hood, the datastore is joining host_dep_assignments to the hosts table to - // look up DEP hosts by serial number. It grabs the team id and platform from the - // hosts table. Then it uses the team id to get either the global config or team config. - // Finally, it uses the platform to get os updates settings from the config for - // one of ios, ipados, or darwin, as applicable. There's a lot of assumptions going on here, not - // least of which is that the platform is correct in the hosts table. If the platform is wrong, - // we'll end up with a meaningless comparison of unrelated versions. We could potentially add - // some cross-check against the machine info to ensure that the platform of the host aligns with - // what we expect from the machine info. But that would involve work to derive the platform from - // the machine info (presumably from the product name, but that's not a 1:1 mapping). - settings, err := svc.ds.GetMDMAppleOSUpdatesSettingsByHostSerial(ctx, m.Serial) - if err != nil { - if fleet.IsNotFound(err) { - level.Info(svc.logger).Log("msg", "settings not found, skipping os version check", "machine_info", *m) - return nil, nil - } - return nil, ctxerr.Wrap(ctx, err, "get os updates settings") - } - - // TODO: confirm what this check should do - if !settings.MinimumVersion.Set || !settings.MinimumVersion.Valid || settings.MinimumVersion.Value == "" { - level.Info(svc.logger).Log("msg", "settings not set, skipping os version check", "machine_info", *m, "settings", settings) - return nil, nil - } - - want, err := semver.NewVersion(settings.MinimumVersion.Value) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "parsing minimum version") - } - - got, err := semver.NewVersion(m.OSVersion) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "parsing device os version") - } - - if got.LessThan(want) { - return fleet.NewMDMAppleSoftwareUpdateRequired(*settings), nil - } - - return nil, nil -} - func (svc *Service) mdmPushCertTopic(ctx context.Context) (string, error) { assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetAPNSCert, @@ -3573,9 +3463,13 @@ func RenewSCEPCertificates( } } - migrationEnrollmentProfile := os.Getenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE") + decodedMigrationEnrollmentProfile, err := base64.StdEncoding.DecodeString(os.Getenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE")) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to decode silent migration enrollment profile") + } hasAssocsFromMigration := len(assocsFromMigration) > 0 + migrationEnrollmentProfile := string(decodedMigrationEnrollmentProfile) if migrationEnrollmentProfile == "" && hasAssocsFromMigration { level.Debug(logger).Log("msg", "found devices from migration that need SCEP renewals but FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE is empty") } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 4c959a7649..545de31a0a 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -24,7 +24,6 @@ import ( "testing" "time" - "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -3372,80 +3371,3 @@ func TestUnmarshalAppList(t *testing.T) { require.NoError(t, err) assert.ElementsMatch(t, expectedSoftware, software) } - -func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) { - svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium}) - - ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serialNumber string) (*fleet.AppleOSUpdateSettings, error) { - return &fleet.AppleOSUpdateSettings{ - MinimumVersion: optjson.SetString("14.2"), - }, nil - } - - testCases := []struct { - name string - deviceOSVersion string - mdmCanRequestSoftwareUpdate bool - wantUpdateRequired string - }{ - { - name: "OS version is greater than minimum", - deviceOSVersion: "15.0", - mdmCanRequestSoftwareUpdate: true, - wantUpdateRequired: "", - }, - { - name: "OS version is equal to minimum", - deviceOSVersion: "14.2", - mdmCanRequestSoftwareUpdate: true, - wantUpdateRequired: "", - }, - { - name: "OS version is less than minimum", - deviceOSVersion: "14.0.2", - mdmCanRequestSoftwareUpdate: true, - wantUpdateRequired: "14.2", - }, - { - name: "OS version is less than minimum but MDM cannot request software update", - deviceOSVersion: "14.0.2", - mdmCanRequestSoftwareUpdate: false, - wantUpdateRequired: "", - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, &fleet.MDMAppleMachineInfo{OSVersion: tt.deviceOSVersion, MDMCanRequestSoftwareUpdate: tt.mdmCanRequestSoftwareUpdate}) - require.NoError(t, err) - if tt.wantUpdateRequired == "" { - require.Nil(t, sur) - } else { - require.Equal(t, &fleet.MDMAppleSoftwareUpdateRequired{ - Code: fleet.MDMAppleSoftwareUpdateRequiredCode, - Details: fleet.MDMAppleSoftwareUpdateRequiredDetails{ - OSVersion: tt.wantUpdateRequired, - }, - }, sur) - } - }) - } - - t.Run("error getting OS update settings", func(t *testing.T) { - ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serialNumber string) (*fleet.AppleOSUpdateSettings, error) { - return nil, newNotFoundError() - } - - sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, &fleet.MDMAppleMachineInfo{OSVersion: "14.0.2", MDMCanRequestSoftwareUpdate: true}) - require.NoError(t, err) - require.Nil(t, sur) - - ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serialNumber string) (*fleet.AppleOSUpdateSettings, error) { - return nil, errors.New("error") - } - - sur, err = svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, &fleet.MDMAppleMachineInfo{OSVersion: "14.0.2", MDMCanRequestSoftwareUpdate: true}) - require.Error(t, err) - require.Nil(t, sur) - }) -} diff --git a/server/service/client.go b/server/service/client.go index 73515d0211..740ac19578 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -14,6 +14,7 @@ import ( "time" "golang.org/x/text/unicode/norm" + "gopkg.in/yaml.v2" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/spec" @@ -393,6 +394,7 @@ func (c *Client) ApplyGroup( specs *spec.Group, baseDir string, logf func(format string, args ...interface{}), + appconfig *fleet.EnrichedAppConfig, opts fleet.ApplyClientSpecOptions, ) (map[string]uint, error) { logfn := func(format string, args ...interface{}) { @@ -609,60 +611,10 @@ func (c *Client) ApplyGroup( tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams) tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmScripts)) for tmName, software := range tmSoftwarePackages { - softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(software)) - for i, si := range software { - var qc string - var err error - if si.PreInstallQuery.Path != "" { - queryFile := resolveApplyRelativePath(baseDir, si.PreInstallQuery.Path) - rawSpec, err := os.ReadFile(queryFile) - if err != nil { - return nil, fmt.Errorf("reading pre-install query: %w", err) - } - - group, err := spec.GroupFromBytes(rawSpec) - if err != nil { - return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install query YAML file %s: %w", si.URL, queryFile, err) - } - - if len(group.Queries) > 1 { - return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile) - } - - if len(group.Queries) == 0 { - return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile) - } - - qc = group.Queries[0].Query - } - - var ic []byte - if si.InstallScript.Path != "" { - installScriptFile := resolveApplyRelativePath(baseDir, si.InstallScript.Path) - ic, err = os.ReadFile(installScriptFile) - if err != nil { - return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read install script file %s: %w", si.URL, si.InstallScript.Path, err) - } - } - - var pc []byte - if si.PostInstallScript.Path != "" { - postInstallScriptFile := resolveApplyRelativePath(baseDir, si.PostInstallScript.Path) - pc, err = os.ReadFile(postInstallScriptFile) - if err != nil { - return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read post-install script file %s: %w", si.URL, si.PostInstallScript.Path, err) - } - } - - softwarePayloads[i] = fleet.SoftwareInstallerPayload{ - URL: si.URL, - SelfService: si.SelfService, - PreInstallQuery: qc, - InstallScript: string(ic), - PostInstallScript: string(pc), - } + softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software) + if err != nil { + return nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } - tmSoftwarePackagesPayloads[tmName] = softwarePayloads } @@ -779,6 +731,95 @@ func (c *Client) ApplyGroup( return teamIDsByName, nil } +func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec) ([]fleet.SoftwareInstallerPayload, error) { + softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(specs)) + for i, si := range specs { + var qc string + var err error + if si.PreInstallQuery.Path != "" { + queryFile := resolveApplyRelativePath(baseDir, si.PreInstallQuery.Path) + rawSpec, err := os.ReadFile(queryFile) + if err != nil { + return nil, fmt.Errorf("reading pre-install query: %w", err) + } + + rawSpecExpanded, err := spec.ExpandEnvBytes(rawSpec) + if err != nil { + return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err) + } + + var top any + + if err := yaml.Unmarshal(rawSpecExpanded, &top); err != nil { + return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err) + } + + if _, ok := top.(map[any]any); ok { + // Old apply format + group, err := spec.GroupFromBytes(rawSpecExpanded) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install apply format query YAML file %s: %w", si.URL, queryFile, err) + } + + if len(group.Queries) > 1 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile) + } + + if len(group.Queries) == 0 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile) + } + + qc = group.Queries[0].Query + } else { + // Gitops format + var querySpecs []fleet.QuerySpec + if err := yaml.Unmarshal(rawSpecExpanded, &querySpecs); err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install query YAML file %s: %w", si.URL, queryFile, err) + } + + if len(querySpecs) > 1 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile) + } + + if len(querySpecs) == 0 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile) + } + + qc = querySpecs[0].Query + } + } + + var ic []byte + if si.InstallScript.Path != "" { + installScriptFile := resolveApplyRelativePath(baseDir, si.InstallScript.Path) + ic, err = os.ReadFile(installScriptFile) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read install script file %s: %w", si.URL, si.InstallScript.Path, err) + } + } + + var pc []byte + if si.PostInstallScript.Path != "" { + postInstallScriptFile := resolveApplyRelativePath(baseDir, si.PostInstallScript.Path) + pc, err = os.ReadFile(postInstallScriptFile) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read post-install script file %s: %w", si.URL, si.PostInstallScript.Path, err) + } + } + + softwarePayloads[i] = fleet.SoftwareInstallerPayload{ + URL: si.URL, + SelfService: si.SelfService, + PreInstallQuery: qc, + InstallScript: string(ic), + PostInstallScript: string(pc), + } + + } + + return softwarePayloads, nil +} + func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup { asMap, ok := appCfg.(map[string]interface{}) if !ok { @@ -1014,8 +1055,8 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string]profi return m } -func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecSoftwarePackage { - var m map[string][]fleet.TeamSpecSoftwarePackage +func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.SoftwarePackageSpec { + var m map[string][]fleet.SoftwarePackageSpec for _, tm := range tmSpecs { var spec struct { Name string `json:"name"` @@ -1028,10 +1069,10 @@ func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]flee spec.Name = norm.NFC.String(spec.Name) if spec.Name != "" && len(spec.Software) > 0 { if m == nil { - m = make(map[string][]fleet.TeamSpecSoftwarePackage) + m = make(map[string][]fleet.SoftwarePackageSpec) } - var software fleet.TeamSpecSoftware - var packages []fleet.TeamSpecSoftwarePackage + var software fleet.SoftwareSpec + var packages []fleet.SoftwarePackageSpec if err := json.Unmarshal(spec.Software, &software); err != nil { // ignore, will fail in apply team specs call continue @@ -1039,7 +1080,7 @@ func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]flee if !software.Packages.Valid { // to be consistent with the AppConfig custom settings, set it to an // empty slice if the provided custom settings are present but empty. - packages = []fleet.TeamSpecSoftwarePackage{} + packages = []fleet.SoftwarePackageSpec{} } else { packages = software.Packages.Value } @@ -1065,7 +1106,7 @@ func extractTmSpecsSoftwareApps(tmSpecs []json.RawMessage) map[string][]fleet.Te if m == nil { m = make(map[string][]fleet.TeamSpecAppStoreApp) } - var software fleet.TeamSpecSoftware + var software fleet.SoftwareSpec var apps []fleet.TeamSpecAppStoreApp if err := json.Unmarshal(spec.Software, &software); err != nil { // ignore, will fail in apply team specs call @@ -1224,6 +1265,8 @@ func (c *Client) DoGitOps( } } group.AppConfig.(map[string]interface{})["scripts"] = scripts + + group.Software = config.Software.Packages } else { team = make(map[string]interface{}) team["name"] = *config.TeamName @@ -1382,7 +1425,7 @@ func (c *Client) DoGitOps( } // Apply org settings, scripts, enroll secrets, and controls - teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, fleet.ApplyClientSpecOptions{ + teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ ApplySpecOptions: fleet.ApplySpecOptions{ DryRun: dryRun, }, @@ -1418,9 +1461,39 @@ func (c *Client) DoGitOps( return nil, err } + err = c.doGitOpsNoTeamSoftware(group, baseDir, appConfig, logFn, dryRun) + if err != nil { + return nil, err + } + return teamAssumptions, nil } +func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) error { + if len(specs.Teams) == 0 && appconfig != nil && appconfig.License.IsPremium() { + packages := make([]fleet.SoftwarePackageSpec, 0, len(specs.Software)) + for _, software := range specs.Software { + if software != nil { + packages = append(packages, *software) + } + } + payload, err := buildSoftwarePackagesPayload(baseDir, packages) + if err != nil { + return fmt.Errorf("applying software installers: %w", err) + } + if err := c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}); err != nil { + return fmt.Errorf("applying software installers: %w", err) + } + + if dryRun { + logFn("[+] would've applied 'No Team' software installers\n") + } else { + logFn("[+] applied 'No Team' software installers\n") + } + } + return nil +} + func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error { // Get the ids and names of current policies to figure out which ones to delete policies, err := c.GetPolicies(config.TeamID) diff --git a/server/service/client_software.go b/server/service/client_software.go index 22c602e96c..d08faee404 100644 --- a/server/service/client_software.go +++ b/server/service/client_software.go @@ -1,6 +1,8 @@ package service import ( + "net/url" + "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -25,3 +27,12 @@ func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResu } return responseBody.SoftwareTitles, nil } + +func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) error { + verb, path := "POST", "/api/latest/fleet/software/batch" + query, err := url.ParseQuery(opts.RawQuery()) + if err != nil { + return err + } + return c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, nil, query.Encode()) +} diff --git a/server/service/devices.go b/server/service/devices.go index 31e9889cb5..288fbb304e 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -564,41 +564,6 @@ func (svc *Service) GetDeviceMDMAppleEnrollmentProfile(ctx context.Context) ([]b return signed, nil } -//////////////////////////////////////////////////////////////////////////////// -// Request a disk encryption reset -//////////////////////////////////////////////////////////////////////////////// - -type rotateEncryptionKeyRequest struct { - Token string `url:"token"` -} - -func (r *rotateEncryptionKeyRequest) deviceAuthToken() string { - return r.Token -} - -type rotateEncryptionKeyResponse struct { - Err error `json:"error,omitempty"` -} - -func (r rotateEncryptionKeyResponse) error() error { return r.Err } - -func rotateEncryptionKeyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - host, ok := hostctx.FromContext(ctx) - if !ok { - err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) - return rotateEncryptionKeyResponse{Err: err}, nil - } - - if err := svc.RequestEncryptionKeyRotation(ctx, host.ID); err != nil { - return rotateEncryptionKeyResponse{Err: err}, nil - } - return rotateEncryptionKeyResponse{}, nil -} - -func (svc *Service) RequestEncryptionKeyRotation(ctx context.Context, hostID uint) error { - return fleet.ErrMissingLicense -} - //////////////////////////////////////////////////////////////////////////////// // Signal start of mdm migration on a device //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/handler.go b/server/service/handler.go index caaf16ddb4..3d606bd159 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -799,10 +799,6 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC errorLimiter.Limit("get_device_mdm", desktopQuota), ).GET("/api/_version_/fleet/device/{token}/mdm/apple/manual_enrollment_profile", getDeviceMDMManualEnrollProfileEndpoint, getDeviceMDMManualEnrollProfileRequest{}) - demdm.WithCustomMiddleware( - errorLimiter.Limit("post_device_rotate_encryption_key", desktopQuota), - ).POST("/api/_version_/fleet/device/{token}/rotate_encryption_key", rotateEncryptionKeyEndpoint, rotateEncryptionKeyRequest{}) - demdm.WithCustomMiddleware( errorLimiter.Limit("post_device_migrate_mdm", desktopQuota), ).POST("/api/_version_/fleet/device/{token}/migrate_mdm", migrateMDMDeviceEndpoint, deviceMigrateMDMRequest{}) diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 67492d1c4d..1e9e663ed6 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "errors" "fmt" + "net/http" "strconv" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/capabilities" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" @@ -23,6 +25,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + kitlog "github.com/go-kit/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mozilla.org/pkcs7" @@ -137,7 +140,7 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { OperationType: fleet.MDMOperationTypeInstall, }, fleet.DiskEncryptionActionRequired, - fleet.ActionRequiredLogOut, + fleet.ActionRequiredRotateKey, &fleet.MDMDeliveryPending, }, { @@ -1857,3 +1860,239 @@ func TestBulkOperationFilterValidation(t *testing.T) { }) } } + +func TestSetDiskEncryptionNotifications(t *testing.T) { + ds := new(mock.Store) + ctx := context.Background() + svc := &Service{ds: ds, logger: kitlog.NewNopLogger()} + + tests := []struct { + name string + host *fleet.Host + appConfig *fleet.AppConfig + diskEncryptionConfigured bool + isConnectedToFleetMDM bool + mdmInfo *fleet.HostMDM + getHostDiskEncryptionKey func(context.Context, uint) (*fleet.HostDiskEncryptionKey, error) + expectedNotifications *fleet.OrbitConfigNotifications + expectedError bool + disableCapability bool + }{ + { + name: "no MDM configured", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: false}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: nil, + expectedNotifications: &fleet.OrbitConfigNotifications{}, + expectedError: false, + }, + { + name: "not connected to Fleet MDM", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: false, + mdmInfo: nil, + getHostDiskEncryptionKey: nil, + expectedNotifications: &fleet.OrbitConfigNotifications{}, + expectedError: false, + }, + { + name: "host not enrolled in osquery", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: nil}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: nil, + expectedNotifications: &fleet.OrbitConfigNotifications{}, + expectedError: false, + }, + { + name: "disk encryption not configured", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: false, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: nil, + expectedNotifications: &fleet.OrbitConfigNotifications{}, + expectedError: false, + }, + { + name: "darwin with decryptable key", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{Decryptable: ptr.Bool(true)}, nil + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + RotateDiskEncryptionKey: false, + }, + expectedError: false, + }, + { + name: "darwin needs rotation but client is old", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{Decryptable: ptr.Bool(false)}, nil + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + RotateDiskEncryptionKey: true, + }, + expectedError: false, + disableCapability: true, + }, + { + name: "darwin needs rotation", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{Decryptable: ptr.Bool(false)}, nil + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + RotateDiskEncryptionKey: true, + }, + expectedError: false, + }, + { + name: "windows server with no encryption needed", + host: &fleet.Host{ID: 1, Platform: "windows", DiskEncryptionEnabled: ptr.Bool(true), OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: &fleet.HostMDM{IsServer: true}, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return nil, newNotFoundError() + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + EnforceBitLockerEncryption: false, + }, + expectedError: false, + }, + { + name: "windows with encryption enabled but key missing", + host: &fleet.Host{ID: 1, Platform: "windows", DiskEncryptionEnabled: ptr.Bool(true), OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: &fleet.HostMDM{IsServer: false}, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return nil, newNotFoundError() + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + EnforceBitLockerEncryption: true, + }, + expectedError: false, + }, + { + name: "darwin with missing encryption key", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return nil, newNotFoundError() + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + RotateDiskEncryptionKey: false, + }, + expectedError: false, + }, + { + name: "windows with encryption key and not decryptable", + host: &fleet.Host{ID: 1, Platform: "windows", DiskEncryptionEnabled: ptr.Bool(true), OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: &fleet.HostMDM{IsServer: false}, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{Decryptable: ptr.Bool(false)}, nil + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + EnforceBitLockerEncryption: true, + }, + expectedError: false, + }, + { + name: "windows with enforce BitLocker", + host: &fleet.Host{ID: 1, Platform: "windows", DiskEncryptionEnabled: ptr.Bool(false), OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: &fleet.HostMDM{IsServer: false}, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return nil, newNotFoundError() + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + EnforceBitLockerEncryption: true, + }, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.getHostDiskEncryptionKey != nil { + ds.GetHostDiskEncryptionKeyFunc = tt.getHostDiskEncryptionKey + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return tt.appConfig, nil + } + + if !tt.disableCapability { + r := http.Request{ + Header: http.Header{fleet.CapabilitiesHeader: []string{string(fleet.CapabilityEscrowBuddy)}}, + } + ctx = capabilities.NewContext(ctx, &r) + } + + notifs := &fleet.OrbitConfigNotifications{} + err := svc.setDiskEncryptionNotifications(ctx, notifs, tt.host, tt.appConfig, tt.diskEncryptionConfigured, tt.isConnectedToFleetMDM, tt.mdmInfo) + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.expectedNotifications.RotateDiskEncryptionKey, notifs.RotateDiskEncryptionKey) + }) + } +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index afbb7aa39f..79e61b96d1 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -9892,7 +9892,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD meta, err := s.ds.GetSoftwareInstallerMetadataByID(context.Background(), id) require.NoError(t, err) - if payload.TeamID != nil { + if payload.TeamID != nil && *payload.TeamID > 0 { require.Equal(t, *payload.TeamID, *meta.TeamID) } else { require.Nil(t, meta.TeamID) @@ -9951,8 +9951,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // download the installer s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusBadRequest) - // delete the installer + // delete the installer from nil team fails s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusBadRequest) + + // delete from team 0 succeeds + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0") }) t.Run("create team software installer", func(t *testing.T) { @@ -10027,6 +10030,72 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // download the installer, not found anymore s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", *payload.TeamID)) }) + + t.Run("create team 0 software installer", func(t *testing.T) { + payload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: ptr.Uint(0), + InstallScript: "another install script", + PreInstallQuery: "another pre install query", + PostInstallScript: "another post install script", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + SelfService: true, + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + // check the software installer + installerID, titleID := checkSoftwareInstaller(t, payload) + + // check activity + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`), 0) + + // upload again fails + s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") + + // download the installer + r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", 0)) + checkDownloadResponse(t, r, payload.Filename) + + // create an orbit host that is not in the team + hostNotInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-no-team", s.ds) + // downloading installer still works because we allow it explicitly + s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostNotInTeam.OrbitNodeKey, + }, http.StatusOK) + + // create an orbit host, assign to team + hostInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-team", s.ds) + + // requesting download with alt != media fails + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusBadRequest) + errMsg := extractServerErrorText(r.Body) + require.Contains(t, errMsg, "only alt=media is supported") + + // valid download + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusOK) + checkDownloadResponse(t, r, payload.Filename) + + // delete the installer + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0") + + // check activity + s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`), 0) + + // download the installer, not found anymore + s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", 0)) + }) } func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { @@ -10092,7 +10161,7 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { } s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) - wantSoftwarePackages := []fleet.TeamSpecSoftwarePackage{ + wantSoftwarePackages := []fleet.SoftwarePackageSpec{ { URL: "http://foo.com", SelfService: true, @@ -10251,9 +10320,6 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { t := s.T() - // a team name is required (we don't allow installers for "no team") - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusBadRequest) - // non-existent team s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo") @@ -10328,6 +10394,42 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) require.Equal(t, 0, titlesResp.Count) require.Len(t, titlesResp.SoftwareTitles, 0) + + ////////////////////////// + // Do a request with a valid URL with no team + ////////////////////////// + softwareToInstall = []fleet.SoftwareInstallerPayload{ + {URL: srv.URL}, + } + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + + // check the application status on team 0 + titlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + require.Equal(t, 1, titlesResp.Count) + require.Len(t, titlesResp.SoftwareTitles, 1) + + // same payload doesn't modify anything + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + newTitlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + require.Equal(t, titlesResp, newTitlesResp) + + // setting self-service to true updates the software title metadata + softwareToInstall[0].SelfService = true + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + newTitlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true) + require.Equal(t, titlesResp, newTitlesResp) + + // empty payload cleans the software items + softwareToInstall = []fleet.SoftwareInstallerPayload{} + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + titlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + require.Equal(t, 0, titlesResp.Count) + require.Len(t, titlesResp.SoftwareTitles, 0) } func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestPlatformValidation() { @@ -10619,6 +10721,22 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_version_id and software_title_id is not allowed.") r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_id", "1") require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_id and software_title_id is not allowed.") + + // Access software install result after host is deleted + err = s.ds.DeleteHost(context.Background(), h.ID) + require.NoError(t, err) + + instResult, err := s.ds.GetSoftwareInstallResults(context.Background(), installUUID) + require.NoError(t, err) + require.NotNil(t, instResult.HostDeletedAt) + + gsirr = getSoftwareInstallResultsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr) + require.NoError(t, gsirr.Err) + require.NotNil(t, gsirr.Results) + results = gsirr.Results + require.Equal(t, installUUID, results.InstallUUID) + require.Equal(t, fleet.SoftwareInstallerPending, results.Status) } func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() { @@ -11643,11 +11761,15 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { require.NotZero(t, event.StartTime) require.NotZero(t, event.EndTime) require.NotEmpty(t, event.UUID) + bodyTag := event.GetBodyTag() + assert.NotEmpty(t, bodyTag) assert.Equal(t, 1, calendar.MockChannelsCount()) // Get channel ID type eventDetails struct { ChannelID string `json:"channel_id"` + BodyTag string `json:"body_tag"` + ETag string `json:"etag"` } var details eventDetails err = json.Unmarshal(event.Data, &details) @@ -11770,6 +11892,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { err = json.Unmarshal(eventRecreated.Data, &details) require.NoError(t, err) + assert.NotEmpty(t, details.BodyTag) + bodyTag = details.BodyTag // New event callback should work _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, @@ -11806,6 +11930,30 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { assert.Greater(t, eventUpdated.StartTime, eventRecreated.StartTime) assert.Equal(t, eventRecreated.EndTime, eventUpdated.EndTime) assert.Equal(t, 1, calendar.MockChannelsCount()) + assert.Equal(t, bodyTag, eventRecreated.GetBodyTag()) + + // Change the body contents of event. + events = calendar.ListGoogleMockEvents() + require.Len(t, events, 1) + eTag := "description change etag" + for _, e := range events { + e.Etag = eTag + e.Description = "new description" + } + // New event callback should cause Etag to update but Body tag to remain the same + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, + map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) + require.NoError(t, err) + require.Len(t, team1CalendarEvents, 1) + eventDescUpdated := team1CalendarEvents[0] + err = json.Unmarshal(eventDescUpdated.Data, &details) + require.NoError(t, err) + assert.Equal(t, bodyTag, details.BodyTag) + assert.Equal(t, eTag, details.ETag) // Update the time of the event again events = calendar.ListGoogleMockEvents() @@ -11815,6 +11963,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { require.NoError(t, err) newStartTime := st.Add(5 * time.Minute).Format(time.RFC3339) e.Start.DateTime = newStartTime + e.Etag = e.Etag + "1" } // Grab the lock @@ -11865,6 +12014,9 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { require.NoError(t, err) if len(team1CalendarEvents) == 1 && team1CalendarEvents[0].UUID == event.UUID && team1CalendarEvents[0].StartTime.After(event.StartTime) { + err = json.Unmarshal(team1CalendarEvents[0].Data, &details) + require.NoError(t, err) + assert.NotEqual(t, eTag, details.ETag, "ETag should have updated") done <- struct{}{} return } @@ -12227,7 +12379,6 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEventBodyUpdate() { require.Len(t, calEvents, 1) assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultDescription) assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultResolution) - } func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 5dce43108d..bd467afe55 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -3,6 +3,7 @@ package service import ( "context" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/xml" "fmt" @@ -772,7 +773,7 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { require.NoError(t, err) // set the env var, and run the cron - t.Setenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE", "") + t.Setenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE", base64.StdEncoding.EncodeToString([]byte(""))) err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) require.NoError(t, err) checkRenewCertCommand(migratedDevice, "", "") diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 647f298c29..4ccd1cba50 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1624,6 +1624,42 @@ func (s *integrationMDMTestSuite) TestDiskEncryptionSharedSetting() { checkConfigSetSucceeds() } +func (s *integrationMDMTestSuite) TestEscrowBuddyBackwardsCompat() { + t := s.T() + ctx := context.Background() + + // create a host + host, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) + orbitKey := setOrbitEnrollment(t, host, s.ds) + host.OrbitNodeKey = &orbitKey + + // install a filevault profile for that host + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": true } + }`), http.StatusOK, &acResp) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) + + // set the status as non-decryptable so a notification should be sent + err := s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "", "", ptr.Bool(false)) + require.NoError(t, err) + + // notification is false because the escrow buddy capability is not set + orbitConfigResp := orbitGetConfigResponse{} + s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &orbitConfigResp) + require.False(t, orbitConfigResp.Notifications.RotateDiskEncryptionKey) + + // send the request again, this time with the right header + orbitConfigResp = orbitGetConfigResponse{} + res := s.DoRawWithHeaders("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", s.token), + fleet.CapabilitiesHeader: string(fleet.CapabilityEscrowBuddy), + }) + err = json.NewDecoder(res.Body).Decode(&orbitConfigResp) + require.NoError(t, err) + require.True(t, orbitConfigResp.Notifications.RotateDiskEncryptionKey) +} + func (s *integrationMDMTestSuite) TestMDMAppleHostDiskEncryption() { t := s.T() ctx := context.Background() @@ -1729,7 +1765,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleHostDiskEncryption() { require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.DiskEncryption) require.Equal(t, fleet.DiskEncryptionActionRequired, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption) require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.ActionRequired) - require.Equal(t, fleet.ActionRequiredLogOut, *getHostResp.Host.MDM.MacOSSettings.ActionRequired) + require.Equal(t, fleet.ActionRequiredRotateKey, *getHostResp.Host.MDM.MacOSSettings.ActionRequired) require.NotNil(t, getHostResp.Host.MDM.OSSettings) require.NotNil(t, getHostResp.Host.MDM.OSSettings.DiskEncryption.Status) require.Equal(t, fleet.DiskEncryptionActionRequired, *getHostResp.Host.MDM.OSSettings.DiskEncryption.Status) @@ -2521,36 +2557,6 @@ func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() { require.Equal(t, h.ID, got.ID) } -func (s *integrationMDMTestSuite) TestDiskEncryptionRotation() { - t := s.T() - h := createOrbitEnrolledHost(t, "darwin", "h", s.ds) - - // false by default - resp := orbitGetConfigResponse{} - s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp) - require.False(t, resp.Notifications.RotateDiskEncryptionKey) - - // create an auth token for h - token := "much_valid" - mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { - _, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, h.ID, token) - return err - }) - - tokRes := s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/rotate_encryption_key", nil, http.StatusOK) - tokRes.Body.Close() - - // true after the POST request - resp = orbitGetConfigResponse{} - s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp) - require.True(t, resp.Notifications.RotateDiskEncryptionKey) - - // false on following requests - resp = orbitGetConfigResponse{} - s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp) - require.False(t, resp.Notifications.RotateDiskEncryptionKey) -} - func (s *integrationMDMTestSuite) TestFleetdConfiguration() { t := s.T() s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetdConfigPayloadIdentifier, false) @@ -10320,7 +10326,6 @@ func (s *integrationMDMTestSuite) TestVPPApps() { extraAvailable: 1}, } - expectedInstalls := 1 for name, install := range installs { t.Run(name, func(t *testing.T) { @@ -10337,6 +10342,21 @@ func (s *integrationMDMTestSuite) TestVPPApps() { strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) require.Equal(t, 1, countResp.Count) + // Get pending activity + var hostActivitiesResp listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", installHost.ID), + nil, http.StatusOK, &hostActivitiesResp) + activitiesToString := func(activities []*fleet.Activity) []string { + var res []string + for _, activity := range activities { + res = append(res, fmt.Sprintf("%+v", activity)) + } + return res + } + require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", activitiesToString(hostActivitiesResp.Activities)) + assert.Equal(t, hostActivitiesResp.Activities[0].Type, fleet.ActivityInstalledAppStoreApp{}.ActivityName()) + assert.EqualValues(t, 1, hostActivitiesResp.Count) + // Simulate successful installation on the host cmd, err = mdmClient.Idle() require.NoError(t, err) @@ -10354,12 +10374,11 @@ func (s *integrationMDMTestSuite) TestVPPApps() { listResp = listHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) - assert.Len(t, listResp.Hosts, expectedInstalls) + assert.Len(t, listResp.Hosts, 1) countResp = countHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id", strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) - assert.Equal(t, expectedInstalls, countResp.Count) - expectedInstalls++ + assert.Equal(t, 1, countResp.Count) s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), diff --git a/server/service/orbit.go b/server/service/orbit.go index 0afa2631e4..e6241a0640 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -10,6 +10,7 @@ import ( "net/url" "github.com/fleetdm/fleet/v4/server" + "github.com/fleetdm/fleet/v4/server/contexts/capabilities" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -207,15 +208,6 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro notifs.NeedsMDMMigration = true } - if host.DiskEncryptionResetRequested != nil && *host.DiskEncryptionResetRequested { - notifs.RotateDiskEncryptionKey = true - - // Since this is an user initiated action, we disable - // the flag when we deliver the notification to Orbit - if err := svc.ds.SetDiskEncryptionResetStatus(ctx, host.ID, false); err != nil { - return fleet.OrbitConfig{}, err - } - } } // set the host's orbit notifications for Windows MDM @@ -309,9 +301,17 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } - if mdmConfig.EnableDiskEncryption && - fleet.IsEligibleForBitLockerEncryption(host, mdmInfo, isConnectedToFleetMDM) { - notifs.EnforceBitLockerEncryption = true + err = svc.setDiskEncryptionNotifications( + ctx, + ¬ifs, + host, + appConfig, + mdmConfig.EnableDiskEncryption, + isConnectedToFleetMDM, + mdmInfo, + ) + if err != nil { + return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "setting team disk encryption notifications") } var updateChannels *fleet.OrbitUpdateChannels @@ -371,10 +371,17 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } - if appConfig.MDM.WindowsEnabledAndConfigured && - appConfig.MDM.EnableDiskEncryption.Value && - fleet.IsEligibleForBitLockerEncryption(host, mdmInfo, isConnectedToFleetMDM) { - notifs.EnforceBitLockerEncryption = true + err = svc.setDiskEncryptionNotifications( + ctx, + ¬ifs, + host, + appConfig, + appConfig.MDM.EnableDiskEncryption.Value, + isConnectedToFleetMDM, + mdmInfo, + ) + if err != nil { + return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "setting no-team disk encryption notifications") } var updateChannels *fleet.OrbitUpdateChannels @@ -396,6 +403,57 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro }, nil } +func (svc *Service) setDiskEncryptionNotifications( + ctx context.Context, + notifs *fleet.OrbitConfigNotifications, + host *fleet.Host, + appConfig *fleet.AppConfig, + diskEncryptionConfigured bool, + isConnectedToFleetMDM bool, + mdmInfo *fleet.HostMDM, +) error { + anyMDMConfigured := appConfig.MDM.EnabledAndConfigured || appConfig.MDM.WindowsEnabledAndConfigured + if !anyMDMConfigured || + !isConnectedToFleetMDM || + !host.IsOsqueryEnrolled() || + !diskEncryptionConfigured { + return nil + } + + encryptionKey, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID) + if err != nil { + if !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "fetching host disk encryption key") + } + } + + switch host.FleetPlatform() { + case "darwin": + mp, ok := capabilities.FromContext(ctx) + if !ok { + level.Debug(svc.logger).Log("msg", "no capabilities in context, skipping disk encryption notification") + return nil + } + + if !mp.Has(fleet.CapabilityEscrowBuddy) { + level.Debug(svc.logger).Log("msg", "host doesn't support Escrow Buddy, skipping disk encryption notification", "host_uuid", host.UUID) + return nil + } + + notifs.RotateDiskEncryptionKey = encryptionKey != nil && encryptionKey.Decryptable != nil && !*encryptionKey.Decryptable + case "windows": + isServer := mdmInfo != nil && mdmInfo.IsServer + needsEncryption := host.DiskEncryptionEnabled != nil && !*host.DiskEncryptionEnabled + keyWasDecrypted := encryptionKey != nil && encryptionKey.Decryptable != nil && *encryptionKey.Decryptable + encryptedWithoutKey := host.DiskEncryptionEnabled != nil && *host.DiskEncryptionEnabled && !keyWasDecrypted + notifs.EnforceBitLockerEncryption = !isServer && + mdmInfo != nil && + (needsEncryption || encryptedWithoutKey) + } + + return nil +} + // filterExtensionsForHost filters a extensions configuration depending on the host platform and label membership. // // If all extensions are filtered, then it returns (nil, nil) (Orbit expects empty extensions if there diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index fb4fe0aa2f..5d4c5ce60c 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -146,7 +146,7 @@ func NewOrbitClient( orbitHostInfo fleet.OrbitHostInfo, onGetConfigErrFns *OnGetConfigErrFuncs, ) (*OrbitClient, error) { - orbitCapabilities := fleet.CapabilityMap{} + orbitCapabilities := fleet.GetOrbitClientCapabilities() bc, err := newBaseClient(addr, insecureSkipVerify, rootCA, "", fleetClientCert, orbitCapabilities) if err != nil { return nil, err diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index b811fc0026..59b2430b54 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -944,7 +944,7 @@ SELECT '' AS arch, '' AS installed_path FROM deb_packages -WHERE status = 'install ok installed' +WHERE status LIKE '%% ok installed' UNION SELECT package AS name, diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 5de426c2d6..0cdff35c2a 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -318,7 +318,7 @@ func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID st //////////////////////////////////////////////////////////////////////////////// type batchSetSoftwareInstallersRequest struct { - TeamName string `json:"-" query:"team_name"` + TeamName string `json:"-" query:"team_name,optional"` DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes Software []fleet.SoftwareInstallerPayload `json:"software"` } diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 82e367c7e6..39f38177ae 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -198,7 +198,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "get VPP app metadata") } if meta != nil { - summary, err := svc.ds.GetSummaryHostVPPAppInstalls(ctx, teamID, meta.VPPAppID.AdamID) + summary, err := svc.ds.GetSummaryHostVPPAppInstalls(ctx, teamID, meta.VPPAppID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get VPP app status summary") } diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go index c69897179d..fb8ac08070 100644 --- a/server/vulnerabilities/nvd/cve_test.go +++ b/server/vulnerabilities/nvd/cve_test.go @@ -320,6 +320,7 @@ func TestTranslateCPEToCVE(t *testing.T) { }, excludedCVEs: []string{ "CVE-2023-28205", // This vulnerability is for Safari 16.4.0 + "CVE-2024-23252", // Rejected CVE }, continuesToUpdate: true, }, diff --git a/terraform/addons/mdm/README.md b/terraform/addons/mdm/README.md index 0cb04aac59..4acccf3d9b 100644 --- a/terraform/addons/mdm/README.md +++ b/terraform/addons/mdm/README.md @@ -1,7 +1,7 @@ # MDM addon Notice: Previous versions of this module referred to `dep`, but to reduce confusion that has been replaces with `abm` -to mach the change to the newer Apple Busines Manager. For each key/value pair below, the key names have been changed +to mach the change to the newer Apple Business Manager. For each key/value pair below, the key names have been changed from previous version to match the name of the env var for easier usability. Older unused env vars were also removed for simplification. This includes removing the need for `extra_environment_variables` completely. diff --git a/tools/dbutils/schema_generator.go b/tools/dbutils/schema_generator.go index 2fecce8683..60c01869d3 100644 --- a/tools/dbutils/schema_generator.go +++ b/tools/dbutils/schema_generator.go @@ -67,7 +67,7 @@ func main() { // Dump schema to dumpfile cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_test", + "docker", "compose", "exec", "-T", "mysql_test", // Command run inside container "mysqldump", "-u"+testUsername, "-p"+testPassword, "schemadb", "--compact", "--skip-comments", ) diff --git a/tools/fleetctl-npm/README.md b/tools/fleetctl-npm/README.md index ecb4499e93..8ac619853d 100644 --- a/tools/fleetctl-npm/README.md +++ b/tools/fleetctl-npm/README.md @@ -1,4 +1,4 @@ -[![banner-fleet-cloud-city](https://user-images.githubusercontent.com/618009/98254443-eaf21100-1f41-11eb-9e2c-63a0545601f3.jpg)](https://fleetdm.com) +![fleet-banner](https://github.com/user-attachments/assets/fa90b8b2-cb3e-4277-a561-5719968c4bbd) Use the `fleetctl` CLI to interact with Fleet, the lightweight telemetry platform for servers and workstations. Have a look at the [Fleet README](https://github.com/fleetdm/fleet#readme) for more information. @@ -8,4 +8,4 @@ From the command line, install `fleetctl` with `npm install -g fleetctl`. ## Usage -See the [fleetctl documentation](https://fleetdm.com/docs/using-fleet/fleetctl-CLI) or `fleetctl --help` for usage instructions. \ No newline at end of file +See the [fleetctl documentation](https://fleetdm.com/docs/using-fleet/fleetctl-cli) or `fleetctl --help` for usage instructions. diff --git a/tools/mdm/decrypt-disk-encryption-key/main.go b/tools/mdm/decrypt-disk-encryption-key/main.go new file mode 100644 index 0000000000..22bf6ef714 --- /dev/null +++ b/tools/mdm/decrypt-disk-encryption-key/main.go @@ -0,0 +1,56 @@ +// Command decrypt-disk-encryption-key decrypts a base64-encoded encrypted key +// using the provided X509 certificate and private key. This is typically used +// to manually decrypt a disk encryption key, e.g. BitLocker on Windows or +// FileVault on macOS. The certificate and private key used are the SCEP files +// for a macOS host and the WSTEP files for a Windows host. +// +// Example usage (running from the root of this repository): +// +// go run ./tools/mdm/decrypt-disk-encryption-key/main.go -cert path/to/file.crt \ +// -key path/to/file.key -value-to-decrypt base64-encoded-value +package main + +import ( + "errors" + "flag" + "fmt" + + "github.com/apex/log" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/mdm" +) + +func main() { + var ( + certFile = flag.String("cert", "", "The path to the X509 certificate file (required).") + keyFile = flag.String("key", "", "The path to the X509 private key file (required).") + valueToDecrypt = flag.String("value-to-decrypt", "", "The base64-encoded value to decrypt (required).") + ) + flag.Parse() + + if *certFile == "" || *keyFile == "" || *valueToDecrypt == "" { + flag.Usage() + return + } + + cfg := config.MDMConfig{ + WindowsWSTEPIdentityCert: *certFile, + WindowsWSTEPIdentityKey: *keyFile, + } + cert, _, _, err := cfg.MicrosoftWSTEP() + if err != nil { + // unwrap the error once to remove "Microsoft WSTEP" from the error + // message, as we don't know in this tool if the cert is for WSTEP or SCEP + // (it doesn't matter) + if uerr := errors.Unwrap(err); uerr != nil { + err = uerr + } + log.Fatalf("Error loading certificate: %v", err) + } + + decrypted, err := mdm.DecryptBase64CMS(*valueToDecrypt, cert.Leaf, cert.PrivateKey) + if err != nil { + log.Fatalf("Error decrypting value: %v", err) + } + fmt.Printf("Decrypted value: %s\n", string(decrypted)) +} diff --git a/tools/release/publish_release.sh b/tools/release/publish_release.sh index ea15c37499..6aec1605e7 100755 --- a/tools/release/publish_release.sh +++ b/tools/release/publish_release.sh @@ -628,6 +628,8 @@ start_ver_tag=fleet-$start_version if [[ "$minor" == "true" ]]; then echo "Minor release from $start_version to $next_ver" + # For scheduled minor releases, we want to branch off of main + start_ver_tag="main" else echo "Patch release from $start_version to $next_ver" fi @@ -699,7 +701,7 @@ if [ "$cherry_pick_resolved" = "false" ]; then git checkout $start_ver_tag git pull origin $start_ver_tag else - echo "DRYRUN: Would have checked out starting tag $start_ver_tag" + echo "DRYRUN: Would have checked out starting at $start_ver_tag" fi local_exists=$(git branch | $GREP_CMD $target_branch) @@ -730,7 +732,7 @@ if [ "$cherry_pick_resolved" = "false" ]; then prs_for_issue=$(gh api repos/fleetdm/fleet/issues/$issue/timeline --paginate | jq -r '.[]' | $GREP_CMD "fleetdm/fleet/" | $GREP_CMD -oP "pulls\/\K(?:\d+)") echo -n "https://github.com/fleetdm/fleet/issues/$issue" if [[ "$prs_for_issue" == "" ]]; then - echo -n " NO PR's found, please verify they are not missing in the issue, if no PR's were required for this ticket please reconsider adding it to this release." + echo -n " - No PRs found, please verify they are not missing in the issue." fi for val in $prs_for_issue; do echo -n " $val" diff --git a/tools/tuf/README.md b/tools/tuf/README.md index 9f30f296f2..3915fdb892 100644 --- a/tools/tuf/README.md +++ b/tools/tuf/README.md @@ -225,6 +225,19 @@ make nudge-app-tar-gz version=1.1.10.81462 out-path=. fleetctl updates add --target /path/to/macos/nudge.app.tar.gz --platform macos --name nudge --version 1.1.10.81462 -t edge ``` +#### Releasing `Escrow Buddy` to `stable` + +> `releaser.sh` doesn't support `Escrow Buddy` yet. +> macOS only component + +The `Escrow Buddy` pkg installer can be generated by running: +```sh +make escrow-buddy-pkg version=1.0.0 out-path=. +``` +```sh +fleetctl updates add --target /path/to/escrowBuddy.pkg --platform macos --name escrowBuddy --version 1.0.0 -t stable +``` + #### Updating timestamp ```sh diff --git a/tools/tuf/status/tuf-status.go b/tools/tuf/status/tuf-status.go index 6f8aa6fd36..e62dc14f12 100644 --- a/tools/tuf/status/tuf-status.go +++ b/tools/tuf/status/tuf-status.go @@ -186,6 +186,9 @@ func channelVersionCommand() *cli.Command { "swiftDialog": { "macos": "swiftDialog.app.tar.gz", }, + "escrowBuddy": { + "macos": "escrowBuddy.pkg", + }, } var ( channel string @@ -208,7 +211,7 @@ func channelVersionCommand() *cli.Command { &cli.StringSliceFlag{ Name: "components", EnvVars: []string{"TUF_STATUS_COMPONENTS"}, - Value: cli.NewStringSlice("orbit", "desktop", "osqueryd", "nudge", "swiftDialog"), + Value: cli.NewStringSlice("orbit", "desktop", "osqueryd", "nudge", "swiftDialog", "escrowBuddy"), Destination: &components, Usage: "List of components", }, @@ -324,7 +327,7 @@ func channelVersionCommand() *cli.Command { Right: true, }) var rows [][]string - componentsInOrder := []string{"orbit", "desktop", "osqueryd", "nudge", "swiftDialog"} + componentsInOrder := []string{"orbit", "desktop", "osqueryd", "nudge", "swiftDialog", "escrowBuddy"} setIfEmpty := func(m map[string]string, k string) string { v := m[k] if v == "" { diff --git a/tools/tuf/test/create_repository.sh b/tools/tuf/test/create_repository.sh index 86fa0f5e17..0f13a21357 100755 --- a/tools/tuf/test/create_repository.sh +++ b/tools/tuf/test/create_repository.sh @@ -28,8 +28,7 @@ SYSTEMS=${SYSTEMS:-macos linux linux-arm64 windows} echo "Generating packages for $SYSTEMS" NUDGE_VERSION=stable -SWIFT_DIALOG_MACOS_APP_VERSION=2.2.1 -SWIFT_DIALOG_MACOS_APP_BUILD_VERSION=4591 +ESCROW_BUDDY_PKG_VERSION=1.0.0 if [[ -z "$OSQUERY_VERSION" ]]; then OSQUERY_VERSION=5.12.2 @@ -168,6 +167,20 @@ for system in $SYSTEMS; do rm swiftDialog.app.tar.gz fi + # Add Escrow Buddy on macos (if enabled). + if [[ $system == "macos" && -n "$ESCROW_BUDDY" ]]; then + make escrow-buddy-pkg version=$ESCROW_BUDDY_PKG_VERSION out-path=. + + ./build/fleetctl updates add \ + --path $TUF_PATH \ + --target escrowBuddy.pkg \ + --platform macos \ + --name escrowBuddy \ + --version 42.0.0 -t 42.0 -t 42 -t stable + rm escrowBuddy.pkg + fi + + # Add Fleet Desktop application on windows (if enabled). if [[ $system == "windows" && -n "$FLEET_DESKTOP" ]]; then FLEET_DESKTOP_VERSION=42.0.0 \ diff --git a/website/api/controllers/admin/view-email-template-preview.js b/website/api/controllers/admin/view-email-template-preview.js index 5692353463..6898cb3370 100644 --- a/website/api/controllers/admin/view-email-template-preview.js +++ b/website/api/controllers/admin/view-email-template-preview.js @@ -117,19 +117,22 @@ module.exports = { case 'email-nurture-stage-three': layout = 'layout-nurture-email'; fakeData = { - firstName: 'Sage' + firstName: 'Sage', + emailAddress: 'sage@example.com', }; break; case 'email-nurture-stage-four': layout = 'layout-nurture-email'; fakeData = { - firstName: 'Sage' + firstName: 'Sage', + emailAddress: 'sage@example.com', }; break; case 'email-nurture-stage-five': layout = 'layout-nurture-email'; fakeData = { - firstName: 'Sage' + firstName: 'Sage', + emailAddress: 'sage@example.com', }; break; case 'email-deal-registration': diff --git a/website/api/controllers/articles/view-articles.js b/website/api/controllers/articles/view-articles.js index f8b685a8eb..8f28c6c5c4 100644 --- a/website/api/controllers/articles/view-articles.js +++ b/website/api/controllers/articles/view-articles.js @@ -50,7 +50,7 @@ module.exports = { }); } - let pageTitleForMeta = 'Fleet blog | Fleet'; + let pageTitleForMeta = 'Fleet blog'; let pageDescriptionForMeta = 'Read the latest articles written by Fleet.'; // Create a currentSection variable, this will be used to highlight the header dropdown that this article category lives under. // There are three possible values for this (documentation, community, and platform), so we'll default to the one with the most article categories (community) and set the value to another section if needed. @@ -60,39 +60,39 @@ module.exports = { // Set a pageTitleForMeta, pageDescriptionForMeta, and currentSection variable based on the article category. switch(category) { case 'success-stories': - pageTitleForMeta = 'Success stories | Fleet'; + pageTitleForMeta = 'Success stories'; pageDescriptionForMeta = 'Read about how others are using Fleet and osquery.'; currentSection = 'platform'; break; case 'deploy': - pageTitleForMeta = 'Deployment guides | Fleet'; + pageTitleForMeta = 'Deployment guides'; pageDescriptionForMeta = 'Learn how to deploy Fleet on a variety of production environments.'; currentSection = 'documentation'; break; case 'releases': - pageTitleForMeta = 'Releases | Fleet'; + pageTitleForMeta = 'Releases'; pageDescriptionForMeta = 'Fleet releases new and updated features every three weeks. Read about the latest product improvements here.'; currentSection = 'documentation'; break; case 'guides': - pageTitleForMeta = 'Guides | Fleet'; + pageTitleForMeta = 'Guides'; pageDescriptionForMeta = 'A collection of how-to guides for Fleet and osquery.'; currentSection = 'documentation'; break; case 'securing': - pageTitleForMeta = 'Security articles | Fleet'; + pageTitleForMeta = 'Security articles'; pageDescriptionForMeta = 'Learn more about how we secure Fleet.'; break; case 'engineering': - pageTitleForMeta = 'Engineering articles | Fleet'; + pageTitleForMeta = 'Engineering articles'; pageDescriptionForMeta = 'Read about engineering at Fleet and beyond.'; break; case 'announcements': - pageTitleForMeta = 'Announcements | Fleet'; + pageTitleForMeta = 'Announcements'; pageDescriptionForMeta = 'Read the latest news from Fleet.'; break; case 'podcasts': - pageTitleForMeta = 'Podcasts | Fleet'; + pageTitleForMeta = 'Podcasts'; pageDescriptionForMeta = 'Listen to the Future of Device Management podcast.'; break; } diff --git a/website/api/controllers/articles/view-basic-article.js b/website/api/controllers/articles/view-basic-article.js index e7fc38ff83..17b997f5f7 100644 --- a/website/api/controllers/articles/view-basic-article.js +++ b/website/api/controllers/articles/view-basic-article.js @@ -52,7 +52,7 @@ module.exports = { // Note: Leaving title and description as `undefined` in our view means we'll default to the generic title and description set in layout.ejs. let pageTitleForMeta; if(thisPage.meta.articleTitle) { - pageTitleForMeta = thisPage.meta.articleTitle + ' | Fleet'; + pageTitleForMeta = thisPage.meta.articleTitle; }//fi let pageDescriptionForMeta; if(thisPage.meta.description){ diff --git a/website/api/controllers/docs/view-basic-documentation.js b/website/api/controllers/docs/view-basic-documentation.js index 3961775bad..b5d1746e8b 100644 --- a/website/api/controllers/docs/view-basic-documentation.js +++ b/website/api/controllers/docs/view-basic-documentation.js @@ -69,7 +69,7 @@ module.exports = { compiledPagePartialsAppPath: sails.config.builtStaticContent.compiledPagePartialsAppPath, pageTitleForMeta: ( thisPage.title !== 'Readme.md' ? thisPage.title + ' | Fleet documentation'// « custom meta title for this page, if provided in markdown - : 'Documentation | Fleet' // « otherwise we're on the landing page for this section of the site, so we'll follow the title format of other top-level pages + : 'Documentation' // « otherwise we're on the landing page for this section of the site, so we'll follow the title format of other top-level pages ), pageDescriptionForMeta: ( thisPage.meta.description ? thisPage.meta.description // « custom meta description for this page, if provided in markdown diff --git a/website/api/controllers/handbook/view-basic-handbook.js b/website/api/controllers/handbook/view-basic-handbook.js index bd30a469d6..6c9f1b4b01 100644 --- a/website/api/controllers/handbook/view-basic-handbook.js +++ b/website/api/controllers/handbook/view-basic-handbook.js @@ -64,7 +64,7 @@ module.exports = { compiledPagePartialsAppPath: sails.config.builtStaticContent.compiledPagePartialsAppPath, pageTitleForMeta: ( thisPage.title !== 'Readme.md' ? thisPage.title + ' | Fleet handbook'// « custom meta title for this page, if provided in markdown - : 'Handbook | Fleet' // « otherwise we're on the landing page for this section of the site, so we'll follow the title format of other top-level pages + : 'Handbook' // « otherwise we're on the landing page for this section of the site, so we'll follow the title format of other top-level pages ), pageDescriptionForMeta: ( thisPage.meta.description ? thisPage.meta.description // « custom meta description for this page, if provided in markdown diff --git a/website/api/controllers/unsubscribe-from-marketing-emails.js b/website/api/controllers/unsubscribe-from-marketing-emails.js new file mode 100644 index 0000000000..26a9b53ee8 --- /dev/null +++ b/website/api/controllers/unsubscribe-from-marketing-emails.js @@ -0,0 +1,79 @@ +module.exports = { + + + friendlyName: 'Unsubscribe from marketing emails', + + + description: 'Unsubscribes a specified email address from the nurture email automation.', + + + inputs: { + emailAddress: { + type: 'string', + description: 'The email address of the user who wants to unsubscribe from marketing emails.', + required: true, + } + }, + + + exits: { + userNotFound: { + description: 'The provided email address could not be matched to a Fleet user account', + responseType: 'badRequest', + }, + success: { + description: 'The user has opted out of markering emails', + } + }, + + + fn: async function ({emailAddress}) { + + let userRecord = await User.findOne({emailAddress: emailAddress}); + + if(!userRecord){ + throw 'userNotFound'; + } + // Update the user record for this email address to set their nurture email timestamps to 1 + // so they are excluded them from future runs of the deliver-nurture-emails script. + // FUTURE: update the user model to have a subscribedToNurtureEmails attribute. + await User.updateOne({emailAddress: emailAddress}).set({ + stageThreeNurtureEmailSentAt: 1, + stageFourNurtureEmailSentAt: 1, + stageFiveNurtureEmailSentAt: 1, + }); + + // Update the contact record in salesforce for this email address to indicate that they have opted out of marketing emails. + if(sails.config.environment === 'production'){ + require('assert')(sails.config.custom.salesforceIntegrationUsername); + require('assert')(sails.config.custom.salesforceIntegrationPasskey); + + // Log in to Salesforce. + let jsforce = require('jsforce'); + let salesforceConnection = new jsforce.Connection({ + loginUrl : 'https://fleetdm.my.salesforce.com' + }); + await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey); + + let existingContactRecord = await salesforceConnection.sobject('Contact') + .findOne({ + Email: emailAddress, + }); + + if(existingContactRecord) { + //If we found an existing contact record in salesforce, update its status to be "Do not contact" + let salesforceContactId = existingContactRecord.Id; + await salesforceConnection.sobject('Contact') + .update({ + Id: salesforceContactId, + Unsubscribed_from_email_contact__c: true,// eslint-disable-line camelcase + }); + } + } + // Redirect the user to the homepage with a #unsubscribe hash link. + return this.res.redirect('/#unsubscribed'); + + } + + +}; diff --git a/website/api/controllers/view-pricing.js b/website/api/controllers/view-pricing.js index e3c914e0ae..7d07b6b770 100644 --- a/website/api/controllers/view-pricing.js +++ b/website/api/controllers/view-pricing.js @@ -44,36 +44,39 @@ module.exports = { pricingTable.push(allFeaturesInThisCategory); } - let pricingTableForSecurity = _.filter(pricingTable, (category)=>{ - return category.categoryName !== 'Device management' && (category.usualDepartment === 'Security' || category.usualDepartment === undefined); - }); + let pricingTableForSecurity = []; let categoryOrderForSecurityPricingTable = ['Support', 'Deployment', 'Integrations', 'Endpoint operations', 'Vulnerability management']; - // Sort the security-focused pricing table from the order of the elements in the categoryOrderForSecurityPricingTable array. - pricingTableForSecurity.sort((a, b)=>{ - // If there is a category that is not in the list above, sort it to the end of the list. - if(categoryOrderForSecurityPricingTable.indexOf(a.categoryName) === -1){ - return 1; - } else if(categoryOrderForSecurityPricingTable.indexOf(b.categoryName) === -1) { - return -1; - } - return categoryOrderForSecurityPricingTable.indexOf(a.categoryName) - categoryOrderForSecurityPricingTable.indexOf(b.categoryName); - }); + for(let category of categoryOrderForSecurityPricingTable) { + // Get all the features in that have a pricingTableFeatures array that contains this category. + let featuresInThisCategory = _.filter(pricingTableFeatures, (feature)=>{ + return _.contains(feature.pricingTableCategories, category) && (feature.usualDepartment === 'Security' || feature.usualDepartment === undefined); + }); + // Build a dictionary containing the category name, and all features in the category + let allSecurityFeaturesInThisCategory = { + categoryName: category, + features: featuresInThisCategory, + }; + // Add the dictionaries to the arrays that we'll use to build the features table. + pricingTableForSecurity.push(allSecurityFeaturesInThisCategory); + } - let pricingTableForIt = _.filter(pricingTable, (category)=>{ - return category.categoryName !== 'Vulnerability management' && (category.usualDepartment === 'Security' || category.usualDepartment === undefined); - }); let categoryOrderForITPricingTable = [ 'Deployment','Device management', 'Endpoint operations', 'Integrations', 'Support']; + let pricingTableForIt = []; // Sort the IT-focused pricing table from the order of the elements in the categoryOrderForITPricingTable array. - pricingTableForIt.sort((a, b)=>{ - // If there is a category that is not in the list above, sort it to the end of the list. - if(categoryOrderForITPricingTable.indexOf(a.categoryName) === -1){ - return 1; - } else if(categoryOrderForITPricingTable.indexOf(b.categoryName) === -1) { - return -1; - } - return categoryOrderForITPricingTable.indexOf(a.categoryName) - categoryOrderForITPricingTable.indexOf(b.categoryName); - }); + for(let category of categoryOrderForITPricingTable) { + // Get all the features in that have a pricingTableFeatures array that contains this category. + let featuresInThisCategory = _.filter(pricingTableFeatures, (feature)=>{ + return _.contains(feature.pricingTableCategories, category) && (feature.usualDepartment === 'IT' || feature.usualDepartment === undefined); + }); + // Build a dictionary containing the category name, and all features in the category, sorting premium features to the bottom of the list. + let allItFeaturesInThisCategory = { + categoryName: category, + features: featuresInThisCategory, + }; + // Add the dictionaries to the arrays that we'll use to build the features table. + pricingTableForIt.push(allItFeaturesInThisCategory); + } // Respond with view. diff --git a/website/api/controllers/view-query-detail.js b/website/api/controllers/view-query-detail.js index 3d974034ad..c4f8f51430 100644 --- a/website/api/controllers/view-query-detail.js +++ b/website/api/controllers/view-query-detail.js @@ -35,7 +35,7 @@ module.exports = { } // Setting the meta title and description of this page using the query object, and falling back to a generic title or description if query.name or query.description are missing. - let pageTitleForMeta = query.name ? query.name + ' | Query details' : 'Query details | Fleet'; + let pageTitleForMeta = query.name ? query.name + ' | Query details' : 'Query details'; let pageDescriptionForMeta = query.description ? query.description : 'View more information about a query in Fleet\'s standard query library'; // Respond with view. return { diff --git a/website/api/controllers/view-transparency.js b/website/api/controllers/view-transparency.js index c079ed5300..7267792026 100644 --- a/website/api/controllers/view-transparency.js +++ b/website/api/controllers/view-transparency.js @@ -6,7 +6,6 @@ module.exports = { description: 'Display "Transparency" page.', - exits: { success: { @@ -17,9 +16,8 @@ module.exports = { fn: async function () { - // Respond with view. - return {}; + return {showSecureframeBanner: this.req.param('utm_content') === 'secureframe'}; } diff --git a/website/api/helpers/get-extended-osquery-schema.js b/website/api/helpers/get-extended-osquery-schema.js index d19b090b7b..1a697a5847 100644 --- a/website/api/helpers/get-extended-osquery-schema.js +++ b/website/api/helpers/get-extended-osquery-schema.js @@ -11,6 +11,10 @@ module.exports = { type: 'boolean', defaultsTo: false, description: 'Whether or not to include a lastModifiedAt value for each table.', + }, + githubAccessToken: { + type: 'string', + description: 'A github token used to authenticate requests to the GitHub API' } }, @@ -25,11 +29,10 @@ module.exports = { }, - fn: async function ({includeLastModifiedAtValue}) { + fn: async function ({includeLastModifiedAtValue, githubAccessToken}) { let path = require('path'); let YAML = require('yaml'); let util = require('util'); - let topLvlRepoPath = path.resolve(sails.config.appPath, '../'); require('assert')(sails.config.custom.versionOfOsquerySchemaToUseWhenGeneratingDocumentation, 'Please set sails.config.custom.sails.config.custom.versionOfOsquerySchemaToUseWhenGeneratingDocumentation to the version of osquery to use, for example \'5.8.1\'.'); let VERSION_OF_OSQUERY_SCHEMA_TO_USE = sails.config.custom.versionOfOsquerySchemaToUseWhenGeneratingDocumentation; @@ -40,6 +43,14 @@ module.exports = { let rawOsqueryTablesLastModifiedAt; if(includeLastModifiedAtValue) { // If we're including a lastModifiedAt value for schema tables, we'll send a request to the GitHub API to get a timestamp of when the last commit + let baseHeadersForGithubRequests = { + 'User-Agent': 'fleet-schema-builder', + 'Accept': 'application/vnd.github.v3+json', + }; + // If a GitHub access token was provided, add it to the headers. + if(githubAccessToken){ + baseHeadersForGithubRequests['Authorization'] = `token ${githubAccessToken}`; + } let responseData = await sails.helpers.http.get.with({// [?]: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits url: 'https://api.github.com/repos/osquery/osquery-site/commits', data: { @@ -47,10 +58,7 @@ module.exports = { page: 1, per_page: 1,//eslint-disable-line camelcase }, - headers: { - 'User-Agent': 'fleet-schema-builder', - 'Accept': 'application/vnd.github.v3+json', - }, + headers: baseHeadersForGithubRequests }).intercept((err)=>{ return new Error(`When trying to send a request to GitHub get a timestamp of the last commit to the osqeury schema JSON, an error occurred. Full error: ${util.inspect(err)}`); }); diff --git a/website/api/models/User.js b/website/api/models/User.js index 72b603338d..53be23f9c6 100644 --- a/website/api/models/User.js +++ b/website/api/models/User.js @@ -248,20 +248,19 @@ without necessarily having a billing card.` stageThreeNurtureEmailSentAt: { type: 'number', - description: 'A JS timestamp of when the stage 3 nurture email was sent to the user.' + description: 'A JS timestamp of when the stage 3 nurture email was sent to the user, or 1 if the user is unsubscribed from automated emails.', }, stageFourNurtureEmailSentAt: { type: 'number', - description: 'A JS timestamp of when the stage 4 nurture email was sent to the user.' + description: 'A JS timestamp of when the stage 4 nurture email was sent to the user, or 1 if the user is unsubscribed from automated emails.', }, stageFiveNurtureEmailSentAt: { type: 'number', - description: 'A JS timestamp of when the stage 5 nurture email was sent to the user.' + description: 'A JS timestamp of when the stage 5 nurture email was sent to the user, or 1 if the user is unsubscribed from automated emails.', }, - // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ diff --git a/website/assets/.eslintrc b/website/assets/.eslintrc index 96d18f3427..6cb581fa72 100644 --- a/website/assets/.eslintrc +++ b/website/assets/.eslintrc @@ -49,6 +49,7 @@ "docsearch": true, "Chart": true, "gtag": true, + "analytics": true, // ...etc. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-2-900x450@2x.png b/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-2-900x450@2x.png index 8405577e42..468a372a38 100644 Binary files a/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-2-900x450@2x.png and b/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-2-900x450@2x.png differ diff --git a/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-cover-900x450@2x.png b/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-cover-900x450@2x.png index cb61dba9bb..8537b5e285 100644 Binary files a/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-cover-900x450@2x.png and b/website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-cover-900x450@2x.png differ diff --git a/website/assets/images/fleet-profile-image.png b/website/assets/images/fleet-profile-image.png new file mode 100644 index 0000000000..3d28053398 Binary files /dev/null and b/website/assets/images/fleet-profile-image.png differ diff --git a/website/assets/images/logo-secureframe-46x48@2x.png b/website/assets/images/logo-secureframe-46x48@2x.png new file mode 100644 index 0000000000..86107888f9 Binary files /dev/null and b/website/assets/images/logo-secureframe-46x48@2x.png differ diff --git a/website/assets/js/components/animated-arrow-button.component.js b/website/assets/js/components/animated-arrow-button.component.js index 00e7df9e76..d7a42e0049 100644 --- a/website/assets/js/components/animated-arrow-button.component.js +++ b/website/assets/js/components/animated-arrow-button.component.js @@ -14,6 +14,8 @@ parasails.registerComponent('animatedArrowButton', { // ╩ ╩╚═╚═╝╩ ╚═╝ props: [ 'buttonType', + 'arrowColor',// For customizing the color of the animated arrow. e.g., arrow-color="#FFF" + 'textColor',// For customizing the color of the button text. e.g., text-color="#FFF" ], // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ @@ -22,6 +24,8 @@ parasails.registerComponent('animatedArrowButton', { data: function (){ return { type: 'secondary', + strokeColor: this.arrowColor ? this.arrowColor : '#FF5C83', + fontColor: this.textColor ? this.textColor : '#192147' // FUTURE: support more button types (primary and secondary) // type: this.buttonType && this.buttonType === 'primary' ? 'primary' : 'secondary', }; @@ -32,8 +36,8 @@ parasails.registerComponent('animatedArrowButton', { // ╩ ╩ ╩ ╩ ╩╩═╝ template: ` - - + + diff --git a/website/assets/js/pages/contact.page.js b/website/assets/js/pages/contact.page.js index 4982f49cf8..36571b672a 100644 --- a/website/assets/js/pages/contact.page.js +++ b/website/assets/js/pages/contact.page.js @@ -86,6 +86,9 @@ parasails.registerPage('contact', { if(typeof window.lintrk !== 'undefined') { window.lintrk('track', { conversion_id: 18587089 });// eslint-disable-line camelcase } + if(typeof analytics !== 'undefined'){ + analytics.track('fleet_website__contact_forms'); + } // Show the success message. this.cloudSuccess = true; @@ -98,6 +101,9 @@ parasails.registerPage('contact', { if(typeof window.lintrk !== 'undefined') { window.lintrk('track', { conversion_id: 18587089 });// eslint-disable-line camelcase } + if(typeof analytics !== 'undefined'){ + analytics.track('fleet_website__contact_forms'); + } if(this.formData.numberOfHosts > 700){ this.goto(`https://calendly.com/fleetdm/talk-to-us?email=${encodeURIComponent(this.formData.emailAddress)}&name=${encodeURIComponent(this.formData.firstName+' '+this.formData.lastName)}`); } else { diff --git a/website/assets/js/pages/customers/new-license.page.js b/website/assets/js/pages/customers/new-license.page.js index 6917592f51..3f1b582300 100644 --- a/website/assets/js/pages/customers/new-license.page.js +++ b/website/assets/js/pages/customers/new-license.page.js @@ -39,7 +39,31 @@ parasails.registerPage('new-license', { // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ beforeMount: function() { - //… + if(window.location.hash) { + if(typeof analytics !== 'undefined') { + if(window.location.hash === '#signup') { + analytics.identify(this.me.id, { + email: this.me.emailAddress, + firstName: this.me.firstName, + lastName: this.me.lastName, + company: this.me.organization, + primaryBuyingSituation: this.me.primaryBuyingSituation, + psychologicalStage: this.me.psychologicalStage, + }); + analytics.track('fleet_website__sign_up'); + } else if(window.location.hash === '#login') { + analytics.identify(this.me.id, { + email: this.me.emailAddress, + firstName: this.me.firstName, + lastName: this.me.lastName, + company: this.me.organization, + primaryBuyingSituation: this.me.primaryBuyingSituation, + psychologicalStage: this.me.psychologicalStage, + }); + } + } + window.location.hash = ''; + } }, mounted: async function() { diff --git a/website/assets/js/pages/docs/basic-documentation.page.js b/website/assets/js/pages/docs/basic-documentation.page.js index 2c40e629da..d09d42f605 100644 --- a/website/assets/js/pages/docs/basic-documentation.page.js +++ b/website/assets/js/pages/docs/basic-documentation.page.js @@ -229,6 +229,9 @@ parasails.registerPage('basic-documentation', { if(typeof window.lintrk !== 'undefined') { window.lintrk('track', { conversion_id: 18587105 });// eslint-disable-line camelcase } + if(typeof analytics !== 'undefined'){ + analytics.track('fleet_website__swag_request'); + } this.goto('https://kqphpqst851.typeform.com/to/ZfA3sOu0'); }, diff --git a/website/assets/js/pages/entrance/login.page.js b/website/assets/js/pages/entrance/login.page.js index a506effa58..a18738b75d 100644 --- a/website/assets/js/pages/entrance/login.page.js +++ b/website/assets/js/pages/entrance/login.page.js @@ -27,7 +27,7 @@ parasails.registerPage('login', { showCustomerLogin: true, // For redirecting users coming from the "Get your license" link to the license dispenser. registerSlug: '/register', - pageToRedirectToAfterLogin: '/start', + pageToRedirectToAfterLogin: '/start#login', }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ @@ -41,7 +41,7 @@ parasails.registerPage('login', { // If we're redirecting this user to the license dispenser after they log in, modify the link to the /register page and the pageToRedirectToAfterLogin. if(window.location.hash && window.location.hash === '#purchaseLicense'){ this.registerSlug = '/register#purchaseLicense'; - this.pageToRedirectToAfterLogin = '/new-license'; + this.pageToRedirectToAfterLogin = '/new-license#login'; window.location.hash = ''; } }, diff --git a/website/assets/js/pages/entrance/signup.page.js b/website/assets/js/pages/entrance/signup.page.js index 6cad63f897..d749af7b30 100644 --- a/website/assets/js/pages/entrance/signup.page.js +++ b/website/assets/js/pages/entrance/signup.page.js @@ -26,7 +26,7 @@ parasails.registerPage('signup', { showFullForm: false, // For redirecting users coming from the "Get your license" link to the license dispenser. loginSlug: '/login', - pageToRedirectToAfterRegistration: '/start', + pageToRedirectToAfterRegistration: '/start#signup', primaryBuyingSituation: undefined }, @@ -37,7 +37,7 @@ parasails.registerPage('signup', { // If we're redirecting this user to the license dispenser after they sign up, modify the link to the login page and the pageToRedirectToAfterRegistration if(window.location.hash && window.location.hash === '#purchaseLicense'){ this.loginSlug = '/login#purchaseLicense'; - this.pageToRedirectToAfterRegistration = '/new-license'; + this.pageToRedirectToAfterRegistration = '/new-license#signup'; window.location.hash = ''; } }, diff --git a/website/assets/js/pages/homepage.page.js b/website/assets/js/pages/homepage.page.js index 5a5100cebc..b9bfc560b4 100644 --- a/website/assets/js/pages/homepage.page.js +++ b/website/assets/js/pages/homepage.page.js @@ -12,6 +12,10 @@ parasails.registerPage('homepage', { // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ beforeMount: function() { //… + if(window.location.hash === '#unsubscribed'){ + this.modal = 'unsubscribed'; + window.location.hash = ''; + } }, mounted: async function() { //… diff --git a/website/assets/js/pages/start.page.js b/website/assets/js/pages/start.page.js index ac31cb69d4..e6f9514e05 100644 --- a/website/assets/js/pages/start.page.js +++ b/website/assets/js/pages/start.page.js @@ -85,7 +85,34 @@ parasails.registerPage('start', { } // If this user has not completed the 'what are you using fleet for' step, and has a primaryBuyingSituation set by an ad. prefill the formData for this step. if(this.primaryBuyingSituation && _.isEmpty(this.formData['what-are-you-using-fleet-for'])){ - this.formData['what-are-you-using-fleet-for'] = {primaryBuyingSituation: this.primaryBuyingSituation}; + if(this.primaryBuyingSituation !== 'vm') { + this.formData['what-are-you-using-fleet-for'] = {primaryBuyingSituation: this.primaryBuyingSituation}; + } + } + if(window.location.hash) { + if(typeof analytics !== 'undefined') { + if(window.location.hash === '#signup') { + analytics.identify(this.me.id, { + email: this.me.emailAddress, + firstName: this.me.firstName, + lastName: this.me.lastName, + company: this.me.organization, + primaryBuyingSituation: this.me.primaryBuyingSituation, + psychologicalStage: this.me.psychologicalStage, + }); + analytics.track('fleet_website__sign_up'); + } else if(window.location.hash === '#login') { + analytics.identify(this.me.id, { + email: this.me.emailAddress, + firstName: this.me.firstName, + lastName: this.me.lastName, + company: this.me.organization, + primaryBuyingSituation: this.me.primaryBuyingSituation, + psychologicalStage: this.me.psychologicalStage, + }); + } + } + window.location.hash = ''; } }, mounted: async function() { diff --git a/website/assets/js/pages/transparency.page.js b/website/assets/js/pages/transparency.page.js index 211b9f441f..be7cfde1f3 100644 --- a/website/assets/js/pages/transparency.page.js +++ b/website/assets/js/pages/transparency.page.js @@ -4,6 +4,7 @@ parasails.registerPage('transparency', { // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ data: { //… + showSecureframeBanner: false, }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ diff --git a/website/assets/styles/components/animated-arrow-button.component.less b/website/assets/styles/components/animated-arrow-button.component.less index 60102c4d7b..61d9b459b8 100644 --- a/website/assets/styles/components/animated-arrow-button.component.less +++ b/website/assets/styles/components/animated-arrow-button.component.less @@ -21,7 +21,7 @@ height: 12px; width: 12px; fill: none; - stroke: #FF5C83; + // stroke: #FF5C83; « Note: this is overridden by the arrow-color prop; } [purpose='arrow-line'] { opacity: 0; diff --git a/website/assets/styles/pages/homepage.less b/website/assets/styles/pages/homepage.less index bffa783ff5..489671a645 100644 --- a/website/assets/styles/pages/homepage.less +++ b/website/assets/styles/pages/homepage.less @@ -60,7 +60,11 @@ min-width: 540px; text-align: center; h1 { + max-width: 640px; margin-bottom: 16px; + &.vm { + max-width: unset; + } } p { margin-bottom: 32px; @@ -334,10 +338,10 @@ .selected { font-weight: 700; } - .eo-selected { + .it-selected { transform: translateX(100%); } - .vm-selected { + .security-selected { transform: translateX(200%); } } @@ -639,6 +643,16 @@ } } + [purpose='bottom-cta'] { + h1 { + font-size: 48px; + max-width: 640px; + &.vm { + max-width: unset; + } + } + } + [purpose='video-modal'] { [purpose='modal-dialog'] { width: 100%; @@ -707,11 +721,6 @@ [purpose='integrations-section'] { margin-top: 160px; } - [purpose='bottom-cta'] { - h1 { - font-size: 48px; - } - } [purpose='video-modal'] { [purpose='modal-dialog'] { width: 100%; @@ -995,11 +1004,11 @@ height: 56px; width: 100%; } - .eo-selected { + .it-selected { width: 100%; transform: translateY(56px); } - .vm-selected { + .security-selected { width: 100%; transform: translateY(113px); } diff --git a/website/assets/styles/pages/transparency.less b/website/assets/styles/pages/transparency.less index ac5831ccf0..15334ab0a9 100644 --- a/website/assets/styles/pages/transparency.less +++ b/website/assets/styles/pages/transparency.less @@ -54,7 +54,7 @@ } [purpose='hero-text'] { text-align: center; - margin-bottom: 40px; + margin-bottom: 80px; h1 { font-size: 48px; font-weight: 800; @@ -128,6 +128,31 @@ } } } + [purpose='secureframe-banner'] { + width: 100%; + text-align: center; + color: #FFF; + h2, p { + color: #FFF; + } + h2 { + margin-bottom: 16px; + } + [parasails-component='animated-arrow-button'] { + font-weight: 600; + } + border-radius: 16px; + background: #091922; + padding: 32px; + img { + height: 48px; + width: 45.474px; + margin-bottom: 16px; + margin-left: auto; + margin-right: auto; + } + } + [purpose='feature-headline'] { max-width: 510px; margin-bottom: 80px; @@ -168,13 +193,13 @@ [purpose='accordion'] { - padding-top: 64px; + h2 { padding-bottom: 16px; } [purpose='accordion-body'] { width: 100%; - padding-top: 64px; + [purpose='accordion-item'] { border-bottom: 1px solid #E2E4EA; padding-bottom: ; @@ -332,6 +357,12 @@ margin-right: auto; } } + [purpose='secureframe-banner'] { + padding: 32px 16px; + h2 { + font-size: 24px; + } + } } @media (max-width: 375px) { diff --git a/website/assets/styles/pages/vulnerability-management.less b/website/assets/styles/pages/vulnerability-management.less index 17a1504178..87998dd3d2 100644 --- a/website/assets/styles/pages/vulnerability-management.less +++ b/website/assets/styles/pages/vulnerability-management.less @@ -46,7 +46,7 @@ [purpose='page-headline'] { padding-bottom: 80px; - width: 680px; + max-width: 780px; h2 { font-size: 48px; font-style: normal; diff --git a/website/config/routes.js b/website/config/routes.js index 8339089831..1a87ea2567 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -23,7 +23,7 @@ module.exports.routes = { 'GET /contact': { action: 'view-contact', locals: { - pageTitleForMeta: 'Contact us | Fleet', + pageTitleForMeta: 'Contact us', pageDescriptionForMeta: 'Get in touch with our team.', hideFooterLinks: true, } @@ -34,7 +34,7 @@ module.exports.routes = { locals: { hideHeaderLinks: true, hideFooterLinks: true, - pageTitleForMeta: 'fleetctl preview | Fleet', + pageTitleForMeta: 'fleetctl preview', pageDescriptionForMeta: 'Learn about getting started with Fleet using fleetctl.' } }, @@ -43,7 +43,7 @@ module.exports.routes = { action: 'view-pricing', locals: { currentSection: 'pricing', - pageTitleForMeta: 'Pricing | Fleet', + pageTitleForMeta: 'Pricing', pageDescriptionForMeta: 'Use Fleet for free or get started with Fleet Premium (self-hosted or managed cloud). Have a large deployment? We\'ve got you covered.' } }, @@ -51,7 +51,7 @@ module.exports.routes = { 'GET /logos': { action: 'view-press-kit', locals: { - pageTitleForMeta: 'Logos | Fleet', + pageTitleForMeta: 'Logos', pageDescriptionForMeta: 'Download Fleet logos, wallpapers, and screenshots.' } }, @@ -60,7 +60,7 @@ module.exports.routes = { action: 'view-query-library', locals: { currentSection: 'documentation', - pageTitleForMeta: 'Queries | Fleet', + pageTitleForMeta: 'Queries', pageDescriptionForMeta: 'A growing collection of useful queries for organizations deploying Fleet and osquery.' } }, @@ -106,7 +106,7 @@ module.exports.routes = { hideHeaderLinks: true, hideFooterLinks: true, hideStartCTA: true, - pageTitleForMeta: 'Get Fleet Premium | Fleet', + pageTitleForMeta: 'Get Fleet Premium', pageDescriptionForMeta: 'Generate your quote and start using Fleet Premium today.', } }, @@ -114,7 +114,7 @@ module.exports.routes = { action: 'entrance/view-signup', locals: { hideFooterLinks: true, - pageTitleForMeta: 'Sign up | Fleet', + pageTitleForMeta: 'Sign up', pageDescriptionForMeta: 'Sign up for a Fleet account.', } }, @@ -122,7 +122,7 @@ module.exports.routes = { action: 'entrance/view-login', locals: { hideFooterLinks: true, - pageTitleForMeta: 'Log in | Fleet', + pageTitleForMeta: 'Log in', pageDescriptionForMeta: 'Log in to Fleet.', } }, @@ -132,7 +132,7 @@ module.exports.routes = { hideHeaderLinks: true, hideFooterLinks: true, hideStartCTA: true, - pageTitleForMeta: 'Customer dashboard | Fleet', + pageTitleForMeta: 'Customer dashboard', pageDescriptionForMeta: 'View and edit information about your Fleet Premium license.', } }, @@ -142,7 +142,7 @@ module.exports.routes = { hideHeaderLinks: true, hideFooterLinks: true, hideStartCTA: true, - pageTitleForMeta: 'Forgot password | Fleet', + pageTitleForMeta: 'Forgot password', pageDescriptionForMeta: 'Recover the password for your Fleet customer account.', } }, @@ -152,7 +152,7 @@ module.exports.routes = { hideHeaderLinks: true, hideFooterLinks: true, hideStartCTA: true, - pageTitleForMeta: 'New password | Fleet', + pageTitleForMeta: 'New password', pageDescriptionForMeta: 'Change the password for your Fleet customer account.', } }, @@ -160,7 +160,7 @@ module.exports.routes = { 'GET /reports/state-of-device-management': { action: 'reports/view-state-of-device-management', locals: { - pageTitleForMeta: 'State of device management | Fleet', + pageTitleForMeta: 'State of device management', pageDescriptionForMeta: 'We surveyed 200+ security practitioners to discover the state of device management in 2022. Click here to learn about their struggles and best practices.', } }, @@ -221,7 +221,7 @@ module.exports.routes = { 'GET /device-management': { action: 'view-device-management', locals: { - pageTitleForMeta: 'Device management (MDM) | Fleet', + pageTitleForMeta: 'Device management (MDM)', pageDescriptionForMeta: 'Manage your devices in any browser or use git to make changes as code.', currentSection: 'platform', } @@ -230,7 +230,7 @@ module.exports.routes = { 'GET /endpoint-ops': { action: 'view-endpoint-ops', locals: { - pageTitleForMeta: 'Endpoint ops | Fleet', + pageTitleForMeta: 'Endpoint ops', pageDescriptionForMeta: 'Pulse check anything, build reports, and ship data to any platform with Fleet.', currentSection: 'platform', } @@ -239,7 +239,7 @@ module.exports.routes = { 'GET /vulnerability-management': { action: 'view-vulnerability-management', locals: { - pageTitleForMeta: 'Vulnerability management | Fleet', + pageTitleForMeta: 'Vulnerability management', pageDescriptionForMeta: 'Report CVEs, software inventory, security posture, and other risks down to the chipset of any endpoint with Fleet.', currentSection: 'platform', } @@ -248,7 +248,7 @@ module.exports.routes = { 'GET /support': { action: 'view-support', locals: { - pageTitleForMeta: 'Support | Fleet', + pageTitleForMeta: 'Support', pageDescriptionForMeta: 'Ask a question, chat with engineers, or get in touch with the Fleet team.', currentSection: 'documentation', } @@ -257,7 +257,7 @@ module.exports.routes = { 'GET /integrations': { action: 'view-integrations', locals: { - pageTitleForMeta: 'Integrations | Fleet', + pageTitleForMeta: 'Integrations', pageDescriptionForMeta: 'Integrate IT ticketing systems, SIEM and SOAR platforms, custom IT workflows, and more.', currentSection: 'platform' } @@ -269,7 +269,7 @@ module.exports.routes = { hideFooterLinks: true, hideGetStartedButton: true, hideStartCTA: true, - pageTitleForMeta: 'Start | Fleet', + pageTitleForMeta: 'Start', pageDescriptionForMeta: 'Get Started with Fleet. Spin up a local demo or get your Premium license key.', } }, @@ -278,7 +278,7 @@ module.exports.routes = { action: 'view-transparency', locals: { pageDescriptionForMeta: 'Discover how Fleet simplifies IT and security, prioritizing privacy, transparency, and trust for end users.', - pageTitleForMeta: 'Better with Fleet | Fleet' + pageTitleForMeta: 'Better with Fleet' } }, @@ -480,7 +480,7 @@ module.exports.routes = { 'GET /get-started': '/try-fleet', 'GET /g': (req,res)=> { let originalQueryStringWithAmp = req.url.match(/\?(.+)$/) ? '&'+req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/?meet-fleet'+originalQueryStringWithAmp); }, 'GET /test-fleet-sandbox': '/register', - 'GET /unsubscribe': (req,res)=> { let originalQueryString = req.url.match(/\?(.+)$/) ? req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/api/v1/unsubscribe-from-all-newsletters?'+originalQueryString);}, + 'GET /unsubscribe': (req,res)=> { let originalQueryString = req.url.match(/\?(.+)$/) ? req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/api/v1/unsubscribe-from-marketing-emails?'+originalQueryString);}, 'GET /tables': '/tables/account_policy_data', 'GET /imagine/launch-party': 'https://www.eventbrite.com/e/601763519887', 'GET /blackhat2023': 'https://github.com/fleetdm/fleet/tree/main/tools/blackhat-mdm', // Assets from @marcosd4h & @zwass Black Hat 2023 talk @@ -605,4 +605,5 @@ module.exports.routes = { 'POST /api/v1/save-questionnaire-progress': { action: 'save-questionnaire-progress' }, 'POST /api/v1/account/update-start-cta-visibility': { action: 'account/update-start-cta-visibility' }, 'POST /api/v1/deliver-deal-registration-submission': { action: 'deliver-deal-registration-submission' }, + '/api/v1/unsubscribe-from-marketing-emails': { action: 'unsubscribe-from-marketing-emails' }, }; diff --git a/website/scripts/build-static-content.js b/website/scripts/build-static-content.js index 470da38f6c..a34e2b5640 100644 --- a/website/scripts/build-static-content.js +++ b/website/scripts/build-static-content.js @@ -16,7 +16,7 @@ module.exports = { fn: async function ({ dry, githubAccessToken }) { let path = require('path'); let YAML = require('yaml'); - + let util = require('util'); // FUTURE: If we ever need to gather source files from other places or branches, etc, see git history of this file circa 2021-05-19 for an example of a different strategy we might use to do that. let topLvlRepoPath = path.resolve(sails.config.appPath, '../'); @@ -390,11 +390,29 @@ module.exports = { }//fi // Get last modified timestamp using git, and represent it as a JS timestamp. - // > Inspired by https://github.com/uncletammy/doc-templater/blob/2969726b598b39aa78648c5379e4d9503b65685e/lib/compile-markdown-tree-from-remote-git-repo.js#L265-L273 - let lastModifiedAt = (new Date((await sails.helpers.process.executeCommand.with({ - command: `git log -1 --format="%ai" '${path.relative(topLvlRepoPath, pageSourcePath)}'`, - dir: topLvlRepoPath, - })).stdout)).getTime(); + let lastModifiedAt; + if(!githubAccessToken) { + lastModifiedAt = Date.now(); + } else { + let responseData = await sails.helpers.http.get.with({// [?]: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + url: 'https://api.github.com/repos/fleetdm/fleet/commits', + data: { + path: path.join(sectionRepoPath, pageRelSourcePath), + page: 1, + per_page: 1,//eslint-disable-line camelcase + }, + headers: baseHeadersForGithubRequests, + }).intercept((err)=>{ + return new Error(`When getting the commit history for ${path.join(sectionRepoPath, pageRelSourcePath)} to get a lastModifiedAt timestamp, an error occured.`, err); + }); + // The value we'll use for the lastModifiedAt timestamp will be date value of the `commiter` property of the `commit` we got in the API response from github. + let mostRecentCommitToOsquerySchema = responseData[0]; + if(!mostRecentCommitToOsquerySchema.commit || !mostRecentCommitToOsquerySchema.commit.committer) { + // Throw an error if the the response from GitHub is missing a commit or commiter. + throw new Error(`When getting the commit history for ${path.join(sectionRepoPath, pageRelSourcePath)} to get a lastModifiedAt timestamp, the response from the GitHub API did not include information about the most recent commit. Response from GitHub: ${util.inspect(responseData, {depth:null})}`); + } + lastModifiedAt = (new Date(mostRecentCommitToOsquerySchema.commit.committer.date)).getTime(); // Convert the UTC timestamp from GitHub to a JS timestamp. + } // Determine display title (human-readable title) to use for this page. let pageTitle; @@ -560,11 +578,30 @@ module.exports = { let RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO = 'handbook/company/open-positions.yml'; // Get last modified timestamp using git, and represent it as a JS timestamp. - // > Inspired by https://github.com/uncletammy/doc-templater/blob/2969726b598b39aa78648c5379e4d9503b65685e/lib/compile-markdown-tree-from-remote-git-repo.js#L265-L273 - let lastModifiedAt = (new Date((await sails.helpers.process.executeCommand.with({ - command: `git log -1 --format="%ai" '${path.join(topLvlRepoPath, RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO)}'`, - dir: topLvlRepoPath, - })).stdout)).getTime(); + let lastModifiedAt; + if(!githubAccessToken) { + lastModifiedAt = Date.now(); + } else { + // If we're including a lastModifiedAt value for schema tables, we'll send a request to the GitHub API to get a timestamp of when the last commit + let responseData = await sails.helpers.http.get.with({// [?]: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + url: 'https://api.github.com/repos/fleetdm/fleet/commits', + data: { + path: RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO, + page: 1, + per_page: 1,//eslint-disable-line camelcase + }, + headers: baseHeadersForGithubRequests, + }).intercept((err)=>{ + return new Error(`When getting the commit history for the open positions YAML to get a lastModifiedAt timestamp, an error occured.`, err); + }); + // The value we'll use for the lastModifiedAt timestamp will be date value of the `commiter` property of the `commit` we got in the API response from github. + let mostRecentCommitToOsquerySchema = responseData[0]; + if(!mostRecentCommitToOsquerySchema.commit || !mostRecentCommitToOsquerySchema.commit.committer) { + // Throw an error if the the response from GitHub is missing a commit or commiter. + throw new Error(`When trying to get a lastModifiedAt timestamp for the open positions YAML, the response from the GitHub API did not include information about the most recent commit. Response from GitHub: ${util.inspect(responseData, {depth:null})}`); + } + lastModifiedAt = (new Date(mostRecentCommitToOsquerySchema.commit.committer.date)).getTime(); // Convert the UTC timestamp from GitHub to a JS timestamp. + } let openPositionsYaml = await sails.helpers.fs.read(path.join(topLvlRepoPath, RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO)).intercept('doesNotExist', (err)=>new Error(`Could not find open positions YAML file at "${RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO}". Was it accidentally moved? Raw error: `+err.message)); let openPositionsToCreatePartialsFor = YAML.parse(openPositionsYaml, {prettyErrors: true}); @@ -602,7 +639,7 @@ module.exports = { let pageTitle = openPosition.jobTitle; - let mdStringForThisOpenPosition = `# ${openPosition.jobTitle}\n\n## Let's start with why we exist. 📡\n\nEver wondered if your employer is monitoring your work computer?\n\nOrganizations make huge investments every year to keep their laptops and servers online, secure, compliant, and usable from anywhere. This is called "device management".\n\nAt Fleet, we think it's time device management became [transparent](https://fleetdm.com/transparency) and [open source](https://fleetdm.com/handbook/company#open-source).\n\n\n## About the company 🌈\n\nYou can read more about the company in our [handbook](https://fleetdm.com/handbook/company), which is public and open to the world.\n\ntldr; Fleet Device Management Inc. is a [recently-funded](https://techcrunch.com/2022/04/28/fleet-nabs-20m-to-enable-enterprises-to-manage-their-devices/) Series A startup founded and backed by the same people who created osquery, the leading open source security agent. Today, osquery is installed on millions of laptops and servers, and it is especially popular with [enterprise IT and security teams](https://www.linuxfoundation.org/press/press-release/the-linux-foundation-announces-intent-to-form-new-foundation-to-support-osquery-community).\n\n\n## Your primary responsibilities 🔭\n${openPosition.responsibilities}\n\n## Are you our new team member? 🧑‍🚀\nIf most of these qualities sound like you, we would love to chat and see if we're a good fit.\n\n${openPosition.experience}\n\n## Why should you join us? 🛸\n\nLearn more about the company and [why you should join us here](https://fleetdm.com/handbook/company#is-it-any-good).\n\n
    Deloitte logo

    “One of the best teams out there to go work for and help shape security platforms.”

    Dhruv Majumdar

    Director Of Cyber Risk & Advisory

    \n\n\n## Want to join the team?\n\nWant to join the team?\n\nReach out to [${openPosition.hiringManagerName} on Linkedin](${openPosition.hiringManagerLinkedInUrl}).`; + let mdStringForThisOpenPosition = `# ${openPosition.jobTitle}\n\n## Let's start with why we exist. 📡\n\nEver wondered if your employer is monitoring your work computer?\n\nOrganizations make huge investments every year to keep their laptops and servers online, secure, compliant, and usable from anywhere. This is called "device management".\n\nAt Fleet, we think it's time device management became [transparent](https://fleetdm.com/transparency) and [open source](https://fleetdm.com/handbook/company#open-source).\n\n\n## About the company 🌈\n\nYou can read more about the company in our [handbook](https://fleetdm.com/handbook/company), which is public and open to the world.\n\ntldr; Fleet Device Management Inc. is a [recently-funded](https://techcrunch.com/2022/04/28/fleet-nabs-20m-to-enable-enterprises-to-manage-their-devices/) Series A startup founded and backed by the same people who created osquery, the leading open source security agent. Today, osquery is installed on millions of laptops and servers, and it is especially popular with [enterprise IT and security teams](https://www.linuxfoundation.org/press/press-release/the-linux-foundation-announces-intent-to-form-new-foundation-to-support-osquery-community).\n\n\n## Your primary responsibilities 🔭\n${openPosition.responsibilities}\n\n## Are you our new team member? 🧑‍🚀\nIf most of these qualities sound like you, we would love to chat and see if we're a good fit.\n\n${openPosition.experience}\n\n## Why should you join us? 🛸\n\nLearn more about the company and [why you should join us here](https://fleetdm.com/handbook/company#is-it-any-good).\n\n
    Deloitte logo

    “One of the best teams out there to go work for and help shape security platforms.”

    Dhruv Majumdar

    Director Of Cyber Risk & Advisory

    \n\n\n## Want to join the team?\n\nWant to join the team?\n\nReach out to [${openPosition.hiringManagerName} on Linkedin](${openPosition.hiringManagerLinkedInUrl}). \n\n\n >The salary range for this role is $48,000 - $480,000. Fleet provides competitive compensation based on our [compensation philosophy](https://fleetdm.com/handbook/company/communications#compensation), as well as comprehensive [benefits](https://fleetdm.com/handbook/company/communications#benefits).`; let htmlStringForThisPosition = await sails.helpers.strings.toHtml.with({mdString: mdStringForThisOpenPosition}); @@ -673,7 +710,12 @@ module.exports = { } // After we build the Markdown pages, we'll merge the osquery schema with the Fleet schema overrides, then create EJS partials for each table in the merged schema. - let expandedTables = await sails.helpers.getExtendedOsquerySchema.with({includeLastModifiedAtValue: true}); + let expandedTables; + if(githubAccessToken){ + expandedTables = await sails.helpers.getExtendedOsquerySchema.with({includeLastModifiedAtValue: true, githubAccessToken,}); + } else { + expandedTables = await sails.helpers.getExtendedOsquerySchema(); + } // Once we have our merged schema, we'll create ejs partials for each table. for(let table of expandedTables) { @@ -804,7 +846,14 @@ module.exports = { let pricingTableFeatures = YAML.parse(yaml, {prettyErrors: true}); let VALID_PRODUCT_CATEGORIES = ['Endpoint operations', 'Device management', 'Vulnerability management']; let VALID_PRICING_TABLE_CATEGORIES = ['Support', 'Deployment', 'Integrations', 'Endpoint operations', 'Device management', 'Vulnerability management']; + let VALID_PRICING_TABLE_KEYS = ['industryName', 'description', 'documentationUrl', 'tier', 'jamfProHasFeature', 'jamfProtectHasFeature', 'usualDepartment', 'productCategories', 'pricingTableCategories', 'waysToUse', 'buzzwords', 'demos', 'dri', 'friendlyName', 'moreInfoUrl', 'comingSoonOn', 'screenshotSrc', 'isExperimental']; for(let feature of pricingTableFeatures){ + // Throw an error if a feature contains an unrecognized key. + for(let key of _.keys(feature)){ + if(!VALID_PRICING_TABLE_KEYS.includes(key)){ + throw new Error(`Unrecognized key. Could not build pricing table config from pricing-features-table.yml. The "${feature.industryName}" feature contains an unrecognized key (${key}). To resolve, fix any typos or remove this key and try running this script again.`); + } + } if(feature.name) {// Compatibility check throw new Error(`Could not build pricing table config from pricing-features-table.yml. A feature has a "name" (${feature.name}) which is no longer supported. To resolve, add a "industryName" to this feature: ${feature}`); } @@ -1091,7 +1140,6 @@ module.exports = { } }); } - } diff --git a/website/scripts/deliver-nurture-emails.js b/website/scripts/deliver-nurture-emails.js index 552c75b0a5..e3f90e254f 100644 --- a/website/scripts/deliver-nurture-emails.js +++ b/website/scripts/deliver-nurture-emails.js @@ -52,7 +52,8 @@ module.exports = { template: 'email-nurture-stage-three', layout: 'layout-nurture-email', templateData: { - firstName: user.firstName + firstName: user.firstName, + emailAddress: user.emailAddress }, to: user.emailAddress, toName: `${user.firstName} ${user.lastName}`, @@ -80,7 +81,8 @@ module.exports = { template: 'email-nurture-stage-four', layout: 'layout-nurture-email', templateData: { - firstName: user.firstName + firstName: user.firstName, + emailAddress: user.emailAddress }, to: user.emailAddress, toName: `${user.firstName} ${user.lastName}`, @@ -109,7 +111,8 @@ module.exports = { template: 'email-nurture-stage-five', layout: 'layout-nurture-email', templateData: { - firstName: user.firstName + firstName: user.firstName, + emailAddress: user.emailAddress }, to: user.emailAddress, toName: `${user.firstName} ${user.lastName}`, diff --git a/website/views/layouts/layout-nurture-email.ejs b/website/views/layouts/layout-nurture-email.ejs index 007880839b..5c4df42a0e 100644 --- a/website/views/layouts/layout-nurture-email.ejs +++ b/website/views/layouts/layout-nurture-email.ejs @@ -18,5 +18,6 @@

    © <%= (new Date()).getFullYear() %> Fleet Inc.
    All trademarks are the property of their respective owners.

    + Unsubscribe diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index 9b090d48db..ed4ebaf20b 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -61,6 +61,13 @@ + <%/* Segment */%> + <% /* Meta pixel code */ %> - <% /* Delete the global `self` to help avoid client-side bugs. - (see https://developer.mozilla.org/en-US/docs/Web/API/Window/self) */ %> - - - <%/* bowser.js (for browser detection) -- included inline to avoid issues with minification that could affect the unsupported browser overlay */%> @@ -635,6 +637,7 @@ window.history.replaceState({}, '', queryParameterLessUrl);// https://caniuse.com/mdn-api_history_replacestate } }); + // Adding hover events to header dropdown menus. $(function(){ $('[purpose=dropdown-button]').hover( diff --git a/website/views/pages/device-management.ejs b/website/views/pages/device-management.ejs index 93215dc33a..f7b0f37a84 100644 --- a/website/views/pages/device-management.ejs +++ b/website/views/pages/device-management.ejs @@ -416,7 +416,10 @@ <%/* Shorten the feedback loop section */%>
    -
    +
    +
    + A laptop using Fleet to accurately display the status of MDM configurations +

    Shorten the feedback loop

    Spend less time debugging whether changes actually happened.

    @@ -426,14 +429,8 @@

    Every change to a policy or security control is tracked and auditable in Fleet’s UI, or in your logs.

    -
    - A laptop using Fleet to accurately display the status of MDM configurations -
    -
    - <%- partial('../partials/calendar-banner.partial.ejs') %> -
    <%/* Scope transparency section */%>
    diff --git a/website/views/pages/endpoint-ops.ejs b/website/views/pages/endpoint-ops.ejs index fa80f91e7d..c9cfbb40fa 100644 --- a/website/views/pages/endpoint-ops.ejs +++ b/website/views/pages/endpoint-ops.ejs @@ -3,7 +3,7 @@
    -

    Endpoint operations

    +

    Endpoint operations <%= ['eo-security', 'vm'].includes(primaryBuyingSituation) ? 'for security' : ['eo-it', 'mdm'].includes(primaryBuyingSituation) ? 'for IT' : '' %>

    <%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

    @@ -123,10 +123,8 @@

    Remote-control IT tasks on every kind of computer – even you, Linux.

    Write and run scripts remotely, report progress, and replay queued up tasks on computers that went offline.

    -

    Integrate Google Calendar to install updates and force restarts when your users’ computers are actually free.

    -

    Reduce human error by automating click-tastic tasks directly on the host with Open Interpreter.*

    +

    Optionally integrate Google Calendar to make changes when certain users’ devices are actually free.

    -

    *Coming soon

    Ship data to any platform @@ -228,7 +226,6 @@
    - <%- partial('../partials/calendar-banner.partial.ejs') %>
    Ship data to any platform diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index f46f1b6c48..ef71b85ee8 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -6,8 +6,8 @@
    <%/* Hero text */%>
    -

    <%- partial('../partials/primary-tagline.partial.ejs') %>

    -

    Replace the sprawl with <%= primaryBuyingSituation === 'vm'? 'secure, open-source reporting that works the way you want' : primaryBuyingSituation === 'eo-security'? 'universal, open-source endpoint visibility' : 'secure, open-source device management that works the way you want' %>.

    +

    <%- partial('../partials/primary-tagline.partial.ejs') %>

    +

    Replace the sprawl with <%= primaryBuyingSituation === 'vm'? 'secure, open-source reporting that works the way you want' : primaryBuyingSituation === 'eo-security'? 'universal, open-source endpoint visibility' : 'a modern device management platform that works the way you want' %>.

    Learn how Talk to us @@ -27,9 +27,9 @@ <% if(!primaryBuyingSituation){ %>
    Device management
    -
    Endpoint ops
    -
    Vulnerability management
    -
    +
    IT engineering
    +
    Security engineering
    +
    <%/* Device management block */%>
    @@ -49,38 +49,38 @@
    - <%/* Endpoint ops block */%> -
    + <%/* IT engineering block */%> +
    Endpoint ops
    - Pulse check anything -

    Use a live connection to every endpoint to simplify audit, compliance, and reporting from workstations to data centers.

    + Automate anything +

    Remotely run scripts and prompts to complete tasks on every kind of computer, including Linux.

    Ship data to any platform

    Ship logs to any platform like Splunk, Snowflake, or any streaming infrastructure like AWS Kinesis and Apache Kafka.

    Osquery on easy mode

    Use "read-only" mode or enable remote scripting to automate anything on every operating system, including Linux.

    - <%/* Vulnerability management block */%> -
    + <%/* Security engineering block */%> +
    An orb being scanned for vulnerabilities
    - Report what matters -

    Let's face it, most built-in graphs leave you wanting more. Report MTTR and any other custom metrics exactly the way you want to using fresh data from real computers.

    - Deep context from the environment -

    Fleet gives you data down to the chip level on every endpoint to help you make sense of which vulnerabilities to prioritize.

    - Untangle your security stack -

    Use open data and APIs to connect your vulnerability solution with osquery, the agent you might already have deployed.

    + Osquery on easy mode +

    Use "read-only" mode or enable remote scripting to automate anything on every operating system, including Linux.

    + Pulse check anything +

    Use a live connection to every endpoint to simplify audit, compliance, and reporting from workstations to data centers.

    + Ship data to any platform +

    Ship logs to any platform like Splunk, Snowflake, or any streaming infrastructure like AWS Kinesis and Apache Kafka.

    @@ -104,11 +104,11 @@ <% } %>
    -

    Endpoint ops

    +

    <%= primaryBuyingSituation==='eo-security'? 'Security engineering' : 'IT engineering'%>

    <%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

    A <%= primaryBuyingSituation==='eo-security'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='eo-security'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.

    - Start with endpoint ops + Start with <%= primaryBuyingSituation==='eo-security' ? 'security engineering' : 'IT engineering'%>
    @@ -171,18 +171,15 @@ Endpoint ops
    -

    Endpoint ops

    -

    <%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%>

    -

    A <%= primaryBuyingSituation==='eo-security'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='eo-security'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.

    +

    <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'Security engineering'%>

    +

    <%= primaryBuyingSituation==='vm'? 'Instrument your endpoints' : 'Understand your computers'%>

    +

    A <%= primaryBuyingSituation==='vm'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='vm'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.

    - Start with endpoint ops + Start with <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'security engineering'%>
    <% } %> -
    - <%- partial('../partials/calendar-banner.partial.ejs') %> -
    <%/* Integration cards */%>

    Connect your favorite tools

    @@ -376,7 +373,7 @@

    For teams with lots of computing devices

    -

    <%- partial('../partials/primary-tagline.partial.ejs') %>

    +

    <%- partial('../partials/primary-tagline.partial.ejs') %>

    Start now Talk to us @@ -387,6 +384,9 @@
    <%/* Cloud city banner */%> + +

    Your email preferences have been updated.

    +
    diff --git a/website/views/pages/transparency.ejs b/website/views/pages/transparency.ejs index 12f255e580..fd94fc5f32 100644 --- a/website/views/pages/transparency.ejs +++ b/website/views/pages/transparency.ejs @@ -2,56 +2,12 @@
    -

    A better employee experience

    -

    What if the way your employer maintained your computer was more transparent? What if it helped you get more done?

    +

    What can Fleet access on my device?

    +

    End users deserve to know what their employer can see and do on their laptops. Here’s what Fleet can manage on your device:

    +
    -
    -
    -

    Tell me when and why

    -

    Fleet installs updates and forces restarts when you and your computer are actually free.

    -
    -

    No more restarts blocking you from important meetings

    -

    See what's getting changed on your work computer

    -

    Avoid losing unsaved work

    -
    - - I wish my company did this - -
    -
    - Schedule maintenance with Fleet -
    -
    -
    - -
    - -
    -
    -

    Don’t make me think about IT

    -

    Stop distracting employees with computer maintenance tasks and reclaim countless hours of lost time across your organization.

    -
    - -
    -
    - -
    - A secure laptop -

    Get your team’s laptops secure with as little thought as possible. Minimize employee interaction required for security updates.

    -
    - -
    - Automatically fix vulnerabilities -

    Give people a reliable digital experience at work. Automatically fix laptop vulnerabilities so security doesn’t have to chase your staff.

    -
    - -
    -
    - -
    -

    What can Fleet access on my device?

    -

    End users deserve to know what their employer can see and do on their laptops. Here’s what Fleet can manage on your device:

    @@ -156,6 +112,17 @@

    +
    + +
    +
    +
    + Secureframe logo +

    Automate compliance.
    Improve security. Reduce risk.

    +

    Build trust with customers using automation backed by world-class experts

    + Learn more +
    +
    diff --git a/website/views/pages/vulnerability-management.ejs b/website/views/pages/vulnerability-management.ejs index 833a0abe04..e1f3982eb7 100644 --- a/website/views/pages/vulnerability-management.ejs +++ b/website/views/pages/vulnerability-management.ejs @@ -3,8 +3,8 @@
    -

    Vulnerability management

    -

    Build the vulnerability program you actually want

    +

    Open-source vulnerability reporting

    +

    Check vulnerabilities anywhere

    @@ -12,14 +12,14 @@
    Report what matters -

    Let's face it, most built-in graphs leave you wanting more. Report MTTR and any other custom metrics exactly the way you want to using fresh data from real computers.

    +

    Report exactly when CVEs were fixed or mitigated, down to the hour.

    Deep context from the environment

    Fleet gives you data down to the chip level on every endpoint to help you make sense of which vulnerabilities to prioritize.

    Untangle your security stack

    Use open data and APIs to connect your vulnerability solution with osquery, the agent you might already have deployed.

    - Start now - Talk to us + Try localhost + Ask us anything
    @@ -110,7 +110,6 @@ Mitigate CVEs automatically
    - <%- partial('../partials/calendar-banner.partial.ejs') %>
    Up-to-date data without scans @@ -141,11 +140,11 @@
    -

    Open-source vulnerability management

    -

    Build the vulnerability program you actually want

    +

    Open-source vulnerability reporting

    +

    Check vulnerabilities anywhere

    - Start now - Talk to us + Try localhost + Get help
    diff --git a/website/views/partials/calendar-banner.partial.ejs b/website/views/partials/calendar-banner.partial.ejs index 7da1b7e7e8..e3c91817fc 100644 --- a/website/views/partials/calendar-banner.partial.ejs +++ b/website/views/partials/calendar-banner.partial.ejs @@ -2,7 +2,7 @@
    NEW
    <% if(!primaryBuyingSituation) {%> -

    "This is a terrible time to mess with my computer."

    +

    "This is a terrible time to mess with my computer."

    Install updates and force restarts when your users’ computers are actually free.

    <%} else if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) {%>

    Patch your C-suite with confidence

    diff --git a/website/views/partials/primary-tagline.partial.ejs b/website/views/partials/primary-tagline.partial.ejs index 52bd6fc370..869c05f94f 100644 --- a/website/views/partials/primary-tagline.partial.ejs +++ b/website/views/partials/primary-tagline.partial.ejs @@ -1 +1,7 @@ -<%= typeof primaryBuyingSituation !== 'undefined' ? (primaryBuyingSituation === 'vm' ? 'Focus on vulnerabilities, not vendors' : primaryBuyingSituation === 'eo-security' ? 'Light in every corner' : primaryBuyingSituation === 'eo-it' ? 'Untangle your endpoints' : 'Your easiest MDM migration') : 'Your easiest MDM migration' %> +<%= + typeof primaryBuyingSituation === 'undefined' ? 'Open-source device management for everyone' // Default (no buying situation) + : primaryBuyingSituation === 'vm' ? 'Focus on vulnerabilities, not vendors' // vm + : primaryBuyingSituation === 'eo-security' ? 'Light in every corner'// eo-security + : primaryBuyingSituation === 'eo-it' ? 'Untangle your endpoints' : // eo-it + 'Open-source device management for everyone'// mdm +%>