fleet/server/mdm/apple/gdmf/api.go
Eliott Hauteclair 3f9bd7c2d5
Add support for iPods under iOS devices (#27432)
Currently in Fleet Device Management, there is no support for Apple
iPods.

Eventhough iPods are considered vintage by Apple already, we still use
them and I know that in various companies they are still used as a low
cost device within the company. (eg. shops/warehouses to look up stock
levels)

Currently, enrolling an iPod through ABM, results in the device being
recognised as a Mac device.

With this PR, I'd like to add support for iPods, similar functionality
as iPhones to Fleet, simply as iOS device, which works fine. Considering
that all commands are the same (if available) and considering iPods
aren't updated anymore, I don't think we need to explicitly mention it,
perhaps just in docs, and add them to a separate category than iPhones.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [ ] Added/updated automated tests
- I have not added automated tests since it'd basically be a 1:1 copy of
iPhone tests
- [x] Manual QA for all new/changed functionality

> Follows up on discussion from #27263 with @noahtalerman 

Manual QA:

- adding an iPod in ABM results in the device being recognised as iOS
<img width="1754" alt="overview"
src="https://github.com/user-attachments/assets/7681c613-2b34-489a-8b94-10eff8977e19"
/>
<img width="1766" alt="detail-abm"
src="https://github.com/user-attachments/assets/f88c8e84-e55f-4c5f-8998-8b6697b57abc"
/>

- after enrolling the iPod through setup, it is correctly synced with
Fleet and all commands are possible. (tried Restart, Rename device, push
apps)
<img width="1766" alt="ipod-post-sync"
src="https://github.com/user-attachments/assets/7668942e-b110-4c38-a448-b6027419507c"
/>

- enrollment video (can be uploaded if needed)

- manual enrollment works fine too (using Enroll url)

![image](https://github.com/user-attachments/assets/db3073b7-25e0-4ba6-b6a8-e7cbbb56d5d6)

---------

Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com>
2026-01-29 13:54:37 -05:00

215 lines
6.9 KiB
Go

package gdmf
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/cenkalti/backoff"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/rootcert"
)
const baseURL = "https://gdmf.apple.com/v2/pmv"
// Asset represents the metadata for an asset in the Apple Software Lookup Service[1][2].
// Example:
//
// {
// "ProductVersion": "14.6.1",
// "Build": "23G93",
// "PostingDate": "2024-08-07",
// "ExpirationDate": "2024-11-11",
// "SupportedDevices": [
// "J132AP",
// "VMA2MACOSAP",
// "VMM-x86_64"
// ]
// }
//
// [1]: http://gdmf.apple.com/v2/pmv
// [2]:
// https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web
type Asset struct {
ProductVersion string `json:"ProductVersion"`
Build string `json:"Build"`
PostingDate string `json:"PostingDate"`
ExpirationDate string `json:"ExpirationDate"`
SupportedDevices []string `json:"SupportedDevices"`
}
// AssetSets represents the metadata for a set of assets in the Apple Software Lookup Service[1][2].
// [1]: http://gdmf.apple.com/v2/pmv
// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web
type AssetSets struct {
IOS []Asset `json:"iOS"`
MacOS []Asset `json:"macOS"`
// VisionOS []Asset `json:"visionOS"` // Fleet doesn't support visionOS yet
// XROS []Asset `json:"xrOS"` // Fleet doesn't support xrOS yet
}
// APIResponse represents the response from the Apple Software Lookup Service[1][2].
// [1]: http://gdmf.apple.com/v2/pmv
// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web
type APIResponse struct {
PublicAssetSets AssetSets `json:"PublicAssetSets"`
AssetSets AssetSets `json:"AssetSets"`
// PublicRapidSecurityResponses interface{} `json:"PublicRapidSecurityResponses"` // Fleet doesn't support PublicRapidSecurityResponses yet
}
// GetLatestOSVersion returns the latest OS version for the given device. The device is matched
// against the Apple Software Update Lookup Service[1][2] to find the latest version. If no matching
// asset is found, an error is returned.
// [1]: http://gdmf.apple.com/v2/pmv
// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web
func GetLatestOSVersion(device fleet.MDMAppleMachineInfo) (*Asset, error) {
r, err := GetAssetMetadata()
if err != nil {
return nil, fmt.Errorf("retrieving asset metadata: %w", err)
}
assetSet := r.PublicAssetSets.MacOS // default to public asset set; note that if the device is not macOS, iPhone, iPad, or iPod we'll fail to match the supported device and return an error below
if strings.HasPrefix(device.Product, "iPhone") ||
strings.HasPrefix(device.Product, "iPod") ||
strings.HasPrefix(device.Product, "iPad") ||
strings.HasPrefix(device.SoftwareUpdateDeviceID, "iPhone") ||
strings.HasPrefix(device.SoftwareUpdateDeviceID, "iPod") ||
strings.HasPrefix(device.SoftwareUpdateDeviceID, "iPad") {
assetSet = r.PublicAssetSets.IOS
}
latestIdx := -1
for i, s := range assetSet {
for _, d := range s.SupportedDevices {
if d == device.Product || d == device.SoftwareUpdateDeviceID {
if latestIdx == -1 {
latestIdx = i // first match found, update the index
continue
}
if fleet.CompareVersions(assetSet[latestIdx].ProductVersion, s.ProductVersion) < 0 {
latestIdx = i // found a later version, update the index
}
}
}
}
if latestIdx == -1 {
return nil, fmt.Errorf("no matching asset found for device %s", device.Product)
}
return &assetSet[latestIdx], nil
}
// client is a package-level client (similar to http.DefaultClient) so it can
// be reused instead of created as needed, as the internal Transport typically
// has internal state (cached connections, etc) and it's safe for concurrent
// use.
var client = createClient()
func createClient() *http.Client {
// Create TLS config with Apple Root CA certificate.
certPool := x509.NewCertPool()
certPool.AddCert(rootcert.AppleRootCA)
return fleethttp.NewClient(
fleethttp.WithTLSClientConfig(&tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
}),
fleethttp.WithTimeout(10*time.Second),
)
}
// GetAssetMetadata retrieves the asset metadata from the Apple Software Lookup Service[1][2].
// [1]: http://gdmf.apple.com/v2/pmv
// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web
func GetAssetMetadata() (*APIResponse, error) {
baseURL := getBaseURL()
reqURL, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("parsing base URL: %w", err)
}
req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request to Apple endpoint: %w", err)
}
req.Header.Set("User-Agent", "fleet-device-management")
resp, err := doWithRetry(req)
if err != nil {
return nil, fmt.Errorf("retrieving asset metadata: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body from Apple endpoint: %w", err)
}
var dest APIResponse
if err := json.Unmarshal(body, &dest); err != nil {
return nil, fmt.Errorf("decoding response data from Apple endpoint: %w", err)
}
return &dest, nil
}
func doWithRetry(req *http.Request) (*http.Response, error) {
const (
maxRetries = 3
retryBackoff = 1 * time.Second
maxWaitForRetryAfter = 10 * time.Second
)
var resp *http.Response
var err error
op := func() error {
resp, err = client.Do(req)
if err != nil {
return err
}
defer func() {
if resp != nil && resp.StatusCode >= http.StatusBadRequest {
// consume and close the body for retried requests to prevent resource leaks
_, _ = io.ReadAll(resp.Body)
resp.Body.Close()
}
}()
if resp.StatusCode == http.StatusTooManyRequests {
// handle 429 rate-limits
rawAfter := resp.Header.Get("Retry-After")
afterSecs, err := strconv.ParseInt(rawAfter, 10, 0)
if err == nil && (time.Duration(afterSecs)*time.Second) < maxWaitForRetryAfter {
// the retry-after duration is reasonable, wait for it and return a
// retryable error so that we try again.
time.Sleep(time.Duration(afterSecs) * time.Second)
return errors.New("retry after requested delay")
}
}
if resp.StatusCode >= http.StatusBadRequest {
// 400+ status can be worth retrying
return fmt.Errorf("calling gdmf endpoint failed with status %d", resp.StatusCode)
}
return nil
}
if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewConstantBackOff(retryBackoff), uint64(maxRetries))); err != nil {
return nil, err
}
return resp, err
}
func getBaseURL() string {
devURL := dev_mode.Env("FLEET_DEV_GDMF_URL")
if devURL != "" {
return devURL
}
return baseURL
}