Add TestMDMClient to simulate MDM clients in osquery-perf (#11672)

#11528

osquery-perf simulated hosts enroll and are identified as manually
enrolled. (Enrolling as DEP requires more work, e.g. a new mocked Apple
DEP endpoint).

Given that these are simulated MDM clients, they cannot be woken up with
push notifications. Instead, these check for new commands to execute
every 10 seconds (which is not realistic, but could serve as a good
loadtesting exercise).

I will now start setting up the loadtest environment with MDM enabled
and configured to test this.

- ~[ ] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.~
- ~[ ] Documented any API changes (docs/Using-Fleet/REST-API.md or
docs/Contributing/API-for-contributors.md)~
- ~[ ] Documented any permissions changes~
- ~[ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)~
- [X] Added support on fleet's osquery simulator `cmd/osquery-perf` for
new osquery data ingestion features.
- [X] Added/updated tests
- [X] Manual QA for all new/changed functionality
  - ~For Orbit and Fleet Desktop changes:~
- ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows
and Linux.~
- ~[ ] Auto-update manual QA, from released version of component to new
version (see [tools/tuf/test](../tools/tuf/test/README.md)).~
This commit is contained in:
Lucas Manuel Rodriguez 2023-05-12 13:50:20 -03:00 committed by GitHub
parent 2c13f16db7
commit bb3b21b574
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 992 additions and 391 deletions

View file

@ -52,6 +52,7 @@ import (
kitprometheus "github.com/go-kit/kit/metrics/prometheus"
"github.com/kolide/kit/version"
"github.com/micromdm/nanomdm/cryptoutil"
"github.com/micromdm/nanomdm/push"
"github.com/micromdm/nanomdm/push/buford"
nanomdm_pushsvc "github.com/micromdm/nanomdm/push/service"
scep_depot "github.com/micromdm/scep/v2/depot"
@ -166,7 +167,7 @@ the way that the Fleet server works.
if config.MysqlReadReplica.Address != "" {
opts = append(opts, mysql.Replica(&config.MysqlReadReplica))
}
if dev && os.Getenv("FLEET_ENABLE_DEV_SQL_INTERCEPTOR") != "" {
if dev && os.Getenv("FLEET_DEV_ENABLE_SQL_INTERCEPTOR") != "" {
opts = append(opts, mysql.WithInterceptor(&devSQLInterceptor{
logger: kitlog.With(logger, "component", "sql-interceptor"),
}))
@ -455,7 +456,7 @@ the way that the Fleet server works.
appleAPNsKeyPEM []byte
depStorage *mysql.NanoDEPStorage
mdmStorage *mysql.NanoMDMStorage
mdmPushService *nanomdm_pushsvc.PushService
mdmPushService push.Pusher
mdmCheckinAndCommandService *service.MDMAppleCheckinAndCommandService
mdmPushCertTopic string
)
@ -534,7 +535,11 @@ the way that the Fleet server works.
}
nanoMDMLogger := service.NewNanoMDMLogger(kitlog.With(logger, "component", "apple-mdm-push"))
pushProviderFactory := buford.NewPushProviderFactory()
mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger)
if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_PUSH") == "1" {
mdmPushService = nopPusher{}
} else {
mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger)
}
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
mdmCheckinAndCommandService = service.NewMDMAppleCheckinAndCommandService(ds, commander, logger)
appCfg.MDM.EnabledAndConfigured = true
@ -1110,3 +1115,13 @@ func (m *debugMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
m.fleetAuthenticatedHandler.ServeHTTP(w, r)
}
// nopPusher is a no-op push.Pusher.
type nopPusher struct{}
var _ push.Pusher = nopPusher{}
// Push implements push.Pusher.
func (n nopPusher) Push(context.Context, []string) (map[string]*push.Response, error) {
return nil, nil
}

View file

