DigiCert backend (#26914)
For #26609 This PR includes - ability to get a DigiCert certificate to a macOS device - integration test for the above - some validation This PR does not include the following. They will be included in subsequent PRs: - support for User Principal Name in certificate - support for $FLEET_VAR_HOST_HARDWARE_SERIAL - saving certificate expiration date - not resending DigiCert profile after failure # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Added/updated automated tests - [x] A detailed QA plan exists on the associated ticket (if it isn't there, work with the product group's QA engineer to add it) - [x] Manual QA for all new/changed functionality
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Added integration with DigiCert Trust Lifecycle Manager. Fleet admins can now deploy DigiCert certificates to their macOS devices via configuration profiles.
|
||||
@@ -2,17 +2,24 @@ package digicert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/go-json-experiment/json"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// REST client for https://one.digicert.com/mpki/docs/swagger-ui/index.html
|
||||
@@ -36,14 +43,7 @@ func WithTimeout(t time.Duration) Opt {
|
||||
|
||||
func VerifyProfileID(ctx context.Context, logger kitlog.Logger, config fleet.DigiCertIntegration, opts ...Opt) error {
|
||||
|
||||
o := integrationOpts{
|
||||
timeout: defaultTimeout,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
client := fleethttp.NewClient(fleethttp.WithTimeout(o.timeout))
|
||||
client := fleethttp.NewClient(fleethttp.WithTimeout(populateOpts(opts).timeout))
|
||||
|
||||
config.URL = strings.TrimRight(config.URL, "/")
|
||||
req, err := http.NewRequest("GET", config.URL+"/mpki/api/v2/profile/"+url.PathEscape(config.ProfileID), nil)
|
||||
@@ -73,6 +73,152 @@ func VerifyProfileID(ctx context.Context, logger kitlog.Logger, config fleet.Dig
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "unmarshaling DigiCert response")
|
||||
}
|
||||
if p.Status != "Active" {
|
||||
return ctxerr.Errorf(ctx, "DigiCert profile status is not Active: %s", p.Status)
|
||||
}
|
||||
level.Debug(logger).Log("msg", "DigiCert profile verified", "id", p.ID, "name", p.Name, "status", p.Status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func populateOpts(opts []Opt) integrationOpts {
|
||||
o := integrationOpts{
|
||||
timeout: defaultTimeout,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func GetCertificate(ctx context.Context, logger kitlog.Logger, config fleet.DigiCertIntegration, opts ...Opt) (pfxData []byte,
|
||||
password string, err error) {
|
||||
client := fleethttp.NewClient(fleethttp.WithTimeout(populateOpts(opts).timeout))
|
||||
|
||||
// Generate a CSR (Certificate Signing Request).
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "generating RSA private key")
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: config.CertificateCommonName,
|
||||
},
|
||||
}
|
||||
|
||||
// TODO(#26609): Add support for User Principal Name
|
||||
|
||||
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privateKey)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "creating CSR")
|
||||
}
|
||||
|
||||
csr := strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
})))
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"profile": map[string]string{
|
||||
"id": config.ProfileID,
|
||||
},
|
||||
"seat": map[string]string{
|
||||
"seat_id": config.CertificateSeatID,
|
||||
},
|
||||
"delivery_format": "x509",
|
||||
"attributes": map[string]interface{}{
|
||||
"subject": map[string]string{
|
||||
"common_name": config.CertificateCommonName,
|
||||
},
|
||||
},
|
||||
"csr": csr,
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "marshaling request body")
|
||||
}
|
||||
|
||||
config.URL = strings.TrimRight(config.URL, "/")
|
||||
req, err := http.NewRequest("POST", config.URL+"/mpki/api/v1/certificate", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "creating DigiCert POST request")
|
||||
}
|
||||
|
||||
req.Header.Set("X-API-key", config.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "sending DigiCert POST request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
// Try to see if errors are present in body
|
||||
type errorResponse struct {
|
||||
Errors []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
var errResp errorResponse
|
||||
err = json.UnmarshalRead(resp.Body, &errResp)
|
||||
if err != nil || len(errResp.Errors) == 0 {
|
||||
return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert status code for POST request: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
combinedErrorMessages := make([]string, len(errResp.Errors))
|
||||
for i, e := range errResp.Errors {
|
||||
combinedErrorMessages[i] = e.Message
|
||||
}
|
||||
return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert status code for POST request: %d, errors: %s", resp.StatusCode,
|
||||
strings.Join(combinedErrorMessages, "; "))
|
||||
}
|
||||
|
||||
type certificateResponse struct {
|
||||
SerialNumber string `json:"serial_number"`
|
||||
DeliveryFormat string `json:"delivery_format"`
|
||||
Certificate string `json:"certificate"`
|
||||
}
|
||||
|
||||
var certResp certificateResponse
|
||||
err = json.UnmarshalRead(resp.Body, &certResp)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "unmarshaling DigiCert POST response")
|
||||
}
|
||||
|
||||
if certResp.DeliveryFormat != "x509" {
|
||||
return nil, "", ctxerr.Errorf(ctx, "unexpected DigiCert delivery format: %s", certResp.DeliveryFormat)
|
||||
}
|
||||
|
||||
if len(certResp.Certificate) == 0 {
|
||||
return nil, "", ctxerr.Errorf(ctx, "did not receive DigiCert certificate")
|
||||
}
|
||||
|
||||
level.Debug(logger).Log("msg", "DigiCert certificate created", "serial_number", certResp.SerialNumber)
|
||||
|
||||
// Decode the certificate from PEM format
|
||||
certBlock, _ := pem.Decode([]byte(certResp.Certificate))
|
||||
if certBlock == nil {
|
||||
return nil, "", ctxerr.Errorf(ctx, "failed to decode certificate PEM block")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "parsing certificate from PEM")
|
||||
}
|
||||
|
||||
// Encode the private key and certificate into PKCS12
|
||||
password, err = server.GenerateRandomText(10)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "generating password for PKCS12 bundle")
|
||||
}
|
||||
pkcs12Data, err := pkcs12.Legacy.Encode(privateKey, cert, nil, password)
|
||||
if err != nil {
|
||||
return nil, "", ctxerr.Wrap(ctx, err, "creating PKCS12 bundle")
|
||||
}
|
||||
|
||||
return pkcs12Data, password, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -73,6 +75,33 @@ func (ds *Datastore) saveCAConfigAssets(ctx context.Context, tx sqlx.ExtContext,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetCAConfigAsset(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) {
|
||||
stmt := `
|
||||
SELECT
|
||||
name, type, value
|
||||
FROM
|
||||
ca_config_assets
|
||||
WHERE
|
||||
name = ? AND type = ?
|
||||
`
|
||||
|
||||
var asset fleet.CAConfigAsset
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &asset, stmt, name, assetType); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, notFound("CAConfigAsset").WithName(name)
|
||||
}
|
||||
return nil, ctxerr.Wrapf(ctx, err, "get CA config asset %s", name)
|
||||
}
|
||||
|
||||
decryptedVal, err := decrypt(asset.Value, ds.serverPrivateKey)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "decrypting CA config asset %s", asset.Name)
|
||||
}
|
||||
asset.Value = decryptedVal
|
||||
|
||||
return &asset, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) DeleteCAConfigAssets(ctx context.Context, names []string) error {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestCAConfigAssets(t *testing.T) {
|
||||
{"GetAllCAConfigAssets", testGetAllCAConfigAssets},
|
||||
{"SaveCAConfigAssets", testSaveCAConfigAssets},
|
||||
{"DeleteCAConfigAssets", testDeleteCAConfigAssets},
|
||||
{"GetCAConfigAsset", testGetCAConfigAsset},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -154,3 +155,43 @@ func testDeleteCAConfigAssets(t *testing.T, ds *Datastore) {
|
||||
err = ds.DeleteCAConfigAssets(ctx, []string{"non-existent-asset"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func testGetCAConfigAsset(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with non-existent asset - should return not found error
|
||||
asset, err := ds.GetCAConfigAsset(ctx, "non-existent-asset", fleet.CAConfigDigiCert)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, asset)
|
||||
assert.True(t, fleet.IsNotFound(err))
|
||||
|
||||
// Insert some test assets
|
||||
testAssets := []fleet.CAConfigAsset{
|
||||
{Name: "asset1", Type: fleet.CAConfigDigiCert, Value: []byte("value1")},
|
||||
{Name: "asset2", Type: fleet.CAConfigCustomSCEPProxy, Value: []byte("value2")},
|
||||
}
|
||||
err = ds.SaveCAConfigAssets(ctx, testAssets)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test retrieving an existing asset by name and type
|
||||
asset, err = ds.GetCAConfigAsset(ctx, "asset1", fleet.CAConfigDigiCert)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, asset)
|
||||
assert.Equal(t, "asset1", asset.Name)
|
||||
assert.Equal(t, fleet.CAConfigDigiCert, asset.Type)
|
||||
assert.Equal(t, []byte("value1"), asset.Value)
|
||||
|
||||
// Test retrieving another existing asset
|
||||
asset, err = ds.GetCAConfigAsset(ctx, "asset2", fleet.CAConfigCustomSCEPProxy)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, asset)
|
||||
assert.Equal(t, "asset2", asset.Name)
|
||||
assert.Equal(t, fleet.CAConfigCustomSCEPProxy, asset.Type)
|
||||
assert.Equal(t, []byte("value2"), asset.Value)
|
||||
|
||||
// Test retrieving an asset with a matching name but different type
|
||||
asset, err = ds.GetCAConfigAsset(ctx, "asset1", fleet.CAConfigCustomSCEPProxy)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, asset)
|
||||
assert.True(t, fleet.IsNotFound(err))
|
||||
}
|
||||
|
||||
@@ -1448,6 +1448,7 @@ type Datastore interface {
|
||||
|
||||
// GetAllCAConfigAssets returns the config assets for DigiCert and custom SCEP CAs.
|
||||
GetAllCAConfigAssets(ctx context.Context) (map[string]CAConfigAsset, error)
|
||||
GetCAConfigAsset(ctx context.Context, name string, assetType CAConfigAssetType) (*CAConfigAsset, error)
|
||||
SaveCAConfigAssets(ctx context.Context, assets []CAConfigAsset) error
|
||||
DeleteCAConfigAssets(ctx context.Context, names []string) error
|
||||
|
||||
|
||||
@@ -109,12 +109,17 @@ func getSignedProfileData(mc Mobileconfig) (Mobileconfig, error) {
|
||||
// Adapted from https://github.com/micromdm/micromdm/blob/main/platform/profile/profile.go
|
||||
func (mc Mobileconfig) ParseConfigProfile() (*Parsed, error) {
|
||||
mcBytes := mc
|
||||
// Remove Fleet variables expected in <data> section.
|
||||
mcBytes = mdm.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte(""))
|
||||
if mc.isSignedProfile() {
|
||||
profileData, err := getSignedProfileData(mc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcBytes = profileData
|
||||
if mdm.ProfileVariableRegex.Match(mcBytes) {
|
||||
return nil, errors.New("a signed profile cannot contain Fleet variables ($FLEET_VAR_*)")
|
||||
}
|
||||
}
|
||||
var p Parsed
|
||||
if _, err := plist.Unmarshal(mcBytes, &p); err != nil {
|
||||
@@ -145,12 +150,17 @@ type payloadSummary struct {
|
||||
// See also https://developer.apple.com/documentation/devicemanagement/toplevel
|
||||
func (mc Mobileconfig) payloadSummary() ([]payloadSummary, error) {
|
||||
mcBytes := mc
|
||||
// Remove Fleet variables expected in <data> section.
|
||||
mcBytes = mdm.ProfileDataVariableRegex.ReplaceAll(mcBytes, []byte(""))
|
||||
if mc.isSignedProfile() {
|
||||
profileData, err := getSignedProfileData(mc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcBytes = profileData
|
||||
if mdm.ProfileVariableRegex.Match(mcBytes) {
|
||||
return nil, errors.New("a signed profile cannot contain Fleet variables ($FLEET_VAR_*)")
|
||||
}
|
||||
}
|
||||
|
||||
// unmarshal the values we need from the top-level object
|
||||
|
||||
@@ -10,10 +10,16 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
|
||||
"github.com/smallstep/pkcs7"
|
||||
)
|
||||
|
||||
var ProfileVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_(?P<name1>\w+))|(\${FLEET_VAR_(?P<name2>\w+)})`)
|
||||
|
||||
// ProfileDataVariableRegex matches variables present in <data> section of Apple profile, which may cause validation issues.
|
||||
var ProfileDataVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_DIGICERT_DATA_(?P<name1>\w+))|(\${FLEET_VAR_DIGICERT_DATA_(?P<name2>\w+)})`)
|
||||
|
||||
// MaxProfileRetries is the maximum times an install profile command may be
|
||||
// retried, after which marked as failed and no further attempts will be made
|
||||
// to install the profile.
|
||||
|
||||
@@ -958,6 +958,8 @@ type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConf
|
||||
|
||||
type GetAllCAConfigAssetsFunc func(ctx context.Context) (map[string]fleet.CAConfigAsset, error)
|
||||
|
||||
type GetCAConfigAssetFunc func(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error)
|
||||
|
||||
type SaveCAConfigAssetsFunc func(ctx context.Context, assets []fleet.CAConfigAsset) error
|
||||
|
||||
type DeleteCAConfigAssetsFunc func(ctx context.Context, names []string) error
|
||||
@@ -2663,6 +2665,9 @@ type DataStore struct {
|
||||
GetAllCAConfigAssetsFunc GetAllCAConfigAssetsFunc
|
||||
GetAllCAConfigAssetsFuncInvoked bool
|
||||
|
||||
GetCAConfigAssetFunc GetCAConfigAssetFunc
|
||||
GetCAConfigAssetFuncInvoked bool
|
||||
|
||||
SaveCAConfigAssetsFunc SaveCAConfigAssetsFunc
|
||||
SaveCAConfigAssetsFuncInvoked bool
|
||||
|
||||
@@ -6392,6 +6397,13 @@ func (s *DataStore) GetAllCAConfigAssets(ctx context.Context) (map[string]fleet.
|
||||
return s.GetAllCAConfigAssetsFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetCAConfigAsset(ctx context.Context, name string, assetType fleet.CAConfigAssetType) (*fleet.CAConfigAsset, error) {
|
||||
s.mu.Lock()
|
||||
s.GetCAConfigAssetFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetCAConfigAssetFunc(ctx, name, assetType)
|
||||
}
|
||||
|
||||
func (s *DataStore) SaveCAConfigAssets(ctx context.Context, assets []fleet.CAConfigAsset) error {
|
||||
s.mu.Lock()
|
||||
s.SaveCAConfigAssetsFuncInvoked = true
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
|
||||
@@ -1044,7 +1045,8 @@ func (svc *Service) processAppConfigCAs(ctx context.Context, newAppConfig *fleet
|
||||
additionalDigiCertValidationNeeded = true
|
||||
for _, ca := range newAppConfig.Integrations.DigiCert.Value {
|
||||
ca.Name = fleet.Preprocess(ca.Name)
|
||||
if !validateCAName(ca.Name, "digicert", allCANames, invalid) {
|
||||
if !validateCAName(ca.Name, "digicert", allCANames, invalid) ||
|
||||
!validateCACN(ca.CertificateCommonName, invalid) || !validateSeatID(ca.CertificateSeatID, invalid) {
|
||||
additionalDigiCertValidationNeeded = false
|
||||
continue
|
||||
}
|
||||
@@ -1230,6 +1232,22 @@ func validateCAName(name string, caType string, allCANames map[string]struct{},
|
||||
return true
|
||||
}
|
||||
|
||||
func validateCACN(cn string, invalid *fleet.InvalidArgumentError) bool {
|
||||
if len(strings.TrimSpace(cn)) == 0 {
|
||||
invalid.Append("integrations.digicert.certificate_common_name", "CA Common Name (CN) cannot be empty")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateSeatID(seatID string, invalid *fleet.InvalidArgumentError) bool {
|
||||
if len(strings.TrimSpace(seatID)) == 0 {
|
||||
invalid.Append("integrations.digicert.certificate_seat_id", "CA Seat ID cannot be empty")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type appConfigCAStatus struct {
|
||||
ndes caStatusType
|
||||
digicert map[string]caStatusType
|
||||
|
||||
@@ -1939,6 +1939,22 @@ func TestAppConfigCAs(t *testing.T) {
|
||||
checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.api_token", "DigiCert API token must be set")
|
||||
})
|
||||
|
||||
t.Run("digicert common name not set", func(t *testing.T) {
|
||||
mt := setUp()
|
||||
mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateCommonName = "\n\t"
|
||||
status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid)
|
||||
require.NoError(t, err)
|
||||
checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.certificate_common_name", "Common Name (CN) cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("digicert seat id not set", func(t *testing.T) {
|
||||
mt := setUp()
|
||||
mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateSeatID = "\t\n"
|
||||
status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid)
|
||||
require.NoError(t, err)
|
||||
checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.certificate_seat_id", "Seat ID cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("digicert happy path -- add one", func(t *testing.T) {
|
||||
mt := setUp()
|
||||
status, err := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid)
|
||||
@@ -2011,7 +2027,7 @@ func TestAppConfigCAs(t *testing.T) {
|
||||
URL: mockDigiCertServer.URL,
|
||||
APIToken: "api_token",
|
||||
ProfileID: "profile_id",
|
||||
CertificateCommonName: "",
|
||||
CertificateCommonName: "other_cn",
|
||||
CertificateUserPrincipalNames: nil,
|
||||
CertificateSeatID: "seat_id",
|
||||
},
|
||||
@@ -2044,7 +2060,7 @@ func TestAppConfigCAs(t *testing.T) {
|
||||
URL: mockDigiCertServer.URL,
|
||||
APIToken: "api_token",
|
||||
ProfileID: "profile_id",
|
||||
CertificateCommonName: "",
|
||||
CertificateCommonName: "other_cn",
|
||||
CertificateUserPrincipalNames: nil,
|
||||
CertificateSeatID: "seat_id",
|
||||
},
|
||||
|
||||
+208
-72
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/docker/go-units"
|
||||
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
|
||||
"github.com/fleetdm/fleet/v4/pkg/file"
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
@@ -59,9 +60,12 @@ const (
|
||||
// FleetVarNDESSCEPChallenge and other variables are used as $FLEET_VAR_<VARIABLE_NAME>.
|
||||
// For example: $FLEET_VAR_NDES_SCEP_CHALLENGE
|
||||
// Currently, we assume the variables are fully unique and not substrings of each other.
|
||||
FleetVarNDESSCEPChallenge = "NDES_SCEP_CHALLENGE"
|
||||
FleetVarNDESSCEPProxyURL = "NDES_SCEP_PROXY_URL"
|
||||
FleetVarHostEndUserEmailIDP = "HOST_END_USER_EMAIL_IDP"
|
||||
FleetVarNDESSCEPChallenge = "NDES_SCEP_CHALLENGE"
|
||||
FleetVarNDESSCEPProxyURL = "NDES_SCEP_PROXY_URL"
|
||||
FleetVarHostEndUserEmailIDP = "HOST_END_USER_EMAIL_IDP"
|
||||
FleetVarHostHardwareSerial = "HOST_HARDWARE_SERIAL"
|
||||
FleetVarDigiCertDataPrefix = "DIGICERT_DATA_"
|
||||
FleetVarDigiCertPasswordPrefix = "DIGICERT_PASSWORD_" // nolint:gosec // G101: Potential hardcoded credentials
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -69,14 +73,18 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
profileVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_(?P<name1>\w+))|(\${FLEET_VAR_(?P<name2>\w+)})`)
|
||||
fleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPChallenge,
|
||||
FleetVarNDESSCEPChallenge))
|
||||
fleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPProxyURL,
|
||||
FleetVarNDESSCEPProxyURL))
|
||||
fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarHostEndUserEmailIDP,
|
||||
FleetVarHostEndUserEmailIDP))
|
||||
fleetVarsSupportedInConfigProfiles = []string{FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarHostEndUserEmailIDP}
|
||||
fleetVarDigiCertData = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s\w+)|(\${FLEET_VAR_%s\w+})`, FleetVarDigiCertDataPrefix,
|
||||
FleetVarDigiCertDataPrefix))
|
||||
fleetVarDigiCertPassword = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s\w+)|(\${FLEET_VAR_%s\w+})`, FleetVarDigiCertPasswordPrefix,
|
||||
FleetVarDigiCertPasswordPrefix))
|
||||
fleetVarsSupportedInConfigProfiles = []string{FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarHostEndUserEmailIDP,
|
||||
FleetVarHostHardwareSerial}
|
||||
)
|
||||
|
||||
type hostProfileUUID struct {
|
||||
@@ -401,7 +409,11 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
|
||||
if err := cp.ValidateUserProvided(); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
|
||||
}
|
||||
err = validateConfigProfileFleetVariables(string(cp.Mobileconfig))
|
||||
appConfig, err := svc.ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
err = validateConfigProfileFleetVariables(appConfig, string(cp.Mobileconfig))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "validating fleet variables")
|
||||
}
|
||||
@@ -465,12 +477,32 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
|
||||
return newCP, nil
|
||||
}
|
||||
|
||||
func validateConfigProfileFleetVariables(contents string) error {
|
||||
func validateConfigProfileFleetVariables(appConfig *fleet.AppConfig, contents string) error {
|
||||
fleetVars := findFleetVariables(contents)
|
||||
for k := range fleetVars {
|
||||
if !slices.Contains(fleetVarsSupportedInConfigProfiles, k) {
|
||||
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles",
|
||||
k)}
|
||||
found := false
|
||||
switch {
|
||||
case strings.HasPrefix(k, FleetVarDigiCertDataPrefix):
|
||||
caName := strings.TrimPrefix(k, FleetVarDigiCertDataPrefix)
|
||||
for _, ca := range appConfig.Integrations.DigiCert.Value {
|
||||
if ca.Name == caName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(k, FleetVarDigiCertPasswordPrefix):
|
||||
caName := strings.TrimPrefix(k, FleetVarDigiCertPasswordPrefix)
|
||||
for _, ca := range appConfig.Integrations.DigiCert.Value {
|
||||
if ca.Name == caName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles", k)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -3614,7 +3646,7 @@ func ReconcileAppleProfiles(
|
||||
}
|
||||
|
||||
// Insert variables into profile contents of install targets. Variables may be host-specific.
|
||||
err = preprocessProfileContents(ctx, appConfig, ds, installTargets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appConfig, ds, logger, installTargets, profileContents, hostProfilesToInstallMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -3733,6 +3765,7 @@ func preprocessProfileContents(
|
||||
ctx context.Context,
|
||||
appConfig *fleet.AppConfig,
|
||||
ds fleet.Datastore,
|
||||
logger kitlog.Logger,
|
||||
targets map[string]*cmdTarget,
|
||||
profileContents map[string]mobileconfig.Mobileconfig,
|
||||
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
||||
@@ -3740,45 +3773,9 @@ func preprocessProfileContents(
|
||||
// This method replaces Fleet variables ($FLEET_VAR_<NAME>) in the profile contents, generating a unique profile for each host.
|
||||
// For a 2KB profile and 30K hosts, this method may generate ~60MB of profile data in memory.
|
||||
|
||||
isNDESSCEPConfigured := func(profUUID string, target *cmdTarget) (bool, error) {
|
||||
if !license.IsPremium(ctx) {
|
||||
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
|
||||
for _, hostUUID := range target.hostUUIDs {
|
||||
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
|
||||
if !ok { // Should never happen
|
||||
continue
|
||||
}
|
||||
profile.Status = &fleet.MDMDeliveryFailed
|
||||
profile.Detail = "NDES SCEP Proxy requires a Fleet Premium license."
|
||||
profilesToUpdate = append(profilesToUpdate, profile)
|
||||
}
|
||||
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if !appConfig.Integrations.NDESSCEPProxy.Valid {
|
||||
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
|
||||
for _, hostUUID := range target.hostUUIDs {
|
||||
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
|
||||
if !ok { // Should never happen
|
||||
continue
|
||||
}
|
||||
profile.Status = &fleet.MDMDeliveryFailed
|
||||
profile.Detail = "NDES SCEP Proxy is not configured. " +
|
||||
"Please configure in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol."
|
||||
profilesToUpdate = append(profilesToUpdate, profile)
|
||||
}
|
||||
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
return appConfig.Integrations.NDESSCEPProxy.Valid, nil
|
||||
}
|
||||
|
||||
// Copy of NDES SCEP config which will contain unencrypted password, if needed
|
||||
var ndesConfig *fleet.NDESSCEPProxyIntegration
|
||||
digiCertCAs := make(map[string]*fleet.DigiCertIntegration)
|
||||
|
||||
var addedTargets map[string]*cmdTarget
|
||||
for profUUID, target := range targets {
|
||||
@@ -3797,10 +3794,11 @@ func preprocessProfileContents(
|
||||
|
||||
// Do common validation that applies to all hosts in the target
|
||||
valid := true
|
||||
var digiCertVars digiCertVarsFound
|
||||
for fleetVar := range fleetVars {
|
||||
switch fleetVar {
|
||||
case FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL:
|
||||
configured, err := isNDESSCEPConfigured(profUUID, target)
|
||||
switch {
|
||||
case fleetVar == FleetVarNDESSCEPChallenge || fleetVar == FleetVarNDESSCEPProxyURL:
|
||||
configured, err := getIsNDESSCEPConfiguredFunc(ctx, appConfig, ds, hostProfilesToInstallMap)(profUUID, target)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration")
|
||||
}
|
||||
@@ -3808,27 +3806,44 @@ func preprocessProfileContents(
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
case FleetVarHostEndUserEmailIDP:
|
||||
// No extra validation needed for this variable
|
||||
default:
|
||||
// Error out if we find an unknown variable
|
||||
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
|
||||
for _, hostUUID := range target.hostUUIDs {
|
||||
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
|
||||
if !ok { // Should never happen
|
||||
continue
|
||||
}
|
||||
profile.Status = &fleet.MDMDeliveryFailed
|
||||
profile.Detail = fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.",
|
||||
fleetVar)
|
||||
profilesToUpdate = append(profilesToUpdate, profile)
|
||||
case fleetVar == FleetVarHostEndUserEmailIDP || fleetVar == FleetVarHostHardwareSerial:
|
||||
// No extra validation needed for these variables
|
||||
case strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix) || strings.HasPrefix(fleetVar, FleetVarDigiCertDataPrefix):
|
||||
var caName string
|
||||
if strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix) {
|
||||
digiCertVars.password = true
|
||||
caName = strings.TrimPrefix(fleetVar, FleetVarDigiCertPasswordPrefix)
|
||||
} else {
|
||||
digiCertVars.data = true
|
||||
caName = strings.TrimPrefix(fleetVar, FleetVarDigiCertDataPrefix)
|
||||
}
|
||||
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profiles for unknown variable")
|
||||
configured, err := getIsDigiCertConfiguredFunc(ctx, appConfig, ds, hostProfilesToInstallMap, digiCertCAs)(profUUID, target, caName)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking DigiCert configuration")
|
||||
}
|
||||
if !configured {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
default:
|
||||
// Otherwise, error out since this variable is unknown
|
||||
detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.",
|
||||
fleetVar)
|
||||
_, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
if !digiCertVars.Ok() {
|
||||
_, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "For DigiCert integration, "+
|
||||
"both $FLEET_VAR_DIGICERT_PASSWORD_<ca_name> and $FLEET_VAR_DIGICERT_DATA_<ca_name> must be present in the profile.")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valid = false
|
||||
}
|
||||
if !valid {
|
||||
// We marked the profile as failed, so we will not do any additional processing on it
|
||||
delete(targets, profUUID)
|
||||
@@ -3860,8 +3875,8 @@ func preprocessProfileContents(
|
||||
|
||||
failed := false
|
||||
for fleetVar := range fleetVars {
|
||||
switch fleetVar {
|
||||
case FleetVarNDESSCEPChallenge:
|
||||
switch {
|
||||
case fleetVar == FleetVarNDESSCEPChallenge:
|
||||
if ndesConfig == nil {
|
||||
// Retrieve the NDES admin password. This is done once per run.
|
||||
configAssets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetNDESPassword}, nil)
|
||||
@@ -3918,12 +3933,12 @@ func preprocessProfileContents(
|
||||
managedCertificatePayloads = append(managedCertificatePayloads, payload)
|
||||
|
||||
hostContents = replaceFleetVariable(fleetVarNDESSCEPChallengeRegexp, hostContents, challenge)
|
||||
case FleetVarNDESSCEPProxyURL:
|
||||
case fleetVar == FleetVarNDESSCEPProxyURL:
|
||||
// Insert the SCEP URL into the profile contents
|
||||
proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath,
|
||||
url.PathEscape(fmt.Sprintf("%s,%s", hostUUID, profUUID)))
|
||||
hostContents = replaceFleetVariable(fleetVarNDESSCEPProxyURLRegexp, hostContents, proxyURL)
|
||||
case FleetVarHostEndUserEmailIDP:
|
||||
case fleetVar == FleetVarHostEndUserEmailIDP:
|
||||
// Insert the end user email IDP into the profile contents
|
||||
emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts)
|
||||
if err != nil {
|
||||
@@ -3949,6 +3964,37 @@ func preprocessProfileContents(
|
||||
break
|
||||
}
|
||||
hostContents = replaceFleetVariable(fleetVarHostEndUserEmailIDPRegexp, hostContents, emails[0])
|
||||
case fleetVar == FleetVarHostHardwareSerial:
|
||||
// TODO(#26609): swap in host serial
|
||||
case strings.HasPrefix(fleetVar, FleetVarDigiCertPasswordPrefix):
|
||||
// We will replace the password when we populate the certificate data
|
||||
case strings.HasPrefix(fleetVar, FleetVarDigiCertDataPrefix):
|
||||
caName := strings.TrimPrefix(fleetVar, FleetVarDigiCertDataPrefix)
|
||||
ca, ok := digiCertCAs[caName]
|
||||
if !ok {
|
||||
continue // Should never happen since we validated/populated DigiCert CAs earlier
|
||||
}
|
||||
|
||||
// TODO(#26609): populate Fleet vars in the CA fields
|
||||
|
||||
data, password, err := digicert.GetCertificate(ctx, logger, *ca)
|
||||
if err != nil {
|
||||
detail := fmt.Sprintf("Couldn't get certificate from DigiCert. %s", err)
|
||||
err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
|
||||
CommandUUID: target.cmdUUID,
|
||||
HostUUID: hostUUID,
|
||||
Status: &fleet.MDMDeliveryFailed,
|
||||
Detail: detail,
|
||||
OperationType: fleet.MDMOperationTypeInstall,
|
||||
})
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert")
|
||||
}
|
||||
failed = true
|
||||
break
|
||||
}
|
||||
hostContents = replaceFleetVariable(fleetVarDigiCertData, hostContents, base64.StdEncoding.EncodeToString(data))
|
||||
hostContents = replaceFleetVariable(fleetVarDigiCertPassword, hostContents, password)
|
||||
default:
|
||||
// This was handled in the above switch statement, so we should never reach this case
|
||||
}
|
||||
@@ -3983,6 +4029,96 @@ func preprocessProfileContents(
|
||||
return nil
|
||||
}
|
||||
|
||||
type digiCertVarsFound struct {
|
||||
data bool
|
||||
password bool
|
||||
}
|
||||
|
||||
// Ok makes sure that both DATA and PASSWORD variables are present in a DigiCert profile.
|
||||
func (d digiCertVarsFound) Ok() bool {
|
||||
return d.data && d.password || !d.data && !d.password
|
||||
}
|
||||
|
||||
func getIsDigiCertConfiguredFunc(ctx context.Context, appConfig *fleet.AppConfig, ds fleet.Datastore,
|
||||
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
||||
digiCertCAs map[string]*fleet.DigiCertIntegration) func(profUUID string, target *cmdTarget, caName string) (bool, error) {
|
||||
return func(profUUID string, target *cmdTarget, caName string) (bool, error) {
|
||||
if !license.IsPremium(ctx) {
|
||||
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "DigiCert integration requires a Fleet Premium license.")
|
||||
}
|
||||
if _, ok := digiCertCAs[caName]; ok {
|
||||
return true, nil
|
||||
}
|
||||
configured := false
|
||||
var digiCertCA *fleet.DigiCertIntegration
|
||||
if appConfig.Integrations.DigiCert.Valid {
|
||||
for _, ca := range appConfig.Integrations.DigiCert.Value {
|
||||
if ca.Name == caName {
|
||||
digiCertCA = &ca
|
||||
configured = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !configured || digiCertCA == nil {
|
||||
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID,
|
||||
fmt.Sprintf("DigiCert CA '%s' is not configured. Please configure in Settings > Integrations > Certificates.", caName))
|
||||
}
|
||||
|
||||
// Get the API token
|
||||
asset, err := ds.GetCAConfigAsset(ctx, digiCertCA.Name, fleet.CAConfigDigiCert)
|
||||
switch {
|
||||
case fleet.IsNotFound(err):
|
||||
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID,
|
||||
fmt.Sprintf("DigiCert CA '%s' is missing API token. Please configure in Settings > Integrations > Certificates.", caName))
|
||||
case err != nil:
|
||||
return false, ctxerr.Wrap(ctx, err, "getting CA config asset")
|
||||
}
|
||||
digiCertCA.APIToken = string(asset.Value)
|
||||
digiCertCAs[caName] = digiCertCA
|
||||
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getIsNDESSCEPConfiguredFunc(ctx context.Context, appConfig *fleet.AppConfig, ds fleet.Datastore,
|
||||
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload) func(profUUID string, target *cmdTarget) (bool, error) {
|
||||
return func(profUUID string, target *cmdTarget) (bool, error) {
|
||||
if !license.IsPremium(ctx) {
|
||||
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.")
|
||||
}
|
||||
if !appConfig.Integrations.NDESSCEPProxy.Valid {
|
||||
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, profUUID,
|
||||
"NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.")
|
||||
}
|
||||
return appConfig.Integrations.NDESSCEPProxy.Valid, nil
|
||||
}
|
||||
}
|
||||
|
||||
func markProfilesFailed(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
target *cmdTarget,
|
||||
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
||||
profUUID string,
|
||||
detail string,
|
||||
) (bool, error) {
|
||||
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs))
|
||||
for _, hostUUID := range target.hostUUIDs {
|
||||
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
|
||||
if !ok { // Should never happen
|
||||
continue
|
||||
}
|
||||
profile.Status = &fleet.MDMDeliveryFailed
|
||||
profile.Detail = detail
|
||||
profilesToUpdate = append(profilesToUpdate, profile)
|
||||
}
|
||||
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
|
||||
return false, ctxerr.Wrap(ctx, err, "marking host profiles failed")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func replaceFleetVariable(regExp *regexp.Regexp, contents string, replacement string) string {
|
||||
// Escape XML characters
|
||||
b := make([]byte, 0, len(replacement))
|
||||
@@ -3994,12 +4130,12 @@ func replaceFleetVariable(regExp *regexp.Regexp, contents string, replacement st
|
||||
|
||||
func findFleetVariables(contents string) map[string]interface{} {
|
||||
var result map[string]interface{}
|
||||
matches := profileVariableRegex.FindAllStringSubmatch(contents, -1)
|
||||
matches := mdm_types.ProfileVariableRegex.FindAllStringSubmatch(contents, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
nameToIndex := make(map[string]int, 2)
|
||||
for i, name := range profileVariableRegex.SubexpNames() {
|
||||
for i, name := range mdm_types.ProfileVariableRegex.SubexpNames() {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2798,6 +2798,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
logger := kitlog.NewNopLogger()
|
||||
appCfg := &fleet.AppConfig{}
|
||||
appCfg.ServerSettings.ServerURL = "https://test.example.com"
|
||||
appCfg.MDM.EnabledAndConfigured = true
|
||||
@@ -2805,7 +2806,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
// No-op
|
||||
err := preprocessProfileContents(ctx, appCfg, ds, nil, nil, nil)
|
||||
err := preprocessProfileContents(ctx, appCfg, ds, logger, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
hostUUID := "host-1"
|
||||
@@ -2845,7 +2846,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
}
|
||||
// Can't use NDES SCEP proxy with free tier
|
||||
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree})
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedPayload)
|
||||
assert.Contains(t, updatedPayload.Detail, "Premium license")
|
||||
@@ -2856,7 +2857,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
appCfg.Integrations.NDESSCEPProxy.Valid = false
|
||||
updatedPayload = nil
|
||||
populateTargets()
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedPayload)
|
||||
assert.Contains(t, updatedPayload.Detail, "not configured")
|
||||
@@ -2869,7 +2870,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
appCfg.Integrations.NDESSCEPProxy.Valid = true
|
||||
updatedPayload = nil
|
||||
populateTargets()
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedPayload)
|
||||
assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO")
|
||||
@@ -2914,7 +2915,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
assert.Empty(t, payload) // no profiles to update since FLEET VAR could not be populated
|
||||
return nil
|
||||
}
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedProfile)
|
||||
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
|
||||
@@ -2928,7 +2929,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
}
|
||||
updatedProfile = nil
|
||||
populateTargets()
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedProfile)
|
||||
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
|
||||
@@ -2942,7 +2943,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
}
|
||||
updatedProfile = nil
|
||||
populateTargets()
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedProfile)
|
||||
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
|
||||
@@ -2956,7 +2957,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
}
|
||||
updatedProfile = nil
|
||||
populateTargets()
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedProfile)
|
||||
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge)
|
||||
@@ -2983,7 +2984,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
assert.NotNil(t, payload[0].ChallengeRetrievedAt)
|
||||
return nil
|
||||
}
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, updatedProfile)
|
||||
require.NotEmpty(t, targets)
|
||||
@@ -3006,7 +3007,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
assert.Empty(t, payload)
|
||||
return nil
|
||||
}
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, updatedProfile)
|
||||
require.NotEmpty(t, targets)
|
||||
@@ -3027,7 +3028,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
}
|
||||
updatedProfile = nil
|
||||
populateTargets()
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedProfile)
|
||||
assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarHostEndUserEmailIDP)
|
||||
@@ -3041,7 +3042,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
}
|
||||
updatedProfile = nil
|
||||
populateTargets()
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, updatedProfile)
|
||||
require.NotEmpty(t, targets)
|
||||
@@ -3109,7 +3110,7 @@ func TestPreprocessProfileContents(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap)
|
||||
err = preprocessProfileContents(ctx, appCfg, ds, logger, targets, profileContents, hostProfilesToInstallMap)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, targets)
|
||||
assert.Len(t, targets, 3)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"database/sql"
|
||||
@@ -78,6 +79,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
func TestIntegrationsMDM(t *testing.T) {
|
||||
@@ -13240,34 +13242,27 @@ func (s *integrationMDMTestSuite) TestSCEPProxy() {
|
||||
assert.Equal(t, scep.CertRep, pkiMessage.MessageType)
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
|
||||
func (s *integrationMDMTestSuite) TestDigiCertConfig() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
mockDigiCertServer := createMockDigiCertServer(t)
|
||||
|
||||
pathRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`)
|
||||
mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
matches := pathRegex.FindStringSubmatch(r.URL.Path)
|
||||
if len(matches) != 2 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
profileID := matches[1]
|
||||
|
||||
resp := map[string]string{
|
||||
"id": profileID,
|
||||
"name": "Test CA",
|
||||
"status": "Active",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err := json.NewEncoder(w).Encode(resp)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer mockDigiCertServer.Close()
|
||||
// Add DigiCert integration with bad URL
|
||||
caBad := getDigiCertIntegration("https://httpstat.us/410", "ca")
|
||||
appConfig := map[string]interface{}{
|
||||
"integrations": map[string]interface{}{
|
||||
"digicert": []fleet.DigiCertIntegration{caBad},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(appConfig)
|
||||
require.NoError(t, err)
|
||||
var req modifyAppConfigRequest
|
||||
req.RawMessage = raw
|
||||
rawRes := s.Do("PATCH", "/api/latest/fleet/config", &req, http.StatusUnprocessableEntity, "dry_run", "true")
|
||||
errMsg := extractServerErrorText(rawRes.Body)
|
||||
require.Contains(t, errMsg, "Could not verify DigiCert profile ID")
|
||||
_, err = s.ds.GetAllCAConfigAssets(ctx)
|
||||
assert.True(t, fleet.IsNotFound(err))
|
||||
|
||||
// Add 3 DigiCert integrations
|
||||
ca0 := getDigiCertIntegration(mockDigiCertServer.URL, "ca0")
|
||||
@@ -13276,16 +13271,16 @@ func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
|
||||
ca1.APIToken = "api_token1"
|
||||
ca2 := getDigiCertIntegration(mockDigiCertServer.URL, "ca2")
|
||||
ca2.APIToken = "api_token2"
|
||||
appConfig := map[string]interface{}{
|
||||
appConfig = map[string]interface{}{
|
||||
"integrations": map[string]interface{}{
|
||||
"digicert": []fleet.DigiCertIntegration{ca0, ca1, ca2},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(appConfig)
|
||||
raw, err = json.Marshal(appConfig)
|
||||
require.NoError(t, err)
|
||||
var req modifyAppConfigRequest
|
||||
req = modifyAppConfigRequest{}
|
||||
req.RawMessage = raw
|
||||
var res appConfigResponse
|
||||
res := appConfigResponse{}
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res, "dry_run", "true")
|
||||
assert.Empty(t, res.Integrations.DigiCert.Value)
|
||||
_, err = s.ds.GetAllCAConfigAssets(ctx)
|
||||
@@ -13429,6 +13424,288 @@ func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
|
||||
assert.EqualValues(t, caNames, []string{"ca1", "ca2", "ca3"})
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestDigiCertIntegration() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
mockDigiCertServer := createMockDigiCertServer(t)
|
||||
|
||||
// Add DigiCert config
|
||||
ca := getDigiCertIntegration(mockDigiCertServer.URL, "my_CA")
|
||||
ca.APIToken = "api_token0"
|
||||
appConfig := map[string]interface{}{
|
||||
"integrations": map[string]interface{}{
|
||||
"digicert": []fleet.DigiCertIntegration{ca},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(appConfig)
|
||||
require.NoError(t, err)
|
||||
var req modifyAppConfigRequest
|
||||
req.RawMessage = raw
|
||||
var res appConfigResponse
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res)
|
||||
assert.Len(t, res.Integrations.DigiCert.Value, 1)
|
||||
|
||||
// Create a host and then enroll to MDM.
|
||||
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||||
setupPusher(s, t, mdmDevice)
|
||||
// trigger a profile sync
|
||||
s.awaitTriggerProfileSchedule(t)
|
||||
profiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(profiles), 0)
|
||||
// Receive enrollment profiles (we are not checking/testing these here)
|
||||
for {
|
||||
cmd, err := mdmDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
if cmd == nil {
|
||||
break
|
||||
}
|
||||
_, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Add a profile with a bad CA
|
||||
profile := digiCertForTest("N1", "BadCA", "badName")
|
||||
rawRes := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||||
{Name: "N1", Contents: profile},
|
||||
}}, http.StatusBadRequest)
|
||||
errMsg := extractServerErrorText(rawRes.Body)
|
||||
require.Contains(t, errMsg, "_badName is not supported")
|
||||
|
||||
// Add good profile
|
||||
profile = digiCertForTest("N2", "I2", "my_CA")
|
||||
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||||
{Name: "N2", Contents: profile},
|
||||
}}, http.StatusNoContent)
|
||||
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(profiles), 1)
|
||||
|
||||
// trigger a profile sync
|
||||
s.awaitTriggerProfileSchedule(t)
|
||||
p := s.assertConfigProfilesByIdentifier(nil, "I2", true)
|
||||
require.Contains(t, string(p.Mobileconfig), "com.fleetdm.pkcs12")
|
||||
|
||||
cmd, err := mdmDevice.Idle()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd, "Expecting PKCS12 certificate")
|
||||
var fullCmd micromdm.CommandPayload
|
||||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, cmd)
|
||||
require.NotNil(t, fullCmd.Command)
|
||||
require.NotNil(t, fullCmd.Command.InstallProfile)
|
||||
rawProfile := fullCmd.Command.InstallProfile.Payload
|
||||
if !bytes.HasPrefix(rawProfile, []byte("<?xml")) {
|
||||
p7, err := pkcs7.Parse(rawProfile)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, p7.Verify())
|
||||
rawProfile = p7.Content
|
||||
}
|
||||
|
||||
var pkcs12Profile struct {
|
||||
PayloadContent []map[string]interface{} `plist:"PayloadContent"`
|
||||
}
|
||||
require.NoError(t, plist.Unmarshal(rawProfile, &pkcs12Profile))
|
||||
assert.Equal(t, "com.apple.security.pkcs12", pkcs12Profile.PayloadContent[0]["PayloadType"].(string))
|
||||
password := pkcs12Profile.PayloadContent[0]["Password"].(string)
|
||||
data := pkcs12Profile.PayloadContent[0]["PayloadContent"].([]byte)
|
||||
_, certificate, err := pkcs12.Decode(data, password)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ca.CertificateCommonName, certificate.Subject.CommonName)
|
||||
|
||||
// Try a DigiCert CA that will fail
|
||||
caFail := getDigiCertIntegration(mockDigiCertServer.URL, "fail_CA")
|
||||
caFail.CertificateCommonName = "Fail"
|
||||
appConfig = map[string]interface{}{
|
||||
"integrations": map[string]interface{}{
|
||||
"digicert": []fleet.DigiCertIntegration{ca, caFail},
|
||||
},
|
||||
}
|
||||
raw, err = json.Marshal(appConfig)
|
||||
require.NoError(t, err)
|
||||
req = modifyAppConfigRequest{}
|
||||
req.RawMessage = raw
|
||||
res = appConfigResponse{}
|
||||
s.DoJSON("PATCH", "/api/latest/fleet/config", &req, http.StatusOK, &res)
|
||||
assert.Len(t, res.Integrations.DigiCert.Value, 2)
|
||||
|
||||
profileFail := digiCertForTest("N3", "I3", "fail_CA")
|
||||
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||||
{Name: "N2", Contents: profile}, // resend the previous profile so it doesn't get removed
|
||||
{Name: "N3", Contents: profileFail},
|
||||
}}, http.StatusNoContent)
|
||||
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(profiles), 2)
|
||||
|
||||
s.awaitTriggerProfileSchedule(t)
|
||||
p = s.assertConfigProfilesByIdentifier(nil, "I3", true)
|
||||
require.Contains(t, string(p.Mobileconfig), "com.fleetdm.pkcs12")
|
||||
|
||||
getHostResp := getDeviceHostResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
|
||||
var foundGood, foundFailed bool
|
||||
require.NotNil(t, getHostResp.Host.MDM.Profiles)
|
||||
for _, prof := range *getHostResp.Host.MDM.Profiles {
|
||||
if prof.Name == "N2" {
|
||||
foundGood = true
|
||||
assert.Equal(t, fleet.MDMDeliveryVerifying, *prof.Status)
|
||||
assert.Equal(t, fleet.MDMOperationTypeInstall, prof.OperationType)
|
||||
continue
|
||||
}
|
||||
if prof.Name == "N3" {
|
||||
foundFailed = true
|
||||
assert.Equal(t, fleet.MDMDeliveryFailed, *prof.Status)
|
||||
assert.Equal(t, fleet.MDMOperationTypeInstall, prof.OperationType)
|
||||
assert.Contains(t, prof.Detail, "Expected Fail")
|
||||
continue
|
||||
}
|
||||
}
|
||||
assert.True(t, foundGood)
|
||||
assert.True(t, foundFailed)
|
||||
}
|
||||
|
||||
func digiCertForTest(name, identifier, caName string) []byte {
|
||||
return []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>Password</key>
|
||||
<string>$FLEET_VAR_DIGICERT_PASSWORD_%s</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>${FLEET_VAR_DIGICERT_DATA_%s}</data>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>CertificatePKCS12</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.fleetdm.pkcs12</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.pkcs12</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>ee86cfcb-2409-42c2-9394-1f8113412e04</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>%s</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>%s</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>%s</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
`, caName, caName, name, identifier, uuid.New().String()))
|
||||
}
|
||||
|
||||
func createMockDigiCertServer(t *testing.T) *httptest.Server {
|
||||
profileRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`)
|
||||
certRegex := regexp.MustCompile(`^/mpki/api/v1/certificate`)
|
||||
mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
matches := profileRegex.FindStringSubmatch(r.URL.Path)
|
||||
if len(matches) != 2 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
profileID := matches[1]
|
||||
|
||||
resp := map[string]string{
|
||||
"id": profileID,
|
||||
"name": "Test CA",
|
||||
"status": "Active",
|
||||
}
|
||||
err := json.NewEncoder(w).Encode(resp)
|
||||
require.NoError(t, err)
|
||||
case http.MethodPost:
|
||||
if len(certRegex.FindStringSubmatch(r.URL.Path)) != 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Decode the PEM format CSR into DER
|
||||
block, _ := pem.Decode([]byte(req.CSR))
|
||||
require.NotNil(t, block, "failed to decode PEM block containing CSR")
|
||||
require.Equal(t, "CERTIFICATE REQUEST", block.Type, "unexpected PEM block type")
|
||||
|
||||
// Parse the CSR
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, csr.CheckSignature())
|
||||
|
||||
// Setting CertificateCommonName to "Fail" allows us to test a failure getting a DigiCert certificate
|
||||
if csr.Subject.CommonName == "Fail" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
type errorResponse struct {
|
||||
Errors interface{} `json:"errors"`
|
||||
}
|
||||
err = json.NewEncoder(w).Encode(errorResponse{
|
||||
Errors: []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
{
|
||||
Message: "Expected Fail",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a self-signed certificate based on the CSR
|
||||
serialNumber := big.NewInt(time.Now().Unix())
|
||||
certTemplate := &x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year validity
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encode the certificate to PEM
|
||||
certPEM := new(bytes.Buffer)
|
||||
err = pem.Encode(certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
require.NoError(t, err)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
resp := map[string]string{
|
||||
"serial_number": serialNumber.String(),
|
||||
"delivery_format": "x509",
|
||||
"certificate": certPEM.String(),
|
||||
}
|
||||
err = json.NewEncoder(w).Encode(resp)
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(mockDigiCertServer.Close)
|
||||
return mockDigiCertServer
|
||||
}
|
||||
|
||||
type noopCertDepot struct{ depot.Depot }
|
||||
|
||||
func (d *noopCertDepot) Put(_ string, _ *x509.Certificate) error {
|
||||
|
||||
@@ -1673,7 +1673,7 @@ func (svc *Service) BatchSetMDMProfiles(
|
||||
return nil
|
||||
}
|
||||
|
||||
err = validateFleetVariables(ctx, appleProfiles, windowsProfiles, appleDecls)
|
||||
err = validateFleetVariables(ctx, appCfg, appleProfiles, windowsProfiles, appleDecls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1756,13 +1756,13 @@ func (svc *Service) BatchSetMDMProfiles(
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFleetVariables(ctx context.Context, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
|
||||
func validateFleetVariables(ctx context.Context, appConfig *fleet.AppConfig, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
|
||||
windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration,
|
||||
) error {
|
||||
var err error
|
||||
|
||||
for _, p := range appleProfiles {
|
||||
err = validateConfigProfileFleetVariables(string(p.Mobileconfig))
|
||||
err = validateConfigProfileFleetVariables(appConfig, string(p.Mobileconfig))
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validating config profile Fleet variables")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user