Merge branch 'main' into feat-create-policies-from-fleet-apps

This commit is contained in:
Jahziel Villasana-Espinoza 2024-11-25 10:41:31 -05:00
commit c1719478f1
80 changed files with 1539 additions and 353 deletions

View file

@ -3,10 +3,10 @@ name: Check TUF timestamps
on:
pull_request:
paths:
- '.github/workflows/check-tuf-timestamps.yml'
- ".github/workflows/check-tuf-timestamps.yml"
workflow_dispatch: # Manual
schedule:
- cron: '0 10,22 * * *'
- cron: "0 10,22 * * *"
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
@ -29,81 +29,151 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0
with:
egress-policy: audit
- name: Harden Runner
uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0
with:
egress-policy: audit
- name: Check remote timestamp.json file
id: check_timestamp
run: |
expires=$(curl -s http://tuf.fleetctl.com/timestamp.json | jq -r '.signed.expires' | cut -c 1-10)
today=$(date "+%Y-%m-%d")
warning_at=$(date -d "$today + 4 day" "+%Y-%m-%d")
expires_sec=$(date -d "$expires" "+%s")
warning_at_sec=$(date -d "$warning_at" "+%s")
- name: Check remote timestamp.json file
id: check_timestamp
run: |
expires=$(curl -s http://tuf.fleetctl.com/timestamp.json | jq -r '.signed.expires' | cut -c 1-10)
today=$(date "+%Y-%m-%d")
warning_at=$(date -d "$today + 4 day" "+%Y-%m-%d")
expires_sec=$(date -d "$expires" "+%s")
warning_at_sec=$(date -d "$warning_at" "+%s")
if [ "$expires_sec" -le "$warning_at_sec" ]; then
echo "timestamp_warn=true" >> ${GITHUB_OUTPUT}
else
echo "timestamp_warn=false" >> ${GITHUB_OUTPUT}
fi
if [ "$expires_sec" -le "$warning_at_sec" ]; then
echo "timestamp_warn=true" >> ${GITHUB_OUTPUT}
else
echo "timestamp_warn=false" >> ${GITHUB_OUTPUT}
fi
- name: Check remote root.json file
id: check_root
run: |
expires=$(curl -s http://tuf.fleetctl.com/root.json | jq -r '.signed.expires' | cut -c 1-10)
today=$(date "+%Y-%m-%d")
warning_at=$(date -d "$today + 30 day" "+%Y-%m-%d")
expires_sec=$(date -d "$expires" "+%s")
warning_at_sec=$(date -d "$warning_at" "+%s")
- name: Check remote snapshot.json file
id: check_snapshot
run: |
expires=$(curl -s http://tuf.fleetctl.com/snapshot.json | jq -r '.signed.expires' | cut -c 1-10)
today=$(date "+%Y-%m-%d")
warning_at=$(date -d "$today + 30 day" "+%Y-%m-%d")
expires_sec=$(date -d "$expires" "+%s")
warning_at_sec=$(date -d "$warning_at" "+%s")
if [ "$expires_sec" -le "$warning_at_sec" ]; then
echo "root_warn=true" >> ${GITHUB_OUTPUT}
else
echo "root_warn=false" >> ${GITHUB_OUTPUT}
fi
if [ "$expires_sec" -le "$warning_at_sec" ]; then
echo "snapshot_warn=true" >> ${GITHUB_OUTPUT}
else
echo "snapshot_warn=false" >> ${GITHUB_OUTPUT}
fi
- name: Check remote targets.json file
id: check_targets
run: |
expires=$(curl -s http://tuf.fleetctl.com/targets.json | jq -r '.signed.expires' | cut -c 1-10)
today=$(date "+%Y-%m-%d")
warning_at=$(date -d "$today + 30 day" "+%Y-%m-%d")
expires_sec=$(date -d "$expires" "+%s")
warning_at_sec=$(date -d "$warning_at" "+%s")
- name: Slack Timestamp Notification
if: ${{ steps.check_timestamp.outputs.timestamp_warn == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
{
"text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "⚠️ TUF timestamp.json is about to expire or has already expired\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}"
if [ "$expires_sec" -le "$warning_at_sec" ]; then
echo "targets_warn=true" >> ${GITHUB_OUTPUT}
else
echo "targets_warn=false" >> ${GITHUB_OUTPUT}
fi
- name: Check remote root.json file
id: check_root
run: |
expires=$(curl -s http://tuf.fleetctl.com/root.json | jq -r '.signed.expires' | cut -c 1-10)
today=$(date "+%Y-%m-%d")
warning_at=$(date -d "$today + 30 day" "+%Y-%m-%d")
expires_sec=$(date -d "$expires" "+%s")
warning_at_sec=$(date -d "$warning_at" "+%s")
if [ "$expires_sec" -le "$warning_at_sec" ]; then
echo "root_warn=true" >> ${GITHUB_OUTPUT}
else
echo "root_warn=false" >> ${GITHUB_OUTPUT}
fi
- name: Slack timestamp notification
if: ${{ steps.check_timestamp.outputs.timestamp_warn == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
{
"text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "⚠️ TUF timestamp.json is about to expire or has already expired\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}"
}
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Slack Root Notification
if: ${{ steps.check_root.outputs.root_warn == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
{
"text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "⚠️ TUF root.json is about to expire or has already expired\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}"
- name: Slack snapshot notification
if: ${{ steps.check_snapshot.outputs.snapshot_warn == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
{
"text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "⚠️ TUF snapshot.json is about to expire or has already expired\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}"
}
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Slack targets notification
if: ${{ steps.check_targets.outputs.targets_warn == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
{
"text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "⚠️ TUF targets.json is about to expire or has already expired\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Slack root notification
if: ${{ steps.check_root.outputs.root_warn == 'true' }}
uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0
with:
payload: |
{
"text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "⚠️ TUF root.json is about to expire or has already expired\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

View file

@ -48,13 +48,6 @@ jobs:
ref: main
path: fleet-gitops
- name: Apply env vars to profiles
env:
MANAGED_CHROME_ENROLLMENT_TOKEN: ${{ secrets.CLOUD_MANAGEMENT_ENROLLMENT_TOKEN }}
run: |
envsubst < ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.mobileconfig > ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.confidential.mobileconfig
mv ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.confidential.mobileconfig ./it-and-security/lib/configuration-profiles/macos-chrome-enrollment.mobileconfig
- name: Apply latest configuration to Fleet
uses: ./fleet-gitops/.github/gitops-action
with:
@ -81,4 +74,5 @@ jobs:
DOGFOOD_CALENDAR_API_KEY: ${{ secrets.DOGFOOD_CALENDAR_API_KEY }}
DOGFOOD_COMPLIANCE_EXCLUSIONS_ENROLL_SECRET: ${{ secrets.DOGFOOD_COMPLIANCE_EXCLUSIONS_ENROLL_SECRET }}
DOGFOOD_COMPANY_OWNED_IPHONES_ENROLL_SECRET: ${{ secrets.DOGFOOD_COMPANY_OWNED_IPHONES_ENROLL_SECRET }}
DOGFOOD_COMPANY_OWNED_IPADS_ENROLL_SECRET: ${{ secrets.DOGFOOD_COMPANY_OWNED_IPADS_ENROLL_SECRET }}
DOGFOOD_COMPANY_OWNED_IPADS_ENROLL_SECRET: ${{ secrets.DOGFOOD_COMPANY_OWNED_IPADS_ENROLL_SECRET }}
MANAGED_CHROME_ENROLLMENT_TOKEN: ${{ secrets.CLOUD_MANAGEMENT_ENROLLMENT_TOKEN }}

View file

@ -18,6 +18,7 @@ Below is the JSON payload that is sent to Fleet Device Management Inc:
"numUsers": 999,
"numTeams": 999,
"numPolicies": 999,
"numQueries": 999,
"numLabels": 999,
"softwareInventoryEnabled": true,
"vulnDetectionEnabled": true,

View file

@ -2,7 +2,7 @@
![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 recently](https://workbrew.com/) made waves with its [official launch](https://workbrew.com/blog/workbrew-1-0), 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.
## Workbrews mission: From single-player to multiplayer

Binary file not shown.

1
changes/18539-font-bug Normal file
View file

@ -0,0 +1 @@
* Update Inter font to latest version for woff2 files

View file

@ -0,0 +1,2 @@
- Fixed issue where minimum OS version enforcement was not being applied during Apple ADE if MDM
IdP integration was enabled.

View file

@ -0,0 +1 @@
- Fleet UI: Better information on what deleting a host does

View file

@ -0,0 +1 @@
- Adds a clearer error message when users attempt to turn MDM off on a Windows host.

View file

@ -0,0 +1 @@
- Added additional statistics item for number of saved queries

View file

@ -0,0 +1 @@
- Fleet UI: Fix learn more about JIT provisioning link

View file

@ -0,0 +1 @@
* Fixed a bug where the HTTP client used for MDM APNs push notifications did not support using a configured proxy.

View file

@ -0,0 +1,2 @@
- Modifies the Fleet setup experience feature to not run if there is no software or script
configured for the setup experience.

View file

@ -22,6 +22,7 @@ import (
"github.com/e-dard/netbug"
"github.com/fleetdm/fleet/v4/ee/server/licensing"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server"
configpkg "github.com/fleetdm/fleet/v4/server/config"
@ -498,7 +499,11 @@ the way that the Fleet server works.
var mdmPushService push.Pusher
nanoMDMLogger := service.NewNanoMDMLogger(kitlog.With(logger, "component", "apple-mdm-push"))
pushProviderFactory := buford.NewPushProviderFactory()
pushProviderFactory := buford.NewPushProviderFactory(buford.WithNewClient(func(cert *tls.Certificate) (*http.Client, error) {
return fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{
Certificates: []tls.Certificate{*cert},
})), nil
}))
if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_PUSH") == "1" {
mdmPushService = nopPusher{}
} else {
@ -1029,6 +1034,9 @@ the way that the Fleet server works.
"get_frontend",
service.ServeFrontend(config.Server.URLPrefix, config.Server.SandboxEnabled, httpLogger),
)
frontendHandler = service.WithMDMEnrollmentMiddleware(svc, httpLogger, frontendHandler)
apiHandler = service.MakeHandler(svc, config, httpLogger, limiterStore)
setupRequired, err := svc.SetupRequired(baseCtx)

View file

@ -100,6 +100,7 @@ func TestMaybeSendStatistics(t *testing.T) {
NumSoftwareCVEs: 105,
NumTeams: 9,
NumPolicies: 0,
NumQueries: 200,
NumLabels: 3,
SoftwareInventoryEnabled: true,
VulnDetectionEnabled: true,
@ -139,7 +140,7 @@ func TestMaybeSendStatistics(t *testing.T) {
require.NoError(t, err)
assert.True(t, recorded)
require.True(t, cleanedup)
assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"numHostsFleetDesktopEnabled":1984}`, requestBody)
assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"numHostsFleetDesktopEnabled":1984}`, requestBody)
}
func TestMaybeSendStatisticsSkipsSendingIfNotNeeded(t *testing.T) {

View file

@ -2790,7 +2790,7 @@ Device-authenticated routes are routes used by the Fleet Desktop application. Un
- [Get device's transparency URL](#get-devices-transparency-url)
- [Download device's MDM manual enrollment profile](#download-devices-mdm-manual-enrollment-profile)
- [Migrate device to Fleet from another MDM solution](#migrate-device-to-fleet-from-another-mdm-solution)
- [Trigger FileVault key escrow](#trigger-filevault-key-escrow)
- [Trigger Linux disk encryption escrow](#trigger-linux-disk-encryption-escrow)
- [Report an agent error](#report-an-agent-error)
#### Refetch device's host
@ -2876,7 +2876,6 @@ Gets all information required by Fleet Desktop, this includes things like the nu
"notifications": {
"needs_mdm_migration": true,
"renew_enrollment_profile": false,
"enforce_bitlocker_encryption": false,
},
"config": {
"org_info": {
@ -2898,8 +2897,6 @@ In regards to the `notifications` key:
- `needs_mdm_migration` means that the device fits all the requirements to allow the user to initiate an MDM migration to Fleet.
- `renew_enrollment_profile` means that the device is currently unmanaged from MDM but should be DEP enrolled into Fleet.
- `enforce_bitlocker_encryption` applies only to Windows devices and means that it should encrypt the disk and report the encryption key back to Fleet.
#### Get device's software
@ -3098,7 +3095,7 @@ This supports the dynamic discovery of API features supported by the server for
#### Get device's transparency URL
Returns the URL to open when clicking the "Transparency" menu item in Fleet Desktop. Note that _Fleet Premium_ is required to configure a custom transparency URL.
Returns the URL to open when clicking the "About Fleet" menu item in Fleet Desktop. Note that _Fleet Premium_ is required to configure a custom transparency URL.
`GET /api/v1/fleet/device/{token}/transparency`
@ -3170,6 +3167,30 @@ Signals the Fleet server to send a webbook request with the device UUID and seri
---
### Trigger Linux disk encryption escrow
_Available in Fleet Premium_
Signals the fleet server to queue up the LUKS disk encryption escrow process (LUKS passphrase and slot key). If validation succeeds (disk encryption must be enforced for the team, the host's platform must be supported, the host's disk must already be encrypted, and the host's Orbit version must be new enough), this adds a notification flag for Orbit that, triggers escrow from the Orbit side.
`POST /api/v1/fleet/device/{token}/mdm/linux/trigger_escrow`
##### Parameters
| Name | Type | In | Description |
| ----- | ------ | ---- | ---------------------------------- |
| token | string | path | The device's authentication token. |
##### Example
`POST /api/v1/fleet/device/abcdef012456789/mdm/linux/trigger_escrow`
##### Default response
`Status: 204`
---
### Report an agent error
Notifies the server about an agent error, resulting in two outcomes:
@ -3199,8 +3220,46 @@ Notifies the server about an agent error, resulting in two outcomes:
## Orbit-authenticated routes
- [Escrow LUKS data](#escrow-luks-data)
- [Get the status of a device in the setup experience](#get-the-status-of-a-device-in-the-setup-experience)
---
### Escrow LUKS data
`POST /api/fleet/orbit/luks_data`
##### Parameters
| Name | Type | In | Description |
| ----- | ------ | ---- | ---------------------------------- |
| orbit_node_key | string | body | The Orbit's node key for authentication. |
| client_error | string | body | An error description if the LUKS key escrow process fails client-side. If provided, passphrase/salt/key slot request parameters are ignored and may be omitted. |
| passphrase | string | body | The LUKS passphrase generated for Fleet (the end user's existing passphrase is not transmitted) |
| key_slot | int | body | The LUKS key slot ID corresponding to the provided passphrase |
| salt | string | body | The salt corresponding to the specified LUKS key slot. Provided to track cases where an end user rotates LUKS credentials (at which point we'll no longer be able to decrypt data with the escrowed passphrase). |
##### Example
`POST /api/v1/fleet/orbit/luks_data`
##### Request body
```json
{
"orbit_node_key":"FbvSsWfTRwXEecUlCBTLmBcjGFAdzqd/",
"passphrase": "6e657665-7220676f-6e6e6120-67697665-20796f75-207570",
"salt": "d34db33f",
"key_slot": 1,
"client_error": ""
}
```
##### Default response
`Status: 204`
---
### Get the status of a device in the setup experience

View file

@ -30,6 +30,8 @@ Fleet requires at least MySQL version 8.0.36, and is tested using the InnoDB sto
There are many "drop-in replacements" for MySQL available. If you'd like to experiment with some bleeding-edge technology and use Fleet with one of these alternative database servers, we think that's awesome! Please be aware they are not officially supported and that it is very important to set up a dev environment to thoroughly test new releases.
> If you use multiple databases per database server for multiple Fleet instances, you'll need to provision more resources for your database server to ensure performance. You can experiment with finding the right resourcing for your needs.
### Redis
Fleet uses Redis to ingest and queue the results of distributed queries, cache data, etc. Many cloud providers (such as [AWS](https://aws.amazon.com/elasticache/) and [GCP](https://console.cloud.google.com/launcher/details/click-to-deploy-images/redis)) host reliable Redis services which you may consider for this purpose. A well supported Redis [Docker image](https://hub.docker.com/_/redis/) also exists if you would rather run Redis in a container. For more information on how to configure the `fleet` binary to use the correct Redis instance, see the [Redis configuration](https://fleetdm.com/docs/configuration/fleet-server-configuration#redis) documentation.

View file

@ -143,3 +143,4 @@ This workflow takes about 30 minutes to complete and supports between 10 and 350
<meta name="pageOrderInSection" value="100">
<meta name="description" value="Learn how to easily deploy Fleet on Render or AWS with Terraform.">
<meta name="title" value="Hosting Fleet">

View file

@ -1516,15 +1516,15 @@ 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
var didUpdate 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 {
if *enable && 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")
}
tm.Config.MDM.EnableDiskEncryption = *enable
didUpdate = true
didUpdateMacOSDiskEncryption = true
}
}
@ -1537,13 +1537,7 @@ func (svc *Service) updateTeamMDMDiskEncryption(ctx context.Context, tm *fleet.T
if err != nil {
return err
}
// macOS-specific stuff. For legacy reasons we check if apple is configured
// via `appCfg.MDM.EnabledAndConfigured`
//
// TODO: is there a missing bitlocker activity feed item? (see same TODO on
// other methods that deal with disk encryption)
if appCfg.MDM.EnabledAndConfigured && didUpdateMacOSDiskEncryption {
if appCfg.MDM.EnabledAndConfigured {
var act fleet.ActivityDetails
if tm.Config.MDM.EnableDiskEncryption {
act = fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &tm.ID, TeamName: &tm.Name}

View file

@ -8,6 +8,7 @@ import InputField from "components/forms/fields/InputField";
import validUrl from "components/forms/validators/valid_url";
import SectionHeader from "components/SectionHeader";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import { IAppConfigFormProps, IFormField } from "../constants";
const baseClass = "app-config-form";
@ -209,15 +210,18 @@ const Sso = ({
name="enableJitProvisioning"
value={enableJitProvisioning}
parseTarget
helpText={
<>
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/just-in-time-provisioning`}
text="Learn more"
newTab
/>{" "}
about just-in-time (JIT) user provisioning.
</>
}
>
<>
Create user and sync permissions on login{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/just-in-time-provisioning"
text="Learn more"
newTab
/>
</>
Create user and sync permissions on login
</Checkbox>
)}
<Button

View file

@ -4,6 +4,8 @@ import strUtils from "utilities/strings";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
const baseClass = "delete-host-modal";
@ -61,8 +63,21 @@ const DeleteHostModal = ({
<Modal title="Delete host" onExit={onCancel} className={baseClass}>
<>
<p>
This will remove the record of <b>{hostText()}</b>.{largeVolumeText()}
This will remove the record of <b>{hostText()}</b> and associated data
(e.g. unlock PINs).{largeVolumeText()}
</p>
<ul>
<li>
macOS, Windows, or Linux hosts will re-appear unless Fleet&apos;s
agent is uninstalled.{" "}
<CustomLink
text="Uninstall Fleet's agent"
url={`${LEARN_MORE_ABOUT_BASE_LINK}/uninstall-fleetd`}
newTab
/>
</li>
<li>iOS and iPadOS hosts will re-appear unless MDM is turned off.</li>
</ul>
<div className="modal-cta-wrap">
<Button
type="button"

View file

@ -0,0 +1,10 @@
.delete-host-modal {
ul {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $pad-medium;
align-self: stretch;
padding-inline-start: $pad-large;
}
}

View file

@ -1,6 +1,6 @@
import React from "react";
import { noop } from "lodash";
import { screen, waitFor } from "@testing-library/react";
import { screen, fireEvent } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockUser from "__mocks__/userMock";
@ -50,7 +50,7 @@ describe("Host Summary section", () => {
const osqueryVersion = summaryData.osquery_version as string;
const fleetdVersion = summaryData.fleet_desktop_version as string;
const { user } = render(
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
@ -60,18 +60,17 @@ describe("Host Summary section", () => {
);
expect(screen.getByText("Agent")).toBeInTheDocument();
await waitFor(() => {
waitFor(() => {
user.hover(screen.getByText(new RegExp(orbitVersion, "i")));
});
expect(
screen.getByText(new RegExp(osqueryVersion, "i"))
).toBeInTheDocument();
expect(
screen.getByText(new RegExp(fleetdVersion, "i"))
).toBeInTheDocument();
});
await fireEvent.mouseEnter(
screen.getByText(new RegExp(orbitVersion, "i"))
);
expect(
screen.getByText(new RegExp(osqueryVersion, "i"))
).toBeInTheDocument();
expect(
screen.getByText(new RegExp(fleetdVersion, "i"))
).toBeInTheDocument();
});
it("omit fleet desktop from tooltip if no fleet desktop version", async () => {
@ -90,7 +89,7 @@ describe("Host Summary section", () => {
const orbitVersion = summaryData.orbit_version as string;
const osqueryVersion = summaryData.osquery_version as string;
const { user } = render(
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
@ -100,7 +99,10 @@ describe("Host Summary section", () => {
);
expect(screen.getByText("Agent")).toBeInTheDocument();
await user.hover(screen.getByText(orbitVersion));
await fireEvent.mouseEnter(
screen.getByText(new RegExp(orbitVersion, "i"))
);
expect(
screen.getByText(new RegExp(osqueryVersion, "i"))

View file

@ -168,7 +168,7 @@ const mdmService = {
},
getProfilesStatusSummary: (teamId: number) => {
let { MDM_PROFILES_STATUS_SUMMARY: path } = endpoints;
let { PROFILES_STATUS_SUMMARY: path } = endpoints;
if (teamId) {
path = `${path}?${buildQueryStringFromParams({ team_id: teamId })}`;

View file

@ -114,7 +114,7 @@ export default {
MDM_PROFILE: (id: string) => `/${API_VERSION}/fleet/mdm/profiles/${id}`,
MDM_UPDATE_APPLE_SETTINGS: `/${API_VERSION}/fleet/mdm/apple/settings`,
MDM_PROFILES_STATUS_SUMMARY: `/${API_VERSION}/fleet/mdm/profiles/summary`,
PROFILES_STATUS_SUMMARY: `/${API_VERSION}/fleet/configuration_profiles/summary`,
MDM_DISK_ENCRYPTION_SUMMARY: `/${API_VERSION}/fleet/mdm/disk_encryption/summary`,
MDM_APPLE_SSO: `/${API_VERSION}/fleet/mdm/sso`,
MDM_APPLE_ENROLLMENT_PROFILE: (token: string, ref?: string) => {

View file

@ -796,20 +796,6 @@ You can learn more about how Fleet approaches security in the [security handbook
In responding to security questionnaires, Fleet endeavors to provide full transparency via our [security policies](https://fleetdm.com/handbook/digital-experience/security-policies#security-policies), [trust](https://trust.fleetdm.com/), and [application security](https://fleetdm.com/handbook/digital-experience/application-security) documentation. In addition to this documentation, please refer to [the vendor questionnaires page](https://fleetdm.com/handbook/digital-experience/vendor-questionnaires). [Contact the Sales department](https://fleetdm.com/handbook/sales#contact-us) to address any pending questionnaires.
## Getting a contract signed
If a contract is ready for signature and requires no review or revision, log into DocuSign (credentials in 1Password) and route the agreement to the CEO for signature.
When a contract is going to be routed for signature by someone outside of Fleet (i.e. the vendor or customer), the requestor is responsible for working with the other party to make sure the document gets routed to the CEO for signature.
The SLA for contract signature is **2 business days**. Please do not follow up on signature unless this time has elapsed.
_**Note:** Signature open time for the CEO is not currently measured, to avoid the overhead of creating separate signature issues to measure open and close time. This may change as signature volume increases._
> _**Note:** If a contract is ready for signature and requires no review or revision, log into DocuSign (credentials in 1Password) and route the agreement to the CEO for signature._
Please use [Fleet's billing email address](https://fleetdm.com/handbook/company/communications#email-relays) for all contracts, and never use individual emails except for signature. If the page to sign includes any individual emails in the docusign contract, please remove it before routing to the CEO for signature.
## Getting a contract reviewed
@ -831,6 +817,20 @@ When no further review or action is required for an agreement and the document i
> **Note:** Please submit other legal questions and requests to [Digital Experience](https://fleetdm.com/handbook/digital-experience#contact-us).
## Getting a contract signed
If a contract is ready for signature and requires no review or revision, log into DocuSign (credentials in 1Password) and route the agreement to the CEO for signature.
When a contract is going to be routed for signature by someone outside of Fleet (i.e. the vendor or customer), the requestor is responsible for working with the other party to make sure the document gets routed to the CEO for signature.
The SLA for contract signature is **2 business days**. Please do not follow up on signature unless this time has elapsed.
_**Note:** Signature open time for the CEO is not currently measured, to avoid the overhead of creating separate signature issues to measure open and close time. This may change as signature volume increases._
> _**Note:** If a contract is ready for signature and requires no review or revision, log into DocuSign (credentials in 1Password) and route the agreement to the CEO for signature._
Please use [Fleet's billing email address](https://fleetdm.com/handbook/company/communications#email-relays) for all contracts, and never use individual emails except for signature. If the page to sign includes any individual emails in the docusign contract, please remove it before routing to the CEO for signature.
## Trust

View file

@ -471,6 +471,11 @@ Beginning with macOS 16, Fleet will offer same-day support for all major version
6. When all bugs are fixed, follow the [writing a feature guide](https://fleetdm.com/handbook/engineering#write-a-feature-guide) process to publish an article announcing Fleet same-day support for the new major release.
### Maintain TUF repo for secure agent updates
Instructions for creating and maintaining a TUF repo are available on our [TUF handbook page](https://fleetdm.com/handbook/engineering/tuf).
## Rituals
<rituals :rituals="rituals['handbook/engineering/engineering.rituals.yml']"></rituals>

View file

@ -1,3 +1,13 @@
-
task: "Rotate the TUF root keys"
startedOn: "2024-09-01"
frequency: "Annually"
description: https://fleetdm.com/handbook/engineering/tuf#rotate-the-root-keys
moreInfoUrl: https://fleetdm.com/handbook/engineering/tuf#rotate-the-root-keys
dri: "lukeheath"
autoIssue:
labels: [ "~engineering-initiated", "P2" ]
repo: "fleet"
-
task: "Renew Apple certificate signing request (CSR)"
startedOn: "2024-09-01"

138
handbook/engineering/tuf.md Normal file
View file

@ -0,0 +1,138 @@
# The Update Framework (TUF)
This handbook page outlines the processes required to create and maintain a TUF repo at Fleet.
## Create a new TUF repo
> This process requires use of the `fleetctl` binary on Ubuntu. As of Nov. 6, 2024, Fleet only builds a `fleetctl` binary for Linux on x64. For this reason, a VM of Ubuntu on a newer Silicon MacBook (ARM) will not work. A device with an x64 processor is required.
1. Follow the guide to create a [bootable Ubuntu USB drive](https://ubuntu.com/tutorials/create-a-usb-stick-on-ubuntu#1-overview) running the latest LTS version of Ubuntu Desktop.
2. Download the latest version of the `fleetctl` for Linux from the [Fleet releases GitHub page](https://github.com/fleetdm/fleet/releases).
3. Download the `tuf` CLI from [go-tuf's v0.5.2 GitHub releases page](https://github.com/theupdateframework/go-tuf/releases/tag/v0.5.2). It's important to use the same version of `go-tuf` that is used by `fleetctl`.
4. Connect a new USB drive and copy the `fleetctl` binary and `tuf` binary to the USB drive. The `tuf` binary will likely be in `~/go/bin/`.
5. Open 1Password and click "New item", then select "Secure note". Name the note to match the new TUF repo.
6. Next, generate four passwords, one for each role's key. Click "Add more", select "Password", then "Generate a new password".
7. Click the "Password" label in the input field and change it to "root passphrase". Repeat this two two more times for "root2 passphrase" and "root3 passphrase" as backups.
8. Repeat three more times for "targets passphrase", "snapshot passphrase", and "timestamp passphrase". Backups are not necessary for these keys because root keys can generate new ones.
9. Disconnect both USB drives.
10. Connect the bootable Ubuntu USB drive to the signing device and boot to Ubuntu. When the boot screen appears, press the key the manufacturer has set to enter the boot menu. This is typically F1, F10, or ESC.
11. On the boot menu, select the Ubuntu USB drive, then "Try or Install Ubuntu" to boot directly from the USB drive.
12. Walk through the setup steps and **do not** connect to the internet.
13. After reaching the Ubuntu desktop, plug in the USB drive containing the `fleetctl` and `tuf` binaries.
14. Click the "Show Apps" icon in the bottom-left corner, and open the Terminal app.
15. Mount the USB drive and navigate to the directory.
16. Run `./fleetctl updates init` to initialize a new TUF repo on the USB drive. Manually type in the passphrases for each role's key that you generated in 1Password.
17. Create multiple root keys in case one is lost. Run `mv keys/root.json keys/root1.json` to retain the first root key. Then run `./tuf gen-key root` and enter the passphrase for "root2". Repeat one more time for "root3". When complete, you should have three root keys: `root1.json`, `root2.json`, `root3.json`.
18. The last root key generated (`root3.json`) will be the only signature on the metadata at `staged/root.json`. We want to sign with all root keys. Run `mv keys/root1.json keys/root.json`, then run `./tuf sign root.json` to sign with key 1. Repeat the step for key 2 so that your `staged/root.json` is signed by all three root keys.
19. Plug in additional USB drives and copy only the `keys` directory. They will serve USB root backups.
20. Next, plug in a USB drive to serve as the repo drive to copy files for signing. This USB drive will never contain keys. When plugged in, copy only the `/repository` and `/staged` directories. Make sure **not** to copy the `/keys` directory.
21. Next, plug in a last USB drive to serve as your day-to-day signing drive. This will contains the targets, snapshot, and timestamp keys, but will not contain the root keys. Copy the `repository` and `staged` directories. Next, copy only the `keys/targets.json`, `keys/snapshot.json`, and `keys/timestamp.json` keys to the drive. Do **NOT** copy any of the root keys.
22. At this point, all USB drives can be removed and your offline signing device or VM turned off.
23. On your device connected to the internet, plug in the repo USB drive. This one should contain only the `repository` and `staged` directories. Copy the files from the USB drive to a working directory on your internet-connective device.
24. Upload the files to your desired file hosting location, typically AWS S3 or CloudFlare R2.
You now have a functional, secure TUF repo. You can now configure and use the [Fleet TUF repo release script](https://github.com/fleetdm/fleet/tree/main/tools/tuf) to add new file targets.
If you need to run TUF commands that are not available using the `fleetctl` binary, additional functionality is available using the `tuf` binary [documented by go-tuf](https://pkg.go.dev/github.com/theupdateframework/go-tuf#section-readme).
## Read and write to TUF repo on Cloudflare R2
Fleet hosts our TUF repo in Cloudflare R2 buckets for production and staging, updates.fleetdm.com and updates-staging.fleetdm.com. Read and write operations are performed used the [AWS CLI](https://developers.cloudflare.com/r2/examples/aws/aws-cli/) tool configured to communicate with R2.
Once configured, use the [Fleet TUF repo release script](https://github.com/fleetdm/fleet/tree/main/tools/tuf) to add new file targets. You can use the `aws s3 cp` command to push and pull objects: `aws s3 cp . s3://<bucket-name> --recursive --endpoint-url https://<accountid>.r2.cloudflarestorage.com`
## Add new TUF keys for authorized team members
The CTO is responsible for determining who has access to push agent updates. Timestamp and Snapshot keys can be held online, so their use can be automated, but Targets and Root keys must always be held offline. The root keys are held by the CTO and CEO in secure locations. Root keys are retrieved once per year to rotate them before their annual expiration, or to sign for new Targets keys as needed. Targets keys may be generated to provide approved team members the ability to push agent updates to the TUF repo.
This process requires running TUF commands that are not available using the `fleetctl` binary, so the `tuf` v0.5.2 CLI binary [documented by go-tuf](https://pkg.go.dev/github.com/theupdateframework/go-tuf#section-readme) needs to be [downloaded and compiled](https://github.com/theupdateframework/go-tuf/releases/tag/v0.5.2) for local use.
There are two roles required to complete these steps, the "Root" role who holds the root keys, and the "Releaser" role, who is gaining access to push updates.
1. The Releaser creates a new local directory to store the TUF repo. The Releaser creates a sub-directory called `repository`.
2. The Realeaser pulls down the contents of the TUF repo into the `repository` sub-directory.
3. From the root of their TUF directory, the Releaser runs `tuf gen-key --expires=365 targets`. This will create a `keys` sub-directory and `staged` sub-directory. Next, the Releaser runs `tuf gen-key --expires=365 snapshot`, then `tuf gen-key --expires=365 timestamp` to create keys for those roles. Passphrases should be generated by and stored on 1Password.
4. The Releaser copies the `keys` directory to a USB drive, and deletes the `keys` directory from their local hard drive.
5. The Releaser sends the `staged/root.json` to the Root role for signing. Note this file is safe to share and is publicly available.
6. The Root role receives the `staged/root.json` file and copies it to a USB drive.
7. The Root role boots into the secure Ubuntu boot drive created during TUF repo creation.
8. The Root role connects the USB drive containing the `staged/root.json` file for signing.
9. The Root role connects the USB drive containing the root keys.
10. The Root role copies the `staged/root.json` onto the root keys USB at `staged/root.json`.
11. The root keys USB contains the `tuf` binary. Run `./tuf sign root.json` to sign the staged root metadata.
12. The Root role copies the signed `staged/root.json` back to the original USB drive they copied it from.
13. The Root role turns off the Ubuntu boot drive and accesses an online computer.
14. The Root role connects the USB drive containing the signed `staged/root.json` file and copies it to their local hard drive's TUF location in the same `staged/root.json`.
15. From the root of their local TUF repo, the Root role runs `tuf commit` to commit the staged root metadata to the `repository` directory.
16. The Root role pushes the updated contents of the `repository` directory to the remote TUF server.
17. The Releaser role can now run `tuf sign` to sign agent updates using their offline Targets key.
## Rotate the root keys
The root keys expire every year and must be manually rotated at least 30 days prior to expiration.
1. The root keys are retrieved from their secure location.
2. The offline Ubuntu bootable USB drive is turned on.
3. The root keys USB drive is connected to the Ubuntu bootable instance. Before proceeding, make two backups of the root keys on USB drives for safe keeping. They will be deleted when the root keys have been successfully rotated.
4. Add three new root keys using the steps documented in creating a new TUF repo.
5. Run `tuf sign root.json` to sign the newly added root keys with an existing root key.
6. Run `tuf commit` to commit the staged metadata with new root keys.
7. Using one of the new root keys, run `tuf revoke-key <role> <id>`. Run this command for each of the old, expiring root keys.
8. Using each of the new root keys, run `tuf sign root.json` to sign the root metadata removing the old root keys and adding the new keys so that the new root.json is signed by all root keys.
9. Using one of the new root keys, run `tuf commit` to commit the staged root metadata.
10. Confirm the file in `repository/root.json` contains the new root key ids by comparing the ids listed in `signed.roles.root.keyids` to the signatures in `signatures`. Make sure all root ids have signed.
11. Copy the `repository` directory to the local drive of an online device and push to the remote TUF repo.
12. Confirm that agent updates are continuing with the new `root.json`. Once confirmed, it is safe to delete the old root keys and backup the new keys.
<meta name="maintainedBy" value="lukeheath">
<meta name="description" value="This page outlines our TUF creation and maintenance processes.">

View file

@ -92,8 +92,8 @@ controls:
enable_end_user_authentication: true
macos_setup_assistant: null
macos_updates:
deadline: "2024-08-23"
minimum_version: "14.6.1"
deadline: "2024-12-02"
minimum_version: "15.1.1"
windows_settings:
custom_settings:
- path: ../lib/configuration-profiles/windows-firewall.xml

View file

@ -61,8 +61,8 @@ controls:
enable_end_user_authentication: true
macos_setup_assistant: null
macos_updates:
deadline: "2024-08-23"
minimum_version: "14.6.1"
deadline: "2024-12-02"
minimum_version: "15.1.1"
windows_settings:
custom_settings: null
windows_updates:

View file

@ -2,8 +2,6 @@
// SYSTEM service on Windows) as the current login user.
package execuser
import "context"
type eopts struct {
env [][2]string
args [][2]string
@ -51,14 +49,3 @@ func RunWithOutput(path string, opts ...Option) (output []byte, exitCode int, er
}
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)
}

View file

@ -1,7 +1,6 @@
package execuser
import (
"context"
"errors"
"fmt"
"io"
@ -10,6 +9,8 @@ import (
)
// run uses macOS open command to start application as the current login user.
// Note that the child process spawns a new process in user space and thus it is not
// effective to add a context to this function to cancel the child process.
func run(path string, opts eopts) (lastLogs string, err error) {
info, err := os.Stat(path)
if err != nil {
@ -53,7 +54,3 @@ func run(path string, opts eopts) (lastLogs string, err error) {
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")
}

View file

@ -3,7 +3,6 @@ package execuser
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
@ -33,6 +32,12 @@ func run(path string, opts eopts) (lastLogs string, err error) {
path,
)
if len(opts.args) > 0 {
for _, arg := range opts.args {
args = append(args, arg[0], arg[1])
}
}
cmd := exec.Command("sudo", args...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
@ -74,37 +79,6 @@ func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err er
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 {

View file

@ -6,7 +6,6 @@ package execuser
// To view what was modified/added, you can use the execuser_windows_diff.sh script.
import (
"context"
"errors"
"fmt"
"os"
@ -122,10 +121,6 @@ func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err er
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.

View file

@ -25,10 +25,10 @@ const (
entryDialogTitle = "Enter disk encryption passphrase"
entryDialogText = "Passphrase:"
retryEntryDialogText = "Passphrase incorrect. Please try again."
infoFailedTitle = "Encryption key escrow"
infoTitle = "Disk encryption"
infoFailedText = "Failed to escrow key. Please try again later."
infoSuccessTitle = "Encryption key escrow"
infoSuccessText = "Key escrowed successfully."
infoSuccessText = "Success! Now, return to your browser window and follow the instructions to verify disk encryption."
timeoutMessage = "Please visit Fleet Desktop > My device and click Create key"
maxKeySlots = 8
userKeySlot = 0 // Key slot 0 is assumed to be the location of the user's passphrase
)
@ -53,6 +53,11 @@ func (lr *LuksRunner) Run(oc *fleet.OrbitConfig) error {
response.Err = err.Error()
}
if len(key) == 0 && err == nil {
// dialog was canceled or timed out
return nil
}
response.Passphrase = string(key)
response.KeySlot = keyslot
@ -76,7 +81,7 @@ func (lr *LuksRunner) Run(oc *fleet.OrbitConfig) error {
}
// Show error in dialog
if err := lr.infoPrompt(ctx, infoFailedTitle, infoFailedText); err != nil {
if err := lr.infoPrompt(ctx, infoTitle, infoFailedText); err != nil {
log.Info().Err(err).Msg("failed to show failed escrow key dialog")
}
@ -84,14 +89,14 @@ func (lr *LuksRunner) Run(oc *fleet.OrbitConfig) error {
}
if response.Err != "" {
if err := lr.infoPrompt(ctx, infoFailedTitle, response.Err); err != nil {
if err := lr.infoPrompt(ctx, infoTitle, 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 {
if err := lr.infoPrompt(ctx, infoTitle, infoSuccessText); err != nil {
log.Info().Err(err).Msg("failed to show success escrow key dialog")
}
@ -108,6 +113,19 @@ func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]by
return nil, nil, fmt.Errorf("Failed to show passphrase entry prompt: %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
}
err = lr.notifier.ShowProgress(ctx, dialog.ProgressOptions{
Title: infoTitle,
Text: "Validating passphrase...",
})
if err != nil {
log.Error().Err(err).Msg("failed to show progress dialog")
}
// Validate the passphrase
for {
valid, err := lr.passphraseIsValid(ctx, device, devicePath, passphrase, userKeySlot)
@ -123,11 +141,27 @@ func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]by
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
}
err = lr.notifier.ShowProgress(ctx, dialog.ProgressOptions{
Title: infoTitle,
Text: "Validating passphrase...",
})
if err != nil {
log.Error().Err(err).Msg("failed to show progress dialog after retry")
}
}
if len(passphrase) == 0 {
log.Debug().Msg("Passphrase is empty, no password supplied, dialog was canceled, or timed out")
return nil, nil, nil
err = lr.notifier.ShowProgress(ctx, dialog.ProgressOptions{
Title: infoTitle,
Text: "Key escrow in progress...",
})
if err != nil {
log.Error().Err(err).Msg("failed to show progress dialog")
}
escrowPassphrase, err := generateRandomPassphrase()
@ -216,14 +250,18 @@ func (lr *LuksRunner) entryPrompt(ctx context.Context, title, text string) ([]by
TimeOut: 1 * time.Minute,
})
if err != nil {
switch err {
case dialog.ErrCanceled:
switch {
case errors.Is(err, dialog.ErrCanceled):
log.Debug().Msg("end user canceled key escrow dialog")
return nil, nil
case dialog.ErrTimeout:
case errors.Is(err, dialog.ErrTimeout):
log.Debug().Msg("key escrow dialog timed out")
err := lr.infoPrompt(ctx, infoTitle, timeoutMessage)
if err != nil {
log.Info().Err(err).Msg("failed to show timeout dialog")
}
return nil, nil
case dialog.ErrUnknown:
case errors.Is(err, dialog.ErrUnknown):
return nil, err
default:
return nil, err
@ -237,11 +275,11 @@ 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,
TimeOut: 1 * time.Minute,
})
if err != nil {
switch err {
case dialog.ErrTimeout:
switch {
case errors.Is(err, dialog.ErrTimeout):
log.Debug().Msg("successPrompt timed out")
return nil
default:

View file

@ -7,28 +7,37 @@ import (
"github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
"github.com/fleetdm/fleet/v4/orbit/pkg/execuser"
"github.com/fleetdm/fleet/v4/orbit/pkg/platform"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/rs/zerolog/log"
)
const zenityProcessName = "zenity"
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
// killZenityFunc can be set in tests to mock killing the zenity process.
killZenityFunc func()
}
// 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,
cmdWithOutput: execCmdWithOutput,
cmdWithWait: execCmdWithWait,
killZenityFunc: killZenityProcesses,
}
}
// 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) {
z.killZenityFunc()
args := []string{"--entry"}
if opts.Title != "" {
args = append(args, fmt.Sprintf("--title=%s", opts.Title))
@ -47,9 +56,9 @@ func (z *Zenity) ShowEntry(ctx context.Context, opts dialog.EntryOptions) ([]byt
if err != nil {
switch statusCode {
case 1:
return nil, ctxerr.Wrap(ctx, dialog.ErrCanceled)
return nil, dialog.ErrCanceled
case 5:
return nil, ctxerr.Wrap(ctx, dialog.ErrTimeout)
return nil, dialog.ErrTimeout
default:
return nil, ctxerr.Wrap(ctx, dialog.ErrUnknown, err.Error())
}
@ -60,6 +69,8 @@ func (z *Zenity) ShowEntry(ctx context.Context, opts dialog.EntryOptions) ([]byt
// ShowInfo displays an information dialog. It returns errors ErrTimeout or ErrUnknown.
func (z *Zenity) ShowInfo(ctx context.Context, opts dialog.InfoOptions) error {
z.killZenityFunc()
args := []string{"--info"}
if opts.Title != "" {
args = append(args, fmt.Sprintf("--title=%s", opts.Title))
@ -94,6 +105,8 @@ func (z *Zenity) ShowInfo(ctx context.Context, opts dialog.InfoOptions) error {
// 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 {
z.killZenityFunc()
args := []string{"--progress"}
if opts.Title != "" {
args = append(args, fmt.Sprintf("--title=%s", opts.Title))
@ -122,7 +135,7 @@ func execCmdWithOutput(ctx context.Context, args ...string) ([]byte, int, error)
opts = append(opts, execuser.WithArg(arg, "")) // Using empty value for positional args
}
output, exitCode, err := execuser.RunWithOutput("zenity", opts...)
output, exitCode, err := execuser.RunWithOutput(zenityProcessName, opts...)
// Trim the newline from zenity output
output = bytes.TrimSuffix(output, []byte("\n"))
@ -136,5 +149,13 @@ func execCmdWithWait(ctx context.Context, args ...string) error {
opts = append(opts, execuser.WithArg(arg, "")) // Using empty value for positional args
}
return execuser.RunWithWait(ctx, "zenity", opts...)
_, err := execuser.Run(zenityProcessName, opts...)
return err
}
func killZenityProcesses() {
_, err := platform.KillAllProcessByName(zenityProcessName)
if err != nil {
log.Warn().Err(err).Msg("failed to kill zenity process")
}
}

View file

@ -76,7 +76,8 @@ func TestShowEntryArgs(t *testing.T) {
output: []byte("some output"),
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
cmdWithOutput: mock.runWithOutput,
killZenityFunc: func() {},
}
output, err := z.ShowEntry(ctx, tt.opts)
assert.NoError(t, err)
@ -117,7 +118,8 @@ func TestShowEntryError(t *testing.T) {
exitCode: tt.exitCode,
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
cmdWithOutput: mock.runWithOutput,
killZenityFunc: func() {},
}
output, err := z.ShowEntry(ctx, dialog.EntryOptions{})
require.ErrorIs(t, err, tt.expectedErr)
@ -133,7 +135,8 @@ func TestShowEntrySuccess(t *testing.T) {
output: []byte("some output"),
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
cmdWithOutput: mock.runWithOutput,
killZenityFunc: func() {},
}
output, err := z.ShowEntry(ctx, dialog.EntryOptions{})
assert.NoError(t, err)
@ -168,7 +171,8 @@ func TestShowInfoArgs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
mock := &mockExecCmd{}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
cmdWithOutput: mock.runWithOutput,
killZenityFunc: func() {},
}
err := z.ShowInfo(ctx, tt.opts)
assert.NoError(t, err)
@ -203,7 +207,8 @@ func TestShowInfoError(t *testing.T) {
exitCode: tt.exitCode,
}
z := &Zenity{
cmdWithOutput: mock.runWithOutput,
cmdWithOutput: mock.runWithOutput,
killZenityFunc: func() {},
}
err := z.ShowInfo(ctx, dialog.InfoOptions{})
require.ErrorIs(t, err, tt.expectedErr)
@ -233,7 +238,8 @@ func TestProgressArgs(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
mock := &mockExecCmd{}
z := &Zenity{
cmdWithWait: mock.runWithWait,
cmdWithWait: mock.runWithWait,
killZenityFunc: func() {},
}
err := z.ShowProgress(ctx, tt.opts)
assert.NoError(t, err)
@ -249,7 +255,8 @@ func TestProgressKillOnCancel(t *testing.T) {
waitDuration: 5 * time.Second,
}
z := &Zenity{
cmdWithWait: mock.runWithWait,
cmdWithWait: mock.runWithWait,
killZenityFunc: func() {},
}
done := make(chan struct{})

View file

@ -97,7 +97,8 @@ func WithTLSConfig(conf *tls.Config) TransportOpt {
// NewTransport creates an http transport (a type that implements
// http.RoundTripper) with the provided optional options. The transport is
// derived from Go's http.DefaultTransport and only overrides the specific
// parts it needs to, so that it keeps its sane defaults for the rest.
// parts it needs to, so that it keeps its sane defaults for the rest (such as
// timeouts and proxy support).
func NewTransport(opts ...TransportOpt) *http.Transport {
var to transportOpts
for _, opt := range opts {

View file

@ -274,7 +274,7 @@ func (ds *Datastore) AggregateEnrollSecretPerTeam(ctx context.Context) ([]*fleet
return secrets, nil
}
func (ds *Datastore) getConfigEnableDiskEncryption(ctx context.Context, teamID *uint) (bool, error) {
func (ds *Datastore) GetConfigEnableDiskEncryption(ctx context.Context, teamID *uint) (bool, error) {
if teamID != nil && *teamID > 0 {
tc, err := ds.TeamMDMConfig(ctx, *teamID)
if err != nil {

View file

@ -449,7 +449,7 @@ func testGetConfigEnableDiskEncryption(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.False(t, ac.MDM.EnableDiskEncryption.Value)
enabled, err := ds.getConfigEnableDiskEncryption(ctx, nil)
enabled, err := ds.GetConfigEnableDiskEncryption(ctx, nil)
require.NoError(t, err)
require.False(t, enabled)
@ -461,7 +461,7 @@ func testGetConfigEnableDiskEncryption(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, ac.MDM.EnableDiskEncryption.Value)
enabled, err = ds.getConfigEnableDiskEncryption(ctx, nil)
enabled, err = ds.GetConfigEnableDiskEncryption(ctx, nil)
require.NoError(t, err)
require.True(t, enabled)
@ -474,7 +474,7 @@ func testGetConfigEnableDiskEncryption(t *testing.T, ds *Datastore) {
require.NotNil(t, tm)
require.False(t, tm.Config.MDM.EnableDiskEncryption)
enabled, err = ds.getConfigEnableDiskEncryption(ctx, &team1.ID)
enabled, err = ds.GetConfigEnableDiskEncryption(ctx, &team1.ID)
require.NoError(t, err)
require.False(t, enabled)

View file

@ -1218,7 +1218,7 @@ func (ds *Datastore) applyHostFilters(
return "", nil, ctxerr.Wrap(ctx, err, "building query to filter macOS settings status")
}
sqlStmt, whereParams = filterHostsByMacOSDiskEncryptionStatus(sqlStmt, opt, whereParams)
if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil {
if enableDiskEncryption, err := ds.GetConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil, ctxerr.Wrap(
ctx, &fleet.BadRequestError{
@ -4547,6 +4547,7 @@ func (ds *Datastore) HostLite(ctx context.Context, id uint) (*fleet.Host, error)
"hardware_model",
"computer_name",
"platform",
"os_version",
"team_id",
"distributed_interval",
"logger_tls_period",

View file

@ -662,7 +662,7 @@ func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.Tea
}
query, whereParams = filterHostsByMacOSDiskEncryptionStatus(query, opt, whereParams)
query, whereParams = filterHostsByMDMBootstrapPackageStatus(query, opt, whereParams)
if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil {
if enableDiskEncryption, err := ds.GetConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil {
return "", nil, err
} else if opt.OSSettingsFilter.IsValid() {
query, whereParams, err = ds.filterHostsByOSSettingsStatus(query, opt, whereParams, enableDiskEncryption)

View file

@ -585,7 +585,7 @@ AND (
}
func (ds *Datastore) GetMDMWindowsBitLockerSummary(ctx context.Context, teamID *uint) (*fleet.MDMWindowsBitLockerSummary, error) {
enabled, err := ds.getConfigEnableDiskEncryption(ctx, teamID)
enabled, err := ds.GetConfigEnableDiskEncryption(ctx, teamID)
if err != nil {
return nil, err
}
@ -655,7 +655,7 @@ func (ds *Datastore) GetMDMWindowsBitLockerStatus(ctx context.Context, host *fle
return nil, nil
}
enabled, err := ds.getConfigEnableDiskEncryption(ctx, host.TeamID)
enabled, err := ds.GetConfigEnableDiskEncryption(ctx, host.TeamID)
if err != nil {
return nil, err
}
@ -887,7 +887,7 @@ func subqueryHostsMDMWindowsOSSettingsStatusVerified() (string, []interface{}, e
}
func (ds *Datastore) GetMDMWindowsProfilesSummary(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
includeBitLocker, err := ds.getConfigEnableDiskEncryption(ctx, teamID)
includeBitLocker, err := ds.GetConfigEnableDiskEncryption(ctx, teamID)
if err != nil {
return nil, err
}

View file

@ -714,3 +714,15 @@ func (ds *Datastore) UpdateLiveQueryStats(ctx context.Context, queryID uint, sta
}
return nil
}
func numSavedQueriesDB(ctx context.Context, db sqlx.QueryerContext) (int, error) {
var count int
const stmt = `
SELECT count(*) FROM queries WHERE saved
`
if err := sqlx.GetContext(ctx, db, &count, stmt); err != nil {
return 0, err
}
return count, nil
}

View file

@ -109,8 +109,11 @@ WHERE global_or_team_id = ?`
}
totalInsertions += uint(inserts) // nolint: gosec
if err := setHostAwaitingConfiguration(ctx, tx, hostUUID, true); err != nil {
return ctxerr.Wrap(ctx, err, "setting host awaiting configuration to true")
// Only run setup experience on hosts that have something configured.
if totalInsertions > 0 {
if err := setHostAwaitingConfiguration(ctx, tx, hostUUID, true); err != nil {
return ctxerr.Wrap(ctx, err, "setting host awaiting configuration to true")
}
}
return nil
@ -529,7 +532,7 @@ WHERE host_uuid = ?
if err := sqlx.GetContext(ctx, ds.reader(ctx), &awaitingConfiguration, stmt, hostUUID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
return false, notFound("HostAwaitingConfiguration")
}
return false, ctxerr.Wrap(ctx, err, "getting host awaiting configuration")

View file

@ -40,20 +40,22 @@ func TestSetupExperience(t *testing.T) {
}
}
// TODO(JVE): this test could probably be simplified and most of the ad-hoc SQL removed.
func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
// Create some teams
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
team3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
require.NoError(t, err)
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
// Create some software installers and add them to setup experience
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
@ -97,6 +99,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
return err
})
// Create some VPP apps and add them to setup experience
app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID)
require.NoError(t, err)
@ -110,33 +113,17 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
return err
})
var script1ID, script2ID int64
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
res, err := insertScriptContents(ctx, q, "SCRIPT 1")
if err != nil {
return err
}
id1, _ := res.LastInsertId()
res, err = insertScriptContents(ctx, q, "SCRIPT 2")
if err != nil {
return err
}
id2, _ := res.LastInsertId()
// Create some scripts and add them to setup experience
err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script1", ScriptContents: "SCRIPT 1", TeamID: &team1.ID})
require.NoError(t, err)
err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script2", ScriptContents: "SCRIPT 2", TeamID: &team2.ID})
require.NoError(t, err)
res, err = q.ExecContext(ctx, "INSERT INTO setup_experience_scripts (team_id, global_or_team_id, name, script_content_id) VALUES (?, ?, ?, ?)", team1.ID, team1.ID, "script1", id1)
if err != nil {
return err
}
script1ID, _ = res.LastInsertId()
script1, err := ds.GetSetupExperienceScript(ctx, &team1.ID)
require.NoError(t, err)
res, err = q.ExecContext(ctx, "INSERT INTO setup_experience_scripts (team_id, global_or_team_id, name, script_content_id) VALUES (?, ?, ?, ?)", team2.ID, team2.ID, "script2", id2)
if err != nil {
return err
}
script2ID, _ = res.LastInsertId()
return nil
})
script2, err := ds.GetSetupExperienceScript(ctx, &team2.ID)
require.NoError(t, err)
hostTeam1 := "123"
hostTeam2 := "456"
@ -145,14 +132,26 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, hostTeam1, team1.ID)
require.NoError(t, err)
require.True(t, anythingEnqueued)
awaitingConfig, err := ds.GetHostAwaitingConfiguration(ctx, hostTeam1)
require.NoError(t, err)
require.True(t, awaitingConfig)
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam2, team2.ID)
require.NoError(t, err)
require.True(t, anythingEnqueued)
awaitingConfig, err = ds.GetHostAwaitingConfiguration(ctx, hostTeam2)
require.NoError(t, err)
require.True(t, awaitingConfig)
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam3, team3.ID)
require.NoError(t, err)
require.False(t, anythingEnqueued)
// Nothing is configured for setup experience in team 3, so we do not set
// host_mdm_apple_awaiting_configuration.
awaitingConfig, err = ds.GetHostAwaitingConfiguration(ctx, hostTeam3)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
require.False(t, awaitingConfig)
seRows := []setupExperienceInsertTestRows{}
@ -191,13 +190,13 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
HostUUID: hostTeam1,
Name: "script1",
Status: "pending",
ScriptID: nullableUint(uint(script1ID)), // nolint: gosec
ScriptID: nullableUint(script1.ID),
},
{
HostUUID: hostTeam2,
Name: "script2",
Status: "pending",
ScriptID: nullableUint(uint(script2ID)), // nolint: gosec
ScriptID: nullableUint(script2.ID),
},
} {
var found bool
@ -212,35 +211,28 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
}
}
for _, row := range seRows {
if row.HostUUID == hostTeam3 {
t.Error("team 3 shouldn't have any any entries")
}
}
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "DELETE FROM setup_experience_scripts WHERE global_or_team_id = ?", team2.ID)
if err != nil {
return err
require.Condition(t, func() (success bool) {
for _, row := range seRows {
if row.HostUUID == hostTeam3 {
return false
}
}
_, err = q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = false WHERE global_or_team_id = ?", team2.ID)
if err != nil {
return err
}
_, err = q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = false WHERE global_or_team_id = ?", team2.ID)
if err != nil {
return err
}
return nil
return true
})
// Remove team2's setup experience items
err = ds.DeleteSetupExperienceScript(ctx, &team2.ID)
require.NoError(t, err)
err = ds.SetSetupExperienceSoftwareTitles(ctx, team2.ID, []uint{})
require.NoError(t, err)
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam1, team1.ID)
require.NoError(t, err)
require.True(t, anythingEnqueued)
// team2 now has nothing enqueued
anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam2, team2.ID)
require.NoError(t, err)
require.False(t, anythingEnqueued)
@ -272,7 +264,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
HostUUID: hostTeam1,
Name: "script1",
Status: "pending",
ScriptID: nullableUint(uint(script1ID)), // nolint: gosec
ScriptID: nullableUint(script1.ID),
},
} {
var found bool
@ -908,6 +900,12 @@ func testHostInSetupExperience(t *testing.T, ds *Datastore) {
inSetupExperience, err = ds.GetHostAwaitingConfiguration(ctx, "abc")
require.NoError(t, err)
require.False(t, inSetupExperience)
// host without a record in the table returns not found
inSetupExperience, err = ds.GetHostAwaitingConfiguration(ctx, "404")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
require.False(t, inSetupExperience)
}
func testGetSetupExperienceScriptByID(t *testing.T, ds *Datastore) {

View file

@ -103,6 +103,10 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
if err != nil {
return ctxerr.Wrap(ctx, err, "number of hosts with Fleet desktop installed")
}
numQueries, err := numSavedQueriesDB(ctx, ds.reader(ctx))
if err != nil {
return ctxerr.Wrap(ctx, err, "number of saved queries in DB")
}
stats.NumHostsEnrolled = amountEnrolledHosts
stats.NumUsers = amountUsers
@ -152,6 +156,7 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du
}
}
stats.NumHostsFleetDesktopEnabled = numHostsFleetDesktopEnabled
stats.NumQueries = numQueries
return nil
}

View file

@ -72,6 +72,7 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.Equal(t, 0, stats.NumSoftwareCVEs)
assert.Equal(t, 0, stats.NumTeams)
assert.Equal(t, 0, stats.NumPolicies)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, builtinLabels, stats.NumLabels)
assert.Equal(t, false, stats.SoftwareInventoryEnabled)
assert.Equal(t, true, stats.SystemUsersEnabled)
@ -220,6 +221,7 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.Equal(t, 0, stats.NumSoftwareCVEs)
assert.Equal(t, 1, stats.NumTeams)
assert.Equal(t, 1, stats.NumPolicies)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, builtinLabels+1, stats.NumLabels)
assert.Equal(t, false, stats.SoftwareInventoryEnabled)
assert.Equal(t, false, stats.SystemUsersEnabled)
@ -320,6 +322,7 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.Equal(t, "Fleet", stats.Organization)
assert.Equal(t, 5, stats.NumHostsEnrolled)
assert.Equal(t, 2, stats.NumUsers)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, 0, stats.NumSoftwareVersions)
assert.Equal(t, 0, stats.NumHostSoftwares)
assert.Equal(t, 0, stats.NumSoftwareTitles)
@ -368,6 +371,7 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
assert.Equal(t, "Fleet", stats.Organization)
assert.Equal(t, 5, stats.NumHostsEnrolled)
assert.Equal(t, 2, stats.NumUsers)
assert.Equal(t, 0, stats.NumQueries)
assert.Equal(t, 0, stats.NumSoftwareVersions)
assert.Equal(t, 0, stats.NumHostSoftwares)
assert.Equal(t, 0, stats.NumSoftwareTitles)

View file

@ -899,6 +899,7 @@ type Datastore interface {
GetHostEmails(ctx context.Context, hostUUID string, source string) ([]string, error)
SetOrUpdateHostDisksSpace(ctx context.Context, hostID uint, gigsAvailable, percentAvailable, gigsTotal float64) error
GetConfigEnableDiskEncryption(ctx context.Context, teamID *uint) (bool, error)
SetOrUpdateHostDisksEncryption(ctx context.Context, hostID uint, encrypted bool) error
// SetOrUpdateHostDiskEncryptionKey sets the base64, encrypted key for
// a host

View file

@ -24,6 +24,8 @@ var (
WindowsMDMNotConfiguredMessage = "Windows MDM isn't turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM."
AppleMDMNotConfiguredMessage = "macOS MDM isn't turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM."
AppleABMDefaultTeamDeprecatedMessage = "mdm.apple_bm_default_team has been deprecated. Please use the new mdm.apple_business_manager key documented here: https://fleetdm.com/learn-more-about/apple-business-manager-gitops"
CantTurnOffMDMForWindowsHostsMessage = "Can't turn off MDM for Windows hosts."
CantTurnOffMDMForIOSOrIPadOSMessage = "Can't turn off MDM for iOS or iPadOS hosts. Use wipe instead."
)
// ErrWithStatusCode is an interface for errors that should set a specific HTTP

View file

@ -310,8 +310,8 @@ type MDMDiskEncryptionSummary struct {
RemovingEnforcement MDMPlatformsCounts `db:"removing_enforcement" json:"removing_enforcement"`
}
// MDMProfilesSummary reports the number of hosts being managed with MDM configuration
// profiles. Each host may be counted in only one of four mutually-exclusive categories:
// MDMProfilesSummary reports the number of hosts being managed with configuration
// profiles and/or disk encryption. Each host may be counted in only one of four mutually-exclusive categories:
// Failed, Pending, Verifying, or Verified.
type MDMProfilesSummary struct {
// Verified includes each host where Fleet has verified the installation of all of the

View file

@ -1059,6 +1059,11 @@ type Service interface {
// Returns empty status if the host is not a supported Linux host
LinuxHostDiskEncryptionStatus(ctx context.Context, host Host) (HostMDMDiskEncryption, error)
// GetMDMLinuxProfilesSummary summarizes the current status of Linux disk encryption for
// the provided team (or hosts without a team if teamId is nil), or returns zeroes if disk
// encryption is not enforced on the selected team
GetMDMLinuxProfilesSummary(ctx context.Context, teamId *uint) (MDMProfilesSummary, error)
///////////////////////////////////////////////////////////////////////////////
// Common MDM

View file

@ -20,6 +20,7 @@ type StatisticsPayload struct {
NumSoftwareCVEs int `json:"numSoftwareCVEs"`
NumTeams int `json:"numTeams"`
NumPolicies int `json:"numPolicies"`
NumQueries int `json:"numQueries"`
NumLabels int `json:"numLabels"`
SoftwareInventoryEnabled bool `json:"softwareInventoryEnabled"`
VulnDetectionEnabled bool `json:"vulnDetectionEnabled"`

View file

@ -635,6 +635,8 @@ type GetHostEmailsFunc func(ctx context.Context, hostUUID string, source string)
type SetOrUpdateHostDisksSpaceFunc func(ctx context.Context, hostID uint, gigsAvailable float64, percentAvailable float64, gigsTotal float64) error
type GetConfigEnableDiskEncryptionFunc func(ctx context.Context, teamID *uint) (bool, error)
type SetOrUpdateHostDisksEncryptionFunc func(ctx context.Context, hostID uint, encrypted bool) error
type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string, clientError string, decryptable *bool) error
@ -2087,6 +2089,9 @@ type DataStore struct {
SetOrUpdateHostDisksSpaceFunc SetOrUpdateHostDisksSpaceFunc
SetOrUpdateHostDisksSpaceFuncInvoked bool
GetConfigEnableDiskEncryptionFunc GetConfigEnableDiskEncryptionFunc
GetConfigEnableDiskEncryptionFuncInvoked bool
SetOrUpdateHostDisksEncryptionFunc SetOrUpdateHostDisksEncryptionFunc
SetOrUpdateHostDisksEncryptionFuncInvoked bool
@ -5034,6 +5039,13 @@ func (s *DataStore) SetOrUpdateHostDisksSpace(ctx context.Context, hostID uint,
return s.SetOrUpdateHostDisksSpaceFunc(ctx, hostID, gigsAvailable, percentAvailable, gigsTotal)
}
func (s *DataStore) GetConfigEnableDiskEncryption(ctx context.Context, teamID *uint) (bool, error) {
s.mu.Lock()
s.GetConfigEnableDiskEncryptionFuncInvoked = true
s.mu.Unlock()
return s.GetConfigEnableDiskEncryptionFunc(ctx, teamID)
}
func (s *DataStore) SetOrUpdateHostDisksEncryption(ctx context.Context, hostID uint, encrypted bool) error {
s.mu.Lock()
s.SetOrUpdateHostDisksEncryptionFuncInvoked = true

View file

@ -416,7 +416,7 @@ 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 == "" {
if newAppConfig.MDM.EnableDiskEncryption.Value && 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

View file

@ -1526,7 +1526,13 @@ func (svc *Service) needsOSUpdateForDEPEnrollment(ctx context.Context, m fleet.M
return false, nil
}
return apple_mdm.IsLessThanVersion(m.OSVersion, settings.MinimumVersion.Value)
needsUpdate, err := apple_mdm.IsLessThanVersion(m.OSVersion, settings.MinimumVersion.Value)
if err != nil {
level.Info(svc.logger).Log("msg", "checking os updates settings, cannot compare versions", "serial", m.Serial, "current_version", m.OSVersion, "minimum_version", settings.MinimumVersion.Value)
return false, nil
}
return needsUpdate, nil
}
func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) {
@ -1603,10 +1609,19 @@ func (svc *Service) EnqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Co
return ctxerr.Wrap(ctx, err, "getting host info for mdm apple remove profile command")
}
if h.Platform == "ios" || h.Platform == "ipados" {
switch h.Platform {
case "ios":
fallthrough
case "ipados":
return &fleet.BadRequestError{
Message: "Can't turn off MDM for iOS or iPadOS hosts. Use wipe instead.",
Message: fleet.CantTurnOffMDMForIOSOrIPadOSMessage,
}
case "windows":
return &fleet.BadRequestError{
Message: fleet.CantTurnOffMDMForWindowsHostsMessage,
}
default:
// host is darwin, so continue
}
info, err := svc.ds.GetHostMDMCheckinInfo(ctx, h.UUID)
@ -2137,12 +2152,15 @@ func (svc *Service) updateAppConfigMDMDiskEncryption(ctx context.Context, enable
return err
}
var didUpdate, didUpdateMacOSDiskEncryption bool
var didUpdate bool
if enabled != nil {
if ac.MDM.EnableDiskEncryption.Value != *enabled {
if *enabled && 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")
}
ac.MDM.EnableDiskEncryption = optjson.SetBool(*enabled)
didUpdate = true
didUpdateMacOSDiskEncryption = true
}
}
@ -2150,7 +2168,7 @@ func (svc *Service) updateAppConfigMDMDiskEncryption(ctx context.Context, enable
if err := svc.ds.SaveAppConfig(ctx, ac); err != nil {
return err
}
if didUpdateMacOSDiskEncryption {
if ac.MDM.EnabledAndConfigured { // if macOS MDM is configured, set up FileVault escrow
var act fleet.ActivityDetails
if ac.MDM.EnableDiskEncryption.Value {
act = fleet.ActivityTypeEnabledMacosDiskEncryption{}

View file

@ -4092,7 +4092,7 @@ func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) {
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
err: "invalid current version",
err: "", // no error, allow enrollment to proceed without software update
},
}

View file

@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -1233,3 +1234,44 @@ func registerMDM(
mux.Handle(apple_mdm.MDMPath, mdmHandler)
return nil
}
func WithMDMEnrollmentMiddleware(svc fleet.Service, logger kitlog.Logger, next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/mdm/sso" {
next.ServeHTTP(w, r)
return
}
// if x-apple-aspen-deviceinfo custom header is present, we need to check for minimum os version
di := r.Header.Get("x-apple-aspen-deviceinfo")
if di != "" {
parsed, err := apple_mdm.ParseDeviceinfo(di, false) // FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879)
if err != nil {
// just log the error and continue to next
level.Error(logger).Log("msg", "parsing x-apple-aspen-deviceinfo", "err", err)
next.ServeHTTP(w, r)
return
}
sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(r.Context(), parsed)
if err != nil {
// just log the error and continue to next
level.Error(logger).Log("msg", "checking minimum os version for mdm", "err", err)
next.ServeHTTP(w, r)
return
}
if sur != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
if err := json.NewEncoder(w).Encode(sur); err != nil {
level.Error(logger).Log("msg", "failed to encode software update required", "err", err)
http.Redirect(w, r, r.URL.String()+"?error=true", http.StatusSeeOther)
}
return
}
}
next.ServeHTTP(w, r)
}
}

View file

@ -8638,6 +8638,11 @@ func (s *integrationTestSuite) TestGetHostDiskEncryption() {
require.Equal(t, hostLin.ID, getHostResp.Host.ID)
require.True(t, *getHostResp.Host.DiskEncryptionEnabled)
// should succeed as we no longer require MDM to access this endpoint, as Linux encryption doesn't require MDM
var profiles getMDMProfilesSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profiles)
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profiles)
// set unencrypted for all hosts
require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), hostWin.ID, false))
require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), hostMac.ID, false))
@ -8653,7 +8658,7 @@ func (s *integrationTestSuite) TestGetHostDiskEncryption() {
require.Equal(t, hostMac.ID, getHostResp.Host.ID)
require.False(t, *getHostResp.Host.DiskEncryptionEnabled)
// Linux does not return false, it omits the field when false
// Linux may omit the field when false
getHostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostLin.ID), nil, http.StatusOK, &getHostResp)
require.Equal(t, hostLin.ID, getHostResp.Host.ID)

View file

@ -2881,6 +2881,168 @@ func (s *integrationEnterpriseTestSuite) TestAppleOSUpdatesTeamConfig() {
}, http.StatusUnprocessableEntity, &tmResp)
}
func (s *integrationEnterpriseTestSuite) TestLinuxDiskEncryption() {
t := s.T()
// create a Linux host
noTeamHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "3"),
OsqueryHostID: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "3"),
UUID: t.Name() + "3",
Hostname: t.Name() + "foo3.local",
PrimaryIP: "192.168.1.3",
PrimaryMac: "30-65-EC-6F-C4-60",
Platform: "ubuntu",
OSVersion: "Ubuntu 22.04",
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, noTeamHost, s.ds)
noTeamHost.OrbitNodeKey = &orbitKey
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: "A team"})
require.NoError(t, err)
teamID := ptr.Uint(team.ID)
teamHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "2"),
OsqueryHostID: ptr.String(strings.ReplaceAll(t.Name(), "/", "_") + "2"),
UUID: t.Name() + "2",
Hostname: t.Name() + "foo2.local",
PrimaryIP: "192.168.1.2",
PrimaryMac: "30-65-EC-6F-C4-59",
Platform: "rhel",
OSVersion: "Fedora 38.0", // this check is why HostLite now includes os_version in the data it's selecting
TeamID: teamID,
})
require.NoError(t, err)
teamOrbitKey := setOrbitEnrollment(t, teamHost, s.ds)
teamHost.OrbitNodeKey = &teamOrbitKey
// NO TEAM //
// config profiles endpoint should work but show all zeroes
var profileSummary getMDMProfilesSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// set encrypted for host
require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), noTeamHost.ID, true))
// should still show zeroes
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// turn on disk encryption enforcement
s.Do("POST", "/api/latest/fleet/disk_encryption", updateDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent)
// should show the Linux host as pending
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{Pending: 1}, profileSummary.MDMProfilesSummary)
// encryption summary should succeed (Linux encryption doesn't require MDM)
var summary getMDMDiskEncryptionSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryRequest{}, http.StatusOK, &summary)
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{}, http.StatusOK, &summary)
// disk is encrypted but key hasn't been escrowed yet
require.Equal(t, fleet.MDMDiskEncryptionSummary{ActionRequired: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// trigger escrow process from device
token := "much_valid"
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, noTeamHost.ID, token)
return err
})
// should fail because default Orbit version is too old
res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/mdm/linux/trigger_escrow", token), nil, http.StatusBadRequest)
res.Body.Close()
// should succeed now that Orbit version isn't too old
require.NoError(t, s.ds.SetOrUpdateHostOrbitInfo(context.Background(), noTeamHost.ID, fleet.MinOrbitLUKSVersion, sql.NullString{}, sql.NullBool{}))
res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/mdm/linux/trigger_escrow", token), nil, http.StatusNoContent)
res.Body.Close()
// confirm that Orbit endpoint shows notification flag
var orbitResponse orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config", orbitGetConfigRequest{OrbitNodeKey: orbitKey}, http.StatusOK, &orbitResponse)
require.True(t, orbitResponse.Notifications.RunDiskEncryptionEscrow)
// confirm that second Orbit pull doesn't show notification flag
var secondOrbitResponse orbitGetConfigResponse
s.DoJSON("POST", "/api/fleet/orbit/config", orbitGetConfigRequest{OrbitNodeKey: orbitKey}, http.StatusOK, &secondOrbitResponse)
require.False(t, secondOrbitResponse.Notifications.RunDiskEncryptionEscrow)
// set an error first; the successful write should overwrite that
s.Do("POST", "/api/fleet/orbit/luks_data", orbitPostLUKSRequest{
OrbitNodeKey: *noTeamHost.OrbitNodeKey,
ClientError: "Houston, we had a problem",
}, http.StatusNoContent)
// upload LUKS data
keySlot := ptr.Uint(1)
s.Do("POST", "/api/fleet/orbit/luks_data", orbitPostLUKSRequest{
OrbitNodeKey: *noTeamHost.OrbitNodeKey,
Passphrase: "whale makes pail rise",
Salt: "the team i like lost",
KeySlot: keySlot,
}, http.StatusNoContent)
// confirm verified
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{}, http.StatusOK, &summary)
require.Equal(t, fleet.MDMDiskEncryptionSummary{Verified: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// get passphrase back
var keyResponse getHostEncryptionKeyResponse
s.DoJSON("GET", fmt.Sprintf(`/api/latest/fleet/mdm/hosts/%d/encryption_key`, noTeamHost.ID), getHostEncryptionKeyRequest{}, http.StatusOK, &keyResponse)
s.DoJSON("GET", fmt.Sprintf(`/api/latest/fleet/hosts/%d/encryption_key`, noTeamHost.ID), getHostEncryptionKeyRequest{}, http.StatusOK, &keyResponse)
require.Equal(t, "whale makes pail rise", keyResponse.EncryptionKey.DecryptedValue)
// TEAM //
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{TeamID: teamID}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// set encrypted for host
require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), teamHost.ID, true))
// should still show zeroes
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{TeamID: teamID}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{}, profileSummary.MDMProfilesSummary)
// turn on disk encryption enforcement for team
s.Do("POST", "/api/latest/fleet/disk_encryption", updateDiskEncryptionRequest{TeamID: teamID, EnableDiskEncryption: true}, http.StatusNoContent)
// should show the Linux host as pending
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{TeamID: teamID}, http.StatusOK, &profileSummary)
require.Equal(t, fleet.MDMProfilesSummary{Pending: 1}, profileSummary.MDMProfilesSummary)
// encryption summary should show host as action required
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{TeamID: teamID}, http.StatusOK, &summary)
require.Equal(t, fleet.MDMDiskEncryptionSummary{ActionRequired: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// upload LUKS data (no error, and no trigger, first this time)
keySlot = ptr.Uint(3)
s.Do("POST", "/api/fleet/orbit/luks_data", orbitPostLUKSRequest{
OrbitNodeKey: *teamHost.OrbitNodeKey,
Passphrase: "the mome raths outgrabe",
Salt: "jabberwocky, but salty",
KeySlot: keySlot,
}, http.StatusNoContent)
// confirm verified
s.DoJSON("GET", "/api/latest/fleet/disk_encryption", getMDMDiskEncryptionSummaryRequest{TeamID: teamID}, http.StatusOK, &summary)
require.Equal(t, fleet.MDMDiskEncryptionSummary{Verified: fleet.MDMPlatformsCounts{Linux: 1}}, *summary.MDMDiskEncryptionSummary)
// get passphrase back
s.DoJSON("GET", fmt.Sprintf(`/api/latest/fleet/hosts/%d/encryption_key`, teamHost.ID), getHostEncryptionKeyRequest{}, http.StatusOK, &keyResponse)
require.Equal(t, "the mome raths outgrabe", keyResponse.EncryptionKey.DecryptedValue)
}
func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
t := s.T()
ctx := context.Background()

View file

@ -1,7 +1,13 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@ -15,13 +21,16 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
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/godep"
"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/scep/depot"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/log"
@ -31,6 +40,7 @@ import (
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mozilla.org/pkcs7"
)
type profileAssignmentReq struct {
@ -111,12 +121,6 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() {
s.enableABM("fleet_ade_test")
// test manual and automatic release with the new setup experience flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, globalDevice, enableReleaseManually, nil, "I1", false)
})
}
// test manual and automatic release with the old worker flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
@ -207,12 +211,6 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() {
// enable FileVault
s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings", json.RawMessage([]byte(fmt.Sprintf(`{"enable_disk_encryption":true,"team_id":%d}`, tm.ID))), http.StatusNoContent)
// test manual and automatic release with the new setup experience flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
s.runDEPEnrollReleaseDeviceTest(t, teamDevice, enableReleaseManually, &tm.ID, "I2", false)
})
}
// test manual and automatic release with the old worker flow
for _, enableReleaseManually := range []bool{false, true} {
t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) {
@ -528,7 +526,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
require.NoError(t, err)
require.NoError(t, json.Unmarshal(b, &orbitConfigResp))
// should be notified of the setup experience flow
require.True(t, orbitConfigResp.Notifications.RunSetupExperience)
require.False(t, orbitConfigResp.Notifications.RunSetupExperience)
if enableReleaseManually {
// get the worker's pending job from the future, there should not be any
@ -2652,3 +2650,457 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingItFromABM
// make sure the host gets post enrollment requests
checkPostEnrollmentCommands(mdmDevice, true)
}
func (s *integrationMDMTestSuite) TestEnforceMiniumOSVersion() {
t := s.T()
s.enableABM(t.Name())
latestMacOSVersion := "14.6.1" // this is the latest version in our test data (see ../mdm/apple/gdmf/testdata/gdmf.json)
latestMacOSBuild := "23G93" // this is the latest version in our test data (see ../mdm/apple/gdmf/testdata/gdmf.json)
deadline := "2023-12-31"
scepChallenge := "scepcha/><llenge"
scepURL := s.server.URL + "/mdm/apple/scep"
mdmURL := s.server.URL + "/mdm/apple/mdm"
// for our tests, we'll crete two devices: devices[0] will be enrolled with no team and
// devices[1] will be enrolled with a team (created later in this test)
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
}
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
require.NoError(t, err)
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
require.NoError(t, err)
case "/server/devices":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
require.NoError(t, err)
case "/devices/sync":
// This endpoint is polled over time to sync devices from
// ABM, send a repeated serial and a new one
err := encoder.Encode(godep.DeviceResponse{Devices: devices, Cursor: "foo"})
require.NoError(t, err)
case "/profile/devices":
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
var prof profileAssignmentReq
require.NoError(t, json.Unmarshal(b, &prof))
var resp godep.ProfileResponse
resp.ProfileUUID = prof.ProfileUUID
resp.Devices = make(map[string]string, len(prof.Devices))
for _, device := range prof.Devices {
resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
}
err = encoder.Encode(resp)
require.NoError(t, err)
default:
_, _ = w.Write([]byte(`{}`))
}
}))
s.runDEPSchedule()
// confirm that the devices were created
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 2)
// create a team and add the second device to it
team := &fleet.Team{
Name: t.Name() + "team1",
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
for _, h := range listHostsRes.Hosts {
if h.HardwareSerial == devices[1].SerialNumber {
err := s.ds.AddHostsToTeam(context.Background(), &team.ID, []uint{h.ID})
require.NoError(t, err)
break
}
}
// this helper function mocks the x-aspen-deviceinfo header that is sent by the device during
// the enrollment
encodeDeviceInfo := func(machineInfo fleet.MDMAppleMachineInfo) string {
body, err := plist.Marshal(machineInfo)
require.NoError(t, err)
// body is expected to be a PKCS7 signed message, although we don't currently verify the signature
signedData, err := pkcs7.NewSignedData(body)
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
crtBytes, err := depot.NewCACert().SelfSign(rand.Reader, key.Public(), key)
require.NoError(t, err)
crt, err := x509.ParseCertificate(crtBytes)
require.NoError(t, err)
require.NoError(t, signedData.AddSigner(crt, key, pkcs7.SignerInfoConfig{}))
sig, err := signedData.Finish()
require.NoError(t, err)
return base64.StdEncoding.EncodeToString(sig)
}
// this helper function calls the /enroll endpoint with the supplied machineInfo (from the test
// case) and checks for the expected response
checkMDMEnrollEndpoint := func(machineInfo *fleet.MDMAppleMachineInfo, expectEnrollInfo *mdmtest.AppleEnrollInfo, expectSoftwareUpdate *fleet.MDMAppleSoftwareUpdateRequiredDetails) error {
request, err := http.NewRequest("GET", s.server.URL+apple_mdm.EnrollPath+"?token="+loadEnrollmentProfileDEPToken(t, s.ds), nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if machineInfo != nil {
request.Header.Set("x-apple-aspen-deviceinfo", encodeDeviceInfo(*machineInfo))
}
// nolint:gosec // this client is used for testing only
cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
}))
response, err := cc.Do(request)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer response.Body.Close()
switch {
case expectEnrollInfo != nil:
require.Equal(t, http.StatusOK, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
rawProfile := body
if !bytes.HasPrefix(rawProfile, []byte("<?xml")) {
p7, err := pkcs7.Parse(body)
if err != nil {
return fmt.Errorf("enrollment profile is not XML nor PKCS7 parseable: %w", err)
}
err = p7.Verify()
if err != nil {
return err
}
rawProfile = p7.Content
}
enrollInfo, err := mdmtest.ParseEnrollmentProfile(rawProfile)
if err != nil {
return fmt.Errorf("parse enrollment profile: %w", err)
}
require.NotNil(t, enrollInfo)
require.Equal(t, expectEnrollInfo, enrollInfo)
return nil
case expectSoftwareUpdate != nil:
require.Equal(t, http.StatusForbidden, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
var sur fleet.MDMAppleSoftwareUpdateRequired
require.NoError(t, json.Unmarshal(body, &sur))
require.NotNil(t, sur)
require.Equal(t, fleet.MDMAppleSoftwareUpdateRequiredCode, sur.Code)
require.Equal(t, *expectSoftwareUpdate, sur.Details)
return nil
default:
return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
}
}
// this helper function calls the /mdm/sso endpoint with the supplied machineInfo (from the test
// case) and checks for the expected response
checkMDMSSOEndpoint := func(machineInfo *fleet.MDMAppleMachineInfo, expectSoftwareUpdate *fleet.MDMAppleSoftwareUpdateRequiredDetails) error {
request, err := http.NewRequest("GET", s.server.URL+"/mdm/sso", nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if machineInfo != nil {
request.Header.Set("x-apple-aspen-deviceinfo", encodeDeviceInfo(*machineInfo))
}
// nolint:gosec // this client is used for testing only
cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
}))
response, err := cc.Do(request)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer response.Body.Close()
switch {
case expectSoftwareUpdate != nil:
require.Equal(t, http.StatusForbidden, response.StatusCode)
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
var sur fleet.MDMAppleSoftwareUpdateRequired
require.NoError(t, json.Unmarshal(body, &sur))
require.NotNil(t, sur)
require.Equal(t, fleet.MDMAppleSoftwareUpdateRequiredCode, sur.Code)
require.Equal(t, *expectSoftwareUpdate, sur.Details)
return nil
case response.StatusCode == http.StatusOK:
// this is the expected happy path based on the test server setup (note that the full
// SSO callback flow is not being tested here, just the OS enforcement that is tied to
// the `GET /mdm/sso` initial request)
// https://github.com/fleetdm/fleet/blob/e62956924bbe041a1e75be2b0b7c6d1dd235a09d/server/service/testing_utils.go#L420-L421
return nil
default:
return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
}
}
// this helper function sets the minimum OS version for the team or no team
setMinOSVersion := func(minVersion string, deadline string, teamID *uint) {
raw := json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_updates": { "minimum_version": "%s", "deadline": "%s" } } }`, minVersion, deadline))
if teamID == nil {
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", raw, http.StatusOK, &acResp)
assert.NotNil(t, acResp.MDM.MacOSUpdates)
} else {
tcResp := teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", *teamID), raw, http.StatusOK, &tcResp)
}
}
// this helper function sets the enable end user authentication for the team or no team
setEnableEndUserAuth := func(enable bool, teamID *uint) {
raw := json.RawMessage(fmt.Sprintf(`{ "mdm": { "macos_setup": { "enable_end_user_authentication": %v } } }`, enable))
if teamID == nil {
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", raw, http.StatusOK, &acResp)
} else {
tcResp := teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", *teamID), raw, http.StatusOK, &tcResp)
}
}
// configure idp settings
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"end_user_authentication": { "entity_id": "https://example.com", "idp_name": "example-idp", "metadata_url": "https://idp.example.com/metadata" }
}
}`), http.StatusOK, &acResp)
t.Cleanup(func() {
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": { "minimum_version": null, "deadline": null },
"macos_setup": { "enable_end_user_authentication": false },
"end_user_authentication": { "entity_id": "", "idp_name": "", "metadata_url": "", "issuer_uri": "", "metadata": "" }
}
}`), http.StatusOK, &acResp)
})
testCases := []struct {
name string
machineInfo *fleet.MDMAppleMachineInfo
updateRequired *fleet.MDMAppleSoftwareUpdateRequiredDetails
// err is reserved for future test cases; currently we aren't expecting errors with this endpoint
// because product specs say to allow enrollment to proceed without software update so this
// is here so we can be explicit about those expectations and allow for future test cases
// that may need to check for errors
err string
}{
{
name: "device above latest",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "14.6.2",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
},
{
name: "device equal to latest",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: latestMacOSVersion,
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
},
{
name: "device below latest",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "14.4",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: &fleet.MDMAppleSoftwareUpdateRequiredDetails{
OSVersion: latestMacOSVersion,
BuildVersion: latestMacOSBuild,
},
},
{
name: "device below latest but MDM cannot request software update",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: false,
Product: "Mac15,7",
OSVersion: "14.4",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
},
{
name: "no match for software update device ID",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "14.4",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "INVALID",
},
updateRequired: nil,
err: "", // no error, allow enrollment to proceed without software update
},
{
name: "no machine info",
machineInfo: nil,
updateRequired: nil,
err: "", // no error, allow enrollment to proceed without software update
},
{
name: "cannot parse OS version",
machineInfo: &fleet.MDMAppleMachineInfo{
MDMCanRequestSoftwareUpdate: true,
Product: "Mac15,7",
OSVersion: "INVALID",
SupplementalBuildVersion: "IRRELEVANT",
SoftwareUpdateDeviceID: "J516sAP",
},
updateRequired: nil,
err: "", // no error, allow enrollment to proceed without software update
},
}
t.Run("no team setting equal to latest", func(t *testing.T) {
setMinOSVersion(latestMacOSVersion, deadline, nil)
t.Run("sso disabled", func(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mi fleet.MDMAppleMachineInfo
if tc.machineInfo != nil {
mi = *tc.machineInfo
mi.Serial = devices[0].SerialNumber
}
var expectEnrollInfo *mdmtest.AppleEnrollInfo
if tc.updateRequired == nil && tc.err == "" {
expectEnrollInfo = &mdmtest.AppleEnrollInfo{
SCEPChallenge: scepChallenge,
SCEPURL: scepURL,
MDMURL: mdmURL,
}
}
err := checkMDMEnrollEndpoint(&mi, expectEnrollInfo, tc.updateRequired)
if tc.err != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.err)
} else {
require.NoError(t, err)
}
})
}
})
t.Run("sso enabled", func(t *testing.T) {
setEnableEndUserAuth(true, nil)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mi fleet.MDMAppleMachineInfo
if tc.machineInfo != nil {
mi = *tc.machineInfo
mi.Serial = devices[0].SerialNumber
}
err := checkMDMSSOEndpoint(&mi, tc.updateRequired)
if tc.err != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.err)
} else {
require.NoError(t, err)
}
})
}
})
})
t.Run("team setting equal to latest", func(t *testing.T) {
setMinOSVersion(latestMacOSVersion, deadline, &team.ID)
t.Run("sso disabled", func(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.machineInfo != nil {
tc.machineInfo.Serial = devices[1].SerialNumber
}
var expectEnrollInfo *mdmtest.AppleEnrollInfo
if tc.updateRequired == nil && tc.err == "" {
expectEnrollInfo = &mdmtest.AppleEnrollInfo{
SCEPChallenge: "scepcha/><llenge",
SCEPURL: s.server.URL + "/mdm/apple/scep",
MDMURL: s.server.URL + "/mdm/apple/mdm",
}
}
err := checkMDMEnrollEndpoint(tc.machineInfo, expectEnrollInfo, tc.updateRequired)
if tc.err != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.err)
} else {
require.NoError(t, err)
}
})
}
})
t.Run("sso enabled", func(t *testing.T) {
setEnableEndUserAuth(true, &team.ID)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.machineInfo != nil {
tc.machineInfo.Serial = devices[0].SerialNumber
}
err := checkMDMSSOEndpoint(tc.machineInfo, tc.updateRequired)
if tc.err != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.err)
} else {
require.NoError(t, err)
}
})
}
})
})
}

View file

@ -296,6 +296,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
},
APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589",
EnableSCEPProxy: true,
WithDEPWebview: true,
}
// ensure all our tests support challenges with invalid XML characters
@ -11894,3 +11895,22 @@ func (s *integrationMDMTestSuite) TestSetupExperience() {
require.NoError(t, err)
require.True(t, awaitingConfig)
}
func (s *integrationMDMTestSuite) TestHostsCantTurnMDMOff() {
t := s.T()
iOSHost, _ := s.createAppleMobileHostThenEnrollMDM("ios")
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), iOSHost.ID, false, true, "https://foo.com", true, "", ""))
r := s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", iOSHost.ID), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), fleet.CantTurnOffMDMForIOSOrIPadOSMessage)
iPadOSHost, _ := s.createAppleMobileHostThenEnrollMDM("ipados")
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), iPadOSHost.ID, false, true, "https://foo.com", true, "", ""))
r = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", iPadOSHost.ID), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), fleet.CantTurnOffMDMForIOSOrIPadOSMessage)
winHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
r = s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", winHost.ID), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), fleet.CantTurnOffMDMForWindowsHostsMessage)
}

View file

@ -3,6 +3,7 @@ package service
import (
"context"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
@ -42,3 +43,28 @@ func (svc *Service) LinuxHostDiskEncryptionStatus(ctx context.Context, host flee
Status: &verified,
}, nil
}
func (svc *Service) GetMDMLinuxProfilesSummary(ctx context.Context, teamId *uint) (summary fleet.MDMProfilesSummary, err error) {
if err = svc.authz.Authorize(ctx, fleet.MDMConfigProfileAuthz{TeamID: teamId}, fleet.ActionRead); err != nil {
return summary, ctxerr.Wrap(ctx, err)
}
// Linux doesn't have configuration profiles, so if we aren't enforcing disk encryption we have nothing to report
includeDiskEncryptionStats, err := svc.ds.GetConfigEnableDiskEncryption(ctx, teamId)
if err != nil {
return summary, ctxerr.Wrap(ctx, err)
} else if !includeDiskEncryptionStats {
return summary, nil
}
counts, err := svc.ds.GetLinuxDiskEncryptionSummary(ctx, teamId)
if err != nil {
return summary, ctxerr.Wrap(ctx, err)
}
return fleet.MDMProfilesSummary{
Verified: counts.Verified,
Pending: counts.ActionRequired,
Failed: counts.Failed,
}, nil
}

View file

@ -907,7 +907,8 @@ func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uin
}
////////////////////////////////////////////////////////////////////////////////
// GET /mdm/profiles/summary
// GET /mdm/profiles/summary (deprecated)
// GET /configuration_profiles/summary
////////////////////////////////////////////////////////////////////////////////
type getMDMProfilesSummaryRequest struct {
@ -935,10 +936,15 @@ func getMDMProfilesSummaryEndpoint(ctx context.Context, request interface{}, svc
return &getMDMProfilesSummaryResponse{Err: err}, nil
}
res.Verified = as.Verified + ws.Verified
ls, err := svc.GetMDMLinuxProfilesSummary(ctx, req.TeamID)
if err != nil {
return &getMDMProfilesSummaryResponse{Err: err}, nil
}
res.Verified = as.Verified + ws.Verified + ls.Verified
res.Verifying = as.Verifying + ws.Verifying
res.Failed = as.Failed + ws.Failed
res.Pending = as.Pending + ws.Pending
res.Failed = as.Failed + ws.Failed + ls.Failed
res.Pending = as.Pending + ws.Pending + ls.Pending
return &res, nil
}
@ -2606,9 +2612,52 @@ func (svc *Service) UploadMDMAppleAPNSCert(ctx context.Context, cert io.ReadSeek
return ctxerr.Wrap(ctx, err, "retrieving app config")
}
wasEnabledAndConfigured := appCfg.MDM.EnabledAndConfigured
appCfg.MDM.EnabledAndConfigured = true
err = svc.ds.SaveAppConfig(ctx, appCfg)
if err != nil {
return ctxerr.Wrap(ctx, err, "saving app config")
}
return svc.ds.SaveAppConfig(ctx, appCfg)
// Disk encryption can be enabled prior to Apple MDM being configured, but we need MDM to be set up to escrow
// FileVault keys. We handle the other order of operations elsewhere (on encryption enable, after checking to see
// if Mac MDM is already enabled). We skip this step if we were just re-uploading an APNs cert when MDM was already
// enabled.
if wasEnabledAndConfigured {
return nil
}
// Enable FileVault escrow if no-team already has disk encryption enforced
if appCfg.MDM.EnableDiskEncryption.Value {
if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil {
return ctxerr.Wrap(ctx, err, "enable no-team FileVault escrow")
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEnabledMacosDiskEncryption{}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for enabling no-team macOS disk encryption")
}
}
// Enable FileVault escrow for teams that already have disk encryption enforced
// For later: add a data store method to avoid making an extra query per team to check whether encryption is enforced
teams, err := svc.ds.TeamsSummary(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "listing teams")
}
for _, team := range teams {
isEncryptionEnforced, err := svc.ds.GetConfigEnableDiskEncryption(ctx, &team.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving encryption enforcement status for team")
}
if isEncryptionEnforced {
if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, &team.ID); err != nil {
return ctxerr.Wrap(ctx, err, "enable FileVault escrow for team")
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEnabledMacosDiskEncryption{TeamID: &team.ID, TeamName: &team.Name}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for enabling macOS disk encryption for team")
}
}
}
return nil
}
////////////////////////////////////////////////////////////////////////////////

View file

@ -235,19 +235,29 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
}
if isConnectedToFleetMDM {
// If there is no software or script configured for setup experience and this is the
// first time orbit is calling the /config endpoint, then this host
// will not have a row in host_mdm_apple_awaiting_configuration.
// On subsequent calls to /config, the host WILL have a row in
// host_mdm_apple_awaiting_configuration.
inSetupAssistant, err := svc.ds.GetHostAwaitingConfiguration(ctx, host.UUID)
if err != nil {
if err != nil && !fleet.IsNotFound(err) {
return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "checking if host is in setup experience")
}
if inSetupAssistant {
notifs.RunSetupExperience = true
}
// check if the client is running an old fleetd version that doesn't support the new
// setup experience flow.
if inSetupAssistant || fleet.IsNotFound(err) {
// If the client is running a fleetd that doesn't support setup experience, or if no
// software/script has been configured for setup experience, then we should fall back to
// the "old way" of releasing the device. We do an additional check for
// !inSetupAssistant to prevent enqueuing a new job every time the /config
// endpoint is hit.
mp, ok := capabilities.FromContext(ctx)
if !ok || !mp.Has(fleet.CapabilitySetupExperience) {
level.Debug(svc.logger).Log("msg", "host doesn't support Setup experience, falling back to worker-based device release", "host_uuid", host.UUID)
if !ok || !mp.Has(fleet.CapabilitySetupExperience) || !inSetupAssistant {
level.Debug(svc.logger).Log("msg", "host doesn't support setup experience or no setup experience configured, falling back to worker-based device release", "host_uuid", host.UUID)
if err := svc.processReleaseDeviceForOldFleetd(ctx, host); err != nil {
return fleet.OrbitConfig{}, err
}

View file

@ -338,6 +338,7 @@ type TestServerOpts struct {
BootstrapPackageStore fleet.MDMBootstrapPackageStore
KeyValueStore fleet.KeyValueStore
EnableSCEPProxy bool
WithDEPWebview bool
}
func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) {
@ -413,6 +414,14 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ
}
}
if len(opts) > 0 && opts[0].WithDEPWebview {
frontendHandler := WithMDMEnrollmentMiddleware(svc, logger, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// do nothing and return 200
w.WriteHeader(http.StatusOK)
}))
rootMux.Handle("/", frontendHandler)
}
apiHandler := MakeHandler(svc, cfg, logger, limitStore, WithLoginRateLimit(throttled.PerMin(1000)))
rootMux.Handle("/api/", apiHandler)
var errHandler *errorstore.Handler

View file

@ -2,7 +2,7 @@ FROM rust:latest@sha256:56418f03475cf7b107f87d7fabe99ce9a4a9f9904daafa99be7c50d9
ARG transporter_url=https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/resources/download/public/Transporter__Linux/bin
RUN cargo install --version 0.16.0 apple-codesign \
RUN cargo install --locked --version 0.16.0 apple-codesign \
&& curl -sSf $transporter_url -o transporter_install.sh \
&& sh transporter_install.sh --target transporter --accept --noexec

View file

@ -40,7 +40,7 @@ parasails.registerPage('new-license', {
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
if(window.location.hash) {
if(typeof analytics !== 'undefined') {
if(window.analytics !== undefined) {
if(window.location.hash === '#signup') {
analytics.identify(this.me.id, {
email: this.me.emailAddress,

View file

@ -31,13 +31,13 @@ parasails.registerPage('device-management-page', {
this.modal = undefined;
},
clickSwagRequestCTA: function () {
if(typeof gtag !== 'undefined') {
if(window.gtag !== undefined){
gtag('event','fleet_website__swag_request');
}
if(typeof window.lintrk !== 'undefined') {
if(window.lintrk !== undefined) {
window.lintrk('track', { conversion_id: 18587105 });// eslint-disable-line camelcase
}
if(typeof analytics !== 'undefined'){
if(window.analytics !== undefined) {
analytics.track('fleet_website__swag_request');
}
this.goto('https://kqphpqst851.typeform.com/to/ZfA3sOu0#from_page=device-managment');

View file

@ -221,13 +221,13 @@ parasails.registerPage('basic-documentation', {
methods: {
clickSwagRequestCTA: function () {
if(typeof gtag !== 'undefined') {
if(window.gtag !== undefined){
gtag('event','fleet_website__swag_request');
}
if(typeof window.lintrk !== 'undefined') {
if(window.lintrk !== undefined) {
window.lintrk('track', { conversion_id: 18587105 });// eslint-disable-line camelcase
}
if(typeof analytics !== 'undefined'){
if(window.analytics !== undefined) {
analytics.track('fleet_website__swag_request');
}
this.goto('https://kqphpqst851.typeform.com/to/ZfA3sOu0#from_page=docs');

View file

@ -64,19 +64,22 @@ parasails.registerPage('signup', {
}
},
submittedSignUpForm: async function() {
// redirect to the /start page.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
//
// Naming convention: (like sails config)
// "Website - Sign up" becomes "fleet_website__sign_up" (double-underscore representing hierarchy)
if(typeof gtag !== 'undefined'){
gtag('event','fleet_website__sign_up');
submittedSignUpForm: async function() {// When the server says everything worked…
// Track a "key event" in Google Analytics. (? but don't we do that when we call analytics.track() [segment] later on in start.page.js? TODO: eric help please I suspect this one is either duplicate OR it's actually writing to Google Ads, and not to Google Analytics. I'm pretty sure segment's .track() is what writes to google analytics.)
// > Naming convention: (like sails config)
// > "Website - Sign up" becomes "fleet_website__sign_up" (double-underscore representing hierarchy)
if(window.gtag !== undefined){
window.gtag('event','fleet_website__sign_up');
}
if(typeof window.lintrk !== 'undefined') {
// Track a "conversion" in LinkedIn Campaign Manager.
if(window.lintrk !== undefined) {
window.lintrk('track', { conversion_id: 18587097 });// eslint-disable-line camelcase
}
// Redirect to the /start page.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
this.goto(this.pageToRedirectToAfterRegistration);// « / start if the user came here from the start now button, or customers/new-license if the user came here from the "Get your license" link.
}

View file

@ -97,7 +97,7 @@ parasails.registerPage('start', {
this.psychologicalStage = this.me.psychologicalStage;
}
if(window.location.hash) {
if(typeof analytics !== 'undefined') {
if(window.analytics !== undefined) {
if(window.location.hash === '#signup') {
analytics.identify(this.me.id, {
email: this.me.emailAddress,

View file

@ -451,7 +451,7 @@
padding: 0px 32px 64px 32px;
}
[purpose='section-heading'] {
padding: 40px 32px;
padding: 40px 24px;
}
[parasails-component='scrollable-tweets'] {
[purpose='tweets'] {

View file

@ -595,6 +595,7 @@ module.exports.routes = {
'GET /feature-request': 'https://github.com/fleetdm/fleet/issues/new?assignees=&labels=~feature+fest%2C%3Aproduct&projects=&template=feature-request.md&title=',
'GET /learn-more-about/policy-automation-run-script': '/guides/policy-automation-run-script',
'GET /learn-more-about/installing-fleetctl': '/guides/fleetctl#installing-fleetctl',
'GET /learn-more-about/mdm-disk-encryption': '/guides/enforce-disk-encryption',
'GET /contribute-to/policies': 'https://github.com/fleetdm/fleet/edit/main/docs/01-Using-Fleet/standard-query-library/standard-query-library.yml',
// Sitemap

View file

@ -4,7 +4,7 @@
<div purpose="hero">
<div purpose="page-headline">
<h4>Observability <%= ['eo-security', 'vm'].includes(pagePersonalization) ? 'for security' : ['eo-it', 'mdm'].includes(pagePersonalization) ? 'for IT' : '' %></h4>
<h1><%= pagePersonalization==='eo-security'? 'Instrument your endpoints' : 'Talk to your computers'%></h1>
<h1><%= pagePersonalization==='eo-security'? 'Deeper, faster visibility for every OS' : 'Talk to your computers'%></h1>
</div>
<div purpose="hero-content" class="d-flex flex-md-row flex-column align-items-center justify-content-between">
<div purpose="hero-image">
@ -46,6 +46,7 @@
<div purpose="quote">
<img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png">
<p>"We will have no, zero, blind spots in our entire infrastructure, more than 100,000 servers. It just works, it's awesome."</p>
<!-- TODO: Fleet reduces costs through tool consolidation. Plus if you cant confidently answer questions about the state and configuration of all your devices in seconds - regardless of operating system - you should take a look at what these guys are doing. -->
</div>
<a href="https://www.linkedin.com/in/charleszaffery/" target="_blank">
<div purpose="quote-attribution" class="d-flex flex-row align-items-center">

View file

@ -10,17 +10,17 @@
<h3>How does it affect me?</h3>
<p>Your IT team has to maintain your computer and keep it compliant with a bunch of security requirements. But they also realize you have a job to do. So they want to be as un-intrusive as possible.</p>
<p>Thats why they chose Fleet. Its compatible with everything, including Linux, so it doesnt limit what operating system you use. And its open source, meaning if you want to, you can <a href="https://github.com/fleetdm/fleet" target="_blank">take it apart</a> and see what its doing to your computer.</p>
<div purpose="swag-cta" v-if="showSwagForm">
<!-- <div purpose="swag-cta" v-if="showSwagForm">
<a purpose="swag-link" style="text-decoration: none;" class="d-inline" href="https://kqphpqst851.typeform.com/to/Whm2imZc">
<div class="d-flex flex-row justify-content-center align-items-center">
<div><img alt="A tumbler with a Fleet logo" src="/images/icon-fleet-tumbler-55x51@2x.png"></div>
<div class="d-flex flex-column">
<strong>Get a Fleet tumbler</strong>
<animated-arrow-button target="_blank" style="font-weight: 400;">It's free</animated-arrow-button>
</div>
</div>
<div class="d-flex flex-row justify-content-center align-items-center">
<div><img alt="A tumbler with a Fleet logo" src="/images/icon-fleet-tumbler-55x51@2x.png"></div>
<div class="d-flex flex-column">
<strong>Get a Fleet tumbler</strong>
<animated-arrow-button target="_blank" style="font-weight: 400;">It's free</animated-arrow-button>
</div>
</div>
</a>
</div>
</div> -->
</div>
<div purpose="feature-image">
<img alt="Information about a users device in Fleet." src="/images/better-hero-image-468x380@2x.png">