@ -23,7 +23,9 @@ import (
"text/template"
"time"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/google/uuid"
@ -60,12 +62,15 @@ func init() {
}
type Stats struct {
errors int
enrollments int
orbitenrollments int
distributedwrites int
orbitErrors int
desktopErrors int
errors int
osqueryEnrollments int
orbitEnrollments int
mdmEnrollments int
distributedWrites int
mdmCommandsReceived int
orbitErrors int
mdmErrors int
desktopErrors int
l sync.Mutex
}
@ -79,19 +84,31 @@ func (s *Stats) IncrementErrors(errors int) {
func (s *Stats) IncrementEnrollments() {
s.l.Lock()
defer s.l.Unlock()
s.enrollments++
s.osqueryEnrollments++
}
func (s *Stats) IncrementOrbitEnrollments() {
s.l.Lock()
defer s.l.Unlock()
s.orbitenrollments++
s.orbitEnrollments++
}
func (s *Stats) IncrementMDMEnrollments() {
s.l.Lock()
defer s.l.Unlock()
s.mdmEnrollments++
}
func (s *Stats) IncrementDistributedWrites() {
s.l.Lock()
defer s.l.Unlock()
s.distributedwrites++
s.distributedWrites++
}
func (s *Stats) IncrementMDMCommandsReceived() {
s.l.Lock()
defer s.l.Unlock()
s.mdmCommandsReceived++
}
func (s *Stats) IncrementOrbitErrors() {
@ -100,6 +117,12 @@ func (s *Stats) IncrementOrbitErrors() {
s.orbitErrors++
}
func (s *Stats) IncrementMDMErrors() {
s.l.Lock()
defer s.l.Unlock()
s.mdmErrors++
}
func (s *Stats) IncrementDesktopErrors() {
s.l.Lock()
defer s.l.Unlock()
@ -111,14 +134,17 @@ func (s *Stats) Log() {
defer s.l.Unlock()
fmt.Printf(
"%s :: error rate: %.2f \t enrollments: %d \t orbit enrollments: %d \t writes: %d\n \t orbit errors: %d \t desktop errors: %d",
time.Now().String(),
float64(s.errors)/float64(s.enrollments),
s.enrollments,
s.orbitenrollments,
s.distributedwrites,
"%s :: error rate: %.2f, osquery enrolls: %d, orbit enrolls: %d, mdm enrolls: %d, distributed/writes: %d, mdm commands received: %d, orbit errors: %d, desktop errors: %d, mdm errors: %d\n",
time.Now().Format("2006-01-02T15:04:05Z"),
float64(s.errors)/float64(s.osqueryEnrollments),
s.osqueryEnrollments,
s.orbitEnrollments,
s.mdmEnrollments,
s.distributedWrites,
s.mdmCommandsReceived,
s.orbitErrors,
s.desktopErrors,
s.mdmErrors,
)
}
@ -208,13 +234,23 @@ type agent struct {
scheduledQueries []string
// mdmClient simulates a device running the MDM protocol (client side).
mdmClient *mdmtest.TestMDMClient
// isEnrolledToMDM is true when the mdmDevice has enrolled.
isEnrolledToMDM bool
// isEnrolledToMDMMu protects isEnrolledToMDM.
isEnrolledToMDMMu sync.Mutex
//
// The following are exported to be used by the templates.
//
EnrollSecret string
UUID string
SerialNumber string
ConfigInterval time.Duration
QueryInterval time.Duration
MDMCheckInInterval time.Duration
DiskEncryptionEnabled bool
}
@ -236,12 +272,17 @@ type softwareEntityCount struct {
func newAgent(
agentIndex int,
serverAddress, enrollSecret string, templates *template.Template,
configInterval, queryInterval time.Duration, softwareCount softwareEntityCount, userCount entityCount,
serverAddress, enrollSecret string,
templates *template.Template,
configInterval, queryInterval, mdmCheckInInterval time.Duration,
softwareCount softwareEntityCount,
userCount entityCount,
policyPassProb float64,
orbitProb float64,
munkiIssueProb float64, munkiIssueCount int,
emptySerialProb float64,
mdmProb float64,
mdmSCEPChallenge string,
) *agent {
var deviceAuthToken *string
if rand.Float64() <= orbitProb {
@ -251,9 +292,21 @@ func newAgent(
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
serial := randSerial()
serialNumber := mdmtest.RandSerialNumber()
if rand.Float64() <= emptySerialProb {
serial = ""
serialNumber = ""
}
uuid := strings.ToUpper(uuid.New().String())
var mdmClient *mdmtest.TestMDMClient
if rand.Float64() <= mdmProb {
mdmClient = mdmtest.NewTestMDMClientDirect(mdmtest.EnrollInfo{
SCEPChallenge: mdmSCEPChallenge,
SCEPURL: serverAddress + apple_mdm.SCEPPath,
MDMURL: serverAddress + apple_mdm.MDMPath,
})
// Have the osquery agent match the MDM device serial number and UUID.
serialNumber = mdmClient.SerialNumber
uuid = mdmClient.UUID
}
return &agent{
agentIndex: agentIndex,
@ -271,11 +324,14 @@ func newAgent(
deviceAuthToken: deviceAuthToken,
os: strings.TrimRight(templates.Name(), ".tmpl"),
EnrollSecret: enrollSecret,
ConfigInterval: configInterval,
QueryInterval: queryInterval,
UUID: strings.ToUpper(uuid.New().String()),
SerialNumber: serial,
EnrollSecret: enrollSecret,
ConfigInterval: configInterval,
QueryInterval: queryInterval,
MDMCheckInInterval: mdmCheckInInterval,
UUID: uuid,
SerialNumber: serialNumber,
mdmClient: mdmClient,
}
}
@ -316,6 +372,17 @@ func (a *agent) runLoop(i int, onlyAlreadyEnrolled bool) {
go a.runOrbitLoop()
}
if a.mdmClient != nil {
if err := a.mdmClient.Enroll(); err != nil {
log.Printf("MDM enroll failed: %s\n", err)
a.stats.IncrementMDMErrors()
return
}
a.setMDMEnrolled()
a.stats.IncrementMDMEnrollments()
go a.runMDMLoop()
}
configTicker := time.Tick(a.ConfigInterval)
liveQueryTicker := time.Tick(a.QueryInterval)
for {
@ -459,6 +526,29 @@ func (a *agent) runOrbitLoop() {
}
}
func (a *agent) runMDMLoop() {
mdmCheckInTicker := time.Tick(a.MDMCheckInInterval)
for range mdmCheckInTicker {
mdmCommandPayload, err := a.mdmClient.Idle()
if err != nil {
log.Printf("MDM Idle request failed: %s\n", err)
a.stats.IncrementMDMErrors()
continue
}
INNER_FOR_LOOP:
for mdmCommandPayload != nil {
a.stats.IncrementMDMCommandsReceived()
mdmCommandPayload, err = a.mdmClient.Acknowledge(mdmCommandPayload.CommandUUID)
if err != nil {
log.Printf("MDM Acknowledge request failed: %s\n", err)
a.stats.IncrementMDMErrors()
break INNER_FOR_LOOP
}
}
}
}
func (a *agent) waitingDo(req *fasthttp.Request, res *fasthttp.Response) {
err := a.fastClient.Do(req, res)
for err != nil || res.StatusCode() != http.StatusOK {
@ -888,21 +978,38 @@ var possibleMDMServerURLs = []string{
"https://example.com/2",
}
// mdmMac returns the results for the `mdm` table query.
//
// If the host is enrolled via MDM it will return installed_from_dep as false
// (which means the host will be identified as manually enrolled).
//
// NOTE: To support proper DEP simulation in a loadtest environment
// we may need to implement a mocked Apple DEP endpoint.
func (a *agent) mdmMac() []map[string]string {
enrolled := "true"
if rand.Intn(2) == 1 {
enrolled = "false"
if !a.mdmEnrolled() {
return []map[string]string{
{"enrolled": "false", "server_url": "", "installed_from_dep": "false"},
}
}
installedFromDep := "true"
if rand.Intn(2) == 1 {
installedFromDep = "false"
}
ix := rand.Intn(len(possibleMDMServerURLs))
return []map[string]string{
{"enrolled": enrolled, "server_url": possibleMDMServerURLs[ix], "installed_from_dep": installedFromDep},
{"enrolled": "true", "server_url": a.mdmClient.EnrollInfo.MDMURL, "installed_from_dep": "false"},
}
}
func (a *agent) mdmEnrolled() bool {
a.isEnrolledToMDMMu.Lock()
defer a.isEnrolledToMDMMu.Unlock()
return a.isEnrolledToMDM
}
func (a *agent) setMDMEnrolled() {
a.isEnrolledToMDMMu.Lock()
defer a.isEnrolledToMDMMu.Unlock()
a.isEnrolledToMDM = true
}
func (a *agent) mdmWindows() []map[string]string {
autopilot := rand.Intn(2) == 1
ix := rand.Intn(len(possibleMDMServerURLs))
@ -1216,6 +1323,7 @@ func main() {
startPeriod = flag.Duration("start_period", 10*time.Second, "Duration to spread start of hosts over")
configInterval = flag.Duration("config_interval", 1*time.Minute, "Interval for config requests")
queryInterval = flag.Duration("query_interval", 10*time.Second, "Interval for live query requests")
mdmCheckInInterval = flag.Duration("mdm_check_in_interval", 10*time.Second, "Interval for performing MDM check ins")
onlyAlreadyEnrolled = flag.Bool("only_already_enrolled", false, "Only start agents that are already enrolled")
nodeKeyFile = flag.String("node_key_file", "", "File with node keys to use")
@ -1237,7 +1345,10 @@ func main() {
munkiIssueProb = flag.Float64("munki_issue_prob", 0.5, "Probability of a host having munki issues (note that ~50% of hosts have munki installed) [0, 1]")
munkiIssueCount = flag.Int("munki_issue_count", 10, "Number of munki issues reported by hosts identified to have munki issues")
osTemplates = flag.String("os_templates", "mac10.14.6", fmt.Sprintf("Comma separated list of host OS templates to use (any of %v, with or without the .tmpl extension)", allowedTemplateNames))
emptySerialProb = flag.Float64("empty_serial_prob", 0.1, "Probability of a host having no serial number [0, 1]")
emptySerialProb = flag.Float64("empty_serial_prob", 0.0, "Probability of a host having no serial number [0, 1]")
mdmProb = flag.Float64("mdm_prob", 0.1, "Probability of a host enrolling via MDM (for macOS) [0, 1]")
mdmSCEPChallenge = flag.String("mdm_scep_challenge", "", "SCEP challenge to use when running MDM enroll")
)
flag.Parse()
@ -1287,7 +1398,13 @@ func main() {
for i := 0; i < *hostCount; i++ {
tmpl := tmpls[i%len(tmpls)]
a := newAgent(i+1, *serverURL, *enrollSecret, tmpl, *configInterval, *queryInterval,
a := newAgent(i+1,
*serverURL,
*enrollSecret,
tmpl,
*configInterval,
*queryInterval,
*mdmCheckInInterval,
softwareEntityCount{
entityCount: entityCount{
common: *commonSoftwareCount,
@ -1309,6 +1426,8 @@ func main() {
*munkiIssueProb,
*munkiIssueCount,
*emptySerialProb,
*mdmProb,
*mdmSCEPChallenge,
)
a.stats = stats
a.nodeKeyManager = nodeKeyManager
@ -1319,15 +1438,3 @@ func main() {
fmt.Println("Agents running. Kill with C-c.")
<-make(chan struct{})
}
// numbers plus capital letters without I, L, O for readability
const serialLetters = "0123456789ABCDEFGHJKMNPQRSTUVWXYZ"
func randSerial() string {
b := make([]byte, 12)
for i := range b {
//nolint:gosec // not used for crypto, only to generate random serial for testing
b[i] = serialLetters[rand.Intn(len(serialLetters))]
}
return string(b)
}

625
pkg/mdm/mdmtest/mdmtest.go Normal file
View file

@ -0,0 +1,625 @@
// Package mdmtest contains types and methods useful for testing MDM servers.
package mdmtest
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"errors"
"fmt"
"io"
"math/big"
mrand "math/rand"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/go-kit/kit/log"
kitlog "github.com/go-kit/kit/log"
httptransport "github.com/go-kit/kit/transport/http"
"github.com/google/uuid"
"github.com/groob/plist"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/micromdm/nanomdm/mdm"
"github.com/micromdm/scep/v2/cryptoutil/x509util"
"github.com/micromdm/scep/v2/scep"
scepserver "github.com/micromdm/scep/v2/server"
"go.mozilla.org/pkcs7"
)
// TestMDMClient simulates an MDM client.
type TestMDMClient struct {
// UUID is a random fake unique ID of the simulated device.
UUID string
// SerialNumber is a random fake serial number of the simulated device.
SerialNumber string
// Model is the model of the simulated device.
Model string
// EnrollInfo holds the information necessary to enroll to an MDM server.
EnrollInfo EnrollInfo
// fleetServerURL is the URL of the Fleet server, used to fetch the enrollment profile.
fleetServerURL string
// debug enables debug logging of request/responses.
debug bool
// fetchEnrollmentProfileFromDesktop indicates whether this simulated device
// will fetch the enrollment profile from Fleet as if it were a device running
// Fleet Desktop.
fetchEnrollmentProfileFromDesktop bool
// desktopURLToken is the Fleet Desktop token used to fetch the enrollment profile
// from Fleet as if it were a device running Fleet Desktop.
desktopURLToken string
// fetchEnrollmentProfileFromDEP indicates whether this simulated device will fetch
// the enrollment profile from Fleet as if it were a device running the DEP flow.
fetchEnrollmentProfileFromDEP bool
// desktopURLToken is the token used to fetch the enrollment profile
// from Fleet as if it were a device running the DEP flow.
depURLToken string
// scepCert contains the SCEP client certificate generated during the
// SCEP enrollment process.
scepCert *x509.Certificate
// scepKey contains the SCEP client private key generated during the
// SCEP enrollment process.
scepKey *rsa.PrivateKey
}
// TestMDMClientOption allows configuring a TestMDMClient.
type TestMDMClientOption func(*TestMDMClient)
// TestMDMClientDebug configures the TestMDMClient to run in debug mode.
func TestMDMClientDebug() TestMDMClientOption {
return func(c *TestMDMClient) {
c.debug = true
}
}
// EnrollInfo contains the necessary information to enroll to an MDM server.
type EnrollInfo struct {
// SCEPChallenge is the SCEP challenge to present to the SCEP server when enrolling.
SCEPChallenge string
// SCEPURL is the URL of the SCEP server.
SCEPURL string
// MDMURL is the URL of the MDM server.
MDMURL string
}
// NewTestMDMClientDesktopManual will create a simulated device that will fetch
// enrollment profile from Fleet as if it were a device running Fleet Desktop.
func NewTestMDMClientDesktopManual(serverURL string, desktopURLToken string, opts ...TestMDMClientOption) *TestMDMClient {
c := TestMDMClient{
UUID: strings.ToUpper(uuid.New().String()),
SerialNumber: RandSerialNumber(),
Model: "MacBookPro16,1",
fetchEnrollmentProfileFromDesktop: true,
desktopURLToken: desktopURLToken,
fleetServerURL: serverURL,
}
for _, fn := range opts {
fn(&c)
}
return &c
}
// NewTestMDMClientDEP will create a simulated device that will fetch
// enrollment profile from Fleet as if it were a device running the DEP flow.
func NewTestMDMClientDEP(serverURL string, depURLToken string, opts ...TestMDMClientOption) *TestMDMClient {
c := TestMDMClient{
UUID: strings.ToUpper(uuid.New().String()),
SerialNumber: RandSerialNumber(),
Model: "MacBookPro16,1",
fetchEnrollmentProfileFromDEP: true,
depURLToken: depURLToken,
fleetServerURL: serverURL,
}
for _, fn := range opts {
fn(&c)
}
return &c
}
// NewTestMDMClientDEP will create a simulated device that will not fetch the enrollment
// profile from Fleet. The enrollment information is to be provided in the enrollInfo.
func NewTestMDMClientDirect(enrollInfo EnrollInfo, opts ...TestMDMClientOption) *TestMDMClient {
c := TestMDMClient{
UUID: strings.ToUpper(uuid.New().String()),
SerialNumber: RandSerialNumber(),
Model: "MacBookPro16,1",
EnrollInfo: enrollInfo,
}
for _, fn := range opts {
fn(&c)
}
return &c
}
// Enroll runs the MDM enroll protocol on the simulated device.
func (c *TestMDMClient) Enroll() error {
switch {
case c.fetchEnrollmentProfileFromDesktop:
if err := c.fetchEnrollmentProfileFromDesktopURL(); err != nil {
return fmt.Errorf("get enrollment profile from desktop URL: %w", err)
}
case c.fetchEnrollmentProfileFromDEP:
if err := c.fetchEnrollmentProfileFromDEPURL(); err != nil {
return fmt.Errorf("get enrollment profile from DEP URL: %w", err)
}
default:
if c.EnrollInfo.SCEPURL == "" || c.EnrollInfo.MDMURL == "" || c.EnrollInfo.SCEPChallenge == "" {
return fmt.Errorf("missing info needed to perform enrollment: %+v", c.EnrollInfo)
}
}
if err := c.SCEPEnroll(); err != nil {
return fmt.Errorf("scep enroll: %w", err)
}
if err := c.Authenticate(); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
if err := c.TokenUpdate(); err != nil {
return fmt.Errorf("token update: %w", err)
}
return nil
}
func (c *TestMDMClient) fetchEnrollmentProfileFromDesktopURL() error {
return c.fetchEnrollmentProfile(
"/api/latest/fleet/device/" + c.desktopURLToken + "/mdm/apple/manual_enrollment_profile",
)
}
func (c *TestMDMClient) fetchEnrollmentProfileFromDEPURL() error {
return c.fetchEnrollmentProfile(
apple_mdm.EnrollPath + "?token=" + c.depURLToken,
)
}
func (c *TestMDMClient) fetchEnrollmentProfile(path string) error {
request, err := http.NewRequest("GET", c.fleetServerURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
// #nosec (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)
}
if response.StatusCode != http.StatusOK {
return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if err := response.Body.Close(); err != nil {
return fmt.Errorf("close body: %w", err)
}
enrollInfo, err := ParseEnrollmentProfile(body)
if err != nil {
return fmt.Errorf("parse enrollment profile: %w", err)
}
c.EnrollInfo = *enrollInfo
return nil
}
// SCEPEnroll runs the SCEP enroll protocol for the simulated device.
func (c *TestMDMClient) SCEPEnroll() error {
ctx := context.Background()
var logger log.Logger
if c.debug {
logger = kitlog.NewJSONLogger(os.Stdout)
} else {
logger = kitlog.NewNopLogger()
}
client, err := newSCEPClient(c.EnrollInfo.SCEPURL, logger)
if err != nil {
return fmt.Errorf("scep client: %w", err)
}
// (1). Get the CA certificate from the SCEP server.
resp, _, err := client.GetCACert(ctx, "")
if err != nil {
return fmt.Errorf("get CA cert: %w", err)
}
caCert, err := x509.ParseCertificates(resp)
if err != nil {
return fmt.Errorf("parse CA cert: %w", err)
}
// (2). Generate RSA key pair.
devicePrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("generate RSA private key: %w", err)
}
// (3). Generate CSR.
cn := fmt.Sprintf("fleet-testdevice-%s", c.UUID)
csrTemplate := x509util.CertificateRequest{
CertificateRequest: x509.CertificateRequest{
Subject: pkix.Name{
CommonName: cn,
Organization: []string{"fleet-organization"},
},
SignatureAlgorithm: x509.SHA256WithRSA,
},
ChallengePassword: c.EnrollInfo.SCEPChallenge,
}
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, devicePrivateKey)
if err != nil {
return fmt.Errorf("create CSR: %w", err)
}
csr, err := x509.ParseCertificateRequest(csrDerBytes)
if err != nil {
return fmt.Errorf("parse CSR: %w", err)
}
// (4). SCEP requires a certificate for client authentication. We generate a new one
// that uses the same CommonName and Key that we are trying to have signed.
//
// From RFC-8894:
// If the client does not have an appropriate existing certificate, then a locally generated
// self-signed certificate MUST be used. The keyUsage extension in the certificate MUST indicate that
// it is valid for digitalSignature and keyEncipherment (if available). The self-signed certificate
// SHOULD use the same subject name and key as in the PKCS #10 request.
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
certSerialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return fmt.Errorf("generate cert serial number: %w", err)
}
deviceCertificateTemplate := x509.Certificate{
SerialNumber: certSerialNumber,
Subject: pkix.Name{
CommonName: cn,
Organization: csr.Subject.Organization,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
deviceCertificateDerBytes, err := x509.CreateCertificate(
rand.Reader,
&deviceCertificateTemplate,
&deviceCertificateTemplate,
&devicePrivateKey.PublicKey,
devicePrivateKey,
)
if err != nil {
return fmt.Errorf("create device certificate: %w", err)
}
deviceCertificateForRequest, err := x509.ParseCertificate(deviceCertificateDerBytes)
if err != nil {
return fmt.Errorf("parse device certificate: %w", err)
}
// (5). Send the PKCSReq message to the SCEP server.
pkiMsgReq := &scep.PKIMessage{
MessageType: scep.PKCSReq,
Recipients: caCert,
SignerKey: devicePrivateKey,
SignerCert: deviceCertificateForRequest,
CSRReqMessage: &scep.CSRReqMessage{
ChallengePassword: c.EnrollInfo.SCEPChallenge,
},
}
msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(logger))
if err != nil {
return fmt.Errorf("create CSR request: %w", err)
}
respBytes, err := client.PKIOperation(ctx, msg.Raw)
if err != nil {
return fmt.Errorf("do CSR request: %w", err)
}
pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(msg.Recipients))
if err != nil {
return fmt.Errorf("parse PKIMessage response: %w", err)
}
if pkiMsgResp.PKIStatus != scep.SUCCESS {
return fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo)
}
if err := pkiMsgResp.DecryptPKIEnvelope(deviceCertificateForRequest, devicePrivateKey); err != nil {
return fmt.Errorf("decrypt PKI envelope: %w", err)
}
// (6). Finally, set the signed certificate returned from the server as the device certificate and key.
c.scepCert = pkiMsgResp.CertRepMessage.Certificate
c.scepKey = devicePrivateKey
if c.debug {
fmt.Println("SCEP enrollment successful")
}
return nil
}
// Authenticate sends the Authenticate message to the MDM server (Check In protocol).
func (c *TestMDMClient) Authenticate() error {
payload := map[string]any{
"MessageType": "Authenticate",
"UDID": c.UUID,
"Model": c.Model,
"DeviceName": "testdevice" + c.SerialNumber,
"Topic": "com.apple.mgmt.External." + c.UUID,
"EnrollmentID": "testenrollmentid-" + c.UUID,
"SerialNumber": c.SerialNumber,
}
_, err := c.request("application/x-apple-aspen-mdm-checkin", payload)
return err
}
// TokenUpdate sends the TokenUpdate message to the MDM server (Check In protocol).
func (c *TestMDMClient) TokenUpdate() error {
payload := map[string]any{
"MessageType": "TokenUpdate",
"UDID": c.UUID,
"Topic": "com.apple.mgmt.External." + c.UUID,
"EnrollmentID": "testenrollmentid-" + c.UUID,
"NotOnConsole": "false",
"PushMagic": "pushmagic" + c.SerialNumber,
"Token": []byte("token" + c.SerialNumber),
}
_, err := c.request("application/x-apple-aspen-mdm-checkin", payload)
return err
}
// Checkout sends the CheckOut message to the MDM server.
func (c *TestMDMClient) Checkout() error {
payload := map[string]any{
"MessageType": "CheckOut",
"Topic": "com.apple.mgmt.External." + c.UUID,
"UDID": c.UUID,
"EnrollmentID": "testenrollmentid-" + c.UUID,
}
_, err := c.request("application/x-apple-aspen-mdm-checkin", payload)
return err
}
// Idle sends an Idle message to the MDM server.
//
// Devices send an Idle status to signal the server that they're ready to
// receive commands. The server can signal back with either a command to run
// or an empty (nil, nil) response body to end the communication
// (i.e. no commands to run).
func (c *TestMDMClient) Idle() (*micromdm.CommandPayload, error) {
payload := map[string]any{
"Status": "Idle",
"Topic": "com.apple.mgmt.External." + c.UUID,
"UDID": c.UUID,
"EnrollmentID": "testenrollmentid-" + c.UUID,
}
return c.sendAndDecodeCommandResponse(payload)
}
// Acknowledge sends an Acknowledge message to the MDM server.
// The cmdUUID is the UUID of the command to reference.
//
// The server can signal back with either a command to run
// or an empty (nil, nil) response body to end the communication
// (i.e. no commands to run).
func (c *TestMDMClient) Acknowledge(cmdUUID string) (*micromdm.CommandPayload, error) {
payload := map[string]any{
"Status": "Acknowledged",
"Topic": "com.apple.mgmt.External." + c.UUID,
"UDID": c.UUID,
"EnrollmentID": "testenrollmentid-" + c.UUID,
"CommandUUID": cmdUUID,
}
return c.sendAndDecodeCommandResponse(payload)
}
// Err sends an Error message to the MDM server.
// The cmdUUID is the UUID of the command to reference.
//
// The server can signal back with either a command to run
// or an empty (nil, nil) response body to end the communication
// (i.e. no commands to run).
func (c *TestMDMClient) Err(cmdUUID string, errChain []mdm.ErrorChain) (*micromdm.CommandPayload, error) {
payload := map[string]any{
"Status": "Error",
"Topic": "com.apple.mgmt.External." + c.UUID,
"UDID": c.UUID,
"EnrollmentID": "testenrollmentid-" + c.UUID,
"CommandUUID": cmdUUID,
"ErrorChain": errChain,
}
return c.sendAndDecodeCommandResponse(payload)
}
func (c *TestMDMClient) sendAndDecodeCommandResponse(payload map[string]any) (*micromdm.CommandPayload, error) {
res, err := c.request("", payload)
if err != nil {
return nil, fmt.Errorf("request error: %w", err)
}
if res.ContentLength == 0 {
if c.debug {
fmt.Printf("response: no commands returned\n")
}
return nil, nil
}
raw, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if c.debug {
fmt.Printf("response: %s\n", raw)
}
if err = res.Body.Close(); err != nil {
return nil, fmt.Errorf("close response body: %w", err)
}
cmd, err := mdm.DecodeCommand(raw)
if err != nil {
return nil, fmt.Errorf("decode command: %w", err)
}
var p micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &p)
if err != nil {
return nil, fmt.Errorf("unmarshal command payload: %w", err)
}
return &p, nil
}
func (c *TestMDMClient) request(contentType string, payload map[string]any) (*http.Response, error) {
body, err := plist.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
signedData, err := pkcs7.NewSignedData(body)
if err != nil {
return nil, fmt.Errorf("create signed data: %w", err)
}
err = signedData.AddSigner(c.scepCert, c.scepKey, pkcs7.SignerInfoConfig{})
if err != nil {
return nil, fmt.Errorf("add signer: %w", err)
}
sig, err := signedData.Finish()
if err != nil {
return nil, fmt.Errorf("finish signing: %w", err)
}
if c.debug {
fmt.Printf("request: %s\n", body)
}
request, err := http.NewRequest("POST", c.EnrollInfo.MDMURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
request.Header.Set("Content-Type", contentType)
request.Header.Set("Mdm-Signature", base64.StdEncoding.EncodeToString(sig))
// #nosec (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 nil, fmt.Errorf("send request: %w", err)
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status)
}
return response, nil
}
// ParseEnrollmentProfile parses the enrollment profile and returns the parsed information as EnrollInfo.
func ParseEnrollmentProfile(mobileConfig []byte) (*EnrollInfo, error) {
var enrollmentProfile struct {
PayloadContent []map[string]interface{} `plist:"PayloadContent"`
}
if err := plist.Unmarshal(mobileConfig, &enrollmentProfile); err != nil {
return nil, fmt.Errorf("unmarshal enrollment profile: %w", err)
}
payloadContent := enrollmentProfile.PayloadContent[0]["PayloadContent"].(map[string]interface{})
scepChallenge, ok := payloadContent["Challenge"].(string)
if !ok || scepChallenge == "" {
return nil, errors.New("SCEP Challenge field not found")
}
scepURL, ok := payloadContent["URL"].(string)
if !ok || scepURL == "" {
return nil, errors.New("SCEP URL field not found")
}
mdmURL, ok := enrollmentProfile.PayloadContent[1]["ServerURL"].(string)
if !ok || mdmURL == "" {
return nil, errors.New("MDM ServerURL field not found")
}
// Check the server sent a proper APNS topic.
if apnsTopic, ok := enrollmentProfile.PayloadContent[1]["Topic"].(string); !ok || apnsTopic == "" {
return nil, errors.New("MDM Topic field not found")
}
return &EnrollInfo{
SCEPChallenge: scepChallenge,
SCEPURL: scepURL,
MDMURL: mdmURL,
}, nil
}
// numbers plus capital letters without I, L, O for readability
const serialLetters = "0123456789ABCDEFGHJKMNPQRSTUVWXYZ"
// RandSerialNumber returns a fake random serial number.
func RandSerialNumber() string {
b := make([]byte, 12)
for i := range b {
//nolint:gosec // not used for crypto, only to generate random serial for testing
b[i] = serialLetters[mrand.Intn(len(serialLetters))]
}
return string(b)
}
type scepClient interface {
scepserver.Service
Supports(cap string) bool
}
func newSCEPClient(
serverURL string,
logger log.Logger,
) (scepClient, error) {
endpoints, err := makeClientSCEPEndpoints(serverURL)
if err != nil {
return nil, err
}
endpoints.GetEndpoint = scepserver.EndpointLoggingMiddleware(logger)(endpoints.GetEndpoint)
endpoints.PostEndpoint = scepserver.EndpointLoggingMiddleware(logger)(endpoints.PostEndpoint)
return endpoints, nil
}
// makeClientSCEPClientEndpoints returns an Endpoints struct where each endpoint invokes
// the corresponding method on the remote instance, via a transport/http.Client.
func makeClientSCEPEndpoints(instance string) (*scepserver.Endpoints, error) {
if !strings.HasPrefix(instance, "http") {
instance = "http://" + instance
}
tgt, err := url.Parse(instance)
if err != nil {
return nil, err
}
// #nosec (this client is used for testing only)
c := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
}))
options := []httptransport.ClientOption{
httptransport.SetClient(c),
}
return &scepserver.Endpoints{
GetEndpoint: httptransport.NewClient(
"GET",
tgt,
scepserver.EncodeSCEPRequest,
scepserver.DecodeSCEPResponse,
options...).Endpoint(),
PostEndpoint: httptransport.NewClient(
"POST",
tgt,
scepserver.EncodeSCEPRequest,
scepserver.DecodeSCEPResponse,
options...).Endpoint(),
}, nil
}

View file

@ -648,7 +648,7 @@ type Service interface {
// EnqueueMDMAppleCommand enqueues a command for execution on the given
// devices. Note that a deviceID is the same as a host's UUID.
EnqueueMDMAppleCommand(ctx context.Context, rawBase64Cmd string, deviceIDs []string, noPush bool) (status int, result *CommandEnqueueResult, err error)
EnqueueMDMAppleCommand(ctx context.Context, rawBase64Cmd string, deviceIDs []string) (status int, result *CommandEnqueueResult, err error)
// EnqueueMDMAppleCommandRemoveEnrollmentProfile enqueues a command to remove the
// profile used for Fleet MDM enrollment from the specified device.

View file

@ -59,10 +59,6 @@ func ResolveAppleMDMURL(serverURL string) (string, error) {
return resolveURL(serverURL, MDMPath)
}
func ResolveAppleEnrollMDMURL(serverURL string) (string, error) {
return resolveURL(serverURL, EnrollPath)
}
func ResolveAppleSCEPURL(serverURL string) (string, error) {
return resolveURL(serverURL, SCEPPath)
}

View file

@ -906,7 +906,7 @@ func (r enqueueMDMAppleCommandResponse) Status() int { return r.status }
func enqueueMDMAppleCommandEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*enqueueMDMAppleCommandRequest)
status, result, err := svc.EnqueueMDMAppleCommand(ctx, req.Command, req.DeviceIDs, false)
status, result, err := svc.EnqueueMDMAppleCommand(ctx, req.Command, req.DeviceIDs)
if err != nil {
return enqueueMDMAppleCommandResponse{Err: err}, nil
}
@ -920,7 +920,6 @@ func (svc *Service) EnqueueMDMAppleCommand(
ctx context.Context,
rawBase64Cmd string,
deviceIDs []string,
noPush bool,
) (status int, result *fleet.CommandEnqueueResult, err error) {
premiumCommands := map[string]bool{
"EraseDevice": true,
@ -2548,5 +2547,6 @@ func ReconcileProfiles(
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, failed); err != nil {
return ctxerr.Wrap(ctx, err, "reverting status of failed profiles")
}
return nil
}

View file

@ -303,7 +303,7 @@ func TestAppleMDMAuthorization(t *testing.T) {
for _, c := range enqueueCmdCases {
t.Run(c.desc, func(t *testing.T) {
ctx = test.UserContext(ctx, c.user)
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids, false)
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids)
checkAuthErr(t, err, c.shoudFailWithAuth)
})
}
@ -324,7 +324,7 @@ func TestAppleMDMAuthorization(t *testing.T) {
<string>uuid</string>
</dict>
</plist>`, "DeviceLock")))
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}, false)
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"})
require.Error(t, err)
require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
})

View file

@ -3,17 +3,12 @@ package service
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
mathrand "math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
@ -29,6 +24,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
@ -40,7 +36,6 @@ import (
"github.com/fleetdm/fleet/v4/server/service/schedule"
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/google/uuid"
"github.com/groob/plist"
"github.com/jmoiron/sqlx"
@ -52,9 +47,6 @@ import (
"github.com/micromdm/nanomdm/mdm"
"github.com/micromdm/nanomdm/push"
nanomdm_pushsvc "github.com/micromdm/nanomdm/push/service"
scepclient "github.com/micromdm/scep/v2/client"
"github.com/micromdm/scep/v2/cryptoutil/x509util"
"github.com/micromdm/scep/v2/scep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@ -160,6 +152,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
}
},
},
APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589",
}
users, server := RunServerForTestsWithDS(s.T(), s.ds, &config)
s.server = server
@ -180,6 +173,12 @@ func (s *integrationMDMTestSuite) SetupSuite() {
}))
s.T().Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL)
appConf, err = s.ds.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.ServerSettings.ServerURL = server.URL
err = s.ds.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
s.T().Cleanup(fleetdmSrv.Close)
}
@ -251,7 +250,7 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() {
require.NoError(t, getAppleBMResp.Err)
require.Equal(t, "abc", getAppleBMResp.AppleID)
require.Equal(t, "test_org", getAppleBMResp.OrgName)
require.Equal(t, "https://example.org/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
require.Equal(t, s.server.URL+"/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
require.Empty(t, getAppleBMResp.DefaultTeam)
// create a new team
@ -274,7 +273,7 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() {
require.NoError(t, getAppleBMResp.Err)
require.Equal(t, "abc", getAppleBMResp.AppleID)
require.Equal(t, "test_org", getAppleBMResp.OrgName)
require.Equal(t, "https://example.org/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
require.Equal(t, s.server.URL+"/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
require.Equal(t, tm.Name, getAppleBMResp.DefaultTeam)
}
@ -304,7 +303,7 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
var fleetdProfile bytes.Buffer
params := mobileconfig.FleetdProfileOptions{
EnrollSecret: t.Name(),
ServerURL: "https://example.org",
ServerURL: s.server.URL,
PayloadType: mobileconfig.FleetdConfigPayloadIdentifier,
}
err = mobileconfig.FleetdProfileTemplate.Execute(&fleetdProfile, params)
@ -332,6 +331,7 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
// create a non-macOS host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 1,
OsqueryHostID: ptr.String("non-macos-host"),
NodeKey: ptr.String("non-macos-host"),
UUID: uuid.New().String(),
@ -342,6 +342,7 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
// create a host that's not enrolled into MDM
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 2,
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
@ -350,26 +351,14 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
})
require.NoError(t, err)
// create and enroll a host in MDM
d := newDevice(s)
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: d.uuid,
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
HardwareSerial: d.serial,
})
require.NoError(t, err)
d.mdmEnroll(s)
// Create a host and then enroll to MDM.
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
triggerSchedule := func() {
ch := make(chan bool)
s.onScheduleDone = func() { close(ch) }
ch := make(chan struct{})
s.onScheduleDone = func() {
close(ch)
}
_, err := s.profileSchedule.Trigger()
require.NoError(t, err)
<-ch
@ -377,7 +366,7 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
require.Len(t, pushes, 1)
require.Equal(t, pushes[0].PushMagic, "pushmagic"+d.serial)
require.Equal(t, pushes[0].PushMagic, "pushmagic"+mdmDevice.SerialNumber)
res := map[string]*push.Response{
pushes[0].Token.String(): {
Id: uuid.New().String(),
@ -396,10 +385,11 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
// on the first run, cmd will be nil and we need to
// ping the server via idle
if cmd == nil {
cmd = d.idle()
cmd, err = mdmDevice.Idle()
} else {
cmd = d.acknowledge(cmd.CommandUUID)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
}
require.NoError(t, err)
// if after idle or acknowledge cmd is still nil, it
// means there aren't any commands left to run
@ -422,7 +412,10 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
// trigger a profile sync
triggerSchedule()
time.Sleep(5 * time.Second)
installs, removes := checkNextPayloads()
// verify that we received all profiles
require.ElementsMatch(t, wantGlobalProfiles, installs)
require.Empty(t, removes)
@ -483,6 +476,33 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
require.Equal(t, uint(0), noTeamSummaryResp.Verifying)
}
func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestMDMClient) {
desktopToken := uuid.New().String()
mdmDevice := mdmtest.NewTestMDMClientDesktopManual(fleetServerURL, desktopToken)
fleetHost, err := ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name() + uuid.New().String()),
NodeKey: ptr.String(t.Name() + uuid.New().String()),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
UUID: mdmDevice.UUID,
HardwareSerial: mdmDevice.SerialNumber,
})
require.NoError(t, err)
err = ds.SetOrUpdateDeviceAuthToken(context.Background(), fleetHost.ID, desktopToken)
require.NoError(t, err)
err = mdmDevice.Enroll()
require.NoError(t, err)
return fleetHost, mdmDevice
}
func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
t := s.T()
devices := []godep.Device{
@ -553,25 +573,29 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 2)
d := newDevice(s)
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
// enroll one of the hosts
d.serial = devices[0].SerialNumber
d.mdmEnroll(s)
// Enroll one of the hosts
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
mdmDevice := mdmtest.NewTestMDMClientDEP(s.server.URL, depURLToken)
mdmDevice.SerialNumber = devices[0].SerialNumber
err = mdmDevice.Enroll()
require.NoError(t, err)
// make sure the host gets a request to install fleetd
var fleetdCmd *micromdm.CommandPayload
cmd := d.idle()
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
if cmd.Command.RequestType == "InstallEnterpriseApplication" &&
cmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
strings.Contains(*cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) {
fleetdCmd = cmd
}
cmd = d.acknowledge(cmd.CommandUUID)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.NotNil(t, fleetdCmd)
require.NotNil(t, fleetdCmd.Command)
@ -603,6 +627,15 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
require.True(t, found)
}
func loadEnrollmentProfileDEPToken(t *testing.T, ds *mysql.Datastore) string {
var token string
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &token,
`SELECT token FROM mdm_apple_enrollment_profiles`)
})
return token
}
func (s *integrationMDMTestSuite) TestDeviceMDMManualEnroll() {
t := s.T()
@ -620,8 +653,17 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
t := s.T()
// Enroll two devices into MDM
deviceA := newMDMEnrolledDevice(s)
deviceB := newMDMEnrolledDevice(s)
mdmEnrollInfo := mdmtest.EnrollInfo{
SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
}
mdmDeviceA := mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)
err := mdmDeviceA.Enroll()
require.NoError(t, err)
mdmDeviceB := mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)
err = mdmDeviceB.Enroll()
require.NoError(t, err)
// Find the ID of Fleet's MDM solution
var mdmID uint
@ -636,14 +678,14 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
require.Len(t, listHostsRes.Hosts, 2)
require.EqualValues(
t,
[]string{deviceA.uuid, deviceB.uuid},
[]string{mdmDeviceA.UUID, mdmDeviceB.UUID},
[]string{listHostsRes.Hosts[0].UUID, listHostsRes.Hosts[1].UUID},
)
var targetHostID uint
var lastEnroll time.Time
for _, host := range listHostsRes.Hosts {
if host.UUID == deviceA.uuid {
if host.UUID == mdmDeviceA.UUID {
targetHostID = host.ID
lastEnroll = host.LastEnrolledAt
break
@ -664,8 +706,8 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
}
}
require.Len(t, details, 2)
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, deviceA.serial, deviceA.model, deviceA.serial), string(*details[len(details)-2]))
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, deviceB.serial, deviceB.model, deviceB.serial), string(*details[len(details)-1]))
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, mdmDeviceA.SerialNumber, mdmDeviceA.Model, mdmDeviceA.SerialNumber), string(*details[len(details)-2]))
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, mdmDeviceB.SerialNumber, mdmDeviceB.Model, mdmDeviceB.SerialNumber), string(*details[len(details)-1]))
// set an enroll secret
var applyResp applyEnrollSecretSpecResponse
@ -678,7 +720,7 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
// simulate a matching host enrolling via osquery
j, err := json.Marshal(&enrollAgentRequest{
EnrollSecret: t.Name(),
HostIdentifier: deviceA.uuid,
HostIdentifier: mdmDeviceA.UUID,
})
require.NoError(t, err)
var enrollResp enrollAgentResponse
@ -699,7 +741,8 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
require.Greater(t, getHostResp.Host.LastEnrolledAt, lastEnroll)
// Unenroll a device
deviceA.checkout()
err = mdmDeviceA.Checkout()
require.NoError(t, err)
// An activity is created
activities = listActivitiesResponse{}
@ -712,21 +755,30 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
require.Nil(t, activity.ActorID)
require.Nil(t, activity.ActorFullName)
details = append(details, activity.Details)
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, deviceA.serial, deviceA.model, deviceA.serial), string(*activity.Details))
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, mdmDeviceA.SerialNumber, mdmDeviceA.Model, mdmDeviceA.SerialNumber), string(*activity.Details))
}
}
require.True(t, found)
}
func (s *integrationMDMTestSuite) TestDeviceMultipleAuthMessages() {
d := newMDMEnrolledDevice(s)
t := s.T()
mdmDevice := mdmtest.NewTestMDMClientDirect(mdmtest.EnrollInfo{
SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
})
err := mdmDevice.Enroll()
require.NoError(t, err)
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(s.T(), listHostsRes.Hosts, 1)
// send the auth message again, we still have only one host
d.authenticate()
err = mdmDevice.Authenticate()
require.NoError(t, err)
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(s.T(), listHostsRes.Hosts, 1)
@ -777,8 +829,15 @@ func (s *integrationMDMTestSuite) TestAppleMDMCSRRequest() {
func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
t := s.T()
// enroll into mdm
d := newMDMEnrolledDevice(s)
// Enroll a device into MDM.
mdmDevice := mdmtest.NewTestMDMClientDirect(mdmtest.EnrollInfo{
SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
})
err := mdmDevice.Enroll()
require.NoError(t, err)
// set an enroll secret
var applyResp applyEnrollSecretSpecResponse
@ -791,7 +850,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
// simulate a matching host enrolling via osquery
j, err := json.Marshal(&enrollAgentRequest{
EnrollSecret: t.Name(),
HostIdentifier: d.uuid,
HostIdentifier: mdmDevice.UUID,
})
require.NoError(t, err)
var enrollResp enrollAgentResponse
@ -852,13 +911,16 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/unenroll", h.ID), nil, http.StatusInternalServerError)
// try again, but this time the host is online and answers
var checkoutErr error
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
res, err := mockSuccessfulPush(pushes)
d.checkout()
checkoutErr = mdmDevice.Checkout()
return res, err
}
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/unenroll", h.ID), nil, http.StatusOK)
require.NoError(t, checkoutErr)
// profiles are removed and the host is no longer enrolled
hostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", h.ID), getHostRequest{}, http.StatusOK, &hostResp)
@ -1984,14 +2046,18 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesStatus() {
// always receive the "no team" profiles on mdm enrollment since it would
// not be part of any team yet (team assignment is done when it enrolls
// with orbit).
d := newDevice(s)
mdmDevice := mdmtest.NewTestMDMClientDirect(mdmtest.EnrollInfo{
SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
})
// enroll the device with orbit
var resp EnrollOrbitResponse
s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{
EnrollSecret: secret,
HardwareUUID: d.uuid, // will not match any existing host
HardwareSerial: d.serial,
HardwareUUID: mdmDevice.UUID, // will not match any existing host
HardwareSerial: mdmDevice.SerialNumber,
}, http.StatusOK, &resp)
require.NotEmpty(t, resp.OrbitNodeKey)
orbitNodeKey := resp.OrbitNodeKey
@ -1999,7 +2065,8 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesStatus() {
require.NoError(t, err)
h.OrbitNodeKey = &orbitNodeKey
d.mdmEnroll(s)
err = mdmDevice.Enroll()
require.NoError(t, err)
return h
}
@ -2467,8 +2534,17 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
ctx := context.Background()
t := s.T()
// Create host enrolled via osquery, but not enrolled in MDM.
unenrolledHost := createHostAndDeviceToken(t, s.ds, "unused")
enrolledHost := newMDMEnrolledDevice(s)
// Create device enrolled in MDM but not enrolled via osquery.
mdmDevice := mdmtest.NewTestMDMClientDirect(mdmtest.EnrollInfo{
SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
})
err := mdmDevice.Enroll()
require.NoError(t, err)
base64Cmd := func(rawCmd string) string {
return base64.RawStdEncoding.EncodeToString([]byte(rawCmd))
@ -2522,7 +2598,7 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
res = s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
Command: base64Cmd(string(mobileconfigForTest("test config profile", uuid.New().String()))),
DeviceIDs: []string{enrolledHost.uuid},
DeviceIDs: []string{mdmDevice.UUID},
}, http.StatusUnsupportedMediaType)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "unable to decode plist command")
@ -2534,7 +2610,7 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
Command: base64Cmd(rawCmd),
DeviceIDs: []string{enrolledHost.uuid},
DeviceIDs: []string{mdmDevice.UUID},
}, http.StatusOK, &resp)
require.NotEmpty(t, resp.CommandUUID)
require.Contains(t, rawCmd, resp.CommandUUID)
@ -2546,8 +2622,8 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
require.Len(t, cmdResResp.Results, 0)
// simulate a result and call again
err := s.mdmStorage.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: enrolledHost.uuid},
err = s.mdmStorage.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: mdmDevice.UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: uuid2,
@ -2557,7 +2633,7 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
})
require.NoError(t, err)
h, err := s.ds.HostByIdentifier(ctx, enrolledHost.uuid)
h, err := s.ds.HostByIdentifier(ctx, mdmDevice.UUID)
require.NoError(t, err)
h.Hostname = "test-host"
err = s.ds.UpdateHost(ctx, h)
@ -2568,7 +2644,7 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
require.NotZero(t, cmdResResp.Results[0].UpdatedAt)
cmdResResp.Results[0].UpdatedAt = time.Time{}
require.Equal(t, &fleet.MDMAppleCommandResult{
DeviceID: enrolledHost.uuid,
DeviceID: mdmDevice.UUID,
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "ProfileList",
@ -2582,7 +2658,7 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
require.NotZero(t, listCmdResp.Results[0].UpdatedAt)
listCmdResp.Results[0].UpdatedAt = time.Time{}
require.Equal(t, &fleet.MDMAppleCommand{
DeviceID: enrolledHost.uuid,
DeviceID: mdmDevice.UUID,
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "ProfileList",
@ -2700,7 +2776,7 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
type deviceWithResponse struct {
bootstrapResponse string
device *device
device *mdmtest.TestMDMClient
}
// Note: The responses specified here are not a 1:1 mapping of the possible responses specified
@ -2714,42 +2790,47 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
// - Error means that the device will enroll and fail to install the bp
// - Offline means that the device will enroll but won't acknowledge nor fail the bp request
// - Pending means that the device won't enroll at all
mdmEnrollInfo := mdmtest.EnrollInfo{
SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
}
noTeamDevices := []deviceWithResponse{
{"Acknowledge", newDevice(s)},
{"Acknowledge", newDevice(s)},
{"Acknowledge", newDevice(s)},
{"Error", newDevice(s)},
{"Offline", newDevice(s)},
{"Offline", newDevice(s)},
{"Pending", newDevice(s)},
{"Pending", newDevice(s)},
{"Acknowledge", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Acknowledge", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Acknowledge", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Error", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Offline", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Offline", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Pending", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Pending", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
}
teamDevices := []deviceWithResponse{
{"Acknowledge", newDevice(s)},
{"Acknowledge", newDevice(s)},
{"Error", newDevice(s)},
{"Error", newDevice(s)},
{"Error", newDevice(s)},
{"Offline", newDevice(s)},
{"Pending", newDevice(s)},
{"Acknowledge", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Acknowledge", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Error", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Error", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Error", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Offline", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
{"Pending", mdmtest.NewTestMDMClientDirect(mdmEnrollInfo)},
}
expectedSerialsByTeamAndStatus := make(map[uint]map[fleet.MDMBootstrapPackageStatus][]string)
expectedSerialsByTeamAndStatus[0] = map[fleet.MDMBootstrapPackageStatus][]string{
fleet.MDMBootstrapPackageInstalled: {noTeamDevices[0].device.serial, noTeamDevices[1].device.serial, noTeamDevices[2].device.serial},
fleet.MDMBootstrapPackageFailed: {noTeamDevices[3].device.serial},
fleet.MDMBootstrapPackagePending: {noTeamDevices[4].device.serial, noTeamDevices[5].device.serial, noTeamDevices[6].device.serial, noTeamDevices[7].device.serial},
fleet.MDMBootstrapPackageInstalled: {noTeamDevices[0].device.SerialNumber, noTeamDevices[1].device.SerialNumber, noTeamDevices[2].device.SerialNumber},
fleet.MDMBootstrapPackageFailed: {noTeamDevices[3].device.SerialNumber},
fleet.MDMBootstrapPackagePending: {noTeamDevices[4].device.SerialNumber, noTeamDevices[5].device.SerialNumber, noTeamDevices[6].device.SerialNumber, noTeamDevices[7].device.SerialNumber},
}
expectedSerialsByTeamAndStatus[team.ID] = map[fleet.MDMBootstrapPackageStatus][]string{
fleet.MDMBootstrapPackageInstalled: {teamDevices[0].device.serial, teamDevices[1].device.serial},
fleet.MDMBootstrapPackageFailed: {teamDevices[2].device.serial, teamDevices[3].device.serial, teamDevices[4].device.serial},
fleet.MDMBootstrapPackagePending: {teamDevices[5].device.serial, teamDevices[6].device.serial},
fleet.MDMBootstrapPackageInstalled: {teamDevices[0].device.SerialNumber, teamDevices[1].device.SerialNumber},
fleet.MDMBootstrapPackageFailed: {teamDevices[2].device.SerialNumber, teamDevices[3].device.SerialNumber, teamDevices[4].device.SerialNumber},
fleet.MDMBootstrapPackagePending: {teamDevices[5].device.SerialNumber, teamDevices[6].device.SerialNumber},
}
// for good measure, add a couple of manually enrolled hosts
_ = newMDMEnrolledDevice(s)
_ = newMDMEnrolledDevice(s)
createHostThenEnrollMDM(s.ds, s.server.URL, t)
createHostThenEnrollMDM(s.ds, s.server.URL, t)
// create a non-macOS host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
@ -2789,7 +2870,7 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
case "/devices/sync":
depResp := []godep.Device{}
for _, gd := range mockRespDevices {
depResp = append(depResp, godep.Device{SerialNumber: gd.device.serial})
depResp = append(depResp, godep.Device{SerialNumber: gd.device.SerialNumber})
}
err := encoder.Encode(godep.DeviceResponse{Devices: depResp})
require.NoError(t, err)
@ -2834,24 +2915,28 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
// devices send their responses
enrollAndCheckBootstrapPackage := func(d *deviceWithResponse, bp *fleet.MDMAppleBootstrapPackage) {
d.device.mdmEnroll(s)
cmd := d.device.idle()
err := d.device.Enroll()
require.NoError(t, err)
cmd, err := d.device.Idle()
require.NoError(t, err)
for cmd != nil {
// if the command is to install the bootstrap package
if manifest := cmd.Command.InstallEnterpriseApplication.Manifest; manifest != nil {
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
require.Equal(t, "software-package", (*manifest).ManifestItems[0].Assets[0].Kind)
wantURL, err := bp.URL("https://example.org")
wantURL, err := bp.URL(s.server.URL)
require.NoError(t, err)
require.Equal(t, wantURL, (*manifest).ManifestItems[0].Assets[0].URL)
// respond to the command accordingly
switch d.bootstrapResponse {
case "Acknowledge":
cmd = d.device.acknowledge(cmd.CommandUUID)
cmd, err = d.device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
case "Error":
cmd = d.device.err(cmd.CommandUUID, mockErrorChain)
cmd, err = d.device.Err(cmd.CommandUUID, mockErrorChain)
require.NoError(t, err)
continue
case "Offline":
// host is offline, can't process any more commands
@ -2859,7 +2944,8 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
continue
}
}
cmd = d.device.acknowledge(cmd.CommandUUID)
cmd, err = d.device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
@ -3639,240 +3725,6 @@ func (s *integrationMDMTestSuite) uploadEULA(
}
}
type device struct {
uuid string
serial string
model string
s *integrationMDMTestSuite
scepCert *x509.Certificate
scepKey *rsa.PrivateKey
}
func newDevice(s *integrationMDMTestSuite) *device {
return &device{
uuid: strings.ToUpper(uuid.New().String()),
serial: randSerial(),
model: "MacBookPro16,1",
s: s,
}
}
func newMDMEnrolledDevice(s *integrationMDMTestSuite) *device {
d := newDevice(s)
d.mdmEnroll(s)
return d
}
func (d *device) mdmEnroll(s *integrationMDMTestSuite) {
d.scepEnroll()
d.authenticate()
d.tokenUpdate()
}
func (d *device) authenticate() {
payload := map[string]any{
"MessageType": "Authenticate",
"UDID": d.uuid,
"Model": d.model,
"DeviceName": "testdevice" + d.serial,
"Topic": "com.apple.mgmt.External." + d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"SerialNumber": d.serial,
}
d.request("application/x-apple-aspen-mdm-checkin", payload)
}
func (d *device) tokenUpdate() {
payload := map[string]any{
"MessageType": "TokenUpdate",
"UDID": d.uuid,
"Topic": "com.apple.mgmt.External." + d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"NotOnConsole": "false",
"PushMagic": "pushmagic" + d.serial,
"Token": []byte("token" + d.serial),
}
d.request("application/x-apple-aspen-mdm-checkin", payload)
}
func (d *device) checkout() {
payload := map[string]any{
"MessageType": "CheckOut",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
}
d.request("application/x-apple-aspen-mdm-checkin", payload)
}
// Devices send an Idle status to signal the server that they're ready to
// receive commands.
// The server can signal back with either a command to run
// or an empty response body to end the communication.
func (d *device) idle() *micromdm.CommandPayload {
payload := map[string]any{
"Status": "Idle",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
}
return d.sendAndDecodeCommandResponse(payload)
}
func (d *device) acknowledge(cmdUUID string) *micromdm.CommandPayload {
payload := map[string]any{
"Status": "Acknowledged",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"CommandUUID": cmdUUID,
}
return d.sendAndDecodeCommandResponse(payload)
}
func (d *device) err(cmdUUID string, errChain []mdm.ErrorChain) *micromdm.CommandPayload {
payload := map[string]any{
"Status": "Error",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"CommandUUID": cmdUUID,
"ErrorChain": errChain,
}
return d.sendAndDecodeCommandResponse(payload)
}
func (d *device) sendAndDecodeCommandResponse(payload map[string]any) *micromdm.CommandPayload {
res := d.request("", payload)
if res.ContentLength == 0 {
return nil
}
raw, err := io.ReadAll(res.Body)
require.NoError(d.s.T(), err)
cmd, err := mdm.DecodeCommand(raw)
require.NoError(d.s.T(), err)
var p micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &p)
require.NoError(d.s.T(), err)
return &p
}
func (d *device) request(reqType string, payload map[string]any) *http.Response {
body, err := plist.Marshal(payload)
require.NoError(d.s.T(), err)
signedData, err := pkcs7.NewSignedData(body)
require.NoError(d.s.T(), err)
err = signedData.AddSigner(d.scepCert, d.scepKey, pkcs7.SignerInfoConfig{})
require.NoError(d.s.T(), err)
sig, err := signedData.Finish()
require.NoError(d.s.T(), err)
return d.s.DoRawWithHeaders(
"POST",
"/mdm/apple/mdm",
body,
200,
map[string]string{
"Content-Type": reqType,
"Mdm-Signature": base64.StdEncoding.EncodeToString(sig),
},
)
}
func (d *device) scepEnroll() {
t := d.s.T()
ctx := context.Background()
logger := kitlog.NewJSONLogger(os.Stdout)
logger = level.NewFilter(logger, level.AllowDebug())
client, err := scepclient.New(d.s.server.URL+apple_mdm.SCEPPath, logger)
require.NoError(t, err)
resp, _, err := client.GetCACert(ctx, "")
require.NoError(t, err)
certs, err := x509.ParseCertificates(resp)
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
csrTemplate := x509util.CertificateRequest{
CertificateRequest: x509.CertificateRequest{
Subject: pkix.Name{
CommonName: "fleet-test",
},
SignatureAlgorithm: x509.SHA256WithRSA,
},
ChallengePassword: d.s.fleetCfg.MDM.AppleSCEPChallenge,
}
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
require.NoError(t, err)
csr, err := x509.ParseCertificateRequest(csrDerBytes)
require.NoError(t, err)
notBefore := time.Now()
notAfter := notBefore.Add(time.Hour)
certTemplate := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "SCEP SIGNER",
Organization: csr.Subject.Organization,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certDerBytes, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &key.PublicKey, key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(certDerBytes)
require.NoError(t, err)
tmpl := &scep.PKIMessage{
MessageType: scep.PKCSReq,
Recipients: certs,
SignerKey: key,
SignerCert: cert,
CSRReqMessage: &scep.CSRReqMessage{
ChallengePassword: d.s.fleetCfg.MDM.AppleSCEPChallenge,
},
}
msg, err := scep.NewCSRRequest(csr, tmpl, scep.WithLogger(logger))
require.NoError(t, err)
respBytes, err := client.PKIOperation(ctx, msg.Raw)
require.NoError(t, err)
respMsg, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(msg.Recipients))
require.NoError(t, err)
require.Equal(t, scep.SUCCESS, respMsg.PKIStatus)
err = respMsg.DecryptPKIEnvelope(cert, key)
require.NoError(t, err)
d.scepCert = respMsg.CertRepMessage.Certificate
d.scepKey = key
}
// numbers plus capital letters without I, L, O for readability
const serialLetters = "0123456789ABCDEFGHJKMNPQRSTUVWXYZ"
func randSerial() string {
b := make([]byte, 12)
for i := range b {
//nolint:gosec // not used for crypto, only to generate random serial for testing
b[i] = serialLetters[mathrand.Intn(len(serialLetters))]
}
return string(b)
}
var testBMToken = &nanodep_client.OAuth1Tokens{
ConsumerKey: "test_consumer",
ConsumerSecret: "test_secret",

View file

@ -123,6 +123,11 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
}
}
mdmPushCertTopic := ""
if len(opts) > 0 && opts[0].APNSTopic != "" {
mdmPushCertTopic = opts[0].APNSTopic
}
svc, err := NewService(
ctx,
ds,
@ -143,7 +148,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
depStorage,
mdmStorage,
mdmPusher,
"",
mdmPushCertTopic,
cronSchedulesService,
)
if err != nil {
@ -262,6 +267,7 @@ type TestServerOpts struct {
HTTPServerConfig *http.Server
StartCronSchedules []TestNewScheduleFunc
UseMailService bool
APNSTopic string
}
func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) {