diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index c7fa291fe1..4c9f3aa800 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -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 +} diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index 2c0b7b8644..676cdd6e95 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -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) -} diff --git a/pkg/mdm/mdmtest/mdmtest.go b/pkg/mdm/mdmtest/mdmtest.go new file mode 100644 index 0000000000..f41a83a77c --- /dev/null +++ b/pkg/mdm/mdmtest/mdmtest.go @@ -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 +} diff --git a/server/fleet/service.go b/server/fleet/service.go index fce79e4aae..0641a2d150 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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. diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 0c0bb573b1..b0f5c669fe 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -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) } diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 23432b0c2a..3d2b2f3125 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -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 } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 88db591d2d..45c5bf49f0 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -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) { uuid `, "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()) }) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index c6c800f498..a3844a9515 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -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", diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 0646cd35d4..d28eb32463 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -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) {