mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
**Related issue:** Resolves #42240. - [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/guides/committing-changes.md#changes-files) for more information. ## Testing - [X] Added/updated automated tests - [X] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Statistics now include GitOps mode: whether it’s enabled and the ordered list of configured exception categories (serializes as an empty list when none). * **Tests** * Added tests for GitOps-related statistics transitions and made statistics-timing tests deterministic for reliable behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
371 lines
14 KiB
Go
371 lines
14 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/version"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
type statistics struct {
|
|
fleet.UpdateCreateTimestamps
|
|
Identifier string `db:"anonymous_identifier"`
|
|
}
|
|
|
|
func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error) {
|
|
lic, _ := license.FromContext(ctx)
|
|
|
|
computeStats := func(stats *fleet.StatisticsPayload, since time.Time) error {
|
|
enrolledHostsByOS, amountEnrolledHosts, err := amountEnrolledHostsByOSDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount enrolled hosts by os")
|
|
}
|
|
|
|
numHostsABMPending, err := numHostsABMPendingDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount hosts that are ABM pending")
|
|
}
|
|
|
|
amountUsers, err := tableRowsCount(ctx, ds.reader(ctx), "users")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount users")
|
|
}
|
|
amountSoftwaresVersions, err := tableRowsCount(ctx, ds.reader(ctx), "software")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount software")
|
|
}
|
|
amountHostSoftwares, err := tableRowsCount(ctx, ds.reader(ctx), "host_software")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount host_software")
|
|
}
|
|
amountSoftwareTitles, err := tableRowsCount(ctx, ds.reader(ctx), "software_titles")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount software_titles")
|
|
}
|
|
amountHostSoftwareInstalledPaths, err := tableRowsCount(ctx, ds.reader(ctx), "host_software_installed_paths")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount host_software_installed_paths")
|
|
}
|
|
amountSoftwareCpes, err := tableRowsCount(ctx, ds.reader(ctx), "software_cpe")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount software_cpe")
|
|
}
|
|
amountSoftwareCves, err := tableRowsCount(ctx, ds.reader(ctx), "software_cve")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount software_cve")
|
|
}
|
|
amountTeams, err := amountTeamsDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount teams")
|
|
}
|
|
amountPolicies, err := amountPoliciesDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount policies")
|
|
}
|
|
amountLabels, err := amountLabelsDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount labels")
|
|
}
|
|
appConfig, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "statistics app config")
|
|
}
|
|
amountWeeklyUsers, err := amountActiveUsersSinceDB(ctx, ds.reader(ctx), since)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount active users")
|
|
}
|
|
amountPolicyViolationDaysActual, amountPolicyViolationDaysPossible, err := amountPolicyViolationDaysDB(ctx, ds.reader(ctx))
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return ctxerr.Wrap(ctx, err, "amount policy violation days")
|
|
}
|
|
storedErrs, err := ctxerr.Aggregate(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "statistics error store")
|
|
}
|
|
amountHostsNotResponding, err := countHostsNotRespondingDB(ctx, ds.reader(ctx), ds.logger, config)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount hosts not responding")
|
|
}
|
|
amountHostsByOrbitVersion, err := amountHostsByOrbitVersionDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount hosts by orbit version")
|
|
}
|
|
amountHostsByOsqueryVersion, err := amountHostsByOsqueryVersionDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "amount hosts by osquery version")
|
|
}
|
|
numHostsFleetDesktopEnabled, err := numHostsFleetDesktopEnabledDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "number of hosts with Fleet desktop installed")
|
|
}
|
|
numQueries, err := numSavedQueriesDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "number of saved queries in DB")
|
|
}
|
|
fleetMaintainedAppsMacOS, fleetMaintainedAppsWindows, err := fleetMaintainedAppsInUseDB(ctx, ds.reader(ctx))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "fleet maintained apps")
|
|
}
|
|
|
|
stats.NumHostsEnrolled = amountEnrolledHosts
|
|
stats.NumHostsABMPending = numHostsABMPending
|
|
stats.NumUsers = amountUsers
|
|
stats.NumSoftwareVersions = amountSoftwaresVersions
|
|
stats.NumHostSoftwares = amountHostSoftwares
|
|
stats.NumSoftwareTitles = amountSoftwareTitles
|
|
stats.NumHostSoftwareInstalledPaths = amountHostSoftwareInstalledPaths
|
|
stats.NumSoftwareCPEs = amountSoftwareCpes
|
|
stats.NumSoftwareCVEs = amountSoftwareCves
|
|
stats.NumTeams = amountTeams
|
|
stats.NumPolicies = amountPolicies
|
|
stats.NumLabels = amountLabels
|
|
stats.SoftwareInventoryEnabled = appConfig.Features.EnableSoftwareInventory
|
|
stats.VulnDetectionEnabled = config.Vulnerabilities.DatabasesPath != "" || appConfig.VulnerabilitySettings.DatabasesPath != ""
|
|
stats.SystemUsersEnabled = appConfig.Features.EnableHostUsers
|
|
stats.HostsStatusWebHookEnabled = appConfig.WebhookSettings.HostStatusWebhook.Enable
|
|
stats.MDMMacOsEnabled = appConfig.MDM.EnabledAndConfigured
|
|
stats.HostExpiryEnabled = appConfig.HostExpirySettings.HostExpiryEnabled
|
|
stats.MDMWindowsEnabled = appConfig.MDM.WindowsEnabledAndConfigured
|
|
stats.MDMRecoveryLockPasswordEnabled = appConfig.MDM.EnableRecoveryLockPassword.Value
|
|
stats.LiveQueryDisabled = appConfig.ServerSettings.LiveQueryDisabled
|
|
stats.NumWeeklyActiveUsers = amountWeeklyUsers
|
|
stats.NumWeeklyPolicyViolationDaysActual = amountPolicyViolationDaysActual
|
|
stats.NumWeeklyPolicyViolationDaysPossible = amountPolicyViolationDaysPossible
|
|
stats.HostsEnrolledByOperatingSystem = enrolledHostsByOS
|
|
stats.HostsEnrolledByOrbitVersion = amountHostsByOrbitVersion
|
|
stats.HostsEnrolledByOsqueryVersion = amountHostsByOsqueryVersion
|
|
stats.StoredErrors = storedErrs
|
|
stats.NumHostsNotResponding = amountHostsNotResponding
|
|
stats.Organization = "unknown"
|
|
if lic != nil && lic.IsPremium() {
|
|
stats.Organization = lic.GetOrganization()
|
|
}
|
|
stats.AIFeaturesDisabled = appConfig.ServerSettings.AIFeaturesDisabled
|
|
stats.MaintenanceWindowsConfigured = len(appConfig.Integrations.GoogleCalendar) > 0 && appConfig.Integrations.GoogleCalendar[0].Domain != "" && !appConfig.Integrations.GoogleCalendar[0].ApiKey.IsEmpty()
|
|
|
|
stats.MaintenanceWindowsEnabled = false
|
|
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: &fleet.User{
|
|
GlobalRole: ptr.String(fleet.RoleAdmin),
|
|
}}, fleet.ListOptions{})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list teams")
|
|
}
|
|
for _, team := range teams {
|
|
if team.Config.Integrations.GoogleCalendar != nil && team.Config.Integrations.GoogleCalendar.Enable {
|
|
stats.MaintenanceWindowsEnabled = true
|
|
break
|
|
}
|
|
}
|
|
stats.NumHostsFleetDesktopEnabled = numHostsFleetDesktopEnabled
|
|
stats.NumQueries = numQueries
|
|
stats.FleetMaintainedAppsMacOS = fleetMaintainedAppsMacOS
|
|
stats.FleetMaintainedAppsWindows = fleetMaintainedAppsWindows
|
|
|
|
stats.ConditionalAccessEnabled, err = ds.conditionalAccessEnabledOnATeam(ctx, teams)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "conditional access enabled on a team")
|
|
}
|
|
|
|
if appConfig.ConditionalAccess != nil {
|
|
stats.OktaConditionalAccessConfigured = appConfig.ConditionalAccess.OktaConfigured()
|
|
stats.ConditionalAccessBypassDisabled = !appConfig.ConditionalAccess.BypassEnabled()
|
|
}
|
|
|
|
stats.EntraConditionalAccessConfigured, err = ds.entraConditionalAccessConfigured(ctx, config)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "entra conditional access configured")
|
|
}
|
|
|
|
stats.GitOpsModeEnabled = appConfig.GitOpsConfig.GitopsModeEnabled
|
|
stats.GitOpsModeExceptions = gitOpsExceptionsList(appConfig.GitOpsConfig.Exceptions)
|
|
|
|
return nil
|
|
}
|
|
|
|
dest := statistics{}
|
|
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
anonIdentifier, err := server.GenerateRandomText(64)
|
|
if err != nil {
|
|
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "generate random text")
|
|
}
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `INSERT INTO statistics(anonymous_identifier) VALUES (?)`, anonIdentifier)
|
|
if err != nil {
|
|
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "insert statistics")
|
|
}
|
|
|
|
// compute active weekly users since now - frequency
|
|
stats := fleet.StatisticsPayload{
|
|
AnonymousIdentifier: anonIdentifier,
|
|
FleetVersion: version.Version().Version,
|
|
LicenseTier: fleet.TierFree,
|
|
}
|
|
if lic != nil {
|
|
stats.LicenseTier = lic.GetTier()
|
|
}
|
|
if err := computeStats(&stats, time.Now().Add(-frequency)); err != nil {
|
|
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "compute statistics")
|
|
}
|
|
|
|
return stats, true, nil
|
|
}
|
|
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "get statistics")
|
|
}
|
|
|
|
lastUpdated := dest.UpdatedAt
|
|
if dest.CreatedAt.After(dest.UpdatedAt) {
|
|
lastUpdated = dest.CreatedAt
|
|
}
|
|
if time.Now().Before(lastUpdated.Add(frequency)) {
|
|
return fleet.StatisticsPayload{}, false, nil
|
|
}
|
|
|
|
stats := fleet.StatisticsPayload{
|
|
AnonymousIdentifier: dest.Identifier,
|
|
FleetVersion: version.Version().Version,
|
|
LicenseTier: fleet.TierFree,
|
|
}
|
|
if lic != nil {
|
|
stats.LicenseTier = lic.GetTier()
|
|
}
|
|
if err := computeStats(&stats, lastUpdated); err != nil {
|
|
return fleet.StatisticsPayload{}, false, ctxerr.Wrap(ctx, err, "compute statistics")
|
|
}
|
|
|
|
return stats, true, nil
|
|
}
|
|
|
|
func (ds *Datastore) RecordStatisticsSent(ctx context.Context) error {
|
|
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE statistics SET updated_at = CURRENT_TIMESTAMP LIMIT 1`)
|
|
return ctxerr.Wrap(ctx, err, "update statistics")
|
|
}
|
|
|
|
func (ds *Datastore) CleanupStatistics(ctx context.Context) error {
|
|
// reset weekly count of policy violation days
|
|
if err := ds.InitializePolicyViolationDays(ctx); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) GetTableRowCounts(ctx context.Context) (map[string]uint, error) {
|
|
return ds.getTableRowCountsViaInformationSchema(ctx)
|
|
}
|
|
|
|
func (ds *Datastore) getTableRowCountsViaInformationSchema(ctx context.Context) (map[string]uint, error) {
|
|
var results []struct {
|
|
Table string `db:"TABLE_NAME"`
|
|
Rows uint `db:"table_rows"`
|
|
}
|
|
|
|
if err := sqlx.SelectContext(
|
|
ctx,
|
|
ds.reader(ctx),
|
|
&results,
|
|
"SELECT table_name, COALESCE(table_rows, 0) table_rows FROM information_schema.tables WHERE table_schema = (SELECT DATABASE())",
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
byName := make(map[string]uint)
|
|
for _, row := range results {
|
|
byName[row.Table] = row.Rows
|
|
}
|
|
return byName, nil
|
|
}
|
|
|
|
// fleetMaintainedAppsInUseDB returns arrays of Fleet-maintained app slugs grouped by platform (darwin for macOS, windows for Windows)
|
|
func fleetMaintainedAppsInUseDB(ctx context.Context, db sqlx.QueryerContext) (macOSApps []string, windowsApps []string, err error) {
|
|
const query = `
|
|
SELECT DISTINCT fma.slug, fma.platform
|
|
FROM software_installers si
|
|
INNER JOIN fleet_maintained_apps fma ON si.fleet_maintained_app_id = fma.id
|
|
WHERE si.fleet_maintained_app_id IS NOT NULL AND fma.platform IN ('darwin', 'windows')
|
|
ORDER BY fma.platform, fma.slug
|
|
`
|
|
|
|
type appResult struct {
|
|
Slug string `db:"slug"`
|
|
Platform string `db:"platform"`
|
|
}
|
|
|
|
var results []appResult
|
|
if err := sqlx.SelectContext(ctx, db, &results, query); err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "selecting fleet maintained apps in use")
|
|
}
|
|
|
|
// Initialize as empty slices (not nil) so they serialize as [] instead of null
|
|
macOSApps = make([]string, 0)
|
|
windowsApps = make([]string, 0)
|
|
|
|
for _, app := range results {
|
|
if app.Platform == "darwin" {
|
|
macOSApps = append(macOSApps, app.Slug)
|
|
} else if app.Platform == "windows" {
|
|
windowsApps = append(windowsApps, app.Slug)
|
|
}
|
|
}
|
|
|
|
return macOSApps, windowsApps, nil
|
|
}
|
|
|
|
func (ds *Datastore) entraConditionalAccessConfigured(ctx context.Context, fleetConfig config.FleetConfig) (bool, error) {
|
|
// Check if the needed server configuration for Conditional Access is set.
|
|
if !fleetConfig.MicrosoftCompliancePartner.IsSet() {
|
|
return false, nil
|
|
}
|
|
|
|
// Check if the integration is fully configured.
|
|
integration, err := ds.ConditionalAccessMicrosoftGet(ctx)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return false, nil
|
|
}
|
|
return false, ctxerr.Wrap(ctx, err, "failed to load the integration")
|
|
}
|
|
return integration.SetupDone, nil
|
|
}
|
|
|
|
// gitOpsExceptionsList returns the names of enabled GitOps mode exceptions, in a stable order.
|
|
// Always returns a non-nil slice so the payload serializes as [] when empty.
|
|
func gitOpsExceptionsList(e fleet.GitOpsExceptions) []string {
|
|
exceptions := make([]string, 0, 3)
|
|
if e.Labels {
|
|
exceptions = append(exceptions, "labels")
|
|
}
|
|
if e.Software {
|
|
exceptions = append(exceptions, "software")
|
|
}
|
|
if e.Secrets {
|
|
exceptions = append(exceptions, "secrets")
|
|
}
|
|
return exceptions
|
|
}
|
|
|
|
func (ds *Datastore) conditionalAccessEnabledOnATeam(ctx context.Context, teams []*fleet.Team) (bool, error) {
|
|
// Check configuration for "Unassigned" is stored in the main appconfig.
|
|
cfg, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "failed to load appconfig")
|
|
}
|
|
if cfg.Integrations.ConditionalAccessEnabled.Set && cfg.Integrations.ConditionalAccessEnabled.Value {
|
|
return true, nil
|
|
}
|
|
|
|
// Check for the setting in teams.
|
|
for _, team := range teams {
|
|
if team.Config.Integrations.ConditionalAccessEnabled.Set && team.Config.Integrations.ConditionalAccessEnabled.Value {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|