diff --git a/.github/ISSUE_TEMPLATE/story.md b/.github/ISSUE_TEMPLATE/story.md index 40d2e21192..27f772ea8d 100644 --- a/.github/ISSUE_TEMPLATE/story.md +++ b/.github/ISSUE_TEMPLATE/story.md @@ -19,9 +19,9 @@ It is [planned and ready](https://fleetdm.com/handbook/company/development-group | I want to _________________________________________ | so that I can _________________________________________. -## Objective +## Key result - + ## Original requests diff --git a/.github/workflows/test-go.yaml b/.github/workflows/test-go.yaml index 084b1ea56b..1e42bd3fbe 100644 --- a/.github/workflows/test-go.yaml +++ b/.github/workflows/test-go.yaml @@ -186,9 +186,112 @@ jobs: name: ${{ matrix.suite }}-${{ env.MATRIX_MYSQL_ID }}-summary-test-log path: /tmp/summary.txt + # Based on https://github.com/micromdm/nanomdm/blob/main/.github/workflows/on-push-pr.yml#L87 + test-go-nanomdm: + runs-on: 'ubuntu-latest' + services: + mysql: + image: mysql:8.0.36 + env: + MYSQL_RANDOM_ROOT_PASSWORD: yes + MYSQL_DATABASE: testdb + MYSQL_USER: testuser + MYSQL_PASSWORD: testpw + ports: + - 3800:3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + env: + MYSQL_PWD: testpw + PORT: 3800 + RACE_ENABLED: true + GO_TEST_TIMEOUT: 20m + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + - name: Install Go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + + - name: verify mysql + run: | + while ! mysqladmin ping --host=localhost --port=$PORT --protocol=TCP --silent; do + sleep 1 + done + + - name: mysql schema + run: | + mysql --version + mysql --user=testuser --host=localhost --port=$PORT --protocol=TCP testdb < ./server/mdm/nanomdm/storage/mysql/schema.sql + + - name: set test dsn + run: echo "NANOMDM_MYSQL_STORAGE_TEST_DSN=testuser:testpw@tcp(localhost:$PORT)/testdb" >> $GITHUB_ENV + + - name: Run Go tests + run: | + go test -v -parallel 8 -race=$RACE_ENABLED -timeout=$GO_TEST_TIMEOUT \ + -coverprofile=coverage.txt -covermode=atomic -coverpkg=github.com/fleetdm/fleet/v4/server/mdm/nanomdm/... \ + ./server/mdm/nanomdm/storage/mysql 2>&1 | tee /tmp/gotest.log + + - name: Save coverage + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + with: + name: nanomdm-coverage + path: ./coverage.txt + if-no-files-found: error + + - name: Generate summary of errors + if: failure() + run: | + c1grep() { grep "$@" || test $? = 1; } + c1grep -oP 'FAIL: .*$' /tmp/gotest.log > /tmp/summary.txt + c1grep 'test timed out after' /tmp/gotest.log >> /tmp/summary.txt + c1grep 'fatal error:' /tmp/gotest.log >> /tmp/summary.txt + c1grep -A 10 'panic: runtime error: ' /tmp/gotest.log >> /tmp/summary.txt + c1grep ' FAIL\t' /tmp/gotest.log >> /tmp/summary.txt + GO_FAIL_SUMMARY=$(head -n 5 /tmp/summary.txt | sed ':a;N;$!ba;s/\n/\\n/g') + echo "GO_FAIL_SUMMARY=$GO_FAIL_SUMMARY" + if [[ -z "$GO_FAIL_SUMMARY" ]]; then + GO_FAIL_SUMMARY="unknown, please check the build URL" + fi + GO_FAIL_SUMMARY=$GO_FAIL_SUMMARY envsubst < .github/workflows/config/slack_payload_template.json > ./payload.json + + - name: Slack Notification + if: github.event.schedule == '0 4 * * *' && failure() + uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0 + with: + payload-file-path: ./payload.json + env: + JOB_STATUS: ${{ job.status }} + EVENT_URL: ${{ github.event.pull_request.html_url || github.event.head.html_url }} + RUN_URL: https://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + + - name: Upload test log + if: always() + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + with: + name: nanomdm-test-log + path: /tmp/gotest.log + if-no-files-found: error + + - name: Upload summary test log + if: always() + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + with: + name: nanomdm-summary-test-log + path: /tmp/summary.txt + # We upload all backend coverage in one step so that we're less like to end up in a situation with a partial coverage report. upload-coverage: - needs: [test-go] + needs: [test-go, test-go-nanomdm] runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/CHANGELOG.md b/CHANGELOG.md index 46bf107edf..ec554cea35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Fleet 4.59.1 (Nov 18, 2024) + +### Bug fixes + +* Added `team_identifier` signature information to Apple macOS applications to the `/api/latest/fleet/hosts/:id/software` API endpoint. + ## Fleet 4.59.0 (Nov 12, 2024) ### Endpoint operations diff --git a/articles/automatic-software-install-in-fleet.md b/articles/automatic-software-install-in-fleet.md index ec45efc864..3c7a4fc715 100644 --- a/articles/automatic-software-install-in-fleet.md +++ b/articles/automatic-software-install-in-fleet.md @@ -52,7 +52,7 @@ Upon failure of the selected policy, the selected software installation will be * After configuring Fleet to auto-install a specific software the rest will be done automatically. * The policy check mechanism runs on a typical 1 hour cadence on all online hosts. -* Fleet will send install requests to the hosts on the first policy failure (first "No" result for the host) or if a policy goes from "Yes" to "No". On this iteration it will not send a install request if a policy is already failing and continues to fail ("No" -> "No"). See the following flowchart for details. +* Fleet will send install requests to the hosts on the first policy failure (first "No" result for the host) or if a policy goes from "Yes" to "No". On this iteration it will not send an install request if a policy is already failing and continues to fail ("No" -> "No"). See the following flowchart for details. ![Flowchart](../website/assets/images/articles/automatic-software-install-workflow.png) *Detailed flowchart* diff --git a/articles/deploy-software-packages.md b/articles/deploy-software-packages.md index bad5550d8f..b8dfeaf12e 100644 --- a/articles/deploy-software-packages.md +++ b/articles/deploy-software-packages.md @@ -29,6 +29,11 @@ Learn more about automatically installing software in a separate guide [here](ht * Choose a file to upload. `.pkg`, `.msi`, `.exe`, `.rpm`, and `.deb` files are supported. > Software installer uploads will fail if Fleet is unable to extract information from the installer package such as bundle ID and version number. +> - [.pkg extractor code](https://github.com/fleetdm/fleet/blob/main/pkg/file/xar.go#:~:text=func%20ExtractXARMetadata) +> - [.msi extractor code](https://github.com/fleetdm/fleet/blob/main/pkg/file/msi.go#:~:text=func%20ExtractMSIMetadata) +> - [.exe extractor code](https://github.com/fleetdm/fleet/blob/main/pkg/file/pe.go#:~:text=func%20ExtractPEMetadata) +> - [.deb extractor code](https://github.com/fleetdm/fleet/blob/main/pkg/file/deb.go#:~:text=func%20ExtractDebMetadata) +> - [.rpm extractor code](https://github.com/fleetdm/fleet/blob/main/pkg/file/rpm.go#:~:text=func%20ExtractRPMMetadata) * To allow users to install the software from Fleet Desktop, check the “Self-service” checkbox. diff --git a/articles/fleet-4.32.0.md b/articles/fleet-4.32.0.md index f18d481d35..d03d449320 100644 --- a/articles/fleet-4.32.0.md +++ b/articles/fleet-4.32.0.md @@ -35,7 +35,7 @@ Learn more about customizing the [macOS Setup Assistant](https://fleetdm.com/doc ## More new features, improvements, and bug fixes -* Added support to add a EULA as part of the AEP/DEP unboxing flow. +* Added support to add an EULA as part of the AEP/DEP unboxing flow. * DEP enrollments configured with SSO now pre-populate the username/fullname fields during account creation. * Integrated the macOS setup assistant feature with Apple DEP so that the setup assistants are assigned to the enrolled devices. * Re-assign and update the macOS setup assistants (and the default one) whenever required, such as when it is modified, when a host is transferred, a team is deleted, etc. diff --git a/articles/install-fleet-maintained-apps-on-macos-hosts.md b/articles/install-fleet-maintained-apps-on-macos-hosts.md index b0ac7a0b64..8dc8eecccd 100644 --- a/articles/install-fleet-maintained-apps-on-macos-hosts.md +++ b/articles/install-fleet-maintained-apps-on-macos-hosts.md @@ -4,7 +4,9 @@ _Available in Fleet Premium_ In Fleet, you can install Fleet-maintained apps on macOS hosts without the need for manual uploads or extra configuration. This simplifies the process and adds another source of applications for your fleet. -Fleet starts with some of the most common and popular apps, enabling you to pull directly from this curated list and install them on your hosts without any additional configuration. +Fleet maintains these [celebrity apps](https://github.com/fleetdm/fleet/blob/main/server/mdm/maintainedapps/apps.json), enabling you to pull directly from this curated list and install them on your hosts without any additional configuration. + +> Currently, these apps are only supported for Apple Silicon Macs: 1Password, Brave, Docker Desktop, Figma, Microsoft Visual Studio (VS) Code, Notion, Postman, Slack, and Zoom. ## Add a Fleet-maintained app diff --git a/articles/introducing-workbrew.md b/articles/introducing-workbrew.md new file mode 100644 index 0000000000..da05080bb7 --- /dev/null +++ b/articles/introducing-workbrew.md @@ -0,0 +1,42 @@ +# Introducing Workbrew: bringing enterprise control to Homebrew deployments + +![Fleet and Workbrew](../website/assets/images/articles/fleet-and-workbrew-1600x900@2x.png) + +[Workbrew recently](https://workbrew.com/) made waves with its official launch, highlighted in a [TechCrunch article](https://techcrunch.com/2024/11/19/workbrew-makes-open-source-package-manager-homebrew-enterprise-friendly/). Backed by $5 million in funding from developer-focused VC firms like Heavybit and Operator Collective, Workbrew is tackling a critical challenge: transforming Homebrew from a developer-centric tool into a secure, enterprise-ready solution. + +## Workbrew’s mission: From single-player to multiplayer + +Homebrew has become an essential part of the developer’s toolkit by simplifying the installation and maintenance of software packages. However, as organizations grow, managing Homebrew installations across an entire fleet of devices introduces complexity, security risks, and compliance challenges. + +Enter Workbrew. Their platform provides a centralized way for IT and security teams to manage and deploy Homebrew across their organizations. With features like: + +- **Fleet-wide dashboards** to monitor devices, packages, and licenses. +- **MDM integrations (including Fleet!)** for automated synchronization of device data. +- **Vulnerability detection** and policy enforcement to enhance security. +- **Remote management** to install, upgrade, and remove any of the tens of thousands of packages in the `brew` ecosystem with ease. + +Workbrew enables companies to maintain the agility developers love while ensuring security and compliance standards are met. + +As noted in the article, Workbrew brings a customizable solution to organizations struggling with “[shadow IT](https://techcrunch.com/2015/09/25/its-time-to-embrace-not-fear-shadow-it/)” risks. By offering a fleet dashboard, vulnerability detection, and deep integrations with tools like [Fleet](https://fleetdm.com/device-management), Workbrew helps companies maintain visibility and control over Homebrew deployments at scale. Whether it’s ensuring compliance in regulated industries or automating package installations for remote teams, Workbrew is paving the way for safer, smarter IT management. + +![Workbrew console](../website/assets/images/articles/workbrew-console-3412x2020px.png) + +At Fleet, we’re excited to support Workbrew’s efforts. Our [integration](https://fleetdm.com/integrations) ensures that Workbrew users can easily sync device data, enabling seamless management across teams. Workbrew’s approach resonates with our belief in open-source and transparent tools for IT and security. + +## Why this matters for IT teams + +Managing Homebrew deployments used to be a manual process fraught with unknowns. IT professionals often found themselves asking: +- What’s actually installed on our devices? +- Are there unpatched vulnerabilities in our software? +- How do we enforce security policies without stifling developer productivity? + +With Workbrew, these questions have answers. And for organizations already using Fleet, the integration creates a powerful synergy that brings even more value to your existing workflows. Together, Fleet and Workbrew give you the tools to confidently oversee and manage every device, app, and package across your organization. It’s an essential step for any organization looking to balance developer flexibility with operational controls. + +To learn more about how Workbrew and Fleet can work together, visit [Workbrew’s website](https://www.workbrew.com) + + + + + + + diff --git a/articles/lock-wipe-hosts.md b/articles/lock-wipe-hosts.md index 519640b173..57e61ec8d2 100644 --- a/articles/lock-wipe-hosts.md +++ b/articles/lock-wipe-hosts.md @@ -18,7 +18,7 @@ where a host might have been lost or stolen, or to remotely prepare a device to ## Wipe a host -1. Navigate to the **Hosts** page by clicking the "Hosts" tab in the main navigation header. Find the device you want to lock. You can search by name, hostname, UUID, serial number, or private IP address in the search box in the upper right corner. +1. Navigate to the **Hosts** page by clicking the "Hosts" tab in the main navigation header. Find the device you want to wipe. You can search by name, hostname, UUID, serial number, or private IP address in the search box in the upper right corner. 2. Click the host to open the **Host Overview** page. 3. Click the **Actions** dropdown, then click **Wipe**. 4. Confirm that you want to wipe the device in the dialog. The host will now be marked with a "Wipe pending" badge. Once the wipe command is acknowledged by the host, the badge will update to "Wiped". @@ -29,12 +29,12 @@ where a host might have been lost or stolen, or to remotely prepare a device to To unlock a locked host: -1. Navigate to the **Hosts** page by clicking the "Hosts" tab in the main navigation header. Find the device you want to lock. You can search by name, hostname, UUID, serial number, or private IP address in the search box in the upper right corner. +1. Navigate to the **Hosts** page by clicking the "Hosts" tab in the main navigation header. Find the device you want to unlock. You can search by name, hostname, UUID, serial number, or private IP address in the search box in the upper right corner. 2. Click the host to open the **Host Overview** page. 3. Click the **Actions** menu, then click **Unlock**. - **macOS**: A dialog with the PIN will appear. Type the PIN into the device to unlock it. - **Windows and Linux**: The command to unlock the host will be queued and the host will unlock once it receives the command (no PIN needed). -5. When you click **Unlock**, the host will be marked with an "Unlock pending" badge. Once the host is unlocked and checks back in with Fleet, the "Unlock pending" badge will be removed. +5. When you click **Unlock**, Windows and Linux hosts will be marked with an "Unlock pending" badge. Once the host is unlocked and checks back in with Fleet, the "Unlock pending" badge will be removed. macOS hosts do not have an "Unlock pending" badge as they cannot be remotely unlocked (the PIN has to be typed into the device). ## Lock and wipe using `fleetctl` diff --git a/articles/ndes-scep-proxy.md b/articles/ndes-scep-proxy.md index 5e5f60d564..74047600b6 100644 --- a/articles/ndes-scep-proxy.md +++ b/articles/ndes-scep-proxy.md @@ -128,8 +128,7 @@ A common use case for SCEP is connecting devices to a corporate WiFi network. Th 1. Send the root CA certificate to the device using a [CertificateRoot profile](https://developer.apple.com/documentation/devicemanagement/certificateroot?language=objc). 2. Create a profile with a SCEP payload and a [WiFi payload](https://developer.apple.com/documentation/devicemanagement/wifi?language=objc), and send it to the device. - The `PayloadCertificateUUID` in the WiFi payload should reference the `PayloadUUID` of the SCEP payload. - - + - For more information on connecting your Apple devices to 802.1X networks, see [this guide from Apple](https://support.apple.com/en-my/guide/deployment/depabc994b84/web). ## Assumptions and limitations * NDES SCEP proxy is currently supported for macOS devices via Apple config profiles. Support for DDM (Declarative Device Management) is coming soon, as is support for iOS, iPadOS, Windows, and Linux. diff --git a/articles/tales-from-fleet-security-securing-the-startup.md b/articles/tales-from-fleet-security-securing-the-startup.md index 6a8423dfc9..92a7b0d2a1 100644 --- a/articles/tales-from-fleet-security-securing-the-startup.md +++ b/articles/tales-from-fleet-security-securing-the-startup.md @@ -43,7 +43,7 @@ I thought using Apple’s Automated Device Enrollment (or Device Enrollment Prog Technically, I was not wrong, but there are non-technical challenges. -1. The requirements to establish a ADE account vary by country. In the US, for example, it requires a [DUNS](https://en.wikipedia.org/wiki/Data_Universal_Numbering_System) number. Getting a DUNS number is simple for US companies, but what is not easy is to fulfill similar requirements in every country where you would like to use ADE. We could not register for ADE in Canada. We have people in many other countries with a similar situation. +1. The requirements to establish an ADE account vary by country. In the US, for example, it requires a [DUNS](https://en.wikipedia.org/wiki/Data_Universal_Numbering_System) number. Getting a DUNS number is simple for US companies, but what is not easy is to fulfill similar requirements in every country where you would like to use ADE. We could not register for ADE in Canada. We have people in many other countries with a similar situation. 2. The delays for obtaining hardware are very long. When planning endpoint deployment strategies, we must consider this, as supply chain issues will not disappear soon. 3. The benchmarks made by the Center for Internet Security (CIS) are excellent but are incredibly long (700+ pages) and written for experts. We wanted to be transparent about why we configured company devices a certain way and explain it so everyone could understand without Googling for hours. diff --git a/changes/19696-missing-instrumentation b/changes/19696-missing-instrumentation new file mode 100644 index 0000000000..43d10469fd --- /dev/null +++ b/changes/19696-missing-instrumentation @@ -0,0 +1 @@ +Added missing APM instrumentation for Fleet API routes. diff --git a/changes/21340-improve-nano-enrollments-last-seen-at-update b/changes/21340-improve-nano-enrollments-last-seen-at-update new file mode 100644 index 0000000000..55a978a05b --- /dev/null +++ b/changes/21340-improve-nano-enrollments-last-seen-at-update @@ -0,0 +1 @@ +* Improve performance of updating the `nano_enrollments.last_seen_at` timestamp of Apple MDM devices by an order of magnitude under load. diff --git a/changes/21986-fix-to-abm-token-table-responsive b/changes/21986-fix-to-abm-token-table-responsive new file mode 100644 index 0000000000..c2d626e410 --- /dev/null +++ b/changes/21986-fix-to-abm-token-table-responsive @@ -0,0 +1 @@ +- fix responsive styles for the adm table diff --git a/changes/22702-linux-encryption-frontend b/changes/22702-linux-encryption-frontend new file mode 100644 index 0000000000..a35d242375 --- /dev/null +++ b/changes/22702-linux-encryption-frontend @@ -0,0 +1 @@ +- Added UI features supporting disk encryption for Ubuntu and Fedora Linux. diff --git a/changes/22810-fleetd-enroll-activity b/changes/22810-fleetd-enroll-activity new file mode 100644 index 0000000000..b9b9380a05 --- /dev/null +++ b/changes/22810-fleetd-enroll-activity @@ -0,0 +1 @@ +Added activity item for fleetd enrollment with host serial and display name. diff --git a/changes/23540-pe-sfx b/changes/23540-pe-sfx new file mode 100644 index 0000000000..63c241a8be --- /dev/null +++ b/changes/23540-pe-sfx @@ -0,0 +1 @@ +Fixed name/version parsing issue with PE (EXE) installer self-extracting archives such as Opera. diff --git a/changes/23787-script-name b/changes/23787-script-name new file mode 100644 index 0000000000..af50855bad --- /dev/null +++ b/changes/23787-script-name @@ -0,0 +1,2 @@ +- Fixes a bug where the name of the setup experience script was not showing up in the activity for + that script execution. \ No newline at end of file diff --git a/changes/23834-improve-label-flag-validation b/changes/23834-improve-label-flag-validation new file mode 100644 index 0000000000..5d8d8e4b0a --- /dev/null +++ b/changes/23834-improve-label-flag-validation @@ -0,0 +1 @@ +* Improved label validation when running live queries. Previously, when passing label(s) that do not exist, the labels were ignored. Now, an error is returned indicating which labels were not found. This change affects both the API and `fleetctl query` command. \ No newline at end of file diff --git a/changes/23893-fix-docker-fleetctl b/changes/23893-fix-docker-fleetctl new file mode 100644 index 0000000000..3d68bd2540 --- /dev/null +++ b/changes/23893-fix-docker-fleetctl @@ -0,0 +1 @@ +* Fixed bug in `fleetdm/fleetctl` docker image where the `build` directory does not exist when generating deb/rpm packages. diff --git a/changes/23905-update-nanomdm b/changes/23905-update-nanomdm new file mode 100644 index 0000000000..5399590bdc --- /dev/null +++ b/changes/23905-update-nanomdm @@ -0,0 +1 @@ +Update nanomdm dependency with latest bug fixes and improvements. diff --git a/changes/23942-wrong-link b/changes/23942-wrong-link new file mode 100644 index 0000000000..f7ac167582 --- /dev/null +++ b/changes/23942-wrong-link @@ -0,0 +1 @@ +- Updates a link in the Fleet-maintained apps UI to point to the correct place. \ No newline at end of file diff --git a/changes/23967-doc-firefox_preferences-linux-windows b/changes/23967-doc-firefox_preferences-linux-windows new file mode 100644 index 0000000000..3faa0f6980 --- /dev/null +++ b/changes/23967-doc-firefox_preferences-linux-windows @@ -0,0 +1 @@ +* doc: document firefox_preferences table for Linux and Windows platforms diff --git a/changes/8750-add-team_identifier-to-software b/changes/8750-add-team_identifier-to-software deleted file mode 100644 index 0d05d81b09..0000000000 --- a/changes/8750-add-team_identifier-to-software +++ /dev/null @@ -1 +0,0 @@ -* Added `team_identifier` signature information to Apple macOS applications to the `/api/latest/fleet/hosts/:id/software` API endpoint. diff --git a/charts/fleet/Chart.yaml b/charts/fleet/Chart.yaml index 0a7be2bfdb..f9b80ed1e3 100644 --- a/charts/fleet/Chart.yaml +++ b/charts/fleet/Chart.yaml @@ -4,11 +4,11 @@ name: fleet keywords: - fleet - osquery -version: v6.2.1 +version: v6.2.2 home: https://github.com/fleetdm/fleet sources: - https://github.com/fleetdm/fleet.git -appVersion: v4.59.0 +appVersion: v4.59.1 dependencies: - name: mysql condition: mysql.enabled diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index da975de66a..7e1c7f7916 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -3,7 +3,7 @@ hostName: fleet.localhost replicas: 3 # The number of Fleet instances to deploy imageRepository: fleetdm/fleet -imageTag: v4.59.0 # Version of Fleet to deploy +imageTag: v4.59.1 # Version of Fleet to deploy podAnnotations: {} # Additional annotations to add to the Fleet pod serviceAccountAnnotations: {} # Additional annotations to add to the Fleet service account resources: diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 4f93d5a540..4923cb95ad 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -64,6 +64,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/spf13/cobra" + "go.elastic.co/apm/module/apmhttp/v2" _ "go.elastic.co/apm/module/apmsql/v2" _ "go.elastic.co/apm/module/apmsql/v2/mysql" "go.opentelemetry.io/otel" @@ -1252,7 +1253,12 @@ the way that the Fleet server works. liveQueryRestPeriod += 10 * time.Second // Create the handler based on whether tracing should be there - handler := launcher.Handler(rootMux) + var handler http.Handler + if config.Logging.TracingEnabled && config.Logging.TracingType == "elasticapm" { + handler = launcher.Handler(apmhttp.Wrap(rootMux)) + } else { + handler = launcher.Handler(rootMux) + } srv := config.Server.DefaultHTTPServer(ctx, handler) if liveQueryRestPeriod > srv.WriteTimeout { diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index ba5ba641f7..3c2bbee356 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -3781,7 +3781,9 @@ spec: macos_settings: enable_disk_encryption: true `, - wantErr: `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on`, + + // Since Linux disk encryption does not use MDM, we allow enabling it even without MDM enabled and configured + wantOutput: `[+] applied fleet config`, }, { desc: "app config macos_settings.enable_disk_encryption false", diff --git a/cmd/fleetctl/query.go b/cmd/fleetctl/query.go index 3c64d2c191..e54fbee2bc 100644 --- a/cmd/fleetctl/query.go +++ b/cmd/fleetctl/query.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "regexp" "strings" "time" @@ -138,6 +139,12 @@ func queryCommand() *cli.Command { if strings.Contains(err.Error(), "no hosts targeted") { return errors.New(fleet.NoHostsTargetedErrMsg) } + if strings.Contains(err.Error(), fleet.InvalidLabelSpecifiedErrMsg) { + pattern := fmt.Sprintf("(%s.*)$", regexp.QuoteMeta(fleet.InvalidLabelSpecifiedErrMsg)) + regex := regexp.MustCompile(pattern) + match := regex.FindString(err.Error()) + return errors.New(match) + } return err } diff --git a/cmd/fleetctl/query_test.go b/cmd/fleetctl/query_test.go index 2ee7f8b7c1..8e5441b58a 100644 --- a/cmd/fleetctl/query_test.go +++ b/cmd/fleetctl/query_test.go @@ -219,8 +219,9 @@ func TestAdHocLiveQuery(t *testing.T) { return []uint{1234}, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { - return nil, nil + return map[string]uint{"label1": uint(1)}, nil } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } @@ -299,6 +300,14 @@ func TestAdHocLiveQuery(t *testing.T) { ) }() + // test label not found + _, err = runAppNoChecks([]string{"query", "--hosts", "1234", "--labels", "iamnotalabel", "--query", "select 42, * from time"}) + assert.ErrorContains(t, err, "Invalid label name(s): iamnotalabel.") + + // test if some labels were not found + _, err = runAppNoChecks([]string{"query", "--labels", "label1, mac, windows", "--hosts", "1234", "--query", "select 42, * from time"}) + assert.ErrorContains(t, err, "Invalid label name(s): mac, windows.") + expected := `{"host":"somehostname","rows":[{"bing":"fds","host_display_name":"somehostname","host_hostname":"somehostname"}]} ` assert.Equal(t, expected, runAppForTest(t, []string{"query", "--hosts", "1234", "--query", "select 42, * from time"})) diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index 58e237d691..6b306da012 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -1413,6 +1413,7 @@ func (a *agent) orbitEnroll() error { EnrollSecret: a.EnrollSecret, HardwareUUID: a.UUID, HardwareSerial: a.SerialNumber, + Hostname: a.CachedString("hostname"), } jsonBytes, err := json.Marshal(params) if err != nil { diff --git a/docs/Contributing/Audit-logs.md b/docs/Contributing/Audit-logs.md index 85f22fe219..26c8a02f3d 100644 --- a/docs/Contributing/Audit-logs.md +++ b/docs/Contributing/Audit-logs.md @@ -521,6 +521,23 @@ This activity contains the following fields: } ``` +## fleet_enrolled + +Generated when a host is enrolled to Fleet (Fleet's agent fleetd is installed). + +This activity contains the following fields: +- "host_serial": Serial number of the host. +- "host_display_name": Display name of the host. + +#### Example + +```json +{ + "host_serial": "B04FL3ALPT21", + "host_display_name": "WIN-DESKTOP-JGS78KJ7C" +} +``` + ## mdm_enrolled Generated when a host is enrolled in Fleet's MDM. diff --git a/docs/Get started/FAQ.md b/docs/Get started/FAQ.md index e99b901cfe..d5da69c959 100644 --- a/docs/Get started/FAQ.md +++ b/docs/Get started/FAQ.md @@ -4,7 +4,7 @@ Fleet offers managed cloud hosting for [Fleet Premium](https://fleetdm.com/pricing) customers with large deployments. -> While organizations of all kinds use Fleet, from Fortune 500 companies to school districts to hobbyists, we are only currently able to provide cost-effective hosting for deployments larger than 1000 hosts. (Instead, you can [buy a license](https://fleetdm.com/customers/register) and self-host Fleet Premium with support.) +> While organizations of all kinds use Fleet, from Fortune 500 companies to school districts to hobbyists, today we are only currently able to provide fully-managed hosting for deployments larger than 300 hosts. (Instead, you can [buy a license](https://fleetdm.com/customers/register) and self-host Fleet Premium with support.) Fleet is simple enough to [spin up for yourself](https://fleetdm.com/docs/deploy/introduction). Premium features are [available](https://fleetdm.com/pricing) either way. @@ -142,9 +142,9 @@ If you opt not to renew Fleet Premium, you can continue using only the free capa We aren’t able to sell licenses and support separately. -## Do you offer pricing for ephemeral hosts which may scale up or down? +## Do you offer pricing for unmanaged hosts? What about ephemeral hosts which may scale up or down? -For now, the number of hosts is the maximum cap of distinct agents enrolled at any given time. +For now, the number of hosts is the maximum cap of hosts enrolled at any given time. Umanaged hosts ("Pending" MDM status in Fleet) are not included in the enrolled hosts count. ## When run locally, what resources does the Fleet app typically consume on an individual instance, and when run in HA, at high volume? And how is latency on an individual instance vs clustered deployment? diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index e652789f34..579dd9772d 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -497,7 +497,7 @@ for pagination. For a comprehensive list of activity types and detailed informat "host_display_name": "Marko's MacBook Pro", "software_title": "Adobe Acrobat.app", "script_execution_id": "eeeddb94-52d3-4071-8b18-7322cd382abb", - "status": "failed" + "status": "failed_install" } }, { @@ -2545,8 +2545,6 @@ the `software` table. | os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | populate_software | boolean | query | If `true`, the response will include a list of installed software for each host, including vulnerability data. (Note that software lists can be large, so this may cause significant CPU and RAM usage depending on page size and request concurrency.) | | populate_policies | boolean | query | If `true`, the response will include policy data for each host. | -| populate_users | boolean | query | If `true`, the response will include user data for each host. | -| populate_labels | boolean | query | If `true`, the response will include labels for each host. | > `software_id` is deprecated as of Fleet 4.42. It is maintained for backwards compatibility. Please use the `software_version_id` instead. @@ -2566,7 +2564,7 @@ If `after` is being used with `created_at` or `updated_at`, the table must be sp #### Example -`GET /api/v1/fleet/hosts?page=0&per_page=100&order_key=hostname&query=2ce&populate_software=true&populate_policies=true&populate_users=true&populate_labels=true` +`GET /api/v1/fleet/hosts?page=0&per_page=100&order_key=hostname&query=2ce&populate_software=true&populate_policies=true` ##### Request query parameters @@ -2712,57 +2710,6 @@ If `after` is being used with `created_at` or `updated_at`, the table must be sp "response": "fail", "critical": false } - ], - "users": [ - { - "uid": 0, - "username": "root", - "type": "", - "groupname": "root", - "shell": "/bin/bash" - }, - { - "uid": 1, - "username": "bin", - "type": "", - "groupname": "bin", - "shell": "/sbin/nologin" - } - ], - "labels": [ - { - "created_at": "2021-08-19T02:02:17Z", - "updated_at": "2021-08-19T02:02:17Z", - "id": 6, - "name": "All Hosts", - "description": "All hosts which have enrolled in Fleet", - "query": "SELECT 1;", - "platform": "", - "label_type": "builtin", - "label_membership_type": "dynamic" - }, - { - "created_at": "2021-08-19T02:02:17Z", - "updated_at": "2021-08-19T02:02:17Z", - "id": 9, - "name": "CentOS Linux", - "description": "All CentOS hosts", - "query": "SELECT 1 FROM os_version WHERE platform = 'centos' OR name LIKE '%centos%'", - "platform": "", - "label_type": "builtin", - "label_membership_type": "dynamic" - }, - { - "created_at": "2021-08-19T02:02:17Z", - "updated_at": "2021-08-19T02:02:17Z", - "id": 12, - "name": "All Linux", - "description": "All Linux distributions", - "query": "SELECT 1 FROM osquery_info WHERE build_platform LIKE '%ubuntu%' OR build_distro LIKE '%centos%';", - "platform": "", - "label_type": "builtin", - "label_membership_type": "dynamic" - } ] } ] @@ -3244,6 +3191,8 @@ Returns the information of the specified host. > Note: `installed_paths` may be blank depending on installer package. For example, on Linux, RPM-installed packages do not provide installed path information. +> Note: `signature_information` is only set for macOS (.app) applications. + > Note: > - `orbit_version: null` means this agent is not a fleetd agent > - `fleet_desktop_version: null` means this agent is not a fleetd agent, or this agent is version <=1.23.0 which is not collecting the desktop version @@ -4359,13 +4308,20 @@ Resends a configuration profile for the specified host. }, "app_store_app": null, "source": "apps", - "status": "failed", + "status": "failed_install", "installed_versions": [ { "version": "121.0", + "bundle_identifier": "com.google.Chrome", "last_opened_at": "2024-04-01T23:03:07Z", "vulnerabilities": ["CVE-2023-1234","CVE-2023-4321","CVE-2023-7654"], - "installed_paths": ["/Applications/Google Chrome.app"] + "installed_paths": ["/Applications/Google Chrome.app"], + "signature_information": [ + { + "installed_path": "/Applications/Google Chrome.app", + "team_identifier": "EQHXZ8M8AV" + } + ] } ] }, @@ -4405,9 +4361,16 @@ Resends a configuration profile for the specified host. "installed_versions": [ { "version": "118.0", + "bundle_identifier": "com.apple.logic10", "last_opened_at": "2024-04-01T23:03:07Z", "vulnerabilities": ["CVE-2023-1234"], - "installed_paths": ["/Applications/Logic Pro.app"] + "installed_paths": ["/Applications/Logic Pro.app"], + "signature_information": [ + { + "installed_path": "/Applications/Logic Pro.app", + "team_identifier": "" + } + ] } ] }, @@ -4679,7 +4642,7 @@ To wipe a macOS, iOS, iPadOS, or Windows host, the host must have MDM turned on. "host_display_name": "Marko’s MacBook Pro", "software_title": "Adobe Acrobat.app", "script_execution_id": "ecf22dba-07dc-40a9-b122-5480e948b756", - "status": "failed" + "status": "failed_uninstall" } }, { @@ -10066,7 +10029,7 @@ To get the results of an App Store app install, use the [List MDM commands](#lis "software_package": "FalconSensor-6.44.pkg", "host_id": 123, "host_display_name": "Marko's MacBook Pro", - "status": "failed", + "status": "failed_install", "output": "Installing software...\nError: The operation can’t be completed because the item “Falcon” is in use.", "pre_install_query_output": "Query returned result\nSuccess", "post_install_script_output": "Running script...\nExit code: 1 (Failed)\nRolling back software install...\nSuccess" diff --git a/ee/server/service/devices.go b/ee/server/service/devices.go index 7c3b580e92..77a6c7ce46 100644 --- a/ee/server/service/devices.go +++ b/ee/server/service/devices.go @@ -166,3 +166,57 @@ func (svc *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSu return sum, nil } + +func (svc *Service) TriggerLinuxDiskEncryptionEscrow(ctx context.Context, host *fleet.Host) error { + if svc.ds.IsHostPendingEscrow(ctx, host.ID) { + return nil + } + + if err := svc.validateReadyForLinuxEscrow(ctx, host); err != nil { + _ = svc.ds.ReportEscrowError(ctx, host.ID, err.Error()) + return err + } + + return svc.ds.QueueEscrow(ctx, host.ID) +} + +func (svc *Service) validateReadyForLinuxEscrow(ctx context.Context, host *fleet.Host) error { + if !host.IsLUKSSupported() { + return &fleet.BadRequestError{Message: "Fleet does not yet support creating LUKS disk encryption keys on this platform."} + } + + ac, err := svc.ds.AppConfig(ctx) + if err != nil { + return err + } + + if host.TeamID == nil { + if !ac.MDM.EnableDiskEncryption.Value { + return &fleet.BadRequestError{Message: "Disk encryption is not enabled for hosts not assigned to a team."} + } + } else { + tc, err := svc.ds.TeamMDMConfig(ctx, *host.TeamID) + if err != nil { + return err + } + if !tc.EnableDiskEncryption { + return &fleet.BadRequestError{Message: "Disk encryption is not enabled for this host's team."} + } + } + + if host.DiskEncryptionEnabled == nil || !*host.DiskEncryptionEnabled { + return &fleet.BadRequestError{Message: "Host's disk is not encrypted. Please encrypt your disk first."} + } + + // We have to pull Orbit info because the auth context doesn't fill in host.OrbitVersion + orbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID) + if err != nil { + return err + } + + if orbitInfo == nil || !fleet.IsAtLeastVersion(orbitInfo.Version, fleet.MinOrbitLUKSVersion) { + return &fleet.BadRequestError{Message: "Your version of fleetd does not support creating disk encryption keys on Linux. Please upgrade fleetd, then click Refetch, then try again."} + } + + return svc.ds.AssertHasNoEncryptionKeyStored(ctx, host.ID) +} diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 354ae1e322..674329d9e4 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1013,10 +1013,16 @@ func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uin windows = *w } + linux, err := svc.ds.GetLinuxDiskEncryptionSummary(ctx, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting linux disk encryption summary") + } + return &fleet.MDMDiskEncryptionSummary{ Verified: fleet.MDMPlatformsCounts{ MacOS: macOS.Verified, Windows: windows.Verified, + Linux: linux.Verified, }, Verifying: fleet.MDMPlatformsCounts{ MacOS: macOS.Verifying, @@ -1025,6 +1031,7 @@ func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uin ActionRequired: fleet.MDMPlatformsCounts{ MacOS: macOS.ActionRequired, Windows: windows.ActionRequired, + Linux: linux.ActionRequired, }, Enforcing: fleet.MDMPlatformsCounts{ MacOS: macOS.Enforcing, @@ -1033,6 +1040,7 @@ func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uin Failed: fleet.MDMPlatformsCounts{ MacOS: macOS.Failed, Windows: windows.Failed, + Linux: linux.Failed, }, RemovingEnforcement: fleet.MDMPlatformsCounts{ MacOS: macOS.RemovingEnforcement, diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 9cb9aa46f0..71272b08af 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -45,6 +45,9 @@ func setupMockDatastorePremiumService(t testing.TB) (*mock.Store, *eeservice.Ser AppleSCEPCertBytes: eeservice.TestCert, AppleSCEPKeyBytes: eeservice.TestKey, }, + Server: config.ServerConfig{ + PrivateKey: "foo", + }, } depStorage := &nanodep_mock.Storage{} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/ee/server/service/setup_experience.go b/ee/server/service/setup_experience.go index 077630d3c4..7bece3103e 100644 --- a/ee/server/service/setup_experience.go +++ b/ee/server/service/setup_experience.go @@ -220,9 +220,10 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, hostUUID string return false, ctxerr.Errorf(ctx, "setup experience script missing content id: %d", *script.SetupExperienceScriptID) } req := &fleet.HostScriptRequestPayload{ - HostID: host.ID, - ScriptName: script.Name, - ScriptContentID: *script.ScriptContentID, + HostID: host.ID, + ScriptName: script.Name, + ScriptContentID: *script.ScriptContentID, + SetupExperienceScriptID: script.SetupExperienceScriptID, } res, err := svc.ds.NewHostScriptExecutionRequest(ctx, req) if err != nil { diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 34d3b1f76c..20a526197b 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1046,13 +1046,9 @@ func (svc *Service) createTeamFromSpec( } invalid := &fleet.InvalidArgumentError{} - if enableDiskEncryption && !appCfg.MDM.AtLeastOnePlatformEnabledAndConfigured() { - invalid.Append( - "mdm", - `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`, - ) + if enableDiskEncryption && svc.config.Server.PrivateKey == "" { + return nil, ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") } - validateTeamCustomSettings(invalid, "macos", macOSSettings.CustomSettings) validateTeamCustomSettings(invalid, "windows", spec.MDM.WindowsSettings.CustomSettings.Value) @@ -1210,11 +1206,10 @@ func (svc *Service) editTeamFromSpec( team.Config.MDM.EnableDiskEncryption = *de } didUpdateDiskEncryption := team.Config.MDM.EnableDiskEncryption != oldEnableDiskEncryption - if !appCfg.MDM.AtLeastOnePlatformEnabledAndConfigured() && didUpdateDiskEncryption && team.Config.MDM.EnableDiskEncryption { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", - `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) - } + if didUpdateDiskEncryption && team.Config.MDM.EnableDiskEncryption && svc.config.Server.PrivateKey == "" { + return ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } if !team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) } @@ -1523,6 +1518,9 @@ func unmarshalWithGlobalDefaults(b *json.RawMessage) (fleet.Features, error) { func (svc *Service) updateTeamMDMDiskEncryption(ctx context.Context, tm *fleet.Team, enable *bool) error { var didUpdate, didUpdateMacOSDiskEncryption bool if enable != nil { + if svc.config.Server.PrivateKey == "" { + return ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } if tm.Config.MDM.EnableDiskEncryption != *enable { tm.Config.MDM.EnableDiskEncryption = *enable didUpdate = true diff --git a/frontend/components/InfoBanner/InfoBanner.tsx b/frontend/components/InfoBanner/InfoBanner.tsx index 1a78e0a486..bd49e3d206 100644 --- a/frontend/components/InfoBanner/InfoBanner.tsx +++ b/frontend/components/InfoBanner/InfoBanner.tsx @@ -15,10 +15,11 @@ export interface IInfoBannerProps { /** default 4px */ borderRadius?: "large" | "xlarge"; pageLevel?: boolean; - /** cta and link are mutually exclusive */ + /** Add this element to the end of the banner message. Mutually exclusive with `link`. */ cta?: JSX.Element; /** closable and link are mutually exclusive */ closable?: boolean; + /** Makes the entire banner clickable */ link?: string; icon?: IconNames; } diff --git a/frontend/components/Modal/_styles.scss b/frontend/components/Modal/_styles.scss index e12ba66409..433e2e886d 100644 --- a/frontend/components/Modal/_styles.scss +++ b/frontend/components/Modal/_styles.scss @@ -26,7 +26,7 @@ margin-top: $pad-large; font-size: $x-small; max-height: 800px; - overflow-y: auto; + overflow: visible; .input-field { width: 100%; diff --git a/frontend/components/SectionHeader/SectionHeader.tsx b/frontend/components/SectionHeader/SectionHeader.tsx index f540ae821c..c3ebe5e768 100644 --- a/frontend/components/SectionHeader/SectionHeader.tsx +++ b/frontend/components/SectionHeader/SectionHeader.tsx @@ -7,24 +7,32 @@ interface ISectionHeaderProps { title: string; subTitle?: React.ReactNode; details?: JSX.Element; - className?: string; + wrapperCustomClass?: string; + alignLeftHeaderVertically?: boolean; + greySubtitle?: boolean; } const SectionHeader = ({ title, subTitle, details, - className, + wrapperCustomClass, + alignLeftHeaderVertically, + greySubtitle, }: ISectionHeaderProps) => { - const classNames = classnames(baseClass, className); + const wrapperClassnames = classnames(baseClass, wrapperCustomClass); + const leftHeaderClassnames = classnames(`${baseClass}__left-header`, { + [`${baseClass}__left-header--vertical`]: alignLeftHeaderVertically, + }); + const subTitleClassnames = classnames(`${baseClass}__sub-title`, { + [`${baseClass}__sub-title--grey`]: greySubtitle, + }); return ( -
-
+
+

{title}

- {subTitle && ( -
{subTitle}
- )} + {subTitle &&
{subTitle}
}
{details &&
{details}
}
diff --git a/frontend/components/SectionHeader/_styles.scss b/frontend/components/SectionHeader/_styles.scss index c9f1b6412e..943ea5c9e1 100644 --- a/frontend/components/SectionHeader/_styles.scss +++ b/frontend/components/SectionHeader/_styles.scss @@ -7,6 +7,15 @@ display: flex; align-items: center; gap: $pad-small; + &--vertical { + flex-direction: column; + } + } + + &__sub-title { + &--grey { + @include grey-text; + } } h2 { diff --git a/frontend/components/TeamsDropdown/TeamsDropdown.tsx b/frontend/components/TeamsDropdown/TeamsDropdown.tsx index 3e134954ca..e38b1c1b13 100644 --- a/frontend/components/TeamsDropdown/TeamsDropdown.tsx +++ b/frontend/components/TeamsDropdown/TeamsDropdown.tsx @@ -113,10 +113,6 @@ const TeamsDropdown = ({ }; const customStyles: StylesConfig = { - container: (provided) => ({ - ...provided, - width: "80px", - }), control: (provided, state) => ({ ...provided, display: "flex", diff --git a/frontend/components/forms/fields/Dropdown/_styles.scss b/frontend/components/forms/fields/Dropdown/_styles.scss index c86dc61bf6..dc7a763f7c 100644 --- a/frontend/components/forms/fields/Dropdown/_styles.scss +++ b/frontend/components/forms/fields/Dropdown/_styles.scss @@ -225,6 +225,10 @@ animation: fade-in 150ms ease-out; } + .Select-menu { + max-height: 190px; + } + .Select-noresults { font-size: $x-small; } diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 0d7b3acb28..d6fcafc8c7 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -33,6 +33,7 @@ export enum ActivityType { UserDeletedGlobalRole = "deleted_user_global_role", UserChangedTeamRole = "changed_user_team_role", UserDeletedTeamRole = "deleted_user_team_role", + FleetEnrolled = "fleet_enrolled", MdmEnrolled = "mdm_enrolled", MdmUnenrolled = "mdm_unenrolled", EditedMacosMinVersion = "edited_macos_min_version", diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 07fe2cffb9..fba6866e33 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -1,5 +1,4 @@ import { IConfigServerSettings } from "./config"; -import { ITeamSummary } from "./team"; export interface IMdmApple { common_name: string; @@ -93,7 +92,7 @@ export interface IMdmSummaryResponse { mobile_device_management_solution: IMdmSummaryMdmSolution[] | null; } -export type ProfilePlatform = "darwin" | "windows" | "ios" | "ipados"; +export type ProfilePlatform = "darwin" | "windows" | "ios" | "ipados" | "linux"; export interface IProfileLabel { name: string; @@ -129,10 +128,11 @@ export interface IHostMdmProfile { name: string; operation_type: ProfileOperationType | null; platform: ProfilePlatform; - status: MdmProfileStatus | MdmDDMProfileStatus; + status: MdmProfileStatus | MdmDDMProfileStatus | LinuxDiskEncryptionStatus; detail: string; } +// TODO - move disk encryption related types to dedicated file export type DiskEncryptionStatus = | "verified" | "verifying" @@ -143,14 +143,14 @@ export type DiskEncryptionStatus = /** Currently windows disk enxryption status will only be one of these four values. In the future we may add more. */ -export type IWindowsDiskEncryptionStatus = Extract< +export type WindowsDiskEncryptionStatus = Extract< DiskEncryptionStatus, "verified" | "verifying" | "enforcing" | "failed" >; export const isWindowsDiskEncryptionStatus = ( status: DiskEncryptionStatus -): status is IWindowsDiskEncryptionStatus => { +): status is WindowsDiskEncryptionStatus => { switch (status) { case "verified": case "verifying": @@ -162,6 +162,16 @@ export const isWindowsDiskEncryptionStatus = ( } }; +export type LinuxDiskEncryptionStatus = Extract< + DiskEncryptionStatus, + "verified" | "failed" | "action_required" +>; + +export const isLinuxDiskEncryptionStatus = ( + status: DiskEncryptionStatus +): status is LinuxDiskEncryptionStatus => + ["verified", "failed", "action_required"].includes(status); + export const FLEET_FILEVAULT_PROFILE_DISPLAY_NAME = "Disk encryption"; export interface IMdmSSOReponse { diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts index 2be1f412d4..1f33a1639f 100644 --- a/frontend/interfaces/platform.ts +++ b/frontend/interfaces/platform.ts @@ -64,9 +64,9 @@ export const MACADMINS_EXTENSION_TABLES: Record = { */ export const HOST_LINUX_PLATFORMS = [ "linux", - "ubuntu", + "ubuntu", // covers Kubuntu "debian", - "rhel", + "rhel", // covers Fedora "centos", "sles", "kali", @@ -111,3 +111,55 @@ export const isAppleDevice = (platform: string) => { export const isIPadOrIPhone = (platform: string | HostPlatform) => ["ios", "ipados"].includes(platform); + +export const DISK_ENCRYPTION_SUPPORTED_LINUX_PLATFORMS = [ + "ubuntu", // covers Kubuntu + "rhel", // *included here to support Fedora systems. Necessary to cross-check with `os_versions` as well to confrim host is Fedora and not another, non-support rhel-like platform. +] as const; + +export const isDiskEncryptionSupportedLinuxPlatform = ( + platform: HostPlatform, + os_version: string +) => { + const isFedora = + platform === "rhel" && os_version.toLowerCase().includes("fedora"); + return isFedora || platform === "ubuntu"; +}; + +const DISK_ENCRYPTION_SUPPORTED_PLATFORMS = [ + "darwin", + "windows", + "chrome", + ...DISK_ENCRYPTION_SUPPORTED_LINUX_PLATFORMS, +] as const; + +export type DiskEncryptionSupportedPlatform = typeof DISK_ENCRYPTION_SUPPORTED_PLATFORMS[number]; + +export const platformSupportsDiskEncryption = ( + platform: HostPlatform, + /** os_version necessary to differentiate Fedora from other rhel-like platforms */ + os_version?: string +) => { + if (platform === "rhel") { + return !!os_version && os_version.toLowerCase().includes("fedora"); + } + return DISK_ENCRYPTION_SUPPORTED_PLATFORMS.includes( + platform as DiskEncryptionSupportedPlatform + ); +}; + +const OS_SETTINGS_DISPLAY_PLATFORMS = [ + ...DISK_ENCRYPTION_SUPPORTED_PLATFORMS, + "ios", + "ipados", +]; + +export const isOsSettingsDisplayPlatform = ( + platform: HostPlatform, + os_version: string +) => { + if (platform === "rhel") { + return !!os_version && os_version.toLowerCase().includes("fedora"); + } + return OS_SETTINGS_DISPLAY_PLATFORMS.includes(platform); +}; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 245f7aaddd..6318b9ce89 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -279,6 +279,14 @@ const TAGGED_TEMPLATES = { ); }, + fleetEnrolled: (activity: IActivity) => { + const hostDisplayName = activity.details?.host_display_name ? ( + {activity.details.host_display_name} + ) : ( + "A host" + ); + return <>{hostDisplayName} enrolled in Fleet.; + }, mdmEnrolled: (activity: IActivity) => { if (activity.details?.mdm_platform === "microsoft") { return ( @@ -1167,6 +1175,9 @@ const getDetail = ( case ActivityType.UserDeletedTeamRole: { return TAGGED_TEMPLATES.userDeletedTeamRole(activity); } + case ActivityType.FleetEnrolled: { + return TAGGED_TEMPLATES.fleetEnrolled(activity); + } case ActivityType.MdmEnrolled: { return TAGGED_TEMPLATES.mdmEnrolled(activity); } diff --git a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx index f6cf8c435f..dff1dc41df 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx @@ -9,7 +9,6 @@ import mdmAPI from "services/entities/mdm"; import OS_SETTINGS_NAV_ITEMS from "./OSSettingsNavItems"; import ProfileStatusAggregate from "./ProfileStatusAggregate"; -import TurnOnMdmMessage from "../../../components/TurnOnMdmMessage"; const baseClass = "os-settings"; @@ -29,7 +28,7 @@ const OSSettings = ({ params, }: IOSSettingsProps) => { const { section } = params; - const { config, currentTeam } = useContext(AppContext); + const { currentTeam } = useContext(AppContext); // TODO: consider using useTeamIdParam hook here instead in the future const teamId = @@ -51,14 +50,6 @@ const OSSettings = ({ } ); - // MDM is not on so show messaging for user to enable it. - if ( - !config?.mdm.enabled_and_configured && - !config?.mdm.windows_enabled_and_configured - ) { - return ; - } - const DEFAULT_SETTINGS_SECTION = OS_SETTINGS_NAV_ITEMS[0]; const currentFormSection = diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx index ede9ac75b1..b0e31bf3b2 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx @@ -14,6 +14,7 @@ import CustomLink from "components/CustomLink"; import SectionHeader from "components/SectionHeader"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; +import TurnOnMdmMessage from "components/TurnOnMdmMessage"; import Pagination from "pages/ManageControlsPage/components/Pagination"; @@ -46,7 +47,11 @@ const CustomSettings = ({ onMutation, }: ICustomSettingsProps) => { const { renderFlash } = useContext(NotificationContext); - const { isPremiumTier } = useContext(AppContext); + const { config, isPremiumTier } = useContext(AppContext); + + const mdmEnabled = + config?.mdm.enabled_and_configured || + config?.mdm.windows_enabled_and_configured; const [showAddProfileModal, setShowAddProfileModal] = useState(false); const [ @@ -78,6 +83,7 @@ const CustomSettings = ({ per_page: PROFILES_PER_PAGE, }), { + enabled: mdmEnabled, refetchOnWindowFocus: false, } ); @@ -185,7 +191,14 @@ const CustomSettings = ({ url="https://fleetdm.com/learn-more-about/custom-os-settings" />

- <>{renderProfileList()} + {!mdmEnabled ? ( + + ) : ( + renderProfileList() + )} {showAddProfileModal && ( { try { - await mdmAPI.updateAppleMdmSettings(diskEncryptionEnabled, currentTeamId); + await diskEncryptionAPI.updateDiskEncryption( + diskEncryptionEnabled, + currentTeamId + ); renderFlash( "success", "Successfully updated disk encryption enforcement!" @@ -91,11 +99,24 @@ const DiskEncryption = ({ if (currentTeamId === 0) { getUpdatedAppConfig(); } - } catch { - renderFlash( - "error", - "Could not update the disk encryption enforcement. Please try again." - ); + } catch (e) { + if (getErrorReason(e).includes("Missing required private key")) { + const link = + "https://fleetdm.com/learn-more-about/fleet-server-private-key"; + renderFlash( + "error", + <> + Could't enable disk encryption. Missing required private key. + Learn how to configure the private key here:{" "} + {link} + + ); + } else { + renderFlash( + "error", + "Could not update the disk encryption enforcement. Please try again." + ); + } } }; @@ -103,18 +124,43 @@ const DiskEncryption = ({ setIsLoadingTeam(false); } - const createDescriptionText = () => { - // table is showing disk encryption status. - if (showAggregate) { - return "If turned on, hosts' disk encryption keys will be stored in Fleet. "; - } - - return `Also known as “FileVault” on macOS and “BitLocker” on Windows. If turned on, hosts' disk encryption keys will be stored in Fleet. `; + const getTipContent = (platform: "windows" | "macOS") => { + const [AppleOrWindows, DEMethod] = + platform === "windows" + ? ["Windows", "BitLocker"] + : ["Apple", "FileVault"]; + return ( + <> + {AppleOrWindows} MDM must be turned on in{" "} + + Settings > Integrations >{" "} + Mobile Device Management (MDM) + {" "} + to enforce disk encryption via {DEMethod}. + + ); }; + const subTitle = ( + <> + Disk encryption is available on{" "} + macOS + ,{" "} + + Windows + + , Ubuntu Linux, and Fedora Linux hosts. + + ); + return (
- + {!isPremiumTier ? (

- {createDescriptionText()} + If turned on, hosts' disk encryption keys will be stored in + Fleet{" "}

diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/_styles.scss index 761a1f0e9e..c79883ca85 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/_styles.scss @@ -1,7 +1,7 @@ .disk-encryption { - &__premium-feature-message { - margin-top: 80px; - text-align: center; + .premium-feature-message-container { + justify-content: initial; + align-items: initial; } > p { @@ -15,4 +15,8 @@ .disk-encryption-content { animation: fade-in 250ms ease-out; } + .section-header__sub-title a { + font-size: inherit; + color: inherit; + } } diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx index 75391fa7f5..ae267d3ad1 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTable.tsx @@ -7,7 +7,9 @@ import PATHS from "router/paths"; import { buildQueryStringFromParams } from "utilities/url"; -import mdmAPI, { IDiskEncryptionSummaryResponse } from "services/entities/mdm"; +import diskEncryptionAPI, { + IDiskEncryptionSummaryResponse, +} from "services/entities/disk_encryption"; import { HOSTS_QUERY_PARAMS } from "services/entities/hosts"; import TableContainer from "components/TableContainer"; @@ -43,7 +45,7 @@ const DiskEncryptionTable = ({ error: diskEncryptionStatusError, } = useQuery( ["disk-encryption-summary", currentTeamId], - () => mdmAPI.getDiskEncryptionSummary(currentTeamId), + () => diskEncryptionAPI.getDiskEncryptionSummary(currentTeamId), { refetchOnWindowFocus: false, retry: false, diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx index 1eddba30ad..6930b1cd59 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx @@ -4,7 +4,7 @@ import { DiskEncryptionStatus } from "interfaces/mdm"; import { IDiskEncryptionStatusAggregate, IDiskEncryptionSummaryResponse, -} from "services/entities/mdm"; +} from "services/entities/disk_encryption"; import TextCell from "components/TableContainer/DataTable/TextCell"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; @@ -108,6 +108,21 @@ const defaultTableHeaders: IDataColumn[] = [ return ; }, }, + { + title: "Linux hosts", + Header: (cellProps: IHeaderProps) => ( + + ), + disableSortBy: true, + accessor: "linuxHosts", + Cell: ({ cell: { value: aggregateCount } }: ICellProps) => { + return ; + }, + }, { title: "", Header: "", @@ -200,6 +215,7 @@ export const generateTableData = ( status: STATUS_CELL_VALUES[status], macosHosts: statusAggregate.macos, windowsHosts: statusAggregate.windows, + linuxHosts: statusAggregate.linux, teamId: currentTeamId, }); diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx index a8305675c1..7e1cc7f085 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx @@ -136,7 +136,7 @@ const CurrentVersionSection = ({ {renderTable()}
diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx index 0bc2f80c91..b0bb07e3c1 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx @@ -194,7 +194,10 @@ const TargetSection = ({ return (
- + {renderTargetForms()}
); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx index 7dd997a204..f993128a90 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppsTable/FleetMaintainedAppsTable.tsx @@ -27,7 +27,7 @@ const EmptyFleetAppsTable = () => ( Can't find app?{" "} diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx index 56a3aac5f7..8157684190 100644 --- a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx @@ -173,13 +173,19 @@ const SoftwareOSTable = ({ router.push(path); }; + // Determines if a user should be able to filter the table + const hasData = data?.os_versions && data?.os_versions.length > 0; + const hasPlatformFilter = platform !== "all"; + + const showFilterHeaders = isSoftwareEnabled && (hasData || hasPlatformFilter); + const renderSoftwareCount = () => { if (!data) return null; return ( <> - {data?.os_versions && data?.counts_updated_at && ( + {showFilterHeaders && data?.counts_updated_at && ( <>} + customControl={showFilterHeaders ? renderPlatformDropdown : undefined} disableNextPage={!data?.meta.has_next_results} searchable={false} onQueryChange={onQueryChange} - stackControls renderCount={renderSoftwareCount} renderTableHelpText={renderTableHelpText} disableMultiRowSelect diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AppleBusinessManagerTable/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AppleBusinessManagerTable/_styles.scss index 6dc700f6bd..db84ef005b 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AppleBusinessManagerTable/_styles.scss +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AppleBusinessManagerTable/_styles.scss @@ -1,5 +1,16 @@ .apple-business-manager-table { + .data-table-block .data-table { + td.apple_id__cell { + max-width: 180px; + } + + td.macos_team__cell, td.ios_team__cell, td.ipados_team__cell { + max-width: 150px; + } + } + + // The desired behavior is to hide the header and team cell one by one // as the viewport gets smaller. This is achieved by using the max-width // media query with the breakpoint values taken from when the table content diff --git a/frontend/pages/admin/components/SettingsSection/SettingsSection.tsx b/frontend/pages/admin/components/SettingsSection/SettingsSection.tsx index d3a93ab09b..2c692ec224 100644 --- a/frontend/pages/admin/components/SettingsSection/SettingsSection.tsx +++ b/frontend/pages/admin/components/SettingsSection/SettingsSection.tsx @@ -21,7 +21,7 @@ const SettingsSection = ({ return (
- + <>{children}
); diff --git a/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/CreateLinuxKeyModal.tsx b/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/CreateLinuxKeyModal.tsx new file mode 100644 index 0000000000..b6e593acdc --- /dev/null +++ b/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/CreateLinuxKeyModal.tsx @@ -0,0 +1,57 @@ +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; +import React from "react"; + +const baseClass = "create-linux-key-modal"; + +interface ICreateLinuxKeyModal { + isTriggeringCreateLinuxKey: boolean; + onExit: () => void; +} + +const CreateLinuxKeyModal = ({ + isTriggeringCreateLinuxKey, + onExit, +}: ICreateLinuxKeyModal) => { + const renderModalBody = () => ( + <> +
    +
  1. + Wait 30 seconds for the Enter disk encryption passphrase pop-up + to open. +
  2. +
  3. + In the pop-up, enter the passphrase used to encrypt your device during + setup. +
  4. +
  5. + Close this window and select Refetch on your My device{" "} + page. This shares the new key with your organization. +
  6. +
+
+ +
+ + ); + return ( + + {renderModalBody()} + + ); +}; + +export default CreateLinuxKeyModal; diff --git a/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/_styles.scss new file mode 100644 index 0000000000..6e35542a93 --- /dev/null +++ b/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/_styles.scss @@ -0,0 +1,8 @@ +.create-linux-key-modal { + ol { + display: flex; + flex-direction: column; + gap: $pad-medium; + line-height: $medium; + } +} diff --git a/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/index.ts b/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/index.ts new file mode 100644 index 0000000000..d418b5f07a --- /dev/null +++ b/frontend/pages/hosts/details/DeviceUserPage/CreateLinuxKeyModal/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateLinuxKeyModal"; diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 467b40d4cb..868383d237 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -7,6 +7,7 @@ import { pick, findIndex } from "lodash"; import { NotificationContext } from "context/notification"; import deviceUserAPI from "services/entities/device_user"; +import diskEncryptionAPI from "services/entities/disk_encryption"; import { IDeviceMappingResponse, IMacadminsResponse, @@ -46,6 +47,7 @@ import FleetIcon from "../../../../../assets/images/fleet-avatar-24x24@2x.png"; import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal"; import AutoEnrollMdmModal from "./AutoEnrollMdmModal"; import ManualEnrollMdmModal from "./ManualEnrollMdmModal"; +import CreateLinuxKeyModal from "./CreateLinuxKeyModal"; import OSSettingsModal from "../OSSettingsModal"; import BootstrapPackageModal from "../HostDetailsPage/modals/BootstrapPackageModal"; import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware"; @@ -108,10 +110,14 @@ const DeviceUserPage = ({ const [showBootstrapPackageModal, setShowBootstrapPackageModal] = useState( false ); + const [showCreateLinuxKeyModal, setShowCreateLinuxKeyModal] = useState(false); const [globalConfig, setGlobalConfig] = useState( null ); const [hasSelfService, setSelfService] = useState(false); + const [isTriggeringCreateLinuxKey, setIsTriggeringCreateLinuxKey] = useState( + false + ); const { data: deviceMapping, refetch: refetchDeviceMapping } = useQuery( ["deviceMapping", deviceAuthToken], @@ -321,6 +327,22 @@ const DeviceUserPage = ({ ); }; + const onTriggerEscrowLinuxKey = async () => { + setIsTriggeringCreateLinuxKey(true); + // modal opens in loading state + setShowCreateLinuxKeyModal(true); + try { + await diskEncryptionAPI.triggerLinuxDiskEncryptionKeyEscrow( + deviceAuthToken + ); + } catch (e) { + renderFlash("error", "Failed to trigger key creation."); + setShowCreateLinuxKeyModal(false); + } finally { + setIsTriggeringCreateLinuxKey(false); + } + }; + const renderDeviceUserPage = () => { const failingPoliciesCount = host?.issues?.failing_policies_count || 0; @@ -353,22 +375,25 @@ const DeviceUserPage = ({ mdmEnabledAndConfigured={ !!globalConfig?.mdm.enabled_and_configured } - mdmConnectedToFleet={!!host.mdm.connected_to_fleet} - diskEncryptionStatus={ + connectedToFleetMdm={!!host.mdm.connected_to_fleet} + macDiskEncryptionStatus={ host.mdm.macos_settings?.disk_encryption ?? null } diskEncryptionActionRequired={ host.mdm.macos_settings?.action_required ?? null } onTurnOnMdm={toggleEnrollMdmModal} + onTriggerEscrowLinuxKey={onTriggerEscrowLinuxKey} + diskEncryptionOSSetting={host.mdm.os_settings?.disk_encryption} + diskIsEncrypted={host.disk_encryption_enabled} + diskEncryptionKeyAvailable={host.mdm.encryption_key_available} /> setShowBootstrapPackageModal(false)} /> )} + {showCreateLinuxKeyModal && !!host && ( + { + setShowCreateLinuxKeyModal(false); + }} + /> + )}
); }; 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 cd3f28c01b..0341ad8f00 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tests.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tests.tsx @@ -6,7 +6,8 @@ import DeviceUserBanners from "./DeviceUserBanners"; describe("Device User Banners", () => { const turnOnMdmExpcetedText = /Mobile device management \(MDM\) is off\./; - const resetKeyDiskEncryptExpcetedText = /Disk encryption: Log out of your device or restart it to safeguard your data in case your device is lost or stolen\./; + const resetNonLinuxDiskEncryptKeyExpectedText = /Disk encryption: Log out of your device or restart it to safeguard your data in case your device is lost or stolen\./; + const createNewLinuxDiskEncryptKeyExpectedText = /Disk encryption: Create a new disk encryption key\. This lets your organization help you unlock your device if you forget your passphrase\./; it("renders the turn on mdm banner correctly", () => { render( @@ -14,52 +15,121 @@ describe("Device User Banners", () => { hostPlatform="darwin" mdmEnrollmentStatus="Off" mdmEnabledAndConfigured - mdmConnectedToFleet - diskEncryptionStatus={null} + connectedToFleetMdm + macDiskEncryptionStatus={null} diskEncryptionActionRequired={null} onTurnOnMdm={noop} + onTriggerEscrowLinuxKey={noop} /> ); expect(screen.getByText(turnOnMdmExpcetedText)).toBeInTheDocument(); }); - it("renders the reset key for disk encryption banner correctly", () => { + it("renders the reset key for non-linux disk encryption banner correctly", () => { render( ); expect( - screen.getByText(resetKeyDiskEncryptExpcetedText) + screen.getByText(resetNonLinuxDiskEncryptKeyExpectedText) + ).toBeInTheDocument(); + }); + it("renders the create new linux disk encryption key banner correctly for Ubuntu", () => { + render( + + ); + expect( + screen.getByText(createNewLinuxDiskEncryptKeyExpectedText) + ).toBeInTheDocument(); + }); + it("renders the create new linux disk encryption key banner correctly for Fedora", () => { + render( + + ); + expect( + screen.getByText(createNewLinuxDiskEncryptKeyExpectedText) ).toBeInTheDocument(); }); - it("renders no banner correctly", () => { + it("renders no banner correctly for a mac that is verifying its disk encryption", () => { + render( + + ); + + expect(screen.queryByText(turnOnMdmExpcetedText)).not.toBeInTheDocument(); + expect( + screen.queryByText(resetNonLinuxDiskEncryptKeyExpectedText) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(resetNonLinuxDiskEncryptKeyExpectedText) + ).not.toBeInTheDocument(); + }); + it("renders no banner correctly for a mac without MDM set up", () => { // setup so mdm is not enabled and configured. render( ); expect(screen.queryByText(turnOnMdmExpcetedText)).not.toBeInTheDocument(); expect( - screen.queryByText(resetKeyDiskEncryptExpcetedText) + screen.queryByText(resetNonLinuxDiskEncryptKeyExpectedText) ).not.toBeInTheDocument(); expect( - screen.queryByText(resetKeyDiskEncryptExpcetedText) + screen.queryByText(resetNonLinuxDiskEncryptKeyExpectedText) ).not.toBeInTheDocument(); }); }); diff --git a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx index 286101a026..a461caa212 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/DeviceUserBanners.tsx @@ -2,42 +2,48 @@ import React from "react"; import InfoBanner from "components/InfoBanner"; import Button from "components/buttons/Button"; -import { DiskEncryptionStatus, MdmEnrollmentStatus } from "interfaces/mdm"; import { MacDiskEncryptionActionRequired } from "interfaces/host"; +import { IHostBannersBaseProps } from "pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners"; +import CustomLink from "components/CustomLink"; +import { + isDiskEncryptionSupportedLinuxPlatform, + platformSupportsDiskEncryption, +} from "interfaces/platform"; const baseClass = "device-user-banners"; -interface IDeviceUserBannersProps { - hostPlatform: string; - mdmEnrollmentStatus: MdmEnrollmentStatus | null; +interface IDeviceUserBannersProps extends IHostBannersBaseProps { mdmEnabledAndConfigured: boolean; - mdmConnectedToFleet: boolean; - diskEncryptionStatus: DiskEncryptionStatus | null; diskEncryptionActionRequired: MacDiskEncryptionActionRequired | null; onTurnOnMdm: () => void; + onTriggerEscrowLinuxKey: () => void; } const DeviceUserBanners = ({ hostPlatform, + hostOsVersion, mdmEnrollmentStatus, mdmEnabledAndConfigured, - mdmConnectedToFleet, - diskEncryptionStatus, + connectedToFleetMdm, + macDiskEncryptionStatus, diskEncryptionActionRequired, onTurnOnMdm, + diskEncryptionOSSetting, + diskIsEncrypted, + diskEncryptionKeyAvailable, + onTriggerEscrowLinuxKey, }: IDeviceUserBannersProps) => { const isMdmUnenrolled = mdmEnrollmentStatus === "Off" || mdmEnrollmentStatus === null; - const diskEncryptionBannersEnabled = - mdmEnabledAndConfigured && mdmConnectedToFleet; + const mdmEnabledAndConnected = mdmEnabledAndConfigured && connectedToFleetMdm; - const showTurnOnMdmBanner = + const showTurnOnAppleMdmBanner = hostPlatform === "darwin" && isMdmUnenrolled && mdmEnabledAndConfigured; - const showDiskEncryptionKeyResetRequired = - diskEncryptionBannersEnabled && - diskEncryptionStatus === "action_required" && + const showMacDiskEncryptionKeyResetRequired = + mdmEnabledAndConnected && + macDiskEncryptionStatus === "action_required" && diskEncryptionActionRequired === "rotate_key"; const turnOnMdmButton = ( @@ -47,7 +53,7 @@ const DeviceUserBanners = ({ ); const renderBanner = () => { - if (showTurnOnMdmBanner) { + if (showTurnOnAppleMdmBanner) { return ( Mobile device management (MDM) is off. MDM allows your organization to @@ -58,7 +64,7 @@ const DeviceUserBanners = ({ ); } - if (showDiskEncryptionKeyResetRequired) { + if (showMacDiskEncryptionKeyResetRequired) { return ( Disk encryption: Log out of your device or restart it to safeguard @@ -68,6 +74,60 @@ const DeviceUserBanners = ({ ); } + // setting applies to a supported Linux host + if ( + hostPlatform && + isDiskEncryptionSupportedLinuxPlatform( + hostPlatform, + hostOsVersion ?? "" + ) && + diskEncryptionOSSetting?.status + ) { + // host not in compliance with setting + if (!diskIsEncrypted) { + // banner 1 + return ( + + } + color="yellow" + > + Disk encryption: Follow the instructions in the guide to encrypt + your device. This lets your organization help you unlock your device + if you forget your password. + + ); + } + // host disk is encrypted, so in compliance with the setting + if (!diskEncryptionKeyAvailable) { + // key is not escrowed: banner 3 + return ( + + Create key + + } + color="yellow" + > + Disk encryption: Create a new disk encryption key. This lets your + organization help you unlock your device if you forget your + passphrase. + + ); + } + } + return null; }; diff --git a/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/_styles.scss new file mode 100644 index 0000000000..a09bdf15e0 --- /dev/null +++ b/frontend/pages/hosts/details/DeviceUserPage/components/DeviceUserBanners/_styles.scss @@ -0,0 +1,6 @@ +.device-user-banners { + .create-key-button { + color: $core-fleet-black; + font-weight: $bold; + } +} diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 8ef7adccff..8947a18cf4 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -93,7 +93,6 @@ import OSSettingsModal from "../OSSettingsModal"; import BootstrapPackageModal from "./modals/BootstrapPackageModal"; import ScriptModalGroup from "./modals/ScriptModalGroup"; import SelectQueryModal from "./modals/SelectQueryModal"; -import { isSupportedPlatform } from "./modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal"; import HostDetailsBanners from "./components/HostDetailsBanners"; import { IShowActivityDetailsData } from "../cards/Activity/Activity"; import LockModal from "./modals/LockModal"; @@ -740,11 +739,6 @@ const HostDetailsPage = ({ } }; - // const hostDeviceStatusUIState = getHostDeviceStatusUIState( - // host.mdm.device_status, - // host.mdm.pending_action - // ); - const renderActionDropdown = () => { if (!host) { return null; @@ -851,10 +845,13 @@ const HostDetailsPage = ({ <>
)} - {showDiskEncryptionModal && - host && - isSupportedPlatform(host.platform) && ( - setShowDiskEncryptionModal(false)} - /> - )} + {showDiskEncryptionModal && host && ( + setShowDiskEncryptionModal(false)} + /> + )} {showBootstrapPackageModal && bootstrapPackageData.details && bootstrapPackageData.name && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx index 656d3f2deb..188282bab1 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx @@ -2,27 +2,43 @@ import React, { useContext } from "react"; import { AppContext } from "context/app"; import { DiskEncryptionStatus, MdmEnrollmentStatus } from "interfaces/mdm"; -import { hasLicenseExpired, willExpireWithinXDays } from "utilities/helpers"; +import { hasLicenseExpired } from "utilities/helpers"; import InfoBanner from "components/InfoBanner"; +import { IOSSettings } from "interfaces/host"; +import { + HostPlatform, + platformSupportsDiskEncryption, +} from "interfaces/platform"; const baseClass = "host-details-banners"; -interface IHostDetailsBannersProps { - hostMdmEnrollmentStatus?: MdmEnrollmentStatus | null; - hostPlatform?: string; - diskEncryptionStatus: DiskEncryptionStatus | null | undefined; +export interface IHostBannersBaseProps { + macDiskEncryptionStatus: DiskEncryptionStatus | null | undefined; + mdmEnrollmentStatus: MdmEnrollmentStatus | null; connectedToFleetMdm?: boolean; + hostPlatform?: HostPlatform; + // used to identify Fedora hosts, whose platform is "rhel" + hostOsVersion?: string; + /** Disk encryption setting status and detail, if any, that apply to this host (via a team or the "no team" team) */ + diskEncryptionOSSetting?: IOSSettings["disk_encryption"]; + /** Whether or not this host's disk is encrypted */ + diskIsEncrypted?: boolean; + /** Whether or not Fleet has escrowed the host's disk encryption key */ + diskEncryptionKeyAvailable?: boolean; } - /** * Handles the displaying of banners on the host details page */ const HostDetailsBanners = ({ - hostMdmEnrollmentStatus, + mdmEnrollmentStatus, hostPlatform, + hostOsVersion, connectedToFleetMdm, - diskEncryptionStatus, -}: IHostDetailsBannersProps) => { + macDiskEncryptionStatus, + diskEncryptionOSSetting, + diskIsEncrypted, + diskEncryptionKeyAvailable, +}: IHostBannersBaseProps) => { const { config, isPremiumTier, @@ -53,8 +69,7 @@ const HostDetailsBanners = ({ willVppExpire || isFleetLicenseExpired); - const isMdmUnenrolled = - hostMdmEnrollmentStatus === "Off" || !hostMdmEnrollmentStatus; + const isMdmUnenrolled = mdmEnrollmentStatus === "Off" || !mdmEnrollmentStatus; const showTurnOnMdmInfoBanner = !showingAppWideBanner && @@ -62,31 +77,53 @@ const HostDetailsBanners = ({ isMdmUnenrolled && config?.mdm.enabled_and_configured; - const showDiskEncryptionUserActionRequired = + const showMacDiskEncryptionUserActionRequired = !showingAppWideBanner && config?.mdm.enabled_and_configured && connectedToFleetMdm && - diskEncryptionStatus === "action_required"; + macDiskEncryptionStatus === "action_required"; - if (showTurnOnMdmInfoBanner || showDiskEncryptionUserActionRequired) { + if (showTurnOnMdmInfoBanner) { return (
- {showTurnOnMdmInfoBanner && ( - - 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 log out of their device or restart it. - - )} + + 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. +
); } + if (showMacDiskEncryptionUserActionRequired) { + return ( +
+ + Disk encryption: Requires action from the end user. Ask the end user + to log out of their device or restart it. + +
+ ); + } + // setting applies + if ( + hostPlatform && + platformSupportsDiskEncryption(hostPlatform, hostOsVersion) && + diskEncryptionOSSetting?.status + ) { + // host either not in compliance with setting, or is but Fleet doesn't yet have a disk + // encryption key escrowed for the host (possible for Linux hosts) + if (!diskIsEncrypted || !diskEncryptionKeyAvailable) { + return ( +
+ + Disk encryption: Requires action from the end user. Ask the user to + follow Disk encryption instructions on their My device{" "} + page. + +
+ ); + } + } return null; }; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx index 69e3763d46..bb87157940 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx @@ -8,26 +8,14 @@ import Modal from "components/Modal"; import Button from "components/buttons/Button"; import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenContent"; import DataError from "components/DataError"; -import { QueryablePlatform } from "interfaces/platform"; +import CustomLink from "components/CustomLink"; +import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; +import { HostPlatform } from "interfaces/platform"; const baseClass = "disk-encryption-key-modal"; -// currently these are the only supported platforms for the disk encryption -// key modal. -export type ModalSupportedPlatform = Extract< - QueryablePlatform, - "darwin" | "windows" ->; - -// Checks to see if the platform is supported by the modal. -export const isSupportedPlatform = ( - platform: string -): platform is ModalSupportedPlatform => { - return ["darwin", "windows"].includes(platform); -}; - interface IDiskEncryptionKeyModal { - platform: ModalSupportedPlatform; + platform: HostPlatform; hostId: number; onCancel: () => void; } @@ -37,7 +25,7 @@ const DiskEncryptionKeyModal = ({ hostId, onCancel, }: IDiskEncryptionKeyModal) => { - const { data: encrpytionKey, error: encryptionKeyError } = useQuery< + const { data: encryptionKey, error: encryptionKeyError } = useQuery< IHostEncrpytionKeyResponse, unknown, string @@ -49,14 +37,10 @@ const DiskEncryptionKeyModal = ({ select: (data) => data.encryption_key.key, }); - const isMacOS = platform === "darwin"; - const descriptionText = isMacOS - ? "The disk encryption key refers to the FileVault recovery key for macOS." - : "The disk encryption key refers to the BitLocker recovery key for Windows."; - - const recoveryText = isMacOS - ? "Use this key to log in to the host if you forgot the password." - : "Use this key to unlock the encrypted drive."; + const recoveryText = + platform === "darwin" + ? "Use this key to log in to the host if you forgot the password." + : "Use this key to unlock the encrypted drive."; return ( ) : ( <> - -

{descriptionText}

-

{recoveryText}

+ +

+ {recoveryText}{" "} + +

diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx index f86226a967..f73601f931 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx @@ -29,8 +29,7 @@ const OSSettingsModal = ({ onClose, onProfileResent, }: IOSSettingsModalProps) => { - // the caller should ensure that hostMDMData is not undefined and that platform is "windows" or - // "darwin", otherwise we will allow an empty modal will be rendered. + // the caller should ensure that hostMDMData is not undefined and that platform is supported otherwise we will allow an empty modal will be rendered. // https://fleetdm.com/handbook/company/why-this-way#why-make-it-obvious-when-stuff-breaks const memoizedTableData = useMemo( diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx index d3e515908c..4fe684de77 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx @@ -4,7 +4,11 @@ import { uniqueId } from "lodash"; import Icon from "components/Icon"; import TextCell from "components/TableContainer/DataTable/TextCell"; -import { ProfileOperationType } from "interfaces/mdm"; +import { + LinuxDiskEncryptionStatus, + ProfileOperationType, + ProfilePlatform, +} from "interfaces/mdm"; import { COLORS } from "styles/var/colors"; import { @@ -14,6 +18,7 @@ import { import TooltipContent from "./components/Tooltip/TooltipContent"; import { isDiskEncryptionProfile, + LINUX_DISK_ENCRYPTION_DISPLAY_CONFIG, PROFILE_DISPLAY_CONFIG, ProfileDisplayOption, WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG, @@ -25,22 +30,27 @@ interface IOSSettingStatusCellProps { status: OsSettingsTableStatusValue; operationType: ProfileOperationType | null; profileName: string; + hostPlatform?: ProfilePlatform; } const OSSettingStatusCell = ({ status, operationType, profileName = "", + hostPlatform, }: IOSSettingStatusCellProps) => { let displayOption: ProfileDisplayOption = null; - // windows hosts do not have an operation type at the moment and their display options are - // different than mac hosts. - if (!operationType && isMdmProfileStatus(status)) { - displayOption = WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG[status]; + if (hostPlatform === "linux") { + displayOption = + LINUX_DISK_ENCRYPTION_DISPLAY_CONFIG[status as LinuxDiskEncryptionStatus]; } - if (operationType) { + // windows hosts do not have an operation type at the moment and their display options are + // different than mac hosts. + else if (!operationType && isMdmProfileStatus(status)) { + displayOption = WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG[status]; + } else if (operationType) { displayOption = PROFILE_DISPLAY_CONFIG[operationType]?.[status]; } diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts index 588e4e8d29..60996eab61 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts @@ -135,3 +135,27 @@ export const WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG: WindowsDiskEncryptionDispla tooltip: null, }, }; + +type LinuxDiskEncryptionDisplayConfig = Omit< + OperationTypeOption, + "success" | "pending" | "acknowledged" | "verifying" +>; + +export const LINUX_DISK_ENCRYPTION_DISPLAY_CONFIG: LinuxDiskEncryptionDisplayConfig = { + verified: { + statusText: "Verified", + iconName: "success", + tooltip: () => + "The host turned disk encryption on and sent the key to Fleet. Fleet verified.", + }, + failed: { + statusText: "Failed", + iconName: "error", + tooltip: null, + }, + action_required: { + statusText: "Action required (pending)", + iconName: "pending-outline", + tooltip: TooltipInnerContentActionRequired as TooltipInnerContentFunc, + }, +}; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx index 8f6d2650c9..0d9b4017c8 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx @@ -9,12 +9,17 @@ import { IHostMdmProfile, MdmDDMProfileStatus, MdmProfileStatus, + isLinuxDiskEncryptionStatus, isWindowsDiskEncryptionStatus, } from "interfaces/mdm"; +import { isDiskEncryptionSupportedLinuxPlatform } from "interfaces/platform"; import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell"; import OSSettingStatusCell from "./OSSettingStatusCell"; -import { generateWinDiskEncryptionProfile } from "../../helpers"; +import { + generateLinuxDiskEncryptionSetting, + generateWinDiskEncryptionSetting, +} from "../../helpers"; import OSSettingsErrorCell from "./OSSettingsErrorCell"; export const isMdmProfileStatus = ( @@ -69,6 +74,7 @@ const generateTableConfig = ( status={cellProps.row.original.status} operationType={cellProps.row.original.operation_type} profileName={cellProps.row.original.name} + hostPlatform={cellProps.row.original.platform} /> ); }, @@ -101,7 +107,33 @@ const makeWindowsRows = ({ profiles, os_settings }: IHostMdmData) => { isWindowsDiskEncryptionStatus(os_settings.disk_encryption.status) ) { rows.push( - generateWinDiskEncryptionProfile( + generateWinDiskEncryptionSetting( + os_settings.disk_encryption.status, + os_settings.disk_encryption.detail + ) + ); + } + + if (rows.length === 0 && !profiles) { + return null; + } + + return rows; +}; + +const makeLinuxRows = ({ profiles, os_settings }: IHostMdmData) => { + const rows: IHostMdmProfileWithAddedStatus[] = []; + + if (profiles) { + rows.push(...profiles); + } + + if ( + os_settings?.disk_encryption?.status && + isLinuxDiskEncryptionStatus(os_settings.disk_encryption.status) + ) { + rows.push( + generateLinuxDiskEncryptionSetting( os_settings.disk_encryption.status, os_settings.disk_encryption.detail ) @@ -145,6 +177,10 @@ export const generateTableData = ( return makeWindowsRows(hostMDMData); case "darwin": return makeDarwinRows(hostMDMData); + case "ubuntu": + return makeLinuxRows(hostMDMData); + case "rhel": + return makeLinuxRows(hostMDMData); case "ios": return hostMDMData.profiles; case "ipados": diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss index dc33019945..9469111a6d 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss @@ -1,18 +1,22 @@ .os-settings-table { - // stylings for the table cells. This was the explicit width we want // for these cells in the table. Total width of the table cell will be // 240px including the padding. - .data-table-block .data-table tbody td { - .os-settings-name-cell { - width: 135px; - max-width: none; + .data-table-block .data-table { + &__wrapper { + width: initial; } - .os-settings-status-cell { - width: 200px; - } - .os-settings-error-cell { - width: 237px; + tbody td { + .os-settings-name-cell { + width: 135px; + max-width: none; + } + .os-settings-status-cell { + width: 200px; + } + .os-settings-error-cell { + width: 237px; + } } } diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index 63caddf9e0..1c9c8e52ad 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -6,9 +6,17 @@ import { IHostMdmProfile, BootstrapPackageStatus, isWindowsDiskEncryptionStatus, + isLinuxDiskEncryptionStatus, } from "interfaces/mdm"; import { IOSSettings, IHostMaintenanceWindow } from "interfaces/host"; import { IAppleDeviceUpdates } from "interfaces/config"; +import { + DiskEncryptionSupportedPlatform, + isDiskEncryptionSupportedLinuxPlatform, + isOsSettingsDisplayPlatform, + platformSupportsDiskEncryption, +} from "interfaces/platform"; + import getHostStatusTooltipText from "pages/hosts/helpers"; import TooltipWrapper from "components/TooltipWrapper"; @@ -37,7 +45,8 @@ import BootstrapPackageIndicator from "./BootstrapPackageIndicator/BootstrapPack import { HostMdmDeviceStatusUIState, - generateWinDiskEncryptionProfile, + generateLinuxDiskEncryptionSetting, + generateWinDiskEncryptionSetting, } from "../../helpers"; import { DEVICE_STATUS_TAGS, REFETCH_TOOLTIP_MESSAGES } from "./helpers"; @@ -118,8 +127,7 @@ interface IHostSummaryProps { isPremiumTier?: boolean; toggleOSSettingsModal?: () => void; toggleBootstrapPackageModal?: () => void; - hostMdmProfiles?: IHostMdmProfile[]; - isConnectedToFleetMdm?: boolean; + hostSettings?: IHostMdmProfile[]; showRefetchSpinner: boolean; onRefetchHost: ( evt: React.MouseEvent @@ -131,7 +139,7 @@ interface IHostSummaryProps { hostMdmDeviceStatus?: HostMdmDeviceStatusUIState; } -const MAC_WINDOWS_DISK_ENCRYPTION_MESSAGES = { +const DISK_ENCRYPTION_MESSAGES = { darwin: { enabled: ( <> @@ -155,20 +163,28 @@ const MAC_WINDOWS_DISK_ENCRYPTION_MESSAGES = { ), disabled: "The disk is unencrypted.", }, + linux: { + enabled: "The disk is encrypted.", + unknown: "The disk may be encrypted.", + }, }; const getHostDiskEncryptionTooltipMessage = ( - platform: "darwin" | "windows" | "chrome", // TODO: improve this type + platform: DiskEncryptionSupportedPlatform, // TODO: improve this type diskEncryptionEnabled = false ) => { if (platform === "chrome") { return "Fleet does not check for disk encryption on Chromebooks, as they are encrypted by default."; } - if (!["windows", "darwin"].includes(platform)) { - return "Disk encryption is enabled."; + if (platform === "rhel" || platform === "ubuntu") { + return DISK_ENCRYPTION_MESSAGES.linux[ + diskEncryptionEnabled ? "enabled" : "unknown" + ]; } - return MAC_WINDOWS_DISK_ENCRYPTION_MESSAGES[platform][ + + // mac or windows + return DISK_ENCRYPTION_MESSAGES[platform][ diskEncryptionEnabled ? "enabled" : "disabled" ]; }; @@ -179,8 +195,7 @@ const HostSummary = ({ isPremiumTier, toggleOSSettingsModal, toggleBootstrapPackageModal, - hostMdmProfiles, - isConnectedToFleetMdm, + hostSettings, showRefetchSpinner, onRefetchHost, renderActionDropdown, @@ -192,6 +207,7 @@ const HostSummary = ({ const { status, platform, + os_version, disk_encryption_enabled: diskEncryptionEnabled, } = summaryData; @@ -281,8 +297,7 @@ const HostSummary = ({ ); }; const renderDiskEncryptionSummary = () => { - // TODO: improve this typing, platforms! - if (!["darwin", "windows", "chrome"].includes(platform)) { + if (!platformSupportsDiskEncryption(platform, os_version)) { return <>; } const tooltipMessage = getHostDiskEncryptionTooltipMessage( @@ -301,6 +316,11 @@ const HostSummary = ({ case diskEncryptionEnabled === false: statusText = "Off"; break; + case (diskEncryptionEnabled === null || + diskEncryptionEnabled === undefined) && + platformSupportsDiskEncryption(platform, os_version): + statusText = "Unknown"; + break; default: // something unexpected happened on the way to this component, display whatever we got or // "Unknown" to draw attention to the issue. @@ -441,21 +461,35 @@ const HostSummary = ({ }; const renderSummary = () => { - // for windows hosts we have to manually add a profile for disk encryption + // for windows and linux hosts we have to manually add a profile for disk encryption // as this is not currently included in the `profiles` value from the API - // response for windows hosts. + // response for windows and linux hosts. if ( platform === "windows" && osSettings?.disk_encryption?.status && isWindowsDiskEncryptionStatus(osSettings.disk_encryption.status) ) { - const winDiskEncryptionProfile: IHostMdmProfile = generateWinDiskEncryptionProfile( + const winDiskEncryptionSetting: IHostMdmProfile = generateWinDiskEncryptionSetting( osSettings.disk_encryption.status, osSettings.disk_encryption.detail ); - hostMdmProfiles = hostMdmProfiles - ? [...hostMdmProfiles, winDiskEncryptionProfile] - : [winDiskEncryptionProfile]; + hostSettings = hostSettings + ? [...hostSettings, winDiskEncryptionSetting] + : [winDiskEncryptionSetting]; + } + + if ( + isDiskEncryptionSupportedLinuxPlatform(platform, os_version) && + osSettings?.disk_encryption?.status && + isLinuxDiskEncryptionStatus(osSettings.disk_encryption.status) + ) { + const linuxDiskEncryptionSetting: IHostMdmProfile = generateLinuxDiskEncryptionSetting( + osSettings.disk_encryption.status, + osSettings.disk_encryption.detail + ); + hostSettings = hostSettings + ? [...hostSettings, linuxDiskEncryptionSetting] + : [linuxDiskEncryptionSetting]; } return ( @@ -484,19 +518,15 @@ const HostSummary = ({ renderIssues()} {isPremiumTier && renderHostTeam()} {/* Rendering of OS Settings data */} - {(platform === "darwin" || - platform === "windows" || - platform === "ios" || - platform === "ipados") && + {isOsSettingsDisplayPlatform(platform, os_version) && isPremiumTier && - isConnectedToFleetMdm && // show if 1 - host is enrolled in Fleet MDM, and - hostMdmProfiles && - hostMdmProfiles.length > 0 && ( // 2 - host has at least one setting (profile) enforced + hostSettings && + hostSettings.length > 0 && ( } diff --git a/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx b/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx index 60cc9c069b..3a145e4f0e 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx @@ -47,7 +47,7 @@ const countHostProfilesByStatus = ( (acc, { status }) => { if (status === "failed") { acc.failed += 1; - } else if (status === "pending") { + } else if (status === "pending" || status === "action_required") { acc.pending += 1; } else if (status === "verifying") { acc.verifying += 1; diff --git a/frontend/pages/hosts/details/helpers.ts b/frontend/pages/hosts/details/helpers.ts index 52a0efd6dd..5c78a68bc9 100644 --- a/frontend/pages/hosts/details/helpers.ts +++ b/frontend/pages/hosts/details/helpers.ts @@ -2,12 +2,13 @@ import { HostMdmDeviceStatus, HostMdmPendingAction } from "interfaces/host"; import { IHostMdmProfile, - IWindowsDiskEncryptionStatus, + WindowsDiskEncryptionStatus, MdmProfileStatus, + LinuxDiskEncryptionStatus, } from "interfaces/mdm"; -const convertWinDiskEncryptionStatusToProfileStatus = ( - diskEncryptionStatus: IWindowsDiskEncryptionStatus +const convertWinDiskEncryptionStatusToSettingStatus = ( + diskEncryptionStatus: WindowsDiskEncryptionStatus ): MdmProfileStatus => { return diskEncryptionStatus === "enforcing" ? "pending" @@ -15,20 +16,40 @@ const convertWinDiskEncryptionStatusToProfileStatus = ( }; /** - * Manually generates a profile for the windows disk encryption status. We need + * Manually generates a setting for the windows disk encryption status. We need * this as we don't have a windows disk encryption profile in the `profiles` * attribute coming back from the GET /hosts/:id API response. */ // eslint-disable-next-line import/prefer-default-export -export const generateWinDiskEncryptionProfile = ( - diskEncryptionStatus: IWindowsDiskEncryptionStatus, +export const generateWinDiskEncryptionSetting = ( + diskEncryptionStatus: WindowsDiskEncryptionStatus, detail: string ): IHostMdmProfile => { return { profile_uuid: "0", // This s the only type of profile that can have this value platform: "windows", name: "Disk Encryption", - status: convertWinDiskEncryptionStatusToProfileStatus(diskEncryptionStatus), + status: convertWinDiskEncryptionStatusToSettingStatus(diskEncryptionStatus), + detail, + operation_type: null, + }; +}; + +/** + * Manually generates a setting for the linux disk encryption status. We need + * this as we don't have a linux disk encryption setting in the `profiles` + * attribute coming back from the GET /hosts/:id API response. + */ +// eslint-disable-next-line import/prefer-default-export +export const generateLinuxDiskEncryptionSetting = ( + diskEncryptionStatus: LinuxDiskEncryptionStatus, + detail: string +): IHostMdmProfile => { + return { + profile_uuid: "0", // This s the only type of profile that can have this value + platform: "linux", + name: "Disk Encryption", + status: diskEncryptionStatus, detail, operation_type: null, }; diff --git a/frontend/services/entities/disk_encryption.ts b/frontend/services/entities/disk_encryption.ts new file mode 100644 index 0000000000..50a0eb63ef --- /dev/null +++ b/frontend/services/entities/disk_encryption.ts @@ -0,0 +1,60 @@ +import sendRequest from "services"; + +import endpoints from "utilities/endpoints"; +import { buildQueryStringFromParams } from "utilities/url"; + +// TODO - move disk encryption types like this to dedicated file +import { DiskEncryptionStatus } from "interfaces/mdm"; +import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; + +export interface IDiskEncryptionStatusAggregate { + macos: number; + windows: number; + linux: number; +} + +export type IDiskEncryptionSummaryResponse = Record< + DiskEncryptionStatus, + IDiskEncryptionStatusAggregate +>; + +const diskEncryptionService = { + getDiskEncryptionSummary: (teamId?: number) => { + let { MDM_DISK_ENCRYPTION_SUMMARY: path } = endpoints; + + if (teamId) { + path = `${path}?${buildQueryStringFromParams({ team_id: teamId })}`; + } + return sendRequest("GET", path); + }, + updateDiskEncryption: (enableDiskEncryption: boolean, teamId?: number) => { + // TODO - use same endpoint for both once issue with new endpoint for no team is resolved + const { + UPDATE_DISK_ENCRYPTION: teamsEndpoint, + CONFIG: noTeamsEndpoint, + } = endpoints; + if (teamId === 0) { + return sendRequest("PATCH", noTeamsEndpoint, { + mdm: { + enable_disk_encryption: enableDiskEncryption, + }, + }); + } + return sendRequest("POST", teamsEndpoint, { + enable_disk_encryption: enableDiskEncryption, + // TODO - it would be good to be able to use an API_CONTEXT_NO_TEAM_ID here, but that is + // currently set to 0, which should actually be undefined since the server expects teamId == + // nil for no teams, not 0. + team_id: teamId === APP_CONTEXT_NO_TEAM_ID ? undefined : teamId, + }); + }, + triggerLinuxDiskEncryptionKeyEscrow: (token: string) => { + const { DEVICE_TRIGGER_LINUX_DISK_ENCRYPTION_KEY_ESCROW } = endpoints; + return sendRequest( + "POST", + DEVICE_TRIGGER_LINUX_DISK_ENCRYPTION_KEY_ESCROW(token) + ); + }, +}; + +export default diskEncryptionService; diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index 50ed3bd428..ec7499390b 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -1,5 +1,4 @@ import { - DiskEncryptionStatus, IHostMdmProfile, IMdmCommandResult, IMdmProfile, @@ -21,16 +20,6 @@ export interface IEulaMetadataResponse { export type ProfileStatusSummaryResponse = Record; -export interface IDiskEncryptionStatusAggregate { - macos: number; - windows: number; -} - -export type IDiskEncryptionSummaryResponse = Record< - DiskEncryptionStatus, - IDiskEncryptionStatusAggregate ->; - export interface IGetProfilesApiParams { page?: number; per_page?: number; @@ -188,37 +177,6 @@ const mdmService = { return sendRequest("GET", path); }, - getDiskEncryptionSummary: (teamId?: number) => { - let { MDM_DISK_ENCRYPTION_SUMMARY: path } = endpoints; - - if (teamId) { - path = `${path}?${buildQueryStringFromParams({ team_id: teamId })}`; - } - return sendRequest("GET", path); - }, - - // TODO: API INTEGRATION: change when API is implemented that works for windows - // disk encryption too. - updateAppleMdmSettings: (enableDiskEncryption: boolean, teamId?: number) => { - const { - MDM_UPDATE_APPLE_SETTINGS: teamsEndpoint, - CONFIG: noTeamsEndpoint, - } = endpoints; - if (teamId === 0) { - return sendRequest("PATCH", noTeamsEndpoint, { - mdm: { - // TODO: API INTEGRATION: remove macos_settings when API change is merged in. - macos_settings: { enable_disk_encryption: enableDiskEncryption }, - // enable_disk_encryption: enableDiskEncryption, - }, - }); - } - return sendRequest("PATCH", teamsEndpoint, { - enable_disk_encryption: enableDiskEncryption, - team_id: teamId, - }); - }, - initiateMDMAppleSSO: () => { const { MDM_APPLE_SSO } = endpoints; return sendRequest("POST", MDM_APPLE_SSO, {}); diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index bb6b3dea65..196345bf2b 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -34,6 +34,9 @@ export default { DEVICE_USER_MDM_ENROLLMENT_PROFILE: (token: string): string => { return `/${API_VERSION}/fleet/device/${token}/mdm/apple/manual_enrollment_profile`; }, + DEVICE_TRIGGER_LINUX_DISK_ENCRYPTION_KEY_ESCROW: (token: string): string => { + return `/${API_VERSION}/fleet/device/${token}/mdm/linux/trigger_escrow`; + }, // Host endpoints HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`, @@ -138,6 +141,9 @@ export default { ME: `/${API_VERSION}/fleet/me`, + // Disk encryption endpoints + UPDATE_DISK_ENCRYPTION: `/${API_VERSION}/fleet/disk_encryption`, + // Setup experiece endpoints MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`, MDM_SETUP_EXPERIENCE_SOFTWARE: `/${API_VERSION}/fleet/setup_experience/software`, diff --git a/go.mod b/go.mod index 822b953698..e5939b1ae1 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/go-kit/kit v0.12.0 github.com/go-kit/log v0.2.1 github.com/go-ole/go-ole v1.2.6 - github.com/go-sql-driver/mysql v1.7.1 + github.com/go-sql-driver/mysql v1.8.1 github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055 github.com/golang-jwt/jwt/v4 v4.5.1 github.com/gomodule/oauth1 v0.2.0 @@ -67,9 +67,10 @@ require ( github.com/jmoiron/sqlx v1.3.5 github.com/josephspurrier/goversioninfo v1.4.0 github.com/kevinburke/go-bindata v3.24.0+incompatible + github.com/klauspost/compress v1.17.9 github.com/kolide/launcher v1.0.12 github.com/lib/pq v1.10.9 - github.com/macadmins/osquery-extension v1.2.1 + github.com/macadmins/osquery-extension v1.2.3 github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e github.com/mattn/go-sqlite3 v1.14.22 github.com/micromdm/micromdm v1.9.0 @@ -92,11 +93,12 @@ require ( github.com/quasilyte/go-ruleguard/dsl v0.3.22 github.com/rs/zerolog v1.32.0 github.com/russellhaering/goxmldsig v1.2.0 - github.com/saferwall/pe v1.5.2 + github.com/saferwall/pe v1.5.5 github.com/sassoftware/relic/v8 v8.0.1 github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 github.com/sethvargo/go-password v0.3.0 github.com/shirou/gopsutil/v3 v3.24.3 + github.com/siderolabs/go-blockdevice/v2 v2.0.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa github.com/smallstep/scep v0.0.0-20240214080410-892e41795b99 @@ -111,9 +113,10 @@ require ( github.com/urfave/cli/v2 v2.23.5 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 github.com/ziutek/mymysql v1.5.4 - go.elastic.co/apm/module/apmgorilla/v2 v2.3.0 - go.elastic.co/apm/module/apmsql/v2 v2.4.3 - go.elastic.co/apm/v2 v2.4.3 + go.elastic.co/apm/module/apmgorilla/v2 v2.6.2 + go.elastic.co/apm/module/apmhttp/v2 v2.6.2 + go.elastic.co/apm/module/apmsql/v2 v2.6.2 + go.elastic.co/apm/v2 v2.6.2 go.etcd.io/bbolt v1.3.9 go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.56.0 @@ -152,6 +155,7 @@ require ( cloud.google.com/go/storage v1.39.1 // indirect code.gitea.io/sdk/gitea v0.15.0 // indirect dario.cat/mergo v1.0.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect @@ -181,6 +185,7 @@ require ( github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 // indirect github.com/antchfx/xpath v1.2.2 // indirect github.com/apache/thrift v0.18.1 // indirect + github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/atc0005/go-teams-notify/v2 v2.6.0 // indirect github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect @@ -266,7 +271,6 @@ require ( github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.17.8 // indirect github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.5 // indirect @@ -275,6 +279,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mattn/go-tty v0.0.3 // indirect + github.com/micromdm/nanolib v0.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect @@ -292,9 +297,11 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d // indirect github.com/secure-systems-lab/go-securesystemslib v0.5.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/siderolabs/go-cmd v0.1.1 // indirect github.com/skeema/knownhosts v1.2.1 // indirect github.com/slack-go/slack v0.9.4 // indirect github.com/spf13/afero v1.6.0 // indirect @@ -315,7 +322,6 @@ require ( github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/yashtewari/glob-intersection v0.1.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.elastic.co/apm/module/apmhttp/v2 v2.3.0 // indirect go.elastic.co/fastjson v1.1.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect diff --git a/go.sum b/go.sum index 36ba36ea7d..e00f1410fe 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0Wk dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d h1:NjHwOOuOgGswUOPzDlsEDJOqKdjOjwL8Vi1mj9qx9+o= fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/AbGuthrie/goquery/v2 v2.0.1 h1:h0tIhmeRroyqYjT9zxXPXOrheNp1xqNTV+XFWuDI+eA= @@ -233,6 +235,8 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs= +github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -422,11 +426,8 @@ github.com/e-dard/netbug v0.0.0-20151029172837-e64d308a0b20 h1:eDPsdileewX4H5a2J github.com/e-dard/netbug v0.0.0-20151029172837-e64d308a0b20/go.mod h1:WXFUXJ0Y/SzNqXmhUU7VkE7a2Pag0zZnE2b6I87YWIs= github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= -github.com/elastic/go-licenser v0.4.0/go.mod h1:V56wHMpmdURfibNBggaSBfqgPxyT1Tldns1i87iTEvU= -github.com/elastic/go-sysinfo v1.7.1/go.mod h1:i1ZYdU10oLNfRzq4vq62BEwD2fH8KaWh6eh0ikPT9F0= github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn0jg4= github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= -github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw= @@ -460,6 +461,8 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/foxcpp/go-mockdns v0.0.0-20210729171921-fb145fc6f897 h1:E52jfcE64UG42SwLmrW0QByONfGynWuzBvm86BoB9z8= github.com/foxcpp/go-mockdns v0.0.0-20210729171921-fb145fc6f897/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4= +github.com/freddierice/go-losetup/v2 v2.0.1 h1:wPDx/Elu9nDV8y/CvIbEDz5Xi5Zo80y4h7MKbi3XaAI= +github.com/freddierice/go-losetup/v2 v2.0.1/go.mod h1:TEyBrvlOelsPEhfWD5rutNXDmUszBXuFnwT1kIQF4J8= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= @@ -523,8 +526,8 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.7.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -691,8 +694,6 @@ github.com/goreleaser/goreleaser v1.1.0 h1:YySqqYTX9kxRU0e/fGQrhivXk8/zD4iUOlL7h github.com/goreleaser/goreleaser v1.1.0/go.mod h1:Xi4DvX/N7e2hXC5tJlXsKEb+XEo83tkSqcinWunNtjs= github.com/goreleaser/nfpm/v2 v2.10.0 h1:SshT2D1MTzCifmjaagQA+5XW9Iq+qvXUavrgP0HvmWg= github.com/goreleaser/nfpm/v2 v2.10.0/go.mod h1:Bj/ztLvdnBnEgMae0fl/bLF6By1+yFFKeL97WiS6ZJg= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= @@ -779,7 +780,6 @@ github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4 github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jcchavezs/porto v0.1.0/go.mod h1:fESH0gzDHiutHRdX2hv27ojnOVFco37hg1W6E9EZF4A= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -817,8 +817,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= -github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab h1:KVR7cs+oPyy85i+8t1ZaNSy1bymCy5FuWyt51pdrXu4= github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab/go.mod h1:OYYulo9tUqRadRLwB0+LE914sa1ui2yL7OrcU3Q/1XY= github.com/kolide/launcher v1.0.12 h1:f2uT1kKYGIbj/WVsHDc10f7MIiwu8MpmgwaGaT7D09k= @@ -848,6 +848,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/macadmins/osquery-extension v1.2.1 h1:p7tAAhfEjUjoMQJNb+X7Qc3FraVqGZqMhZ1BYJbrlaw= github.com/macadmins/osquery-extension v1.2.1/go.mod h1:q0BnBuYocHBRB+m3AQwdQNETH5a2KzVT3S8TKMHo9Lk= +github.com/macadmins/osquery-extension v1.2.3 h1:PAAQVRBnpOwnzEUROiJbrjDf9RPwcAfJrNAkXUcjS3Y= +github.com/macadmins/osquery-extension v1.2.3/go.mod h1:cNd/9INYpAYJFjfmAEJKgiuHgDkGuFMPu6GVrn7oups= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -891,6 +893,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/micromdm/micromdm v1.9.0 h1:FAsIKOpnGcq21UQCrHCUxZwSW4NwBLGOoUtzbURxds8= github.com/micromdm/micromdm v1.9.0/go.mod h1:YsAtsEvfEIwpjYTUPpWkJXSfH0hhp9mMHW1BgIZgRt8= +github.com/micromdm/nanolib v0.2.0 h1:g5GHQuUpS82WIAB15LyenjF/0/WSUNJMe5XZfCJSXq4= +github.com/micromdm/nanolib v0.2.0/go.mod h1:FwBKCvvphgYvbdUZ+qw5kay7NHJcg6zPi8W7kXNajmE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= @@ -1005,9 +1009,7 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= @@ -1034,14 +1036,16 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/saferwall/pe v1.5.2 h1:h5lLtLsyxGHQ9dN6cd8EfeLEBEo5gdqJpkuw4o4vTMY= -github.com/saferwall/pe v1.5.2/go.mod h1:SNzv3cdgk8SBI0UwHfyTcdjawfdnN+nbydnEL7GZ25s= +github.com/saferwall/pe v1.5.5 h1:GGbzKjXDm7i+1K6riOgtgblyTdRmTbr3r11IzjovAK8= +github.com/saferwall/pe v1.5.5/go.mod h1:mJx+PuptmNpoPFBNhWs/uDMFL/kTHVZIkg0d4OUJFbQ= github.com/sassoftware/relic/v8 v8.0.1 h1:uYUoaoTQMs67up8/46NgrSxSftgfY4VWBusDVg56k7I= github.com/sassoftware/relic/v8 v8.0.1/go.mod h1:s/MwugRcovgYcNJNOyvLfqRHDX7iArHtFtUR9kEodz8= github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 h1:rc/CcqLH3lh8n+csdOuDfP+NuykE0U6AeYSJJHKDgSg= github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9/go.mod h1:a/83NAfUXvEuLpmxDssAXxgUgrEy12MId3Wd7OTs76s= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= +github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d h1:RQqyEogx5J6wPdoxqL132b100j8KjcVHO1c0KLRoIhc= +github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d/go.mod h1:PegD7EVqlN88z7TpCqH92hHP+GBpfomGCCnw1PFtNOA= github.com/secure-systems-lab/go-securesystemslib v0.5.0 h1:oTiNu0QnulMQgN/hLK124wJD/r2f9ZhIUuKIeBsCBT8= github.com/secure-systems-lab/go-securesystemslib v0.5.0/go.mod h1:uoCqUC0Ap7jrBSEanxT+SdACYJTVplRXWLkGMuDjXqk= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -1057,6 +1061,12 @@ github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/siderolabs/gen v0.5.0 h1:Afdjx+zuZDf53eH5DB+E+T2JeCwBXGinV66A6osLgQI= +github.com/siderolabs/gen v0.5.0/go.mod h1:1GUMBNliW98Xeq8GPQeVMYqQE09LFItE8enR3wgMh3Q= +github.com/siderolabs/go-blockdevice/v2 v2.0.3 h1:IEgDqd3H3gPphahrdvfAzU8RmD4r5eQdWC+vgFQQoEg= +github.com/siderolabs/go-blockdevice/v2 v2.0.3/go.mod h1:74htzCV913UzaLZ4H+NBXkwWlYnBJIq5m/379ZEcu8w= +github.com/siderolabs/go-cmd v0.1.1 h1:nTouZUSxLeiiEe7hFexSVvaTsY/3O8k1s08BxPRrsps= +github.com/siderolabs/go-cmd v0.1.1/go.mod h1:6hY0JG34LxEEwYE8aH2iIHkHX/ir12VRLqfwAf2yJIY= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -1187,8 +1197,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -1198,15 +1206,14 @@ github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.einride.tech/aip v0.66.0 h1:XfV+NQX6L7EOYK11yoHHFtndeaWh3KbD9/cN/6iWEt8= go.einride.tech/aip v0.66.0/go.mod h1:qAhMsfT7plxBX+Oy7Huol6YUvZ0ZzdUz26yZsQwfl1M= -go.elastic.co/apm/module/apmgorilla/v2 v2.3.0 h1:jHw8N252UTwKTk945+Am8AaawhHC6DWpFVeTXQO8Gko= -go.elastic.co/apm/module/apmgorilla/v2 v2.3.0/go.mod h1:2LXDBbVhFf9rF65jZecvl78IZMuvSRldQ+9A/fjfIo0= -go.elastic.co/apm/module/apmhttp/v2 v2.3.0 h1:yGZyp26uJXUCfRTwvMmDt1d1jJrHgTBBncZfpYAxR8s= -go.elastic.co/apm/module/apmhttp/v2 v2.3.0/go.mod h1:JCszLIey4ndJGuUUu5FQjNOiTfaln1dqCqXnRcXVxVc= -go.elastic.co/apm/module/apmsql/v2 v2.4.3 h1:wFIibO4FLDNm6B5bQt4YAgt1ZS0X2Rd27HYXXaqVPOo= -go.elastic.co/apm/module/apmsql/v2 v2.4.3/go.mod h1:y8TG3VQepEkAZxMZfyPbb9s3J4B7SP9fWiVnwxmIrJg= -go.elastic.co/apm/v2 v2.3.0/go.mod h1:HdwVuAeoJMmoqAZZBNN2YVzj3UVLebtqoRCCydyCP+Q= -go.elastic.co/apm/v2 v2.4.3 h1:k6mj63O7IIyqqn3S52C2vBXvaSK9M5FHp0aZHpPH/as= -go.elastic.co/apm/v2 v2.4.3/go.mod h1:+CiBUdrrAGnGCL9TNx7tQz3BrfYV23L8Ljvotoc87so= +go.elastic.co/apm/module/apmgorilla/v2 v2.6.2 h1:/myBx0D/JiwTUjFkVFG3zXmDfGPfQjP/cg27qcBbdfU= +go.elastic.co/apm/module/apmgorilla/v2 v2.6.2/go.mod h1:uONZzSIh/cKjQ2rZmINR1VXVOJDq5eWOzKrCY+bu00w= +go.elastic.co/apm/module/apmhttp/v2 v2.6.2 h1:+aYtP1Lnrsm+XtEs87RWG2PAyU6LHDDnYnJl3Lth0Qk= +go.elastic.co/apm/module/apmhttp/v2 v2.6.2/go.mod h1:vlH+vXHaEijKK4pk605LOK+lbLDKwcByhlq4J24PeXw= +go.elastic.co/apm/module/apmsql/v2 v2.6.2 h1:wKCfsGhU9L1w0xM5hVMnukzTb35eIFU3L68gg0v55wU= +go.elastic.co/apm/module/apmsql/v2 v2.6.2/go.mod h1:W2tSac0SXRQwtj4DS+IJTb2oLWffW6fDHQmiw3GKAvk= +go.elastic.co/apm/v2 v2.6.2 h1:VBplAxgbOgTv+Giw/FS91xJpHYw/q8fz/XKPvqC+7/o= +go.elastic.co/apm/v2 v2.6.2/go.mod h1:33rOXgtHwbgZcDgi6I/GtCSMZQqgxkHC0IQT3gudKvo= go.elastic.co/fastjson v1.1.0 h1:3MrGBWWVIxe/xvsbpghtkFoPciPhOCmjsR/HfwEeQR4= go.elastic.co/fastjson v1.1.0/go.mod h1:boNGISWMjQsUPy/t6yqt2/1Wx4YNPSe+mZjlyw9vKKI= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -1333,7 +1340,6 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= @@ -1387,9 +1393,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= @@ -1462,7 +1466,6 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1507,14 +1510,10 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211102192858-4dd72447c267/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1625,8 +1624,6 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= -golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= @@ -1839,7 +1836,6 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= -howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/handbook/company/communications.md b/handbook/company/communications.md index a77b113575..372bb6941e 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -62,7 +62,8 @@ We track competitors' capabilities and adjacent (or commonly integrated) product | Ads | _See [🫧 Demand team](https://fleetdm.com/handbook/demand#team)_ | Video | _See [🫧 Digital Marketing Manager](https://fleetdm.com/handbook/demand#team)_ | Social media | _See [🫧 Digital Marketing Manager](https://fleetdm.com/handbook/demand#team)_ -| Blog | _See [🚀 Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team)_ +| Guides | _See [🌦️ Customer Success & VP of Customer Success](https://fleetdm.com/handbook/customer-success#team)_ +| Release article | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ | Information technology (IT) | _See [🚀 Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team)_ | Payroll, bookkeeping, AR/AP | _See [💸 Finance Engineer](https://fleetdm.com/handbook/finance#team)_ | Legal contracts | _See [🌐 Digital Experience team](https://fleetdm.com/handbook/digital-experience#team)_ @@ -573,7 +574,7 @@ We use `` tags in Markdown articles to set metadata information about the - `announcements` - News and announcements about new features and changes to Fleet. Articles in this category are available at fleeetdm.com/announcements - `guides` - Non-reference documentation and how-to guides. Articles in this category are available at fleetdm.com/guides - `podcasts` - Episodes of Fleet's podcast. Articles in this category are available at fleetdm.com/podcasts - - `publishedOn`: A ISO 8601 formatted date (YYYY-MM-DD) of the articles publish date. If the article is a guide, this value should be updated whenever a change to the guide is made. + - `publishedOn`: An ISO 8601 formatted date (YYYY-MM-DD) of the articles publish date. If the article is a guide, this value should be updated whenever a change to the guide is made. - Optional meta tags: - `articleImageUrl`: A relative link to a cover image for the article. If provided, the image needs to live in the /website/assets/images/articles folder. The image will be added to the card for this article on it's category page, as well as a cover image on the article page. If this value is not provided, the card for the article will display the Fleet logo and the article will have no cover image. - `description`: A description of the article that will be visible in search results and social share previews. If provided, this value will override the generated meta description for this article. otherwise, the description will default to `[articleTitle] by [authorFullName]`. diff --git a/handbook/company/leadership.md b/handbook/company/leadership.md index 2e4e470021..893050307a 100644 --- a/handbook/company/leadership.md +++ b/handbook/company/leadership.md @@ -109,22 +109,44 @@ In this meeting, the department leader discusses actual week-over-week progress - Use this meeting to add, remove, or change the definitions or ownership of KPIs. Otherwise, KPI definitions do not change, even if those definitions have problems. For help with KPIs, contact the [Digital Experience department](https://fleetdm.com/handbook/digital-experience#contact-us). +## Adding an advisor -## Hiring +First: -At Fleet, we collaborate with [core team members](#creating-a-new-position), [consultants](#hiring-a-consultant), [advisors](#adding-an-advisor), and [outside contributors](https://github.com/fleetdm/fleet/graphs/contributors) from the community. +Advisor agreements are sent through [DocuSign](https://www.docusign.com/), using the "Advisor Agreement" template. +- Update the ["Advisors" sheet](https://docs.google.com/spreadsheets/d/15knBE2-PrQ1Ad-QcIk0mxCN-xFsATKK9hcifqrm0qFQ/edit#gid=1803674483) + >**_Note:_** *Be sure to mark any columns that haven't been completed yet as "TODO"* +- Update the "Equity plan" sheet (which should have been automatically updated after updating "Advisors" thanks to the global unique IDs next to each row which are used to connect the spreadsheets) to reflect the default number of shares for advisor equity grants. +- Send the advisor agreement [through Docusign](https://apps.docusign.com/send/templates?view=shared&folder=0482b0fd-a752-41be-93a0-185e2fb7ef54) using the CEO's account, pulling the advisor's email address from a recent calendar event on the CEO's calendar. +- Complete the first step of signing, which involves filling in the number of shares. +- Then wait for the advisor to sign. (Fleet's CEO will sign after that.) -> Are you a new fleetie joining the Digital Experience team? For Loom recordings demonstrating how to make offers, hire, onboard, and more please see [this classified Google Doc](https://docs.google.com/document/d/1fimxQguPOtK-2YLAVjWRNCYqs5TszAHJslhtT_23Ly0/edit). +Then, to finalize a new advisor after signing is complete: +- Schedule quarterly recurring 1h meeting between the CEO and the advisor, with 30m of recurring prep scheduled back to back ahead of the meeting. +- Update the status columns in the ["Advisors" sheet](https://docs.google.com/spreadsheets/d/15knBE2-PrQ1Ad-QcIk0mxCN-xFsATKK9hcifqrm0qFQ/edit#gid=1803674483) to show that the agreement has been signed, and ask the new advisor to add us on [LinkedIn](https://www.linkedin.com/company/71111416), [Crunchbase](https://www.crunchbase.com/organization/fleet-device-management), and [Angellist](https://angel.co/company/fleetdm). +- Update "Equity plan" status columns to reflect updated status for this advisor, and to ensure the advisor's equity is queued up for the next quarterly equity grant ritual. -### Consultants - - -#### Hiring a consultant +## Hiring a consultant In addition to [core team members](#hiring-a-new-team-member), from time to time Fleet hires consultants who may work for only a handful of hours on short projects. -A consultant is someone who we expect to either: +**Who ISN'T a consultant?**: If a consultant plans to work _more_ than 10 hours per week, or for _longer_ than 6 weeks, they should instead be hired as a [core team member](#hiring-a-new-team-member). + +Core team members: +- are hired for an existing [open position](#creating-a-new-position) +- are hired using Fleet's "Hiring" issue template, including receiving a company-issued laptop and Yubikeys +- must be onboarded (complete the entire, unabridged onboarding process in Fleet's "Onboarding" issue template) +- must be offboarded +- get an email address +- have a manager and a formal place in the company [org chart](https://fleetdm.com/handbook/company#org-chart) +- are listed in ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) (private Google Doc) +- are paid as part of the standard payroll ritual for the place they work and their employment classification. + +Consultants aren't required to do any of those things. + + +**Who IS a consultant?**: A consultant is someone who we expect to either: - complete their relationship with the company in less than 6 weeks - or have a longer-term relationship with the company, but never work more than 10 hours per week. @@ -141,24 +163,7 @@ Consultants [track time using the company's tools](#tracking-hours) and sign [Fl To hire a consultant, [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the Digital Experience team. -#### Who ISN'T a consultant? - -If a consultant plans to work _more_ than 10 hours per week, or for _longer_ than 6 weeks, they should instead be hired as a [core team member](#hiring-a-new-team-member). - -Core team members: -- are hired for an existing [open position](#creating-a-new-position) -- are hired using Fleet's "Hiring" issue template, including receiving a company-issued laptop and Yubikeys -- must be onboarded (complete the entire, unabridged onboarding process in Fleet's "Onboarding" issue template) -- must be offboarded -- get an email address -- have a manager and a formal place in the company [org chart](https://fleetdm.com/handbook/company#org-chart) -- are listed in ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) (private Google Doc) -- are paid as part of the standard payroll ritual for the place they work and their employment classification. - -Consultants aren't required to do any of those things. - - -#### Sending a consulting agreement +### Sending a consulting agreement To send a consulting agreement, you will need to [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the Digital Experience team. They will then peform the steps needed to bring aboard a new consultant. @@ -180,25 +185,36 @@ If the consultant is international, you will also provide: image -### Adding an advisor +## Hiring -First: +At Fleet, we collaborate with [core team members](#creating-a-new-position), [consultants](#hiring-a-consultant), [advisors](#adding-an-advisor), and [outside contributors](https://github.com/fleetdm/fleet/graphs/contributors) from the community. -Advisor agreements are sent through [DocuSign](https://www.docusign.com/), using the "Advisor Agreement" template. -- Update the ["Advisors" sheet](https://docs.google.com/spreadsheets/d/15knBE2-PrQ1Ad-QcIk0mxCN-xFsATKK9hcifqrm0qFQ/edit#gid=1803674483) - >**_Note:_** *Be sure to mark any columns that haven't been completed yet as "TODO"* -- Update the "Equity plan" sheet (which should have been automatically updated after updating "Advisors" thanks to the global unique IDs next to each row which are used to connect the spreadsheets) to reflect the default number of shares for advisor equity grants. -- Send the advisor agreement [through Docusign](https://apps.docusign.com/send/templates?view=shared&folder=0482b0fd-a752-41be-93a0-185e2fb7ef54) using the CEO's account, pulling the advisor's email address from a recent calendar event on the CEO's calendar. -- Complete the first step of signing, which involves filling in the number of shares. -- Then wait for the advisor to sign. (Fleet's CEO will sign after that.) - -Then, to finalize a new advisor after signing is complete: -- Schedule quarterly recurring 1h meeting between the CEO and the advisor, with 30m of recurring prep scheduled back to back ahead of the meeting. -- Update the status columns in the ["Advisors" sheet](https://docs.google.com/spreadsheets/d/15knBE2-PrQ1Ad-QcIk0mxCN-xFsATKK9hcifqrm0qFQ/edit#gid=1803674483) to show that the agreement has been signed, and ask the new advisor to add us on [LinkedIn](https://www.linkedin.com/company/71111416), [Crunchbase](https://www.crunchbase.com/organization/fleet-device-management), and [Angellist](https://angel.co/company/fleetdm). -- Update "Equity plan" status columns to reflect updated status for this advisor, and to ensure the advisor's equity is queued up for the next quarterly equity grant ritual. +> Are you a new fleetie joining the Digital Experience team? For Loom recordings demonstrating how to make offers, hire, onboard, and more please see [this classified Google Doc](https://docs.google.com/document/d/1fimxQguPOtK-2YLAVjWRNCYqs5TszAHJslhtT_23Ly0/edit). -### Creating a new position +### Hiring restrictions + +**Incompatible former employers**: Fleet maintains a list of companies with whom Fleet has do-not-solicit terms that prevents us from making offers to employees of these companies. The list is in the Do Not Solicit tab of the [Digital Experience spreadsheet](https://docs.google.com/spreadsheets/d/1lp3OugxfPfMjAgQWRi_rbyL_3opILq-duHmlng_pwyo/edit#gid=0). + +**Incompatible locations**: Fleet is unable to hire team members in some countries. See [this internal document](https://docs.google.com/document/d/1jHHJqShIyvlVwzx1C-FB9GC74Di_Rfdgmhpai1SPC0g/edit) for the list. + + +## Interviewing + +> TODO: Rewrite this section for the hiring manager as our audience. + +We're glad you're interested in joining the team! +Here are some of the things you can anticipate throughout this process: + - We will reply by email within one business day from the time when the application arrives. + - You may receive a rejection email (Bummer, consider applying again in the future). + - You may receive an invitation to "book with us." +If you've been invited to "book with us," you'll have a Zoom meeting with the hiring team to discuss the next steps. + +Department-specific interviewing instructions: +- [Engineering](https://fleetdm.com/handbook/engineering#interview-a-developer-candidate) + + +## Creating a new position Want to hire? Use these steps to hire a [fleetie, not a consultant](https://fleetdm.com/handbook/company/leadership#who-isnt-a-consultant). Here's how to open up a new position on the core team: @@ -250,7 +266,7 @@ A completed open position entry should look something like this: 4. **Get it approved and merged:** When you submit your proposed job description, the CEO will be automatically tagged for review and get a notification. He will consider where this role fits into Fleet's strategy and decide whether Fleet will open this position at this time. He will review the data carefully to try and catch any simple mistakes, then tentatively budget cash and equity compensation and document this compensation research. He will set a tentative start date (which also indicates this position is no longer just "proposed"; it's now part of the hiring plan.) Then the CEO will start a `#hiring-xxxxx-YYYY` Slack channel, at-mentioning the original proposer and letting them know their position is approved. (Unless it isn't.) -- _**Why bother with approvals?** We avoid cancelling or significantly changing a role after opening it. It hurts candidates too much. Instead, get the position approved first, before you start recruiting and interviewing. This gives you a sounding board and avoids misunderstandings._ +- _**Why bother with approvals?** We avoid canceling or significantly changing a role after opening it. It hurts candidates too much. Instead, get the position approved first, before you start recruiting and interviewing. This gives you a sounding board and avoids misunderstandings._ ### Approving a new position @@ -277,60 +293,36 @@ When review is requested on a proposal to open a new position, the Apprentice to > _**Note:** Most columns of the "Equity plan" are updated automatically when "Fleeties" is, based on the unique identifier of each row, like `🧑‍🚀890`. (Advisors have their own flavor of unique IDs, such as `🦉755`, which are defined in ["Advisors and investors"](https://docs.google.com/spreadsheets/d/15knBE2-PrQ1Ad-QcIk0mxCN-xFsATKK9hcifqrm0qFQ/edit).)_ -### Recruiting +## Recruiting Fleet accepts job applications, but the company does not list positions on general purpose job boards. This prevents us being overwhelmed with candidates so we can fulfill our goal of responding promptly to every applicant. This means that outbound recruiting, 3rd party recruiters, and references from team members are important aspect of the company's hiring strategy. Fleet's CEO is happy to assist with outreach, intros, and recruiting strategy for candidates. image -#### Receiving job applications +### Receiving job applications Every job description page ends with a "call to action", including a link to the hiring manager's LinkedIn to apply for the job directly with the hiring manager. Fleet replies to all candidates within **1 business day** and always provides either a **rejection** or **decisive next steps**; even if the next step is just a promise. Hiring managers are encouraged to use [email/message templates](https://fleetdm.com/handbook/company/leadership#candidate-correspondence-email-templates) for consistency and efficiency. -#### Candidate correspondence email templates +### Candidate correspondence email templates Fleet uses [certain email templates](https://docs.google.com/document/d/1VAMWIH8o7_vH7lV9nM1wQCM4Vc3GxBRBDPK-mKO9HNk/edit?usp=sharing) when responding to candidates. This helps us live our value of [🔴 empathy](https://fleetdm.com/handbook/company#empathy) and helps the company meet the aspiration of replying to all applications within one business day. -### Hiring restrictions - - -#### Incompatible former employers - -Fleet maintains a list of companies with whom Fleet has do-not-solicit terms that prevents us from making offers to employees of these companies. The list is in the Do Not Solicit tab of the [Digital Experience spreadsheet](https://docs.google.com/spreadsheets/d/1lp3OugxfPfMjAgQWRi_rbyL_3opILq-duHmlng_pwyo/edit#gid=0). - - -#### Incompatible locations - -Fleet is unable to hire team members in some countries. See [this internal document](https://docs.google.com/document/d/1jHHJqShIyvlVwzx1C-FB9GC74Di_Rfdgmhpai1SPC0g/edit) for the list. - - -### Interviewing - -> TODO: Rewrite this section for the hiring manager as our audience. - -We're glad you're interested in joining the team! -Here are some of the things you can anticipate throughout this process: - - We will reply by email within one business day from the time when the application arrives. - - You may receive a rejection email (Bummer, consider applying again in the future). - - You may receive an invitation to "book with us." -If you've been invited to "book with us," you'll have a Zoom meeting with the hiring team to discuss the next steps. - -Department specific interviewing instructions: -- [Engineering](https://fleetdm.com/handbook/engineering#interview-a-developer-candidate) - - -#### Hiring a new team member +## Hiring a new team member This section is about the hiring process a new core team member, or fleetie. > **_Note:_** _Employment classification isn't what makes someone a fleetie. Some Fleet team members are contractors and others are employees. The distinction between "contractor" and "employee" varies in different geographies, and the appropriate employment classification and agreement for any given team member and the place where they work is determined by Head of Digital Experience during the process of making an offer._ Here are the steps hiring managers follow to get an offer out to a candidate: -1. **Call references:** Before proceeding, make sure you have 2-5+ references. Ask the candidate for at least 2-5+ references and contact each reference in parallel using the instructions in [Fleet's reference check template](https://docs.google.com/document/d/1LMOUkLJlAohuFykdgxTPL0RjAQxWkypzEYP_AT-bUAw/edit?usp=sharing). Be respectful and keep these calls very short. -2. **Add to team database:** Update the [Fleeties](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) doc to accurately reflect the candidate's: + +1. **Create a copy of the "Why hire?"**: Create a copy of the ["Why hire?" template](https://docs.google.com/document/d/1Hoq7qeZT683JErYeqPYs0v2mIoQCw2lvWOepsZn9IDg/copy?) and complete all TODOs on the "🧑‍🚀 Candidate details" tab. + +2. **Call references:** Before proceeding, make sure you have 2-5+ references. Ask the candidate for at least 2-5+ references and contact each reference in parallel using the instructions in [Fleet's reference check template](https://docs.google.com/document/d/1LMOUkLJlAohuFykdgxTPL0RjAQxWkypzEYP_AT-bUAw/edit?usp=sharing). Be respectful and keep these calls very short. + +3. **Add to team database:** Update the [Fleeties](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) doc to accurately reflect the candidate's: - Start date > _**Tip:** No need to check with the candidate if you haven't already. Just guess. First Mondays tend to make good start dates. When hiring an international employee, Pilot.co recommends starting the hiring process a month before the new employee's start date._ @@ -340,10 +332,9 @@ Here are the steps hiring managers follow to get an offer out to a candidate: - LinkedIn URL _(If the fleetie does not have a LinkedIn account, enter `N/A`)_ - Location of candidate -3. **Compile feedback into a single doc:** In the "interview packet", include feedback from interviews, reference checks, and challenge submissions. Include any other notes you can think of offhand, and embed links to any supporting documents that were impactful in your final decision-making, such as portfolios or challenge submissions. - - Name the doc with a short, formulaic name that's easy to understand in an instant from just an email subject line (e.g. "_Why hire Jane Doe ("Train Conductor") - 2023-03-21_"). - - _Share_ this single document with the [CEO and Head of Digital Experience](https://fleetdm.com/handbook/digital-experience#team). -4. **Request a CEO interview:** Copy the template below, paste it in the hiring Slack channel for the position, and complete all "TODOs" before sending. +4. **Compile feedback**: Include feedback from interviews, and challenge submissions, links to any supporting documents that were impactful in your final decision-making, and any other notes you can think of offhand as tabs in the document. Share the "Why hire?" document with the [CEO and Head of Digital Experience](https://fleetdm.com/handbook/digital-experience#team). + +5. **Request a CEO interview:** Copy the template below, paste it in the hiring Slack channel for the position, and complete all "TODOs" before sending. ``` *CEO interview request:* @@ -365,13 +356,16 @@ Here are the steps hiring managers follow to get an offer out to a candidate: After receiving the interview packet, the Head of Digital Experience uses the following steps to make an offer: + 1. **Prepare the "exit scenarios" spreadsheet:** 🌐 Head of Digital Experience [copies the "Exit scenarios (template)"](https://docs.google.com/spreadsheets/d/1k2TzsFYR0QxlD-KGPxuhuvvlJMrCvLPo2z8s8oGChT0/copy) for the candidate, and renames the copy to e.g. "Exit scenarios for Jane Doe". - _Edit the candidate's copy of the exit scenarios spreadsheet_ to reflect the number of shares in ["🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0), and the spreadsheet will update automatically to reflect their approximate ownership percentage. > _**Note:** Don't play with numbers in the exit scenarios spreadsheet. The revision history is visible to the candidate, and they might misunderstand._ + 2. **Prepare offer:** 🌐 Head of Digital Experience [copies "Offer email (template)"](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/copy) and renames to e.g. "Offer email for Jane Doe". Edit the candidate's copy of the offer email template doc and fill in the missing information: - _Benefits:_ If candidate will work outside the US, [change the "Benefits" bullet](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/edit) to reflect what will be included through Fleet's international payroll provider, depending on the candidate's location. - _Equity:_ Highlight the number of shares with a link to the candidate's custom "exit scenarios" spreadsheet. - _Hand off:_ Share the offer email doc with the [Apprentice to the CEO](https://fleetdm.com/handbook/digital-experience#team). + 3. **Draft email:** 🦿 Apprentice to the CEO drafts the offer email in the CEO's inbox, reviews one more time, and then brings it to their next daily meeting for CEO's approval: - To: The candidate's personal email address _(use the email from the CEO interview calendar event)_ - Cc: Head of Digital Experience @@ -380,13 +374,15 @@ After receiving the interview packet, the Head of Digital Experience uses the fo - _Check all links in offer letter for accuracy (e.g. LinkedIn profile of hiring manager, etc.)_ - _Click the surrounding areas to ensure no "ghost links" are left from previous edits... which has happened before._ - _Re-read the offer email one last time, and especially double-check that the salary, number of shares, and start date match the numbers that are currently in the equity plan._ + 4. **Send offer:** 🐈‍⬛ CEO reviews and sends the offer to the candidate: - _Grant the candidate "edit" access_ to their "exit scenarios" spreadsheet. - _Send_ the email. + 5. **Process the offer response** The Head of Digital Experience will process the offer response by either creating a new ["Teammate pre-onboarding" issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=pre-onboarding.md&title=Pre-onboarding%3A+______________________) and following the steps if the offer is accepted or notifying the stakeholders that the offer was not accepted and we should continue the search. -#### After an offer is accepted +### After an offer is accepted The Head of Digital Experience will then follow the steps in the ["Teammate pre-onboarding"](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=pre-onboarding.md&title=Pre-onboarding%3A+______________________) issue, which includes reaching out to the new team member within 1 business day from a separate email thread to get additional information as needed, prepare their agreement, add them to the company's payroll system, and get their new laptop and hardware security keys ordered so that everything is ready for them to start on their first day. diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index 0c6f9a334c..06c85abd52 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -608,7 +608,7 @@ # ║║║╣ ╚╗╔╝║║ ║╣ ╠╦╝║╣ ║║║║╣ ║║║╠═╣ ║ ║║ ║║║║ # ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩╚═╚═╝╩ ╩╚═╝═╩╝╩╩ ╩ ╩ ╩╚═╝╝╚╝ - industryName: Device remediation - description: Use Fleet Policies to detect the device state. Automate remediations for issues or allow users to remediate problems in self-service. + description: Use Fleet Policies to detect the device state. Automate remediations for issues or allow users to remediate problems in self-service (Fleet Desktop > My device page). documentationUrl: https://fleetdm.com/securing/end-user-self-remediation # « NOTE: This link will change when auto-remediation is delivered. tier: Premium jamfProHasFeature: yes diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index a58b2bb508..7402ff3aaf 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -350,7 +350,7 @@ The relevant release page on GitHub is updated to indicate that the release cont When a critical bug is identified, we will then follow the patch release process in [our documentation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases). -> After a critical bug is fixed, [an incident postmortem](https://fleetdm.com/handbook/engineering#preform-an-incident-postmortem) is scheduled by the EM of the product group that fixed the bug. +> After a critical bug is fixed, [an incident postmortem](https://fleetdm.com/handbook/engineering#perform-an-incident-postmortem) is scheduled by the EM of the product group that fixed the bug. ## Feature fest @@ -373,7 +373,7 @@ To prioritize a new feature, it must meet one of these criteria: 1. Bug 2. Small UX improvement that isn't quite a bug but it's so small that it's worthwhile -3. Contributes to Fleet's [quarterly objectives (OKRs)](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=1846478041#gid=1846478041&range=A1) +3. Contributes to Fleet's [quarterly key results (KRs)](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=1846478041#gid=1846478041&range=A1) 4. High priority customer request (customer request, workflow blocking, etc.) 5. Prospect request in an order form diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index b67e6ecec1..a828ac0129 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -150,7 +150,7 @@ productCategories: [Vulnerability management, Endpoint operations] - quote: Fleet has such a huge amount of use cases. My goal was to get telemetry on endpoints, but then our IR team, our TBM team, and multiple other folks in security started heavily utilizing the system in ways I didn’t expect. It spread so naturally, even our corporate and infrastructure teams want to run it. - quoteAuthorName: Charles Zaffery + quoteAuthorName: charles zaffery # Note: this name is lowercased here so we can sort the two endpoint-ops-related Charles Zaffery quotes in different spots. (The name will be capitalized via CSS) quoteLinkUrl: https://www.linkedin.com/in/charleszaffery/ quoteAuthorJobTitle: Principle computer janitor quoteAuthorProfileImageFilename: testimonial-author-charles-zaffery-48x48@2x.png @@ -195,3 +195,11 @@ quoteAuthorProfileImageFilename: testimonial-author-scott-macvicar-100x100@2x.png quoteAuthorJobTitle: Head of Developer Infrastructure & Corporate Technology productCategories: [Device management, Endpoint operations] +- + quote: Fleet helped us determine that Norton antivirus (pre-installed on some machines) was interfering with Crowdstrike's ability to detect downloaded malware. + # quoteImageFilename: social-proof-logo-proofpoint-67x32@2x.png #TODO + quoteLinkUrl: https://www.linkedin.com/in/arsenio-figueroa-81a56198/ + quoteAuthorName: Arsenio Figueroa + quoteAuthorProfileImageFilename: testimonial-author-arsenio-figueroa-48x48@2x.png + quoteAuthorJobTitle: Senior Systems Security Engineer + productCategories: [Endpoint operations, Vulnerability management] diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index 6c5e89a942..c520a7dfa6 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -242,6 +242,11 @@ Why bother with all that? And why do it in this particular order? - **It helps us prevent future outages.** By finding outages sooner, we incentivize ourselves to fix the root cause sooner. And by fixing bugs sooner, we prevent them from stacking and bleeding into one another, and we prevent ourselves from implementing future fixes and improvements on top of shaky foundations. This makes contributions less risky and reduces the number of outages. +## Why fix small inconsistencies quickly? + +Fixing small inconsistencies quickly is worth it. When we tolerate and overlook little things, we send contributors mixed messages, like "[broken windows](https://en.wikipedia.org/wiki/Broken_windows_theory)". (Are these things actually important?) Since new contributors join the team all the time, we also prevent learning the right way to do things through osmosis, since pattern matching from a bad pattern creates even more inconsistencies. Plus these things add up over time, creating problems for both users and contributors. + + ## Why make it obvious when stuff breaks? At Fleet, we detect and fix bugs as quickly as possible. @@ -278,7 +283,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/ ## Why don't we sell like everyone else? Many companies encourage salespeople to ["spray and pray"](https://www.linkedin.com/posts/amstech_the-rampant-abuse-of-linkedin-connections-activity-7178412289413246978-Ci0I?utm_source=share&utm_medium=member_ios) email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach: -- **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://github.com/fleetdm/confidential/blob/main/cold-outbound-strategy.md). +- **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://docs.google.com/document/d/1IbucpsZZ0qbJQRPRtm9e2kMcSBDTXjixAMVOWyTu_pA/edit?tab=t.0). - **Be a helper.** We focus on [🔴being helpers](https://fleetdm.com/handbook/company#empathy). Always be depositing value. This is how we create a virtuous cycle. (That doesn't mean sharing a random article; it means genuinely hearing, doing whatever it takes to fully understand, and offering only advice or links that we would actually want.) We are genuinely curious and desperate to help, because creating real value for people is the way we win. - **Engineers first.** We always talk to engineers first, and learn how it's going. Security and IT engineers are the people closest to the work, and the people best positioned to know what their organizations need. - **Fewer words. Fewer pings.** People are busy. We don't waste their time. Avoid dumping work on prospect's plates at all costs. Light touches, no asks. Every notification from Fleet is a ping they have to deal with. We don't overload people with words and links. We [🟢keep things simple](https://fleetdm.com/handbook/company#results) and [write briefly](http://www.paulgraham.com/writing44.html). diff --git a/handbook/customer-success/README.md b/handbook/customer-success/README.md index 4f0646c1fe..b39c9e2d17 100644 --- a/handbook/customer-success/README.md +++ b/handbook/customer-success/README.md @@ -45,6 +45,19 @@ Before a routine customer call, the CSM prepares an agenda including the followi 6. Provide updates to open bug reports + ### Invite new customer DRI + +Sometimes there is a change in the champion within the customer's organization. +1. Get an introduction to the new DRIs including names, roles, contact information. +2. Make sure they're in the Slack channel. +3. Invite them to the *Success* meetings. +4. In the first meeting understand their proficiency level of osquery. + 1. Make sure the meeting time is still convenient for their team. + 2. Understand their needs and goals for visibility. + 3. Offer training to get them up to speed. + 4. Provide a white glove experience. + + ### Generate an expansion opportunity in Salesforce [Customer Success Managers (CSMs)](https://fleetdm.com/handbook/customer-success#team) are responsible for developing customer expansion opportunities that are not being worked on in conjunction with an Account Executive (AE). An AE may be assigned by the [Chief Revenue Officer (CRO)](https://fleetdm.com/handbook/sales#team) for large-scale expansion opportunities such as bringing on a new Fleet use case or bringing on a new group of hosts to an existing Fleet use case. CSMs manage expansion opportunities for things like host count increases for customer growth and price increases on renewals. Discuss examples of these scenarios with your manager to learn more. Moving forward, CSM's are responsible for keeping the stage, next steps, and date of next steps fields updated as the opportunity progresses through the sales cycle. Take the steps below when creating an expansion opportunity in Salesforce: diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index aefd4cd245..d76516ca43 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -207,6 +207,11 @@ To update the host count on a user's subscription: 7. Let the person who created the request know what actions were taken so they can communicate them to the customer. +### Change customer credit card number + +You can help a Premium license dispenser customers change their credit card by directing them to their [account dashboard](https://fleetdm.com/customers/dashboard). On that page, the customer can update their billing card by clicking the pencil icon next to their billing information. + + ### Cancel a Fleet Premium subscription Use the following steps to cancel a Fleet Premium subscription: diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 00de8fb9fc..e818057573 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -170,7 +170,7 @@ How to deploy a new release to dogfood: > Note that this action will not handle down migrations. Always deploy a newer version than is currently deployed. > -> Note that "fleetdm/fleet:main" is not a image name, instead use the commit hash in place of "main". +> Note that "fleetdm/fleet:main" is not an image name, instead use the commit hash in place of "main". ### Conclude current milestone @@ -205,7 +205,17 @@ Immediately after publishing a new release, we close out the associated GitHub i 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. +### Handle process exceptions for non-released code +Some of our code does not go through a scheduled release process, but is released immediately via GitHub workflows. +This includes: +- Our [fleetdm/nvd](https://github.com/fleetdm/nvd) repository +- Our [fleetdm/vulnerabilities](https://github.com/fleetdm/vulnerabilities) repository +- Our [website](https://github.com/fleetdm/fleet/tree/main/website) directory + +In these cases there are two differences in our process: +- QA is done before merging the code change to the main branch. +- Tickets are not moved to "Ready for release". Bug are closed, and user stories are moved to the product drafting board's "Confirm and celebrate" column. ### Register a domain for Fleet @@ -355,7 +365,7 @@ We use Cloudflare to manage the DNS records of fleetdm.com and our other domains 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). +- Performing 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). diff --git a/handbook/finance/README.md b/handbook/finance/README.md index fc59bbeb34..a92b7f8958 100644 --- a/handbook/finance/README.md +++ b/handbook/finance/README.md @@ -1,23 +1,28 @@ # Finance + This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. ## Team + | Role | Contributor(s) | |:------------------------------|:-----------------------------------------------------------------------------------------------------------| | Finance Engineer | [Isabell Reedy](https://www.linkedin.com/in/isabell-reedy-202aa3123/) _([@ireedy](https://github.com/ireedy))_ ## Contact us + - To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-finance&projects=&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 [#g-finance](https://fleetdm.slack.com/archives/C047N5L6EGH). - 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-finance-63f3dc3cc931f6247fcf55a9/board?sprints=none) for this department, including pending tasks and the status of new requests. ## Responsibilities + The Finance department is directly responsible for accounts receivable including invoicing, accounts payable including commision calculations, exspense reporting including Brex memos and maintaining accurate spend projections in "🧮The numbers", sales taxes, payroll taxes, corporate income/franchise taxes, and financial operations including bank accounts and cash flow management. ### Run payroll + Many of these processes are automated, but it's vital to check Gusto and Plane manually for accuracy. - Salaried fleeties are automated in Gusto and Plane. @@ -76,12 +81,14 @@ To complete payroll for an international contractor, use the following steps: ### Reconcile monthly recurring expenses + Recurring monthly or annual expenses, such as the tools we use throughout Fleet, are tracked as recurring, non-personnel expenses in ["🧮 The Numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) _(¶confidential Google Sheet)_, along with their payment source. Reconciliation of recurring expenses happens monthly. > Use this spreadsheet as the source of truth. Always make changes to it first before adding or removing a recurring expense. Only track significant expenses. (Other things besides amount can make a payment significant; like it being an individualized expense, for example.) ### Register Fleet as an employer with a new state + Fleet must register as an employer in any state where we hire new teammates. To do this, complete the following steps in Gusto: 1. After a new teammate completes their Gusto profile, the Finance department will be prompted to approve it for payroll. Sign in to your Gusto admin account and begin the approval process. 2. Select "yes" when prompted to file a new hire report and complete the approval process. @@ -93,6 +100,7 @@ Fleet must register as an employer in any state where we hire new teammates. To ### Process an email from a state agency + From time to time, you may get notices via email (or in the mail) from state agencies regarding Fleet's withholding and/or unemployment tax accounts. You can resolve some of these notices on your own by verifying and/or updating the settings in your Gusto account. If the notice is regarding an upcoming change to your deposit schedule or unemployment tax rate, make the required change in Gusto, such as: @@ -193,6 +201,7 @@ Use the following steps to update the [💸Finance department KPIs](https://docs ### Create an invoice + To create a new invoice for a Fleet customer, follow these steps: 1. Go to the [invoice folder in Google Drive](https://drive.google.com/drive/folders/11limC_KQYNYQPApPoXN0CplHo_5Qgi2b?usp=drive_link). 2. Create a copy of the invoice template, and title the copy `[invoice number] Fleet invoice - [customer name]`. @@ -226,7 +235,17 @@ Thanks, > Certain vendors require invoices submitted via a payment portal (such as Coupa). Once you've generated the invoice using the steps above, upload it to the relevant payment portal and email the billing contact to let them know you've submitted the invoice. +### Provide payment information to a customer + +For customers with large deployments, Fleet accepts payment via wire transfer or electronic debit (ACH/SWIFT). + +Payment information for customers within the United States is on Fleet's invoices. Typically, payment information does not need to be sent separately. + +For Fleet customers outside of the United States or instances where a customer is requesting payment information prior to invoicing, provide remittance information to customers by exporting ["💸 Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect. + + ### Communicate the status of customer financial actions + This reporting is performed to update the status of open or upcoming customer actions regarding the financial health of the opportunity. To complete the report: 1. Check [SVB](https://connect.svb.com/#/) and [Brex](https://accounts.brex.com/login) for any recently received payments from customers and record them in SFDC. 2. Go to this [report folder](https://fleetdm.lightning.force.com/lightning/r/Folder/00lUG000000DstpYAC/view?queryScope=userFolders) in SFDC. The three reports will provide the data used in the report. @@ -252,10 +271,17 @@ Thanks, ``` 5. If any accounts will become overdue within a week, reply in thread to the slack post, mention the opportunity owner of the account, and ask them to notify their contact that Fleet is still awaiting payment. -6. Review the [billing cycles](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000000yGjR2AU/view) report in SFDC for customers on multiyear deals. For any customers due for invoicing within the next week, create an issue on the Finance board. +6. If Finance is still awaiting a PO one week after the opportunity has been closed/won, reply to the thread in the Slack post, mention the CSM, and ask them to follow up with their customer contact. +7. Review the [billing cycles](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000000yGjR2AU/view) report in SFDC for customers on multiyear deals. For any customers due for invoicing within the next week, create an issue on the Finance board. + + +### Obtain a copy of Fleet's W-9 + +A recent signed copy of Fleet's W-9 form can be found in [this confidential PDF in Google Drive](https://drive.google.com/file/d/1ugXazEBk1oVm_LqGbYNsIFECcv5jXLA9/view?usp=drivesdk). ### Run US commission payroll + 1. Update individual teammates commission calculators (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with new revenue from any deals that are closed-won (have a subscription agreement signed by both parties) and have a **close date** within the previous month. - Verify closed-won deal numbers with CRO to ensure any agreed upon exceptions are captured (eg: CRO approves an AE to receive commission on a renewal deal due to cross-sell). 2. In the "Monthly commission payroll party" meeting, present the commission calculations for Fleeties receiving commission for approval. @@ -265,6 +291,7 @@ Thanks, 4. Once commission payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the commission as paid. ### Run international commission payroll + 1. Follow the steps in [run US commission payroll](https://fleetdm.com/handbook/finance#run-us-commission-payroll) to have the commission amounts approved by the CRO. 2. After the amounts are approved in the "Monthly commission payroll party", navigate to Help > Ask a question in Plane to request a commission payment for the teammate. 3. Send a message using the following template @@ -283,6 +310,7 @@ Thanks, ### Run quarterly or annual employee bonus payroll + 1. Update individual teammate bonus calculator (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with relevant metrics. - Bonus plans will have details specified on how to measure success, with most drawing from the [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) or from linked SFDC reports. If unsure where to pull achievement metrics from, contact teammate's manager to clarify. 2. In the "Monthly commission payroll party" meeting, present the bonus calculations for Fleeties receiving bonus for approval. @@ -293,6 +321,7 @@ Thanks, ### Process monthly accounting + Create a [new montly accounting issue](https://github.com/fleetdm/confidential/issues/new/choose) for the current month and year named "Closing out YYYY-MM" in GitHub and complete all of the tasks in the issue. (This uses the [monthly accounting issue template](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/5-monthly-accounting.md). - **SLA:** The monthly accounting issue should be completed and closed before the 7th of the month. @@ -301,6 +330,7 @@ Create a [new montly accounting issue](https://github.com/fleetdm/confidential/i ### Respond to low credit alert + Fleet admins will receive an email alert when the usage of company cards for the month is aproaching the company credit limit. To avoid the limit being exceeded, a Brex admin will follow these steps: 1. Sign in to Fleet's Brex account. 2. On the landing page, use the "Move money" button to "Add funds to your Brex business accounts". @@ -310,6 +340,7 @@ Fleet admins will receive an email alert when the usage of company cards for the No further action needs to be taken, the amount available for use will increase without disruption to regular processes. ### Check franchise tax status + No later than the second month of every quarter, we check [Delaware divison of corporations](https://icis.corp.delaware.gov) to ensure that Fleet has paid the quarterly franchise tax amounts to remain in good standing with the state of Delaware. - Go to the [DCIS - eCorp website](https://icis.corp.delaware.gov/ecorp/logintax.aspx?FilingType=FranchiseTax) and use the details in 1Password to look up Fleet's status. - If no outstanding amounts: the tax has been paid. @@ -317,6 +348,7 @@ No later than the second month of every quarter, we check [Delaware divison of c ### Check finances for quirks + Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up on quirks. 1. Check to make sure [bookkeeping quirks](https://docs.google.com/spreadsheets/d/1nuUPMZb1z_lrbaQEcgjnxppnYv_GWOTTo4FMqLOlsWg/edit?usp=sharing) are all accounted for and resolved or in progress toward resolution. 2. Check balance sheet and profit and loss statements (P&Ls) in QBO against the latest [monthly workbooks](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM) in Google Drive. Ensure reports are in the "accural" accounting method. @@ -325,6 +357,7 @@ Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up ### Report quarterly numbers in Chronograph + Follow these steps to perform quarterly reporting for Fleet's investors: 1. Login to Chronograph and upload our profit and loss statement (P&L), balance sheet and cash flow statements for CRV (all in one book saved in [Google Drive](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM). 2. Provide updated metrics for the following items using Fleet's [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). @@ -345,6 +378,7 @@ Follow these steps to perform quarterly reporting for Fleet's investors: ### Deliver annual report for venture line + Within 60 days of the end of the year, follow these steps: 1. Provide Silicon Valley Bank (SVB) with our balance sheet and profit and loss statement (P&L, sometimes called a cashflow statement) for the past twelve months. 2. Provide SVB with our board-approved annual operating budgets and projections (on a quarterly granularity) for the new year. @@ -352,6 +386,7 @@ Within 60 days of the end of the year, follow these steps: ### Process a new vendor invoice + Fleet pays its vendors in less than 15 business days in most cases. All invoices and tax documents should be submitted to the Finance department using the [appropriate Fleet email address (confidential Google Doc)](https://docs.google.com/document/d/1tE-NpNfw1icmU2MjYuBRib0VWBPVAdmq4NiCrpuI0F0/edit#heading=h.wqalwz1je6rq). - After making sure the invoice received from a new vendor is valid, add the new vendor to the recurring expenses section of ["The numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) before paying the invoice. - If we have not paid this vendor before, make sure we have received the required W-9 or W-8 form from the vendor. **Accounting cannot process a payment without these tax forms for compliance reasons.** @@ -360,6 +395,7 @@ Fleet pays its vendors in less than 15 business days in most cases. All invoices ### Process a request to cancel a vendor + - Make the cancellation notification in accordance with the contract terms between Fleet and the vendor, typically these notifications are made via email and may have a specific address that notice must be sent to. If the vendor has an autorenew contract with Fleet there will often be a window of time in which Fleet can cancel, if notification is made after this time period Fleet may be obligated to pay for the subsequent year even if we don't use the vendor during the next contract term. - Once cancelled, update the recurring expenses section of [The Numbers](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) to reflect the cancellation by changing the projected monthly burn in column G to $0 and adding "CANCELLED" in front of the vendor's name in column C. diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index c65fbc6ba1..8174ac82e6 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -28,7 +28,7 @@ The Product Design department is responsible for reviewing and collecting feedba The Head of Product Design and a former IT admin review the new customer/prospect/community requests in the "Inbox" column the [drafting board](https://github.com/fleetdm/fleet/issues#workspaces/drafting-6192dd66ea2562000faea25c/board) to synthesize why users are making the request (i.e. what problem are they trying to solve). -If a customer/prospect request is missing a Gong snippet or requires additional information to understand the "why", the Head of Product Design will @ mention the relevant Customer Success Manager (CSM) or Account Executive (AE), assign them, and move the request to the "Waiting" column. +If a customer/prospect request is missing a Gong snippet or requires additional information to understand the "why", the Head of Product Design will @ mention the relevant Customer Success Manager (CSM), assign them, and move the request to the [🏹 #g-customer-success](https://github.com/fleetdm/fleet/issues#workspaces/g-customer-success-642c83a53e96760014c978bd/board) board. ### Unpacking the how @@ -102,6 +102,8 @@ The EM is responsible for moving the user story to the "Specified" and "Estimate Before assigning an EM, double-check that the "Product" section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete (no TODOs). +If the story is tied to a customer feature request, the Head of Product Design (HPD) is responsible for adding the feature request issue to the [🏹 #g-customer-success board](https://github.com/fleetdm/fleet/issues#workspaces/g-customer-success-642c83a53e96760014c978bd/board). This way the Customer Success Manager (CSM) can review the wireframes and provide feedback on whether the proposed changes solve the customer's problem. If the changes don't, it's up to the HPD to decide whether to bring the user story back for more drafting or file a follow up user story (iteration). + Once a bug is approved in design review, The Product Designer is responsible for moving the bug to the appropriate release board. diff --git a/handbook/product-design/product-design.rituals.yml b/handbook/product-design/product-design.rituals.yml index e0a11b77de..af7ef179c1 100644 --- a/handbook/product-design/product-design.rituals.yml +++ b/handbook/product-design/product-design.rituals.yml @@ -23,7 +23,7 @@ task: "Sprint kickoff review" # 2024-03-06 TODO: Link to responsibility or corresponding "how to" info e.g. https://fleetdm.com/handbook/company/product-groups#making-changes startedOn: "2024-03-07" frequency: "Triweekly" - description: "Review stories that made it into this sprint and stories that didn't make it into this sprint. Ensure stories/bugs have been effectively prioritized across teams. After the call, the Head of Product Designer @ mentions the API design DRI in #help-design about stories that didn't make it into the sprint so that they can update the reference docs release branches." + description: "Review stories that made it into this sprint and stories that didn't make it into this sprint. Ensure stories/bugs have been effectively prioritized across teams. After the call, the Head of Product Design @ mentions the API design DRI in #help-design about stories that didn't make it into the sprint so that they can update the reference docs release branches." moreInfoUrl: dri: "noahtalerman" - diff --git a/handbook/sales/README.md b/handbook/sales/README.md index a1cde2cf67..ce0cd254c1 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -25,11 +25,25 @@ This handbook page details processes specific to working [with](#contact-us) and The Sales department is directly responsible for attaining the revenue goals of Fleet and helping to deliver upon our customers' objectives. +### Set up a Fleet trial + +You can set up a Fleet Managed Cloud environment for a prospect with >300 hosts, or you can help them generate a trial license key to configure on their own self-managed Fleet server. + +- **To set up a new Fleet Managed Cloud environment** for a user: First, [create a "New customer environment" issue](https://fleetdm.com/docs/configuration/fleet-server-configuration#license-key). Then, once the environment is set up, you'll get a notification and you can let the user know. +- **To set up only a trial license key** for a user's self-managed Fleet server: Point the user towards fleetdm.com/start, where they can sign up and choose to "Run your own trial with Docker". On that page, they'll see a license key located in the `fleectl preview` CLI instructions, and they can configure this by copying and pasting it as the [`FLEET_LICENSE_KEY`](https://fleetdm.com/docs/configuration/fleet-server-configuration#license-key) environment variable on the server(s) where Fleet is deployed. + +### Demo Fleet to a prospect + +To run a demo for a prospect, follow the relevant steps in ["Why Fleet?"](https://docs.google.com/document/d/1E0VU4AcB6UTVRd4JKD45Saxh9Gz-mkO3LnGSTBDLEZo/edit#heading=h.vfxwnwufxzzi) + +### Introduce Fleet's CEO + +To get the CEO's attention and introduce him to an account, follow the relevant steps in ["Why Fleet?"](https://docs.google.com/document/d/1E0VU4AcB6UTVRd4JKD45Saxh9Gz-mkO3LnGSTBDLEZo/edit#heading=h.vfxwnwufxzzi) + + ### Track an objection -We often hear objections to using Fleet that are important to track, understand, and solve for. To track an objection: -1. Navigate to the ["Understanding objections document" (Confidential Google Doc)](https://docs.google.com/document/d/1UFjHaIBdoSGDiqNqwgxRdwRz9Wn9SqP7h-g2OM8Runk/edit). -2. Copy the template at the top of the page and paste it at the top of the "Objections" section completing all TODOs. +To track an objection you heard from a prospect, follow the relevant steps in ["Why Fleet?"](https://docs.google.com/document/d/1E0VU4AcB6UTVRd4JKD45Saxh9Gz-mkO3LnGSTBDLEZo/edit#heading=h.vfxwnwufxzzi) ### Change a contact's organization in Salesforce @@ -38,10 +52,6 @@ Use the following steps to change a contact's organization in Salesforce: - If the contact's organization in Salesforce is incorrect but their new organization is unknown, navigate to the contact in Salesforce and change the "Account name" to "?" and save. - If the contact's organization in Salesforce is incorrect and we know where they're moving to, navigate to the contact in Salesforce, change the "Account name" to the contact's new organization, and save. -### Onboard a new sales team member - -Once the standard Fleetie onboarding issue is complete, create a new ["Sales team onboarding"](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-sales&projects=&template=sales-team-onboarding.md&title=Sales%20onboarding%3A_____________) issue and complete it. - ### Send a quote @@ -55,18 +65,15 @@ The Fleet owner of the opportunity (usually AE or CSM) will prepare a quote and/ - Before sending to prospect, work with the Finance team to verify if sales tax needs to be charged and, if so, how much. -### Obtain a copy of Fleet's W-9 +### Schedule a Solutions Consultant for prospect meeting -A recent signed copy of Fleet's W-9 form can be found in [this confidential PDF in Google Drive](https://drive.google.com/file/d/1ugXazEBk1oVm_LqGbYNsIFECcv5jXLA9/view?usp=drivesdk). +To schedule an [ad hoc meeting](https://www.vocabulary.com/dictionary/ad%20hoc) with a Fleet prospect, the Account Executive (AE) will [open an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-sales%2C%23solutions-consultant%2C%3Adiscovery%2C%3Ademo%2C%3Ascoping%2C%3Atech-eval&projects=&template=custom-request.md&title=prospect+name+-+prep+%28date%29+-+discovery%2Cdemo%2Cscoping+%28date%29). + - Use [this calendly link](https://calendly.com/fleetdm/talk-to-a-solutions-consultant) to obtain SC availability. + - The AE will populate this issue with the appropriate dates for an internal prep meeting as well as the dates for the external prospect meeting. + - Do not assign the issue. The Director of Solutions Consulting will assign the issue. + - Ensure that the product category is defined ("Endpoint ops", "Device management", or "Vulnerability management") in the description of the issue. - -### Provide payment information to a prospect - -For customers with large deployments, Fleet accepts payment via wire transfer or electronic debit (ACH/SWIFT). - -Payment information for customers within the United States is on Fleet's invoices. Typically, payment information does not need to be sent separately. - -For Fleet customers outside of the United States or instances where a customer is requesting payment information prior to invoicing, provide remittance information to customers by exporting ["💸 Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect. + ### Send an NDA to a customer @@ -248,11 +196,6 @@ Temp Transfer to: Temp technical DRI To close a deal with a new customer (non-self-service), create and complete a GitHub issue using the ["Sale" issue template](https://github.com/fleetdm/confidential/issues/new?assignees=alexmitchelliii&labels=%23g-sales&projects=&template=3-sale.md&title=New+customer%3A+_____________). -### Change customer credit card number - -You can help a Premium license dispenser customers change their credit card by directing them to their [account dashboard](https://fleetdm.com/customers/dashboard). On that page, the customer can update their billing card by clicking the pencil icon next to their billing information. - - ### Process a security questionnaire - The AE will [use the handbook](https://fleetdm.com/handbook/company/communications#vendor-questionnaires) to answer most of the questions with links to appropriate sections in the handbook. After this first pass has been completed, and if there are outstanding questions, the AE will [assign the issue to Digital Experience (#g-digital-experience)](https://fleetdm.com/handbook/digital-experience#contact-us) with a requested timeline for completion defined. diff --git a/infrastructure/dogfood/terraform/aws-tf-module/free.tf b/infrastructure/dogfood/terraform/aws-tf-module/free.tf index 079c29a5e7..943d57cda8 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/free.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/free.tf @@ -88,7 +88,7 @@ module "free" { prefix = local.customer_free enabled = true } - idle_timeout = 605 + idle_timeout = 905 } } diff --git a/infrastructure/dogfood/terraform/aws-tf-module/main.tf b/infrastructure/dogfood/terraform/aws-tf-module/main.tf index 1ccbd3449f..2e717e692b 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/main.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/main.tf @@ -214,7 +214,7 @@ module "main" { prefix = local.customer enabled = true } - idle_timeout = 605 + idle_timeout = 905 # extra_target_groups = [ # { # name = module.saml_auth_proxy.name diff --git a/infrastructure/dogfood/terraform/aws/ecs.tf b/infrastructure/dogfood/terraform/aws/ecs.tf index b506d813f1..f0ee8af37a 100644 --- a/infrastructure/dogfood/terraform/aws/ecs.tf +++ b/infrastructure/dogfood/terraform/aws/ecs.tf @@ -16,7 +16,7 @@ resource "aws_alb" "main" { internal = false #tfsec:ignore:aws-elb-alb-not-public security_groups = [aws_security_group.lb.id, aws_security_group.backend.id] subnets = module.vpc.public_subnets - idle_timeout = 605 + idle_timeout = 905 name = "fleetdm" drop_invalid_header_fields = true } diff --git a/infrastructure/dogfood/terraform/aws/percona/percona.tf b/infrastructure/dogfood/terraform/aws/percona/percona.tf index c9769b90c1..1577429e02 100644 --- a/infrastructure/dogfood/terraform/aws/percona/percona.tf +++ b/infrastructure/dogfood/terraform/aws/percona/percona.tf @@ -27,7 +27,7 @@ resource "aws_lb" "main" { internal = false #tfsec:ignore:aws-elb-alb-not-public security_groups = [aws_security_group.lb.id, aws_security_group.backend.id] subnets = var.public_subnets - idle_timeout = 605 + idle_timeout = 905 drop_invalid_header_fields = true } diff --git a/infrastructure/dogfood/terraform/aws/variables.tf b/infrastructure/dogfood/terraform/aws/variables.tf index f81ed35ca0..cd04b77c50 100644 --- a/infrastructure/dogfood/terraform/aws/variables.tf +++ b/infrastructure/dogfood/terraform/aws/variables.tf @@ -56,7 +56,7 @@ variable "database_name" { variable "fleet_image" { description = "the name of the container image to run" - default = "fleetdm/fleet:v4.59.0" + default = "fleetdm/fleet:v4.59.1" } variable "software_inventory" { diff --git a/infrastructure/dogfood/terraform/gcp/variables.tf b/infrastructure/dogfood/terraform/gcp/variables.tf index 6e690ee8a5..deb96bc38e 100644 --- a/infrastructure/dogfood/terraform/gcp/variables.tf +++ b/infrastructure/dogfood/terraform/gcp/variables.tf @@ -68,7 +68,7 @@ variable "redis_mem" { } variable "image" { - default = "fleetdm/fleet:v4.59.0" + default = "fleetdm/fleet:v4.59.1" } variable "software_installers_bucket_name" { diff --git a/infrastructure/guardduty/.terraform.lock.hcl b/infrastructure/guardduty/.terraform.lock.hcl index c58c510949..5b743eb544 100644 --- a/infrastructure/guardduty/.terraform.lock.hcl +++ b/infrastructure/guardduty/.terraform.lock.hcl @@ -2,8 +2,8 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "4.59.0" - constraints = ">= 3.0.0, >= 4.8.0, >= 4.9.0, ~> 4.59.0" + version = "4.59.1" + constraints = ">= 3.0.0, >= 4.8.0, >= 4.9.0, ~> 4.59.1" hashes = [ "h1:fuIdjl9f2JEH0TLoq5kc9NIPbJAAV7YBbZ8fvNp5XSg=", "zh:0341a460210463a0bebd5c12ce13dc49bd8cae2399b215418c5efa607fed84e4", diff --git a/infrastructure/guardduty/main.tf b/infrastructure/guardduty/main.tf index b201829295..fdeb7607e0 100644 --- a/infrastructure/guardduty/main.tf +++ b/infrastructure/guardduty/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.59.0" + version = "~> 4.59.1" } } backend "s3" { diff --git a/infrastructure/infrastructure/cloudtrail/main.tf b/infrastructure/infrastructure/cloudtrail/main.tf index cce55a6999..0eaff5aff2 100644 --- a/infrastructure/infrastructure/cloudtrail/main.tf +++ b/infrastructure/infrastructure/cloudtrail/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.59.0" + version = "~> 4.59.1" } } backend "s3" { diff --git a/infrastructure/infrastructure/elastic-agent/.terraform.lock.hcl b/infrastructure/infrastructure/elastic-agent/.terraform.lock.hcl index ec59d9ece6..c327efe675 100644 --- a/infrastructure/infrastructure/elastic-agent/.terraform.lock.hcl +++ b/infrastructure/infrastructure/elastic-agent/.terraform.lock.hcl @@ -2,8 +2,8 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "4.59.0" - constraints = ">= 3.63.0, ~> 4.59.0" + version = "4.59.1" + constraints = ">= 3.63.0, ~> 4.59.1" hashes = [ "h1:fuIdjl9f2JEH0TLoq5kc9NIPbJAAV7YBbZ8fvNp5XSg=", "zh:0341a460210463a0bebd5c12ce13dc49bd8cae2399b215418c5efa607fed84e4", diff --git a/infrastructure/infrastructure/elastic-agent/main.tf b/infrastructure/infrastructure/elastic-agent/main.tf index ae7ef8d890..78f310682b 100644 --- a/infrastructure/infrastructure/elastic-agent/main.tf +++ b/infrastructure/infrastructure/elastic-agent/main.tf @@ -20,7 +20,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.59.0" + version = "~> 4.59.1" } } backend "s3" { diff --git a/infrastructure/infrastructure/guardduty-alerts/.terraform.lock.hcl b/infrastructure/infrastructure/guardduty-alerts/.terraform.lock.hcl index c58c510949..5b743eb544 100644 --- a/infrastructure/infrastructure/guardduty-alerts/.terraform.lock.hcl +++ b/infrastructure/infrastructure/guardduty-alerts/.terraform.lock.hcl @@ -2,8 +2,8 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "4.59.0" - constraints = ">= 3.0.0, >= 4.8.0, >= 4.9.0, ~> 4.59.0" + version = "4.59.1" + constraints = ">= 3.0.0, >= 4.8.0, >= 4.9.0, ~> 4.59.1" hashes = [ "h1:fuIdjl9f2JEH0TLoq5kc9NIPbJAAV7YBbZ8fvNp5XSg=", "zh:0341a460210463a0bebd5c12ce13dc49bd8cae2399b215418c5efa607fed84e4", diff --git a/infrastructure/infrastructure/guardduty-alerts/main.tf b/infrastructure/infrastructure/guardduty-alerts/main.tf index 1e1ee8abe2..4d0e0f4a68 100644 --- a/infrastructure/infrastructure/guardduty-alerts/main.tf +++ b/infrastructure/infrastructure/guardduty-alerts/main.tf @@ -15,7 +15,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.59.0" + version = "~> 4.59.1" } } backend "s3" { diff --git a/infrastructure/infrastructure/spend_alerts/main.tf b/infrastructure/infrastructure/spend_alerts/main.tf index 1754cfdf8c..837d69399e 100644 --- a/infrastructure/infrastructure/spend_alerts/main.tf +++ b/infrastructure/infrastructure/spend_alerts/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.59.0" + version = "~> 4.59.1" } } backend "s3" { diff --git a/infrastructure/loadtesting/terraform/alb.tf b/infrastructure/loadtesting/terraform/alb.tf index 8f0f3213aa..cb9c85e6dc 100644 --- a/infrastructure/loadtesting/terraform/alb.tf +++ b/infrastructure/loadtesting/terraform/alb.tf @@ -3,7 +3,7 @@ resource "aws_lb" "internal" { internal = true security_groups = [data.terraform_remote_state.shared.outputs.alb_security_group.id] subnets = data.terraform_remote_state.shared.outputs.vpc.private_subnets - idle_timeout = 600 + idle_timeout = 905 drop_invalid_header_fields = true #checkov:skip=CKV_AWS_150:don't like it } diff --git a/infrastructure/loadtesting/terraform/shared/alb.tf b/infrastructure/loadtesting/terraform/shared/alb.tf index 58d3b3ec67..63bbe9e874 100644 --- a/infrastructure/loadtesting/terraform/shared/alb.tf +++ b/infrastructure/loadtesting/terraform/shared/alb.tf @@ -3,7 +3,7 @@ resource "aws_alb" "main" { internal = false #tfsec:ignore:aws-elb-alb-not-public security_groups = [aws_security_group.lb.id] subnets = module.vpc.public_subnets - idle_timeout = 600 + idle_timeout = 905 drop_invalid_header_fields = true #checkov:skip=CKV_AWS_150:don't like it } diff --git a/orbit/changes/22047-linux-key-escrow b/orbit/changes/22047-linux-key-escrow new file mode 100644 index 0000000000..d8a3daa001 --- /dev/null +++ b/orbit/changes/22047-linux-key-escrow @@ -0,0 +1 @@ +* added functionality to support linux disk encryption key escrow including end user prompts and LUKS key management \ No newline at end of file diff --git a/orbit/changes/22810-fleetd-enroll-activity b/orbit/changes/22810-fleetd-enroll-activity new file mode 100644 index 0000000000..2b99a1a860 --- /dev/null +++ b/orbit/changes/22810-fleetd-enroll-activity @@ -0,0 +1,2 @@ +Added computer_name and hardware_model for fleetd enrollment. +Added serial number for fleetd enrollment for Windows hosts (already present for macOS and Linux). diff --git a/orbit/changes/upgrade-macadmins-osquery-extension-to-1.2.3 b/orbit/changes/upgrade-macadmins-osquery-extension-to-1.2.3 new file mode 100644 index 0000000000..81025b3fbc --- /dev/null +++ b/orbit/changes/upgrade-macadmins-osquery-extension-to-1.2.3 @@ -0,0 +1 @@ +* Upgraded macadmins osquery-extension to v1.2.3. diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index da467570ba..1be45d7cfb 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -26,6 +26,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/installer" "github.com/fleetdm/fleet/v4/orbit/pkg/keystore" "github.com/fleetdm/fleet/v4/orbit/pkg/logging" + "github.com/fleetdm/fleet/v4/orbit/pkg/luks" "github.com/fleetdm/fleet/v4/orbit/pkg/osquery" "github.com/fleetdm/fleet/v4/orbit/pkg/osservice" "github.com/fleetdm/fleet/v4/orbit/pkg/platform" @@ -38,6 +39,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/update" "github.com/fleetdm/fleet/v4/orbit/pkg/update/filestore" "github.com/fleetdm/fleet/v4/orbit/pkg/user" + "github.com/fleetdm/fleet/v4/orbit/pkg/zenity" "github.com/fleetdm/fleet/v4/pkg/certificate" "github.com/fleetdm/fleet/v4/pkg/file" retrypkg "github.com/fleetdm/fleet/v4/pkg/retry" @@ -694,6 +696,8 @@ func main() { HardwareUUID: osqueryHostInfo.HardwareUUID, Hostname: osqueryHostInfo.Hostname, Platform: osqueryHostInfo.Platform, + ComputerName: osqueryHostInfo.ComputerName, + HardwareModel: osqueryHostInfo.HardwareModel, } if runtime.GOOS == "darwin" { @@ -737,13 +741,6 @@ func main() { orbitHostInfo.OsqueryIdentifier = osqueryHostInfo.InstanceID } - // The hardware serial was not sent when Windows MDM was implemented, - // thus we clear its value here to not break any existing enroll functionality - // on the server. - if runtime.GOOS == "windows" { - orbitHostInfo.HardwareSerial = "" - } - var ( options []osquery.Option // optionsAfterFlagfile is populated with options that will be set after the '--flagfile' argument @@ -940,6 +937,8 @@ func main() { case "windows": orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMEnrollmentFetcherMiddleware(windowsMDMEnrollmentCommandFrequency, orbitHostInfo.HardwareUUID, orbitClient)) orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMBitlockerFetcherMiddleware(windowsMDMBitlockerCommandFrequency, orbitClient)) + case "linux": + orbitClient.RegisterConfigReceiver(luks.New(orbitClient, zenity.New())) } flagUpdateReceiver := update.NewFlagReceiver(orbitClient.TriggerOrbitRestart, update.FlagUpdateOptions{ @@ -1697,6 +1696,10 @@ type osqueryHostInfo struct { HardwareSerial string `json:"hardware_serial"` // Hostname is the device's hostname (extracted from `system_info` osquery table). Hostname string `json:"hostname"` + // ComputerName is the friendly computer name (optional) (extracted from `system_info` osquery table). + ComputerName string `json:"computer_name"` + // HardwareModel is the device's hardware model (extracted from `system_info` osquery table). + HardwareModel string `json:"hardware_model"` // Platform is the device's platform as defined by osquery (extracted from `os_version` osquery table). Platform string `json:"platform"` // InstanceID is the osquery's randomly generated instance ID @@ -1714,7 +1717,18 @@ func getHostInfo(osqueryPath string, osqueryDBPath string) (*osqueryHostInfo, er if err := os.MkdirAll(filepath.Dir(osqueryDBPath), constant.DefaultDirMode); err != nil { return nil, err } - const systemQuery = "SELECT si.uuid, si.hardware_serial, si.hostname, os.platform, os.version as os_version, oi.instance_id, oi.version as osquery_version FROM system_info si, os_version os, osquery_info oi" + const systemQuery = ` + SELECT + si.uuid, + si.hardware_serial, + si.hostname, + si.computer_name, + si.hardware_model, + os.platform, + os.version as os_version, + oi.instance_id, + oi.version as osquery_version + FROM system_info si, os_version os, osquery_info oi` args := []string{ "-S", "--database_path", osqueryDBPath, diff --git a/orbit/pkg/dialog/dialog.go b/orbit/pkg/dialog/dialog.go new file mode 100644 index 0000000000..362c77b0b6 --- /dev/null +++ b/orbit/pkg/dialog/dialog.go @@ -0,0 +1,66 @@ +package dialog + +import ( + "context" + "errors" + "time" +) + +var ( + // ErrCanceled is returned when the dialog is canceled by the cancel button. + ErrCanceled = errors.New("dialog canceled") + // ErrTimeout is returned when the dialog is automatically closed due to a timeout. + ErrTimeout = errors.New("dialog timed out") + // ErrUnknown is returned when an unknown error occurs. + ErrUnknown = errors.New("unknown error") +) + +// Dialog represents a UI dialog that can be displayed to the end user +// on a host +type Dialog interface { + // ShowEntry displays a dialog that accepts end user input. It returns the entered + // text or errors ErrCanceled, ErrTimeout, or ErrUnknown. + ShowEntry(ctx context.Context, opts EntryOptions) ([]byte, error) + // ShowInfo displays a dialog that displays information. It returns an error if the dialog + // could not be displayed. + ShowInfo(ctx context.Context, opts InfoOptions) error + // Progress displays a dialog that shows progress. It waits until the + // context is cancelled. + ShowProgress(ctx context.Context, opts ProgressOptions) error +} + +// EntryOptions represents options for a dialog that accepts end user input. +type EntryOptions struct { + // Title sets the title of the dialog. + Title string + + // Text sets the text of the dialog. + Text string + + // HideText hides the text entered by the user. + HideText bool + + // TimeOut sets the time in seconds before the dialog is automatically closed. + TimeOut time.Duration +} + +// InfoOptions represents options for a dialog that displays information. +type InfoOptions struct { + // Title sets the title of the dialog. + Title string + + // Text sets the text of the dialog. + Text string + + // Timeout sets the time in seconds before the dialog is automatically closed. + TimeOut time.Duration +} + +// ProgressOptions represents options for a dialog that shows progress. +type ProgressOptions struct { + // Title sets the title of the dialog. + Title string + + // Text sets the text of the dialog. + Text string +} diff --git a/orbit/pkg/execuser/execuser.go b/orbit/pkg/execuser/execuser.go index 5dc188ea99..5d4aaa353f 100644 --- a/orbit/pkg/execuser/execuser.go +++ b/orbit/pkg/execuser/execuser.go @@ -2,6 +2,8 @@ // SYSTEM service on Windows) as the current login user. package execuser +import "context" + type eopts struct { env [][2]string args [][2]string @@ -19,10 +21,6 @@ func WithEnv(name, value string) Option { } // WithArg sets command line arguments for the application. -// -// TODO: for now CLI arguments are only used by the darwin -// implementation, just because it's the only platform that needs -// them. func WithArg(name, value string) Option { return func(a *eopts) { a.args = append(a.args, [2]string{name, value}) @@ -40,3 +38,27 @@ func Run(path string, opts ...Option) (lastLogs string, err error) { } return run(path, o) } + +// RunWithOutput runs an application as the current login user and returns its output. +// It assumes the caller is running with high privileges (root on UNIX). +// +// It blocks until the child process exits. +// Non ExitError errors return with a -1 exitCode. +func RunWithOutput(path string, opts ...Option) (output []byte, exitCode int, err error) { + var o eopts + for _, fn := range opts { + fn(&o) + } + return runWithOutput(path, o) +} + +// RunWithWait runs an application as the current login user and waits for it to finish +// or to be canceled by the context. Canceling the context will not return an error. +// It assumes the caller is running with high privileges (root on UNIX). +func RunWithWait(ctx context.Context, path string, opts ...Option) error { + var o eopts + for _, fn := range opts { + fn(&o) + } + return runWithWait(ctx, path, o) +} diff --git a/orbit/pkg/execuser/execuser_darwin.go b/orbit/pkg/execuser/execuser_darwin.go index 7902b2c761..ca92601ba9 100644 --- a/orbit/pkg/execuser/execuser_darwin.go +++ b/orbit/pkg/execuser/execuser_darwin.go @@ -1,6 +1,8 @@ package execuser import ( + "context" + "errors" "fmt" "io" "os" @@ -47,3 +49,11 @@ func run(path string, opts eopts) (lastLogs string, err error) { } return tw.String(), nil } + +func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) { + return nil, 0, errors.New("not implemented") +} + +func runWithWait(ctx context.Context, path string, opts eopts) error { + return errors.New("not implemented") +} diff --git a/orbit/pkg/execuser/execuser_linux.go b/orbit/pkg/execuser/execuser_linux.go index 3ed91d7a62..1e9614d01b 100644 --- a/orbit/pkg/execuser/execuser_linux.go +++ b/orbit/pkg/execuser/execuser_linux.go @@ -3,6 +3,7 @@ package execuser import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -18,9 +19,96 @@ import ( // run uses sudo to run the given path as login user. func run(path string, opts eopts) (lastLogs string, err error) { + args, err := getUserAndDisplayArgs(path, opts) + if err != nil { + return "", fmt.Errorf("get args: %w", err) + } + + args = append(args, + // Append the packaged libayatana-appindicator3 libraries path to LD_LIBRARY_PATH. + // + // Fleet Desktop doesn't use libayatana-appindicator3 since 1.18.3, but we need to + // keep this to support older versions of Fleet Desktop. + fmt.Sprintf("LD_LIBRARY_PATH=%s:%s", filepath.Dir(path), os.ExpandEnv("$LD_LIBRARY_PATH")), + path, + ) + + cmd := exec.Command("sudo", args...) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + log.Printf("cmd=%s", cmd.String()) + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("open path %q: %w", path, err) + } + return "", nil +} + +// run uses sudo to run the given path as login user and waits for the process to finish. +func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) { + args, err := getUserAndDisplayArgs(path, opts) + if err != nil { + return nil, -1, fmt.Errorf("get args: %w", err) + } + + args = append(args, path) + + if len(opts.args) > 0 { + for _, arg := range opts.args { + args = append(args, arg[0], arg[1]) + } + } + + cmd := exec.Command("sudo", args...) + log.Printf("cmd=%s", cmd.String()) + + output, err = cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + return output, exitCode, fmt.Errorf("%q exited with code %d: %w", path, exitCode, err) + } + return output, -1, fmt.Errorf("%q error: %w", path, err) + } + + return output, exitCode, nil +} + +func runWithWait(ctx context.Context, path string, opts eopts) error { + args, err := getUserAndDisplayArgs(path, opts) + if err != nil { + return fmt.Errorf("get args: %w", err) + } + + args = append(args, path) + + if len(opts.args) > 0 { + for _, arg := range opts.args { + args = append(args, arg[0], arg[1]) + } + } + + cmd := exec.CommandContext(ctx, "sudo", args...) + log.Printf("cmd=%s", cmd.String()) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("cmd start %q: %w", path, err) + } + + if err := cmd.Wait(); err != nil { + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + return fmt.Errorf("cmd wait %q: %w", path, err) + } + + return nil +} + +func getUserAndDisplayArgs(path string, opts eopts) ([]string, error) { user, err := getLoginUID() if err != nil { - return "", fmt.Errorf("get user: %w", err) + return nil, fmt.Errorf("get user: %w", err) } // TODO(lucas): Default to display :0 if user DISPLAY environment variable @@ -68,23 +156,9 @@ func run(path string, opts eopts) (lastLogs string, err error) { // This is required for Ubuntu 18, and not required for Ubuntu 21/22 // (because it's already part of the user). fmt.Sprintf("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%d/bus", user.id), - // Append the packaged libayatana-appindicator3 libraries path to LD_LIBRARY_PATH. - // - // Fleet Desktop doesn't use libayatana-appindicator3 since 1.18.3, but we need to - // keep this to support older versions of Fleet Desktop. - fmt.Sprintf("LD_LIBRARY_PATH=%s:%s", filepath.Dir(path), os.ExpandEnv("$LD_LIBRARY_PATH")), - path, ) - cmd := exec.Command("sudo", args...) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - log.Printf("cmd=%s", cmd.String()) - - if err := cmd.Start(); err != nil { - return "", fmt.Errorf("open path %q: %w", path, err) - } - return "", nil + return args, nil } type user struct { diff --git a/orbit/pkg/execuser/execuser_windows.go b/orbit/pkg/execuser/execuser_windows.go index 90e274b7a3..f3bd58038d 100644 --- a/orbit/pkg/execuser/execuser_windows.go +++ b/orbit/pkg/execuser/execuser_windows.go @@ -6,6 +6,7 @@ package execuser // To view what was modified/added, you can use the execuser_windows_diff.sh script. import ( + "context" "errors" "fmt" "os" @@ -117,6 +118,14 @@ func run(path string, opts eopts) (lastLogs string, err error) { return "", startProcessAsCurrentUser(path, "", "") } +func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) { + return nil, 0, errors.New("not implemented") +} + +func runWithWait(ctx context.Context, path string, opts eopts) error { + return errors.New("not implemented") +} + // getCurrentUserSessionId will attempt to resolve // the session ID of the user currently active on // the system. diff --git a/orbit/pkg/luks/luks.go b/orbit/pkg/luks/luks.go new file mode 100644 index 0000000000..6376b24eaa --- /dev/null +++ b/orbit/pkg/luks/luks.go @@ -0,0 +1,37 @@ +package luks + +import ( + "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" +) + +type KeyEscrower interface { + SendLinuxKeyEscrowResponse(LuksResponse) error +} + +type LuksRunner struct { + escrower KeyEscrower + notifier dialog.Dialog +} + +type LuksResponse struct { + // Passphrase is a newly created passphrase generated by fleetd for securing the LUKS volume. + // This passphrase will be securely escrowed to the server. + Passphrase string + + // KeySlot specifies the LUKS key slot where this new passphrase was created. + // It is currently not used, but may be useful in the future for passphrase rotation. + KeySlot *uint + + // Salt is the salt used to generate the LUKS key. + Salt string + + // Err is the error message that occurred during the escrow process. + Err string +} + +func New(escrower KeyEscrower, notifier dialog.Dialog) *LuksRunner { + return &LuksRunner{ + escrower: escrower, + notifier: notifier, + } +} diff --git a/orbit/pkg/luks/luks_linux.go b/orbit/pkg/luks/luks_linux.go new file mode 100644 index 0000000000..9c32307a50 --- /dev/null +++ b/orbit/pkg/luks/luks_linux.go @@ -0,0 +1,294 @@ +//go:build linux + +package luks + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "math/big" + "os/exec" + "regexp" + "time" + + "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" + "github.com/fleetdm/fleet/v4/orbit/pkg/lvm" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/rs/zerolog/log" + "github.com/siderolabs/go-blockdevice/v2/encryption" + luksdevice "github.com/siderolabs/go-blockdevice/v2/encryption/luks" +) + +const ( + entryDialogTitle = "Enter disk encryption passphrase" + entryDialogText = "Passphrase:" + retryEntryDialogText = "Passphrase incorrect. Please try again." + infoFailedTitle = "Encryption key escrow" + infoFailedText = "Failed to escrow key. Please try again later." + infoSuccessTitle = "Encryption key escrow" + infoSuccessText = "Key escrowed successfully." + maxKeySlots = 8 + userKeySlot = 0 // Key slot 0 is assumed to be the location of the user's passphrase +) + +var ErrKeySlotFull = regexp.MustCompile(`Key slot \d+ is full`) + +func (lr *LuksRunner) Run(oc *fleet.OrbitConfig) error { + ctx := context.Background() + + if !oc.Notifications.RunDiskEncryptionEscrow { + return nil + } + + devicePath, err := lvm.FindRootDisk() + if err != nil { + return fmt.Errorf("Failed to find LUKS Root Partition: %w", err) + } + + var response LuksResponse + key, keyslot, err := lr.getEscrowKey(ctx, devicePath) + if err != nil { + response.Err = err.Error() + } + + response.Passphrase = string(key) + response.KeySlot = keyslot + + if keyslot != nil { + salt, err := getSaltforKeySlot(ctx, devicePath, *keyslot) + if err != nil { + if err := removeKeySlot(ctx, devicePath, *keyslot); err != nil { + log.Error().Err(err).Msgf("failed to remove key slot %d", *keyslot) + } + return fmt.Errorf("Failed to get salt for key slot: %w", err) + } + response.Salt = salt + } + + if err := lr.escrower.SendLinuxKeyEscrowResponse(response); err != nil { + // If sending the response fails, remove the key slot + if keyslot != nil { + if err := removeKeySlot(ctx, devicePath, *keyslot); err != nil { + log.Error().Err(err).Msg("failed to remove key slot") + } + } + + // Show error in dialog + if err := lr.infoPrompt(ctx, infoFailedTitle, infoFailedText); err != nil { + log.Info().Err(err).Msg("failed to show failed escrow key dialog") + } + + return fmt.Errorf("escrower escrowKey err: %w", err) + } + + if response.Err != "" { + if err := lr.infoPrompt(ctx, infoFailedTitle, response.Err); err != nil { + log.Info().Err(err).Msg("failed to show response error dialog") + } + return fmt.Errorf("error getting linux escrow key: %s", response.Err) + } + + // Show success dialog + if err := lr.infoPrompt(ctx, infoSuccessTitle, infoSuccessText); err != nil { + log.Info().Err(err).Msg("failed to show success escrow key dialog") + } + + return nil +} + +func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]byte, *uint, error) { + // AESXTSPlain64Cipher is the default cipher used by ubuntu/kubuntu/fedora + device := luksdevice.New(luksdevice.AESXTSPlain64Cipher) + + // Prompt user for existing LUKS passphrase + passphrase, err := lr.entryPrompt(ctx, entryDialogTitle, entryDialogText) + if err != nil { + return nil, nil, fmt.Errorf("Failed to show passphrase entry prompt: %w", err) + } + + // Validate the passphrase + for { + valid, err := lr.passphraseIsValid(ctx, device, devicePath, passphrase, userKeySlot) + if err != nil { + return nil, nil, fmt.Errorf("Failed validating passphrase: %w", err) + } + + if valid { + break + } + + passphrase, err = lr.entryPrompt(ctx, entryDialogTitle, retryEntryDialogText) + if err != nil { + return nil, nil, fmt.Errorf("Failed re-prompting for passphrase: %w", err) + } + } + + if len(passphrase) == 0 { + log.Debug().Msg("Passphrase is empty, no password supplied, dialog was canceled, or timed out") + return nil, nil, nil + } + + escrowPassphrase, err := generateRandomPassphrase() + if err != nil { + return nil, nil, fmt.Errorf("Failed to generate random passphrase: %w", err) + } + + // Create a new key slot and error if all key slots are full + // Start at slot 1 as keySlot 0 is assumed to be the location of + // the user's passphrase + var keySlot uint = userKeySlot + 1 + for { + if keySlot == maxKeySlots { + return nil, nil, errors.New("all LUKS key slots are full") + } + + userKey := encryption.NewKey(userKeySlot, passphrase) + escrowKey := encryption.NewKey(int(keySlot), escrowPassphrase) // #nosec G115 + + if err := device.AddKey(ctx, devicePath, userKey, escrowKey); err != nil { + if ErrKeySlotFull.MatchString(err.Error()) { + keySlot++ + continue + } + return nil, nil, fmt.Errorf("Failed to add key: %w", err) + } + + break + } + + valid, err := lr.passphraseIsValid(ctx, device, devicePath, escrowPassphrase, keySlot) + if err != nil { + return nil, nil, fmt.Errorf("Error while validating escrow passphrase: %w", err) + } + + if !valid { + return nil, nil, errors.New("Failed to validate escrow passphrase") + } + + return escrowPassphrase, &keySlot, nil +} + +func (lr *LuksRunner) passphraseIsValid(ctx context.Context, device *luksdevice.LUKS, devicePath string, passphrase []byte, keyslot uint) (bool, error) { + if len(passphrase) == 0 { + return false, nil + } + + valid, err := device.CheckKey(ctx, devicePath, encryption.NewKey(int(keyslot), passphrase)) // #nosec G115 + if err != nil { + return false, fmt.Errorf("Error validating passphrase: %w", err) + } + + return valid, nil +} + +// generateRandomPassphrase generates a random passphrase with 32 characters +// in the format XXXX-XXXX-XXXX-XXXX where X is a random character from the +// set [0-9A-Za-z]. +func generateRandomPassphrase() ([]byte, error) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + const length = 35 // 32 characters + 3 dashes + passphrase := make([]byte, length) + + for i := 0; i < length; i++ { + // Insert dashes at positions 8, 17, and 26 + if i == 8 || i == 17 || i == 26 { + passphrase[i] = '-' + continue + } + + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + if err != nil { + return nil, err + } + passphrase[i] = chars[num.Int64()] + } + + return passphrase, nil +} + +func (lr *LuksRunner) entryPrompt(ctx context.Context, title, text string) ([]byte, error) { + passphrase, err := lr.notifier.ShowEntry(ctx, dialog.EntryOptions{ + Title: title, + Text: text, + HideText: true, + TimeOut: 1 * time.Minute, + }) + if err != nil { + switch err { + case dialog.ErrCanceled: + log.Debug().Msg("end user canceled key escrow dialog") + return nil, nil + case dialog.ErrTimeout: + log.Debug().Msg("key escrow dialog timed out") + return nil, nil + case dialog.ErrUnknown: + return nil, err + default: + return nil, err + } + } + + return passphrase, nil +} + +func (lr *LuksRunner) infoPrompt(ctx context.Context, title, text string) error { + err := lr.notifier.ShowInfo(ctx, dialog.InfoOptions{ + Title: title, + Text: text, + TimeOut: 30 * time.Second, + }) + if err != nil { + switch err { + case dialog.ErrTimeout: + log.Debug().Msg("successPrompt timed out") + return nil + default: + return err + } + } + + return nil +} + +type LuksDump struct { + Keyslots map[string]Keyslot `json:"keyslots"` +} + +type Keyslot struct { + KDF KDF `json:"kdf"` +} + +type KDF struct { + Salt string `json:"salt"` +} + +func getSaltforKeySlot(ctx context.Context, devicePath string, keySlot uint) (string, error) { + cmd := exec.CommandContext(ctx, "cryptsetup", "luksDump", "--dump-json-metadata", devicePath) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("Failed to run cryptsetup luksDump: %w", err) + } + + var dump LuksDump + if err := json.Unmarshal(output, &dump); err != nil { + return "", fmt.Errorf("Failed to unmarshal luksDump output: %w", err) + } + + slot, ok := dump.Keyslots[fmt.Sprintf("%d", keySlot)] + if !ok { + return "", errors.New("key slot not found") + } + + return slot.KDF.Salt, nil +} + +func removeKeySlot(ctx context.Context, devicePath string, keySlot uint) error { + cmd := exec.CommandContext(ctx, "cryptsetup", "luksKillSlot", devicePath, fmt.Sprintf("%d", keySlot)) // #nosec G204 + if err := cmd.Run(); err != nil { + return fmt.Errorf("Failed to run cryptsetup luksKillSlot: %w", err) + } + + return nil +} diff --git a/orbit/pkg/luks/luks_stub.go b/orbit/pkg/luks/luks_stub.go new file mode 100644 index 0000000000..4358df26c7 --- /dev/null +++ b/orbit/pkg/luks/luks_stub.go @@ -0,0 +1,13 @@ +//go:build !linux +// +build !linux + +package luks + +import ( + "github.com/fleetdm/fleet/v4/server/fleet" +) + +// Run is a placeholder method for non-Linux builds. +func (lr *LuksRunner) Run(oc *fleet.OrbitConfig) error { + return nil +} diff --git a/orbit/pkg/lvm/lvm.go b/orbit/pkg/lvm/lvm.go new file mode 100644 index 0000000000..c662d80d78 --- /dev/null +++ b/orbit/pkg/lvm/lvm.go @@ -0,0 +1,104 @@ +package lvm + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os/exec" +) + +type BlockDevice struct { + Name string `json:"name"` + Type string `json:"type"` + Mountpoints []string `json:"mountpoints"` + Children []BlockDevice `json:"children,omitempty"` +} + +// FindRootDisk finds the physical partition that +// contains the root filesystem mounted at "/" on a +// LVM Linux volume. +func FindRootDisk() (string, error) { + cmd := exec.Command("lsblk", "--json") + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to run lsblk: %w", err) + } + + return rootDiskFromJson(out) +} + +func rootDiskFromJson(input bytes.Buffer) (string, error) { + var data struct { + Blockdevices []BlockDevice `json:"blockdevices"` + } + + if err := json.Unmarshal(input.Bytes(), &data); err != nil { + return "", fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + // Find the root partition mounted at "/" + rootPartition := findRootPartition(data.Blockdevices) + if rootPartition == nil { + return "", errors.New("root partition not found") + } + + // Trace up to the nearest parent partition of type "part" (partition) + physicalPartition := findParentPartitionOfTypePart(data.Blockdevices, rootPartition) + if physicalPartition == nil { + return "", errors.New("physical partition of type 'part' not found") + } + + return fmt.Sprintf("/dev/%s", physicalPartition.Name), nil +} + +// findRootPartition recursively searches for the partition +// mounted at "/" within the device tree. +func findRootPartition(devices []BlockDevice) *BlockDevice { + for _, device := range devices { + if result := searchForRoot(device); result != nil { + return result + } + } + return nil +} + +// searchForRoot recursively checks each device and its children +// to find the one mounted at "/". +func searchForRoot(device BlockDevice) *BlockDevice { + for _, mountpoint := range device.Mountpoints { + if mountpoint == "/" { + return &device + } + } + for _, child := range device.Children { + if result := searchForRoot(child); result != nil { + return result + } + } + return nil +} + +// findParentPartitionOfTypePart traverses upwards from the given device to find the nearest "part" type parent. +func findParentPartitionOfTypePart(devices []BlockDevice, target *BlockDevice) *BlockDevice { + var queue []BlockDevice + queue = append(queue, devices...) + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + for _, child := range current.Children { + if child.Name == target.Name { + if current.Type == "part" { + return ¤t + } + return findParentPartitionOfTypePart(devices, ¤t) + } + queue = append(queue, child) + } + } + return nil +} diff --git a/orbit/pkg/lvm/lvm_test.go b/orbit/pkg/lvm/lvm_test.go new file mode 100644 index 0000000000..73058caf09 --- /dev/null +++ b/orbit/pkg/lvm/lvm_test.go @@ -0,0 +1,344 @@ +package lvm + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +// sample from real LUKS encrypted Ubuntu disk +var testJsonUbuntu = `{ + "blockdevices": [ + { + "name": "loop0", + "maj:min": "7:0", + "rm": false, + "size": "4K", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/bare/5" + ] + },{ + "name": "loop1", + "maj:min": "7:1", + "rm": false, + "size": "74.3M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/core22/1564" + ] + },{ + "name": "loop2", + "maj:min": "7:2", + "rm": false, + "size": "73.9M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/core22/1663" + ] + },{ + "name": "loop3", + "maj:min": "7:3", + "rm": false, + "size": "269.8M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/firefox/4793" + ] + },{ + "name": "loop4", + "maj:min": "7:4", + "rm": false, + "size": "10.7M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/firmware-updater/127" + ] + },{ + "name": "loop5", + "maj:min": "7:5", + "rm": false, + "size": "11.1M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/firmware-updater/147" + ] + },{ + "name": "loop6", + "maj:min": "7:6", + "rm": false, + "size": "505.1M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/gnome-42-2204/176" + ] + },{ + "name": "loop7", + "maj:min": "7:7", + "rm": false, + "size": "91.7M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/gtk-common-themes/1535" + ] + },{ + "name": "loop8", + "maj:min": "7:8", + "rm": false, + "size": "10.7M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/snap-store/1218" + ] + },{ + "name": "loop9", + "maj:min": "7:9", + "rm": false, + "size": "10.5M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/snap-store/1173" + ] + },{ + "name": "loop10", + "maj:min": "7:10", + "rm": false, + "size": "38.8M", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/snapd/21759" + ] + },{ + "name": "loop11", + "maj:min": "7:11", + "rm": false, + "size": "500K", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/snapd-desktop-integration/178" + ] + },{ + "name": "loop12", + "maj:min": "7:12", + "rm": false, + "size": "568K", + "ro": true, + "type": "loop", + "mountpoints": [ + "/snap/snapd-desktop-integration/253" + ] + },{ + "name": "nvme0n1", + "maj:min": "259:0", + "rm": false, + "size": "476.9G", + "ro": false, + "type": "disk", + "mountpoints": [ + null + ], + "children": [ + { + "name": "nvme0n1p1", + "maj:min": "259:1", + "rm": false, + "size": "1G", + "ro": false, + "type": "part", + "mountpoints": [ + "/boot/efi" + ] + },{ + "name": "nvme0n1p2", + "maj:min": "259:2", + "rm": false, + "size": "2G", + "ro": false, + "type": "part", + "mountpoints": [ + "/boot" + ] + },{ + "name": "nvme0n1p3", + "maj:min": "259:3", + "rm": false, + "size": "473.9G", + "ro": false, + "type": "part", + "mountpoints": [ + null + ], + "children": [ + { + "name": "dm_crypt-0", + "maj:min": "252:0", + "rm": false, + "size": "473.9G", + "ro": false, + "type": "crypt", + "mountpoints": [ + null + ], + "children": [ + { + "name": "ubuntu--vg-ubuntu--lv", + "maj:min": "252:1", + "rm": false, + "size": "473.9G", + "ro": false, + "type": "lvm", + "mountpoints": [ + "/" + ] + } + ] + } + ] + } + ] + } + ] +}` + +var testJsonFedora = `{ + "blockdevices": [ + { + "name": "sr0", + "maj:min": "11:0", + "rm": true, + "size": "2.1G", + "ro": false, + "type": "rom", + "mountpoints": [ + "/run/media/luk/Fedora-WS-Live-40-1-14" + ] + },{ + "name": "zram0", + "maj:min": "252:0", + "rm": false, + "size": "1.9G", + "ro": false, + "type": "disk", + "mountpoints": [ + "[SWAP]" + ] + },{ + "name": "nvme0n1", + "maj:min": "259:0", + "rm": false, + "size": "20G", + "ro": false, + "type": "disk", + "mountpoints": [ + null + ], + "children": [ + { + "name": "nvme0n1p1", + "maj:min": "259:1", + "rm": false, + "size": "600M", + "ro": false, + "type": "part", + "mountpoints": [ + "/boot/efi" + ] + },{ + "name": "nvme0n1p2", + "maj:min": "259:2", + "rm": false, + "size": "1G", + "ro": false, + "type": "part", + "mountpoints": [ + "/boot" + ] + },{ + "name": "nvme0n1p3", + "maj:min": "259:3", + "rm": false, + "size": "18.4G", + "ro": false, + "type": "part", + "mountpoints": [ + null + ], + "children": [ + { + "name": "luks-21fc9b67-752e-42fb-83bb-8c92864382e9", + "maj:min": "253:0", + "rm": false, + "size": "18.4G", + "ro": false, + "type": "crypt", + "mountpoints": [ + "/home", "/" + ] + } + ] + } + ] + } + ] +}` + +func TestFindRootDisk(t *testing.T) { + var input bytes.Buffer + _, err := input.WriteString(testJsonUbuntu) + assert.NoError(t, err) + + output, err := rootDiskFromJson(input) + assert.NoError(t, err) + assert.Equal(t, "/dev/nvme0n1p3", output) + + input = bytes.Buffer{} + _, err = input.WriteString(testJsonFedora) + assert.NoError(t, err) + + output, err = rootDiskFromJson(input) + assert.NoError(t, err) + assert.Equal(t, "/dev/nvme0n1p3", output) +} + +func TestErrorNoMountPoint(t *testing.T) { + var input bytes.Buffer + _, err := input.WriteString(`{"blockdevices": [{"name": "nvme0n1", "mountpoints": [null]}]}`) + assert.NoError(t, err) + + output, err := rootDiskFromJson(input) + assert.Error(t, err) + assert.Empty(t, output) +} + +func TestErrorNoRootPartition(t *testing.T) { + var input bytes.Buffer + _, err := input.WriteString(`{"blockdevices": [{"name": "nvme0n1", "mountpoints": ["/boot"]}]}`) + assert.NoError(t, err) + + output, err := rootDiskFromJson(input) + assert.Error(t, err) + assert.Empty(t, output) +} + +func TestErrorInvalidJson(t *testing.T) { + var input bytes.Buffer + _, err := input.WriteString(`{`) + assert.NoError(t, err) + + output, err := rootDiskFromJson(input) + assert.Error(t, err) + assert.Empty(t, output) +} diff --git a/orbit/pkg/packaging/linux_shared.go b/orbit/pkg/packaging/linux_shared.go index 58bc2f2d64..0aaab13b16 100644 --- a/orbit/pkg/packaging/linux_shared.go +++ b/orbit/pkg/packaging/linux_shared.go @@ -250,6 +250,12 @@ func buildNFPM(opt Options, pkger nfpm.Packager) (string, error) { return "", fmt.Errorf("removing existing file: %w", err) } + if opt.NativeTooling { + if err := secure.MkdirAll(filepath.Dir(filename), 0o700); err != nil { + return "", fmt.Errorf("cannot create build dir: %w", err) + } + } + out, err := secure.OpenFile(filename, os.O_CREATE|os.O_RDWR, constant.DefaultFileMode) if err != nil { return "", fmt.Errorf("open output file: %w", err) diff --git a/orbit/pkg/zenity/zenity.go b/orbit/pkg/zenity/zenity.go new file mode 100644 index 0000000000..bd51d214f8 --- /dev/null +++ b/orbit/pkg/zenity/zenity.go @@ -0,0 +1,140 @@ +package zenity + +import ( + "bytes" + "context" + "fmt" + + "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" + "github.com/fleetdm/fleet/v4/orbit/pkg/execuser" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" +) + +type Zenity struct { + // cmdWithOutput can be set in tests to mock execution of the dialog. + cmdWithOutput func(ctx context.Context, args ...string) ([]byte, int, error) + // cmdWithWait can be set in tests to mock execution of the dialog. + cmdWithWait func(ctx context.Context, args ...string) error +} + +// New creates a new Zenity dialog instance for zenity v4 on Linux. +// Zenity implements the Dialog interface. +func New() *Zenity { + return &Zenity{ + cmdWithOutput: execCmdWithOutput, + cmdWithWait: execCmdWithWait, + } +} + +// ShowEntry displays an dialog that accepts end user input. It returns the entered +// text or errors ErrCanceled, ErrTimeout, or ErrUnknown. +func (z *Zenity) ShowEntry(ctx context.Context, opts dialog.EntryOptions) ([]byte, error) { + args := []string{"--entry"} + if opts.Title != "" { + args = append(args, fmt.Sprintf("--title=%s", opts.Title)) + } + if opts.Text != "" { + args = append(args, fmt.Sprintf("--text=%s", opts.Text)) + } + if opts.HideText { + args = append(args, "--hide-text") + } + if opts.TimeOut > 0 { + args = append(args, fmt.Sprintf("--timeout=%d", int(opts.TimeOut.Seconds()))) + } + + output, statusCode, err := z.cmdWithOutput(ctx, args...) + if err != nil { + switch statusCode { + case 1: + return nil, ctxerr.Wrap(ctx, dialog.ErrCanceled) + case 5: + return nil, ctxerr.Wrap(ctx, dialog.ErrTimeout) + default: + return nil, ctxerr.Wrap(ctx, dialog.ErrUnknown, err.Error()) + } + } + + return output, nil +} + +// ShowInfo displays an information dialog. It returns errors ErrTimeout or ErrUnknown. +func (z *Zenity) ShowInfo(ctx context.Context, opts dialog.InfoOptions) error { + args := []string{"--info"} + if opts.Title != "" { + args = append(args, fmt.Sprintf("--title=%s", opts.Title)) + } + if opts.Text != "" { + args = append(args, fmt.Sprintf("--text=%s", opts.Text)) + } + if opts.TimeOut > 0 { + args = append(args, fmt.Sprintf("--timeout=%d", int(opts.TimeOut.Seconds()))) + } + + _, statusCode, err := z.cmdWithOutput(ctx, args...) + if err != nil { + switch statusCode { + case 5: + return ctxerr.Wrap(ctx, dialog.ErrTimeout) + default: + return ctxerr.Wrap(ctx, dialog.ErrUnknown, err.Error()) + } + } + + return nil +} + +// ShowProgress starts a Zenity progress dialog with the given options. +// This function is designed to block until the provided context is canceled. +// It is intended to be used within a separate goroutine to avoid blocking +// the main execution flow. +// +// If the context is already canceled, the function will return immediately. +// +// Use this function for cases where a progress dialog is needed to run +// alongside other operations, with explicit cancellation or termination. +func (z *Zenity) ShowProgress(ctx context.Context, opts dialog.ProgressOptions) error { + args := []string{"--progress"} + if opts.Title != "" { + args = append(args, fmt.Sprintf("--title=%s", opts.Title)) + } + if opts.Text != "" { + args = append(args, fmt.Sprintf("--text=%s", opts.Text)) + } + + // --pulsate shows a pulsating progress bar + args = append(args, "--pulsate") + + // --no-cancel disables the cancel button + args = append(args, "--no-cancel") + + err := z.cmdWithWait(ctx, args...) + if err != nil { + return ctxerr.Wrap(ctx, dialog.ErrUnknown, err.Error()) + } + + return nil +} + +func execCmdWithOutput(ctx context.Context, args ...string) ([]byte, int, error) { + var opts []execuser.Option + for _, arg := range args { + opts = append(opts, execuser.WithArg(arg, "")) // Using empty value for positional args + } + + output, exitCode, err := execuser.RunWithOutput("zenity", opts...) + + // Trim the newline from zenity output + output = bytes.TrimSuffix(output, []byte("\n")) + + return output, exitCode, err +} + +func execCmdWithWait(ctx context.Context, args ...string) error { + var opts []execuser.Option + for _, arg := range args { + opts = append(opts, execuser.WithArg(arg, "")) // Using empty value for positional args + } + + return execuser.RunWithWait(ctx, "zenity", opts...) +} diff --git a/orbit/pkg/zenity/zenity_test.go b/orbit/pkg/zenity/zenity_test.go new file mode 100644 index 0000000000..5d57f52d91 --- /dev/null +++ b/orbit/pkg/zenity/zenity_test.go @@ -0,0 +1,268 @@ +package zenity + +import ( + "context" + "os/exec" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" + "github.com/stretchr/testify/require" + "github.com/tj/assert" +) + +type mockExecCmd struct { + output []byte + exitCode int + capturedArgs []string + waitDuration time.Duration +} + +// MockCommandContext simulates exec.CommandContext and captures arguments +func (m *mockExecCmd) runWithOutput(ctx context.Context, args ...string) ([]byte, int, error) { + m.capturedArgs = append(m.capturedArgs, args...) + + if m.exitCode != 0 { + return nil, m.exitCode, &exec.ExitError{} + } + + return m.output, m.exitCode, nil +} + +func (m *mockExecCmd) runWithWait(ctx context.Context, args ...string) error { + m.capturedArgs = append(m.capturedArgs, args...) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(m.waitDuration): + + } + + return nil +} + +func TestShowEntryArgs(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + opts dialog.EntryOptions + expectedArgs []string + }{ + { + name: "Basic Entry", + opts: dialog.EntryOptions{ + Title: "A Title", + Text: "Some text", + }, + expectedArgs: []string{"--entry", "--title=A Title", "--text=Some text"}, + }, + { + name: "All Options", + opts: dialog.EntryOptions{ + Title: "Another Title", + Text: "Some more text", + HideText: true, + TimeOut: 1 * time.Minute, + }, + expectedArgs: []string{"--entry", "--title=Another Title", "--text=Some more text", "--hide-text", "--timeout=60"}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mock := &mockExecCmd{ + output: []byte("some output"), + } + z := &Zenity{ + cmdWithOutput: mock.runWithOutput, + } + output, err := z.ShowEntry(ctx, tt.opts) + assert.NoError(t, err) + assert.Equal(t, tt.expectedArgs, mock.capturedArgs) + assert.Equal(t, []byte("some output"), output) + }) + } +} + +func TestShowEntryError(t *testing.T) { + ctx := context.Background() + + testcases := []struct { + name string + exitCode int + expectedErr error + }{ + { + name: "Dialog Cancelled", + exitCode: 1, + expectedErr: dialog.ErrCanceled, + }, + { + name: "Dialog Timed Out", + exitCode: 5, + expectedErr: dialog.ErrTimeout, + }, + { + name: "Unknown Error", + exitCode: 99, + expectedErr: dialog.ErrUnknown, + }, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + mock := &mockExecCmd{ + exitCode: tt.exitCode, + } + z := &Zenity{ + cmdWithOutput: mock.runWithOutput, + } + output, err := z.ShowEntry(ctx, dialog.EntryOptions{}) + require.ErrorIs(t, err, tt.expectedErr) + assert.Nil(t, output) + }) + } +} + +func TestShowEntrySuccess(t *testing.T) { + ctx := context.Background() + + mock := &mockExecCmd{ + output: []byte("some output"), + } + z := &Zenity{ + cmdWithOutput: mock.runWithOutput, + } + output, err := z.ShowEntry(ctx, dialog.EntryOptions{}) + assert.NoError(t, err) + assert.Equal(t, []byte("some output"), output) +} + +func TestShowInfoArgs(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + opts dialog.InfoOptions + expectedArgs []string + }{ + { + name: "Basic Entry", + opts: dialog.InfoOptions{}, + expectedArgs: []string{"--info"}, + }, + { + name: "All Options", + opts: dialog.InfoOptions{ + Title: "Another Title", + Text: "Some more text", + TimeOut: 1 * time.Minute, + }, + expectedArgs: []string{"--info", "--title=Another Title", "--text=Some more text", "--timeout=60"}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mock := &mockExecCmd{} + z := &Zenity{ + cmdWithOutput: mock.runWithOutput, + } + err := z.ShowInfo(ctx, tt.opts) + assert.NoError(t, err) + assert.Equal(t, tt.expectedArgs, mock.capturedArgs) + }) + } +} + +func TestShowInfoError(t *testing.T) { + ctx := context.Background() + + testcases := []struct { + name string + exitCode int + expectedErr error + }{ + { + name: "Dialog Timed Out", + exitCode: 5, + expectedErr: dialog.ErrTimeout, + }, + { + name: "Unknown Error", + exitCode: 99, + expectedErr: dialog.ErrUnknown, + }, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + mock := &mockExecCmd{ + exitCode: tt.exitCode, + } + z := &Zenity{ + cmdWithOutput: mock.runWithOutput, + } + err := z.ShowInfo(ctx, dialog.InfoOptions{}) + require.ErrorIs(t, err, tt.expectedErr) + }) + } +} + +func TestProgressArgs(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + opts dialog.ProgressOptions + expectedArgs []string + }{ + { + name: "Basic Entry", + opts: dialog.ProgressOptions{ + Title: "A Title", + Text: "Some text", + }, + expectedArgs: []string{"--progress", "--title=A Title", "--text=Some text", "--pulsate", "--no-cancel"}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mock := &mockExecCmd{} + z := &Zenity{ + cmdWithWait: mock.runWithWait, + } + err := z.ShowProgress(ctx, tt.opts) + assert.NoError(t, err) + assert.Equal(t, tt.expectedArgs, mock.capturedArgs) + }) + } +} + +func TestProgressKillOnCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + mock := &mockExecCmd{ + waitDuration: 5 * time.Second, + } + z := &Zenity{ + cmdWithWait: mock.runWithWait, + } + + done := make(chan struct{}) + start := time.Now() + + go func() { + _ = z.ShowProgress(ctx, dialog.ProgressOptions{}) + close(done) + }() + + time.Sleep(100 * time.Millisecond) + cancel() + <-done + + assert.True(t, time.Since(start) < 5*time.Second) +} diff --git a/pkg/file/pe.go b/pkg/file/pe.go index 27e872221d..4516b5a25e 100644 --- a/pkg/file/pe.go +++ b/pkg/file/pe.go @@ -48,21 +48,51 @@ func ExtractPEMetadata(tfr *fleet.TempFileReader) (*InstallerMetadata, error) { return nil, fmt.Errorf("error parsing PE file: %w", err) } - v, err := pep.ParseVersionResources() + resources, err := pep.ParseVersionResourcesForEntries() if err != nil { return nil, fmt.Errorf("error parsing PE version resources: %w", err) } - name := strings.TrimSpace(v["ProductName"]) + var name, version, sfxName, sfxVersion string + + for _, e := range resources { + productName, ok := e["ProductName"] + if !ok { + productName = e["productname"] // used by Opera SFX (self-extracting archive) + } + productVersion := strings.TrimSpace(e["ProductVersion"]) + if productName != "" { + productName = strings.TrimSpace(productName) + if productName == "7-Zip" { + // This may be a 7-Zip self-extracting archive. + sfxName = productName + sfxVersion = productVersion + continue + } + name = productName + } + if productVersion != "" { + version = productVersion + } + } + if name == "" && sfxName != "" { + // If we didn't find a ProductName, we may be + // dealing with an archive executable (e.g., if we're dealing with the 7-Zip executable itself rather than Opera) + name = sfxName + if sfxVersion != "" { + version = sfxVersion + } + } + return applySpecialCases(&InstallerMetadata{ Name: name, - Version: strings.TrimSpace(v["ProductVersion"]), + Version: version, PackageIDs: []string{name}, SHASum: h.Sum(nil), - }, v), nil + }, resources), nil } -var exeSpecialCases = map[string]func(*InstallerMetadata, map[string]string) *InstallerMetadata{ - "Notion": func(meta *InstallerMetadata, resources map[string]string) *InstallerMetadata { +var exeSpecialCases = map[string]func(*InstallerMetadata, []map[string]string) *InstallerMetadata{ + "Notion": func(meta *InstallerMetadata, _ []map[string]string) *InstallerMetadata { if meta.Version != "" { meta.Name = meta.Name + " " + meta.Version } @@ -82,7 +112,7 @@ var exeSpecialCases = map[string]func(*InstallerMetadata, map[string]string) *In // 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 { +func applySpecialCases(meta *InstallerMetadata, resources []map[string]string) *InstallerMetadata { if fn := exeSpecialCases[meta.Name]; fn != nil { return fn(meta, resources) } diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index 0a72676a8c..3a12aee973 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -10971,14 +10971,17 @@ "name": "firefox_preferences", "description": "Get the filepath where the host's Firefox preferences live.", "evented": false, + "examples": "Check if a preference is enabled:\n\n```\nSELECT * FROM firefox_preferences WHERE path IN (SELECT path FROM file WHERE path LIKE '/home/%/.mozilla/firefox/%/prefs.js') AND fullkey = 'network.dns.disablePrefetch' and value = 'true';\n```", "notes": "This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher).", "platforms": [ - "darwin" + "darwin", + "linux", + "windows" ], "columns": [ { "name": "path", - "description": "The path to the host's Firefox preferences.", + "description": "The absolute path to the host's Firefox preferences.", "type": "text", "required": true }, diff --git a/schema/tables/firefox_preferences.yml b/schema/tables/firefox_preferences.yml index f83bf45ea3..1f79fb8bd2 100644 --- a/schema/tables/firefox_preferences.yml +++ b/schema/tables/firefox_preferences.yml @@ -1,12 +1,20 @@ name: firefox_preferences -description: Get the filepath where the host's Firefox preferences live. +description: Get the filepath where the host's Firefox preferences live. evented: false +examples: |- + Check if a preference is enabled: + + ``` + SELECT * FROM firefox_preferences WHERE path IN (SELECT path FROM file WHERE path LIKE '/home/%/.mozilla/firefox/%/prefs.js') AND fullkey = 'network.dns.disablePrefetch' and value = 'true'; + ``` notes: This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher). platforms: - darwin + - linux + - windows columns: - name: path - description: The path to the host's Firefox preferences. + description: The absolute path to the host's Firefox preferences. type: text required: true - name: key @@ -29,4 +37,4 @@ columns: description: The query is printed in this column. For example the SQL `SELECT * FROM firefox_preferences WHERE path = 'testdata/prefs.js'` will print "*" in the query column. type: text required: false - \ No newline at end of file + diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index cdbf3b440b..bfe732426e 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -312,7 +312,7 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint JSON_OBJECT( 'host_id', hsr.host_id, 'host_display_name', COALESCE(hdn.display_name, ''), - 'script_name', COALESCE(scr.name, ''), + 'script_name', COALESCE(ses.name, COALESCE(scr.name, '')), 'script_execution_id', hsr.execution_id, 'async', NOT hsr.sync_request, 'policy_id', hsr.policy_id, @@ -330,6 +330,8 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint scripts scr ON scr.id = hsr.script_id LEFT OUTER JOIN host_software_installs hsi ON hsi.execution_id = hsr.execution_id + LEFT OUTER JOIN + setup_experience_scripts ses ON ses.id = hsr.setup_experience_script_id WHERE hsr.host_id = :host_id AND hsr.exit_code IS NULL AND diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 2e19fddccf..ce3a33815b 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -541,6 +541,15 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h2SelfService, err := ds.InsertSoftwareInstallRequest(noUserCtx, h2.ID, sw1Meta.InstallerID, true, nil) require.NoError(t, err) + setupExpScript := &fleet.Script{Name: "setup_experience_script", ScriptContents: "setup_experience"} + err = ds.SetSetupExperienceScript(ctx, setupExpScript) + require.NoError(t, err) + ses, err := ds.GetSetupExperienceScript(ctx, h2.TeamID) + require.NoError(t, err) + hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptContents: "setup_experience", SetupExperienceScriptID: &ses.ID}) + require.NoError(t, err) + h2SetupExp := hsr.ExecutionID + // create pending install and uninstall requests for h3 that will be deleted _, err = ds.InsertSoftwareInstallRequest(ctx, h3.ID, sw3Meta.InstallerID, false, nil) require.NoError(t, err) @@ -560,7 +569,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2SelfService) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Bar) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h2A, h2F) - SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_vpp_software_installs", "command_uuid", vppCommand1, vppCommand2) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_vpp_software_installs", "command_uuid", vppCommand1, vppCommand2) + SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h2SetupExp) execIDsWithUser := map[string]bool{ h1A: true, @@ -576,11 +586,13 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h2Bar: true, vppCommand1: true, vppCommand2: false, + h2SetupExp: false, } execIDsScriptName := map[string]string{ - h1A: scr1.Name, - h1B: scr2.Name, - h2A: scr1.Name, + h1A: scr1.Name, + h1B: scr2.Name, + h2A: scr1.Name, + h2SetupExp: setupExpScript.Name, } execIDsSoftwareTitle := map[string]string{ h1Fleet: "foo", @@ -641,10 +653,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { - opts: fleet.ListOptions{PerPage: 4}, + opts: fleet.ListOptions{PerPage: 5}, hostID: h2.ID, - wantExecs: []string{h2SelfService, h2Bar, h2A, vppCommand2}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 4}, + wantExecs: []string{h2SelfService, h2Bar, h2A, vppCommand2, h2SetupExp}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 5}, }, { opts: fleet.ListOptions{}, diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 3cf3e99694..ea7f92ec89 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -90,6 +90,7 @@ func TestMDMApple(t *testing.T) { {"HostMDMCommands", testHostMDMCommands}, {"IngestMDMAppleDeviceFromOTAEnrollment", testIngestMDMAppleDeviceFromOTAEnrollment}, {"MDMManagedCertificates", testMDMManagedCertificates}, + {"AppleMDMSetBatchAsyncLastSeenAt", testAppleMDMSetBatchAsyncLastSeenAt}, {"TestMDMAppleProfileLabels", testMDMAppleProfileLabels}, } @@ -2975,7 +2976,6 @@ func testGetMDMAppleCommandResults(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: uuid2, Status: "Acknowledged", - RequestType: "ProfileList", Raw: []byte(rawCmd2), }) require.NoError(t, err) @@ -3005,7 +3005,6 @@ func testGetMDMAppleCommandResults(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: uuid2, Status: "Error", - RequestType: "ProfileList", Raw: []byte(rawCmd2), }) require.NoError(t, err) @@ -3306,7 +3305,6 @@ func testListMDMAppleCommands(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: uuid1, Status: "Acknowledged", - RequestType: "ListApps", Raw: []byte(rawCmd1), }) require.NoError(t, err) @@ -3347,7 +3345,6 @@ func testListMDMAppleCommands(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: uuid1, Status: "Error", - RequestType: "ListApps", Raw: []byte(rawCmd1), }) require.NoError(t, err) @@ -3394,7 +3391,6 @@ func testListMDMAppleCommands(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: uuid2, Status: "Acknowledged", - RequestType: "InstallApp", Raw: []byte(rawCmd2), }) require.NoError(t, err) @@ -3404,7 +3400,6 @@ func testListMDMAppleCommands(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: uuid2, Status: "Acknowledged", - RequestType: "InstallApp", Raw: []byte(rawCmd2), }) require.NoError(t, err) @@ -4765,7 +4760,6 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: cmd.CommandUUID, Status: "Acknowledged", - RequestType: "DeviceLock", Raw: cmd.Raw, }) require.NoError(t, err) @@ -4818,7 +4812,6 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: cmd.CommandUUID, Status: "Error", - RequestType: cmd.Command.RequestType, Raw: cmd.Raw, }) require.NoError(t, err) @@ -4852,7 +4845,6 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: cmd.CommandUUID, Status: "Acknowledged", - RequestType: cmd.Command.RequestType, Raw: cmd.Raw, }) require.NoError(t, err) @@ -6188,7 +6180,6 @@ func testGetHostUUIDsWithPendingMDMAppleCommands(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: uuid1, Status: "Acknowledged", - RequestType: "ProfileList", Raw: []byte(rawCmd1), }) require.NoError(t, err) @@ -7233,6 +7224,76 @@ func testMDMManagedCertificates(t *testing.T, ds *Datastore) { require.ErrorIs(t, err, sql.ErrNoRows) } +func testAppleMDMSetBatchAsyncLastSeenAt(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create some hosts, all enrolled + enrolledHosts := make([]*fleet.Host, 2) + for i := 0; i < len(enrolledHosts); i++ { + h, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: fmt.Sprintf("test-host%d-name", i), + OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)), + NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)), + UUID: fmt.Sprintf("test-uuid-%d", i), + Platform: "darwin", + }) + require.NoError(t, err) + nanoEnroll(t, ds, h, false) + enrolledHosts[i] = h + t.Logf("enrolled host [%d]: %s", i, h.UUID) + } + + getHostLastSeenAt := func(h *fleet.Host) time.Time { + var lastSeenAt time.Time + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &lastSeenAt, `SELECT last_seen_at FROM nano_enrollments WHERE device_id = ?`, h.UUID) + }) + return lastSeenAt + } + + storage, err := ds.NewTestMDMAppleMDMStorage(2, 5*time.Second) + require.NoError(t, err) + commander := apple_mdm.NewMDMAppleCommander(storage, pusherFunc(okPusherFunc)) + + // enqueue a command for a couple of enrolled hosts + uuid1 := uuid.NewString() + rawCmd1 := createRawAppleCmd("ProfileList", uuid1) + err = commander.EnqueueCommand(ctx, []string{enrolledHosts[0].UUID, enrolledHosts[1].UUID}, rawCmd1) + require.NoError(t, err) + + // at this point, last_seen_at is still the original value + ts1, ts2 := getHostLastSeenAt(enrolledHosts[0]), getHostLastSeenAt(enrolledHosts[1]) + + time.Sleep(time.Second + time.Millisecond) // ensure a distinct mysql timestamp + + // simulate a result for enrolledHosts[0] + err = storage.StoreCommandReport(&mdm.Request{ + EnrollID: &mdm.EnrollID{ID: enrolledHosts[0].UUID}, + Context: ctx, + }, &mdm.CommandResults{ + CommandUUID: uuid1, + Status: "Acknowledged", + Raw: []byte(rawCmd1), + }) + require.NoError(t, err) + + // simulate a result for enrolledHosts[1] + err = storage.StoreCommandReport(&mdm.Request{ + EnrollID: &mdm.EnrollID{ID: enrolledHosts[1].UUID}, + Context: ctx, + }, &mdm.CommandResults{ + CommandUUID: uuid1, + Status: "Error", + Raw: []byte(rawCmd1), + }) + require.NoError(t, err) + + // timestamps should've been updated + ts1b, ts2b := getHostLastSeenAt(enrolledHosts[0]), getHostLastSeenAt(enrolledHosts[1]) + require.True(t, ts1b.After(ts1)) + require.True(t, ts2b.After(ts2)) +} + func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index d259e91409..b2f7f3a650 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -1904,9 +1904,20 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf } // NOTE: allow an empty serial, currently it is empty for Windows. - var host fleet.Host + host := fleet.Host{ + ComputerName: hostInfo.ComputerName, + Hostname: hostInfo.Hostname, + HardwareModel: hostInfo.HardwareModel, + HardwareSerial: hostInfo.HardwareSerial, + } err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - enrolledHostInfo, err := matchHostDuringEnrollment(ctx, tx, orbitEnroll, isMDMEnabled, hostInfo.OsqueryIdentifier, hostInfo.HardwareUUID, hostInfo.HardwareSerial) + serialToMatch := hostInfo.HardwareSerial + if hostInfo.Platform == "windows" { + // For Windows, don't match by serial number to retain legacy functionality. + serialToMatch = "" + } + enrolledHostInfo, err := matchHostDuringEnrollment(ctx, tx, orbitEnroll, isMDMEnabled, hostInfo.OsqueryIdentifier, + hostInfo.HardwareUUID, serialToMatch) // If the osquery identifier that osqueryd will use was not sent by Orbit, then use the hardware UUID as identifier // (using the hardware UUID is Orbit's default behavior). @@ -1936,6 +1947,8 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf uuid = COALESCE(NULLIF(uuid, ''), ?), osquery_host_id = COALESCE(NULLIF(osquery_host_id, ''), ?), hardware_serial = COALESCE(NULLIF(hardware_serial, ''), ?), + computer_name = COALESCE(NULLIF(computer_name, ''), ?), + hardware_model = COALESCE(NULLIF(hardware_model, ''), ?), team_id = ? WHERE id = ?` _, err := tx.ExecContext(ctx, sqlUpdate, @@ -1943,6 +1956,8 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf hostInfo.HardwareUUID, osqueryIdentifier, hostInfo.HardwareSerial, + hostInfo.ComputerName, + hostInfo.HardwareModel, teamID, enrolledHostInfo.ID, ) @@ -1977,8 +1992,10 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf orbit_node_key, hardware_serial, hostname, + computer_name, + hardware_model, platform - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) ` result, err := tx.ExecContext(ctx, sqlInsert, zeroTime, @@ -1992,6 +2009,8 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf orbitNodeKey, hostInfo.HardwareSerial, hostInfo.Hostname, + hostInfo.ComputerName, + hostInfo.HardwareModel, hostInfo.Platform, ) if err != nil { @@ -1999,9 +2018,9 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf } hostID, _ := result.LastInsertId() const sqlHostDisplayName = ` - INSERT INTO host_display_names (host_id, display_name) VALUES (?, '') + INSERT INTO host_display_names (host_id, display_name) VALUES (?, ?) ` - _, err = tx.ExecContext(ctx, sqlHostDisplayName, hostID) + _, err = tx.ExecContext(ctx, sqlHostDisplayName, hostID, host.DisplayName()) if err != nil { return ctxerr.Wrap(ctx, err, "insert host_display_names") } @@ -2421,6 +2440,7 @@ func (ds *Datastore) LoadHostByDeviceAuthToken(ctx context.Context, authToken st COALESCE(hd.gigs_disk_space_available, 0) as gigs_disk_space_available, COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available, COALESCE(hd.gigs_total_disk_space, 0) as gigs_total_disk_space, + hd.encrypted as disk_encryption_enabled, IF(hdep.host_id AND ISNULL(hdep.deleted_at), true, false) AS dep_assigned_to_fleet FROM host_device_auth hda @@ -3800,6 +3820,60 @@ ON DUPLICATE KEY UPDATE return err } +func (ds *Datastore) SaveLUKSData(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error { + if encryptedBase64Passphrase == "" || encryptedBase64Salt == "" { // should have been caught at service level + return errors.New("passphrase and salt must be set") + } + + _, err := ds.writer(ctx).ExecContext(ctx, ` +INSERT INTO host_disk_encryption_keys + (host_id, base64_encrypted, base64_encrypted_salt, key_slot, client_error, decryptable) +VALUES + (?, ?, ?, ?, '', TRUE) +ON DUPLICATE KEY UPDATE + decryptable = TRUE, + base64_encrypted = VALUES(base64_encrypted), + base64_encrypted_salt = VALUES(base64_encrypted_salt), + key_slot = VALUES(key_slot), + client_error = '' +`, hostID, encryptedBase64Passphrase, encryptedBase64Salt, keySlot) + return err +} +func (ds *Datastore) IsHostPendingEscrow(ctx context.Context, hostID uint) bool { + var pendingEscrowCount uint + _ = sqlx.GetContext(ctx, ds.reader(ctx), &pendingEscrowCount, ` + SELECT COUNT(*) FROM host_disk_encryption_keys WHERE host_id = ? AND reset_requested = TRUE`, hostID) + return pendingEscrowCount > 0 +} +func (ds *Datastore) ClearPendingEscrow(ctx context.Context, hostID uint) error { + _, err := ds.writer(ctx).ExecContext(ctx, `UPDATE host_disk_encryption_keys SET reset_requested = FALSE WHERE host_id = ?`, hostID) + return err +} +func (ds *Datastore) ReportEscrowError(ctx context.Context, hostID uint, errorMessage string) error { + _, err := ds.writer(ctx).ExecContext(ctx, ` +INSERT INTO host_disk_encryption_keys + (host_id, base64_encrypted, client_error) VALUES (?, '', ?) ON DUPLICATE KEY UPDATE client_error = VALUES(client_error) +`, hostID, errorMessage) + return err +} +func (ds *Datastore) QueueEscrow(ctx context.Context, hostID uint) error { + _, err := ds.writer(ctx).ExecContext(ctx, ` +INSERT INTO host_disk_encryption_keys + (host_id, base64_encrypted, reset_requested) VALUES (?, '', TRUE) ON DUPLICATE KEY UPDATE reset_requested = TRUE +`, hostID) + return err +} +func (ds *Datastore) AssertHasNoEncryptionKeyStored(ctx context.Context, hostID uint) error { + var hasKeyCount uint + err := sqlx.GetContext(ctx, ds.reader(ctx), &hasKeyCount, ` + SELECT COUNT(*) FROM host_disk_encryption_keys WHERE host_id = ? AND base64_encrypted != ''`, hostID) + if hasKeyCount > 0 { + return &fleet.BadRequestError{Message: "Key has already been escrowed for this host"} + } + + return err +} + func (ds *Datastore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { // NOTE(mna): currently we only verify encryption keys for macOS, // Windows/bitlocker uses a different approach where orbit sends the @@ -3849,7 +3923,7 @@ func (ds *Datastore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) var key fleet.HostDiskEncryptionKey err := sqlx.GetContext(ctx, ds.reader(ctx), &key, ` SELECT - host_id, base64_encrypted, decryptable, updated_at + host_id, base64_encrypted, decryptable, updated_at, client_error FROM host_disk_encryption_keys WHERE host_id = ?`, hostID) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index fd41dc45e8..de3fe566e7 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -155,6 +155,7 @@ func TestHosts(t *testing.T) { {"SetOrUpdateHostDiskEncryptionKeys", testHostsSetOrUpdateHostDisksEncryptionKey}, {"SetHostsDiskEncryptionKeyStatus", testHostsSetDiskEncryptionKeyStatus}, {"GetUnverifiedDiskEncryptionKeys", testHostsGetUnverifiedDiskEncryptionKeys}, + {"LUKS", testLUKSDatastoreFunctions}, {"EnrollOrbit", testHostsEnrollOrbit}, {"EnrollUpdatesMissingInfo", testHostsEnrollUpdatesMissingInfo}, {"EncryptionKeyRawDecryption", testHostsEncryptionKeyRawDecryption}, @@ -6193,6 +6194,17 @@ func testHostsLoadHostByDeviceAuthToken(t *testing.T, ds *Datastore) { require.Equal(t, hSimple.ID, loadSimple.ID) require.True(t, loadSimple.IsOsqueryEnrolled()) + // make sure disk encryption state is reflected + require.Nil(t, loadSimple.DiskEncryptionEnabled) + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(ctx, hSimple.ID, false)) + loadSimple, err = ds.LoadHostByDeviceAuthToken(ctx, "simple", time.Second*3) + require.NoError(t, err) + require.False(t, *loadSimple.DiskEncryptionEnabled) + require.NoError(t, ds.SetOrUpdateHostDisksEncryption(ctx, hSimple.ID, true)) + loadSimple, err = ds.LoadHostByDeviceAuthToken(ctx, "simple", time.Second*3) + require.NoError(t, err) + require.True(t, *loadSimple.DiskEncryptionEnabled) + // create a host that will be pending enrollment in Fleet MDM hFleet := createHostWithDeviceToken("fleet") err = ds.SetOrUpdateMDMData(ctx, hFleet.ID, false, false, "https://fleetdm.com", true, fleet.WellKnownMDMFleet, "") @@ -7807,6 +7819,101 @@ func checkEncryptionKeyStatus(t *testing.T, ds *Datastore, hostID uint, expected require.Equal(t, expectedDecryptable, got.Decryptable) } +func testLUKSDatastoreFunctions(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host1, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: "1", + OsqueryHostID: ptr.String("1"), + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + }) + require.NoError(t, err) + host2, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("2"), + UUID: "2", + OsqueryHostID: ptr.String("2"), + Hostname: "foo.local2", + PrimaryIP: "192.168.1.2", + PrimaryMac: "30-65-EC-6F-C4-59", + }) + require.NoError(t, err) + host3, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("3"), + UUID: "3", + OsqueryHostID: ptr.String("3"), + Hostname: "foo.local3", + PrimaryIP: "192.168.1.3", + PrimaryMac: "30-65-EC-6F-C4-60", + }) + require.NoError(t, err) + + // queue shows as pending + require.False(t, ds.IsHostPendingEscrow(ctx, host1.ID)) + err = ds.QueueEscrow(ctx, host1.ID) + require.NoError(t, err) + require.False(t, ds.IsHostPendingEscrow(ctx, host2.ID)) + require.True(t, ds.IsHostPendingEscrow(ctx, host1.ID)) + + // clear removes pending + err = ds.QueueEscrow(ctx, host2.ID) + require.NoError(t, err) + err = ds.ClearPendingEscrow(ctx, host1.ID) + require.NoError(t, err) + require.False(t, ds.IsHostPendingEscrow(ctx, host1.ID)) + require.True(t, ds.IsHostPendingEscrow(ctx, host2.ID)) + + // report escrow error does not remove pending + err = ds.ReportEscrowError(ctx, host2.ID, "this broke") + require.NoError(t, err) + require.True(t, ds.IsHostPendingEscrow(ctx, host2.ID)) + // TODO confirm error was persisted + + // assert no key stored on hosts with varying no-key-stored states + require.NoError(t, ds.AssertHasNoEncryptionKeyStored(ctx, host1.ID)) + require.NoError(t, ds.AssertHasNoEncryptionKeyStored(ctx, host2.ID)) + require.NoError(t, ds.AssertHasNoEncryptionKeyStored(ctx, host3.ID)) + + // no change when blank key or salt attempted to save + err = ds.SaveLUKSData(ctx, host1.ID, "", "", 0) + require.Error(t, err) + require.NoError(t, ds.AssertHasNoEncryptionKeyStored(ctx, host1.ID)) + err = ds.SaveLUKSData(ctx, host1.ID, "foo", "", 0) + require.Error(t, err) + require.NoError(t, ds.AssertHasNoEncryptionKeyStored(ctx, host1.ID)) + + // persists with passphrase and salt set + err = ds.SaveLUKSData(ctx, host2.ID, "bazqux", "fuzzmuffin", 0) + require.NoError(t, err) + require.NoError(t, ds.AssertHasNoEncryptionKeyStored(ctx, host1.ID)) + require.Error(t, ds.AssertHasNoEncryptionKeyStored(ctx, host2.ID)) + key, err := ds.GetHostDiskEncryptionKey(ctx, host2.ID) + require.NoError(t, err) + require.Equal(t, "bazqux", key.Base64Encrypted) + + // persists when host hasn't had anything queued + err = ds.SaveLUKSData(ctx, host3.ID, "newstuff", "fuzzball", 1) + require.NoError(t, err) + require.Error(t, ds.AssertHasNoEncryptionKeyStored(ctx, host3.ID)) + key, err = ds.GetHostDiskEncryptionKey(ctx, host3.ID) + require.NoError(t, err) + require.Equal(t, "newstuff", key.Base64Encrypted) +} + func testHostsSetOrUpdateHostDisksEncryptionKey(t *testing.T, ds *Datastore) { host, err := ds.NewHost(context.Background(), &fleet.Host{ DetailUpdatedAt: time.Now(), @@ -8061,6 +8168,11 @@ func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) { func testHostsEnrollOrbit(t *testing.T, ds *Datastore) { ctx := context.Background() + const ( + computerName = "My computer" + hardwareModel = "CMP-1000" + ) + createHost := func(osqueryID, serial string) *fleet.Host { dbZeroTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) var osqueryIDPtr *string @@ -8075,6 +8187,8 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) { DetailUpdatedAt: dbZeroTime, OsqueryHostID: osqueryIDPtr, RefetchRequested: true, + ComputerName: computerName, + HardwareModel: hardwareModel, }) require.NoError(t, err) return h @@ -8112,10 +8226,19 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) { h, err = ds.EnrollOrbit(ctx, true, fleet.OrbitHostInfo{ HardwareUUID: *hBoth.OsqueryHostID, HardwareSerial: hBoth.HardwareSerial, + ComputerName: hBoth.ComputerName, + HardwareModel: hBoth.HardwareModel, }, uuid.New().String(), nil) require.NoError(t, err) require.Equal(t, hBoth.ID, h.ID) - require.Empty(t, h.HardwareSerial) // this is just to prove that it was loaded based on osquery_host_id, the serial was not set in the lookup + assert.Equal(t, hBoth.HardwareSerial, h.HardwareSerial) + assert.Equal(t, hBoth.ComputerName, h.ComputerName) + assert.Equal(t, hBoth.HardwareModel, h.HardwareModel) + h, err = ds.Host(ctx, h.ID) + require.NoError(t, err) + assert.Equal(t, hBoth.HardwareSerial, h.HardwareSerial) + assert.Equal(t, hBoth.ComputerName, h.ComputerName) + assert.Equal(t, hBoth.HardwareModel, h.HardwareModel) // enroll with osquery id from hBoth and serial from hSerialNoOsquery (should // use the osquery match) @@ -8125,14 +8248,17 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) { }, uuid.New().String(), nil) require.NoError(t, err) require.Equal(t, hBoth.ID, h.ID) - require.Empty(t, h.HardwareSerial) + assert.Equal(t, hSerialNoOsquery.HardwareSerial, h.HardwareSerial) // enroll with no match, will create a new one + newSerial := uuid.NewString() h, err = ds.EnrollOrbit(ctx, true, fleet.OrbitHostInfo{ HardwareUUID: uuid.New().String(), - HardwareSerial: uuid.New().String(), + HardwareSerial: newSerial, Hostname: "foo2", Platform: "darwin", + ComputerName: "New computer", + HardwareModel: "ABC-3000", }, uuid.New().String(), nil) require.NoError(t, err) require.Greater(t, h.ID, hBoth.ID) @@ -8141,6 +8267,9 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, "foo2", h.Hostname) require.Equal(t, "darwin", h.Platform) + assert.Equal(t, "New computer", h.ComputerName) + assert.Equal(t, "ABC-3000", h.HardwareModel) + assert.Equal(t, newSerial, h.HardwareSerial) // simulate a "corrupt database" where two hosts have the same serial and // enroll by serial should always use the same (the smaller ID) diff --git a/server/datastore/mysql/linux_mdm.go b/server/datastore/mysql/linux_mdm.go new file mode 100644 index 0000000000..2cd88843ef --- /dev/null +++ b/server/datastore/mysql/linux_mdm.go @@ -0,0 +1,69 @@ +package mysql + +import ( + "context" + "fmt" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +func (ds *Datastore) GetLinuxDiskEncryptionSummary(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) { + var args []interface{} + var teamFilter string + if teamID != nil { + teamFilter = "AND h.team_id = ?" + args = append(args, *teamID) + } else { + teamFilter = "AND h.team_id IS NULL" + } + + stmt := fmt.Sprintf(`SELECT + CASE WHEN hdek.base64_encrypted IS NOT NULL + AND hdek.base64_encrypted != '' + AND hdek.client_error = '' THEN + 'verified' + WHEN hdek.client_error IS NOT NULL + AND hdek.client_error != '' THEN + 'failed' + WHEN hdek.base64_encrypted IS NULL + OR (hdek.base64_encrypted = '' + AND hdek.client_error = '') THEN + 'action_required' + END AS status, + COUNT(h.id) AS host_count + FROM + hosts h + LEFT JOIN host_disk_encryption_keys hdek ON h.id = hdek.host_id + WHERE + (h.os_version LIKE '%%fedora%%' + OR h.platform LIKE 'ubuntu') + %s + GROUP BY + status`, teamFilter) + + type countRow struct { + Status string `db:"status"` + HostCount uint `db:"host_count"` + } + + var counts []countRow + summary := fleet.MDMLinuxDiskEncryptionSummary{} + + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &counts, stmt, args...); err != nil { + return summary, err + } + + for _, count := range counts { + switch count.Status { + case "verified": + summary.Verified = count.HostCount + case "action_required": + summary.ActionRequired = count.HostCount + case "failed": + summary.Failed = count.HostCount + } + } + + return summary, nil +} diff --git a/server/datastore/mysql/linux_mdm_test.go b/server/datastore/mysql/linux_mdm_test.go new file mode 100644 index 0000000000..d0cecb405b --- /dev/null +++ b/server/datastore/mysql/linux_mdm_test.go @@ -0,0 +1,146 @@ +package mysql + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +func TestLinuxDiskEncryptionSummary(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + // 5 new ubuntu hosts + var ubuntuHosts []*fleet.Host + for i := 0; i < 5; i++ { + h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", + fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now(), test.WithPlatform("ubuntu")) + ubuntuHosts = append(ubuntuHosts, h) + } + + // 5 new fedora hosts + var fedoraHosts []*fleet.Host + for i := 5; i < 10; i++ { + h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", + fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now(), + test.WithOSVersion("Fedora Linux 38.0.0"), test.WithPlatform("rhel")) + fedoraHosts = append(fedoraHosts, h) + } + + // 5 macos hosts + var macosHosts []*fleet.Host + for i := 10; i < 15; i++ { + h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", + fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now(), test.WithPlatform("darwin")) + macosHosts = append(macosHosts, h) + } + + // no teams tests ===== + summary, err := ds.GetLinuxDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + + require.Equal(t, uint(0), summary.Verified) + require.Equal(t, uint(10), summary.ActionRequired) + require.Equal(t, uint(0), summary.Failed) + + // Add disk encryption keys + + // ubuntu + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, ubuntuHosts[0].ID, "base64_encrypted", "", nil) + require.NoError(t, err) + // fedora + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, fedoraHosts[0].ID, "base64_encrypted", "", nil) + require.NoError(t, err) + // macos + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, macosHosts[0].ID, "base64_encrypted", "", nil) + require.NoError(t, err) + + summary, err = ds.GetLinuxDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + + require.Equal(t, uint(2), summary.Verified) + require.Equal(t, uint(8), summary.ActionRequired) + require.Equal(t, uint(0), summary.Failed) + + // update ubuntu with key and client error + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, ubuntuHosts[0].ID, "base64_encrypted", "client error", nil) + require.NoError(t, err) + + summary, err = ds.GetLinuxDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + + require.Equal(t, uint(1), summary.Verified) + require.Equal(t, uint(8), summary.ActionRequired) + require.Equal(t, uint(1), summary.Failed) + + // add ubuntu with no key and client error + err = ds.SetOrUpdateHostDiskEncryptionKey(ctx, ubuntuHosts[1].ID, "", "client error", nil) + require.NoError(t, err) + + summary, err = ds.GetLinuxDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + + require.Equal(t, uint(1), summary.Verified) + require.Equal(t, uint(7), summary.ActionRequired) + require.Equal(t, uint(2), summary.Failed) + + // move verified fedora host to team will remove existing key + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + err = ds.AddHostsToTeam(ctx, &team.ID, []uint{fedoraHosts[0].ID}) + require.NoError(t, err) + + // team summary + summary, err = ds.GetLinuxDiskEncryptionSummary(ctx, &team.ID) + require.NoError(t, err) + + require.Equal(t, uint(0), summary.Verified) + require.Equal(t, uint(1), summary.ActionRequired) + require.Equal(t, uint(0), summary.Failed) + + // no team summary + summary, err = ds.GetLinuxDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + + require.Equal(t, uint(0), summary.Verified) + require.Equal(t, uint(7), summary.ActionRequired) + require.Equal(t, uint(2), summary.Failed) + + // move all hosts to team + for _, h := range ubuntuHosts { + err = ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID}) + require.NoError(t, err) + } + + for _, h := range fedoraHosts { + err = ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID}) + require.NoError(t, err) + } + + for _, h := range macosHosts { + err = ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID}) + require.NoError(t, err) + } + + // team summary + summary, err = ds.GetLinuxDiskEncryptionSummary(ctx, &team.ID) + require.NoError(t, err) + + require.Equal(t, uint(0), summary.Verified) + require.Equal(t, uint(10), summary.ActionRequired) + require.Equal(t, uint(0), summary.Failed) + + // no team summary + summary, err = ds.GetLinuxDiskEncryptionSummary(ctx, nil) + require.NoError(t, err) + + require.Equal(t, uint(0), summary.Verified) + require.Equal(t, uint(0), summary.ActionRequired) + require.Equal(t, uint(0), summary.Failed) +} diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 7e41ce03d2..fe139d89ff 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -174,7 +174,6 @@ func testMDMCommands(t *testing.T, ds *Datastore) { }, &mdm.CommandResults{ CommandUUID: appleCmdUUID, Status: "Acknowledged", - RequestType: "ProfileList", Raw: []byte(appleCmd), }) require.NoError(t, err) diff --git a/server/datastore/mysql/migrations/tables/20241116233322_AddLuksDataToHostDiskEncryptionKeys.go b/server/datastore/mysql/migrations/tables/20241116233322_AddLuksDataToHostDiskEncryptionKeys.go new file mode 100644 index 0000000000..7a790a4540 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20241116233322_AddLuksDataToHostDiskEncryptionKeys.go @@ -0,0 +1,25 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20241116233322, Down_20241116233322) +} + +func Up_20241116233322(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE host_disk_encryption_keys + ADD COLUMN base64_encrypted_salt VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' AFTER base64_encrypted, + ADD COLUMN key_slot TINYINT UNSIGNED DEFAULT NULL AFTER base64_encrypted_salt`) + if err != nil { + return fmt.Errorf("failed to add base64_encrypted_salt and key_slot columns to host_disk_encryption_keys: %w", err) + } + + return nil +} + +func Down_20241116233322(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index ed5f99352c..2f2ea28482 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" abmctx "github.com/fleetdm/fleet/v4/server/contexts/apple_bm" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -16,7 +17,9 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" nanomdm_mysql "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/mysql" "github.com/go-kit/log" + "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" + nanomdm_log "github.com/micromdm/nanolib/log" ) // NanoMDMStorage wraps a *nanomdm_mysql.MySQLStorage and overrides further functionality. @@ -28,10 +31,50 @@ type NanoMDMStorage struct { ds fleet.Datastore } +type nanoMDMLogAdapter struct { + logger log.Logger +} + +func (l nanoMDMLogAdapter) Info(args ...interface{}) { + level.Info(l.logger).Log(args...) +} + +func (l nanoMDMLogAdapter) Debug(args ...interface{}) { + level.Debug(l.logger).Log(args...) +} + +func (l nanoMDMLogAdapter) With(args ...interface{}) nanomdm_log.Logger { + wl := log.With(l.logger, args...) + return nanoMDMLogAdapter{logger: wl} +} + // NewMDMAppleMDMStorage returns a MySQL nanomdm storage that uses the Datastore // underlying MySQL writer *sql.DB. func (ds *Datastore) NewMDMAppleMDMStorage() (*NanoMDMStorage, error) { - s, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB)) + s, err := nanomdm_mysql.New( + nanomdm_mysql.WithDB(ds.primary.DB), + nanomdm_mysql.WithLogger(nanoMDMLogAdapter{logger: ds.logger}), + ) + if err != nil { + return nil, err + } + return &NanoMDMStorage{ + MySQLStorage: s, + db: ds.primary, + logger: ds.logger, + ds: ds, + }, nil +} + +// NewTestMDMAppleMDMStorage returns a test MySQL nanomdm storage that uses the +// Datastore underlying MySQL writer *sql.DB. It allows configuring the async +// last seen time's capacity and interval and should only be used in tests. +func (ds *Datastore) NewTestMDMAppleMDMStorage(asyncCap int, asyncInterval time.Duration) (*NanoMDMStorage, error) { + s, err := nanomdm_mysql.New( + nanomdm_mysql.WithDB(ds.primary.DB), + nanomdm_mysql.WithLogger(nanoMDMLogAdapter{logger: ds.logger}), + nanomdm_mysql.WithAsyncLastSeen(asyncCap, asyncInterval), + ) if err != nil { return nil, err } diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index e9740f5e9d..45718cbd1d 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -303,6 +303,8 @@ CREATE TABLE `host_device_auth` ( CREATE TABLE `host_disk_encryption_keys` ( `host_id` int unsigned NOT NULL, `base64_encrypted` text COLLATE utf8mb4_unicode_ci NOT NULL, + `base64_encrypted_salt` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `key_slot` tinyint unsigned DEFAULT NULL, `decryptable` tinyint(1) DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -1102,9 +1104,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=330 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=331 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'),(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'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,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'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,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/scripts.go b/server/datastore/mysql/scripts.go index d09b616455..b6e4ff0576 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -39,8 +39,8 @@ func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request func newHostScriptExecutionRequest(ctx context.Context, tx sqlx.ExtContext, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { const ( - insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_content_id, output, script_id, policy_id, user_id, sync_request) VALUES (?, ?, ?, '', ?, ?, ?, ?)` - getStmt = `SELECT hsr.id, hsr.host_id, hsr.execution_id, hsr.created_at, hsr.script_id, hsr.policy_id, hsr.user_id, hsr.sync_request, sc.contents as script_contents FROM host_script_results hsr JOIN script_contents sc WHERE sc.id = hsr.script_content_id AND hsr.id = ?` + insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_content_id, output, script_id, policy_id, user_id, sync_request, setup_experience_script_id) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)` + getStmt = `SELECT hsr.id, hsr.host_id, hsr.execution_id, hsr.created_at, hsr.script_id, hsr.policy_id, hsr.user_id, hsr.sync_request, sc.contents as script_contents, hsr.setup_experience_script_id FROM host_script_results hsr JOIN script_contents sc WHERE sc.id = hsr.script_content_id AND hsr.id = ?` ) execID := uuid.New().String() @@ -52,6 +52,7 @@ func newHostScriptExecutionRequest(ctx context.Context, tx sqlx.ExtContext, requ request.PolicyID, request.UserID, request.SyncRequest, + request.SetupExperienceScriptID, ) if err != nil { return nil, ctxerr.Wrap(ctx, err, "new host script execution request") @@ -269,7 +270,8 @@ func (ds *Datastore) getHostScriptExecutionResultDB(ctx context.Context, q sqlx. hsr.created_at, hsr.user_id, hsr.sync_request, - hsr.host_deleted_at + hsr.host_deleted_at, + hsr.setup_experience_script_id FROM host_script_results hsr JOIN diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go index 328cbb12ae..fe69a14aef 100644 --- a/server/datastore/mysql/setup_experience.go +++ b/server/datastore/mysql/setup_experience.go @@ -404,6 +404,32 @@ WHERE return &script, nil } +func (ds *Datastore) GetSetupExperienceScriptByID(ctx context.Context, scriptID uint) (*fleet.Script, error) { + query := ` +SELECT + id, + team_id, + name, + script_content_id, + created_at, + updated_at +FROM + setup_experience_scripts +WHERE + id = ? +` + + var script fleet.Script + if err := sqlx.GetContext(ctx, ds.reader(ctx), &script, query, scriptID); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("SetupExperienceScript"), "get setup experience script by id") + } + return nil, ctxerr.Wrap(ctx, err, "get setup experience script by id") + } + + return &script, nil +} + func (ds *Datastore) SetSetupExperienceScript(ctx context.Context, script *fleet.Script) error { err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go index fa48a4107c..f1f387fe1a 100644 --- a/server/datastore/mysql/setup_experience_test.go +++ b/server/datastore/mysql/setup_experience_test.go @@ -29,6 +29,7 @@ func TestSetupExperience(t *testing.T) { {"ListSetupExperienceStatusResults", testSetupExperienceStatusResults}, {"SetupExperienceScriptCRUD", testSetupExperienceScriptCRUD}, {"TestHostInSetupExperience", testHostInSetupExperience}, + {"TestGetSetupExperienceScriptByID", testGetSetupExperienceScriptByID}, } for _, c := range cases { @@ -675,7 +676,6 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) { assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup) assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup) assert.False(t, *titles[2].AppStoreApp.InstallDuringSetup) - } func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) { @@ -909,3 +909,28 @@ func testHostInSetupExperience(t *testing.T, ds *Datastore) { require.NoError(t, err) require.False(t, inSetupExperience) } + +func testGetSetupExperienceScriptByID(t *testing.T, ds *Datastore) { + ctx := context.Background() + + script := &fleet.Script{ + Name: "setup_experience_script", + ScriptContents: "echo hello", + } + + err := ds.SetSetupExperienceScript(ctx, script) + require.NoError(t, err) + + scriptByTeamID, err := ds.GetSetupExperienceScript(ctx, nil) + require.NoError(t, err) + + gotScript, err := ds.GetSetupExperienceScriptByID(ctx, scriptByTeamID.ID) + require.NoError(t, err) + + require.Equal(t, script.Name, gotScript.Name) + require.NotZero(t, gotScript.ScriptContentID) + + b, err := ds.GetAnyScriptContents(ctx, gotScript.ScriptContentID) + require.NoError(t, err) + require.Equal(t, script.ScriptContents, string(b)) +} diff --git a/server/fleet/activities.go b/server/fleet/activities.go index f62689adaa..751218ac6e 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -50,6 +50,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeChangedUserTeamRole{}, ActivityTypeDeletedUserTeamRole{}, + ActivityTypeFleetEnrolled{}, ActivityTypeMDMEnrolled{}, ActivityTypeMDMUnenrolled{}, @@ -795,6 +796,25 @@ func (a ActivityTypeDeletedUserTeamRole) Documentation() (activity string, detai }` } +type ActivityTypeFleetEnrolled struct { + HostSerial string `json:"host_serial"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeFleetEnrolled) ActivityName() string { + return "fleet_enrolled" +} + +func (a ActivityTypeFleetEnrolled) Documentation() (activity string, details string, detailsExample string) { + return `Generated when a host is enrolled to Fleet (Fleet's agent fleetd is installed).`, + `This activity contains the following fields: +- "host_serial": Serial number of the host. +- "host_display_name": Display name of the host.`, `{ + "host_serial": "B04FL3ALPT21", + "host_display_name": "WIN-DESKTOP-JGS78KJ7C" +}` +} + type ActivityTypeMDMEnrolled struct { HostSerial string `json:"host_serial"` HostDisplayName string `json:"host_display_name"` diff --git a/server/fleet/capabilities.go b/server/fleet/capabilities.go index 7da6f36205..d8abea7731 100644 --- a/server/fleet/capabilities.go +++ b/server/fleet/capabilities.go @@ -80,6 +80,9 @@ const ( CapabilityEndUserEmail Capability = "end_user_email" // CapabilityEscrowBuddy allows to use Escrow Buddy to rotate FileVault keys CapabilityEscrowBuddy Capability = "escrow_buddy" + // CapabilityLinuxDiskEncryptionEscrow denotes the ability of the server to escrow Ubuntu and Fedora disk + // encryption LUKS passphrases + CapabilityLinuxDiskEncryptionEscrow Capability = "linux_disk_encryption_escrow" // CapabilitySetupExperience denotes the ability of the server to support // installing software and running a script during macOS ADE enrollment, and // the ability of the client to show the corresponding UI to support that @@ -89,11 +92,12 @@ const ( func GetServerOrbitCapabilities() CapabilityMap { return CapabilityMap{ - CapabilityOrbitEndpoints: {}, - CapabilityTokenRotation: {}, - CapabilityEndUserEmail: {}, - CapabilityEscrowBuddy: {}, - CapabilitySetupExperience: {}, + CapabilityOrbitEndpoints: {}, + CapabilityTokenRotation: {}, + CapabilityEndUserEmail: {}, + CapabilityEscrowBuddy: {}, + CapabilityLinuxDiskEncryptionEscrow: {}, + CapabilitySetupExperience: {}, } } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2cfaf6deb3..6dfaae64b2 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -898,19 +898,29 @@ type Datastore interface { // GetHostEmails returns the emails associated with the provided host for a given source, such as "google_chrome_profiles" GetHostEmails(ctx context.Context, hostUUID string, source string) ([]string, error) SetOrUpdateHostDisksSpace(ctx context.Context, hostID uint, gigsAvailable, percentAvailable, gigsTotal float64) error + SetOrUpdateHostDisksEncryption(ctx context.Context, hostID uint, encrypted bool) error // SetOrUpdateHostDiskEncryptionKey sets the base64, encrypted key for // a host SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID uint, encryptedBase64Key, clientError string, decryptable *bool) error + // SaveLUKSData sets base64'd encrypted LUKS passphrase, key slot, and salt data for a host that has successfully + // escrowed LUKS data + SaveLUKSData(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error + // GetUnverifiedDiskEncryptionKeys returns all the encryption keys that // are collected but their decryptable status is not known yet (ie: // we're able to decrypt the key using a private key in the server) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]HostDiskEncryptionKey, error) // SetHostsDiskEncryptionKeyStatus sets the encryptable status for the set // of encription keys provided - SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error + SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, decryptable bool, threshold time.Time) error // GetHostDiskEncryptionKey returns the encryption key information for a given host GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*HostDiskEncryptionKey, error) + IsHostPendingEscrow(ctx context.Context, hostID uint) bool + ClearPendingEscrow(ctx context.Context, hostID uint) error + ReportEscrowError(ctx context.Context, hostID uint, err string) error + QueueEscrow(ctx context.Context, hostID uint) error + AssertHasNoEncryptionKeyStored(ctx context.Context, hostID uint) error // GetHostCertAssociationsToExpire retrieves host certificate // associations that are close to expire and don't have a renewal in @@ -1504,6 +1514,14 @@ type Datastore interface { // GetHostMDMProfileInstallStatus returns the status of the profile for the host. GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profileUUID string) (MDMDeliveryStatus, error) + /////////////////////////////////////////////////////////////////////////////// + // Linux MDM + + // GetLinuxDiskEncryptionSummary summarizes the current state of Linux disk encryption on + // each Linux host in the specified team (or, if no team is specified, each host that is not assigned + // to any team). + GetLinuxDiskEncryptionSummary(ctx context.Context, teamID *uint) (MDMLinuxDiskEncryptionSummary, error) + /////////////////////////////////////////////////////////////////////////////// // MDM Commands @@ -1778,14 +1796,46 @@ type Datastore interface { // Setup Experience // + // ListSetupExperienceResultsByHostUUID lists the setup experience results for a host by its UUID. ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*SetupExperienceStatusResult, error) + + // UpdateSetupExperienceStatusResult updates the given setup experience status result. UpdateSetupExperienceStatusResult(ctx context.Context, status *SetupExperienceStatusResult) error + + // EnqueueSetupExperienceItems enqueues the relevant setup experience items (software and + // script) for a given host. It first clears out any pre-existing setup experience items that + // were previously enqueued for the host (since the setup experience only happens once during + // the initial device setup). It then adds any software and script that have been configured for + // this team to the host's queue and sets their status to pending. If any items were enqueued, + // it returns true, otherwise it returns false. EnqueueSetupExperienceItems(ctx context.Context, hostUUID string, teamID uint) (bool, error) + + // GetSetupExperienceScript gets the setup experience script for a team. There can only be 1 + // setup experience script per team. GetSetupExperienceScript(ctx context.Context, teamID *uint) (*Script, error) + + // GetSetupExperienceScriptByID gets the setup experience script by its ID. + GetSetupExperienceScriptByID(ctx context.Context, scriptID uint) (*Script, error) + + // SetSetupExperienceScript sets the setup experience script to the given script. SetSetupExperienceScript(ctx context.Context, script *Script) error + + // DeleteSetupExperienceScript deletes the setup experience script for the given team. DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error + + // MaybeUpdateSetupExperienceScriptStatus updates the status of the setup experience script for + // the given host if the script result row exists. If there was an update, it returns true. + // Otherwise, it returns false. MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status SetupExperienceStatusResultStatus) (bool, error) + + // MaybeUpdateSetupExperienceSoftwareInstallStatus updates the status of the setup experience + // software installer for the given host if the software installer result row exists. If there + // was an update, it returns true. Otherwise, it returns false. MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status SetupExperienceStatusResultStatus) (bool, error) + + // MaybeUpdateSetupExperienceVPPStatus updates the status of the setup experience + // VPP app for the given host if the VPP app installer row exists. If there was an update, it + // returns true. Otherwise, it returns false. MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, commandUUID string, status SetupExperienceStatusResultStatus) (bool, error) // Fleet-maintained apps diff --git a/server/fleet/errors.go b/server/fleet/errors.go index bc2159e8b9..8a6dde5ae3 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -593,6 +593,8 @@ const ( // End user authentication EndUserAuthDEPWebURLConfiguredErrMsg = `End user authentication can't be configured when the configured automatic enrollment (DEP) profile specifies a configuration_web_url.` // #nosec G101 + // Labels + InvalidLabelSpecifiedErrMsg = "Invalid label name(s):" ) // ConflictError is used to indicate a conflict, such as a UUID conflict in the DB. diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index e402af5472..95ce9ee268 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -322,7 +322,7 @@ type Host struct { // DiskEncryptionEnabled is only returned by GET /host/{id} and so is not // exportable as CSV (which is the result of List Hosts endpoint). It is - // a *bool because for Linux we set it to NULL and omit it from the JSON + // a *bool because for some Linux we set it to NULL and omit it from the JSON // response if the host does not have disk encryption enabled. It is also // omitted if we don't have encryption information yet. DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty" db:"disk_encryption_enabled" csv:"-"` @@ -682,6 +682,12 @@ func (h *Host) IsDEPAssignedToFleet() bool { return h.DEPAssignedToFleet != nil && *h.DEPAssignedToFleet } +// IsLUKSSupported returns true if the host's platform is Linux and running +// one of the supported OS versions. +func (h *Host) IsLUKSSupported() bool { + return h.Platform == "ubuntu" || strings.Contains(h.OSVersion, "Fedora") // fedora h.Platform reports as "rhel" +} + // IsEligibleForWindowsMDMUnenrollment returns true if the host must be // unenrolled from Fleet's Windows MDM (if it MDM was disabled). func (h *Host) IsEligibleForWindowsMDMUnenrollment(isConnectedToFleetMDM bool) bool { @@ -1173,6 +1179,7 @@ type HostDiskEncryptionKey struct { Decryptable *bool `json:"-" db:"decryptable"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` DecryptedValue string `json:"key" db:"-"` + ClientError string `json:"-" db:"client_error"` } // HostSoftwareInstalledPath represents where in the file system a software on a host was installed diff --git a/server/fleet/labels.go b/server/fleet/labels.go index e7fd5d93c5..b9158ebb8b 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -2,6 +2,7 @@ package fleet import ( "fmt" + "strings" "time" ) @@ -179,3 +180,17 @@ func ReservedLabelNames() map[string]struct{} { BuiltinLabelFedoraLinux: {}, } } + +// DetectMissingLabels returns a list of labels present in the unvalidatedLabels list that could not be found in the validLabelMap. +func DetectMissingLabels(validLabelMap map[string]uint, unvalidatedLabels []string) []string { + missingLabels := make([]string, 0, len(unvalidatedLabels)) + + for _, rawLabel := range unvalidatedLabels { + label := strings.TrimSpace(rawLabel) + if _, ok := validLabelMap[label]; len(label) > 0 && !ok { + missingLabels = append(missingLabels, label) + } + } + + return missingLabels +} diff --git a/server/fleet/labels_test.go b/server/fleet/labels_test.go new file mode 100644 index 0000000000..1d390e3e57 --- /dev/null +++ b/server/fleet/labels_test.go @@ -0,0 +1,53 @@ +package fleet + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectMissingLabels(t *testing.T) { + labelMap := map[string]uint{"label1": 1, "label2": 2} + + testCases := []struct { + name string + labels []string + expected []string + }{ + { + name: "no labels", + labels: []string{}, + expected: []string{}, + }, + { + name: "empty label", + labels: []string{""}, + expected: []string{}, + }, + { + name: "one missing label", + labels: []string{"iamnotalabel"}, + expected: []string{"iamnotalabel"}, + }, + { + name: "missing multiple labels", + labels: []string{"mac", "windows"}, + expected: []string{"mac", "windows"}, + }, + { + name: "some missing labels and some valid labels", + labels: []string{"label1", "mac", "label2", "windows"}, + expected: []string{"mac", "windows"}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + missing := DetectMissingLabels(labelMap, tt.labels) + if !assert.Equal(t, tt.expected, missing) { + t.Errorf("Expected [%s], but got [%s]", strings.Join(tt.expected, ", "), strings.Join(missing, ", ")) + } + }) + } +} diff --git a/server/fleet/linux_mdm.go b/server/fleet/linux_mdm.go new file mode 100644 index 0000000000..7a9a5544e1 --- /dev/null +++ b/server/fleet/linux_mdm.go @@ -0,0 +1,7 @@ +package fleet + +type MDMLinuxDiskEncryptionSummary struct { + Verified uint `json:"verified"` + ActionRequired uint `json:"action_required"` + Failed uint `json:"failed"` +} diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 7f717eeb14..55e28bc7b9 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -298,6 +298,7 @@ type MDMCommandFilters struct { type MDMPlatformsCounts struct { MacOS uint `db:"macos" json:"macos"` Windows uint `db:"windows" json:"windows"` + Linux uint `db:"linux" json:"linux"` } type MDMDiskEncryptionSummary struct { diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go index 1d340b1e7b..6c06a963e2 100644 --- a/server/fleet/orbit.go +++ b/server/fleet/orbit.go @@ -40,6 +40,9 @@ type OrbitConfigNotifications struct { // RunSetupExperience indicates whether or not Orbit should run the Fleet setup experience // during macOS Setup Assistant. RunSetupExperience bool `json:"run_setup_experience,omitempty"` + + // RunDiskEncryptionEscrow tells Orbit to prompt the end user to escrow disk encryption data + RunDiskEncryptionEscrow bool `json:"run_disk_encryption_escrow,omitempty"` } type OrbitConfig struct { @@ -91,6 +94,10 @@ type OrbitHostInfo struct { // // If not set, then the HardwareUUID is used/set as the osquery identifier. OsqueryIdentifier string + // ComputerName is the device's friendly name (optional). + ComputerName string + // HardwareModel is the device's hardware model. For example: Standard PC (Q35 + ICH9, 2009) + HardwareModel string } // ExtensionInfo holds the data of a osquery extension to apply to an Orbit client. diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 28bba6ad8c..8b011642b1 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -149,6 +149,9 @@ type HostScriptRequestPayload struct { // SyncRequest is filled automatically based on the endpoint used to create // the execution request (synchronous or asynchronous). SyncRequest bool `json:"-"` + // SetupExperienceScriptID is the ID of the setup experience script related to this request + // payload, if such a script exists. + SetupExperienceScriptID *uint `json:"-"` } func (r HostScriptRequestPayload) ValidateParams(waitForResult time.Duration) error { @@ -251,6 +254,10 @@ type HostScriptResult struct { // results can still be returned to see activity details after the host got // deleted. HostDeletedAt *time.Time `json:"-" db:"host_deleted_at"` + + // SetupExperienceScriptID is the ID of the setup experience script, if this script execution + // was part of setup experience. + SetupExperienceScriptID *uint `json:"-" db:"setup_experience_script_id"` } func (hsr HostScriptResult) AuthzType() string { diff --git a/server/fleet/service.go b/server/fleet/service.go index 285ddac74d..ad12547a1d 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -394,6 +394,7 @@ type Service interface { GetMunkiIssue(ctx context.Context, munkiIssueID uint) (*MunkiIssue, error) HostEncryptionKey(ctx context.Context, id uint) (*HostDiskEncryptionKey, error) + EscrowLUKSData(ctx context.Context, passphrase string, salt string, keySlot *uint, clientError string) error // AddLabelsToHost adds the given label names to the host's label membership. // @@ -964,6 +965,8 @@ type Service interface { GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte, error) + TriggerLinuxDiskEncryptionEscrow(ctx context.Context, host *Host) error + // CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error) @@ -1049,11 +1052,18 @@ type Service interface { assumeEnabled *bool, ) error + /////////////////////////////////////////////////////////////////////////////// + // Linux MDM + + // LinuxHostDiskEncryptionStatus returns the current disk encryption status of the specified Linux host + // Returns empty status if the host is not a supported Linux host + LinuxHostDiskEncryptionStatus(ctx context.Context, host Host) (HostMDMDiskEncryption, error) + /////////////////////////////////////////////////////////////////////////////// // Common MDM - // GetMDMDiskEncryptionSummary returns the current disk encryption status of all macOS and - // Windows hosts in the specified team (or, if no team is specified, each host that is not + // GetMDMDiskEncryptionSummary returns the current disk encryption status of all macOS, Windows, and + // Linux hosts in the specified team (or, if no team is specified, each host that is not // assigned to any team). GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*MDMDiskEncryptionSummary, error) @@ -1169,4 +1179,6 @@ const ( BatchSetSoftwareInstallersStatusCompleted = "completed" // BatchSetSoftwareInstallerStatusFailed is the value returned for a failed BatchSetSoftwareInstallers operation. BatchSetSoftwareInstallersStatusFailed = "failed" + // MinOrbitLUKSVersion is the earliest version of Orbit that can escrow LUKS passphrases + MinOrbitLUKSVersion = "1.36.0" ) diff --git a/server/fleet/utils.go b/server/fleet/utils.go index ddab26e8e7..fb286b56a7 100644 --- a/server/fleet/utils.go +++ b/server/fleet/utils.go @@ -6,6 +6,7 @@ import ( "io" "strings" + "github.com/Masterminds/semver" "github.com/fatih/color" "golang.org/x/text/unicode/norm" ) @@ -65,3 +66,28 @@ func Preprocess(input string) string { // Normalize Unicode characters. return norm.NFC.String(input) } + +// CompareVersions returns an integer comparing two versions according to semantic version +// precedence. The result will be 0 if a == b, -1 if a < b, or +1 if a > b. +// An invalid semantic version string is considered less than a valid one. All invalid semantic +// version strings compare equal to each other. +func CompareVersions(a string, b string) int { + verA, errA := semver.NewVersion(a) + verB, errB := semver.NewVersion(b) + switch { + case errA != nil && errB != nil: + return 0 + case errA != nil: + return -1 + case errB != nil: + return 1 + default: + return verA.Compare(verB) + } +} + +// IsAtLeastVersion returns whether currentVersion is at least minimumVersion, using semantics +// of CompareVersions for version validity +func IsAtLeastVersion(currentVersion string, minimumVersion string) bool { + return CompareVersions(currentVersion, minimumVersion) >= 0 +} diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 6f63d10540..397ac63372 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service" @@ -20,6 +19,7 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/micromdm/nanolib/log/stdlogfmt" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" ) @@ -172,7 +172,7 @@ func newMockAPNSPushProviderFactory() (*svcmock.APNSPushProviderFactory, *svcmoc return factory, provider } -func mockSuccessfulPush(pushes []*mdm.Push) (map[string]*push.Response, error) { +func mockSuccessfulPush(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { res := make(map[string]*push.Response, len(pushes)) for _, p := range pushes { res[p.Token.String()] = &push.Response{ diff --git a/server/mdm/apple/gdmf/api.go b/server/mdm/apple/gdmf/api.go index ee8c671814..d969c25800 100644 --- a/server/mdm/apple/gdmf/api.go +++ b/server/mdm/apple/gdmf/api.go @@ -15,7 +15,6 @@ import ( "github.com/cenkalti/backoff" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/fleet" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" ) const baseURL = "https://gdmf.apple.com/v2/pmv" @@ -91,7 +90,7 @@ func GetLatestOSVersion(device fleet.MDMAppleMachineInfo) (*Asset, error) { latestIdx = i // first match found, update the index continue } - if apple_mdm.CompareVersions(assetSet[latestIdx].ProductVersion, s.ProductVersion) < 0 { + if fleet.CompareVersions(assetSet[latestIdx].ProductVersion, s.ProductVersion) < 0 { latestIdx = i // found a later version, update the index } } diff --git a/server/mdm/apple/util.go b/server/mdm/apple/util.go index 88267f953f..200e1d4f39 100644 --- a/server/mdm/apple/util.go +++ b/server/mdm/apple/util.go @@ -124,22 +124,3 @@ func IsLessThanVersion(current string, target string) (bool, error) { return cv.LessThan(tv), nil } - -// CompareVersions returns an integer comparing two versions according to semantic version -// precedence. The result will be 0 if a == b, -1 if a < b, or +1 if a > b. -// An invalid semantic version string is considered less than a valid one. All invalid semantic -// version strings compare equal to each other. -func CompareVersions(a string, b string) int { - verA, errA := semver.NewVersion(a) - verB, errB := semver.NewVersion(b) - switch { - case errA != nil && errB != nil: - return 0 - case errA != nil: - return -1 - case errB != nil: - return 1 - default: - return verA.Compare(verB) - } -} diff --git a/server/mdm/crypto/scep.go b/server/mdm/crypto/scep.go index 2ba39d37d6..ae2b8bb927 100644 --- a/server/mdm/crypto/scep.go +++ b/server/mdm/crypto/scep.go @@ -23,7 +23,7 @@ func NewSCEPVerifier(ds fleet.MDMAssetRetriever) *SCEPVerifier { } } -func (s *SCEPVerifier) Verify(cert *x509.Certificate) error { +func (s *SCEPVerifier) Verify(ctx context.Context, cert *x509.Certificate) error { if cert == nil { return errors.New("no certificate provided") } @@ -33,8 +33,7 @@ func (s *SCEPVerifier) Verify(cert *x509.Certificate) error { Roots: x509.NewCertPool(), } - // TODO(roberto): nano interfaces don't allow to pass a context to this function - rootCert, err := assets.X509Cert(context.Background(), s.ds, fleet.MDMAssetCACert) + rootCert, err := assets.X509Cert(ctx, s.ds, fleet.MDMAssetCACert) if err != nil { return fmt.Errorf("loading existing assets from the database: %w", err) } diff --git a/server/mdm/crypto/scep_test.go b/server/mdm/crypto/scep_test.go index b59bc62e82..7ff15958f4 100644 --- a/server/mdm/crypto/scep_test.go +++ b/server/mdm/crypto/scep_test.go @@ -21,7 +21,7 @@ import ( func TestSCEPVerifierVerifyEmptyCerts(t *testing.T) { v := &SCEPVerifier{} - err := v.Verify(nil) + err := v.Verify(context.Background(), nil) require.ErrorContains(t, err, "no certificate provided") } @@ -88,7 +88,7 @@ func TestVerify(t *testing.T) { }, nil } - err := verifier.Verify(tt.certToVerify) + err := verifier.Verify(context.Background(), tt.certToVerify) if tt.wantErr == "" { require.NoError(t, err) } else { diff --git a/server/mdm/maintainedapps/apps.json b/server/mdm/maintainedapps/apps.json index d7d06e1cfb..c35dfb4f90 100644 --- a/server/mdm/maintainedapps/apps.json +++ b/server/mdm/maintainedapps/apps.json @@ -35,6 +35,9 @@ "identifier": "cloudflare-warp", "bundle_identifier": "com.cloudflare.1dot1dot1dot1.macos", "installer_format": "pkg", + "post_uninstall_scripts": [ + "/Applications/Cloudflare\\ WARP.app/Contents/Resources/uninstall.sh" + ], "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.cloudflare.1dot1dot1dot1.macos';" }, { diff --git a/server/mdm/maintainedapps/testdata/scripts/cloudflare-warp_uninstall.golden.sh b/server/mdm/maintainedapps/testdata/scripts/cloudflare-warp_uninstall.golden.sh index 838490f85e..d118bf83e5 100644 --- a/server/mdm/maintainedapps/testdata/scripts/cloudflare-warp_uninstall.golden.sh +++ b/server/mdm/maintainedapps/testdata/scripts/cloudflare-warp_uninstall.golden.sh @@ -113,6 +113,7 @@ remove_launchctl_service 'com.cloudflare.1dot1dot1dot1.macos.loginlauncherapp' remove_launchctl_service 'com.cloudflare.1dot1dot1dot1.macos.warp.daemon' quit_application 'com.cloudflare.1dot1dot1dot1.macos' sudo pkgutil --forget 'com.cloudflare.1dot1dot1dot1.macos' +/Applications/Cloudflare\ WARP.app/Contents/Resources/uninstall.sh trash $LOGGED_IN_USER '~/Library/Application Scripts/com.cloudflare.1dot1dot1dot1.macos.loginlauncherapp' trash $LOGGED_IN_USER '~/Library/Application Support/com.cloudflare.1dot1dot1dot1.macos' trash $LOGGED_IN_USER '~/Library/Caches/com.cloudflare.1dot1dot1dot1.macos' diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index 5aaae483d8..cf800dcfa6 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -3,8 +3,13 @@ package mdm import ( "bytes" "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" "crypto/x509" "encoding/base64" + "fmt" + "io" "github.com/smallstep/pkcs7" ) @@ -81,6 +86,55 @@ func GuessProfileExtension(profile []byte) string { } } +func EncryptAndEncode(plainText string, symmetricKey string) (string, error) { + block, err := aes.NewCipher([]byte(symmetricKey)) + if err != nil { + return "", fmt.Errorf("create new cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create new gcm: %w", err) + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + + return base64.StdEncoding.EncodeToString(aesGCM.Seal(nonce, nonce, []byte(plainText), nil)), nil +} + +func DecodeAndDecrypt(base64CipherText string, symmetricKey string) (string, error) { + encrypted, err := base64.StdEncoding.DecodeString(base64CipherText) + if err != nil { + return "", fmt.Errorf("base64 decode: %w", err) + } + + block, err := aes.NewCipher([]byte(symmetricKey)) + if err != nil { + return "", fmt.Errorf("create new cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create new gcm: %w", err) + } + + // Get the nonce size + nonceSize := aesGCM.NonceSize() + + // Extract the nonce from the encrypted data + nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:] + + decrypted, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decrypting: %w", err) + } + + return string(decrypted), nil +} + const ( // FleetdConfigProfileName is the value for the PayloadDisplayName used by // fleetd to read configuration values from the system. diff --git a/server/mdm/nanomdm/README.md b/server/mdm/nanomdm/README.md index ff719ba6c2..5a0cc5a342 100644 --- a/server/mdm/nanomdm/README.md +++ b/server/mdm/nanomdm/README.md @@ -1,10 +1,9 @@ # NanoMDM > The contents of this directory were copied (on January 2024) from https://github.com/fleetdm/nanomdm (the `apple-mdm` branch) which was forked from https://github.com/micromdm/nanomdm. +> They were updated in November 2024 with changes up to github.com/micromdm/nanomdm@825f2979a2dc28c6cc57bb62aff16737978bd90e -[![Go](https://github.com/micromdm/nanomdm/workflows/Go/badge.svg)](https://github.com/micromdm/nanomdm/actions) - -NanoMDM is a minimalist [Apple MDM server](https://support.apple.com/business/enrollment-deployment) heavily inspired by MicroMDM. +NanoMDM is a minimalist [Apple MDM server](https://developer.apple.com/documentation/devicemanagement) heavily inspired by [MicroMDM](https://github.com/micromdm/micromdm). ## Getting started & Documentation @@ -56,3 +55,25 @@ NanoMDM, at its core, is a thin composable layer between HTTP handlers and a set - The storage layer is a set of interfaces and implementations that store & retrieve MDM enrollment and command data. These exist under the `storage` package. You can read more about the architecture in the blog post [Introducing NanoMDM](https://micromdm.io/blog/introducing-nanomdm/). + +## Running unit tests locally + +1. Start up MySQL `docker compose up` +2. Load schema: `mysql --user=fleet -pinsecure --host=127.0.0.1 --port=3800 --protocol=TCP fleet < ./storage/mysql/schema.sql` +3. `export NANOMDM_MYSQL_STORAGE_TEST_DSN=fleet:insecure@tcp(127.0.0.1:3800)/fleet` +4. `go test -v -parallel 8 -race=true ./...` + +### Clean up MySQL after running tests + +```mysql +SET FOREIGN_KEY_CHECKS = 0; +truncate table nano_cert_auth_associations; +truncate table nano_command_results; +truncate table nano_commands; +truncate table nano_devices; +truncate table nano_enrollment_queue; +truncate table nano_enrollments; +truncate table nano_push_certs; +truncate table nano_users; +SET FOREIGN_KEY_CHECKS = 1; +``` diff --git a/server/mdm/nanomdm/certverify/fallback.go b/server/mdm/nanomdm/certverify/fallback.go new file mode 100644 index 0000000000..29115f9053 --- /dev/null +++ b/server/mdm/nanomdm/certverify/fallback.go @@ -0,0 +1,41 @@ +package certverify + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "strings" +) + +// CertVerifier is a simple interface for verifying a certificate. +type CertVerifier interface { + Verify(context.Context, *x509.Certificate) error +} + +// FallbackVerifier verfies certificate validity using multiple verifiers. +type FallbackVerifier struct { + verifiers []CertVerifier +} + +// NewFallbackVerifier creates a new verifier using other verifiers. +func NewFallbackVerifier(verifiers ...CertVerifier) *FallbackVerifier { + return &FallbackVerifier{verifiers: verifiers} +} + +// Verify performs certificate verification. +// Any verifier returning nil ("passes") will pass (return nil) and not +// check any other verifier. +// If all verifiers return non-nil ("fail") then an error for all +// verifiers will be returned. +func (v *FallbackVerifier) Verify(ctx context.Context, cert *x509.Certificate) error { + var errs []string + for i, verifier := range v.verifiers { + err := verifier.Verify(ctx, cert) + if err == nil { + return nil + } + errs = append(errs, fmt.Sprintf("fallback error (%d): %v", i, err)) + } + return errors.New(strings.Join(errs, "; ")) +} diff --git a/server/mdm/nanomdm/certverify/fallback_test.go b/server/mdm/nanomdm/certverify/fallback_test.go new file mode 100644 index 0000000000..f814e0da1c --- /dev/null +++ b/server/mdm/nanomdm/certverify/fallback_test.go @@ -0,0 +1,50 @@ +package certverify + +import ( + "context" + "crypto/x509" + "errors" + "testing" +) + +type errVerifier struct{ err error } + +func (v *errVerifier) Verify(_ context.Context, _ *x509.Certificate) error { + return v.err +} + +var nilErroringVerifier = &errVerifier{} +var errErroringVerifier = &errVerifier{err: errors.New("verifier error")} + +func TestFallbackVerifier(t *testing.T) { + v := NewFallbackVerifier(nilErroringVerifier) + err := v.Verify(context.Background(), nil) + if err != nil { + t.Errorf("should not have errored: %v", err) + } + + v = NewFallbackVerifier(nilErroringVerifier, nilErroringVerifier) + if err = v.Verify(context.Background(), nil); err != nil { + t.Errorf("should not have errored: %v", err) + } + + v = NewFallbackVerifier(errErroringVerifier) + if err = v.Verify(context.Background(), nil); err == nil { + t.Error("should have errored") + } + + v = NewFallbackVerifier(errErroringVerifier, nilErroringVerifier) + if err = v.Verify(context.Background(), nil); err != nil { + t.Errorf("should not have errored: %v", err) + } + + v = NewFallbackVerifier(nilErroringVerifier, errErroringVerifier) + if err = v.Verify(context.Background(), nil); err != nil { + t.Errorf("should not have errored: %v", err) + } + + v = NewFallbackVerifier(errErroringVerifier, errErroringVerifier) + if err = v.Verify(context.Background(), nil); err == nil { + t.Error("should have errored") + } +} diff --git a/server/mdm/nanomdm/certverify/pool.go b/server/mdm/nanomdm/certverify/pool.go index 244c22a736..7c9fbc931e 100644 --- a/server/mdm/nanomdm/certverify/pool.go +++ b/server/mdm/nanomdm/certverify/pool.go @@ -1,6 +1,7 @@ package certverify import ( + "context" "crypto/x509" "errors" ) @@ -25,7 +26,7 @@ func NewPoolVerifier(rootsPEM []byte, keyUsages ...x509.ExtKeyUsage) (*PoolVerif } // Verify performs certificate verification -func (v *PoolVerifier) Verify(cert *x509.Certificate) error { +func (v *PoolVerifier) Verify(_ context.Context, cert *x509.Certificate) error { if cert == nil { return errors.New("missing MDM certificate") } diff --git a/server/mdm/nanomdm/certverify/signature.go b/server/mdm/nanomdm/certverify/signature.go index 62ea306e26..bae05e4585 100644 --- a/server/mdm/nanomdm/certverify/signature.go +++ b/server/mdm/nanomdm/certverify/signature.go @@ -1,6 +1,7 @@ package certverify import ( + "context" "crypto/x509" "errors" @@ -25,7 +26,7 @@ func NewSignatureVerifier(rootPEM []byte) (*SignatureVerifier, error) { } // Verify checks only the signature of the certificate against the CA -func (v *SignatureVerifier) Verify(cert *x509.Certificate) error { +func (v *SignatureVerifier) Verify(_ context.Context, cert *x509.Certificate) error { if cert == nil { return errors.New("missing MDM certificate") } diff --git a/server/mdm/nanomdm/cli/cli.go b/server/mdm/nanomdm/cli/cli.go index 5c454bac03..c91da5150e 100644 --- a/server/mdm/nanomdm/cli/cli.go +++ b/server/mdm/nanomdm/cli/cli.go @@ -6,15 +6,14 @@ import ( "fmt" "strings" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/allmulti" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/file" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/mysql" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/pgsql" _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" + "github.com/micromdm/nanolib/log" ) type StringAccumulator []string @@ -74,12 +73,6 @@ func (s *Storage) Parse(logger log.Logger) (storage.AllStorage, error) { return nil, err } mdmStorage = append(mdmStorage, mysqlStorage) - case "pgsql": - pgsqlStorage, err := pgsqlStorageConfig(dsn, options, logger) - if err != nil { - return nil, err - } - mdmStorage = append(mdmStorage, pgsqlStorage) default: return nil, fmt.Errorf("unknown storage: %s", storage) } @@ -142,27 +135,3 @@ func splitOptions(s string) map[string]string { } return out } - -func pgsqlStorageConfig(dsn, options string, logger log.Logger) (*pgsql.PgSQLStorage, error) { - logger = logger.With("storage", "pgsql") - opts := []pgsql.Option{ - pgsql.WithDSN(dsn), - pgsql.WithLogger(logger), - } - if options != "" { - for k, v := range splitOptions(options) { - switch k { - case "delete": - if v == "1" { - opts = append(opts, pgsql.WithDeleteCommands()) - logger.Debug("msg", "deleting commands") - } else if v != "0" { - return nil, fmt.Errorf("invalid value for delete option: %q", v) - } - default: - return nil, fmt.Errorf("invalid option: %q", k) - } - } - } - return pgsql.New(opts...) -} diff --git a/server/mdm/nanomdm/cmd/nano2nano/main.go b/server/mdm/nanomdm/cmd/nano2nano/main.go index b395eca412..da2c525e6b 100644 --- a/server/mdm/nanomdm/cmd/nano2nano/main.go +++ b/server/mdm/nanomdm/cmd/nano2nano/main.go @@ -11,8 +11,9 @@ import ( "net/http" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cli" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + + "github.com/micromdm/nanolib/log/stdlogfmt" ) // overridden by -ldflags -X diff --git a/server/mdm/nanomdm/cmd/nanomdm/main.go b/server/mdm/nanomdm/cmd/nanomdm/main.go index dbf8e1ccec..806937121c 100644 --- a/server/mdm/nanomdm/cmd/nanomdm/main.go +++ b/server/mdm/nanomdm/cmd/nanomdm/main.go @@ -9,15 +9,17 @@ import ( "math/rand" "net/http" "os" + "strings" "time" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/certverify" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cli" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" mdmhttp "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http" httpapi "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/api" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/authproxy" httpmdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/mdm" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/buford" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/nanopush" pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth" @@ -25,6 +27,8 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/microwebhook" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/multi" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/nanomdm" + + "github.com/micromdm/nanolib/log/stdlogfmt" ) // overridden by -ldflags -X @@ -34,6 +38,8 @@ const ( endpointMDM = "/mdm" endpointCheckin = "/checkin" + endpointAuthProxy = "/authproxy/" + endpointAPIPushCert = "/v1/pushcert" endpointAPIPush = "/v1/push/" endpointAPIEnqueue = "/v1/enqueue/" @@ -41,6 +47,11 @@ const ( endpointAPIVersion = "/version" ) +const ( + EnrollmentIDHeader = "X-Enrollment-ID" + TraceIDHeader = "X-Trace-ID" +) + func main() { cliStorage := cli.NewStorage() flag.Var(&cliStorage.Storage, "storage", "name of storage backend") @@ -61,6 +72,8 @@ func main() { flMigration = flag.Bool("migration", false, "HTTP endpoint for enrollment migrations") flRetro = flag.Bool("retro", false, "Allow retroactive certificate-authorization association") flDMURLPfx = flag.String("dm", "", "URL to send Declarative Management requests to") + flAuthProxy = flag.String("auth-proxy-url", "", "Reverse proxy URL target for MDM-authenticated HTTP requests") + flUAZLChal = flag.Bool("ua-zl-dc", false, "reply with zero-length DigestChallenge for UserAuthenticate") ) flag.Parse() @@ -92,10 +105,20 @@ func main() { stdlog.Fatal(err) } + tokenMux := nanomdm.NewTokenMux() + // create 'core' MDM service - nanoOpts := []nanomdm.Option{nanomdm.WithLogger(logger.With("service", "nanomdm"))} + nanoOpts := []nanomdm.Option{ + nanomdm.WithUserAuthenticate(nanomdm.NewUAService(mdmStorage, *flUAZLChal)), + nanomdm.WithGetToken(tokenMux), + nanomdm.WithLogger(logger.With("service", "nanomdm")), + } if *flDMURLPfx != "" { - logger.Debug("msg", "declarative management setup", "url", *flDMURLPfx) + var warningText string + if !strings.HasSuffix(*flDMURLPfx, "/") { + warningText = ": warning: URL has no trailing slash" + } + logger.Debug("msg", "declarative management setup"+warningText, "url", *flDMURLPfx) dm, err := nanomdm.NewDeclarativeManagementHTTPCaller(*flDMURLPfx, http.DefaultClient) if err != nil { stdlog.Fatal(err) @@ -121,6 +144,22 @@ func main() { mdmService = dump.New(mdmService, os.Stdout) } + // helper for authorizing MDM clients requests + certAuthMiddleware := func(h http.Handler) http.Handler { + h = httpmdm.CertVerifyMiddleware(h, verifier, logger.With("handler", "cert-verify")) + if *flCertHeader != "" { + h = httpmdm.CertExtractPEMHeaderMiddleware(h, *flCertHeader, logger.With("handler", "cert-extract")) + } else { + opts := []httpmdm.SigLogOption{httpmdm.SigLogWithLogger(logger.With("handler", "cert-extract"))} + if *flDebug { + opts = append(opts, httpmdm.SigLogWithLogErrors(true)) + } + h = httpmdm.CertExtractMdmSignatureMiddleware(h, httpmdm.MdmSignatureVerifierFunc(cryptoutil.VerifyMdmSignature), + opts...) + } + return h + } + // register 'core' MDM HTTP handler var mdmHandler http.Handler if *flCheckin { @@ -130,33 +169,41 @@ func main() { // if we don't use a check-in handler then do both mdmHandler = httpmdm.CheckinAndCommandHandler(mdmService, logger.With("handler", "checkin-command")) } - mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, verifier, logger.With("handler", "cert-verify")) - if *flCertHeader != "" { - mdmHandler = httpmdm.CertExtractPEMHeaderMiddleware(mdmHandler, *flCertHeader, logger.With("handler", "cert-extract")) - } else { - mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, logger.With("handler", "cert-extract")) - } + mdmHandler = certAuthMiddleware(mdmHandler) mux.Handle(endpointMDM, mdmHandler) if *flCheckin { // if we specified a separate check-in handler, set it up var checkinHandler http.Handler checkinHandler = httpmdm.CheckinHandler(mdmService, logger.With("handler", "checkin")) - checkinHandler = httpmdm.CertVerifyMiddleware(checkinHandler, verifier, logger.With("handler", "cert-verify")) - if *flCertHeader != "" { - checkinHandler = httpmdm.CertExtractPEMHeaderMiddleware(checkinHandler, *flCertHeader, logger.With("handler", "cert-extract")) - } else { - checkinHandler = httpmdm.CertExtractMdmSignatureMiddleware(checkinHandler, logger.With("handler", "cert-extract")) - } + checkinHandler = certAuthMiddleware(checkinHandler) mux.Handle(endpointCheckin, checkinHandler) } + + if *flAuthProxy != "" { + var authProxyHandler http.Handler + authProxyHandler, err = authproxy.New(*flAuthProxy, + authproxy.WithLogger(logger.With("handler", "authproxy")), + authproxy.WithHeaderFunc(EnrollmentIDHeader, httpmdm.GetEnrollmentID), + authproxy.WithHeaderFunc(TraceIDHeader, mdmhttp.GetTraceID), + ) + if err != nil { + stdlog.Fatal(err) + } + logger.Debug("msg", "authproxy setup", "url", *flAuthProxy) + authProxyHandler = http.StripPrefix(endpointAuthProxy, authProxyHandler) + authProxyHandler = httpmdm.CertWithEnrollmentIDMiddleware(authProxyHandler, certauth.HashCert, mdmStorage, true, + logger.With("handler", "with-enrollment-id")) + authProxyHandler = certAuthMiddleware(authProxyHandler) + mux.Handle(endpointAuthProxy, authProxyHandler) + } } if *flAPIKey != "" { const apiUsername = "nanomdm" // create our push provider and push service - pushProviderFactory := buford.NewPushProviderFactory() + pushProviderFactory := nanopush.NewFactory() pushService := pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, logger.With("service", "push")) // register API handler for push cert storage/upload. diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil.go b/server/mdm/nanomdm/cryptoutil/cryptoutil.go index ad62b4587a..b4c56154dd 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil.go @@ -40,9 +40,7 @@ func TopicFromPEMCert(pemCert []byte) (string, error) { return TopicFromCert(cert) } -// VerifyMdmSignature verifies an Apple MDM "Mdm-Signature" header and -// returns the signing certificate. -// +// VerifyMdmSignature verifies an Apple MDM "Mdm-Signature" header and returns the signing certificate. // See https://developer.apple.com/documentation/devicemanagement/implementing_device_management/managing_certificates_for_mdm_servers_and_devices // section "Pass an Identity Certificate Through a Proxy." func VerifyMdmSignature(header string, body []byte) (*x509.Certificate, error) { diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go index 5b5291709c..d5b71a7b00 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go @@ -7,10 +7,13 @@ import ( "github.com/smallstep/pkcs7" ) +const mdmSignatureHeader1 = "MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIDIzCCAx8wggIHoAMCAQICAQQwDQYJKoZIhvcNAQELBQAwPTETMBEGCgmSJomT8ixkARkWA2NvbTEVMBMGCgmSJomT8ixkARkWBUd1c3RvMQ8wDQYDVQQDDAZNRE0gQ0EwHhcNMjEwOTE4MTg0NTA1WhcNMjIwOTE4MTg0NTA1WjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Weag+4AQFkLrgm2/lZCdjGj5KC2rbIKdBdfExxaFWmvTtNCdXWyd5eROboRuEG/D1Zun0WKaKc1/emikBhnXP4qzEnNobx1OOfzeR1ZiazwftgAKrDZK6e4IJo15x8juRZvbjfKAQV+fw6TIGe4COUKpBtJo1idxJzI6OO2pQ6tvfzxhvbeD8VtYoHFgTXmBDHqUjmixdM+RIDUqReemaTeK5ybWTw3ZrydR7lM+I92Y9x/sRSxTODjgcczmprMVFl7a/d7biuqJtxg/RRVA85LWE3Gl+3BaVi9TC8xzaVioC++RmbXe3Z5qHmm+fkhfIzHksBW0Yn0DmWZRoWpgwIDAQABo2cwZTAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFCxqnx50ZpbKaED6AAsxSScMguy6MB8GA1UdIwQYMBaAFBoyVn803d9H43znmXRJGmE066VrMA0GCSqGSIb3DQEBCwUAA4IBAQAU0jY/wjNth2fJsp49hbhEUFFPJIvM9lS5cWmSX2Xg7cK1pzDZJktA5MAZaLxbYCqpM9HegE3WhpyzaFRcIpBWV6T4R70gWbKcwn7WzAII0TBbDD4nZz2tO0kdLXA4LPyPjm/tJxzNvLfYmVNF61oImU2KXT/zp7rXOLU3KhkA4cWN9TApClTIZqlzr64T07HUA94S2ee9ia8/U2ITOswtYrGNYmky1PA9/GlcGaxm5LkthmIq4qh5/e8J8rfSXvz7GVuVqoZOBPVTQkBChG6ANCtTr8nniRIv+3L3042XjclVFj5mcLsXO5EN/v0i11ICcLs2SRJAF058CPLS7azgMYIBaTCCAWUCAQEwQjA9MRMwEQYKCZImiZPyLGQBGRYDY29tMRUwEwYKCZImiZPyLGQBGRYFR3VzdG8xDzANBgNVBAMMBk1ETSBDQQIBBDAJBgUrDgMCGgUAMA0GCSqGSIb3DQEBBQUABIIBAABiveq4A69qvK2FjCMdhm6o9aBPfTw8WiJU9I6UppTbvw1+o2OBVLAOCXw46v1SIbj7Lhq5EDm3qXLD2xkF9zd5W43PvNZFleL735De+I1IeyXOvkmElOioipDNwrRpsET6vL2zwYlE0JZuGVhr2EU8ra3czy4eAbJwvV2xHLjpvqQJZh0LNvBc10sp7Q/99qpVdCXagUPJTh68Pcua51JiUWn0tDn0eaj083Yyx+I1XNR9opYuBEVz/LwFSsUGiB9zV7KbsLikajD2+Jmues5vS2jOrmCpV+yMN3uMa4lmOlgrQoi4l62edTo45zgnEZOUle0zT2pInMgML8KiWt8AAAAAAAA=" +const mdmSignatureHeader2 = "MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIDITCCAx0wggIFoAMCAQICASIwDQYJKoZIhvcNAQELBQAwOzELMAkGA1UEBhMCVVMxDTALBgNVBAoTBHNjZXAxEDAOBgNVBAsTB1NDRVAgQ0ExCzAJBgNVBAMTAmNhMB4XDTIxMDUyODA1MDkyM1oXDTMxMDUyNjA1MDkyM1owADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO1oBJs+BT6daIs7bUWib7Ji1XGpDJ9e5uPtBJ3SVj2eIStDcl9bJj5JPle8HF+WI1HQ7yIT15zu3GSN6xO6gATAk1j/vv0JFimKXa9Q017mvg5ACUcRScY1mnya7pYL9b5C/b02ubOglEMCLnekhDKJhlVTwbdC0n0ZFr6aDWZlIxHFIym28Et9F+JeRJSRCAR6jybr+nRPavxPhGnbrImBM0z6HkzbjlHDlc5g3TOrj67bjz2nT+i6isEyj1stsOeDXH4Ij3z6A3jVW12/XAsHVoaC1KqmzXV2SWuOT9oXnlxs5NxoYJYQC+XN1x604cKauLNZFHjCFOFeeubVHucCAwEAAaNnMGUwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMCMB0GA1UdDgQWBBQpqTV2kEzUfYz+4Mws1S6/D2hGnzAfBgNVHSMEGDAWgBT5TUXoM3msHybCjct8RyHuOpPr6DANBgkqhkiG9w0BAQsFAAOCAQEAMdbAM0+753sYs9erWV+QW8PTYHHTlrGaOU9VB3QaY0TG6iy/k6+l/6rYWQGW+RyElxju6xof9BVYgKb17EB0vm635PSFrAviyp495vLSFesvUUkUr7ALKC7f6H8ud3JIRhycyyih30YSlZrCwduMNjocYXPZN36hsyLeG3c977RGdHLrXFAMCvOSqHLktj/pYChR95XwrKVBaX2jtsk9B9UcDmubccPjROUKWHn3k5f2R+EJv9WVmFqNt7doG4nAAZx0ktDdgzVO7eWz/RwfFZlA9Qtm4+gG5eQd8e8HVefcCNOT/HcjfBblsOM7jzQlIvxvNss8haQ6TC//Fk/0rTGCAWcwggFjAgEBMEAwOzELMAkGA1UEBhMCVVMxDTALBgNVBAoTBHNjZXAxEDAOBgNVBAsTB1NDRVAgQ0ExCzAJBgNVBAMTAmNhAgEiMAkGBSsOAwIaBQAwDQYJKoZIhvcNAQEFBQAEggEA5h26uR27aLsghfAuDGe0mefTcxnPbqpiOFzh8t7WbK69giGQIIfs/NVqdFvYn210l2y5cAyHFVU81w6MmvqjHQJqVLptyYVDXgYy0/tsRc6Tbc+5kKD3GEkP8Z4uMCn75/8QlXFCj5DTVNWTZ8Vn/Itns51z+JYccOcsj9TnLX+0aaLz3I8X+r31Ftlx3UP6keW7HDo8m6JeGN4P3t51Dqjt08zyu72yR/gxaS91SHROypPAwMbiKqaxGiAPChgACmZDf0c/+g5T6Q0qc++cEAP9n+ViSjXyoDtS3YbfWxiiJaiieXLAiPjU9eVcXajEAwJUOJtTfECrNd5pgNpLBgAAAAAAAA==" +const mdmSignatureBody2 = "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0IFBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI+CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo8ZGljdD4KCTxrZXk+U3RhdHVzPC9rZXk+Cgk8c3RyaW5nPklkbGU8L3N0cmluZz4KCTxrZXk+VURJRDwva2V5PgoJPHN0cmluZz5ENEQ1NjA4My1CRkIwLTUwNDctOEJERi0yNzI3QkFDREM1OUI8L3N0cmluZz4KPC9kaWN0Pgo8L3BsaXN0Pgo=" + func TestPKCS7ParseTagLengthError(t *testing.T) { // Regression test: Older versions of the library might return this BER tag length error. - mdmSignatureHeader := "MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIDIzCCAx8wggIHoAMCAQICAQQwDQYJKoZIhvcNAQELBQAwPTETMBEGCgmSJomT8ixkARkWA2NvbTEVMBMGCgmSJomT8ixkARkWBUd1c3RvMQ8wDQYDVQQDDAZNRE0gQ0EwHhcNMjEwOTE4MTg0NTA1WhcNMjIwOTE4MTg0NTA1WjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Weag+4AQFkLrgm2/lZCdjGj5KC2rbIKdBdfExxaFWmvTtNCdXWyd5eROboRuEG/D1Zun0WKaKc1/emikBhnXP4qzEnNobx1OOfzeR1ZiazwftgAKrDZK6e4IJo15x8juRZvbjfKAQV+fw6TIGe4COUKpBtJo1idxJzI6OO2pQ6tvfzxhvbeD8VtYoHFgTXmBDHqUjmixdM+RIDUqReemaTeK5ybWTw3ZrydR7lM+I92Y9x/sRSxTODjgcczmprMVFl7a/d7biuqJtxg/RRVA85LWE3Gl+3BaVi9TC8xzaVioC++RmbXe3Z5qHmm+fkhfIzHksBW0Yn0DmWZRoWpgwIDAQABo2cwZTAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFCxqnx50ZpbKaED6AAsxSScMguy6MB8GA1UdIwQYMBaAFBoyVn803d9H43znmXRJGmE066VrMA0GCSqGSIb3DQEBCwUAA4IBAQAU0jY/wjNth2fJsp49hbhEUFFPJIvM9lS5cWmSX2Xg7cK1pzDZJktA5MAZaLxbYCqpM9HegE3WhpyzaFRcIpBWV6T4R70gWbKcwn7WzAII0TBbDD4nZz2tO0kdLXA4LPyPjm/tJxzNvLfYmVNF61oImU2KXT/zp7rXOLU3KhkA4cWN9TApClTIZqlzr64T07HUA94S2ee9ia8/U2ITOswtYrGNYmky1PA9/GlcGaxm5LkthmIq4qh5/e8J8rfSXvz7GVuVqoZOBPVTQkBChG6ANCtTr8nniRIv+3L3042XjclVFj5mcLsXO5EN/v0i11ICcLs2SRJAF058CPLS7azgMYIBaTCCAWUCAQEwQjA9MRMwEQYKCZImiZPyLGQBGRYDY29tMRUwEwYKCZImiZPyLGQBGRYFR3VzdG8xDzANBgNVBAMMBk1ETSBDQQIBBDAJBgUrDgMCGgUAMA0GCSqGSIb3DQEBBQUABIIBAABiveq4A69qvK2FjCMdhm6o9aBPfTw8WiJU9I6UppTbvw1+o2OBVLAOCXw46v1SIbj7Lhq5EDm3qXLD2xkF9zd5W43PvNZFleL735De+I1IeyXOvkmElOioipDNwrRpsET6vL2zwYlE0JZuGVhr2EU8ra3czy4eAbJwvV2xHLjpvqQJZh0LNvBc10sp7Q/99qpVdCXagUPJTh68Pcua51JiUWn0tDn0eaj083Yyx+I1XNR9opYuBEVz/LwFSsUGiB9zV7KbsLikajD2+Jmues5vS2jOrmCpV+yMN3uMa4lmOlgrQoi4l62edTo45zgnEZOUle0zT2pInMgML8KiWt8AAAAAAAA=" - p7signature, _ := base64.StdEncoding.DecodeString(mdmSignatureHeader) + p7signature, _ := base64.StdEncoding.DecodeString(mdmSignatureHeader1) _, err := pkcs7.Parse(p7signature) @@ -18,3 +21,14 @@ func TestPKCS7ParseTagLengthError(t *testing.T) { t.Error("pkcs7.Parse() failed with error:", err) } } + +func TestVerifyMdmSignature(t *testing.T) { + body, err := base64.StdEncoding.DecodeString(mdmSignatureBody2) + if err != nil { + t.Error(err) + } + _, err = VerifyMdmSignature(mdmSignatureHeader2, body) + if err != nil { + t.Error(err) + } +} diff --git a/server/mdm/nanomdm/docker-compose.yml b/server/mdm/nanomdm/docker-compose.yml new file mode 100644 index 0000000000..629410ebaa --- /dev/null +++ b/server/mdm/nanomdm/docker-compose.yml @@ -0,0 +1,34 @@ +--- +services: + # To run in macOS M1, set FLEET_MYSQL_PLATFORM=linux/arm64/v8 + mysql_nanomdm_test: + image: ${FLEET_MYSQL_IMAGE:-mysql:8.0.36} + platform: ${FLEET_MYSQL_PLATFORM:-linux/x86_64} + # innodb-file-per-table=OFF gives ~20% speedup for test runs. + command: [ + "mysqld", + "--datadir=/tmpfs", + "--slow_query_log=1", + "--log_output=TABLE", + "--log-queries-not-using-indexes", + "--innodb-file-per-table=OFF", + "--table-definition-cache=8192", + # These 3 keys run MySQL with GTID consistency enforced to avoid issues with production deployments that use it. + "--enforce-gtid-consistency=ON", + "--log-bin=bin.log", + "--server-id=1", + # Required for storage of Apple MDM bootstrap packages. + "--max_allowed_packet=536870912", + ] + environment: &mysql-default-environment + MYSQL_ROOT_PASSWORD: toor + MYSQL_DATABASE: fleet + MYSQL_USER: fleet + MYSQL_PASSWORD: insecure + # This is required by Percona XtraDB server. + CLUSTER_NAME: fleet + ports: + - "3800:3306" + tmpfs: + - /var/lib/mysql:rw,noexec,nosuid + - /tmpfs diff --git a/server/mdm/nanomdm/docs/enroll.mobileconfig b/server/mdm/nanomdm/docs/enroll.mobileconfig index 366958f60c..463b254e0d 100644 --- a/server/mdm/nanomdm/docs/enroll.mobileconfig +++ b/server/mdm/nanomdm/docs/enroll.mobileconfig @@ -46,6 +46,7 @@ com.apple.mdm.per-user-connections com.apple.mdm.bootstraptoken + com.apple.mdm.token ServerURL https://mdm.example.org/mdm @@ -59,8 +60,6 @@ Enrollment Profile PayloadIdentifier com.github.micromdm.nanomdm - PayloadScope - System PayloadType Configuration PayloadUUID diff --git a/server/mdm/nanomdm/docs/operations-guide.md b/server/mdm/nanomdm/docs/operations-guide.md index 0df985330c..5dc83b85fd 100644 --- a/server/mdm/nanomdm/docs/operations-guide.md +++ b/server/mdm/nanomdm/docs/operations-guide.md @@ -129,6 +129,8 @@ This switch disables MDM client capability. This effecitvely turns this running Specifies the "base" URL to send Declarative Management requests to. The full URL is constructed from this base URL appended with the type of Declarative Management ["Endpoint" request](https://developer.apple.com/documentation/devicemanagement/declarativemanagementrequest?language=objc) such as "status" or "declaration-items". Each HTTP request includes the NanoMDM enrollment ID as the HTTP header "X-Enrollment-ID". See [this blog post](https://micromdm.io/blog/wwdc21-declarative-management/) for more details. +Note that the URL should likely have a trailing slash. Otherwise path elements of the URL may to be cut off but by Golang's relative URL path resolver. + ### -migration * HTTP endpoint for enrollment migrations @@ -157,6 +159,20 @@ Print version and exit. NanoMDM supports a MicroMDM-compatible [webhook callback](https://github.com/micromdm/micromdm/blob/main/docs/user-guide/api-and-webhooks.md) option. This switch turns on the webhook and specifies the URL. +### -auth-proxy-url string + +* Reverse proxy URL target for MDM-authenticated HTTP requests + +Enables the authentication proxy and reverse proxies HTTP requests from the server's `/authproxy/` endpoint to this URL if the client provides the device's enrollment authentication. See below for more information. + +### -ua-zl-dc + +* reply with zero-length DigestChallenge for UserAuthenticate + +By default NanoMDM will respond to a `UserAuthenticate` message with an HTTP 410. This effectively declines management of that the user channel for that MDM user. Enabling this option turns on the "zero-length" Digest Challenge mode where NanoMDM replies with an empty Digest Challenge to enable management each time a client enrolls. + +Note that the `UserAuthenticate` message is only for "directory" MDM users and not the "primary" MDM user enrollment. See also [Apple's discussion of UserAthenticate](https://developer.apple.com/documentation/devicemanagement/userauthenticate#discussion) for more information. + ## HTTP endpoints & APIs ### MDM @@ -307,6 +323,16 @@ The migration endpoint (as talked about above under the `-migration` switch) is Returns a JSON response with the version of the running NanoMDM server. +### Authentication Proxy + +* Endpoint: `/authproxy/` + +If the `-auth-proxy-url` flag is provided then URLs that begin with `/authproxy/` will be reverse-proxied to the given target URL. Importantly this endpoint will authenticate the incoming request in the same way as other MDM endpoints (i.e. Check-In or Command Report and Response) — including whether we're using TLS client configuration or not (the `-cert-header` flag). Put together this allow you to have MDM-authenticated content retrieval. + +This feature is ostensibly to support Declarative Device Management and in particular the ability for some "Asset" declarations to use "MDM" authentication for their content. For example the `com.apple.asset.data` declaration supports an [Authentication key](https://github.com/apple/device-management/blob/2bb1726786047949b5b1aa923be33b9ba0f83e37/declarative/declarations/assets/data.yaml#L40-L54) for configuring this ability. + +As an example example if this feature is enabled and a request comes to the server as `/authproxy/foo/bar` and the `-auth-proxy-url` was set to, say, `http://[::1]:9008` then NanoMDM will reverse proxy this URL to `http://[::1]:9008/foo/bar`. An HTP 502 Bad Gateway response is sent back to the client for any issues proxying. + # Enrollment Migration (nano2nano) The `nano2nano` tool extracts migration enrollment data from a given storage backend and sends it to a NanoMDM migration endpoint. In this way you can effectively migrate between database backends. For example if you started with a `file` backend you could migrate to a `mysql` backend and vice versa. Note that MDM servers must have *exactly* the same server URL for migrations to operate. diff --git a/server/mdm/nanomdm/http/api/api.go b/server/mdm/nanomdm/http/api/api.go index 34a6a8717d..69174440f9 100644 --- a/server/mdm/nanomdm/http/api/api.go +++ b/server/mdm/nanomdm/http/api/api.go @@ -11,14 +11,16 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" mdmhttp "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) // enrolledAPIResult is a per-enrollment API result. @@ -139,6 +141,12 @@ func PushHandler(pusher push.Pusher, logger log.Logger) http.HandlerFunc { // using. Also note we expose Go errors to the output as this is meant // for "API" users. func RawCommandEnqueueHandler(enqueuer storage.CommandEnqueuer, pusher push.Pusher, logger log.Logger) http.HandlerFunc { + if enqueuer == nil { + panic("nil enqueuer") + } + if logger == nil { + panic("nil logger") + } return func(w http.ResponseWriter, r *http.Request) { ids := strings.Split(r.URL.Path, ",") ctx, logger := setupCtxLog(r.Context(), ids, logger) @@ -204,14 +212,14 @@ func RawCommandEnqueueHandler(enqueuer storage.CommandEnqueuer, pusher push.Push // optionally send pushes pushResp := make(map[string]*push.Response) var pushErr error - if !nopush { + if !nopush && pusher != nil { pushResp, pushErr = pusher.Push(ctx, ids) if err != nil { logger.Info("msg", "push", "err", err) output.PushError = err.Error() } - } else { - pushErr = nil + } else if !nopush && pusher == nil { + pushErr = errors.New("nil pusher") } // loop through our push errors, if any, and add to output var pushCt, pushErrCt int @@ -312,24 +320,32 @@ func StorePushCertHandler(storage storage.PushCertStore, logger log.Logger) http http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - cert, key, err := readPEMCertAndKey(b) + certPEM, keyPEM, err := readPEMCertAndKey(b) if err == nil { // sanity check the provided cert and key to make sure they're usable as a pair. - _, err = tls.X509KeyPair(cert, key) + _, err = tls.X509KeyPair(certPEM, keyPEM) + } + var cert *x509.Certificate + if err == nil { + cert, err = cryptoutil.DecodePEMCertificate(certPEM) } var topic string if err == nil { - topic, err = cryptoutil.TopicFromPEMCert(cert) + topic, err = cryptoutil.TopicFromCert(cert) } if err == nil { - err = storage.StorePushCert(r.Context(), cert, key) + err = storage.StorePushCert(r.Context(), certPEM, keyPEM) } output := &struct { - Error string `json:"error,omitempty"` - Topic string `json:"topic,omitempty"` + Error string `json:"error,omitempty"` + Topic string `json:"topic,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` }{ Topic: topic, } + if cert != nil { + output.NotAfter = cert.NotAfter + } if err != nil { logger.Info("msg", "store push cert", "err", err) output.Error = err.Error() diff --git a/server/mdm/nanomdm/http/authproxy/authproxy.go b/server/mdm/nanomdm/http/authproxy/authproxy.go new file mode 100644 index 0000000000..1ece0cfa8b --- /dev/null +++ b/server/mdm/nanomdm/http/authproxy/authproxy.go @@ -0,0 +1,87 @@ +// Package authproxy is a simple reverse proxy for Apple MDM clients. +package authproxy + +import ( + "context" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" +) + +// HeaderFunc takes an HTTP request and returns a string value. +// Ostensibly to be set in a header on the proxy target. +type HeaderFunc func(context.Context) string +type config struct { + logger log.Logger + fwdSig bool + headerFuncs map[string]HeaderFunc +} +type Option func(*config) + +// WithLogger sets a logger for error reporting. +func WithLogger(logger log.Logger) Option { + return func(c *config) { + c.logger = logger + } +} + +// WithHeaderFunc configures fn to be called and added as an HTTP header to the proxy target request. +func WithHeaderFunc(header string, fn HeaderFunc) Option { + return func(c *config) { + c.headerFuncs[header] = fn + } +} + +// WithForwardMDMSignature forwards the MDM-Signature header onto the proxy destination. +// This option is off by default because the header adds about two kilobytes to the request. +func WithForwardMDMSignature() Option { + return func(c *config) { + c.fwdSig = true + } +} + +// New creates a new NanoMDM enrollment authenticating reverse proxy. +// This reverse proxy is mostly the standard httputil proxy. It depends +// on middleware HTTP handlers to enforce authentication and set the +// context value for the enrollment ID. +func New(dest string, opts ...Option) (*httputil.ReverseProxy, error) { + config := &config{ + logger: log.NopLogger, + headerFuncs: make(map[string]HeaderFunc), + } + for _, opt := range opts { + opt(config) + } + target, err := url.Parse(dest) + if err != nil { + return nil, err + } + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + ctxlog.Logger(r.Context(), config.logger).Info("err", err) + // use the same error as the standrad reverse proxy + w.WriteHeader(http.StatusBadGateway) + } + dir := proxy.Director + proxy.Director = func(req *http.Request) { + dir(req) + req.Host = target.Host + if !config.fwdSig { + // save the effort of forwarding this huge header + req.Header.Del("Mdm-Signature") + } + // set any headers we want to forward. + for k, fn := range config.headerFuncs { + if k == "" || fn == nil { + continue + } + if v := fn(req.Context()); v != "" { + req.Header.Set(k, v) + } + } + } + return proxy, nil +} diff --git a/server/mdm/nanomdm/http/http.go b/server/mdm/nanomdm/http/http.go index 5683ce6ab1..6ef804f3b1 100644 --- a/server/mdm/nanomdm/http/http.go +++ b/server/mdm/nanomdm/http/http.go @@ -10,8 +10,8 @@ import ( "net" "net/http" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) // ReadAllAndReplaceBody reads all of r.Body and replaces it with a new byte buffer. @@ -51,6 +51,12 @@ func VersionHandler(version string) http.HandlerFunc { type ctxKeyTraceID struct{} +// GetTraceID returns the trace ID from ctx. +func GetTraceID(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyTraceID{}).(string) + return id +} + // TraceLoggingMiddleware sets up a trace ID in the request context and // logs HTTP requests. func TraceLoggingMiddleware(next http.Handler, logger log.Logger, traceID func(*http.Request) string) http.HandlerFunc { diff --git a/server/mdm/nanomdm/http/mdm/mdm.go b/server/mdm/nanomdm/http/mdm/mdm.go index bad75bce2b..afa156230c 100644 --- a/server/mdm/nanomdm/http/mdm/mdm.go +++ b/server/mdm/nanomdm/http/mdm/mdm.go @@ -6,10 +6,11 @@ import ( "strings" mdmhttp "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) func mdmReqFromHTTPReq(r *http.Request) *mdm.Request { @@ -27,6 +28,12 @@ func mdmReqFromHTTPReq(r *http.Request) *mdm.Request { // CheckinHandler decodes an MDM check-in request and adapts it to service. func CheckinHandler(svc service.Checkin, logger log.Logger) http.HandlerFunc { + if svc == nil { + panic("nil service") + } + if logger == nil { + panic("nil logger") + } return func(w http.ResponseWriter, r *http.Request) { logger := ctxlog.Logger(r.Context(), logger) bodyBytes, err := mdmhttp.ReadAllAndReplaceBody(r) diff --git a/server/mdm/nanomdm/http/mdm/mdm_cert.go b/server/mdm/nanomdm/http/mdm/mdm_cert.go index 93308ec841..b429860ff7 100644 --- a/server/mdm/nanomdm/http/mdm/mdm_cert.go +++ b/server/mdm/nanomdm/http/mdm/mdm_cert.go @@ -9,12 +9,16 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" mdmhttp "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) type contextKeyCert struct{} +type contextEnrollmentID struct{} + // CertExtractPEMHeaderMiddleware extracts the MDM enrollment identity // certificate from the request into the HTTP request context. It looks // at the request header which should be a URL-encoded PEM certificate. @@ -66,6 +70,53 @@ func CertExtractTLSMiddleware(next http.Handler, logger log.Logger) http.Handler } } +// sigLogConfig is a configuration struct for CertExtractMdmSignatureMiddleware. +type sigLogConfig struct { + logger log.Logger + always bool + errors bool +} + +// SigLogOption sets configurations. +type SigLogOption func(*sigLogConfig) + +// SigLogWithLogger sets the logger to use when logging with the MDM signature header. +func SigLogWithLogger(logger log.Logger) SigLogOption { + return func(c *sigLogConfig) { + c.logger = logger + } +} + +// SigLogWithLogAlways always logs the raw Mdm-Signature header. +func SigLogWithLogAlways(always bool) SigLogOption { + return func(c *sigLogConfig) { + c.always = always + } +} + +// SigLogWithLogErrors logs the raw Mdm-Signature header when errors occur. +func SigLogWithLogErrors(errors bool) SigLogOption { + return func(c *sigLogConfig) { + c.errors = errors + } +} + +// MdmSignatureVerifier verifies Apple Mdm-Signature headers and extracts certificates. +type MdmSignatureVerifier interface { + // VerifyMdmSignature verifies an Apple MDM "Mdm-Signature" header and returns the signing certificate. + // See https://developer.apple.com/documentation/devicemanagement/implementing_device_management/managing_certificates_for_mdm_servers_and_devices + // section "Pass an Identity Certificate Through a Proxy." + VerifyMdmSignature(header string, body []byte) (*x509.Certificate, error) +} + +// MdmSignatureVerifierFunc is an adapter for verifying Apple MDM "Mdm-Signature" headers. +type MdmSignatureVerifierFunc func(header string, body []byte) (*x509.Certificate, error) + +// VerifyMdmSignature calls v with header and body. +func (v MdmSignatureVerifierFunc) VerifyMdmSignature(header string, body []byte) (*x509.Certificate, error) { + return v(header, body) +} + // CertExtractMdmSignatureMiddleware extracts the MDM enrollment // identity certificate from the request into the HTTP request context. // It tries to verify the Mdm-Signature header on the request. @@ -73,15 +124,25 @@ func CertExtractTLSMiddleware(next http.Handler, logger log.Logger) http.Handler // This middleware does not error if a certificate is not found. It // will, however, error with an HTTP 400 status if the signature // verification fails. -func CertExtractMdmSignatureMiddleware(next http.Handler, logger log.Logger) http.HandlerFunc { +func CertExtractMdmSignatureMiddleware(next http.Handler, verifier MdmSignatureVerifier, opts ...SigLogOption) http.HandlerFunc { + if verifier == nil { + panic("nil verifier") + } + config := &sigLogConfig{logger: log.NopLogger} + for _, opt := range opts { + opt(config) + } return func(w http.ResponseWriter, r *http.Request) { - logger := ctxlog.Logger(r.Context(), logger) + logger := ctxlog.Logger(r.Context(), config.logger) mdmSig := r.Header.Get("Mdm-Signature") if mdmSig == "" { logger.Debug("msg", "empty Mdm-Signature header") next.ServeHTTP(w, r) return } + if config.errors || config.always { + logger = logger.With("mdm-signature", mdmSig) + } b, err := mdmhttp.ReadAllAndReplaceBody(r) if err != nil { logger.Info("msg", "reading body", "err", err) @@ -93,11 +154,13 @@ func CertExtractMdmSignatureMiddleware(next http.Handler, logger log.Logger) htt http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - cert, err := cryptoutil.VerifyMdmSignature(mdmSig, b) + cert, err := verifier.VerifyMdmSignature(mdmSig, b) if err != nil { logger.Info("msg", "verifying Mdm-Signature header", "err", err) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return + } else if config.always { + logger.Debug("msg", "verifying Mdm-Signature header") } ctx := context.WithValue(r.Context(), contextKeyCert{}, cert) next.ServeHTTP(w, r.WithContext(ctx)) @@ -113,7 +176,7 @@ func GetCert(ctx context.Context) *x509.Certificate { // CertVerifier is a simple interface for verifying a certificate. type CertVerifier interface { - Verify(*x509.Certificate) error + Verify(context.Context, *x509.Certificate) error } // CertVerifyMiddleware checks the MDM certificate against verifier and @@ -123,7 +186,7 @@ type CertVerifier interface { // MDM unenrollments in the case of bugs or something going wrong. func CertVerifyMiddleware(next http.Handler, verifier CertVerifier, logger log.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if err := verifier.Verify(GetCert(r.Context())); err != nil { + if err := verifier.Verify(r.Context(), GetCert(r.Context())); err != nil { ctxlog.Logger(r.Context(), logger).Info( "msg", "error verifying MDM certificate", "err", err, @@ -134,3 +197,69 @@ func CertVerifyMiddleware(next http.Handler, verifier CertVerifier, logger log.L next.ServeHTTP(w, r) } } + +// GetEnrollmentID retrieves the MDM enrollment ID from ctx. +func GetEnrollmentID(ctx context.Context) string { + id, _ := ctx.Value(contextEnrollmentID{}).(string) + return id +} + +type HashFn func(*x509.Certificate) string + +// CertWithEnrollmentIDMiddleware tries to associate the enrollment ID to the request context. +// It does this by looking up the certificate on the context, hashing it with +// hasher, looking up the hash in storage, and setting the ID on the context. +// +// The next handler will be called even if cert or ID is not found unless +// enforce is true. This way next is able to use the existence of the ID on +// the context to make its own decisions. +func CertWithEnrollmentIDMiddleware(next http.Handler, hasher HashFn, store storage.CertAuthRetriever, enforce bool, + logger log.Logger) http.HandlerFunc { + if store == nil || hasher == nil { + panic("store and hasher must not be nil") + } + return func(w http.ResponseWriter, r *http.Request) { + cert := GetCert(r.Context()) + if cert == nil { + if enforce { + ctxlog.Logger(r.Context(), logger).Info( + "err", "missing certificate", + ) + // we cannot send a 401 to the client as it has MDM protocol semantics + // i.e. the device may unenroll + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusBadRequest) + return + } + ctxlog.Logger(r.Context(), logger).Debug( + "msg", "missing certificate", + ) + next.ServeHTTP(w, r) + return + } + id, err := store.EnrollmentFromHash(r.Context(), hasher(cert)) + if err != nil { + ctxlog.Logger(r.Context(), logger).Info( + "msg", "retreiving enrollment from hash", + "err", err, + ) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if id == "" { + if enforce { + ctxlog.Logger(r.Context(), logger).Info( + "err", "missing enrollment id", + ) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusBadRequest) + return + } + ctxlog.Logger(r.Context(), logger).Debug( + "msg", "missing enrollment id", + ) + next.ServeHTTP(w, r) + return + } + ctx := context.WithValue(r.Context(), contextEnrollmentID{}, id) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} diff --git a/server/mdm/nanomdm/http/mdm/mdm_test.go b/server/mdm/nanomdm/http/mdm/mdm_test.go new file mode 100644 index 0000000000..6600567dbb --- /dev/null +++ b/server/mdm/nanomdm/http/mdm/mdm_test.go @@ -0,0 +1,68 @@ +package mdm + +import ( + "bytes" + "context" + "crypto/x509" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/micromdm/nanolib/log" + "github.com/stretchr/testify/require" +) + +const ( + testHash = "ZZZYYYXXX" + testID = "AAABBBCCC" +) + +func testHashCert(_ *x509.Certificate) string { + return testHash +} + +type testCertAuthRetriever struct{} + +func (c *testCertAuthRetriever) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + if hash != testHash { + return "", errors.New("invalid test hash") + } + return testID, nil +} +func TestCertWithEnrollmentIDMiddleware(t *testing.T) { + response := []byte("mock response") + // mock handler + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write(response) + require.NoError(t, err) + }) + handler = CertWithEnrollmentIDMiddleware(handler, testHashCert, &testCertAuthRetriever{}, true, log.NopLogger) + req, err := http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + // we requested enforcement, and did not include a cert, so make sure we get a BadResponse + if have, want := rr.Code, http.StatusBadRequest; have != want { + t.Errorf("have: %d, want: %d", have, want) + } + req, err = http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + // mock "cert" + req = req.WithContext(context.WithValue(req.Context(), contextKeyCert{}, &x509.Certificate{})) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + // now that we have a "cert" included, we should get an OK + if have, want := rr.Code, http.StatusOK; have != want { + t.Errorf("have: %d, want: %d", have, want) + } + // verify the actual body, too + if !bytes.Equal(rr.Body.Bytes(), response) { + t.Error("body not equal") + } +} diff --git a/server/mdm/nanomdm/log/ctxlog/ctxlog.go b/server/mdm/nanomdm/log/ctxlog/ctxlog.go deleted file mode 100644 index 7deada7d71..0000000000 --- a/server/mdm/nanomdm/log/ctxlog/ctxlog.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package ctxlog allows logging data stored with a context. -package ctxlog - -import ( - "context" - "sync" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" -) - -// CtxKVFunc creates logger key-value pairs from a context. -// CtxKVFuncs should aim to be be as efficient as possible—ideally only -// doing the minimum to read context values and generate KV pairs. Each -// associated CtxKVFunc is called every time we adapt a logger with -// Logger. -type CtxKVFunc func(context.Context) []interface{} - -// ctxKeyFuncs is the context key for storing and retriveing -// a funcs{} struct on a context. -type ctxKeyFuncs struct{} - -// funcs holds the associated CtxKVFunc functions to run. -type funcs struct { - sync.RWMutex - funcs []CtxKVFunc -} - -// AddFunc associates a new CtxKVFunc function to a context. -func AddFunc(ctx context.Context, f CtxKVFunc) context.Context { - if ctx == nil { - return ctx - } - ctxFuncs, ok := ctx.Value(ctxKeyFuncs{}).(*funcs) - if !ok || ctxFuncs == nil { - ctxFuncs = &funcs{} - } - ctxFuncs.Lock() - ctxFuncs.funcs = append(ctxFuncs.funcs, f) - ctxFuncs.Unlock() - return context.WithValue(ctx, ctxKeyFuncs{}, ctxFuncs) -} - -// Logger runs the associated CtxKVFunc functions and returns a new -// logger with the results. -func Logger(ctx context.Context, logger log.Logger) log.Logger { - if ctx == nil { - return logger - } - ctxFuncs, ok := ctx.Value(ctxKeyFuncs{}).(*funcs) - if !ok || ctxFuncs == nil { - return logger - } - var acc []interface{} - ctxFuncs.RLock() - for _, f := range ctxFuncs.funcs { - acc = append(acc, f(ctx)...) - } - ctxFuncs.RUnlock() - return logger.With(acc...) -} - -// SimpleStringFunc is a helper that generates a simple CtxKVFunc that -// returns a key-value pair if found on the context. -func SimpleStringFunc(logKey string, ctxKey interface{}) CtxKVFunc { - return func(ctx context.Context) (out []interface{}) { - v, _ := ctx.Value(ctxKey).(string) - if v != "" { - out = []interface{}{logKey, v} - } - return - } -} diff --git a/server/mdm/nanomdm/log/logger.go b/server/mdm/nanomdm/log/logger.go deleted file mode 100644 index 2775e82e94..0000000000 --- a/server/mdm/nanomdm/log/logger.go +++ /dev/null @@ -1,17 +0,0 @@ -package log - -// Pacakge log is embedded (not imported) from: -// https://github.com/jessepeterson/go-log - -// Logger is a generic logging interface to a structured, leveled, nest-able logger -type Logger interface { - // Info logs using the info level - Info(...interface{}) - - // Debug logs using the debug level - Debug(...interface{}) - - // With nests the Logger - // Usually for adding logging context to a sub-logger - With(...interface{}) Logger -} diff --git a/server/mdm/nanomdm/log/nop.go b/server/mdm/nanomdm/log/nop.go deleted file mode 100644 index c0800c5f51..0000000000 --- a/server/mdm/nanomdm/log/nop.go +++ /dev/null @@ -1,21 +0,0 @@ -package log - -// Pacakge log is embedded (not imported) from: -// https://github.com/jessepeterson/go-log - -// nopLogger does nothing -type nopLogger struct{} - -// Info does nothing -func (*nopLogger) Info(_ ...interface{}) {} - -// Debug does nothing -func (*nopLogger) Debug(_ ...interface{}) {} - -// With returns (the same) logger -func (logger *nopLogger) With(_ ...interface{}) Logger { - return logger -} - -// NopLogger is a Logger that does nothing -var NopLogger = &nopLogger{} diff --git a/server/mdm/nanomdm/log/stdlogfmt/stdlog.go b/server/mdm/nanomdm/log/stdlogfmt/stdlog.go deleted file mode 100644 index e433deb2b1..0000000000 --- a/server/mdm/nanomdm/log/stdlogfmt/stdlog.go +++ /dev/null @@ -1,116 +0,0 @@ -package stdlogfmt - -import ( - "fmt" - stdlog "log" - "os" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" -) - -// Logger wraps a standard library logger and adapts it to pkg/log. -type Logger struct { - logger *stdlog.Logger - context []interface{} - debug bool - depth int - ts bool -} - -type Option func(*Logger) - -// WithLogger sets the Go standard logger to use. -func WithLogger(logger *stdlog.Logger) Option { - return func(l *Logger) { - l.logger = logger - } -} - -// WithDebug turns on debug logging. -func WithDebug() Option { - return func(l *Logger) { - l.debug = true - } -} - -// WithDebugFlag sets debug logging on or off. -func WithDebugFlag(flag bool) Option { - return func(l *Logger) { - l.debug = flag - } -} - -// WithCallerDepth sets the call depth of the logger for filename and line -// logging. Set depth to 0 to disable filename and line logging. -func WithCallerDepth(depth int) Option { - return func(l *Logger) { - l.depth = depth - } -} - -// WithoutTimestamp disables outputting an RFC3339 timestamp. -func WithoutTimestamp() Option { - return func(l *Logger) { - l.ts = false - } -} - -// New creates a new logger that adapts the Go standard log package to Logger. -func New(opts ...Option) *Logger { - l := &Logger{ - logger: stdlog.New(os.Stderr, "", 0), - depth: 1, - ts: true, - } - for _, opt := range opts { - opt(l) - } - return l -} - -func (l *Logger) print(args ...interface{}) { - if l.ts { - args = append([]interface{}{"ts", time.Now().Format(time.RFC3339)}, args...) - } - if l.depth > 0 { - _, filename, line, ok := runtime.Caller(l.depth + 1) - if ok { - caller := fmt.Sprintf("%s:%d", filepath.Base(filename), line) - args = append(args, "caller", caller) - } - } - f := strings.Repeat(" %s=%v", len(args)/2)[1:] - if len(args)%2 == 1 { - f += " UNKNOWN=%v" - } - l.logger.Printf(f, args...) -} - -// Info logs using the "info" level -func (l *Logger) Info(args ...interface{}) { - logs := []interface{}{"level", "info"} - logs = append(logs, l.context...) - logs = append(logs, args...) - l.print(logs...) -} - -// Info logs using the "debug" level -func (l *Logger) Debug(args ...interface{}) { - if l.debug { - logs := []interface{}{"level", "debug"} - logs = append(logs, l.context...) - logs = append(logs, args...) - l.print(logs...) - } -} - -// With creates a new logger using args as context -func (l *Logger) With(args ...interface{}) log.Logger { - l2 := *l - l2.context = append(l2.context, args...) - return &l2 -} diff --git a/server/mdm/nanomdm/mdm/checkin.go b/server/mdm/nanomdm/mdm/checkin.go index 0f70817a5f..57bcc90e9b 100644 --- a/server/mdm/nanomdm/mdm/checkin.go +++ b/server/mdm/nanomdm/mdm/checkin.go @@ -38,7 +38,7 @@ type Authenticate struct { // Fields that may be present but are not strictly required for the // operation of the MDM protocol. Nice-to-haves. - SerialNumber string + SerialNumber string `plist:",omitempty"` } type b64Data []byte @@ -116,6 +116,44 @@ type DeclarativeManagement struct { Raw []byte `plist:"-"` // Original XML plist } +// TokenParameters is a representation of a "GetTokenRequest.TokenParameters" structure. +// See https://developer.apple.com/documentation/devicemanagement/gettokenrequest/tokenparameters +type TokenParameters struct { + PhoneUDID string + SecurityToken string + WatchUDID string +} + +// GetTokenResponse is a representation of a "GetTokenResponse" structure. +// See https://developer.apple.com/documentation/devicemanagement/gettokenresponse +type GetTokenResponse struct { + TokenData []byte +} + +// GetToken is a representation of a "GetToken" check-in message type. +// See https://developer.apple.com/documentation/devicemanagement/get_token +type GetToken struct { + Enrollment + MessageType + TokenServiceType string + TokenParameters *TokenParameters `plist:",omitempty"` + Raw []byte `plist:"-"` // Original XML plist +} + +// Validate validates a GetToken check-in message. +func (m *GetToken) Validate() error { + if m == nil { + return errors.New("nil GetToken") + } + if m.TokenServiceType == "" { + return errors.New("empty GetToken TokenServiceType") + } + if m.TokenServiceType == "com.apple.watch.pairing" && m.TokenParameters == nil { + return fmt.Errorf("nil TokenParameters for GetToken: %s", m.TokenServiceType) + } + return nil +} + // newCheckinMessageForType returns a pointer to a check-in struct for MessageType t func newCheckinMessageForType(t string, raw []byte) interface{} { switch t { @@ -133,6 +171,8 @@ func newCheckinMessageForType(t string, raw []byte) interface{} { return &UserAuthenticate{Raw: raw} case "DeclarativeManagement": return &DeclarativeManagement{Raw: raw} + case "GetToken": + return &GetToken{Raw: raw} default: return nil } diff --git a/server/mdm/nanomdm/mdm/checkin_test.go b/server/mdm/nanomdm/mdm/checkin_test.go index 6701a86267..aaf4aca188 100644 --- a/server/mdm/nanomdm/mdm/checkin_test.go +++ b/server/mdm/nanomdm/mdm/checkin_test.go @@ -109,3 +109,36 @@ func TestTokenUpdate(t *testing.T) { }) } } + +func TestGetTokenMAID(t *testing.T) { + test := ` + + + + MessageType + GetToken + UDID + test + TokenServiceType + com.apple.maid + + +` + m, err := DecodeCheckin([]byte(test)) + if err != nil { + t.Fatal(err) + } + msg, ok := m.(*GetToken) + if !ok { + t.Fatal("incorrect decoded check-in message type") + } + if err := msg.Validate(); err != nil { + t.Fatal(err) + } + if msg, want, have := "invalid UDID", "test", msg.UDID; have != want { + t.Errorf("%s: %q, want: %q", msg, have, want) + } + if msg, want, have := "invalid TokenServiceType", "com.apple.maid", msg.TokenServiceType; have != want { + t.Errorf("%s: %q, want: %q", msg, have, want) + } +} diff --git a/server/mdm/nanomdm/mdm/command.go b/server/mdm/nanomdm/mdm/command.go index f7c1ea11b0..4238c0a5ef 100644 --- a/server/mdm/nanomdm/mdm/command.go +++ b/server/mdm/nanomdm/mdm/command.go @@ -23,11 +23,10 @@ type ErrorChain struct { // See https://developer.apple.com/documentation/devicemanagement/implementing_device_management/sending_mdm_commands_to_a_device type CommandResults struct { Enrollment - CommandUUID string + CommandUUID string `plist:",omitempty"` Status string - ErrorChain []ErrorChain - RequestType string - Raw []byte `plist:"-"` // Original command result XML plist + ErrorChain []ErrorChain `plist:",omitempty"` + Raw []byte `plist:"-"` // Original command result XML plist } // DecodeCheckin unmarshals rawMessage into results diff --git a/server/mdm/nanomdm/mdm/command_test.go b/server/mdm/nanomdm/mdm/command_test.go index 2a423b4a73..d0d2a53bdf 100644 --- a/server/mdm/nanomdm/mdm/command_test.go +++ b/server/mdm/nanomdm/mdm/command_test.go @@ -10,14 +10,12 @@ func TestCommandAndReportResults(t *testing.T) { for _, test := range []struct { filename string UDID string - RequestType string Status string CommandUUID string }{ { "testdata/DeviceInformation.1.plist", "66ADE930-5FDF-5EC4-8429-15640684C489", - "DeviceInformation", "Acknowledged", "76eda240-5488-4989-8339-f2ae160113c4", }, @@ -36,9 +34,6 @@ func TestCommandAndReportResults(t *testing.T) { if msg, have, want := "incorrect UDID", a.UDID, test.UDID; have != want { t.Errorf("%s: %q, want: %q", msg, have, want) } - if msg, have, want := "incorrect RequestType", a.RequestType, test.RequestType; have != want { - t.Errorf("%s: %q, want: %q", msg, have, want) - } if msg, have, want := "incorrect Status", a.Status, test.Status; have != want { t.Errorf("%s: %q, want: %q", msg, have, want) } diff --git a/server/mdm/nanomdm/push/buford/buford.go b/server/mdm/nanomdm/push/buford/buford.go index 33c712f015..8cafb681b1 100644 --- a/server/mdm/nanomdm/push/buford/buford.go +++ b/server/mdm/nanomdm/push/buford/buford.go @@ -3,6 +3,7 @@ package buford import ( + "context" "crypto/tls" "errors" "net/http" @@ -125,7 +126,7 @@ func (c *bufordPushProvider) pushMulti(pushInfos []*mdm.Push) map[string]*push.R } // Push sends 'raw' MDM APNs push notifications to service in c. -func (c *bufordPushProvider) Push(pushInfos []*mdm.Push) (map[string]*push.Response, error) { +func (c *bufordPushProvider) Push(_ context.Context, pushInfos []*mdm.Push) (map[string]*push.Response, error) { if len(pushInfos) < 1 { return nil, errors.New("no push data provided") } diff --git a/server/mdm/nanomdm/push/nanopush/nanopush.go b/server/mdm/nanomdm/push/nanopush/nanopush.go new file mode 100644 index 0000000000..f57d7c0c76 --- /dev/null +++ b/server/mdm/nanomdm/push/nanopush/nanopush.go @@ -0,0 +1,98 @@ +// Pacakge nanopush implements an Apple APNs HTTP/2 service for MDM. +// It implements the PushProvider and PushProviderFactory interfaces. +package nanopush + +import ( + "crypto/tls" + "errors" + "net/http" + "time" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + "golang.org/x/net/http2" +) + +// NewClient describes a callback for setting up an HTTP client for Push notifications. +type NewClient func(*tls.Certificate) (*http.Client, error) + +// ClientWithCert configures an mTLS client cert on the HTTP client. +func ClientWithCert(client *http.Client, cert *tls.Certificate) (*http.Client, error) { + if cert == nil { + return client, errors.New("no cert provided") + } + if client == nil { + clone := *http.DefaultClient + client = &clone + } + config := &tls.Config{ + Certificates: []tls.Certificate{*cert}, + MinVersion: tls.VersionTLS12, + } + if client.Transport == nil { + client.Transport = &http.Transport{} // nolint: gocritic // allow not using fleethttp.NewClient + } + transport := client.Transport.(*http.Transport) + transport.TLSClientConfig = config + // force HTTP/2 + err := http2.ConfigureTransport(transport) + return client, err +} + +func defaultNewClient(cert *tls.Certificate) (*http.Client, error) { + return ClientWithCert(nil, cert) +} + +// Factory instantiates new PushProviders. +type Factory struct { + newClient NewClient + expiration time.Duration + workers int +} + +type Option func(*Factory) + +// WithNewClient sets a callback to setup an HTTP client for each +// new Push provider. +func WithNewClient(newClient NewClient) Option { + return func(f *Factory) { + f.newClient = newClient + } +} + +// WithExpiration sets the APNs expiration time for the push notifications. +func WithExpiration(expiration time.Duration) Option { + return func(f *Factory) { + f.expiration = expiration + } +} + +// WithWorkers sets how many worker goroutines to use when sending pushes. +func WithWorkers(workers int) Option { + return func(f *Factory) { + f.workers = workers + } +} + +// NewFactory creates a new Factory. +func NewFactory(opts ...Option) *Factory { + f := &Factory{ + newClient: defaultNewClient, + workers: 5, + } + for _, opt := range opts { + opt(f) + } + return f +} + +// NewPushProvider generates a new PushProvider given a tls keypair. +func (f *Factory) NewPushProvider(cert *tls.Certificate) (push.PushProvider, error) { + p := &Provider{ + expiration: f.expiration, + workers: f.workers, + baseURL: Production, + } + var err error + p.client, err = f.newClient(cert) + return p, err +} diff --git a/server/mdm/nanomdm/push/nanopush/provider.go b/server/mdm/nanomdm/push/nanopush/provider.go new file mode 100644 index 0000000000..2e69d85e68 --- /dev/null +++ b/server/mdm/nanomdm/push/nanopush/provider.go @@ -0,0 +1,179 @@ +package nanopush + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + "golang.org/x/net/http2" +) + +// Doer is ostensibly an *http.Client +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +const ( + Development = "https://api.development.push.apple.com" + Development2197 = "https://api.development.push.apple.com:2197" + Production = "https://api.push.apple.com" + Production2197 = "https://api.push.apple.com:2197" +) + +// Provider sends pushes to Apple's APNs servers. +type Provider struct { + client Doer + expiration time.Duration + workers int + baseURL string +} + +// JSONPushError is a JSON error returned from the APNs service. +type JSONPushError struct { + Reason string `json:"reason"` + Timestamp int64 `json:"timestamp"` +} + +func (e *JSONPushError) Error() string { + s := "APNs push error" + if e == nil { + return s + ": nil" + } + if e.Reason != "" { + s += ": " + e.Reason + } + if e.Timestamp > 0 { + s += ": timestamp " + strconv.FormatInt(e.Timestamp, 10) + } + return s +} + +func newError(body io.Reader, statusCode int) error { + var err error = new(JSONPushError) + if decodeErr := json.NewDecoder(body).Decode(err); decodeErr != nil { + err = fmt.Errorf("decoding JSON push error: %w", decodeErr) + } + return fmt.Errorf("push HTTP status: %d: %w", statusCode, err) +} + +// do performs the HTTP push request +func (p *Provider) do(ctx context.Context, pushInfo *mdm.Push) *push.Response { + jsonPayload := []byte(`{"mdm":"` + pushInfo.PushMagic + `"}`) + + url := p.baseURL + "/3/device/" + pushInfo.Token.String() + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonPayload)) + + if err != nil { + return &push.Response{Err: err} + } + + req.Header.Set("Content-Type", "application/json") + if p.expiration > 0 { + exp := time.Now().Add(p.expiration) + req.Header.Set("apns-expiration", strconv.FormatInt(exp.Unix(), 10)) + } + r, err := p.client.Do(req) + var goAwayErr http2.GoAwayError + if errors.As(err, &goAwayErr) { + body := strings.NewReader(goAwayErr.DebugData) + return &push.Response{Err: newError(body, r.StatusCode)} + } else if err != nil { + return &push.Response{Err: err} + } + + defer r.Body.Close() + response := &push.Response{Id: r.Header.Get("apns-id")} + if r.StatusCode != http.StatusOK { + response.Err = newError(r.Body, r.StatusCode) + } + return response +} + +// pushSerial performs APNs pushes serially. +func (p *Provider) pushSerial(ctx context.Context, pushInfos []*mdm.Push) (map[string]*push.Response, error) { + ret := make(map[string]*push.Response) + for _, pushInfo := range pushInfos { + if pushInfo == nil { + continue + } + ret[pushInfo.Token.String()] = p.do(ctx, pushInfo) + } + return ret, nil +} + +// pushConcurrent performs APNs pushes concurrently. +// It spawns worker goroutines and feeds them from the list of pushInfos. +func (p *Provider) pushConcurrent(ctx context.Context, pushInfos []*mdm.Push) (map[string]*push.Response, error) { + // don't start more workers than we have pushes to send + workers := p.workers + if len(pushInfos) > workers { + workers = len(pushInfos) + } + + // response associates push.Response with token + type response struct { + token string + response *push.Response + } + + jobs := make(chan *mdm.Push) + results := make(chan response) + var wg sync.WaitGroup + + // start our workers + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + for pushInfo := range jobs { + results <- response{ + token: pushInfo.Token.String(), + response: p.do(ctx, pushInfo), + } + } + }() + } + + // start the "feeder" (queue source) + go func() { + for _, pushInfo := range pushInfos { + jobs <- pushInfo + } + close(jobs) + }() + + // watch for our workers finishing (they should after feeding is done) + // stop the collector when the workers have finished. + go func() { + wg.Wait() + close(results) + }() + + // collect our results + ret := make(map[string]*push.Response) + for r := range results { + ret[r.token] = r.response + } + + return ret, nil +} + +// Push sends APNs pushes to MDM enrollments. +func (p *Provider) Push(ctx context.Context, pushInfos []*mdm.Push) (map[string]*push.Response, error) { + if len(pushInfos) < 1 { + return nil, errors.New("no push data provided") + } else if len(pushInfos) == 1 { + return p.pushSerial(ctx, pushInfos) + } + return p.pushConcurrent(ctx, pushInfos) +} diff --git a/server/mdm/nanomdm/push/nanopush/provider_test.go b/server/mdm/nanomdm/push/nanopush/provider_test.go new file mode 100644 index 0000000000..449f93851e --- /dev/null +++ b/server/mdm/nanomdm/push/nanopush/provider_test.go @@ -0,0 +1,112 @@ +package nanopush + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/stretchr/testify/require" +) + +func TestPush(t *testing.T) { + // our "raw" push info + devicePushInfoStrings := [][]string{ + { + "c2732227a1d8021cfaf781d71fb2f908c61f5861079a00954a5453f1d0281433", + "47250C9C-1B37-4381-98A9-0B8315A441C7", + "com.example.apns-topic", + }, + } + + // test a single push + t.Run("single-push", func(t *testing.T) { + testPushDevices(t, devicePushInfoStrings) + }) + + devicePushInfoStrings = append(devicePushInfoStrings, []string{ + "7f1839ca30d5c6d36d6ae426258c4306c14eca90afd709a07375a85ad5a11c69", + "1C0B33FD-9336-4A7A-A080-7BEA9BD032EC", + "com.example.apns-topic", + }) + + // test a multiple push + t.Run("multiple-push", func(t *testing.T) { + testPushDevices(t, devicePushInfoStrings) + }) +} + +func testPushDevices(t *testing.T, input [][]string) { + // assemble it into a list and map + devices := make(map[string]*mdm.Push) + var pushInfos []*mdm.Push + for _, devicePushInfos := range input { + pushInfo := &mdm.Push{ + PushMagic: devicePushInfos[1], + Topic: devicePushInfos[2], + } + err := pushInfo.SetTokenString(devicePushInfos[0]) + require.NoError(t, err) + devices[devicePushInfos[0]] = pushInfo + pushInfos = append(pushInfos, pushInfo) + } + + apnsID := "922D9F1F-B82E-B337-EDC9-DB4FC8527676" + + handler := http.NewServeMux() + + handler.HandleFunc("/3/device/", func(w http.ResponseWriter, r *http.Request) { + url := r.URL.String() + var device string + var pushMagic string + if len(url) > 11 && url[:10] == "/3/device/" { + device = url[10:] + if _, ok := devices[device]; !ok { + t.Errorf("device id not present: %s", device) + } else { + pushMagic = devices[device].PushMagic + } + } else { + t.Fatal("invalid URL form") + } + + payload := []byte(`{"mdm":"` + pushMagic + `"}`) + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if have, want := body, payload; !bytes.Equal(have, want) { + t.Errorf("body: have %q, want %q", string(have), string(want)) + } + + w.Header().Set("apns-id", apnsID) + }) + + server := httptest.NewServer(handler) + defer server.Close() + + prov := &Provider{ + baseURL: server.URL, + client: http.DefaultClient, + } + + resp, err := prov.Push(context.Background(), pushInfos) + if err != nil { + t.Fatal(err) + } + + for k, v := range resp { + if _, ok := devices[k]; !ok || v == nil { + t.Errorf("device not found (or is nil): %s", k) + } else { + if have, want := v.Id, apnsID; have != want { + t.Errorf("url: have %q, want %q", have, want) + } + } + } + +} diff --git a/server/mdm/nanomdm/push/push.go b/server/mdm/nanomdm/push/push.go index 68afdbf26e..7b0260b792 100644 --- a/server/mdm/nanomdm/push/push.go +++ b/server/mdm/nanomdm/push/push.go @@ -24,7 +24,7 @@ type Pusher interface { // The non-error return type maps the string value of the push token // (that is, the hex encoding of the bytes) to a pointer to a Response. type PushProvider interface { - Push([]*mdm.Push) (map[string]*Response, error) + Push(context.Context, []*mdm.Push) (map[string]*Response, error) } // PushProviderFactory generates a new PushProvider given a tls keypair diff --git a/server/mdm/nanomdm/push/service/service.go b/server/mdm/nanomdm/push/service/service.go index d74ae752a8..3281ab1783 100644 --- a/server/mdm/nanomdm/push/service/service.go +++ b/server/mdm/nanomdm/push/service/service.go @@ -8,11 +8,12 @@ import ( "fmt" "sync" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) type provider struct { @@ -86,6 +87,7 @@ func (s *PushService) getProvider(ctx context.Context, topic string) (push.PushP type pushFeedback struct { Responses map[string]*push.Response Err error + Topic string } var ErrIdNotFound = errors.New("push data missing for id") @@ -101,7 +103,7 @@ func (s *PushService) pushSingle(ctx context.Context, pushInfo *mdm.Push) (map[s if err != nil { return nil, err } - return prov.Push([]*mdm.Push{pushInfo}) + return prov.Push(ctx, []*mdm.Push{pushInfo}) } // pushMulti sends pushes to (potentially) multiple push providers @@ -126,24 +128,25 @@ func (s *PushService) pushMulti(ctx context.Context, pushInfos []*mdm.Push) (map continue } topicPushCt += 1 - go func(prov push.PushProvider, pushInfos []*mdm.Push, feedback chan<- pushFeedback) { - resp, err := prov.Push(pushInfos) + go func(prov push.PushProvider, pushInfos []*mdm.Push, feedback chan<- pushFeedback, topic string) { + resp, err := prov.Push(ctx, pushInfos) feedback <- pushFeedback{ Responses: resp, Err: err, + Topic: topic, } - }(prov, pushInfos, feedbackChan) + }(prov, pushInfos, feedbackChan, topic) } responses := make(map[string]*push.Response) for i := 0; i < topicPushCt; i++ { feedback := <-feedbackChan // merge feedback responses into main responses map for token, pushResp := range feedback.Responses { - if finalErr == nil && pushResp.Err != nil { - finalErr = pushResp.Err - } responses[token] = pushResp } + if finalErr == nil && feedback.Err != nil { + finalErr = fmt.Errorf("topic %s: %w", feedback.Topic, feedback.Err) + } } close(feedbackChan) return responses, finalErr @@ -184,14 +187,8 @@ func (s *PushService) Push(ctx context.Context, ids []string) (map[string]*push. // some environments may heavily utilize individual pushes. // this justifies the special case and optimizes for it. tokenToResponse, err = s.pushSingle(ctx, pushInfos[0]) //nolint:gosec - if err != nil { - return nil, err - } } else if len(pushInfos) > 1 { tokenToResponse, err = s.pushMulti(ctx, pushInfos) - if err != nil { - return nil, err - } } // re-associate token responses with ids @@ -206,5 +203,5 @@ func (s *PushService) Push(ctx context.Context, ids []string) (map[string]*push. idToResponse[id] = resp } - return idToResponse, nil + return idToResponse, err } diff --git a/server/mdm/nanomdm/service/certauth/certauth.go b/server/mdm/nanomdm/service/certauth/certauth.go index e7d8e02472..6b1e62cb34 100644 --- a/server/mdm/nanomdm/service/certauth/certauth.go +++ b/server/mdm/nanomdm/service/certauth/certauth.go @@ -8,11 +8,12 @@ import ( "errors" "fmt" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) var ( @@ -97,6 +98,7 @@ func New(next service.CheckinAndCommandService, storage storage.CertAuthStore, o return certAuth } +// HashCert returns the string representation func HashCert(cert *x509.Certificate) string { hashed := sha256.Sum256(cert.Raw) b := make([]byte, len(hashed)) diff --git a/server/mdm/nanomdm/service/certauth/certauth_test.go b/server/mdm/nanomdm/service/certauth/certauth_test.go deleted file mode 100644 index 04420b4d6e..0000000000 --- a/server/mdm/nanomdm/service/certauth/certauth_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package certauth - -import ( - "errors" - "io/ioutil" - "os" - "testing" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/file" -) - -func loadAuthMsg() (*mdm.Authenticate, error) { - b, err := ioutil.ReadFile("../../mdm/testdata/Authenticate.2.plist") - if err != nil { - return nil, err - } - r, err := mdm.DecodeCheckin(b) - if err != nil { - return nil, err - } - a, ok := r.(*mdm.Authenticate) - if !ok { - return nil, errors.New("not an Authenticate message") - } - return a, nil -} - -func loadTokenMsg() (*mdm.TokenUpdate, error) { - b, err := ioutil.ReadFile("../../mdm/testdata/TokenUpdate.2.plist") - if err != nil { - return nil, err - } - r, err := mdm.DecodeCheckin(b) - if err != nil { - return nil, err - } - a, ok := r.(*mdm.TokenUpdate) - if !ok { - return nil, errors.New("not a TokenUpdate message") - } - return a, nil -} - -func TestNilCertAuth(t *testing.T) { - auth, err := loadAuthMsg() - if err != nil { - t.Fatal(err) - } - certAuth := New(nil, nil) - if certAuth == nil { - t.Fatal("New returned nil") - } - err = certAuth.Authenticate(&mdm.Request{}, auth) - if err == nil { - t.Fatal("expected error, nil returned") - } - if !errors.Is(err, ErrMissingCert) { - t.Fatalf("wrong error: %v", err) - } -} - -func TestCertAuth(t *testing.T) { - _, crt, err := SimpleSelfSignedRSAKeypair("TESTDEVICE", 1) - if err != nil { - t.Fatal(err) - } - storage, err := file.New("test-db") - if err != nil { - t.Fatal(err) - } - certAuth := New(&NopService{}, storage) - if certAuth == nil { - t.Fatal("New returned nil") - } - token, err := loadTokenMsg() - if err != nil { - t.Fatal(err) - } - // a non-Auth message without first Auth'ing the cert should - // generate an ErrNoCertAssoc. - err = certAuth.TokenUpdate(&mdm.Request{Certificate: crt}, token) - if err == nil { - t.Fatal("expected err; nil returned") - } - if !errors.Is(err, ErrNoCertAssoc) { - t.Fatalf("wrong error: %v", err) - } - // send another one to make sure we're not accidentally allowing - // retroactive - err = certAuth.TokenUpdate(&mdm.Request{Certificate: crt}, token) - if err == nil { - t.Fatal("expected err; nil returned") - } - if !errors.Is(err, ErrNoCertAssoc) { - t.Fatalf("wrong error: %v", err) - } - authMsg, err := loadAuthMsg() - if err != nil { - t.Fatal(err) - } - // let's actually associate our cert... - err = certAuth.Authenticate(&mdm.Request{Certificate: crt}, authMsg) - if err != nil { - t.Fatal(err) - } - // ... and try again. - err = certAuth.TokenUpdate(&mdm.Request{Certificate: crt}, token) - if err != nil { - t.Fatal(err) - } - _, crt2, err := SimpleSelfSignedRSAKeypair("TESTDEVICE", 2) - if err != nil { - t.Fatal(err) - } - // lets try and spoof our UDID using another certificate (bad!) - err = certAuth.TokenUpdate(&mdm.Request{Certificate: crt2}, token) - if err == nil { - t.Fatal("expected err; nil returned") - } - if !errors.Is(err, ErrNoCertAssoc) { - t.Fatalf("wrong error: %v", err) - } - os.RemoveAll("test-db") -} - -func TestCertAuthRetro(t *testing.T) { - _, crt, err := SimpleSelfSignedRSAKeypair("TESTDEVICE", 1) - if err != nil { - t.Fatal(err) - } - storage, err := file.New("test-db") - if err != nil { - t.Fatal(err) - } - certAuth := New(&NopService{}, storage, WithAllowRetroactive()) - if certAuth == nil { - t.Fatal("New returned nil") - } - token, err := loadTokenMsg() - if err != nil { - t.Fatal(err) - } - // usually a non-Auth message without first Auth'ing the cert would - // generate an ErrNoCertAssoc. instead this should allow us to - // register a cert. - err = certAuth.TokenUpdate(&mdm.Request{Certificate: crt}, token) - if err != nil { - t.Fatal(err) - } - // send another one to make sure we're still associated - err = certAuth.TokenUpdate(&mdm.Request{Certificate: crt}, token) - if err != nil { - t.Fatal(err) - } - _, crt2, err := SimpleSelfSignedRSAKeypair("TESTDEVICE", 2) - if err != nil { - t.Fatal(err) - } - // lets try and spoof our UDID using another certificate (bad!) to - // make sure we were properly setting retroactive association - err = certAuth.TokenUpdate(&mdm.Request{Certificate: crt2}, token) - if err == nil { - t.Fatal("expected err; nil returned") - } - if !errors.Is(err, ErrNoCertReuse) { - t.Fatalf("wrong error: %v", err) - } - os.RemoveAll("test-db") -} diff --git a/server/mdm/nanomdm/service/certauth/service.go b/server/mdm/nanomdm/service/certauth/service.go index a07f69def2..f3d754fee9 100644 --- a/server/mdm/nanomdm/service/certauth/service.go +++ b/server/mdm/nanomdm/service/certauth/service.go @@ -53,6 +53,13 @@ func (s *CertAuth) DeclarativeManagement(r *mdm.Request, m *mdm.DeclarativeManag return s.next.DeclarativeManagement(r, m) } +func (s *CertAuth) GetToken(r *mdm.Request, m *mdm.GetToken) (*mdm.GetTokenResponse, error) { + if err := s.validateOrAssociateForExistingEnrollment(r, &m.Enrollment); err != nil { + return nil, err + } + return s.next.GetToken(r, m) +} + func (s *CertAuth) CommandAndReportResults(r *mdm.Request, results *mdm.CommandResults) (*mdm.Command, error) { if err := s.validateOrAssociateForExistingEnrollment(r, &results.Enrollment); err != nil { return nil, err diff --git a/server/mdm/nanomdm/service/dump/dump.go b/server/mdm/nanomdm/service/dump/dump.go index dee382574c..98502c10ad 100644 --- a/server/mdm/nanomdm/service/dump/dump.go +++ b/server/mdm/nanomdm/service/dump/dump.go @@ -2,6 +2,7 @@ package dump import ( + "encoding/base64" "fmt" "os" @@ -70,6 +71,16 @@ func (svc *Dumper) GetBootstrapToken(r *mdm.Request, m *mdm.GetBootstrapToken) ( return bsToken, err } +func (svc *Dumper) GetToken(r *mdm.Request, m *mdm.GetToken) (*mdm.GetTokenResponse, error) { + svc.file.Write(m.Raw) // nolint:errcheck + token, err := svc.next.GetToken(r, m) + if token != nil && len(token.TokenData) > 0 { + b64 := base64.StdEncoding.EncodeToString(token.TokenData) + svc.file.WriteString("GetToken TokenData: " + b64 + "\n") // nolint:errcheck + } + return token, err +} + func (svc *Dumper) CommandAndReportResults(r *mdm.Request, results *mdm.CommandResults) (*mdm.Command, error) { _, _ = svc.file.Write(results.Raw) cmd, err := svc.next.CommandAndReportResults(r, results) diff --git a/server/mdm/nanomdm/service/microwebhook/service.go b/server/mdm/nanomdm/service/microwebhook/service.go index 3aaead2cd2..4ee9f47b4f 100644 --- a/server/mdm/nanomdm/service/microwebhook/service.go +++ b/server/mdm/nanomdm/service/microwebhook/service.go @@ -143,3 +143,17 @@ func (w *MicroWebhook) DeclarativeManagement(r *mdm.Request, m *mdm.DeclarativeM } return nil, postWebhookEvent(r.Context, w.client, w.url, ev) } + +func (w *MicroWebhook) GetToken(r *mdm.Request, m *mdm.GetToken) (*mdm.GetTokenResponse, error) { + ev := &Event{ + Topic: "mdm.GetToken", + CreatedAt: time.Now(), + CheckinEvent: &CheckinEvent{ + UDID: m.UDID, + EnrollmentID: m.EnrollmentID, + RawPayload: m.Raw, + Params: r.Params, + }, + } + return nil, postWebhookEvent(r.Context, w.client, w.url, ev) +} diff --git a/server/mdm/nanomdm/service/multi/multi.go b/server/mdm/nanomdm/service/multi/multi.go index ad6ba138aa..a69c459b46 100644 --- a/server/mdm/nanomdm/service/multi/multi.go +++ b/server/mdm/nanomdm/service/multi/multi.go @@ -5,10 +5,11 @@ import ( "context" "sync" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) // MultiService executes multiple services for the same service calls. @@ -128,6 +129,16 @@ func (ms *MultiService) DeclarativeManagement(r *mdm.Request, m *mdm.Declarative return retBytes, err } +func (ms *MultiService) GetToken(r *mdm.Request, m *mdm.GetToken) (*mdm.GetTokenResponse, error) { + resp, err := ms.svcs[0].GetToken(r, m) + rc := ms.RequestWithContext(r) + ms.runOthers(r.Context, func(svc service.CheckinAndCommandService) error { + _, err := svc.GetToken(rc, m) + return err + }) + return resp, err +} + func (ms *MultiService) CommandAndReportResults(r *mdm.Request, results *mdm.CommandResults) (*mdm.Command, error) { cmd, err := ms.svcs[0].CommandAndReportResults(r, results) rc := ms.RequestWithContext(r) diff --git a/server/mdm/nanomdm/service/nanomdm/service.go b/server/mdm/nanomdm/service/nanomdm/service.go index 85f943b536..3685a483b2 100644 --- a/server/mdm/nanomdm/service/nanomdm/service.go +++ b/server/mdm/nanomdm/service/nanomdm/service.go @@ -4,13 +4,13 @@ package nanomdm import ( "errors" "fmt" - "net/http" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) // Service is the main NanoMDM service which dispatches to storage. @@ -19,16 +19,14 @@ type Service struct { normalizer func(e *mdm.Enrollment) *mdm.EnrollID store storage.ServiceStore - // By default the UserAuthenticate message will be rejected (410 - // response). If this is set true then a static zero-length - // digest challenge will be supplied to the first UserAuthenticate - // check-in message. See the Discussion section of - // https://developer.apple.com/documentation/devicemanagement/userauthenticate - sendEmptyDigestChallenge bool - storeRejectedUserAuth bool - // Declarative Management dm service.DeclarativeManagement + + // UserAuthenticate processor + ua service.UserAuthenticate + + // GetToken handler + gt service.GetToken } // normalize generates enrollment IDs that are used by other @@ -72,6 +70,20 @@ func WithDeclarativeManagement(dm service.DeclarativeManagement) Option { } } +// WithUserAuthenticate configures a UserAuthenticate check-in message handler. +func WithUserAuthenticate(ua service.UserAuthenticate) Option { + return func(s *Service) { + s.ua = ua + } +} + +// WithGetToken configures a GetToken check-in message handler. +func WithGetToken(gt service.GetToken) Option { + return func(s *Service) { + s.gt = gt + } +} + // New returns a new NanoMDM main service. func New(store storage.ServiceStore, opts ...Option) *Service { nanomdm := &Service{ @@ -144,45 +156,15 @@ func (s *Service) CheckOut(r *mdm.Request, message *mdm.CheckOut) error { return s.store.Disable(r) } -const emptyDigestChallenge = ` - - - - DigestChallenge - - -` - -var emptyDigestChallengeBytes = []byte(emptyDigestChallenge) - +// UserAuthenticate Check-in message implementation func (s *Service) UserAuthenticate(r *mdm.Request, message *mdm.UserAuthenticate) ([]byte, error) { if err := s.setupRequest(r, &message.Enrollment); err != nil { return nil, err } - logger := ctxlog.Logger(r.Context, s.logger) - if s.sendEmptyDigestChallenge || s.storeRejectedUserAuth { - if err := s.store.StoreUserAuthenticate(r, message); err != nil { - return nil, err - } + if s.ua == nil { + return nil, errors.New("no UserAuthenticate handler") } - // if the DigestResponse is empty then this is the first (of two) - // UserAuthenticate messages depending on our response - if message.DigestResponse == "" { - if s.sendEmptyDigestChallenge { - logger.Info( - "msg", "sending empty DigestChallenge response to UserAuthenticate", - ) - return emptyDigestChallengeBytes, nil - } - return nil, service.NewHTTPStatusError( - http.StatusGone, - fmt.Errorf("declining management of user: %s", r.ID), - ) - } - logger.Debug( - "msg", "sending empty response to second UserAuthenticate", - ) - return nil, nil + return s.ua.UserAuthenticate(r, message) } func (s *Service) SetBootstrapToken(r *mdm.Request, message *mdm.SetBootstrapToken) error { @@ -217,6 +199,21 @@ func (s *Service) DeclarativeManagement(r *mdm.Request, message *mdm.Declarative return s.dm.DeclarativeManagement(r, message) } +// GetToken implements the GetToken Check-in message interface. +func (s *Service) GetToken(r *mdm.Request, message *mdm.GetToken) (*mdm.GetTokenResponse, error) { + if err := s.setupRequest(r, &message.Enrollment); err != nil { + return nil, err + } + ctxlog.Logger(r.Context, s.logger).Info( + "msg", "GetToken", + "token_service_type", message.TokenServiceType, + ) + if s.gt == nil { + return nil, errors.New("no GetToken handler") + } + return s.gt.GetToken(r, message) +} + // CommandAndReportResults command report and next-command request implementation. func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandResults) (*mdm.Command, error) { if err := s.setupRequest(r, &results.Enrollment); err != nil { @@ -243,7 +240,6 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe logger.Info( "msg", "host reported status with invalid command uuid", "command_uuid", results.CommandUUID, - "request_type", results.RequestType, "status", results.Status, "error_chain", results.ErrorChain, ) diff --git a/server/mdm/nanomdm/service/nanomdm/token.go b/server/mdm/nanomdm/service/nanomdm/token.go new file mode 100644 index 0000000000..dedcae8862 --- /dev/null +++ b/server/mdm/nanomdm/service/nanomdm/token.go @@ -0,0 +1,72 @@ +package nanomdm + +import ( + "errors" + "fmt" + "sync" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" +) + +// StaticToken holds static token bytes. +type StaticToken struct { + token []byte +} + +// NewStaticToken creates a new static token handler. +func NewStaticToken(token []byte) *StaticToken { + return &StaticToken{token: token} +} + +// GetToken always responds with the static token bytes. +func (t *StaticToken) GetToken(_ *mdm.Request, _ *mdm.GetToken) (*mdm.GetTokenResponse, error) { + return &mdm.GetTokenResponse{TokenData: t.token}, nil +} + +// TokenMux is a middleware multiplexer for GetToken check-in messages. +// A TokenServiceType string is associated with a GetToken handler and +// then dispatched appropriately. +type TokenMux struct { + typesMu sync.RWMutex + types map[string]service.GetToken +} + +// NewTokenMux creates a new TokenMux. +func NewTokenMux() *TokenMux { return &TokenMux{} } + +// Handle registers a GetToken handler for the given service type. +// See https://developer.apple.com/documentation/devicemanagement/gettokenrequest +func (mux *TokenMux) Handle(serviceType string, handler service.GetToken) { + if serviceType == "" { + panic("tokenmux: invalid service type") + } + if handler == nil { + panic("tokenmux: invalid handler") + } + mux.typesMu.Lock() + defer mux.typesMu.Unlock() + if mux.types == nil { + mux.types = make(map[string]service.GetToken) + } else if _, exists := mux.types[serviceType]; exists { + panic("tokenmux: multiple registrations for " + serviceType) + } + mux.types[serviceType] = handler +} + +// GetToken is the middleware that dispatches a GetToken handler based on service type. +func (mux *TokenMux) GetToken(r *mdm.Request, t *mdm.GetToken) (*mdm.GetTokenResponse, error) { + if t == nil { + return nil, errors.New("nil MDM GetToken") + } + var next service.GetToken + mux.typesMu.RLock() + if mux.types != nil { + next = mux.types[t.TokenServiceType] + } + mux.typesMu.RUnlock() + if next == nil { + return nil, fmt.Errorf("no handler for TokenServiceType: %v", t.TokenServiceType) + } + return next.GetToken(r, t) +} diff --git a/server/mdm/nanomdm/service/nanomdm/token_test.go b/server/mdm/nanomdm/service/nanomdm/token_test.go new file mode 100644 index 0000000000..b8caabd51d --- /dev/null +++ b/server/mdm/nanomdm/service/nanomdm/token_test.go @@ -0,0 +1,98 @@ +package nanomdm + +import ( + "bytes" + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" + "github.com/groob/plist" +) + +func newTokenMDMReq() *mdm.Request { + return &mdm.Request{Context: context.Background()} +} + +const tokenTestCheckin = // nolint:gosec // waive G101 hardcoded creds +` + + + + MessageType + GetToken + UDID + test + TokenServiceType + com.apple.maid + + +` + +func TestTokenFull(t *testing.T) { + tokenTestData := []byte("hello") + + // create muxer + m := NewTokenMux() + + // associate a new static token handler with a type + m.Handle("com.apple.maid", NewStaticToken(tokenTestData)) + + // create a new NanoMDM service with our token muxer + s := New(nil, WithGetToken(m)) + + // process GetToken check-in message + respBytes, err := service.CheckinRequest(s, newTokenMDMReq(), []byte(tokenTestCheckin)) + if err != nil { + t.Fatal(err) + } + + // unmarshal response bytes + resp := new(mdm.GetTokenResponse) + err = plist.Unmarshal(respBytes, resp) + if err != nil { + t.Fatal(err) + } + + // check that our token data matches + if want, have := string(tokenTestData), string(resp.TokenData); have != want { + t.Errorf("have %q; want %q", have, want) + } +} + +func newGetToken(serviceType string, id string) *mdm.GetToken { + return &mdm.GetToken{ + TokenServiceType: serviceType, + Enrollment: mdm.Enrollment{UDID: id}, + } +} + +func TestToken(t *testing.T) { + tokenTestData := []byte("hello") + + // create muxer + m := NewTokenMux() + + // associate a new static token handler with a type + m.Handle("com.apple.maid", NewStaticToken(tokenTestData)) + + // create a new NanoMDM service with our token muxer + s := New(nil, WithGetToken(m)) + + // dispatch a GetToken check-in message + resp, err := s.GetToken(newTokenMDMReq(), newGetToken("com.apple.maid", "AAAA-1111")) + if err != nil { + t.Fatal(err) + } + + // check that our token data our matches (from the static handler) + if !bytes.Equal(tokenTestData, resp.TokenData) { + t.Error("input and output not equal") + } + + // supply an invalid service type (not handled) and expect an error + _, err = s.GetToken(newTokenMDMReq(), newGetToken("com.apple.does-not-exist", "AAAA-1111")) + if err == nil { + t.Fatal("should be an error") + } +} diff --git a/server/mdm/nanomdm/service/nanomdm/ua.go b/server/mdm/nanomdm/service/nanomdm/ua.go new file mode 100644 index 0000000000..e1da9f3f01 --- /dev/null +++ b/server/mdm/nanomdm/service/nanomdm/ua.go @@ -0,0 +1,79 @@ +package nanomdm + +import ( + "fmt" + "net/http" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" +) + +// UAService is a basic UserAuthenticate service that optionally implements +// the "zero-length" UserAuthenticate protocol. +// See https://developer.apple.com/documentation/devicemanagement/userauthenticate +type UAService struct { + logger log.Logger + store storage.UserAuthenticateStore + + // By default the UserAuthenticate message will be rejected (410 + // response). If this is set true then a static zero-length + // digest challenge will be supplied to the first UserAuthenticate + // check-in message. See the Discussion section of + // https://developer.apple.com/documentation/devicemanagement/userauthenticate + sendEmptyDigestChallenge bool + storeRejectedUserAuth bool +} + +// NewUAService creates a new UserAuthenticate check-in message handler. +func NewUAService(store storage.UserAuthenticateStore, sendEmptyDigestChallenge bool) *UAService { + return &UAService{ + logger: log.NopLogger, + store: store, + sendEmptyDigestChallenge: sendEmptyDigestChallenge, + } +} + +const emptyDigestChallenge = ` + + + + DigestChallenge + + +` + +var emptyDigestChallengeBytes = []byte(emptyDigestChallenge) + +// UserAuthenticate will decline management of a user unless configured +// for the empty digest 2-step UserAuthenticate protocol. +// It implements the NanoMDM service method for UserAuthenticate check-in messages. +func (s *UAService) UserAuthenticate(r *mdm.Request, message *mdm.UserAuthenticate) ([]byte, error) { + logger := ctxlog.Logger(r.Context, s.logger) + if s.sendEmptyDigestChallenge || s.storeRejectedUserAuth { + if err := s.store.StoreUserAuthenticate(r, message); err != nil { + return nil, err + } + } + // if the DigestResponse is empty then this is the first (of two) + // UserAuthenticate messages depending on our response + if message.DigestResponse == "" { + if s.sendEmptyDigestChallenge { + logger.Info( + "msg", "sending empty DigestChallenge response to UserAuthenticate", + ) + return emptyDigestChallengeBytes, nil + } + return nil, service.NewHTTPStatusError( + http.StatusGone, + fmt.Errorf("declining management of user: %s", r.ID), + ) + } + logger.Debug( + "msg", "sending empty response to second UserAuthenticate", + ) + return nil, nil +} diff --git a/server/mdm/nanomdm/service/nanomdm/ua_test.go b/server/mdm/nanomdm/service/nanomdm/ua_test.go new file mode 100644 index 0000000000..45472bff9a --- /dev/null +++ b/server/mdm/nanomdm/service/nanomdm/ua_test.go @@ -0,0 +1,62 @@ +package nanomdm + +import ( + "bytes" + "errors" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" +) + +type fauxStore struct { + ua *mdm.UserAuthenticate +} + +func (f *fauxStore) StoreUserAuthenticate(_ *mdm.Request, msg *mdm.UserAuthenticate) error { + f.ua = msg + return nil +} + +func newMDMReq() *mdm.Request { + return &mdm.Request{EnrollID: &mdm.EnrollID{ID: ""}} +} + +func TestUAServiceReject(t *testing.T) { + store := &fauxStore{} + s := NewUAService(store, false) + _, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{}) + var httpErr *service.HTTPStatusError + if !errors.As(err, &httpErr) { + // should be returning a HTTPStatusError (to deny management) + t.Fatalf("no error or incorrect error type") + } + if httpErr.Status != 410 { + // if we've kept the "send-empty" false this needs to return a 410 + // i.e. decline management of the user. + t.Error("status not 410") + } +} + +func TestUAService(t *testing.T) { + store := &fauxStore{} + s := NewUAService(store, true) + ret, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{}) + if err != nil { + // should be no error + t.Fatal(err) + } + if !bytes.Equal(ret, emptyDigestChallengeBytes) { + t.Error("response bytes not equal") + } + // second request with DigestResponse + ret, err = s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{DigestResponse: "test"}) + if err != nil { + // should be no error + t.Fatal(err) + } + if ret != nil { + t.Error("response bytes not empty") + } + +} diff --git a/server/mdm/nanomdm/service/request.go b/server/mdm/nanomdm/service/request.go index e80fb6c3ae..cc90033ada 100644 --- a/server/mdm/nanomdm/service/request.go +++ b/server/mdm/nanomdm/service/request.go @@ -93,6 +93,22 @@ func CheckinRequest(svc Checkin, r *mdm.Request, bodyBytes []byte) ([]byte, erro if err != nil { err = fmt.Errorf("declarativemanagement service: %w", err) } + case *mdm.GetToken: + if err := m.Validate(); err != nil { + return nil, fmt.Errorf("gettoken validate: %w", err) + } + resp, err := svc.GetToken(r, m) + if err != nil { + return nil, fmt.Errorf("gettoken service: %w", err) + } + if resp == nil { + return nil, errors.New("gettoken service: no response") + } + respBytes, err = plist.Marshal(resp) + if err != nil { + return nil, fmt.Errorf("gettoken marshal: %w", err) + } + return respBytes, nil default: return nil, errors.New("unhandled check-in request type") } diff --git a/server/mdm/nanomdm/service/service.go b/server/mdm/nanomdm/service/service.go index ebd1e47853..cb0f16ccc6 100644 --- a/server/mdm/nanomdm/service/service.go +++ b/server/mdm/nanomdm/service/service.go @@ -11,6 +11,17 @@ type DeclarativeManagement interface { DeclarativeManagement(*mdm.Request, *mdm.DeclarativeManagement) ([]byte, error) } +// UserAuthenticate is an interface for processing the UserAuthenticate MDM check-in message. +type UserAuthenticate interface { + UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error) +} + +// GetToken is the interface for handling a GetToken check-in message. +// See https://developer.apple.com/documentation/devicemanagement/get_token +type GetToken interface { + GetToken(*mdm.Request, *mdm.GetToken) (*mdm.GetTokenResponse, error) +} + // Checkin represents the various check-in requests. // See https://developer.apple.com/documentation/devicemanagement/check-in type Checkin interface { @@ -19,8 +30,9 @@ type Checkin interface { CheckOut(*mdm.Request, *mdm.CheckOut) error SetBootstrapToken(*mdm.Request, *mdm.SetBootstrapToken) error GetBootstrapToken(*mdm.Request, *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) - UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error) + UserAuthenticate DeclarativeManagement + GetToken } // CommandAndReportResults represents the command report and next-command request. diff --git a/server/mdm/nanomdm/storage/all.go b/server/mdm/nanomdm/storage/all.go index bb6985191d..10b48c059c 100644 --- a/server/mdm/nanomdm/storage/all.go +++ b/server/mdm/nanomdm/storage/all.go @@ -7,6 +7,7 @@ type AllStorage interface { PushCertStore CommandEnqueuer CertAuthStore + CertAuthRetriever StoreMigrator TokenUpdateTallyStore } diff --git a/server/mdm/nanomdm/storage/allmulti/allmulti.go b/server/mdm/nanomdm/storage/allmulti/allmulti.go index 068c2d8d7a..8334fe3dcf 100644 --- a/server/mdm/nanomdm/storage/allmulti/allmulti.go +++ b/server/mdm/nanomdm/storage/allmulti/allmulti.go @@ -3,10 +3,11 @@ package allmulti import ( "context" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) // MultiAllStorage dispatches to multiple AllStorage instances. diff --git a/server/mdm/nanomdm/storage/allmulti/certauth.go b/server/mdm/nanomdm/storage/allmulti/certauth.go index 242f771f70..3fcec098b2 100644 --- a/server/mdm/nanomdm/storage/allmulti/certauth.go +++ b/server/mdm/nanomdm/storage/allmulti/certauth.go @@ -1,6 +1,7 @@ package allmulti import ( + "context" "time" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" @@ -34,3 +35,10 @@ func (ms *MultiAllStorage) AssociateCertHash(r *mdm.Request, hash string, certNo }) return err } + +func (ms *MultiAllStorage) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + val, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) { + return s.EnrollmentFromHash(ctx, hash) + }) + return val.(string), err +} diff --git a/server/mdm/nanomdm/storage/allmulti/migrate.go b/server/mdm/nanomdm/storage/allmulti/migrate.go index 3945fbadc1..469bf80547 100644 --- a/server/mdm/nanomdm/storage/allmulti/migrate.go +++ b/server/mdm/nanomdm/storage/allmulti/migrate.go @@ -3,7 +3,7 @@ package allmulti import ( "context" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" + "github.com/micromdm/nanolib/log/ctxlog" ) func (ms *MultiAllStorage) RetrieveMigrationCheckins(ctx context.Context, c chan<- interface{}) error { diff --git a/server/mdm/nanomdm/storage/file/bstoken.go b/server/mdm/nanomdm/storage/file/bstoken.go index 0e1d55c9ea..2c9a938df4 100644 --- a/server/mdm/nanomdm/storage/file/bstoken.go +++ b/server/mdm/nanomdm/storage/file/bstoken.go @@ -18,10 +18,15 @@ func (s *FileStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapT return nil } +// RetrieveBootstrapToken reads the BootstrapToken from disk and returns it. +// If no token yet exists a nil token and no error are returned. func (s *FileStorage) RetrieveBootstrapToken(r *mdm.Request, _ *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) { e := s.newEnrollment(r.ID) bsTokenRaw, err := e.readFile(BootstrapTokenFile) - if err != nil { + if errors.Is(err, os.ErrNotExist) { + // mute the error if we haven't escrowed a token yet. + return nil, nil + } else if err != nil { return nil, err } bsToken := &mdm.BootstrapToken{ diff --git a/server/mdm/nanomdm/storage/file/certauth.go b/server/mdm/nanomdm/storage/file/certauth.go index 2957d6529c..b3e33a0f8d 100644 --- a/server/mdm/nanomdm/storage/file/certauth.go +++ b/server/mdm/nanomdm/storage/file/certauth.go @@ -2,6 +2,7 @@ package file import ( "bufio" + "context" "errors" "os" "path" @@ -69,3 +70,23 @@ func (s *FileStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time e := s.newEnrollment(r.ID) return e.writeFile(CertAuthFilename, []byte(hash)) } + +func (s *FileStorage) EnrollmentFromHash(_ context.Context, hash string) (string, error) { + f, err := os.Open(path.Join(s.path, CertAuthAssociationsFilename)) + if err != nil { + return "", err + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + text := scanner.Text() + if strings.Contains(text, hash) { + split := strings.Split(text, ",") + if len(split) < 2 { + return "", errors.New("hash and enrollment id not present on line") + } + return split[0], nil + } + } + return "", nil +} diff --git a/server/mdm/nanomdm/storage/file/file.go b/server/mdm/nanomdm/storage/file/file.go index 52535ee00b..2790d4140f 100644 --- a/server/mdm/nanomdm/storage/file/file.go +++ b/server/mdm/nanomdm/storage/file/file.go @@ -166,6 +166,14 @@ func (s *FileStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) e return err } } + if err := e.resetNumericFile(TokenUpdateTallyFilename); err != nil { + return err + } + // remove the BootstrapToken when we receive an Authenticate message + // BS tokens are only valid when a new one is escrowed after enrollment. + if err := os.Remove(e.dirPrefix(BootstrapTokenFile)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } return e.writeFile(AuthenticateFilename, msg.Raw) } @@ -229,9 +237,6 @@ func (s *FileStorage) Disable(r *mdm.Request) error { if err := e.writeFile(DisabledFilename, nil); err != nil { return err } - if err := e.resetNumericFile(TokenUpdateTallyFilename); err != nil { - return err - } } return e.removeSubEnrollments() } diff --git a/server/mdm/nanomdm/storage/file/file_test.go b/server/mdm/nanomdm/storage/file/file_test.go new file mode 100644 index 0000000000..245886e208 --- /dev/null +++ b/server/mdm/nanomdm/storage/file/file_test.go @@ -0,0 +1,17 @@ +package file + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test/e2e" +) + +func TestFileStorage(t *testing.T) { + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + t.Run("e2e", func(t *testing.T) { e2e.TestE2E(t, context.Background(), s) }) +} diff --git a/server/mdm/nanomdm/storage/file/push.go b/server/mdm/nanomdm/storage/file/push.go index f599d1d6c2..5f8d520184 100644 --- a/server/mdm/nanomdm/storage/file/push.go +++ b/server/mdm/nanomdm/storage/file/push.go @@ -3,6 +3,7 @@ package file import ( "context" "errors" + "os" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" ) @@ -13,7 +14,11 @@ func (s *FileStorage) RetrievePushInfo(_ context.Context, ids []string) (map[str for _, id := range ids { e := s.newEnrollment(id) tokenUpdate, err := e.readFile(TokenUpdateFilename) - if err != nil { + if errors.Is(err, os.ErrNotExist) { + // TokenUpdate file missing could be a non-existent or + // incomplete enrollment which should not trigger an error. + continue + } else if err != nil { return nil, err } msg, err := mdm.DecodeCheckin(tokenUpdate) diff --git a/server/mdm/nanomdm/storage/file/queue_test.go b/server/mdm/nanomdm/storage/file/queue_test.go deleted file mode 100644 index 55d314a164..0000000000 --- a/server/mdm/nanomdm/storage/file/queue_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package file - -import ( - "os" - "testing" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/internal/test" -) - -func TestQueue(t *testing.T) { - storage, err := New("test-db") - if err != nil { - t.Fatal(err) - } - test.TestQueue(t, "EA4E19F1-7F8B-493D-BEAB-264B33BCF4E6", storage) - os.RemoveAll("test-db") -} diff --git a/server/mdm/nanomdm/storage/internal/test/queue.go b/server/mdm/nanomdm/storage/internal/test/queue.go deleted file mode 100644 index 55e358fe4b..0000000000 --- a/server/mdm/nanomdm/storage/internal/test/queue.go +++ /dev/null @@ -1,154 +0,0 @@ -package test - -import ( - "context" - "testing" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" - "github.com/groob/plist" -) - -// QueueInterfaces are the storage interfaces needed for testing queue operations. -type QueueInterfaces interface { - storage.CommandEnqueuer - storage.CommandAndReportResultsStore -} - -// newCommand assembles a fake command including the plist raw value -func newCommand(cmd string) (*mdm.Command, error) { - // assemble a fake struct just for marshalling to plist - fCmd := &struct { - CommandUUID string - Command struct { - RequestType string - } - }{ - CommandUUID: cmd, - Command: struct{ RequestType string }{cmd}, - } - // marshal it to plist - rawBytes, err := plist.Marshal(fCmd) - if err != nil { - return nil, err - } - // return a real *mdm.Command which includes the marshalled JSON - return &mdm.Command{ - CommandUUID: fCmd.CommandUUID, - Command: fCmd.Command, - Raw: rawBytes, - }, nil -} - -// enqueue queues a new command -func enqueue(t *testing.T, q QueueInterfaces, ctx context.Context, id, cmdStr string) { - cmd, err := newCommand(cmdStr) - if err != nil { - t.Fatal(err) - } - res, err := q.EnqueueCommand(ctx, []string{id}, cmd) - if err != nil { - t.Fatal(err) - } - for k, v := range res { - t.Fatalf("enqueuing to ID %s: %v", k, v) - } -} - -// compareCommand compares makes sure cmd looks similar to newCommand(cmdStr) -func compareCommand(t *testing.T, cmdStr string, cmd *mdm.Command) { - if cmdStr != "" && cmd == nil { - t.Errorf("expected next command, but got empty response. wanted: %q", cmdStr) - return - } - if cmdStr == "" && cmd != nil { - t.Errorf("expected empty next command, but got: %q", cmd.CommandUUID) - } - if cmd == nil { - return - } - if cmd.CommandUUID != cmdStr { - t.Errorf("mismatched command UUID. want: %q, have: %q", cmdStr, cmd.CommandUUID) - } - if cmd.Command.RequestType != cmdStr { - t.Errorf("mismatched command RequestType. want: %q, have: %q", cmdStr, cmd.Command.RequestType) - } -} - -// retrieve retrieves the next command from the backend -func retrieve(t *testing.T, q QueueInterfaces, r *mdm.Request, cmdStr string, skipNotNow bool) { - retCmd, err := q.RetrieveNextCommand(r, skipNotNow) - if err != nil { - t.Fatal(err) - } - compareCommand(t, cmdStr, retCmd) -} - -// report fakes a command result and reports it to the backend -func report(t *testing.T, q QueueInterfaces, r *mdm.Request, cmdStr, status string) { - fReport := &struct { - CommandUUID string `plist:",omitempty"` - Status string - RequestType string `plist:",omitempty"` - }{CommandUUID: cmdStr, Status: status, RequestType: cmdStr} - rawBytes, err := plist.Marshal(fReport) - if err != nil { - t.Fatal(err) - } - results := &mdm.CommandResults{ - CommandUUID: fReport.CommandUUID, - Status: fReport.Status, - RequestType: fReport.RequestType, - Raw: rawBytes, - } - err = q.StoreCommandReport(r, results) - if err != nil { - t.Error(err) - } -} - -// reportRetrieve behaves similarly to an MDM client: it first reports -// the results and then retrieves the next command. -func reportRetrieve(t *testing.T, q QueueInterfaces, r *mdm.Request, reportCmd, reportStatus, expectedCmd string) { - report(t, q, r, reportCmd, reportStatus) - skipNotNow := false - if reportStatus == "NotNow" { - skipNotNow = true - } - retrieve(t, q, r, expectedCmd, skipNotNow) -} - -// TestQueue performs basic testing of the storage queue -func TestQueue(t *testing.T, id string, q QueueInterfaces) { - ctx := context.Background() - - // build a fake MDM request object - r := &mdm.Request{ - EnrollID: &mdm.EnrollID{ - Type: mdm.Device, - ID: id, - ParentID: "", - }, - Context: ctx, - } - - t.Run("basic", func(t *testing.T) { - reportRetrieve(t, q, r, "", "Idle", "") - enqueue(t, q, ctx, id, "CMD1") - enqueue(t, q, ctx, id, "CMD2") - reportRetrieve(t, q, r, "", "Idle", "CMD1") - reportRetrieve(t, q, r, "CMD1", "Acknowledged", "CMD2") - reportRetrieve(t, q, r, "CMD2", "Acknowledged", "") - reportRetrieve(t, q, r, "", "Idle", "") - }) - - t.Run("notnow", func(t *testing.T) { - reportRetrieve(t, q, r, "", "Idle", "") - enqueue(t, q, ctx, id, "CMD3") - reportRetrieve(t, q, r, "", "Idle", "CMD3") - reportRetrieve(t, q, r, "CMD3", "NotNow", "") - reportRetrieve(t, q, r, "", "Idle", "CMD3") - reportRetrieve(t, q, r, "CMD3", "Acknowledged", "") - reportRetrieve(t, q, r, "", "Idle", "") - }) -} diff --git a/server/mdm/nanomdm/storage/mysql/async.go b/server/mdm/nanomdm/storage/mysql/async.go new file mode 100644 index 0000000000..aa6f4f9629 --- /dev/null +++ b/server/mdm/nanomdm/storage/mysql/async.go @@ -0,0 +1,101 @@ +package mysql + +import ( + "cmp" + "context" + "slices" + "sync" + "time" +) + +type asyncLastSeen struct { + flushInterval time.Duration + flushCap int + set *seenSet[string] + fn func(ctx context.Context, ids []string) +} + +func newAsyncLastSeen(flushInterval time.Duration, flushCap int, fn func(ctx context.Context, ids []string)) *asyncLastSeen { + return &asyncLastSeen{ + flushInterval: flushInterval, + flushCap: flushCap, + set: &seenSet[string]{}, + fn: fn, + } +} + +func (a *asyncLastSeen) markHostSeen(ctx context.Context, id string) { + ids, flush := a.set.add(id, a.flushCap) + if flush && len(ids) > 0 { + a.fn(ctx, ids) + } +} + +func (a *asyncLastSeen) runFlushLoop(ctx context.Context) { + tickCh := time.Tick(a.flushInterval) + for { + select { + case <-tickCh: + ids := a.set.getAndClear() + if len(ids) > 0 { + a.fn(ctx, ids) + } + + case <-ctx.Done(): + return + } + } +} + +// TODO: this could replace the seenHostSet in server/service/async package, +// but I did not want to introduce a dependency between nanomdm and our +// internal code at this point. + +// seenSet implements synchronized storage for the set of seen identifiers. +type seenSet[T cmp.Ordered] struct { + mutex sync.Mutex + seenIDs map[T]struct{} +} + +// add adds the identifier to the set and returns the list of unique IDs - +// clearing the set - if the cap is reached, returning true as the second +// value. Otherwise it returns nil and false. Essentially, if the number of +// unique IDs >= cap, it acts as if getAndClear was called after adding the new +// id. A cap <= 0 is ignored. +func (s *seenSet[T]) add(id T, cap int) ([]T, bool) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.seenIDs == nil { + s.seenIDs = make(map[T]struct{}) + } + s.seenIDs[id] = struct{}{} + + if cap > 0 && len(s.seenIDs) >= cap { + return s.getAndClearLocked(), true + } + return nil, false +} + +// getAndClear gets the list of unique IDs from the set and empties it. +func (s *seenSet[T]) getAndClear() []T { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.getAndClearLocked() +} + +// getAndClearLocked is identical to getAndClear but must only be called when +// the s.mutex lock is held. +func (s *seenSet[T]) getAndClearLocked() []T { + var ids []T + for id := range s.seenIDs { + ids = append(ids, id) + } + // clear the set + s.seenIDs = make(map[T]struct{}) + + // sort to help prevent deadlocks when processing the batch SQL statement + slices.Sort(ids) + return ids +} diff --git a/server/mdm/nanomdm/storage/mysql/async_test.go b/server/mdm/nanomdm/storage/mysql/async_test.go new file mode 100644 index 0000000000..1c1d5859c9 --- /dev/null +++ b/server/mdm/nanomdm/storage/mysql/async_test.go @@ -0,0 +1,147 @@ +package mysql + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAsyncLastSeen(t *testing.T) { + t.Parallel() + + runLoopAndWait := func(t *testing.T, als *asyncLastSeen) (ctx context.Context, stop func()) { + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + go func() { + als.runFlushLoop(ctx) + // runFlushLoop should return once the context is closed + close(done) + }() + + return ctx, func() { + cancel() + select { + case <-done: + // ok + case <-time.After(100 * time.Millisecond): + t.Fatal("runFlushLoop did not return") + } + } + } + + t.Run("always empty", func(t *testing.T) { + t.Parallel() + + als := newAsyncLastSeen(time.Millisecond, 1, func(ctx context.Context, ids []string) { + t.Fatal("unexpected call to fn") + }) + _, stop := runLoopAndWait(t, als) + + time.Sleep(100 * time.Millisecond) + stop() + }) + + t.Run("timed flush", func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var gotIDs []string + als := newAsyncLastSeen(10*time.Millisecond, 10, func(ctx context.Context, ids []string) { + mu.Lock() + defer mu.Unlock() + + // always add a "|" between calls + if len(gotIDs) > 0 { + gotIDs = append(gotIDs, "|") + } + gotIDs = append(gotIDs, ids...) + }) + ctx, stop := runLoopAndWait(t, als) + + als.markHostSeen(ctx, "1") + als.markHostSeen(ctx, "2") + time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI + als.markHostSeen(ctx, "3") + time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI + als.markHostSeen(ctx, "4") + als.markHostSeen(ctx, "5") + als.markHostSeen(ctx, "6") + time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI + + stop() + + mu.Lock() + defer mu.Unlock() + require.Equal(t, "12|3|456", strings.Join(gotIDs, "")) + }) + + t.Run("cap flush", func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var gotIDs []string + als := newAsyncLastSeen(100*time.Millisecond, 2, func(ctx context.Context, ids []string) { + mu.Lock() + defer mu.Unlock() + + // always add a "|" between calls + if len(gotIDs) > 0 { + gotIDs = append(gotIDs, "|") + } + gotIDs = append(gotIDs, ids...) + }) + ctx, stop := runLoopAndWait(t, als) + + als.markHostSeen(ctx, "1") + als.markHostSeen(ctx, "2") + als.markHostSeen(ctx, "3") + als.markHostSeen(ctx, "4") + als.markHostSeen(ctx, "5") + als.markHostSeen(ctx, "6") + + stop() + + mu.Lock() + defer mu.Unlock() + require.Equal(t, "12|34|56", strings.Join(gotIDs, "")) + }) + + t.Run("cap and timed flush", func(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var gotIDs []string + als := newAsyncLastSeen(10*time.Millisecond, 3, func(ctx context.Context, ids []string) { + mu.Lock() + defer mu.Unlock() + + // always add a "|" between calls + if len(gotIDs) > 0 { + gotIDs = append(gotIDs, "|") + } + gotIDs = append(gotIDs, ids...) + }) + ctx, stop := runLoopAndWait(t, als) + + als.markHostSeen(ctx, "1") + als.markHostSeen(ctx, "2") + als.markHostSeen(ctx, "3") + als.markHostSeen(ctx, "4") + time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI + als.markHostSeen(ctx, "5") + time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI + als.markHostSeen(ctx, "6") + time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI + + stop() + + mu.Lock() + defer mu.Unlock() + require.Equal(t, "123|4|5|6", strings.Join(gotIDs, "")) + }) +} diff --git a/server/mdm/nanomdm/storage/mysql/bstoken_test.go b/server/mdm/nanomdm/storage/mysql/bstoken_test.go deleted file mode 100644 index 395221d033..0000000000 --- a/server/mdm/nanomdm/storage/mysql/bstoken_test.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build integration -// +build integration - -package mysql - -import ( - "bytes" - "context" - "encoding/base64" - "testing" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" -) - -func TestBSToken(t *testing.T) { - if *flDSN == "" { - t.Fatal("MySQL DSN flag not provided to test") - } - - storage, err := New(WithDSN(*flDSN), WithDeleteCommands()) - if err != nil { - t.Fatal(err) - } - - var d Device - d, err = enrollTestDevice(storage) - if err != nil { - t.Fatal(err) - } - - ctx := context.Background() - - t.Run("BSToken nil", func(t *testing.T) { - tok, err := storage.RetrieveBootstrapToken(&mdm.Request{Context: ctx, EnrollID: d.EnrollID()}, nil) - if err != nil { - t.Fatal(err) - } - if tok != nil { - t.Fatal("Token for new device was nonnull") - } - }) - t.Run("BSToken set/get", func(t *testing.T) { - data := []byte("test token") - bsToken := mdm.BootstrapToken{BootstrapToken: make([]byte, base64.StdEncoding.EncodedLen(len(data)))} - base64.StdEncoding.Encode(bsToken.BootstrapToken, data) - testReq := &mdm.Request{Context: ctx, EnrollID: d.EnrollID()} - err := storage.StoreBootstrapToken(testReq, &mdm.SetBootstrapToken{BootstrapToken: bsToken}) - if err != nil { - t.Fatal(err) - } - - tok, err := storage.RetrieveBootstrapToken(testReq, nil) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(bsToken.BootstrapToken, tok.BootstrapToken) { - t.Fatalf("Bootstap tokens disequal after roundtrip: %v!=%v", bsToken, tok) - } - }) -} diff --git a/server/mdm/nanomdm/storage/mysql/certauth.go b/server/mdm/nanomdm/storage/mysql/certauth.go index 7e852df061..c64cb1bb69 100644 --- a/server/mdm/nanomdm/storage/mysql/certauth.go +++ b/server/mdm/nanomdm/storage/mysql/certauth.go @@ -2,6 +2,8 @@ package mysql import ( "context" + "database/sql" + "errors" "strings" "time" @@ -53,3 +55,16 @@ UPDATE ) return err } + +func (s *MySQLStorage) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + var id string + err := s.db.QueryRowContext( + ctx, + `SELECT id FROM cert_auth_associations WHERE sha256 = ? LIMIT 1;`, + hash, + ).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err +} diff --git a/server/mdm/nanomdm/storage/mysql/common_test.go b/server/mdm/nanomdm/storage/mysql/common_test.go deleted file mode 100644 index 84e9a898b5..0000000000 --- a/server/mdm/nanomdm/storage/mysql/common_test.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build integration -// +build integration - -package mysql - -import "flag" - -var flDSN = flag.String("dsn", "", "DSN of test MySQL instance") diff --git a/server/mdm/nanomdm/storage/mysql/device_test.go b/server/mdm/nanomdm/storage/mysql/device_test.go deleted file mode 100644 index 905e069c6d..0000000000 --- a/server/mdm/nanomdm/storage/mysql/device_test.go +++ /dev/null @@ -1,89 +0,0 @@ -//go:build integration -// +build integration - -package mysql - -import ( - "context" - "errors" - "io/ioutil" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" -) - -type DeviceInterfaces interface { - storage.CheckinStore -} - -type Device struct { - UDID string -} - -func (d *Device) EnrollID() *mdm.EnrollID { - return &mdm.EnrollID{Type: mdm.Device, ID: d.UDID} -} - -func loadAuthMsg() (*mdm.Authenticate, Device, error) { - var d Device - b, err := ioutil.ReadFile("../../mdm/testdata/Authenticate.2.plist") - if err != nil { - return nil, d, err - } - r, err := mdm.DecodeCheckin(b) - if err != nil { - return nil, d, err - } - a, ok := r.(*mdm.Authenticate) - if !ok { - return nil, d, errors.New("not an Authenticate message") - } - d = Device{UDID: a.UDID} - return a, d, nil -} - -func loadTokenMsg() (*mdm.TokenUpdate, error) { - b, err := ioutil.ReadFile("../../mdm/testdata/TokenUpdate.2.plist") - if err != nil { - return nil, err - } - r, err := mdm.DecodeCheckin(b) - if err != nil { - return nil, err - } - a, ok := r.(*mdm.TokenUpdate) - if !ok { - return nil, errors.New("not a TokenUpdate message") - } - return a, nil -} - -func (d *Device) newMdmReq() *mdm.Request { - return &mdm.Request{ - Context: context.Background(), - EnrollID: &mdm.EnrollID{ - Type: mdm.Device, - ID: d.UDID, - }, - } -} - -func enrollTestDevice(storage DeviceInterfaces) (Device, error) { - authMsg, d, err := loadAuthMsg() - if err != nil { - return d, err - } - err = storage.StoreAuthenticate(d.newMdmReq(), authMsg) - if err != nil { - return d, err - } - tokenMsg, err := loadTokenMsg() - if err != nil { - return d, err - } - err = storage.StoreTokenUpdate(d.newMdmReq(), tokenMsg) - if err != nil { - return d, err - } - return d, nil -} diff --git a/server/mdm/nanomdm/storage/mysql/mysql.go b/server/mdm/nanomdm/storage/mysql/mysql.go index 6ce4a78ca1..a6ec352507 100644 --- a/server/mdm/nanomdm/storage/mysql/mysql.go +++ b/server/mdm/nanomdm/storage/mysql/mysql.go @@ -7,11 +7,14 @@ import ( _ "embed" "errors" "fmt" + "os" + "time" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/jmoiron/sqlx" + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/ctxlog" ) // Schema holds the schema for the NanoMDM MySQL storage. @@ -22,17 +25,20 @@ var Schema string var ErrNoCert = errors.New("no certificate in MDM Request") type MySQLStorage struct { - logger log.Logger - db *sql.DB - rm bool + logger log.Logger + db *sql.DB + rm bool + asyncLastSeen *asyncLastSeen } type config struct { - driver string - dsn string - db *sql.DB - logger log.Logger - rm bool + driver string + dsn string + db *sql.DB + logger log.Logger + rm bool + asyncCap int + asyncInterval time.Duration } type Option func(*config) @@ -67,8 +73,20 @@ func WithDeleteCommands() Option { } } +func WithAsyncLastSeen(cap int, interval time.Duration) Option { + return func(c *config) { + c.asyncCap = cap + c.asyncInterval = interval + } +} + func New(opts ...Option) (*MySQLStorage, error) { - cfg := &config{logger: log.NopLogger, driver: "mysql"} + const ( + asyncLastSeenFlushInterval = 2 * time.Second + asyncLastSeenCap = 1000 + ) + + cfg := &config{logger: log.NopLogger, driver: "mysql", asyncCap: asyncLastSeenCap, asyncInterval: asyncLastSeenFlushInterval} for _, opt := range opts { opt(cfg) } @@ -82,7 +100,17 @@ func New(opts ...Option) (*MySQLStorage, error) { if err = cfg.db.Ping(); err != nil { return nil, err } - return &MySQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm}, nil + + mysqlStore := &MySQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm} + + if v := os.Getenv("FLEET_DISABLE_ASYNC_NANO_LAST_SEEN"); v != "1" { + asyncLastSeen := newAsyncLastSeen(cfg.asyncInterval, cfg.asyncCap, mysqlStore.updateLastSeenBatch) + mysqlStore.asyncLastSeen = asyncLastSeen + + go asyncLastSeen.runFlushLoop(context.Background()) + } + + return mysqlStore, nil } // nullEmptyString returns a NULL string if s is empty. @@ -108,6 +136,8 @@ ON DUPLICATE KEY UPDATE identity_cert = VALUES(identity_cert), serial_number = VALUES(serial_number), + bootstrap_token_b64 = NULL, + bootstrap_token_at = NULL, authenticate = VALUES(authenticate), authenticate_at = CURRENT_TIMESTAMP;`, r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, @@ -264,6 +294,11 @@ func (s *MySQLStorage) Disable(r *mdm.Request) error { } func (s *MySQLStorage) updateLastSeen(r *mdm.Request) (err error) { + if s.asyncLastSeen != nil { + s.asyncLastSeen.markHostSeen(r.Context, r.ID) + return nil + } + _, err = s.db.ExecContext( r.Context, `UPDATE nano_enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`, @@ -274,3 +309,20 @@ func (s *MySQLStorage) updateLastSeen(r *mdm.Request) (err error) { } return } + +func (s *MySQLStorage) updateLastSeenBatch(ctx context.Context, ids []string) { + if len(ids) == 0 { + return + } + + stmt, args, err := sqlx.In(`UPDATE nano_enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id IN (?)`, ids) + if err != nil { + s.logger.Info("msg", "error building nano_enrollments.last_seen_at sql", "err", err) + return + } + + _, err = s.db.ExecContext(ctx, stmt, args...) + if err != nil { + s.logger.Info("msg", "error batch updating nano_enrollments.last_seen_at", "err", err) + } +} diff --git a/server/mdm/nanomdm/storage/mysql/mysql_test.go b/server/mdm/nanomdm/storage/mysql/mysql_test.go new file mode 100644 index 0000000000..db9e074cd4 --- /dev/null +++ b/server/mdm/nanomdm/storage/mysql/mysql_test.go @@ -0,0 +1,31 @@ +package mysql + +import ( + "context" + "os" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test/e2e" + _ "github.com/go-sql-driver/mysql" +) + +func TestMySQL(t *testing.T) { + testDSN := os.Getenv("NANOMDM_MYSQL_STORAGE_TEST_DSN") + if testDSN == "" { + t.Skip("NANOMDM_MYSQL_STORAGE_TEST_DSN not set") + } + + s, err := New(WithDSN(testDSN), WithDeleteCommands()) + if err != nil { + t.Fatal(err) + } + + t.Run("e2e-WithDeleteCommands()", func(t *testing.T) { e2e.TestE2E(t, context.Background(), s) }) + + s, err = New(WithDSN(testDSN)) + if err != nil { + t.Fatal(err) + } + + t.Run("e2e", func(t *testing.T) { e2e.TestE2E(t, context.Background(), s) }) +} diff --git a/server/mdm/nanomdm/storage/mysql/queue.go b/server/mdm/nanomdm/storage/mysql/queue.go index a67262e4f8..28a2c058b4 100644 --- a/server/mdm/nanomdm/storage/mysql/queue.go +++ b/server/mdm/nanomdm/storage/mysql/queue.go @@ -52,7 +52,7 @@ func (m *MySQLStorage) deleteCommand(ctx context.Context, tx *sql.Tx, id, uuid s // trying to each delete it do not race _, err := tx.ExecContext( ctx, ` -SELECT command_uuid FROM commands WHERE command_uuid = ? FOR UPDATE; +SELECT command_uuid FROM nano_commands WHERE command_uuid = ? FOR UPDATE; `, uuid, ) diff --git a/server/mdm/nanomdm/storage/mysql/queue_test.go b/server/mdm/nanomdm/storage/mysql/queue_test.go deleted file mode 100644 index 1b29de5b2d..0000000000 --- a/server/mdm/nanomdm/storage/mysql/queue_test.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build integration -// +build integration - -package mysql - -import ( - "testing" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/internal/test" - - _ "github.com/go-sql-driver/mysql" -) - -func TestQueue(t *testing.T) { - if *flDSN == "" { - t.Fatal("MySQL DSN flag not provided to test") - } - - storage, err := New(WithDSN(*flDSN), WithDeleteCommands()) - if err != nil { - t.Fatal(err) - } - - d, err := enrollTestDevice(storage) - if err != nil { - t.Fatal(err) - } - - t.Run("WithDeleteCommands()", func(t *testing.T) { - test.TestQueue(t, d.UDID, storage) - }) - - storage, err = New(WithDSN(*flDSN)) - if err != nil { - t.Fatal(err) - } - - t.Run("normal", func(t *testing.T) { - test.TestQueue(t, d.UDID, storage) - }) -} diff --git a/server/mdm/nanomdm/storage/mysql/schema.sql b/server/mdm/nanomdm/storage/mysql/schema.sql index 617a570d57..d1f420391d 100644 --- a/server/mdm/nanomdm/storage/mysql/schema.sql +++ b/server/mdm/nanomdm/storage/mysql/schema.sql @@ -274,6 +274,7 @@ CREATE TABLE nano_cert_auth_associations ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + cert_not_valid_after timestamp NULL DEFAULT NULL, PRIMARY KEY (id, sha256), diff --git a/server/mdm/nanomdm/storage/pgsql/bstoken.go b/server/mdm/nanomdm/storage/pgsql/bstoken.go deleted file mode 100644 index 167723703d..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/bstoken.go +++ /dev/null @@ -1,36 +0,0 @@ -package pgsql - -import ( - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" -) - -func (s *PgSQLStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapToken) error { - _, err := s.db.ExecContext( - r.Context, - `UPDATE devices SET bootstrap_token_b64 = $1, bootstrap_token_at = CURRENT_TIMESTAMP WHERE id = $2;`, - nullEmptyString(msg.BootstrapToken.BootstrapToken.String()), - r.ID, - ) - if err != nil { - return err - } - return s.updateLastSeen(r) -} - -func (s *PgSQLStorage) RetrieveBootstrapToken(r *mdm.Request, _ *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) { - var tokenB64 string - err := s.db.QueryRowContext( - r.Context, - `SELECT bootstrap_token_b64 FROM devices WHERE id = $1;`, - r.ID, - ).Scan(&tokenB64) - if err != nil { - return nil, err - } - bsToken := new(mdm.BootstrapToken) - err = bsToken.SetTokenString(tokenB64) - if err == nil { - err = s.updateLastSeen(r) - } - return bsToken, err -} diff --git a/server/mdm/nanomdm/storage/pgsql/certauth.go b/server/mdm/nanomdm/storage/pgsql/certauth.go deleted file mode 100644 index e474319b85..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/certauth.go +++ /dev/null @@ -1,53 +0,0 @@ -package pgsql - -import ( - "context" - "strings" - "time" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" -) - -// Executes SQL statements that return a single COUNT(*) of rows. -func (s *PgSQLStorage) queryRowContextRowExists(ctx context.Context, query string, args ...interface{}) (bool, error) { - var ct int - err := s.db.QueryRowContext(ctx, query, args...).Scan(&ct) - return ct > 0, err -} - -func (s *PgSQLStorage) EnrollmentHasCertHash(r *mdm.Request, _ string) (bool, error) { - return s.queryRowContextRowExists( - r.Context, - `SELECT COUNT(*) FROM cert_auth_associations WHERE id = $1;`, - r.ID, - ) -} - -func (s *PgSQLStorage) HasCertHash(r *mdm.Request, hash string) (bool, error) { - return s.queryRowContextRowExists( - r.Context, - `SELECT COUNT(*) FROM cert_auth_associations WHERE sha256 = $1;`, - strings.ToLower(hash), - ) -} - -func (s *PgSQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, error) { - return s.queryRowContextRowExists( - r.Context, - `SELECT COUNT(*) FROM cert_auth_associations WHERE id = $1 AND sha256 = $2;`, - r.ID, strings.ToLower(hash), - ) -} - -// AssociateCertHash "DO NOTHING" on duplicated keys -func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time) error { - _, err := s.db.ExecContext( - r.Context, ` -INSERT INTO cert_auth_associations (id, sha256) -VALUES ($1, $2) -ON CONFLICT ON CONSTRAINT cert_auth_associations_pkey DO UPDATE SET updated_at=now();`, - r.ID, - strings.ToLower(hash), - ) - return err -} diff --git a/server/mdm/nanomdm/storage/pgsql/migrate.go b/server/mdm/nanomdm/storage/pgsql/migrate.go deleted file mode 100644 index 08b9a1f963..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/migrate.go +++ /dev/null @@ -1,61 +0,0 @@ -package pgsql - -import ( - "context" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" -) - -func (s *PgSQLStorage) RetrieveMigrationCheckins(ctx context.Context, c chan<- interface{}) error { - // TODO: if a TokenUpdate does not include the latest UnlockToken - // then we should synthesize a TokenUpdate to transfer it over. - deviceRows, err := s.db.QueryContext( - ctx, - `SELECT authenticate, token_update FROM devices;`, - ) - if err != nil { - return err - } - defer deviceRows.Close() - for deviceRows.Next() { - var authBytes, tokenBytes []byte - if err := deviceRows.Scan(&authBytes, &tokenBytes); err != nil { - return err - } - for _, msgBytes := range [][]byte{authBytes, tokenBytes} { - msg, err := mdm.DecodeCheckin(msgBytes) - if err != nil { - c <- err - } else { - c <- msg - } - } - } - if err = deviceRows.Err(); err != nil { - return err - } - userRows, err := s.db.QueryContext( - ctx, - `SELECT token_update FROM users;`, - ) - if err != nil { - return err - } - defer userRows.Close() - for userRows.Next() { - var msgBytes []byte - if err := userRows.Scan(&msgBytes); err != nil { - return err - } - msg, err := mdm.DecodeCheckin(msgBytes) - if err != nil { - c <- err - } else { - c <- msg - } - } - if err = userRows.Err(); err != nil { - return err - } - return nil -} diff --git a/server/mdm/nanomdm/storage/pgsql/postgresql.go b/server/mdm/nanomdm/storage/pgsql/postgresql.go deleted file mode 100644 index 123258e57a..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/postgresql.go +++ /dev/null @@ -1,277 +0,0 @@ -// Package pgsql stores and retrieves MDM data from PostgresSQL -package pgsql - -import ( - "context" - "database/sql" - _ "embed" - "errors" - "fmt" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" -) - -// Schema holds the schema for the NanoMDM PostgresSQL storage. -// -//go:embed schema.sql -var Schema string - -var ErrNoCert = errors.New("no certificate in MDM Request") - -type PgSQLStorage struct { - logger log.Logger - db *sql.DB - rm bool -} - -type config struct { - driver string - dsn string - db *sql.DB - logger log.Logger - rm bool -} - -type Option func(*config) - -func WithLogger(logger log.Logger) Option { - return func(c *config) { - c.logger = logger - } -} - -func WithDSN(dsn string) Option { - return func(c *config) { - c.dsn = dsn - } -} - -func WithDriver(driver string) Option { - return func(c *config) { - c.driver = driver - } -} - -func WithDB(db *sql.DB) Option { - return func(c *config) { - c.db = db - } -} - -func WithDeleteCommands() Option { - return func(c *config) { - c.rm = true - } -} - -func New(opts ...Option) (*PgSQLStorage, error) { - cfg := &config{logger: log.NopLogger, driver: "postgres"} - for _, opt := range opts { - opt(cfg) - } - var err error - if cfg.db == nil { - cfg.db, err = sql.Open(cfg.driver, cfg.dsn) - if err != nil { - return nil, err - } - } - if err = cfg.db.Ping(); err != nil { - return nil, err - } - return &PgSQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm}, nil -} - -// nullEmptyString returns a NULL string if s is empty. -func nullEmptyString(s string) sql.NullString { - return sql.NullString{ - String: s, - Valid: s != "", - } -} - -func (s *PgSQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) error { - var pemCert []byte - if r.Certificate != nil { - pemCert = cryptoutil.PEMCertificate(r.Certificate.Raw) - } - _, err := s.db.ExecContext( - r.Context, ` -INSERT INTO devices - (id, identity_cert, serial_number, authenticate, authenticate_at) -VALUES - ($1, $2, $3, $4, CURRENT_TIMESTAMP) -ON CONFLICT ON CONSTRAINT devices_pkey DO -UPDATE SET - identity_cert = EXCLUDED.identity_cert, - serial_number = EXCLUDED.serial_number, - authenticate = EXCLUDED.authenticate, - authenticate_at = CURRENT_TIMESTAMP;`, - r.ID, nullEmptyString(string(pemCert)), nullEmptyString(msg.SerialNumber), msg.Raw, - ) - return err -} - -func (s *PgSQLStorage) storeDeviceTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { - query := `UPDATE devices SET token_update = $1, token_update_at = CURRENT_TIMESTAMP` - where := ` WHERE id = $2;` - args := []interface{}{msg.Raw} - // separately store the Unlock Token per MDM spec - if len(msg.UnlockToken) > 0 { - query += `, unlock_token = $2, unlock_token_at = CURRENT_TIMESTAMP ` - args = append(args, msg.UnlockToken) - where = ` WHERE id = $3;` - } - args = append(args, r.ID) - _, err := s.db.ExecContext(r.Context, query+where, args...) - return err -} - -func (s *PgSQLStorage) storeUserTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { - // there shouldn't be an Unlock Token on the user channel, but - // complain if there is to warn an admin - if len(msg.UnlockToken) > 0 { - ctxlog.Logger(r.Context, s.logger).Info( - "msg", "Unlock Token on user channel not stored", - ) - } - _, err := s.db.ExecContext( - r.Context, ` -INSERT INTO users - (id, device_id, user_short_name, user_long_name, token_update, token_update_at) -VALUES - ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) -ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE -SET - device_id = EXCLUDED.device_id, - user_short_name = EXCLUDED.user_short_name, - user_long_name = EXCLUDED.user_long_name, - token_update = EXCLUDED.token_update, - token_update_at = CURRENT_TIMESTAMP;`, - r.ID, - r.ParentID, - nullEmptyString(msg.UserShortName), - nullEmptyString(msg.UserLongName), - msg.Raw, - ) - return err -} - -func (s *PgSQLStorage) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { - var err error - var deviceId, userId string - resolved := (&msg.Enrollment).Resolved() - if err = resolved.Validate(); err != nil { - return err - } - if resolved.IsUserChannel { - deviceId = r.ParentID - userId = r.ID - err = s.storeUserTokenUpdate(r, msg) - } else { - deviceId = r.ID - err = s.storeDeviceTokenUpdate(r, msg) - } - if err != nil { - return err - } - _, err = s.db.ExecContext( - r.Context, ` -INSERT INTO enrollments - (id, device_id, user_id, type, topic, push_magic, token_hex, last_seen_at, token_update_tally) -VALUES - ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, 1) -ON CONFLICT ON CONSTRAINT enrollments_pkey DO UPDATE -SET - device_id = EXCLUDED.device_id, - user_id = EXCLUDED.user_id, - type = EXCLUDED.type, - topic = EXCLUDED.topic, - push_magic = EXCLUDED.push_magic, - token_hex = EXCLUDED.token_hex, - enabled = TRUE, - last_seen_at = CURRENT_TIMESTAMP, - token_update_tally = enrollments.token_update_tally + 1;`, - r.ID, - deviceId, - nullEmptyString(userId), - r.Type.String(), - msg.Topic, - msg.PushMagic, - msg.Token.String(), - ) - return err -} - -func (s *PgSQLStorage) RetrieveTokenUpdateTally(ctx context.Context, id string) (int, error) { - var tally int - err := s.db.QueryRowContext( - ctx, - `SELECT token_update_tally FROM enrollments WHERE id = $1;`, - id, - ).Scan(&tally) - return tally, err -} - -func (s *PgSQLStorage) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error { - colName := "user_authenticate" - colAtName := "user_authenticate_at" - // if the DigestResponse is empty then this is the first (of two) - // UserAuthenticate messages depending on our response - if msg.DigestResponse != "" { - colName = "user_authenticate_digest" - colAtName = "user_authenticate_digest_at" - } - _, err := s.db.ExecContext( - //nolint:gosec - r.Context, ` -INSERT INTO users - (id, device_id, user_short_name, user_long_name, `+colName+`, `+colAtName+`) -VALUES - ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) -ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE -SET - device_id = EXCLUDED.device_id, - user_short_name = EXCLUDED.user_short_name, - user_long_name = EXCLUDED.user_long_name, - `+colName+` = EXCLUDED.`+colName+`, - `+colAtName+` = EXCLUDED.`+colAtName+`;`, - r.ID, - r.ParentID, - nullEmptyString(msg.UserShortName), - nullEmptyString(msg.UserLongName), - msg.Raw, - ) - if err != nil { - return err - } - return s.updateLastSeen(r) -} - -// Disable can be called for an Authenticate or CheckOut message -func (s *PgSQLStorage) Disable(r *mdm.Request) error { - if r.ParentID != "" { - return errors.New("can only disable a device channel") - } - _, err := s.db.ExecContext( - r.Context, - `UPDATE enrollments SET enabled = FALSE, token_update_tally = 0, last_seen_at = CURRENT_TIMESTAMP WHERE device_id = $1 AND enabled = TRUE;`, - r.ID, - ) - return err -} - -func (s *PgSQLStorage) updateLastSeen(r *mdm.Request) (err error) { - _, err = s.db.ExecContext( - r.Context, - `UPDATE enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id = $1`, - r.ID, - ) - if err != nil { - err = fmt.Errorf("updating last seen: %w", err) - } - return -} diff --git a/server/mdm/nanomdm/storage/pgsql/push.go b/server/mdm/nanomdm/storage/pgsql/push.go deleted file mode 100644 index de14a6306a..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/push.go +++ /dev/null @@ -1,57 +0,0 @@ -package pgsql - -import ( - "context" - "errors" - "strconv" - "strings" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" -) - -// RetrievePushInfo retreives push info for identifiers ids. -// -// Note that we may return fewer results than input. The user of this -// method needs to reconcile that with their requested ids. -func (s *PgSQLStorage) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) { - if len(ids) < 1 { - return nil, errors.New("no ids provided") - } - - // previous: `SELECT id, topic, push_magic, token_hex FROM enrollments WHERE id IN (`+qs+`);`, - // refactor all strings concatenations with strings.Builder which is more efficient - var qs strings.Builder - - qs.WriteString(`SELECT id, topic, push_magic, token_hex FROM enrollments WHERE id IN (`) - args := make([]interface{}, len(ids)) - for i, v := range ids { - args[i] = v - if i > 0 { - qs.WriteString(",") - } - // can be a bit faster than fmt.Fprintf(&qs, "$%d", i+1) - qs.WriteString("$") - qs.WriteString(strconv.Itoa(i + 1)) - } - qs.WriteString(`);`) - - rows, err := s.db.QueryContext(ctx, qs.String(), args...) - if err != nil { - return nil, err - } - defer rows.Close() - pushInfos := make(map[string]*mdm.Push) - for rows.Next() { - push := new(mdm.Push) - var id, token string - if err := rows.Scan(&id, &push.Topic, &push.PushMagic, &token); err != nil { - return nil, err - } - // convert from hex - if err := push.SetTokenString(token); err != nil { - return nil, err - } - pushInfos[id] = push - } - return pushInfos, rows.Err() -} diff --git a/server/mdm/nanomdm/storage/pgsql/pushcert.go b/server/mdm/nanomdm/storage/pgsql/pushcert.go deleted file mode 100644 index 660dbfef1f..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/pushcert.go +++ /dev/null @@ -1,62 +0,0 @@ -package pgsql - -import ( - "context" - "crypto/tls" - "strconv" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" -) - -func (s *PgSQLStorage) RetrievePushCert(ctx context.Context, topic string) (*tls.Certificate, string, error) { - var certPEM, keyPEM []byte - var staleToken int - err := s.db.QueryRowContext( - ctx, - `SELECT cert_pem, key_pem, stale_token FROM push_certs WHERE topic = $1;`, - topic, - ).Scan(&certPEM, &keyPEM, &staleToken) - if err != nil { - return nil, "", err - } - cert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - return nil, "", err - } - return &cert, strconv.Itoa(staleToken), err -} - -func (s *PgSQLStorage) IsPushCertStale(ctx context.Context, topic, staleToken string) (bool, error) { - var staleTokenInt, dbStaleToken int - staleTokenInt, err := strconv.Atoi(staleToken) - if err != nil { - return true, err - } - err = s.db.QueryRowContext( - ctx, - `SELECT stale_token FROM push_certs WHERE topic = $1;`, - topic, - ).Scan(&dbStaleToken) - return dbStaleToken != staleTokenInt, err -} - -func (s *PgSQLStorage) StorePushCert(ctx context.Context, pemCert, pemKey []byte) error { - topic, err := cryptoutil.TopicFromPEMCert(pemCert) - if err != nil { - return err - } - _, err = s.db.ExecContext( - ctx, ` -INSERT INTO push_certs - (topic, cert_pem, key_pem, stale_token) -VALUES - ($1, $2, $3, 0) -ON CONFLICT (topic) DO -UPDATE SET - cert_pem = EXCLUDED.cert_pem, - key_pem = EXCLUDED.key_pem, - stale_token = push_certs.stale_token + 1;`, - topic, pemCert, pemKey, - ) - return err -} diff --git a/server/mdm/nanomdm/storage/pgsql/queue.go b/server/mdm/nanomdm/storage/pgsql/queue.go deleted file mode 100644 index 2ee1e88bbc..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/queue.go +++ /dev/null @@ -1,200 +0,0 @@ -package pgsql - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" -) - -func enqueue(ctx context.Context, tx *sql.Tx, ids []string, cmd *mdm.Command) error { - if len(ids) < 1 { - return errors.New("no id(s) supplied to queue command to") - } - _, err := tx.ExecContext( - ctx, - `INSERT INTO commands (command_uuid, request_type, command) VALUES ($1, $2, $3);`, - cmd.CommandUUID, cmd.Command.RequestType, cmd.Raw, - ) - if err != nil { - return err - } - - var query strings.Builder - - query.WriteString(`INSERT INTO enrollment_queue (id, command_uuid) VALUES `) - args := make([]interface{}, len(ids)*2) - for i, id := range ids { - if i > 0 { - query.WriteString(",") - } - ind := i * 2 - - //previous: query += fmt.Sprintf("($%d, $%d)", ind+1, ind+2) - query.WriteString("($") - query.WriteString(strconv.Itoa(ind + 1)) - query.WriteString(", $") - query.WriteString(strconv.Itoa(ind + 2)) - query.WriteString(")") - - args[ind] = id - args[ind+1] = cmd.CommandUUID - } - query.WriteString(";") - - _, err = tx.ExecContext(ctx, query.String(), args...) - return err -} - -func (s *PgSQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.Command) (map[string]error, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - if err = enqueue(ctx, tx, ids, cmd); err != nil { - if rbErr := tx.Rollback(); rbErr != nil { - return nil, fmt.Errorf("rollback error: %w; while trying to handle error: %v", rbErr, err) - } - return nil, err - } - return nil, tx.Commit() -} - -func (s *PgSQLStorage) deleteCommand(ctx context.Context, tx *sql.Tx, id, uuid string) error { - _, err := tx.ExecContext(ctx, ` -DELETE FROM enrollment_queue -WHERE id =$1 AND command_uuid =$2;`, id, uuid) - if err != nil { - return err - } - // delete command result (i.e. NotNows) and this queued command - _, err = tx.ExecContext(ctx, ` -DELETE FROM command_results -WHERE id =$1 AND command_uuid =$2;`, id, uuid) - if err != nil { - return err - } - - // now delete the actual command if no enrollments have it queued - // nor are there any results for it. - _, err = tx.ExecContext( - ctx, ` -DELETE FROM commands -USING - commands AS c - LEFT JOIN enrollment_queue AS q - ON q.command_uuid = c.command_uuid - LEFT JOIN command_results AS r - ON r.command_uuid = c.command_uuid -WHERE - c.command_uuid =$1 AND - q.command_uuid IS NULL AND - r.command_uuid IS NULL AND - commands.command_uuid = c.command_uuid; -`, - uuid, - ) - return err -} - -func (s *PgSQLStorage) deleteCommandTx(r *mdm.Request, result *mdm.CommandResults) error { - tx, err := s.db.BeginTx(r.Context, nil) - if err != nil { - return err - } - if err = s.deleteCommand(r.Context, tx, r.ID, result.CommandUUID); err != nil { - if rbErr := tx.Rollback(); rbErr != nil { - return fmt.Errorf("rollback error: %w; while trying to handle error: %v", rbErr, err) - } - return err - } - return tx.Commit() -} - -func (s *PgSQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandResults) error { - if err := s.updateLastSeen(r); err != nil { - return err - } - if result.Status == "Idle" { - return nil - } - if s.rm && result.Status != "NotNow" { - return s.deleteCommandTx(r, result) - } - notNowConstants := "NULL, 0" - notNowBumpTallySQL := "" - // note that due to the "ON CONFLICT ON CONSTRAINT command_results_pkey" we don't UPDATE the - // not_now_at field. thus it will only represent the first NotNow. - if result.Status == "NotNow" { - notNowConstants = "CURRENT_TIMESTAMP, 1" - notNowBumpTallySQL = `, not_now_tally = command_results.not_now_tally + 1` - } - _, err := s.db.ExecContext( - //nolint:gosec - r.Context, ` -INSERT INTO command_results - (id, command_uuid, status, result, not_now_at, not_now_tally) -VALUES - ($1, $2, $3, $4, `+notNowConstants+`) -ON CONFLICT ON CONSTRAINT command_results_pkey DO UPDATE -SET - status = EXCLUDED.status, - result = EXCLUDED.result`+notNowBumpTallySQL+`;`, - r.ID, - result.CommandUUID, - result.Status, - result.Raw, - ) - return err -} - -func (s *PgSQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) { - statusWhere := "status IS NULL" - if !skipNotNow { - statusWhere = `(` + statusWhere + ` OR status = 'NotNow')` - } - command := new(mdm.Command) - err := s.db.QueryRowContext( - r.Context, - `SELECT command_uuid, request_type, command FROM view_queue WHERE id = $1 AND active = TRUE AND `+statusWhere+` LIMIT 1;`, - r.ID, - ).Scan(&command.CommandUUID, &command.Command.RequestType, &command.Raw) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, err - } - return command, nil -} - -func (s *PgSQLStorage) ClearQueue(r *mdm.Request) error { - if r.ParentID != "" { - return errors.New("can only clear a device channel queue") - } - // PostgreSQL UPDATE differs from MySQL, uses "FROM" specific - // to pgsql extension - _, err := s.db.ExecContext( - r.Context, - ` -UPDATE enrollment_queue -SET active = FALSE -FROM enrollment_queue AS q - INNER JOIN enrollments AS e - ON q.id = e.id - INNER JOIN commands AS c - ON q.command_uuid = c.command_uuid - LEFT JOIN command_results r - ON r.command_uuid = q.command_uuid AND r.id = q.id -WHERE - e.device_id = $1 AND - enrollment_queue.active = TRUE AND - (r.status IS NULL OR r.status = 'NotNow') AND - enrollment_queue.id = q.id;`, - r.ID) - return err -} diff --git a/server/mdm/nanomdm/storage/pgsql/queue_test.go b/server/mdm/nanomdm/storage/pgsql/queue_test.go deleted file mode 100644 index 4aa9e2c7b7..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/queue_test.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build integration -// +build integration - -package pgsql - -import ( - "context" - "errors" - "flag" - "io/ioutil" - "testing" - - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/internal/test" - _ "github.com/lib/pq" -) - -var flDSN = flag.String("dsn", "", "DSN of test PostgreSQL instance") - -func loadAuthMsg() (*mdm.Authenticate, error) { - b, err := ioutil.ReadFile("../../mdm/testdata/Authenticate.2.plist") - if err != nil { - return nil, err - } - r, err := mdm.DecodeCheckin(b) - if err != nil { - return nil, err - } - a, ok := r.(*mdm.Authenticate) - if !ok { - return nil, errors.New("not an Authenticate message") - } - return a, nil -} - -func loadTokenMsg() (*mdm.TokenUpdate, error) { - b, err := ioutil.ReadFile("../../mdm/testdata/TokenUpdate.2.plist") - if err != nil { - return nil, err - } - r, err := mdm.DecodeCheckin(b) - if err != nil { - return nil, err - } - a, ok := r.(*mdm.TokenUpdate) - if !ok { - return nil, errors.New("not a TokenUpdate message") - } - return a, nil -} - -const deviceUDID = "66ADE930-5FDF-5EC4-8429-15640684C489" - -func newMdmReq() *mdm.Request { - return &mdm.Request{ - Context: context.Background(), - EnrollID: &mdm.EnrollID{ - Type: mdm.Device, - ID: deviceUDID, - }, - } -} - -func enrollTestDevice(storage *PgSQLStorage) error { - authMsg, err := loadAuthMsg() - if err != nil { - return err - } - err = storage.StoreAuthenticate(newMdmReq(), authMsg) - if err != nil { - return err - } - tokenMsg, err := loadTokenMsg() - if err != nil { - return err - } - err = storage.StoreTokenUpdate(newMdmReq(), tokenMsg) - if err != nil { - return err - } - return nil -} - -func TestQueue(t *testing.T) { - if *flDSN == "" { - t.Fatal("PostgreSQL DSN flag not provided to test") - } - - storage, err := New(WithDSN(*flDSN), WithDeleteCommands()) - if err != nil { - t.Fatal(err) - } - - err = enrollTestDevice(storage) - if err != nil { - t.Fatal(err) - } - - t.Run("WithDeleteCommands()", func(t *testing.T) { - test.TestQueue(t, deviceUDID, storage) - }) - - storage, err = New(WithDSN(*flDSN)) - if err != nil { - t.Fatal(err) - } - - t.Run("normal", func(t *testing.T) { - test.TestQueue(t, deviceUDID, storage) - }) -} diff --git a/server/mdm/nanomdm/storage/pgsql/schema.sql b/server/mdm/nanomdm/storage/pgsql/schema.sql deleted file mode 100644 index e4bc723344..0000000000 --- a/server/mdm/nanomdm/storage/pgsql/schema.sql +++ /dev/null @@ -1,317 +0,0 @@ -/* Requires PostgreSQL 9.5 or later. - * From PostgreSQL documentation: ON CONFLICT clause is only available from PostgreSQL 9.5 - */ - -CREATE TABLE devices -( - id VARCHAR(255) NOT NULL, - - identity_cert TEXT NULL, - - serial_number VARCHAR(127) NULL, - - -- If the (iOS, iPadOS) device sent an UnlockToken in the TokenUpdate - -- TODO: Consider using a TEXT field and encoding the binary - unlock_token BYTEA NULL, - unlock_token_at TIMESTAMP NULL, - - -- The last raw Authenticate for this device - authenticate TEXT NOT NULL, - authenticate_at TIMESTAMP NOT NULL, - -- The last raw TokenUpdate for this device - token_update TEXT NULL, - token_update_at TIMESTAMP NULL, - - bootstrap_token_b64 TEXT NULL, - bootstrap_token_at TIMESTAMP NULL, - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- trigger - - PRIMARY KEY (id), - - CHECK (identity_cert IS NULL OR SUBSTRING(identity_cert FROM 1 FOR 27) = '-----BEGIN CERTIFICATE-----'), - CHECK (serial_number IS NULL OR serial_number != ''), - CHECK (unlock_token IS NULL OR LENGTH(unlock_token) > 0), - CHECK (authenticate != ''), - CHECK (token_update IS NULL OR token_update != ''), - CHECK (bootstrap_token_b64 IS NULL OR bootstrap_token_b64 != '') -); -CREATE INDEX serial_number ON devices (serial_number); - -CREATE TABLE users -( - id VARCHAR(255) NOT NULL, - device_id VARCHAR(255) NOT NULL, - - user_short_name VARCHAR(255) NULL, - user_long_name VARCHAR(255) NULL, - - -- The last raw TokenUpdate for this user - token_update TEXT NULL, - token_update_at TIMESTAMP NULL, - - -- The last raw UserAuthenticate (and optional digest) for this user - user_authenticate TEXT NULL, - user_authenticate_at TIMESTAMP NULL, - user_authenticate_digest TEXT NULL, - user_authenticate_digest_at TIMESTAMP NULL, - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- trigger - - PRIMARY KEY (id, device_id), - UNIQUE (id), - - FOREIGN KEY (device_id) - REFERENCES devices (id) - ON DELETE CASCADE ON UPDATE CASCADE, - - CHECK (user_short_name IS NULL OR user_short_name != ''), - CHECK (user_long_name IS NULL OR user_long_name != ''), - CHECK (token_update IS NULL OR token_update != ''), - CHECK (user_authenticate IS NULL OR user_authenticate != ''), - CHECK (user_authenticate_digest IS NULL OR user_authenticate_digest != '') -); - -/* This table represents enrollments which are an amalgamation of - * both device and user enrollments. - */ -CREATE TABLE enrollments -( - -- The enrollment ID of this enrollment - id VARCHAR(255) NOT NULL, - -- The "device" enrollment ID of this enrollment. This will be - -- the same as the `id` field in the case of a "device" enrollment, - -- or will be the "parent" enrollment for a "user" enrollment. - device_id VARCHAR(255) NOT NULL, - -- The "user" enrollment ID of this enrollment. This will be the - -- same as the `id` field in the case of a "user" enrollment or - -- NULL in the case of a device enrollment. - user_id VARCHAR(255) NULL, - - -- Textual representation of the type of device enrollment. - type VARCHAR(31) NOT NULL, - - -- The MDM APNs push trifecta. - topic VARCHAR(255) NOT NULL, - push_magic VARCHAR(127) NOT NULL, - token_hex VARCHAR(255) NOT NULL, -- TODO: Perhaps just CHAR(64)? - - enabled BOOLEAN NOT NULL DEFAULT TRUE, - token_update_tally INTEGER NOT NULL DEFAULT 1, - - last_seen_at TIMESTAMP NOT NULL, -- TODO: additional tests with real device and integration tests. - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (id), - CHECK (id != ''), - - FOREIGN KEY (device_id) - REFERENCES devices (id) - ON DELETE CASCADE ON UPDATE CASCADE, - - FOREIGN KEY (user_id) - REFERENCES users (id) - ON DELETE CASCADE ON UPDATE CASCADE, - UNIQUE (user_id), - - CHECK (type != ''), - CHECK (topic != ''), - CHECK (push_magic != ''), - CHECK (token_hex != '') -); -CREATE INDEX idx_type ON enrollments (type); - -/* Commands stand alone. By themselves they aren't associated with - * a device, a result (response), etc. Joining other tables is required - * for more context. - */ -CREATE TABLE commands -( - command_uuid VARCHAR(127) NOT NULL, - request_type VARCHAR(63) NOT NULL, - -- Raw command Plist - command TEXT NOT NULL, - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (command_uuid), - - CHECK (command_uuid != ''), - CHECK (request_type != ''), - CHECK (SUBSTRING(command FROM 1 FOR 5) = ' 0 { + cmd = new(mdm.Command) + if err = plist.Unmarshal(body, cmd); err != nil { + return nil, fmt.Errorf("decoding command body: %w", err) + } + } + + return cmd, nil +} diff --git a/server/mdm/nanomdm/test/e2e/e2e.go b/server/mdm/nanomdm/test/e2e/e2e.go new file mode 100644 index 0000000000..c6478b9751 --- /dev/null +++ b/server/mdm/nanomdm/test/e2e/e2e.go @@ -0,0 +1,119 @@ +package e2e + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" + httpapi "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/api" + httpmdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/nanomdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + "github.com/micromdm/nanolib/log" + "github.com/micromdm/nanolib/log/stdlogfmt" +) + +const ( + serverURL = "/mdm" + enqueueURL = "/api/enq/" +) + +// setupNanoMDM configures normal-ish NanoMDM HTTP server handlers for testing. +func setupNanoMDM(logger log.Logger, store storage.AllStorage) (http.Handler, error) { + // begin with the primary NanoMDM service + var svc service.CheckinAndCommandService = nanomdm.New(store, nanomdm.WithLogger(logger)) + + // chain the certificate auth middleware + svc = certauth.New(svc, store) + + // setup MDM (check-in and command) handlers + var mdmHandler http.Handler = httpmdm.CheckinAndCommandHandler(svc, logger.With("handler", "mdm")) + // mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, , logger.With("handler", "verify")) + mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, httpmdm.MdmSignatureVerifierFunc(cryptoutil.VerifyMdmSignature)) + + // setup API handlers + var enqueueHandler http.Handler = httpapi.RawCommandEnqueueHandler(store, nil, logger.With("handler", enqueueURL)) + enqueueHandler = http.StripPrefix(enqueueURL, enqueueHandler) + + // create a mux for them + mux := http.NewServeMux() + mux.Handle(serverURL, mdmHandler) + mux.Handle(enqueueURL, enqueueHandler) + + return mux, nil +} + +type NanoMDMAPI interface { + // RawCommandEnqueue enqueues cmd to ids. An APNs push is omitted if nopush is true. + RawCommandEnqueue(ctx context.Context, ids []string, cmd *mdm.Command, nopush bool) error +} + +type IDer interface { + ID() string +} + +func TestE2E(t *testing.T, ctx context.Context, store storage.AllStorage) { + var logger log.Logger = stdlogfmt.New(stdlogfmt.WithDebugFlag(true)) + + mux, err := setupNanoMDM(logger, store) + if err != nil { + t.Fatal(err) + } + + // create a fake HTTP client that dispatches to our raw handlers + c := NewHandlerClient(mux) + + // create our new device for testing + d, err := newDeviceFromCheckins( + c, + serverURL, + "../../mdm/testdata/Authenticate.2.plist", + "../../mdm/testdata/TokenUpdate.2.plist", + ) + if err != nil { + t.Fatal(err) + } + + t.Run("certauth", func(t *testing.T) { certAuth(t, ctx, store) }) + t.Run("certauth-retro", func(t *testing.T) { certAuthRetro(t, ctx, store) }) + + // regression test for retrieving push info of missing devices. + t.Run("invalid-pushinfo", func(t *testing.T) { + _, err := store.RetrievePushInfo(ctx, []string{"INVALID"}) + if err != nil { + // should NOT recieve a "global" error for an enrollment that + // is merely invalid (or not enrolled yet, or not fully enrolled) + t.Errorf("should NOT have errored: %v", err) + } + }) + + t.Run("enroll", func(t *testing.T) { enroll(t, ctx, d, store) }) + + t.Run("tally", func(t *testing.T) { tally(t, ctx, d, store, 1) }) + + t.Run("bstoken", func(t *testing.T) { bstoken(t, ctx, d.Enrollment) }) + + // re-enroll device + // this is to try and catch any leftover crud that a storage backend didn't + // clean up (like the tally count, BS token, etc.) + err = d.DoEnroll(ctx) + if err != nil { + t.Fatal(fmt.Errorf("re-enrolling device %s: %w", d.ID(), err)) + } + + t.Run("tally-after-reenroll", func(t *testing.T) { tally(t, ctx, d, store, 1) }) + + t.Run("bstoken-after-reenroll", func(t *testing.T) { bstoken(t, ctx, d.Enrollment) }) + + err = store.ClearQueue(d.NewMDMRequest(ctx)) + if err != nil { + t.Fatal() + } + + t.Run("queue", func(t *testing.T) { queue(t, ctx, d, &api{doer: c}) }) +} diff --git a/server/mdm/nanomdm/test/e2e/enroll.go b/server/mdm/nanomdm/test/e2e/enroll.go new file mode 100644 index 0000000000..5f59608f1c --- /dev/null +++ b/server/mdm/nanomdm/test/e2e/enroll.go @@ -0,0 +1,39 @@ +package e2e + +import ( + "context" + "reflect" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" +) + +type enrollDevice interface { + IDer + DoEnroll(context.Context) error + GetPush() *mdm.Push +} + +func enroll(t *testing.T, ctx context.Context, d enrollDevice, store storage.PushStore) { + // enroll it + err := d.DoEnroll(ctx) + if err != nil { + t.Fatal(err) + } + + // extract the push info for the given id + pushInfos, err := store.RetrievePushInfo(ctx, []string{d.ID()}) + if err != nil { + t.Fatal(err) + } + + // test that we got the right push data data back + if want, have := 1, len(pushInfos); want != have { + t.Fatalf("len(pushInfos): want: %v, have: %v", want, have) + } + push := d.GetPush() + if !reflect.DeepEqual(pushInfos[d.ID()], push) { + t.Errorf("pushInfo have: %v, want: %v", pushInfos[d.ID()], push) + } +} diff --git a/server/mdm/nanomdm/test/e2e/queue.go b/server/mdm/nanomdm/test/e2e/queue.go new file mode 100644 index 0000000000..63b528fc94 --- /dev/null +++ b/server/mdm/nanomdm/test/e2e/queue.go @@ -0,0 +1,96 @@ +package e2e + +import ( + "context" + "fmt" + "reflect" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" +) + +type queueDevice interface { + CMDDoReportAndFetch(ctx context.Context, cmd *mdm.CommandResults) (*mdm.Command, error) + NewCommandReport(uuid, status string, errors []mdm.ErrorChain) *mdm.CommandResults + IDer +} + +// enqueue enqueues cmd to id using a. +func enqueue(t *testing.T, ctx context.Context, a NanoMDMAPI, id string, cmd *mdm.Command) { + err := a.RawCommandEnqueue(ctx, []string{id}, cmd, true) + if err != nil { + t.Fatal(err) + } +} + +// simpleCmd makes a command with a CommandUUID and RequestType the same string. +func simpleCmd(cmdID string) *mdm.Command { + return newCommand(cmdID, cmdID) +} + +// sendReportExpectCommandReply send a command report and expect a certain command reply. +func sendReportExpectCommandReply(t *testing.T, ctx context.Context, d queueDevice, reportCmd, reportStatus, expectedCmd string) { + cr := d.NewCommandReport(reportCmd, reportStatus, nil) + cmd, err := d.CMDDoReportAndFetch(ctx, cr) + if err != nil { + t.Fatal(fmt.Errorf("reporting cmd=%s status=%s: %w", reportCmd, reportStatus, err)) + } + + // make sure the command we expect was received + if have, want := cmd, simpleCmd(expectedCmd); !reflect.DeepEqual(have, want) { + t.Errorf("command: have: %v, want: %v", have, want) + } +} + +// enqueueSimple enqueues cmd to a for d. +func enqueueSimple(t *testing.T, ctx context.Context, d queueDevice, a NanoMDMAPI, cmd string) { + // we're assuming the UDID is all we need here. + enqueue(t, ctx, a, d.ID(), simpleCmd(cmd)) +} + +func queue(t *testing.T, ctx context.Context, d queueDevice, a NanoMDMAPI) { + t.Run("basic", func(t *testing.T) { + // report Idle. + // expect no command (empty queue for this id). + sendReportExpectCommandReply(t, ctx, d, "", "Idle", "") + // enqueue a couple commands. + enqueueSimple(t, ctx, d, a, "CMD1") + enqueueSimple(t, ctx, d, a, "CMD2") + // report Idle. + // but now expect the CMD1 result (first on the queue). + sendReportExpectCommandReply(t, ctx, d, "", "Idle", "CMD1") + // ack CMD1. + // expect CMD2. + sendReportExpectCommandReply(t, ctx, d, "CMD1", "Acknowledged", "CMD2") + // ack CMD2 (effectively clearning the queue). + // expect no command (only two commands queued). + sendReportExpectCommandReply(t, ctx, d, "CMD2", "Acknowledged", "") + // report Idle. + // expect no command (empty queue). + sendReportExpectCommandReply(t, ctx, d, "", "Idle", "") + }) + t.Run("notnow", func(t *testing.T) { + // report Idle. + // expect no command (empty queue). + sendReportExpectCommandReply(t, ctx, d, "", "Idle", "") + // enqueue CMD3. + enqueueSimple(t, ctx, d, a, "CMD3") + // report Idle. + // expect CMD3. + sendReportExpectCommandReply(t, ctx, d, "", "Idle", "CMD3") + // report NotNow for CMD3. + // expect no command (only NotNow commands in queue). + sendReportExpectCommandReply(t, ctx, d, "CMD3", "NotNow", "") + // report Idle. + // this could be considered as "resetting" NotNow for CMD3. + // expect CMD3 (the NotNow'd command). + sendReportExpectCommandReply(t, ctx, d, "", "Idle", "CMD3") + // ack CMD3. + // expect no command (empty queue). + sendReportExpectCommandReply(t, ctx, d, "CMD3", "Acknowledged", "") + // report Idle. + // expect no command (empty queue). + sendReportExpectCommandReply(t, ctx, d, "", "Idle", "") + }) + +} diff --git a/server/mdm/nanomdm/test/e2e/tally.go b/server/mdm/nanomdm/test/e2e/tally.go new file mode 100644 index 0000000000..9b9a9c4de4 --- /dev/null +++ b/server/mdm/nanomdm/test/e2e/tally.go @@ -0,0 +1,44 @@ +package e2e + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" +) + +type tokenTallyDevice interface { + DoTokenUpdate(context.Context) error + IDer +} + +// tally tests to make sure the TokenUpdate tally functions nominally. +func tally(t *testing.T, ctx context.Context, d tokenTallyDevice, store storage.TokenUpdateTallyStore, initial int) { + // retrieve the tally + tally, err := store.RetrieveTokenUpdateTally(ctx, d.ID()) + if err != nil { + t.Fatal() + } + + // make sure it's what we want + if have, want := tally, initial; have != want { + t.Errorf("token update tally: have: %v, want: %v", have, want) + } + + // perform a TokenUpdate (should increase the tally) + err = d.DoTokenUpdate(ctx) + if err != nil { + t.Fatal() + } + + // retrieve the tally again + tally, err = store.RetrieveTokenUpdateTally(ctx, d.ID()) + if err != nil { + t.Fatal() + } + + // make sure it's what we want (+1) + if have, want := tally, initial+1; have != want { + t.Errorf("token update tally (2nd): have: %v, want: %v", have, want) + } +} diff --git a/server/mdm/nanomdm/test/enrollment/enrollment.go b/server/mdm/nanomdm/test/enrollment/enrollment.go new file mode 100644 index 0000000000..25aeae038c --- /dev/null +++ b/server/mdm/nanomdm/test/enrollment/enrollment.go @@ -0,0 +1,362 @@ +package enrollment + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "os" + "sync" + + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test/protocol" + "github.com/groob/plist" +) + +var ErrAlreadyEnrolled = errors.New("already enrolled") + +type Transport interface { + // DoCheckIn performs an HTTP MDM check-in to the CheckInURL (or ServerURL). + // The caller is responsible for closing the response body. + DoCheckIn(context.Context, io.Reader) (*http.Response, error) + + // DoReportResultsAndFetchNext sends an HTTP MDM report-results-and-retrieve-next-command request to the ServerURL. + // The caller is responsible for closing the response body. + DoReportResultsAndFetchNext(ctx context.Context, report io.Reader) (*http.Response, error) +} + +// Enrollment emulates an MDM enrollment. +// Currently it mostly emulates device channel enrollments. +type Enrollment struct { + enrollID mdm.EnrollID + enrollment mdm.Enrollment + push mdm.Push + + cert *x509.Certificate + key crypto.PrivateKey + + serialNumber string + unlockToken []byte + + transport Transport + + enrolled bool + enrollM sync.Mutex +} + +func loadAuthTokUpd(authPath, tokUpdPath string) (*mdm.Authenticate, *mdm.TokenUpdate, error) { + authBytes, err := os.ReadFile(authPath) + if err != nil { + return nil, nil, err + } + msg, err := mdm.DecodeCheckin(authBytes) + if err != nil { + return nil, nil, err + } + auth, ok := msg.(*mdm.Authenticate) + if !ok { + return auth, nil, errors.New("not an Authenticate message") + } + tokUpdBytes, err := os.ReadFile(tokUpdPath) + if err != nil { + return auth, nil, err + } + msg, err = mdm.DecodeCheckin(tokUpdBytes) + if err != nil { + return auth, nil, err + } + tokUpd, ok := msg.(*mdm.TokenUpdate) + if !ok { + return auth, tokUpd, errors.New("not a TokenUpdate message") + } + return auth, tokUpd, nil +} + +// NewFromCheckins loads device information from authenticate and tokenupdate files on disk. +func NewFromCheckins(doer protocol.Doer, serverURL, checkInURL, authenticatePath, tokenUpdatePath string) (*Enrollment, error) { + auth, tokUpd, err := loadAuthTokUpd(authenticatePath, tokenUpdatePath) + if err != nil { + return nil, err + } + + e := &Enrollment{ + enrollment: auth.Enrollment, + push: tokUpd.Push, + serialNumber: auth.SerialNumber, + + // we're assuming the IDs here are devices + enrollID: mdm.EnrollID{Type: mdm.Device, ID: auth.UDID}, + } + e.key, e.cert, err = test.SimpleSelfSignedRSAKeypair("TESTDEVICE", 2) + + e.transport = protocol.NewTransport( + protocol.WithSignMessage(), + protocol.WithIdentityProvider(e.GetIdentity), + protocol.WithMDMURLs(serverURL, checkInURL), + protocol.WithClient(doer), + ) + + return e, err +} + +// ReplaceIdentityRandom changes the certificate private key to a random certificate and key. +func ReplaceIdentityRandom(e *Enrollment) error { + var err error + e.key, e.cert, err = test.SimpleSelfSignedRSAKeypair("TESTDEVICE", 2) + return err +} + +// NewRandomDeviceEnrollment creates a new randomly identified MDM enrollment. +func NewRandomDeviceEnrollment(doer protocol.Doer, topic, serverURL, checkInURL string) (*Enrollment, error) { + udid := randString(32) + e := &Enrollment{ + enrollment: mdm.Enrollment{UDID: udid}, + push: mdm.Push{ + Topic: topic, + PushMagic: randString(32), + // Token: []byte(randString(32)), // Token is populated in DoTokenUpdate() + }, + serialNumber: randString(8), + // unlockToken: , + enrollID: mdm.EnrollID{Type: mdm.Device, ID: udid}, + } + var err error + e.key, e.cert, err = test.SimpleSelfSignedRSAKeypair("TESTDEVICE", 2) + + e.transport = protocol.NewTransport( + protocol.WithSignMessage(), + protocol.WithIdentityProvider(e.GetIdentity), + protocol.WithMDMURLs(serverURL, checkInURL), + protocol.WithClient(doer), + ) + + return e, err +} + +// GetIdentity supplies the identity certificate and key of this enrollment. +func (e *Enrollment) GetIdentity(context.Context) (*x509.Certificate, crypto.PrivateKey, error) { + return e.cert, e.key, nil +} + +// GenAuthenticate creates an XML Plist Authenticate check-in message. +func (e *Enrollment) GenAuthenticate() (io.Reader, error) { + a := &mdm.Authenticate{ + Enrollment: e.enrollment, + MessageType: mdm.MessageType{MessageType: "Authenticate"}, + Topic: e.push.Topic, + SerialNumber: e.serialNumber, + } + return test.PlistReader(a) +} + +// GenTokenUpdate creates an XML Plist TokenUpdate check-in message. +func (e *Enrollment) GenTokenUpdate() (io.Reader, error) { + t := &mdm.TokenUpdate{ + Enrollment: e.enrollment, + MessageType: mdm.MessageType{MessageType: "TokenUpdate"}, + Push: e.push, + UnlockToken: e.unlockToken, + } + return test.PlistReader(t) +} + +// doAuthenticate sends an Authenticate check-in message to the MDM server. +func (e *Enrollment) doAuthenticate(ctx context.Context) error { + e.enrolled = false + + // generate Authenticate check-in message + auth, err := e.GenAuthenticate() + if err != nil { + return err + } + + // send it to the MDM server + authResp, err := e.transport.DoCheckIn(ctx, auth) + if err != nil { + return err + } + defer authResp.Body.Close() + + // check for any errors + return HTTPErrors(authResp) +} + +// DoAuthenticate sends an Authenticate check-in message to the MDM server. +func (e *Enrollment) DoAuthenticate(ctx context.Context) error { + e.enrollM.Lock() + defer e.enrollM.Unlock() + return e.doAuthenticate(ctx) +} + +// doTokenUpdate sends a TokenUpdate check-in message to the MDM server. +// A new random push token is generated for the device. +func (e *Enrollment) doTokenUpdate(ctx context.Context) error { + // generate new random push token. + // the token comes from Apple's APNs service. so we'll simulate this + // by re-generating the token every time we do a TokenUpdate. + e.push.Token = []byte(randString(32)) + + // generate TokenUpdate check-in message + msg, err := e.GenTokenUpdate() + if err != nil { + return err + } + + // send it to the MDM server + resp, err := e.transport.DoCheckIn(ctx, msg) + if err != nil { + return err + } + defer resp.Body.Close() + + // check for errors + return HTTPErrors(resp) +} + +// DoTokenUpdate sends a TokenUpdate check-in message to the MDM server. +// A new random push token is generated for the device. +func (e *Enrollment) DoTokenUpdate(ctx context.Context) error { + e.enrollM.Lock() + defer e.enrollM.Unlock() + return e.doTokenUpdate(ctx) +} + +// DoEnroll enrolls (or re-enrolls) this enrollment into MDM. +// Authenticate and TokenUpdate check-in messages are sent via the +// transport to the MDM server. +func (e *Enrollment) DoEnroll(ctx context.Context) error { + e.enrollM.Lock() + defer e.enrollM.Unlock() + + err := e.doAuthenticate(ctx) + if err != nil { + return fmt.Errorf("authenticate check-in: %w", err) + } + + err = e.doTokenUpdate(ctx) + if err != nil { + return fmt.Errorf("tokenupdate check-in: %w", err) + } + + e.enrolled = true + + return nil +} + +// GetEnrollment returns the enrollment identifier data. +func (e *Enrollment) GetEnrollment() *mdm.Enrollment { + return &e.enrollment +} + +// ID returns the NanoMDM "normalized" enrollment ID. +func (e *Enrollment) ID() string { + // we know we're only dealing with device IDs at this point. + // make that assumption of the UDID for the normalized ID. + return e.enrollment.UDID +} + +// EnrollID returns the NanoMDM enroll ID. +func (e *Enrollment) EnrollID() *mdm.EnrollID { + return &e.enrollID +} + +func (e *Enrollment) NewMDMRequest(ctx context.Context) *mdm.Request { + return &mdm.Request{ + Context: ctx, + EnrollID: e.EnrollID(), + Certificate: e.cert, + } +} + +// GetPush returns the enrollment push info data. +func (e *Enrollment) GetPush() *mdm.Push { + return &e.push +} + +// DoReportAndFetch sends report to the MDM server. +// Any new command delivered will be in the response. +// The caller is responsible for closing the response body. +func (e *Enrollment) DoReportAndFetch(ctx context.Context, report io.Reader) (*http.Response, error) { + return e.transport.DoReportResultsAndFetchNext(ctx, report) +} + +// genSetBootstrapToken creates an XML Plist SetBootstrapToken check-in message. +func (e *Enrollment) genSetBootstrapToken(token []byte) (io.Reader, error) { + b64Token := base64.StdEncoding.EncodeToString(token) + msg := &mdm.SetBootstrapToken{ + Enrollment: e.enrollment, + MessageType: mdm.MessageType{MessageType: "SetBootstrapToken"}, + BootstrapToken: mdm.BootstrapToken{BootstrapToken: []byte(b64Token)}, + } + return test.PlistReader(msg) +} + +// DoEscrowBootstrapToken sends the Bootstrap Token to the MDM server. +func (e *Enrollment) DoEscrowBootstrapToken(ctx context.Context, token []byte) error { + r, err := e.genSetBootstrapToken(token) + if err != nil { + return err + } + + // send it to the MDM server + resp, err := e.transport.DoCheckIn(ctx, r) + if err != nil { + return err + } + defer resp.Body.Close() + + // check for errors + return HTTPErrors(resp) +} + +// genGetBootstrapToken creates an XML Plist GetBootstrapToken check-in message. +func (e *Enrollment) genGetBootstrapToken() (io.Reader, error) { + msg := &mdm.GetBootstrapToken{ + Enrollment: e.enrollment, + MessageType: mdm.MessageType{MessageType: "GetBootstrapToken"}, + } + return test.PlistReader(msg) +} + +// DoGetBootstrapToken retrieves the Bootstrap Token from the MDM erver. +func (e *Enrollment) DoGetBootstrapToken(ctx context.Context) (*mdm.BootstrapToken, error) { + r, err := e.genGetBootstrapToken() + if err != nil { + return nil, err + } + + // send it to the MDM server + resp, err := e.transport.DoCheckIn(ctx, r) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, Limit10KiB)) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, NewHTTPError(resp, body) + } + + var tok *mdm.BootstrapToken + if len(body) > 0 { + tok = new(mdm.BootstrapToken) + err = plist.Unmarshal(body, tok) + } + return tok, err +} + +func randString(n int) string { + b := make([]byte, n) + rand.Read(b) // nolint:errcheck + return fmt.Sprintf("%x", b) +} diff --git a/server/mdm/nanomdm/test/enrollment/utils.go b/server/mdm/nanomdm/test/enrollment/utils.go new file mode 100644 index 0000000000..5b487cd488 --- /dev/null +++ b/server/mdm/nanomdm/test/enrollment/utils.go @@ -0,0 +1,66 @@ +package enrollment + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" +) + +// HTTPError contains the body and status details. +type HTTPError struct { + Body []byte + Status string + StatusCode int +} + +func NewHTTPError(response *http.Response, body []byte) *HTTPError { + if response == nil { + response = &http.Response{} + } + return &HTTPError{ + Body: body, + Status: response.Status, + StatusCode: response.StatusCode, + } +} + +// Error returns strings for HTTP errors that may include body and status. +func (e *HTTPError) Error() (err string) { + err = "HTTP error" + if e == nil { + return + } + if e.Status != "" { + err += ": " + e.Status + } else { + err += ": " + strconv.Itoa(e.StatusCode) + } + if len(e.Body) > 0 { + err += ": " + string(e.Body) + } + return +} + +const Limit10KiB = 10 * 1024 + +// HTTPErrors reports an HTTP error for a non-200 HTTP response. +// The first 10KiB of the body is read for non-200 response. +// For a 200 response nil is returned. +// Caller is responsible for closing response body. +func HTTPErrors(r *http.Response) error { + if r == nil { + return errors.New("nil response") + } + + if r.StatusCode != 200 { + body, err := io.ReadAll(io.LimitReader(r.Body, Limit10KiB)) + if err != nil { + return fmt.Errorf("error reading body of non-200 response: %w", err) + } + return NewHTTPError(r, body) + } + + return nil +} diff --git a/server/mdm/nanomdm/service/certauth/helpers_test.go b/server/mdm/nanomdm/test/helpers.go similarity index 94% rename from server/mdm/nanomdm/service/certauth/helpers_test.go rename to server/mdm/nanomdm/test/helpers.go index 46e9a4c196..9be326a264 100644 --- a/server/mdm/nanomdm/service/certauth/helpers_test.go +++ b/server/mdm/nanomdm/test/helpers.go @@ -1,4 +1,4 @@ -package certauth +package test import ( "crypto/rand" @@ -81,6 +81,10 @@ func (s *NopService) DeclarativeManagement(r *mdm.Request, m *mdm.DeclarativeMan return nil, nil } +func (s *NopService) GetToken(r *mdm.Request, m *mdm.GetToken) (*mdm.GetTokenResponse, error) { + return nil, nil +} + func (s *NopService) CommandAndReportResults(r *mdm.Request, results *mdm.CommandResults) (*mdm.Command, error) { return nil, nil } diff --git a/server/mdm/nanomdm/test/plist.go b/server/mdm/nanomdm/test/plist.go new file mode 100644 index 0000000000..8280f6a9c8 --- /dev/null +++ b/server/mdm/nanomdm/test/plist.go @@ -0,0 +1,16 @@ +package test + +import ( + "bytes" + "io" + + "github.com/groob/plist" +) + +// PlistReader encodes v to XML Plist. +func PlistReader(v interface{}) (io.Reader, error) { + buf := new(bytes.Buffer) + enc := plist.NewEncoder(buf) + enc.Indent("\t") + return buf, enc.Encode(v) +} diff --git a/server/mdm/nanomdm/test/protocol/transport.go b/server/mdm/nanomdm/test/protocol/transport.go new file mode 100644 index 0000000000..91aa100766 --- /dev/null +++ b/server/mdm/nanomdm/test/protocol/transport.go @@ -0,0 +1,164 @@ +// Package protocol implements primitives and interfaces of the base Apple MDM protocol. +package protocol + +import ( + "bytes" + "context" + "crypto" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + + "github.com/smallstep/pkcs7" +) + +const ( + // CheckInMIMEType is the HTTP MIME type of Apple MDM check-in messages. + CheckInMIMEType = "application/x-apple-aspen-mdm-checkin" + + // MDMSignatureHeader is the HTTP header name for the in-message + // signature checking. + MDMSignatureHeader = "Mdm-Signature" +) + +var ( + ErrMissingDeviceIdentity = errors.New("missing device identity") + ErrNilTransport = errors.New("nil transport") +) + +// Doer executes an HTTP request. +type Doer interface { + Do(*http.Request) (*http.Response, error) +} + +type IdentityProvider func(context.Context) (*x509.Certificate, crypto.PrivateKey, error) + +// Transport encapsulates the MDM enrollment underlying MDM transport. +// The MDM channels utilize this transport to communicate with the host. +type Transport struct { + checkInURL string + serverURL string + signMessage bool + provider IdentityProvider + doer Doer +} + +type TransportOption func(*Transport) + +// WithClient configures the HTTP client for this transport. +func WithClient(doer Doer) TransportOption { + return func(t *Transport) { + t.doer = doer + } +} + +// WithIdentityProvider configures the certificate and private key provider for this transport. +func WithIdentityProvider(f IdentityProvider) TransportOption { + return func(t *Transport) { + t.provider = f + } +} + +// WithMDMURLs supplies the ServerURL and CheckInURLs to the transport. +// Per MDM spec checkInURL is optional. +func WithMDMURLs(serverURL, checkInURL string) TransportOption { + return func(t *Transport) { + t.serverURL = serverURL + t.checkInURL = checkInURL + } +} + +// WithSignMessage include the signed message header. +func WithSignMessage() TransportOption { + return func(t *Transport) { + t.signMessage = true + } +} + +func NewTransport(opts ...TransportOption) *Transport { + t := &Transport{ + doer: http.DefaultClient, + } + for _, opt := range opts { + opt(t) + } + return t +} + +// SignMessage generates the CMS detached signature encoded as Base64. +func (t *Transport) SignMessage(ctx context.Context, body []byte) (string, error) { + if t.provider == nil { + return "", ErrMissingDeviceIdentity + } + cert, key, err := t.provider(ctx) + if err != nil { + return "", err + } + if cert == nil || key == nil { + return "", ErrMissingDeviceIdentity + } + sd, err := pkcs7.NewSignedData(body) + if err != nil { + return "", err + } + err = sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}) + if err != nil { + return "", err + } + sd.Detach() + sig, err := sd.Finish() + return base64.StdEncoding.EncodeToString(sig), err +} + +func (t *Transport) doRequest(ctx context.Context, body io.Reader, checkin bool) (*http.Response, error) { + if t == nil { + return nil, ErrNilTransport + } + var bodyBuf *bytes.Buffer + if t.signMessage { + bodyBuf = new(bytes.Buffer) + if _, err := bodyBuf.ReadFrom(body); err != nil { + return nil, fmt.Errorf("reading body into buffer: %w", err) + } + body = bodyBuf + } + + url := t.serverURL + if checkin && t.checkInURL != "" { + url = t.checkInURL + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + if checkin { + req.Header.Set("Content-Type", CheckInMIMEType) + } + + if t.signMessage { + sig, err := t.SignMessage(ctx, bodyBuf.Bytes()) + if err != nil { + return nil, fmt.Errorf("generating mdm-signature: %w", err) + } + req.Header.Set(MDMSignatureHeader, sig) + } + + return t.doer.Do(req) +} + +// DoCheckIn executes a check-in request with body. +// The caller is responsible for closing the response body. +func (t *Transport) DoCheckIn(ctx context.Context, body io.Reader) (*http.Response, error) { + return t.doRequest(ctx, body, true) +} + +// DoReportResultsAndFetchNext executes a report and fetch request with body. +// The caller is responsible for closing the response body. +func (t *Transport) DoReportResultsAndFetchNext(ctx context.Context, body io.Reader) (*http.Response, error) { + return t.doRequest(ctx, body, false) +} diff --git a/server/mdm/nanomdm/tools/cmdr.py b/server/mdm/nanomdm/tools/cmdr.py index 27fc549b22..2f9d264cee 100755 --- a/server/mdm/nanomdm/tools/cmdr.py +++ b/server/mdm/nanomdm/tools/cmdr.py @@ -127,7 +127,9 @@ def sched_update_subparser(parser): def dev_info_subparser(parser): dev_info_parser = parser.add_parser( - "DeviceInformation", help="DeviceInformation MDM command" + "DeviceInformation", + help="DeviceInformation MDM command", + aliases=["DeviceInfo", "DevInfo"], ) dev_info_parser.add_argument( "query", diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index ad49d7a6aa..6d8fbd8856 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -639,12 +639,24 @@ type SetOrUpdateHostDisksEncryptionFunc func(ctx context.Context, hostID uint, e type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string, clientError string, decryptable *bool) error +type SaveLUKSDataFunc func(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error + type GetUnverifiedDiskEncryptionKeysFunc func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) -type SetHostsDiskEncryptionKeyStatusFunc func(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error +type SetHostsDiskEncryptionKeyStatusFunc func(ctx context.Context, hostIDs []uint, decryptable bool, threshold time.Time) error type GetHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) +type IsHostPendingEscrowFunc func(ctx context.Context, hostID uint) bool + +type ClearPendingEscrowFunc func(ctx context.Context, hostID uint) error + +type ReportEscrowErrorFunc func(ctx context.Context, hostID uint, err string) error + +type QueueEscrowFunc func(ctx context.Context, hostID uint) error + +type AssertHasNoEncryptionKeyStoredFunc func(ctx context.Context, hostID uint) 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 @@ -975,6 +987,8 @@ type ResendHostMDMProfileFunc func(ctx context.Context, hostUUID string, profile type GetHostMDMProfileInstallStatusFunc func(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error) +type GetLinuxDiskEncryptionSummaryFunc func(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) + type GetMDMCommandPlatformFunc func(ctx context.Context, commandUUID string) (string, error) type ListMDMCommandsFunc func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, error) @@ -1127,6 +1141,8 @@ type EnqueueSetupExperienceItemsFunc func(ctx context.Context, hostUUID string, type GetSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) (*fleet.Script, error) +type GetSetupExperienceScriptByIDFunc func(ctx context.Context, scriptID uint) (*fleet.Script, error) + type SetSetupExperienceScriptFunc func(ctx context.Context, script *fleet.Script) error type DeleteSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) error @@ -2077,6 +2093,9 @@ type DataStore struct { SetOrUpdateHostDiskEncryptionKeyFunc SetOrUpdateHostDiskEncryptionKeyFunc SetOrUpdateHostDiskEncryptionKeyFuncInvoked bool + SaveLUKSDataFunc SaveLUKSDataFunc + SaveLUKSDataFuncInvoked bool + GetUnverifiedDiskEncryptionKeysFunc GetUnverifiedDiskEncryptionKeysFunc GetUnverifiedDiskEncryptionKeysFuncInvoked bool @@ -2086,6 +2105,21 @@ type DataStore struct { GetHostDiskEncryptionKeyFunc GetHostDiskEncryptionKeyFunc GetHostDiskEncryptionKeyFuncInvoked bool + IsHostPendingEscrowFunc IsHostPendingEscrowFunc + IsHostPendingEscrowFuncInvoked bool + + ClearPendingEscrowFunc ClearPendingEscrowFunc + ClearPendingEscrowFuncInvoked bool + + ReportEscrowErrorFunc ReportEscrowErrorFunc + ReportEscrowErrorFuncInvoked bool + + QueueEscrowFunc QueueEscrowFunc + QueueEscrowFuncInvoked bool + + AssertHasNoEncryptionKeyStoredFunc AssertHasNoEncryptionKeyStoredFunc + AssertHasNoEncryptionKeyStoredFuncInvoked bool + GetHostCertAssociationsToExpireFunc GetHostCertAssociationsToExpireFunc GetHostCertAssociationsToExpireFuncInvoked bool @@ -2581,6 +2615,9 @@ type DataStore struct { GetHostMDMProfileInstallStatusFunc GetHostMDMProfileInstallStatusFunc GetHostMDMProfileInstallStatusFuncInvoked bool + GetLinuxDiskEncryptionSummaryFunc GetLinuxDiskEncryptionSummaryFunc + GetLinuxDiskEncryptionSummaryFuncInvoked bool + GetMDMCommandPlatformFunc GetMDMCommandPlatformFunc GetMDMCommandPlatformFuncInvoked bool @@ -2809,6 +2846,9 @@ type DataStore struct { GetSetupExperienceScriptFunc GetSetupExperienceScriptFunc GetSetupExperienceScriptFuncInvoked bool + GetSetupExperienceScriptByIDFunc GetSetupExperienceScriptByIDFunc + GetSetupExperienceScriptByIDFuncInvoked bool + SetSetupExperienceScriptFunc SetSetupExperienceScriptFunc SetSetupExperienceScriptFuncInvoked bool @@ -5008,6 +5048,13 @@ func (s *DataStore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID return s.SetOrUpdateHostDiskEncryptionKeyFunc(ctx, hostID, encryptedBase64Key, clientError, decryptable) } +func (s *DataStore) SaveLUKSData(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error { + s.mu.Lock() + s.SaveLUKSDataFuncInvoked = true + s.mu.Unlock() + return s.SaveLUKSDataFunc(ctx, hostID, encryptedBase64Passphrase, encryptedBase64Salt, keySlot) +} + func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) { s.mu.Lock() s.GetUnverifiedDiskEncryptionKeysFuncInvoked = true @@ -5015,11 +5062,11 @@ func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]flee return s.GetUnverifiedDiskEncryptionKeysFunc(ctx) } -func (s *DataStore) SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error { +func (s *DataStore) SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, decryptable bool, threshold time.Time) error { s.mu.Lock() s.SetHostsDiskEncryptionKeyStatusFuncInvoked = true s.mu.Unlock() - return s.SetHostsDiskEncryptionKeyStatusFunc(ctx, hostIDs, encryptable, threshold) + return s.SetHostsDiskEncryptionKeyStatusFunc(ctx, hostIDs, decryptable, threshold) } func (s *DataStore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { @@ -5029,6 +5076,41 @@ func (s *DataStore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) ( return s.GetHostDiskEncryptionKeyFunc(ctx, hostID) } +func (s *DataStore) IsHostPendingEscrow(ctx context.Context, hostID uint) bool { + s.mu.Lock() + s.IsHostPendingEscrowFuncInvoked = true + s.mu.Unlock() + return s.IsHostPendingEscrowFunc(ctx, hostID) +} + +func (s *DataStore) ClearPendingEscrow(ctx context.Context, hostID uint) error { + s.mu.Lock() + s.ClearPendingEscrowFuncInvoked = true + s.mu.Unlock() + return s.ClearPendingEscrowFunc(ctx, hostID) +} + +func (s *DataStore) ReportEscrowError(ctx context.Context, hostID uint, err string) error { + s.mu.Lock() + s.ReportEscrowErrorFuncInvoked = true + s.mu.Unlock() + return s.ReportEscrowErrorFunc(ctx, hostID, err) +} + +func (s *DataStore) QueueEscrow(ctx context.Context, hostID uint) error { + s.mu.Lock() + s.QueueEscrowFuncInvoked = true + s.mu.Unlock() + return s.QueueEscrowFunc(ctx, hostID) +} + +func (s *DataStore) AssertHasNoEncryptionKeyStored(ctx context.Context, hostID uint) error { + s.mu.Lock() + s.AssertHasNoEncryptionKeyStoredFuncInvoked = true + s.mu.Unlock() + return s.AssertHasNoEncryptionKeyStoredFunc(ctx, hostID) +} + func (s *DataStore) GetHostCertAssociationsToExpire(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) { s.mu.Lock() s.GetHostCertAssociationsToExpireFuncInvoked = true @@ -6184,6 +6266,13 @@ func (s *DataStore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID return s.GetHostMDMProfileInstallStatusFunc(ctx, hostUUID, profileUUID) } +func (s *DataStore) GetLinuxDiskEncryptionSummary(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) { + s.mu.Lock() + s.GetLinuxDiskEncryptionSummaryFuncInvoked = true + s.mu.Unlock() + return s.GetLinuxDiskEncryptionSummaryFunc(ctx, teamID) +} + func (s *DataStore) GetMDMCommandPlatform(ctx context.Context, commandUUID string) (string, error) { s.mu.Lock() s.GetMDMCommandPlatformFuncInvoked = true @@ -6716,6 +6805,13 @@ func (s *DataStore) GetSetupExperienceScript(ctx context.Context, teamID *uint) return s.GetSetupExperienceScriptFunc(ctx, teamID) } +func (s *DataStore) GetSetupExperienceScriptByID(ctx context.Context, scriptID uint) (*fleet.Script, error) { + s.mu.Lock() + s.GetSetupExperienceScriptByIDFuncInvoked = true + s.mu.Unlock() + return s.GetSetupExperienceScriptByIDFunc(ctx, scriptID) +} + func (s *DataStore) SetSetupExperienceScript(ctx context.Context, script *fleet.Script) error { s.mu.Lock() s.SetSetupExperienceScriptFuncInvoked = true diff --git a/server/mock/mdm/datastore_mdm_mock.go b/server/mock/mdm/datastore_mdm_mock.go index 5e3f19e6cb..9ad5b58d3d 100644 --- a/server/mock/mdm/datastore_mdm_mock.go +++ b/server/mock/mdm/datastore_mdm_mock.go @@ -19,10 +19,10 @@ type StoreAuthenticateFunc func(r *mdm.Request, msg *mdm.Authenticate) error type StoreTokenUpdateFunc func(r *mdm.Request, msg *mdm.TokenUpdate) error -type StoreUserAuthenticateFunc func(r *mdm.Request, msg *mdm.UserAuthenticate) error - type DisableFunc func(r *mdm.Request) error +type StoreUserAuthenticateFunc func(r *mdm.Request, msg *mdm.UserAuthenticate) error + type StoreCommandReportFunc func(r *mdm.Request, report *mdm.CommandResults) error type RetrieveNextCommandFunc func(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) @@ -33,7 +33,7 @@ type StoreBootstrapTokenFunc func(r *mdm.Request, msg *mdm.SetBootstrapToken) er type RetrieveBootstrapTokenFunc func(r *mdm.Request, msg *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) -type RetrievePushInfoFunc func(p0 context.Context, p1 []string) (map[string]*mdm.Push, error) +type RetrievePushInfoFunc func(ctx context.Context, ids []string) (map[string]*mdm.Push, error) type IsPushCertStaleFunc func(ctx context.Context, topic string, staleToken string) (bool, error) @@ -51,6 +51,8 @@ type IsCertHashAssociatedFunc func(r *mdm.Request, hash string) (bool, error) type AssociateCertHashFunc func(r *mdm.Request, hash string, certNotValidAfter time.Time) error +type EnrollmentFromHashFunc func(ctx context.Context, hash string) (string, error) + type RetrieveMigrationCheckinsFunc func(p0 context.Context, p1 chan<- interface{}) error type RetrieveTokenUpdateTallyFunc func(ctx context.Context, id string) (int, error) @@ -70,12 +72,12 @@ type MDMAppleStore struct { StoreTokenUpdateFunc StoreTokenUpdateFunc StoreTokenUpdateFuncInvoked bool - StoreUserAuthenticateFunc StoreUserAuthenticateFunc - StoreUserAuthenticateFuncInvoked bool - DisableFunc DisableFunc DisableFuncInvoked bool + StoreUserAuthenticateFunc StoreUserAuthenticateFunc + StoreUserAuthenticateFuncInvoked bool + StoreCommandReportFunc StoreCommandReportFunc StoreCommandReportFuncInvoked bool @@ -118,6 +120,9 @@ type MDMAppleStore struct { AssociateCertHashFunc AssociateCertHashFunc AssociateCertHashFuncInvoked bool + EnrollmentFromHashFunc EnrollmentFromHashFunc + EnrollmentFromHashFuncInvoked bool + RetrieveMigrationCheckinsFunc RetrieveMigrationCheckinsFunc RetrieveMigrationCheckinsFuncInvoked bool @@ -153,13 +158,6 @@ func (fs *MDMAppleStore) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) return fs.StoreTokenUpdateFunc(r, msg) } -func (fs *MDMAppleStore) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error { - fs.mu.Lock() - fs.StoreUserAuthenticateFuncInvoked = true - fs.mu.Unlock() - return fs.StoreUserAuthenticateFunc(r, msg) -} - func (fs *MDMAppleStore) Disable(r *mdm.Request) error { fs.mu.Lock() fs.DisableFuncInvoked = true @@ -167,6 +165,13 @@ func (fs *MDMAppleStore) Disable(r *mdm.Request) error { return fs.DisableFunc(r) } +func (fs *MDMAppleStore) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error { + fs.mu.Lock() + fs.StoreUserAuthenticateFuncInvoked = true + fs.mu.Unlock() + return fs.StoreUserAuthenticateFunc(r, msg) +} + func (fs *MDMAppleStore) StoreCommandReport(r *mdm.Request, report *mdm.CommandResults) error { fs.mu.Lock() fs.StoreCommandReportFuncInvoked = true @@ -202,11 +207,11 @@ func (fs *MDMAppleStore) RetrieveBootstrapToken(r *mdm.Request, msg *mdm.GetBoot return fs.RetrieveBootstrapTokenFunc(r, msg) } -func (fs *MDMAppleStore) RetrievePushInfo(p0 context.Context, p1 []string) (map[string]*mdm.Push, error) { +func (fs *MDMAppleStore) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) { fs.mu.Lock() fs.RetrievePushInfoFuncInvoked = true fs.mu.Unlock() - return fs.RetrievePushInfoFunc(p0, p1) + return fs.RetrievePushInfoFunc(ctx, ids) } func (fs *MDMAppleStore) IsPushCertStale(ctx context.Context, topic string, staleToken string) (bool, error) { @@ -265,6 +270,13 @@ func (fs *MDMAppleStore) AssociateCertHash(r *mdm.Request, hash string, certNotV return fs.AssociateCertHashFunc(r, hash, certNotValidAfter) } +func (fs *MDMAppleStore) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + fs.mu.Lock() + fs.EnrollmentFromHashFuncInvoked = true + fs.mu.Unlock() + return fs.EnrollmentFromHashFunc(ctx, hash) +} + func (fs *MDMAppleStore) RetrieveMigrationCheckins(p0 context.Context, p1 chan<- interface{}) error { fs.mu.Lock() fs.RetrieveMigrationCheckinsFuncInvoked = true diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 5618509012..ad8778b570 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -416,6 +416,9 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle // 1. To get the JSON value from the database // 2. To update fields with the incoming values if newAppConfig.MDM.EnableDiskEncryption.Valid { + if svc.config.Server.PrivateKey == "" { + return nil, ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } appConfig.MDM.EnableDiskEncryption = newAppConfig.MDM.EnableDiskEncryption } else if appConfig.MDM.EnableDiskEncryption.Set && !appConfig.MDM.EnableDiskEncryption.Valid { appConfig.MDM.EnableDiskEncryption = oldAppConfig.MDM.EnableDiskEncryption @@ -1130,15 +1133,6 @@ func (svc *Service) validateMDM( return nil } } - - // if either macOS or Windows MDM is enabled, this setting can be set. - if !mdm.AtLeastOnePlatformEnabledAndConfigured() { - if mdm.EnableDiskEncryption.Valid && mdm.EnableDiskEncryption.Value && mdm.EnableDiskEncryption.Value != oldMdm.EnableDiskEncryption.Value { - invalid.Append("mdm.enable_disk_encryption", - `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) - } - } - return nil } diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 1e601997c4..734ef43f1c 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1208,6 +1208,59 @@ func TestMDMAppleConfig(t *testing.T) { } } +func TestDiskEncryptionSetting(t *testing.T) { + ds := new(mock.Store) + + admin := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)} + t.Run("enableDiskEncryptionWithNoPrivateKey", func(t *testing.T) { + testConfig = config.TestConfig() + testConfig.Server.PrivateKey = "" + svc, ctx := newTestServiceWithConfig(t, ds, testConfig, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + + dsAppConfig := &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{OrgName: "Test"}, + ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"}, + MDM: fleet.MDM{}, + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return dsAppConfig, nil + } + + ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { + *dsAppConfig = *conf + return nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + return nil, sql.ErrNoRows + } + ds.NewMDMAppleEnrollmentProfileFunc = func(ctx context.Context, enrollmentPayload fleet.MDMAppleEnrollmentProfilePayload) (*fleet.MDMAppleEnrollmentProfile, error) { + return &fleet.MDMAppleEnrollmentProfile{}, nil + } + ds.GetMDMAppleEnrollmentProfileByTypeFunc = func(ctx context.Context, typ fleet.MDMAppleEnrollmentType) (*fleet.MDMAppleEnrollmentProfile, error) { + raw := json.RawMessage("{}") + return &fleet.MDMAppleEnrollmentProfile{DEPProfile: &raw}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return job, nil + } + + ac, err := svc.AppConfigObfuscated(ctx) + require.NoError(t, err) + require.Equal(t, dsAppConfig.MDM, ac.MDM) + + raw, err := json.Marshal(fleet.MDM{ + EnableDiskEncryption: optjson.SetBool(true), + }) + require.NoError(t, err) + raw = []byte(`{"mdm":` + string(raw) + `}`) + _, err = svc.ModifyAppConfig(ctx, raw, fleet.ApplySpecOptions{}) + require.Error(t, err) + require.ErrorContains(t, err, "Missing required private key") + }) +} + func TestModifyAppConfigSMTPSSOAgentOptions(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil) diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 0e0255a097..2c0f435e55 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2845,6 +2845,15 @@ func (svc *MDMAppleCheckinAndCommandService) DeclarativeManagement(r *mdm.Reques return nil, nil } +// GetToken handles MDM [GetToken][1] requests. +// +// This method is executed after the request has been handled by nanomdm. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/get_token +func (svc *MDMAppleCheckinAndCommandService) GetToken(_ *mdm.Request, _ *mdm.GetToken) (*mdm.GetTokenResponse, error) { + return nil, nil +} + // CommandAndReportResults handles MDM [Commands and Queries][1]. // // This method is executed after the request has been handled by nanomdm. @@ -4745,7 +4754,7 @@ func (svc *Service) MDMAppleProcessOTAEnrollment( // otherwise we might be in the second phase, check if the signing cert // was issued by Fleet, only let the enrollment through if so. certVerifier := mdmcrypto.NewSCEPVerifier(svc.ds) - if err := certVerifier.Verify(rootSigner); err != nil { + if err := certVerifier.Verify(ctx, rootSigner); err != nil { return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil) } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 60ae723f9a..247eababf1 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -39,7 +39,6 @@ import ( mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service" "github.com/fleetdm/fleet/v4/server/mock" @@ -52,6 +51,7 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/micromdm/nanolib/log/stdlogfmt" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1523,7 +1523,6 @@ func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) { Enrollment: mdm.Enrollment{UDID: hostUUID}, CommandUUID: commandUUID, Status: c.status, - RequestType: c.requestType, ErrorChain: c.errors, }, ) @@ -1939,7 +1938,7 @@ func TestUpdateMDMAppleSettings(t *testing.T) { &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, false, nil, - ErrMissingLicense.Error(), + fleet.ErrMissingLicense.Error(), }, { "global admin premium", @@ -1960,7 +1959,7 @@ func TestUpdateMDMAppleSettings(t *testing.T) { &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, false, nil, - ErrMissingLicense.Error(), + fleet.ErrMissingLicense.Error(), }, { "global maintainer premium", @@ -2037,7 +2036,7 @@ func TestUpdateMDMAppleSettings(t *testing.T) { &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, false, ptr.Uint(1), - ErrMissingLicense.Error(), + fleet.ErrMissingLicense.Error(), }, } diff --git a/server/service/campaigns.go b/server/service/campaigns.go index 053b3cf256..e02406fe04 100644 --- a/server/service/campaigns.go +++ b/server/service/campaigns.go @@ -206,11 +206,23 @@ func (svc *Service) NewDistributedQueryCampaignByIdentifiers(ctx context.Context return nil, ctxerr.Wrap(ctx, err, "finding host IDs") } + if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil { + return nil, err + } labelMap, err := svc.ds.LabelIDsByName(ctx, labels) if err != nil { return nil, ctxerr.Wrap(ctx, err, "finding label IDs") } + // DetectMissingLabels will return the list of labels that are not found in the database + // These labels are considered invalid + invalidLabels := fleet.DetectMissingLabels(labelMap, labels) + if len(invalidLabels) > 0 { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("%s %s.", fleet.InvalidLabelSpecifiedErrMsg, strings.Join(invalidLabels, ", ")), + }, "invalid labels") + } + var labelIDs []uint for _, labelID := range labelMap { labelIDs = append(labelIDs, labelID) diff --git a/server/service/campaigns_test.go b/server/service/campaigns_test.go index 28c51ad863..91206ce990 100644 --- a/server/service/campaigns_test.go +++ b/server/service/campaigns_test.go @@ -10,6 +10,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/pubsub" + "github.com/stretchr/testify/require" ) type nopLiveQuery struct{} @@ -237,3 +238,82 @@ func TestLiveQueryAuth(t *testing.T) { }) } } + +func TestLiveQueryLabelValidation(t *testing.T) { + ds := new(mock.Store) + qr := pubsub.NewInmemQueryResults() + svc, ctx := newTestService(t, ds, qr, nopLiveQuery{}) + + user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)} + query := &fleet.Query{ + ID: 1, + Name: "q1", + Query: "SELECT 1", + ObserverCanRun: true, + } + ds.NewQueryFunc = func(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error) { + query.ID = 123 + return query, nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{LiveQueryDisabled: false}}, nil + } + ds.NewDistributedQueryCampaignFunc = func(ctx context.Context, camp *fleet.DistributedQueryCampaign) (*fleet.DistributedQueryCampaign, error) { + return camp, nil + } + ds.NewDistributedQueryCampaignTargetFunc = func(ctx context.Context, target *fleet.DistributedQueryCampaignTarget) (*fleet.DistributedQueryCampaignTarget, error) { + return target, nil + } + ds.HostIDsInTargetsFunc = func(ctx context.Context, filters fleet.TeamFilter, targets fleet.HostTargets) ([]uint, error) { + return []uint{1}, nil + } + ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, identifiers []string) ([]uint, error) { + return nil, nil + } + ds.CountHostsInTargetsFunc = func(ctx context.Context, filters fleet.TeamFilter, targets fleet.HostTargets, now time.Time) (fleet.TargetMetrics, error) { + return fleet.TargetMetrics{}, nil + } + ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) { + return query, nil + } + + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) { + return map[string]uint{"label1": uint(1)}, nil + } + + testCases := []struct { + name string + labels []string + expectedError string + }{ + { + name: "no labels", + labels: []string{}, + expectedError: "", + }, + { + name: "invalid label", + labels: []string{"iamnotalabel"}, + expectedError: "Invalid label name(s): iamnotalabel.", + }, + { + name: "valid label", + labels: []string{"label1"}, + expectedError: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx := viewer.NewContext(ctx, viewer.Viewer{User: user}) + _, err := svc.NewDistributedQueryCampaignByIdentifiers(ctx, query.Query, nil, nil, tt.labels) + + if tt.expectedError == "" { + require.Nil(t, err) + } else { + require.NotNil(t, err) + require.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} diff --git a/server/service/client_labels.go b/server/service/client_labels.go index 168dfdd6f7..e9dc69f8a1 100644 --- a/server/service/client_labels.go +++ b/server/service/client_labels.go @@ -23,7 +23,7 @@ func (c *Client) GetLabel(name string) (*fleet.LabelSpec, error) { return responseBody.Spec, err } -// GetLabels retrieves the list of all Labels. +// GetLabels retrieves the list of all LabelSpecs. func (c *Client) GetLabels() ([]*fleet.LabelSpec, error) { verb, path := "GET", "/api/latest/fleet/spec/labels" var responseBody getLabelSpecsResponse diff --git a/server/service/devices.go b/server/service/devices.go index 3ae57851b1..187e168bf4 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -609,6 +609,43 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos return fleet.ErrMissingLicense } +//////////////////////////////////////////////////////////////////////////////// +// Trigger linux key escrow +//////////////////////////////////////////////////////////////////////////////// + +type triggerLinuxDiskEncryptionEscrowRequest struct { + Token string `url:"token"` +} + +func (r *triggerLinuxDiskEncryptionEscrowRequest) deviceAuthToken() string { + return r.Token +} + +type triggerLinuxDiskEncryptionEscrowResponse struct { + Err error `json:"error,omitempty"` +} + +func (r triggerLinuxDiskEncryptionEscrowResponse) error() error { return r.Err } + +func (r triggerLinuxDiskEncryptionEscrowResponse) Status() int { return http.StatusNoContent } + +func triggerLinuxDiskEncryptionEscrowEndpoint(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 triggerLinuxDiskEncryptionEscrowResponse{Err: err}, nil + } + + if err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, host); err != nil { + return triggerLinuxDiskEncryptionEscrowResponse{Err: err}, nil + } + return triggerLinuxDiskEncryptionEscrowResponse{}, nil +} + +func (svc *Service) TriggerLinuxDiskEncryptionEscrow(ctx context.Context, host *fleet.Host) error { + return fleet.ErrMissingLicense +} + //////////////////////////////////////////////////////////////////////////////// // Get Current Device's Software //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/devices_test.go b/server/service/devices_test.go index 1100683be4..53d9644931 100644 --- a/server/service/devices_test.go +++ b/server/service/devices_test.go @@ -3,10 +3,12 @@ package service import ( "context" "database/sql" + "errors" "fmt" "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" @@ -475,3 +477,116 @@ func TestGetFleetDesktopSummary(t *testing.T) { }) } + +func TestTriggerLinuxDiskEncryptionEscrow(t *testing.T) { + t.Run("unavailable in Fleet Free", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true}) + err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, &fleet.Host{ID: 1}) + require.ErrorIs(t, err, fleet.ErrMissingLicense) + }) + + t.Run("no-op on already pending", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true}) + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return true + } + + err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, &fleet.Host{ID: 1}) + require.NoError(t, err) + require.True(t, ds.IsHostPendingEscrowFuncInvoked) + }) + + t.Run("validation failures", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true}) + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return false + } + var reportedErrors []string + host := &fleet.Host{ID: 1, Platform: "rhel", OSVersion: "Red Hat Enterprise Linux 9.0.0"} + ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error { + require.Equal(t, hostID, host.ID) + reportedErrors = append(reportedErrors, err) + return nil + } + + // invalid platform + err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.ErrorContains(t, err, "Fleet does not yet support creating LUKS disk encryption keys on this platform.") + require.True(t, ds.IsHostPendingEscrowFuncInvoked) + + // valid platform, no-team, encryption not enabled + host.OSVersion = "Fedora 32.0.0" + appConfig := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(false)}} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return appConfig, nil + } + err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.ErrorContains(t, err, "Disk encryption is not enabled for hosts not assigned to a team.") + + // valid platform, team, encryption not enabled + host.TeamID = ptr.Uint(1) + teamConfig := &fleet.TeamMDM{} + ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) { + require.Equal(t, uint(1), teamID) + return teamConfig, nil + } + err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.ErrorContains(t, err, "Disk encryption is not enabled for this host's team.") + + // valid platform, team, host disk is not encrypted or unknown encryption state + teamConfig = &fleet.TeamMDM{EnableDiskEncryption: true} + err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.ErrorContains(t, err, "Host's disk is not encrypted. Please encrypt your disk first.") + host.DiskEncryptionEnabled = ptr.Bool(false) + err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.ErrorContains(t, err, "Host's disk is not encrypted. Please encrypt your disk first.") + + // No Fleet Desktop + host.DiskEncryptionEnabled = ptr.Bool(true) + orbitInfo := &fleet.HostOrbitInfo{Version: "1.35.1"} + ds.GetHostOrbitInfoFunc = func(ctx context.Context, id uint) (*fleet.HostOrbitInfo, error) { + return orbitInfo, nil + } + err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.ErrorContains(t, err, "Your version of fleetd does not support creating disk encryption keys on Linux. Please upgrade fleetd, then click Refetch, then try again.") + + // Encryption key is already escrowed + orbitInfo.Version = fleet.MinOrbitLUKSVersion + ds.AssertHasNoEncryptionKeyStoredFunc = func(ctx context.Context, hostID uint) error { + return errors.New("encryption key is already escrowed") + } + err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.ErrorContains(t, err, "encryption key is already escrowed") + + require.Len(t, reportedErrors, 7) + }) + + t.Run("validation success", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true}) + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return false + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}}, nil + } + ds.GetHostOrbitInfoFunc = func(ctx context.Context, id uint) (*fleet.HostOrbitInfo, error) { + return &fleet.HostOrbitInfo{Version: "1.36.0", DesktopVersion: ptr.String("42")}, nil + } + ds.AssertHasNoEncryptionKeyStoredFunc = func(ctx context.Context, hostID uint) error { + return nil + } + host := &fleet.Host{ID: 1, Platform: "ubuntu", DiskEncryptionEnabled: ptr.Bool(true), OrbitVersion: ptr.String(fleet.MinOrbitLUKSVersion)} + ds.QueueEscrowFunc = func(ctx context.Context, hostID uint) error { + require.Equal(t, uint(1), hostID) + return nil + } + + err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, host) + require.NoError(t, err) + require.True(t, ds.QueueEscrowFuncInvoked) + }) +} diff --git a/server/service/handler.go b/server/service/handler.go index 4f916f576b..071d28a859 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -16,8 +16,8 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" httpmdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/mdm" - nanomdm_log "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log" nanomdm_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/multi" @@ -32,6 +32,7 @@ import ( kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/gorilla/mux" + nanomdm_log "github.com/micromdm/nanolib/log" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/throttled/throttled/v2" @@ -696,18 +697,18 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Deprecated: GET /mdm/disk_encryption/summary is now deprecated, replaced by the // GET /disk_encryption endpoint. - mdmAnyMW.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) - mdmAnyMW.GET("/api/_version_/fleet/disk_encryption", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) + ue.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) + ue.GET("/api/_version_/fleet/disk_encryption", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) // Deprecated: GET /mdm/hosts/:id/encryption_key is now deprecated, replaced by // GET /hosts/:id/encryption_key. - mdmAnyMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) - mdmAnyMW.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + ue.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) // Deprecated: GET /mdm/profiles/summary is now deprecated, replaced by the // GET /configuration_profiles/summary endpoint. - mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{}) - mdmAnyMW.GET("/api/_version_/fleet/configuration_profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{}) + ue.GET("/api/_version_/fleet/mdm/profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{}) + ue.GET("/api/_version_/fleet/configuration_profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{}) // Deprecated: GET /mdm/profiles/:profile_uuid is now deprecated, replaced by // GET /configuration_profiles/:profile_uuid. @@ -734,7 +735,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Deprecated: PATCH /mdm/apple/settings is deprecated, replaced by POST /disk_encryption. // It was only used to set disk encryption. mdmAnyMW.PATCH("/api/_version_/fleet/mdm/apple/settings", updateMDMAppleSettingsEndpoint, updateMDMAppleSettingsRequest{}) - mdmAnyMW.POST("/api/_version_/fleet/disk_encryption", updateMDMDiskEncryptionEndpoint, updateMDMDiskEncryptionRequest{}) + ue.POST("/api/_version_/fleet/disk_encryption", updateDiskEncryptionEndpoint, updateDiskEncryptionRequest{}) // the following set of mdm endpoints must always be accessible (even // if MDM is not configured) as it bootstraps the setup of MDM @@ -837,6 +838,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC errorLimiter.Limit("post_device_migrate_mdm", desktopQuota), ).POST("/api/_version_/fleet/device/{token}/migrate_mdm", migrateMDMDeviceEndpoint, deviceMigrateMDMRequest{}) + de.WithCustomMiddleware( + errorLimiter.Limit("post_device_trigger_linux_escrow", desktopQuota), + ).POST("/api/_version_/fleet/device/{token}/mdm/linux/trigger_escrow", triggerLinuxDiskEncryptionEscrowEndpoint, triggerLinuxDiskEncryptionEscrowRequest{}) + // host-authenticated endpoints he := newHostAuthenticatedEndpointer(svc, logger, opts, r, apiVersions...) @@ -879,6 +884,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{}) + oe.POST("/api/fleet/orbit/luks_data", postOrbitLUKSEndpoint, orbitPostLUKSRequest{}) + // unauthenticated endpoints - most of those are either login-related, // invite-related or host-enrolling. So they typically do some kind of // one-time authentication by verifying that a valid secret token is provided @@ -1221,7 +1228,8 @@ func registerMDM( } else { mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, certVerifier, mdmLogger.With("handler", "cert-verify")) } - mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, mdmLogger.With("handler", "cert-extract")) + mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, httpmdm.MdmSignatureVerifierFunc(cryptoutil.VerifyMdmSignature), + httpmdm.SigLogWithLogger(mdmLogger.With("handler", "cert-extract"))) mux.Handle(apple_mdm.MDMPath, mdmHandler) return nil } diff --git a/server/service/hosts.go b/server/service/hosts.go index 33e32c4380..4af625c202 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1242,6 +1242,20 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f } host.MDM.Profiles = &profiles + if host.IsLUKSSupported() { + status, err := svc.LinuxHostDiskEncryptionStatus(ctx, *host) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host disk encryption status") + } + host.MDM.OSSettings = &fleet.HostMDMOSSettings{ + DiskEncryption: status, + } + + if status.Status != nil && *status.Status == fleet.DiskEncryptionVerified { + host.MDM.EncryptionKeyAvailable = true + } + } + var macOSSetup *fleet.HostMDMMacOSSetup if ac.MDM.EnabledAndConfigured && license.IsPremium(ctx) { macOSSetup, err = svc.ds.GetHostMDMMacOSSetup(ctx, host.ID) @@ -2200,50 +2214,32 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host return nil, err } - // The middleware checks that either Apple or Windows MDM are configured and - // enabled, but here we must check if the specific one is enabled for that - // particular host's platform. - var decryptCert *tls.Certificate - switch host.FleetPlatform() { - case "windows": - if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { + var key *fleet.HostDiskEncryptionKey + if host.IsLUKSSupported() { + if svc.config.Server.PrivateKey == "" { + return nil, ctxerr.Wrap(ctx, errors.New("private key is unavailable"), "getting host encryption key") + } + + key, err = svc.ds.GetHostDiskEncryptionKey(ctx, id) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + } + if key.Base64Encrypted == "" { + return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not set") + } + + decryptedKey, err := mdm.DecodeAndDecrypt(key.Base64Encrypted, svc.config.Server.PrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key") + } + key.DecryptedValue = decryptedKey + } else { + key, err = svc.decryptForMDMPlatform(ctx, host) + if err != nil { return nil, err } - - // use Microsoft's WSTEP certificate for decrypting - cert, _, _, err := svc.config.MDM.MicrosoftWSTEP() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key") - } - decryptCert = cert - - default: - if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { - return nil, err - } - - // use Apple's SCEP certificate for decrypting - cert, err := assets.CAKeyPair(ctx, svc.ds) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "loading existing assets from the database") - } - decryptCert = cert } - key, err := svc.ds.GetHostDiskEncryptionKey(ctx, id) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") - } - if key.Decryptable == nil || !*key.Decryptable { - return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not decryptable") - } - - decryptedKey, err := mdm.DecryptBase64CMS(key.Base64Encrypted, decryptCert.Leaf, decryptCert.PrivateKey) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key") - } - key.DecryptedValue = string(decryptedKey) - err = svc.NewActivity( ctx, authz.UserFromContext(ctx), @@ -2259,6 +2255,50 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host return key, nil } +func (svc *Service) decryptForMDMPlatform(ctx context.Context, host *fleet.Host) (*fleet.HostDiskEncryptionKey, error) { + // Here we must check if the appropriate MDM is enabled for that particular host's platform. + var decryptCert *tls.Certificate + if host.FleetPlatform() == "windows" { + if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { + return nil, err + } + + // use Microsoft's WSTEP certificate for decrypting + cert, _, _, err := svc.config.MDM.MicrosoftWSTEP() + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key") + } + decryptCert = cert + } else { + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + return nil, err + } + + // use Apple's SCEP certificate for decrypting + cert, err := assets.CAKeyPair(ctx, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "loading existing assets from the database") + } + decryptCert = cert + } + + key, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting host encryption key") + } + if key.Decryptable == nil || !*key.Decryptable { + return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not decryptable") + } + + decryptedKey, err := mdm.DecryptBase64CMS(key.Base64Encrypted, decryptCert.Leaf, decryptCert.PrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key") + } + + key.DecryptedValue = string(decryptedKey) + return key, nil +} + //////////////////////////////////////////////////////////////////////////////// // Host Health //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 5b0837cf60..035a552486 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -19,6 +19,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" @@ -399,6 +400,10 @@ func TestHostDetailsOSSettings(t *testing.T) { return &fleet.HostLockWipeStatus{}, nil } + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{}, nil + } + type testCase struct { name string host *fleet.Host @@ -1315,7 +1320,8 @@ func TestHostEncryptionKey(t *testing.T) { } ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, - _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM}, fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM}, @@ -1368,7 +1374,8 @@ func TestHostEncryptionKey(t *testing.T) { return nil, keyErr } ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, - _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM}, fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM}, @@ -1429,7 +1436,8 @@ func TestHostEncryptionKey(t *testing.T) { return nil } ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, - _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM}, fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM}, @@ -1448,6 +1456,73 @@ func TestHostEncryptionKey(t *testing.T) { }) } }) + + t.Run("Linux encryption", func(t *testing.T) { + ds := new(mock.Store) + host := &fleet.Host{ID: 1, Platform: "ubuntu"} + symmetricKey := "this_is_a_32_byte_symmetric_key!" + passphrase := "this_is_a_passphrase" + base64EncryptedKey, err := mdm.EncryptAndEncode(passphrase, symmetricKey) + require.NoError(t, err) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return host, nil + } + + ds.NewActivityFunc = func( + ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, + ) error { + return nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { // needed for new activity + return &fleet.AppConfig{}, nil + } + + // error when no server private key + fleetCfg.Server.PrivateKey = "" + svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) + ctx = test.UserContext(ctx, test.UserAdmin) + key, err := svc.HostEncryptionKey(ctx, 1) + require.Error(t, err, "private key is unavailable") + require.Nil(t, key) + + // error when key is not set + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{}, nil + } + fleetCfg.Server.PrivateKey = symmetricKey + svc, ctx = newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) + ctx = test.UserContext(ctx, test.UserAdmin) + key, err = svc.HostEncryptionKey(ctx, 1) + require.Error(t, err, "host encryption key is not set") + require.Nil(t, key) + + // error when key is not set + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{ + Base64Encrypted: "thisIsWrong", + Decryptable: ptr.Bool(true), + }, nil + } + svc, ctx = newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) + ctx = test.UserContext(ctx, test.UserAdmin) + key, err = svc.HostEncryptionKey(ctx, 1) + require.Error(t, err, "decrypt host encryption key") + require.Nil(t, key) + + // happy path + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{ + Base64Encrypted: base64EncryptedKey, + Decryptable: ptr.Bool(true), + }, nil + } + svc, ctx = newTestServiceWithConfig(t, ds, fleetCfg, nil, nil) + ctx = test.UserContext(ctx, test.UserAdmin) + key, err = svc.HostEncryptionKey(ctx, 1) + require.NoError(t, err) + require.Equal(t, passphrase, key.DecryptedValue) + }) } func TestHostMDMProfileDetail(t *testing.T) { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 45c56a3d76..999b6bfb34 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -6223,10 +6223,8 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Fleet MDM is not configured") - // update MDM disk encryption, the endpoint returns an error if MDM is not enabled - res = s.Do("POST", "/api/latest/fleet/disk_encryption", fleet.MDMAppleSettingsPayload{}, fleet.ErrMDMNotConfigured.StatusCode()) - errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error()) + // update MDM disk encryption + _ = s.Do("POST", "/api/latest/fleet/disk_encryption", fleet.MDMAppleSettingsPayload{}, http.StatusPaymentRequired) // device migrate mdm endpoint returns an error if not premium createHostAndDeviceToken(t, s.ds, "some-token") diff --git a/server/service/integration_live_queries_test.go b/server/service/integration_live_queries_test.go index 5d5c009aa4..8ecd65fb7b 100644 --- a/server/service/integration_live_queries_test.go +++ b/server/service/integration_live_queries_test.go @@ -1021,6 +1021,17 @@ func (s *liveQueriesTestSuite) TestCreateDistributedQueryCampaign() { }, } s.DoJSON("POST", "/api/latest/fleet/queries/run_by_identifiers", req2, http.StatusOK, &createResp) + + // create with invalid label + req3 := createDistributedQueryCampaignByIdentifierRequest{ + QuerySQL: "SELECT 4", + Selected: distributedQueryCampaignTargetsByIdentifiers{ + Hosts: []string{h1.Hostname}, + Labels: []string{"label1"}, + }, + } + + s.DoJSON("POST", "/api/latest/fleet/queries/run_by_identifiers", req3, http.StatusBadRequest, &createResp) } func (s *liveQueriesTestSuite) TestOsqueryDistributedRead() { diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index d2a32c8e33..235d8c7aa0 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -302,7 +302,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) require.Empty(t, listHostsRes.Hosts) - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { return map[string]*push.Response{}, nil } @@ -871,7 +871,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { require.Equal(t, host.DisplayName, fmt.Sprintf("MacBook Mini (%s)", host.HardwareSerial)) } - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { return map[string]*push.Response{}, nil } @@ -1902,7 +1902,7 @@ func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileS // no bootstrap package, no custom setup assistant (those are already tested // in the DEPEnrollReleaseDevice tests). - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { return map[string]*push.Response{}, nil } @@ -1952,6 +1952,7 @@ func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileS require.Len(t, listHostsRes.Hosts, 1) require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, teamDevice.SerialNumber) enrolledHost := listHostsRes.Hosts[0].Host + enrolledHost.TeamID = &tm.ID // transfer it to the team s.Do("POST", "/api/v1/fleet/hosts/transfer", @@ -2076,6 +2077,57 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) + // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull + // it out manually + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + require.Len(t, results, 2) + require.NoError(t, err) + var installUUID string + for _, r := range results { + if r.HostSoftwareInstallsExecutionID != nil { + installUUID = *r.HostSoftwareInstallsExecutionID + } + } + + require.NotEmpty(t, installUUID) + + // Need to get the software title to get the package name + var getSoftwareTitleResp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *statusResp.Results.Software[0].SoftwareTitleID), nil, http.StatusOK, &getSoftwareTitleResp, "team_id", fmt.Sprintf("%d", *enrolledHost.TeamID)) + require.NotNil(t, getSoftwareTitleResp.SoftwareTitle) + require.NotNil(t, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage) + + debugPrintActivities := func(activities []*fleet.Activity) []string { + var res []string + for _, activity := range activities { + res = append(res, fmt.Sprintf("%+v", activity)) + } + return res + } + + // Check upcoming activities: we should only have the software upcoming because we don't run the + // script until after the software is done + var hostActivitiesResp listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID), + nil, http.StatusOK, &hostActivitiesResp) + + expectedActivityDetail := fmt.Sprintf(` + { + "status": "pending_install", + "host_id": %d, + "policy_id": null, + "policy_name": null, + "install_uuid": "%s", + "self_service": false, + "software_title": "%s", + "software_package": "%s", + "host_display_name": "%s" + } + `, enrolledHost.ID, installUUID, getSoftwareTitleResp.SoftwareTitle.Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, enrolledHost.DisplayName()) + require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", debugPrintActivities(hostActivitiesResp.Activities)) + require.NotNil(t, hostActivitiesResp.Activities[0].Details) + require.JSONEq(t, expectedActivityDetail, string(*hostActivitiesResp.Activities[0].Details)) + // no MDM command got enqueued due to the /status call (device not released yet) cmd, err = mdmDevice.Idle() require.NoError(t, err) @@ -2093,20 +2145,6 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) - // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull - // it out manually - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) - require.Len(t, results, 2) - require.NoError(t, err) - var installUUID string - for _, r := range results { - if r.HostSoftwareInstallsExecutionID != nil { - installUUID = *r.HostSoftwareInstallsExecutionID - } - } - - require.NotEmpty(t, installUUID) - // record a result for software installation s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ @@ -2137,12 +2175,10 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu // Software is installed, now we should run the script statusResp = getOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - // Software is now running, script is still pending require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) - require.NotNil(t, statusResp.Results.Script) require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Script.Status) @@ -2158,6 +2194,48 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu } } + // Validate past activity for software install + // For some reason the display name that's included in the `enrolledHost` is _slightly_ + // different than the expected value in the activities. Pulling the host directly gets the + // correct display name. + var getHostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", enrolledHost.ID), nil, http.StatusOK, &getHostResp) + + expectedActivityDetail = fmt.Sprintf(` +{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "installed", + "policy_id": null, + "policy_name": null +} + `, enrolledHost.ID, getHostResp.Host.DisplayName, statusResp.Results.Software[0].Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, installUUID) + + s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0) + + // Validate upcoming activity for the script + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID), + nil, http.StatusOK, &hostActivitiesResp) + + expectedActivityDetail = fmt.Sprintf(` +{ + "async": true, + "host_id": %d, + "policy_id": null, + "policy_name": null, + "script_name": "%s", + "host_display_name": "%s", + "script_execution_id": "%s" +} + `, enrolledHost.ID, statusResp.Results.Script.Name, enrolledHost.DisplayName(), execID) + require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", debugPrintActivities(hostActivitiesResp.Activities)) + require.NotNil(t, hostActivitiesResp.Activities[0].Details) + require.JSONEq(t, expectedActivityDetail, string(*hostActivitiesResp.Activities[0].Details)) + // record a result for script execution var scriptResp orbitPostScriptResultResponse s.DoJSON("POST", "/api/fleet/orbit/scripts/result", @@ -2168,7 +2246,6 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu // release of the device, as all setup experience steps are now complete. statusResp = getOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - // Software is now running, script is still pending require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) @@ -2202,6 +2279,21 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu } require.Equal(t, 1, deviceConfiguredCount) require.Equal(t, 0, otherCount) + + // Validate activity for script run + expectedActivityDetail = fmt.Sprintf(` +{ + "async": true, + "host_id": %d, + "policy_id": null, + "policy_name": null, + "script_name": "%s", + "host_display_name": "%s", + "script_execution_id": "%s" +} + `, enrolledHost.ID, statusResp.Results.Script.Name, getHostResp.Host.DisplayName, execID) + + s.lastActivityMatches(fleet.ActivityTypeRanScript{}.ActivityName(), expectedActivityDetail, 0) } func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptForceRelease() { @@ -2360,7 +2452,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptFo require.Equal(t, 0, otherCount) } -func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM() { +func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingItFromABM() { t := s.T() s.enableABM(t.Name()) ctx := context.Background() @@ -2470,7 +2562,7 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM( } })) - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { return map[string]*push.Response{}, nil } @@ -2534,7 +2626,6 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM( {SerialNumber: mdmDevice.SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "deleted", OpDate: time.Now()}, } - t.Log("RUN AFTER DELETED") s.runDEPSchedule() a := checkHostDEPAssignProfileResponses([]string{mdmDevice.SerialNumber}, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) @@ -2550,7 +2641,6 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM( {SerialNumber: mdmDevice.SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now(), ProfileUUID: a[mdmDevice.SerialNumber].ProfileUUID}, } - t.Log("RUN AFTER RE-ADDED") s.runDEPSchedule() a = checkHostDEPAssignProfileResponses([]string{mdmDevice.SerialNumber}, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 4bbc784435..01c2a525e9 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -46,6 +46,11 @@ type mdmLifecycleAssertion[T any] func(t *testing.T, host *fleet.Host, device T) func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() { t := s.T() + // Skip worker jobs to avoid running into timing issues with this test. + // We can manually run the jobs if needed with s.runWorker(). + s.skipWorkerJobs = true + t.Cleanup(func() { s.skipWorkerJobs = false }) + s.setupLifecycleSettings() testCases := []struct { @@ -104,8 +109,8 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() { originalPushMock := s.pushProvider.PushFunc defer func() { s.pushProvider.PushFunc = originalPushMock }() - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { - res, err := mockSuccessfulPush(pushes) + s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { + res, err := mockSuccessfulPush(ctx, pushes) require.NoError(t, err) err = device.Checkout() require.NoError(t, err) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 2d98542328..fe930b96c4 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1058,7 +1058,7 @@ func setupExpectedCAProfile(t *testing.T, ds *mysql.Datastore) []byte { func setupPusher(s *integrationMDMTestSuite, t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient) { origPush := s.pushProvider.PushFunc - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { require.Len(t, pushes, 1) require.Equal(t, pushes[0].PushMagic, "pushmagic"+mdmDevice.SerialNumber) res := map[string]*push.Response{ @@ -1477,7 +1477,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { defer func() { s.pushProvider.PushFunc = originalPushMock }() // if there's an error coming from APNs servers - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { return map[string]*push.Response{ pushes[0].Token.String(): { Id: uuid.New().String(), @@ -1488,7 +1488,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusBadGateway) // if there was an error unrelated to APNs - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { res := map[string]*push.Response{ pushes[0].Token.String(): { Id: uuid.New().String(), @@ -1501,8 +1501,8 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { // try again, but this time the host is online and answers var checkoutErr error - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { - res, err := mockSuccessfulPush(pushes) + s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { + res, err := mockSuccessfulPush(ctx, pushes) checkoutErr = mdmDevice.Checkout() return res, err } @@ -1656,45 +1656,6 @@ func (s *integrationMDMTestSuite) TestDiskEncryptionSharedSetting() { require.NoError(s.T(), err) }) - checkConfigSetErrors := func() { - // try to set app config - res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "enable_disk_encryption": true } - }`), http.StatusUnprocessableEntity) - errMsg := extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") - - // try to create a new team using specs - teamSpecs := map[string]any{ - "specs": []any{ - map[string]any{ - "name": teamName + uuid.NewString(), - "mdm": map[string]any{ - "enable_disk_encryption": true, - }, - }, - }, - } - res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity) - errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") - - // try to edit the existing team using specs - teamSpecs = map[string]any{ - "specs": []any{ - map[string]any{ - "name": teamName, - "mdm": map[string]any{ - "enable_disk_encryption": true, - }, - }, - }, - } - res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity) - errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.") - } - checkConfigSetSucceeds := func() { res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "enable_disk_encryption": true } @@ -1749,10 +1710,9 @@ func (s *integrationMDMTestSuite) TestDiskEncryptionSharedSetting() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) } - // disable both windows and mac mdm - // we should get an error + // MDM config succeeds because we have a private key baked into default suite config setMDMEnabled(false, false) - checkConfigSetErrors() + checkConfigSetSucceeds() // enable windows mdm, no errors setMDMEnabled(false, true) @@ -2218,7 +2178,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // use the MDM disk encryption endpoint to set it to true s.Do("POST", "/api/latest/fleet/disk_encryption", - updateMDMDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent) + updateDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent) enabledDiskActID = s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0) @@ -2263,13 +2223,13 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() { // flip and verify the value s.Do("POST", "/api/latest/fleet/disk_encryption", - updateMDMDiskEncryptionRequest{EnableDiskEncryption: false}, http.StatusNoContent) + updateDiskEncryptionRequest{EnableDiskEncryption: false}, http.StatusNoContent) acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) assert.False(t, acResp.MDM.EnableDiskEncryption.Value) s.Do("POST", "/api/latest/fleet/disk_encryption", - updateMDMDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent) + updateDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent) acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) assert.True(t, acResp.MDM.EnableDiskEncryption.Value) @@ -2573,7 +2533,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { // use the MDM settings endpoint to set it to true s.Do("POST", "/api/latest/fleet/disk_encryption", - updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent) + updateDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent) lastDiskActID = s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0) @@ -2599,7 +2559,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { // use the MDM settings endpoint with an unknown team id s.Do("POST", "/api/latest/fleet/disk_encryption", - updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(9999), EnableDiskEncryption: true}, http.StatusNotFound) + updateDiskEncryptionRequest{TeamID: ptr.Uint(9999), EnableDiskEncryption: true}, http.StatusNotFound) // mdm/apple/settings works for windows as well as it's being used by // clients (UI) this way @@ -2620,13 +2580,13 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() { // flip and verify the value s.Do("POST", "/api/latest/fleet/disk_encryption", - updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: false}, http.StatusNoContent) + updateDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: false}, http.StatusNoContent) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.False(t, teamResp.Team.Config.MDM.EnableDiskEncryption) s.Do("POST", "/api/latest/fleet/disk_encryption", - updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent) + updateDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption) @@ -2661,10 +2621,14 @@ func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() { // enroll the host from orbit, it should match the host above via the serial var resp EnrollOrbitResponse hostUUID := uuid.New().String() + h.ComputerName = "My Mac" + h.HardwareModel = "MacBook Pro" s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{ EnrollSecret: secret, HardwareUUID: hostUUID, // will not match any existing host HardwareSerial: h.HardwareSerial, + ComputerName: h.ComputerName, + HardwareModel: h.HardwareModel, }, http.StatusOK, &resp) require.NotEmpty(t, resp.OrbitNodeKey) @@ -2674,11 +2638,21 @@ func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp) require.Equal(t, h.ID, hostResp.Host.ID) require.NotEqual(t, dbZeroTime, hostResp.Host.LastEnrolledAt) + assert.Equal(t, h.ComputerName, hostResp.Host.ComputerName) + assert.Equal(t, h.HardwareModel, hostResp.Host.HardwareModel) + assert.Equal(t, h.HardwareSerial, hostResp.Host.HardwareSerial) + assert.Equal(t, h.DisplayName(), hostResp.Host.DisplayName) got, err := s.ds.LoadHostByOrbitNodeKey(ctx, resp.OrbitNodeKey) require.NoError(t, err) require.Equal(t, h.ID, got.ID) + s.lastActivityMatches( + "fleet_enrolled", + fmt.Sprintf(`{"host_display_name": "%s", "host_serial": "%s"}`, h.DisplayName(), h.HardwareSerial), + 0, + ) + // enroll the host from osquery, it should match the same host var osqueryResp enrollAgentResponse osqueryID := uuid.New().String() @@ -2889,7 +2863,6 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() { }, &mdm.CommandResults{ CommandUUID: uuid2, Status: "Acknowledged", - RequestType: "ProfileList", Raw: []byte(rawCmd), }) require.NoError(t, err) @@ -7558,7 +7531,7 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { // TODO: Some global MDM config settings don't have MDMEnabledAndConfigured or // WindowsMDMEnabledAndConfigured validations currently. Either add validations - // and test them or test abscence of validation. + // and test them or test absence of validation. t.Run("apply app config spec", func(t *testing.T) { t.Run("disk encryption", func(t *testing.T) { t.Cleanup(func() { @@ -7591,14 +7564,14 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // no change require.Equal(t, "f1337", acResp.AppConfig.OrgInfo.OrgName) - // disabling disk encryption doesn't cause validation error because Windows is still enabled + // disabling disk encryption doesn't cause validation error ac.MDM.EnableDiskEncryption = optjson.SetBool(false) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) acResp = checkAppConfig(t, false, true) // only windows mdm enabled require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // disabled require.Equal(t, "f1337", acResp.AppConfig.OrgInfo.OrgName) - // enabling disk encryption doesn't cause validation error because Windows is still enabled + // enabling disk encryption doesn't cause validation error ac.MDM.EnableDiskEncryption = optjson.SetBool(true) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) @@ -7611,25 +7584,26 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // disabling mdm doesn't change disk encryption + // disabling disk encryption doesn't cause validation error + ac.MDM.EnableDiskEncryption = optjson.SetBool(false) + s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) + acResp = checkAppConfig(t, false, false) // no MDM enabled + require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // disabled + require.Equal(t, "f1337", acResp.AppConfig.OrgInfo.OrgName) + + // enabling disk encryption doesn't cause validation error + ac.MDM.EnableDiskEncryption = optjson.SetBool(true) + s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + acResp = checkAppConfig(t, false, false) // no MDM enabled + require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // enabled + // changing unrelated config doesn't cause validation error ac.OrgInfo.OrgName = "f1338" s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // no change require.Equal(t, "f1338", acResp.AppConfig.OrgInfo.OrgName) - - // changing MDM config doesn't cause validation error when switching to default values - ac.MDM.EnableDiskEncryption = optjson.SetBool(false) - // TODO: Should it be ok to disable disk encryption when MDM is disabled? - s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) - acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled - require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // changed to disabled - - // changing MDM config does cause validation error when switching to non-default vailes - ac.MDM.EnableDiskEncryption = optjson.SetBool(true) - s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusUnprocessableEntity, &acResp) - acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled - require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // still disabled }) t.Run("macos setup", func(t *testing.T) { @@ -8093,17 +8067,18 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { }) checkTeam := func(t *testing.T, team *fleet.Team, checkMDM *fleet.TeamSpecMDM) teamResponse { - var wantDiskEncryption bool + // TODO - remove check of disk encryption from this function entirely? + // var wantDiskEncryption bool var wantMacOSSetup fleet.MacOSSetup if checkMDM != nil { wantMacOSSetup = checkMDM.MacOSSetup - wantDiskEncryption = checkMDM.EnableDiskEncryption.Value + // wantDiskEncryption = checkMDM.EnableDiskEncryption.Value } var resp teamResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &resp) require.Equal(t, team.Name, resp.Team.Name) - require.Equal(t, wantDiskEncryption, resp.Team.Config.MDM.EnableDiskEncryption) + // require.Equal(t, wantDiskEncryption, resp.Team.Config.MDM.EnableDiskEncryption) require.Equal(t, wantMacOSSetup.BootstrapPackage.Value, resp.Team.Config.MDM.MacOSSetup.BootstrapPackage.Value) require.Equal(t, wantMacOSSetup.MacOSSetupAssistant.Value, resp.Team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value) require.Equal(t, wantMacOSSetup.EnableEndUserAuthentication, resp.Team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) @@ -8181,9 +8156,10 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { &fleet.TeamSpecMDM{ EnableDiskEncryption: optjson.SetBool(true), }, - // disk encryption requires mdm enabled and configured - http.StatusUnprocessableEntity, + // disk encryption does not require mdm enabled and configured + http.StatusOK, }, + // Ian - this test still passes, that is, returns 4xx – perhaps related to one of the endpoints we still need to update { "enable end user auth", &fleet.TeamSpecMDM{ @@ -9935,11 +9911,11 @@ func (s *integrationMDMTestSuite) TestAPNsPushCron() { var recordedPushes []*mdm.Push var mu sync.Mutex - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { mu.Lock() defer mu.Unlock() recordedPushes = pushes - return mockSuccessfulPush(pushes) + return mockSuccessfulPush(ctx, pushes) } // trigger the reconciliation schedule diff --git a/server/service/linux_mdm.go b/server/service/linux_mdm.go new file mode 100644 index 0000000000..d4ae8da27e --- /dev/null +++ b/server/service/linux_mdm.go @@ -0,0 +1,44 @@ +package service + +import ( + "context" + + "github.com/fleetdm/fleet/v4/server/fleet" +) + +func (svc *Service) LinuxHostDiskEncryptionStatus(ctx context.Context, host fleet.Host) (fleet.HostMDMDiskEncryption, error) { + if !host.IsLUKSSupported() { + return fleet.HostMDMDiskEncryption{}, nil + } + + actionRequired := fleet.DiskEncryptionActionRequired + verified := fleet.DiskEncryptionVerified + failed := fleet.DiskEncryptionFailed + + key, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID) + if err != nil { + if fleet.IsNotFound(err) { + return fleet.HostMDMDiskEncryption{ + Status: &actionRequired, + }, nil + } + return fleet.HostMDMDiskEncryption{}, err + } + + if key.ClientError != "" { + return fleet.HostMDMDiskEncryption{ + Status: &failed, + Detail: key.ClientError, + }, nil + } + + if key.Base64Encrypted == "" { + return fleet.HostMDMDiskEncryption{ + Status: &actionRequired, + }, nil + } + + return fleet.HostMDMDiskEncryption{ + Status: &verified, + }, nil +} diff --git a/server/service/linux_mdm_test.go b/server/service/linux_mdm_test.go new file mode 100644 index 0000000000..05809eb4fc --- /dev/null +++ b/server/service/linux_mdm_test.go @@ -0,0 +1,118 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" +) + +func TestLinuxHostDiskEncryptionStatus(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + actionRequired := fleet.DiskEncryptionActionRequired + verified := fleet.DiskEncryptionVerified + failed := fleet.DiskEncryptionFailed + + testcases := []struct { + name string + host fleet.Host + keyExists bool + clientErrorExists bool + status fleet.HostMDMDiskEncryption + notFound bool + }{ + { + name: "no key", + host: fleet.Host{ID: 1, Platform: "ubuntu"}, + keyExists: false, + clientErrorExists: false, + status: fleet.HostMDMDiskEncryption{ + Status: &actionRequired, + }, + }, + { + name: "key exists", + host: fleet.Host{ID: 1, Platform: "ubuntu"}, + keyExists: true, + clientErrorExists: false, + status: fleet.HostMDMDiskEncryption{ + Status: &verified, + }, + }, + { + name: "key exists && client error", + host: fleet.Host{ID: 1, Platform: "ubuntu"}, + keyExists: true, + clientErrorExists: true, + status: fleet.HostMDMDiskEncryption{ + Status: &failed, + Detail: "client error", + }, + }, + { + name: "no key && client error", + host: fleet.Host{ID: 1, Platform: "ubuntu"}, + keyExists: false, + clientErrorExists: true, + status: fleet.HostMDMDiskEncryption{ + Status: &failed, + Detail: "client error", + }, + }, + { + name: "key not found", + host: fleet.Host{ID: 1, Platform: "ubuntu"}, + keyExists: false, + clientErrorExists: false, + status: fleet.HostMDMDiskEncryption{ + Status: &actionRequired, + }, + notFound: true, + }, + { + name: "unsupported platform", + host: fleet.Host{ID: 1, Platform: "amzn"}, + status: fleet.HostMDMDiskEncryption{}, + }, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { + var encrypted string + if tt.keyExists { + encrypted = "encrypted" + } + + var clientError string + if tt.clientErrorExists { + clientError = "client error" + } + + var nfe notFoundError + if tt.notFound { + return nil, &nfe + } + + return &fleet.HostDiskEncryptionKey{ + HostID: hostID, + Base64Encrypted: encrypted, + Decryptable: ptr.Bool(true), + UpdatedAt: time.Now(), + ClientError: clientError, + }, nil + } + + status, err := svc.LinuxHostDiskEncryptionStatus(ctx, tt.host) + assert.Nil(t, err) + + assert.Equal(t, tt.status, status) + }) + } +} diff --git a/server/service/mdm.go b/server/service/mdm.go index 92524ba1f1..12876b95dd 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2165,7 +2165,7 @@ func (svc *Service) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt // Update MDM Disk encryption //////////////////////////////////////////////////////////////////////////////// -type updateMDMDiskEncryptionRequest struct { +type updateDiskEncryptionRequest struct { TeamID *uint `json:"team_id"` EnableDiskEncryption bool `json:"enable_disk_encryption"` } @@ -2178,8 +2178,8 @@ func (r updateMDMDiskEncryptionResponse) error() error { return r.Err } func (r updateMDMDiskEncryptionResponse) Status() int { return http.StatusNoContent } -func updateMDMDiskEncryptionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - req := request.(*updateMDMDiskEncryptionRequest) +func updateDiskEncryptionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*updateDiskEncryptionRequest) if err := svc.UpdateMDMDiskEncryption(ctx, req.TeamID, &req.EnableDiskEncryption); err != nil { return updateMDMDiskEncryptionResponse{Err: err}, nil } @@ -2194,7 +2194,7 @@ func (svc *Service) UpdateMDMDiskEncryption(ctx context.Context, teamID *uint, e lic, _ := license.FromContext(ctx) if lic == nil || !lic.IsPremium() { svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden" - return ErrMissingLicense + return fleet.ErrMissingLicense } // for historical reasons (the deprecated PATCH /mdm/apple/settings diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 59ea63c8b4..e763cf6c05 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -606,6 +606,11 @@ func TestMDMCommonAuthorization(t *testing.T) { ds.GetMDMWindowsProfilesSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) { return &fleet.MDMProfilesSummary{}, nil } + + ds.GetLinuxDiskEncryptionSummaryFunc = func(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) { + return fleet.MDMLinuxDiskEncryptionSummary{}, nil + } + ds.AreHostsConnectedToFleetMDMFunc = func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) { res := make(map[string]bool, len(hosts)) for _, h := range hosts { @@ -874,6 +879,11 @@ func TestGetMDMDiskEncryptionSummary(t *testing.T) { return res, nil } + ds.GetLinuxDiskEncryptionSummaryFunc = func(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) { + require.Nil(t, teamID) + return fleet.MDMLinuxDiskEncryptionSummary{Verified: 1, ActionRequired: 2, Failed: 3}, nil + } + // Test that the summary properly combines the results of the two methods des, err := svc.GetMDMDiskEncryptionSummary(ctx, nil) require.NoError(t, err) @@ -882,6 +892,7 @@ func TestGetMDMDiskEncryptionSummary(t *testing.T) { Verified: fleet.MDMPlatformsCounts{ MacOS: 1, Windows: 7, + Linux: 1, }, Verifying: fleet.MDMPlatformsCounts{ MacOS: 2, @@ -890,10 +901,12 @@ func TestGetMDMDiskEncryptionSummary(t *testing.T) { ActionRequired: fleet.MDMPlatformsCounts{ MacOS: 3, Windows: 0, + Linux: 2, }, Failed: fleet.MDMPlatformsCounts{ MacOS: 4, Windows: 8, + Linux: 3, }, Enforcing: fleet.MDMPlatformsCounts{ MacOS: 5, diff --git a/server/service/mock/service_push_provider.go b/server/service/mock/service_push_provider.go index 8ddc84a9dd..159b643c0c 100644 --- a/server/service/mock/service_push_provider.go +++ b/server/service/mock/service_push_provider.go @@ -3,6 +3,7 @@ package mock import ( + "context" "sync" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" @@ -11,7 +12,7 @@ import ( var _ push.PushProvider = (*APNSPushProvider)(nil) -type PushFunc func(p0 []*mdm.Push) (map[string]*push.Response, error) +type PushFunc func(p0 context.Context, p1 []*mdm.Push) (map[string]*push.Response, error) type APNSPushProvider struct { PushFunc PushFunc @@ -20,9 +21,9 @@ type APNSPushProvider struct { mu sync.Mutex } -func (s *APNSPushProvider) Push(p0 []*mdm.Push) (map[string]*push.Response, error) { +func (s *APNSPushProvider) Push(p0 context.Context, p1 []*mdm.Push) (map[string]*push.Response, error) { s.mu.Lock() s.PushFuncInvoked = true s.mu.Unlock() - return s.PushFunc(p0) + return s.PushFunc(p0, p1) } diff --git a/server/service/orbit.go b/server/service/orbit.go index e5b73e3e52..0a0e852bb3 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -16,6 +16,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/worker" @@ -41,6 +42,10 @@ type EnrollOrbitRequest struct { // OsqueryIdentifier holds the identifier used by osquery. // If not set, then the hardware UUID is used to match orbit and osquery. OsqueryIdentifier string `json:"osquery_identifier"` + // ComputerName is the device's friendly name (optional). + ComputerName string `json:"computer_name"` + // HardwareModel is the device's hardware model. + HardwareModel string `json:"hardware_model"` } type EnrollOrbitResponse struct { @@ -90,6 +95,8 @@ func enrollOrbitEndpoint(ctx context.Context, request interface{}, svc fleet.Ser Hostname: req.Hostname, Platform: req.Platform, OsqueryIdentifier: req.OsqueryIdentifier, + ComputerName: req.ComputerName, + HardwareModel: req.HardwareModel, }, req.EnrollSecret) if err != nil { return EnrollOrbitResponse{Err: err}, nil @@ -129,6 +136,8 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf "hostname", hostInfo.Hostname, "platform", hostInfo.Platform, "osquery_identifier", hostInfo.OsqueryIdentifier, + "computer_name", hostInfo.ComputerName, + "hardware_model", hostInfo.HardwareModel, ), level.Info, ) @@ -155,11 +164,22 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf return "", fleet.OrbitError{Message: "app config load failed: " + err.Error()} } - _, err = svc.ds.EnrollOrbit(ctx, appConfig.MDM.EnabledAndConfigured, hostInfo, orbitNodeKey, secret.TeamID) + host, err := svc.ds.EnrollOrbit(ctx, appConfig.MDM.EnabledAndConfigured, hostInfo, orbitNodeKey, secret.TeamID) if err != nil { return "", fleet.OrbitError{Message: "failed to enroll " + err.Error()} } + if err := svc.NewActivity( + ctx, + nil, + fleet.ActivityTypeFleetEnrolled{ + HostSerial: hostInfo.HardwareSerial, + HostDisplayName: host.DisplayName(), + }, + ); err != nil { + level.Error(svc.logger).Log("msg", "record fleet enroll activity", "err", err) + } + return orbitNodeKey, nil } @@ -268,6 +288,9 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } } + notifs.RunDiskEncryptionEscrow = host.IsLUKSSupported() && + host.DiskEncryptionEnabled != nil && *host.DiskEncryptionEnabled && svc.ds.IsHostPendingEscrow(ctx, host.ID) + pendingInstalls, err := svc.ds.ListPendingSoftwareInstalls(ctx, host.ID) if err != nil { return fleet.OrbitConfig{}, err @@ -349,6 +372,11 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro updateChannels = &uc } + // only unset this flag once we know there were no errors so this notification will be picked up by the agent + if notifs.RunDiskEncryptionEscrow { + _ = svc.ds.ClearPendingEscrow(ctx, host.ID) + } + return fleet.OrbitConfig{ ScriptExeTimeout: opts.ScriptExecutionTimeout, Flags: opts.CommandLineStartUpFlags, @@ -419,6 +447,11 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro updateChannels = &uc } + // only unset this flag once we know there were no errors so this notification will be picked up by the agent + if notifs.RunDiskEncryptionEscrow { + _ = svc.ds.ClearPendingEscrow(ctx, host.ID) + } + return fleet.OrbitConfig{ ScriptExeTimeout: opts.ScriptExecutionTimeout, Flags: opts.CommandLineStartUpFlags, @@ -804,11 +837,20 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host } } var scriptName string - if hsr.ScriptID != nil { + + switch { + case hsr.ScriptID != nil: scr, err := svc.ds.Script(ctx, *hsr.ScriptID) if err != nil { return ctxerr.Wrap(ctx, err, "get saved script") } + scriptName = scr.Name + case hsr.SetupExperienceScriptID != nil: + scr, err := svc.ds.GetSetupExperienceScriptByID(ctx, *hsr.SetupExperienceScriptID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get setup experience script") + } + scriptName = scr.Name } @@ -985,6 +1027,85 @@ func (svc *Service) SetOrUpdateDiskEncryptionKey(ctx context.Context, encryption return nil } +///////////////////////////////////////////////////////////////////////////////// +// Post Orbit LUKS (Linux disk encryption) data +///////////////////////////////////////////////////////////////////////////////// + +type orbitPostLUKSRequest struct { + OrbitNodeKey string `json:"orbit_node_key"` + Passphrase string `json:"passphrase"` + Salt string `json:"salt"` + KeySlot *uint `json:"key_slot"` + ClientError string `json:"client_error"` +} + +// interface implementation required by the OrbitClient +func (r *orbitPostLUKSRequest) setOrbitNodeKey(nodeKey string) { + r.OrbitNodeKey = nodeKey +} + +// interface implementation required by orbit authentication +func (r *orbitPostLUKSRequest) orbitHostNodeKey() string { + return r.OrbitNodeKey +} + +type orbitPostLUKSResponse struct { + Err error `json:"error,omitempty"` +} + +func (r orbitPostLUKSResponse) error() error { return r.Err } +func (r orbitPostLUKSResponse) Status() int { return http.StatusNoContent } + +func postOrbitLUKSEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*orbitPostLUKSRequest) + if err := svc.EscrowLUKSData(ctx, req.Passphrase, req.Salt, req.KeySlot, req.ClientError); err != nil { + return orbitPostLUKSResponse{Err: err}, nil + } + return orbitPostLUKSResponse{}, nil +} + +func (svc *Service) EscrowLUKSData(ctx context.Context, passphrase string, salt string, keySlot *uint, clientError string) error { + // this is not a user-authenticated endpoint + svc.authz.SkipAuthorization(ctx) + + host, ok := hostctx.FromContext(ctx) + if !ok { + return newOsqueryError("internal error: missing host from request context") + } + + if clientError != "" { + return svc.ds.ReportEscrowError(ctx, host.ID, clientError) + } + + encryptedPassphrase, encryptedSalt, validatedKeySlot, err := svc.validateAndEncrypt(ctx, passphrase, salt, keySlot) + if err != nil { + _ = svc.ds.ReportEscrowError(ctx, host.ID, err.Error()) + return err + } + + return svc.ds.SaveLUKSData(ctx, host.ID, encryptedPassphrase, encryptedSalt, validatedKeySlot) +} + +func (svc *Service) validateAndEncrypt(ctx context.Context, passphrase string, salt string, keySlot *uint) (encryptedPassphrase string, encryptedSalt string, validatedKeySlot uint, err error) { + if passphrase == "" || salt == "" || keySlot == nil { + return "", "", 0, badRequest("passphrase, salt, and key_slot must be provided to escrow LUKS data") + } + if svc.config.Server.PrivateKey == "" { + return "", "", 0, newOsqueryError("internal error: missing server private key") + } + + encryptedPassphrase, err = mdm.EncryptAndEncode(passphrase, svc.config.Server.PrivateKey) + if err != nil { + return "", "", 0, ctxerr.Wrap(ctx, err, "internal error: could not encrypt LUKS data") + } + encryptedSalt, err = mdm.EncryptAndEncode(salt, svc.config.Server.PrivateKey) + if err != nil { + return "", "", 0, ctxerr.Wrap(ctx, err, "internal error: could not encrypt LUKS data") + } + + return encryptedPassphrase, encryptedSalt, *keySlot, nil +} + ///////////////////////////////////////////////////////////////////////////////// // Get Orbit pending software installations ///////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index 4c13f07db5..912d47c86f 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -22,6 +22,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/orbit/pkg/logging" + "github.com/fleetdm/fleet/v4/orbit/pkg/luks" "github.com/fleetdm/fleet/v4/orbit/pkg/platform" "github.com/fleetdm/fleet/v4/pkg/retry" "github.com/fleetdm/fleet/v4/server/fleet" @@ -456,6 +457,8 @@ func (oc *OrbitClient) enroll() (string, error) { Hostname: oc.hostInfo.Hostname, Platform: oc.hostInfo.Platform, OsqueryIdentifier: oc.hostInfo.OsqueryIdentifier, + ComputerName: oc.hostInfo.ComputerName, + HardwareModel: oc.hostInfo.HardwareModel, } var resp EnrollOrbitResponse err := oc.request(verb, path, params, &resp) @@ -666,3 +669,18 @@ func (oc *OrbitClient) GetSetupExperienceStatus() (*fleet.SetupExperienceStatusP return resp.Results, nil } + +func (oc *OrbitClient) SendLinuxKeyEscrowResponse(lr luks.LuksResponse) error { + verb, path := "POST", "/api/fleet/orbit/luks_data" + var resp orbitPostLUKSResponse + if err := oc.authenticatedRequest(verb, path, &orbitPostLUKSRequest{ + Passphrase: lr.Passphrase, + KeySlot: lr.KeySlot, + Salt: lr.Salt, + ClientError: lr.Err, + }, &resp); err != nil { + return err + } + + return nil +} diff --git a/server/service/orbit_test.go b/server/service/orbit_test.go index 992d8b057c..3dc98b64aa 100644 --- a/server/service/orbit_test.go +++ b/server/service/orbit_test.go @@ -4,16 +4,268 @@ import ( "context" "database/sql" "encoding/json" + "errors" "testing" "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/stretchr/testify/require" ) +func TestGetOrbitConfigLinuxEscrow(t *testing.T) { + t.Run("don't check for pending escrow if unsupported platform or encryption is not enabled", func(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + os := &fleet.OperatingSystem{ + Platform: "rhel", + Version: "9.0", + } + host := &fleet.Host{ + OsqueryHostID: ptr.String("test"), + ID: 1, + OSVersion: "Red Hat Enterprise Linux 9.0", + Platform: "rhel", + } + + team := fleet.Team{ID: 1} + teamMDM := fleet.TeamMDM{EnableDiskEncryption: true} + ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) { + require.Equal(t, team.ID, teamID) + return &teamMDM, nil + } + ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) { + return ptr.RawMessage(json.RawMessage(`{}`)), nil + } + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { + return nil, nil + } + ds.ListPendingSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) { + return nil, nil + } + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return true, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return nil, nil + } + + appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return appCfg, nil + } + ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) { + return os, nil + } + + ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) { + return false, nil + } + + ctx = test.HostContext(ctx, host) + + cfg, err := svc.GetOrbitConfig(ctx) + require.NoError(t, err) + require.False(t, cfg.Notifications.RunDiskEncryptionEscrow) + + host.OSVersion = "Fedora 38.0" + cfg, err = svc.GetOrbitConfig(ctx) + require.NoError(t, err) + require.False(t, cfg.Notifications.RunDiskEncryptionEscrow) + }) + + t.Run("pending escrow sets config flag and clears in DB", func(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + os := &fleet.OperatingSystem{ + Platform: "ubuntu", + Version: "20.04", + } + host := &fleet.Host{ + OsqueryHostID: ptr.String("test"), + ID: 1, + OSVersion: "Ubuntu 20.04", + Platform: "ubuntu", + DiskEncryptionEnabled: ptr.Bool(true), + } + + team := fleet.Team{ID: 1} + teamMDM := fleet.TeamMDM{EnableDiskEncryption: true} + ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) { + require.Equal(t, team.ID, teamID) + return &teamMDM, nil + } + ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) { + return ptr.RawMessage(json.RawMessage(`{}`)), nil + } + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { + return nil, nil + } + ds.ListPendingSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) { + return nil, nil + } + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return true, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return nil, nil + } + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return true + } + ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error { + return nil + } + + appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return appCfg, nil + } + ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) { + return os, nil + } + + ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) { + return false, nil + } + + ctx = test.HostContext(ctx, host) + + // no-team + cfg, err := svc.GetOrbitConfig(ctx) + require.NoError(t, err) + require.True(t, cfg.Notifications.RunDiskEncryptionEscrow) + require.True(t, ds.ClearPendingEscrowFuncInvoked) + + // with team + ds.ClearPendingEscrowFuncInvoked = false + host.TeamID = ptr.Uint(team.ID) + cfg, err = svc.GetOrbitConfig(ctx) + require.NoError(t, err) + require.True(t, cfg.Notifications.RunDiskEncryptionEscrow) + require.True(t, ds.ClearPendingEscrowFuncInvoked) + + // ignore clear escrow errors + ds.ClearPendingEscrowFuncInvoked = false + ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error { + return errors.New("clear pending escrow") + } + cfg, err = svc.GetOrbitConfig(ctx) + require.NoError(t, err) + require.True(t, cfg.Notifications.RunDiskEncryptionEscrow) + require.True(t, ds.ClearPendingEscrowFuncInvoked) + }) +} + +func TestOrbitLUKSDataSave(t *testing.T) { + t.Run("when private key is set", func(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + host := &fleet.Host{ + OsqueryHostID: ptr.String("test"), + ID: 1, + } + ctx = test.HostContext(ctx, host) + expectedErrorMessage := "There was an error." + ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error { + require.Equal(t, expectedErrorMessage, err) + return nil + } + + // test reporting client errors + err := svc.EscrowLUKSData(ctx, "foo", "bar", nil, expectedErrorMessage) + require.NoError(t, err) + require.True(t, ds.ReportEscrowErrorFuncInvoked) + + // blank passphrase + ds.ReportEscrowErrorFuncInvoked = false + expectedErrorMessage = "passphrase, salt, and key_slot must be provided to escrow LUKS data" + err = svc.EscrowLUKSData(ctx, "", "bar", ptr.Uint(0), "") + require.Error(t, err) + require.True(t, ds.ReportEscrowErrorFuncInvoked) + + ds.ReportEscrowErrorFuncInvoked = false + passphrase, salt := "foo", "" + var keySlot *uint + ds.SaveLUKSDataFunc = func(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlotToPersist uint) error { + require.Equal(t, host.ID, hostID) + key := config.TestConfig().Server.PrivateKey + + decryptedPassphrase, err := mdm.DecodeAndDecrypt(encryptedBase64Passphrase, key) + require.NoError(t, err) + require.Equal(t, passphrase, decryptedPassphrase) + + decryptedSalt, err := mdm.DecodeAndDecrypt(encryptedBase64Salt, key) + require.NoError(t, err) + require.Equal(t, salt, decryptedSalt) + + require.Equal(t, *keySlot, keySlotToPersist) + + return nil + } + + // with no salt + err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "") + require.Error(t, err) + require.True(t, ds.ReportEscrowErrorFuncInvoked) + require.False(t, ds.SaveLUKSDataFuncInvoked) + + // with no key slot + ds.ReportEscrowErrorFuncInvoked = false + salt = "baz" + err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "") + require.Error(t, err) + require.True(t, ds.ReportEscrowErrorFuncInvoked) + require.False(t, ds.SaveLUKSDataFuncInvoked) + + // with salt and key slot + keySlot = ptr.Uint(0) + ds.ReportEscrowErrorFuncInvoked = false + err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "") + require.NoError(t, err) + require.False(t, ds.ReportEscrowErrorFuncInvoked) + require.True(t, ds.SaveLUKSDataFuncInvoked) + }) + + t.Run("fail when no/invalid private key is set", func(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + host := &fleet.Host{ + OsqueryHostID: ptr.String("test"), + ID: 1, + } + expectedErrorMessage := "internal error: missing server private key" + ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error { + require.Equal(t, expectedErrorMessage, err) + return nil + } + + cfg := config.TestConfig() + cfg.Server.PrivateKey = "" + svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + ctx = test.HostContext(ctx, host) + err := svc.EscrowLUKSData(ctx, "foo", "bar", ptr.Uint(0), "") + require.Error(t, err) + require.True(t, ds.ReportEscrowErrorFuncInvoked) + + expectedErrorMessage = "internal error: could not encrypt LUKS data: create new cipher: crypto/aes: invalid key size 7" + ds.ReportEscrowErrorFuncInvoked = false + cfg.Server.PrivateKey = "invalid" + svc, ctx = newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + ctx = test.HostContext(ctx, host) + err = svc.EscrowLUKSData(ctx, "foo", "bar", ptr.Uint(0), "") + require.Error(t, err) + require.True(t, ds.ReportEscrowErrorFuncInvoked) + }) +} + func TestGetOrbitConfigNudge(t *testing.T) { t.Run("missing values in AppConfig", func(t *testing.T) { ds := new(mock.Store) @@ -39,6 +291,9 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { return true, nil } + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return false + } ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { return &fleet.HostMDM{ @@ -114,6 +369,9 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { return true, nil } + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return false + } ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { return &fleet.HostMDM{ @@ -161,7 +419,7 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.TeamMDMConfigFuncInvoked = false }) - t.Run("non-elegible MDM status", func(t *testing.T) { + t.Run("non-eligible MDM status", func(t *testing.T) { ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium} svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) @@ -207,6 +465,9 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) { return false, nil } + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return false + } checkEmptyNudgeConfig := func(h *fleet.Host) { ctx := test.HostContext(ctx, h) @@ -283,6 +544,9 @@ func TestGetOrbitConfigNudge(t *testing.T) { Name: fleet.WellKnownMDMFleet, }, nil } + ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool { + return false + } appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}} appCfg.MDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01") @@ -315,6 +579,7 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.GetHostOperatingSystemFuncInvoked = false cfg, err = svc.GetOrbitConfig(ctx) require.NoError(t, err) + require.False(t, cfg.Notifications.RunDiskEncryptionEscrow) require.Empty(t, cfg.NudgeConfig) require.True(t, ds.GetHostOperatingSystemFuncInvoked) diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 99a0851bdc..1aaee12e7f 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -653,7 +653,7 @@ func newMockAPNSPushProviderFactory() (*mock.APNSPushProviderFactory, *mock.APNS return factory, provider } -func mockSuccessfulPush(pushes []*mdm.Push) (map[string]*push.Response, error) { +func mockSuccessfulPush(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) { res := make(map[string]*push.Response, len(pushes)) for _, p := range pushes { res[p.Token.String()] = &push.Response{ @@ -684,18 +684,13 @@ func mdmConfigurationRequiredEndpoints() []struct { {"GET", "/api/latest/fleet/mdm/apple/profiles/1", false, false}, {"DELETE", "/api/latest/fleet/mdm/apple/profiles/1", false, false}, {"GET", "/api/latest/fleet/mdm/apple/profiles/summary", false, false}, - {"GET", "/api/latest/fleet/mdm/profiles/summary", false, false}, - {"GET", "/api/latest/fleet/configuration_profiles/summary", false, false}, {"PATCH", "/api/latest/fleet/mdm/hosts/1/unenroll", false, false}, {"DELETE", "/api/latest/fleet/hosts/1/mdm", false, false}, - {"GET", "/api/latest/fleet/mdm/hosts/1/encryption_key", false, false}, - {"GET", "/api/latest/fleet/hosts/1/encryption_key", false, false}, {"GET", "/api/latest/fleet/mdm/hosts/1/profiles", false, true}, {"GET", "/api/latest/fleet/hosts/1/configuration_profiles", false, true}, {"POST", "/api/latest/fleet/mdm/hosts/1/lock", false, false}, {"POST", "/api/latest/fleet/mdm/hosts/1/wipe", false, false}, {"PATCH", "/api/latest/fleet/mdm/apple/settings", false, false}, - {"POST", "/api/latest/fleet/disk_encryption", false, false}, {"GET", "/api/latest/fleet/mdm/apple", false, false}, {"GET", "/api/latest/fleet/apns", false, false}, {"GET", apple_mdm.EnrollPath + "?token=test", false, false}, @@ -725,8 +720,6 @@ func mdmConfigurationRequiredEndpoints() []struct { {"GET", "/api/latest/fleet/mdm/commands", false, false}, {"GET", "/api/latest/fleet/commands", false, false}, {"POST", "/api/fleet/orbit/disk_encryption_key", false, false}, - {"GET", "/api/latest/fleet/mdm/disk_encryption/summary", false, true}, - {"GET", "/api/latest/fleet/disk_encryption", false, true}, {"GET", "/api/latest/fleet/mdm/profiles/1", false, false}, {"GET", "/api/latest/fleet/configuration_profiles/1", false, false}, {"DELETE", "/api/latest/fleet/mdm/profiles/1", false, false}, diff --git a/server/test/new_objects.go b/server/test/new_objects.go index 8a285783e5..f56496faea 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -217,6 +217,12 @@ func WithPlatform(s string) NewHostOption { } } +func WithOSVersion(s string) NewHostOption { + return func(h *fleet.Host) { + h.OSVersion = s + } +} + func WithTeamID(teamID uint) NewHostOption { return func(h *fleet.Host) { h.TeamID = &teamID diff --git a/server/vulnerabilities/nvd/tools/README.md b/server/vulnerabilities/nvd/tools/README.md index d5685d52c0..67dae52d6f 100644 --- a/server/vulnerabilities/nvd/tools/README.md +++ b/server/vulnerabilities/nvd/tools/README.md @@ -126,7 +126,7 @@ host2.foo.bar CVE-2017-8817 cpe:/a:haxx:curl:7.55.0 ### `csv2cpe` -*csv2cpe* is a tool that generates an URI-bound CPE from CSV input, flags configure the meaning of each input field: +*csv2cpe* is a tool that generates a URI-bound CPE from CSV input, flags configure the meaning of each input field: * `-cpe_part` -- identifies the class of a product: h for hardware, a for application and o for OS * `-cpe_vendor` -- identifies the person or organisation that manufactured or created the product diff --git a/terraform/README.md b/terraform/README.md index f8015f428c..243586d485 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -72,7 +72,7 @@ No resources. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [alb\_config](#input\_alb\_config) | n/a |
object({
name = optional(string, "fleet")
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 60)
})
| `{}` | no | +| [alb\_config](#input\_alb\_config) | n/a |
object({
name = optional(string, "fleet")
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 905)
})
| `{}` | no | | [certificate\_arn](#input\_certificate\_arn) | n/a | `string` | n/a | yes | | [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module |
object({
autoscaling_capacity_providers = optional(any, {})
cluster_configuration = optional(any, {
execute_command_configuration = {
logging = "OVERRIDE"
log_configuration = {
cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
}
}
})
cluster_name = optional(string, "fleet")
cluster_settings = optional(map(string), {
"name" : "containerInsights",
"value" : "enabled",
})
create = optional(bool, true)
default_capacity_provider_use_fargate = optional(bool, true)
fargate_capacity_providers = optional(any, {
FARGATE = {
default_capacity_provider_strategy = {
weight = 100
}
}
FARGATE_SPOT = {
default_capacity_provider_strategy = {
weight = 0
}
}
})
tags = optional(map(string))
})
|
{
"autoscaling_capacity_providers": {},
"cluster_configuration": {
"execute_command_configuration": {
"log_configuration": {
"cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
},
"logging": "OVERRIDE"
}
},
"cluster_name": "fleet",
"cluster_settings": {
"name": "containerInsights",
"value": "enabled"
},
"create": true,
"default_capacity_provider_use_fargate": true,
"fargate_capacity_providers": {
"FARGATE": {
"default_capacity_provider_strategy": {
"weight": 100
}
},
"FARGATE_SPOT": {
"default_capacity_provider_strategy": {
"weight": 0
}
}
},
"tags": {}
}
| no | | [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
object({
task_mem = optional(number, null)
task_cpu = optional(number, null)
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
image = optional(string, "fleetdm/fleet:v4.54.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
mount_points = optional(list(any), [])
volumes = optional(list(any), [])
extra_environment_variables = optional(map(string), {})
extra_iam_policies = optional(list(string), [])
extra_execution_iam_policies = optional(list(string), [])
extra_secrets = optional(map(string), {})
security_group_name = optional(string, "fleet")
iam_role_arn = optional(string, null)
repository_credentials = optional(string, "")
private_key_secret_name = optional(string, "fleet-server-private-key")
service = optional(object({
name = optional(string, "fleet")
}), {
name = "fleet"
})
database = optional(object({
password_secret_arn = string
user = string
database = string
address = string
rr_address = optional(string, null)
}), {
password_secret_arn = null
user = null
database = null
address = null
rr_address = null
})
redis = optional(object({
address = string
use_tls = optional(bool, true)
}), {
address = null
use_tls = true
})
awslogs = optional(object({
name = optional(string, null)
region = optional(string, null)
create = optional(bool, true)
prefix = optional(string, "fleet")
retention = optional(number, 5)
}), {
name = null
region = null
prefix = "fleet"
retention = 5
})
loadbalancer = optional(object({
arn = string
}), {
arn = null
})
extra_load_balancers = optional(list(any), [])
networking = optional(object({
subnets = optional(list(string), null)
security_groups = optional(list(string), null)
ingress_sources = optional(object({
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
prefix_list_ids = optional(list(string), [])
}), {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
})
}), {
subnets = null
security_groups = null
ingress_sources = {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
}
})
autoscaling = optional(object({
max_capacity = optional(number, 5)
min_capacity = optional(number, 1)
memory_tracking_target_value = optional(number, 80)
cpu_tracking_target_value = optional(number, 80)
}), {
max_capacity = 5
min_capacity = 1
memory_tracking_target_value = 80
cpu_tracking_target_value = 80
})
iam = optional(object({
role = optional(object({
name = optional(string, "fleet-role")
policy_name = optional(string, "fleet-iam-policy")
}), {
name = "fleet-role"
policy_name = "fleet-iam-policy"
})
execution = optional(object({
name = optional(string, "fleet-execution-role")
policy_name = optional(string, "fleet-execution-role")
}), {
name = "fleet-execution-role"
policy_name = "fleet-iam-policy-execution"
})
}), {
name = "fleetdm-execution-role"
})
software_installers = optional(object({
create_bucket = optional(bool, true)
bucket_name = optional(string, null)
bucket_prefix = optional(string, "fleet-software-installers-")
s3_object_prefix = optional(string, "")
}), {
create_bucket = true
bucket_name = null
bucket_prefix = "fleet-software-installers-"
s3_object_prefix = ""
})
})
|
{
"autoscaling": {
"cpu_tracking_target_value": 80,
"max_capacity": 5,
"memory_tracking_target_value": 80,
"min_capacity": 1
},
"awslogs": {
"create": true,
"name": null,
"prefix": "fleet",
"region": null,
"retention": 5
},
"cpu": 256,
"database": {
"address": null,
"database": null,
"password_secret_arn": null,
"rr_address": null,
"user": null
},
"depends_on": [],
"extra_environment_variables": {},
"extra_execution_iam_policies": [],
"extra_iam_policies": [],
"extra_load_balancers": [],
"extra_secrets": {},
"family": "fleet",
"iam": {
"execution": {
"name": "fleet-execution-role",
"policy_name": "fleet-iam-policy-execution"
},
"role": {
"name": "fleet-role",
"policy_name": "fleet-iam-policy"
}
},
"iam_role_arn": null,
"image": "fleetdm/fleet:v4.54.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"ingress_sources": {
"cidr_blocks": [],
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"security_groups": []
},
"security_groups": null,
"subnets": null
},
"pid_mode": null,
"private_key_secret_name": "fleet-server-private-key",
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"software_installers": {
"bucket_name": null,
"bucket_prefix": "fleet-software-installers-",
"create_bucket": true,
"s3_object_prefix": ""
},
"task_cpu": null,
"task_mem": null,
"volumes": []
}
| no | diff --git a/terraform/addons/mdmproxy/variables.tf b/terraform/addons/mdmproxy/variables.tf index cb3d924d25..cd743e0276 100644 --- a/terraform/addons/mdmproxy/variables.tf +++ b/terraform/addons/mdmproxy/variables.tf @@ -79,7 +79,7 @@ variable "alb_config" { extra_target_groups = optional(any, []) https_listener_rules = optional(any, []) tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01") - idle_timeout = optional(number, 60) + idle_timeout = optional(number, 905) }) } diff --git a/terraform/addons/vuln-processing/variables.tf b/terraform/addons/vuln-processing/variables.tf index 17947300da..b372a36ff8 100644 --- a/terraform/addons/vuln-processing/variables.tf +++ b/terraform/addons/vuln-processing/variables.tf @@ -24,7 +24,7 @@ variable "fleet_config" { vuln_processing_cpu = optional(number, 2048) vuln_data_stream_mem = optional(number, 1024) vuln_data_stream_cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.59.0") + image = optional(string, "fleetdm/fleet:v4.59.1") family = optional(string, "fleet-vuln-processing") sidecars = optional(list(any), []) extra_environment_variables = optional(map(string), {}) @@ -82,7 +82,7 @@ variable "fleet_config" { vuln_processing_cpu = 2048 vuln_data_stream_mem = 1024 vuln_data_stream_cpu = 512 - image = "fleetdm/fleet:v4.59.0" + image = "fleetdm/fleet:v4.59.1" family = "fleet-vuln-processing" sidecars = [] extra_environment_variables = {} diff --git a/terraform/byo-vpc/README.md b/terraform/byo-vpc/README.md index 16998b14b1..c40128a9da 100644 --- a/terraform/byo-vpc/README.md +++ b/terraform/byo-vpc/README.md @@ -31,7 +31,7 @@ No requirements. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [alb\_config](#input\_alb\_config) | n/a |
object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 60)
})
| n/a | yes | +| [alb\_config](#input\_alb\_config) | n/a |
object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 905)
})
| n/a | yes | | [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module |
object({
autoscaling_capacity_providers = optional(any, {})
cluster_configuration = optional(any, {
execute_command_configuration = {
logging = "OVERRIDE"
log_configuration = {
cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
}
}
})
cluster_name = optional(string, "fleet")
cluster_settings = optional(map(string), {
"name" : "containerInsights",
"value" : "enabled",
})
create = optional(bool, true)
default_capacity_provider_use_fargate = optional(bool, true)
fargate_capacity_providers = optional(any, {
FARGATE = {
default_capacity_provider_strategy = {
weight = 100
}
}
FARGATE_SPOT = {
default_capacity_provider_strategy = {
weight = 0
}
}
})
tags = optional(map(string))
})
|
{
"autoscaling_capacity_providers": {},
"cluster_configuration": {
"execute_command_configuration": {
"log_configuration": {
"cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
},
"logging": "OVERRIDE"
}
},
"cluster_name": "fleet",
"cluster_settings": {
"name": "containerInsights",
"value": "enabled"
},
"create": true,
"default_capacity_provider_use_fargate": true,
"fargate_capacity_providers": {
"FARGATE": {
"default_capacity_provider_strategy": {
"weight": 100
}
},
"FARGATE_SPOT": {
"default_capacity_provider_strategy": {
"weight": 0
}
}
},
"tags": {}
}
| no | | [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
object({
task_mem = optional(number, null)
task_cpu = optional(number, null)
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
image = optional(string, "fleetdm/fleet:v4.54.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
mount_points = optional(list(any), [])
volumes = optional(list(any), [])
extra_environment_variables = optional(map(string), {})
extra_iam_policies = optional(list(string), [])
extra_execution_iam_policies = optional(list(string), [])
extra_secrets = optional(map(string), {})
security_group_name = optional(string, "fleet")
iam_role_arn = optional(string, null)
repository_credentials = optional(string, "")
private_key_secret_name = optional(string, "fleet-server-private-key")
service = optional(object({
name = optional(string, "fleet")
}), {
name = "fleet"
})
database = optional(object({
password_secret_arn = string
user = string
database = string
address = string
rr_address = optional(string, null)
}), {
password_secret_arn = null
user = null
database = null
address = null
rr_address = null
})
redis = optional(object({
address = string
use_tls = optional(bool, true)
}), {
address = null
use_tls = true
})
awslogs = optional(object({
name = optional(string, null)
region = optional(string, null)
create = optional(bool, true)
prefix = optional(string, "fleet")
retention = optional(number, 5)
}), {
name = null
region = null
prefix = "fleet"
retention = 5
})
loadbalancer = optional(object({
arn = string
}), {
arn = null
})
extra_load_balancers = optional(list(any), [])
networking = optional(object({
subnets = optional(list(string), null)
security_groups = optional(list(string), null)
ingress_sources = optional(object({
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
prefix_list_ids = optional(list(string), [])
}), {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
})
}), {
subnets = null
security_groups = null
ingress_sources = {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
}
})
autoscaling = optional(object({
max_capacity = optional(number, 5)
min_capacity = optional(number, 1)
memory_tracking_target_value = optional(number, 80)
cpu_tracking_target_value = optional(number, 80)
}), {
max_capacity = 5
min_capacity = 1
memory_tracking_target_value = 80
cpu_tracking_target_value = 80
})
iam = optional(object({
role = optional(object({
name = optional(string, "fleet-role")
policy_name = optional(string, "fleet-iam-policy")
}), {
name = "fleet-role"
policy_name = "fleet-iam-policy"
})
execution = optional(object({
name = optional(string, "fleet-execution-role")
policy_name = optional(string, "fleet-execution-role")
}), {
name = "fleet-execution-role"
policy_name = "fleet-iam-policy-execution"
})
}), {
name = "fleetdm-execution-role"
})
software_installers = optional(object({
create_bucket = optional(bool, true)
bucket_name = optional(string, null)
bucket_prefix = optional(string, "fleet-software-installers-")
s3_object_prefix = optional(string, "")
}), {
create_bucket = true
bucket_name = null
bucket_prefix = "fleet-software-installers-"
s3_object_prefix = ""
})
})
|
{
"autoscaling": {
"cpu_tracking_target_value": 80,
"max_capacity": 5,
"memory_tracking_target_value": 80,
"min_capacity": 1
},
"awslogs": {
"create": true,
"name": null,
"prefix": "fleet",
"region": null,
"retention": 5
},
"cpu": 256,
"database": {
"address": null,
"database": null,
"password_secret_arn": null,
"rr_address": null,
"user": null
},
"depends_on": [],
"extra_environment_variables": {},
"extra_execution_iam_policies": [],
"extra_iam_policies": [],
"extra_load_balancers": [],
"extra_secrets": {},
"family": "fleet",
"iam": {
"execution": {
"name": "fleet-execution-role",
"policy_name": "fleet-iam-policy-execution"
},
"role": {
"name": "fleet-role",
"policy_name": "fleet-iam-policy"
}
},
"iam_role_arn": null,
"image": "fleetdm/fleet:v4.54.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"ingress_sources": {
"cidr_blocks": [],
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"security_groups": []
},
"security_groups": null,
"subnets": null
},
"pid_mode": null,
"private_key_secret_name": "fleet-server-private-key",
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"software_installers": {
"bucket_name": null,
"bucket_prefix": "fleet-software-installers-",
"create_bucket": true,
"s3_object_prefix": ""
},
"task_cpu": null,
"task_mem": null,
"volumes": []
}
| no | | [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. |
object({
mem = number
cpu = number
})
|
{
"cpu": 1024,
"mem": 2048
}
| no | diff --git a/terraform/byo-vpc/byo-db/README.md b/terraform/byo-vpc/byo-db/README.md index 60d1444489..ddc7c0e656 100644 --- a/terraform/byo-vpc/byo-db/README.md +++ b/terraform/byo-vpc/byo-db/README.md @@ -26,7 +26,7 @@ No requirements. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [alb\_config](#input\_alb\_config) | n/a |
object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 60)
})
| n/a | yes | +| [alb\_config](#input\_alb\_config) | n/a |
object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 905)
})
| n/a | yes | | [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module |
object({
autoscaling_capacity_providers = optional(any, {})
cluster_configuration = optional(any, {
execute_command_configuration = {
logging = "OVERRIDE"
log_configuration = {
cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
}
}
})
cluster_name = optional(string, "fleet")
cluster_settings = optional(map(string), {
"name" : "containerInsights",
"value" : "enabled",
})
create = optional(bool, true)
default_capacity_provider_use_fargate = optional(bool, true)
fargate_capacity_providers = optional(any, {
FARGATE = {
default_capacity_provider_strategy = {
weight = 100
}
}
FARGATE_SPOT = {
default_capacity_provider_strategy = {
weight = 0
}
}
})
tags = optional(map(string))
})
|
{
"autoscaling_capacity_providers": {},
"cluster_configuration": {
"execute_command_configuration": {
"log_configuration": {
"cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
},
"logging": "OVERRIDE"
}
},
"cluster_name": "fleet",
"cluster_settings": {
"name": "containerInsights",
"value": "enabled"
},
"create": true,
"default_capacity_provider_use_fargate": true,
"fargate_capacity_providers": {
"FARGATE": {
"default_capacity_provider_strategy": {
"weight": 100
}
},
"FARGATE_SPOT": {
"default_capacity_provider_strategy": {
"weight": 0
}
}
},
"tags": {}
}
| no | | [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
object({
task_mem = optional(number, null)
task_cpu = optional(number, null)
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
image = optional(string, "fleetdm/fleet:v4.54.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
mount_points = optional(list(any), [])
volumes = optional(list(any), [])
extra_environment_variables = optional(map(string), {})
extra_iam_policies = optional(list(string), [])
extra_execution_iam_policies = optional(list(string), [])
extra_secrets = optional(map(string), {})
security_group_name = optional(string, "fleet")
iam_role_arn = optional(string, null)
repository_credentials = optional(string, "")
private_key_secret_name = optional(string, "fleet-server-private-key")
service = optional(object({
name = optional(string, "fleet")
}), {
name = "fleet"
})
database = optional(object({
password_secret_arn = string
user = string
database = string
address = string
rr_address = optional(string, null)
}), {
password_secret_arn = null
user = null
database = null
address = null
rr_address = null
})
redis = optional(object({
address = string
use_tls = optional(bool, true)
}), {
address = null
use_tls = true
})
awslogs = optional(object({
name = optional(string, null)
region = optional(string, null)
create = optional(bool, true)
prefix = optional(string, "fleet")
retention = optional(number, 5)
}), {
name = null
region = null
prefix = "fleet"
retention = 5
})
loadbalancer = optional(object({
arn = string
}), {
arn = null
})
extra_load_balancers = optional(list(any), [])
networking = optional(object({
subnets = optional(list(string), null)
security_groups = optional(list(string), null)
ingress_sources = optional(object({
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
prefix_list_ids = optional(list(string), [])
}), {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
})
}), {
subnets = null
security_groups = null
ingress_sources = {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
}
})
autoscaling = optional(object({
max_capacity = optional(number, 5)
min_capacity = optional(number, 1)
memory_tracking_target_value = optional(number, 80)
cpu_tracking_target_value = optional(number, 80)
}), {
max_capacity = 5
min_capacity = 1
memory_tracking_target_value = 80
cpu_tracking_target_value = 80
})
iam = optional(object({
role = optional(object({
name = optional(string, "fleet-role")
policy_name = optional(string, "fleet-iam-policy")
}), {
name = "fleet-role"
policy_name = "fleet-iam-policy"
})
execution = optional(object({
name = optional(string, "fleet-execution-role")
policy_name = optional(string, "fleet-execution-role")
}), {
name = "fleet-execution-role"
policy_name = "fleet-iam-policy-execution"
})
}), {
name = "fleetdm-execution-role"
})
software_installers = optional(object({
create_bucket = optional(bool, true)
bucket_name = optional(string, null)
bucket_prefix = optional(string, "fleet-software-installers-")
s3_object_prefix = optional(string, "")
}), {
create_bucket = true
bucket_name = null
bucket_prefix = "fleet-software-installers-"
s3_object_prefix = ""
})
})
|
{
"autoscaling": {
"cpu_tracking_target_value": 80,
"max_capacity": 5,
"memory_tracking_target_value": 80,
"min_capacity": 1
},
"awslogs": {
"create": true,
"name": null,
"prefix": "fleet",
"region": null,
"retention": 5
},
"cpu": 256,
"database": {
"address": null,
"database": null,
"password_secret_arn": null,
"rr_address": null,
"user": null
},
"depends_on": [],
"extra_environment_variables": {},
"extra_execution_iam_policies": [],
"extra_iam_policies": [],
"extra_load_balancers": [],
"extra_secrets": {},
"family": "fleet",
"iam": {
"execution": {
"name": "fleet-execution-role",
"policy_name": "fleet-iam-policy-execution"
},
"role": {
"name": "fleet-role",
"policy_name": "fleet-iam-policy"
}
},
"iam_role_arn": null,
"image": "fleetdm/fleet:v4.54.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"ingress_sources": {
"cidr_blocks": [],
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"security_groups": []
},
"security_groups": null,
"subnets": null
},
"pid_mode": null,
"private_key_secret_name": "fleet-server-private-key",
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"software_installers": {
"bucket_name": null,
"bucket_prefix": "fleet-software-installers-",
"create_bucket": true,
"s3_object_prefix": ""
},
"task_cpu": null,
"task_mem": null,
"volumes": []
}
| no | | [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. |
object({
mem = number
cpu = number
})
|
{
"cpu": 1024,
"mem": 2048
}
| no | diff --git a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf index 049841c1f7..580e94cbf5 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf +++ b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf @@ -16,7 +16,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.59.0") + image = optional(string, "fleetdm/fleet:v4.59.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -119,7 +119,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.59.0" + image = "fleetdm/fleet:v4.59.1" family = "fleet" sidecars = [] depends_on = [] diff --git a/terraform/byo-vpc/byo-db/variables.tf b/terraform/byo-vpc/byo-db/variables.tf index 20040d516c..16c7d7a1e9 100644 --- a/terraform/byo-vpc/byo-db/variables.tf +++ b/terraform/byo-vpc/byo-db/variables.tf @@ -77,7 +77,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.59.0") + image = optional(string, "fleetdm/fleet:v4.59.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -205,7 +205,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.59.0" + image = "fleetdm/fleet:v4.59.1" family = "fleet" sidecars = [] depends_on = [] @@ -309,6 +309,6 @@ variable "alb_config" { extra_target_groups = optional(any, []) https_listener_rules = optional(any, []) tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01") - idle_timeout = optional(number, 60) + idle_timeout = optional(number, 905) }) } diff --git a/terraform/byo-vpc/example/main.tf b/terraform/byo-vpc/example/main.tf index 8b0eefb3be..855ab59f9f 100644 --- a/terraform/byo-vpc/example/main.tf +++ b/terraform/byo-vpc/example/main.tf @@ -17,7 +17,7 @@ provider "aws" { } locals { - fleet_image = "fleetdm/fleet:v4.59.0" + fleet_image = "fleetdm/fleet:v4.59.1" domain_name = "example.com" } diff --git a/terraform/byo-vpc/variables.tf b/terraform/byo-vpc/variables.tf index 593d3a390f..4c8e173387 100644 --- a/terraform/byo-vpc/variables.tf +++ b/terraform/byo-vpc/variables.tf @@ -170,7 +170,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.59.0") + image = optional(string, "fleetdm/fleet:v4.59.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -298,7 +298,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.59.0" + image = "fleetdm/fleet:v4.59.1" family = "fleet" sidecars = [] depends_on = [] @@ -402,6 +402,6 @@ variable "alb_config" { extra_target_groups = optional(any, []) https_listener_rules = optional(any, []) tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01") - idle_timeout = optional(number, 60) + idle_timeout = optional(number, 905) }) } diff --git a/terraform/example/main.tf b/terraform/example/main.tf index 8b92f669be..81ff3cd693 100644 --- a/terraform/example/main.tf +++ b/terraform/example/main.tf @@ -63,8 +63,8 @@ module "fleet" { fleet_config = { # To avoid pull-rate limiting from dockerhub, consider using our quay.io mirror - # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.59.0" - image = "fleetdm/fleet:v4.59.0" # override default to deploy the image you desire + # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.59.1" + image = "fleetdm/fleet:v4.59.1" # override default to deploy the image you desire # See https://fleetdm.com/docs/deploy/reference-architectures#aws for appropriate scaling # memory and cpu. autoscaling = { @@ -108,7 +108,7 @@ module "fleet" { alb_config = { # Script execution can run for up to 300s plus overhead. # Ensure the load balancer does not 5XX before we have results. - idle_timeout = 605 + idle_timeout = 905 } } diff --git a/terraform/variables.tf b/terraform/variables.tf index 34c6d7a1f5..6bb2f22317 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -218,7 +218,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.59.0") + image = optional(string, "fleetdm/fleet:v4.59.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -346,7 +346,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.59.0" + image = "fleetdm/fleet:v4.59.1" family = "fleet" sidecars = [] depends_on = [] @@ -448,7 +448,7 @@ variable "alb_config" { extra_target_groups = optional(any, []) https_listener_rules = optional(any, []) tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01") - idle_timeout = optional(number, 60) + idle_timeout = optional(number, 905) }) default = {} } diff --git a/tools/apm-elastic/README.md b/tools/apm-elastic/README.md index 7d29e7aa8a..2f0aca4429 100644 --- a/tools/apm-elastic/README.md +++ b/tools/apm-elastic/README.md @@ -5,8 +5,8 @@ To setup a full Elastic APM stack, from this directory, run: ``` -$ docker-compose up -d -$ docker-compose exec apm-server ./apm-server setup +$ docker compose up -d +$ docker compose exec apm-server ./apm-server setup ``` Give it a few seconds to complete setup, and then you should be able to view the APM website at `http://localhost:5601`. diff --git a/tools/apm-elastic/docker-compose.yml b/tools/apm-elastic/docker-compose.yml index 9caf3a042a..ee8a20700c 100644 --- a/tools/apm-elastic/docker-compose.yml +++ b/tools/apm-elastic/docker-compose.yml @@ -18,14 +18,14 @@ version: "3" services: elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.8.0 + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.25 ports: - "9200:9200" - "9300:9300" environment: - discovery.type=single-node kibana: - image: docker.elastic.co/kibana/kibana:7.8.0 + image: docker.elastic.co/kibana/kibana:7.17.25 ports: - "5601:5601" links: @@ -33,7 +33,7 @@ services: depends_on: - elasticsearch apm-server: - image: docker.elastic.co/apm/apm-server:7.8.0 + image: docker.elastic.co/apm/apm-server:7.17.25 ports: - "8200:8200" volumes: diff --git a/tools/dialog/main.go b/tools/dialog/main.go new file mode 100644 index 0000000000..23e46da66c --- /dev/null +++ b/tools/dialog/main.go @@ -0,0 +1,55 @@ +package main + +// This is a tool to test the zenity package on Linux +// It will show an entry dialog, a progress dialog, and an info dialog + +import ( + "context" + "fmt" + "time" + + "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" + "github.com/fleetdm/fleet/v4/orbit/pkg/zenity" +) + +func main() { + prompt := zenity.New() + ctx := context.Background() + + output, err := prompt.ShowEntry(ctx, dialog.EntryOptions{ + Title: "Zenity Test Entry Title", + Text: "Zenity Test Entry Text", + HideText: true, + TimeOut: 10 * time.Second, + }) + if err != nil { + fmt.Println("Err ShowEntry") + panic(err) + } + + ctx, cancelProgress := context.WithCancel(context.Background()) + + go func() { + err := prompt.ShowProgress(ctx, dialog.ProgressOptions{ + Title: "Zenity Test Progress Title", + Text: "Zenity Test Progress Text", + }) + if err != nil { + fmt.Println("Err ShowProgress") + panic(err) + } + }() + + time.Sleep(2 * time.Second) + cancelProgress() + + err = prompt.ShowInfo(ctx, dialog.InfoOptions{ + Title: "Zenity Test Info Title", + Text: "Result: " + string(output), + TimeOut: 10 * time.Second, + }) + if err != nil { + fmt.Println("Err ShowInfo") + panic(err) + } +} diff --git a/tools/fleetctl-npm/package.json b/tools/fleetctl-npm/package.json index 4832a3f837..d9d07156ca 100644 --- a/tools/fleetctl-npm/package.json +++ b/tools/fleetctl-npm/package.json @@ -1,6 +1,6 @@ { "name": "fleetctl", - "version": "v4.59.0", + "version": "v4.59.1", "description": "Installer for the fleetctl CLI tool", "bin": { "fleetctl": "./run.js" diff --git a/tools/luks/luks/main.go b/tools/luks/luks/main.go new file mode 100644 index 0000000000..f20c28f4e2 --- /dev/null +++ b/tools/luks/luks/main.go @@ -0,0 +1,72 @@ +//go:build linux + +package main + +import ( + "context" + "errors" + "fmt" + + "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" + "github.com/fleetdm/fleet/v4/orbit/pkg/lvm" + "github.com/fleetdm/fleet/v4/orbit/pkg/zenity" + "github.com/siderolabs/go-blockdevice/v2/encryption" + "github.com/siderolabs/go-blockdevice/v2/encryption/luks" +) + +func main() { + devicePath, err := lvm.FindRootDisk() + if err != nil { + fmt.Println("devicepath err:", err) + panic(err) + } + + prompt := zenity.New() + + // Prompt existing passphrase from the user. + currentPassphrase, err := prompt.ShowEntry(context.Background(), dialog.EntryOptions{ + Title: "Enter Existing LUKS Passphrase", + Text: "Enter your existing LUKS passphrase:", + HideText: true, + }) + if err != nil { + fmt.Println("Err ShowEntry") + panic(err) + } + + const escrowPassPhrase = "fleet123" + + device := luks.New(luks.AESXTSPlain64Cipher) + + keySlot := 1 + for { + if keySlot == 8 { + panic(errors.New("all LUKS key slots are full")) + } + + userKey := encryption.NewKey(0, currentPassphrase) + escrowKey := encryption.NewKey(keySlot, []byte(escrowPassPhrase)) + + if err := device.AddKey(context.Background(), devicePath, userKey, escrowKey); err != nil { + if errors.Is(err, encryption.ErrEncryptionKeyRejected) { + currentPassphrase, err = prompt.ShowEntry(context.Background(), dialog.EntryOptions{ + Title: "Enter Existing LUKS Passphrase", + Text: "Bad password. Enter your existing LUKS passphrase:", + HideText: true, + }) + if err != nil { + fmt.Println("Err Retry ShowEntry") + panic(err) + } + continue + } + + keySlot++ + continue + } + + break + } + + fmt.Println("Key escrowed successfully.") +} diff --git a/tools/luks/lvm/main.go b/tools/luks/lvm/main.go new file mode 100644 index 0000000000..de6cbe1431 --- /dev/null +++ b/tools/luks/lvm/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + + "github.com/fleetdm/fleet/v4/orbit/pkg/lvm" +) + +func main() { + disk, err := lvm.FindRootDisk() + if err != nil { + panic(err) + } + fmt.Println("Root Partition:", disk) +} diff --git a/tools/osquery/README.md b/tools/osquery/README.md index 4f90f19df8..bed074d556 100644 --- a/tools/osquery/README.md +++ b/tools/osquery/README.md @@ -81,7 +81,7 @@ docker-compose rm We have had no trouble running up to 100 containerized osqueryd instances on a single processor core and about 1GB of RAM. -### Generating a osqueryd core file +### Generating an osqueryd core file The docker containers are configured to allow core files to be generated if osqueryd crashes for some reason. You can attach to the container hosting the errant osqueryd diff --git a/tools/release/README.md b/tools/release/README.md index 5f21ad47a6..99b89edb0d 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -116,9 +116,7 @@ Go update osquery-slack version # Publish patch ./tools/release/publish_release.sh -u -# Make sure to wait for the CLI to open NPM to publish fleetctl. -# If that fails, manually publish by going to the `/tools/fleetctl-npm/` directory -# and running `npm publish` - -# Go update osquery-slack version +- Make sure to wait for the CLI to open NPM to publish fleetctl. +- If that fails, manually publish by going to the `/tools/fleetctl-npm/` directory and running `npm publish` +- Go update osquery-slack version ``` diff --git a/tools/tuf/test/README.md b/tools/tuf/test/README.md index dd9dd4c332..e22b5642e2 100644 --- a/tools/tuf/test/README.md +++ b/tools/tuf/test/README.md @@ -96,7 +96,7 @@ GOOS=windows GOARCH=amd64 go build -o orbit-windows.exe ./orbit/cmd/orbit ./tools/tuf/test/push_target.sh windows orbit orbit-windows.exe 43 ``` -If the script was executed on a macOS host, the Orbit binary will be an universal binary. To push updates you can do: +If the script was executed on a macOS host, the Orbit binary will be a universal binary. To push updates you can do: ```sh # Compile a universal binary of Orbit: diff --git a/website/api/controllers/get-est-device-certificate.js b/website/api/controllers/get-est-device-certificate.js index a69e83d0eb..5a69979f2f 100644 --- a/website/api/controllers/get-est-device-certificate.js +++ b/website/api/controllers/get-est-device-certificate.js @@ -83,11 +83,11 @@ module.exports = { throw 'invalidToken'; } - if (!introspectResponse.body.active) { + const introspectBody = JSON.parse(introspectResponse.body); + if (!introspectBody.active) { throw 'invalidToken'; } - - const introspectUsername = introspectResponse.body.username; + const introspectUsername = introspectBody.username; // Extract the email and username from the CSR. Ensure they match. let jsrsasign = require('jsrsasign'); diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index 7e1f33c5ac..7854159b65 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -22,8 +22,8 @@ module.exports = { } // Get testimonials for the component. let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials); - // Default the pagePersonalization to the user's primaryBuyingSituation. - let pagePersonalization = this.req.session.primaryBuyingSituation; + // Default the pagePersonalization to the user's primaryBuyingSituation if it is set, otherwise, default to the eo-it view.. + let pagePersonalization = this.req.session.primaryBuyingSituation ? this.req.session.primaryBuyingSituation : 'eo-it'; // If a purpose query parameter is set, update the pagePersonalization value. // Note: This is the only page we're using this method instead of using the primaryBuyingSiutation value set in the users session. // This lets us link to the security and IT versions of the endpoint ops page from the unpersonalized homepage without changing the users primaryBuyingSituation. diff --git a/website/api/controllers/view-vulnerability-management.js b/website/api/controllers/view-software-management.js similarity index 90% rename from website/api/controllers/view-vulnerability-management.js rename to website/api/controllers/view-software-management.js index 2a9c2ae04c..b95b292b45 100644 --- a/website/api/controllers/view-vulnerability-management.js +++ b/website/api/controllers/view-software-management.js @@ -1,16 +1,16 @@ module.exports = { - friendlyName: 'View vulnerability-management', + friendlyName: 'View software-management', - description: 'Display "Vulnerability management" page.', + description: 'Display "Software management" page.', exits: { success: { - viewTemplatePath: 'pages/vulnerability-management' + viewTemplatePath: 'pages/software-management' }, badConfig: { responseType: 'badConfig' }, }, diff --git a/website/api/controllers/view-testimonials.js b/website/api/controllers/view-testimonials.js index dfd040ccf5..5365a312d8 100644 --- a/website/api/controllers/view-testimonials.js +++ b/website/api/controllers/view-testimonials.js @@ -28,12 +28,82 @@ module.exports = { let testimonialsForMdm = _.filter(testimonials, (testimonial)=>{ return _.contains(testimonial.productCategories, 'Device management'); }); + let testimonialOrderForMdm = [ + 'Scott MacVicar', + 'Wes Whetstone', + 'Nick Fohs', + 'Erik Gomez', + 'Matt Carr', + 'Nico Waisman', + 'Kenny Botelho', + 'Dan Grzelak', + 'Eric Tan', + ]; + testimonialsForMdm.sort((a, b)=>{ + if(testimonialOrderForMdm.indexOf(a.quoteAuthorName) === -1){ + return 1; + } else if(testimonialOrderForMdm.indexOf(b.quoteAuthorName) === -1) { + return -1; + } + return testimonialOrderForMdm.indexOf(a.quoteAuthorName) - testimonialOrderForMdm.indexOf(b.quoteAuthorName); + }); let testimonialsForSecurityEngineering = _.filter(testimonials, (testimonial)=>{ return _.contains(testimonial.productCategories, 'Vulnerability management'); }); + let testimonialOrderForSecurityEngineering = [ + 'Nico Waisman', + 'Austin Anderson', + 'Chandra Majumdar', + 'Andre Shields', + 'Dan Grzelak', + 'Charles Zaffery', + 'Erik Gomez', + 'Nick Fohs', + 'Dhruv Majumdar', + 'Arsenio Figueroa', + ]; + testimonialsForSecurityEngineering.sort((a, b)=>{ + if(testimonialOrderForSecurityEngineering.indexOf(a.quoteAuthorName) === -1){ + return 1; + } else if(testimonialOrderForSecurityEngineering.indexOf(b.quoteAuthorName) === -1) { + return -1; + } + return testimonialOrderForSecurityEngineering.indexOf(a.quoteAuthorName) - testimonialOrderForSecurityEngineering.indexOf(b.quoteAuthorName); + }); let testimonialsForItEngineering = _.filter(testimonials, (testimonial)=>{ return _.contains(testimonial.productCategories, 'Endpoint operations'); }); + let testimonialOrderForItEngineering = [ + 'Charles Zaffery', + 'Nico Waisman', + 'Erik Gomez', + 'Mike Arpaia', + 'Ahmed Elshaer', + 'Kenny Botelho', + 'Alvaro Gutierrez', + 'Tom Larkin', + 'Nick Fohs', + 'charles zaffery',// Note: This testimonial's quoteAuthorName value is lowercased so it can be sorted to a different position than the other Charles Zaffery quote. + 'Andre Shields', + 'Abubakar Yousafzai', + 'Chandra Majumdar', + 'Joe Pistone', + 'Dan Grzelak', + 'Austin Anderson', + 'Brendan Shaklovitz', + 'Dhruv Majumdar', + 'Wes Whetstone', + 'Eric Tan', + 'Arsenio Figueroa', + ]; + testimonialsForItEngineering.sort((a, b)=>{ + if(testimonialOrderForItEngineering.indexOf(a.quoteAuthorName) === -1){ + return 1; + } else if(testimonialOrderForItEngineering.indexOf(b.quoteAuthorName) === -1) { + return -1; + } + return testimonialOrderForItEngineering.indexOf(a.quoteAuthorName) - testimonialOrderForItEngineering.indexOf(b.quoteAuthorName); + }); let testimonialsWithVideoLinks = _.filter(testimonials, (testimonial)=>{ return testimonial.youtubeVideoUrl; }); diff --git a/website/api/hooks/custom/index.js b/website/api/hooks/custom/index.js index 0c421415d2..1e6d150087 100644 --- a/website/api/hooks/custom/index.js +++ b/website/api/hooks/custom/index.js @@ -325,6 +325,7 @@ will be disabled and/or hidden in the UI. return await salesforceConnection.sobject('fleet_website_page_views__c') .create({ Contact__c: recordIds.salesforceContactId,// eslint-disable-line camelcase + Account__c: recordIds.salesforceAccountId,// eslint-disable-line camelcase Page_URL__c: `https://fleetdm.com${req.url}`,// eslint-disable-line camelcase Visited_on__c: nowOn,// eslint-disable-line camelcase Website_visit_reason__c: websiteVisitReason// eslint-disable-line camelcase diff --git a/website/assets/images/articles/fleet-and-workbrew-1600x900@2x.png b/website/assets/images/articles/fleet-and-workbrew-1600x900@2x.png new file mode 100644 index 0000000000..9074d10b97 Binary files /dev/null and b/website/assets/images/articles/fleet-and-workbrew-1600x900@2x.png differ diff --git a/website/assets/images/articles/workbrew-console-3412x2020px.png b/website/assets/images/articles/workbrew-console-3412x2020px.png new file mode 100644 index 0000000000..060143e987 Binary files /dev/null and b/website/assets/images/articles/workbrew-console-3412x2020px.png differ diff --git a/website/assets/images/software-management-feature-image-1-528x377@2x.png b/website/assets/images/software-management-feature-image-1-528x377@2x.png new file mode 100644 index 0000000000..451b9beb3d Binary files /dev/null and b/website/assets/images/software-management-feature-image-1-528x377@2x.png differ diff --git a/website/assets/images/software-management-feature-image-2-528x377@2x.png b/website/assets/images/software-management-feature-image-2-528x377@2x.png new file mode 100644 index 0000000000..3f6ca292fd Binary files /dev/null and b/website/assets/images/software-management-feature-image-2-528x377@2x.png differ diff --git a/website/assets/images/software-management-feature-image-3-528x377@2x.png b/website/assets/images/software-management-feature-image-3-528x377@2x.png new file mode 100644 index 0000000000..ec2d6cb51a Binary files /dev/null and b/website/assets/images/software-management-feature-image-3-528x377@2x.png differ diff --git a/website/assets/images/software-management-feature-image-4-528x377@2x.png b/website/assets/images/software-management-feature-image-4-528x377@2x.png new file mode 100644 index 0000000000..cc5d385262 Binary files /dev/null and b/website/assets/images/software-management-feature-image-4-528x377@2x.png differ diff --git a/website/assets/images/software-management-feature-image-5-528x377@2x.png b/website/assets/images/software-management-feature-image-5-528x377@2x.png new file mode 100644 index 0000000000..2e14bf963f Binary files /dev/null and b/website/assets/images/software-management-feature-image-5-528x377@2x.png differ diff --git a/website/assets/images/software-management-feature-slide-1-1072x480@2x.png b/website/assets/images/software-management-feature-slide-1-1072x480@2x.png new file mode 100644 index 0000000000..ca7d076f2a Binary files /dev/null and b/website/assets/images/software-management-feature-slide-1-1072x480@2x.png differ diff --git a/website/assets/images/software-management-feature-slide-2-1072x480@2x.png b/website/assets/images/software-management-feature-slide-2-1072x480@2x.png new file mode 100644 index 0000000000..7df8d28de4 Binary files /dev/null and b/website/assets/images/software-management-feature-slide-2-1072x480@2x.png differ diff --git a/website/assets/images/software-management-feature-slide-3-1072x480@2x.png b/website/assets/images/software-management-feature-slide-3-1072x480@2x.png new file mode 100644 index 0000000000..b75534a680 Binary files /dev/null and b/website/assets/images/software-management-feature-slide-3-1072x480@2x.png differ diff --git a/website/assets/images/testimonial-author-arsenio-figueroa-48x48@2x.png b/website/assets/images/testimonial-author-arsenio-figueroa-48x48@2x.png new file mode 100644 index 0000000000..ccd1ec9306 Binary files /dev/null and b/website/assets/images/testimonial-author-arsenio-figueroa-48x48@2x.png differ diff --git a/website/assets/images/vuln-management-feature-image-1-380x281@2x.png b/website/assets/images/vuln-management-feature-image-1-380x281@2x.png deleted file mode 100644 index 38b72e56d4..0000000000 Binary files a/website/assets/images/vuln-management-feature-image-1-380x281@2x.png and /dev/null differ diff --git a/website/assets/images/vuln-management-feature-image-2-380x323@2x.png b/website/assets/images/vuln-management-feature-image-2-380x323@2x.png deleted file mode 100644 index 154dd72365..0000000000 Binary files a/website/assets/images/vuln-management-feature-image-2-380x323@2x.png and /dev/null differ diff --git a/website/assets/images/vuln-management-feature-image-3-380x320@2x.png b/website/assets/images/vuln-management-feature-image-3-380x320@2x.png deleted file mode 100644 index 23a9928dd5..0000000000 Binary files a/website/assets/images/vuln-management-feature-image-3-380x320@2x.png and /dev/null differ diff --git a/website/assets/images/vuln-management-feature-image-4-380x270@2x.png b/website/assets/images/vuln-management-feature-image-4-380x270@2x.png deleted file mode 100644 index 8b3131ca6b..0000000000 Binary files a/website/assets/images/vuln-management-feature-image-4-380x270@2x.png and /dev/null differ diff --git a/website/assets/images/vuln-management-feature-image-5-380x312@2x.png b/website/assets/images/vuln-management-feature-image-5-380x312@2x.png deleted file mode 100644 index 7f5d1da9f8..0000000000 Binary files a/website/assets/images/vuln-management-feature-image-5-380x312@2x.png and /dev/null differ diff --git a/website/assets/js/pages/osquery-table-details.page.js b/website/assets/js/pages/osquery-table-details.page.js index 4897b68ccc..d940b76af9 100644 --- a/website/assets/js/pages/osquery-table-details.page.js +++ b/website/assets/js/pages/osquery-table-details.page.js @@ -73,20 +73,32 @@ parasails.registerPage('osquery-table-details', { keywordsForThisTable = keywordsForThisTable.sort((a,b)=>{// Sorting the array of keywords by length to match larger keywords first. return a.length < b.length ? 1 : -1; }); + keywordsForThisTable = _.pull(keywordsForThisTable, this.tableToDisplay.title); (()=>{ $('pre code').each((i, block) => { - let keywordsToHighlight = [];// Empty array to track the keywords that we will need to highlight - for(let keyword of keywordsForThisTable){// Going through the array of keywords for this table, if the entire word matches, we'll add it to the - for(let match of block.innerHTML.match(keyword)||[]){ - keywordsToHighlight.push(match); - } + let tableNamesToHighlight = [];// Empty array to track the keywords that we will need to highlight + for(let match of block.innerHTML.match(this.tableToDisplay.title)||[]){ + tableNamesToHighlight.push(match); } // Now iterate through the keywordsToHighlight, replacing all matches in the elements innerHTML. let replacementHMTL = block.innerHTML; - for(let keywordInExample of keywordsToHighlight) { + for(let keywordInExample of tableNamesToHighlight) { let regexForThisExample = new RegExp(keywordInExample, 'g'); replacementHMTL = replacementHMTL.replace(regexForThisExample, ''+keywordInExample+''); } + // $(block).html(replacementHMTL); + let columnNamesToHighlight = [];// Empty array to track the keywords that we will need to highlight + for(let keyword of keywordsForThisTable){// Going through the array of keywords for this table, if the entire word matches, we'll add it to the + for(let match of block.innerHTML.match(keyword)||[]){ + columnNamesToHighlight.push(match); + } + } + // Now iterate through the keywordsToHighlight, replacing all matches in the elements innerHTML. + // let replacementHMTL = block.innerHTML; + for(let keywordInExample of columnNamesToHighlight) { + let regexForThisExample = new RegExp(keywordInExample, 'g'); + replacementHMTL = replacementHMTL.replace(regexForThisExample, ''+keywordInExample+''); + } $(block).html(replacementHMTL); // After we've highlighted our keywords, we'll highlight the rest of the codeblock window.hljs.highlightElement(block); diff --git a/website/assets/js/pages/vulnerability-management.page.js b/website/assets/js/pages/software-management.page.js similarity index 63% rename from website/assets/js/pages/vulnerability-management.page.js rename to website/assets/js/pages/software-management.page.js index 29c8bfc12f..02c7a6f39f 100644 --- a/website/assets/js/pages/vulnerability-management.page.js +++ b/website/assets/js/pages/software-management.page.js @@ -1,9 +1,10 @@ -parasails.registerPage('vulnerability-management-page', { +parasails.registerPage('software-management-page', { // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ data: { modal: undefined, + visibleFeature: 'mitigate-cves-automatically', }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ @@ -13,13 +14,24 @@ parasails.registerPage('vulnerability-management-page', { //… }, mounted: async function() { - //… + $('#heroCarousel').carousel({ + interval: 5000, + }); + $('#heroCarousel').on('slide.bs.carousel', (e)=>{ + let toIndicatorElement = $('ol[purpose="carousel-indicators"] li')[e.to]; + let fromIndicatorElement = $('ol[purpose="carousel-indicators"] li')[e.from]; + $(toIndicatorElement).addClass('active'); + $(fromIndicatorElement).removeClass('active'); + }); }, // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ methods: { + clickSwitchFeature: function(feature) { + this.visibleFeature = feature; + }, clickOpenVideoModal: function(modalName) { this.modal = modalName; }, diff --git a/website/assets/styles/components/scrollable-tweets.component.less b/website/assets/styles/components/scrollable-tweets.component.less index b11473169f..b890e5d6c3 100644 --- a/website/assets/styles/components/scrollable-tweets.component.less +++ b/website/assets/styles/components/scrollable-tweets.component.less @@ -92,6 +92,7 @@ line-height: 18px !important;//lesshint-disable-line importantRule,duplicateProperty } [purpose='name'] { + text-transform: capitalize; color: @core-fleet-black !important;//lesshint-disable-line importantRule,duplicateProperty } [purpose='profile-picture'] { diff --git a/website/assets/styles/importer.less b/website/assets/styles/importer.less index f68370a395..ef6d10bbcb 100644 --- a/website/assets/styles/importer.less +++ b/website/assets/styles/importer.less @@ -70,7 +70,7 @@ @import 'pages/endpoint-ops.less'; @import 'pages/transparency.less'; @import 'pages/press-kit.less'; -@import 'pages/vulnerability-management.less'; +@import 'pages/software-management.less'; @import 'pages/support.less'; @import 'pages/try-fleet/waitlist.less'; @import 'pages/admin/sandbox-waitlist.less'; diff --git a/website/assets/styles/layout.less b/website/assets/styles/layout.less index 7452a27bd7..61dbd0f5fd 100644 --- a/website/assets/styles/layout.less +++ b/website/assets/styles/layout.less @@ -17,6 +17,27 @@ html, body { padding-bottom: @footer-height; background: linear-gradient(180deg, #E8F1F6 0%, #FFFFFF 200px); background-position: center 40px; + opacity: 1; + // Note: This element has the "show" class toggled by the mobile header nav menu button. + // We are overriding bootstrap classes here to allow us to prevent the page being scrolled while the mobile menu is open. + &.collapse { + display: block; + [purpose='mobile-nav'] { + display: none; + } + } + &.collapsing { + opacity: 0; + transition: 0s; + } + &.collapse.show { + max-height: 100vh; + overflow: hidden; + [purpose='mobile-nav'] { + display: block; + opacity: 1; + } + } } [purpose='header-background'] { @@ -40,7 +61,7 @@ html, body { } [purpose='continue-banner'] { - z-index: 199; + z-index: 198; position: fixed; bottom: 24px; left: 24px; @@ -279,14 +300,14 @@ html, body { left: 0; right: 0; bottom: 0; - pointer-events: none; + z-index: 200; background-color: #ffffff; hr { margin-top: 4px; margin-bottom: 8px; } [purpose='mobile-nav-header'] { - padding: 19px 40px; + padding: 19px 32px; height: 80px; } [purpose='mobile-nav-container'] { @@ -783,6 +804,7 @@ body.detected-mobile { @media (max-width: 375px) { [purpose='page-header'] { + padding: 19px 16px; [purpose='mobile-nav'] { [purpose='mobile-nav-header'] { padding: 19px 16px; diff --git a/website/assets/styles/pages/articles/articles.less b/website/assets/styles/pages/articles/articles.less index 770943f00c..7d5fd9f097 100644 --- a/website/assets/styles/pages/articles/articles.less +++ b/website/assets/styles/pages/articles/articles.less @@ -220,7 +220,7 @@ } [purpose='guides'] { column-count: 3; - margin-left: -2px; + margin-left: -10px; margin-right: 14px; } [purpose='guide-card'] { diff --git a/website/assets/styles/pages/entrance/login.less b/website/assets/styles/pages/entrance/login.less index 46ffa53c30..de5aafbe2c 100644 --- a/website/assets/styles/pages/entrance/login.less +++ b/website/assets/styles/pages/entrance/login.less @@ -164,7 +164,7 @@ [purpose='customer-portal-form'] { max-width: unset; } - [purpose='signup-form'] { + [purpose='login-form'] { width: 100%; } [purpose='quote-and-logos'] { @@ -183,7 +183,7 @@ [purpose='page-container'] { padding: 48px 24px; } - [purpose='login-link'] { + [purpose='register-link'] { margin-bottom: 12px; } [purpose='customer-portal-form'] { diff --git a/website/assets/styles/pages/osquery-table-details.less b/website/assets/styles/pages/osquery-table-details.less index b452731b13..e7b42e2f31 100644 --- a/website/assets/styles/pages/osquery-table-details.less +++ b/website/assets/styles/pages/osquery-table-details.less @@ -278,19 +278,26 @@ .hljs-keyword { color: #FFF; } + .hljs-string { // For words wrapped in quotation marks + color: #FFF; + } color: #FFF; background-color: #AE6DDF; - border-radius: 4px; - padding: 4px 4px 4px 4px; + border-radius: 3px; white-space: pre; vertical-align: baseline; - line-height: 16px; span { padding: 0; } } + .hljs-number { + color: #f5871f; + } .hljs-string { // For words wrapped in quotation marks - color: #3DB67B; + color: #4fd061; + .hljs-keyword { + color: #4fd061; + } } background-color: @ui-off-white; border: none; diff --git a/website/assets/styles/pages/query-library.less b/website/assets/styles/pages/query-library.less index 7560755e2b..924a4dc887 100644 --- a/website/assets/styles/pages/query-library.less +++ b/website/assets/styles/pages/query-library.less @@ -51,6 +51,7 @@ } .DocSearch-Button-Placeholder { font-size: 16px; + line-height: 16px; font-weight: 400; padding-left: 0px; } diff --git a/website/assets/styles/pages/software-management.less b/website/assets/styles/pages/software-management.less new file mode 100644 index 0000000000..56ea6c8f02 --- /dev/null +++ b/website/assets/styles/pages/software-management.less @@ -0,0 +1,479 @@ +#software-management-page { + @heading-lineheight: 120%; + @text-lineheight: 150%; + + h1 { + color: @core-fleet-black; + font-size: 48px; + font-weight: 800; + line-height: @heading-lineheight; /* 120% */ + } + h2 { + color: @core-fleet-black; + text-align: center; + font-feature-settings: 'salt' on, 'ss01' on, 'ss02' on; + font-size: 32px; + font-weight: 800; + line-height: @text-lineheight; + } + h3 { + margin-bottom: 32px; + color: @core-fleet-black; + font-size: 24px; + font-weight: 800; + line-height: @heading-lineheight; + } + h4 { + color: @core-fleet-black-75; + font-feature-settings: 'salt' on, 'ss01' on, 'ss02' on; + font-family: 'Roboto Mono'; + font-size: 14px; + font-weight: 400; + line-height: @text-lineheight; + text-transform: uppercase; + } + p { + font-size: 16px; + line-height: @text-lineheight; + color: @core-fleet-black-75; + } + + + + [parasails-component='animated-arrow-button'] { + font-weight: 600; + } + [purpose='page-container'] { + padding: 64px; + } + [purpose='page-content'] { + max-width: 1072px; + margin-left: auto; + margin-right: auto; + } + [purpose='hero-text'] { + max-width: 568px; + padding-top: 16px; + padding-bottom: 32px; + } + [purpose='button-row'] { + [purpose='contact-button'] { + display: flex; + height: 36px; + padding: 16px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 8px; + background: @core-vibrant-red; + color: #FFF; + text-align: center; + font-size: 16px; + font-weight: 700; + line-height: @text-lineheight; + margin-right: 24px; + &:hover { + text-decoration: none; + } + } + } + [purpose='hero-slides'] { + padding-top: 64px; + padding-bottom: 24px; + [purpose='carousel-headings'] { + position: unset; + padding-top: 24px; + padding-bottom: 16px; + margin-left: unset; + margin-right: unset; + } + [purpose='slide-text'] { + width: 33%; + cursor: pointer; + h5 { + font-size: 16px; + font-weight: 800; + line-height: @heading-lineheight; + } + p { + margin-right: 16px; + font-size: 14px; + font-weight: 400; + line-height: @text-lineheight; + } + h5, p { + color: @core-fleet-black-50; + } + &:not(:last-of-type) { + margin-right: 16px; + } + &.active { + h5, p { + color: @core-fleet-black; + } + } + &:hover { + h5, p { + color: @core-fleet-black; + } + } + } + [purpose='carousel-indicators'] { + position: relative; + bottom: 0px; + margin-top: 16px; + margin-bottom: 16px; + li { + width: 40px; + background-color: @core-fleet-black-10; + &.active { + background-color: @core-fleet-black-50; + } + } + } + } + [purpose='logo-row'] { + [parasails-component='logo-carousel'] { + margin-top: 64px; + margin-bottom: 32px; + } + } + [purpose='feature-slides'] { + height: 660px; + } + [purpose='feature-switch'] { + padding-top: 64px; + margin-right: auto; + margin-left: auto; + border-bottom: 1px solid @core-fleet-black-10; + } + [purpose='feature-option'] { + width: 33%; + padding: 16px 40px; + white-space: nowrap; + text-align: center; + cursor: pointer; + color: @core-fleet-black-50; + &.active { + color: @core-fleet-black; + border-bottom: 2px solid @core-fleet-black; + } + &:hover { + color: @core-fleet-black; + } + } + [purpose='feature-slide'] { + padding-top: 64px; + padding-bottom: 32px; + &.invisible { + height: 0; + padding: 0; + } + [purpose='feature-text'] { + padding-left: 48px; + margin-left: 16px; + width: 50%; + } + } + [purpose='feature-image'] { + width: 50%; + img { + max-width: 100%; + max-height: 100%; + } + } + [purpose='checklist'] { + margin-top: 8px; + p { + font-size: 14px; + font-weight: 400; + line-height: @text-lineheight; + padding-left: 37px; + text-indent: -37px; + margin-bottom: 1.5rem; + &:last-of-type { + margin-bottom: 0px; + } + } + p::before { + content: ' '; + background-image: url('/images/icon-checkmark-green-20x20@2x.png'); + background-size: 20px 20px; + display: inline-block; + position: relative; + top: 5px; + margin-right: 16px; + width: 20px; + height: 20px; + } + } + [purpose='feature-link'] { + padding-top: 32px; + } + + [purpose='testimonial'] { + max-width: 524px; + margin-left: auto; + margin-right: auto; + padding-bottom: 64px; + padding-top: 32px; + text-align: center; + [purpose='testimonial-image'] { + height: 48px; + margin-bottom: 5px; + } + [purpose='testimonial-text'] { + color: @core-fleet-black-75; + text-align: center; + font-size: 18px; + font-style: italic; + font-weight: 400; + line-height: @text-lineheight; + margin-bottom: 24px; + } + [purpose='testimonial-attribution'] { + [purpose='name'] { + color: @core-fleet-black-75; + text-align: center; + font-size: 12px; + font-weight: 700; + line-height: @text-lineheight; /* 150% */ + margin-bottom: 0px; + } + [purpose='job-title'] { + color: @core-fleet-black-75; + margin-bottom: 0px; + font-size: 12px; + font-weight: 400; + line-height: @text-lineheight; + } + } + } + + + [purpose='feature'] { + padding-top: 64px; + padding-bottom: 64px; + h3 { + color: @core-fleet-black; + font-size: 24px; + font-weight: 800; + line-height: @heading-lineheight; /* 120% */ + margin-bottom: 32px; + } + &:last-of-type { + margin-bottom: 0px; + } + } + + [purpose='feature'].flex-column { + [purpose='feature-text'] { + margin-left: 16px; + padding-left: 48px; + } + } + [purpose='feature'].flex-column-reverse { + [purpose='feature-text'] { + margin-right: 16px; + padding-right: 48px; + } + } + [purpose='feature-text'] { + width: 50%; + } + [parasails-component='scrollable-tweets'] { + [purpose='tweets'] { + margin-top: 32px; + margin-bottom: 88px; + [purpose='quote'] { + color: @core-fleet-black-75; + font-size: 14px; + font-weight: 400; + line-height: @text-lineheight; + } + [purpose='video-link'] { + text-decoration: underline; + color: @core-fleet-black-75; + font-size: 14px; + font-weight: 400; + line-height: @text-lineheight; + text-decoration-line: underline; + } + } + } + [purpose='section-heading'] { + h4 { + margin-bottom: 0px; + } + [purpose='button-row'] { + [purpose='contact-button'] { + padding: 16px 32px; + } + } + } + + [parasails-component='parallax-city'] { + background: linear-gradient(358deg, #E9F4F4 0%, #FFFFFF 100%); + } + + @media (max-width: 991px) { + [purpose='page-container'] { + padding: 64px 32px; + } + [purpose='feature-slides'] { + height: unset; + } + [purpose='feature-option'] { + width: 33%; + padding: 16px 8px; + &:last-of-type { + padding: 16px 8px; + } + } + [purpose='feature-slide'] { + [purpose='feature-text'] { + padding-left: 16px; + width: 50%; + } + } + [purpose='feature'].flex-column { + [purpose='feature-text'] { + margin-left: 16px; + padding-left: 16px; + } + } + [purpose='feature'].flex-column-reverse { + [purpose='feature-text'] { + margin-right: 16px; + padding-right: 16px; + } + } + } + @media (max-width: 767px) { + [purpose='page-container'] { + padding: 64px 32px; + } + [purpose='hero-slides'] { + [purpose='carousel-headings'] { + position: unset; + padding-top: 16px; + padding-bottom: 16px; + margin-left: unset; + margin-right: unset; + } + [purpose='slide-text'] { + width: 100%; + p { + margin-right: 16px; + margin-bottom: 0px; + } + &:not(:last-of-type) { + margin-right: unset; + margin-bottom: 32px; + } + } + [purpose='feature-slide'] { + padding-top: 64px; + padding-bottom: 62px; + [purpose='feature-text'] { + margin-left: 0px; + padding-left: 0px; + width: 50%; + } + } + } + [purpose='feature-switch'] { + margin-right: 0; + margin-left: auto; + border-bottom: none; + border-left: 1px solid @core-fleet-black-10; + margin-top: 64px; + padding-top: 0px; + } + [purpose='feature-option'] { + width: 100%; + text-align: center; + &.active { + border-left: 2px solid @core-fleet-black; + border-bottom: none; + } + } + [purpose='feature-slide'] { + padding-top: 32px; + padding-bottom: 64px; + &.invisible { + height: 0; + padding: 0; + } + [purpose='feature-text'] { + margin-left: 0px; + padding-left: 0px; + width: 100%; + margin-bottom: 32px; + } + } + [purpose='testimonial'] { + padding-bottom: 64px; + padding-top: 0px; + } + [purpose='feature'].flex-column { + padding-top: 32px; + padding-bottom: 48px; + [purpose='feature-text'] { + margin-left: 0px; + padding-left: 0px; + } + } + [purpose='feature'].flex-column-reverse { + padding-top: 48px; + padding-bottom: 32px; + [purpose='feature-text'] { + padding-right: 0px; + margin-right: 0px; + } + } + [purpose='feature-text'] { + width: 100%; + } + [purpose='feature-image'] { + width: 100%; + margin-bottom: 32px; + } + [purpose='section-heading'] { + padding: 64px 32px; + } + } + @media (max-width: 576px) { + [purpose='page-container'] { + padding: 32px 24px; + } + [purpose='quote'] { + padding: 0px 32px 64px 32px; + } + [purpose='section-heading'] { + padding: 40px 32px; + } + [parasails-component='scrollable-tweets'] { + [purpose='tweets'] { + margin-bottom: 56px; + } + } + } + + @media (max-width: 376px) { + h1 { + font-size: 32px; + } + [purpose='page-container'] { + padding: 32px 16px; + } + [purpose='testimonial'] { + padding-bottom: 32px; + padding-top: 0px; + } + [purpose='section-heading'] { + padding: 40px 0px; + } + } + +} diff --git a/website/assets/styles/pages/testimonials.less b/website/assets/styles/pages/testimonials.less index 7a1f24e14a..79e1e3d8c8 100644 --- a/website/assets/styles/pages/testimonials.less +++ b/website/assets/styles/pages/testimonials.less @@ -115,7 +115,7 @@ border-radius: 16px; border: 1px solid var(--UI-Fleet-Black-10, #E2E4EA); background: var(--Core-White, #FFF); - height: min-content; + margin-bottom: 24px; [purpose='logo'] { img { max-height: 32px; @@ -146,6 +146,7 @@ } [purpose='name'] { color: @core-fleet-black; + text-transform: capitalize; } [purpose='profile-picture'] { margin-right: 16px; @@ -352,7 +353,22 @@ height: 641px; } } - + @media (max-width: 1199px) { + [purpose='video-modal'] { + [purpose='modal-dialog'] { + width: 100%; + max-width: 100%; + } + [purpose='modal-content'] { + max-width: 960px; + height: 540px; + } + iframe { + width: 960px; + height: 540px; + } + } + } @media (max-width: 991px) { @@ -362,14 +378,40 @@ [purpose='page-container'] { padding: 64px 32px; } + [purpose='video-modal'] { + [purpose='modal-dialog'] { + max-width: 97vw; + } + [purpose='modal-content'] { + max-width: 540px; + height: 304px; + } + iframe { + width: 540px; + height: 304px; + } + } } @media (max-width: 776px) { - [purpose='page-container'] { - padding: 48px 24px; - } - [purpose='testimonials-container'] { - columns: 2; - } + [purpose='page-container'] { + padding: 48px 24px; + } + [purpose='testimonials-container'] { + columns: 2; + } + [purpose='video-modal'] { + [purpose='modal-dialog'] { + max-width: 97vw; + } + [purpose='modal-content'] { + max-width: 540px; + height: 304px; + } + iframe { + width: 540px; + height: 304px; + } + } } @media (max-width: 576px) { @@ -435,7 +477,16 @@ } } } - + [purpose='video-modal'] { + [purpose='modal-content'] { + width: 95vw; + height: calc(~'9/16 * 95vw'); + } + iframe { + width: 95vw; + height: calc(~'9/16 * 95vw'); + } + } } } diff --git a/website/assets/styles/pages/vulnerability-management.less b/website/assets/styles/pages/vulnerability-management.less deleted file mode 100644 index 87998dd3d2..0000000000 --- a/website/assets/styles/pages/vulnerability-management.less +++ /dev/null @@ -1,709 +0,0 @@ -#vulnerability-management-page { - background: linear-gradient(180deg, #E8F1F6 0%, #FFF 8.76%); - h1 { - font-size: 56px; - font-weight: 800; - line-height: 54px; - } - h2 { - font-weight: 800; - font-size: 32px; - line-height: 38px; - } - h3 { - font-weight: 800; - font-size: 32px; - line-height: 120%; - } - h4 { - font-family: 'Roboto Mono'; - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 150%; - text-transform: uppercase; - color: @core-fleet-black-75; - margin-bottom: 4px; - } - p { - font-size: 16px; - line-height: 24px; - color: @core-fleet-black-75; - } - strong { - colore: @core-fleet-black; - } - [purpose='page-content'] { - max-width: 960px; - } - - [purpose='page-container'] { - padding-left: 120px; - padding-right: 120px; - margin-left: auto; - margin-right: auto; - } - - [purpose='page-headline'] { - padding-bottom: 80px; - max-width: 780px; - h2 { - font-size: 48px; - font-style: normal; - font-weight: 800; - line-height: 57.6px; - margin-bottom: 0px; - } - } - [purpose='hero'] { - padding-top: 80px; - padding-bottom: 80px; - } - [purpose='hero-image'] { - max-width: 360px; - img { - padding-left: 25px; - max-width: 100%; - max-height: 100%; - width: 360px; - } - } - [purpose='hero-text'] { - width: 468px; - margin-left: 40px; - text-align: left; - strong { - margin-bottom: 8px; - font-weight: 800; - display: block; - } - p { - margin-bottom: 40px; - } - } - - [purpose='button-row'] { - a { - font-weight: 700; - font-size: 16px; - line-height: 24px; - } - [purpose='cta-button'] { - cursor: pointer; - margin-right: 32px; - background: @core-vibrant-red; - border-radius: 8px; - padding: 16px 32px; - height: 36px; - display: flex; - justify-content: center; - align-items: center; - color: #FFF; - position: relative; - text-decoration: none; - overflow: hidden; - } - [purpose='cta-button']::before { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%); - opacity: 1; - content: ' '; - position: absolute; - top: 0; - left: -5px; - width: 70%; - height: 100%; - transform: skew(-10deg); - transition: left 0.5s ease-in, opacity 0.50s ease-in, width 0.5s ease-in; - } - [purpose='cta-button']:hover:before { - opacity: 0; - left: 160px; - width: 110%; - } - - } - - - [purpose='testimonial-videos'] { - width: 468px; - margin-left: 40px; - } - [purpose='testimonials'] { - margin-bottom: 80px; - } - [purpose='testimonial-quote'] { - width: 380px; - a { - text-decoration: none; - color: unset; - &:hover { - text-decoration: none; - } - } - [purpose='quote'] { - p { - color: @core-fleet-black-75; - font-size: 20px; - font-style: italic; - font-weight: 400; - line-height: 30px; - } - } - [purpose='quote-image'] { - margin-right: 16px; - img { - width: 48px; - height: 48px; - } - } - [purpose='quote-attribution'] { - display: inline-flex; - padding: 4px 16px 4px 4px; - border-radius: 28px; - width: fit-content; - margin-top: 8px; - [purpose='name'] { - font-size: 12px; - font-weight: 700; - line-height: 18px; - margin-bottom: 0px; - } - [purpose='title'] { - color: @core-fleet-black-75; - font-size: 12px; - font-weight: 400; - line-height: 18px; - margin-bottom: 0px; - } - &:hover { - background-color: #F9FAFC; - } - &:active { - background-color: #F2F2F5; - } - } - - - } - - [purpose='testimonial-video'] { - cursor: pointer; - width: 223px; - height: 168px; - border-radius: 22.265px; - border: 0.975px solid @core-fleet-black-50; - display: flex; - margin-bottom: 0px; - position: relative; - span { - img { - height: 10.235px; - width: auto; - margin-right: 5.5px; - } - position: absolute; - left: 20px; - bottom: 20px; - border-radius: 16.698px; - background: rgba(0, 2, 10, 0.50); - backdrop-filter: blur(5.566145420074463px); - display: inline-flex; - padding: 5.566px 11.132px; - justify-content: center; - align-items: center; - color: #FFF; - font-size: 11.132px; - font-style: normal; - font-weight: 400; - line-height: 16.698px; - } - &:hover { - box-shadow: 0px 4px 16px 0px #E2E4EA; - } - &:first-of-type { - background: url('/images/video-testimonial-thumbnail-austin-anderson-223x168@2x.jpg'); - background-position: center; - background-size: cover; - margin-right: 12px; - margin-left: 0px; - } - &:last-of-type { - background: url('/images/video-testimonial-thumbnail-andre-shields-223x168@2x.png'); - background-position: center; - background-size: cover; - margin-right: 0px; - margin-left: 12px; - } - } - [purpose='video-modal'] { - [purpose='modal-dialog'] { - width: 100%; - max-width: 100%; - } - [purpose='modal-content'] { - max-width: 1140px; - height: 641px; - background-color: transparent; - box-shadow: none; - border: none; - padding: 0px; - margin-top: 150px; - margin-left: auto; - margin-right: auto; - [purpose='modal-close-button'] { - top: -40px; - right: 0px; - border-radius: 50%; - width: 32px; - height: 32px; - padding: 0px 0px 4px 0px; - background-color: #192147; - color: #FFF; - opacity: 1; - } - } - iframe { - width: 1140px; - height: 641px; - } - } - [parasails-component='logo-carousel'] { - margin-bottom: 80px; - } - [purpose='calendar-feature'] { - margin-bottom: 140px; - h3 { - margin-bottom: 24px; - } - [purpose='calendar-feature-text'] { - max-width: 480px; - } - [purpose='new-badge'] { - background-color: #0587FF; - padding: 4px 8px 3px 8px; - display: flex; - align-items: center; - border-radius: 14px; - color: #FFF; - font-size: 12px; - font-weight: 500; - line-height: 18px; - text-transform: uppercase; - margin-bottom: 12px; - width: min-content; - } - [purpose='calendar-checklist'] { - margin-top: 8px; - margin-bottom: 24px; - p { - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 21px; - padding-left: 37px; - text-indent: -37px; - margin-bottom: 1.5rem; - &:last-of-type { - margin-bottom: 0px; - } - } - p::before { - content: ' '; - background-image: url('/images/icon-checkmark-green-20x20@2x.png'); - background-size: 20px 20px; - display: inline-block; - position: relative; - top: 5px; - margin-right: 16px; - width: 20px; - height: 20px; - } - } - [purpose='feature-video'] { - margin-left: 80px; - max-width: 468px; - video { - max-width: 100%; - max-height: 100%; - border-radius: 16px; - } - } - [purpose='video-button'] { - margin-top: 12px; - cursor: pointer; - img { - height: 32px; - margin-right: 8px; - } - font-size: 14px; - font-weight: 700; - line-height: 21px; - } - } - - [purpose='feature'] { - margin-bottom: 180px; - h3 { - margin-bottom: 24px; - } - &:last-of-type { - margin-bottom: 0px; - } - } - - [purpose='feature'].flex-column { - [purpose='feature-text'] { - margin-left: 48px; - } - } - [purpose='feature'].flex-column-reverse { - [purpose='feature-text'] { - margin-right: 48px; - } - } - [purpose='feature-image'] { - max-width: 380px; - img { - max-width: 100%; - max-height: 100%; - width: 380px; - } - } - [purpose='feature-text'] { - width: 468px; - } - [purpose='checklist'] { - margin-top: 8px; - p { - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 21px; - padding-left: 37px; - text-indent: -37px; - margin-bottom: 1.5rem; - &:last-of-type { - margin-bottom: 0px; - } - } - p::before { - content: ' '; - background-image: url('/images/icon-checkmark-green-20x20@2x.png'); - background-size: 20px 20px; - display: inline-block; - position: relative; - top: 5px; - margin-right: 16px; - width: 20px; - height: 20px; - } - } - [purpose='tweets-container'] { - padding-top: 200px; - max-width: 960px; - } - [parasails-component='scrollable-tweets'] { - [purpose='tweets'] { - margin-top: 40px; - } - } - [purpose='bottom-gradient'] { - background: linear-gradient(180deg, #FFFFFF 0%, #E9F4F4 100%); - } - [purpose='bottom-cloud-city-banner'] { - background: linear-gradient(180deg, #E9F4F4 0%, #FFFFFF 100%); - img { - width: 100%; - } - } - - @media (max-width: 1199px) { - [purpose='page-container'] { - padding-left: 80px; - padding-right: 80px; - } - [purpose='video-modal'] { - [purpose='modal-dialog'] { - width: 100%; - max-width: 100%; - } - [purpose='modal-content'] { - max-width: 960px; - height: 540px; - } - iframe { - width: 960px; - height: 540px; - } - } - } - - @media (max-width: 991px) { - [purpose='page-container'] { - padding-left: 40px; - padding-right: 40px; - } - [purpose='calendar-section'] { - padding: 0px 40px 40px 40px; - } - [purpose='calendar-feature'] { - [purpose='feature-video'] { - margin-left: auto; - margin-right: auto; - margin-bottom: 40px; - } - } - [purpose='page-content'] { - max-width: 840px; - } - [purpose='tweets-container'] { - max-width: 840px; - } - [purpose='testimonial-videos'] { - width: 410px; - } - [purpose='hero-text'] { - width: 410px; - } - [purpose='feature-text'] { - width: 410px; - } - } - - @media (max-width: 767px) { - - [purpose='page-container'] { - padding-left: 40px; - padding-right: 40px; - } - [purpose='page-content'] { - max-width: 480px; - } - [purpose='tweets-container'] { - max-width: 480px; - padding-left: 40px; - padding-right: 40px; - padding-top: 120px; - } - [purpose='page-headline'] { - padding-bottom: 80px; - width: 100%; - h2 { - font-size: 42px; - line-height: 50.4px; - } - } - [purpose='hero-image'] { - margin-right: unset; - } - [purpose='hero-text'] { - width: unset; - margin-left: auto; - } - [purpose='testimonial-videos'] { - width: unset; - margin-top: 60px; - } - [purpose='button-row'] { - max-width: 100%; - [purpose='cta-button'] { - margin-right: 0px; - width: 100%; - margin-bottom: 24px; - } - } - [purpose='calendar-section'] { - padding: 0px 32px 40px 32px; - } - [purpose='calendar-card-body'] { - width: 100%; - padding-left: 24px; - padding-right: 24px; - padding-bottom: 48px; - padding-top: 60px; - margin-right: 0px; - text-align: center; - } - [purpose='calendar-card'] { - height: unset; - } - [purpose='calendar-image'] { - padding-top: 0; - padding-bottom: 0; - height: 420px; - width: 100%; - &:before { - content: ''; - background: none; - } - } - [purpose='feature-text'] { - width: unset; - } - [purpose='feature'] { - margin-bottom: 120px; - } - [purpose='feature'].flex-column { - [purpose='feature-text'] { - margin-left: auto; - } - } - [purpose='feature'].flex-column-reverse { - [purpose='feature-text'] { - margin-right: auto; - } - } - [purpose='feature-image'] { - margin-left: auto; - margin-right: auto; - margin-bottom: 60px; - } - [purpose='hero-image'] { - margin-bottom: 60px; - } - [purpose='testimonial-videos'] { - margin-left: auto; - margin-right: auto; - } - [purpose='testimonial-quote'] { - width: 100%; - [purpose='quote'] { - img { - margin-right: auto; - margin-left: auto; - } - text-align: center; - } - [purpose='quote-attribution'] { - margin-right: auto; - margin-left: auto; - } - } - [purpose='video-modal'] { - [purpose='modal-dialog'] { - max-width: 97vw; - } - [purpose='modal-content'] { - max-width: 540px; - height: 304px; - } - iframe { - width: 540px; - height: 304px; - } - } - } - - @media (max-width: 575px) { - [purpose='page-container'] { - padding-left: 40px; - padding-right: 40px; - } - [purpose='tweets-container'] { - padding-left: 40px; - padding-right: 40px; - } - [purpose='feature-image'] { - img { - max-width: 100%; - } - } - [purpose='hero-image'] { - img { - max-width: 100%; - } - } - [purpose='testimonial-video'] { - width: 200px; - height: 160px; - &:first-of-type { - margin-right: 10px; - margin-left: auto; - } - &:last-of-type { - margin-right: auto; - margin-left: 10px; - } - } - - [purpose='video-modal'] { - [purpose='modal-content'] { - width: 95vw; - height: calc(~'9/16 * 95vw'); - } - iframe { - width: 95vw; - height: calc(~'9/16 * 95vw'); - } - } - [purpose='calendar-section'] { - padding: 0px 24px 40px 24px; - } - } - @media (max-width: 472px) { - [purpose='testimonial-videos'] { - flex-direction: column; - } - [purpose='testimonial-video'] { - width: 223px; - height: 168px; - &:first-of-type { - margin-right: auto; - margin-left: auto; - margin-bottom: 24px; - } - &:last-of-type { - margin-right: auto; - margin-left: auto; - } - } - - } - @media (max-width: 375px) { - [purpose='page-container'] { - padding-left: 32px; - padding-right: 32px; - } - [purpose='tweets-container'] { - padding-left: 32px; - padding-right: 32px; - } - [purpose='hero'] { - padding-top: 40px; - padding-bottom: 40px; - } - [purpose='hero-image'] { - max-height: 360px; - max-width: unset; - img { - max-width: 100%; - max-height: 100%; - width: 261px; - } - } - [purpose='testimonials'] { - padding-top: 40px; - margin-bottom: 80px; - } - [purpose='testimonial-videos'] { - flex-direction: column; - margin-top: 40px; - } - [purpose='calendar-card-body'] { - padding-left: 16px; - padding-right: 16px; - h3 { - font-size: 24px; - } - } - [purpose='calendar-image'] { - height: 287px; - } - [purpose='calendar-section'] { - padding: 0px 16px 40px 16px; - } - [purpose='feature-image'] { - margin-bottom: 40px; - } - } -} diff --git a/website/config/custom.js b/website/config/custom.js index f791a82e6f..4e0331a1ff 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -301,7 +301,6 @@ module.exports.custom = { // "Secret handbook" // Standard operating procedures (SOP), etc that would be public handbook content except for that it's confidential. 'README.md': ['mikermcneil'],// « about this repo - 'cold-outbound-strategy.md': ['mikermcneil', 'sampfluger88'],// « Cold outbound strategy (see fleetdm.com/handbook/company/why-this-way for our vision of a better way to sell) // GitHub issue templates '.github/ISSUE_TEMPLATE': ['mikermcneil', 'sampfluger88', 'lukeheath'],// FUTURE: Bust out individual maintainership for issue templates once relevant DRIs are GitHub, markdown, and content design-certified diff --git a/website/config/policies.js b/website/config/policies.js index befa1d166c..1681d1d645 100644 --- a/website/config/policies.js +++ b/website/config/policies.js @@ -46,7 +46,7 @@ module.exports.policies = { 'deliver-apple-csr': true, 'download-rss-feed': true, 'view-endpoint-ops': true, - 'view-vulnerability-management': true, + 'view-software-management': true, 'deliver-mdm-demo-email': true, 'view-support': true, 'view-integrations': true, diff --git a/website/config/routes.js b/website/config/routes.js index d73683ded0..48f47ac732 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -236,11 +236,11 @@ module.exports.routes = { } }, - 'GET /vulnerability-management': { - action: 'view-vulnerability-management', + 'GET /software-management': { + action: 'view-software-management', locals: { - pageTitleForMeta: 'Vulnerability management', - pageDescriptionForMeta: 'Report CVEs, software inventory, security posture, and other risks down to the chipset of any endpoint with Fleet.', + pageTitleForMeta: 'Software management', + pageDescriptionForMeta: 'Pick from a curated app library or upload your own custom packages. Configure custom installation scripts if you need or let Fleet do it for you.', currentSection: 'platform', } }, @@ -538,12 +538,13 @@ module.exports.routes = { 'GET /try-fleet/waitlist': '/try-fleet', 'GET /endpoint-operations': '/endpoint-ops',// « just in case we type it the wrong way 'GET /example-dep-profile': 'https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/automatic-enrollment.dep.json', + 'GET /vulnerability-management': (req,res)=> { let originalQueryString = req.url.match(/\?(.+)$/) ? '?'+req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/software-management'+originalQueryString);}, // Shortlinks for texting friends, radio ads, etc 'GET /mdm': '/device-management?utm_content=mdm',// « alias for radio ad 'GET /it': '/endpoint-ops?utm_content=eo-it', 'GET /seceng': '/endpoint-ops?utm_content=eo-security', - 'GET /vm': '/vulnerability-management?utm_content=vm', + 'GET /vm': '/software-management?utm_content=vm', // Fleet UI // ============================================================================================================= diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index 0338df7a7b..e33ef86293 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -130,7 +130,7 @@ -