iOS/iPadOS as platforms/labels (#20126)

#19963 

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [X] Added/updated tests
- [X] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [X] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [X] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [X] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [X] Manual QA for all new/changed functionality

---

# API changes for dashboard UI changes

## Main dashboard page

`GET /api/latest/fleet/host_summary?low_disk_space=32` (see
`ios`/`ipados` platforms and `iOS`/`iPadOS` labels)
```json
{
  "totals_hosts_count": 9,
  "online_count": 0,
  "offline_count": 9,
  "mia_count": 0,
  "missing_30_days_count": 0,
  "new_count": 0,
  "all_linux_count": 2,
  "low_disk_space_count": 3,
  "builtin_labels": [
    {
      "id": 1,
      "name": "macOS 14+ (Sonoma+)",
      "description": "macOS hosts with version 14 and above",
      "label_type": "builtin"
    },
    {
      "id": 7,
      "name": "All Hosts",
      "description": "All hosts which have enrolled in Fleet",
      "label_type": "builtin"
    },
    {
      "id": 8,
      "name": "macOS",
      "description": "All macOS hosts",
      "label_type": "builtin"
    },
    {
      "id": 9,
      "name": "Ubuntu Linux",
      "description": "All Ubuntu hosts",
      "label_type": "builtin"
    },
    {
      "id": 10,
      "name": "CentOS Linux",
      "description": "All CentOS hosts",
      "label_type": "builtin"
    },
    {
      "id": 11,
      "name": "MS Windows",
      "description": "All Windows hosts",
      "label_type": "builtin"
    },
    {
      "id": 12,
      "name": "Red Hat Linux",
      "description": "All Red Hat Enterprise Linux hosts",
      "label_type": "builtin"
    },
    {
      "id": 13,
      "name": "All Linux",
      "description": "All Linux distributions",
      "label_type": "builtin"
    },
    {
      "id": 14,
      "name": "chrome",
      "description": "All Chrome hosts",
      "label_type": "builtin"
    },
    {
      "id": 15,
      "name": "iOS",
      "description": "All iOS hosts",
      "label_type": "builtin"
    },
    {
      "id": 16,
      "name": "iPadOS",
      "description": "All iPadOS hosts",
      "label_type": "builtin"
    }
  ],
  "platforms": [
    {
      "platform": "darwin",
      "hosts_count": 3
    },
    {
      "platform": "ios",
      "hosts_count": 1
    },
    {
      "platform": "ipados",
      "hosts_count": 1
    },
    {
      "platform": "rhel",
      "hosts_count": 1
    },
    {
      "platform": "ubuntu",
      "hosts_count": 1
    },
    {
      "platform": "windows",
      "hosts_count": 2
    }
  ]
}
```

## After selecting a platform

`GET /api/latest/fleet/host_summary?platform=ios&low_disk_space=100`
(similar with `ipados`)
```json
{
  "totals_hosts_count": 1,
  "online_count": 0,
  "offline_count": 1,
  "mia_count": 0,
  "missing_30_days_count": 0,
  "new_count": 0,
  "all_linux_count": 0,
  "low_disk_space_count": 1,
  "builtin_labels": [
    {
      "id": 1,
      "name": "macOS 14+ (Sonoma+)",
      "description": "macOS hosts with version 14 and above",
      "label_type": "builtin"
    },
    {
      "id": 7,
      "name": "All Hosts",
      "description": "All hosts which have enrolled in Fleet",
      "label_type": "builtin"
    },
    {
      "id": 8,
      "name": "macOS",
      "description": "All macOS hosts",
      "label_type": "builtin"
    },
    {
      "id": 9,
      "name": "Ubuntu Linux",
      "description": "All Ubuntu hosts",
      "label_type": "builtin"
    },
    {
      "id": 10,
      "name": "CentOS Linux",
      "description": "All CentOS hosts",
      "label_type": "builtin"
    },
    {
      "id": 11,
      "name": "MS Windows",
      "description": "All Windows hosts",
      "label_type": "builtin"
    },
    {
      "id": 12,
      "name": "Red Hat Linux",
      "description": "All Red Hat Enterprise Linux hosts",
      "label_type": "builtin"
    },
    {
      "id": 13,
      "name": "All Linux",
      "description": "All Linux distributions",
      "label_type": "builtin"
    },
    {
      "id": 14,
      "name": "chrome",
      "description": "All Chrome hosts",
      "label_type": "builtin"
    },
    {
      "id": 15,
      "name": "iOS",
      "description": "All iOS hosts",
      "label_type": "builtin"
    },
    {
      "id": 16,
      "name": "iPadOS",
      "description": "All iPadOS hosts",
      "label_type": "builtin"
    }
  ],
  "platforms": [
    {
      "platform": "ios",
      "hosts_count": 1
    }
  ]
}
```

### To populate list of MDM solutions of a selected platform

`GET /api/latest/fleet/hosts/summary/mdm\?platform=ios` (similar with
`ipados`)

```json
{
  "counts_updated_at": "2024-06-27T21:56:45Z",
  "mobile_device_management_enrollment_status": {
    "enrolled_manual_hosts_count": 0,
    "enrolled_automated_hosts_count": 1,
    "pending_hosts_count": 0,
    "unenrolled_hosts_count": 0,
    "hosts_count": 1
  },
  "mobile_device_management_solution": [
    {
      "id": 1,
      "name": "Fleet",
      "server_url": "https://lucas-fleet.ngrok.app/mdm/apple/mdm",
      "hosts_count": 1
    }
  ]
}
```

### To populate OS versions of a selected platform

`GET /api/latest/fleet/os_versions?platform=ipados` (similar with `ios`)
```json
{
  "meta": {
    "has_next_results": false,
    "has_previous_results": false
  },
  "count": 1,
  "counts_updated_at": "2024-06-27T21:36:12Z",
  "os_versions": [
    {
      "os_version_id": 7,
      "hosts_count": 1,
      "name": "iPadOS 17.5.1",
      "name_only": "iPadOS",
      "version": "17.5.1",
      "platform": "ipados",
      "vulnerabilities": []
    }
  ]
}
```

## Filtering hosts by the two new `iOS`/`iPadOS` labels

Works the same as with other labels.
This commit is contained in:
Lucas Manuel Rodriguez 2024-07-08 18:05:29 -03:00 committed by GitHub
parent ed3417de9d
commit 28ca463d13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 605 additions and 55 deletions

View file

@ -0,0 +1,3 @@
- Added iOS/iPadOS builtin manual labels. IMPORTANT: Before migrating to this version, make sure to delete any labels with name "iOS" or "iPadOS".
- Added aggregation of iOS/iPadOS OS versions.
- Added change to custom profiles for iOS/iPadOS to go from 'pending' straight to 'verified' (skip 'verifying').

View file

@ -5,12 +5,11 @@ services:
# officially supported).
# To run in macOS M1, set FLEET_MYSQL_IMAGE=arm64v8/mysql:oracle FLEET_MYSQL_PLATFORM=linux/arm64/v8
mysql:
image: ${FLEET_MYSQL_IMAGE:-mysql:5.7}
image: ${FLEET_MYSQL_IMAGE:-mysql:5.7.21}
platform: ${FLEET_MYSQL_PLATFORM:-linux/x86_64}
volumes:
- mysql-persistent-volume:/tmp
command:
[
command: [
"mysqld",
"--datadir=/tmp/mysqldata",
# These 3 keys run MySQL with GTID consistency enforced to avoid issues with production deployments that use it.
@ -18,7 +17,7 @@ services:
"--log-bin=bin.log",
"--server-id=master-01",
# Required for storage of Apple MDM bootstrap packages.
"--max_allowed_packet=536870912"
"--max_allowed_packet=536870912",
]
environment: &mysql-default-environment
MYSQL_ROOT_PASSWORD: toor
@ -31,11 +30,10 @@ services:
- "3306:3306"
mysql_test:
image: ${FLEET_MYSQL_IMAGE:-mysql:5.7}
image: ${FLEET_MYSQL_IMAGE:-mysql:5.7.21}
platform: ${FLEET_MYSQL_PLATFORM:-linux/x86_64}
# innodb-file-per-table=OFF gives ~20% speedup for test runs.
command:
[
command: [
"mysqld",
"--datadir=/tmpfs",
"--slow_query_log=1",
@ -48,7 +46,7 @@ services:
"--log-bin=bin.log",
"--server-id=1",
# Required for storage of Apple MDM bootstrap packages.
"--max_allowed_packet=536870912"
"--max_allowed_packet=536870912",
]
environment: *mysql-default-environment
ports:
@ -58,11 +56,10 @@ services:
- /tmpfs
mysql_replica_test:
image: ${FLEET_MYSQL_IMAGE:-mysql:5.7}
image: ${FLEET_MYSQL_IMAGE:-mysql:5.7.21}
platform: ${FLEET_MYSQL_PLATFORM:-linux/x86_64}
# innodb-file-per-table=OFF gives ~20% speedup for test runs.
command:
[
command: [
"mysqld",
"--datadir=/tmpfs",
"--slow_query_log=1",
@ -75,7 +72,7 @@ services:
"--log-bin=bin.log",
"--server-id=2",
# Required for storage of Apple MDM bootstrap packages.
"--max_allowed_packet=536870912"
"--max_allowed_packet=536870912",
]
environment: *mysql-default-environment
ports:
@ -100,11 +97,7 @@ services:
- "1026:1025"
volumes:
- ./tools/mailpit/auth.txt:/auth.txt
command:
[
"--smtp-auth-file=/auth.txt",
"--smtp-auth-allow-insecure=true"
]
command: ["--smtp-auth-file=/auth.txt", "--smtp-auth-allow-insecure=true"]
# SMTP server with TLS
smtp4dev_test:
@ -183,5 +176,3 @@ services:
volumes:
mysql-persistent-volume:
data-minio:

View file

@ -1143,38 +1143,53 @@ func upsertMDMAppleHostLabelMembershipDB(ctx context.Context, tx sqlx.ExtContext
ID uint `db:"id"`
Name string `db:"name"`
}{}
err := sqlx.SelectContext(ctx, tx, &labels, `SELECT id, name FROM labels WHERE label_type = 1 AND (name = 'All Hosts' OR name = 'macOS')`)
err := sqlx.SelectContext(ctx, tx, &labels, `SELECT id, name FROM labels WHERE label_type = 1 AND (name = 'All Hosts' OR name = 'macOS' OR name = 'iOS' OR name = 'iPadOS')`)
switch {
case err != nil:
return ctxerr.Wrap(ctx, err, "get builtin labels")
case len(labels) != 2:
case len(labels) != 4:
// Builtin labels can get deleted so it is important that we check that
// they still exist before we continue.
level.Error(logger).Log("err", fmt.Sprintf("expected 2 builtin labels but got %d", len(labels)))
level.Error(logger).Log("err", fmt.Sprintf("expected 4 builtin labels but got %d", len(labels)))
return nil
default:
// continue
}
// Put "All Hosts" label first (we don't want to make assumptions around ids of builtin labels).
labelIDs := make([]uint, 0, 2)
if labels[0].Name == "All Hosts" {
labelIDs = append(labelIDs, labels[0].ID, labels[1].ID)
} else {
labelIDs = append(labelIDs, labels[1].ID, labels[0].ID)
// We cannot assume IDs on labels, thus we look by name.
var (
allHostsLabelID uint
macOSLabelID uint
iOSLabelID uint
iPadOSLabelID uint
)
for _, label := range labels {
switch label.Name {
case "All Hosts":
allHostsLabelID = label.ID
case "macOS":
macOSLabelID = label.ID
case "iOS":
iOSLabelID = label.ID
case "iPadOS":
iPadOSLabelID = label.ID
}
}
parts := []string{}
args := []interface{}{}
for _, h := range hosts {
// iOS/iPadOS devices only get the "All Hosts" label.
if h.Platform == "ios" || h.Platform == "ipados" {
parts = append(parts, "(?,?)")
args = append(args, h.ID, labelIDs[0])
} else { // macOS devices get both labels, "All Hosts" and "macOS".
parts = append(parts, "(?,?),(?,?)")
args = append(args, h.ID, labelIDs[0], h.ID, labelIDs[1])
var osLabelID uint
switch h.Platform {
case "ios":
osLabelID = iOSLabelID
case "ipados":
osLabelID = iPadOSLabelID
default: // at this point, assume "darwin"
osLabelID = macOSLabelID
}
parts = append(parts, "(?,?),(?,?)")
args = append(args, h.ID, allHostsLabelID, h.ID, osLabelID)
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO label_membership (host_id, label_id) VALUES %s
@ -2269,11 +2284,29 @@ func (ds *Datastore) UpdateOrDeleteHostMDMAppleProfile(ctx context.Context, prof
detail = fmt.Sprintf("Failed to remove: %s", detail)
}
// Check whether we want to set a install operation as 'verifying' for an iOS/iPadOS device.
var isIOSIPadOSInstallVerifiying bool
if profile.OperationType == fleet.MDMOperationTypeInstall && profile.Status != nil && *profile.Status == fleet.MDMDeliveryVerifying {
if err := ds.writer(ctx).GetContext(ctx, &isIOSIPadOSInstallVerifiying, `
SELECT platform = 'ios' OR platform = 'ipados' FROM hosts WHERE uuid = ?`,
profile.HostUUID,
); err != nil {
return err
}
}
status := profile.Status
if isIOSIPadOSInstallVerifiying {
// iOS/iPadOS devices do not have osquery,
// thus they go from 'pending' straight to 'verified'
status = &fleet.MDMDeliveryVerified
}
_, err := ds.writer(ctx).ExecContext(ctx, `
UPDATE host_mdm_apple_profiles
SET status = ?, operation_type = ?, detail = ?
WHERE host_uuid = ? AND command_uuid = ?
`, profile.Status, profile.OperationType, detail, profile.HostUUID, profile.CommandUUID)
`, status, profile.OperationType, detail, profile.HostUUID, profile.CommandUUID)
return err
}

View file

@ -46,6 +46,7 @@ func TestMDMApple(t *testing.T) {
{"TestDeleteMDMAppleConfigProfileByTeamAndIdentifier", testDeleteMDMAppleConfigProfileByTeamAndIdentifier},
{"TestListMDMAppleConfigProfiles", testListMDMAppleConfigProfiles},
{"TestHostDetailsMDMProfiles", testHostDetailsMDMProfiles},
{"TestHostDetailsMDMProfilesIOSIPadOS", testHostDetailsMDMProfilesIOSIPadOS},
{"TestBatchSetMDMAppleProfiles", testBatchSetMDMAppleProfiles},
{"TestMDMAppleProfileManagement", testMDMAppleProfileManagement},
{"TestMDMAppleProfileManagementBatch2", testMDMAppleProfileManagementBatch2},
@ -1730,14 +1731,22 @@ func testGetMDMAppleProfilesContents(t *testing.T, ds *Datastore) {
// createBuiltinLabels creates entries for "All Hosts" and "macOS" labels, which are assumed to be
// extant for MDM flows
func createBuiltinLabels(t *testing.T, ds *Datastore) {
// Labels are deleted when truncating tables in between tests.
// We need to delete the iOS/iPadOS labels because these two are created on a table migration,
// and also we want to keep their indexes higher than "All Hosts" and "macOS" (to not break existing tests).
_, err := ds.writer(context.Background()).Exec(`
DELETE FROM labels WHERE name = 'iOS' OR name = 'iPadOS'`,
)
require.NoError(t, err)
_, err = ds.writer(context.Background()).Exec(`
INSERT INTO labels (
name,
description,
query,
platform,
label_type
) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`,
"All Hosts",
"",
"",
@ -1748,6 +1757,16 @@ func createBuiltinLabels(t *testing.T, ds *Datastore) {
"",
"",
fleet.LabelTypeBuiltIn,
"iOS",
"",
"",
"",
fleet.LabelTypeBuiltIn,
"iPadOS",
"",
"",
"",
fleet.LabelTypeBuiltIn,
)
require.NoError(t, err)
}
@ -5735,8 +5754,16 @@ func testMDMAppleUpsertHostIOSIPadOS(t *testing.T, ds *Datastore) {
labels, err := ds.ListLabelsForHost(ctx, h.ID)
require.NoError(t, err)
require.Len(t, labels, 1)
require.Len(t, labels, 2)
sort.Slice(labels, func(i, j int) bool {
return labels[i].ID < labels[j].ID
})
require.Equal(t, "All Hosts", labels[0].Name)
if i == 0 {
require.Equal(t, "iOS", labels[1].Name)
} else {
require.Equal(t, "iPadOS", labels[1].Name)
}
// Insert again to test updateMDMAppleHostDB.
err = ds.MDMAppleUpsertHost(ctx, &fleet.Host{
@ -5754,8 +5781,16 @@ func testMDMAppleUpsertHostIOSIPadOS(t *testing.T, ds *Datastore) {
labels, err = ds.ListLabelsForHost(ctx, h.ID)
require.NoError(t, err)
require.Len(t, labels, 1)
require.Len(t, labels, 2)
sort.Slice(labels, func(i, j int) bool {
return labels[i].ID < labels[j].ID
})
require.Equal(t, "All Hosts", labels[0].Name)
if i == 0 {
require.Equal(t, "iOS", labels[1].Name)
} else {
require.Equal(t, "iPadOS", labels[1].Name)
}
}
err := ds.MDMAppleUpsertHost(ctx, &fleet.Host{
@ -5870,3 +5905,146 @@ func testMDMAppleProfilesOnIOSIPadOS(t *testing.T, ds *Datastore) {
require.Len(t, profiles, 1)
require.Equal(t, someProfile.Name, profiles[0].Name)
}
func testHostDetailsMDMProfilesIOSIPadOS(t *testing.T, ds *Datastore) {
ctx := context.Background()
p0, err := ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{
Name: "Name0",
Identifier: "Identifier0",
Mobileconfig: []byte("profile0-bytes"),
})
require.NoError(t, err)
profiles, err := ds.ListMDMAppleConfigProfiles(ctx, ptr.Uint(0))
require.NoError(t, err)
require.Len(t, profiles, 1)
iOS, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host0-osquery-id"),
NodeKey: ptr.String("host0-node-key"),
UUID: "host0-test-mdm-profiles",
Hostname: "hostname0",
Platform: "ios",
})
require.NoError(t, err)
iPadOS, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host0-osquery-id-2"),
NodeKey: ptr.String("host0-node-key-2"),
UUID: "host0-test-mdm-profiles-2",
Hostname: "hostname0-2",
Platform: "ipados",
})
require.NoError(t, err)
gotHost, err := ds.Host(ctx, iOS.ID)
require.NoError(t, err)
require.Nil(t, gotHost.MDM.Profiles)
gotProfs, err := ds.GetHostMDMAppleProfiles(ctx, iOS.UUID)
require.NoError(t, err)
require.Nil(t, gotProfs)
gotHost, err = ds.Host(ctx, iPadOS.ID)
require.NoError(t, err)
require.Nil(t, gotHost.MDM.Profiles)
gotProfs, err = ds.GetHostMDMAppleProfiles(ctx, iPadOS.UUID)
require.NoError(t, err)
require.Nil(t, gotProfs)
expectedProfilesIOS := map[string]fleet.HostMDMAppleProfile{
p0.ProfileUUID: {
HostUUID: iOS.UUID,
Name: p0.Name,
ProfileUUID: p0.ProfileUUID,
CommandUUID: "cmd0-uuid",
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
Detail: "",
},
}
expectedProfilesIPadOS := map[string]fleet.HostMDMAppleProfile{
p0.ProfileUUID: {
HostUUID: iPadOS.UUID,
Name: p0.Name,
ProfileUUID: p0.ProfileUUID,
CommandUUID: "cmd0-uuid",
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
Detail: "",
},
}
var args []interface{}
for _, p := range expectedProfilesIOS {
args = append(args, p.HostUUID, p.ProfileUUID, p.CommandUUID, *p.Status, p.OperationType, p.Detail, p.Name)
}
for _, p := range expectedProfilesIPadOS {
args = append(args, p.HostUUID, p.ProfileUUID, p.CommandUUID, *p.Status, p.OperationType, p.Detail, p.Name)
}
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO host_mdm_apple_profiles (
host_uuid, profile_uuid, command_uuid, status, operation_type, detail, profile_name)
VALUES (?,?,?,?,?,?,?),(?,?,?,?,?,?,?)
`, args...,
)
if err != nil {
return err
}
return nil
})
for _, tc := range []struct {
host *fleet.Host
expectedProfiles map[string]fleet.HostMDMAppleProfile
}{
{
host: iOS,
expectedProfiles: expectedProfilesIOS,
},
{
host: iPadOS,
expectedProfiles: expectedProfilesIPadOS,
},
} {
gotProfs, err = ds.GetHostMDMAppleProfiles(ctx, tc.host.UUID)
require.NoError(t, err)
require.Len(t, gotProfs, 1)
for _, gp := range gotProfs {
ep, ok := expectedProfilesIOS[gp.ProfileUUID]
require.True(t, ok)
require.Equal(t, ep.Name, gp.Name)
require.Equal(t, *ep.Status, *gp.Status)
require.Equal(t, ep.OperationType, gp.OperationType)
require.Equal(t, ep.Detail, gp.Detail)
}
// mark pending profile to 'verifying', which should instead set it as 'verified'.
installPendingProfile := expectedProfilesIOS[p0.ProfileUUID]
err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
HostUUID: installPendingProfile.HostUUID,
CommandUUID: installPendingProfile.CommandUUID,
ProfileUUID: installPendingProfile.ProfileUUID,
Name: installPendingProfile.Name,
Status: &fleet.MDMDeliveryVerifying,
OperationType: fleet.MDMOperationTypeInstall,
Detail: "",
})
require.NoError(t, err)
// Check that the profile is the 'verified' state.
gotProfs, err = ds.GetHostMDMAppleProfiles(ctx, iOS.UUID)
require.NoError(t, err)
require.Len(t, gotProfs, 1)
require.NotNil(t, gotProfs[0].Status)
require.Equal(t, fleet.MDMDeliveryVerified, *gotProfs[0].Status)
}
}

View file

@ -26,8 +26,10 @@ import (
// Since many hosts may have issues, we need to batch the inserts of host issues.
// This is a variable, so it can be adjusted during unit testing.
var hostIssuesInsertBatchSize = 10000
var hostIssuesUpdateFailingPoliciesBatchSize = 10000
var (
hostIssuesInsertBatchSize = 10000
hostIssuesUpdateFailingPoliciesBatchSize = 10000
)
// A large number of hosts could be changing teams at once, so we need to batch this operation to prevent excessive locks
var addHostsToTeamBatchSize = 10000
@ -4082,7 +4084,7 @@ func (ds *Datastore) AggregatedMDMSolutions(ctx context.Context, teamID *uint, p
func (ds *Datastore) GenerateAggregatedMunkiAndMDM(ctx context.Context) error {
var (
platforms = []string{"", "darwin", "windows"}
platforms = []string{"", "darwin", "windows", "ios", "ipados"}
teamIDs []uint
)

View file

@ -5724,15 +5724,21 @@ func testAggregatedHostMDMAndMunki(t *testing.T, ds *Datastore) {
h2 := test.NewHost(t, ds, "h2"+t.Name(), "192.168.1.11", "2", "2", time.Now(), test.WithPlatform("darwin"))
h3 := test.NewHost(t, ds, "h3"+t.Name(), "192.168.1.11", "3", "3", time.Now(), test.WithPlatform("darwin"))
h4 := test.NewHost(t, ds, "h4"+t.Name(), "192.168.1.11", "4", "4", time.Now(), test.WithPlatform("windows"))
h5 := test.NewHost(t, ds, "h5"+t.Name(), "192.168.1.12", "5", "5", time.Now(), test.WithPlatform("ios"))
h6 := test.NewHost(t, ds, "h6"+t.Name(), "192.168.1.12", "6", "6", time.Now(), test.WithPlatform("ipados"))
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{h1.ID}))
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team2.ID, []uint{h2.ID}))
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{h3.ID}))
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{h4.ID}))
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{h6.ID}))
require.NoError(t, ds.SetOrUpdateMDMData(context.Background(), h1.ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, ""))
require.NoError(t, ds.SetOrUpdateMDMData(context.Background(), h2.ID, false, true, "url", false, "", ""))
require.NoError(t, ds.SetOrUpdateMDMData(context.Background(), h5.ID, false, true, "https://fleet.example.com", true, fleet.WellKnownMDMFleet, ""))
require.NoError(t, ds.SetOrUpdateMDMData(context.Background(), h6.ID, false, true, "https://fleet.example.com", true, fleet.WellKnownMDMFleet, ""))
// Add a server, this will be ignored in lists and aggregated data.
require.NoError(t, ds.SetOrUpdateMDMData(context.Background(), h4.ID, true, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, ""))
@ -5782,20 +5788,39 @@ func testAggregatedHostMDMAndMunki(t *testing.T, ds *Datastore) {
})
require.True(t, updatedAt.After(firstUpdatedAt))
status, _, err = ds.AggregatedMDMStatus(context.Background(), nil, "")
require.NoError(t, err)
assert.Equal(t, 11, status.HostsCount)
assert.Equal(t, 1, status.UnenrolledHostsCount)
assert.Equal(t, 5, status.EnrolledManualHostsCount)
assert.Equal(t, 3, status.EnrolledAutomatedHostsCount)
status, _, err = ds.AggregatedMDMStatus(context.Background(), &team1.ID, "")
require.NoError(t, err)
assert.Equal(t, 1, status.HostsCount)
assert.Equal(t, 2, status.HostsCount)
assert.Equal(t, 0, status.UnenrolledHostsCount)
assert.Equal(t, 1, status.EnrolledManualHostsCount)
assert.Equal(t, 0, status.EnrolledAutomatedHostsCount)
assert.Equal(t, 1, status.EnrolledAutomatedHostsCount)
solutions, updatedAt, err = ds.AggregatedMDMSolutions(context.Background(), nil, "")
require.True(t, updatedAt.After(firstUpdatedAt))
require.NoError(t, err)
require.Len(t, solutions, 5)
// Check the new MDM solution used by the iOS/iPadOS
assert.Equal(t, "https://fleet.example.com", solutions[4].ServerURL)
assert.Equal(t, fleet.WellKnownMDMFleet, solutions[4].Name)
assert.Equal(t, 2, solutions[4].HostsCount)
solutions, updatedAt, err = ds.AggregatedMDMSolutions(context.Background(), &team1.ID, "")
require.True(t, updatedAt.After(firstUpdatedAt))
require.NoError(t, err)
require.Len(t, solutions, 1)
require.Len(t, solutions, 2)
assert.Equal(t, "https://simplemdm.com", solutions[0].ServerURL)
assert.Equal(t, fleet.WellKnownMDMSimpleMDM, solutions[0].Name)
assert.Equal(t, 1, solutions[0].HostsCount)
assert.Equal(t, "https://fleet.example.com", solutions[1].ServerURL)
assert.Equal(t, fleet.WellKnownMDMFleet, solutions[1].Name)
assert.Equal(t, 1, solutions[1].HostsCount)
status, _, err = ds.AggregatedMDMStatus(context.Background(), &team1.ID, "darwin")
require.NoError(t, err)
@ -5816,6 +5841,34 @@ func testAggregatedHostMDMAndMunki(t *testing.T, ds *Datastore) {
assert.Equal(t, 1, status.EnrolledManualHostsCount)
assert.Equal(t, 0, status.EnrolledAutomatedHostsCount)
status, _, err = ds.AggregatedMDMStatus(context.Background(), &team1.ID, "ios")
require.NoError(t, err)
assert.Equal(t, 0, status.HostsCount)
assert.Equal(t, 0, status.UnenrolledHostsCount)
assert.Equal(t, 0, status.EnrolledManualHostsCount)
assert.Equal(t, 0, status.EnrolledAutomatedHostsCount)
status, _, err = ds.AggregatedMDMStatus(context.Background(), nil, "ios")
require.NoError(t, err)
assert.Equal(t, 1, status.HostsCount)
assert.Equal(t, 0, status.UnenrolledHostsCount)
assert.Equal(t, 0, status.EnrolledManualHostsCount)
assert.Equal(t, 1, status.EnrolledAutomatedHostsCount)
status, _, err = ds.AggregatedMDMStatus(context.Background(), &team1.ID, "ipados")
require.NoError(t, err)
assert.Equal(t, 1, status.HostsCount)
assert.Equal(t, 0, status.UnenrolledHostsCount)
assert.Equal(t, 0, status.EnrolledManualHostsCount)
assert.Equal(t, 1, status.EnrolledAutomatedHostsCount)
status, _, err = ds.AggregatedMDMStatus(context.Background(), nil, "ipados")
require.NoError(t, err)
assert.Equal(t, 1, status.HostsCount)
assert.Equal(t, 0, status.UnenrolledHostsCount)
assert.Equal(t, 0, status.EnrolledManualHostsCount)
assert.Equal(t, 1, status.EnrolledAutomatedHostsCount)
solutions, updatedAt, err = ds.AggregatedMDMSolutions(context.Background(), &team1.ID, "windows")
require.True(t, updatedAt.After(firstUpdatedAt))
require.NoError(t, err)
@ -5823,6 +5876,35 @@ func testAggregatedHostMDMAndMunki(t *testing.T, ds *Datastore) {
assert.Equal(t, "https://simplemdm.com", solutions[0].ServerURL)
assert.Equal(t, fleet.WellKnownMDMSimpleMDM, solutions[0].Name)
assert.Equal(t, 1, solutions[0].HostsCount)
solutions, updatedAt, err = ds.AggregatedMDMSolutions(context.Background(), nil, "ios")
require.True(t, updatedAt.After(firstUpdatedAt))
require.NoError(t, err)
require.Len(t, solutions, 1)
assert.Equal(t, "https://fleet.example.com", solutions[0].ServerURL)
assert.Equal(t, fleet.WellKnownMDMFleet, solutions[0].Name)
assert.Equal(t, 1, solutions[0].HostsCount)
solutions, updatedAt, err = ds.AggregatedMDMSolutions(context.Background(), &team1.ID, "ios")
require.True(t, updatedAt.After(firstUpdatedAt))
require.NoError(t, err)
require.Len(t, solutions, 0)
solutions, updatedAt, err = ds.AggregatedMDMSolutions(context.Background(), nil, "ipados")
require.True(t, updatedAt.After(firstUpdatedAt))
require.NoError(t, err)
require.Len(t, solutions, 1)
assert.Equal(t, "https://fleet.example.com", solutions[0].ServerURL)
assert.Equal(t, fleet.WellKnownMDMFleet, solutions[0].Name)
assert.Equal(t, 1, solutions[0].HostsCount)
solutions, updatedAt, err = ds.AggregatedMDMSolutions(context.Background(), &team1.ID, "ipados")
require.True(t, updatedAt.After(firstUpdatedAt))
require.NoError(t, err)
require.Len(t, solutions, 1)
assert.Equal(t, "https://fleet.example.com", solutions[0].ServerURL)
assert.Equal(t, fleet.WellKnownMDMFleet, solutions[0].Name)
assert.Equal(t, 1, solutions[0].HostsCount)
}
func testHostsLite(t *testing.T, ds *Datastore) {

View file

@ -1342,7 +1342,6 @@ func (ds *Datastore) AreHostsConnectedToFleetMDM(ctx context.Context, hosts []*f
}
return res, nil
}
func (ds *Datastore) IsHostConnectedToFleetMDM(ctx context.Context, host *fleet.Host) (bool, error) {

View file

@ -0,0 +1,114 @@
package tables
import (
"database/sql"
"fmt"
"time"
"github.com/VividCortex/mysqlerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-sql-driver/mysql"
)
func init() {
MigrationClient.AddMigration(Up_20240707134036, Down_20240707134036)
}
func Up_20240707134036(tx *sql.Tx) error {
// Create new builtin+manual labels for iOS/iPadOS
iOSLabelID, iPadOSLabelID, err := createBuiltinManualIOSAndIPadOSLabels(tx)
if err != nil {
return fmt.Errorf("failed to create iOS/iPadOS labels: %w", err)
}
// Add label membership to existing iOS/iPadOS devices.
if _, err := tx.Exec(`
INSERT INTO label_membership (host_id, label_id)
SELECT id AS host_id, IF(platform = 'ios', ?, ?) AS label_id
FROM hosts WHERE platform = 'ios' OR platform = 'ipados';`,
iOSLabelID, iPadOSLabelID,
); err != nil {
return fmt.Errorf("failed to insert label membership: %w", err)
}
// Move existing iOS/iPadOS profiles from "Verifying" to "Verified"
// (there's no osquery in these devices).
if _, err := tx.Exec(`
UPDATE host_mdm_apple_profiles hmap
JOIN hosts h ON hmap.host_uuid = h.uuid AND
(h.platform = 'ios' OR h.platform = 'ipados') AND hmap.status = 'verifying'
SET hmap.status = 'verified';`,
); err != nil {
return fmt.Errorf("failed to update host_mdm_apple_profiles: %w", err)
}
return nil
}
func createBuiltinManualIOSAndIPadOSLabels(tx *sql.Tx) (iOSLabelID uint, iPadOSLabelID uint, err error) {
// hard-coded timestamps are used so that schema.sql is stable
stableTS := time.Date(2024, 6, 28, 0, 0, 0, 0, time.UTC)
for _, label := range []struct {
name string
description string
platform string
}{
{
fleet.BuiltinLabelIOS,
"All iOS hosts",
"ios",
},
{
fleet.BuiltinLabelIPadOS,
"All iPadOS hosts",
"ipados",
},
} {
res, err := tx.Exec(`
INSERT INTO labels (
name,
description,
query,
platform,
label_type,
label_membership_type,
created_at,
updated_at
) VALUES (?, ?, '', ?, ?, ?, ?, ?);`,
label.name,
label.description,
label.platform,
fleet.LabelTypeBuiltIn,
fleet.LabelMembershipTypeManual,
stableTS,
stableTS,
)
if err != nil {
if driverErr, ok := err.(*mysql.MySQLError); ok {
if driverErr.Number == mysqlerr.ER_DUP_ENTRY {
// All label names need to be unique across built-in and regular.
// Thus we return an error and instruct the user how to solve the issue.
//
// NOTE(lucas): This is using the same approach we used when creating the Sonoma builtin label.
return 0, 0, fmt.Errorf(
"label with the name %q already exists, please rename it before applying this migration: %w",
label.name,
err,
)
}
}
return 0, 0, fmt.Errorf("failed to insert label: %w", err)
}
labelID, _ := res.LastInsertId()
if label.name == fleet.BuiltinLabelIOS {
iOSLabelID = uint(labelID)
} else {
iPadOSLabelID = uint(labelID)
}
}
return iOSLabelID, iPadOSLabelID, nil
}
func Down_20240707134036(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,100 @@
package tables
import (
"fmt"
"sort"
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20240707134036(t *testing.T) {
db := applyUpToPrev(t)
// Insert existing hosts before migration.
hostID := 1
newHost := func(platform, uuid string) uint {
id := fmt.Sprintf("%d", hostID)
hostID++
return uint(execNoErrLastID(t, db,
`INSERT INTO hosts (osquery_host_id, node_key, uuid, platform) VALUES (?, ?, ?, ?);`,
id, id, uuid, platform,
))
}
iOSID := newHost("ios", "iOS_UUID")
iPadOSID := newHost("ipados", "iPadOS_UUID")
newHost("darwin", "macOS_UUID")
// Insert existing profiles and host profiles before migration.
stmt := `
INSERT INTO
mdm_apple_configuration_profiles (team_id, identifier, name, mobileconfig, checksum, profile_uuid)
VALUES (?, ?, ?, ?, '', ?)`
_, err := db.Exec(stmt, 0, "profileID0", "TestPayloadName0", `<?xml version="1.0"`, "profileID0")
require.NoError(t, err)
_, err = db.Exec(stmt, 0, "profileID1", "TestPayloadName1", `<?xml version="1.0"`, "profileID1")
require.NoError(t, err)
stmt = `
INSERT INTO host_mdm_apple_profiles
(profile_uuid, profile_identifier, host_uuid, command_uuid, status, operation_type, detail, checksum)
VALUES
(?, 'com.foo.bar', ?, 'command-uuid', ?, ?, 'detail', '')`
execNoErr(t, db, stmt, "profileID0", "iOS_UUID", "verifying", "install")
execNoErr(t, db, stmt, "profileID0", "iPadOS_UUID", "verifying", "install")
execNoErr(t, db, stmt, "profileID0", "macOS_UUID", "verifying", "install")
execNoErr(t, db, stmt, "profileID1", "iOS_UUID", "pending", "install")
execNoErr(t, db, stmt, "profileID1", "iPadOS_UUID", "pending", "install")
// Apply current migration.
applyNext(t, db)
var labelIDs []uint
err = db.Select(&labelIDs, `SELECT id FROM labels WHERE name = 'iOS' OR name = 'iPadOS';`)
require.NoError(t, err)
require.Len(t, labelIDs, 2)
iOSLabelID := labelIDs[0]
iPadOSLabelID := labelIDs[1]
type hostAndLabel struct {
HostID uint `db:"host_id"`
LabelID uint `db:"label_id"`
}
var labelMemberships []hostAndLabel
err = db.Select(&labelMemberships, `SELECT host_id, label_id FROM label_membership;`)
require.NoError(t, err)
require.Len(t, labelMemberships, 2)
sort.Slice(labelMemberships, func(i, j int) bool {
return labelMemberships[i].HostID < labelMemberships[j].HostID
})
require.Equal(t, iOSID, labelMemberships[0].HostID)
require.Equal(t, iOSLabelID, labelMemberships[0].LabelID)
require.Equal(t, iPadOSID, labelMemberships[1].HostID)
require.Equal(t, iPadOSLabelID, labelMemberships[1].LabelID)
type hostPlusProfile struct {
HostUUID string `db:"host_uuid"`
ProfileUUID string `db:"profile_uuid"`
Status string `db:"status"`
}
var hostProfiles []hostPlusProfile
err = db.Select(&hostProfiles, `SELECT host_uuid, profile_uuid, status FROM host_mdm_apple_profiles;`)
require.NoError(t, err)
require.Len(t, hostProfiles, 5)
for _, hostProfile := range hostProfiles {
switch {
case hostProfile.HostUUID == "iOS_UUID" && hostProfile.ProfileUUID == "profileID0":
require.Equal(t, "verified", hostProfile.Status) // should now be verified
case hostProfile.HostUUID == "iPadOS_UUID" && hostProfile.ProfileUUID == "profileID0":
require.Equal(t, "verified", hostProfile.Status) // should now be verified
case hostProfile.HostUUID == "macOS_UUID" && hostProfile.ProfileUUID == "profileID0":
require.Equal(t, "verifying", hostProfile.Status) // should remain unchanged
case hostProfile.HostUUID == "iOS_UUID" && hostProfile.ProfileUUID == "profileID1":
require.Equal(t, "pending", hostProfile.Status) // should remain unchanged because it's pending
case hostProfile.HostUUID == "iPadOS_UUID" && hostProfile.ProfileUUID == "profileID1":
require.Equal(t, "pending", hostProfile.Status) // should remain unchanged because it's pending
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -156,6 +156,8 @@ const (
BuiltinLabelNameAllLinux = "All Linux"
BuiltinLabelNameChrome = "chrome"
BuiltinLabelMacOS14Plus = "macOS 14+ (Sonoma+)"
BuiltinLabelIOS = "iOS"
BuiltinLabelIPadOS = "iPadOS"
)
// ReservedLabelNames returns a map of label name strings
@ -171,5 +173,7 @@ func ReservedLabelNames() map[string]struct{} {
BuiltinLabelNameAllLinux: {},
BuiltinLabelNameChrome: {},
BuiltinLabelMacOS14Plus: {},
BuiltinLabelIOS: {},
BuiltinLabelIPadOS: {},
}
}

View file

@ -99,8 +99,11 @@ func VerifyHostMDMProfiles(ctx context.Context, ds fleet.ProfileVerificationStor
// the MDM protocol and updates the verification status in the datastore. It is intended to be
// called by the Fleet MDM checkin and command service install profile request handler.
func HandleHostMDMProfileInstallResult(ctx context.Context, ds fleet.ProfileVerificationStore, hostUUID string, cmdUUID string, status *fleet.MDMDeliveryStatus, detail string) error {
host := &fleet.Host{UUID: hostUUID, Platform: "darwin"}
if status != nil && *status == fleet.MDMDeliveryFailed {
// Here we set the host.Platform to "darwin" but it applies to iOS/iPadOS too.
// The logic in GetHostMDMProfileRetryCountByCommandUUID and UpdateHostMDMProfilesVerification
// is the exact same when platform is "darwin", "ios" or "ipados".
host := &fleet.Host{UUID: hostUUID, Platform: "darwin"}
m, err := ds.GetHostMDMProfileRetryCountByCommandUUID(ctx, host, cmdUUID)
if err != nil {
return err

View file

@ -2737,13 +2737,18 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
host.Hostname = deviceName
host.GigsDiskSpaceAvailable = availableDeviceCapacity
host.GigsTotalDiskSpace = deviceCapacity
var osVersionPrefix string
var (
osVersionPrefix string
platform string
)
if strings.HasPrefix(productName, "iPhone") {
osVersionPrefix = "iOS "
osVersionPrefix = "iOS"
platform = "ios"
} else { // iPad
osVersionPrefix = "iPadOS "
osVersionPrefix = "iPadOS"
platform = "ipados"
}
host.OSVersion = osVersionPrefix + osVersion
host.OSVersion = osVersionPrefix + " " + osVersion
host.PrimaryMac = wifiMac
host.HardwareModel = productName
host.DetailUpdatedAt = time.Now()
@ -2753,6 +2758,13 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
if err := svc.ds.SetOrUpdateHostDisksSpace(r.Context, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity, deviceCapacity); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "failed to update host storage")
}
if err := svc.ds.UpdateHostOperatingSystem(r.Context, host.ID, fleet.OperatingSystem{
Name: osVersionPrefix,
Version: osVersion,
Platform: platform,
}); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "failed to update host operating system")
}
return nil, nil
}

View file

@ -3237,6 +3237,13 @@ func TestMDMCommandAndReportResultsIOSIPadOSRefetch(t *testing.T) {
require.NotZero(t, 64, int64(gigsTotal))
return nil
}
ds.UpdateHostOperatingSystemFunc = func(ctx context.Context, hostID uint, hostOS fleet.OperatingSystem) error {
require.Equal(t, hostID, hostID)
require.Equal(t, "iPadOS", hostOS.Name)
require.Equal(t, "17.5.1", hostOS.Version)
require.Equal(t, "ipados", hostOS.Platform)
return nil
}
_, err := svc.CommandAndReportResults(
&mdm.Request{Context: ctx},
@ -3277,4 +3284,5 @@ func TestMDMCommandAndReportResultsIOSIPadOSRefetch(t *testing.T) {
require.True(t, ds.UpdateHostFuncInvoked)
require.True(t, ds.HostByIdentifierFuncInvoked)
require.True(t, ds.SetOrUpdateHostDisksSpaceFuncInvoked)
require.True(t, ds.UpdateHostOperatingSystemFuncInvoked)
}

View file

@ -162,6 +162,20 @@ func AddBuiltinLabels(t *testing.T, ds fleet.Datastore) {
LabelType: fleet.LabelTypeBuiltIn,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
},
{
Name: "iOS",
Platform: "ios",
Query: "",
LabelType: fleet.LabelTypeBuiltIn,
LabelMembershipType: fleet.LabelMembershipTypeManual,
},
{
Name: "iPadOS",
Platform: "ipados",
Query: "",
LabelType: fleet.LabelTypeBuiltIn,
LabelMembershipType: fleet.LabelMembershipTypeManual,
},
}
names := fleet.ReservedLabelNames()

View file

@ -38,6 +38,13 @@ func main() {
log.Fatal("only one of -profile-uuid or -serial-number must be provided")
}
if len(*serverPrivateKey) > 32 {
// We truncate to 32 bytes because AES-256 requires a 32 byte (256 bit) PK, but some
// infra setups generate keys that are longer than 32 bytes.
truncatedServerPrivateKey := (*serverPrivateKey)[:32]
serverPrivateKey = &truncatedServerPrivateKey
}
cfg := config.MysqlConfig{
Protocol: "tcp",
Address: *mysqlAddr,