mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
automatically renew macOS identity certificates 30 days prior to their expiration (#17057)
#15332 # 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/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests
This commit is contained in:
parent
12f519c853
commit
261332f76c
1
changes/15332-scep-renew
Normal file
1
changes/15332-scep-renew
Normal file
@ -0,0 +1 @@
|
||||
* Automatically renew macOS identity certificates for devices 30 days prior to their expiration.
|
@ -703,6 +703,7 @@ func newCleanupsAndAggregationSchedule(
|
||||
logger kitlog.Logger,
|
||||
enrollHostLimiter fleet.EnrollHostLimiter,
|
||||
config *config.FleetConfig,
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronCleanupsThenAggregation)
|
||||
@ -803,6 +804,12 @@ func newCleanupsAndAggregationSchedule(
|
||||
return verifyDiskEncryptionKeys(ctx, logger, ds, config)
|
||||
},
|
||||
),
|
||||
schedule.WithJob(
|
||||
"renew_scep_certificates",
|
||||
func(ctx context.Context) error {
|
||||
return service.RenewSCEPCertificates(ctx, logger, ds, config, commander)
|
||||
},
|
||||
),
|
||||
schedule.WithJob("query_results_cleanup", func(ctx context.Context) error {
|
||||
config, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
|
@ -681,7 +681,11 @@ the way that the Fleet server works.
|
||||
}()
|
||||
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
return newCleanupsAndAggregationSchedule(ctx, instanceID, ds, logger, redisWrapperDS, &config)
|
||||
var commander *apple_mdm.MDMAppleCommander
|
||||
if appCfg.MDM.EnabledAndConfigured {
|
||||
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
}
|
||||
return newCleanupsAndAggregationSchedule(ctx, instanceID, ds, logger, redisWrapperDS, &config, commander)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register cleanups_then_aggregations schedule")
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
|
||||
nanodep_client "github.com/micromdm/nanodep/client"
|
||||
"github.com/micromdm/nanodep/tokenpki"
|
||||
"github.com/spf13/cast"
|
||||
@ -594,6 +595,20 @@ 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) {
|
||||
|
@ -953,3 +953,80 @@ func (ds *Datastore) MDMDeleteEULA(ctx context.Context, token string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostCertAssociationsToExpire(ctx context.Context, expiryDays, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
// TODO(roberto): this is not good because we don't have any indexes on
|
||||
// h.uuid, due to time constraints, I'm assuming that this
|
||||
// function is called with a relatively low amount of shas
|
||||
//
|
||||
// Note that we use GROUP BY because we can't guarantee unique entries
|
||||
// based on uuid in the hosts table.
|
||||
stmt, args, err := sqlx.In(
|
||||
`SELECT
|
||||
h.uuid as host_uuid,
|
||||
ncaa.sha256 as sha256,
|
||||
COALESCE(MAX(hm.fleet_enroll_ref), '') as enroll_reference
|
||||
FROM
|
||||
nano_cert_auth_associations ncaa
|
||||
LEFT JOIN hosts h ON h.uuid = ncaa.id
|
||||
LEFT JOIN host_mdm hm ON hm.host_id = h.id
|
||||
WHERE
|
||||
cert_not_valid_after BETWEEN '0000-00-00' AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
|
||||
AND renew_command_uuid IS NULL
|
||||
GROUP BY
|
||||
host_uuid, ncaa.sha256, cert_not_valid_after
|
||||
ORDER BY cert_not_valid_after ASC
|
||||
LIMIT ?
|
||||
`, expiryDays, limit)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In query")
|
||||
}
|
||||
|
||||
var uuids []fleet.SCEPIdentityAssociation
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &uuids, stmt, args...); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "get identity certs close to expiry")
|
||||
}
|
||||
return uuids, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
|
||||
if len(assocs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
args := make([]any, len(assocs)*3)
|
||||
for i, assoc := range assocs {
|
||||
sb.WriteString("(?, ?, ?),")
|
||||
args[i*3] = assoc.HostUUID
|
||||
args[i*3+1] = assoc.SHA256
|
||||
args[i*3+2] = cmdUUID
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf(`
|
||||
INSERT INTO nano_cert_auth_associations (id, sha256, renew_command_uuid) VALUES %s
|
||||
ON DUPLICATE KEY UPDATE
|
||||
renew_command_uuid = VALUES(renew_command_uuid)
|
||||
`, strings.TrimSuffix(sb.String(), ","))
|
||||
|
||||
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||||
res, err := tx.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update cert associations: %w", err)
|
||||
}
|
||||
|
||||
// NOTE: we can't use insertOnDuplicateDidInsert because the
|
||||
// LastInsertId check only works tables that have an
|
||||
// auto-incrementing primary key. See notes in that function
|
||||
// and insertOnDuplicateDidUpdate to understand the mechanism.
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 1 {
|
||||
return errors.New("this function can only be used to update existing associations")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
@ -2,19 +2,25 @@ package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/nanomdm/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/test"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/micromdm/nanodep/tokenpki"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -35,6 +41,8 @@ func TestMDMShared(t *testing.T) {
|
||||
{"TestBatchSetProfileLabelAssociations", testBatchSetProfileLabelAssociations},
|
||||
{"TestBatchSetProfilesTransactionError", testBatchSetMDMProfilesTransactionError},
|
||||
{"TestMDMEULA", testMDMEULA},
|
||||
{"TestGetHostCertAssociationsToExpire", testSCEPRenewalHelpers},
|
||||
{"TestSCEPRenewalHelpers", testSCEPRenewalHelpers},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@ -3130,3 +3138,133 @@ func testMDMEULA(t *testing.T, ds *Datastore) {
|
||||
err = ds.MDMInsertEULA(ctx, eula)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
nanoStorage, err := ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
|
||||
require.NoError(t, err)
|
||||
|
||||
var i int
|
||||
setHost := func(notAfter time.Time) *fleet.Host {
|
||||
i++
|
||||
h, err := ds.NewHost(ctx, &fleet.Host{
|
||||
Hostname: fmt.Sprintf("test-host%d-name", i),
|
||||
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
|
||||
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
|
||||
UUID: fmt.Sprintf("test-uuid-%d", i),
|
||||
Platform: "darwin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create a cert + association
|
||||
serial, err := scepDepot.Serial()
|
||||
require.NoError(t, err)
|
||||
cert := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "FleetDM Identity",
|
||||
},
|
||||
NotAfter: notAfter,
|
||||
// use the host UUID, just to make sure they're
|
||||
// different from each other, we don't care about the
|
||||
// DER contents here
|
||||
Raw: []byte(h.UUID)}
|
||||
err = scepDepot.Put(cert.Subject.CommonName, cert)
|
||||
require.NoError(t, err)
|
||||
req := mdm.Request{
|
||||
EnrollID: &mdm.EnrollID{ID: h.UUID},
|
||||
Context: ctx,
|
||||
}
|
||||
certHash := certauth.HashCert(cert)
|
||||
err = nanoStorage.AssociateCertHash(&req, certHash, notAfter)
|
||||
require.NoError(t, err)
|
||||
nanoEnroll(t, ds, h, false)
|
||||
return h
|
||||
}
|
||||
|
||||
// certs expired at lest 1 year ago
|
||||
h1 := setHost(time.Now().AddDate(-1, -1, 0))
|
||||
h2 := setHost(time.Now().AddDate(-1, 0, 0))
|
||||
// cert that expires in 1 month
|
||||
h3 := setHost(time.Now().AddDate(0, 1, 0))
|
||||
// cert that expires in 1 year
|
||||
h4 := setHost(time.Now().AddDate(1, 0, 0))
|
||||
|
||||
// list assocs that expire in the next 10 days
|
||||
assocs, err := ds.GetHostCertAssociationsToExpire(ctx, 10, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assocs, 2)
|
||||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||||
require.Equal(t, h2.UUID, assocs[1].HostUUID)
|
||||
|
||||
// list certs that expire in the next 1000 days with limit = 1
|
||||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assocs, 1)
|
||||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||||
|
||||
// list certs that expire in the next 50 days
|
||||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 50, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assocs, 3)
|
||||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||||
require.Equal(t, h2.UUID, assocs[1].HostUUID)
|
||||
require.Equal(t, h3.UUID, assocs[2].HostUUID)
|
||||
|
||||
// list certs that expire in the next 1000 days
|
||||
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assocs, 4)
|
||||
require.Equal(t, h1.UUID, assocs[0].HostUUID)
|
||||
require.Equal(t, h2.UUID, assocs[1].HostUUID)
|
||||
require.Equal(t, h3.UUID, assocs[2].HostUUID)
|
||||
require.Equal(t, h4.UUID, assocs[3].HostUUID)
|
||||
|
||||
checkSCEPRenew := func(assoc fleet.SCEPIdentityAssociation, want *string) {
|
||||
var got *string
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &got, `SELECT renew_command_uuid FROM nano_cert_auth_associations WHERE id = ?`, assoc.HostUUID)
|
||||
})
|
||||
require.EqualValues(t, want, got)
|
||||
}
|
||||
|
||||
// insert dummy nano commands
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err = q.ExecContext(ctx, `
|
||||
INSERT INTO nano_commands (command_uuid, request_type, command)
|
||||
VALUES ('foo', 'foo', '<?xml'), ('bar', 'bar', '<?xml')
|
||||
`)
|
||||
return err
|
||||
})
|
||||
|
||||
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{}, "foo")
|
||||
checkSCEPRenew(assocs[0], nil)
|
||||
checkSCEPRenew(assocs[1], nil)
|
||||
checkSCEPRenew(assocs[2], nil)
|
||||
checkSCEPRenew(assocs[3], nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{assocs[0]}, "foo")
|
||||
require.NoError(t, err)
|
||||
checkSCEPRenew(assocs[0], ptr.String("foo"))
|
||||
checkSCEPRenew(assocs[1], nil)
|
||||
checkSCEPRenew(assocs[2], nil)
|
||||
checkSCEPRenew(assocs[3], nil)
|
||||
|
||||
err = ds.SetCommandForPendingSCEPRenewal(ctx, assocs, "bar")
|
||||
require.NoError(t, err)
|
||||
checkSCEPRenew(assocs[0], ptr.String("bar"))
|
||||
checkSCEPRenew(assocs[1], ptr.String("bar"))
|
||||
checkSCEPRenew(assocs[2], ptr.String("bar"))
|
||||
checkSCEPRenew(assocs[3], ptr.String("bar"))
|
||||
|
||||
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{{HostUUID: "foo", SHA256: "bar"}}, "bar")
|
||||
require.ErrorContains(t, err, "this function can only be used to update existing associations")
|
||||
}
|
||||
|
@ -0,0 +1,143 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/reflectx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20240222073518, Down_20240222073518)
|
||||
}
|
||||
|
||||
func Up_20240222073518(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
ALTER TABLE nano_cert_auth_associations
|
||||
-- used to detect identity certificates that are about to expire. While we have
|
||||
-- access to the scep_certificates table, nanomdm assumes that you can use any CA
|
||||
-- to issue your identity certificates, as such we can't add a foreign key here
|
||||
-- without major changes.
|
||||
ADD COLUMN cert_not_valid_after TIMESTAMP NULL,
|
||||
-- used to track the command issued to renew the identity certificate (if one)
|
||||
ADD COLUMN renew_command_uuid VARCHAR(127) COLLATE utf8mb4_unicode_ci NULL,
|
||||
ADD CONSTRAINT renew_command_uuid_fk
|
||||
FOREIGN KEY (renew_command_uuid) REFERENCES nano_commands (command_uuid)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to alter nano_cert_auth_associations table: %w", err)
|
||||
}
|
||||
|
||||
if err := batchUpdateCertAssociationsTimestamps(tx); err != nil {
|
||||
return fmt.Errorf("failed to update associations timestamps: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func batchUpdateCertAssociationsTimestamps(tx *sql.Tx) error {
|
||||
txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)}
|
||||
var totalCount int
|
||||
if err := txx.Get(&totalCount, "SELECT COUNT(*) FROM scep_certificates"); err != nil {
|
||||
return fmt.Errorf("failed to get total count of scep_certificates: %w", err)
|
||||
}
|
||||
|
||||
const batchSize = 100
|
||||
for offset := 0; offset < totalCount; offset += batchSize {
|
||||
if err := updateCertAssociationTimestamps(&txx, batchSize, offset); err != nil {
|
||||
return fmt.Errorf("updating batch with offset %d: %w", offset, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateCertAssociationTimestamps(txx *sqlx.Tx, limit, offset int) error {
|
||||
var scepCerts []struct {
|
||||
Serial string `db:"serial"`
|
||||
CertificatePEM []byte `db:"certificate_pem"`
|
||||
}
|
||||
|
||||
if err := txx.Select(
|
||||
&scepCerts, `
|
||||
SELECT certificate_pem
|
||||
FROM scep_certificates
|
||||
ORDER BY serial
|
||||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
limit, offset,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to retrieve scep_certificates: %w", err)
|
||||
}
|
||||
|
||||
shas := make([]string, len(scepCerts))
|
||||
expiries := make(map[string]time.Time, len(scepCerts))
|
||||
for i, rawCert := range scepCerts {
|
||||
block, _ := pem.Decode(rawCert.CertificatePEM)
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse certificate with serial %s", rawCert.Serial)
|
||||
continue
|
||||
}
|
||||
|
||||
hashed := hashCert(cert)
|
||||
shas[i] = hashed
|
||||
expiries[hashed] = cert.NotAfter
|
||||
}
|
||||
|
||||
var assocs []struct {
|
||||
HostUUID string `db:"id"`
|
||||
SHA256 string `db:"sha256"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
selectAssocStmt, selectAssocArgs, err := sqlx.In(`
|
||||
SELECT id, sha256
|
||||
FROM nano_cert_auth_associations
|
||||
WHERE sha256 IN (?)
|
||||
`, shas)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building sqlx.In for cert associations: %w", err)
|
||||
}
|
||||
|
||||
if err := txx.Select(&assocs, selectAssocStmt, selectAssocArgs...); err != nil {
|
||||
return fmt.Errorf("failed to retrieve cert associations: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
updateAssocArgs := make([]any, len(assocs)*3)
|
||||
for i, assoc := range assocs {
|
||||
sb.WriteString("(?, ?, ?),")
|
||||
updateAssocArgs[i*3] = assoc.HostUUID
|
||||
updateAssocArgs[i*3+1] = assoc.SHA256
|
||||
updateAssocArgs[i*3+2] = expiries[assoc.SHA256]
|
||||
}
|
||||
|
||||
updateAssocStmt := fmt.Sprintf(`
|
||||
INSERT INTO nano_cert_auth_associations (id, sha256, cert_not_valid_after) VALUES %s
|
||||
ON DUPLICATE KEY UPDATE
|
||||
cert_not_valid_after = VALUES(cert_not_valid_after),
|
||||
updated_at = updated_at
|
||||
`, strings.TrimSuffix(sb.String(), ","))
|
||||
if _, err := txx.Exec(updateAssocStmt, updateAssocArgs...); err != nil {
|
||||
return fmt.Errorf("failed to update cert associations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashCert(cert *x509.Certificate) string {
|
||||
hashed := sha256.Sum256(cert.Raw)
|
||||
return hex.EncodeToString(hashed[:])
|
||||
}
|
||||
|
||||
func Down_20240222073518(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
@ -0,0 +1,137 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20240222073518(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
_, err := db.Exec("INSERT INTO scep_serials (serial) VALUES (1), (2)")
|
||||
require.NoError(t, err)
|
||||
|
||||
threeDaysAgo := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO scep_certificates (serial, not_valid_before, not_valid_after, certificate_pem)
|
||||
VALUES (?, ?, ?, ?), (?, ?, ?, ?)`,
|
||||
// not_valid_* values don't really matter as the migration
|
||||
// takes the value from the parsed cert.
|
||||
1, threeDaysAgo, threeDaysAgo, dummyCert1,
|
||||
2, threeDaysAgo, threeDaysAgo, dummyCert2,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
sha1, sha2 := "4c51d40f56f5c5e13448995d4d2fd0b6b7befef860e4e7341c355ab38031ee35", "53c2dc9ce116a1df4adfba0c556843625fd1e91f83fc89a47c3267dff9a4c4ba" // #nosec G101
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO nano_cert_auth_associations (id, sha256, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)`,
|
||||
"uuid-1", sha1, threeDaysAgo, threeDaysAgo,
|
||||
"uuid-2", sha2, threeDaysAgo, threeDaysAgo,
|
||||
// host with duplicate cert, should never happen, but we don't
|
||||
// have constraints in the db.
|
||||
"uuid-3", sha2, threeDaysAgo, threeDaysAgo,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
applyNext(t, db)
|
||||
|
||||
var assoc struct {
|
||||
HostUUID string `db:"id"`
|
||||
SHA256 string `db:"sha256"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
CertNotValidAfter *time.Time `db:"cert_not_valid_after"`
|
||||
RenewCommandUUID *string `db:"renew_command_uuid"`
|
||||
}
|
||||
|
||||
selectStmt := "SELECT id, sha256, created_at, updated_at, cert_not_valid_after, renew_command_uuid FROM nano_cert_auth_associations WHERE id = ?"
|
||||
|
||||
// new values are filled and timestamps preserved
|
||||
err = sqlx.Get(db, &assoc, selectStmt, "uuid-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "uuid-1", assoc.HostUUID)
|
||||
require.Equal(t, sha1, assoc.SHA256)
|
||||
require.Equal(t, threeDaysAgo, assoc.CreatedAt)
|
||||
require.Equal(t, threeDaysAgo, assoc.UpdatedAt)
|
||||
require.Equal(t, "2025-02-20 19:57:24", (*assoc.CertNotValidAfter).Format("2006-01-02 15:04:05"))
|
||||
require.Nil(t, assoc.RenewCommandUUID)
|
||||
|
||||
err = sqlx.Get(db, &assoc, selectStmt, "uuid-2")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "uuid-2", assoc.HostUUID)
|
||||
require.Equal(t, sha2, assoc.SHA256)
|
||||
require.Equal(t, threeDaysAgo, assoc.CreatedAt)
|
||||
require.Equal(t, threeDaysAgo, assoc.UpdatedAt)
|
||||
require.Equal(t, "2025-02-20 19:57:25", (*assoc.CertNotValidAfter).Format("2006-01-02 15:04:05"))
|
||||
require.Nil(t, assoc.RenewCommandUUID)
|
||||
|
||||
err = sqlx.Get(db, &assoc, selectStmt, "uuid-3")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "uuid-3", assoc.HostUUID)
|
||||
require.Equal(t, sha2, assoc.SHA256)
|
||||
require.Equal(t, threeDaysAgo, assoc.CreatedAt)
|
||||
require.Equal(t, threeDaysAgo, assoc.UpdatedAt)
|
||||
require.Equal(t, "2025-02-20 19:57:25", (*assoc.CertNotValidAfter).Format("2006-01-02 15:04:05"))
|
||||
require.Nil(t, assoc.RenewCommandUUID)
|
||||
|
||||
// creating a new association sets NULL as default values
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO nano_cert_auth_associations (id, sha256)
|
||||
VALUES (?, ?)`, "uuid-4", sha1)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = sqlx.Get(db, &assoc, selectStmt, "uuid-4")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "uuid-4", assoc.HostUUID)
|
||||
require.Equal(t, sha1, assoc.SHA256)
|
||||
require.Nil(t, assoc.CertNotValidAfter)
|
||||
require.Nil(t, assoc.RenewCommandUUID)
|
||||
}
|
||||
|
||||
var dummyCert1 = []byte(`-----BEGIN CERTIFICATE-----
|
||||
MIIDgDCCAmigAwIBAgIBAjANBgkqhkiG9w0BAQsFADBBMQkwBwYDVQQGEwAxEDAO
|
||||
BgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1NDRVAgQ0ExEDAOBgNVBAMTB0ZsZWV0
|
||||
RE0wHhcNMjQwMjIxMTk0NzI0WhcNMjUwMjIwMTk1NzI0WjBdMRswGQYDVQQKExJm
|
||||
bGVldC1vcmdhbml6YXRpb24xPjA8BgNVBAMTNWZsZWV0LXRlc3RkZXZpY2UtN0U0
|
||||
RUJENTQtNjVDNy00RkU2LThFODUtOUUyNDEwMTQ1RENEMIIBIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAQ8AMIIBCgKCAQEAtgu75XAA5B2iys8DIZwdf2pdzFk157vyZZTnLI4r
|
||||
7whAtLV556c6hjstyXhOmkut+kfiWHWoKQgbrtBj5LTfXwDbu11FapJvYPiI/GwD
|
||||
vAQ+KbV9JcoGX70vL5Qmh+M2P+Ky//cE/zDc2YvPpEk4lcR+BNMJ1SnpRqZQ7ggC
|
||||
0mw62TWbnOuQM4o+1ykvDpJBJhrLxdsEVNaGZVRb0W/GRLzMZNbkQtcBxhpi0yqy
|
||||
iAScF75A0uy7pRSg1Fkr612qqA2bUcPMY901t264Hn7/YyAorVQS7iEvX9DVQbVu
|
||||
T4GNtU5VaDrFsWBlDjVyj2+KUUU2g4klYJLEbfIjSa62WQIDAQABo2cwZTAOBgNV
|
||||
HQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFK9+cymm
|
||||
EOOnA6EYicjk/OrJQI74MB8GA1UdIwQYMBaAFGmjhvWYNxfl4+HKLj8gWPCbXnqc
|
||||
MA0GCSqGSIb3DQEBCwUAA4IBAQClD0xOhWS7Pqmiz6t0cC91sL2nAHgFtFhSQKNY
|
||||
bQFGb0GIJQe0YVV1fJbDDqgHdaYXz+QwJWKfCui0ixYEPgho4SqdeNWsRgDs5EqU
|
||||
chV6P/+yksXdKiu5f2wmf1T3oqgnrBxTm9bXe2ZQFR77FeeeA1AHUAOCESI3d6QF
|
||||
ClvMWXXA/cutC3Wp/34M540trLGiM914whaf0Pb6Rx8HjldEn/dThWOKZDYK4MSK
|
||||
4W2h3vw2aouSe46i86VtYTaDfTP5H4As+N6NunT7lK6sc3UWeWo7k3dliiywnQ4Y
|
||||
AiCWE3wJMLpNwPaxdxz/grg8MLw9uPvfznZ9K9G/i5IDvI9O
|
||||
-----END CERTIFICATE-----`)
|
||||
|
||||
var dummyCert2 = []byte(`-----BEGIN CERTIFICATE-----
|
||||
MIIDgDCCAmigAwIBAgIBAzANBgkqhkiG9w0BAQsFADBBMQkwBwYDVQQGEwAxEDAO
|
||||
BgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1NDRVAgQ0ExEDAOBgNVBAMTB0ZsZWV0
|
||||
RE0wHhcNMjQwMjIxMTk0NzI1WhcNMjUwMjIwMTk1NzI1WjBdMRswGQYDVQQKExJm
|
||||
bGVldC1vcmdhbml6YXRpb24xPjA8BgNVBAMTNWZsZWV0LXRlc3RkZXZpY2UtMjQw
|
||||
RkI4NEQtQzFBOS00ODhCLUEzNDItQkFBOTI2NTkwOEJBMIIBIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAQ8AMIIBCgKCAQEA0H/BTmCHrLrYHn0CWC+V0qMVvvOjE9fE178DOU8W
|
||||
x/W5FGw9Vm+kYE2Tt/dQVLDYUnEg8u1v6JCN2YErGc3eLjyUPVz28778sVQCTc7s
|
||||
Ax1QTxoRjxss7KDhSArdPyEu2YzbKfefEcVqPymDxQTeTKrscgN9XTIe6uvb6qCM
|
||||
3HHKQJsUb8me8Sat8RyR1q+ahR7vrj9pHCXC/nyeK2l1xmnTgz2++C47zMVzjJ7g
|
||||
VduG3SV440spcd/0TbCjYvu2qe4KcK1TypAbjyo/XOBI75ZV/S8uLmFR9C1XxDvQ
|
||||
1rngNjyHa24LiweOYd3MIVe+g8htsOCOB8S9hWhN8Xn1OwIDAQABo2cwZTAOBgNV
|
||||
HQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFL3wSLk7
|
||||
LWXNnzzNM4ZrIhPEL/0OMB8GA1UdIwQYMBaAFGmjhvWYNxfl4+HKLj8gWPCbXnqc
|
||||
MA0GCSqGSIb3DQEBCwUAA4IBAQBXEbOh4hCbOfnRbtBUtd5s1aNd0N+E11eFJM6k
|
||||
hwgOzHCgGrfG7eh/8QQ+4fYAnpyBEEz863EEqfmPY++MifLI7AI8b82EqxNVT8UK
|
||||
YeFIvbtOwgKiq+YDLIzXPzRzOS6lgGB68nFNRyni4TeTCx5aaKBKfWlDNwOCdI7c
|
||||
F97od8YqLp1wDG5caCKVvzLXbOvMZmdmjztKZoI+/SjPDpVsNKZrixYmijDVhZNf
|
||||
Hd2ktxwNgxBx6TDAbCjwXhim2vPAg7ZoklxLHN4KS2F+ZtKDUbdR2WZyolxJh5QC
|
||||
KuY7qFtlQZQFIcXnSpgXTC6tpG+oldTkz9exA4Zm5eXqTBfU
|
||||
-----END CERTIFICATE-----`)
|
File diff suppressed because one or more lines are too long
@ -518,3 +518,20 @@ type ProfileMatcher interface {
|
||||
PreassignProfile(ctx context.Context, payload MDMApplePreassignProfilePayload) error
|
||||
RetrieveProfiles(ctx context.Context, externalHostIdentifier string) (MDMApplePreassignHostProfiles, error)
|
||||
}
|
||||
|
||||
// SCEPIdentityCertificate represents a certificate issued during MDM
|
||||
// enrollment.
|
||||
type SCEPIdentityCertificate struct {
|
||||
Serial string `db:"serial"`
|
||||
NotValidAfter time.Time `db:"not_valid_after"`
|
||||
CertificatePEM []byte `db:"certificate_pem"`
|
||||
}
|
||||
|
||||
// SCEPIdentityAssociation represents an association between an identity
|
||||
// certificate an a specific host.
|
||||
type SCEPIdentityAssociation struct {
|
||||
HostUUID string `db:"host_uuid"`
|
||||
SHA256 string `db:"sha256"`
|
||||
EnrollReference string `db:"enroll_reference"`
|
||||
RenewCommandUUID string `db:"renew_command_uuid"`
|
||||
}
|
||||
|
@ -777,6 +777,14 @@ type Datastore interface {
|
||||
|
||||
SetDiskEncryptionResetStatus(ctx context.Context, hostID uint, status bool) error
|
||||
|
||||
// GetHostCertAssociationsToExpire retrieves host certificate
|
||||
// associations that are close to expire and don't have a renewal in
|
||||
// progress based on the provided arguments.
|
||||
GetHostCertAssociationsToExpire(ctx context.Context, expiryDays, limit int) ([]SCEPIdentityAssociation, error)
|
||||
|
||||
// SetCommandForPendingSCEPRenewal tracks the command used to renew a scep certificate
|
||||
SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []SCEPIdentityAssociation, cmdUUID string) error
|
||||
|
||||
// UpdateVerificationHostMacOSProfiles updates status of macOS profiles installed on a given
|
||||
// host. The toVerify, toFail, and toRetry slices contain the identifiers of the profiles that
|
||||
// should be verified, failed, and retried, respectively. For each profile in the toRetry slice,
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
@ -14,6 +15,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/logging"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/go-kit/log/level"
|
||||
@ -741,6 +743,21 @@ func GenerateEnrollmentProfileMobileconfig(orgName, fleetURL, scepChallenge, top
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func AddEnrollmentRefToFleetURL(fleetURL, reference string) (string, error) {
|
||||
if reference == "" {
|
||||
return fleetURL, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(fleetURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing configured server URL: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Add(mobileconfig.FleetEnrollReferenceKey, reference)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// ProfileBimap implements bidirectional mapping for profiles, and utility
|
||||
// functions to generate those mappings based on frequently used operations.
|
||||
type ProfileBimap struct {
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
|
||||
"github.com/go-kit/log"
|
||||
@ -133,6 +134,54 @@ func TestDEPService(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddEnrollmentRefToFleetURL(t *testing.T) {
|
||||
const (
|
||||
baseFleetURL = "https://example.com"
|
||||
reference = "enroll-ref"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fleetURL string
|
||||
reference string
|
||||
expectedOutput string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "empty Reference",
|
||||
fleetURL: baseFleetURL,
|
||||
reference: "",
|
||||
expectedOutput: baseFleetURL,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid URL and Reference",
|
||||
fleetURL: baseFleetURL,
|
||||
reference: reference,
|
||||
expectedOutput: baseFleetURL + "?" + mobileconfig.FleetEnrollReferenceKey + "=" + reference,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
fleetURL: "://invalid-url",
|
||||
reference: reference,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output, err := AddEnrollmentRefToFleetURL(tc.fleetURL, tc.reference)
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type notFoundError struct{}
|
||||
|
||||
func (e notFoundError) IsNotFound() bool { return true }
|
||||
|
@ -97,7 +97,7 @@ func New(next service.CheckinAndCommandService, storage storage.CertAuthStore, o
|
||||
return certAuth
|
||||
}
|
||||
|
||||
func hashCert(cert *x509.Certificate) string {
|
||||
func HashCert(cert *x509.Certificate) string {
|
||||
hashed := sha256.Sum256(cert.Raw)
|
||||
b := make([]byte, len(hashed))
|
||||
copy(b, hashed[:])
|
||||
@ -112,7 +112,7 @@ func (s *CertAuth) associateNewEnrollment(r *mdm.Request) error {
|
||||
return err
|
||||
}
|
||||
logger := ctxlog.Logger(r.Context, s.logger)
|
||||
hash := hashCert(r.Certificate)
|
||||
hash := HashCert(r.Certificate)
|
||||
if hasHash, err := s.storage.HasCertHash(r, hash); err != nil {
|
||||
return err
|
||||
} else if hasHash {
|
||||
@ -137,7 +137,7 @@ func (s *CertAuth) associateNewEnrollment(r *mdm.Request) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.storage.AssociateCertHash(r, hash); err != nil {
|
||||
if err := s.storage.AssociateCertHash(r, hash, r.Certificate.NotAfter); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(
|
||||
@ -157,7 +157,7 @@ func (s *CertAuth) validateAssociateExistingEnrollment(r *mdm.Request) error {
|
||||
return err
|
||||
}
|
||||
logger := ctxlog.Logger(r.Context, s.logger)
|
||||
hash := hashCert(r.Certificate)
|
||||
hash := HashCert(r.Certificate)
|
||||
if isAssoc, err := s.storage.IsCertHashAssociated(r, hash); err != nil {
|
||||
return err
|
||||
} else if isAssoc {
|
||||
@ -211,7 +211,7 @@ func (s *CertAuth) validateAssociateExistingEnrollment(r *mdm.Request) error {
|
||||
if s.warnOnly {
|
||||
return nil
|
||||
}
|
||||
if err := s.storage.AssociateCertHash(r, hash); err != nil {
|
||||
if err := s.storage.AssociateCertHash(r, hash, r.Certificate.NotAfter); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(
|
||||
|
@ -1,6 +1,8 @@
|
||||
package allmulti
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
|
||||
)
|
||||
@ -26,9 +28,9 @@ func (ms *MultiAllStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bo
|
||||
return val.(bool), err
|
||||
}
|
||||
|
||||
func (ms *MultiAllStorage) AssociateCertHash(r *mdm.Request, hash string) error {
|
||||
func (ms *MultiAllStorage) AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error {
|
||||
_, err := ms.execStores(r.Context, func(s storage.AllStorage) (interface{}, error) {
|
||||
return nil, s.AssociateCertHash(r, hash)
|
||||
return nil, s.AssociateCertHash(r, hash, certNotValidAfter)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
)
|
||||
@ -52,7 +53,7 @@ func (s *FileStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, e
|
||||
return strings.ToLower(string(b)) == strings.ToLower(hash), nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) AssociateCertHash(r *mdm.Request, hash string) error {
|
||||
func (s *FileStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time) error {
|
||||
f, err := os.OpenFile(
|
||||
path.Join(s.path, CertAuthAssociationsFilename),
|
||||
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
|
||||
|
@ -3,6 +3,7 @@ package mysql
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
)
|
||||
@ -38,14 +39,18 @@ func (s *MySQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *MySQLStorage) AssociateCertHash(r *mdm.Request, hash string) error {
|
||||
func (s *MySQLStorage) AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error {
|
||||
_, err := s.db.ExecContext(
|
||||
r.Context, `
|
||||
INSERT INTO nano_cert_auth_associations (id, sha256) VALUES (?, ?)
|
||||
INSERT INTO nano_cert_auth_associations (id, sha256, cert_not_valid_after) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY
|
||||
UPDATE sha256 = VALUES(sha256);`,
|
||||
UPDATE
|
||||
sha256 = VALUES(sha256),
|
||||
cert_not_valid_after = VALUES(cert_not_valid_after),
|
||||
renew_command_uuid = NULL;`,
|
||||
r.ID,
|
||||
strings.ToLower(hash),
|
||||
certNotValidAfter,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package pgsql
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
)
|
||||
@ -39,7 +40,7 @@ func (s *PgSQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool,
|
||||
}
|
||||
|
||||
// AssociateCertHash "DO NOTHING" on duplicated keys
|
||||
func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string) error {
|
||||
func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time) error {
|
||||
_, err := s.db.ExecContext(
|
||||
r.Context, `
|
||||
INSERT INTO cert_auth_associations (id, sha256)
|
||||
|
@ -5,6 +5,7 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
)
|
||||
@ -62,7 +63,7 @@ type CertAuthStore interface {
|
||||
HasCertHash(r *mdm.Request, hash string) (bool, error)
|
||||
EnrollmentHasCertHash(r *mdm.Request, hash string) (bool, error)
|
||||
IsCertHashAssociated(r *mdm.Request, hash string) (bool, error)
|
||||
AssociateCertHash(r *mdm.Request, hash string) error
|
||||
AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error
|
||||
}
|
||||
|
||||
// StoreMigrator retrieves MDM check-ins
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
@ -47,7 +48,7 @@ type EnrollmentHasCertHashFunc func(r *mdm.Request, hash string) (bool, error)
|
||||
|
||||
type IsCertHashAssociatedFunc func(r *mdm.Request, hash string) (bool, error)
|
||||
|
||||
type AssociateCertHashFunc func(r *mdm.Request, hash string) error
|
||||
type AssociateCertHashFunc func(r *mdm.Request, hash string, certNotValidAfter time.Time) error
|
||||
|
||||
type RetrieveMigrationCheckinsFunc func(p0 context.Context, p1 chan<- interface{}) error
|
||||
|
||||
@ -241,11 +242,11 @@ func (fs *MDMAppleStore) IsCertHashAssociated(r *mdm.Request, hash string) (bool
|
||||
return fs.IsCertHashAssociatedFunc(r, hash)
|
||||
}
|
||||
|
||||
func (fs *MDMAppleStore) AssociateCertHash(r *mdm.Request, hash string) error {
|
||||
func (fs *MDMAppleStore) AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error {
|
||||
fs.mu.Lock()
|
||||
fs.AssociateCertHashFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.AssociateCertHashFunc(r, hash)
|
||||
return fs.AssociateCertHashFunc(r, hash, certNotValidAfter)
|
||||
}
|
||||
|
||||
func (fs *MDMAppleStore) RetrieveMigrationCheckins(p0 context.Context, p1 chan<- interface{}) error {
|
||||
|
@ -544,6 +544,10 @@ type GetHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint) (*fleet
|
||||
|
||||
type SetDiskEncryptionResetStatusFunc func(ctx context.Context, hostID uint, status bool) error
|
||||
|
||||
type GetHostCertAssociationsToExpireFunc func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error)
|
||||
|
||||
type SetCommandForPendingSCEPRenewalFunc func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error
|
||||
|
||||
type UpdateHostMDMProfilesVerificationFunc func(ctx context.Context, host *fleet.Host, toVerify []string, toFail []string, toRetry []string) error
|
||||
|
||||
type GetHostMDMProfilesExpectedForVerificationFunc func(ctx context.Context, host *fleet.Host) (map[string]*fleet.ExpectedMDMProfile, error)
|
||||
@ -1610,6 +1614,12 @@ type DataStore struct {
|
||||
SetDiskEncryptionResetStatusFunc SetDiskEncryptionResetStatusFunc
|
||||
SetDiskEncryptionResetStatusFuncInvoked bool
|
||||
|
||||
GetHostCertAssociationsToExpireFunc GetHostCertAssociationsToExpireFunc
|
||||
GetHostCertAssociationsToExpireFuncInvoked bool
|
||||
|
||||
SetCommandForPendingSCEPRenewalFunc SetCommandForPendingSCEPRenewalFunc
|
||||
SetCommandForPendingSCEPRenewalFuncInvoked bool
|
||||
|
||||
UpdateHostMDMProfilesVerificationFunc UpdateHostMDMProfilesVerificationFunc
|
||||
UpdateHostMDMProfilesVerificationFuncInvoked bool
|
||||
|
||||
@ -3868,6 +3878,20 @@ func (s *DataStore) SetDiskEncryptionResetStatus(ctx context.Context, hostID uin
|
||||
return s.SetDiskEncryptionResetStatusFunc(ctx, hostID, status)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetHostCertAssociationsToExpire(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
s.mu.Lock()
|
||||
s.GetHostCertAssociationsToExpireFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetHostCertAssociationsToExpireFunc(ctx, expiryDays, limit)
|
||||
}
|
||||
|
||||
func (s *DataStore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
|
||||
s.mu.Lock()
|
||||
s.SetCommandForPendingSCEPRenewalFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.SetCommandForPendingSCEPRenewalFunc(ctx, assocs, cmdUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify []string, toFail []string, toRetry []string) error {
|
||||
s.mu.Lock()
|
||||
s.UpdateHostMDMProfilesVerificationFuncInvoked = true
|
||||
|
@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -20,6 +19,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
||||
@ -32,8 +32,8 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
"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"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"github.com/google/uuid"
|
||||
"github.com/groob/plist"
|
||||
"github.com/micromdm/nanodep/godep"
|
||||
@ -1087,16 +1087,9 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
enrollURL := appConfig.ServerSettings.ServerURL
|
||||
if ref != "" {
|
||||
u, err := url.Parse(enrollURL)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "parsing configured server URL")
|
||||
}
|
||||
q := u.Query()
|
||||
q.Add(mobileconfig.FleetEnrollReferenceKey, ref)
|
||||
u.RawQuery = q.Encode()
|
||||
enrollURL = u.String()
|
||||
enrollURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.ServerSettings.ServerURL, ref)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
|
||||
}
|
||||
|
||||
mobileconfig, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
||||
@ -2824,3 +2817,123 @@ func (svc *Service) getConfigAppleBMDefaultTeamID(ctx context.Context, appCfg *f
|
||||
|
||||
return tmID, nil
|
||||
}
|
||||
|
||||
// scepCertRenewalThresholdDays defines the number of days before a SCEP
|
||||
// certificate must be renewed.
|
||||
const scepCertRenewalThresholdDays = 30
|
||||
|
||||
// maxCertsRenewalPerRun specifies the maximum number of certificates to renew
|
||||
// in a single cron run.
|
||||
//
|
||||
// Assuming that the cron runs every hour, we'll enqueue 24,000 renewals per
|
||||
// day, and we have room for 24,000 * scepCertRenewalThresholdDays total
|
||||
// renewals.
|
||||
//
|
||||
// For a default of 30 days as a threshold this gives us room for a fleet of
|
||||
// 720,000 devices expiring at the same time.
|
||||
const maxCertsRenewalPerRun = 100
|
||||
|
||||
func RenewSCEPCertificates(
|
||||
ctx context.Context,
|
||||
logger kitlog.Logger,
|
||||
ds fleet.Datastore,
|
||||
config *config.FleetConfig,
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
) error {
|
||||
if !config.MDM.IsAppleSCEPSet() {
|
||||
logger.Log("inf", "skipping renewal of macOS SCEP certificates as MDM is not fully configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
if commander == nil {
|
||||
logger.Log("inf", "skipping renewal of macOS SCEP certificates as apple_mdm.MDMAppleCommander was not provided")
|
||||
return nil
|
||||
}
|
||||
|
||||
// for each hash, grab the host that uses it as its identity certificate
|
||||
certAssociations, err := ds.GetHostCertAssociationsToExpire(ctx, scepCertRenewalThresholdDays, maxCertsRenewalPerRun)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting host cert associations")
|
||||
}
|
||||
|
||||
appConfig, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting AppConfig")
|
||||
}
|
||||
|
||||
mdmPushCertTopic, err := config.MDM.AppleAPNsTopic()
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting certificate topic")
|
||||
}
|
||||
|
||||
// assocsWithRefs stores hosts that have enrollment references on their
|
||||
// enrollment profiles. This is the case for ADE-enrolled hosts using
|
||||
// SSO to authenticate.
|
||||
assocsWithRefs := []fleet.SCEPIdentityAssociation{}
|
||||
// assocsWithoutRefs stores hosts that don't have an enrollment
|
||||
// reference in their enrollment profile.
|
||||
assocsWithoutRefs := []fleet.SCEPIdentityAssociation{}
|
||||
for _, assoc := range certAssociations {
|
||||
if assoc.EnrollReference != "" {
|
||||
assocsWithRefs = append(assocsWithRefs, assoc)
|
||||
continue
|
||||
}
|
||||
assocsWithoutRefs = append(assocsWithoutRefs, assoc)
|
||||
}
|
||||
|
||||
// send a single command for all the hosts without references.
|
||||
if len(assocsWithoutRefs) > 0 {
|
||||
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
||||
appConfig.OrgInfo.OrgName,
|
||||
appConfig.ServerSettings.ServerURL,
|
||||
config.MDM.AppleSCEPChallenge,
|
||||
mdmPushCertTopic,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts without enroll reference")
|
||||
}
|
||||
|
||||
cmdUUID := uuid.NewString()
|
||||
var uuids []string
|
||||
for _, assoc := range assocsWithoutRefs {
|
||||
uuids = append(uuids, assoc.HostUUID)
|
||||
assoc.RenewCommandUUID = cmdUUID
|
||||
}
|
||||
|
||||
if err := commander.InstallProfile(ctx, uuids, profile, cmdUUID); err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "sending InstallProfile command for hosts %s", assocsWithoutRefs)
|
||||
}
|
||||
|
||||
if err := ds.SetCommandForPendingSCEPRenewal(ctx, assocsWithoutRefs, cmdUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "setting pending command associations")
|
||||
}
|
||||
}
|
||||
|
||||
// send individual commands for each host with a reference
|
||||
for _, assoc := range assocsWithRefs {
|
||||
enrollURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.ServerSettings.ServerURL, assoc.EnrollReference)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
|
||||
}
|
||||
|
||||
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
||||
appConfig.OrgInfo.OrgName,
|
||||
enrollURL,
|
||||
config.MDM.AppleSCEPChallenge,
|
||||
mdmPushCertTopic,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts with enroll reference")
|
||||
}
|
||||
cmdUUID := uuid.NewString()
|
||||
if err := commander.InstallProfile(ctx, []string{assoc.HostUUID}, profile, cmdUUID); err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "sending InstallProfile command for hosts %s", assocsWithRefs)
|
||||
}
|
||||
|
||||
if err := ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{assoc}, cmdUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "setting pending command associations")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -3,17 +3,25 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
@ -23,6 +31,7 @@ import (
|
||||
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/nanomdm/log/stdlogfmt"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
@ -2656,3 +2665,248 @@ func mobileconfigForTestWithContent(outerName, outerIdentifier, innerIdentifier,
|
||||
</plist>
|
||||
`, innerName, innerIdentifier, innerType, outerName, outerIdentifier, uuid.New().String()))
|
||||
}
|
||||
|
||||
func generateCertWithAPNsTopic() ([]byte, []byte, error) {
|
||||
// generate a new private key
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// set up the OID for UID
|
||||
oidUID := asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1}
|
||||
|
||||
// set up a certificate template with the required UID in the Subject
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour)
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
ExtraNames: []pkix.AttributeTypeAndValue{
|
||||
{
|
||||
Type: oidUID,
|
||||
Value: "com.apple.mgmt.Example",
|
||||
},
|
||||
},
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// create a self-signed certificate
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// encode to PEM
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *config.FleetConfig, *mock.MDMAppleStore, *apple_mdm.MDMAppleCommander) {
|
||||
ctx := context.Background()
|
||||
logger := kitlog.NewNopLogger()
|
||||
cfg := config.TestConfig()
|
||||
testCertPEM, testKeyPEM, err := generateCertWithAPNsTopic()
|
||||
require.NoError(t, err)
|
||||
config.SetTestMDMConfig(t, &cfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
|
||||
ds := new(mock.Store)
|
||||
mdmStorage := &mock.MDMAppleStore{}
|
||||
pushFactory, _ := newMockAPNSPushProviderFactory()
|
||||
pusher := nanomdm_pushsvc.New(
|
||||
mdmStorage,
|
||||
mdmStorage,
|
||||
pushFactory,
|
||||
stdlogfmt.New(),
|
||||
)
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
|
||||
|
||||
return ctx, logger, ds, &cfg, mdmStorage, commander
|
||||
}
|
||||
|
||||
func TestRenewSCEPCertificatesMDMConfigNotSet(t *testing.T) {
|
||||
ctx, logger, ds, cfg, _, commander := setupTest(t)
|
||||
cfg.MDM = config.MDMConfig{} // ensure MDM is not fully configured
|
||||
err := RenewSCEPCertificates(ctx, logger, ds, cfg, commander)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRenewSCEPCertificatesCommanderNil(t *testing.T) {
|
||||
ctx, logger, ds, cfg, _, _ := setupTest(t)
|
||||
err := RenewSCEPCertificates(ctx, logger, ds, cfg, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRenewSCEPCertificatesBranches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
customExpectations func(*testing.T, *mock.Store, *config.FleetConfig, *mock.MDMAppleStore, *apple_mdm.MDMAppleCommander)
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "No Certs to Renew",
|
||||
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
|
||||
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "GetHostCertAssociationsToExpire Errors",
|
||||
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
|
||||
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
return nil, errors.New("database error")
|
||||
}
|
||||
},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "AppConfig Errors",
|
||||
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return nil, errors.New("app config error")
|
||||
}
|
||||
},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "InstallProfile for hostsWithoutRefs",
|
||||
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
|
||||
var wantCommandUUID string
|
||||
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: ""}}, nil
|
||||
}
|
||||
|
||||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
|
||||
wantCommandUUID = cmd.CommandUUID
|
||||
return map[string]error{}, nil
|
||||
}
|
||||
ds.SetCommandForPendingSCEPRenewalFunc = func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
|
||||
require.Len(t, assocs, 1)
|
||||
require.Equal(t, "hostUUID1", assocs[0].HostUUID)
|
||||
require.Equal(t, cmdUUID, wantCommandUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.True(t, appleStore.EnqueueCommandFuncInvoked)
|
||||
require.True(t, ds.SetCommandForPendingSCEPRenewalFuncInvoked)
|
||||
})
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "InstallProfile for hostsWithoutRefs fails",
|
||||
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
|
||||
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: ""}}, nil
|
||||
}
|
||||
|
||||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
|
||||
return map[string]error{}, errors.New("foo")
|
||||
}
|
||||
},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "InstallProfile for hostsWithRefs",
|
||||
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
|
||||
var wantCommandUUID string
|
||||
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID2", EnrollReference: "ref1"}}, nil
|
||||
}
|
||||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
|
||||
wantCommandUUID = cmd.CommandUUID
|
||||
return map[string]error{}, nil
|
||||
}
|
||||
ds.SetCommandForPendingSCEPRenewalFunc = func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
|
||||
require.Len(t, assocs, 1)
|
||||
require.Equal(t, "hostUUID2", assocs[0].HostUUID)
|
||||
require.Equal(t, cmdUUID, wantCommandUUID)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
require.True(t, appleStore.EnqueueCommandFuncInvoked)
|
||||
require.True(t, ds.SetCommandForPendingSCEPRenewalFuncInvoked)
|
||||
})
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "InstallProfile for hostsWithRefs fails",
|
||||
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
|
||||
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: "ref1"}}, nil
|
||||
}
|
||||
|
||||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
|
||||
return map[string]error{}, errors.New("foo")
|
||||
}
|
||||
},
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, logger, ds, cfg, appleStorage, commander := setupTest(t)
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
appCfg := &fleet.AppConfig{}
|
||||
appCfg.OrgInfo.OrgName = "fl33t"
|
||||
appCfg.ServerSettings.ServerURL = "https://foo.example.com"
|
||||
return appCfg, nil
|
||||
}
|
||||
|
||||
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
|
||||
return []fleet.SCEPIdentityAssociation{}, nil
|
||||
}
|
||||
|
||||
ds.SetCommandForPendingSCEPRenewalFunc = func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
appleStorage.RetrievePushInfoFunc = func(ctx context.Context, targets []string) (map[string]*mdm.Push, error) {
|
||||
pushes := make(map[string]*mdm.Push, len(targets))
|
||||
for _, uuid := range targets {
|
||||
pushes[uuid] = &mdm.Push{
|
||||
PushMagic: "magic" + uuid,
|
||||
Token: []byte("token" + uuid),
|
||||
Topic: "topic" + uuid,
|
||||
}
|
||||
}
|
||||
|
||||
return pushes, nil
|
||||
}
|
||||
|
||||
appleStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
|
||||
cert, err := tls.LoadX509KeyPair("./testdata/server.pem", "./testdata/server.key")
|
||||
return &cert, "", err
|
||||
}
|
||||
|
||||
tc.customExpectations(t, ds, cfg, appleStorage, commander)
|
||||
|
||||
err := RenewSCEPCertificates(ctx, logger, ds, cfg, commander)
|
||||
if tc.expectedError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +85,7 @@ type integrationMDMTestSuite struct {
|
||||
onDEPScheduleDone func() // function called when depSchedule.Trigger() job completed
|
||||
mdmStorage *mysql.NanoMDMStorage
|
||||
worker *worker.Worker
|
||||
mdmCommander *apple_mdm.MDMAppleCommander
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
@ -200,6 +201,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
s.depSchedule = depSchedule
|
||||
s.profileSchedule = profileSchedule
|
||||
s.mdmStorage = mdmStorage
|
||||
s.mdmCommander = mdmCommander
|
||||
|
||||
macosJob := &worker.MacosSetupAssistant{
|
||||
Datastore: s.ds,
|
||||
@ -7092,8 +7094,13 @@ func (s *integrationMDMTestSuite) downloadAndVerifyEnrollmentProfile(path string
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(body), headerLen)
|
||||
|
||||
return s.verifyEnrollmentProfile(body, "")
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) verifyEnrollmentProfile(rawProfile []byte, enrollmentRef string) *enrollmentProfile {
|
||||
t := s.T()
|
||||
var profile enrollmentProfile
|
||||
require.NoError(t, plist.Unmarshal(body, &profile))
|
||||
require.NoError(t, plist.Unmarshal(rawProfile, &profile))
|
||||
|
||||
for _, p := range profile.PayloadContent {
|
||||
switch p.PayloadType {
|
||||
@ -7101,8 +7108,10 @@ func (s *integrationMDMTestSuite) downloadAndVerifyEnrollmentProfile(path string
|
||||
require.Equal(t, s.getConfig().ServerSettings.ServerURL+apple_mdm.SCEPPath, p.PayloadContent.URL)
|
||||
require.Equal(t, s.fleetCfg.MDM.AppleSCEPChallenge, p.PayloadContent.Challenge)
|
||||
case "com.apple.mdm":
|
||||
// Use Contains as the url may have query params
|
||||
require.Contains(t, p.ServerURL, s.getConfig().ServerSettings.ServerURL+apple_mdm.MDMPath)
|
||||
if enrollmentRef != "" {
|
||||
require.Contains(t, p.ServerURL, enrollmentRef)
|
||||
}
|
||||
default:
|
||||
require.Failf(t, "unrecognized payload type in enrollment profile: %s", p.PayloadType)
|
||||
}
|
||||
@ -11323,3 +11332,112 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
|
||||
func (s *integrationMDMTestSuite) TestGetManualEnrollmentProfile() {
|
||||
s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/mdm/manual_enrollment_profile")
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestSCEPCertExpiration() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
// ensure there's a token for automatic enrollments
|
||||
s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
|
||||
}))
|
||||
s.runDEPSchedule()
|
||||
|
||||
// add a device that's manually enrolled
|
||||
desktopToken := uuid.New().String()
|
||||
manualHost := createOrbitEnrolledHost(t, "darwin", "h1", s.ds)
|
||||
err := s.ds.SetOrUpdateDeviceAuthToken(context.Background(), manualHost.ID, desktopToken)
|
||||
require.NoError(t, err)
|
||||
manualEnrolledDevice := mdmtest.NewTestMDMClientAppleDesktopManual(s.server.URL, desktopToken)
|
||||
manualEnrolledDevice.UUID = manualHost.UUID
|
||||
err = manualEnrolledDevice.Enroll()
|
||||
require.NoError(t, err)
|
||||
|
||||
// add a device that's automatically enrolled
|
||||
automaticHost := createOrbitEnrolledHost(t, "darwin", "h2", s.ds)
|
||||
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
||||
automaticEnrolledDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||||
automaticEnrolledDevice.UUID = automaticHost.UUID
|
||||
automaticEnrolledDevice.SerialNumber = automaticHost.HardwareSerial
|
||||
err = automaticEnrolledDevice.Enroll()
|
||||
require.NoError(t, err)
|
||||
|
||||
// add a device that's automatically enrolled with a server ref
|
||||
automaticHostWithRef := createOrbitEnrolledHost(t, "darwin", "h3", s.ds)
|
||||
automaticEnrolledDeviceWithRef := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
|
||||
automaticEnrolledDeviceWithRef.UUID = automaticHostWithRef.UUID
|
||||
automaticEnrolledDeviceWithRef.SerialNumber = automaticHostWithRef.HardwareSerial
|
||||
err = automaticEnrolledDeviceWithRef.Enroll()
|
||||
require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, automaticHostWithRef.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "foo"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, key, err := generateCertWithAPNsTopic()
|
||||
require.NoError(t, err)
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(s.T(), &fleetCfg, cert, key, testBMToken, "")
|
||||
logger := kitlog.NewJSONLogger(os.Stdout)
|
||||
|
||||
// run without expired certs, no command enqueued
|
||||
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
|
||||
require.NoError(t, err)
|
||||
cmd, err := manualEnrolledDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, cmd)
|
||||
|
||||
cmd, err = automaticEnrolledDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, cmd)
|
||||
|
||||
cmd, err = automaticEnrolledDeviceWithRef.Idle()
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, cmd)
|
||||
|
||||
// expire all the certs we just created
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
UPDATE nano_cert_auth_associations
|
||||
SET cert_not_valid_after = DATE_SUB(CURDATE(), INTERVAL 1 YEAR)
|
||||
WHERE id IN (?, ?, ?)
|
||||
`, manualHost.UUID, automaticHost.UUID, automaticHostWithRef.UUID)
|
||||
return err
|
||||
})
|
||||
|
||||
// generate a new config here so we can manipulate the certs.
|
||||
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
|
||||
require.NoError(t, err)
|
||||
|
||||
checkRenewCertCommand := func(device *mdmtest.TestAppleMDMClient, enrollRef string) {
|
||||
var renewCmd *micromdm.CommandPayload
|
||||
cmd, err := device.Idle()
|
||||
require.NoError(t, err)
|
||||
for cmd != nil {
|
||||
if cmd.Command.RequestType == "InstallProfile" {
|
||||
renewCmd = cmd
|
||||
}
|
||||
cmd, err = device.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NotNil(t, renewCmd)
|
||||
s.verifyEnrollmentProfile(renewCmd.Command.InstallProfile.Payload, enrollRef)
|
||||
}
|
||||
|
||||
checkRenewCertCommand(manualEnrolledDevice, "")
|
||||
checkRenewCertCommand(automaticEnrolledDevice, "")
|
||||
checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo")
|
||||
|
||||
// another cron run shouldn't enqueue more commands
|
||||
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, err = manualEnrolledDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, cmd)
|
||||
|
||||
cmd, err = automaticEnrolledDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, cmd)
|
||||
|
||||
cmd, err = automaticEnrolledDeviceWithRef.Idle()
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, cmd)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user