mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 17:08:53 +00:00
Feat save certs (#19390)
for #10383 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality
This commit is contained in:
commit
a343eda885
153 changed files with 5230 additions and 1607 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -38,6 +38,5 @@
|
|||
"prettier.requireConfig": true,
|
||||
"yaml.schemas": {
|
||||
"https://json.schemastore.org/codecov.json": ".github/workflows/codecov.yml"
|
||||
},
|
||||
"favorites.sortOrder": "ASC"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
changes/10383-mdm-saved-certs-ui
Normal file
1
changes/10383-mdm-saved-certs-ui
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Updated UI to support new workflows for macOS MDM setup and credentials.
|
||||
2
changes/19014-certs-endpoints
Normal file
2
changes/19014-certs-endpoints
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Adds a `GET /fleet/mdm/apple/request_csr` endpoint, which returns the signed APNS CSR needed to
|
||||
activate Apple MDM.
|
||||
1
changes/19179-bm
Normal file
1
changes/19179-bm
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added new endpoints to configure ABM keypairs and tokens
|
||||
2
changes/jve-pk-docs
Normal file
2
changes/jve-pk-docs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Updates the private key requirements to allow keys longer than 32 bytes
|
||||
- Adds documentation around the new `FLEET_SERVER_PRIVATE_KEY` var
|
||||
2
changes/post-apns-cert
Normal file
2
changes/post-apns-cert
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Adds 2 new endpoints: `POST` and `DELETE /fleet/mdm/apple/apns_certificate`. These endpoints let
|
||||
users manage APNS certificates in Fleet.
|
||||
2
changes/save-certs-encrypted
Normal file
2
changes/save-certs-encrypted
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Adds a new Fleet server config variable, `FLEET_SERVER_PRIVATE_KEY`. This variable contains the
|
||||
private key used to encrypt the MDM certificates and keys stored in Fleet.
|
||||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
||||
"github.com/fleetdm/fleet/v4/server/policies"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
|
|
@ -807,7 +808,7 @@ func newCleanupsAndAggregationSchedule(
|
|||
schedule.WithJob(
|
||||
"verify_disk_encryption_keys",
|
||||
func(ctx context.Context) error {
|
||||
return verifyDiskEncryptionKeys(ctx, logger, ds, config)
|
||||
return verifyDiskEncryptionKeys(ctx, logger, ds)
|
||||
},
|
||||
),
|
||||
schedule.WithJob(
|
||||
|
|
@ -904,9 +905,15 @@ func verifyDiskEncryptionKeys(
|
|||
ctx context.Context,
|
||||
logger kitlog.Logger,
|
||||
ds fleet.Datastore,
|
||||
config *config.FleetConfig,
|
||||
) error {
|
||||
if !config.MDM.IsAppleSCEPSet() {
|
||||
|
||||
appCfg, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
logger.Log("err", "unable to get app config", "details", err)
|
||||
return ctxerr.Wrap(ctx, err, "fetching app config")
|
||||
}
|
||||
|
||||
if !appCfg.MDM.EnabledAndConfigured {
|
||||
logger.Log("inf", "skipping verification of macOS encryption keys as MDM is not fully configured")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -917,10 +924,10 @@ func verifyDiskEncryptionKeys(
|
|||
return err
|
||||
}
|
||||
|
||||
cert, _, _, err := config.MDM.AppleSCEP()
|
||||
cert, err := assets.CAKeyPair(ctx, ds)
|
||||
if err != nil {
|
||||
logger.Log("err", "unable to get SCEP keypair to decrypt keys", "details", err)
|
||||
return err
|
||||
logger.Log("err", "unable to get CA keypair", "details", err)
|
||||
return ctxerr.Wrap(ctx, err, "parsing SCEP keypair")
|
||||
}
|
||||
|
||||
decryptable := []uint{}
|
||||
|
|
@ -1013,11 +1020,24 @@ func newAppleMDMDEPProfileAssigner(
|
|||
) (*schedule.Schedule, error) {
|
||||
const name = string(fleet.CronAppleMDMDEPProfileAssigner)
|
||||
logger = kitlog.With(logger, "cron", name, "component", "nanodep-syncer")
|
||||
fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger)
|
||||
var fleetSyncer *apple_mdm.DEPService
|
||||
s := schedule.New(
|
||||
ctx, name, instanceID, periodicity, ds, ds,
|
||||
schedule.WithLogger(logger),
|
||||
schedule.WithJob("dep_syncer", func(ctx context.Context) error {
|
||||
appCfg, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "retrieving app config")
|
||||
}
|
||||
|
||||
if !appCfg.MDM.AppleBMEnabledAndConfigured {
|
||||
return nil
|
||||
}
|
||||
|
||||
if fleetSyncer == nil {
|
||||
fleetSyncer = apple_mdm.NewDEPService(ds, depStorage, logger)
|
||||
}
|
||||
|
||||
return fleetSyncer.RunAssigner(ctx)
|
||||
}),
|
||||
)
|
||||
|
|
@ -1031,8 +1051,6 @@ func newMDMProfileManager(
|
|||
ds fleet.Datastore,
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
logger kitlog.Logger,
|
||||
loggingDebug bool,
|
||||
cfg config.MDMConfig,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronMDMAppleProfileManager)
|
||||
|
|
@ -1047,7 +1065,7 @@ func newMDMProfileManager(
|
|||
ctx, name, instanceID, defaultInterval, ds, ds,
|
||||
schedule.WithLogger(logger),
|
||||
schedule.WithJob("manage_apple_profiles", func(ctx context.Context) error {
|
||||
return service.ReconcileAppleProfiles(ctx, ds, commander, logger, cfg)
|
||||
return service.ReconcileAppleProfiles(ctx, ds, commander, logger)
|
||||
}),
|
||||
schedule.WithJob("manage_apple_declarations", func(ctx context.Context) error {
|
||||
return service.ReconcileAppleDeclarations(ctx, ds, commander, logger)
|
||||
|
|
@ -1196,6 +1214,16 @@ func newIPhoneIPadRefetcher(
|
|||
ctx, name, instanceID, periodicity, ds, ds,
|
||||
schedule.WithLogger(logger),
|
||||
schedule.WithJob("cron_iphone_ipad_refetcher", func(ctx context.Context) error {
|
||||
appCfg, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "fetching app config")
|
||||
}
|
||||
|
||||
if !appCfg.MDM.EnabledAndConfigured {
|
||||
level.Debug(logger).Log("msg", "apple mdm is not configured, skipping run")
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
uuids, err := ds.ListIOSAndIPadOSToRefetch(ctx, 1*time.Hour)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -6,21 +6,20 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
||||
kitlog "github.com/go-kit/log"
|
||||
)
|
||||
|
||||
func TestNewMDMProfileManagerWithoutConfig(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mdmStorage := &mock.MDMAppleStore{}
|
||||
mdmStorage := &mdmmock.MDMAppleStore{}
|
||||
ds := new(mock.Store)
|
||||
mdmConfig := config.MDMConfig{}
|
||||
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, nil, mdmConfig)
|
||||
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, nil)
|
||||
logger := kitlog.NewNopLogger()
|
||||
|
||||
sch, err := newMDMProfileManager(ctx, "foo", ds, cmdr, logger, false, mdmConfig)
|
||||
sch, err := newMDMProfileManager(ctx, "foo", ds, cmdr, logger)
|
||||
require.NotNil(t, sch)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,11 +44,9 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/mail"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/buford"
|
||||
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
|
||||
scep_depot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
"github.com/fleetdm/fleet/v4/server/pubsub"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/fleetdm/fleet/v4/server/service/async"
|
||||
|
|
@ -163,6 +161,14 @@ the way that the Fleet server works.
|
|||
}
|
||||
}
|
||||
|
||||
if len([]byte(config.Server.PrivateKey)) < 32 {
|
||||
initFatal(errors.New("private key must be at least 32 bytes long"), "validate private key")
|
||||
}
|
||||
|
||||
// We truncate to 32 bytes because AES-256 requires a 32 byte (256 bit) PK, but some
|
||||
// infra setups generate keys that are longer than 32 bytes.
|
||||
config.Server.PrivateKey = config.Server.PrivateKey[:32]
|
||||
|
||||
var ds fleet.Datastore
|
||||
var carveStore fleet.CarveStore
|
||||
var installerStore fleet.InstallerStore
|
||||
|
|
@ -457,19 +463,29 @@ the way that the Fleet server works.
|
|||
}
|
||||
}
|
||||
|
||||
var (
|
||||
scepStorage scep_depot.Depot
|
||||
appleSCEPCertPEM []byte
|
||||
appleSCEPKeyPEM []byte
|
||||
appleAPNsCertPEM []byte
|
||||
appleAPNsKeyPEM []byte
|
||||
depStorage *mysql.NanoDEPStorage
|
||||
mdmStorage *mysql.NanoMDMStorage
|
||||
mdmPushService push.Pusher
|
||||
mdmCheckinAndCommandService *service.MDMAppleCheckinAndCommandService
|
||||
ddmService *service.MDMAppleDDMService
|
||||
mdmPushCertTopic string
|
||||
)
|
||||
mdmStorage, err := mds.NewMDMAppleMDMStorage()
|
||||
if err != nil {
|
||||
initFatal(err, "initialize mdm apple MySQL storage")
|
||||
}
|
||||
|
||||
depStorage, err := mds.NewMDMAppleDEPStorage()
|
||||
if err != nil {
|
||||
initFatal(err, "initialize Apple BM DEP storage")
|
||||
}
|
||||
|
||||
scepStorage, err := mds.NewSCEPDepot()
|
||||
if err != nil {
|
||||
initFatal(err, "initialize mdm apple scep storage")
|
||||
}
|
||||
|
||||
var mdmPushService push.Pusher
|
||||
nanoMDMLogger := service.NewNanoMDMLogger(kitlog.With(logger, "component", "apple-mdm-push"))
|
||||
pushProviderFactory := buford.NewPushProviderFactory()
|
||||
if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_PUSH") == "1" {
|
||||
mdmPushService = nopPusher{}
|
||||
} else {
|
||||
mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger)
|
||||
}
|
||||
|
||||
// validate Apple APNs/SCEP config
|
||||
if config.MDM.IsAppleAPNsSet() || config.MDM.IsAppleSCEPSet() {
|
||||
|
|
@ -483,14 +499,8 @@ the way that the Fleet server works.
|
|||
if err != nil {
|
||||
initFatal(err, "validate Apple APNs certificate and key")
|
||||
}
|
||||
appleAPNsCertPEM, appleAPNsKeyPEM = apnsCertPEM, apnsKeyPEM
|
||||
|
||||
mdmPushCertTopic, err = cryptoutil.TopicFromCert(apnsCert.Leaf)
|
||||
if err != nil {
|
||||
initFatal(err, "validate Apple APNs certificate: failed to get topic from certificate")
|
||||
}
|
||||
|
||||
_, appleSCEPCertPEM, appleSCEPKeyPEM, err = config.MDM.AppleSCEP()
|
||||
_, appleSCEPCertPEM, appleSCEPKeyPEM, err := config.MDM.AppleSCEP()
|
||||
if err != nil {
|
||||
initFatal(err, "validate Apple SCEP certificate and key")
|
||||
}
|
||||
|
|
@ -506,16 +516,25 @@ the way that the Fleet server works.
|
|||
initFatal(err, "validate authentication with Apple APNs certificate")
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
appCfg, err := ds.AppConfig(context.Background())
|
||||
if err != nil {
|
||||
initFatal(err, "loading app config")
|
||||
err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
|
||||
{Name: fleet.MDMAssetAPNSCert, Value: apnsCertPEM},
|
||||
{Name: fleet.MDMAssetAPNSKey, Value: apnsKeyPEM},
|
||||
{Name: fleet.MDMAssetCACert, Value: appleSCEPCertPEM},
|
||||
{Name: fleet.MDMAssetCAKey, Value: appleSCEPKeyPEM},
|
||||
})
|
||||
if err != nil {
|
||||
// duplicate key errors mean that we already
|
||||
// have a value for those keys in the
|
||||
// database, fail to initalize on other
|
||||
// cases.
|
||||
if !mysql.IsDuplicate(err) {
|
||||
initFatal(err, "inserting MDM APNs and SCEP assets")
|
||||
}
|
||||
|
||||
level.Warn(logger).Log("msg", "Your server already has stored SCEP and APNs certificates. Fleet will ignore any certificates provided via environment variables when this happens.")
|
||||
}
|
||||
}
|
||||
// assume MDM is disabled until we verify that
|
||||
// everything is properly configured below
|
||||
appCfg.MDM.EnabledAndConfigured = false
|
||||
appCfg.MDM.AppleBMEnabledAndConfigured = false
|
||||
|
||||
// validate Apple BM config
|
||||
if config.MDM.IsAppleBMSet() {
|
||||
|
|
@ -523,37 +542,62 @@ the way that the Fleet server works.
|
|||
initFatal(errors.New("Apple Business Manager configuration is only available in Fleet Premium"), "validate Apple BM")
|
||||
}
|
||||
|
||||
tok, err := config.MDM.AppleBM()
|
||||
appleBM, err := config.MDM.AppleBM()
|
||||
if err != nil {
|
||||
initFatal(err, "validate Apple BM token, certificate and key")
|
||||
}
|
||||
depStorage, err = mds.NewMDMAppleDEPStorage(*tok)
|
||||
|
||||
err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
|
||||
{Name: fleet.MDMAssetABMKey, Value: appleBM.KeyPEM},
|
||||
{Name: fleet.MDMAssetABMCert, Value: appleBM.CertPEM},
|
||||
{Name: fleet.MDMAssetABMToken, Value: appleBM.EncryptedToken},
|
||||
})
|
||||
if err != nil {
|
||||
initFatal(err, "initialize Apple BM DEP storage")
|
||||
// duplicate key errors mean that we already
|
||||
// have a value for those keys in the
|
||||
// database, fail to initalize on other
|
||||
// cases.
|
||||
if !mysql.IsDuplicate(err) {
|
||||
initFatal(err, "inserting MDM ABM assets")
|
||||
}
|
||||
|
||||
level.Warn(logger).Log("msg", "Your server already has stored ABM certificates and token. Fleet will ignore any certificates provided via environment variables when this happens.")
|
||||
}
|
||||
appCfg.MDM.AppleBMEnabledAndConfigured = true
|
||||
}
|
||||
|
||||
if config.MDM.IsAppleAPNsSet() && config.MDM.IsAppleSCEPSet() {
|
||||
scepStorage, err = mds.NewSCEPDepot(appleSCEPCertPEM, appleSCEPKeyPEM)
|
||||
appCfg, err := ds.AppConfig(context.Background())
|
||||
if err != nil {
|
||||
initFatal(err, "loading app config")
|
||||
}
|
||||
|
||||
checkMDMAssets := func(names []fleet.MDMAssetName) (bool, error) {
|
||||
_, err = ds.GetAllMDMConfigAssetsByName(context.Background(), names)
|
||||
if err != nil {
|
||||
initFatal(err, "initialize mdm apple scep storage")
|
||||
if fleet.IsNotFound(err) || errors.Is(err, mysql.ErrPartialResult) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
mdmStorage, err = mds.NewMDMAppleMDMStorage(appleAPNsCertPEM, appleAPNsKeyPEM)
|
||||
if err != nil {
|
||||
initFatal(err, "initialize mdm apple MySQL storage")
|
||||
}
|
||||
nanoMDMLogger := service.NewNanoMDMLogger(kitlog.With(logger, "component", "apple-mdm-push"))
|
||||
pushProviderFactory := buford.NewPushProviderFactory()
|
||||
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, config.MDM)
|
||||
mdmCheckinAndCommandService = service.NewMDMAppleCheckinAndCommandService(ds, commander, logger)
|
||||
ddmService = service.NewMDMAppleDDMService(ds, logger)
|
||||
appCfg.MDM.EnabledAndConfigured = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
appCfg.MDM.EnabledAndConfigured, err = checkMDMAssets([]fleet.MDMAssetName{
|
||||
fleet.MDMAssetCACert,
|
||||
fleet.MDMAssetCAKey,
|
||||
fleet.MDMAssetAPNSKey,
|
||||
fleet.MDMAssetAPNSCert,
|
||||
})
|
||||
if err != nil {
|
||||
initFatal(err, "validating MDM assets from database")
|
||||
}
|
||||
|
||||
appCfg.MDM.AppleBMEnabledAndConfigured, err = checkMDMAssets([]fleet.MDMAssetName{
|
||||
fleet.MDMAssetABMCert,
|
||||
fleet.MDMAssetABMKey,
|
||||
fleet.MDMAssetABMToken,
|
||||
})
|
||||
if err != nil {
|
||||
initFatal(err, "validating MDM ABM assets from database")
|
||||
}
|
||||
|
||||
// register the Microsoft MDM services
|
||||
|
|
@ -622,7 +666,6 @@ the way that the Fleet server works.
|
|||
depStorage,
|
||||
mdmStorage,
|
||||
mdmPushService,
|
||||
mdmPushCertTopic,
|
||||
cronSchedules,
|
||||
wstepCertManager,
|
||||
)
|
||||
|
|
@ -632,10 +675,7 @@ the way that the Fleet server works.
|
|||
|
||||
var softwareInstallStore fleet.SoftwareInstallerStore
|
||||
if license.IsPremium() {
|
||||
var profileMatcher fleet.ProfileMatcher
|
||||
if appCfg.MDM.EnabledAndConfigured {
|
||||
profileMatcher = apple_mdm.NewProfileMatcher(redisPool)
|
||||
}
|
||||
profileMatcher := apple_mdm.NewProfileMatcher(redisPool)
|
||||
if config.S3.Bucket != "" {
|
||||
store, err := s3.NewSoftwareInstallerStore(config.S3)
|
||||
if err != nil {
|
||||
|
|
@ -666,8 +706,7 @@ the way that the Fleet server works.
|
|||
mailService,
|
||||
clock.C,
|
||||
depStorage,
|
||||
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM),
|
||||
mdmPushCertTopic,
|
||||
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
|
||||
ssoSessionStore,
|
||||
profileMatcher,
|
||||
softwareInstallStore,
|
||||
|
|
@ -722,10 +761,7 @@ the way that the Fleet server works.
|
|||
|
||||
if err := cronSchedules.StartCronSchedule(
|
||||
func() (fleet.CronSchedule, error) {
|
||||
var commander *apple_mdm.MDMAppleCommander
|
||||
if appCfg.MDM.EnabledAndConfigured {
|
||||
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
|
||||
}
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
return newCleanupsAndAggregationSchedule(
|
||||
ctx, instanceID, ds, logger, redisWrapperDS, &config, commander, softwareInstallStore,
|
||||
)
|
||||
|
|
@ -765,42 +801,33 @@ the way that the Fleet server works.
|
|||
}
|
||||
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
var commander *apple_mdm.MDMAppleCommander
|
||||
if appCfg.MDM.EnabledAndConfigured {
|
||||
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
|
||||
}
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register worker integrations schedule")
|
||||
}
|
||||
|
||||
if license.IsPremium() && appCfg.MDM.EnabledAndConfigured && config.MDM.IsAppleBMSet() {
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
return newAppleMDMDEPProfileAssigner(ctx, instanceID, config.MDM.AppleDEPSyncPeriodicity, ds, depStorage, logger)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register apple_mdm_dep_profile_assigner schedule")
|
||||
}
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
return newAppleMDMDEPProfileAssigner(ctx, instanceID, config.MDM.AppleDEPSyncPeriodicity, ds, depStorage, logger)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register apple_mdm_dep_profile_assigner schedule")
|
||||
}
|
||||
|
||||
if appCfg.MDM.EnabledAndConfigured || appCfg.MDM.WindowsEnabledAndConfigured {
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
return newMDMProfileManager(
|
||||
ctx,
|
||||
instanceID,
|
||||
ds,
|
||||
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM),
|
||||
logger,
|
||||
config.Logging.Debug,
|
||||
config.MDM,
|
||||
)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register mdm_apple_profile_manager schedule")
|
||||
}
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
return newMDMProfileManager(
|
||||
ctx,
|
||||
instanceID,
|
||||
ds,
|
||||
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
|
||||
logger,
|
||||
)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register mdm_apple_profile_manager schedule")
|
||||
}
|
||||
|
||||
if license.IsPremium() && appCfg.MDM.EnabledAndConfigured {
|
||||
if license.IsPremium() {
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
return newIPhoneIPadRefetcher(ctx, instanceID, 10*time.Minute, ds, commander, logger)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register apple_mdm_iphone_ipad_refetcher schedule")
|
||||
|
|
@ -919,18 +946,19 @@ the way that the Fleet server works.
|
|||
rootMux.Handle("/version", service.PrometheusMetricsHandler("version", version.Handler()))
|
||||
rootMux.Handle("/assets/", service.PrometheusMetricsHandler("static_assets", service.ServeStaticAssets("/assets/")))
|
||||
|
||||
if appCfg.MDM.EnabledAndConfigured {
|
||||
if err := service.RegisterAppleMDMProtocolServices(
|
||||
rootMux,
|
||||
config.MDM,
|
||||
mdmStorage,
|
||||
scepStorage,
|
||||
logger,
|
||||
mdmCheckinAndCommandService,
|
||||
ddmService,
|
||||
); err != nil {
|
||||
initFatal(err, "setup mdm apple services")
|
||||
}
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
ddmService := service.NewMDMAppleDDMService(ds, logger)
|
||||
mdmCheckinAndCommandService := service.NewMDMAppleCheckinAndCommandService(ds, commander, logger)
|
||||
if err := service.RegisterAppleMDMProtocolServices(
|
||||
rootMux,
|
||||
config.MDM,
|
||||
mdmStorage,
|
||||
scepStorage,
|
||||
logger,
|
||||
mdmCheckinAndCommandService,
|
||||
ddmService,
|
||||
); err != nil {
|
||||
initFatal(err, "setup mdm apple services")
|
||||
}
|
||||
|
||||
if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
|
|
@ -1040,13 +1039,6 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
|
||||
testBMToken := &nanodep_client.OAuth1Tokens{
|
||||
ConsumerKey: "test_consumer",
|
||||
ConsumerSecret: "test_secret",
|
||||
AccessToken: "test_access_token",
|
||||
AccessSecret: "test_access_secret",
|
||||
AccessTokenExpiry: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
|
|
@ -1057,8 +1049,18 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey)
|
||||
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
|
||||
assets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
fleet.MDMAssetCACert: {Value: testCertPEM},
|
||||
fleet.MDMAssetCAKey: {Value: testKeyPEM},
|
||||
}
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return assets, nil
|
||||
}
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
appCfg := fleet.AppConfig{}
|
||||
appCfg.MDM.EnabledAndConfigured = true
|
||||
return &appCfg, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
|
|
@ -1087,7 +1089,7 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
|
|||
return nil
|
||||
}
|
||||
|
||||
err = verifyDiskEncryptionKeys(ctx, logger, ds, &fleetCfg)
|
||||
err = verifyDiskEncryptionKeys(ctx, logger, ds)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ds.GetUnverifiedDiskEncryptionKeysFuncInvoked)
|
||||
require.True(t, ds.SetHostsDiskEncryptionKeyStatusFuncInvoked)
|
||||
|
|
@ -1110,7 +1112,7 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
|
|||
return nil
|
||||
}
|
||||
|
||||
err = verifyDiskEncryptionKeys(ctx, logger, ds, &fleetCfg)
|
||||
err = verifyDiskEncryptionKeys(ctx, logger, ds)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ds.GetUnverifiedDiskEncryptionKeysFuncInvoked)
|
||||
require.True(t, ds.SetHostsDiskEncryptionKeyStatusFuncInvoked)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import (
|
|||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var ErrGeneric = errors.New(`Something's gone wrong. Please try again. If this keeps happening please file an issue:
|
||||
https://github.com/fleetdm/fleet/issues/new/choose`)
|
||||
|
||||
func unauthenticatedClientFromCLI(c *cli.Context) (*service.Client, error) {
|
||||
cc, err := clientConfigFromCLI(c)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -1109,7 +1110,7 @@ func mobileconfigForTest(name, identifier string) []byte {
|
|||
}
|
||||
|
||||
func TestApplyAsGitOps(t *testing.T) {
|
||||
enqueuer := new(mock.MDMAppleStore)
|
||||
enqueuer := new(mdmmock.MDMAppleStore)
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
// mdm test configuration must be set so that activating windows MDM works.
|
||||
|
|
@ -1118,7 +1119,7 @@ func TestApplyAsGitOps(t *testing.T) {
|
|||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
|
||||
|
||||
_, ds := runServerWithMockedDS(t, &service.TestServerOpts{
|
||||
License: license,
|
||||
|
|
|
|||
|
|
@ -4,22 +4,18 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
apnsKeyPath = "fleet-mdm-apple-apns.key"
|
||||
scepCACertPath = "fleet-mdm-apple-scep.crt"
|
||||
scepCAKeyPath = "fleet-mdm-apple-scep.key"
|
||||
apnsCSRPath = "fleet-mdm-csr.csr"
|
||||
bmPublicKeyCertPath = "fleet-apple-mdm-bm-public-key.crt"
|
||||
bmPrivateKeyPath = "fleet-apple-mdm-bm-private.key"
|
||||
)
|
||||
|
||||
func generateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "generate",
|
||||
Usage: "Generate certificates and keys required for MDM",
|
||||
Usage: "Generate certificates and keys required for MDM.",
|
||||
Flags: []cli.Flag{
|
||||
configFlag(),
|
||||
contextFlag(),
|
||||
|
|
@ -36,91 +32,54 @@ func generateMDMAppleCommand() *cli.Command {
|
|||
return &cli.Command{
|
||||
Name: "mdm-apple",
|
||||
Aliases: []string{"mdm_apple"},
|
||||
Usage: "Generates certificate signing request (CSR) and key for Apple Push Notification Service (APNs) and certificate and key for Simple Certificate Enrollment Protocol (SCEP) to turn on MDM features.",
|
||||
Usage: "Generates certificate signing request (CSR) to turn on MDM features.",
|
||||
Flags: []cli.Flag{
|
||||
contextFlag(),
|
||||
debugFlag(),
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Usage: "The email address to send the signed APNS csr to.",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "org",
|
||||
Usage: "The organization requesting the signed APNS csr.",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "apns-key",
|
||||
Usage: "The output path for the APNs private key.",
|
||||
Value: apnsKeyPath,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "scep-cert",
|
||||
Usage: "The output path for the SCEP CA certificate.",
|
||||
Value: scepCACertPath,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "scep-key",
|
||||
Usage: "The output path for the SCEP CA private key.",
|
||||
Value: scepCAKeyPath,
|
||||
Name: "csr",
|
||||
Usage: "The output path for the APNs CSR.",
|
||||
Value: apnsCSRPath,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
email := c.String("email")
|
||||
org := c.String("org")
|
||||
apnsKeyPath := c.String("apns-key")
|
||||
scepCACertPath := c.String("scep-cert")
|
||||
scepCAKeyPath := c.String("scep-key")
|
||||
csrPath := c.String("csr")
|
||||
|
||||
// get the fleet API client first, so that any login requirement are met
|
||||
// before printing the CSR output message.
|
||||
client, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(c.App.ErrWriter, "client from CLI: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
|
||||
fmt.Fprintf(
|
||||
c.App.Writer,
|
||||
`Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s...
|
||||
Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate, and SCEP key...
|
||||
|
||||
`,
|
||||
email,
|
||||
)
|
||||
|
||||
csr, err := client.RequestAppleCSR(email, org)
|
||||
csr, err := client.RequestAppleCSR()
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(c.App.ErrWriter, "requesting APNs CSR: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
|
||||
if err := os.WriteFile(apnsKeyPath, csr.APNsKey, defaultFileMode); err != nil {
|
||||
return fmt.Errorf("failed to write APNs private key: %w", err)
|
||||
if err := os.WriteFile(csrPath, csr, defaultFileMode); err != nil {
|
||||
fmt.Fprintf(c.App.ErrWriter, "write CSR: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
if err := os.WriteFile(scepCACertPath, csr.SCEPCert, defaultFileMode); err != nil {
|
||||
return fmt.Errorf("failed to write SCEP CA certificate: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, defaultFileMode); err != nil {
|
||||
return fmt.Errorf("failed to write SCEP CA private key: %w", err)
|
||||
|
||||
appCfg, err := client.GetAppConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(c.App.ErrWriter, "fetching app config: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
|
||||
fmt.Fprintf(
|
||||
c.App.Writer,
|
||||
`Success!
|
||||
|
||||
Generated your APNs key at %s
|
||||
Generated your certificate signing request (CSR) at %s
|
||||
|
||||
Generated your SCEP certificate at %s
|
||||
|
||||
Generated your SCEP key at %s
|
||||
|
||||
Go to your email to download a CSR from Fleet. Then, visit https://identity.apple.com/pushcert to upload the CSR. You should receive an APNs certificate in return from Apple.
|
||||
|
||||
Next, use the generated certificates to deploy Fleet with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
|
||||
Go to %s/settings/integrations/mdm/apple and follow the steps.
|
||||
`,
|
||||
apnsKeyPath,
|
||||
scepCACertPath,
|
||||
scepCAKeyPath,
|
||||
csrPath,
|
||||
appCfg.ServerSettings.ServerURL,
|
||||
)
|
||||
|
||||
return nil
|
||||
|
|
@ -132,7 +91,7 @@ func generateMDMAppleBMCommand() *cli.Command {
|
|||
return &cli.Command{
|
||||
Name: "mdm-apple-bm",
|
||||
Aliases: []string{"mdm_apple_bm"},
|
||||
Usage: "Generate Apple Business Manager public and private keys to enable automatic enrollment for macOS hosts.",
|
||||
Usage: "Generate Apple Business Manager public key to enable automatic enrollment for macOS hosts.",
|
||||
Flags: []cli.Flag{
|
||||
contextFlag(),
|
||||
debugFlag(),
|
||||
|
|
@ -141,27 +100,33 @@ func generateMDMAppleBMCommand() *cli.Command {
|
|||
Usage: "The output path for the Apple Business Manager public key certificate.",
|
||||
Value: bmPublicKeyCertPath,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "private-key",
|
||||
Usage: "The output path for the Apple Business Manager private key.",
|
||||
Value: bmPrivateKeyPath,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
publicKeyPath := c.String("public-key")
|
||||
privateKeyPath := c.String("private-key")
|
||||
|
||||
publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM()
|
||||
// get the fleet API client first, so that any login requirement are met
|
||||
// before printing the CSR output message.
|
||||
client, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate key pair: %w", err)
|
||||
fmt.Fprintf(c.App.ErrWriter, "client from CLI: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
|
||||
if err := os.WriteFile(publicKeyPath, publicKeyPEM, defaultFileMode); err != nil {
|
||||
return fmt.Errorf("write public key: %w", err)
|
||||
publicKey, err := client.RequestAppleABM()
|
||||
if err != nil {
|
||||
fmt.Fprintf(c.App.ErrWriter, "requesting ABM public key: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
|
||||
if err := os.WriteFile(privateKeyPath, privateKeyPEM, defaultFileMode); err != nil {
|
||||
return fmt.Errorf("write private key: %w", err)
|
||||
if err := os.WriteFile(publicKeyPath, publicKey, defaultFileMode); err != nil {
|
||||
fmt.Fprintf(c.App.ErrWriter, "write public key: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
|
||||
appCfg, err := client.GetAppConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(c.App.ErrWriter, "fetching app config: %s", err)
|
||||
return ErrGeneric
|
||||
}
|
||||
|
||||
fmt.Fprintf(
|
||||
|
|
@ -170,14 +135,11 @@ func generateMDMAppleBMCommand() *cli.Command {
|
|||
|
||||
Generated your public key at %s
|
||||
|
||||
Generated your private key at %s
|
||||
Go to %s/settings/integrations/automatic-enrollment/apple and follow the steps.
|
||||
|
||||
Visit https://business.apple.com/ and create a new MDM server with the public key. Then, download the new MDM server's token.
|
||||
|
||||
Next, deploy Fleet with with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
|
||||
`,
|
||||
publicKeyPath,
|
||||
privateKeyPath,
|
||||
appCfg.ServerSettings.ServerURL,
|
||||
)
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -14,37 +14,33 @@ import (
|
|||
)
|
||||
|
||||
func TestGenerateMDMAppleBM(t *testing.T) {
|
||||
// TODO(roberto): update when the new endpoint to get a CSR is ready
|
||||
t.Skip()
|
||||
outdir, err := os.MkdirTemp("", t.Name())
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(outdir)
|
||||
publicKeyPath := filepath.Join(outdir, "public-key.crt")
|
||||
privateKeyPath := filepath.Join(outdir, "private-key.key")
|
||||
|
||||
out := runAppForTest(t, []string{
|
||||
"generate", "mdm-apple-bm",
|
||||
"--public-key", publicKeyPath,
|
||||
"--private-key", privateKeyPath,
|
||||
})
|
||||
|
||||
require.Contains(t, out, fmt.Sprintf("Generated your public key at %s", outdir))
|
||||
require.Contains(t, out, fmt.Sprintf("Generated your private key at %s", outdir))
|
||||
|
||||
// validate that the keypair is valid
|
||||
cert, err := tls.LoadX509KeyPair(publicKeyPath, privateKeyPath)
|
||||
// validate that the certificate is valid
|
||||
certPEMBlock, err := os.ReadFile(publicKeyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
parsed, err := x509.ParseCertificate(certPEMBlock)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "FleetDM", parsed.Issuer.CommonName)
|
||||
}
|
||||
|
||||
func TestGenerateMDMApple(t *testing.T) {
|
||||
t.Run("missing input", func(t *testing.T) {
|
||||
runAppCheckErr(t, []string{"generate", "mdm-apple"}, `Required flags "email, org" not set`)
|
||||
runAppCheckErr(t, []string{"generate", "mdm-apple", "--email", "user@example.com"}, `Required flag "org" not set`)
|
||||
runAppCheckErr(t, []string{"generate", "mdm-apple", "--org", "Acme"}, `Required flag "email" not set`)
|
||||
})
|
||||
|
||||
t.Run("CSR API call fails", func(t *testing.T) {
|
||||
// TODO(roberto): update when the new endpoint to get a CSR is ready
|
||||
t.Skip()
|
||||
_, _ = runServerWithMockedDS(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// fail this call
|
||||
|
|
@ -57,14 +53,14 @@ func TestGenerateMDMApple(t *testing.T) {
|
|||
t,
|
||||
[]string{
|
||||
"generate", "mdm-apple",
|
||||
"--email", "user@example.com",
|
||||
"--org", "Acme",
|
||||
},
|
||||
`POST /api/latest/fleet/mdm/apple/request_csr received status 422 Validation Failed: this email address is not valid: bad request`,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("successful run", func(t *testing.T) {
|
||||
// TODO(roberto): update when the new endpoint to get a CSR is ready
|
||||
t.Skip()
|
||||
_, _ = runServerWithMockedDS(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
@ -76,29 +72,24 @@ func TestGenerateMDMApple(t *testing.T) {
|
|||
outdir, err := os.MkdirTemp("", "TestGenerateMDMApple")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(outdir)
|
||||
apnsKeyPath := filepath.Join(outdir, "apns.key")
|
||||
scepCertPath := filepath.Join(outdir, "scep.crt")
|
||||
scepKeyPath := filepath.Join(outdir, "scep.key")
|
||||
csrPath := filepath.Join(outdir, "csr.csr")
|
||||
out := runAppForTest(t, []string{
|
||||
"generate", "mdm-apple",
|
||||
"--email", "user@example.com",
|
||||
"--org", "Acme",
|
||||
"--apns-key", apnsKeyPath,
|
||||
"--scep-cert", scepCertPath,
|
||||
"--scep-key", scepKeyPath,
|
||||
"--csr", csrPath,
|
||||
"--debug",
|
||||
"--context", "default",
|
||||
})
|
||||
|
||||
require.Contains(t, out, fmt.Sprintf("Generated your APNs key at %s", apnsKeyPath))
|
||||
require.Contains(t, out, fmt.Sprintf("Generated your SCEP certificate at %s", scepCertPath))
|
||||
require.Contains(t, out, fmt.Sprintf("Generated your SCEP key at %s", scepKeyPath))
|
||||
require.Contains(t, out, fmt.Sprintf("Generated your SCEP key at %s", csrPath))
|
||||
|
||||
// validate that the keypair is valid
|
||||
scepCrt, err := tls.LoadX509KeyPair(scepCertPath, scepKeyPath)
|
||||
// validate that the CSR is valid
|
||||
csrPEM, err := os.ReadFile(csrPath)
|
||||
require.NoError(t, err)
|
||||
parsed, err := x509.ParseCertificate(scepCrt.Certificate[0])
|
||||
|
||||
block, _ := pem.Decode(csrPEM)
|
||||
require.NotNil(t, block)
|
||||
require.Equal(t, "CERTIFICATE REQUEST", block.Type)
|
||||
_, err = x509.ParseCertificateRequest(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "FleetDM", parsed.Issuer.CommonName)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -20,6 +22,8 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/pkg/spec"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
||||
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -1996,13 +2000,35 @@ func TestGetAppleMDM(t *testing.T) {
|
|||
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
|
||||
}
|
||||
|
||||
// can only test when no MDM cert is provided, otherwise they would have to
|
||||
// be valid Apple APNs and SCEP certs.
|
||||
expected := `Error: No Apple Push Notification service (APNs) certificate found.`
|
||||
assert.Contains(t, runAppForTest(t, []string{"get", "mdm_apple"}), expected)
|
||||
out := runAppForTest(t, []string{"get", "mdm_apple"})
|
||||
assert.Contains(t, out, "Common name (CN):")
|
||||
assert.Contains(t, out, "Serial number:")
|
||||
assert.Contains(t, out, "Issuer:")
|
||||
}
|
||||
|
||||
func TestGetAppleBM(t *testing.T) {
|
||||
depStorage := new(nanodep_mock.Storage)
|
||||
depSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
switch r.URL.Path {
|
||||
case "/session":
|
||||
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
|
||||
case "/account":
|
||||
_, _ = w.Write([]byte(`{"admin_id": "abc", "org_name": "test_org"}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(depSrv.Close)
|
||||
|
||||
depStorage.RetrieveConfigFunc = func(p0 context.Context, p1 string) (*nanodep_client.Config, error) {
|
||||
return &nanodep_client.Config{BaseURL: depSrv.URL}, nil
|
||||
}
|
||||
depStorage.RetrieveAuthTokensFunc = func(ctx context.Context, name string) (*nanodep_client.OAuth1Tokens, error) {
|
||||
return &nanodep_client.OAuth1Tokens{}, nil
|
||||
}
|
||||
depStorage.StoreAssignerProfileFunc = func(ctx context.Context, name string, profileUUID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("free license", func(t *testing.T) {
|
||||
runServerWithMockedDS(t)
|
||||
|
||||
|
|
@ -2013,10 +2039,14 @@ func TestGetAppleBM(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("premium license", func(t *testing.T) {
|
||||
runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
|
||||
runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage})
|
||||
|
||||
expected := `No Apple Business Manager server token found`
|
||||
assert.Contains(t, runAppForTest(t, []string{"get", "mdm_apple_bm"}), expected)
|
||||
out := runAppForTest(t, []string{"get", "mdm_apple_bm"})
|
||||
assert.Contains(t, out, "Apple ID:")
|
||||
assert.Contains(t, out, "Organization name:")
|
||||
assert.Contains(t, out, "MDM server URL:")
|
||||
assert.Contains(t, out, "Renew date:")
|
||||
assert.Contains(t, out, "Default team:")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,14 +51,22 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() {
|
|||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
|
||||
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
|
||||
fleetCfg.Osquery.EnrollCooldown = 0
|
||||
|
||||
mdmStorage, err := s.ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
|
||||
err = s.ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
|
||||
{Name: fleet.MDMAssetAPNSCert, Value: testCertPEM},
|
||||
{Name: fleet.MDMAssetAPNSKey, Value: testKeyPEM},
|
||||
{Name: fleet.MDMAssetCACert, Value: testCertPEM},
|
||||
{Name: fleet.MDMAssetCAKey, Value: testKeyPEM},
|
||||
})
|
||||
require.NoError(s.T(), err)
|
||||
depStorage, err := s.ds.NewMDMAppleDEPStorage(*testBMToken)
|
||||
|
||||
mdmStorage, err := s.ds.NewMDMAppleMDMStorage()
|
||||
require.NoError(s.T(), err)
|
||||
scepStorage, err := s.ds.NewSCEPDepot(testCertPEM, testKeyPEM)
|
||||
depStorage, err := s.ds.NewMDMAppleDEPStorage()
|
||||
require.NoError(s.T(), err)
|
||||
scepStorage, err := s.ds.NewSCEPDepot()
|
||||
require.NoError(s.T(), err)
|
||||
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
|
||||
|
||||
|
|
|
|||
|
|
@ -47,14 +47,14 @@ func (s *integrationGitopsTestSuite) SetupSuite() {
|
|||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
|
||||
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
|
||||
fleetCfg.Osquery.EnrollCooldown = 0
|
||||
|
||||
mdmStorage, err := s.ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
|
||||
mdmStorage, err := s.ds.NewMDMAppleMDMStorage()
|
||||
require.NoError(s.T(), err)
|
||||
depStorage, err := s.ds.NewMDMAppleDEPStorage(*testBMToken)
|
||||
depStorage, err := s.ds.NewMDMAppleDEPStorage()
|
||||
require.NoError(s.T(), err)
|
||||
scepStorage, err := s.ds.NewSCEPDepot(testCertPEM, testKeyPEM)
|
||||
scepStorage, err := s.ds.NewSCEPDepot()
|
||||
require.NoError(s.T(), err)
|
||||
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -265,12 +267,12 @@ func TestFullGlobalGitOps(t *testing.T) {
|
|||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
|
||||
|
||||
// License is not needed because we are not using any premium features in our config.
|
||||
_, ds := runServerWithMockedDS(
|
||||
t, &service.TestServerOpts{
|
||||
MDMStorage: new(mock.MDMAppleStore),
|
||||
MDMStorage: new(mdmmock.MDMAppleStore),
|
||||
MDMPusher: mockPusher{},
|
||||
FleetConfig: &fleetCfg,
|
||||
},
|
||||
|
|
@ -432,13 +434,13 @@ func TestFullTeamGitOps(t *testing.T) {
|
|||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
|
||||
|
||||
// License is not needed because we are not using any premium features in our config.
|
||||
_, ds := runServerWithMockedDS(
|
||||
t, &service.TestServerOpts{
|
||||
License: license,
|
||||
MDMStorage: new(mock.MDMAppleStore),
|
||||
MDMStorage: new(mdmmock.MDMAppleStore),
|
||||
MDMPusher: mockPusher{},
|
||||
FleetConfig: &fleetCfg,
|
||||
NoCacheDatastore: true,
|
||||
|
|
@ -942,11 +944,27 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
|
|||
return *savedTeamPtr, nil
|
||||
}
|
||||
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
require.NoError(t, err)
|
||||
crt, key, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
scepCert := tokenpki.PEMCertificate(crt.Raw)
|
||||
scepKey := tokenpki.PEMRSAPrivateKey(key)
|
||||
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
fleet.MDMAssetCACert: {Value: scepCert},
|
||||
fleet.MDMAssetCAKey: {Value: scepKey},
|
||||
fleet.MDMAssetAPNSKey: {Value: apnsKey},
|
||||
fleet.MDMAssetAPNSCert: {Value: apnsCert},
|
||||
}, nil
|
||||
}
|
||||
|
||||
globalFile := "./testdata/gitops/global_config_no_paths.yml"
|
||||
teamFile := "./testdata/gitops/team_config_no_paths.yml"
|
||||
|
||||
// Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided)
|
||||
_, err := runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "team name not found"))
|
||||
|
||||
|
|
@ -1032,12 +1050,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
|
|||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
|
||||
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
||||
_, ds := runServerWithMockedDS(
|
||||
t, &service.TestServerOpts{
|
||||
MDMStorage: new(mock.MDMAppleStore),
|
||||
MDMStorage: new(mdmmock.MDMAppleStore),
|
||||
MDMPusher: mockPusher{},
|
||||
FleetConfig: &fleetCfg,
|
||||
License: license,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -158,7 +159,7 @@ func TestMDMRunCommand(t *testing.T) {
|
|||
|
||||
for _, lic := range []string{fleet.TierFree, fleet.TierPremium} {
|
||||
t.Run(lic, func(t *testing.T) {
|
||||
enqueuer := new(mock.MDMAppleStore)
|
||||
enqueuer := new(mdmmock.MDMAppleStore)
|
||||
license := &fleet.LicenseInfo{Tier: lic, Expiration: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
_, ds := runServerWithMockedDS(t, &service.TestServerOpts{
|
||||
|
|
@ -1203,7 +1204,7 @@ func writeTmpMobileconfig(t *testing.T, name string) string {
|
|||
|
||||
// sets up the test server with the mock datastore and returns the mock datastore
|
||||
func setupTestServer(t *testing.T) *mock.Store {
|
||||
enqueuer := new(mock.MDMAppleStore)
|
||||
enqueuer := new(mdmmock.MDMAppleStore)
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -206,6 +208,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st
|
|||
for _, item := range []string{
|
||||
filepath.Join(previewDir, "logs"),
|
||||
filepath.Join(previewDir, "vulndb"),
|
||||
filepath.Join(previewDir, "config"),
|
||||
} {
|
||||
if err := os.MkdirAll(item, 0o777); err != nil {
|
||||
return fmt.Errorf("create directory %q: %w", item, err)
|
||||
|
|
@ -215,6 +218,48 @@ Use the stop and reset subcommands to manage the server and dependencies once st
|
|||
}
|
||||
}
|
||||
|
||||
generatePrivateKey := func(n int) (string, error) {
|
||||
bytes := make([]byte, n/2)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes)[:n], nil
|
||||
}
|
||||
|
||||
// Create a random private key for MDM asset encryption and save it to the filesystem
|
||||
// for use in subsequent runs. If one already exists, use that one.
|
||||
getPrivateKey := func() (string, error) {
|
||||
pkFilename := filepath.Join(previewDir, "config", ".private_key")
|
||||
filePK, err := os.ReadFile(pkFilename)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
genPK, err := generatePrivateKey(32) // use AES-256
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generating private key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(pkFilename, []byte(genPK), 0o777); err != nil {
|
||||
return "", fmt.Errorf("writing private key file: %w", err)
|
||||
}
|
||||
|
||||
return genPK, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("reading private key file: %w", err)
|
||||
}
|
||||
|
||||
return string(filePK), nil
|
||||
}
|
||||
|
||||
pk, err := getPrivateKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting private key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Setenv("FLEET_SERVER_PRIVATE_KEY", pk); err != nil {
|
||||
return fmt.Errorf("failed to set private key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Setenv("FLEET_VERSION", c.String(tagFlagName)); err != nil {
|
||||
return fmt.Errorf("failed to set Fleet version: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/datastore/cached_mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
nanodepClient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/fleetdm/fleet/v4/server/test"
|
||||
|
|
@ -87,14 +86,6 @@ func (ts *withServer) getTestToken(email string, password string) string {
|
|||
return jsn.Token
|
||||
}
|
||||
|
||||
var testBMToken = &nanodepClient.OAuth1Tokens{
|
||||
ConsumerKey: "test_consumer",
|
||||
ConsumerSecret: "test_secret",
|
||||
AccessToken: "test_access_token",
|
||||
AccessSecret: "test_access_secret",
|
||||
AccessTokenExpiry: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
// runServerWithMockedDS runs the fleet server with several mocked DS methods.
|
||||
//
|
||||
// NOTE: Assumes the current session is always from the admin user (see ds.SessionByKeyFunc below).
|
||||
|
|
@ -130,6 +121,32 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
|
|||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
require.NoError(t, err)
|
||||
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
|
||||
require.NoError(t, err)
|
||||
ds.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
|
||||
return map[fleet.MDMAssetName]string{
|
||||
fleet.MDMAssetABMCert: "abmcert",
|
||||
fleet.MDMAssetABMKey: "abmkey",
|
||||
fleet.MDMAssetABMToken: "abmtoken",
|
||||
fleet.MDMAssetAPNSCert: "apnscert",
|
||||
fleet.MDMAssetAPNSKey: "apnskey",
|
||||
fleet.MDMAssetCACert: "scepcert",
|
||||
fleet.MDMAssetCAKey: "scepkey",
|
||||
}, nil
|
||||
}
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM},
|
||||
fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM},
|
||||
fleet.MDMAssetABMToken: {Name: fleet.MDMAssetABMToken, Value: tokenBytes},
|
||||
fleet.MDMAssetAPNSCert: {Name: fleet.MDMAssetAPNSCert, Value: apnsCert},
|
||||
fleet.MDMAssetAPNSKey: {Name: fleet.MDMAssetAPNSKey, Value: apnsKey},
|
||||
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: certPEM},
|
||||
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: keyPEM},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var cachedDS fleet.Datastore
|
||||
if len(opts) > 0 && opts[0].NoCacheDatastore {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func TestVulnerabilityDataStream(t *testing.T) {
|
||||
t.Skip("TODO: removeme before merging the feature branch")
|
||||
nettest.Run(t)
|
||||
|
||||
runAppCheckErr(t, []string{"vulnerability-data-stream"}, "No directory provided")
|
||||
|
|
|
|||
|
|
@ -678,6 +678,23 @@ Setting to true will disable the origin check.
|
|||
websockets_allow_unsafe_origin: true
|
||||
```
|
||||
|
||||
##### server_private_key
|
||||
|
||||
The private key used to encrypt sensitive data in Fleet, for example, MDM certificates and keys.
|
||||
The key must be at least 32 bytes long. If the key is longer than 32 bytes, only the first 32 bytes
|
||||
will be used (the data is encrypted using AES-256, which requires a 32 byte key). This key is
|
||||
required for enabling MDM features in Fleet. If you are using the `FLEET_APPLE_APNS_*` and
|
||||
`FLEET_APPLE_SCEP_*` variables, Fleet will automatically encrypt the values of those variables using
|
||||
`FLEET_SERVER_PRIVATE_KEY` and save them in the database when you restart after updating.
|
||||
|
||||
- Default value: ""
|
||||
- Environment variable: FLEET_SERVER_PRIVATE_KEY
|
||||
- Config file format:
|
||||
```yaml
|
||||
server:
|
||||
private_key: 72414F4A688151F75D032F5CDA095FC4
|
||||
```
|
||||
|
||||
##### Example YAML
|
||||
|
||||
```yaml
|
||||
|
|
|
|||
|
|
@ -20,16 +20,18 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
||||
depclient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
|
||||
"github.com/fleetdm/fleet/v4/server/sso"
|
||||
"github.com/fleetdm/fleet/v4/server/worker"
|
||||
kitlog "github.com/go-kit/kit/log"
|
||||
"github.com/go-kit/kit/log/level"
|
||||
"github.com/go-kit/log/level"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
|
@ -38,11 +40,6 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// if there is no apple bm config, fail with a 404
|
||||
if !svc.config.MDM.IsAppleBMSet() {
|
||||
return nil, notFoundError{}
|
||||
}
|
||||
|
||||
appCfg, err := svc.AppConfigObfuscated(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -51,8 +48,24 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := svc.config.MDM.AppleBM()
|
||||
|
||||
abmAssets, err := svc.ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{
|
||||
fleet.MDMAssetABMKey,
|
||||
fleet.MDMAssetABMCert,
|
||||
fleet.MDMAssetABMToken,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, mysql.ErrPartialResult) {
|
||||
_, hasABMKey := abmAssets[fleet.MDMAssetABMKey]
|
||||
_, hasABMCert := abmAssets[fleet.MDMAssetABMCert]
|
||||
_, hasABMToken := abmAssets[fleet.MDMAssetABMToken]
|
||||
|
||||
// to preserve existing behavior, if the ABM setup is
|
||||
// incomplete, return a not found error
|
||||
if hasABMKey && hasABMCert && !hasABMToken {
|
||||
return nil, notFoundError{}
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -61,8 +74,13 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
token, err := assets.ABMToken(ctx, svc.ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fill the rest of the AppleBM fields
|
||||
appleBM.RenewDate = tok.AccessTokenExpiry
|
||||
appleBM.RenewDate = token.AccessTokenExpiry
|
||||
appleBM.DefaultTeam = appCfg.MDM.AppleBMDefaultTeam
|
||||
appleBM.MDMServerURL = mdmServerURL
|
||||
|
||||
|
|
@ -171,16 +189,16 @@ func (svc *Service) MDMListHostConfigurationProfiles(ctx context.Context, hostID
|
|||
}
|
||||
|
||||
func (svc *Service) MDMAppleEnableFileVaultAndEscrow(ctx context.Context, teamID *uint) error {
|
||||
cert, _, _, err := svc.config.MDM.AppleSCEP()
|
||||
cert, err := assets.X509Cert(ctx, svc.ds, fleet.MDMAssetCACert)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enabling FileVault")
|
||||
return ctxerr.Wrap(ctx, err, "retrieving CA cert")
|
||||
}
|
||||
|
||||
var contents bytes.Buffer
|
||||
params := fileVaultProfileOptions{
|
||||
PayloadIdentifier: mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
PayloadName: mdm.FleetFileVaultProfileName,
|
||||
Base64DerCertificate: base64.StdEncoding.EncodeToString(cert.Leaf.Raw),
|
||||
Base64DerCertificate: base64.StdEncoding.EncodeToString(cert.Raw),
|
||||
}
|
||||
if err := fileVaultProfileTemplate.Execute(&contents, params); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enabling FileVault")
|
||||
|
|
@ -1154,11 +1172,16 @@ func (svc *Service) GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte,
|
|||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
topic, err := assets.APNSTopic(ctx, svc.ds)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
|
||||
}
|
||||
|
||||
mobileConfig, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
||||
appConfig.OrgInfo.OrgName,
|
||||
appConfig.ServerSettings.ServerURL,
|
||||
svc.config.MDM.AppleSCEPChallenge,
|
||||
svc.mdmPushCertTopic,
|
||||
topic,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
|
||||
|
|
@ -63,7 +64,6 @@ func setupMockDatastorePremiumService() (*mock.Store, *eeservice.Service, contex
|
|||
depStorage,
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
|
@ -79,7 +79,6 @@ func setupMockDatastorePremiumService() (*mock.Store, *eeservice.Service, contex
|
|||
clock.C,
|
||||
depStorage,
|
||||
nil,
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
|
|
@ -139,6 +138,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
|
|||
ds.LabelIDsByNameFuncInvoked = false
|
||||
ds.SetOrUpdateMDMAppleDeclarationFuncInvoked = false
|
||||
ds.BulkSetPendingMDMHostProfilesFuncInvoked = false
|
||||
ds.GetAllMDMConfigAssetsByNameFuncInvoked = false
|
||||
}
|
||||
setupDS := func(t *testing.T) {
|
||||
resetInvoked()
|
||||
|
|
@ -201,6 +201,21 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
|
|||
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
|
||||
return nil
|
||||
}
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
require.NoError(t, err)
|
||||
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
|
||||
require.NoError(t, err)
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM},
|
||||
fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM},
|
||||
fleet.MDMAssetABMToken: {Name: fleet.MDMAssetABMToken, Value: tokenBytes},
|
||||
fleet.MDMAssetAPNSCert: {Name: fleet.MDMAssetAPNSCert, Value: apnsCert},
|
||||
fleet.MDMAssetAPNSKey: {Name: fleet.MDMAssetAPNSKey, Value: apnsKey},
|
||||
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: certPEM},
|
||||
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: keyPEM},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
authzCtx := &authz_ctx.AuthorizationContext{}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
|
|
@ -17,14 +16,18 @@ import (
|
|||
|
||||
func setup(t *testing.T) (*mock.Store, *Service) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
fleet.MDMAssetCACert: {Value: []byte(testCert)},
|
||||
fleet.MDMAssetCAKey: {Value: []byte(testKey)},
|
||||
fleet.MDMAssetAPNSKey: {Value: []byte(testKey)},
|
||||
fleet.MDMAssetAPNSCert: {Value: []byte(testCert)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
ds: ds,
|
||||
config: config.FleetConfig{
|
||||
MDM: config.MDMConfig{
|
||||
AppleSCEPCertBytes: testCert,
|
||||
AppleSCEPKeyBytes: testKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
return ds, svc
|
||||
}
|
||||
|
|
@ -34,7 +37,10 @@ func TestMDMAppleEnableFileVaultAndEscrow(t *testing.T) {
|
|||
|
||||
t.Run("fails if SCEP is not configured", func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
svc := &Service{ds: ds, config: config.FleetConfig{}}
|
||||
svc := &Service{ds: ds}
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return nil, nil
|
||||
}
|
||||
err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ type Service struct {
|
|||
authz *authz.Authorizer
|
||||
depStorage storage.AllDEPStorage
|
||||
mdmAppleCommander fleet.MDMAppleCommandIssuer
|
||||
mdmPushCertTopic string
|
||||
ssoSessionStore sso.SessionStore
|
||||
depService *apple_mdm.DEPService
|
||||
profileMatcher fleet.ProfileMatcher
|
||||
|
|
@ -40,7 +39,6 @@ func NewService(
|
|||
c clock.Clock,
|
||||
depStorage storage.AllDEPStorage,
|
||||
mdmAppleCommander fleet.MDMAppleCommandIssuer,
|
||||
mdmPushCertTopic string,
|
||||
sso sso.SessionStore,
|
||||
profileMatcher fleet.ProfileMatcher,
|
||||
softwareInstallStore fleet.SoftwareInstallerStore,
|
||||
|
|
@ -59,7 +57,6 @@ func NewService(
|
|||
authz: authorizer,
|
||||
depStorage: depStorage,
|
||||
mdmAppleCommander: mdmAppleCommander,
|
||||
mdmPushCertTopic: mdmPushCertTopic,
|
||||
ssoSessionStore: sso,
|
||||
depService: apple_mdm.NewDEPService(ds, depStorage, logger),
|
||||
profileMatcher: profileMatcher,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ const App = ({ children, location }: IAppProps): JSX.Element => {
|
|||
const abmInfo = await mdmAppleBMAPI.getAppleBMInfo();
|
||||
setABMExpiry(abmInfo.renew_date);
|
||||
}
|
||||
if (configResponse.mdm.apple_bm_enabled_and_configured) {
|
||||
if (configResponse.mdm.enabled_and_configured) {
|
||||
const apnsInfo = await mdmAppleAPI.getAppleAPNInfo();
|
||||
setAPNsExpiry(apnsInfo.renew_date);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import classnames from "classnames";
|
|||
import Button from "components/buttons/Button";
|
||||
import Card from "components/Card";
|
||||
import { GraphicNames } from "components/graphics";
|
||||
import Graphic from "components/Graphic";
|
||||
import Icon from "components/Icon";
|
||||
import Graphic from "components/Graphic";
|
||||
|
||||
const baseClass = "file-uploader";
|
||||
|
||||
|
|
@ -22,12 +22,37 @@ type ISupportedGraphicNames = Extract<
|
|||
| "file-pem"
|
||||
>;
|
||||
|
||||
export const FileDetails = ({
|
||||
details: { name, platform },
|
||||
graphicName = "file-pkg",
|
||||
}: {
|
||||
details: {
|
||||
name: string;
|
||||
platform?: string;
|
||||
};
|
||||
graphicName?: ISupportedGraphicNames;
|
||||
}) => (
|
||||
<div className={`${baseClass}__selected-file`}>
|
||||
<Graphic name={graphicName} />
|
||||
<div className={`${baseClass}__selected-file--details`}>
|
||||
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
|
||||
{platform && (
|
||||
<div className={`${baseClass}__selected-file--details--platform`}>
|
||||
{platform}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface IFileUploaderProps {
|
||||
graphicName: ISupportedGraphicNames | ISupportedGraphicNames[];
|
||||
message: string;
|
||||
additionalInfo?: string;
|
||||
/** Controls the loading spinner on the upload button */
|
||||
isLoading?: boolean;
|
||||
/** Disables the upload button */
|
||||
diabled?: boolean;
|
||||
/** A comma seperated string of one or more file types accepted to upload.
|
||||
* This is the same as the html accept attribute.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
|
||||
|
|
@ -46,18 +71,19 @@ interface IFileUploaderProps {
|
|||
/** If provided FileUploader will display this component when the file is
|
||||
* selected. This is used for previewing the file before uploading.
|
||||
*/
|
||||
filePreview?: ReactNode;
|
||||
filePreview?: ReactNode; // TODO: refactor this to be a function that returns a ReactNode?
|
||||
onFileUpload: (files: FileList | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that encapsulates the UI for uploading a file.
|
||||
*/
|
||||
const FileUploader = ({
|
||||
export const FileUploader = ({
|
||||
graphicName: graphicNames,
|
||||
message,
|
||||
additionalInfo,
|
||||
isLoading = false,
|
||||
diabled = false,
|
||||
accept,
|
||||
filePreview,
|
||||
className,
|
||||
|
|
@ -107,6 +133,7 @@ const FileUploader = ({
|
|||
className={`${baseClass}__upload-button`}
|
||||
variant={buttonVariant}
|
||||
isLoading={isLoading}
|
||||
disabled={diabled}
|
||||
>
|
||||
<label htmlFor="upload-file">
|
||||
{buttonType === "link" && <Icon name="upload" />}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,26 @@
|
|||
padding: $pad-medium $pad-large;
|
||||
}
|
||||
|
||||
&__selected-file {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&--details {
|
||||
&--name {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
&--platform {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__graphics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { IConfigServerSettings } from "./config";
|
||||
|
||||
export interface IMdmApple {
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
|
|
@ -13,6 +15,10 @@ export interface IMdmAppleBm {
|
|||
renew_date: string;
|
||||
}
|
||||
|
||||
export const getMdmServerUrl = ({ server_url }: IConfigServerSettings) => {
|
||||
return server_url.concat("/mdm/apple/mdm");
|
||||
};
|
||||
|
||||
export const MDM_ENROLLMENT_STATUS = {
|
||||
"On (manual)": "manual",
|
||||
"On (automatic)": "automatic",
|
||||
|
|
|
|||
|
|
@ -4,15 +4,18 @@ import getInstallScript from "utilities/software_install_scripts";
|
|||
|
||||
import Spinner from "components/Spinner";
|
||||
import Button from "components/buttons/Button";
|
||||
import FileUploader from "components/FileUploader";
|
||||
import Graphic from "components/Graphic";
|
||||
import Editor from "components/Editor";
|
||||
import FileUploader, {
|
||||
FileDetails,
|
||||
} from "components/FileUploader/FileUploader";
|
||||
|
||||
import { getFileDetails } from "utilities/file/fileUtils";
|
||||
|
||||
import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
|
||||
|
||||
import { generateFormValidation, getFileDetails } from "./helpers";
|
||||
import { generateFormValidation } from "./helpers";
|
||||
|
||||
const baseClass = "add-software-form";
|
||||
export const baseClass = "add-software-form";
|
||||
|
||||
const UploadingSoftware = () => {
|
||||
return (
|
||||
|
|
@ -23,28 +26,6 @@ const UploadingSoftware = () => {
|
|||
);
|
||||
};
|
||||
|
||||
// TODO: if we reuse this one more time, we should consider moving this
|
||||
// into FileUploader as a default preview. Currently we have this in
|
||||
// AddProfileModal.tsx and here.
|
||||
const FileDetails = ({
|
||||
details: { name, platform },
|
||||
}: {
|
||||
details: {
|
||||
name: string;
|
||||
platform: string;
|
||||
};
|
||||
}) => (
|
||||
<div className={`${baseClass}__selected-file`}>
|
||||
<Graphic name="file-pkg" />
|
||||
<div className={`${baseClass}__selected-file--details`}>
|
||||
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
|
||||
<div className={`${baseClass}__selected-file--details--platform`}>
|
||||
{platform}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface IAddSoftwareFormData {
|
||||
software: File | null;
|
||||
installScript: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
.add-software-form {
|
||||
|
||||
&__uploading-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -7,7 +6,7 @@
|
|||
gap: $pad-large;
|
||||
|
||||
p {
|
||||
margin: 0
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -20,24 +19,4 @@
|
|||
&__file-uploader {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__selected-file {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&--details {
|
||||
&--name {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
&--platform {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import validator from "validator";
|
|||
|
||||
// @ts-ignore
|
||||
import validateQuery from "components/forms/validators/validate_query";
|
||||
import { getPlatformDisplayName } from "utilities/file/fileUtils";
|
||||
|
||||
import { IAddSoftwareFormData, IFormValidation } from "./AddSoftwareForm";
|
||||
|
||||
|
|
@ -155,9 +154,4 @@ export const generateFormValidation = (
|
|||
return formValidation;
|
||||
};
|
||||
|
||||
export const getFileDetails = (file: File) => {
|
||||
return {
|
||||
name: file.name,
|
||||
platform: getPlatformDisplayName(file),
|
||||
};
|
||||
};
|
||||
export default generateFormValidation;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
import React, { useCallback, useContext, useState } from "react";
|
||||
|
||||
import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import { IMdmAppleBm } from "interfaces/mdm";
|
||||
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
|
||||
import { readableDate } from "utilities/helpers";
|
||||
|
||||
import BackLink from "components/BackLink";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink/CustomLink";
|
||||
import DataError from "components/DataError";
|
||||
import FileUploader from "components/FileUploader";
|
||||
import MainContent from "components/MainContent";
|
||||
import Spinner from "components/Spinner";
|
||||
|
||||
import DownloadKey from "../../../../components/DownloadFileButtons/DownloadABMKey";
|
||||
import DisableAutomaticEnrollmentModal from "./modals/DisableAutomaticEnrollmentModal";
|
||||
import RenewTokenModal from "./modals/RenewTokenModal";
|
||||
|
||||
const baseClass = "apple-automatic-enrollment-page";
|
||||
|
||||
const AppleAutomaticEnrollmentPage = ({
|
||||
router,
|
||||
}: {
|
||||
router: InjectedRouter;
|
||||
}) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [showRenewModal, setShowRenewModal] = useState(false);
|
||||
|
||||
const {
|
||||
data: mdmAppleBm,
|
||||
error: errorMdmAppleBm,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
refetch,
|
||||
} = useQuery<IMdmAppleBm, AxiosError, IMdmAppleBm>(
|
||||
["mdmAppleBmAPI"],
|
||||
() => mdmAppleBmAPI.getAppleBMInfo(),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (tries, error) => error.status !== 404 && tries <= 3,
|
||||
}
|
||||
);
|
||||
|
||||
const uploadToken = useCallback(
|
||||
async (data: FileList | null) => {
|
||||
setIsUploading(true);
|
||||
const token = data?.[0];
|
||||
if (!token) {
|
||||
setIsUploading(false);
|
||||
renderFlash("error", "No token selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mdmAppleBmAPI.uploadToken(token);
|
||||
renderFlash(
|
||||
"success",
|
||||
"Automatic enrollment for macOS hosts is enabled."
|
||||
);
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT);
|
||||
} catch (e) {
|
||||
const msg = getErrorReason(e);
|
||||
if (msg.toLowerCase().includes("valid token")) {
|
||||
renderFlash("error", msg);
|
||||
} else {
|
||||
renderFlash("error", "Couldn’t enable. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[renderFlash, router]
|
||||
);
|
||||
|
||||
const onClickDisable = useCallback(() => {
|
||||
setShowDisableModal(true);
|
||||
}, []);
|
||||
|
||||
const onClickRenew = useCallback(() => {
|
||||
setShowRenewModal(true);
|
||||
}, []);
|
||||
|
||||
const disableAutomaticEnrollment = useCallback(async () => {
|
||||
try {
|
||||
await mdmAppleBmAPI.disableAutomaticEnrollment();
|
||||
renderFlash("success", "Automatic enrollment disabled successfully.");
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT);
|
||||
} catch (e) {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Couldn’t disable automatic enrollment. Please try again."
|
||||
);
|
||||
setShowDisableModal(false);
|
||||
}
|
||||
}, [renderFlash, router]);
|
||||
|
||||
const onCancelDisable = useCallback(() => {
|
||||
setShowDisableModal(false);
|
||||
}, []);
|
||||
|
||||
const onRenew = useCallback(() => {
|
||||
refetch();
|
||||
setShowRenewModal(false);
|
||||
}, [refetch]);
|
||||
|
||||
const onCancelRenew = useCallback(() => {
|
||||
setShowRenewModal(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (errorMdmAppleBm && errorMdmAppleBm?.status !== 404) {
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
<DataError />
|
||||
</MainContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
<>
|
||||
<BackLink
|
||||
text="Back to automatic enrollment"
|
||||
path={PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT}
|
||||
className={`${baseClass}__back-to-automatic-enrollment`}
|
||||
/>
|
||||
<h1>Apple Business Manager (ABM)</h1>
|
||||
{mdmAppleBm ? (
|
||||
<div>
|
||||
<h4>Apple ID</h4>
|
||||
<p>{mdmAppleBm.apple_id}</p>
|
||||
<h4>Organization name</h4>
|
||||
<p>{mdmAppleBm.org_name}</p>
|
||||
<h4>MDM server URL</h4>
|
||||
<p>{mdmAppleBm.mdm_server_url}</p>
|
||||
<h4>Renew date</h4>
|
||||
<p>{readableDate(mdmAppleBm.renew_date)}</p>
|
||||
<div className={`${baseClass}__button-wrap`}>
|
||||
<Button variant="inverse" onClick={onClickDisable}>
|
||||
Disable automatic enrollment
|
||||
</Button>
|
||||
<Button variant="brand" onClick={onClickRenew}>
|
||||
Renew token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Connect Fleet to your Apple Business Manager account to
|
||||
automatically enroll macOS hosts to Fleet when they’re first
|
||||
booted.{" "}
|
||||
</p>
|
||||
{/* Ideally we'd use the native browser list styles and css to display
|
||||
the list numbers but this does not allow us to style the list items as we'd
|
||||
like so we write the numbers in the JSX instead. */}
|
||||
<ol className={`${baseClass}__setup-list`}>
|
||||
<li>
|
||||
<span>1.</span>
|
||||
<p>
|
||||
Download your public key.{" "}
|
||||
<DownloadKey baseClass={baseClass} />
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<span>2.</span>
|
||||
<span>
|
||||
<span>
|
||||
Sign in to{" "}
|
||||
<CustomLink
|
||||
newTab
|
||||
text="Apple Business Manager"
|
||||
url="https://business.apple.com"
|
||||
/>
|
||||
<br />
|
||||
If your organization doesn’t have an account, select{" "}
|
||||
<b>Enroll now</b>.
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>3.</span>
|
||||
<span>
|
||||
Select your <b>account name</b> at the bottom left of the
|
||||
screen, then select <b>Preferences</b>.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>4.</span>
|
||||
<span>
|
||||
In the <b>Your MDM Servers</b> section, select <b>Add</b>.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>5.</span>
|
||||
<span>Enter a name for the server such as “Fleet”.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>6.</span>
|
||||
<span>
|
||||
Under <b>MDM Server Settings</b>, upload the public key
|
||||
downloaded in the first step and select <b>Save</b>.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>7.</span>
|
||||
<span>
|
||||
In the <b>Default Device Assignment</b> section, select{" "}
|
||||
<b>Change</b>, then assign the newly created server as the
|
||||
default for your Macs, and select <b>Done</b>.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>8.</span>
|
||||
<span>
|
||||
Select newly created server in the sidebar, then select{" "}
|
||||
<b>Download Token</b> on the top.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>9.</span>
|
||||
<span>Upload the downloaded token (.p7m file).</span>
|
||||
</li>
|
||||
</ol>
|
||||
<FileUploader
|
||||
className={`${baseClass}__file-uploader ${
|
||||
isUploading ? `${baseClass}__file-uploader--loading` : ""
|
||||
}`}
|
||||
accept=".p7m"
|
||||
message="ABM token (.p7m)"
|
||||
graphicName={"file-p7m"}
|
||||
buttonType="link"
|
||||
buttonMessage={isUploading ? "Uploading..." : "Upload"}
|
||||
onFileUpload={uploadToken}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{showDisableModal && (
|
||||
<DisableAutomaticEnrollmentModal
|
||||
onCancel={onCancelDisable}
|
||||
onConfirm={disableAutomaticEnrollment}
|
||||
/>
|
||||
)}
|
||||
{showRenewModal && (
|
||||
<RenewTokenModal onCancel={onCancelRenew} onRenew={onRenew} />
|
||||
)}
|
||||
</MainContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppleAutomaticEnrollmentPage;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
.apple-automatic-enrollment-page {
|
||||
&__back-to-automatic-enrollment {
|
||||
margin-bottom: $pad-xlarge;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: $pad-xxlarge;
|
||||
font-size: $large;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 0;
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: $x-small;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
|
||||
&__setup-list {
|
||||
font-size: $x-small;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 660px;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $pad-small;
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__url-inputs-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-icon;
|
||||
margin-top: $pad-large;
|
||||
}
|
||||
|
||||
&__url-input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__permissions-list {
|
||||
margin-top: $pad-large;
|
||||
list-style: disc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
&__request-button {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
align-items: center;
|
||||
margin-top: $pad-small;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__file-uploader {
|
||||
margin-top: $pad-medium;
|
||||
margin-left: $pad-medium;
|
||||
border-radius: 6px;
|
||||
|
||||
.file-uploader__message {
|
||||
color: $ui-fleet-black-75;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__button-wrap {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
.data-error {
|
||||
margin-top: $pad-xxlarge;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AppleAutomaticEnrollmentPage";
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
|
||||
const baseClass = "modal disable-automatic-enrollment-modal";
|
||||
|
||||
interface IDisableAutomaticEnrollmentModalProps {
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const DisableAutomaticEnrollmentModal = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: IDisableAutomaticEnrollmentModalProps): JSX.Element => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const onClickConfirm = useCallback(() => {
|
||||
setIsDeleting(true);
|
||||
onConfirm();
|
||||
}, [onConfirm]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Disable macOS automatic enrollment"
|
||||
onExit={onCancel}
|
||||
className={baseClass}
|
||||
>
|
||||
<div className={baseClass}>
|
||||
New macOS hosts won’t automatically enroll to Fleet. If you want to
|
||||
enable automatic enrollment, you’ll have to upload a new token.{" "}
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="alert"
|
||||
onClick={onClickConfirm}
|
||||
disabled={isDeleting}
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
variant="inverse-alert"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableAutomaticEnrollmentModal;
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import React, { useState, useContext, useCallback } from "react";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
|
||||
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import {
|
||||
FileUploader,
|
||||
FileDetails,
|
||||
} from "components/FileUploader/FileUploader";
|
||||
import Modal from "components/Modal";
|
||||
|
||||
const baseClass = "modal renew-token-modal";
|
||||
|
||||
interface IRenewCertModalProps {
|
||||
onCancel: () => void;
|
||||
onRenew: () => void;
|
||||
}
|
||||
|
||||
const RenewCertModal = ({
|
||||
onCancel,
|
||||
onRenew,
|
||||
}: IRenewCertModalProps): JSX.Element => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [tokenFile, setTokenFile] = useState<File | null>(null);
|
||||
|
||||
const onSelectFile = useCallback((files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (file) {
|
||||
setTokenFile(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRenewClick = useCallback(async () => {
|
||||
if (!tokenFile) {
|
||||
// this shouldn'r happen, but just in case
|
||||
renderFlash("error", "Please provide a token file.");
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await mdmAppleBmAPI.uploadToken(tokenFile);
|
||||
renderFlash("success", "ABM token renewed successfully.");
|
||||
setIsUploading(false);
|
||||
onRenew();
|
||||
} catch (e) {
|
||||
const msg = getErrorReason(e);
|
||||
if (msg.toLowerCase().includes("valid token")) {
|
||||
renderFlash("error", msg);
|
||||
} else {
|
||||
renderFlash("error", "Couldn’t renew. Please try again.");
|
||||
}
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [tokenFile, renderFlash, onRenew]);
|
||||
|
||||
return (
|
||||
<Modal title="Renew token" onExit={onCancel} className={baseClass}>
|
||||
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
|
||||
<ol className={`${baseClass}__setup-instructions-list`}>
|
||||
<li>
|
||||
<p>
|
||||
1. Sign in to{" "}
|
||||
<CustomLink
|
||||
url="https://business.apple.com/"
|
||||
text="Apple Business Manager"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
2. Select your <b>account name</b> at the bottom left of the
|
||||
screen, then select <b>Preferences</b>.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
3. In the <b>Your MDM Servers</b> section, select your Fleet
|
||||
server, then select <b>Download Token</b> at the top.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
4. Upload the downloaded token (.p7m file) below.
|
||||
<FileUploader
|
||||
className={`${baseClass}__file-uploader`}
|
||||
accept=".p7m"
|
||||
buttonMessage="Choose file"
|
||||
buttonType="link"
|
||||
graphicName="file-p7m"
|
||||
message="ABM token (.p7m)"
|
||||
onFileUpload={onSelectFile}
|
||||
filePreview={
|
||||
tokenFile && (
|
||||
<FileDetails
|
||||
details={{ name: tokenFile.name }}
|
||||
graphicName="file-p7m"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<div className={`${baseClass}__button-wrap`}>
|
||||
<Button
|
||||
className={`${baseClass}__submit-button ${
|
||||
isUploading ? `uploading` : ""
|
||||
}`}
|
||||
variant="brand"
|
||||
disabled={!tokenFile || isUploading}
|
||||
isLoading={isUploading}
|
||||
type="button"
|
||||
onClick={onRenewClick}
|
||||
>
|
||||
Renew token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenewCertModal;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
.renew-token-modal {
|
||||
width: 760px;
|
||||
|
||||
&__info-header {
|
||||
margin-bottom: $pad-xlarge;
|
||||
}
|
||||
|
||||
&__setup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
color: $core-fleet-black;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__setup-instructions-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
li > p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__request-button {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
align-items: center;
|
||||
margin-top: $pad-small;
|
||||
margin-left: $pad-medium;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__file-uploader {
|
||||
margin-top: $pad-medium;
|
||||
margin-left: $pad-medium;
|
||||
border-radius: 6px;
|
||||
|
||||
.file-uploader__message {
|
||||
color: $ui-fleet-black-75;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.renew-token-modal__submit-button.uploading.button--disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./RenewTokenModal";
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useContext } from "react";
|
||||
import React, { useCallback, useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { AppContext } from "context/app";
|
||||
import { IConfig } from "interfaces/config";
|
||||
import { IMdmApple } from "interfaces/mdm";
|
||||
import mdmAppleAPI from "services/entities/mdm_apple";
|
||||
|
||||
|
|
@ -13,9 +14,10 @@ import DataError from "components/DataError";
|
|||
import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage";
|
||||
import EmptyTable from "components/EmptyTable/EmptyTable";
|
||||
import Button from "components/buttons/Button/Button";
|
||||
import AppleBusinessManagerSection from "./components/AppleBusinessManagerSection/AppleBusinessManagerSection";
|
||||
import IdpSection from "./components/IdpSection/IdpSection";
|
||||
|
||||
import MdmPlatformsSection from "./components/MdmPlatformsSection/MdmPlatformsSection";
|
||||
import DefaultTeamSection from "./components/DefaultTeamSection/DefaultTeamSection";
|
||||
import IdpSection from "./components/IdpSection/IdpSection";
|
||||
import EulaSection from "./components/EulaSection/EulaSection";
|
||||
|
||||
const baseClass = "automatic-enrollment";
|
||||
|
|
@ -27,7 +29,7 @@ interface IAutomaticEnrollment {
|
|||
const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
|
||||
const { config, isPremiumTier } = useContext(AppContext);
|
||||
|
||||
const { isLoading: isLoadingMdmApple, error: errorMdmApple } = useQuery<
|
||||
const { isLoading: isLoadingAPNInfo, error: errorAPNInfo } = useQuery<
|
||||
IMdmApple,
|
||||
AxiosError
|
||||
>(["appleAPNInfo"], () => mdmAppleAPI.getAppleAPNInfo(), {
|
||||
|
|
@ -42,7 +44,7 @@ const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
|
|||
|
||||
if (!isPremiumTier) return <PremiumFeatureMessage />;
|
||||
|
||||
if (isLoadingMdmApple) {
|
||||
if (isLoadingAPNInfo) {
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Spinner />
|
||||
|
|
@ -50,7 +52,7 @@ const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (errorMdmApple?.status === 404) {
|
||||
if (errorAPNInfo?.status === 404) {
|
||||
return (
|
||||
<EmptyTable
|
||||
header="Automatic enrollment for macOS hosts"
|
||||
|
|
@ -61,15 +63,20 @@ const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (errorMdmApple) {
|
||||
if (errorAPNInfo) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<div className={`${baseClass}__section`}>
|
||||
<AppleBusinessManagerSection router={router} />
|
||||
<MdmPlatformsSection router={router} />
|
||||
</div>
|
||||
{!!config?.mdm.apple_bm_enabled_and_configured && (
|
||||
<div className={`${baseClass}__section`}>
|
||||
<DefaultTeamSection />
|
||||
</div>
|
||||
)}
|
||||
<div className={`${baseClass}__section`}>
|
||||
<IdpSection />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
&__section:not(:last-child) {
|
||||
margin-bottom: $pad-xxxlarge;
|
||||
&__section {
|
||||
margin-bottom: $pad-xxlarge;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,251 +0,0 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import FileSaver from "file-saver";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { IMdmAppleBm } from "interfaces/mdm";
|
||||
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
|
||||
import { readableDate } from "utilities/helpers";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import DataError from "components/DataError";
|
||||
import Spinner from "components/Spinner/Spinner";
|
||||
import SectionHeader from "components/SectionHeader";
|
||||
|
||||
import EditTeamModal from "../EditTeamModal";
|
||||
import WindowsAutomaticEnrollmentCard from "./components/WindowsAutomaticEnrollmentCard/WindowsAutomaticEnrollmentCard";
|
||||
|
||||
const baseClass = "apple-business-manager-section";
|
||||
|
||||
interface IABMKeys {
|
||||
decodedPublic: string;
|
||||
decodedPrivate: string;
|
||||
}
|
||||
|
||||
interface IAppleBusinessManagerSectionProps {
|
||||
router: InjectedRouter;
|
||||
}
|
||||
|
||||
const AppleBusinessManagerSection = ({
|
||||
router,
|
||||
}: IAppleBusinessManagerSectionProps) => {
|
||||
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
|
||||
const [defaultTeamName, setDefaultTeamName] = useState("No team");
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const { config } = useContext(AppContext);
|
||||
|
||||
const {
|
||||
data: mdmAppleBm,
|
||||
isLoading: isLoadingMdmAppleBm,
|
||||
error: errorMdmAppleBm,
|
||||
} = useQuery<IMdmAppleBm, AxiosError, IMdmAppleBm>(
|
||||
["mdmAppleBmAPI"],
|
||||
() => mdmAppleBmAPI.getAppleBMInfo(),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (tries, error) => error.status !== 404 && tries <= 3,
|
||||
onSuccess: (appleBmData) => {
|
||||
setDefaultTeamName(appleBmData.default_team ?? "No team");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: keys,
|
||||
error: fetchKeysError,
|
||||
isFetching: isFetchingKeys,
|
||||
} = useQuery<IABMKeys, Error>(["keys"], () => mdmAppleBmAPI.loadKeys(), {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const toggleEditTeamModal = () => {
|
||||
setShowEditTeamModal(!showEditTeamModal);
|
||||
};
|
||||
|
||||
const navigateToWindowsAutomaticEnrollment = () => {
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS);
|
||||
};
|
||||
|
||||
const onDownloadKeys = (evt: React.MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
|
||||
// MDM TODO: Confirm error flash message
|
||||
if (isFetchingKeys || fetchKeysError) {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Your MDM business manager keys could not be downloaded. Please try again."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keys) {
|
||||
const publicFilename = "fleet-apple-mdm-bm-public-key.crt";
|
||||
const publicFile = new global.window.File(
|
||||
[keys.decodedPublic],
|
||||
publicFilename,
|
||||
{
|
||||
type: "application/x-pem-file",
|
||||
}
|
||||
);
|
||||
|
||||
const privateFilename = "fleet-apple-mdm-bm-private.key";
|
||||
const privateFile = new global.window.File(
|
||||
[keys.decodedPrivate],
|
||||
privateFilename,
|
||||
{
|
||||
type: "application/x-pem-file",
|
||||
}
|
||||
);
|
||||
|
||||
FileSaver.saveAs(publicFile);
|
||||
setTimeout(() => {
|
||||
FileSaver.saveAs(privateFile);
|
||||
}, 100);
|
||||
} else {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Your MDM business manager keys could not be downloaded. Please try again."
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderAppleBMInfo = () => {
|
||||
// we want to give a more useful error message for 400s.
|
||||
if (errorMdmAppleBm && errorMdmAppleBm.status === 400) {
|
||||
return (
|
||||
<DataError>
|
||||
<span className={`${baseClass}__400-error-info`}>
|
||||
The Apple Business Manager certificate or server token is invalid.
|
||||
Restart Fleet with a valid certificate and token.
|
||||
</span>
|
||||
<span className={`${baseClass}__400-error-info`}>
|
||||
See our{" "}
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/learn-more-about/setup-abm"
|
||||
text="ABM documentation"
|
||||
newTab
|
||||
/>{" "}
|
||||
for help.
|
||||
</span>
|
||||
</DataError>
|
||||
);
|
||||
}
|
||||
|
||||
// The API returns a 404 error if ABM is not configured yet, in that case we
|
||||
// want to prompt the user to download the certs and keys to configure the
|
||||
// server instead of the default error message.
|
||||
const showMdmAppleBmError =
|
||||
errorMdmAppleBm && errorMdmAppleBm.status !== 404;
|
||||
|
||||
if (showMdmAppleBmError) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
// no error, but no apple bm data yet. TODO: when does this happen?
|
||||
if (!mdmAppleBm) {
|
||||
return (
|
||||
<>
|
||||
<div className={`${baseClass}__section-description`}>
|
||||
Connect Fleet to your Apple Business Manager account to
|
||||
automatically enroll macOS hosts to Fleet when they're first
|
||||
setup.
|
||||
</div>
|
||||
<div className={`${baseClass}__section-instructions`}>
|
||||
<p>1. Download your public and private keys.</p>
|
||||
<Button onClick={onDownloadKeys} variant="brand">
|
||||
Download
|
||||
</Button>
|
||||
<p>
|
||||
2. Sign in to{" "}
|
||||
<CustomLink
|
||||
url="https://business.apple.com/"
|
||||
text="Apple Business Manager"
|
||||
newTab
|
||||
/>
|
||||
<br />
|
||||
If your organization doesn't have an account, select{" "}
|
||||
<b>Enroll now</b>.
|
||||
</p>
|
||||
<p>
|
||||
3. In Apple Business Manager, upload your public key and download
|
||||
your server token.
|
||||
</p>
|
||||
<p>
|
||||
4. Deploy Fleet with <b>mdm</b> configuration.{" "}
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm"
|
||||
text="See how"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// we have the apple bm data and render it
|
||||
return (
|
||||
<>
|
||||
<div className={`${baseClass}__section-description`}>
|
||||
To use automatically enroll macOS hosts to Fleet when they’re first
|
||||
unboxed, Apple Inc. requires a server token.
|
||||
</div>
|
||||
<div className={`${baseClass}__section-information`}>
|
||||
<h4>
|
||||
<TooltipWrapper tipContent="macOS hosts will be added to this team when they’re first unboxed.">
|
||||
Team
|
||||
</TooltipWrapper>
|
||||
</h4>
|
||||
<p>
|
||||
{defaultTeamName}{" "}
|
||||
<Button
|
||||
className={`${baseClass}__edit-team-btn`}
|
||||
onClick={toggleEditTeamModal}
|
||||
variant="text-icon"
|
||||
>
|
||||
Edit <Icon name="pencil" />
|
||||
</Button>
|
||||
</p>
|
||||
<h4>Apple ID</h4>
|
||||
<p>{mdmAppleBm.apple_id}</p>
|
||||
<h4>Organization name</h4>
|
||||
<p>{mdmAppleBm.org_name}</p>
|
||||
<h4>MDM server URL</h4>
|
||||
<p>{mdmAppleBm.mdm_server_url}</p>
|
||||
<h4>Renew date</h4>
|
||||
<p>{readableDate(mdmAppleBm.renew_date)}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<SectionHeader title="Apple Business Manager" />
|
||||
{isLoadingMdmAppleBm ? <Spinner /> : renderAppleBMInfo()}
|
||||
<WindowsAutomaticEnrollmentCard
|
||||
viewDetails={navigateToWindowsAutomaticEnrollment}
|
||||
/>
|
||||
{showEditTeamModal && (
|
||||
<EditTeamModal
|
||||
onCancel={toggleEditTeamModal}
|
||||
defaultTeamName={defaultTeamName}
|
||||
onUpdateSuccess={(newDefaultTeamName) =>
|
||||
setDefaultTeamName(newDefaultTeamName)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppleBusinessManagerSection;
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
.apple-business-manager-section {
|
||||
h4 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mdm-settings-team-btn {
|
||||
margin-left: 12px;
|
||||
|
||||
.children-wrapper {
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
|
||||
.component__tooltip-wrapper__tip-text {
|
||||
max-width: initial;
|
||||
}
|
||||
|
||||
&__section-description,
|
||||
&__section-instructions,
|
||||
&__section-information {
|
||||
font-size: $x-small;
|
||||
color: $core-fleet-black;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__section-information {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__400-error-info {
|
||||
display: block;
|
||||
color: $core-fleet-black;
|
||||
font-weight: normal;
|
||||
font-size: $x-small;
|
||||
text-align: left;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
.windows-automatic-enrollment-card {
|
||||
margin-top: $pad-xxxlarge;
|
||||
font-size: $x-small;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
margin: 0 0 $pad-xsmall;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 520px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./AppleBusinessManagerSection";
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import React, { useCallback, useContext, useState } from "react";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import Icon from "components/Icon";
|
||||
import SectionHeader from "components/SectionHeader";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import EditTeamModal from "../EditTeamModal";
|
||||
|
||||
const baseClass = "default-team-section";
|
||||
|
||||
const DefaultTeamSection = () => {
|
||||
const { config } = useContext(AppContext);
|
||||
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
|
||||
|
||||
const toggleEditTeamModal = useCallback(() => {
|
||||
setShowEditTeamModal((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const defaultTeamName = config?.mdm?.apple_bm_default_team || "No team";
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}`}>
|
||||
<SectionHeader title="Default team" />
|
||||
<p>macOS hosts automatically enroll to this team.</p>
|
||||
<h4>
|
||||
<TooltipWrapper
|
||||
position="top-start"
|
||||
tipContent="macOS hosts will be added to this team when they’re first unboxed."
|
||||
>
|
||||
Team
|
||||
</TooltipWrapper>
|
||||
</h4>
|
||||
<p>
|
||||
{config?.mdm?.apple_bm_default_team || "No team"}{" "}
|
||||
<Button
|
||||
className={`${baseClass}__edit-team-btn`}
|
||||
onClick={toggleEditTeamModal}
|
||||
variant="text-icon"
|
||||
>
|
||||
Edit <Icon name="pencil" />
|
||||
</Button>
|
||||
</p>
|
||||
{showEditTeamModal && (
|
||||
<EditTeamModal
|
||||
defaultTeamName={defaultTeamName}
|
||||
onCancel={toggleEditTeamModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultTeamSection;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
.default-team-section {
|
||||
h4 {
|
||||
margin: $pad-medium 0 0;
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__edit-team-btn {
|
||||
margin-left: $pad-medium;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./DefaultTeamSection";
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useContext, FormEvent } from "react";
|
||||
import React, { useState, useContext, FormEvent, useCallback } from "react";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import {
|
||||
APP_CONTEXT_NO_TEAM_ID,
|
||||
APP_CONTEX_NO_TEAM_SUMMARY,
|
||||
|
|
@ -15,7 +16,6 @@ import Button from "components/buttons/Button";
|
|||
interface IEditTeamModal {
|
||||
onCancel: () => void;
|
||||
defaultTeamName: string;
|
||||
onUpdateSuccess: (newName: string) => void;
|
||||
}
|
||||
|
||||
const baseClass = "edit-team-modal";
|
||||
|
|
@ -23,9 +23,9 @@ const baseClass = "edit-team-modal";
|
|||
const EditTeamModal = ({
|
||||
onCancel,
|
||||
defaultTeamName,
|
||||
onUpdateSuccess,
|
||||
}: IEditTeamModal): JSX.Element => {
|
||||
const { availableTeams } = useContext(AppContext);
|
||||
const { availableTeams, setConfig } = useContext(AppContext);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const [selectedTeam, setSelectedTeam] = useState(defaultTeamName);
|
||||
|
||||
|
|
@ -43,19 +43,34 @@ const EditTeamModal = ({
|
|||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onFormSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const handleUpdateTeam = useCallback(
|
||||
async (newName: string) => {
|
||||
try {
|
||||
const configData = await configAPI.update({
|
||||
mdm: { apple_bm_default_team: newName },
|
||||
});
|
||||
renderFlash("success", "Default team updated successfully.");
|
||||
setConfig(configData);
|
||||
} catch (e) {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Unable to update default team. Please try again."
|
||||
);
|
||||
} finally {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[renderFlash, setConfig, onCancel]
|
||||
);
|
||||
|
||||
const onFormSubmit = useCallback(
|
||||
(evt: FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
setIsLoading(true);
|
||||
const configData = await configAPI.update({
|
||||
mdm: { apple_bm_default_team: selectedTeam },
|
||||
});
|
||||
setIsLoading(false);
|
||||
onUpdateSuccess(configData.mdm.apple_bm_default_team);
|
||||
} finally {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
handleUpdateTeam(selectedTeam);
|
||||
},
|
||||
[selectedTeam, setIsLoading, handleUpdateTeam]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Edit team" onExit={onCancel} className={baseClass}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import React, { useContext } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { IMdmAppleBm } from "interfaces/mdm";
|
||||
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import DataError from "components/DataError";
|
||||
import Spinner from "components/Spinner/Spinner";
|
||||
import SectionHeader from "components/SectionHeader";
|
||||
|
||||
import WindowsAutomaticEnrollmentCard from "./components/WindowsAutomaticEnrollmentCard";
|
||||
import AppleAutomaticEnrollmentCard from "./components/AppleAutomaticEnrollmentCard";
|
||||
|
||||
const baseClass = "mdm-platforms-section";
|
||||
|
||||
interface IMdmPlatformsSectionProps {
|
||||
router: InjectedRouter;
|
||||
}
|
||||
|
||||
const MdmPlatformsSection = ({ router }: IMdmPlatformsSectionProps) => {
|
||||
const { config } = useContext(AppContext);
|
||||
|
||||
const {
|
||||
data: mdmAppleBm,
|
||||
isLoading: isLoadingMdmAppleBm,
|
||||
error: errorMdmAppleBm,
|
||||
} = useQuery<IMdmAppleBm, AxiosError, IMdmAppleBm>(
|
||||
["mdmAppleBmAPI"],
|
||||
() => mdmAppleBmAPI.getAppleBMInfo(),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (tries, error) => error.status !== 404 && tries <= 3,
|
||||
}
|
||||
);
|
||||
|
||||
const navigateToWindowsAutomaticEnrollment = () => {
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS);
|
||||
};
|
||||
|
||||
const navigateToAppleAutomaticEnrollment = () => {
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_APPLE);
|
||||
};
|
||||
|
||||
const navigateToApplePushCertSetup = () => {
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
|
||||
};
|
||||
|
||||
if (isLoadingMdmAppleBm) {
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showMdmAppleBmError =
|
||||
errorMdmAppleBm &&
|
||||
// API returns a 404 error if ABM is not configured yet
|
||||
errorMdmAppleBm.status !== 404 &&
|
||||
// API returns a 400 error if ABM credentials are invalid
|
||||
errorMdmAppleBm.status !== 400; // TODO: does this still signal expire/invalid credentials? do we need any special error handling? can anything else result in 400?
|
||||
|
||||
if (showMdmAppleBmError) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<SectionHeader title="Apple Business Manager" />
|
||||
<AppleAutomaticEnrollmentCard
|
||||
viewDetails={navigateToAppleAutomaticEnrollment}
|
||||
turnOn={
|
||||
!config?.mdm.enabled_and_configured
|
||||
? navigateToApplePushCertSetup
|
||||
: undefined
|
||||
}
|
||||
configured={!!config?.mdm.apple_bm_enabled_and_configured}
|
||||
/>
|
||||
<WindowsAutomaticEnrollmentCard
|
||||
viewDetails={navigateToWindowsAutomaticEnrollment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MdmPlatformsSection;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
.mdm-platforms-section {
|
||||
h4 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: $pad-xxlarge;
|
||||
}
|
||||
|
||||
.mdm-settings-team-btn {
|
||||
margin-left: 12px;
|
||||
|
||||
.children-wrapper {
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
|
||||
.component__tooltip-wrapper__tip-text {
|
||||
max-width: initial;
|
||||
}
|
||||
|
||||
&__section-description,
|
||||
&__section-instructions,
|
||||
&__section-information {
|
||||
font-size: $x-small;
|
||||
color: $core-fleet-black;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__section-information {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__400-error-info {
|
||||
display: block;
|
||||
color: $core-fleet-black;
|
||||
font-weight: normal;
|
||||
font-size: $x-small;
|
||||
text-align: left;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.automatic-enrollment-card {
|
||||
margin-top: 0;
|
||||
margin-bottom: $pad-xxlarge;
|
||||
font-size: $x-small;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
margin: 0 0 $pad-xsmall;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 520px;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
|
||||
&__turn-on-mdm {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $pad-medium;
|
||||
|
||||
.button {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import React from "react";
|
||||
|
||||
import Card from "components/Card";
|
||||
import Button from "components/buttons/Button";
|
||||
import Icon from "components/Icon/Icon";
|
||||
|
||||
const baseClass = "automatic-enrollment-card";
|
||||
|
||||
interface IAppleAutomaticEnrollmentCardProps {
|
||||
viewDetails: () => void;
|
||||
turnOn?: () => void;
|
||||
configured?: boolean;
|
||||
}
|
||||
|
||||
const AppleAutomaticEnrollmentCard = ({
|
||||
viewDetails,
|
||||
turnOn,
|
||||
configured,
|
||||
}: IAppleAutomaticEnrollmentCardProps) => {
|
||||
let icon = "";
|
||||
let msg =
|
||||
"To enable automatic enrollment for macOS devices, first turn on macOS MDM.";
|
||||
if (!turnOn && !configured) {
|
||||
msg =
|
||||
"Automatically enroll newly purchased macOS devices when they’re first unboxed and set up by your end user.";
|
||||
} else if (!turnOn && configured) {
|
||||
msg = "Automatic enrollment for macOS enabled.";
|
||||
icon = "success";
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`${baseClass} ${turnOn ? `${baseClass}__turn-on-mdm` : ""}`}
|
||||
color="gray"
|
||||
>
|
||||
<div>
|
||||
{!icon && <h3>Automatic enrollment for macOS hosts</h3>}
|
||||
<p>
|
||||
{icon ? (
|
||||
<span>
|
||||
<Icon name="success" />
|
||||
{msg}
|
||||
</span>
|
||||
) : (
|
||||
msg
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{turnOn && (
|
||||
<Button
|
||||
className="apple-details-button"
|
||||
onClick={turnOn}
|
||||
variant="text-icon"
|
||||
>
|
||||
Turn on MDM
|
||||
</Button>
|
||||
)}
|
||||
{!turnOn && !configured && (
|
||||
<Button
|
||||
className="apple-details-button"
|
||||
onClick={viewDetails}
|
||||
variant="brand"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{!turnOn && configured && (
|
||||
<Button
|
||||
className="apple-details-button"
|
||||
onClick={viewDetails}
|
||||
variant="text-icon"
|
||||
>
|
||||
<Icon name="pencil" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppleAutomaticEnrollmentCard;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AppleAutomaticEnrollmentCard";
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import React from "react";
|
||||
import { noop } from "lodash";
|
||||
|
||||
import Card from "components/Card";
|
||||
import Button from "components/buttons/Button";
|
||||
import Icon from "components/Icon/Icon";
|
||||
|
||||
const baseClass = "windows-automatic-enrollment-card";
|
||||
const baseClass = "automatic-enrollment-card";
|
||||
|
||||
interface IWindowsAutomaticEnrollmentCardProps {
|
||||
viewDetails: () => void;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./MdmPlatformsSection";
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import React, { useState, useContext, FormEvent } from "react";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import {
|
||||
APP_CONTEXT_NO_TEAM_ID,
|
||||
APP_CONTEX_NO_TEAM_SUMMARY,
|
||||
} from "interfaces/team";
|
||||
import configAPI from "services/entities/config";
|
||||
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
interface IRenameTeamModal {
|
||||
onCancel: () => void;
|
||||
defaultTeamName: string;
|
||||
onUpdateSuccess: (newName: string) => void;
|
||||
}
|
||||
|
||||
const baseClass = "edit-team-modal";
|
||||
|
||||
const RenameTeamModal = ({
|
||||
onCancel,
|
||||
defaultTeamName,
|
||||
onUpdateSuccess,
|
||||
}: IRenameTeamModal): JSX.Element => {
|
||||
const { availableTeams } = useContext(AppContext);
|
||||
|
||||
const [selectedTeam, setSelectedTeam] = useState(defaultTeamName);
|
||||
|
||||
const teamNameOptions = availableTeams
|
||||
?.filter((t) => t.id >= APP_CONTEXT_NO_TEAM_ID)
|
||||
.map((teamSummary) => {
|
||||
return {
|
||||
value:
|
||||
teamSummary.name === APP_CONTEX_NO_TEAM_SUMMARY.name
|
||||
? ""
|
||||
: teamSummary.name,
|
||||
label: teamSummary.name,
|
||||
};
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onFormSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const configData = await configAPI.update({
|
||||
mdm: { apple_bm_default_team: selectedTeam },
|
||||
});
|
||||
setIsLoading(false);
|
||||
onUpdateSuccess(configData.mdm.apple_bm_default_team);
|
||||
} finally {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="Rename team" onExit={onCancel} className={baseClass}>
|
||||
<form className={`${baseClass}__form`} onSubmit={onFormSubmit}>
|
||||
<div className="bottom-label">
|
||||
<Dropdown
|
||||
placeholder={selectedTeam}
|
||||
options={teamNameOptions}
|
||||
onChange={setSelectedTeam}
|
||||
value={selectedTeam}
|
||||
label="Team"
|
||||
helpText="macOS hosts will be added to this team when they're first unboxed."
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit" variant="brand" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="inverse">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameTeamModal;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./RenameTeamModal";
|
||||
|
|
@ -1,127 +1,44 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import React, { useCallback, useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import mdmAppleAPI from "services/entities/mdm_apple";
|
||||
import { IMdmApple } from "interfaces/mdm";
|
||||
import { readableDate } from "utilities/helpers";
|
||||
import { IMdmApple, getMdmServerUrl } from "interfaces/mdm";
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import BackLink from "components/BackLink";
|
||||
import MainContent from "components/MainContent";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import DataError from "components/DataError";
|
||||
import Spinner from "components/Spinner";
|
||||
import RequestCSRModal from "../components/RequestCSRModal";
|
||||
|
||||
const baseClass = "mac-os-mdm-page";
|
||||
import ApplePushCertSetup from "./components/content/ApplePushCertSetup";
|
||||
import ApplePushCertInfo from "./components/content/ApplePushCertInfo";
|
||||
|
||||
interface IApplePuushCertificatePortalSetupProps {
|
||||
onClickRequest: () => void;
|
||||
}
|
||||
import RenewCertModal from "./components/modals/RenewCertModal";
|
||||
import TurnOffMacOsMdmModal from "./components/modals/TurnOffMacOsMdmModal";
|
||||
|
||||
const ApplePushCertificatePortalSetup = ({
|
||||
onClickRequest,
|
||||
}: IApplePuushCertificatePortalSetupProps) => {
|
||||
return (
|
||||
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
|
||||
<p className={`${baseClass}__setup-description`}>
|
||||
Connect Fleet to Apple Push Certificates Portal to change settings and
|
||||
install software on your macOS hosts.
|
||||
</p>
|
||||
<ol className={`${baseClass}__setup-instructions-list`}>
|
||||
<li>
|
||||
<p>
|
||||
1. Request a certificate signing request (CSR) and key for Apple
|
||||
Push Notification Service (APNs) and a certificate and key for
|
||||
Simple Certificate Enrollment Protocol (SCEP).
|
||||
</p>
|
||||
<Button
|
||||
className={`${baseClass}__request-button`}
|
||||
onClick={onClickRequest}
|
||||
variant="brand"
|
||||
>
|
||||
Request
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<p>2. Go to your email to download your CSR.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
3.{" "}
|
||||
<CustomLink
|
||||
url="https://identity.apple.com/pushcert/"
|
||||
text="Sign in to Apple Push Certificates Portal"
|
||||
newTab
|
||||
/>
|
||||
<br />
|
||||
If you don't have an Apple ID, select <b>Create yours now</b>.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
4. In Apple Push Certificates Portal, select{" "}
|
||||
<b>Create a Certificate</b>, upload your CSR, and download your APNs
|
||||
certificate.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
5. Deploy Fleet with <b>mdm</b> configuration.{" "}
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm"
|
||||
text="See how"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const baseClass = "mac-os-mdm-page";
|
||||
|
||||
interface IApplePushCertificatePortalSetupInfoProps {
|
||||
appleAPNInfo: IMdmApple;
|
||||
}
|
||||
|
||||
const ApplePushCertificatePortalSetupInfo = ({
|
||||
appleAPNInfo,
|
||||
}: IApplePushCertificatePortalSetupInfoProps) => {
|
||||
return (
|
||||
<dl className={`${baseClass}__page-content ${baseClass}__apc-info`}>
|
||||
<div>
|
||||
<dt>Common name (CN)</dt>
|
||||
<dd>{appleAPNInfo.common_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Serial number</dt>
|
||||
<dd>{appleAPNInfo.serial_number}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Issuer</dt>
|
||||
<dd>{appleAPNInfo.issuer}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Renew date</dt>
|
||||
<dd>{readableDate(appleAPNInfo.renew_date)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
const MacOSMdmPage = () => {
|
||||
const MacOSMdmPage = ({ router }: { router: InjectedRouter }) => {
|
||||
const { config } = useContext(AppContext);
|
||||
const [showRequestCSRModal, setShowRequestCSRModal] = useState(false);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showRenewCertModal, setShowRenewCertModal] = useState(false);
|
||||
const [showTurnOffMdmModal, setShowTurnOffMdmModal] = useState(false);
|
||||
|
||||
// Currently the status of this API call is what determines various UI states on
|
||||
// this page. Because of this we will not render any of this components UI until this API
|
||||
// call has completed.
|
||||
const {
|
||||
data: appleAPNInfo,
|
||||
isLoading: isLoadingMdmApple,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
refetch,
|
||||
error: errorMdmApple,
|
||||
} = useQuery<IMdmApple, AxiosError, IMdmApple>(
|
||||
["appleAPNInfo"],
|
||||
|
|
@ -130,34 +47,49 @@ const MacOSMdmPage = () => {
|
|||
retry: (tries, error) => error.status !== 404 && tries <= 3,
|
||||
enabled: config?.mdm.enabled_and_configured,
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
onSettled: () => setIsUpdating(false),
|
||||
}
|
||||
);
|
||||
|
||||
const toggleRequestCSRModal = () => {
|
||||
setShowRequestCSRModal((prevState) => !prevState);
|
||||
const toggleRenewCertModal = () => {
|
||||
setShowRenewCertModal((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const renderPageContent = () => {
|
||||
// The API returns a 404 error if APNs is not configured yet, in that case we
|
||||
// want to prompt the user to download the certs and keys to configure the
|
||||
// server instead of the default error message.
|
||||
const showMdmAppleError = errorMdmApple && errorMdmApple.status !== 404;
|
||||
|
||||
if (showMdmAppleError) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
if (!appleAPNInfo) {
|
||||
return (
|
||||
<ApplePushCertificatePortalSetup
|
||||
onClickRequest={toggleRequestCSRModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ApplePushCertificatePortalSetupInfo appleAPNInfo={appleAPNInfo} />;
|
||||
const toggleTurnOffMdmModal = () => {
|
||||
setShowTurnOffMdmModal((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const turnOffMdm = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
toggleTurnOffMdmModal();
|
||||
try {
|
||||
await mdmAppleAPI.deleteApplePushCertificate();
|
||||
renderFlash("success", "macOS MDM turned off successfully.");
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
|
||||
} catch (e) {
|
||||
renderFlash("error", "Couldn’t turn off MDM. Please try again.");
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [renderFlash, router]);
|
||||
|
||||
const onRenewCert = useCallback(() => {
|
||||
refetch();
|
||||
toggleRenewCertModal();
|
||||
}, [refetch]);
|
||||
|
||||
const onSetupSuccess = useCallback(() => {
|
||||
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
|
||||
}, [router]);
|
||||
|
||||
// The API returns a 404 error if APNs is not configured yet, in that case we
|
||||
// want to prompt the user to configure the server instead of the default error message.
|
||||
const isMdmNotConfigured = errorMdmApple && errorMdmApple.status !== 404;
|
||||
|
||||
const showSpinner = isLoading || isUpdating || isRefetching;
|
||||
const showError = !config || isMdmNotConfigured;
|
||||
const showContent = !showSpinner && !showError;
|
||||
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
<>
|
||||
|
|
@ -167,9 +99,35 @@ const MacOSMdmPage = () => {
|
|||
className={`${baseClass}__back-to-mdm`}
|
||||
/>
|
||||
<h1>Apple Push Certificate Portal</h1>
|
||||
{isLoadingMdmApple ? <Spinner /> : renderPageContent()}
|
||||
{showRequestCSRModal && (
|
||||
<RequestCSRModal onCancel={toggleRequestCSRModal} />
|
||||
{showSpinner && <Spinner />}
|
||||
{showError && <DataError />}
|
||||
{showContent &&
|
||||
(!appleAPNInfo ? (
|
||||
<ApplePushCertSetup
|
||||
baseClass={baseClass}
|
||||
onSetupSuccess={onSetupSuccess}
|
||||
/>
|
||||
) : (
|
||||
<ApplePushCertInfo
|
||||
baseClass={baseClass}
|
||||
appleAPNInfo={appleAPNInfo}
|
||||
orgName={config.org_info.org_name}
|
||||
serverUrl={getMdmServerUrl(config.server_settings)}
|
||||
onClickRenew={toggleRenewCertModal}
|
||||
onClickTurnOff={toggleTurnOffMdmModal}
|
||||
/>
|
||||
))}
|
||||
{showRenewCertModal && (
|
||||
<RenewCertModal
|
||||
onCancel={toggleRenewCertModal}
|
||||
onRenew={onRenewCert}
|
||||
/>
|
||||
)}
|
||||
{showTurnOffMdmModal && (
|
||||
<TurnOffMacOsMdmModal
|
||||
onCancel={toggleTurnOffMdmModal}
|
||||
onConfirm={turnOffMdm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</MainContent>
|
||||
|
|
|
|||
|
|
@ -39,10 +39,33 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
};
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $pad-small;
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin: 0;
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__request-button {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
align-items: center;
|
||||
margin-top: $pad-small;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__apc-info {
|
||||
|
|
@ -55,4 +78,31 @@
|
|||
margin-bottom: $pad-xsmall;
|
||||
}
|
||||
}
|
||||
|
||||
&__apns-button-wrap {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
align-items: center;
|
||||
margin-top: $pad-xxlarge;
|
||||
}
|
||||
|
||||
&__file-uploader {
|
||||
margin-top: $pad-medium;
|
||||
margin-left: $pad-medium;
|
||||
border-radius: 6px;
|
||||
|
||||
.file-uploader__message {
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import React from "react";
|
||||
|
||||
import { IMdmApple } from "interfaces/mdm";
|
||||
|
||||
import { readableDate } from "utilities/helpers";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
interface IApplePushCertInfoProps {
|
||||
baseClass: string;
|
||||
appleAPNInfo: IMdmApple;
|
||||
orgName: string;
|
||||
serverUrl: string;
|
||||
onClickRenew: () => void;
|
||||
onClickTurnOff: () => void;
|
||||
}
|
||||
const ApplePushCertInfo = ({
|
||||
baseClass,
|
||||
appleAPNInfo,
|
||||
orgName,
|
||||
serverUrl,
|
||||
onClickRenew,
|
||||
onClickTurnOff,
|
||||
}: IApplePushCertInfoProps) => {
|
||||
return (
|
||||
<>
|
||||
<dl className={`${baseClass}__page-content ${baseClass}__apc-info`}>
|
||||
<div>
|
||||
<dt>Common name (CN)</dt>
|
||||
<dd>{appleAPNInfo.common_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Organization name</dt>
|
||||
<dd>{orgName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>MDM server URL</dt>
|
||||
<dd>{serverUrl}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Renew date</dt>
|
||||
<dd>{readableDate(appleAPNInfo.renew_date)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className={`${baseClass}__apns-button-wrap`}>
|
||||
<Button variant="inverse" onClick={onClickTurnOff}>
|
||||
Turn off MDM
|
||||
</Button>
|
||||
<Button className="save-loading" variant="brand" onClick={onClickRenew}>
|
||||
Renew certificate
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplePushCertInfo;
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useCallback, useContext, useState } from "react";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import mdmAppleApi from "services/entities/mdm_apple";
|
||||
|
||||
import CustomLink from "components/CustomLink";
|
||||
import FileUploader from "components/FileUploader";
|
||||
import DownloadCSR from "../../../../../../components/DownloadFileButtons/DownloadCSR";
|
||||
|
||||
interface IApplePushCertSetupProps {
|
||||
baseClass: string;
|
||||
onSetupSuccess: () => void;
|
||||
}
|
||||
const ApplePushCertSetup = ({
|
||||
baseClass,
|
||||
onSetupSuccess,
|
||||
}: IApplePushCertSetupProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const onFileUpload = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files?.length) {
|
||||
renderFlash("error", "No file selected");
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await mdmAppleApi.uploadApplePushCertificate(files[0]);
|
||||
renderFlash("success", "macOS MDM turned on successfully.");
|
||||
onSetupSuccess();
|
||||
} catch (e) {
|
||||
const msg = getErrorReason(e);
|
||||
if (msg.toLowerCase().includes("invalid certificate")) {
|
||||
renderFlash("error", msg);
|
||||
} else {
|
||||
renderFlash("error", "Couldn’t connect. Please try again.");
|
||||
}
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[renderFlash, onSetupSuccess]
|
||||
);
|
||||
|
||||
const onDownloadError = useCallback(
|
||||
(e: unknown) => {
|
||||
const msg = getErrorReason(e);
|
||||
if (msg.toLowerCase().includes("email address")) {
|
||||
renderFlash("error", msg);
|
||||
} else {
|
||||
renderFlash("error", "Something’s gone wrong. Please try again.");
|
||||
}
|
||||
},
|
||||
[renderFlash]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
|
||||
<p className={`${baseClass}__setup-description`}>
|
||||
Connect Fleet to Apple Push Certificates Portal to turn on MDM.
|
||||
</p>
|
||||
<div>
|
||||
<ol className={`${baseClass}__setup-instructions-list`}>
|
||||
<li>
|
||||
<span>1. </span>
|
||||
<span>
|
||||
<span>
|
||||
Download a certificate signing request (CSR) for Apple Push
|
||||
Notification service (APNs).
|
||||
</span>
|
||||
<DownloadCSR baseClass={baseClass} onError={onDownloadError} />
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>2. </span>
|
||||
<span>
|
||||
Sign in to{" "}
|
||||
<CustomLink
|
||||
url="https://identity.apple.com/pushcert/"
|
||||
text="Apple Push Certificates Portal"
|
||||
newTab
|
||||
/>
|
||||
<br />
|
||||
If you don't have an Apple ID, select <b>Create yours now</b>
|
||||
.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>3. </span>
|
||||
<span>
|
||||
In Apple Push Certificates Portal, select{" "}
|
||||
<b>Create a Certificate</b>, upload your CSR, and download your
|
||||
APNs certificate.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>4. </span>
|
||||
<span>Upload APNs certificate (.pem file) below.</span>
|
||||
</li>
|
||||
</ol>
|
||||
<FileUploader
|
||||
className={`${baseClass}__file-uploader ${
|
||||
isUploading ? `${baseClass}__file-uploader--loading` : ""
|
||||
}`}
|
||||
accept=".pem"
|
||||
buttonMessage={isUploading ? "Uploading..." : "Upload"}
|
||||
buttonType="link"
|
||||
diabled={isUploading}
|
||||
graphicName="file-pem"
|
||||
message="APNs certificate (.pem)"
|
||||
onFileUpload={onFileUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplePushCertSetup;
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useState, useContext, useEffect, useCallback } from "react";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import mdmAppleApi from "services/entities/mdm_apple";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import {
|
||||
FileUploader,
|
||||
FileDetails,
|
||||
} from "components/FileUploader/FileUploader";
|
||||
import Modal from "components/Modal";
|
||||
import DownloadCSR from "../../../../../../../components/DownloadFileButtons/DownloadCSR";
|
||||
|
||||
const baseClass = "modal renew-cert-modal";
|
||||
|
||||
interface IRenewCertModalProps {
|
||||
onCancel: () => void;
|
||||
onRenew: () => void;
|
||||
}
|
||||
|
||||
const RenewCertModal = ({
|
||||
onCancel,
|
||||
onRenew,
|
||||
}: IRenewCertModalProps): JSX.Element => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
|
||||
const onSelectFile = useCallback((files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (file) {
|
||||
setCertFile(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRenewClick = useCallback(async () => {
|
||||
if (!certFile) {
|
||||
// this shouldn'r happen, but just in case
|
||||
renderFlash("error", "Please provide a certificate file.");
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await mdmAppleApi.uploadApplePushCertificate(certFile);
|
||||
renderFlash("success", "APNs certificate renewed successfully.");
|
||||
setIsUploading(false);
|
||||
onRenew();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const msg = getErrorReason(e);
|
||||
if (msg.toLowerCase().includes("valid certificate")) {
|
||||
renderFlash("error", msg);
|
||||
} else {
|
||||
renderFlash("error", "Couldn’t renew. Please try again.");
|
||||
}
|
||||
setIsUploading(false);
|
||||
onCancel();
|
||||
}
|
||||
}, [certFile, renderFlash, onCancel, onRenew]);
|
||||
|
||||
const onDownloadError = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(e: unknown) => {
|
||||
renderFlash("error", "Something’s gone wrong. Please try again.");
|
||||
},
|
||||
[renderFlash]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Renew certificate" onExit={onCancel} className={baseClass}>
|
||||
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
|
||||
<ol className={`${baseClass}__setup-instructions-list`}>
|
||||
<li>
|
||||
<p>
|
||||
1. Download a certificate signing request (CSR) for Apple Push
|
||||
Notification service (APNs).
|
||||
</p>
|
||||
<DownloadCSR baseClass={baseClass} onError={onDownloadError} />
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
2. Sign in to{" "}
|
||||
<CustomLink
|
||||
url="https://identity.apple.com/pushcert/"
|
||||
text="Apple Push Certificates Portal"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
3. In Apple Push Certificates Portal, select <b>Renew</b> next to
|
||||
your certificate (make sure that the certificate's{" "}
|
||||
<b>Common Name (CN)</b> matches the one presented in Fleet).
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>4. Upload your CSR and download new APNs certificate.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
5. Upload APNs certificate (.pem file) below.
|
||||
<FileUploader
|
||||
className={`${baseClass}__file-uploader`}
|
||||
accept=".pem"
|
||||
buttonMessage="Choose file"
|
||||
buttonType="link"
|
||||
graphicName="file-pem"
|
||||
message="APNs certificate (.pem)"
|
||||
onFileUpload={onSelectFile}
|
||||
filePreview={
|
||||
certFile && (
|
||||
<FileDetails
|
||||
details={{ name: certFile.name }}
|
||||
graphicName="file-pem"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<div className={`${baseClass}__button-wrap`}>
|
||||
<Button
|
||||
className={`${baseClass}__submit-button ${
|
||||
isUploading ? `uploading` : ""
|
||||
}`}
|
||||
variant="brand"
|
||||
disabled={!certFile || isUploading}
|
||||
isLoading={isUploading}
|
||||
type="button"
|
||||
onClick={onRenewClick}
|
||||
>
|
||||
Renew certificate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenewCertModal;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
.renew-cert-modal {
|
||||
width: 730px;
|
||||
|
||||
&__info-header {
|
||||
margin-bottom: $pad-xlarge;
|
||||
}
|
||||
|
||||
&__setup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
color: $core-fleet-black;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__setup-instructions-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
li > p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__request-button {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
align-items: center;
|
||||
margin-top: $pad-small;
|
||||
margin-left: $pad-medium;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__file-uploader {
|
||||
margin-top: $pad-medium;
|
||||
margin-left: $pad-medium;
|
||||
border-radius: 6px;
|
||||
|
||||
.file-uploader__message {
|
||||
color: $ui-fleet-black-75;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.renew-cert-modal__submit-button.uploading.button--disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./RenewCertModal";
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
|
||||
const baseClass = "modal turn-off-mdm-modal";
|
||||
|
||||
interface ITurnOffMacOsMdmModalProps {
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const TurnOffMacOsMdmModal = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ITurnOffMacOsMdmModalProps): JSX.Element => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const onClickConfirm = useCallback(() => {
|
||||
setIsDeleting(true);
|
||||
onConfirm();
|
||||
}, [onConfirm]);
|
||||
|
||||
return (
|
||||
<Modal title="Turn off macOS MDM" onExit={onCancel} className={baseClass}>
|
||||
<div className={baseClass}>
|
||||
If you want to use MDM features again, you’ll have to upload a new APNs
|
||||
certificate and all end users will have to turn MDM off and back on.
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="alert"
|
||||
onClick={onClickConfirm}
|
||||
isLoading={isDeleting}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
variant="inverse-alert"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TurnOffMacOsMdmModal;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
.turn-off-mdm-modal {
|
||||
&__info-header {
|
||||
margin-bottom: $pad-xlarge;
|
||||
}
|
||||
|
||||
&__button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.renew-cert-modal__submit-button.uploading.button--disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./TurnOffMacOsMdmModal";
|
||||
|
|
@ -37,8 +37,14 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
|
|||
["appleAPNInfo"],
|
||||
() => mdmAppleAPI.getAppleAPNInfo(),
|
||||
{
|
||||
retry: (tries, error) => error.status !== 404 && tries <= 3,
|
||||
enabled: config?.mdm.enabled_and_configured,
|
||||
retry: (tries, error) =>
|
||||
error.status !== 404 && error.status !== 400 && tries <= 3,
|
||||
// TODO: There is a potential race condition here immediately after MDM is turned off. This
|
||||
// component gets remounted and stale config data is used to determine it this API call is
|
||||
// enabled, resulting in a 400 response. The race really should be fixed higher up the chain where
|
||||
// we're fetching and setting the config, but for now we'll just assume that any 400 response
|
||||
// means that MDM is not enabled and we'll show the "Turn on MDM" button.
|
||||
enabled: !!config?.mdm.enabled_and_configured,
|
||||
staleTime: 5000,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,13 +18,10 @@ const TurnOnMacOSMdm = ({ onClickTurnOn }: ITurnOnMacOSMdmProps) => {
|
|||
<div className={`${baseClass}__turn-on-mac-os`}>
|
||||
<div>
|
||||
<h3>Turn on macOS MDM</h3>
|
||||
<p>
|
||||
Connect Fleet to Apple Push Certificates Portal to change settings and
|
||||
install software on your macOS hosts.
|
||||
</p>
|
||||
<p>Enforce settings, OS updates, disk encryption, and more.</p>
|
||||
</div>
|
||||
<Button variant="brand" onClick={onClickTurnOn}>
|
||||
Connect APNS
|
||||
Turn on
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -42,8 +39,8 @@ const SeeDetailsMacOSMdm = ({ onClickDetails }: ITurnOffMacOSMdmProps) => {
|
|||
<p>macOS MDM turned on</p>
|
||||
</div>
|
||||
<Button onClick={onClickDetails} variant="text-icon">
|
||||
Details
|
||||
<Icon name="chevron-right" color="core-fleet-blue" />
|
||||
<Icon name="pencil" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -68,9 +65,10 @@ const MacOSMdmCard = ({
|
|||
turnOnMacOSMdm,
|
||||
viewDetails,
|
||||
}: IMacOSMdmCardProps) => {
|
||||
// The API returns a 404 error if APNS is not configured yet. If there is any
|
||||
// other error we will show the DataError component.
|
||||
const showError = errorData !== null && errorData.status !== 404;
|
||||
// The API returns an error if MDM is turned off or APNS is not configured yet.
|
||||
// If there is any other error we will show the DataError component.
|
||||
const showError =
|
||||
errorData !== null && errorData.status !== 404 && errorData.status !== 400;
|
||||
|
||||
if (showError) {
|
||||
return <DataError />;
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
import React, { FormEvent, useState, useContext } from "react";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import MdmAPI from "services/entities/mdm";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import DataError from "components/DataError";
|
||||
import Icon from "components/Icon";
|
||||
import Modal from "components/Modal";
|
||||
import validEmail from "components/forms/validators/valid_email";
|
||||
import validate_presence from "components/forms/validators/validate_presence";
|
||||
|
||||
export interface IRequestCSRFormData {
|
||||
email: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
const baseClass = "modal request-csr-modal";
|
||||
interface IRequestCSRModalProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface IFormField {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const FILES: CSRFile[] = [
|
||||
{ name: "mdmcert.download.push.key", key: "apns_key" }, // APNS key
|
||||
{ name: "fleet-mdm-apple-scep.key", key: "scep_key" }, // SCEP key
|
||||
{ name: "fleet-mdm-apple-scep.crt", key: "scep_cert" }, // SCEP cert
|
||||
];
|
||||
|
||||
const downloadFile = (tokens: string, fileName: string) => {
|
||||
const linkSource = `data:application/octet-stream;base64,${tokens}`;
|
||||
const downloadLink = document.createElement("a");
|
||||
|
||||
downloadLink.href = linkSource;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.click();
|
||||
};
|
||||
|
||||
type RequestCsrResponse = {
|
||||
apns_key: string;
|
||||
scep_key: string;
|
||||
scep_cert: string;
|
||||
};
|
||||
|
||||
type ResponseKeys = keyof RequestCsrResponse;
|
||||
|
||||
type CSRFile = {
|
||||
name: string;
|
||||
key: ResponseKeys;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const downloadCSRFiles = (data: RequestCsrResponse) => {
|
||||
FILES.forEach((file) => {
|
||||
downloadFile(data[file.key], file.name);
|
||||
});
|
||||
};
|
||||
|
||||
const RequestCSRModal = ({ onCancel }: IRequestCSRModalProps): JSX.Element => {
|
||||
const { currentUser, config } = useContext(AppContext);
|
||||
|
||||
const [formData, setFormData] = useState<IRequestCSRFormData>({
|
||||
email: currentUser?.email ?? "",
|
||||
orgName: config?.org_info?.org_name ?? "",
|
||||
});
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [orgError, setOrgError] = useState("");
|
||||
const [requestState, setRequestState] = useState<
|
||||
"loading" | "error" | "success" | undefined
|
||||
>(undefined);
|
||||
|
||||
const { email, orgName } = formData;
|
||||
|
||||
const onInputChange = ({ name, value }: IFormField) => {
|
||||
setFormData({ ...formData, [name]: value });
|
||||
};
|
||||
|
||||
const onFormSubmit = async (evt: FormEvent) => {
|
||||
evt.preventDefault();
|
||||
|
||||
// TODO: improve error handling. considering pulling out form err handling
|
||||
// into reusable hook.
|
||||
if (!validEmail(formData.email)) {
|
||||
setEmailError("Email is not a valid format.");
|
||||
return;
|
||||
}
|
||||
if (!validate_presence(formData.orgName)) {
|
||||
setOrgError("Organization name is required.");
|
||||
return;
|
||||
}
|
||||
setEmailError("");
|
||||
setOrgError("");
|
||||
setRequestState("loading");
|
||||
try {
|
||||
const data = await MdmAPI.requestCSR(email, orgName);
|
||||
downloadCSRFiles(data);
|
||||
setRequestState("success");
|
||||
} catch (e) {
|
||||
const err = e as any;
|
||||
if (
|
||||
err.status >= 400 &&
|
||||
err.status <= 499 &&
|
||||
err.data?.errors[0]?.name === "email_address"
|
||||
) {
|
||||
setEmailError("Email does not have the correct domain.");
|
||||
setRequestState(undefined);
|
||||
}
|
||||
if (err.status >= 500 && err.status <= 599) {
|
||||
setRequestState("error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const RequestCSRSuccess = () => {
|
||||
return (
|
||||
<div className="success">
|
||||
<Icon name="success" size="extra-large" />
|
||||
<h2>You're almost there</h2>
|
||||
<p>
|
||||
Go to your <strong>{email}</strong> email to download your CSR.
|
||||
</p>
|
||||
<p>
|
||||
Your APNs key and SCEP certificate and key will be downloaded in the
|
||||
browser.
|
||||
<br />
|
||||
You'll need these later.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRequestCSRForm = () => {
|
||||
if (requestState === "success") {
|
||||
return <RequestCSRSuccess />;
|
||||
}
|
||||
if (requestState === "error") {
|
||||
return <DataError />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
A CSR and key for APNs and a certificate and key for SCEP are required
|
||||
to connect Fleet to Apple Developer. Apple Inc. requires the following
|
||||
information. <br />
|
||||
<br />
|
||||
fleetdm.com will send your CSR to the below email. Your APNs key and
|
||||
SCEP certificate and key will be downloaded in the browser.
|
||||
</p>
|
||||
<form
|
||||
className={`${baseClass}__form`}
|
||||
onSubmit={onFormSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="bottom-label">
|
||||
<InputField
|
||||
name="email"
|
||||
onChange={onInputChange}
|
||||
label="Email"
|
||||
parseTarget
|
||||
value={email}
|
||||
error={emailError}
|
||||
helpText={
|
||||
<>
|
||||
Apple Inc. requires a work email (ex.
|
||||
name@your-organization.com).
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
name="orgName"
|
||||
onChange={onInputChange}
|
||||
label="Organization name"
|
||||
parseTarget
|
||||
value={orgName}
|
||||
error={orgError}
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
isLoading={requestState === "loading"}
|
||||
>
|
||||
Request
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="inverse">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="Request" onExit={onCancel} className={baseClass}>
|
||||
{renderRequestCSRForm()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestCSRModal;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
.request-csr-modal {
|
||||
&__info-header {
|
||||
margin-bottom: $pad-xlarge;
|
||||
}
|
||||
|
||||
.success {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.data-error__inner {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./RequestCSRModal";
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import React, { FormEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import mdmAppleBusinessManagerApi from "services/entities/mdm_apple_bm";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
import Button from "components/buttons/Button";
|
||||
import { downloadBase64ToFile, RequestState } from "./helpers";
|
||||
|
||||
interface IDownloadABMKeyProps {
|
||||
baseClass: string;
|
||||
onSuccess?: () => void;
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
const downloadKeyFile = (data: { public_key: string }) => {
|
||||
downloadBase64ToFile(data.public_key, "fleet-mdm-apple-bm-public-key.crt");
|
||||
};
|
||||
|
||||
// TODO: why can't we use Content-Dispostion for these? We're only getting one file back now.
|
||||
|
||||
const useDownloadABMKey = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: Omit<IDownloadABMKeyProps, "baseClass">) => {
|
||||
const [downloadState, setDownloadState] = useState<RequestState>(undefined);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (evt: FormEvent) => {
|
||||
evt.preventDefault();
|
||||
setDownloadState("loading");
|
||||
try {
|
||||
const data = await mdmAppleBusinessManagerApi.downloadPublicKey();
|
||||
downloadKeyFile(data);
|
||||
setDownloadState("success");
|
||||
onSuccess && onSuccess();
|
||||
} catch (e) {
|
||||
setDownloadState("error");
|
||||
onError && onError(e);
|
||||
}
|
||||
},
|
||||
[onError, onSuccess]
|
||||
);
|
||||
|
||||
const memoized = useMemo(
|
||||
() => ({
|
||||
downloadState,
|
||||
handleDownload,
|
||||
}),
|
||||
[downloadState, handleDownload]
|
||||
);
|
||||
|
||||
return memoized;
|
||||
};
|
||||
|
||||
export const DownloadABMKey = ({
|
||||
baseClass,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IDownloadABMKeyProps) => {
|
||||
const { handleDownload } = useDownloadABMKey({ onSuccess, onError });
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${baseClass}__request-button`}
|
||||
variant="text-icon"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<label htmlFor="download-key">
|
||||
<Icon name="download" color="core-fleet-blue" size="medium" />
|
||||
<span>Download public key</span>
|
||||
</label>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadABMKey;
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import React, { FormEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import mdmAppleApi from "services/entities/mdm_apple";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
import Button from "components/buttons/Button";
|
||||
import { RequestState, downloadBase64ToFile } from "./helpers";
|
||||
|
||||
interface IDownloadCSRProps {
|
||||
baseClass: string;
|
||||
onSuccess?: () => void;
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
const downloadCSRFile = (data: { csr: string }) => {
|
||||
downloadBase64ToFile(data.csr, "fleet-mdm-apple.csr");
|
||||
};
|
||||
|
||||
// TODO: why can't we use Content-Dispostion for these? We're only getting one file back now.
|
||||
|
||||
const useDownloadCSR = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: Omit<IDownloadCSRProps, "baseClass">) => {
|
||||
const [downloadState, setDownloadState] = useState<RequestState>(undefined);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (evt: FormEvent) => {
|
||||
evt.preventDefault();
|
||||
setDownloadState("loading");
|
||||
try {
|
||||
const data = await mdmAppleApi.requestCSR();
|
||||
downloadCSRFile(data);
|
||||
setDownloadState("success");
|
||||
onSuccess && onSuccess();
|
||||
} catch (e) {
|
||||
setDownloadState("error");
|
||||
onError && onError(e);
|
||||
}
|
||||
},
|
||||
[onError, onSuccess]
|
||||
);
|
||||
|
||||
const memoized = useMemo(
|
||||
() => ({
|
||||
downloadState,
|
||||
handleDownload,
|
||||
}),
|
||||
[downloadState, handleDownload]
|
||||
);
|
||||
|
||||
return memoized;
|
||||
};
|
||||
|
||||
export const DownloadCSR = ({
|
||||
baseClass,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IDownloadCSRProps) => {
|
||||
const { handleDownload } = useDownloadCSR({ onSuccess, onError });
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${baseClass}__request-button`}
|
||||
variant="text-icon"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<label htmlFor="request-csr">
|
||||
<Icon name="download" color="core-fleet-blue" size="medium" />
|
||||
<span>Download CSR</span>
|
||||
</label>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadCSR;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export type RequestState = "loading" | "error" | "success" | undefined;
|
||||
|
||||
export const downloadBase64ToFile = (data: string, fileName: string) => {
|
||||
const linkSource = `data:application/octet-stream;base64,${data}`;
|
||||
const downloadLink = document.createElement("a");
|
||||
|
||||
downloadLink.href = linkSource;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.click();
|
||||
};
|
||||
|
|
@ -59,6 +59,7 @@ import SetupExperience from "pages/ManageControlsPage/SetupExperience/SetupExper
|
|||
import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage";
|
||||
import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMdmPage";
|
||||
import Scripts from "pages/ManageControlsPage/Scripts/Scripts";
|
||||
import AppleAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AppleAutomaticEnrollmentPage";
|
||||
import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage";
|
||||
import HostQueryReport from "pages/hosts/details/HostQueryReport";
|
||||
import SoftwarePage from "pages/SoftwarePage";
|
||||
|
|
@ -157,6 +158,10 @@ const routes = (
|
|||
</Route>
|
||||
<Route path="integrations/mdm/windows" component={WindowsMdmPage} />
|
||||
<Route path="integrations/mdm/apple" component={MacOSMdmPage} />
|
||||
<Route
|
||||
path="integrations/automatic-enrollment/apple"
|
||||
component={AppleAutomaticEnrollmentPage}
|
||||
/>
|
||||
<Route
|
||||
path="integrations/automatic-enrollment/windows"
|
||||
component={WindowsAutomaticEnrollmentPage}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export default {
|
|||
ADMIN_INTEGRATIONS_MDM_MAC: `${URL_PREFIX}/settings/integrations/mdm/apple`,
|
||||
ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`,
|
||||
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`,
|
||||
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_APPLE: `${URL_PREFIX}/settings/integrations/automatic-enrollment/apple`,
|
||||
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`,
|
||||
ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`,
|
||||
ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`,
|
||||
|
|
|
|||
|
|
@ -80,13 +80,10 @@ const mdmService = {
|
|||
timeout
|
||||
);
|
||||
},
|
||||
requestCSR: (email: string, organization: string) => {
|
||||
requestCSR: () => {
|
||||
const { MDM_REQUEST_CSR } = endpoints;
|
||||
|
||||
return sendRequest("POST", MDM_REQUEST_CSR, {
|
||||
email_address: email,
|
||||
organization,
|
||||
});
|
||||
return sendRequest("GET", MDM_REQUEST_CSR);
|
||||
},
|
||||
|
||||
getProfiles: (
|
||||
|
|
|
|||
|
|
@ -8,4 +8,21 @@ export default {
|
|||
const path = MDM_APPLE_PNS;
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
|
||||
uploadApplePushCertificate: (certificate: File) => {
|
||||
const { MDM_APPLE_APNS_CERTIFICATE } = endpoints;
|
||||
const formData = new FormData();
|
||||
formData.append("certificate", certificate);
|
||||
return sendRequest("POST", MDM_APPLE_APNS_CERTIFICATE, formData);
|
||||
},
|
||||
|
||||
deleteApplePushCertificate: () => {
|
||||
const { MDM_APPLE_APNS_CERTIFICATE } = endpoints;
|
||||
return sendRequest("DELETE", MDM_APPLE_APNS_CERTIFICATE);
|
||||
},
|
||||
|
||||
requestCSR: () => {
|
||||
const { MDM_REQUEST_CSR } = endpoints;
|
||||
return sendRequest("GET", MDM_REQUEST_CSR);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
import sendRequest from "services";
|
||||
import endpoints from "utilities/endpoints";
|
||||
|
||||
export interface IAppleBusinessManagerTokenFormData {
|
||||
token: File | null;
|
||||
}
|
||||
|
||||
export default {
|
||||
getAppleBMInfo: () => {
|
||||
const { MDM_APPLE_BM } = endpoints;
|
||||
|
|
@ -28,4 +32,22 @@ export default {
|
|||
return Promise.resolve({ decodedPublic, decodedPrivate });
|
||||
});
|
||||
},
|
||||
|
||||
downloadPublicKey: () => {
|
||||
const { MDM_APPLE_ABM_PUBLIC_KEY } = endpoints;
|
||||
return sendRequest("GET", MDM_APPLE_ABM_PUBLIC_KEY);
|
||||
},
|
||||
|
||||
uploadToken: (token: File) => {
|
||||
const { MDM_APPLE_ABM_TOKEN: MDM_APPLE_BM_TOKEN } = endpoints;
|
||||
const formData = new FormData();
|
||||
formData.append("token", token);
|
||||
|
||||
return sendRequest("POST", MDM_APPLE_BM_TOKEN, formData);
|
||||
},
|
||||
|
||||
disableAutomaticEnrollment: () => {
|
||||
const { MDM_APPLE_ABM_TOKEN: MDM_APPLE_BM_TOKEN } = endpoints;
|
||||
return sendRequest("DELETE", MDM_APPLE_BM_TOKEN);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ export default {
|
|||
MACADMINS: `/${API_VERSION}/fleet/macadmins`,
|
||||
|
||||
// MDM endpoints
|
||||
MDM_APPLE: `/${API_VERSION}/fleet/mdm/apple`,
|
||||
MDM_APPLE_ABM_TOKEN: `/${API_VERSION}/fleet/mdm/apple/abm_token`,
|
||||
MDM_APPLE_ABM_PUBLIC_KEY: `/${API_VERSION}/fleet/mdm/apple/abm_public_key`,
|
||||
MDM_APPLE_APNS_CERTIFICATE: `/${API_VERSION}/fleet/mdm/apple/apns_certificate`,
|
||||
MDM_APPLE_PNS: `/${API_VERSION}/fleet/apns`,
|
||||
MDM_APPLE_BM: `/${API_VERSION}/fleet/abm`,
|
||||
MDM_APPLE_BM_KEYS: `/${API_VERSION}/fleet/mdm/apple/dep/key_pair`,
|
||||
|
|
|
|||
|
|
@ -25,3 +25,13 @@ export const getPlatformDisplayName = (file: File) => {
|
|||
const fileExt = getFileExtension(file);
|
||||
return FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[fileExt];
|
||||
};
|
||||
|
||||
/**
|
||||
* This gets the file details from the file.
|
||||
*/
|
||||
export const getFileDetails = (file: File) => {
|
||||
return {
|
||||
name: file.name,
|
||||
platform: getPlatformDisplayName(file),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import (
|
|||
|
||||
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
|
@ -95,6 +94,7 @@ type ServerConfig struct {
|
|||
SandboxEnabled bool `yaml:"sandbox_enabled"`
|
||||
WebsocketsAllowUnsafeOrigin bool `yaml:"websockets_allow_unsafe_origin"`
|
||||
FrequentCleanupsEnabled bool `yaml:"frequent_cleanups_enabled"`
|
||||
PrivateKey string `yaml:"private_key"`
|
||||
}
|
||||
|
||||
func (s *ServerConfig) DefaultHTTPServer(ctx context.Context, handler http.Handler) *http.Server {
|
||||
|
|
@ -453,6 +453,12 @@ type MDMConfig struct {
|
|||
AppleBMKey string `yaml:"apple_bm_key"`
|
||||
AppleBMKeyBytes string `yaml:"apple_bm_key_bytes"`
|
||||
|
||||
// the following fields hold the PEM-encoded bytes for the certificate
|
||||
// and private key set the first time AppleBM is called
|
||||
appleBMPEMCert []byte
|
||||
appleBMPEMKey []byte
|
||||
appleBMRawToken []byte
|
||||
|
||||
// the following fields hold the decrypted, validated Apple BM token set the
|
||||
// first time AppleBM is called.
|
||||
appleBMToken *nanodep_client.OAuth1Tokens
|
||||
|
|
@ -601,20 +607,6 @@ func (m *MDMConfig) AppleAPNs() (cert *tls.Certificate, pemCert, pemKey []byte,
|
|||
return m.appleAPNs, m.appleAPNsPEMCert, m.appleAPNsPEMKey, nil
|
||||
}
|
||||
|
||||
func (m *MDMConfig) AppleAPNsTopic() (string, error) {
|
||||
apnsCert, _, _, err := m.AppleAPNs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing APNs certificates: %w", err)
|
||||
}
|
||||
|
||||
mdmPushCertTopic, err := cryptoutil.TopicFromCert(apnsCert.Leaf)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extracting topic from APNs certificate: %w", err)
|
||||
}
|
||||
|
||||
return mdmPushCertTopic, nil
|
||||
}
|
||||
|
||||
// AppleSCEP returns the parsed and validated TLS certificate for Apple SCEP.
|
||||
// It parses and validates it if it hasn't been done yet.
|
||||
func (m *MDMConfig) AppleSCEP() (cert *tls.Certificate, pemCert, pemKey []byte, err error) {
|
||||
|
|
@ -636,10 +628,36 @@ func (m *MDMConfig) AppleSCEP() (cert *tls.Certificate, pemCert, pemKey []byte,
|
|||
return m.appleSCEP, m.appleSCEPPEMCert, m.appleSCEPPEMKey, nil
|
||||
}
|
||||
|
||||
type ParsedAppleBM struct {
|
||||
CertPEM []byte
|
||||
KeyPEM []byte
|
||||
EncryptedToken []byte
|
||||
Token *nanodep_client.OAuth1Tokens
|
||||
}
|
||||
|
||||
func decryptAndValidateABMToken(tokenBytes []byte, cert *x509.Certificate, keyPEM []byte) (*nanodep_client.OAuth1Tokens, error) {
|
||||
bmKey, err := tokenpki.RSAKeyFromPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Apple BM configuration: parse private key: %w", err)
|
||||
}
|
||||
token, err := tokenpki.DecryptTokenJSON(tokenBytes, cert, bmKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Apple BM configuration: decrypt token: %w", err)
|
||||
}
|
||||
var jsonTok nanodep_client.OAuth1Tokens
|
||||
if err := json.Unmarshal(token, &jsonTok); err != nil {
|
||||
return nil, fmt.Errorf("Apple BM configuration: unmarshal JSON token: %w", err)
|
||||
}
|
||||
if jsonTok.AccessTokenExpiry.Before(time.Now()) {
|
||||
return nil, errors.New("Apple BM configuration: token is expired")
|
||||
}
|
||||
return &jsonTok, nil
|
||||
}
|
||||
|
||||
// AppleBM returns the parsed, validated and decrypted server token for Apple
|
||||
// Business Manager. It also parses and validates the Apple BM certificate and
|
||||
// private key in the process, in order to decrypt the token.
|
||||
func (m *MDMConfig) AppleBM() (tok *nanodep_client.OAuth1Tokens, err error) {
|
||||
func (m *MDMConfig) AppleBM() (*ParsedAppleBM, error) {
|
||||
if m.appleBMToken == nil {
|
||||
pair := x509KeyPairConfig{
|
||||
m.AppleBMCert,
|
||||
|
|
@ -655,24 +673,22 @@ func (m *MDMConfig) AppleBM() (tok *nanodep_client.OAuth1Tokens, err error) {
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("Apple BM configuration: %w", err)
|
||||
}
|
||||
bmKey, err := tokenpki.RSAKeyFromPEM(pair.keyBytes)
|
||||
jsonTok, err := decryptAndValidateABMToken(encToken, cert.Leaf, pair.keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Apple BM configuration: parse private key: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
token, err := tokenpki.DecryptTokenJSON(encToken, cert.Leaf, bmKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Apple BM configuration: decrypt token: %w", err)
|
||||
}
|
||||
var jsonTok nanodep_client.OAuth1Tokens
|
||||
if err := json.Unmarshal(token, &jsonTok); err != nil {
|
||||
return nil, fmt.Errorf("Apple BM configuration: unmarshal JSON token: %w", err)
|
||||
}
|
||||
if jsonTok.AccessTokenExpiry.Before(time.Now()) {
|
||||
return nil, errors.New("Apple BM configuration: token is expired")
|
||||
}
|
||||
m.appleBMToken = &jsonTok
|
||||
m.appleBMToken = jsonTok
|
||||
m.appleBMPEMCert = pair.certBytes
|
||||
m.appleBMPEMKey = pair.keyBytes
|
||||
m.appleBMRawToken = encToken
|
||||
}
|
||||
return m.appleBMToken, nil
|
||||
|
||||
return &ParsedAppleBM{
|
||||
CertPEM: m.appleBMPEMCert,
|
||||
KeyPEM: m.appleBMPEMKey,
|
||||
EncryptedToken: m.appleBMRawToken,
|
||||
Token: m.appleBMToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MDMConfig) loadAppleBMEncryptedToken() ([]byte, error) {
|
||||
|
|
@ -848,6 +864,7 @@ func (man Manager) addConfigs() {
|
|||
"When enabled, Fleet limits some features for the Sandbox")
|
||||
man.addConfigBool("server.websockets_allow_unsafe_origin", false, "Disable checking the origin header on websocket connections, this is sometimes necessary when proxies rewrite origin headers between the client and the Fleet webserver")
|
||||
man.addConfigBool("server.frequent_cleanups_enabled", false, "Enable frequent cleanups of expired data (15 minute interval)")
|
||||
man.addConfigString("server.private_key", "", "Used for encrypting sensitive data, such as MDM certificates.")
|
||||
|
||||
// Hide the sandbox flag as we don't want it to be discoverable for users for now
|
||||
sandboxFlag := man.command.PersistentFlags().Lookup(flagNameFromConfigKey("server.sandbox_enabled"))
|
||||
|
|
@ -1208,6 +1225,7 @@ func (man Manager) LoadConfig() FleetConfig {
|
|||
SandboxEnabled: man.getConfigBool("server.sandbox_enabled"),
|
||||
WebsocketsAllowUnsafeOrigin: man.getConfigBool("server.websockets_allow_unsafe_origin"),
|
||||
FrequentCleanupsEnabled: man.getConfigBool("server.frequent_cleanups_enabled"),
|
||||
PrivateKey: man.getConfigString("server.private_key"),
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
BcryptCost: man.getConfigInt("auth.bcrypt_cost"),
|
||||
|
|
@ -1729,6 +1747,7 @@ func TestConfig() FleetConfig {
|
|||
AuditLogFile: testLogFile,
|
||||
MaxSize: 500,
|
||||
},
|
||||
Server: ServerConfig{PrivateKey: "72414F4A688151F75D032F5CDA095FC4"},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1737,33 +1756,7 @@ func TestConfig() FleetConfig {
|
|||
// all required pairs and the Apple BM token is used as-is, instead of
|
||||
// decrypting the encrypted value that is usually provided via the fleet
|
||||
// server's flags.
|
||||
func SetTestMDMConfig(t testing.TB, cfg *FleetConfig, cert, key []byte, appleBMToken *nanodep_client.OAuth1Tokens, wstepCertAndKeyDir string) {
|
||||
tlsCert, err := tls.X509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parsed, err := x509.ParseCertificate(tlsCert.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tlsCert.Leaf = parsed
|
||||
|
||||
cfg.MDM.AppleAPNsCertBytes = string(cert)
|
||||
cfg.MDM.AppleAPNsKeyBytes = string(key)
|
||||
cfg.MDM.AppleSCEPCertBytes = string(cert)
|
||||
cfg.MDM.AppleSCEPKeyBytes = string(key)
|
||||
cfg.MDM.AppleBMCertBytes = string(cert)
|
||||
cfg.MDM.AppleBMKeyBytes = string(key)
|
||||
cfg.MDM.AppleBMServerTokenBytes = "whatever-will-not-be-accessed"
|
||||
|
||||
cfg.MDM.appleAPNs = &tlsCert
|
||||
cfg.MDM.appleAPNsPEMCert = cert
|
||||
cfg.MDM.appleAPNsPEMKey = key
|
||||
cfg.MDM.appleSCEP = &tlsCert
|
||||
cfg.MDM.appleSCEPPEMCert = cert
|
||||
cfg.MDM.appleSCEPPEMKey = key
|
||||
cfg.MDM.appleBMToken = appleBMToken
|
||||
func SetTestMDMConfig(t testing.TB, cfg *FleetConfig, cert, key []byte, wstepCertAndKeyDir string) {
|
||||
cfg.MDM.AppleSCEPSignerValidityDays = 365
|
||||
cfg.MDM.AppleSCEPChallenge = "testchallenge"
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ const (
|
|||
defaultQueryByNameExpiration = 1 * time.Second
|
||||
queryResultsCountKey = "QueryResultsCount:%d"
|
||||
defaultQueryResultsCountExpiration = 1 * time.Second
|
||||
// NOTE: MDM assets are cached using their checksum as well, as it's
|
||||
// important for them to always be fresh if they changed (see cachedi
|
||||
// mplementation below for details)
|
||||
mdmConfigAssetKey = "MDMConfigAsset:%s:%s"
|
||||
// NOTE: given how mdmConfigAssetKey works, it means that once an asset
|
||||
// changes, it'll linger for this amount of time. The curent
|
||||
// implementation assumes infrequent asset changes.
|
||||
defaultMDMConfigAssetExpiration = 15 * time.Minute
|
||||
)
|
||||
|
||||
// cloneCache wraps the in memory cache with one that clones items before returning them.
|
||||
|
|
@ -107,6 +115,7 @@ type cachedMysql struct {
|
|||
teamMDMConfigExp time.Duration
|
||||
queryByNameExp time.Duration
|
||||
queryResultsCountExp time.Duration
|
||||
mdmConfigAssetExp time.Duration
|
||||
}
|
||||
|
||||
type Option func(*cachedMysql)
|
||||
|
|
@ -159,6 +168,12 @@ func WithQueryResultsCountExpiration(d time.Duration) Option {
|
|||
}
|
||||
}
|
||||
|
||||
func WithMDMConfigAssetExpiration(d time.Duration) Option {
|
||||
return func(o *cachedMysql) {
|
||||
o.mdmConfigAssetExp = d
|
||||
}
|
||||
}
|
||||
|
||||
func New(ds fleet.Datastore, opts ...Option) fleet.Datastore {
|
||||
c := &cachedMysql{
|
||||
Datastore: ds,
|
||||
|
|
@ -171,6 +186,7 @@ func New(ds fleet.Datastore, opts ...Option) fleet.Datastore {
|
|||
teamMDMConfigExp: defaultTeamMDMConfigExpiration,
|
||||
queryByNameExp: defaultQueryByNameExpiration,
|
||||
queryResultsCountExp: defaultQueryResultsCountExpiration,
|
||||
mdmConfigAssetExp: defaultMDMConfigAssetExpiration,
|
||||
}
|
||||
for _, fn := range opts {
|
||||
fn(c)
|
||||
|
|
@ -386,3 +402,49 @@ func (ds *cachedMysql) ResultCountForQuery(ctx context.Context, queryID uint) (i
|
|||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (ds *cachedMysql) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
// always reach the database to get the latest hashes
|
||||
latestHashes, err := ds.Datastore.GetAllMDMConfigAssetsHashes(ctx, assetNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cachedAssets := make(map[fleet.MDMAssetName]fleet.MDMConfigAsset)
|
||||
var missingAssets []fleet.MDMAssetName
|
||||
var missingKeys []string
|
||||
|
||||
for _, name := range assetNames {
|
||||
key := fmt.Sprintf(mdmConfigAssetKey, name, latestHashes[name])
|
||||
|
||||
if x, found := ds.c.Get(ctx, key); found {
|
||||
asset, ok := x.(fleet.MDMConfigAsset)
|
||||
if ok {
|
||||
cachedAssets[name] = asset
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
missingAssets = append(missingAssets, name)
|
||||
missingKeys = append(missingKeys, key)
|
||||
}
|
||||
|
||||
if len(missingAssets) == 0 {
|
||||
return cachedAssets, nil
|
||||
}
|
||||
|
||||
// fetch missing assets from the database
|
||||
assetMap, err := ds.Datastore.GetAllMDMConfigAssetsByName(ctx, missingAssets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// update the cache with the fetched assets and their hashes
|
||||
for name, asset := range assetMap {
|
||||
key := fmt.Sprintf(mdmConfigAssetKey, name, latestHashes[name])
|
||||
ds.c.Set(ctx, key, asset, ds.mdmConfigAssetExp)
|
||||
cachedAssets[name] = asset
|
||||
}
|
||||
|
||||
return cachedAssets, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
|
@ -66,7 +65,7 @@ func TestClone(t *testing.T) {
|
|||
t.Run(tc.name, func(t *testing.T) {
|
||||
clone, err := tc.src.Clone()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, clone)
|
||||
require.Equal(t, tc.want, clone)
|
||||
|
||||
// ensure that writing to src does not alter the cloned value (i.e. that
|
||||
// the nested fields are deeply cloned too).
|
||||
|
|
@ -74,7 +73,7 @@ func TestClone(t *testing.T) {
|
|||
case *fleet.AppConfig:
|
||||
if len(src.ServerSettings.DebugHostIDs) > 0 {
|
||||
src.ServerSettings.DebugHostIDs[0] = 999
|
||||
assert.NotEqual(t, src.ServerSettings.DebugHostIDs, clone.(*fleet.AppConfig).ServerSettings.DebugHostIDs)
|
||||
require.NotEqual(t, src.ServerSettings.DebugHostIDs, clone.(*fleet.AppConfig).ServerSettings.DebugHostIDs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -111,7 +110,7 @@ func TestCachedAppConfig(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
require.NotEmpty(t, data)
|
||||
assert.Equal(t, json.RawMessage(`"TestCachedAppConfig"`), *data.Features.AdditionalQueries)
|
||||
require.Equal(t, json.RawMessage(`"TestCachedAppConfig"`), *data.Features.AdditionalQueries)
|
||||
})
|
||||
|
||||
t.Run("AppConfig", func(t *testing.T) {
|
||||
|
|
@ -130,12 +129,12 @@ func TestCachedAppConfig(t *testing.T) {
|
|||
},
|
||||
}))
|
||||
|
||||
assert.True(t, mockedDS.SaveAppConfigFuncInvoked)
|
||||
require.True(t, mockedDS.SaveAppConfigFuncInvoked)
|
||||
|
||||
ac, err := ds.AppConfig(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ac.Features.AdditionalQueries)
|
||||
assert.Equal(t, json.RawMessage(`"NewSAVED"`), *ac.Features.AdditionalQueries)
|
||||
require.Equal(t, json.RawMessage(`"NewSAVED"`), *ac.Features.AdditionalQueries)
|
||||
})
|
||||
|
||||
t.Run("External SaveAppConfig gets caught", func(t *testing.T) {
|
||||
|
|
@ -152,7 +151,7 @@ func TestCachedAppConfig(t *testing.T) {
|
|||
ac, err := ds.AppConfig(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ac.Features.AdditionalQueries)
|
||||
assert.Equal(t, json.RawMessage(`"SavedSomewhereElse"`), *ac.Features.AdditionalQueries)
|
||||
require.Equal(t, json.RawMessage(`"SavedSomewhereElse"`), *ac.Features.AdditionalQueries)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -746,3 +745,104 @@ func TestCachedResultCountForQuery(t *testing.T) {
|
|||
require.Equal(t, testCount, c3)
|
||||
require.True(t, mockedDS.ResultCountForQueryFuncInvoked)
|
||||
}
|
||||
|
||||
func TestGetAllMDMConfigAssetsByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockedDS := new(mock.Store)
|
||||
ds := New(mockedDS)
|
||||
|
||||
assetNames := []fleet.MDMAssetName{"asset1", "asset2", "asset3"}
|
||||
assetMap := map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
"asset1": {Name: "asset1", Value: []byte("value1")},
|
||||
"asset2": {Name: "asset2", Value: []byte("value2")},
|
||||
"asset3": {Name: "asset2", Value: []byte("value3")},
|
||||
}
|
||||
assetHashes := map[fleet.MDMAssetName]string{
|
||||
"asset1": "hash1",
|
||||
"asset2": "hash2",
|
||||
"asset3": "hash3",
|
||||
}
|
||||
|
||||
mockedDS.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
|
||||
result := map[fleet.MDMAssetName]string{}
|
||||
for _, n := range assetNames {
|
||||
result[n] = assetHashes[n]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
result := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
|
||||
for _, n := range assetNames {
|
||||
result[n] = assetMap[n]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// returns cached assets if hashes match
|
||||
result, err := ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
|
||||
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
|
||||
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
|
||||
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
|
||||
require.Equal(t, assetMap["asset1"], result["asset1"])
|
||||
require.Equal(t, assetMap["asset2"], result["asset2"])
|
||||
require.NotContains(t, result, "asset3")
|
||||
|
||||
result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"})
|
||||
require.NoError(t, err)
|
||||
require.False(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
|
||||
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
|
||||
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
|
||||
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
|
||||
require.Equal(t, assetMap["asset1"], result["asset1"])
|
||||
require.Equal(t, assetMap["asset2"], result["asset2"])
|
||||
require.NotContains(t, result, "asset3")
|
||||
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
|
||||
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
|
||||
|
||||
// fetches missing assets from the db
|
||||
result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2", "asset3"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, assetMap["asset1"], result["asset1"])
|
||||
require.Equal(t, assetMap["asset2"], result["asset2"])
|
||||
require.Equal(t, assetMap["asset3"], result["asset3"])
|
||||
require.True(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
|
||||
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
|
||||
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
|
||||
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
|
||||
|
||||
// fetches updated assets from the db
|
||||
assetHashes["asset1"] = "newhash"
|
||||
assetMap["asset1"] = fleet.MDMConfigAsset{Name: "asset1", Value: []byte("newvalue")}
|
||||
result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), assetNames)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, assetMap["asset1"], result["asset1"])
|
||||
require.Equal(t, assetMap["asset2"], result["asset2"])
|
||||
require.Equal(t, assetMap["asset3"], result["asset3"])
|
||||
require.True(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
|
||||
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
|
||||
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
|
||||
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
|
||||
|
||||
// passes errors fetching assets from downstream
|
||||
mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return nil, errors.New("error fetching assets")
|
||||
}
|
||||
|
||||
_, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "error fetching assets", err.Error())
|
||||
|
||||
// passes errors fetching hashes from downstream
|
||||
mockedDS.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
|
||||
return nil, errors.New("error fetching hashes")
|
||||
}
|
||||
|
||||
_, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "error fetching hashes", err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ func applyEnrollSecretsDB(ctx context.Context, q sqlx.ExtContext, teamID *uint,
|
|||
args = append(args, s.Secret, teamID, secretCreatedAt)
|
||||
}
|
||||
if _, err := q.ExecContext(ctx, sql, args...); err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
// Obfuscate the secret in the error message
|
||||
err = alreadyExists("secret", fleet.MaskedPassword)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ package mysql
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -48,7 +52,7 @@ INSERT INTO
|
|||
profUUID, teamID, cp.Identifier, cp.Name, cp.Mobileconfig, cp.Mobileconfig, cp.Name, teamID, cp.Name, teamID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case isDuplicate(err):
|
||||
case IsDuplicate(err):
|
||||
return ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
|
||||
default:
|
||||
return ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
|
||||
|
|
@ -2825,7 +2829,7 @@ func (ds *Datastore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fle
|
|||
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, stmt, bp.TeamID, bp.Name, bp.Sha256, bp.Bytes, bp.Token)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
return ctxerr.Wrap(ctx, alreadyExists("BootstrapPackage", fmt.Sprintf("for team %d", bp.TeamID)))
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "create bootstrap package")
|
||||
|
|
@ -2852,7 +2856,7 @@ WHERE team_id = 0
|
|||
`
|
||||
_, err := tx.ExecContext(ctx, insertStmt, toTeamID, uuid.New().String())
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
return ctxerr.Wrap(ctx, &existsError{
|
||||
ResourceType: "BootstrapPackage",
|
||||
TeamID: &toTeamID,
|
||||
|
|
@ -3765,7 +3769,7 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO
|
|||
declUUID, tmID, declaration.Identifier, declaration.Name, declaration.RawJSON, checksum, declaration.Name, tmID, declaration.Name, tmID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case isDuplicate(err):
|
||||
case IsDuplicate(err):
|
||||
return ctxerr.Wrap(ctx, formatErrorDuplicateDeclaration(err, declaration))
|
||||
default:
|
||||
return ctxerr.Wrap(ctx, err, "creating new apple mdm declaration")
|
||||
|
|
@ -4177,6 +4181,185 @@ VALUES
|
|||
return nil
|
||||
}
|
||||
|
||||
func encrypt(plainText []byte, privateKey string) ([]byte, error) {
|
||||
block, err := aes.NewCipher([]byte(privateKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create new cipher: %w", err)
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create new gcm: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
return aesGCM.Seal(nonce, nonce, plainText, nil), nil
|
||||
}
|
||||
|
||||
func decrypt(encrypted []byte, privateKey string) ([]byte, error) {
|
||||
block, err := aes.NewCipher([]byte(privateKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create new cipher: %w", err)
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create new gcm: %w", err)
|
||||
}
|
||||
|
||||
// Get the nonce size
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
|
||||
// Extract the nonce from the encrypted data
|
||||
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
|
||||
decrypted, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error {
|
||||
stmt := `
|
||||
INSERT INTO mdm_config_assets
|
||||
(name, value, md5_checksum)
|
||||
VALUES
|
||||
%s`
|
||||
|
||||
var args []any
|
||||
var insertVals strings.Builder
|
||||
|
||||
for _, a := range assets {
|
||||
encryptedVal, err := encrypt(a.Value, ds.serverPrivateKey)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, fmt.Sprintf("encrypting mdm config asset %s", a.Name))
|
||||
}
|
||||
|
||||
hexChecksum := md5ChecksumBytes(encryptedVal)
|
||||
insertVals.WriteString(`(?, ?, UNHEX(?)),`)
|
||||
args = append(args, a.Name, encryptedVal, hexChecksum)
|
||||
}
|
||||
|
||||
stmt = fmt.Sprintf(stmt, strings.TrimSuffix(insertVals.String(), ","))
|
||||
|
||||
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
_, err := tx.ExecContext(ctx, stmt, args...)
|
||||
return err
|
||||
})
|
||||
|
||||
return ctxerr.Wrap(ctx, err, "writing mdm config assets to db")
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
if len(assetNames) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stmt := `
|
||||
SELECT
|
||||
name, value
|
||||
FROM
|
||||
mdm_config_assets
|
||||
WHERE
|
||||
name IN (?)
|
||||
AND deletion_uuid = ''
|
||||
`
|
||||
|
||||
stmt, args, err := sqlx.In(stmt, assetNames)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In statement")
|
||||
}
|
||||
|
||||
var res []fleet.MDMConfigAsset
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get mdm config assets by name")
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return nil, notFound("MDMConfigAsset")
|
||||
}
|
||||
|
||||
assetMap := make(map[fleet.MDMAssetName]fleet.MDMConfigAsset, len(res))
|
||||
for _, asset := range res {
|
||||
decryptedVal, err := decrypt(asset.Value, ds.serverPrivateKey)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "decrypting mdm config asset %s", asset.Name)
|
||||
}
|
||||
|
||||
assetMap[asset.Name] = fleet.MDMConfigAsset{Name: asset.Name, Value: decryptedVal}
|
||||
}
|
||||
|
||||
if len(res) < len(assetNames) {
|
||||
return assetMap, ErrPartialResult
|
||||
}
|
||||
|
||||
return assetMap, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
|
||||
if len(assetNames) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stmt := `
|
||||
SELECT name, HEX(md5_checksum) as md5_checksum
|
||||
FROM mdm_config_assets
|
||||
WHERE name IN (?) AND deletion_uuid = ''`
|
||||
|
||||
stmt, args, err := sqlx.In(stmt, assetNames)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In statement")
|
||||
}
|
||||
|
||||
var res []fleet.MDMConfigAsset
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get mdm config checksums by name")
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return nil, notFound("MDMConfigAsset")
|
||||
}
|
||||
|
||||
assetMap := make(map[fleet.MDMAssetName]string, len(res))
|
||||
for _, asset := range res {
|
||||
assetMap[asset.Name] = asset.MD5Checksum
|
||||
}
|
||||
|
||||
if len(res) < len(assetNames) {
|
||||
return assetMap, ErrPartialResult
|
||||
}
|
||||
|
||||
return assetMap, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error {
|
||||
stmt := `
|
||||
UPDATE
|
||||
mdm_config_assets
|
||||
SET
|
||||
deleted_at = CURRENT_TIMESTAMP(),
|
||||
deletion_uuid = ?
|
||||
WHERE
|
||||
name IN (?) AND deletion_uuid = ''
|
||||
`
|
||||
|
||||
deletionUUID := uuid.New().String()
|
||||
|
||||
stmt, args, err := sqlx.In(stmt, deletionUUID, assetNames)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "sqlx.In DeleteMDMConfigAssetsByName")
|
||||
}
|
||||
|
||||
_, err = ds.writer(ctx).ExecContext(ctx, stmt, args...)
|
||||
return ctxerr.Wrap(ctx, err, "deleting mdm config assets")
|
||||
}
|
||||
|
||||
// ListIOSAndIPadOSToRefetch returns the UUIDs of iPhones/iPads that should be refetched
|
||||
// (their details haven't been updated in the given `interval`).
|
||||
func (ds *Datastore) ListIOSAndIPadOSToRefetch(ctx context.Context, interval time.Duration) (uuids []string, err error) {
|
||||
|
|
|
|||
|
|
@ -16,13 +16,11 @@ import (
|
|||
|
||||
"github.com/VividCortex/mysqlerr"
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
fleetmdm "github.com/fleetdm/fleet/v4/server/mdm"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
|
|
@ -76,6 +74,7 @@ func TestMDMApple(t *testing.T) {
|
|||
{"MDMAppleSetPendingDeclarationsAs", testMDMAppleSetPendingDeclarationsAs},
|
||||
{"SetOrUpdateMDMAppleDeclaration", testSetOrUpdateMDMAppleDDMDeclaration},
|
||||
{"DEPAssignmentUpdates", testMDMAppleDEPAssignmentUpdates},
|
||||
{"TestMDMConfigAsset", testMDMConfigAsset},
|
||||
{"ListIOSAndIPadOSToRefetch", testListIOSAndIPadOSToRefetch},
|
||||
{"MDMAppleUpsertHostIOSiPadOS", testMDMAppleUpsertHostIOSIPadOS},
|
||||
{"IngestMDMAppleDevicesFromDEPSyncIOSIPadOS", testIngestMDMAppleDevicesFromDEPSyncIOSIPadOS},
|
||||
|
|
@ -3001,14 +3000,10 @@ func testGetMDMAppleCommandResults(t *testing.T, ds *Datastore) {
|
|||
}
|
||||
|
||||
func createMDMAppleCommanderAndStorage(t *testing.T, ds *Datastore) (*apple_mdm.MDMAppleCommander, *NanoMDMStorage) {
|
||||
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
mdmStorage, err := ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
|
||||
mdmStorage, err := ds.NewMDMAppleMDMStorage()
|
||||
require.NoError(t, err)
|
||||
|
||||
return apple_mdm.NewMDMAppleCommander(mdmStorage, pusherFunc(okPusherFunc), config.MDMConfig{}), mdmStorage
|
||||
return apple_mdm.NewMDMAppleCommander(mdmStorage, pusherFunc(okPusherFunc)), mdmStorage
|
||||
}
|
||||
|
||||
func okPusherFunc(ctx context.Context, ids []string) (map[string]*push.Response, error) {
|
||||
|
|
@ -4571,7 +4566,7 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) {
|
|||
// default state
|
||||
checkLockWipeState(t, status, true, false, false, false, false, false)
|
||||
|
||||
appleStore, err := ds.NewMDMAppleMDMStorage(nil, nil)
|
||||
appleStore, err := ds.NewMDMAppleMDMStorage()
|
||||
require.NoError(t, err)
|
||||
|
||||
// record a request to lock the host
|
||||
|
|
@ -5506,6 +5501,95 @@ func createRawAppleCmd(reqType, cmdUUID string) string {
|
|||
</plist>`, reqType, cmdUUID)
|
||||
}
|
||||
|
||||
func testMDMConfigAsset(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
assets := []fleet.MDMConfigAsset{
|
||||
{
|
||||
Name: fleet.MDMAssetCACert,
|
||||
Value: []byte("some bytes"),
|
||||
},
|
||||
{
|
||||
Name: fleet.MDMAssetCAKey,
|
||||
Value: []byte("some other bytes"),
|
||||
},
|
||||
}
|
||||
wantAssets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
|
||||
for _, a := range assets {
|
||||
wantAssets[a.Name] = a
|
||||
}
|
||||
err := ds.InsertMDMConfigAssets(ctx, assets)
|
||||
require.NoError(t, err)
|
||||
|
||||
a, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantAssets, a)
|
||||
|
||||
h, err := ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, h, 2)
|
||||
require.NotEmpty(t, h[fleet.MDMAssetCACert])
|
||||
require.NotEmpty(t, h[fleet.MDMAssetCAKey])
|
||||
|
||||
// try to fetch an asset that doesn't exist
|
||||
var nfe fleet.NotFoundError
|
||||
a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetABMCert})
|
||||
require.ErrorAs(t, err, &nfe)
|
||||
require.Nil(t, a)
|
||||
|
||||
h, err = ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetABMCert})
|
||||
require.ErrorAs(t, err, &nfe)
|
||||
require.Nil(t, h)
|
||||
|
||||
// try to fetch a mix of assets that exist and doesn't exist
|
||||
a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetABMCert})
|
||||
require.ErrorIs(t, err, ErrPartialResult)
|
||||
require.Len(t, a, 1)
|
||||
|
||||
h, err = ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetABMCert})
|
||||
require.ErrorIs(t, err, ErrPartialResult)
|
||||
require.Len(t, h, 1)
|
||||
require.NotEmpty(t, h[fleet.MDMAssetCACert])
|
||||
|
||||
// Soft delete the assets
|
||||
|
||||
err = ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
require.NoError(t, err)
|
||||
|
||||
a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
require.ErrorAs(t, err, &nfe)
|
||||
require.Nil(t, a)
|
||||
|
||||
h, err = ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
require.ErrorAs(t, err, &nfe)
|
||||
require.Nil(t, h)
|
||||
|
||||
// Verify that they're still in the DB. Values should be encrypted.
|
||||
|
||||
type assetRow struct {
|
||||
Name string `db:"name"`
|
||||
Value []byte `db:"value"`
|
||||
DeletionUUID string `db:"deletion_uuid"`
|
||||
DeletedAt time.Time `db:"deleted_at"`
|
||||
}
|
||||
|
||||
var ar []assetRow
|
||||
|
||||
err = sqlx.SelectContext(ctx, ds.reader(ctx), &ar, "SELECT name, value, deletion_uuid, deleted_at FROM mdm_config_assets WHERE name IN (?, ?) ORDER BY name", fleet.MDMAssetCACert, fleet.MDMAssetCAKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, ar, 2)
|
||||
|
||||
for i, a := range ar {
|
||||
require.Equal(t, assets[i].Name, fleet.MDMAssetName(a.Name))
|
||||
require.NotEmpty(t, a.Value)
|
||||
d, err := decrypt(a.Value, ds.serverPrivateKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, assets[i].Value, d)
|
||||
require.NotEmpty(t, a.DeletionUUID)
|
||||
require.NotEmpty(t, a.DeletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type dbOptions struct {
|
|||
tracingConfig *config.LoggingConfig
|
||||
minLastOpenedAtDiff time.Duration
|
||||
sqlMode string
|
||||
privateKey string
|
||||
}
|
||||
|
||||
// Logger adds a logger to the datastore.
|
||||
|
|
@ -73,6 +74,7 @@ func TracingEnabled(lconfig *config.LoggingConfig) DBOption {
|
|||
func WithFleetConfig(conf *config.FleetConfig) DBOption {
|
||||
return func(o *dbOptions) error {
|
||||
o.minLastOpenedAtDiff = conf.Osquery.MinSoftwareLastOpenedAtDiff
|
||||
o.privateKey = conf.Server.PrivateKey
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ func (e *existsError) Resource() string {
|
|||
return e.ResourceType
|
||||
}
|
||||
|
||||
func isDuplicate(err error) bool {
|
||||
func IsDuplicate(err error) bool {
|
||||
err = ctxerr.Cause(err)
|
||||
if driverErr, ok := err.(*mysql.MySQLError); ok {
|
||||
if driverErr.Number == mysqlerr.ER_DUP_ENTRY {
|
||||
|
|
@ -190,3 +190,7 @@ func isMySQLAccessDenied(err error) bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrPartialResult indicates that a batch operation was completed,
|
||||
// but some of the results are missing or incomplete.
|
||||
var ErrPartialResult = errors.New("batch operation completed with partial results")
|
||||
|
|
|
|||
|
|
@ -2354,7 +2354,7 @@ func (ds *Datastore) SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint
|
|||
`
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, authToken)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
return fleet.ConflictError{Message: "auth token conflicts with another host"}
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "upsert host's device auth token")
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func (ds *Datastore) NewInvite(ctx context.Context, i *fleet.Invite) (*fleet.Inv
|
|||
|
||||
result, err := tx.ExecContext(ctx, sqlStmt, i.InvitedBy, i.Email,
|
||||
i.Name, i.Position, i.Token, i.SSOEnabled, i.GlobalRole)
|
||||
if err != nil && isDuplicate(err) {
|
||||
if err != nil && IsDuplicate(err) {
|
||||
return ctxerr.Wrap(ctx, alreadyExists("Invite", i.Email))
|
||||
} else if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "create invite")
|
||||
|
|
|
|||
|
|
@ -1008,7 +1008,7 @@ func (ds *Datastore) MDMInsertEULA(ctx context.Context, eula *fleet.MDMEULA) err
|
|||
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, stmt, eula.Name, eula.Bytes, eula.Token)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
return ctxerr.Wrap(ctx, alreadyExists("MDMEULA", eula.Token))
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "create EULA")
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import (
|
|||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
|
|
@ -6112,14 +6110,10 @@ func testMDMEULA(t *testing.T, ds *Datastore) {
|
|||
|
||||
func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
scepDepot, err := ds.NewSCEPDepot(testCertPEM, testKeyPEM)
|
||||
scepDepot, err := ds.NewSCEPDepot()
|
||||
require.NoError(t, err)
|
||||
|
||||
nanoStorage, err := ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
|
||||
nanoStorage, err := ds.NewMDMAppleMDMStorage()
|
||||
require.NoError(t, err)
|
||||
|
||||
addCert := func(notAfter time.Time, h *fleet.Host) {
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ func (ds *Datastore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device
|
|||
device.MDMNotInOOBE,
|
||||
device.HostUUID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsEnrolledDevice", device.MDMHardwareID))
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsEnrolledDevice")
|
||||
|
|
@ -153,7 +153,7 @@ func (ds *Datastore) mdmWindowsInsertCommandForHostsDB(ctx context.Context, tx s
|
|||
VALUES (?, ?, ?)
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID))
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand")
|
||||
|
|
@ -175,7 +175,7 @@ VALUES ((SELECT id FROM mdm_windows_enrollments WHERE host_uuid = ? OR mdm_devic
|
|||
`
|
||||
|
||||
if _, err := tx.ExecContext(ctx, stmt, hostUUIDOrDeviceID, hostUUIDOrDeviceID, commandUUID); err != nil {
|
||||
if isDuplicate(err) {
|
||||
if IsDuplicate(err) {
|
||||
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommandQueue", commandUUID))
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommandQueue")
|
||||
|
|
@ -1513,7 +1513,7 @@ INSERT INTO
|
|||
res, err := tx.ExecContext(ctx, insertProfileStmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID, cp.Name, teamID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case isDuplicate(err):
|
||||
case IsDuplicate(err):
|
||||
return &existsError{
|
||||
ResourceType: "MDMWindowsConfigProfile.Name",
|
||||
Identifier: cp.Name,
|
||||
|
|
@ -1579,7 +1579,7 @@ ON DUPLICATE KEY UPDATE
|
|||
res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID, cp.Name, teamID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case isDuplicate(err):
|
||||
case IsDuplicate(err):
|
||||
return &existsError{
|
||||
ResourceType: "MDMWindowsConfigProfile.Name",
|
||||
Identifier: cp.Name,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20240521143023, Down_20240521143023)
|
||||
}
|
||||
|
||||
func Up_20240521143023(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
CREATE TABLE mdm_config_assets (
|
||||
id int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
-- name is used for humans to identify what value is stored in this row
|
||||
name varchar(256) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
|
||||
|
||||
-- value holds the raw value of the asset
|
||||
value longblob NOT NULL,
|
||||
|
||||
-- deleted_at is used to track the date in which the row was marked as
|
||||
-- deleted for auditing/debugging purposes.
|
||||
deleted_at timestamp NULL DEFAULT NULL,
|
||||
|
||||
-- deletion_uuid is used as part of an UNIQUE KEY to guarantee that only
|
||||
-- one non-deleted row with a given name exists. This value should be filled
|
||||
-- along with deleted_at
|
||||
deletion_uuid varchar(127) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
|
||||
|
||||
-- md5_checksum holds the binary checksum of the value column.
|
||||
md5_checksum BINARY(16) NOT NULL,
|
||||
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY idx_mdm_config_assets_name_deletion_uuid (name, deletion_uuid)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating mdm_config_assets table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20240521143023(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue