package service import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "io" "maps" "net" "net/http" "net/url" "regexp" strconv "strconv" "strings" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/server/authz" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/version" "github.com/go-kit/log/level" "golang.org/x/text/unicode/norm" ) //////////////////////////////////////////////////////////////////////////////// // Get AppConfig //////////////////////////////////////////////////////////////////////////////// type appConfigResponse struct { fleet.AppConfig appConfigResponseFields } // appConfigResponseFields are grouped separately to aid with JSON unmarshaling type appConfigResponseFields struct { UpdateInterval *fleet.UpdateIntervalConfig `json:"update_interval"` Vulnerabilities *fleet.VulnerabilitiesConfig `json:"vulnerabilities"` // License is loaded from the service License *fleet.LicenseInfo `json:"license,omitempty"` // Logging is loaded on the fly rather than from the database. Logging *fleet.Logging `json:"logging,omitempty"` // Email is returned when the email backend is something other than SMTP, for example SES Email *fleet.EmailConfig `json:"email,omitempty"` // SandboxEnabled is true if fleet serve was ran with server.sandbox_enabled=true SandboxEnabled bool `json:"sandbox_enabled,omitempty"` Err error `json:"error,omitempty"` Partnerships *fleet.Partnerships `json:"partnerships,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface to make sure we serialize // both AppConfig and appConfigResponseFields properly: // // - If this function is not defined, AppConfig.UnmarshalJSON gets promoted and // will be called instead. // - If we try to unmarshal everything in one go, AppConfig.UnmarshalJSON doesn't get // called. func (r *appConfigResponse) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &r.AppConfig); err != nil { return err } if err := json.Unmarshal(data, &r.appConfigResponseFields); err != nil { return err } return nil } // MarshalJSON implements the json.Marshaler interface to make sure we serialize // both AppConfig and responseFields properly: // // - If this function is not defined, AppConfig.MarshalJSON gets promoted and // will be called instead. // - If we try to unmarshal everything in one go, AppConfig.MarshalJSON doesn't get // called. func (r appConfigResponse) MarshalJSON() ([]byte, error) { // Marshal only the response fields responseData, err := json.Marshal(r.appConfigResponseFields) if err != nil { return nil, err } // Marshal the base AppConfig appConfigData, err := json.Marshal(r.AppConfig) if err != nil { return nil, err } // we need to marshal and combine both groups separately because // AppConfig has a custom marshaler. return rawjson.CombineRoots(responseData, appConfigData) } func (r appConfigResponse) Error() error { return r.Err } func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { vc, ok := viewer.FromContext(ctx) if !ok { return nil, errors.New("could not fetch user") } appConfig, err := svc.AppConfigObfuscated(ctx) if err != nil { return nil, err } lic, err := svc.License(ctx) if err != nil { return nil, err } loggingConfig, err := svc.LoggingConfig(ctx) if err != nil { return nil, err } emailConfig, err := svc.EmailConfig(ctx) if err != nil { return nil, err } updateIntervalConfig, err := svc.UpdateIntervalConfig(ctx) if err != nil { return nil, err } vulnConfig, err := svc.VulnerabilitiesConfig(ctx) if err != nil { return nil, err } partnerships, err := svc.PartnershipsConfig(ctx) if err != nil { return nil, err } // Add Microsoft Entra settings from the integration table to appConfig.ConditionalAccess // (Okta settings are already in appConfig.ConditionalAccess from the database) conditionalAccessIntegration, err := svc.ConditionalAccessMicrosoftGet(ctx) if err != nil { return nil, err } // Always initialize ConditionalAccess so it's never nil (even when empty) if appConfig.ConditionalAccess == nil { appConfig.ConditionalAccess = &fleet.ConditionalAccessSettings{} } // Set or clear Microsoft Entra fields based on integration status if conditionalAccessIntegration != nil { appConfig.ConditionalAccess.MicrosoftEntraTenantID = conditionalAccessIntegration.TenantID appConfig.ConditionalAccess.MicrosoftEntraConnectionConfigured = conditionalAccessIntegration.SetupDone } else { // Clear Entra fields when integration is deleted appConfig.ConditionalAccess.MicrosoftEntraTenantID = "" appConfig.ConditionalAccess.MicrosoftEntraConnectionConfigured = false } isGlobalAdmin := vc.User.GlobalRole != nil && *vc.User.GlobalRole == fleet.RoleAdmin isAnyTeamAdmin := false if vc.User.Teams != nil { // check if the user is an admin for any team for _, team := range vc.User.Teams { if team.Role == fleet.RoleAdmin { isAnyTeamAdmin = true break } } } // Only admins should see SMTP and SSO settings var smtpSettings *fleet.SMTPSettings var ssoSettings *fleet.SSOSettings if isGlobalAdmin || isAnyTeamAdmin { smtpSettings = appConfig.SMTPSettings ssoSettings = appConfig.SSOSettings } // Only global admins should see osquery agent settings. var agentOptions *json.RawMessage if isGlobalAdmin { agentOptions = appConfig.AgentOptions } // Fleet Premium license is required for custom transparency url transparencyURL := fleet.DefaultTransparencyURL if lic.IsPremium() && appConfig.FleetDesktop.TransparencyURL != "" { transparencyURL = appConfig.FleetDesktop.TransparencyURL } // Fleet Premium license is required for server side alternative browser host URL var alternativeBrowserHost string if lic.IsPremium() { alternativeBrowserHost = appConfig.FleetDesktop.AlternativeBrowserHost } fleetDesktop := fleet.FleetDesktopSettings{ TransparencyURL: transparencyURL, AlternativeBrowserHost: alternativeBrowserHost, } if appConfig.OrgInfo.ContactURL == "" { appConfig.OrgInfo.ContactURL = fleet.DefaultOrgInfoContactURL } features := appConfig.Features response := appConfigResponse{ AppConfig: fleet.AppConfig{ OrgInfo: appConfig.OrgInfo, ServerSettings: appConfig.ServerSettings, Features: features, VulnerabilitySettings: appConfig.VulnerabilitySettings, HostExpirySettings: appConfig.HostExpirySettings, ActivityExpirySettings: appConfig.ActivityExpirySettings, SMTPSettings: smtpSettings, SSOSettings: ssoSettings, AgentOptions: agentOptions, FleetDesktop: fleetDesktop, WebhookSettings: appConfig.WebhookSettings, Integrations: appConfig.Integrations, MDM: appConfig.MDM, Scripts: appConfig.Scripts, UIGitOpsMode: appConfig.UIGitOpsMode, ConditionalAccess: appConfig.ConditionalAccess, }, appConfigResponseFields: appConfigResponseFields{ UpdateInterval: updateIntervalConfig, Vulnerabilities: vulnConfig, License: lic, Logging: loggingConfig, Email: emailConfig, SandboxEnabled: svc.SandboxEnabled(), Partnerships: partnerships, }, } return response, nil } func (svc *Service) SandboxEnabled() bool { return svc.config.Server.SandboxEnabled } func (svc *Service) AppConfigObfuscated(ctx context.Context) (*fleet.AppConfig, error) { if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) && !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) { if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil { return nil, err } } ac, err := svc.ds.AppConfig(ctx) if err != nil { return nil, err } ac.Obfuscate() return ac, nil } // ////////////////////////////////////////////////////////////////////////////// // Modify AppConfig // ////////////////////////////////////////////////////////////////////////////// type modifyAppConfigRequest struct { Force bool `json:"-" query:"force,optional"` // if true, bypass strict incoming json validation DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes Overwrite bool `json:"-" query:"overwrite,optional"` // if true, overwrite any existing settings with the incoming ones json.RawMessage } func modifyAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*modifyAppConfigRequest) appConfig, err := svc.ModifyAppConfig(ctx, req.RawMessage, fleet.ApplySpecOptions{ Force: req.Force, DryRun: req.DryRun, Overwrite: req.Overwrite, }) if err != nil { return appConfigResponse{appConfigResponseFields: appConfigResponseFields{Err: err}}, nil } // We do not use svc.License(ctx) to allow roles (like GitOps) write but not read access to AppConfig. licChecker, _ := license.FromContext(ctx) lic, _ := licChecker.(*fleet.LicenseInfo) loggingConfig, err := svc.LoggingConfig(ctx) if err != nil { return nil, err } response := appConfigResponse{ AppConfig: *appConfig, appConfigResponseFields: appConfigResponseFields{ License: lic, Logging: loggingConfig, }, } response.Obfuscate() if lic == nil || (!lic.IsPremium()) || response.FleetDesktop.TransparencyURL == "" { response.FleetDesktop.TransparencyURL = fleet.DefaultTransparencyURL } return response, nil } func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fleet.ApplySpecOptions) (*fleet.AppConfig, error) { if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionWrite); err != nil { return nil, err } // we need the config from the datastore because API tokens are obfuscated at // the service layer we will retrieve the obfuscated config before we return. // We bypass the mysql cache because this is a read that will be followed by // modifications and a save, so we need up-to-date data. ctx = ctxdb.BypassCachedMysql(ctx, true) appConfig, err := svc.ds.AppConfig(ctx) if err != nil { return nil, err } // the rest of the calls can use the cache safely (we read the AppConfig // again before returning, either after a dry-run or after saving the // AppConfig, in which case the cache will be up-to-date and safe to use). ctx = ctxdb.BypassCachedMysql(ctx, false) oldAppConfig := appConfig.Copy() // We do not use svc.License(ctx) to allow roles (like GitOps) write but not read access to AppConfig. licChecker, _ := license.FromContext(ctx) lic, _ := licChecker.(*fleet.LicenseInfo) var oldSMTPSettings fleet.SMTPSettings if appConfig.SMTPSettings != nil { oldSMTPSettings = *appConfig.SMTPSettings } else { // SMTPSettings used to be a non-pointer on previous iterations, // so if current SMTPSettings are not present (with empty values), // then this is a bug, let's log an error. level.Error(svc.logger).Log("msg", "smtp_settings are not present") } oldAgentOptions := "" if appConfig.AgentOptions != nil { oldAgentOptions = string(*appConfig.AgentOptions) } oldConditionalAccessEnabled := appConfig.Integrations.ConditionalAccessEnabled storedJiraByProjectKey, err := fleet.IndexJiraIntegrations(appConfig.Integrations.Jira) if err != nil { return nil, ctxerr.Wrap(ctx, err, "modify AppConfig") } storedZendeskByGroupID, err := fleet.IndexZendeskIntegrations(appConfig.Integrations.Zendesk) if err != nil { return nil, ctxerr.Wrap(ctx, err, "modify AppConfig") } invalid := &fleet.InvalidArgumentError{} var newAppConfig fleet.AppConfig if err := json.Unmarshal(p, &newAppConfig); err != nil { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ Message: "failed to decode app config", InternalErr: err, }) } fleetDesktopSettingsInvalidErr := validateFleetDesktopSettings(newAppConfig, lic) if fleetDesktopSettingsInvalidErr.HasErrors() { return nil, ctxerr.Wrap(ctx, fleetDesktopSettingsInvalidErr) } if newAppConfig.SSOSettings != nil { validateSSOSettings(newAppConfig, appConfig, invalid, lic) if invalid.HasErrors() { return nil, ctxerr.Wrap(ctx, invalid) } } // If we're in overwrite mode, clear out any feautures that are not explicitly specified. if applyOpts.Overwrite { appConfig.Features = newAppConfig.Features appConfig.SSOSettings = newAppConfig.SSOSettings appConfig.MDM.EndUserAuthentication = newAppConfig.MDM.EndUserAuthentication } // We apply the config that is incoming to the old one appConfig.EnableStrictDecoding() if err := json.Unmarshal(p, &appConfig); err != nil { err = fleet.NewUserMessageError(err, http.StatusBadRequest) return nil, ctxerr.Wrap(ctx, err) } // AppleOSUpdateSettings.UpdateNewHosts only applies to macOS ... so just ignore w/e posted for iOS/iPadOS appConfig.MDM.IOSUpdates.UpdateNewHosts = optjson.Bool{} appConfig.MDM.IPadOSUpdates.UpdateNewHosts = optjson.Bool{} // Handle Google Calendar API key preservation/replacement. // The custom GoogleCalendarApiKey type handles unmarshaling "********" as masked. if newAppConfig.Integrations.GoogleCalendar != nil { for i, newGC := range newAppConfig.Integrations.GoogleCalendar { if i < len(appConfig.Integrations.GoogleCalendar) { // If api_key_json was omitted (empty) or masked ("********"), preserve the existing value if newGC.ApiKey.IsEmpty() || newGC.ApiKey.IsMasked() { if len(oldAppConfig.Integrations.GoogleCalendar) > i { appConfig.Integrations.GoogleCalendar[i].ApiKey = oldAppConfig.Integrations.GoogleCalendar[i].ApiKey } } else { // api_key_json was provided with real values, use it appConfig.Integrations.GoogleCalendar[i].ApiKey = newGC.ApiKey } } } } // if turning off Windows MDM and Windows Migration is not explicitly set to // on in the same update, set it to off (otherwise, if it is explicitly set // to true, return an error that it can't be done when MDM is off, this is // addressed in validateMDM). if oldAppConfig.MDM.WindowsEnabledAndConfigured != appConfig.MDM.WindowsEnabledAndConfigured && !appConfig.MDM.WindowsEnabledAndConfigured && !newAppConfig.MDM.WindowsMigrationEnabled { appConfig.MDM.WindowsMigrationEnabled = false } // EnableDiskEncryption is an optjson.Bool field in order to support the // legacy field under "mdm.macos_settings". If the field provided to the // PATCH endpoint is set but invalid (that is, "enable_disk_encryption": // null) and no legacy field overwrites it, leave it unchanged (as if not // provided). // TODO: move this logic to the AppConfig unmarshaller? we need to do // this because we unmarshal twice into appConfig: // // 1. To get the JSON value from the database // 2. To update fields with the incoming values if newAppConfig.MDM.EnableDiskEncryption.Valid { if newAppConfig.MDM.EnableDiskEncryption.Value && svc.config.Server.PrivateKey == "" { return nil, ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") } appConfig.MDM.EnableDiskEncryption = newAppConfig.MDM.EnableDiskEncryption } else if appConfig.MDM.EnableDiskEncryption.Set && !appConfig.MDM.EnableDiskEncryption.Valid { appConfig.MDM.EnableDiskEncryption = oldAppConfig.MDM.EnableDiskEncryption } // this is to handle the case where `enable_release_device_manually: null` is // passed in the request payload, which should be treated as "not present/not // changed" by the PATCH. We should really try to find a more general way to // handle this. if !oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { // this makes a DB migration unnecessary, will update the field to its default false value as necessary oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) } if newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually } else { appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually } if appConfig.MDM.MacOSSetup.ManualAgentInstall.Valid && appConfig.MDM.MacOSSetup.ManualAgentInstall.Value { if !lic.IsPremium() { invalid.Append("macos_setup.manual_agent_install", ErrMissingLicense.Error()) return nil, ctxerr.Wrap(ctx, invalid) } } var legacyUsedWarning error if legacyKeys := appConfig.DidUnmarshalLegacySettings(); len(legacyKeys) > 0 { // this "warning" is returned only in dry-run mode, and if no other errors // were encountered. legacyUsedWarning = &fleet.BadRequestError{ Message: fmt.Sprintf("warning: deprecated settings were used in the configuration: %v; consider updating to the new settings: https://fleetdm.com/docs/using-fleet/configuration-files#settings", legacyKeys), } } // required fields must be set, ensure they haven't been removed by applying // the new config if appConfig.OrgInfo.OrgName == "" { invalid.Append("org_name", "organization name must be present") } if appConfig.ServerSettings.ServerURL == "" { invalid.Append("server_url", "Fleet server URL must be present") } else { if err := ValidateServerURL(appConfig.ServerSettings.ServerURL); err != nil { invalid.Append("server_url", "Couldn't update settings: "+err.Error()) } } if appConfig.ActivityExpirySettings.ActivityExpiryEnabled && appConfig.ActivityExpirySettings.ActivityExpiryWindow < 1 { invalid.Append("activity_expiry_settings.activity_expiry_window", "must be greater than 0") } if appConfig.OrgInfo.ContactURL == "" { appConfig.OrgInfo.ContactURL = fleet.DefaultOrgInfoContactURL } if newAppConfig.AgentOptions != nil { // if there were Agent Options in the new app config, then it replaced the // agent options in the resulting app config, so validate those. if err := fleet.ValidateJSONAgentOptions(ctx, svc.ds, *appConfig.AgentOptions, lic.IsPremium(), 0); err != nil { err = fleet.SuggestAgentOptionsCorrection(err) err = fleet.NewUserMessageError(err, http.StatusBadRequest) if applyOpts.Force && !applyOpts.DryRun { level.Info(svc.logger).Log("err", err, "msg", "force-apply appConfig agent options with validation errors") } if !applyOpts.Force { return nil, ctxerr.Wrap(ctx, err, "validate agent options") } } } // If the license is Premium, we should always send usage statisics. if !lic.IsAllowDisableTelemetry() { appConfig.ServerSettings.EnableAnalytics = true } fleet.ValidateGoogleCalendarIntegrations(appConfig.Integrations.GoogleCalendar, invalid) fleet.ValidateEnabledVulnerabilitiesIntegrations(appConfig.WebhookSettings.VulnerabilitiesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledFailingPoliciesIntegrations(appConfig.WebhookSettings.FailingPoliciesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledHostStatusIntegrations(appConfig.WebhookSettings.HostStatusWebhook, invalid) fleet.ValidateEnabledActivitiesWebhook(appConfig.WebhookSettings.ActivitiesWebhook, invalid) // Initialize ConditionalAccess if nil (it's a pointer type) if appConfig.ConditionalAccess == nil { appConfig.ConditionalAccess = &fleet.ConditionalAccessSettings{} } if newAppConfig.ConditionalAccess == nil { newAppConfig.ConditionalAccess = &fleet.ConditionalAccessSettings{} } // Trim whitespace from all Okta fields before setting them applyOptString := func(dest *optjson.String, src optjson.String) { if src.Set { if src.Valid { src.Value = strings.TrimSpace(src.Value) } *dest = src } } applyOptString(&appConfig.ConditionalAccess.OktaIDPID, newAppConfig.ConditionalAccess.OktaIDPID) applyOptString(&appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL, newAppConfig.ConditionalAccess.OktaAssertionConsumerServiceURL) applyOptString(&appConfig.ConditionalAccess.OktaAudienceURI, newAppConfig.ConditionalAccess.OktaAudienceURI) applyOptString(&appConfig.ConditionalAccess.OktaCertificate, newAppConfig.ConditionalAccess.OktaCertificate) // Handle Okta conditional access fields - only update if Set=true (partial update support) // Check if any Okta fields are being set with valid (non-null) non-empty values isNonEmpty := func(s optjson.String) bool { return s.Set && s.Valid && s.Value != "" } oktaFieldsBeingSet := isNonEmpty(newAppConfig.ConditionalAccess.OktaIDPID) || isNonEmpty(newAppConfig.ConditionalAccess.OktaAssertionConsumerServiceURL) || isNonEmpty(newAppConfig.ConditionalAccess.OktaAudienceURI) || isNonEmpty(newAppConfig.ConditionalAccess.OktaCertificate) if oktaFieldsBeingSet && !lic.IsPremium() { invalid.Append("conditional_access", ErrMissingLicense.Error()) return nil, ctxerr.Wrap(ctx, invalid) } // Validate Okta configuration - all fields must be set together or all must be empty oktaFieldsSet := 0 if appConfig.ConditionalAccess.OktaIDPID.Valid && appConfig.ConditionalAccess.OktaIDPID.Value != "" { oktaFieldsSet++ } if appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Valid && appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value != "" { oktaFieldsSet++ } if appConfig.ConditionalAccess.OktaAudienceURI.Valid && appConfig.ConditionalAccess.OktaAudienceURI.Value != "" { oktaFieldsSet++ } if appConfig.ConditionalAccess.OktaCertificate.Valid && appConfig.ConditionalAccess.OktaCertificate.Value != "" { oktaFieldsSet++ } // Either all 4 fields should be set, or none should be set if oktaFieldsSet > 0 && oktaFieldsSet < 4 { invalid.Append("conditional_access", "all Okta fields must be set together (okta_idp_id, okta_assertion_consumer_service_url, okta_audience_uri, okta_certificate) or all must be empty") } // If all fields are set, validate them if oktaFieldsSet == 4 { // Validate max lengths for Okta fields const ( maxURLLength = 2048 // Standard max URL length supported by browsers maxCertLength = 8192 // 8KB for PEM certificate (without private key) ) if len(appConfig.ConditionalAccess.OktaIDPID.Value) > maxURLLength { invalid.Append("conditional_access.okta_idp_id", fmt.Sprintf("must be %d characters or less", maxURLLength)) } if len(appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value) > maxURLLength { invalid.Append("conditional_access.okta_assertion_consumer_service_url", fmt.Sprintf("must be %d characters or less", maxURLLength)) } if len(appConfig.ConditionalAccess.OktaAudienceURI.Value) > maxURLLength { invalid.Append("conditional_access.okta_audience_uri", fmt.Sprintf("must be %d characters or less", maxURLLength)) } if len(appConfig.ConditionalAccess.OktaCertificate.Value) > maxCertLength { invalid.Append("conditional_access.okta_certificate", fmt.Sprintf("must be %d characters or less", maxCertLength)) } // Validate URL format for ACS URL - must have http or https scheme and a host acsURL, err := url.ParseRequestURI(appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value) if err != nil || ((acsURL.Scheme != "http" && acsURL.Scheme != "https") || acsURL.Host == "") { invalid.Append("conditional_access.okta_assertion_consumer_service_url", "must be a valid URL with http or https scheme and a host") } // Validate one or more PEM-encoded CERTIFICATE blocks and parse each rest := []byte(appConfig.ConditionalAccess.OktaCertificate.Value) certCount := 0 for { block, r := pem.Decode(rest) if block == nil { break } rest = r if block.Type != "CERTIFICATE" { invalid.Append("conditional_access.okta_certificate", "PEM block must be a CERTIFICATE") break } if _, err := x509.ParseCertificate(block.Bytes); err != nil { invalid.Append("conditional_access.okta_certificate", "must be a valid x509 certificate") break } certCount++ } if certCount == 0 { invalid.Append("conditional_access.okta_certificate", "must contain at least one PEM-encoded certificate") } } var conditionalAccessNoTeamUpdated bool if newAppConfig.Integrations.ConditionalAccessEnabled.Set { if err := fleet.ValidateConditionalAccessIntegration(ctx, svc, appConfig.ConditionalAccess, oldConditionalAccessEnabled.Value, newAppConfig.Integrations.ConditionalAccessEnabled.Value); err != nil { return nil, err } conditionalAccessNoTeamUpdated = oldConditionalAccessEnabled.Value != newAppConfig.Integrations.ConditionalAccessEnabled.Value appConfig.Integrations.ConditionalAccessEnabled = newAppConfig.Integrations.ConditionalAccessEnabled } if err := svc.validateMDM(ctx, lic, &oldAppConfig.MDM, &appConfig.MDM, invalid); err != nil { return nil, ctxerr.Wrap(ctx, err, "validating MDM config") } abmAssignments, err := svc.validateABMAssignments(ctx, &newAppConfig.MDM, &oldAppConfig.MDM, invalid, lic) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating ABM token assignments") } var vppAssignments map[uint][]uint vppAssignmentsDefined := newAppConfig.MDM.VolumePurchasingProgram.Set && newAppConfig.MDM.VolumePurchasingProgram.Valid if vppAssignmentsDefined { vppAssignments, err = svc.validateVPPAssignments(ctx, newAppConfig.MDM.VolumePurchasingProgram.Value, invalid, lic) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating VPP token assignments") } } if invalid.HasErrors() { return nil, ctxerr.Wrap(ctx, invalid) } // ignore MDM.EnabledAndConfigured MDM.AppleBMTermsExpired, and MDM.AppleBMEnabledAndConfigured // if provided in the modify payload we don't return an error in this case because it would // prevent using the output of fleetctl get config as input to fleetctl apply or this endpoint. appConfig.MDM.AppleBMTermsExpired = oldAppConfig.MDM.AppleBMTermsExpired appConfig.MDM.AppleBMEnabledAndConfigured = oldAppConfig.MDM.AppleBMEnabledAndConfigured appConfig.MDM.EnabledAndConfigured = oldAppConfig.MDM.EnabledAndConfigured // ignore MDM.AndroidEnabledAndConfigured because it is set by the server only appConfig.MDM.AndroidEnabledAndConfigured = oldAppConfig.MDM.AndroidEnabledAndConfigured // do not send a test email in dry-run mode, so this is a good place to stop // (we also delete the removed integrations after that, which we don't want // to do in dry-run mode). if applyOpts.DryRun { if legacyUsedWarning != nil { return nil, legacyUsedWarning } // must reload to get the unchanged app config (retrieve with obfuscated secrets) obfuscatedAppConfig, err := svc.ds.AppConfig(ctx) if err != nil { return nil, err } obfuscatedAppConfig.Obfuscate() return obfuscatedAppConfig, nil } // Perform validation of the applied SMTP settings. if newAppConfig.SMTPSettings != nil { // Ignore the values for SMTPEnabled and SMTPConfigured. oldSMTPSettings.SMTPEnabled = appConfig.SMTPSettings.SMTPEnabled oldSMTPSettings.SMTPConfigured = appConfig.SMTPSettings.SMTPConfigured // If we enable SMTP and the settings have changed, then we send a test email. if appConfig.SMTPSettings.SMTPEnabled { if oldSMTPSettings != *appConfig.SMTPSettings || !appConfig.SMTPSettings.SMTPConfigured { if err = svc.sendTestEmail(ctx, appConfig); err != nil { return nil, fleet.NewInvalidArgumentError("SMTP Options", err.Error()) } } appConfig.SMTPSettings.SMTPConfigured = true } else { appConfig.SMTPSettings.SMTPConfigured = false } } // NOTE: the frontend will always send all integrations back when making // changes, so as soon as Jira or Zendesk has something set, it's fair to // assume that integrations are being modified and we have the full set of // those integrations. When deleting, it does send empty arrays (not nulls), // so this is fine - e.g. when deleting the last integration it sends: // // {"integrations":{"zendesk":[],"jira":[]}} // if newAppConfig.Integrations.Jira != nil || newAppConfig.Integrations.Zendesk != nil { delJira, err := fleet.ValidateJiraIntegrations(ctx, storedJiraByProjectKey, newAppConfig.Integrations.Jira) if err != nil { if errors.As(err, &fleet.IntegrationTestError{}) { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ Message: err.Error(), }) } return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("Jira integration", err.Error())) } appConfig.Integrations.Jira = newAppConfig.Integrations.Jira delZendesk, err := fleet.ValidateZendeskIntegrations(ctx, storedZendeskByGroupID, newAppConfig.Integrations.Zendesk) if err != nil { if errors.As(err, &fleet.IntegrationTestError{}) { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ Message: err.Error(), }) } return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("Zendesk integration", err.Error())) } appConfig.Integrations.Zendesk = newAppConfig.Integrations.Zendesk // if any integration was deleted, remove it from any team that uses it if len(delJira)+len(delZendesk) > 0 { if err := svc.ds.DeleteIntegrationsFromTeams(ctx, fleet.Integrations{Jira: delJira, Zendesk: delZendesk}); err != nil { return nil, ctxerr.Wrap(ctx, err, "delete integrations from teams") } } } // If google_calendar is null, we keep the existing setting. if newAppConfig.Integrations.GoogleCalendar == nil { appConfig.Integrations.GoogleCalendar = oldAppConfig.Integrations.GoogleCalendar } gitopsModeEnabled, gitopsRepoURL := appConfig.UIGitOpsMode.GitopsModeEnabled, appConfig.UIGitOpsMode.RepositoryURL if gitopsModeEnabled { if !lic.IsPremium() { return nil, fleet.NewInvalidArgumentError("UI GitOpsMode: ", ErrMissingLicense.Error()) } if gitopsRepoURL == "" { return nil, fleet.NewInvalidArgumentError("UI GitOps Mode: ", "Repository URL is required when GitOps mode is enabled") } parsedURL, err := url.Parse(gitopsRepoURL) if err != nil { return nil, fleet.NewInvalidArgumentError("UI Gitops Mode: ", "Repository URL is invalid") } if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return nil, fleet.NewInvalidArgumentError("UI Gitops Mode: ", "Git repository URL must include protocol (e.g. https://)") } } if oldAppConfig.UIGitOpsMode.GitopsModeEnabled != appConfig.UIGitOpsMode.GitopsModeEnabled { // generate the activity var act fleet.ActivityDetails if gitopsModeEnabled { act = fleet.ActivityTypeEnabledGitOpsMode{} } else { act = fleet.ActivityTypeDisabledGitOpsMode{} } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName()) } } if !lic.IsPremium() { // reset fleet desktop settings to empty values for downgraded licenses appConfig.FleetDesktop.TransparencyURL = "" appConfig.FleetDesktop.AlternativeBrowserHost = "" } if err := svc.ds.SaveAppConfig(ctx, appConfig); err != nil { return nil, err } // only create activities when config change has been persisted switch { case appConfig.WebhookSettings.ActivitiesWebhook.Enable && !oldAppConfig.WebhookSettings.ActivitiesWebhook.Enable: act := fleet.ActivityTypeEnabledActivityAutomations{WebhookUrl: appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL} if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for enabled activity automations") } case !appConfig.WebhookSettings.ActivitiesWebhook.Enable && oldAppConfig.WebhookSettings.ActivitiesWebhook.Enable: act := fleet.ActivityTypeDisabledActivityAutomations{} if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for disabled activity automations") } case appConfig.WebhookSettings.ActivitiesWebhook.Enable && appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL != oldAppConfig.WebhookSettings.ActivitiesWebhook.DestinationURL: act := fleet.ActivityTypeEditedActivityAutomations{ WebhookUrl: appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL, } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for edited activity automations") } } if oldAppConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value != appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value && appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value == "" { // clear macos setup assistant for no team - note that we cannot call // svc.DeleteMDMAppleSetupAssistant here as it would call the (non-premium) // current service implementation. We have to go through the Enterprise // extensions. if err := svc.EnterpriseOverrides.DeleteMDMAppleSetupAssistant(ctx, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "delete macos setup assistant") } } if oldAppConfig.MDM.MacOSSetup.BootstrapPackage.Value != appConfig.MDM.MacOSSetup.BootstrapPackage.Value && appConfig.MDM.MacOSSetup.BootstrapPackage.Value == "" { // clear bootstrap package for no team - note that we cannot call // svc.DeleteMDMAppleBootstrapPackage here as it would call the (non-premium) // current service implementation. We have to go through the Enterprise // extensions. if err := svc.EnterpriseOverrides.DeleteMDMAppleBootstrapPackage(ctx, nil, applyOpts.DryRun); err != nil { return nil, ctxerr.Wrap(ctx, err, "delete Apple bootstrap package") } } tokensInCfg := make(map[string]struct{}) for _, t := range newAppConfig.MDM.AppleBusinessManager.Value { tokensInCfg[t.OrganizationName] = struct{}{} } toks, err := svc.ds.ListABMTokens(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "listing ABM tokens") } if newAppConfig.MDM.AppleBusinessManager.Set && len(newAppConfig.MDM.AppleBusinessManager.Value) == 0 { for _, tok := range toks { if _, ok := tokensInCfg[tok.OrganizationName]; !ok { tok.MacOSDefaultTeamID = nil tok.IOSDefaultTeamID = nil tok.IPadOSDefaultTeamID = nil if err := svc.ds.SaveABMToken(ctx, tok); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") } } } } if (appConfig.MDM.AppleBusinessManager.Set && appConfig.MDM.AppleBusinessManager.Valid) || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" { for _, tok := range abmAssignments { if err := svc.ds.SaveABMToken(ctx, tok); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") } } } if vppAssignmentsDefined { // 1. Reset teams for VPP tokens that exist in Fleet but aren't present in the config being passed clear(tokensInCfg) for _, t := range newAppConfig.MDM.VolumePurchasingProgram.Value { tokensInCfg[t.Location] = struct{}{} } vppToks, err := svc.ds.ListVPPTokens(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "listing VPP tokens") } for _, tok := range vppToks { if _, ok := tokensInCfg[tok.Location]; !ok { tok.Teams = nil if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tok.ID, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving VPP token teams") } } } // 2. Set VPP assignments that are defined in the config. for tokenID, tokenTeams := range vppAssignments { if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, tokenTeams); err != nil { var errTokConstraint fleet.ErrVPPTokenTeamConstraint if errors.As(err, &errTokConstraint) { return nil, ctxerr.Wrap(ctx, fleet.NewUserMessageError(errTokConstraint, http.StatusConflict)) } return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") } } } // retrieve new app config with obfuscated secrets obfuscatedAppConfig, err := svc.ds.AppConfig(ctxdb.RequirePrimary(ctx, true)) if err != nil { return nil, err } obfuscatedAppConfig.Obfuscate() // if the agent options changed, create the corresponding activity newAgentOptions := "" if obfuscatedAppConfig.AgentOptions != nil { newAgentOptions = string(*obfuscatedAppConfig.AgentOptions) } if oldAgentOptions != newAgentOptions { if err := svc.NewActivity( ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEditedAgentOptions{ Global: true, }, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for app config agent options modification") } } // // Process OS updates config changes for Apple devices. // if err := svc.processAppleOSUpdateSettings(ctx, lic, fleet.MacOS, oldAppConfig.MDM.MacOSUpdates, appConfig.MDM.MacOSUpdates, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "process macOS OS updates config change") } if err := svc.processAppleOSUpdateSettings(ctx, lic, fleet.IOS, oldAppConfig.MDM.IOSUpdates, appConfig.MDM.IOSUpdates, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "process iOS OS updates config change") } if err := svc.processAppleOSUpdateSettings(ctx, lic, fleet.IPadOS, oldAppConfig.MDM.IPadOSUpdates, appConfig.MDM.IPadOSUpdates, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "process iPadOS OS updates config change") } if appConfig.YaraRules != nil { if err := svc.ds.ApplyYaraRules(ctx, appConfig.YaraRules); err != nil { return nil, ctxerr.Wrap(ctx, err, "save yara rules for app config") } } // if the Windows updates requirements changed, create the corresponding // activity. if !oldAppConfig.MDM.WindowsUpdates.Equal(appConfig.MDM.WindowsUpdates) { var deadline, grace *int if appConfig.MDM.WindowsUpdates.DeadlineDays.Valid { deadline = &appConfig.MDM.WindowsUpdates.DeadlineDays.Value } if appConfig.MDM.WindowsUpdates.GracePeriodDays.Valid { grace = &appConfig.MDM.WindowsUpdates.GracePeriodDays.Value } if deadline != nil { if err := svc.EnterpriseOverrides.MDMWindowsEnableOSUpdates(ctx, nil, appConfig.MDM.WindowsUpdates); err != nil { return nil, ctxerr.Wrap(ctx, err, "enable no-team windows OS updates") } } else if err := svc.EnterpriseOverrides.MDMWindowsDisableOSUpdates(ctx, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "disable no-team windows OS updates") } if err := svc.NewActivity( ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEditedWindowsUpdates{ DeadlineDays: deadline, GracePeriodDays: grace, }, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos min version modification") } } if appConfig.MDM.EnableDiskEncryption.Valid && oldAppConfig.MDM.EnableDiskEncryption.Value != appConfig.MDM.EnableDiskEncryption.Value { if oldAppConfig.MDM.EnabledAndConfigured { var act fleet.ActivityDetails if appConfig.MDM.EnableDiskEncryption.Value { act = fleet.ActivityTypeEnabledMacosDiskEncryption{} if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow") } } else { act = fleet.ActivityTypeDisabledMacosDiskEncryption{} if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "disable no-team filevault and escrow") } } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos disk encryption") } } } mdmEnableEndUserAuthChanged := oldAppConfig.MDM.MacOSSetup.EnableEndUserAuthentication != appConfig.MDM.MacOSSetup.EnableEndUserAuthentication if mdmEnableEndUserAuthChanged { var act fleet.ActivityDetails if appConfig.MDM.MacOSSetup.EnableEndUserAuthentication { act = fleet.ActivityTypeEnabledMacosSetupEndUserAuth{} } else { act = fleet.ActivityTypeDisabledMacosSetupEndUserAuth{} } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for macos enable end user auth change") } } mdmSSOSettingsChanged := oldAppConfig.MDM.EndUserAuthentication.SSOProviderSettings != appConfig.MDM.EndUserAuthentication.SSOProviderSettings serverURLChanged := oldAppConfig.ServerSettings.ServerURL != appConfig.ServerSettings.ServerURL appleMDMUrlChanged := oldAppConfig.MDMUrl() != appConfig.MDMUrl() if (mdmEnableEndUserAuthChanged || mdmSSOSettingsChanged || serverURLChanged || appleMDMUrlChanged) && lic.IsPremium() { if err := svc.EnterpriseOverrides.MDMAppleSyncDEPProfiles(ctx); err != nil { return nil, ctxerr.Wrap(ctx, err, "sync DEP profiles") } } // if Windows MDM was enabled or disabled, create the corresponding activity if oldAppConfig.MDM.WindowsEnabledAndConfigured != appConfig.MDM.WindowsEnabledAndConfigured { var act fleet.ActivityDetails if appConfig.MDM.WindowsEnabledAndConfigured { act = fleet.ActivityTypeEnabledWindowsMDM{} } else { act = fleet.ActivityTypeDisabledWindowsMDM{} } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName()) } } if appConfig.MDM.WindowsEnabledAndConfigured && oldAppConfig.MDM.WindowsMigrationEnabled != appConfig.MDM.WindowsMigrationEnabled { var act fleet.ActivityDetails if appConfig.MDM.WindowsMigrationEnabled { act = fleet.ActivityTypeEnabledWindowsMDMMigration{} } else { act = fleet.ActivityTypeDisabledWindowsMDMMigration{} } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { return nil, ctxerr.Wrapf(ctx, err, "create activity %s", act.ActivityName()) } } // Create activity if conditional access was enabled or disabled for "No team". if conditionalAccessNoTeamUpdated { if appConfig.Integrations.ConditionalAccessEnabled.Value { if err := svc.NewActivity( ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEnabledConditionalAccessAutomations{ TeamID: nil, TeamName: "", }, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for enabling conditional access") } } else { if err := svc.NewActivity( ctx, authz.UserFromContext(ctx), fleet.ActivityTypeDisabledConditionalAccessAutomations{ TeamID: nil, TeamName: "", }, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for disabling conditional access") } } } // Create activity if Okta conditional access configuration was added, edited, or deleted oldOktaConfigured := oldAppConfig.ConditionalAccess != nil && oldAppConfig.ConditionalAccess.OktaConfigured() newOktaConfigured := appConfig.ConditionalAccess != nil && appConfig.ConditionalAccess.OktaConfigured() // Check if Okta configuration values changed (for edited case) oktaConfigChanged := false oktaBypassChanged := false if oldOktaConfigured && newOktaConfigured { // Both old and new are configured - check if any values changed oktaConfigChanged = oldAppConfig.ConditionalAccess.OktaIDPID.Value != appConfig.ConditionalAccess.OktaIDPID.Value || oldAppConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value != appConfig.ConditionalAccess.OktaAssertionConsumerServiceURL.Value || oldAppConfig.ConditionalAccess.OktaAudienceURI.Value != appConfig.ConditionalAccess.OktaAudienceURI.Value || oldAppConfig.ConditionalAccess.OktaCertificate.Value != appConfig.ConditionalAccess.OktaCertificate.Value // Only create an activity if bypass is changed after otka has already been initially configured oktaBypassChanged = oldAppConfig.ConditionalAccess.BypassDisabled.Value != newAppConfig.ConditionalAccess.BypassDisabled.Value } if (!oldOktaConfigured && newOktaConfigured) || oktaConfigChanged { // Okta configuration was added or edited if err := svc.NewActivity( ctx, authz.UserFromContext(ctx), fleet.ActivityTypeAddedConditionalAccessOkta{}, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for adding/editing Okta conditional access") } } else if oldOktaConfigured && !newOktaConfigured { // Okta configuration was deleted if err := svc.NewActivity( ctx, authz.UserFromContext(ctx), fleet.ActivityTypeDeletedConditionalAccessOkta{}, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for deleting Okta conditional access") } } if oktaBypassChanged { if err := svc.ds.ConditionalAccessClearBypasses(ctx); err != nil { return nil, ctxerr.Wrap(ctx, err, "clearing existing conditional access bypasses") } if err := svc.NewActivity( ctx, authz.UserFromContext(ctx), fleet.ActivityTypeUpdateConditionalAccessBypass{ BypassDisabled: appConfig.ConditionalAccess.BypassDisabled.Value, }, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for updating conditional access bypass") } } return obfuscatedAppConfig, nil } func validateFleetDesktopSettings(newAppConfig fleet.AppConfig, lic *fleet.LicenseInfo) *fleet.InvalidArgumentError { // default transparency URL is https://fleetdm.com/transparency so you are allowed to apply as long as it's not changing transparencyURLModified := newAppConfig.FleetDesktop.TransparencyURL != "" && newAppConfig.FleetDesktop.TransparencyURL != fleet.DefaultTransparencyURL alternativeBrowserHostModified := newAppConfig.FleetDesktop.AlternativeBrowserHost != "" fleetDesktopSettingsInvalidErr := &fleet.InvalidArgumentError{} if !lic.IsPremium() { if transparencyURLModified { fleetDesktopSettingsInvalidErr.Append("transparency_url", ErrMissingLicense.Error()) } if alternativeBrowserHostModified { fleetDesktopSettingsInvalidErr.Append("alternative_browser_host", ErrMissingLicense.Error()) } // No point in performing further validations if the license is not premium return fleetDesktopSettingsInvalidErr } if transparencyURLModified { if _, err := url.Parse(newAppConfig.FleetDesktop.TransparencyURL); err != nil { fleetDesktopSettingsInvalidErr.Append("transparency_url", err.Error()) } } if alternativeBrowserHostModified { if !validateAddress(newAppConfig.FleetDesktop.AlternativeBrowserHost) { fleetDesktopSettingsInvalidErr.Append("alternative_browser_host", "must be a valid hostname or IP address") } } return fleetDesktopSettingsInvalidErr } // processAppleOSUpdateSettings updates the OS updates configuration if the minimum version+deadline are updated. func (svc *Service) processAppleOSUpdateSettings( ctx context.Context, lic *fleet.LicenseInfo, appleDevice fleet.AppleDevice, oldOSUpdateSettings fleet.AppleOSUpdateSettings, newOSUpdateSettings fleet.AppleOSUpdateSettings, ) error { if oldOSUpdateSettings.MinimumVersion.Value != newOSUpdateSettings.MinimumVersion.Value || oldOSUpdateSettings.Deadline.Value != newOSUpdateSettings.Deadline.Value { if lic.IsPremium() { if err := svc.EnterpriseOverrides.MDMAppleEditedAppleOSUpdates(ctx, nil, appleDevice, newOSUpdateSettings); err != nil { return ctxerr.Wrap(ctx, err, "update DDM profile after Apple OS updates change") } } var activity fleet.ActivityDetails switch appleDevice { case fleet.MacOS: activity = fleet.ActivityTypeEditedMacOSMinVersion{ MinimumVersion: newOSUpdateSettings.MinimumVersion.Value, Deadline: newOSUpdateSettings.Deadline.Value, } case fleet.IOS: activity = fleet.ActivityTypeEditedIOSMinVersion{ MinimumVersion: newOSUpdateSettings.MinimumVersion.Value, Deadline: newOSUpdateSettings.Deadline.Value, } case fleet.IPadOS: activity = fleet.ActivityTypeEditedIPadOSMinVersion{ MinimumVersion: newOSUpdateSettings.MinimumVersion.Value, Deadline: newOSUpdateSettings.Deadline.Value, } } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil { return ctxerr.Wrap(ctx, err, "create activity for app config apple min version modification") } } if oldOSUpdateSettings.UpdateNewHosts.Value != newOSUpdateSettings.UpdateNewHosts.Value && appleDevice == fleet.MacOS { var activity fleet.ActivityDetails activity = fleet.ActivityTypeEnabledMacosUpdateNewHosts{} if !newOSUpdateSettings.UpdateNewHosts.Value { activity = fleet.ActivityTypeDisabledMacosUpdateNewHosts{} } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil { return ctxerr.Wrap(ctx, err, "create activity for app config apple min version modification") } } return nil } func (svc *Service) HasCustomSetupAssistantConfigurationWebURL(ctx context.Context, teamID *uint) (bool, error) { az, ok := authz_ctx.FromContext(ctx) if !ok || !az.Checked() { return false, fleet.NewAuthRequiredError("method requires previous authorization") } asst, err := svc.ds.GetMDMAppleSetupAssistant(ctx, teamID) if err != nil { if fleet.IsNotFound(err) { return false, nil } return false, err } var m map[string]any if err := json.Unmarshal(asst.Profile, &m); err != nil { return false, err } _, ok = m["configuration_web_url"] return ok, nil } func (svc *Service) validateMDM( ctx context.Context, lic *fleet.LicenseInfo, oldMdm *fleet.MDM, mdm *fleet.MDM, invalid *fleet.InvalidArgumentError, ) error { if mdm.EnableDiskEncryption.Value && !lic.IsPremium() { invalid.Append("macos_settings.enable_disk_encryption", ErrMissingLicense.Error()) } if mdm.MacOSSetup.MacOSSetupAssistant.Value != "" && oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value && !lic.IsPremium() { invalid.Append("macos_setup.macos_setup_assistant", ErrMissingLicense.Error()) } if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value && !lic.IsPremium() { invalid.Append("macos_setup.enable_release_device_manually", ErrMissingLicense.Error()) } if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value && !lic.IsPremium() { invalid.Append("macos_setup.bootstrap_package", ErrMissingLicense.Error()) } if mdm.MacOSSetup.EnableEndUserAuthentication && oldMdm.MacOSSetup.EnableEndUserAuthentication != mdm.MacOSSetup.EnableEndUserAuthentication && !lic.IsPremium() { invalid.Append("macos_setup.enable_end_user_authentication", ErrMissingLicense.Error()) } if mdm.MacOSSetup.ManualAgentInstall.Valid && oldMdm.MacOSSetup.ManualAgentInstall.Value != mdm.MacOSSetup.ManualAgentInstall.Value && !lic.IsPremium() { invalid.Append("macos_setup.manual_agent_install", ErrMissingLicense.Error()) } if mdm.WindowsMigrationEnabled && !lic.IsPremium() { invalid.Append("windows_migration_enabled", ErrMissingLicense.Error()) } if mdm.EnableTurnOnWindowsMDMManually && !lic.IsPremium() { invalid.Append("enable_turn_on_windows_mdm_manually", ErrMissingLicense.Error()) } // we want to use `oldMdm` here as this boolean is set by the fleet // server at startup and can't be modified by the user if !oldMdm.EnabledAndConfigured { if len(mdm.MacOSSettings.CustomSettings) > 0 && !fleet.MDMProfileSpecsMatch(mdm.MacOSSettings.CustomSettings, oldMdm.MacOSSettings.CustomSettings) { invalid.Append("macos_settings.custom_settings", `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } if mdm.MacOSSetup.MacOSSetupAssistant.Value != "" && oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value { invalid.Append("macos_setup.macos_setup_assistant", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value { invalid.Append("macos_setup.enable_release_device_manually", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value { invalid.Append("macos_setup.bootstrap_package", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } if mdm.MacOSSetup.EnableEndUserAuthentication && oldMdm.MacOSSetup.EnableEndUserAuthentication != mdm.MacOSSetup.EnableEndUserAuthentication { invalid.Append("macos_setup.enable_end_user_authentication", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } } checkCustomSettings := func(prefix string, customSettings []fleet.MDMProfileSpec) { for i, prof := range customSettings { count := 0 for _, b := range []bool{ len(prof.Labels) > 0, len(prof.LabelsIncludeAll) > 0, len(prof.LabelsIncludeAny) > 0, len(prof.LabelsExcludeAny) > 0, } { if b { count++ } } if count > 1 { invalid.Append(fmt.Sprintf("%s_settings.custom_settings", prefix), fmt.Sprintf(`Couldn't edit %s_settings.custom_settings. For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`, prefix)) } if len(prof.Labels) > 0 { customSettings[i].LabelsIncludeAll = customSettings[i].Labels customSettings[i].Labels = nil } } } checkCustomSettings("macos", mdm.MacOSSettings.CustomSettings) if !mdm.WindowsEnabledAndConfigured { if mdm.WindowsSettings.CustomSettings.Set && len(mdm.WindowsSettings.CustomSettings.Value) > 0 && !fleet.MDMProfileSpecsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) { invalid.Append("windows_settings.custom_settings", `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. This can be enabled by setting "controls.windows_enabled_and_configured: true" in the default configuration. Visit https://fleetdm.com/guides/windows-mdm-setup and https://fleetdm.com/docs/configuration/yaml-files#controls to learn more about enabling MDM.`) } } checkCustomSettings("windows", mdm.WindowsSettings.CustomSettings.Value) // Check oldMdm as we bypass the patching of this value, as it's enabled and disabled elsewhere. if !oldMdm.AndroidEnabledAndConfigured { if mdm.AndroidSettings.CustomSettings.Set && len(mdm.AndroidSettings.CustomSettings.Value) > 0 && !fleet.MDMProfileSpecsMatch(mdm.AndroidSettings.CustomSettings.Value, oldMdm.AndroidSettings.CustomSettings.Value) { invalid.Append("android_settings.custom_settings", `Couldn’t edit android_settings.custom_settings. Android MDM isn’t turned on. This can be enabled by setting "controls.android_enabled_and_configured: true" in the default configuration. Visit https://fleetdm.com/guides/android-mdm-setup and https://fleetdm.com/docs/configuration/yaml-files#controls to learn more about enabling MDM.`) } } checkCustomSettings("android", mdm.AndroidSettings.CustomSettings.Value) // MacOSUpdates updatingMacOSVersion := mdm.MacOSUpdates.MinimumVersion.Value != "" && mdm.MacOSUpdates.MinimumVersion != oldMdm.MacOSUpdates.MinimumVersion updatingMacOSDeadline := mdm.MacOSUpdates.Deadline.Value != "" && mdm.MacOSUpdates.Deadline != oldMdm.MacOSUpdates.Deadline // IOSUpdates updatingIOSVersion := mdm.IOSUpdates.MinimumVersion.Value != "" && mdm.IOSUpdates.MinimumVersion != oldMdm.IOSUpdates.MinimumVersion updatingIOSDeadline := mdm.IOSUpdates.Deadline.Value != "" && mdm.IOSUpdates.Deadline != oldMdm.IOSUpdates.Deadline // IPadOSUpdates updatingIPadOSVersion := mdm.IPadOSUpdates.MinimumVersion.Value != "" && mdm.IPadOSUpdates.MinimumVersion != oldMdm.IPadOSUpdates.MinimumVersion updatingIPadOSDeadline := mdm.IPadOSUpdates.Deadline.Value != "" && mdm.IPadOSUpdates.Deadline != oldMdm.IPadOSUpdates.Deadline if updatingMacOSVersion || updatingMacOSDeadline || updatingIOSVersion || updatingIOSDeadline || updatingIPadOSVersion || updatingIPadOSDeadline { // TODO: Should we validate MDM configured on here too? if !lic.IsPremium() { invalid.Append("macos_updates.minimum_version", ErrMissingLicense.Error()) return nil } } if err := mdm.MacOSUpdates.Validate(); err != nil { invalid.Append("macos_updates", err.Error()) } if err := mdm.IOSUpdates.Validate(); err != nil { invalid.Append("ios_updates", err.Error()) } if err := mdm.IPadOSUpdates.Validate(); err != nil { invalid.Append("ipados_updates", err.Error()) } // WindowsUpdates updatingWindowsUpdates := !mdm.WindowsUpdates.Equal(oldMdm.WindowsUpdates) if updatingWindowsUpdates { // TODO: Should we validate MDM configured on here too? if !lic.IsPremium() { invalid.Append("windows_updates.deadline_days", ErrMissingLicense.Error()) return nil } } if err := mdm.WindowsUpdates.Validate(); err != nil { invalid.Append("windows_updates", err.Error()) } // EndUserAuthentication // only validate SSO settings if they changed if mdm.EndUserAuthentication.SSOProviderSettings != oldMdm.EndUserAuthentication.SSOProviderSettings { if !lic.IsPremium() { invalid.Append("end_user_authentication", ErrMissingLicense.Error()) return nil } validateSSOProviderSettings(mdm.EndUserAuthentication.SSOProviderSettings, oldMdm.EndUserAuthentication.SSOProviderSettings, invalid) } // MacOSSetup validation if mdm.MacOSSetup.EnableEndUserAuthentication { if mdm.EndUserAuthentication.IsEmpty() { // TODO: update this error message to include steps to resolve the issue once docs for IdP // config are available invalid.Append("macos_setup.enable_end_user_authentication", `Couldn't enable macos_setup.enable_end_user_authentication because no IdP is configured for MDM features.`) } } if mdm.MacOSSetup.EnableEndUserAuthentication != oldMdm.MacOSSetup.EnableEndUserAuthentication { hasCustomConfigurationWebURL, err := svc.HasCustomSetupAssistantConfigurationWebURL(ctx, nil) if err != nil { return ctxerr.Wrap(ctx, err, "checking setup assistant configuration web url") } if hasCustomConfigurationWebURL { invalid.Append("end_user_authentication", fleet.EndUserAuthDEPWebURLConfiguredErrMsg) } } updatingMacOSMigration := mdm.MacOSMigration.Enable != oldMdm.MacOSMigration.Enable || mdm.MacOSMigration.Mode != oldMdm.MacOSMigration.Mode || mdm.MacOSMigration.WebhookURL != oldMdm.MacOSMigration.WebhookURL // MacOSMigration validation if updatingMacOSMigration { // TODO: Should we validate MDM configured on here too? if mdm.MacOSMigration.Enable { if !lic.IsPremium() { invalid.Append("macos_migration.enable", ErrMissingLicense.Error()) return nil } if !mdm.MacOSMigration.Mode.IsValid() { invalid.Append("macos_migration.mode", "mode must be one of 'voluntary' or 'forced'") } // TODO: improve url validation generally if u, err := url.ParseRequestURI(mdm.MacOSMigration.WebhookURL); err != nil { invalid.Append("macos_migration.webhook_url", err.Error()) } else if u.Scheme != "https" && u.Scheme != "http" { invalid.Append("macos_migration.webhook_url", "webhook_url must be https or http") } } } // Windows validation if !svc.config.MDM.IsMicrosoftWSTEPSet() { if mdm.WindowsEnabledAndConfigured { invalid.Append("mdm.windows_enabled_and_configured", "Couldn't turn on Windows MDM. Please configure Fleet with a certificate and key pair first.") return nil } } if !mdm.WindowsEnabledAndConfigured && mdm.WindowsMigrationEnabled { invalid.Append("mdm.windows_migration_enabled", "Couldn't enable Windows MDM migration, Windows MDM is not enabled.") } if !mdm.WindowsEnabledAndConfigured && mdm.EnableTurnOnWindowsMDMManually { invalid.Append("mdm.enable_turn_on_windows_mdm_manually", "Couldn't enable Turn on Windows MDM Manually, Windows MDM is not enabled.") } if mdm.WindowsMigrationEnabled && mdm.EnableTurnOnWindowsMDMManually { invalid.Append("mdm.enable_turn_on_windows_mdm_manually", "Couldn't enable Turn on Windows MDM Manually, Windows MDM migration is also enabled. Please enable only one.") } if !mdm.EnableDiskEncryption.Value { switch { case !oldMdm.EnableDiskEncryption.Value && mdm.RequireBitLockerPIN.Value: invalid.Append( "mdm.windows_require_bitlocker_pin", fleet.CantEnablePINRequiredIfDiskEncryptionEnabled, ) case oldMdm.EnableDiskEncryption.Value && mdm.RequireBitLockerPIN.Value: invalid.Append( "mdm.enable_disk_encryption", fleet.CantDisableDiskEncryptionIfPINRequiredErrMsg, ) } } return nil } func (svc *Service) validateABMAssignments( ctx context.Context, mdm, oldMdm *fleet.MDM, invalid *fleet.InvalidArgumentError, lic *fleet.LicenseInfo, ) ([]*fleet.ABMToken, error) { if mdm.DeprecatedAppleBMDefaultTeam != "" && mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid { invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage) return nil, nil } if name := mdm.DeprecatedAppleBMDefaultTeam; name != "" && name != oldMdm.DeprecatedAppleBMDefaultTeam { if !lic.IsPremium() { invalid.Append("mdm.apple_bm_default_team", ErrMissingLicense.Error()) return nil, nil } team, err := svc.ds.TeamByName(ctx, name) if err != nil { invalid.Append("mdm.apple_bm_default_team", "team name not found") return nil, nil } tokens, err := svc.ds.ListABMTokens(ctx) if err != nil { return nil, err } if len(tokens) > 1 { invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage) return nil, nil } if len(tokens) == 0 { invalid.Append("mdm.apple_bm_default_team", "no ABM tokens found") return nil, nil } tok := tokens[0] tok.MacOSDefaultTeamID = &team.ID tok.IOSDefaultTeamID = &team.ID tok.IPadOSDefaultTeamID = &team.ID return []*fleet.ABMToken{tok}, nil } if mdm.AppleBusinessManager.Set && len(mdm.AppleBusinessManager.Value) > 0 { if !lic.IsPremium() { invalid.Append("mdm.apple_business_manager", ErrMissingLicense.Error()) return nil, nil } teams, err := svc.ds.TeamsSummary(ctx) if err != nil { return nil, err } teamsByName := map[string]*uint{"": nil, "No team": nil} for _, tm := range teams { teamsByName[tm.Name] = &tm.ID } tokens, err := svc.ds.ListABMTokens(ctx) if err != nil { return nil, err } tokensByName := map[string]*fleet.ABMToken{} for _, token := range tokens { // The default assignments for all tokens is "no team" // (ie: team_id IS NULL), here we reset the assignments // for all tokens, those will be re-added below. // // This ensures any unassignments are properly handled. token.MacOSDefaultTeamID = nil token.IOSDefaultTeamID = nil token.IPadOSDefaultTeamID = nil tokensByName[token.OrganizationName] = token } var tokensToSave []*fleet.ABMToken for _, bm := range mdm.AppleBusinessManager.Value { for _, tmName := range []string{bm.MacOSTeam, bm.IOSTeam, bm.IpadOSTeam} { if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok { invalid.Appendf("mdm.apple_business_manager", "team %s doesn't exist", tmName) return nil, nil } } if _, ok := tokensByName[norm.NFC.String(bm.OrganizationName)]; !ok { invalid.Appendf("mdm.apple_business_manager", "token with organization name %s doesn't exist", bm.OrganizationName) return nil, nil } tok := tokensByName[bm.OrganizationName] tok.MacOSDefaultTeamID = teamsByName[bm.MacOSTeam] tok.IOSDefaultTeamID = teamsByName[bm.IOSTeam] tok.IPadOSDefaultTeamID = teamsByName[bm.IpadOSTeam] tokensToSave = append(tokensToSave, tok) } return tokensToSave, nil } return nil, nil } func (svc *Service) validateVPPAssignments( ctx context.Context, volumePurchasingProgramInfo []fleet.MDMAppleVolumePurchasingProgramInfo, invalid *fleet.InvalidArgumentError, lic *fleet.LicenseInfo, ) (map[uint][]uint, error) { // Allow clearing VPP assignments in free and premium. if len(volumePurchasingProgramInfo) == 0 { return nil, nil } if !lic.IsPremium() { invalid.Append("mdm.volume_purchasing_program", ErrMissingLicense.Error()) return nil, nil } teams, err := svc.ds.TeamsSummary(ctx) if err != nil { return nil, err } teamsByName := map[string]uint{fleet.TeamNameNoTeam: 0} for _, tm := range teams { teamsByName[tm.Name] = tm.ID } tokens, err := svc.ds.ListVPPTokens(ctx) if err != nil { return nil, err } tokensByLocation := map[string]*fleet.VPPTokenDB{} for _, token := range tokens { // The default assignments for all tokens is "no team" // (ie: team_id IS NULL), here we reset the assignments // for all tokens, those will be re-added below. // // This ensures any unassignments are properly handled. tokensByLocation[token.Location] = token token.Teams = nil } tokensToSave := make(map[uint][]uint, len(volumePurchasingProgramInfo)) for _, vpp := range volumePurchasingProgramInfo { for _, tmName := range vpp.Teams { if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok && tmName != fleet.TeamNameAllTeams { invalid.Appendf("mdm.volume_purchasing_program", "team %s doesn't exist", tmName) return nil, nil } } loc := norm.NFC.String(vpp.Location) if _, ok := tokensByLocation[loc]; !ok { invalid.Appendf("mdm.volume_purchasing_program", "token with location %s doesn't exist", vpp.Location) return nil, nil } var tokenTeams []uint for _, teamName := range vpp.Teams { if teamName == fleet.TeamNameAllTeams { if len(vpp.Teams) > 1 { invalid.Appendf("mdm.volume_purchasing_program", "token cannot belong to %s and other teams", fleet.TeamNameAllTeams) return nil, nil } tokenTeams = []uint{} break } teamID := teamsByName[teamName] tokenTeams = append(tokenTeams, teamID) } tok := tokensByLocation[loc] tokensToSave[tok.ID] = tokenTeams } return tokensToSave, nil } func validateSSOProviderSettings(incoming, existing fleet.SSOProviderSettings, invalid *fleet.InvalidArgumentError) { if incoming.Metadata == "" && incoming.MetadataURL == "" { if existing.Metadata == "" && existing.MetadataURL == "" { invalid.Append("metadata", "either metadata or metadata_url must be defined") } } if incoming.EntityID == "" { if existing.EntityID == "" { invalid.Append("entity_id", "required") } } if incoming.IDPName == "" { if existing.IDPName == "" { invalid.Append("idp_name", "required") } } if incoming.MetadataURL != "" { if u, err := url.ParseRequestURI(incoming.MetadataURL); err != nil { invalid.Append("metadata_url", err.Error()) } else if u.Scheme != "https" && u.Scheme != "http" { invalid.Append("metadata_url", "must be either https or http") } } } func validateSSOSettings(p fleet.AppConfig, existing *fleet.AppConfig, invalid *fleet.InvalidArgumentError, lic *fleet.LicenseInfo) { if p.SSOSettings != nil && p.SSOSettings.EnableSSO { var existingSSOProviderSettings fleet.SSOProviderSettings if existing.SSOSettings != nil { existingSSOProviderSettings = existing.SSOSettings.SSOProviderSettings } validateSSOProviderSettings(p.SSOSettings.SSOProviderSettings, existingSSOProviderSettings, invalid) if !lic.IsPremium() { if p.SSOSettings.EnableJITProvisioning { invalid.Append("enable_jit_provisioning", ErrMissingLicense.Error()) } } } } // ////////////////////////////////////////////////////////////////////////////// // Apply enroll secret spec // ////////////////////////////////////////////////////////////////////////////// type applyEnrollSecretSpecRequest struct { Spec *fleet.EnrollSecretSpec `json:"spec"` DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes } type applyEnrollSecretSpecResponse struct { Err error `json:"error,omitempty"` } func (r applyEnrollSecretSpecResponse) Error() error { return r.Err } func applyEnrollSecretSpecEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*applyEnrollSecretSpecRequest) err := svc.ApplyEnrollSecretSpec( ctx, req.Spec, fleet.ApplySpecOptions{ DryRun: req.DryRun, }, ) if err != nil { return applyEnrollSecretSpecResponse{Err: err}, nil } return applyEnrollSecretSpecResponse{}, nil } func (svc *Service) ApplyEnrollSecretSpec(ctx context.Context, spec *fleet.EnrollSecretSpec, applyOpts fleet.ApplySpecOptions) error { if err := svc.authz.Authorize(ctx, &fleet.EnrollSecret{}, fleet.ActionWrite); err != nil { return err } if len(spec.Secrets) > fleet.MaxEnrollSecretsCount { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "too many secrets")) } for _, s := range spec.Secrets { if s.Secret == "" { return ctxerr.New(ctx, "enroll secret must not be empty") } } if applyOpts.DryRun { for _, s := range spec.Secrets { available, err := svc.ds.IsEnrollSecretAvailable(ctx, s.Secret, false, nil) if err != nil { return err } if !available { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "a provided global enroll secret is already being used")) } } return nil } oldSecrets, err := svc.ds.GetEnrollSecrets(ctx, nil) if err != nil { return err } if err := svc.ds.ApplyEnrollSecrets(ctx, nil, spec.Secrets); err != nil { return err } // Check whether there were any mutations around the provided secrets ... if true, then register // an activity. oldSecretValues := make(map[string]struct{}, len(oldSecrets)) for _, s := range oldSecrets { oldSecretValues[s.Secret] = struct{}{} } newSecretsValues := make(map[string]struct{}, len(spec.Secrets)) for _, s := range spec.Secrets { newSecretsValues[s.Secret] = struct{}{} } if !maps.Equal(oldSecretValues, newSecretsValues) { activity := fleet.ActivityTypeEditedEnrollSecrets{} if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for edited enroll secret") } } return nil } // ////////////////////////////////////////////////////////////////////////////// // Get enroll secret spec // ////////////////////////////////////////////////////////////////////////////// type getEnrollSecretSpecResponse struct { Spec *fleet.EnrollSecretSpec `json:"spec"` Err error `json:"error,omitempty"` } func (r getEnrollSecretSpecResponse) Error() error { return r.Err } func getEnrollSecretSpecEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { specs, err := svc.GetEnrollSecretSpec(ctx) if err != nil { return getEnrollSecretSpecResponse{Err: err}, nil } return getEnrollSecretSpecResponse{Spec: specs}, nil } func (svc *Service) GetEnrollSecretSpec(ctx context.Context) (*fleet.EnrollSecretSpec, error) { if err := svc.authz.Authorize(ctx, &fleet.EnrollSecret{}, fleet.ActionRead); err != nil { return nil, err } secrets, err := svc.ds.GetEnrollSecrets(ctx, nil) if err != nil { return nil, err } return &fleet.EnrollSecretSpec{Secrets: secrets}, nil } // ////////////////////////////////////////////////////////////////////////////// // Version // ////////////////////////////////////////////////////////////////////////////// type versionResponse struct { *version.Info Err error `json:"error,omitempty"` } func (r versionResponse) Error() error { return r.Err } func versionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { info, err := svc.Version(ctx) if err != nil { return versionResponse{Err: err}, nil } return versionResponse{Info: info}, nil } func (svc *Service) Version(ctx context.Context) (*version.Info, error) { if err := svc.authz.Authorize(ctx, &fleet.Version{}, fleet.ActionRead); err != nil { return nil, err } info := version.Version() return &info, nil } // ////////////////////////////////////////////////////////////////////////////// // Get Certificate Chain // ////////////////////////////////////////////////////////////////////////////// type getCertificateResponse struct { CertificateChain []byte `json:"certificate_chain"` Err error `json:"error,omitempty"` } func (r getCertificateResponse) Error() error { return r.Err } func getCertificateEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { chain, err := svc.CertificateChain(ctx) if err != nil { return getCertificateResponse{Err: err}, nil } return getCertificateResponse{CertificateChain: chain}, nil } // Certificate returns the PEM encoded certificate chain for osqueryd TLS termination. func (svc *Service) CertificateChain(ctx context.Context) ([]byte, error) { config, err := svc.AppConfigObfuscated(ctx) if err != nil { return nil, err } u, err := url.Parse(config.ServerSettings.ServerURL) if err != nil { return nil, ctxerr.Wrap(ctx, err, "parsing serverURL") } conn, err := connectTLS(ctx, u) if err != nil { return nil, err } return chain(ctx, conn.ConnectionState(), u.Hostname()) } func connectTLS(ctx context.Context, serverURL *url.URL) (*tls.Conn, error) { var hostport string if serverURL.Port() == "" { hostport = net.JoinHostPort(serverURL.Host, "443") } else { hostport = serverURL.Host } // attempt dialing twice, first with a secure conn, and then // if that fails, use insecure dial := func(insecure bool) (*tls.Conn, error) { conn, err := tls.Dial("tcp", hostport, &tls.Config{ InsecureSkipVerify: insecure, }) if err != nil { return nil, ctxerr.Wrap(ctx, err, "dial tls") } defer conn.Close() return conn, nil } var ( conn *tls.Conn err error ) conn, err = dial(false) if err == nil { return conn, nil } conn, err = dial(true) return conn, err } // chain builds a PEM encoded certificate chain using the PeerCertificates // in tls.ConnectionState. chain uses the hostname to omit the Leaf certificate // from the chain. func chain(ctx context.Context, cs tls.ConnectionState, hostname string) ([]byte, error) { buf := bytes.NewBuffer([]byte("")) verifyEncode := func(chain []*x509.Certificate) error { for _, cert := range chain { if len(chain) > 1 { // drop the leaf certificate from the chain. osqueryd does not // need it to establish a secure connection if err := cert.VerifyHostname(hostname); err == nil { continue } } if err := encodePEMCertificate(buf, cert); err != nil { return err } } return nil } // use verified chains if available(which adds the root CA), otherwise // use the certificate chain offered by the server (if terminated with // self-signed certs) if len(cs.VerifiedChains) != 0 { for _, chain := range cs.VerifiedChains { if err := verifyEncode(chain); err != nil { return nil, ctxerr.Wrap(ctx, err, "encode verified chains pem") } } } else { if err := verifyEncode(cs.PeerCertificates); err != nil { return nil, ctxerr.Wrap(ctx, err, "encode peer certificates pem") } } return buf.Bytes(), nil } func encodePEMCertificate(buf io.Writer, cert *x509.Certificate) error { block := &pem.Block{ Type: "CERTIFICATE", Bytes: cert.Raw, } return pem.Encode(buf, block) } func (svc *Service) HostFeatures(ctx context.Context, host *fleet.Host) (*fleet.Features, error) { if svc.EnterpriseOverrides != nil { return svc.EnterpriseOverrides.HostFeatures(ctx, host) } appConfig, err := svc.ds.AppConfig(ctx) if err != nil { return nil, err } return &appConfig.Features, nil } // validateAddress validates that the provided address is usable for Fleet operations. func validateAddress(addr string) bool { host, portStr, err := net.SplitHostPort(addr) if err != nil { // Missing port is OK... if strings.Contains(err.Error(), "missing port") { host = addr } else { // Bare IPv6 address will make the call to SplitHostPort to fail, // in which case we just need to validate that the address is usable. ip := net.ParseIP(addr) return isUsableIPAddr(ip) } } else { port, err := strconv.Atoi(portStr) if err != nil || port < 0 || port > 65535 { return false } } return isValidHostname(host) } // isUsableIPAddr validates that the provided IP address is usable for Fleet operations. func isUsableIPAddr(addr net.IP) bool { if addr == nil { return false } if ip4 := addr.To4(); ip4 != nil && ip4.Equal(net.IPv4zero) { return false } if len(addr) == net.IPv6len && addr.Equal(net.IPv6zero) { return false } return true } // isValidHostname validates that h is a valid hostname per RFC 1123. It allows IP addresses and DNS names. func isValidHostname(h string) bool { if h == "" { return false } // For IPv6 in brackets, strip them if strings.HasPrefix(h, "[") && strings.HasSuffix(h, "]") { h = h[1 : len(h)-1] } // Check if it's a valid IP address (IPv4 or IPv6) if ip := net.ParseIP(h); ip != nil { return isUsableIPAddr(ip) } // Validate as DNS hostname (RFC 1123) // - Max 253 characters total // - Each label max 63 characters // - Labels contain only alphanumeric and hyphens // - Labels cannot start or end with hyphens if len(h) > 253 { return false } // Regex for valid DNS hostname labels validLabel := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) for label := range strings.SplitSeq(h, ".") { if len(label) == 0 || len(label) > 63 { return false } if !validLabel.MatchString(label) { return false } } return true }